phraseapp_updater 0.1.7 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,9 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'multi_json'
2
4
  require 'oj'
3
5
 
4
6
  # We're working with pure JSON, not
5
7
  # serialized Ruby objects
6
- Oj.default_options = {mode: :strict}
8
+ Oj.default_options = { mode: :strict }
7
9
 
8
10
  class PhraseAppUpdater
9
11
  class LocaleFile
@@ -11,21 +13,26 @@ class PhraseAppUpdater
11
13
  EXTENSION = "json"
12
14
  PHRASEAPP_TYPE = "nested_json"
13
15
 
14
- def self.from_hash(name, hash)
15
- new(name, MultiJson.dump(hash))
16
- end
16
+ class << self
17
+ def load(content)
18
+ Oj.load(content)
19
+ rescue Oj::ParseError => e
20
+ raise ArgumentError.new("Provided content was not valid JSON: #{e}")
21
+ end
17
22
 
18
- def parse(content)
19
- MultiJson.load(content)
20
- rescue MultiJson::ParseError => e
21
- raise ArgumentError.new("Provided content was not valid JSON: #{e}")
22
- end
23
+ def dump(hash)
24
+ # Add indentation for better diffs
25
+ Oj.dump(hash, indent: 2, mode: :strict)
26
+ end
23
27
 
24
- def format_content!
25
- # Add indentation for better diffs
26
- @content = MultiJson.dump(MultiJson.load(@content), pretty: true)
28
+ def extension
29
+ EXTENSION
30
+ end
31
+
32
+ def phraseapp_type
33
+ PHRASEAPP_TYPE
34
+ end
27
35
  end
28
36
  end
29
37
  end
30
38
  end
31
-
@@ -5,17 +5,24 @@ class PhraseAppUpdater
5
5
  EXTENSION = "yml"
6
6
  PHRASEAPP_TYPE = "yml"
7
7
 
8
- def self.from_hash(name, hash)
9
- new(name, Psych.dump(hash))
10
- end
8
+ class << self
9
+ def load(content)
10
+ Psych.load(content)
11
+ rescue Psych::SyntaxError => e
12
+ raise ArgumentError.new("Provided content was not valid YAML")
13
+ end
11
14
 
12
- def parse(content)
13
- Psych.load(content)
14
- rescue Psych::SyntaxError => e
15
- raise ArgumentError.new("Provided content was not valid YAML")
16
- end
15
+ def dump(hash)
16
+ Psych.dump(hash)
17
+ end
18
+
19
+ def extension
20
+ EXTENSION
21
+ end
17
22
 
18
- def format_content!
23
+ def phraseapp_type
24
+ PHRASEAPP_TYPE
25
+ end
19
26
  end
20
27
  end
21
28
  end
@@ -1,44 +1,100 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'phraseapp_updater/locale_file'
4
+ require 'phraseapp_updater/index_by'
2
5
  require 'phraseapp-ruby'
3
- require 'thread'
6
+ require 'parallel'
4
7
  require 'tempfile'
5
8
 
6
9
  class PhraseAppUpdater
10
+ using IndexBy
7
11
  class PhraseAppAPI
12
+ GIT_TAG_PREFIX = 'gitancestor_'
13
+
8
14
  def initialize(api_key, project_id, locale_file_class)
9
15
  @client = PhraseApp::Client.new(PhraseApp::Auth::Credentials.new(token: api_key))
10
16
  @project_id = project_id
11
17
  @locale_file_class = locale_file_class
12
18
  end
13
19
 
