Skip to content

Commit 4fc2e0c

Browse files
authored
Add optional prefixes when merging in ios_merge_strings_files (#345)
2 parents 6f477c3 + 4586e39 commit 4fc2e0c

23 files changed

+397
-100
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66

77
### Breaking Changes
88

9-
_None_
9+
* Update the API of `ios_merge_strings_files` and `ios_extract_keys_from_strings_files` to support using prefixes for string keys when merging/splitting the files.
10+
The actions now expect a `Hash` (instead of an `Array`) for the list of files to provide an associated prefix (or `nil` or `''` when none) for each file to merge/split. [#345]
1011

1112
### New Features
1213

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

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,40 @@ module Actions
33
class IosExtractKeysFromStringsFilesAction < Action
44
def self.run(params)
55
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)
6+
target_original_files = params[:target_original_files].keys # Array [original-file-paths]
7+
keys_to_extract_per_target_file = keys_list_per_target_file(target_original_files) # Hash { original-file-path => [keys] }
8+
prefix_to_remove_per_target_file = params[:target_original_files] # Hash { original-file-path => prefix }
9+
10+
UI.message("Extracting keys from `#{source_parent_dir}/*.lproj/#{params[:source_tablename]}.strings` into:")
11+
target_original_files.each { |f| UI.message(' - ' + replace_lproj_in_path(f, with_lproj: '*.lproj')) }
12+
13+
updated_files_list = []
814

915
# For each locale, extract the right translations from `<source_tablename>.strings` into each target `.strings` file
1016
Dir.glob('*.lproj', base: source_parent_dir).each do |lproj_dir_name|
1117
source_strings_file = File.join(source_parent_dir, lproj_dir_name, "#{params[:source_tablename]}.strings")
1218
translations = Fastlane::Helper::Ios::L10nHelper.read_strings_file_as_hash(path: source_strings_file)
1319

1420
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))
21+
target_strings_file = replace_lproj_in_path(target_original_file, with_lproj: lproj_dir_name)
1622
next if target_strings_file == target_original_file # do not generate/overwrite the original locale itself
1723

18-
keys_to_extract = keys_to_extract_per_target_file[target_original_file]
19-
extracted_translations = translations.slice(*keys_to_extract)
20-
UI.message("Extracting #{extracted_translations.count} keys (out of #{keys_to_extract.count} expected) into #{target_strings_file}...")
24+
keys_prefix = prefix_to_remove_per_target_file[target_original_file] || ''
25+
keys_to_extract = keys_to_extract_per_target_file[target_original_file].map { |k| "#{keys_prefix}#{k}" }
26+
extracted_translations = translations.slice(*keys_to_extract).transform_keys { |k| k.delete_prefix(keys_prefix) }
27+
UI.verbose("Extracting #{extracted_translations.count} keys (out of #{keys_to_extract.count} expected) into #{target_strings_file}...")
2128

2229
FileUtils.mkdir_p(File.dirname(target_strings_file)) # Ensure path up to parent dir exists, create it if not.
2330
Fastlane::Helper::Ios::L10nHelper.generate_strings_file_from_hash(translations: extracted_translations, output_path: target_strings_file)
31+
updated_files_list.append(target_strings_file)
2432
rescue StandardError => e
2533
UI.user_error!("Error while writing extracted translations to `#{target_strings_file}`: #{e.message}")
2634
end
2735
rescue StandardError => e
2836
UI.user_error!("Error while reading the translations from source file `#{source_strings_file}`: #{e.message}")
2937
end
38+
39+
updated_files_list
3040
end
3141

3242
# Pre-load the list of keys to extract for each target file.
@@ -43,6 +53,15 @@ def self.keys_list_per_target_file(original_files)
4353
UI.user_error!("Failed to read the keys to extract from originals file: #{e.message}")
4454
end
4555

