Skip to content

Commit ae15f11

Browse files
authored
Merge pull request #338 from wordpress-mobile/l10n/extract_keys_from_strings_file
Introduce ios_extract_keys_from_strings_files action
2 parents 06d9f92 + 96a9a11 commit ae15f11

File tree

15 files changed

+482
-3
lines changed

15 files changed

+482
-3
lines changed

.rubocop_todo.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ RSpec/FilePath:
147147
- 'spec/ios_merge_strings_files_spec.rb'
148148
- 'spec/gp_update_metadata_source_spec.rb'
149149
- 'spec/ios_download_strings_files_from_glotpress_spec.rb'
150+
- 'spec/ios_extract_keys_from_strings_files_spec.rb'
150151

151152
# Offense count: 8
152153
# Cop supports --auto-correct.

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ _None_
1010

1111
### New Features
1212

13-
_None_
13+
* Introduce new `ios_extract_keys_from_strings_files` action. [#338]
1414

1515
### Bug Fixes
1616

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
module Fastlane
2+
module Actions
3+
class IosExtractKeysFromStringsFilesAction < Action
4+
def self.run(params)
5+
source_parent_dir = params[:source_parent_dir]
6+
target_original_files = params[:target_original_files]
7+
keys_to_extract_per_target_file = keys_list_per_target_file(target_original_files)
8+
9+
# For each locale, extract the right translations from `<source_tablename>.strings` into each target `.strings` file
10+
Dir.glob('*.lproj', base: source_parent_dir).each do |lproj_dir_name|
11+
source_strings_file = File.join(source_parent_dir, lproj_dir_name, "#{params[:source_tablename]}.strings")
12+
translations = Fastlane::Helper::Ios::L10nHelper.read_strings_file_as_hash(path: source_strings_file)
13+
14+
target_original_files.each do |target_original_file|
15+
target_strings_file = File.join(File.dirname(File.dirname(target_original_file)), lproj_dir_name, File.basename(target_original_file))
16+
next if target_strings_file == target_original_file # do not generate/overwrite the original locale itself
17+
18+
keys_to_extract = keys_to_extract_per_target_file[target_original_file]
19+
UI.message("Extracting #{keys_to_extract.count} keys into #{target_strings_file}...")
20+
21+
extracted_translations = translations.slice(*keys_to_extract)
22+
FileUtils.mkdir_p(File.dirname(target_strings_file)) # Ensure path up to parent dir exists, create it if not.
23+
Fastlane::Helper::Ios::L10nHelper.generate_strings_file_from_hash(translations: extracted_translations, output_path: target_strings_file)
24+
rescue StandardError => e
25+
UI.user_error!("Error while writing extracted translations to `#{target_strings_file}`: #{e.message}")
26+
end
27+
rescue StandardError => e
28+
UI.user_error!("Error while reading the translations from source file `#{source_strings_file}`: #{e.message}")
29+
end
30+
end
31+
32+
# Pre-load the list of keys to extract for each target file.
33+
#
34+
# @param [Array<String>] original_files array of paths to the originals of target files
35+
# @return [Hash<String, Array<String>>] The hash listing the keys to extract for each target file
36+
#
37+
def self.keys_list_per_target_file(original_files)
38+
original_files.map do |original_file|
39+
keys = Fastlane::Helper::Ios::L10nHelper.read_strings_file_as_hash(path: original_file).keys
40+
[original_file, keys]
41+
end.to_h
42+
rescue StandardError => e
43+
UI.user_error!("Failed to read the keys to extract from originals file: #{e.message}")
44+
end
45+
46+
#####################################################
47+
# @!group Documentation
48+
#####################################################
49+
50+
def self.description
51+
'Extracts a subset of keys from a `.strings` file into separate `.strings` file(s)'
52+
end
53+
54+
def self.details
55+
<<~DETAILS
56+
Extracts a subset of keys from a `.strings` file into separate `.strings` file(s), for each `*.lproj` subdirectory.
57+
58+
This is especially useful to extract, for each locale, the translations for files like `InfoPlist.strings` or
59+
`<SomeIntentDefinitionFile>.strings` from the `Localizable.strings` file that we exported/downloaded back from GlotPress.
60+
61+
Since we typically merge all `*.strings` original files (e.g. `en.lproj/Localizable.strings` + `en.lproj/InfoPlist.strings` + …)
62+
via `ios_merge_strings_file` before sending the originals to translations, we then need to extract the relevant keys and
63+
translations back into the `*.lproj/InfoPlist.strings` after we pull those translations back from GlotPress
64+
(`ios_download_strings_files_from_glotpress`). This is what this `ios_extract_keys_from_strings_files` action is for.
65+
DETAILS
66+
end
67+
68+
def self.available_options
69+
[
70+
FastlaneCore::ConfigItem.new(key: :source_parent_dir,
71+
env_name: 'FL_IOS_EXTRACT_KEYS_FROM_STRINGS_FILES_SOURCE_PARENT_DIR',
72+
description: 'The parent directory containing all the `*.lproj` subdirectories in which the source `.strings` files reside',
73+
type: String,
74+
verify_block: proc do |value|
75+
UI.user_error!("`source_parent_dir` should be a path to an existing directory, but found `#{value}`.") unless File.directory?(value)
76+
UI.user_error!("`source_parent_dir` should contain at least one `.lproj` subdirectory, but `#{value}` does not contain any.") if Dir.glob('*.lproj', base: value).empty?
77+
end),
78+
FastlaneCore::ConfigItem.new(key: :source_tablename,
79+
env_name: 'FL_IOS_EXTRACT_KEYS_FROM_STRINGS_FILES_SOURCE_TABLENAME',
80+
description: 'The basename of the `.strings` file (without the extension) to extract the keys and translations from for each locale',
81+
type: String,
82+
default_value: 'Localizable'),
83+
FastlaneCore::ConfigItem.new(key: :target_original_files,
84+
env_name: 'FL_IOS_EXTRACT_KEYS_FROM_STRINGS_FILES_TARGET_ORIGINAL_FILES',
85+
description: 'The path(s) to the `<base-locale>.lproj/<target-tablename>.strings` file(s) for which we want to extract the keys to. ' \
86+
+ 'Each of those files should containing the original strings (typically `en` or `Base` locale) and will be used to determine which keys to extract from the `source_tablename`. ' \
87+
+ 'For each of those, the path(s) in which the translations will be extracted will be the files with the same basename in each of the other `*.lproj` sibling folders',
88+
type: Array,
89+
verify_block: proc do |values|
90+
UI.user_error!('`target_original_files` must contain at least one path to an original `.strings` file.') if values.empty?
91+
values.each do |v|
92+
UI.user_error!("Path `#{v}` (found in `target_original_files`) does not exist.") unless File.exist?(v)
93+
UI.user_error! "Expected `#{v}` (found in `target_original_files`) to be a path ending in a `*.lproj/*.strings`." unless File.extname(v) == '.strings' && File.extname(File.dirname(v)) == '.lproj'
94+
end
95+
end),
96+
]
97+
end
98+
99+
def self.return_type
100+
# Describes what type of data is expected to be returned
101+
# see RETURN_TYPES in https://github.com/fastlane/fastlane/blob/master/fastlane/lib/fastlane/action.rb
102+
nil
103+
end
104+
105+
def self.return_value
106+
# Freeform textual description of the return value
107+
end
108+
109+
def self.authors
110+
['Automattic']
111+
end
112+
113+
def self.is_supported?(platform)
114+
[:ios, :mac].include?(platform)
115+
end
116+
end
117+
end
118+
end

lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_generate_strings_file_from_code.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def self.files_matching(paths:, exclude:)
3636
#####################################################
3737

3838
def self.description
39-
'Generate the .strings files from your Objective-C and Swift code'
39+
'Generate the `.strings` files from your Objective-C and Swift code'
4040
end
4141

4242
def self.details

lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_l10n_helper.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ def self.generate_strings_file_from_hash(translations:, output_path:)
108108
xml.comment('Warning: Auto-generated file, do not edit.')
109109
xml.plist(version: '1.0') do
110110
xml.dict do
111-
translations.each do |k, v|
111+
translations.sort.each do |k, v| # NOTE: use `sort` just in order to be deterministic over various runs
112112
xml.key(k.to_s)
113113
xml.string(v.to_s)
114114
end
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
require 'spec_helper'
2+
require 'tmpdir'
3+
4+
describe Fastlane::Actions::IosExtractKeysFromStringsFilesAction do
5+
let(:test_data_dir) { File.join(File.dirname(__FILE__), 'test-data', 'translations', 'ios_extract_keys_from_strings_files') }
6+
7+
describe 'extract the right keys from `Localizable.strings` to all locales' do
8+
def assert_output_files_match(expectations_map)
9+
expectations_map.each do |output_file, expected_file|
10+
expect(File).to exist(output_file), "expected `#{output_file}` to exist"
11+
expect(File.read(output_file)).to eq(File.read(File.join(test_data_dir, expected_file))), "expected content of `#{output_file}` to match `#{expected_file}`"
12+
end
13+
end
14+
15+
it 'can extract keys to a single `InfoPlist.strings` target table' do
16+
in_tmp_dir do |tmp_dir|
17+
# Arrange
18+
lproj_source_dir = File.join(tmp_dir, 'LocalizationFiles')
19+
FileUtils.cp_r(File.join(test_data_dir, 'Resources', '.'), lproj_source_dir)
20+
21+
# Act
22+
run_described_fastlane_action(
23+
source_parent_dir: lproj_source_dir,
24+
target_original_files: File.join(lproj_source_dir, 'en.lproj', 'InfoPlist.strings')
25+
)
26+
27+
# Assert
28+
assert_output_files_match(
29+
File.join(lproj_source_dir, 'fr.lproj', 'InfoPlist.strings') => 'InfoPlist-expected-fr.strings',
30+
File.join(lproj_source_dir, 'zh-Hans.lproj', 'InfoPlist.strings') => 'InfoPlist-expected-zh-Hans.strings'
31+
)
32+
end
33+
end
34+
35+
it 'can extract keys to multiple `.strings` files' do
36+
in_tmp_dir do |tmp_dir|
37+
# Arrange
38+
resources_dir = File.join(tmp_dir, 'Resources')
39+
siri_intent_dir = File.join(tmp_dir, 'SiriIntentTarget')
40+
FileUtils.cp_r(File.join(test_data_dir, 'Resources', '.'), resources_dir)
41+
FileUtils.cp_r(File.join(test_data_dir, 'SiriIntentTarget', '.'), siri_intent_dir)
42+
43+
# Act
44+
run_described_fastlane_action(
45+
source_parent_dir: resources_dir,
46+
target_original_files: [
47+
File.join(resources_dir, 'en.lproj', 'InfoPlist.strings'),
48+
File.join(siri_intent_dir, 'en.lproj', 'Sites.strings'),
49+
]
50+
)
51+
52+
# Assert
53+
assert_output_files_match(
54+
File.join(resources_dir, 'fr.lproj', 'InfoPlist.strings') => 'InfoPlist-expected-fr.strings',
55+
File.join(siri_intent_dir, 'fr.lproj', 'Sites.strings') => 'Sites-expected-fr.strings',
56+
File.join(resources_dir, 'zh-Hans.lproj', 'InfoPlist.strings') => 'InfoPlist-expected-zh-Hans.strings',
57+
File.join(siri_intent_dir, 'zh-Hans.lproj', 'Sites.strings') => 'Sites-expected-zh-Hans.strings'
58+
)
59+
end
60+
end
61+
62+
it 'supports using an input file other than `Localizable.strings`' do
63+
in_tmp_dir do |tmp_dir|
64+
# Arrange
65+
lproj_source_dir = File.join(tmp_dir, 'NonStandardFiles')
66+
FileUtils.cp_r(File.join(test_data_dir, 'Resources', '.'), lproj_source_dir)
67+
Dir.glob('**/Localizable.strings', base: lproj_source_dir).each do |file|
68+
src_file = File.join(lproj_source_dir, file)
69+
FileUtils.mv(src_file, File.join(File.dirname(src_file), 'GlotPressTranslations.strings'))
70+
end
71+
72+
# Act
73+
run_described_fastlane_action(
74+
source_parent_dir: lproj_source_dir,
75+
source_tablename: 'GlotPressTranslations',
76+
target_original_files: File.join(lproj_source_dir, 'en.lproj', 'InfoPlist.strings')
77+
)
78+
79+
# Assert
80+
assert_output_files_match(
81+
File.join(lproj_source_dir, 'fr.lproj', 'InfoPlist.strings') => 'InfoPlist-expected-fr.strings',
82+
File.join(lproj_source_dir, 'zh-Hans.lproj', 'InfoPlist.strings') => 'InfoPlist-expected-zh-Hans.strings'
83+
)
84+
end
85+
end
86+
87+
it 'does not overwrite the original files' do
88+
in_tmp_dir do |tmp_dir|
89+
# Arrange
90+
lproj_source_dir = File.join(tmp_dir, 'Resources')
91+
FileUtils.cp_r(File.join(test_data_dir, 'Resources', '.'), lproj_source_dir)
92+
93+
# Act
94+
run_described_fastlane_action(
95+
source_parent_dir: lproj_source_dir,
96+
target_original_files: File.join(lproj_source_dir, 'en.lproj', 'InfoPlist.strings')
97+
)
98+
99+
# Assert
100+
assert_output_files_match(
101+
File.join(lproj_source_dir, 'en.lproj', 'InfoPlist.strings') => File.join('Resources', 'en.lproj', 'InfoPlist.strings')
102+
)
103+
end
104+
end
105+
end
106+
107+
describe 'input parameters validation' do
108+
it 'errors if the source dir does not exist' do
109+
in_tmp_dir do |tmp_dir|
110+
FileUtils.cp_r(File.join(test_data_dir, 'Resources', '.'), tmp_dir)
111+
112+
expect do
113+
run_described_fastlane_action(
114+
source_parent_dir: '/this/is/not/the/dir/you/are/looking/for/',
115+
target_original_files: File.join(tmp_dir, 'en.lproj', 'InfoPlist.strings')
116+
)
117+
end.to raise_error(FastlaneCore::Interface::FastlaneError, '`source_parent_dir` should be a path to an existing directory, but found `/this/is/not/the/dir/you/are/looking/for/`.')
118+
end
119+
end
120+
121+
it 'errors if the source dir does not contain any `.lproj` subfolder' do
122+
in_tmp_dir do |tmp_dir|
123+
FileUtils.cp_r(File.join(test_data_dir, 'Resources', '.'), tmp_dir)
124+
src_dir = File.join(tmp_dir, 'EmptyDir')
125+
FileUtils.mkdir_p(src_dir)
126+
127+
expect do
128+
run_described_fastlane_action(
129+
source_parent_dir: src_dir,
130+
target_original_files: File.join(tmp_dir, 'en.lproj', 'InfoPlist.strings')
131+
)
132+
end.to raise_error(FastlaneCore::Interface::FastlaneError, "`source_parent_dir` should contain at least one `.lproj` subdirectory, but `#{src_dir}` does not contain any.")
133+
end
134+
end
135+
136+
it 'errors if no target original files provided' do
137+
in_tmp_dir do |tmp_dir|
138+
FileUtils.cp_r(File.join(test_data_dir, 'Resources', '.'), tmp_dir)
139+
140+
expect do
141+
run_described_fastlane_action(
142+
source_parent_dir: tmp_dir,
143+
target_original_files: []
144+
)
145+
end.to raise_error(FastlaneCore::Interface::FastlaneError, '`target_original_files` must contain at least one path to an original `.strings` file.')
146+
end
147+
end
148+
149+
it 'errors if one of the target original files does not exist' do
150+
in_tmp_dir do |tmp_dir|
151+
FileUtils.cp_r(File.join(test_data_dir, 'Resources', '.'), tmp_dir)
152+
non_existing_target_file = File.join(tmp_dir, 'does', 'not', 'exist')
153+
expect do
154+
run_described_fastlane_action(
155+
source_parent_dir: tmp_dir,
156+
target_original_files: [
157+
File.join(tmp_dir, 'en.lproj', 'InfoPlist.strings'),
158+
non_existing_target_file,
159+
]
160+
)
161+
end.to raise_error(FastlaneCore::Interface::FastlaneError, "Path `#{non_existing_target_file}` (found in `target_original_files`) does not exist.")
162+
end
163+
end
164+
165+
it 'errors if one of the target original files does not point to a path like `**/*.lproj/*.strings`' do
166+
in_tmp_dir do |tmp_dir|
167+
FileUtils.cp_r(File.join(test_data_dir, 'Resources', '.'), tmp_dir)
168+
misleading_target_file = File.join(tmp_dir, 'en.lproj', 'Info.plist')
169+
FileUtils.cp(File.join(tmp_dir, 'en.lproj', 'InfoPlist.strings'), misleading_target_file)
170+
171+
expect do
172+
run_described_fastlane_action(
173+
source_parent_dir: tmp_dir,
174+
target_original_files: misleading_target_file
175+
)
176+
end.to raise_error(FastlaneCore::Interface::FastlaneError, "Expected `#{misleading_target_file}` (found in `target_original_files`) to be a path ending in a `*.lproj/*.strings`.")
177+
end
178+
end
179+
end
180+
181+
describe 'error handling during processing' do
182+
it 'errors when failing to read a source file' do
183+
in_tmp_dir do |tmp_dir|
184+
lproj_source_dir = File.join(tmp_dir, 'Resources')
185+
FileUtils.cp_r(File.join(test_data_dir, 'Resources', '.'), lproj_source_dir)
186+
broken_file_path = File.join(lproj_source_dir, 'fr.lproj', 'Localizable.strings')
187+
File.write(broken_file_path, 'Invalid strings file content')
188+
189+
expect do
190+
run_described_fastlane_action(
191+
source_parent_dir: lproj_source_dir,
192+
target_original_files: File.join(lproj_source_dir, 'en.lproj', 'InfoPlist.strings')
193+
)
194+
end.to raise_error(FastlaneCore::Interface::FastlaneError, /^Error while reading the translations from source file `#{broken_file_path}`: #{broken_file_path}: Property List error/)
195+
end
196+
end
197+
198+
it 'errors if fails to read the keys to extract from a target originals file' do
199+
in_tmp_dir do |tmp_dir|
200+
lproj_source_dir = File.join(tmp_dir, 'Resources')
201+
FileUtils.cp_r(File.join(test_data_dir, 'Resources', '.'), lproj_source_dir)
202+
broken_file_path = File.join(lproj_source_dir, 'en.lproj', 'InfoPlist.strings')
203+
File.write(broken_file_path, 'Invalid strings file content')
204+
205+
expect do
206+
run_described_fastlane_action(
207+
source_parent_dir: lproj_source_dir,
208+
target_original_files: File.join(lproj_source_dir, 'en.lproj', 'InfoPlist.strings')
209+
)
210+
end.to raise_error(FastlaneCore::Interface::FastlaneError, /^Failed to read the keys to extract from originals file: #{broken_file_path}: Property List error/)
211+
end
212+
end
213+
214+
it 'errors it if fails to write one of the target files' do
215+
in_tmp_dir do |tmp_dir|
216+
lproj_source_dir = File.join(tmp_dir, 'Resources')
217+
FileUtils.cp_r(File.join(test_data_dir, 'Resources', '.'), lproj_source_dir)
218+
allow(File).to receive(:write) { raise 'Stubbed IO error' }
219+
220+
expect do
221+
run_described_fastlane_action(
222+
source_parent_dir: lproj_source_dir,
223+
target_original_files: File.join(lproj_source_dir, 'en.lproj', 'InfoPlist.strings')
224+
)
225+
end.to raise_error(FastlaneCore::Interface::FastlaneError, /Error while writing extracted translations to `#{File.join(lproj_source_dir, '.*\.lproj', 'InfoPlist\.strings')}`: Stubbed IO error/)
226+
end
227+
end
228+
end
229+
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<!--Warning: Auto-generated file, do not edit.-->
4+
<plist version="1.0">
5+
<dict>
6+
<key>NSCameraUsageDescription</key>
7+
<string>Pour prendre des photos ou réaliser des vidéos à utiliser dans vos articles.</string>
8+
<key>NSLocationUsageDescription</key>
9+
<string>WordPress voudrait ajouter votre position aux articles des sites pour lesquels vous avez activé la géolocalisation.</string>
10+
</dict>
11+
</plist>

0 commit comments

Comments
 (0)