14
- def download_locales
15
- # This is a paginated API, however the maximum page size of 100
16
- # is well above our expected locale size,
17
- # so we take the first page only for now
20
+ def create_project(name, parent_commit)
21
+ params = PhraseApp::RequestParams::ProjectParams.new(
22
+ name: name,
23
+ main_format: @locale_file_class.phraseapp_type)
24
+ project = phraseapp_request { @client.project_create(params) }
25
+ STDERR.puts "Created project #{name} for #{parent_commit}"
26
+
27
+ @project_id = project.id
28
+ store_parent_commit(parent_commit)
29
+
30
+ project.id
31
+ end
32
+
33
+ # We mark projects with their parent git commit using a tag with a
34
+ # well-known prefix. We only allow one tag with this prefix at once.
35
+ def read_parent_commit
36
+ tags = phraseapp_request { @client.tags_list(@project_id, 1, 100) }
37
+ git_tag = tags.detect { |t| t.name.start_with?(GIT_TAG_PREFIX) }
38
+ raise MissingGitParentError.new if git_tag.nil?
39
+
40
+ git_tag.name.delete_prefix(GIT_TAG_PREFIX)
41
+ end
42
+
43
+ def update_parent_commit(commit_hash)
44
+ previous_parent = read_parent_commit
45
+ phraseapp_request do
46
+ @client.tag_delete(@project_id, GIT_TAG_PREFIX + previous_parent)
47
+ end
48
+ store_parent_commit(commit_hash)
49
+ end
50
+
51
+ def fetch_locales
52
+ # This is a paginated API, however the maximum page size of 100 is well
53
+ # above our expected locale size, so we take the first page only for now
18
54
  phraseapp_request { @client.locales_list(@project_id, 1, 100) }.map do |pa_locale|
19
55
  Locale.new(pa_locale)
20
56
  end
21
57
  end
22
58
 
23
- def download_files(locales, skip_unverified)
24
- threaded_request(locales) do |locale|
25
- puts "Downloading file for #{locale}"
59
+ def create_locale(name, default: false)
60
+ phraseapp_request do
61
+ params = PhraseApp::RequestParams::LocaleParams.new(
62
+ name: name,
63
+ code: name,
64
+ default: default,
65
+ )
66
+ @client.locale_create(@project_id, params)
67
+ end
68
+ end
69
+
70
+ def download_files(locales, skip_unverified:)
71
+ results = threaded_request(locales) do |locale|
72
+ STDERR.puts "Downloading file for #{locale}"
26
73
  download_file(locale, skip_unverified)
27
- end.map do |locale, file_contents|
28
- @locale_file_class.new(locale.name, file_contents)
74
+ end
75
+
76
+ locales.zip(results).map do |locale, file_contents|
77
+ @locale_file_class.from_file_content(locale.name, file_contents)
29
78
  end
30
79
  end
31
80
 
32
- def upload_files(locale_files)
81
+ def upload_files(locale_files, default_locale:)
82
+ known_locales = fetch_locales.index_by(&:name)
83
+
33
84
  threaded_request(locale_files) do |locale_file|
34
- puts "Uploading #{locale_file}"
85
+ unless known_locales.has_key?(locale_file.locale_name)
86
+ create_locale(locale_file.locale_name,
87
+ default: (locale_file.locale_name == default_locale))
88
+ end
89
+
90
+ STDERR.puts "Uploading #{locale_file}"
35
91
  upload_file(locale_file)
36
- end.map { |locale_file, upload_id | upload_id }
92
+ end
37
93
  end
38
94
 
39
95
  def remove_keys_not_in_uploads(upload_ids)
40
96
  threaded_request(upload_ids) do |upload_id|
41
- puts "Removing keys not in upload #{upload_id}"
97
+ STDERR.puts "Removing keys not in upload #{upload_id}"
42
98
  remove_keys_not_in_upload(upload_id)
43
99
  end
44
100
  end
@@ -46,18 +102,18 @@ class PhraseAppUpdater
46
102
  def download_file(locale, skip_unverified)
47
103
  download_params = PhraseApp::RequestParams::LocaleDownloadParams.new
48
104
 
49
- download_params.file_format = @locale_file_class.const_get(:PHRASEAPP_TYPE)
105
+ download_params.file_format = @locale_file_class.phraseapp_type
50
106
  download_params.skip_unverified_translations = skip_unverified
51
107
 
52
108
  phraseapp_request { @client.locale_download(@project_id, locale.id, download_params) }
53
109
  end
54
110
 
55
111
  def upload_file(locale_file)
56
- upload_params = create_upload_params(locale_file.name)
112
+ upload_params = create_upload_params(locale_file.locale_name)
57
113
 