56+
# Replaces the `*.lproj` component of the path to a `.strings` file with a different `.lproj` folder
57+
#
58+
# @param [String] path The path the the `.strings` file, assumed to be in a `.lproj` parent folder
59+
# @param [String] with_lproj The new name of the `.lproj` parent folder to point to
60+
#
61+
def self.replace_lproj_in_path(path, with_lproj:)
62+
File.join(File.dirname(File.dirname(path)), with_lproj, File.basename(path))
63+
end
64+
4665
#####################################################
4766
# @!group Documentation
4867
#####################################################
@@ -82,28 +101,27 @@ def self.available_options
82101
default_value: 'Localizable'),
83102
FastlaneCore::ConfigItem.new(key: :target_original_files,
84103
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,
104+
description: 'The path(s) to the `<base-locale>.lproj/<target-tablename>.strings` file(s) for which we want to extract the keys to, and the prefix to remove from their keys. ' \
105+
+ 'Each key in the Hash should point to a file containing the original strings (typically `en` or `Base` locale), and will be used to determine which keys to extract from the `source_tablename`. ' \
106+
+ 'For each key, the associated value is an optional prefix to remove from the keys (which can be useful if you used a prefix during `ios_merge_strings_files` to avoid duplicates). Can be nil or empty if no prefix was used during merge for that file.' \
107+
+ 'Note: For each entry, the path(s) in which the translations will be extracted to will be the files with the same basename as the key in each of the other `*.lproj` sibling folders. ',
108+
type: Hash,
89109
verify_block: proc do |values|
90110
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'
111+
values.each do |path, _|
112+
UI.user_error!("Path `#{path}` (found in `target_original_files`) does not exist.") unless File.exist?(path)
113+
UI.user_error! "Expected `#{path}` (found in `target_original_files`) to be a path ending in a `*.lproj/*.strings`." unless File.extname(path) == '.strings' && File.extname(File.dirname(path)) == '.lproj'
94114
end
95115
end),
96116
]
97117
end
98118

99119
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
120+
:array_of_strings
103121
end
104122

105123
def self.return_value
106-
# Freeform textual description of the return value
124+
'The list of files which have been generated and written to disk by the action'
107125
end
108126

109127
def self.authors

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

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,18 @@ module Fastlane
22
module Actions
33
class IosMergeStringsFilesAction < Action
44
def self.run(params)
5-
UI.message "Merging strings files: #{params[:paths].inspect}"
5+
destination = params[:destination]
6+
# Include the destination as the first of the files (without key prefixes) to be included in the merged result
7+
all_paths_list = { destination => nil }.merge(params[:paths_to_merge])
68

7-
duplicates = Fastlane::Helper::Ios::L10nHelper.merge_strings(paths: params[:paths], output_path: params[:destination])
9+
UI.message "Merging strings files #{all_paths_list.inspect}"
10+
11+
File.write(destination, '') unless File.exist?(destination) # Create empty destination file if it does not exist yet
12+
duplicates = Fastlane::Helper::Ios::L10nHelper.merge_strings(paths: all_paths_list, output_path: params[:destination])
813
duplicates.each do |dup_key|
914
UI.important "Duplicate key found while merging the `.strings` files: `#{dup_key}`"
1015
end
16+
UI.important 'Tip: To avoid those key conflicts, you might want to consider providing different prefixes in the `Hash` you used for the `paths:` parameter.' unless duplicates.empty?
1117
duplicates
1218
end
1319

@@ -21,12 +27,11 @@ def self.description
2127

2228
def self.details
2329
<<~DETAILS
24-
Merge multiple `.strings` files into one.
30+
Merge multiple `.strings` files into another one.
2531
26-
Especially useful to prepare a single `.strings` file merging strings from both `Localizable.strings` from
27-
the app code — typically previously extracted from `ios_generate_strings_file_from_code` —
28-
and string files like `InfoPlist.strings` — which values may not be generated from the codebase but
29-
manually maintained by developers.
32+
Especially useful to prepare a single `.strings` file merging string files like `InfoPlist.strings` — whose
33+
content are typically manually maintained by developers — within the main `Localizable.strings` file — which
34+
would have typically been previously generated from the codebase via `ios_generate_strings_file_from_code`.
3035
3136
The action only supports merging files which are in the OpenStep (`"key" = "value";`) text format (which is
3237
the most common format for `.strings` files, and the one generated by `genstrings`), but can handle the case
@@ -38,19 +43,18 @@ def self.details
3843
def self.available_options
3944
[
4045
FastlaneCore::ConfigItem.new(
41-
key: :paths,
42-
env_name: 'FL_IOS_MERGE_STRINGS_FILES_PATHS',
43-
description: 'The paths of all the `.strings` files to merge together',
44-
type: Array,
46+
key: :paths_to_merge,
47+
env_name: 'FL_IOS_MERGE_STRINGS_FILES_PATHS_TO_MERGE',
48+
description: 'A hash of the paths of all the `.strings` files to merge into the `destination`, with the prefix to be used for their keys as associated value',
49+
type: Hash,
4550
optional: false
4651
),
4752
FastlaneCore::ConfigItem.new(
4853
key: :destination,
4954
env_name: 'FL_IOS_MERGE_STRINGS_FILES_DESTINATION',
50-
description: 'The path of the merged `.strings` file to generate. If nil, the merge will happen in-place in the first file in the `paths:` list',
55+
description: 'The path of the `.strings` file to merge the other ones into',
5156
type: String,
52-
optional: true,
53-
default_value: nil
57+
optional: false
5458
),
5559
]
5660
end
@@ -60,7 +64,7 @@ def self.return_type
6064
end
6165

