discourse_theme 0.1.8 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/.rubocop.yml +1 -110
- data/Rakefile +1 -1
- data/bin/discourse_theme +1 -1
- data/discourse_theme.gemspec +3 -1
- data/lib/discourse_theme.rb +4 -0
- data/lib/discourse_theme/cli.rb +114 -89
- data/lib/discourse_theme/client.rb +183 -0
- data/lib/discourse_theme/config.rb +22 -9
- data/lib/discourse_theme/downloader.rb +31 -0
- data/lib/discourse_theme/scaffold.rb +49 -50
- data/lib/discourse_theme/uploader.rb +16 -67
- data/lib/discourse_theme/version.rb +1 -1
- data/lib/discourse_theme/watcher.rb +52 -40
- metadata +35 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a5c78e3947eb3207c1deec8de4326ca852efabb4c45c9dc616ff8cee76695747
|
4
|
+
data.tar.gz: d5024d27d0f687d6300f4c34cfd3ac6c72ef5049f03f10e15397e9d45dcd8bbf
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 5aff9397fbe13616f9012bef9e3f41dc4ea0587d09ae8f040e806d7ef51c9f79dc71e911ec0808e16a631a46636ff72ebec244ef543b7a69852bd24265cb40fd
|
7
|
+
data.tar.gz: ff3cc429ef37e484c96af103d130140b54c42a07e74a222270fd1f533ede61a3b9e8ee1bf8a4fbeb94d1e047e95481251561ad821eaa58001d2c02fe38d89189
|
data/.gitignore
CHANGED
data/.rubocop.yml
CHANGED
@@ -1,110 +1 @@
|
|
1
|
-
|
2
|
-
TargetRubyVersion: 2.2
|
3
|
-
DisabledByDefault: true
|
4
|
-
Exclude:
|
5
|
-
- 'bundle/**/*'
|
6
|
-
- 'pkg/**/*'
|
7
|
-
|
8
|
-
# Prefer &&/|| over and/or.
|
9
|
-
Style/AndOr:
|
10
|
-
Enabled: true
|
11
|
-
|
12
|
-
# Do not use braces for hash literals when they are the last argument of a
|
13
|
-
# method call.
|
14
|
-
Style/BracesAroundHashParameters:
|
15
|
-
Enabled: true
|
16
|
-
|
17
|
-
# Align `when` with `case`.
|
18
|
-
Layout/CaseIndentation:
|
19
|
-
Enabled: true
|
20
|
-
|
21
|
-
# Align comments with method definitions.
|
22
|
-
Layout/CommentIndentation:
|
23
|
-
Enabled: true
|
24
|
-
|
25
|
-
# No extra empty lines.
|
26
|
-
Layout/EmptyLines:
|
27
|
-
Enabled: true
|
28
|
-
|
29
|
-
# Use Ruby >= 1.9 syntax for hashes. Prefer { a: :b } over { :a => :b }.
|
30
|
-
Style/HashSyntax:
|
31
|
-
Enabled: true
|
32
|
-
|
33
|
-
# Two spaces, no tabs (for indentation).
|
34
|
-
Layout/IndentationWidth:
|
35
|
-
Enabled: true
|
36
|
-
|
37
|
-
Layout/SpaceAfterColon:
|
38
|
-
Enabled: true
|
39
|
-
|
40
|
-
Layout/SpaceAfterComma:
|
41
|
-
Enabled: true
|
42
|
-
|
43
|
-
Layout/SpaceAroundEqualsInParameterDefault:
|
44
|
-
Enabled: true
|
45
|
-
|
46
|
-
Layout/SpaceAroundKeyword:
|
47
|
-
Enabled: true
|
48
|
-
|
49
|
-
Layout/SpaceAroundOperators:
|
50
|
-
Enabled: true
|
51
|
-
|
52
|
-
Layout/SpaceBeforeFirstArg:
|
53
|
-
Enabled: true
|
54
|
-
|
55
|
-
# Defining a method with parameters needs parentheses.
|
56
|
-
Style/MethodDefParentheses:
|
57
|
-
Enabled: true
|
58
|
-
|
59
|
-
# Use `foo {}` not `foo{}`.
|
60
|
-
Layout/SpaceBeforeBlockBraces:
|
61
|
-
Enabled: true
|
62
|
-
|
63
|
-
# Use `foo { bar }` not `foo {bar}`.
|
64
|
-
Layout/SpaceInsideBlockBraces:
|
65
|
-
Enabled: true
|
66
|
-
|
67
|
-
# Use `{ a: 1 }` not `{a:1}`.
|
68
|
-
Layout/SpaceInsideHashLiteralBraces:
|
69
|
-
Enabled: true
|
70
|
-
|
71
|
-
Layout/SpaceInsideParens:
|
72
|
-
Enabled: true
|
73
|
-
|
74
|
-
# Detect hard tabs, no hard tabs.
|
75
|
-
Layout/Tab:
|
76
|
-
Enabled: true
|
77
|
-
|
78
|
-
# Blank lines should not have any spaces.
|
79
|
-
Layout/TrailingBlankLines:
|
80
|
-
Enabled: true
|
81
|
-
|
82
|
-
# No trailing whitespace.
|
83
|
-
Layout/TrailingWhitespace:
|
84
|
-
Enabled: true
|
85
|
-
|
86
|
-
Lint/Debugger:
|
87
|
-
Enabled: true
|
88
|
-
|
89
|
-
Layout/BlockAlignment:
|
90
|
-
Enabled: true
|
91
|
-
|
92
|
-
# Align `end` with the matching keyword or starting expression except for
|
93
|
-
# assignments, where it should be aligned with the LHS.
|
94
|
-
Layout/EndAlignment:
|
95
|
-
Enabled: true
|
96
|
-
EnforcedStyleAlignWith: variable
|
97
|
-
|
98
|
-
# Use my_method(my_arg) not my_method( my_arg ) or my_method my_arg.
|
99
|
-
Lint/RequireParentheses:
|
100
|
-
Enabled: true
|
101
|
-
|
102
|
-
Layout/MultilineMethodCallIndentation:
|
103
|
-
Enabled: true
|
104
|
-
EnforcedStyle: indented
|
105
|
-
|
106
|
-
Layout/AlignHash:
|
107
|
-
Enabled: true
|
108
|
-
|
109
|
-
Bundler/OrderedGems:
|
110
|
-
Enabled: false
|
1
|
+
inherit_from: https://raw.githubusercontent.com/discourse/discourse/master/.rubocop.yml
|
data/Rakefile
CHANGED
data/bin/discourse_theme
CHANGED
data/discourse_theme.gemspec
CHANGED
@@ -23,15 +23,17 @@ Gem::Specification.new do |spec|
|
|
23
23
|
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
24
24
|
spec.require_paths = ["lib"]
|
25
25
|
|
26
|
-
spec.add_development_dependency "bundler", "~>
|
26
|
+
spec.add_development_dependency "bundler", "~> 2.0"
|
27
27
|
spec.add_development_dependency "rake", "~> 10.0"
|
28
28
|
spec.add_development_dependency "minitest", "~> 5.0"
|
29
29
|
spec.add_development_dependency "guard", "~> 2.14"
|
30
30
|
spec.add_development_dependency "guard-minitest", "~> 2.4"
|
31
|
+
spec.add_development_dependency "webmock", "~> 3.5"
|
31
32
|
|
32
33
|
spec.add_dependency "minitar", "~> 0.6"
|
33
34
|
spec.add_dependency "listen", "~> 3.1"
|
34
35
|
spec.add_dependency "multipart-post", "~> 2.0"
|
36
|
+
spec.add_dependency "tty-prompt", "~> 0.18"
|
35
37
|
|
36
38
|
spec.required_ruby_version = '>= 2.2.0'
|
37
39
|
end
|
data/lib/discourse_theme.rb
CHANGED
@@ -11,13 +11,17 @@ require 'uri'
|
|
11
11
|
require 'listen'
|
12
12
|
require 'json'
|
13
13
|
require 'yaml'
|
14
|
+
require 'tty/prompt'
|
14
15
|
|
15
16
|
require 'discourse_theme/version'
|
16
17
|
require 'discourse_theme/config'
|
17
18
|
require 'discourse_theme/cli'
|
19
|
+
require 'discourse_theme/client'
|
20
|
+
require 'discourse_theme/downloader'
|
18
21
|
require 'discourse_theme/uploader'
|
19
22
|
require 'discourse_theme/watcher'
|
20
23
|
require 'discourse_theme/scaffold'
|
21
24
|
|
22
25
|
module DiscourseTheme
|
26
|
+
class ThemeError < StandardError; end
|
23
27
|
end
|
data/lib/discourse_theme/cli.rb
CHANGED
@@ -1,123 +1,148 @@
|
|
1
|
-
|
1
|
+
module DiscourseTheme
|
2
|
+
class Cli
|
2
3
|
|
3
|
-
|
4
|
+
@@prompt = ::TTY::Prompt.new(help_color: :cyan)
|
5
|
+
@@pastel = Pastel.new
|
4
6
|
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
puts "discourse_theme new DIR : Creates a new theme in the designated directory"
|
9
|
-
puts "discourse_theme watch DIR : Watches the theme directory and synchronizes with Discourse"
|
10
|
-
exit 1
|
11
|
-
end
|
7
|
+
def self.yes?(message)
|
8
|
+
@@prompt.yes?(@@pastel.cyan("? ") + message)
|
9
|
+
end
|
12
10
|
|
13
|
-
|
14
|
-
|
15
|
-
if api_key
|
16
|
-
puts "Using api_key provided by DISCOURSE_API_KEY"
|
11
|
+
def self.ask(message, default: nil)
|
12
|
+
@@prompt.ask(@@pastel.cyan("? ") + message, default: default)
|
17
13
|
end
|
18
14
|
|
19
|
-
|
20
|
-
|
21
|
-
puts "Using previously stored api key in #{SETTINGS_FILE}"
|
15
|
+
def self.select(message, options)
|
16
|
+
@@prompt.select(@@pastel.cyan("? ") + message, options)
|
22
17
|
end
|
23
18
|
|
24
|
-
|
25
|
-
puts "
|
26
|
-
api_key = STDIN.gets.strip
|
27
|
-
puts "Would you like me to store this API key in #{SETTINGS_FILE}? (Yes|No)"
|
28
|
-
answer = STDIN.gets.strip
|
29
|
-
if answer =~ /y(es)?/i
|
30
|
-
settings.api_key = api_key
|
31
|
-
end
|
19
|
+
def self.info(message)
|
20
|
+
puts @@pastel.blue("i ") + message
|
32
21
|
end
|
33
22
|
|
34
|
-
|
35
|
-
|
23
|
+
def self.progress(message)
|
24
|
+
puts @@pastel.yellow("» ") + message
|
25
|
+
end
|
36
26
|
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
path = "/" if path.empty?
|
41
|
-
req = Net::HTTP::Get.new("/")
|
42
|
-
response = Net::HTTP.start(url.host, url.port) { |http| http.request(req) }
|
43
|
-
Net::HTTPRedirection === response && response['location'] =~ /^https/i
|
44
|
-
end
|
27
|
+
def self.error(message)
|
28
|
+
puts @@pastel.red("✘ #{message}")
|
29
|
+
end
|
45
30
|
|
46
|
-
|
47
|
-
|
48
|
-
if url
|
49
|
-
puts "Site provided by DISCOURSE_URL"
|
31
|
+
def self.success(message)
|
32
|
+
puts @@pastel.green("✔ #{message}")
|
50
33
|
end
|
51
34
|
|
52
|
-
|
53
|
-
|
54
|
-
|
35
|
+
SETTINGS_FILE = File.expand_path("~/.discourse_theme")
|
36
|
+
|
37
|
+
def usage
|
38
|
+
puts "Usage: discourse_theme COMMAND [--reset]"
|
39
|
+
puts
|
40
|
+
puts "discourse_theme new DIR : Creates a new theme in the designated directory"
|
41
|
+
puts "discourse_theme download DIR : Download a theme from the server, and store in the designated directory"
|
42
|
+
puts "discourse_theme watch DIR : Watches the theme directory and synchronizes with Discourse"
|
43
|
+
puts
|
44
|
+
puts "Use --reset to change the configuration for a directory"
|
45
|
+
exit 1
|
55
46
|
end
|
56
47
|
|
57
|
-
|
58
|
-
|
59
|
-
url = STDIN.gets.strip
|
60
|
-
url = "http://#{url}" unless url =~ /^https?:\/\//
|
48
|
+
def run(args)
|
49
|
+
usage unless args[1]
|
61
50
|
|
62
|
-
|
63
|
-
uri = URI.parse(url)
|
64
|
-
if URI::HTTP === uri && uri.port == 80 && is_https_redirect?(url)
|
65
|
-
puts "Detected an #{url} is an HTTPS domain"
|
66
|
-
url = url.sub("http", "https")
|
67
|
-
end
|
51
|
+
reset = !!args.delete("--reset")
|
68
52
|
|
69
|
-
|
70
|
-
|
71
|
-
if answer =~ /y(es)?/i
|
72
|
-
settings.url = url
|
73
|
-
end
|
74
|
-
end
|
53
|
+
command = args[0].to_s.downcase
|
54
|
+
dir = File.expand_path(args[1])
|
75
55
|
|
76
|
-
|
77
|
-
|
56
|
+
config = DiscourseTheme::Config.new(SETTINGS_FILE)
|
57
|
+
settings = config[dir]
|
78
58
|
|
79
|
-
|
80
|
-
usage unless ARGV[1]
|
59
|
+
theme_id = settings.theme_id
|
81
60
|
|
82
|
-
|
83
|
-
|
61
|
+
if command == "new"
|
62
|
+
raise DiscourseTheme::ThemeError.new "'#{dir} is not empty" if Dir.exists?(dir) && !Dir.empty?(dir)
|
63
|
+
DiscourseTheme::Scaffold.generate(dir)
|
64
|
+
if Cli.yes?("Would you like to start 'watching' this theme?")
|
65
|
+
args[0] = "watch"
|
66
|
+
Cli.progress "Running discourse_theme #{args.join(' ')}"
|
67
|
+
run(args)
|
68
|
+
end
|
69
|
+
elsif command == "watch"
|
70
|
+
raise DiscourseTheme::ThemeError.new "'#{dir} does not exist" unless Dir.exists?(dir)
|
71
|
+
client = DiscourseTheme::Client.new(dir, settings, reset: reset)
|
84
72
|
|
85
|
-
|
73
|
+
theme_list = client.get_themes_list
|
86
74
|
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
usage
|
94
|
-
end
|
75
|
+
options = {}
|
76
|
+
if theme_id && theme = theme_list.find { |t| t["id"] == theme_id }
|
77
|
+
options["Sync with existing theme: '#{theme["name"]}' (id:#{theme_id})"] = :default
|
78
|
+
end
|
79
|
+
options["Create and sync with a new theme"] = :create
|
80
|
+
options["Select a different theme"] = :select
|
95
81
|
|
96
|
-
|
97
|
-
settings = config[dir]
|
82
|
+
choice = Cli.select('How would you like to sync this theme?', options.keys)
|
98
83
|
|
99
|
-
|
100
|
-
|
84
|
+
if options[choice] == :create
|
85
|
+
theme_id = nil
|
86
|
+
elsif options[choice] == :select
|
87
|
+
themes = render_theme_list(theme_list)
|
88
|
+
choice = Cli.select('Which theme would you like to sync with?', themes)
|
89
|
+
theme_id = extract_theme_id(choice)
|
90
|
+
end
|
101
91
|
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
92
|
+
uploader = DiscourseTheme::Uploader.new(dir: dir, client: client, theme_id: theme_id)
|
93
|
+
|
94
|
+
Cli.progress "Uploading theme from #{dir}"
|
95
|
+
settings.theme_id = theme_id = uploader.upload_full_theme
|
96
|
+
|
97
|
+
Cli.success "Theme uploaded (id:#{theme_id})"
|
98
|
+
watcher = DiscourseTheme::Watcher.new(dir: dir, uploader: uploader)
|
99
|
+
|
100
|
+
Cli.progress "Watching for changes in #{dir}..."
|
101
|
+
watcher.watch
|
102
|
+
|
103
|
+
elsif command == "download"
|
104
|
+
client = DiscourseTheme::Client.new(dir, settings, reset: reset)
|
105
|
+
downloader = DiscourseTheme::Downloader.new(dir: dir, client: client)
|
106
|
+
|
107
|
+
FileUtils.mkdir_p dir unless Dir.exists?(dir)
|
108
|
+
raise DiscourseTheme::ThemeError.new "'#{dir} is not empty" unless Dir.empty?(dir)
|
106
109
|
|
107
|
-
|
108
|
-
|
110
|
+
Cli.progress "Loading theme list..."
|
111
|
+
themes = render_theme_list(client.get_themes_list)
|
112
|
+
|
113
|
+
choice = Cli.select('Which theme would you like to download?', themes)
|
114
|
+
theme_id = extract_theme_id(choice)
|
115
|
+
|
116
|
+
Cli.progress "Downloading theme into #{dir}"
|
117
|
+
|
118
|
+
downloader.download_theme(theme_id)
|
119
|
+
settings.theme_id = theme_id
|
120
|
+
|
121
|
+
Cli.success "Theme downloaded"
|
122
|
+
|
123
|
+
if Cli.yes?("Would you like to start 'watching' this theme?")
|
124
|
+
args[0] = "watch"
|
125
|
+
Cli.progress "Running discourse_theme #{args.join(' ')}"
|
126
|
+
run(args)
|
127
|
+
end
|
128
|
+
else
|
109
129
|
usage
|
110
130
|
end
|
111
131
|
|
112
|
-
|
113
|
-
|
114
|
-
|
132
|
+
Cli.progress "Exiting..."
|
133
|
+
rescue DiscourseTheme::ThemeError => e
|
134
|
+
Cli.error "#{e.message}"
|
135
|
+
rescue Interrupt, TTY::Reader::InputInterrupt => e
|
136
|
+
Cli.error "Interrupted"
|
137
|
+
end
|
115
138
|
|
116
|
-
|
139
|
+
def render_theme_list(themes)
|
140
|
+
themes.sort_by { |t| t["updated_at"] }
|
141
|
+
.reverse.map { |theme| "#{theme["name"]} (id:#{theme["id"]})" }
|
142
|
+
end
|
117
143
|
|
118
|
-
|
119
|
-
|
120
|
-
usage
|
144
|
+
def extract_theme_id(rendered_name)
|
145
|
+
/\(id:([0-9]+)\)$/.match(rendered_name)[1].to_i
|
121
146
|
end
|
122
147
|
end
|
123
148
|
end
|
@@ -0,0 +1,183 @@
|
|
1
|
+
module DiscourseTheme
|
2
|
+
class Client
|
3
|
+
THEME_CREATOR_REGEX = /^https:\/\/theme-creator.discourse.org$/i
|
4
|
+
|
5
|
+
def initialize(dir, settings, reset:)
|
6
|
+
@reset = reset
|
7
|
+
@url = guess_url(settings)
|
8
|
+
@api_key = guess_api_key(settings)
|
9
|
+
|
10
|
+
raise "Missing site to synchronize with!" if !@url
|
11
|
+
raise "Missing api key!" if !@api_key
|
12
|
+
|
13
|
+
@is_theme_creator = !!(THEME_CREATOR_REGEX =~ @url)
|
14
|
+
|
15
|
+
parts = discourse_version.split(".").map { |s| s.sub('beta', '').to_i }
|
16
|
+
if parts[0] < 2 || parts[1] < 2 || parts[2] < 0 || (!parts[3].nil? && parts[3] < 10)
|
17
|
+
Cli.info "discourse_theme is designed for Discourse 2.2.0.beta10 or above"
|
18
|
+
Cli.info "download will not function, and syncing destination will be unpredictable"
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def get_themes_list
|
23
|
+
endpoint = root +
|
24
|
+
if @is_theme_creator
|
25
|
+
"/user_themes.json"
|
26
|
+
else
|
27
|
+
"/admin/customize/themes.json?api_key=#{@api_key}"
|
28
|
+
end
|
29
|
+
|
30
|
+
response = request(Net::HTTP::Get.new(endpoint), never_404: true)
|
31
|
+
JSON.parse(response.body)["themes"]
|
32
|
+
end
|
33
|
+
|
34
|
+
def get_raw_theme_export(id)
|
35
|
+
endpoint = root +
|
36
|
+
if @is_theme_creator
|
37
|
+
"/user_themes/#{id}/export"
|
38
|
+
else
|
39
|
+
"/admin/customize/themes/#{id}/export?api_key=#{@api_key}"
|
40
|
+
end
|
41
|
+
|
42
|
+
response = request(Net::HTTP::Get.new endpoint)
|
43
|
+
raise "Error downloading theme: #{response.code}" unless response.code.to_i == 200
|
44
|
+
response.body
|
45
|
+
end
|
46
|
+
|
47
|
+
def update_theme(id, args)
|
48
|
+
endpoint = root +
|
49
|
+
if @is_theme_creator
|
50
|
+
"/user_themes/#{id}"
|
51
|
+
else
|
52
|
+
"/admin/themes/#{id}?api_key=#{@api_key}"
|
53
|
+
end
|
54
|
+
|
55
|
+
put = Net::HTTP::Put.new(endpoint, 'Content-Type' => 'application/json')
|
56
|
+
put.body = args.to_json
|
57
|
+
request(put)
|
58
|
+
end
|
59
|
+
|
60
|
+
def upload_full_theme(tgz, theme_id:)
|
61
|
+
endpoint = root +
|
62
|
+
if @is_theme_creator
|
63
|
+
"/user_themes/import.json"
|
64
|
+
else
|
65
|
+
"/admin/themes/import.json?api_key=#{@api_key}"
|
66
|
+
end
|
67
|
+
|
68
|
+
post = Net::HTTP::Post::Multipart.new(
|
69
|
+
endpoint,
|
70
|
+
"theme_id" => theme_id,
|
71
|
+
"bundle" => UploadIO.new(tgz, "application/tar+gzip", "bundle.tar.gz")
|
72
|
+
)
|
73
|
+
request(post)
|
74
|
+
end
|
75
|
+
|
76
|
+
def discourse_version
|
77
|
+
endpoint = root +
|
78
|
+
if @is_theme_creator
|
79
|
+
"/about.json"
|
80
|
+
else
|
81
|
+
"/about.json?api_key=#{@api_key}"
|
82
|
+
end
|
83
|
+
|
84
|
+
response = request(Net::HTTP::Get.new(endpoint), never_404: true)
|
85
|
+
json = JSON.parse(response.body)
|
86
|
+
json["about"]["version"]
|
87
|
+
end
|
88
|
+
|
89
|
+
private
|
90
|
+
|
91
|
+
def root
|
92
|
+
@url
|
93
|
+
end
|
94
|
+
|
95
|
+
def request(request, never_404: false)
|
96
|
+
uri = URI.parse(@url)
|
97
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
98
|
+
http.use_ssl = URI::HTTPS === uri
|
99
|
+
add_headers(request)
|
100
|
+
http.request(request).tap do |response|
|
101
|
+
if response.code == '404' && never_404
|
102
|
+
raise DiscourseTheme::ThemeError.new "Error: Incorrect site URL, or API key does not have the correct privileges"
|
103
|
+
elsif !['200', '201'].include?(response.code)
|
104
|
+
errors = JSON.parse(response.body)["errors"].join(', ') rescue nil
|
105
|
+
raise DiscourseTheme::ThemeError.new "Error #{response.code} for #{request.path.split("?")[0]}#{(": " + errors) if errors}"
|
106
|
+
end
|
107
|
+
end
|
108
|
+
rescue Errno::ECONNREFUSED
|
109
|
+
raise DiscourseTheme::ThemeError.new "Connection refused for #{request.path}"
|
110
|
+
end
|
111
|
+
|
112
|
+
def add_headers(request)
|
113
|
+
if @is_theme_creator
|
114
|
+
request["User-Api-Key"] = @api_key
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def guess_url(settings)
|
119
|
+
url = ENV['DISCOURSE_URL']
|
120
|
+
if url
|
121
|
+
Cli.progress "Using #{url} from DISCOURSE_URL"
|
122
|
+
end
|
123
|
+
|
124
|
+
if !url && settings.url
|
125
|
+
url = settings.url
|
126
|
+
Cli.progress "Using #{url} from #{DiscourseTheme::Cli::SETTINGS_FILE}"
|
127
|
+
end
|
128
|
+
|
129
|
+
if !url || @reset
|
130
|
+
url = Cli.ask("What is the root URL of your Discourse site?", default: url).strip
|
131
|
+
url = "http://#{url}" unless url =~ /^https?:\/\//
|
132
|
+
|
133
|
+
# maybe this is an HTTPS redirect
|
134
|
+
uri = URI.parse(url)
|
135
|
+
if URI::HTTP === uri && uri.port == 80 && is_https_redirect?(url)
|
136
|
+
Cli.info "Detected that #{url} should be accessed over https"
|
137
|
+
url = url.sub("http", "https")
|
138
|
+
end
|
139
|
+
|
140
|
+
if Cli.yes?("Would you like this site name stored in #{DiscourseTheme::Cli::SETTINGS_FILE}?")
|
141
|
+
settings.url = url
|
142
|
+
else
|
143
|
+
settings.url = nil
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
url
|
148
|
+
end
|
149
|
+
|
150
|
+
def guess_api_key(settings)
|
151
|
+
api_key = ENV['DISCOURSE_API_KEY']
|
152
|
+
if api_key
|
153
|
+
Cli.progress "Using api key from DISCOURSE_API_KEY"
|
154
|
+
end
|
155
|
+
|
156
|
+
if !api_key && settings.api_key
|
157
|
+
api_key = settings.api_key
|
158
|
+
Cli.progress "Using api key from #{DiscourseTheme::Cli::SETTINGS_FILE}"
|
159
|
+
end
|
160
|
+
|
161
|
+
if !api_key || @reset
|
162
|
+
api_key = Cli.ask("What is your API key?", default: api_key).strip
|
163
|
+
if Cli.yes?("Would you like this API key stored in #{DiscourseTheme::Cli::SETTINGS_FILE}?")
|
164
|
+
settings.api_key = api_key
|
165
|
+
else
|
166
|
+
settings.api_key = nil
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
api_key
|
171
|
+
end
|
172
|
+
|
173
|
+
def is_https_redirect?(url)
|
174
|
+
url = URI.parse(url)
|
175
|
+
path = url.path
|
176
|
+
path = "/" if path.empty?
|
177
|
+
req = Net::HTTP::Get.new("/")
|
178
|
+
response = Net::HTTP.start(url.host, url.port) { |http| http.request(req) }
|
179
|
+
Net::HTTPRedirection === response && response['location'] =~ /^https/i
|
180
|
+
end
|
181
|
+
|
182
|
+
end
|
183
|
+
end
|
@@ -7,11 +7,11 @@ class DiscourseTheme::Config
|
|
7
7
|
end
|
8
8
|
|
9
9
|
def api_key
|
10
|
-
safe_config["api_key"]
|
10
|
+
search_api_key(url) || safe_config["api_key"]
|
11
11
|
end
|
12
12
|
|
13
13
|
def api_key=(val)
|
14
|
-
|
14
|
+
set_api_key(url, val)
|
15
15
|
end
|
16
16
|
|
17
17
|
def url
|
@@ -22,6 +22,14 @@ class DiscourseTheme::Config
|
|
22
22
|
set("url", val)
|
23
23
|
end
|
24
24
|
|
25
|
+
def theme_id
|
26
|
+
safe_config["theme_id"].to_i
|
27
|
+
end
|
28
|
+
|
29
|
+
def theme_id=(theme_id)
|
30
|
+
set("theme_id", theme_id.to_i)
|
31
|
+
end
|
32
|
+
|
25
33
|
protected
|
26
34
|
|
27
35
|
def set(name, val)
|
@@ -39,6 +47,18 @@ class DiscourseTheme::Config
|
|
39
47
|
{}
|
40
48
|
end
|
41
49
|
end
|
50
|
+
|
51
|
+
def search_api_key(url)
|
52
|
+
hash = @config.raw_config["api_keys"]
|
53
|
+
hash[url] if hash
|
54
|
+
end
|
55
|
+
|
56
|
+
def set_api_key(url, api_key)
|
57
|
+
hash = @config.raw_config["api_keys"] ||= {}
|
58
|
+
hash[url] = api_key
|
59
|
+
@config.save
|
60
|
+
api_key
|
61
|
+
end
|
42
62
|
end
|
43
63
|
|
44
64
|
attr_reader :raw_config, :filename
|
@@ -59,13 +79,6 @@ class DiscourseTheme::Config
|
|
59
79
|
end
|
60
80
|
end
|
61
81
|
|
62
|
-
def set(path, url:, api_key:)
|
63
|
-
@raw_config[path] = {
|
64
|
-
"url" => url,
|
65
|
-
"api_key" => api_key
|
66
|
-
}
|
67
|
-
end
|
68
|
-
|
69
82
|
def save
|
70
83
|
File.write(@filename, @raw_config.to_yaml)
|
71
84
|
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
class DiscourseTheme::Downloader
|
2
|
+
|
3
|
+
def initialize(dir:, client:)
|
4
|
+
@dir = dir
|
5
|
+
@client = client
|
6
|
+
@theme_id = nil
|
7
|
+
end
|
8
|
+
|
9
|
+
def download_theme(id)
|
10
|
+
raw = @client.get_raw_theme_export(id)
|
11
|
+
sio = StringIO.new(raw)
|
12
|
+
gz = Zlib::GzipReader.new(sio)
|
13
|
+
Minitar.unpack(gz, @dir)
|
14
|
+
|
15
|
+
# Minitar extracts into a sub directory, move all the files up one dir
|
16
|
+
Dir.chdir(@dir) do
|
17
|
+
folders = Dir.glob('*/')
|
18
|
+
raise "Extraction failed" unless folders.length == 1
|
19
|
+
FileUtils.mv(Dir.glob("#{folders[0]}*"), "./")
|
20
|
+
FileUtils.remove_dir(folders[0])
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def add_headers(request)
|
27
|
+
if @is_theme_creator
|
28
|
+
request["User-Api-Key"] = @api_key
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -1,36 +1,39 @@
|
|
1
|
-
|
1
|
+
module DiscourseTheme
|
2
|
+
class Scaffold
|
2
3
|
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
4
|
+
BLANK_FILES = %w{
|
5
|
+
common/common.scss
|
6
|
+
common/header.html
|
7
|
+
common/after_header.html
|
8
|
+
common/footer.html
|
9
|
+
common/head_tag.html
|
10
|
+
common/body_tag.html
|
11
|
+
common/embedded.scss
|
11
12
|
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
13
|
+
desktop/desktop.scss
|
14
|
+
desktop/header.html
|
15
|
+
desktop/after_header.html
|
16
|
+
desktop/footer.html
|
17
|
+
desktop/head_tag.html
|
18
|
+
desktop/body_tag.html
|
18
19
|
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
20
|
+
mobile/mobile.scss
|
21
|
+
mobile/header.html
|
22
|
+
mobile/after_header.html
|
23
|
+
mobile/footer.html
|
24
|
+
mobile/head_tag.html
|
25
|
+
mobile/body_tag.html
|
25
26
|
|
26
|
-
|
27
|
-
}
|
27
|
+
locales/en.yml
|
28
28
|
|
29
|
-
|
29
|
+
settings.yml
|
30
|
+
}
|
31
|
+
|
32
|
+
ABOUT_JSON = <<~STR
|
30
33
|
{
|
31
34
|
"name": "#NAME#",
|
32
|
-
"about_url":
|
33
|
-
"license_url":
|
35
|
+
"about_url": null,
|
36
|
+
"license_url": null,
|
34
37
|
"assets": {
|
35
38
|
},
|
36
39
|
"color_schemes": {
|
@@ -38,39 +41,35 @@ class DiscourseTheme::Scaffold
|
|
38
41
|
}
|
39
42
|
STR
|
40
43
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
+
HELP = <<~STR
|
45
|
+
Are you a bit lost? Be sure to read https://meta.discourse.org/t/how-to-develop-custom-themes/60848
|
46
|
+
STR
|
44
47
|
|
45
|
-
|
48
|
+
GIT_IGNORE = <<~STR
|
46
49
|
.discourse-site
|
47
50
|
HELP
|
48
51
|
STR
|
49
52
|
|
50
|
-
|
51
|
-
|
53
|
+
def self.generate(dir)
|
54
|
+
Cli.progress "Generating a scaffold theme at #{dir}"
|
52
55
|
|
53
|
-
|
54
|
-
name = STDIN.gets.strip
|
55
|
-
if name.length == 0
|
56
|
-
puts "Please pick a name"
|
57
|
-
exit 1
|
58
|
-
end
|
56
|
+
name = Cli.ask("What would you like to call your theme?").strip
|
59
57
|
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
58
|
+
FileUtils.mkdir_p dir
|
59
|
+
Dir.chdir dir do
|
60
|
+
File.write('about.json', ABOUT_JSON.sub("#NAME#", name))
|
61
|
+
File.write('HELP', HELP)
|
62
|
+
File.write('.gitignore', GIT_IGNORE)
|
65
63
|
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
64
|
+
BLANK_FILES.each do |f|
|
65
|
+
Cli.info "Creating #{f}"
|
66
|
+
FileUtils.mkdir_p File.dirname(f)
|
67
|
+
FileUtils.touch f
|
68
|
+
end
|
71
69
|
|
72
|
-
|
73
|
-
|
70
|
+
Cli.info "Initializing git repo"
|
71
|
+
puts `git init .`
|
72
|
+
end
|
74
73
|
end
|
75
74
|
end
|
76
75
|
end
|
@@ -2,12 +2,10 @@ class DiscourseTheme::Uploader
|
|
2
2
|
|
3
3
|
THEME_CREATOR_REGEX = /^https:\/\/theme-creator.discourse.org$/i
|
4
4
|
|
5
|
-
def initialize(dir:,
|
5
|
+
def initialize(dir:, client:, theme_id: nil)
|
6
6
|
@dir = dir
|
7
|
-
@
|
8
|
-
@
|
9
|
-
@is_theme_creator = !!(THEME_CREATOR_REGEX =~ site)
|
10
|
-
@theme_id = nil
|
7
|
+
@client = client
|
8
|
+
@theme_id = theme_id
|
11
9
|
end
|
12
10
|
|
13
11
|
def compress_dir(gzip, dir)
|
@@ -36,16 +34,15 @@ class DiscourseTheme::Uploader
|
|
36
34
|
puts
|
37
35
|
end
|
38
36
|
count += 1
|
39
|
-
|
40
|
-
|
41
|
-
|
37
|
+
Cli.error
|
38
|
+
Cli.error "Error in #{row["target"]} #{row["name"]}: #{row["error"]}"
|
39
|
+
Cli.error
|
42
40
|
end
|
43
41
|
end
|
44
42
|
count
|
45
43
|
end
|
46
44
|
|
47
45
|
def upload_theme_field(target: , name: , type_id: , value:)
|
48
|
-
|
49
46
|
raise "expecting theme_id to be set!" unless @theme_id
|
50
47
|
|
51
48
|
args = {
|
@@ -59,77 +56,29 @@ class DiscourseTheme::Uploader
|
|
59
56
|
}
|
60
57
|
}
|
61
58
|
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
"/admin/themes/#{@theme_id}?api_key=#{@api_key}"
|
59
|
+
response = @client.update_theme(@theme_id, args)
|
60
|
+
json = JSON.parse(response.body)
|
61
|
+
if diagnose_errors(json) != 0
|
62
|
+
Cli.error "(end of errors)"
|
67
63
|
end
|
68
|
-
|
69
|
-
uri = URI.parse(@site + endpoint)
|
70
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
71
|
-
http.use_ssl = URI::HTTPS === uri
|
72
|
-
|
73
|
-
request = Net::HTTP::Put.new(uri.request_uri, 'Content-Type' => 'application/json')
|
74
|
-
request.body = args.to_json
|
75
|
-
add_headers(request)
|
76
|
-
http.start do |h|
|
77
|
-
response = h.request(request)
|
78
|
-
if response.code.to_i == 200
|
79
|
-
json = JSON.parse(response.body)
|
80
|
-
if diagnose_errors(json) == 0
|
81
|
-
puts "(done)"
|
82
|
-
end
|
83
|
-
else
|
84
|
-
puts "Error importing field status: #{response.code}"
|
85
|
-
end
|
86
|
-
end
|
87
64
|
end
|
88
65
|
|
89
66
|
def upload_full_theme
|
90
67
|
filename = "#{Pathname.new(Dir.tmpdir).realpath}/bundle_#{SecureRandom.hex}.tar.gz"
|
91
68
|
compress_dir(filename, @dir)
|
92
69
|
|
93
|
-
endpoint =
|
94
|
-
if @is_theme_creator
|
95
|
-
"/user_themes/import.json"
|
96
|
-
else
|
97
|
-
"/admin/themes/import.json?api_key=#{@api_key}"
|
98
|
-
end
|
99
|
-
|
100
|
-
uri = URI.parse(@site + endpoint)
|
101
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
102
|
-
http.use_ssl = URI::HTTPS === uri
|
103
70
|
File.open(filename) do |tgz|
|
71
|
+
response = @client.upload_full_theme(tgz, theme_id: @theme_id)
|
104
72
|
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
add_headers(request)
|
110
|
-
response = http.request(request)
|
111
|
-
if response.code.to_i == 201
|
112
|
-
json = JSON.parse(response.body)
|
113
|
-
@theme_id = json["theme"]["id"]
|
114
|
-
if diagnose_errors(json) == 0
|
115
|
-
puts "(done)"
|
116
|
-
end
|
117
|
-
else
|
118
|
-
puts "Error importing theme status: #{response.code}"
|
119
|
-
|
120
|
-
puts response.body
|
73
|
+
json = JSON.parse(response.body)
|
74
|
+
@theme_id = json["theme"]["id"]
|
75
|
+
if diagnose_errors(json) != 0
|
76
|
+
Cli.error "(end of errors)"
|
121
77
|
end
|
78
|
+
@theme_id
|
122
79
|
end
|
123
|
-
|
124
80
|
ensure
|
125
81
|
FileUtils.rm_f filename
|
126
82
|
end
|
127
83
|
|
128
|
-
private
|
129
|
-
|
130
|
-
def add_headers(request)
|
131
|
-
if @is_theme_creator
|
132
|
-
request["User-Api-Key"] = @api_key
|
133
|
-
end
|
134
|
-
end
|
135
84
|
end
|
@@ -1,51 +1,63 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
def watch
|
8
|
-
listener = Listen.to(@dir) do |modified, added, removed|
|
9
|
-
if modified.length == 1 &&
|
10
|
-
added.length == 0 &&
|
11
|
-
removed.length == 0 &&
|
12
|
-
(resolved = resolve_file(modified[0]))
|
13
|
-
|
14
|
-
target, name, type_id = resolved
|
15
|
-
print "Updating #{target} #{name}: "
|
16
|
-
|
17
|
-
@uploader.upload_theme_field(
|
18
|
-
target: target,
|
19
|
-
name: name,
|
20
|
-
value: File.read(modified[0]),
|
21
|
-
type_id: type_id
|
22
|
-
)
|
23
|
-
else
|
24
|
-
print "Full re-sync is required, re-uploading theme: "
|
25
|
-
@uploader.upload_full_theme
|
26
|
-
end
|
1
|
+
module DiscourseTheme
|
2
|
+
class Watcher
|
3
|
+
def initialize(dir:, uploader:)
|
4
|
+
@dir = dir
|
5
|
+
@uploader = uploader
|
27
6
|
end
|
28
7
|
|
29
|
-
|
30
|
-
|
8
|
+
def watch
|
9
|
+
listener = Listen.to(@dir) do |modified, added, removed|
|
10
|
+
begin
|
11
|
+
if modified.length == 1 &&
|
12
|
+
added.length == 0 &&
|
13
|
+
removed.length == 0 &&
|
14
|
+
(resolved = resolve_file(modified[0]))
|
15
|
+
|
16
|
+
target, name, type_id = resolved
|
17
|
+
Cli.progress "Fast updating #{target}.scss"
|
18
|
+
|
19
|
+
@uploader.upload_theme_field(
|
20
|
+
target: target,
|
21
|
+
name: name,
|
22
|
+
value: File.read(modified[0]),
|
23
|
+
type_id: type_id
|
24
|
+
)
|
25
|
+
else
|
26
|
+
count = modified.length + added.length + removed.length
|
27
|
+
if count > 1
|
28
|
+
Cli.progress "Detected changes in #{count} files, uploading theme"
|
29
|
+
else
|
30
|
+
Cli.progress "Detected changes in #{modified[0].gsub(@dir, '')}, uploading theme"
|
31
|
+
end
|
32
|
+
@uploader.upload_full_theme
|
33
|
+
end
|
34
|
+
Cli.success "Done! Watching for changes..."
|
35
|
+
rescue DiscourseTheme::ThemeError => e
|
36
|
+
Cli.error "#{e.message}"
|
37
|
+
Cli.progress "Watching for changes..."
|
38
|
+
end
|
39
|
+
end
|
31
40
|
|
32
|
-
|
41
|
+
listener.start
|
42
|
+
sleep
|
43
|
+
end
|
33
44
|
|
34
|
-
|
45
|
+
protected
|
35
46
|
|
36
|
-
|
37
|
-
|
38
|
-
|
47
|
+
def resolve_file(path)
|
48
|
+
dir_len = File.expand_path(@dir).length
|
49
|
+
name = File.expand_path(path)[dir_len + 1..-1]
|
39
50
|
|
40
|
-
|
51
|
+
target, file = name.split("/")
|
41
52
|
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
53
|
+
if ["common", "desktop", "mobile"].include?(target)
|
54
|
+
if file == "#{target}.scss"
|
55
|
+
# a CSS file
|
56
|
+
return [target, "scss", 1]
|
57
|
+
end
|
46
58
|
end
|
47
|
-
end
|
48
59
|
|
49
|
-
|
60
|
+
nil
|
61
|
+
end
|
50
62
|
end
|
51
63
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: discourse_theme
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Sam Saffron
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2019-02-01 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -16,14 +16,14 @@ dependencies:
|
|
16
16
|
requirements:
|
17
17
|
- - "~>"
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version: '
|
19
|
+
version: '2.0'
|
20
20
|
type: :development
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
24
|
- - "~>"
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version: '
|
26
|
+
version: '2.0'
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
28
|
name: rake
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -80,6 +80,20 @@ dependencies:
|
|
80
80
|
- - "~>"
|
81
81
|
- !ruby/object:Gem::Version
|
82
82
|
version: '2.4'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: webmock
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - "~>"
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '3.5'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - "~>"
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '3.5'
|
83
97
|
- !ruby/object:Gem::Dependency
|
84
98
|
name: minitar
|
85
99
|
requirement: !ruby/object:Gem::Requirement
|
@@ -122,6 +136,20 @@ dependencies:
|
|
122
136
|
- - "~>"
|
123
137
|
- !ruby/object:Gem::Version
|
124
138
|
version: '2.0'
|
139
|
+
- !ruby/object:Gem::Dependency
|
140
|
+
name: tty-prompt
|
141
|
+
requirement: !ruby/object:Gem::Requirement
|
142
|
+
requirements:
|
143
|
+
- - "~>"
|
144
|
+
- !ruby/object:Gem::Version
|
145
|
+
version: '0.18'
|
146
|
+
type: :runtime
|
147
|
+
prerelease: false
|
148
|
+
version_requirements: !ruby/object:Gem::Requirement
|
149
|
+
requirements:
|
150
|
+
- - "~>"
|
151
|
+
- !ruby/object:Gem::Version
|
152
|
+
version: '0.18'
|
125
153
|
description: CLI helper for creating Discourse themes
|
126
154
|
email:
|
127
155
|
- sam.saffron@gmail.com
|
@@ -143,7 +171,9 @@ files:
|
|
143
171
|
- discourse_theme.gemspec
|
144
172
|
- lib/discourse_theme.rb
|
145
173
|
- lib/discourse_theme/cli.rb
|
174
|
+
- lib/discourse_theme/client.rb
|
146
175
|
- lib/discourse_theme/config.rb
|
176
|
+
- lib/discourse_theme/downloader.rb
|
147
177
|
- lib/discourse_theme/scaffold.rb
|
148
178
|
- lib/discourse_theme/uploader.rb
|
149
179
|
- lib/discourse_theme/version.rb
|
@@ -168,7 +198,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
168
198
|
version: '0'
|
169
199
|
requirements: []
|
170
200
|
rubyforge_project:
|
171
|
-
rubygems_version: 2.7.
|
201
|
+
rubygems_version: 2.7.8
|
172
202
|
signing_key:
|
173
203
|
specification_version: 4
|
174
204
|
summary: CLI helper for creating Discourse themes
|