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 +7 -0
- data/.gitignore +9 -0
- data/.rspec +2 -0
- data/.travis.yml +5 -0
- data/CODE_OF_CONDUCT.markdown +50 -0
- data/Gemfile +5 -0
- data/LICENSE.txt +22 -0
- data/README.markdown +129 -0
- data/Rakefile +7 -0
- data/bin/console +15 -0
- data/bin/phraseapp_updater +117 -0
- data/bin/setup +9 -0
- data/lib/phraseapp_updater/differ.rb +145 -0
- data/lib/phraseapp_updater/index_by.rb +11 -0
- data/lib/phraseapp_updater/locale_file.rb +41 -0
- data/lib/phraseapp_updater/locale_file_loader.rb +18 -0
- data/lib/phraseapp_updater/phraseapp_api.rb +163 -0
- data/lib/phraseapp_updater/version.rb +3 -0
- data/lib/phraseapp_updater.rb +110 -0
- data/phraseapp_updater.gemspec +34 -0
- metadata +205 -0
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
data/.rspec
ADDED
data/.travis.yml
ADDED
@@ -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
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
|
+
[](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
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,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,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,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: []
|