discourse_theme 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|