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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: b0d34620e813a3d8381bb9e69718c4eddf00861c
4
- data.tar.gz: 83376f665f0c2a2db922f5297f2a29890700be54
2
+ SHA256:
3
+ metadata.gz: a9a01be297b026f1bcf98b8337306f7dd5340f15bd21dc7264ccf78aff82d242
4
+ data.tar.gz: 6c8cd2024db62795d678b148621dd3556e5a7771daa33076e004a00d68bde88e
5
5
  SHA512:
6
- metadata.gz: e17e637e811441675d66c88a948913ba8526a014934405889b31422200991e66cc68d8cc9e0a733d99cd98f87e7b0f6f051fa0b358d5d15f3af8f47ad6eb28c3
7
- data.tar.gz: 4b3e48725db68140f04d6c12f836ce82e6514151b652525331925ea3a8b690209fdf2d67ff5c6594c2f0b75c7629ffd3e1254ca1a191ff43228edc4e224f101a
6
+ metadata.gz: aab7ed4136379375aa89531722ce00af0d7027f0433ad0d77f2793a855966f59f286ad3f154f71c4bfe48a604275f72805e615f3be65e4abf58390f025f7f058
7
+ data.tar.gz: 83aae79c39f421c21deed218e8c0bfa0dcfbbe663ec49520cda9dbd675268728877ce665aae5150d45438ccb5d241cfb23995d8c3168eb237c28689e5b37639c
data/Gemfile CHANGED
@@ -2,4 +2,5 @@ source 'https://rubygems.org'
2
2
 
3
3
  # Specify your gem's dependencies in phraseapp_updater.gemspec
4
4
  gemspec
5
+ gem 'byebug'
5
6
 
@@ -2,34 +2,28 @@
2
2
 
