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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c42bbee99e84d60c23d79bdb0dd289d9b2be382bdfd1c609c77b31f7c689ccda
4
- data.tar.gz: 5c1c7bf95a1c83fe52d677ed766cb77c5d764d6e559f493e71b9bf2e7672bd1c
3
+ metadata.gz: a5c78e3947eb3207c1deec8de4326ca852efabb4c45c9dc616ff8cee76695747
4
+ data.tar.gz: d5024d27d0f687d6300f4c34cfd3ac6c72ef5049f03f10e15397e9d45dcd8bbf
5
5
  SHA512:
6
- metadata.gz: e65319a323e96446890fd013c642bfadcc5fce7e623097a37cb731d5f20f4f9ea7b107adfa8a3db555935a0ee332a244b161c87f9e93cc54618695881e147d42
7
- data.tar.gz: 9706b11b32d297faddd26875a78f6fb9e9b0816e152197f4698ca50c4c03c033406579045cfde2dd5fea34e102ee10f89300e6f8d619bfe168f601c8f1ce8eeb
6
+ metadata.gz: 5aff9397fbe13616f9012bef9e3f41dc4ea0587d09ae8f040e806d7ef51c9f79dc71e911ec0808e16a631a46636ff72ebec244ef543b7a69852bd24265cb40fd
7
+ data.tar.gz: ff3cc429ef37e484c96af103d130140b54c42a07e74a222270fd1f533ede61a3b9e8ee1bf8a4fbeb94d1e047e95481251561ad821eaa58001d2c02fe38d89189
data/.gitignore CHANGED
@@ -7,3 +7,5 @@
7
7
  /spec/reports/
8
8
  /tmp/
9
9
  Gemfile.lock
10
+
11
+ .rubocop-https---raw-githubusercontent-com-discourse-discourse-master--rubocop-yml
data/.rubocop.yml CHANGED
@@ -1,110 +1 @@
1
- AllCops:
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
@@ -7,4 +7,4 @@ Rake::TestTask.new(:test) do |t|
7
7
  t.test_files = FileList["test/**/*_test.rb"]
8
8
  end
9
9
 
10
- task :default => :test
10
+ task default: :test
data/bin/discourse_theme CHANGED
@@ -2,4 +2,4 @@
2
2
 
3
3
  require_relative '../lib/discourse_theme'
4
4
 
5
- DiscourseTheme::Cli.new.run
5
+ DiscourseTheme::Cli.new.run(ARGV)
@@ -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", "~> 1.16"
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
@@ -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
@@ -1,123 +1,148 @@
1
- class DiscourseTheme::Cli
1
+ module DiscourseTheme
2
+ class Cli
2
3
 
3
- SETTINGS_FILE = File.expand_path("~/.discourse_theme")
4
+ @@prompt = ::TTY::Prompt.new(help_color: :cyan)
5
+ @@pastel = Pastel.new
4
6
 
5
- def usage
6
- puts "Usage: discourse_theme COMMAND"
7
- puts
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
- def guess_api_key(settings)
14
- api_key = ENV['DISCOURSE_API_KEY']
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
- if !api_key && settings.api_key
20
- api_key = settings.api_key
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
- if !api_key
25
- puts "No API key found in DISCOURSE_API_KEY env var enter your API key: "
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
- api_key
35
- end
23
+ def self.progress(message)
24
+ puts @@pastel.yellow("» ") + message
25
+ end
36
26
 
37
- def is_https_redirect?(url)
38
- url = URI.parse(url)
39
- path = url.path
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
- def guess_url(settings)
47
- url = ENV['DISCOURSE_URL']
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
- if !url && settings.url
53
- url = settings.url
54
- puts "Using #{url} defined in #{SETTINGS_FILE}"
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
- if !url
58
- puts "No site found! Where would you like to synchronize the theme to: "
59
- url = STDIN.gets.strip
60
- url = "http://#{url}" unless url =~ /^https?:\/\//
48
+ def run(args)
49
+ usage unless args[1]
61
50
 
62
- # maybe this is an HTTPS redirect
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
- puts "Would you like me to store this site name at: #{SETTINGS_FILE}? (Yes|No)"
70
- answer = STDIN.gets.strip
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
- url
77
- end
56
+ config = DiscourseTheme::Config.new(SETTINGS_FILE)
57
+ settings = config[dir]
78
58
 
79
- def run
80
- usage unless ARGV[1]
59
+ theme_id = settings.theme_id
81
60
 
82
- command = ARGV[0].to_s.downcase
83
- dir = File.expand_path(ARGV[1])
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
- dir_exists = File.exist?(dir)
73
+ theme_list = client.get_themes_list
86
74
 