58
114
  # The PhraseApp gem only accepts a filename to upload,
59
115
  # so we need to write the file out and pass it the path
60
- Tempfile.create(["#{locale_file.name}", ".json"]) do |f|
116
+ Tempfile.create([locale_file.locale_name, ".json"]) do |f|
61
117
  f.write(locale_file.content)
62
118
  f.close
63
119
 
@@ -72,7 +128,7 @@ class PhraseAppUpdater
72
128
 
73
129
  begin
74
130
  phraseapp_request { @client.keys_delete(@project_id, delete_params) }
75
- rescue RuntimeError => e
131
+ rescue RuntimeError => _e
76
132
  # PhraseApp will accept but mark invalid uploads, however the gem
77
133
  # returns the same response in both cases. If we call this API
78
134
  # with the ID of an upload of a bad file, it will fail.
@@ -84,65 +140,51 @@ class PhraseAppUpdater
84
140
 
85
141
  private
86
142
 
143
+ def store_parent_commit(commit_hash)
144
+ params = PhraseApp::RequestParams::TagParams.new(
145
+ name: GIT_TAG_PREFIX + commit_hash)
146
+ phraseapp_request { @client.tag_create(@project_id, params) }
147
+ end
148
+
87
149
  def phraseapp_request(&block)
88
- begin
89
- res, err = block.call
90
- rescue RuntimeError => e
91
- if e.message[/\(401\)/]
92
- raise BadAPIKeyError.new(e)
93
- elsif e.message[/not found/]
94
- raise BadProjectIDError.new(e)
95
- else
96
- raise e
97
- end
98
- end
150
+ res, err = block.call
99
151
 
100
152
  unless err.nil?
101
- if err.respond_to?(:error)
102
- error = err.error
103
- else
104
- error = err.errors.join("|")
105
- end
153
+ error =
154
+ if err.respond_to?(:error)
155
+ err.error
156
+ else
157
+ err.errors.join('|')
158
+ end
106
159
 
107
160
  raise RuntimeError.new(error)
108
161
  end
109
162
 
110
163
  res
164
+
165
+ rescue RuntimeError => e
166
+ if e.message.match?(/\(401\)/)
167
+ raise BadAPIKeyError.new(e)
168
+ elsif e.message.match?(/not found/)
169
+ raise BadProjectIDError.new(e, @project_id)
170
+ elsif e.message.match?(/has already been taken/)
171
+ raise ProjectNameTakenError.new(e)
172
+ else
173
+ raise
174
+ end
111
175
  end
112
176
 
113
177
  # PhraseApp allows two concurrent connections at a time.
114
178
  THREAD_COUNT = 2
115
179
 
116
180
  def threaded_request(worklist, &block)
117
- queue = worklist.inject(Queue.new, :push)
118
- threads = []
119
-
120
- THREAD_COUNT.times do
121
- threads << Thread.new do
122
- Thread.current[:result] = {}
123
-
124
- begin
125
- while work = queue.pop(true) do
126
- Thread.current[:result][work] = block.call(work)
127
- end
128
- rescue ThreadError => e
129
- Thread.exit
130
- end
131
-
132
- end
133
- end
134
-
135
- threads.each(&:join)
136
-
137
- threads.each_with_object({}) do |thread, results|
138
- results.merge!(thread[:result])
139
- end
181
+ Parallel.map(worklist, in_threads: THREAD_COUNT, &block)
140
182
  end
141
183
 
142
184
  def create_upload_params(locale_name)
143
185
  upload_params = PhraseApp::RequestParams::UploadParams.new
144
- upload_params.file_encoding = "UTF-8"
145
- upload_params.file_format = @locale_file_class.const_get(:PHRASEAPP_TYPE)
186
+ upload_params.file_encoding = 'UTF-8'
187
+ upload_params.file_format = @locale_file_class.phraseapp_type
146
188
  upload_params.locale_id = locale_name
147
189
  upload_params.skip_unverification = false
148
190
  upload_params.update_translations = true
@@ -171,6 +213,12 @@ class PhraseAppUpdater
171
213
  end
172
214
  end