3
3
  [![Build Status](https://travis-ci.org/iknow/phraseapp_updater.svg?branch=master)](https://travis-ci.org/iknow/phraseapp_updater)
4
4
 
5
- **Version** 0.1.7
6
-
7
- This is a tool for merging PhraseApp locale data with locale data
8
- committed in your project.
9
-
10
- It can perform three-way merges of [PhraseApp](https://phraseapp.com) locale data with locale data commited to your application.
11
- It can also pull from PhraseApp, ignoring missing keys (this is very
12
- useful for using "unverified" status for marking a translation as a
13
- draft).
14
-
15
- Our current workflow has localizers working on a `master` project on
16
- PhraseApp. This regularly gets pulled into the `master` branch of our
17
- application and released. This branch is for "maintenance" localizations:
18
- ongoing translations of existing locale keys.
19
-
20
- However, we also introduce, remove, and change locale data by merging in
21
- feature branches to `master`. When we do this, we want to update the
22
- `master` PhraseApp project with the data newly-commited to our `master`
23
- branch. PhraseApp provides [APIs](https://phraseapp.com/docs/api/v2/) and a [Ruby gem](https://github.com/phrase/phraseapp-ruby) for accessing
24
- them, but the API only allows either a) completely overwriting
25
- PhraseApp's data or b) reapplying PhraseApp's data on top of the
26
- uploaded data.
27
-
28
- What we want instead is a three way merge where the committed data wins
29
- on conflict. Non-conflicting changes on PhraseApp are preserved, while
30
- changes on both sides take the committed data. The result of the merge
31
- is then sent to PhraseApp, keeping it up-to-date with the newest commit
32
- of `master`.
5
+ **Version** 2.0.0
6
+
7
+ This is a tool for managing synchronization between locale data in
8
+ [PhraseApp](https://phraseapp.com) and committed in your project. It can perform
9
+ JSON-aware three-way merges with respect to a common ancestor, and maintains a
10
+ record of the common ancestor on PhraseApp using tags.
11
+
12
+ Our workflow considers localization data stored on PhraseApp to be a working
13
+ copy for a given branch. We expect developers working on the code and
14
+ translators working on PhraseApp to both be able to make changes and have them
15
+ integrated.
16
+
17
+ PhraseApp provides [APIs](https://phraseapp.com/docs/api/v2/) and a [Ruby
18
+ gem](https://github.com/phrase/phraseapp-ruby) for accessing them, but the API
19
+ only allows either a) completely overwriting PhraseApp's data with local data or
20
+ b) reapplying PhraseApp's data on top of the local data. Neither of these cases
21
+ is appropriate for integrating changes made on both sides.
22
+
23
+ What we want instead is a three way merge where the committed data wins on
24
+ conflict. Non-conflicting changes on PhraseApp are preserved, while changes to
25
+ the same key on both sides take the committed data. The result of the merge is
26
+ then applied to both sides, keeping them up to date with each other.
33
27
 
34
28
  This is especially important when removing keys. Imagine we have the
35
29
  following, no-longer useful key:
@@ -48,9 +42,8 @@ unused:
48
42
  zero: No unused's
49
43
  ```
50
44
 
51
- And in our feature branch, we remove it. The result we want is that the
52
- key completely disappears, instead of getting a result like either of
53
- the above.
45
+ And in our feature branch, we remove it. The result we want is that the key
46
+ completely disappears, instead of getting a result like either of the above.
54
47
 
55
48
  ## Installation
56
49
 
@@ -80,68 +73,79 @@ Or install it yourself as:
80
73
  CLI
81
74
  ---
82
75
 
83
- **Push**
76
+ **Setup**
84
77
 
85
- `phraseapp_updater push` operates on two directories and your PhraseApp API
86
- data. The two directories should contain the previous revision of your
87
- locale files from PhraseApp and the latest revision of the same files
88
- committed to your application's respository. These will be used in the
89
- merge with the files on PhraseApp.
78
+ `phraseapp_updater setup` creates and initializes a PhraseApp project
79
+ corresponding to your branch. It must be provided with the current git revision
80
+ of the branch and the path to the locale files.
90
81
 
91
82
  ```
92
- phraseapp_updater push --new_locales_path="/data/new", --previous_locales_path="/data/previous" --phraseapp_api_key="yourkey" --phraseapp_project_id="projectid --file_format=json"
83
+ phraseapp_updater setup --phraseapp_project_name="yourbranch" --parent_commit="yourhash" --phraseapp_api_key=yourkey" path_to_locales
93
84
  ```
94
85
 
95
- The arguments provided to the command can also be specified as shell
96
- variables:
86
+ **Synchronize**
87
+
88
+ `phraseapp_updater synchronize` synchronizes a git remote branch with its
89
+ corresponding PhraseApp project, incorporating changes from each side into the
90
+ other. If both sides were changed, a three-way merge is performed. The result is
91
+ uploaded to PhraseApp and committed and pushed to the git remote as appropriate.
92
+
93
+ The option `--no_commit` may be provided to restrict changes to the PhraseApp
94
+ side. If specified, then in the case that the branch was modified, the merge
95
+ result will be uploaded to PhraseApp and the common ancestor updated to the
96
+ branch head.
97
97
 
98
98
  ```
99
- PA_NEW_LOCALES_PATH
100
- PA_PREVIOUS_LOCALES_PATH
101
- PA_API_KEY
102
- PA_PROJECT_ID
103
- PA_FILE_FORMAT
99
+ phraseapp_updater synchronize <checkout_path>
104
100
  ```
105
101
 
106
- Additionally, PhraseApp credentials can be loaded from a
107
- `.phraseapp.yml` file, specified with `--config-file-path`
102
+ **Download**
103
+
104
+ `phraseapp_updater download` downloads and normalizes locale files from
105
+ PhraseApp, saving them to the specified location. The revision of the recorded
106
+ common ancestor is printed to standard out.
108
107
 
109
- **Pull**
108
+ ```
109
+ phraseapp_updater download --phraseapp_project_id="yourid" --phraseapp_api_key="yourkey" target_path
110
+ ```
110
111
 
111
- `phraseapp_updater pull` pulls data down from your PhraseApp project.
112
- However, when keys are missing from the PhraseApp data, it restores them
113
- (if present) from the files at fallback path provided. This allows you
114
- to mark keys as "unverified" on PhraseApp, meaning you don't pull in
115
- draft translations, while allowing you to keep the current version of
116
- that translation.
112
+ **Upload**
117
113
 
118
- If you want to pull without this fallback behavior, PhraseApp's [client](https://phraseapp.com/docs/developers/cli/)
119
- is the best tool to use.
114
+ `phraseapp_updater upload` uploads normalized locale files from your branch to
115
+ PhraseApp and resets the recorded common ancestor to the specified revision.
120
116
 
121
117
  ```
122
- phraseapp_updater pull --fallback_path="/data/app/locales" --phraseapp_api_key="yourkey" --phraseapp_project_id="projectid --file_format=json""
118
+ phraseapp_updater upload --phraseapp_project_id="yourid" --phraseapp_api_key="yourkey" path_to_locales
123
119
  ```
124
120
 
125
- The PhraseApp data passed to the command can also be specified as shell
126
- variables:
121
+ **Update Parent Commit**
122
+ `phraseapp_updater update_parent_commit` records a new common ancestor on
123
+ PhraseApp without changing the locales.
127
124
 
128
125
  ```
129
- PA_API_KEY
130
- PA_PROJECT_ID
131
- PA_FILE_FORMAT
126
+ phraseapp_updater update_parent_commit --phraseapp_project_id="yourid" --phraseapp_api_key="yourkey" --parent_commit="yourhash"
132
127
  ```
133
128
 
134
- Additionally, PhraseApp credentials can be loaded from a
135
- `.phraseapp.yml` file, specified with `--config-file-path`
129
+ **Merge**
136
130
 
137
- Ruby
138
- ---
131
+ `phraseapp_updater merge` performs a content-aware three-way merge between
132
+ locale files in three directories: `ancestor_path`, `our_path`, and
133
+ `their_path`. In the case of conflicts, the changes from `our_path` are
134
+ accepted. The results are normalized and written to the path specified with
135
+ `to`.
139
136
 
140
- `PhraseAppUpdater.push` and `PhraseAppUpdater.pull` are analogous to the command line versions:
137
+ ```
138
+ phraseapp_updater merge ancestor_path our_path their_path --to target_path
139
+ ```
141
140
 
142
- ```ruby
143
- PhraseAppUpdater.new("api_key", "project_id", "file_format").push("previous/path", "current/path")
144
- PhraseAppUpdater.new("api_key", "project_id", "file_format").pull("fallback/path")
141
+
142
+ **Diff**
143
+
144
+ Performs a content-aware diff between locale files in two directories. Returns
145
+ with exit status 1 or 0 to signal differences or no differences respectively
146
+
147
+ ```
148
+ phraseapp_updater diff path1 path2
145
149
  ```
146
150
 
147
151
 
@@ -157,8 +161,11 @@ https://gist.github.com/kevingriffin/d59821446ce424a56c7da2686d4ae082
157
161
 
158
162
  If you'd like to contribute, these would be very helpful!
159
163
 
160
- * Separating downloading and resolving data from PhraseApp from pushing
161
- back up to it, to enable different kinds of workflows.
164
+ * We'd like to use "unverified" translations on PhraseApp as the equivalent of
165
+ an unstaged working copy. For this to work, we need to be able to recover
166
+ previous translations at the same key. While PhraseApp doesn't itself keep
167
+ this history, we could do this by restoring the absent keys from the diff
168
+ between verified and unverified download from the common ancestor.
162
169
  * Expose the changed files on the command line.
163
170
  * Checking if PhraseApp files changed during execution before upload, to reduce the race condition window.
164
171
  * More specs for the API and shell.
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Set up a working directory
4
+ working_directory=$(mktemp -d -t phraseapp)
5
+
6
+ function cleanup_working_directory(){
7
+ rm -rf "${working_directory}"
8
+ }
9
+
10
+ trap "cleanup_working_directory" EXIT SIGINT
11
+
12
+ function make_temporary_directory() {
13
+ mktemp -d "${working_directory}/XXXXXXXX"
14
+ }
15
+
16
+ function make_tree_from_directory() {
17
+ local directory filename object
18
+ directory="$1"
19
+
20
+ if [ ! -d "$directory" ]; then
21
+ echo "Error: directory not found: '${directory}'" >&2
22
+ exit 1
23
+ fi
24
+
25
+ for file in "$directory"/*; do
26
+ if [ -d "$file" ]; then
27
+ echo "Error: make_tree_from_directory cannot create recursive tree: '${file}'" >&2
28
+ exit 1
29
+ fi
30
+
31
+ filename=$(basename "${file}")
32
+ object=$(git hash-object -w "${file}")
33
+ printf "100644 blob %s\\t%s\\n" "${object}" "${filename}"
34
+ done | git mktree
35
+ }
36
+
37
+
38
+ function extract_commit() {
39
+ extract_files "$1:${PREFIX}"
40
+ }
41
+
42
+ function extract_files() {
43
+ local path
44
+ path=$(make_temporary_directory)
45
+
46
+ git archive --format=tar "$1" | tar -x -C "${path}"
47
+
48
+ echo "${path}"
49
+ }
50
+
51
+ function locales_changed() {
52
+ ! phraseapp_updater diff --quiet "$1" "$2"
53
+ }
54
+
55
+ function tree_changed() {
56
+ ! git diff-tree --quiet "$1" "$2"
57
+ }
58
+
59
+ function replace_nested_tree() {
60
+ local root path tree
61
+ root="$1"
62
+ path="$2"
63
+ tree="$3"
64
+
65
+ while [ "$path" ]; do
66
+ leaf_name=$(basename "$path")
67
+ path=$(dirname "$path")
68
+ [ "$path" = "." ] && path=''
69
+
70
+ # replace `leaf_name` in `path` with `tree`, yielding new tree
71
+ tree=$(git ls-tree "${root}:${path}" | \
72
+ replace_child_in_tree "${leaf_name}" "${tree}" | \
73
+ git mktree)
74
+ done
75
+
76
+ echo "$tree"
77
+ }
78
+
79
+ function replace_child_in_tree(){
80
+ ruby -pe 'BEGIN { file, tree = ARGV.shift(2) };
81
+ gsub(/ [0-9a-z]{40}\t/, " #{tree}\t") if /\t#{file}$/' "$@"
82
+ }
@@ -1,89 +1,202 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
2
4
  require 'thor'
3
5
  require 'phraseapp_updater'
4
6
 
5
7
  class PhraseAppUpdaterCLI < Thor
6
- desc "push", "Update PhraseApp project via a 3-way merge with local locale files and PhraseApp data."
7
- option :new_locales_path, type: :string, desc: "Path to newest revision of local files. Shell variable: PA_NEW_LOCALES_PATH"
8
- option :previous_locales_path, type: :string, desc: "Path to previous revision of local files. Shell variable: PA_PREVIOUS_LOCALES_PATH"
9
- option :phraseapp_api_key, type: :string, desc: "PhraseApp API key. Shell variable: PA_API_KEY"
10
- option :phraseapp_project_id, type: :string, desc: "PhraseApp project ID. Shell variable: PA_PROJECT_ID"
11
- option :config_file_path, type: :string, desc: "Path to .phraseapp.yml config file to read API key and project ID."
12
- option :store_results_path, type: :string, desc: "Path to write the resolved files. Shell variable: PA_STORE_RESULTS_PATH"
13
- option :store_phraseapp_originals_path, type: :string, desc: "Path to write the files downloaded from PhraseApp before the merge. Shell variable: PA_STORE_PHRASEAPP_ORIGINALS_PATH"
14
- option :file_format, type: :string, desc: "Filetype of localization files."
8
+ class_option :default_locale, type: :string, default: 'en', desc: 'PhraseApp default locale'
9
+ class_option :file_format, type: :string, default: 'json', desc: 'Filetype of localization files.'
10
+
11
+ desc 'setup <locale_path>',
12
+ 'Create a new PhraseApp project, initializing it with locale files at <locale_path>. the new project ID is printed to STDOUT'
13
+ method_option :phraseapp_api_key, type: :string, required: true, desc: 'PhraseApp API key.'
14
+ method_option :phraseapp_project_name, type: :string, required: true, desc: 'Name for new PhraseApp project.'
15
+ method_option :parent_commit, type: :string, required: true, desc: 'git commit hash of initial locales'
16
+
17
+ def setup(locales_path)
18
+ validate_readable_path!('locales', locales_path)
15
19
 
16
- def push
17
- phraseapp_api_key, phraseapp_project_id, file_format = load_phraseapp_configuration(options)
20
+ handle_errors do
21
+ updater, project_id = PhraseAppUpdater.for_new_project(
22
+ options[:phraseapp_api_key],
23
+ options[:phraseapp_project_name],
24
+ options[:file_format],
25
+ options[:parent_commit])
26
+
27
+ updater.upload_directory(locales_path)
28
+
29
+ puts project_id
30
+ end
31
+ end
18
32
 
19
- new_locales_path = options.fetch(:new_locales_path, ENV["PA_NEW_LOCALES_PATH"]).to_s
20
- validate_readable_path!('new_locales_path', new_locales_path)
33
+ desc 'synchronize <git_checkout_path>',
34
+ 'Synchronize locales in PhraseApp with '
35
+ method_option :phraseapp_api_key, type: :string, required: true, desc: 'PhraseApp API key.'
36
+ method_option :phraseapp_project_id, type: :string, required: true, desc: 'PhraseApp project ID.'
37
+ method_option :branch, type: :string, required: false, desc: 'Name of (remote) git branch to synchronize'
38
+ method_option :remote, type: :string, required: false, desc: 'Name of git remote to synchronize with'
39
+ method_option :prefix, type: :string, default: 'config/locales', desc: 'Path prefix in git branch for locale files'
40
+ method_option :no_commit, type: :boolean, default: false, desc: 'Do not commit merge results to the branch'
41
+
42
+ def synchronize(checkout_path)
43
+ validate_readable_path!('checkout path', checkout_path)
44
+ Dir.chdir(checkout_path)
45
+
46
+ ENV['PHRASEAPP_API_KEY'] = options[:phraseapp_api_key]
47
+ ENV['PHRASEAPP_PROJECT_ID'] = options[:phraseapp_project_id]
48
+ ENV['FILE_FORMAT'] = options[:file_format]
49
+ ENV['NO_COMMIT'] = options[:no_commit] ? 't' : 'f'
50
+ ENV['PREFIX'] = options[:prefix]
51
+ ENV['BRANCH'] = options.fetch(:branch) { sh('git name-rev --name-only HEAD').chomp }
52
+ ENV['REMOTE'] = options.fetch(:remote) { sh("git config branch.#{ENV['BRANCH']}.remote").chomp }
53
+
54
+ shell_script_path = File.join(__dir__, 'synchronize_phraseapp.sh')
55
+ exec(shell_script_path)
56
+ end
21
57
 
22
- previous_locales_path = options.fetch(:previous_locales_path, ENV["PA_PREVIOUS_LOCALES_PATH"]).to_s
23
- validate_readable_path!('previous_locales_path', previous_locales_path)
58
+ desc 'download <target_path>',
59
+ 'Download and renormalize locale files from PhraseApp to <target_path>'
60
+ method_option :phraseapp_api_key, type: :string, required: true, desc: 'PhraseApp API key.'
61
+ method_option :phraseapp_project_id, type: :string, required: true, desc: 'PhraseApp project ID.'
24
62
 
25
- store_results_path = options.fetch(:store_results_path, ENV["PA_STORE_RESULTS_PATH"]).to_s
26
- validate_writable_path!('store_results_path', store_results_path)
63
+ def download(target_path)
64
+ validate_writable_path!('target path', target_path)
27
65
 
28
- store_phraseapp_originals_path = options.fetch(:store_phraseapp_originals_path, ENV["PA_STORE_PHRASEAPP_ORIGINALS_PATH"]).to_s
29
- validate_writable_path!('store_phraseapp_originals_path', store_phraseapp_originals_path)
66
+ handle_errors do
67
+ updater = PhraseAppUpdater.new(
68
+ options[:phraseapp_api_key],
69
+ options[:phraseapp_project_id],
70
+ options[:file_format])
30
71
 
31
- begin
32
- result = PhraseAppUpdater.new(phraseapp_api_key, phraseapp_project_id, file_format).push(previous_locales_path, new_locales_path)
72
+ updater.download_to_directory(target_path)
73
+ parent_commit = updater.read_parent_commit
33
74
 
34
- unless store_results_path.empty?
35
- write_locale_files(store_results_path, result.resolved_files)
75
+ if parent_commit.nil?
76
+ STDERR.puts 'Error: Locales downloaded from phraseapp, but parent commit details missing'
77
+ exit(1)
36
78
  end
37
79
 
38
- unless store_phraseapp_originals_path.empty?
39
- write_locale_files(store_phraseapp_originals_path, result.original_phraseapp_files)
80
+ puts parent_commit
81
+ end
82
+ end
83
+
84
+ desc 'upload <locale_path>',
85
+ 'Renormalize and upload locale files at <locale_path> to PhraseApp, replacing current contents.'
86
+ method_option :phraseapp_api_key, type: :string, required: true, desc: 'PhraseApp API key.'
87
+ method_option :phraseapp_project_id, type: :string, required: true, desc: 'PhraseApp project ID.'
88
+ method_option :parent_commit, type: :string, required: true, desc: 'git commit hash of locales being uploaded'
89
+
90
+ def upload(source_path)
91
+ validate_readable_path!('source path', source_path)
92
+
93
+ handle_errors do
94
+ updater = PhraseAppUpdater.new(
95
+ options[:phraseapp_api_key],
96
+ options[:phraseapp_project_id],
97
+ options[:file_format])
98
+
99
+ updater.upload_directory(source_path)
100
+ updater.update_parent_commit(options[:parent_commit])
101
+ end
102
+ end
103
+
104
+ desc 'update_parent_commit', 'Record a new merge-base on PhraseApp without changing contents.'
105
+ method_option :phraseapp_api_key, type: :string, required: true, desc: 'PhraseApp API key.'
106
+ method_option :phraseapp_project_id, type: :string, required: true, desc: 'PhraseApp project ID.'
107
+ method_option :parent_commit, type: :string, required: true, desc: 'git commit hash of locales being uploaded'
108
+ def update_parent_commit
109
+ handle_errors do
110
+ updater = PhraseAppUpdater.new(
111
+ options[:phraseapp_api_key],
112
+ options[:phraseapp_project_id],
113
+ options[:file_format])
114
+
115
+ updater.update_parent_commit(options[:parent_commit])
116
+ end
117
+ end
118
+
119
+ desc 'diff <path1> <path2>',
120
+ 'Compare locale file directories <path1> and <path2>'
121
+
122
+ long_desc <<-LONGDESC
123
+ Perform a JSON diff of locale files in path1 and path2.
124
+ Exits with 1 if there were differences, or 0 if no differences"
125
+ LONGDESC
126
+
127
+ method_option :quiet, type: :boolean, default: false, desc: 'Suppress output'
128
+
129
+ def diff(path1, path2)
130
+ validate_readable_path!('path1', path1)
131
+ validate_readable_path!('path2', path2)
132
+
133
+ handle_errors do
134
+ updater = PhraseAppUpdater.new(nil, nil, options[:file_format])
135
+ diffs = updater.diff_directories(path1, path2)
136
+ if diffs.empty?
137
+ exit(0)
138
+ else
139
+ print_diff(diffs) unless options[:quiet]
140
+ exit(1)
40
141
  end
41
- rescue PhraseAppUpdater::PhraseAppAPI::BadAPIKeyError => e
42
- puts "Bad PhraseApp API key."
43
- rescue PhraseAppUpdater::PhraseAppAPI::BadProjectIDError => e
44
- puts "Bad PhraseApp project ID: #{phraseapp_project_id}"
45
- rescue PhraseAppUpdater::LocaleFile::BadFileTypeError => e
46
- puts "Bad filetype for localization files: #{e.message}"
47
- rescue StandardError => e
48
- puts "Unknown error when pushing files."
49
- raise e
50
142
  end
51
143
  end
52
144
 
145
+ desc 'merge <ancestor_path> <our_path> <their_path>',
146
+ '3-way merge locale file directories <ancestor_path>, <our_path>, <their_path> into TO.'
53
147
 
54
- desc "pull", "Pulls data from PhraseApp for deployment, replacing missing keys from fallback."
55
- option :fallback_path, type: :string, required: true, desc: "Path to the files to restore missing keys from."
56
- option :destination_path, type: :string, desc: "Path to write the resolved files to. If not provided, --fallback_path is used."
57
- option :phraseapp_api_key, type: :string, desc: "PhraseApp API key. Shell variable: PA_API_KEY"
58
- option :phraseapp_project_id, type: :string, desc: "PhraseApp project ID. Shell variable: PA_PROJECT_ID"
59
- option :config_file_path, type: :string, desc: "Path to .phraseapp.yml config file to read API key and project ID."
60
- option :file_format, type: :string, desc: "Filetype of localization files."
61
-
62
- def pull
63
- phraseapp_api_key, phraseapp_project_id, file_format = load_phraseapp_configuration(options)
64
-
65
- fallback_path = options[:fallback_path]
66
- validate_readable_path!('fallback_path', fallback_path)
67
-
68
- destination_path = options.fetch(:destination_path, fallback_path).to_s
69
- validate_writable_path!('destination_path', destination_path)
70
-
71
- begin
72
- files = PhraseAppUpdater.new(phraseapp_api_key, phraseapp_project_id, file_format).pull(fallback_path)
73
- write_locale_files(destination_path, files)
74
- rescue PhraseAppUpdater::PhraseAppAPI::BadAPIKeyError => e
75
- puts "Bad PhraseApp API key."
76
- rescue PhraseAppUpdater::PhraseAppAPI::BadProjectIDError => e
77
- puts "Bad PhraseApp project ID: #{phraseapp_project_id}"
78
- rescue PhraseAppUpdater::LocaleFile::BadFileTypeError => e
79
- puts "Bad filetype for localization files: #{e.message}"
80
- rescue StandardError => e
81
- puts "Unknown error when pulling files"
82
- raise e
148
+ long_desc <<-LONGDESC
149
+ Perform a JSON-aware 3-way merge of locale files in directories <ancestor_path>, <our_path>, <their_path> into TO.
150
+
151
+ The merge resolution strategy always selects `ours` in the case of a conflict.
152
+ LONGDESC
153
+
154
+ method_option :to, type: :string, required: true, desc: 'Target directory'
155
+
156
+ def merge(ancestor_path, our_path, their_path)
157
+ validate_readable_path!('ancestor_path', ancestor_path)
158
+ validate_readable_path!('our_path', our_path)
159
+ validate_readable_path!('their_path', their_path)
160
+
161
+ result_path = options[:to]
162
+ validate_writable_path!('to', result_path)
163
+
164
+ handle_errors do
165
+ updater = PhraseAppUpdater.new(nil, nil, options[:file_format])
166
+ updater.merge_directories(our_path, their_path, ancestor_path, result_path)
83
167
  end
84
168
  end
85
169
 
86
- desc "default", "Prints gem information"
170
+ desc 'merge_file <ancestor> <ours> <theirs>',
171
+ 'Perform 3-way merge of a single file into TO'
172
+
173
+ long_desc <<-LONGDESC
174
+ Perform 3-way merge of a single file into TO
175
+
176
+ Intended for use as a git merge-driver with:
177
+ [merge "phraseapp-locale"]
178
+ name = PhraseApp locale file merge driver
179
+ driver = phraseapp_updater merge_file %O %A %B --to %P
180
+ LONGDESC
181
+
182
+ method_option :to, type: :string, required: true, desc: 'Target file'
183
+
184
+ def merge_file(ancestor, ours, theirs)
185
+ validate_readable_file!('ancestor', ancestor)
186
+ validate_readable_file!('ours', ours)
187
+ validate_readable_file!('theirs', theirs)
188
+ validate_writable_file!('to', to)
189
+
190
+ # Git provides an empty file when there is no common ancestor in the
191
+ # merge-base. Because we want to merge from an empty hash structure instead,
192
+ # pass `nil` to `merge_files`.
193
+ ancestor = nil if File.zero?(ancestor)
194
+
195
+ updater = PhraseAppUpdater.new(nil, nil, file_format)
196
+ updater.merge_files(ours, theirs, ancestor, to)
197
+ end
198
+
199
+ desc 'default', 'Prints gem information'
87
200
  option :version, aliases: [:v]
88
201
 
89
202
  def default
@@ -98,37 +211,54 @@ class PhraseAppUpdaterCLI < Thor
98
211
 
99
212
  private
100
213
 
101
- def load_phraseapp_configuration(options)
102
- if options[:config_file_path]
103
-
104
- if [:phraseapp_api_key, :phraseapp_project_id, :file_format].any? { |option| options.has_key?(:option) }
105
- raise RuntimeError.new("Provided both a path to PhraseApp config file and command line arguments. Specify only one. #{options}")
214
+ def print_diff(diffs)
215
+ normalized_diffs = diffs.flat_map do |diff|
216
+ type, path, c1, c2 = diff
217
+ if type == '~'
218
+ [['-', path, c1], ['+', path, c2]]
219
+ else
220
+ [diff]
106
221
  end
107
-
108
- config = PhraseAppUpdater.load_config(options[:config_file_path])
109
-
110
- phraseapp_api_key = config.api_key
111
- phraseapp_project_id = config.project_id
112
- file_format = config.file_format
113
- else
114
- phraseapp_api_key = options.fetch(:phraseapp_api_key, ENV["PA_API_KEY"]).to_s
115
- phraseapp_project_id = options.fetch(:phraseapp_project_id, ENV["PA_PROJECT_ID"]).to_s
116
- file_format = options.fetch(:file_format, ENV["PA_FILE_FORMAT"]).to_s
117
222
  end
118
223
 
119
- if phraseapp_api_key.empty?
120
- raise RuntimeError.new("Must provide Phraseapp API key. --phraseapp_api_key or PA_API_KEY")
224
+ normalized_diffs.each do |type, path, change|
225
+ puts "#{type} #{path}: #{change}"
121
226
  end
227
+ end
122
228
 
123
- if phraseapp_project_id.empty?
124
- raise RuntimeError.new("Must provide Phraseapp project ID. --phraseapp_project_id or PA_PROJECT_ID")
125
- end
229
+ def handle_errors
230
+ yield
231
+ rescue PhraseAppUpdater::PhraseAppAPI::BadAPIKeyError
232
+ STDERR.puts 'Bad PhraseApp API key.'
233
+ exit(1)
234
+ rescue PhraseAppUpdater::PhraseAppAPI::BadProjectIDError => e
235
+ STDERR.puts "Bad PhraseApp project ID: #{e.project_id}"
236
+ exit(1)
237
+ rescue PhraseAppUpdater::PhraseAppAPI::ProjectNameTakenError
238
+ STDERR.puts "PhraseApp project name already taken: #{options[:phraseapp_project_name]}"
239
+ exit(1)
240
+ rescue PhraseAppUpdater::LocaleFile::BadFileTypeError => e
241
+ STDERR.puts "Bad filetype for localization files: #{e.message}"
242
+ exit(1)
243
+ rescue PhraseAppUpdater::PhraseAppAPI::MissingGitParentError
244
+ STDERR.puts 'Git ancestor commit not recorded on PhraseApp project'
245
+ exit(1)
246
+ rescue StandardError => e
247
+ STDERR.puts "Unknown error occurred: #{e.message}"
248
+ STDERR.puts e.backtrace
249
+ exit(1)
250
+ end
126
251
 
127
- if file_format.empty?
128
- raise RuntimeError.new("Must provide file format for Phraseapp project. --file_format or PA_FILE_FORMAT")
252
+ def validate_readable_file!(name, file)
253
+ unless File.readable?(file) && File.file?(file)
254
+ raise RuntimeError.new("#{name} is not a readable file: #{file}")
129
255
  end
256
+ end
130
257
 
131
- return [phraseapp_api_key, phraseapp_project_id, file_format]
258
+ def validate_writable_file!(name, file)
259
+ unless File.writable?(file) && File.file?(file)
260
+ raise RuntimeError.new("#{name} is not a writable file: #{file}")
261
+ end
132
262
  end
133
263
 
134
264
  def validate_path!(name, path)
@@ -153,14 +283,11 @@ class PhraseAppUpdaterCLI < Thor
153
283
  end
154
284
  end
155
285
 
156
- def write_locale_files(path, files)
157
- files.each do |file|
158
- full_path = "#{path.chomp('/')}/#{file.name_with_extension}"
159
- File.write(full_path, file.content)
286
+ def sh(x)
287
+ `#{x}`.tap do
288
+ raise RuntimeError.new("Shell command failed: '#{x}'") unless $?.success?
160
289
  end
161
- puts "Wrote #{files.count} files to #{path}: #{files.map(&:name_with_extension)}"
162
290
  end
163
291
  end
164
292
 
165
293
  PhraseAppUpdaterCLI.start(ARGV)
166
-