87
- if command == "new" && !dir_exists
88
- DiscourseTheme::Scaffold.generate(dir)
89
- elsif command == "watch" && dir_exists
90
- if !File.exist?("#{dir}/about.json")
91
- puts "No about.json file found in #{dir}!"
92
- puts
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
- config = DiscourseTheme::Config.new(SETTINGS_FILE)
97
- settings = config[dir]
82
+ choice = Cli.select('How would you like to sync this theme?', options.keys)
98
83
 
99
- url = guess_url(settings)
100
- api_key = guess_api_key(settings)
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
- if !url
103
- puts "Missing site to synchronize with!"
104
- usage
105
- end
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
- if !api_key
108
- puts "Missing api key!"
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
- uploader = DiscourseTheme::Uploader.new(dir: dir, api_key: api_key, site: url)
113
- print "Uploading theme from #{dir} to #{url} : "
114
- uploader.upload_full_theme
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
- watcher = DiscourseTheme::Watcher.new(dir: dir, uploader: uploader)
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
- watcher.watch
119
- else
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
- set("api_key", val)
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
- class DiscourseTheme::Scaffold
1
+ module DiscourseTheme
2
+ class Scaffold
2
3
 
3
- BLANK_FILES = %w{
4
- common/common.scss
5
- common/header.html
6
- common/after_header.html
7
- common/footer.html
8
- common/head_tag.html
9
- common/body_tag.html
10
- common/embedded.scss
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
- desktop/desktop.scss
13
- desktop/header.html
14
- desktop/after_header.html
15
- desktop/footer.html
16
- desktop/head_tag.html
17
- desktop/body_tag.html
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
- mobile/mobile.scss
20
- mobile/header.html
21
- mobile/after_header.html
22
- mobile/footer.html
23
- mobile/head_tag.html
24
- mobile/body_tag.html
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
- settings.yml
27
- }
27
+ locales/en.yml
28
28
 
29
- ABOUT_JSON = <<~STR
29
+ settings.yml
30
+ }
31
+
32
+ ABOUT_JSON = <<~STR
30
33
  {
31
34
  "name": "#NAME#",
32
- "about_url": "URL",
33
- "license_url": "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
- HELP = <<~STR
42
- Are you a bit lost? Be sure to read https://meta.discourse.org/t/how-to-develop-custom-themes/60848
43
- STR
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
- GIT_IGNORE = <<~STR
48
+ GIT_IGNORE = <<~STR
46
49
  .discourse-site
47
50
  HELP
48
51
  STR
49
52
 
50
- def self.generate(dir)
51
- puts "Generating a scaffold theme at #{dir}"
53
+ def self.generate(dir)
54
+ Cli.progress "Generating a scaffold theme at #{dir}"
52
55
 
53
- puts "What would you like to call your theme? "
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
- FileUtils.mkdir_p dir
61
- Dir.chdir dir do
62
- File.write('about.json', ABOUT_JSON.sub("#NAME#", name))
63
- File.write('HELP', HELP)
64
- File.write('.gitignore', GIT_IGNORE)
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
- BLANK_FILES.each do |f|
67
- puts "#{f}"
68
- FileUtils.mkdir_p File.dirname(f)
69
- FileUtils.touch f
70
- end
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
- puts "Initializing git repo"
73
- puts `git init .`
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:, api_key:, site:)
5
+ def initialize(dir:, client:, theme_id: nil)
6
6
  @dir = dir
7
- @api_key = api_key
8
- @site = site
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
- puts
40
- puts "Error in #{row["target"]} #{row["name"]}: #{row["error"]}"
41
- puts
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
- endpoint =
63
- if @is_theme_creator
64
- "/user_themes/#{@theme_id}"
65
- else
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
- request = Net::HTTP::Post::Multipart.new(
106
- uri.request_uri,
107
- "bundle" => UploadIO.new(tgz, "application/tar+gzip", "bundle.tar.gz"),
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,3 +1,3 @@
1
1
  module DiscourseTheme
2
- VERSION = "0.1.8"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -1,51 +1,63 @@
1
- class DiscourseTheme::Watcher
2
- def initialize(dir:, uploader:)
3
- @dir = dir
4
- @uploader = uploader
5
- end
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
- listener.start
30
- sleep
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
- end
41
+ listener.start
42
+ sleep
43
+ end
33
44
 
34
- protected
45
+ protected
35
46
 
36
- def resolve_file(path)
37
- dir_len = File.expand_path(@dir).length
38
- name = File.expand_path(path)[dir_len + 1..-1]
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
- target, file = name.split("/")
51
+ target, file = name.split("/")
41
52
 
42
- if ["common", "desktop", "mobile"].include?(target)
43
- if file == "#{target}.scss"
44
- # a CSS file
45
- return [target, "scss", 1]
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
- nil
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.1.8
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: 2018-05-22 00:00:00.000000000 Z
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: '1.16'
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: '1.16'
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.6
201
+ rubygems_version: 2.7.8
172
202
  signing_key:
173
203
  specification_version: 4
174
204
  summary: CLI helper for creating Discourse themes