173
215
 
216
+ class MissingGitParentError < RuntimeError
217
+ def initialize
218
+ super('Could not locate tag representing git ancestor commit')
219
+ end
220
+ end
221
+
174
222
  class BadAPIKeyError < RuntimeError
175
223
  def initialize(original_error)
176
224
  super(original_error.message)
@@ -178,10 +226,18 @@ class PhraseAppUpdater
178
226
  end
179
227
 
180
228
  class BadProjectIDError < RuntimeError
229
+ attr_reader :project_id
230
+
231
+ def initialize(original_error, id)
232
+ @project_id = id
233
+ super(original_error.message)
234
+ end
235
+ end
236
+
237
+ class ProjectNameTakenError < RuntimeError
181
238
  def initialize(original_error)
182
239
  super(original_error.message)
183
240
  end
184
241
  end
185
242
  end
186
243
  end
187
-
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class PhraseAppUpdater
2
- VERSION = "0.1.7"
4
+ VERSION = "2.0.0"
3
5
  end
@@ -25,6 +25,7 @@ Gem::Specification.new do |spec|
25
25
  spec.add_dependency "multi_json", "~> 1.12"
26
26
  spec.add_dependency "oj", "~> 2.18"
27
27
  spec.add_dependency "deep_merge", "~> 1.1"
28
+ spec.add_dependency "parallel", "~> 1.12"
28
29
 
29
30
  spec.add_development_dependency "bundler", "~> 1.12"
30
31
  spec.add_development_dependency "rake", "~> 10.0"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phraseapp_updater
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kevin Griffin
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2017-09-12 00:00:00.000000000 Z
11
+ date: 2018-10-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -94,6 +94,20 @@ dependencies:
94
94
  - - "~>"
95
95
  - !ruby/object:Gem::Version
96
96
  version: '1.1'
97
+ - !ruby/object:Gem::Dependency
98
+ name: parallel
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1.12'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1.12'
97
111
  - !ruby/object:Gem::Dependency
98
112
  name: bundler
99
113
  requirement: !ruby/object:Gem::Requirement
@@ -168,14 +182,15 @@ files:
168
182
  - README.markdown
169
183
  - Rakefile
170
184
  - bin/console
185
+ - bin/phraseapp_common.sh
171
186
  - bin/phraseapp_updater
172
187
  - bin/setup
188
+ - bin/synchronize_phraseapp.sh
173
189
  - lib/phraseapp_updater.rb
174
190
  - lib/phraseapp_updater/differ.rb
175
191
  - lib/phraseapp_updater/index_by.rb
176
192
  - lib/phraseapp_updater/locale_file.rb
177
193
  - lib/phraseapp_updater/locale_file/json_file.rb
178
- - lib/phraseapp_updater/locale_file/loader.rb
179
194
  - lib/phraseapp_updater/locale_file/yaml_file.rb
180
195
  - lib/phraseapp_updater/phraseapp_api.rb
181
196
  - lib/phraseapp_updater/version.rb
@@ -201,7 +216,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
201
216
  version: '0'
202
217
  requirements: []
203
218
  rubyforge_project:
204
- rubygems_version: 2.6.11
219
+ rubygems_version: 2.7.7
205
220
  signing_key:
206
221
  specification_version: 4
207
222
  summary: A three-way differ for PhraseApp projects.
@@ -1,24 +0,0 @@
1
- require 'phraseapp_updater/locale_file'
2
-
3
- class PhraseAppUpdater
4
- class LocaleFile
5
- class Loader
6
- def initialize(extension)
7
- @extension = extension
8
- end
9
-
10
- def load(filename)
11
- unless File.readable?(filename) && File.file?(filename)
12
- raise RuntimeError.new("Couldn't read localization file at #{filename}")
13
- end
14
-
15
- LocaleFile.class_for_file_format(@extension).new(File.basename(filename).chomp(".#{@extension}"), File.read(filename))
16
- end
17
-
18
- def filenames(locale_directory)
19
- Dir["#{locale_directory}/*.#{@extension}"]
20
- end
21
- end
22
- end
23
- end
24
-