phraseapp_updater 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 9b417ad418dde918054346c6ae0ab08666a5d205
4
+ data.tar.gz: 8c76240084044ebee4a3a602293914760e72e8f0
5
+ SHA512:
6
+ metadata.gz: bdac23c33192d626f2cfee46bd0481f5efd327742fb0ea2f1e64e4a1db04e06b29ba423156af235627522726c97c8c060cbd8288f1884dc8077cebe79576cee4
7
+ data.tar.gz: 304f52b5b55c1c1c0b2e445e8e72a5563b83d984723d8d0265b9ccc5005d45f0e7db2120df799fb5d42b6797b368f2f755671daf72008cfec40f54e5a496728a
data/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.3.3
5
+ before_install: gem install bundler -v 1.12.5
@@ -0,0 +1,50 @@
1
+ # Contributor Code of Conduct
2
+
3
+ As contributors and maintainers of this project, and in the interest of
4
+ fostering an open and welcoming community, we pledge to respect all people who
5
+ contribute through reporting issues, posting feature requests, updating
6
+ documentation, submitting pull requests or patches, and other activities.
7
+
8
+ We are committed to making participation in this project a harassment-free
9
+ experience for everyone, regardless of level of experience, gender, gender
10
+ identity and expression, sexual orientation, disability, personal appearance,
11
+ body size, race, ethnicity, age, religion, or nationality.
12
+
13
+ Examples of unacceptable behavior by participants include:
14
+
15
+ * The use of sexualized language or imagery
16
+ * Personal attacks
17
+ * Trolling or insulting/derogatory comments
18
+ * Public or private harassment
19
+ * Publishing other's private information, such as physical or electronic
20
+ addresses, without explicit permission
21
+ * Other unethical or unprofessional conduct
22
+
23
+ Project maintainers have the right and responsibility to remove, edit, or
24
+ reject comments, commits, code, wiki edits, issues, and other contributions
25
+ that are not aligned to this Code of Conduct, or to ban temporarily or
26
+ permanently any contributor for other behaviors that they deem inappropriate,
27
+ threatening, offensive, or harmful.
28
+
29
+ By adopting this Code of Conduct, project maintainers commit themselves to
30
+ fairly and consistently applying these principles to every aspect of managing
31
+ this project. Project maintainers who do not follow or enforce the Code of
32
+ Conduct may be permanently removed from the project team.
33
+
34
+ This code of conduct applies both within project spaces and in public spaces
35
+ when an individual is representing the project or its community.
36
+
37
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
38
+ reported by contacting a project maintainer at kev@bibo.com.ph. All
39
+ complaints will be reviewed and investigated and will result in a response that
40
+ is deemed necessary and appropriate to the circumstances. Maintainers are
41
+ obligated to maintain confidentiality with regard to the reporter of an
42
+ incident.
43
+
44
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
45
+ version 1.3.0, available at
46
+ [http://contributor-covenant.org/version/1/3/0/][version]
47
+
48
+ [homepage]: http://contributor-covenant.org
49
+ [version]: http://contributor-covenant.org/version/1/3/0/
50
+
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in phraseapp_updater.gemspec
4
+ gemspec
5
+
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2017 Kevin Griffin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
22
+
data/README.markdown ADDED
@@ -0,0 +1,129 @@
1
+ # PhraseAppUpdater
2
+
3
+ [![Build Status](https://travis-ci.org/iknow/phraseapp_updater.svg?branch=master)](https://travis-ci.org/iknow/phraseapp_updater)
4
+
5
+ **Version** 0.1.0
6
+
7
+ This is a tool for performing three-way merges of [PhraseApp](https://phraseapp.com) locale data with locale data commited to your application.
8
+
9
+ Our current workflow has localizers working on a `master` project on
10
+ PhraseApp. This regularly gets pulled into the `master` branch of our
11
+ application and released. This branch is for "maintenance" localizations:
12
+ ongoing translations of existing locale keys.
13
+
14
+ However, we also introduce, remove, and change locale data by merging in
15
+ feature branches to `master`. When we do this, we want to update the
16
+ `master` PhraseApp project with the data newly-commited to our `master`
17
+ branch. PhraseApp provides [APIs](https://phraseapp.com/docs/api/v2/) and a [Ruby gem](https://github.com/phrase/phraseapp-ruby) for accessing
18
+ them, but the API only allows either a) completely overwriting
19
+ PhraseApp's data or b) reapplying PhraseApp's data on top of the
20
+ uploaded data.
21
+
22
+ What we want instead is a three way merge where the uploaded data wins
23
+ on conflict. Non-conflicting changes on PhraseApp are preserved, while
24
+ changes on both sides take the committed data. The result of the merge
25
+ is then sent to PhraseApp, keeping it up-to-date with the newest commit
26
+ of `master`.
27
+
28
+ This is especially important when removing keys. Imagine we have the
29
+ following, no-longer useful key:
30
+
31
+ ```json
32
+ unused:
33
+ one: An unused
34
+ ```
35
+
36
+ On PhraseApp, we've added another plural form:
37
+
38
+
39
+ ```json
40
+ unused:
41
+ one: An unused
42
+ zero: No unused's
43
+ ```
44
+
45
+ And in our feature branch, we remove it. The result we want is that the
46
+ key completely disappears, instead of getting a result like either of
47
+ the above.
48
+
49
+ ## Installation
50
+
51
+ This gem provides a command line interface for performing the
52
+ merge and uploading the result to PhraseApp. To use it, install the gem:
53
+
54
+ `gem install phraseapp_updater`
55
+
56
+ You may also use this gem programatically from your own application.
57
+
58
+ Add this line to your application's Gemfile:
59
+
60
+ ```ruby
61
+ gem 'phraseapp_updater'
62
+ ```
63
+
64
+ And then execute:
65
+
66
+ $ bundle
67
+
68
+ Or install it yourself as:
69
+
70
+ $ gem install phraseapp_updater
71
+
72
+ ## Usage
73
+
74
+ CLI
75
+ ---
76
+
77
+ `phraseapp_updater` operates on two directories and your PhraseApp API
78
+ data. The two directories should contain the previous revision of your
79
+ locale files and the latest revision of the same files. These will be
80
+ used in the merge with the files on PhraseApp.
81
+
82
+ The main command is the the `push_changes` command:
83
+
84
+ ```
85
+ phraseapp_updater push_changes --new_locales_path="/data/previous", --previous_locales_path="/data/new" --phraseapp_api_key="yourkey" --phraseapp_project_id="projectid"
86
+ ```
87
+
88
+ The arguments provided to the command can also be specified as shell
89
+ variables:
90
+
91
+ ```
92
+ PA_NEW_LOCALES_PATH
93
+ PA_PREVIOUS_LOCALES_PATH
94
+ PA_API_KEY
95
+ PA_PROJECT_ID
96
+ ```
97
+
98
+ Ruby
99
+ ---
100
+
101
+ `PhraseAppUpdater.push` is analogous to the command line version:
102
+
103
+ ```ruby
104
+ PhraseAppUpdater.push("api_key", "project_id", "previous/path", "current/path")
105
+ ```
106
+
107
+ ## Future Improvements
108
+
109
+ If you'd like to contribute, these would be very helpful!
110
+
111
+ * Expose the changed files on the command line
112
+ * Implement other `LocaleFile`s with `parse` for non-JSON types
113
+ * Checking if PhraseApp files changed during execution before upload, to reduce the race condition window
114
+ * More specs for the API and shell
115
+
116
+ ## Development
117
+
118
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
119
+
120
+ To install this gem onto your local machine, run `bundle exec rake install`. When everything is working, make a pull request.
121
+
122
+ ## Contributing
123
+
124
+ Bug reports and pull requests are welcome on GitHub at https://github.com/iknow/phraseapp_updater. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
125
+
126
+ ## License
127
+
128
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
129
+
data/Rakefile ADDED
@@ -0,0 +1,7 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
7
+
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "phraseapp_updater"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "pry"
14
+ Pry.start
15
+
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env ruby
2
+ require 'thor'
3
+ require 'phraseapp_updater'
4
+
5
+ class PhraseAppUpdaterCLI < Thor
6
+ desc "push", "Update PhraseApp project by merging changes from locale file and PhraseApp"
7
+ option :new_locales_path, type: :string
8
+ option :previous_locales_path, type: :string
9
+ option :phraseapp_api_key, type: :string
10
+ option :phraseapp_project_id, type: :string
11
+ option :store_results_path, type: :string
12
+ option :store_phraseapp_originals_path, type: :string
13
+
14
+ def push
15
+ new_locales_path = options.fetch(:new_locales_path, ENV["PA_NEW_LOCALES_PATH"])
16
+ if new_locales_path.to_s.empty?
17
+ raise RuntimeError.new("Must provide a path to the locale files to upload. --new_locales_path or PA_NEW_LOCALES_PATH")
18
+ end
19
+
20
+ unless File.readable?(new_locales_path) && File.directory?(new_locales_path)
21
+ raise RuntimeError.new("Path to locales is not a readable directory: #{new_locales_path}")
22
+ end
23
+
24
+ previous_locales_path = options.fetch(:previous_locales_path, ENV["PA_PREVIOUS_LOCALES_PATH"])
25
+ if previous_locales_path.to_s.empty?
26
+ raise RuntimeError.new("Must provide a path to the locale files to upload. --previous_locales_path or PA_PREVIOUS_LOCALES_PATH")
27
+ end
28
+
29
+ unless File.readable?(previous_locales_path) && File.directory?(previous_locales_path)
30
+ raise RuntimeError.new("Path to locales is not a readable directory: #{previous_locales_path}")
31
+ end
32
+
33
+ phraseapp_api_key = options.fetch(:phraseapp_api_key, ENV["PA_API_KEY"])
34
+ if phraseapp_api_key.to_s.empty?
35
+ raise RuntimeError.new("Must provide Phraseapp API key. --phraseapp_api_key or PA_API_KEY")
36
+ end
37
+
38
+ phraseapp_project_id = options.fetch(:phraseapp_project_id, ENV["PA_PROJECT_ID"])
39
+ if phraseapp_project_id.to_s.empty?
40
+ raise RuntimeError.new("Must provide Phraseapp project ID. --phraseapp_project_id or PA_PROJECT_ID")
41
+ end
42
+
43
+ store_results_path = options.fetch(:store_results_path, ENV["PA_store_results_path"]).to_s
44
+
45
+ if !store_results_path.empty? && (!File.writable?(store_results_path) || !File.directory?(store_results_path))
46
+ raise RuntimeError.new("Path to store results is not a writable directory: #{store_results_path}")
47
+ end
48
+
49
+ store_phraseapp_originals_path = options.fetch(:store_phraseapp_originals_path, ENV["PA_store_phraseapp_originals_path"]).to_s
50
+
51
+ if !store_phraseapp_originals_path.empty? && (!File.writable?(store_phraseapp_originals_path) || !File.directory?(store_phraseapp_originals_path))
52
+ raise RuntimeError.new("Path to store PhraseApp originals is not a writable directory: #{store_phraseapp_originals_path}")
53
+ end
54
+
55
+ begin
56
+ result = PhraseAppUpdater.push(phraseapp_api_key, phraseapp_project_id, previous_locales_path, new_locales_path)
57
+
58
+ unless store_results_path.empty?
59
+ result.resolved_files.each do |file|
60
+ path = "#{store_results_path.chomp("/")}/#{file.name}.json"
61
+ File.write(path, file.content)
62
+ end
63
+ end
64
+
65
+ unless store_phraseapp_originals_path.empty?
66
+ result.resolved_files.each do |file|
67
+ path = "#{store_phraseapp_originals_path.chomp("/")}/#{file.name}.json"
68
+ File.write(path, file.content)
69
+ end
70
+ end
71
+ rescue StandardError => e
72
+ # Like a bad API key
73
+ # Raise more specific errors and handle
74
+ raise e
75
+ end
76
+ end
77
+
78
+
79
+ desc "pull", "Pulls data from PhraseApp for deployment."
80
+ option :fallback_path, type: :string, required: true
81
+ option :destination_path, type: :string
82
+ option :phraseapp_api_key, type: :string
83
+ option :phraseapp_project_id, type: :string
84
+
85
+ def pull
86
+ fallback_path = options[:fallback_path]
87
+ unless File.readable?(fallback_path) && File.directory?(fallback_path)
88
+ raise RuntimeError.new("fallback_path directory is not a readable directory: #{fallback_path}")
89
+ end
90
+
91
+ destination_path = options.fetch(:destination_path, fallback_path)
92
+
93
+ unless File.writable?(fallback_path) && File.directory?(fallback_path)
94
+ raise RuntimeError.new("destination directory is not a writable directory: #{fallback_path}")
95
+ end
96
+
97
+ phraseapp_api_key = options.fetch(:phraseapp_api_key, ENV["PA_API_KEY"])
98
+ if phraseapp_api_key.to_s.empty?
99
+ raise RuntimeError.new("Must provide Phraseapp API key. --phraseapp_api_key or PA_API_KEY")
100
+ end
101
+
102
+ phraseapp_project_id = options.fetch(:phraseapp_project_id, ENV["PA_PROJECT_ID"])
103
+ if phraseapp_project_id.to_s.empty?
104
+ raise RuntimeError.new("Must provide Phraseapp project ID. --phraseapp_project_id or PA_PROJECT_ID")
105
+ end
106
+
107
+ files = PhraseAppUpdater.pull(phraseapp_api_key, phraseapp_project_id, fallback_path)
108
+
109
+ files.each do |file|
110
+ path = "#{destination_path.chomp("/")}/#{file.name}.json"
111
+ File.write(path, file.content)
112
+ end
113
+ end
114
+ end
115
+
116
+ PhraseAppUpdaterCLI.start(ARGV)
117
+
data/bin/setup ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
9
+
@@ -0,0 +1,145 @@
1
+ require 'set'
2
+ require 'hashdiff'
3
+ require 'deep_merge'
4
+
5
+ class PhraseAppUpdater
6
+ class Differ
7
+ SEPARATOR = "~~~"
8
+ using IndexBy
9
+
10
+ class << self
11
+ # Resolution strategy is that primary always wins in the event of a conflict
12
+ def resolve_diffs(primary:, secondary:, secondary_deleted_prefixes:)
13
+ primary = primary.index_by { |op, path, from, to| path }
14
+ secondary = secondary.index_by { |op, path, from, to| path }
15
+
16
+ # As well as explicit conflicts, we want to make sure that deletions or
17
+ # incompatible type changes to a `primary` key prevent addition of child
18
+ # keys in `secondary`. Because input hashes are flattened, it's never
19
+ # possible for a given path and its prefix to be in the same input.
20
+ # For example, in:
21
+ #
22
+ # primary = [["+", "a", 1]]
23
+ # secondary = [["+", "a.b", 2]]
24
+ #
25
+ # the secondary change is impossible to perform on top of the primary, and
26
+ # must be blocked.
27
+ #
28
+ # This applies in reverse: prefixes of paths in `p` need to be available
29
+ # as hashes, so must not appear as terminals in `s`:
30
+ #
31
+ # primary = [["+", "a.b", 2]]
32
+ # secondary = [["+", "a", 1]]
33
+ primary_prefixes = primary.keys.flat_map { |p| path_prefixes(p) }.to_set
34
+
35
+ # Remove conflicting entries from secondary, recording incompatible
36
+ # changes.
37
+ path_conflicts = []
38
+ secondary.delete_if do |path, diff|
39
+ if primary_prefixes.include?(path) || primary.keys.any? { |pk| path.start_with?(pk) }
40
+ path_conflicts << path unless primary.has_key?(path) && diff == primary[path]
41
+ true
42
+ else
43
+ false
44
+ end
45
+ end
46
+
47
+ # For all path conflicts matching secondary_deleted_prefixes, additionally
48
+ # remove other changes with the same prefix.
49
+ prefix_conflicts = secondary_deleted_prefixes.select do |prefix|
50
+ path_conflicts.any? { |path| path.start_with?(prefix) }
51
+ end
52
+
53
+ secondary.delete_if do |path, diff|
54
+ prefix_conflicts.any? { |prefix| path.start_with?(prefix) }
55
+ end
56
+
57
+ primary.values + secondary.values
58
+ end
59
+
60
+ def apply_diffs(hash, diffs)
61
+ deep_compact!(HashDiff.patch!(hash, diffs))
62
+ end
63
+
64
+ def resolve!(original:, primary:, secondary:)
65
+ # To appropriately cope with type changes on either sides, flatten the
66
+ # trees before calculating the difference and then expand afterwards.
67
+ f_original = flatten(original)
68
+ f_primary = flatten(primary)
69
+ f_secondary = flatten(secondary)
70
+
71
+ primary_diffs = HashDiff.diff(f_original, f_primary)
72
+ secondary_diffs = HashDiff.diff(f_original, f_secondary)
73
+
74
+ # However, flattening discards one critical piece of information: when we
75
+ # have deleted or clobbered an entire prefix (subtree) from the original,
76
+ # we want to consider this deletion atomic. If any of the changes is
77
+ # cancelled, they must all be. Motivating example:
78
+ #
79
+ # original: { word: { one: "..", "many": ".." } }
80
+ # primary: { word: { one: "..", "many": "..", "zero": ".." } }
81
+ # secondary: { word: ".." }
82
+ # would unexpectedly result in { word: { zero: ".." } }.
83
+ #
84
+ # Additionally calculate subtree prefixes that were deleted in `secondary`:
85
+ secondary_deleted_prefixes =
86
+ HashDiff.diff(original, secondary, delimiter: SEPARATOR).lazy
87
+ .select { |op, path, from, to| (op == "-" || op == "~") && from.is_a?(Hash) && !to.is_a?(Hash) }
88
+ .map { |op, path, from, to| path }
89
+ .to_a
90
+
91
+
92
+ resolved_diffs = resolve_diffs(primary: primary_diffs,
93
+ secondary: secondary_diffs,
94
+ secondary_deleted_prefixes: secondary_deleted_prefixes)
95
+ HashDiff.patch!(f_original, resolved_diffs)
96
+
97
+ expand(f_original)
98
+ end
99
+
100
+
101
+ # Prefer everything in current except deletions,
102
+ # which are restored from previous if available
103
+ def restore_deletions(current, previous)
104
+ current.deep_merge(previous)
105
+ end
106
+
107
+ private
108
+
109
+ def flatten(hash, prefix = nil, acc = {})
110
+ hash.each do |k, v|
111
+ k = "#{prefix}#{SEPARATOR}#{k}" if prefix
112
+ if v.is_a?(Hash)
113
+ flatten(v, k, acc)
114
+ else
115
+ acc[k] = v
116
+ end
117
+ end
118
+ acc
119
+ end
120
+
121
+ def expand(flat_hash)
122
+ flat_hash.each_with_object({}) do |(key, value), root|
123
+ path = key.split(SEPARATOR)
124
+ leaf_key = path.pop
125
+ leaf = path.inject(root) do |node, path_key|
126
+ node[path_key] ||= {}
127
+ end
128
+ raise ArgumentError.new("Type conflict in flattened hash expand: expected no key at #{key}") if leaf.has_key?(leaf_key)
129
+ leaf[leaf_key] = value
130
+ end
131
+ end
132
+
133
+ def path_prefixes(path_string)
134
+ path = path_string.split(SEPARATOR)
135
+ parents = []
136
+ path.inject do |acc, el|
137
+ parents << acc
138
+ "#{acc}#{SEPARATOR}#{el}"
139
+ end
140
+ parents
141
+ end
142
+ end
143
+ end
144
+ end
145
+
@@ -0,0 +1,11 @@
1
+ class PhraseAppUpdater
2
+ module IndexBy
3
+ refine Array do
4
+ def index_by(&block)
5
+ each_with_object({}) do |value, hash|
6
+ hash[yield(value)] = value
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,41 @@
1
+ require 'multi_json'
2
+ require 'oj'
3
+
4
+ # We're working with pure JSON, not
5
+ # serialized Ruby objects
6
+ Oj.default_options = {mode: :strict}
7
+
8
+ class PhraseAppUpdater
9
+ class LocaleFile
10
+ attr_reader :name, :content, :parsed_content
11
+
12
+ def self.from_hash(name, hash)
13
+ new(name, MultiJson.dump(hash))
14
+ end
15
+
16
+ def initialize(name, content)
17
+ @name = name
18
+ @content = content
19
+ @parsed_content = parse(@content)
20
+ format_content!
21
+ end
22
+
23
+ def to_s
24
+ "#{name}, #{content[0,20]}..."
25
+ end
26
+
27
+ private
28
+
29
+ def parse(content)
30
+ MultiJson.load(content)
31
+ rescue MultiJson::ParseError => e
32
+ raise ArgumentError.new("Provided content was not valid JSON")
33
+ end
34
+
35
+ def format_content!
36
+ # Add indentation for better diffs
37
+ @content = MultiJson.dump(MultiJson.load(@content), pretty: true)
38
+ end
39
+ end
40
+ end
41
+
@@ -0,0 +1,18 @@
1
+ require 'phraseapp_updater/locale_file'
2
+
3
+ class PhraseAppUpdater
4
+ class LocaleFileLoader
5
+ def self.load(filename)
6
+ unless File.readable?(filename) && File.file?(filename)
7
+ raise RuntimeError.new("Couldn't read localization file at #{filename}")
8
+ end
9
+
10
+ LocaleFile.new(File.basename(filename).chomp(".json"), File.read(filename))
11
+ end
12
+
13
+ def self.filenames(locale_directory)
14
+ Dir["#{locale_directory}/*.json"]
15
+ end
16
+ end
17
+ end
18
+
@@ -0,0 +1,163 @@
1
+ require 'phraseapp-ruby'
2
+ require 'phraseapp_updater/locale_file'
3
+ require 'thread'
4
+
5
+ class PhraseAppUpdater
6
+ class PhraseAppAPI
7
+ def initialize(api_key, project_id)
8
+ @client = PhraseApp::Client.new(PhraseApp::Auth::Credentials.new(token: api_key))
9
+ @project_id = project_id
10
+ end
11
+
12
+ def download_locales
13
+ # This is a paginated API, however the maximum page size of 100
14
+ # is well above our expected locale size,
15
+ # so we take the first page only for now
16
+ phraseapp_request { @client.locales_list(@project_id, 1, 100) }.map do |pa_locale|
17
+ Locale.new(pa_locale)
18
+ end
19
+ end
20
+
21
+ def download_files(locales, skip_unverified)
22
+ threaded_request(locales) do |locale|
23
+ puts "Downloading file for #{locale}"
24
+ download_file(locale, skip_unverified)
25
+ end.map do |locale, file_contents|
26
+ LocaleFile.new(locale.name, file_contents)
27
+ end
28
+ end
29
+
30
+ def upload_files(locale_files)
31
+ threaded_request(locale_files) do |locale_file|
32
+ puts "Uploading #{locale_file}"
33
+ upload_file(locale_file)
34
+ end.map { |locale_file, upload_id | upload_id }
35
+ end
36
+
37
+ def remove_keys_not_in_uploads(upload_ids)
38
+ threaded_request(upload_ids) do |upload_id|
39
+ puts "Removing keys not in upload #{upload_id}"
40
+ remove_keys_not_in_upload(upload_id)
41
+ end
42
+ end
43
+
44
+ def download_file(locale, skip_unverified)
45
+ download_params = PhraseApp::RequestParams::LocaleDownloadParams.new
46
+
47
+ download_params.file_format = "nested_json"
48
+ download_params.skip_unverified_translations = skip_unverified
49
+
50
+ phraseapp_request { @client.locale_download(@project_id, locale.id, download_params) }
51
+ end
52
+
53
+ def upload_file(locale_file)
54
+ upload_params = create_upload_params(locale_file.name)
55
+
56
+ # The PhraseApp gem only accepts a filename to upload,
57
+ # so we need to write the file out and pass it the path
58
+ Tempfile.create(["#{locale_file.name}", ".json"]) do |f|
59
+ f.write(locale_file.content)
60
+ f.close
61
+
62
+ upload_params.file = f.path
63
+ phraseapp_request { @client.upload_create(@project_id, upload_params) }.id
64
+ end
65
+ end
66
+
67
+ def remove_keys_not_in_upload(upload_id)
68
+ delete_params = PhraseApp::RequestParams::KeysDeleteParams.new
69
+ delete_params.q = "unmentioned_in_upload:#{upload_id}"
70
+
71
+ begin
72
+ phraseapp_request { @client.keys_delete(@project_id, delete_params) }
73
+ rescue RuntimeError => e
74
+ # PhraseApp will accept but mark invalid uploads, however the gem
75
+ # returns the same response in both cases. If we call this API
76
+ # with the ID of an upload of a bad file, it will fail.
77
+ # This usually occurs when sending up an empty file, which is
78
+ # a case we can ignore. However, it'd be better to have a way
79
+ # to detect a bad upload and find the cause.
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def phraseapp_request(&block)
86
+ res, err = block.call
87
+
88
+ unless err.nil?
89
+ if err.respond_to?(:error)
90
+ error = err.error
91
+ else
92
+ error = err.errors.join("|")
93
+ end
94
+
95
+ raise RuntimeError.new(error)
96
+ end
97
+
98
+ res
99
+ end
100
+
101
+ # PhraseApp allows two concurrent connections at a time.
102
+ THREAD_COUNT = 2
103
+
104
+ def threaded_request(worklist, &block)
105
+ queue = worklist.inject(Queue.new, :push)
106
+ threads = []
107
+
108
+ THREAD_COUNT.times do
109
+ threads << Thread.new do
110
+ Thread.current[:result] = {}
111
+
112
+ begin
113
+ while work = queue.pop(true) do
114
+ Thread.current[:result][work] = block.call(work)
115
+ end
116
+ rescue ThreadError => e
117
+ Thread.exit
118
+ end
119
+
120
+ end
121
+ end
122
+
123
+ threads.each(&:join)
124
+
125
+ threads.each_with_object({}) do |thread, results|
126
+ results.merge!(thread[:result])
127
+ end
128
+ end
129
+
130
+ def create_upload_params(locale_name)
131
+ upload_params = PhraseApp::RequestParams::UploadParams.new
132
+ upload_params.file_encoding = "UTF-8"
133
+ upload_params.file_format = "nested_json"
134
+ upload_params.locale_id = locale_name
135
+ upload_params.skip_unverification = false
136
+ upload_params.update_translations = true
137
+ upload_params.tags = [generate_upload_tag]
138
+ upload_params
139
+ end
140
+
141
+ def generate_upload_tag
142
+ "file_merge_upload_#{Time.now.strftime('%Y%m%d%H%M%S')}"
143
+ end
144
+
145
+ class Locale
146
+ attr_reader :id, :name, :default
147
+ def initialize(phraseapp_locale)
148
+ @name = phraseapp_locale.name
149
+ @id = phraseapp_locale.id
150
+ @default = phraseapp_locale.default
151
+ end
152
+
153
+ def default?
154
+ default
155
+ end
156
+
157
+ def to_s
158
+ "#{name} : #{id}"
159
+ end
160
+ end
161
+ end
162
+ end
163
+
@@ -0,0 +1,3 @@
1
+ class PhraseAppUpdater
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,110 @@
1
+ require 'phraseapp_updater/version'
2
+ require 'phraseapp_updater/index_by'
3
+ require 'phraseapp_updater/differ'
4
+ require 'phraseapp_updater/locale_file'
5
+ require 'phraseapp_updater/locale_file_loader'
6
+ require 'phraseapp_updater/phraseapp_api'
7
+
8
+ class PhraseAppUpdater
9
+ using IndexBy
10
+
11
+ def self.push(phraseapp_api_key, phraseapp_project_id, previous_locales_path, new_locales_path)
12
+ phraseapp_api = PhraseAppAPI.new(phraseapp_api_key, phraseapp_project_id)
13
+ phraseapp_locales = phraseapp_api.download_locales
14
+
15
+ phraseapp_files, (previous_locale_files, new_locale_files) =
16
+ load_files(phraseapp_api, phraseapp_locales, false, previous_locales_path, new_locales_path)
17
+
18
+ new_locale_files = new_locale_files.index_by(&:name)
19
+ phraseapp_files = phraseapp_files.index_by(&:name)
20
+
21
+ resolved_files = previous_locale_files.map do |previous_locale_file|
22
+ new_locale_file = new_locale_files.fetch(previous_locale_file.name)
23
+ phraseapp_file = phraseapp_files.fetch(previous_locale_file.name)
24
+
25
+ resolved_content = Differ.resolve!(original: previous_locale_file.parsed_content,
26
+ primary: new_locale_file.parsed_content,
27
+ secondary: phraseapp_file.parsed_content)
28
+
29
+ LocaleFile.from_hash(previous_locale_file.name, resolved_content)
30
+ end
31
+
32
+ # Upload all of the secondary languages first,
33
+ # so that the missing keys in them get filled in
34
+ # with blanks on PhraseApp by the default locale.
35
+ # If we do the clean up after uploading the default
36
+ # locale file, these blanks will get cleaned because
37
+ # they're not mentioned in the secondary locale files.
38
+
39
+ default_locale_file = find_default_locale_file(phraseapp_locales, resolved_files)
40
+
41
+ resolved_files.delete(default_locale_file)
42
+
43
+ changed_files = resolved_files.select do |file|
44
+ file.parsed_content != phraseapp_files[file.name].parsed_content
45
+ end
46
+
47
+ upload_ids = phraseapp_api.upload_files(changed_files)
48
+ phraseapp_api.remove_keys_not_in_uploads(upload_ids)
49
+
50
+ upload_id = phraseapp_api.upload_file(default_locale_file)
51
+ phraseapp_api.remove_keys_not_in_upload(upload_id)
52
+
53
+ LocaleFileUpdates.new(phraseapp_files.values, changed_files + [default_locale_file])
54
+ end
55
+
56
+ def self.pull(phraseapp_api_key, phraseapp_project_id, fallback_locales_path)
57
+ phraseapp_api = PhraseAppAPI.new(phraseapp_api_key, phraseapp_project_id)
58
+ phraseapp_locales = phraseapp_api.download_locales
59
+
60
+ phraseapp_files, (fallback_files,) =
61
+ load_files(phraseapp_api, phraseapp_locales, true, fallback_locales_path)
62
+
63
+ fallback_files = fallback_files.index_by(&:name)
64
+
65
+ phraseapp_files.map do |phraseapp_file|
66
+ new_content = Differ.restore_deletions(phraseapp_file.parsed_content,
67
+ fallback_files[phraseapp_file.name].parsed_content)
68
+ LocaleFile.from_hash(phraseapp_file.name, new_content)
69
+ end
70
+ end
71
+
72
+ def self.find_default_locale_file(locales, files)
73
+ default_locale = locales.find(&:default?)
74
+
75
+ default_locale_file = files.find do |file|
76
+ file.name == default_locale.name
77
+ end
78
+ end
79
+
80
+ def self.load_files(phraseapp_api, phraseapp_locales, skip_unverified, *paths)
81
+ file_groups = paths.map do |path|
82
+ LocaleFileLoader.filenames(path).map { |l| LocaleFileLoader.load(l) }
83
+ end
84
+
85
+ phraseapp_files = phraseapp_api.download_files(phraseapp_locales, skip_unverified)
86
+
87
+ file_name_lists = [*file_groups, phraseapp_files].map do |files|
88
+ files.map(&:name).to_set
89
+ end
90
+
91
+ # If we don't have the exact same locales for all of the sources, we can't diff them
92
+ unless file_name_lists.uniq.size == 1
93
+ message = "Number of files differs. This tool does not yet support adding\
94
+ or removing langauges: #{file_name_lists}"
95
+ raise RuntimeError.new(message)
96
+ end
97
+
98
+ return [phraseapp_files, file_groups]
99
+ end
100
+
101
+ class LocaleFileUpdates
102
+ attr_reader :original_phraseapp_files, :resolved_files
103
+
104
+ def initialize(original_phraseapp_files, resolved_files)
105
+ @original_phraseapp_files = original_phraseapp_files
106
+ @resolved_files = resolved_files
107
+ end
108
+ end
109
+ end
110
+
@@ -0,0 +1,34 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'phraseapp_updater/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "phraseapp_updater"
8
+ spec.version = PhraseAppUpdater::VERSION
9
+ spec.authors = ["Kevin Griffin"]
10
+ spec.email = ["kev@bibo.com.ph"]
11
+
12
+ spec.summary = %q{A three-way differ for PhraseApp projects.}
13
+ spec.description = %q{A tool for merging data on PhraseApp with local changes (usually two git revisions)}
14
+ spec.homepage = "https://app.engoo.com"
15
+ spec.license = "MIT"
16
+
17
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
18
+ spec.bindir = "bin"
19
+ spec.executables = ["phraseapp_updater"]
20
+ spec.require_paths = ["lib"]
21
+
22
+ spec.add_dependency "thor", "~> 0.19"
23
+ spec.add_dependency "phraseapp-ruby", "~> 1.3"
24
+ spec.add_dependency "hashdiff", "~> 0.3"
25
+ spec.add_dependency "multi_json", "~> 1.12"
26
+ spec.add_dependency "oj", "~> 2.18"
27
+ spec.add_dependency "deep_merge", "~> 1.1"
28
+
29
+ spec.add_development_dependency "bundler", "~> 1.12"
30
+ spec.add_development_dependency "rake", "~> 10.0"
31
+ spec.add_development_dependency "rspec", "~> 3.0"
32
+ spec.add_development_dependency "pry", "~> 0.10"
33
+ end
34
+
metadata ADDED
@@ -0,0 +1,205 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: phraseapp_updater
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kevin Griffin
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2017-02-15 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: thor
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.19'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.19'
27
+ - !ruby/object:Gem::Dependency
28
+ name: phraseapp-ruby
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.3'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.3'
41
+ - !ruby/object:Gem::Dependency
42
+ name: hashdiff
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0.3'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0.3'
55
+ - !ruby/object:Gem::Dependency
56
+ name: multi_json
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.12'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.12'
69
+ - !ruby/object:Gem::Dependency
70
+ name: oj
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '2.18'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '2.18'
83
+ - !ruby/object:Gem::Dependency
84
+ name: deep_merge
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.1'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.1'
97
+ - !ruby/object:Gem::Dependency
98
+ name: bundler
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '1.12'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '1.12'
111
+ - !ruby/object:Gem::Dependency
112
+ name: rake
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '10.0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '10.0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: rspec
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '3.0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '3.0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: pry
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - "~>"
144
+ - !ruby/object:Gem::Version
145
+ version: '0.10'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - "~>"
151
+ - !ruby/object:Gem::Version
152
+ version: '0.10'
153
+ description: A tool for merging data on PhraseApp with local changes (usually two
154
+ git revisions)
155
+ email:
156
+ - kev@bibo.com.ph
157
+ executables:
158
+ - phraseapp_updater
159
+ extensions: []
160
+ extra_rdoc_files: []
161
+ files:
162
+ - ".gitignore"
163
+ - ".rspec"
164
+ - ".travis.yml"
165
+ - CODE_OF_CONDUCT.markdown
166
+ - Gemfile
167
+ - LICENSE.txt
168
+ - README.markdown
169
+ - Rakefile
170
+ - bin/console
171
+ - bin/phraseapp_updater
172
+ - bin/setup
173
+ - lib/phraseapp_updater.rb
174
+ - lib/phraseapp_updater/differ.rb
175
+ - lib/phraseapp_updater/index_by.rb
176
+ - lib/phraseapp_updater/locale_file.rb
177
+ - lib/phraseapp_updater/locale_file_loader.rb
178
+ - lib/phraseapp_updater/phraseapp_api.rb
179
+ - lib/phraseapp_updater/version.rb
180
+ - phraseapp_updater.gemspec
181
+ homepage: https://app.engoo.com
182
+ licenses:
183
+ - MIT
184
+ metadata: {}
185
+ post_install_message:
186
+ rdoc_options: []
187
+ require_paths:
188
+ - lib
189
+ required_ruby_version: !ruby/object:Gem::Requirement
190
+ requirements:
191
+ - - ">="
192
+ - !ruby/object:Gem::Version
193
+ version: '0'
194
+ required_rubygems_version: !ruby/object:Gem::Requirement
195
+ requirements:
196
+ - - ">="
197
+ - !ruby/object:Gem::Version
198
+ version: '0'
199
+ requirements: []
200
+ rubyforge_project:
201
+ rubygems_version: 2.5.1
202
+ signing_key:
203
+ specification_version: 4
204
+ summary: A three-way differ for PhraseApp projects.
205
+ test_files: []