6266
def self.return_value
63-
'The list of duplicate keys found while merging the various `.strings` files'
67+
'The list of duplicate keys (after prefix has been added to each) found while merging the various `.strings` files'
6468
end
6569

6670
def self.authors

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

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ class L10nHelper
1919
# - `nil` if the file does not exist or is neither of those format (e.g. not a `.strings` file at all)
2020
#
2121
def self.strings_file_type(path:)
22+
return :text if File.exist?(path) && File.size(path).zero? # If completely empty file, consider it as a valid `.strings` files in textual format
23+
2224
# Start by checking it seems like a valid property-list file (and not e.g. an image or plain text file)
2325
_, status = Open3.capture2('/usr/bin/plutil', '-lint', path)
2426
return nil unless status.success?
@@ -36,8 +38,8 @@ def self.strings_file_type(path:)
3638

3739
# Merge the content of multiple `.strings` files into a new `.strings` text file.
3840
#
39-
# @param [Array<String>] paths The paths of the `.strings` files to merge together
40-
# @param [String] into The path to the merged `.strings` file to generate as a result.
41+
# @param [Hash<String, String>] paths The paths of the `.strings` files to merge together, associated with the prefix to prepend to each of their respective keys
42+
# @param [String] output_path The path to the merged `.strings` file to generate as a result.
4143
# @return [Array<String>] List of duplicate keys found while validating the merge.
4244
#
4345
# @note For now, this method only supports merging `.strings` file in `:text` format
@@ -48,24 +50,35 @@ def self.strings_file_type(path:)
4850
#
4951
# @raise [RuntimeError] If one of the paths provided is not in text format (but XML or binary instead), or if any of the files are missing.
5052
#
51-
def self.merge_strings(paths:, output_path: nil)
53+
def self.merge_strings(paths:, output_path:)
5254
duplicates = []
5355
Tempfile.create('wpmrt-l10n-merge-', encoding: 'utf-8') do |tmp_file|
5456
all_keys_found = []
5557

5658
tmp_file.write("/* Generated File. Do not edit. */\n\n")
57-
paths.each do |input_file|
59+
paths.each do |input_file, prefix|
60+
next if File.exist?(input_file) && File.size(input_file).zero? # Accept but skip existing-but-totally-empty files (to avoid adding useless `MARK:` comment for them)
61+
5862
fmt = strings_file_type(path: input_file)
5963
raise "The file `#{input_file}` does not exist or is of unknown format." if fmt.nil?
6064
raise "The file `#{input_file}` is in #{fmt} format but we currently only support merging `.strings` files in text format." unless fmt == :text
6165

62-
string_keys = read_strings_file_as_hash(path: input_file).keys
66+
string_keys = read_strings_file_as_hash(path: input_file).keys.map { |k| "#{prefix}#{k}" }
6367
duplicates += (string_keys & all_keys_found) # Find duplicates using Array intersection, and add those to duplicates list
6468
all_keys_found += string_keys
6569

6670
tmp_file.write("/* MARK: - #{File.basename(input_file)} */\n\n")
6771
# Read line-by-line to reduce memory footprint during content copy; Be sure to guess file encoding using the Byte-Order-Mark.
68-
File.readlines(input_file, mode: 'rb:BOM|UTF-8').each { |line| tmp_file.write(line) }
72+
File.readlines(input_file, mode: 'rb:BOM|UTF-8').each do |line|
73+
unless prefix.nil? || prefix.empty?
74+
# We need to ensure the line and RegExp are using the same encoding, so we transcode everything to UTF-8.
75+
line.encode!(Encoding::UTF_8)
76+
# The `/u` modifier on the RegExps is to make them UTF-8
77+
line.gsub!(/^(\s*")/u, "\\1#{prefix}") # Lines starting with a quote are considered to be start of a key; add prefix right after the quote
78+
line.gsub!(/^(\s*)([A-Z0-9_]+)(\s*=\s*")/ui, "\\1\"#{prefix}\\2\"\\3") # Lines starting with an identifier followed by a '=' are considered to be an unquoted key (typical in InfoPlist.strings files for example)
79+
end
80+
tmp_file.write(line)
81+
end
6982
tmp_file.write("\n")
7083
end
7184
tmp_file.close # ensure we flush the content to disk
@@ -81,6 +94,8 @@ def self.merge_strings(paths:, output_path: nil)
8194
# @raise [RuntimeError] If the file is not a valid strings file or there was an error in parsing its content.
8295
#
8396
def self.read_strings_file_as_hash(path:)
97+
return {} if File.exist?(path) && File.size(path).zero? # Return empty hash if completely empty file
98+
8499
output, status = Open3.capture2e('/usr/bin/plutil', '-convert', 'json', '-o', '-', path)
85100
raise output unless status.success?
86101

0 commit comments

Comments
 (0)