fastlane-plugin-wpmreleasetoolkit 3.0.0 → 4.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/an_localize_libs_action.rb +9 -4
- data/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/upload_to_s3.rb +112 -0
- data/lib/fastlane/plugin/wpmreleasetoolkit/actions/configure/configure_download_action.rb +1 -2
- data/lib/fastlane/plugin/wpmreleasetoolkit/actions/configure/configure_validate_action.rb +2 -2
- data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_download_strings_files_from_glotpress.rb +1 -0
- data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_extract_keys_from_strings_files.rb +136 -0
- data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_generate_strings_file_from_code.rb +1 -1
- data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_lint_localizations.rb +7 -7
- data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_merge_strings_files.rb +19 -15
- data/lib/fastlane/plugin/wpmreleasetoolkit/helper/android/android_localize_helper.rb +66 -47
- data/lib/fastlane/plugin/wpmreleasetoolkit/helper/git_helper.rb +10 -18
- data/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_l10n_helper.rb +22 -7
- data/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_l10n_linter_helper.rb +20 -34
- data/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_strings_file_validation_helper.rb +107 -0
- data/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata_download_helper.rb +2 -4
- data/lib/fastlane/plugin/wpmreleasetoolkit/helper/promo_screenshots_helper.rb +1 -2
- data/lib/fastlane/plugin/wpmreleasetoolkit/helper/release_notes_helper.rb +4 -2
- data/lib/fastlane/plugin/wpmreleasetoolkit/models/file_reference.rb +2 -4
- data/lib/fastlane/plugin/wpmreleasetoolkit/version.rb +1 -1
- metadata +7 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ee81192aaa403dc7878821f60017e13f9867c549ba84ba2912d1e3ae0623b7b8
|
4
|
+
data.tar.gz: a702bfd98e9134e8d3f4303a1bad07705e6b27c107ead1295f5583a78b40384e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 87379c5741f84f94127d0388e24080c9e861a37115c16b5e6116afd7e1e13c143c748624870740ff2c25c82eb450d910219abd9c56bddf9fabfcb5494f826df4
|
7
|
+
data.tar.gz: 51493b07f6a15c38147e7b25607b63d4f5808ee334ff0ec7a3a65d52f33cf8a4d71a46aa189f680324ca67023d1072f2b9a115899b4cecabe0ad90c92ccdfe91
|
@@ -32,6 +32,14 @@ module Fastlane
|
|
32
32
|
end
|
33
33
|
|
34
34
|
def self.available_options
|
35
|
+
libs_hash_description = <<~KEYS
|
36
|
+
- `:library`: The library display name.
|
37
|
+
- `:strings_path`: The path to the `strings.xml` file of the library.
|
38
|
+
- `:exclusions`: An optional `Array` of string keys to exclude from merging.
|
39
|
+
- `:source_id`: An optional `String` which will be added as the `a8c-src-lib` XML attribute
|
40
|
+
to strings coming from this library, to help identify their source in the merged file.
|
41
|
+
- `:add_ignore_attr`: If set to true, will add `tools:ignore="UnusedResources"` to merged strings.
|
42
|
+
KEYS
|
35
43
|
[
|
36
44
|
FastlaneCore::ConfigItem.new(key: :app_strings_path,
|
37
45
|
description: 'The path of the main strings file',
|
@@ -41,10 +49,7 @@ module Fastlane
|
|
41
49
|
# See `Fastlane::Helper::Android::LocalizeHelper.merge_lib`'s YARD doc for more details on the keys expected for each Hash.
|
42
50
|
FastlaneCore::ConfigItem.new(key: :libs_strings_path,
|
43
51
|
env_name: 'LOCALIZE_LIBS_STRINGS_PATH',
|
44
|
-
description:
|
45
|
-
+ 'Each item in the provided array must be a Hash with the keys `:library` (The library display name),' \
|
46
|
-
+ '`:strings_path` (The path to the `strings.xml` file of the library) and ' \
|
47
|
-
+ '`:exclusions` (Array of string keys to exclude from merging)',
|
52
|
+
description: "The list of libs to merge. Each item in the provided array must be a Hash with the following keys:\n#{libs_hash_description}",
|
48
53
|
optional: false,
|
49
54
|
type: Array),
|
50
55
|
]
|
@@ -0,0 +1,112 @@
|
|
1
|
+
require 'fastlane/action'
|
2
|
+
require 'digest/sha1'
|
3
|
+
|
4
|
+
module Fastlane
|
5
|
+
module Actions
|
6
|
+
module SharedValues
|
7
|
+
S3_UPLOADED_FILE_PATH = :S3_UPLOADED_FILE_PATH
|
8
|
+
end
|
9
|
+
|
10
|
+
class UploadToS3Action < Action
|
11
|
+
def self.run(params)
|
12
|
+
file_path = params[:file]
|
13
|
+
file_name = File.basename(file_path)
|
14
|
+
|
15
|
+
bucket = params[:bucket]
|
16
|
+
key = params[:key] || file_name
|
17
|
+
|
18
|
+
if params[:auto_prefix] == true
|
19
|
+
file_name_hash = Digest::SHA1.hexdigest(file_name)
|
20
|
+
key = [file_name_hash, key].join('/')
|
21
|
+
end
|
22
|
+
|
23
|
+
UI.user_error!("File already exists in S3 bucket #{bucket} at #{key}") if file_is_already_uploaded?(bucket, key)
|
24
|
+
|
25
|
+
UI.message("Uploading #{file_path} to: #{key}")
|
26
|
+
|
27
|
+
File.open(file_path, 'rb') do |file|
|
28
|
+
Aws::S3::Client.new().put_object(
|
29
|
+
body: file,
|
30
|
+
bucket: bucket,
|
31
|
+
key: key
|
32
|
+
)
|
33
|
+
rescue Aws::S3::Errors::ServiceError => e
|
34
|
+
UI.crash!("Unable to upload file to S3: #{e.message}")
|
35
|
+
end
|
36
|
+
|
37
|
+
UI.success('Upload Complete')
|
38
|
+
|
39
|
+
Actions.lane_context[SharedValues::S3_UPLOADED_FILE_PATH] = key
|
40
|
+
|
41
|
+
return key
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.file_is_already_uploaded?(bucket, key)
|
45
|
+
response = Aws::S3::Client.new().head_object(
|
46
|
+
bucket: bucket,
|
47
|
+
key: key
|
48
|
+
)
|
49
|
+
return response[:content_length].positive?
|
50
|
+
rescue Aws::S3::Errors::NotFound
|
51
|
+
return false
|
52
|
+
end
|
53
|
+
|
54
|
+
def self.description
|
55
|
+
'Uploads a given file to S3'
|
56
|
+
end
|
57
|
+
|
58
|
+
def self.authors
|
59
|
+
['Automattic']
|
60
|
+
end
|
61
|
+
|
62
|
+
def self.return_value
|
63
|
+
'Returns the object\'s derived S3 key'
|
64
|
+
end
|
65
|
+
|
66
|
+
def self.details
|
67
|
+
'Uploads a file to S3, and makes a pre-signed URL available in the lane context'
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.available_options
|
71
|
+
[
|
72
|
+
FastlaneCore::ConfigItem.new(
|
73
|
+
key: :bucket,
|
74
|
+
description: 'The bucket that will store the file',
|
75
|
+
optional: false,
|
76
|
+
type: String,
|
77
|
+
verify_block: proc { |bucket| UI.user_error!('You must provide a valid bucket name') if bucket.empty? }
|
78
|
+
),
|
79
|
+
FastlaneCore::ConfigItem.new(
|
80
|
+
key: :key,
|
81
|
+
description: 'The path to the file within the bucket. If `nil`, will default to the `file\'s basename',
|
82
|
+
optional: true,
|
83
|
+
type: String,
|
84
|
+
verify_block: proc { |key|
|
85
|
+
next if key.is_a?(String) && !key.empty?
|
86
|
+
|
87
|
+
UI.user_error!('The provided key must not be empty. Use nil instead if you want to default to the file basename')
|
88
|
+
}
|
89
|
+
),
|
90
|
+
FastlaneCore::ConfigItem.new(
|
91
|
+
key: :file,
|
92
|
+
description: 'The path to the local file on disk',
|
93
|
+
optional: false,
|
94
|
+
type: String,
|
95
|
+
verify_block: proc { |f| UI.user_error!("Path `#{f}` does not exist.") unless File.file?(f) }
|
96
|
+
),
|
97
|
+
FastlaneCore::ConfigItem.new(
|
98
|
+
key: :auto_prefix,
|
99
|
+
description: 'Generate a derived prefix based on the filename that makes it harder to guess the URL of the uploaded object',
|
100
|
+
optional: true,
|
101
|
+
default_value: true,
|
102
|
+
type: Boolean
|
103
|
+
),
|
104
|
+
]
|
105
|
+
end
|
106
|
+
|
107
|
+
def self.is_supported?(platform)
|
108
|
+
true
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -17,8 +17,7 @@ module Fastlane
|
|
17
17
|
end
|
18
18
|
|
19
19
|
# Ensure the git repository at `~/.mobile-secrets` is up to date.
|
20
|
-
# If the secrets repo is in a detached HEAD state, skip the pull,
|
21
|
-
# since it will fail.
|
20
|
+
# If the secrets repo is in a detached HEAD state, skip the pull, since it will fail.
|
22
21
|
def self.update_repository
|
23
22
|
secrets_repo_branch = Fastlane::Helper::ConfigureHelper.repo_branch_name
|
24
23
|
|
@@ -15,8 +15,8 @@ module Fastlane
|
|
15
15
|
# otherwise, the error messaging isn't as helpful.
|
16
16
|
validate_that_secrets_repo_is_clean
|
17
17
|
|
18
|
-
# Update the repository to get the latest version of the configuration secrets
|
19
|
-
# how we'll know if we're behind in subsequent validations
|
18
|
+
# Update the repository to get the latest version of the configuration secrets
|
19
|
+
# – that's how we'll know if we're behind in subsequent validations
|
20
20
|
ConfigureDownloadAction.run
|
21
21
|
|
22
22
|
validate_that_branches_match
|
data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_download_strings_files_from_glotpress.rb
CHANGED
@@ -10,6 +10,7 @@ module Fastlane
|
|
10
10
|
|
11
11
|
locales.each do |glotpress_locale, lproj_name|
|
12
12
|
# Download the export in the proper `.lproj` directory
|
13
|
+
UI.message "Downloading translations for '#{lproj_name}' from GlotPress (#{glotpress_locale}) [#{params[:filters]}]..."
|
13
14
|
lproj_dir = File.join(download_dir, "#{lproj_name}.lproj")
|
14
15
|
destination = File.join(lproj_dir, "#{params[:table_basename]}.strings")
|
15
16
|
FileUtils.mkdir(lproj_dir) unless Dir.exist?(lproj_dir)
|
@@ -0,0 +1,136 @@
|
|
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].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 = []
|
14
|
+
|
15
|
+
# For each locale, extract the right translations from `<source_tablename>.strings` into each target `.strings` file
|
16
|
+
Dir.glob('*.lproj', base: source_parent_dir).each do |lproj_dir_name|
|
17
|
+
source_strings_file = File.join(source_parent_dir, lproj_dir_name, "#{params[:source_tablename]}.strings")
|
18
|
+
translations = Fastlane::Helper::Ios::L10nHelper.read_strings_file_as_hash(path: source_strings_file)
|
19
|
+
|
20
|
+
target_original_files.each do |target_original_file|
|
21
|
+
target_strings_file = replace_lproj_in_path(target_original_file, with_lproj: lproj_dir_name)
|
22
|
+
next if target_strings_file == target_original_file # do not generate/overwrite the original locale itself
|
23
|
+
|
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}...")
|
28
|
+
|
29
|
+
FileUtils.mkdir_p(File.dirname(target_strings_file)) # Ensure path up to parent dir exists, create it if not.
|
30
|
+
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)
|
32
|
+
rescue StandardError => e
|
33
|
+
UI.user_error!("Error while writing extracted translations to `#{target_strings_file}`: #{e.message}")
|
34
|
+
end
|
35
|
+
rescue StandardError => e
|
36
|
+
UI.user_error!("Error while reading the translations from source file `#{source_strings_file}`: #{e.message}")
|
37
|
+
end
|
38
|
+
|
39
|
+
updated_files_list
|
40
|
+
end
|
41
|
+
|
42
|
+
# Pre-load the list of keys to extract for each target file.
|
43
|
+
#
|
44
|
+
# @param [Array<String>] original_files array of paths to the originals of target files
|
45
|
+
# @return [Hash<String, Array<String>>] The hash listing the keys to extract for each target file
|
46
|
+
#
|
47
|
+
def self.keys_list_per_target_file(original_files)
|
48
|
+
original_files.map do |original_file|
|
49
|
+
keys = Fastlane::Helper::Ios::L10nHelper.read_strings_file_as_hash(path: original_file).keys
|
50
|
+
[original_file, keys]
|
51
|
+
end.to_h
|
52
|
+
rescue StandardError => e
|
53
|
+
UI.user_error!("Failed to read the keys to extract from originals file: #{e.message}")
|
54
|
+
end
|
55
|
+
|
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
|
+
|
65
|
+
#####################################################
|
66
|
+
# @!group Documentation
|
67
|
+
#####################################################
|
68
|
+
|
69
|
+
def self.description
|
70
|
+
'Extracts a subset of keys from a `.strings` file into separate `.strings` file(s)'
|
71
|
+
end
|
72
|
+
|
73
|
+
def self.details
|
74
|
+
<<~DETAILS
|
75
|
+
Extracts a subset of keys from a `.strings` file into separate `.strings` file(s), for each `*.lproj` subdirectory.
|
76
|
+
|
77
|
+
This is especially useful to extract, for each locale, the translations for files like `InfoPlist.strings` or
|
78
|
+
`<SomeIntentDefinitionFile>.strings` from the `Localizable.strings` file that we exported/downloaded back from GlotPress.
|
79
|
+
|
80
|
+
Since we typically merge all `*.strings` original files (e.g. `en.lproj/Localizable.strings` + `en.lproj/InfoPlist.strings` + …)
|
81
|
+
via `ios_merge_strings_file` before sending the originals to translations, we then need to extract the relevant keys and
|
82
|
+
translations back into the `*.lproj/InfoPlist.strings` after we pull those translations back from GlotPress
|
83
|
+
(`ios_download_strings_files_from_glotpress`). This is what this `ios_extract_keys_from_strings_files` action is for.
|
84
|
+
DETAILS
|
85
|
+
end
|
86
|
+
|
87
|
+
def self.available_options
|
88
|
+
[
|
89
|
+
FastlaneCore::ConfigItem.new(key: :source_parent_dir,
|
90
|
+
env_name: 'FL_IOS_EXTRACT_KEYS_FROM_STRINGS_FILES_SOURCE_PARENT_DIR',
|
91
|
+
description: 'The parent directory containing all the `*.lproj` subdirectories in which the source `.strings` files reside',
|
92
|
+
type: String,
|
93
|
+
verify_block: proc do |value|
|
94
|
+
UI.user_error!("`source_parent_dir` should be a path to an existing directory, but found `#{value}`.") unless File.directory?(value)
|
95
|
+
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?
|
96
|
+
end),
|
97
|
+
FastlaneCore::ConfigItem.new(key: :source_tablename,
|
98
|
+
env_name: 'FL_IOS_EXTRACT_KEYS_FROM_STRINGS_FILES_SOURCE_TABLENAME',
|
99
|
+
description: 'The basename of the `.strings` file (without the extension) to extract the keys and translations from for each locale',
|
100
|
+
type: String,
|
101
|
+
default_value: 'Localizable'),
|
102
|
+
FastlaneCore::ConfigItem.new(key: :target_original_files,
|
103
|
+
env_name: 'FL_IOS_EXTRACT_KEYS_FROM_STRINGS_FILES_TARGET_ORIGINAL_FILES',
|
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,
|
109
|
+
verify_block: proc do |values|
|
110
|
+
UI.user_error!('`target_original_files` must contain at least one path to an original `.strings` file.') if values.empty?
|
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'
|
114
|
+
end
|
115
|
+
end),
|
116
|
+
]
|
117
|
+
end
|
118
|
+
|
119
|
+
def self.return_type
|
120
|
+
:array_of_strings
|
121
|
+
end
|
122
|
+
|
123
|
+
def self.return_value
|
124
|
+
'The list of files which have been generated and written to disk by the action'
|
125
|
+
end
|
126
|
+
|
127
|
+
def self.authors
|
128
|
+
['Automattic']
|
129
|
+
end
|
130
|
+
|
131
|
+
def self.is_supported?(platform)
|
132
|
+
[:ios, :mac].include?(platform)
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|
data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_generate_strings_file_from_code.rb
CHANGED
@@ -36,7 +36,7 @@ module Fastlane
|
|
36
36
|
#####################################################
|
37
37
|
|
38
38
|
def self.description
|
39
|
-
'Generate the
|
39
|
+
'Generate the `.strings` files from your Objective-C and Swift code'
|
40
40
|
end
|
41
41
|
|
42
42
|
def self.details
|
@@ -22,17 +22,17 @@ module Fastlane
|
|
22
22
|
install_path: resolve_path(params[:install_path]),
|
23
23
|
version: params[:version]
|
24
24
|
)
|
25
|
-
|
25
|
+
all_violations = helper.run(
|
26
26
|
input_dir: resolve_path(params[:input_dir]),
|
27
27
|
base_lang: params[:base_lang],
|
28
28
|
only_langs: params[:only_langs]
|
29
29
|
)
|
30
30
|
|
31
|
-
|
32
|
-
UI.error "Inconsistencies found between '#{params[:base_lang]}' and '#{lang}':\n\n#{
|
31
|
+
all_violations.each do |lang, lang_violations|
|
32
|
+
UI.error "Inconsistencies found between '#{params[:base_lang]}' and '#{lang}':\n\n#{lang_violations.join("\n")}\n"
|
33
33
|
end
|
34
34
|
|
35
|
-
|
35
|
+
all_violations
|
36
36
|
end
|
37
37
|
|
38
38
|
RETRY_MESSAGE = <<~MSG
|
@@ -148,15 +148,15 @@ module Fastlane
|
|
148
148
|
end
|
149
149
|
|
150
150
|
def self.return_type
|
151
|
-
:
|
151
|
+
:hash
|
152
152
|
end
|
153
153
|
|
154
154
|
def self.return_value
|
155
|
-
'A hash, keyed by language code, whose values are
|
155
|
+
'A hash, keyed by language code, whose values are arrays of violations found for that language'
|
156
156
|
end
|
157
157
|
|
158
158
|
def self.authors
|
159
|
-
['
|
159
|
+
['Automattic']
|
160
160
|
end
|
161
161
|
|
162
162
|
def self.is_supported?(platform)
|
@@ -2,12 +2,18 @@ module Fastlane
|
|
2
2
|
module Actions
|
3
3
|
class IosMergeStringsFilesAction < Action
|
4
4
|
def self.run(params)
|
5
|
-
|
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])
|
6
8
|
|
7
|
-
|
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])
|
8
13
|
duplicates.each do |dup_key|
|
9
14
|
UI.important "Duplicate key found while merging the `.strings` files: `#{dup_key}`"
|
10
15
|
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?
|
11
17
|
duplicates
|
12
18
|
end
|
13
19
|
|
@@ -21,12 +27,11 @@ module Fastlane
|
|
21
27
|
|
22
28
|
def self.details
|
23
29
|
<<~DETAILS
|
24
|
-
Merge multiple `.strings` files into one.
|
30
|
+
Merge multiple `.strings` files into another one.
|
25
31
|
|
26
|
-
Especially useful to prepare a single `.strings` file merging
|
27
|
-
|
28
|
-
|
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`.
|
30
35
|
|
31
36
|
The action only supports merging files which are in the OpenStep (`"key" = "value";`) text format (which is
|
32
37
|
the most common format for `.strings` files, and the one generated by `genstrings`), but can handle the case
|
@@ -38,19 +43,18 @@ module Fastlane
|
|
38
43
|
def self.available_options
|
39
44
|
[
|
40
45
|
FastlaneCore::ConfigItem.new(
|
41
|
-
key: :
|
42
|
-
env_name: '
|
43
|
-
description: '
|
44
|
-
type:
|
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,
|
45
50
|
optional: false
|
46
51
|
),
|
47
52
|
FastlaneCore::ConfigItem.new(
|
48
53
|
key: :destination,
|
49
54
|
env_name: 'FL_IOS_MERGE_STRINGS_FILES_DESTINATION',
|
50
|
-
description: 'The path of the
|
55
|
+
description: 'The path of the `.strings` file to merge the other ones into',
|
51
56
|
type: String,
|
52
|
-
optional:
|
53
|
-
default_value: nil
|
57
|
+
optional: false
|
54
58
|
),
|
55
59
|
]
|
56
60
|
end
|
@@ -60,7 +64,7 @@ module Fastlane
|
|
60
64
|
end
|
61
65
|
|
62
66
|
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'
|
64
68
|
end
|
65
69
|
|
66
70
|
def self.authors
|
@@ -9,86 +9,99 @@ module Fastlane
|
|
9
9
|
module Helper
|
10
10
|
module Android
|
11
11
|
module LocalizeHelper
|
12
|
-
|
13
|
-
|
14
|
-
|
12
|
+
LIB_SOURCE_XML_ATTR = 'a8c-src-lib'.freeze
|
13
|
+
|
14
|
+
# Checks if `string_node` has the `content_override` flag set
|
15
|
+
def self.skip_string_by_tag?(string_node)
|
16
|
+
skip = string_node.attr('content_override') == 'true' unless string_node.attr('content_override').nil?
|
15
17
|
if skip
|
16
|
-
|
18
|
+
UI.message " - Skipping #{string_node.attr('name')} string"
|
17
19
|
return true
|
18
20
|
end
|
19
21
|
|
20
22
|
return false
|
21
23
|
end
|
22
24
|
|
23
|
-
# Checks if string_name is in the
|
24
|
-
def self.skip_string_by_exclusion_list(library, string_name)
|
25
|
-
return false
|
25
|
+
# Checks if `string_name` is in the exclusion list
|
26
|
+
def self.skip_string_by_exclusion_list?(library, string_name)
|
27
|
+
return false if library[:exclusions].nil?
|
26
28
|
|
27
29
|
skip = library[:exclusions].include?(string_name)
|
28
30
|
if skip
|
29
|
-
|
31
|
+
UI.message " - Skipping #{string_name} string"
|
30
32
|
return true
|
31
33
|
end
|
32
34
|
end
|
33
35
|
|
34
|
-
#
|
35
|
-
def self.
|
36
|
-
|
37
|
-
|
36
|
+
# Adds the appropriate XML attributes to an XML `<string>` node according to library configuration
|
37
|
+
def self.add_xml_attributes!(string_node, library)
|
38
|
+
if library[:add_ignore_attr] == true
|
39
|
+
existing_ignores = (string_node['tools:ignore'] || '').split(',')
|
40
|
+
existing_ignores.append('UnusedResources') unless existing_ignores.include?('UnusedResources')
|
41
|
+
string_node['tools:ignore'] = existing_ignores.join(',')
|
42
|
+
end
|
43
|
+
string_node[LIB_SOURCE_XML_ATTR] = library[:source_id] unless library[:source_id].nil?
|
44
|
+
end
|
45
|
+
|
46
|
+
# Merge a single `lib_string_node` XML node into the `main_strings_xml``
|
47
|
+
def self.merge_string_node(main_strings_xml, library, lib_string_node)
|
48
|
+
string_name = lib_string_node.attr('name')
|
49
|
+
string_content = lib_string_node.content
|
38
50
|
|
39
51
|
# Skip strings in the exclusions list
|
40
|
-
return :skipped if skip_string_by_exclusion_list(library, string_name)
|
52
|
+
return :skipped if skip_string_by_exclusion_list?(library, string_name)
|
41
53
|
|
42
54
|
# Search for the string in the main file
|
43
55
|
result = :added
|
44
|
-
|
45
|
-
if
|
56
|
+
main_strings_xml.xpath('//string').each do |main_string_node|
|
57
|
+
if main_string_node.attr('name') == string_name
|
46
58
|
# Skip if the string has the content_override tag
|
47
|
-
return :skipped if skip_string_by_tag(
|
59
|
+
return :skipped if skip_string_by_tag?(main_string_node)
|
48
60
|
|
49
61
|
# If nodes are equivalent, skip
|
50
|
-
return :found if
|
62
|
+
return :found if lib_string_node =~ main_string_node
|
51
63
|
|
52
64
|
# The string needs an update
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
break
|
65
|
+
if main_string_node.attr('tools:ignore').nil?
|
66
|
+
# No `tools:ignore` attribute; completely replace existing main string node with lib's one
|
67
|
+
add_xml_attributes!(lib_string_node, library)
|
68
|
+
main_string_node.replace lib_string_node
|
58
69
|
else
|
59
|
-
#
|
60
|
-
|
61
|
-
|
70
|
+
# Has the `tools:ignore` flag; update the content without touching the other existing attributes
|
71
|
+
add_xml_attributes!(main_string_node, library)
|
72
|
+
main_string_node.content = string_content
|
62
73
|
end
|
74
|
+
return :updated
|
63
75
|
end
|
64
76
|
end
|
65
77
|
|
66
78
|
# String not found, or removed because needing update and not in the exclusion list: add to the main file
|
67
|
-
|
79
|
+
add_xml_attributes!(lib_string_node, library)
|
80
|
+
main_strings_xml.xpath('//string').last().add_next_sibling("\n#{' ' * 4}#{lib_string_node.to_xml().strip}")
|
68
81
|
return result
|
69
82
|
end
|
70
83
|
|
71
|
-
# Verify a string
|
72
|
-
def self.verify_string(
|
73
|
-
string_name =
|
74
|
-
string_content =
|
84
|
+
# Verify a string node from a library has properly been merged into the main one
|
85
|
+
def self.verify_string(main_strings_xml, library, lib_string_node)
|
86
|
+
string_name = lib_string_node.attr('name')
|
87
|
+
string_content = lib_string_node.content
|
75
88
|
|
76
89
|
# Skip strings in the exclusions list
|
77
|
-
return if skip_string_by_exclusion_list(library, string_name)
|
90
|
+
return if skip_string_by_exclusion_list?(library, string_name)
|
78
91
|
|
79
92
|
# Search for the string in the main file
|
80
|
-
|
81
|
-
if
|
93
|
+
main_strings_xml.xpath('//string').each do |main_string_node|
|
94
|
+
if main_string_node.attr('name') == string_name
|
82
95
|
# Skip if the string has the content_override tag
|
83
|
-
return if skip_string_by_tag(
|
96
|
+
return if skip_string_by_tag?(main_string_node)
|
84
97
|
|
85
|
-
#
|
86
|
-
UI.user_error!("String #{string_name} [#{string_content}] has been updated in the main file but not in the library #{library[:library]}.") if
|
98
|
+
# Check if up-to-date
|
99
|
+
UI.user_error!("String #{string_name} [#{string_content}] has been updated in the main file but not in the library #{library[:library]}.") if main_string_node.content != string_content
|
87
100
|
return
|
88
101
|
end
|
89
102
|
end
|
90
103
|
|
91
|
-
# String not found and not in the exclusion list
|
104
|
+
# String not found and not in the exclusion list
|
92
105
|
UI.user_error!("String #{string_name} [#{string_content}] was found in library #{library[:library]} but not in the main file.")
|
93
106
|
end
|
94
107
|
|
@@ -98,27 +111,33 @@ module Fastlane
|
|
98
111
|
# @param [Hash] library Hash describing the library to merge. The Hash should contain the following keys:
|
99
112
|
# - `:library`: The human readable name of the library, used to display in console messages
|
100
113
|
# - `:strings_path`: The path to the strings.xml file of the library to merge into the main one
|
101
|
-
# - `:exclusions`: An array of strings keys to exclude during merge. Any of those keys from the
|
114
|
+
# - `:exclusions`: An array of strings keys to exclude during merge. Any of those keys from the
|
115
|
+
# library's `strings.xml` will be skipped and won't be merged into the main one.
|
116
|
+
# - `:source_id`: An optional `String` which will be added as the `a8c-src-lib` XML attribute
|
117
|
+
# to strings coming from this library, to help identify their source in the merged file.
|
118
|
+
# - `:add_ignore_attr`: If set to `true`, will add `tools:ignore="UnusedResources"` to merged strings.
|
119
|
+
#
|
102
120
|
# @return [Boolean] True if at least one string from the library has been added to (or has updated) the main strings file.
|
121
|
+
#
|
103
122
|
def self.merge_lib(main, library)
|
104
123
|
UI.message("Merging #{library[:library]} strings into #{main}")
|
105
|
-
|
106
|
-
|
124
|
+
main_strings_xml = File.open(main) { |f| Nokogiri::XML(f, nil, Encoding::UTF_8.to_s) }
|
125
|
+
lib_strings_xml = File.open(library[:strings_path]) { |f| Nokogiri::XML(f, nil, Encoding::UTF_8.to_s) }
|
107
126
|
|
108
127
|
updated_count = 0
|
109
128
|
untouched_count = 0
|
110
129
|
added_count = 0
|
111
130
|
skipped_count = 0
|
112
|
-
|
113
|
-
res =
|
131
|
+
lib_strings_xml.xpath('//string').each do |string_node|
|
132
|
+
res = merge_string_node(main_strings_xml, library, string_node)
|
114
133
|
case res
|
115
134
|
when :updated
|
116
|
-
|
135
|
+
UI.verbose "#{string_node.attr('name')} updated."
|
117
136
|
updated_count = updated_count + 1
|
118
137
|
when :found
|
119
138
|
untouched_count = untouched_count + 1
|
120
139
|
when :added
|
121
|
-
|
140
|
+
UI.verbose "#{string_node.attr('name')} added."
|
122
141
|
added_count = added_count + 1
|
123
142
|
when :skipped
|
124
143
|
skipped_count = skipped_count + 1
|
@@ -128,7 +147,7 @@ module Fastlane
|
|
128
147
|
end
|
129
148
|
|
130
149
|
File.open(main, 'w:UTF-8') do |f|
|
131
|
-
f.write(
|
150
|
+
f.write(main_strings_xml.to_xml(indent: 4))
|
132
151
|
end
|
133
152
|
|
134
153
|
UI.message("Done (#{added_count} added, #{updated_count} updated, #{untouched_count} untouched, #{skipped_count} skipped).")
|
@@ -144,8 +163,8 @@ module Fastlane
|
|
144
163
|
|
145
164
|
diff_string = diff_string.slice(0..(end_index - 1))
|
146
165
|
|
147
|
-
lib_strings.xpath('//string').each do |
|
148
|
-
res = verify_string(main_strings, library,
|
166
|
+
lib_strings.xpath('//string').each do |string_node|
|
167
|
+
res = verify_string(main_strings, library, string_node) if string_node.attr('name') == diff_string
|
149
168
|
end
|
150
169
|
end
|
151
170
|
end
|
@@ -8,14 +8,11 @@ module Fastlane
|
|
8
8
|
# Fallback default branch of the client repository.
|
9
9
|
DEFAULT_GIT_BRANCH = 'trunk'.freeze
|
10
10
|
|
11
|
-
# Checks if the given path, or current directory if no path is given, is
|
12
|
-
# inside a Git repository
|
11
|
+
# Checks if the given path, or current directory if no path is given, is inside a Git repository
|
13
12
|
#
|
14
|
-
# @param [String] path An optional path where to check if a Git repo
|
15
|
-
# exists.
|
13
|
+
# @param [String] path An optional path where to check if a Git repo exists.
|
16
14
|
#
|
17
|
-
# @return [Bool] True if the current directory is the root of a git repo
|
18
|
-
# (i.e. a local working copy) or a subdirectory of one.
|
15
|
+
# @return [Bool] True if the current directory is the root of a git repo (i.e. a local working copy) or a subdirectory of one.
|
19
16
|
#
|
20
17
|
def self.is_git_repo?(path: Dir.pwd)
|
21
18
|
# If the path doesn't exist, find its first ancestor.
|
@@ -23,23 +20,19 @@ module Fastlane
|
|
23
20
|
# Get the path's directory, so we can look in it for the Git folder
|
24
21
|
dir = path.directory? ? path : path.dirname
|
25
22
|
|
26
|
-
# Recursively look for the Git folder until it's found or we read the
|
27
|
-
# the file system root
|
23
|
+
# Recursively look for the Git folder until it's found or we read the the file system root
|
28
24
|
dir = dir.parent until Dir.entries(dir).include?('.git') || dir.root?
|
29
25
|
|
30
|
-
# If we reached the root, we haven't found a repo.
|
31
|
-
# could be a repo in the root of the system, but that's a usecase that
|
32
|
-
# we don't need to support at this time)
|
26
|
+
# If we reached the root, we haven't found a repo.
|
27
|
+
# (Technically, there could be a repo in the root of the system, but that's a usecase that we don't need to support at this time)
|
33
28
|
return dir.root? == false
|
34
29
|
end
|
35
30
|
|
36
|
-
# Travels back the hierarchy of the given path until it finds an existing
|
37
|
-
# ancestor, or it reaches the root of the file system.
|
31
|
+
# Travels back the hierarchy of the given path until it finds an existing ancestor, or it reaches the root of the file system.
|
38
32
|
#
|
39
33
|
# @param [String] path The path to inspect
|
40
34
|
#
|
41
|
-
# @return [Pathname] The first existing ancestor, or `path` itself if it
|
42
|
-
# exists
|
35
|
+
# @return [Pathname] The first existing ancestor, or `path` itself if it exists
|
43
36
|
#
|
44
37
|
def self.first_existing_ancestor_of(path:)
|
45
38
|
p = Pathname(path).expand_path
|
@@ -106,7 +99,7 @@ module Fastlane
|
|
106
99
|
#
|
107
100
|
# @param [String] message The commit message to use
|
108
101
|
# @param [String|Array<String>] files A file or array of files to git-add before creating the commit.
|
109
|
-
#
|
102
|
+
# Use `nil` or `[]` if you already added the files in a separate step and don't wan't this method to add any new file before commit.
|
110
103
|
# Also accepts the special symbol `:all` to add all the files (`git commit -a -m …`).
|
111
104
|
# @param [Bool] push If true, will `git push` to `origin` after the commit has been created. Defaults to `false`.
|
112
105
|
#
|
@@ -209,8 +202,7 @@ module Fastlane
|
|
209
202
|
UI.user_error!("This command works only on #{branch_name} branch") unless current_branch_name.include?(branch_name)
|
210
203
|
end
|
211
204
|
|
212
|
-
# Checks whether a given path is ignored by Git, relying on Git's
|
213
|
-
# `check-ignore` under the hood.
|
205
|
+
# Checks whether a given path is ignored by Git, relying on Git's `check-ignore` under the hood.
|
214
206
|
#
|
215
207
|
# @param [String] path The path to check against `.gitignore`
|
216
208
|
#
|
@@ -19,6 +19,8 @@ module Fastlane
|
|
19
19
|
# - `nil` if the file does not exist or is neither of those format (e.g. not a `.strings` file at all)
|
20
20
|
#
|
21
21
|
def self.strings_file_type(path:)
|
22
|
+
return :text if File.empty?(path) # If completely empty file, consider it as a valid `.strings` files in textual format
|
23
|
+
|
22
24
|
# Start by checking it seems like a valid property-list file (and not e.g. an image or plain text file)
|
23
25
|
_, status = Open3.capture2('/usr/bin/plutil', '-lint', path)
|
24
26
|
return nil unless status.success?
|
@@ -36,8 +38,8 @@ module Fastlane
|
|
36
38
|
|
37
39
|
# Merge the content of multiple `.strings` files into a new `.strings` text file.
|
38
40
|
#
|
39
|
-
# @param [
|
40
|
-
# @param [String]
|
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.
|
41
43
|
# @return [Array<String>] List of duplicate keys found while validating the merge.
|
42
44
|
#
|
43
45
|
# @note For now, this method only supports merging `.strings` file in `:text` format
|
@@ -48,24 +50,35 @@ module Fastlane
|
|
48
50
|
#
|
49
51
|
# @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.
|
50
52
|
#
|
51
|
-
def self.merge_strings(paths:, output_path:
|
53
|
+
def self.merge_strings(paths:, output_path:)
|
52
54
|
duplicates = []
|
53
55
|
Tempfile.create('wpmrt-l10n-merge-', encoding: 'utf-8') do |tmp_file|
|
54
56
|
all_keys_found = []
|
55
57
|
|
56
58
|
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.empty?(input_file) # Skip existing but totally empty files, to avoid adding useless `MARK:` comment for them
|
61
|
+
|
58
62
|
fmt = strings_file_type(path: input_file)
|
59
63
|
raise "The file `#{input_file}` does not exist or is of unknown format." if fmt.nil?
|
60
64
|
raise "The file `#{input_file}` is in #{fmt} format but we currently only support merging `.strings` files in text format." unless fmt == :text
|
61
65
|
|
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}" }
|
63
67
|
duplicates += (string_keys & all_keys_found) # Find duplicates using Array intersection, and add those to duplicates list
|
64
68
|
all_keys_found += string_keys
|
65
69
|
|
66
70
|
tmp_file.write("/* MARK: - #{File.basename(input_file)} */\n\n")
|
67
71
|
# 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
|
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
|
69
82
|
tmp_file.write("\n")
|
70
83
|
end
|
71
84
|
tmp_file.close # ensure we flush the content to disk
|
@@ -81,6 +94,8 @@ module Fastlane
|
|
81
94
|
# @raise [RuntimeError] If the file is not a valid strings file or there was an error in parsing its content.
|
82
95
|
#
|
83
96
|
def self.read_strings_file_as_hash(path:)
|
97
|
+
return {} if File.empty?(path) # Return empty hash if completely empty file
|
98
|
+
|
84
99
|
output, status = Open3.capture2e('/usr/bin/plutil', '-convert', 'json', '-o', '-', path)
|
85
100
|
raise output unless status.success?
|
86
101
|
|
@@ -108,7 +123,7 @@ module Fastlane
|
|
108
123
|
xml.comment('Warning: Auto-generated file, do not edit.')
|
109
124
|
xml.plist(version: '1.0') do
|
110
125
|
xml.dict do
|
111
|
-
translations.each do |k, v|
|
126
|
+
translations.sort.each do |k, v| # NOTE: use `sort` just in order to be deterministic over various runs
|
112
127
|
xml.key(k.to_s)
|
113
128
|
xml.string(v.to_s)
|
114
129
|
end
|
@@ -58,8 +58,7 @@ module Fastlane
|
|
58
58
|
#
|
59
59
|
# @param [String] input_dir The path (ideally absolute) to the directory containing the `.lproj` folders to parse
|
60
60
|
# @param [String] base_lang The code name (i.e the basename of one of the `.lproj` folders) of the locale to use as the baseline
|
61
|
-
# @return [Hash<String, String
|
62
|
-
# and the values are the output of the `diff` showing these violations.
|
61
|
+
# @return [Hash<String, Array<String>>] A hash of violations, keyed by language code, whose values are the list of violation messages for that language
|
63
62
|
#
|
64
63
|
def run(input_dir:, base_lang: DEFAULT_BASE_LANG, only_langs: nil)
|
65
64
|
check_swiftgen_installed || install_swiftgen!
|
@@ -87,7 +86,7 @@ module Fastlane
|
|
87
86
|
<<~TEMPLATE
|
88
87
|
{% macro recursiveBlock table item %}
|
89
88
|
{% for string in item.strings %}
|
90
|
-
|
89
|
+
{{string.key}} ==> [{{string.types|join:","}}]
|
91
90
|
{% endfor %}
|
92
91
|
{% for child in item.children %}
|
93
92
|
{% call recursiveBlock table child %}
|
@@ -139,20 +138,19 @@ module Fastlane
|
|
139
138
|
return [config_file, langs]
|
140
139
|
end
|
141
140
|
|
142
|
-
#
|
143
|
-
# We need to sort the output again because SwiftGen only sort case-insensitively so that means keys that are
|
144
|
-
# the same except case might be in swapped order for different outputs
|
141
|
+
# Returns a Hash mapping the list of expected parameter types for each of the keys based in the %… placeholders found in their `.strings` files
|
145
142
|
#
|
146
143
|
# @param [String] dir The temporary directory in which the file to sort lines for is located
|
147
144
|
# @param [String] lang The code for the locale we need to sort the output lines for
|
145
|
+
# @return [Hash<String, String>] A hash whose keys are the strings keys, and corresponding value is a String describing the types expected as parameters.
|
148
146
|
#
|
149
|
-
def
|
147
|
+
def placeholder_types_for_keys(dir, lang)
|
150
148
|
file = File.join(dir, output_filename(lang))
|
151
149
|
return nil unless File.exist?(file)
|
152
150
|
|
153
|
-
|
154
|
-
|
155
|
-
|
151
|
+
File.readlines(file).map do |line|
|
152
|
+
line.match(/^(.*) ==> (\[[A-Za-z,]*\])$/)&.captures
|
153
|
+
end.compact.to_h
|
156
154
|
end
|
157
155
|
|
158
156
|
# Prepares the template and config files, then run SwiftGen, run `diff` on each generated output against the baseline, and returns a Hash of the violations found.
|
@@ -160,7 +158,7 @@ module Fastlane
|
|
160
158
|
# @param [String] input_dir The directory where the `.lproj` folders to scan are located
|
161
159
|
# @param [String] base_lang The base language used as source of truth that all other languages will be compared against
|
162
160
|
# @param [Array<String>] only_langs The list of languages to limit the generation for. Useful to focus only on a couple of issues or just one language
|
163
|
-
# @return [Hash<String, String
|
161
|
+
# @return [Hash<String, Array<String>>] A hash of violations, keyed by language code, whose values are the list of violation messages for that language
|
164
162
|
#
|
165
163
|
# @note The returned Hash contains keys only for locales with violations. Locales parsed but without any violations found will not appear in the resulting hash.
|
166
164
|
#
|
@@ -172,34 +170,22 @@ module Fastlane
|
|
172
170
|
Action.sh(swiftgen_bin, 'config', 'run', '--config', config_file)
|
173
171
|
|
174
172
|
# Run diffs
|
175
|
-
|
173
|
+
params_for_base_lang = placeholder_types_for_keys(tmpdir, base_lang)
|
176
174
|
langs.delete(base_lang)
|
177
175
|
return langs.map do |lang|
|
178
|
-
|
176
|
+
params_for_lang = placeholder_types_for_keys(tmpdir, lang)
|
177
|
+
|
179
178
|
# If the lang ends up not having any translation at all (e.g. a `.lproj` without any `.strings` file in it but maybe just a storyboard or assets catalog), ignore it
|
180
|
-
next nil if
|
181
|
-
|
182
|
-
# Compute the diff
|
183
|
-
diff = `diff -U0 "#{base_file}" "#{file}"`
|
184
|
-
# Remove the lines starting with `---`/`+++` which contains the file names (which are temp files we don't want to expose in the final diff to users)
|
185
|
-
# Note: We still keep the `@@ from-file-line-numbers to-file-line-numbers @@` lines to help the user know the index of the key to find it faster,
|
186
|
-
# and also to serve as a clear visual separator between diff entries in the output.
|
187
|
-
# Those numbers won't be matching the original `.strings` file line numbers because they are line numbers in the SwiftGen-generated intermediate
|
188
|
-
# file instead, but they can still give an indication at the index in the list of keys at which this difference is located.
|
189
|
-
diff.gsub!(/^(---|\+\+\+).*\n/, '')
|
190
|
-
diff.empty? ? nil : [lang, diff]
|
191
|
-
end.compact.to_h
|
192
|
-
end
|
193
|
-
end
|
179
|
+
next nil if params_for_lang.nil? || params_for_lang.empty?
|
194
180
|
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
181
|
+
violations = params_for_lang.map do |key, param_types|
|
182
|
+
next "`#{key}` was unexpected, as it is not present in the base locale." if params_for_base_lang[key].nil?
|
183
|
+
next "`#{key}` expected placeholders for #{params_for_base_lang[key]} but found #{param_types} instead." if params_for_base_lang[key] != param_types
|
184
|
+
end.compact
|
185
|
+
|
186
|
+
[lang, violations] unless violations.empty?
|
187
|
+
end.compact.to_h
|
201
188
|
end
|
202
|
-
return true
|
203
189
|
end
|
204
190
|
end
|
205
191
|
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
module Fastlane
|
2
|
+
module Helper
|
3
|
+
module Ios
|
4
|
+
class StringsFileValidationHelper
|
5
|
+
# context can be one of:
|
6
|
+
# :root, :maybe_comment_start, :in_line_comment, :in_block_comment,
|
7
|
+
# :maybe_block_comment_end, :in_quoted_key,
|
8
|
+
# :after_quoted_key_before_eq, :after_quoted_key_and_equal,
|
9
|
+
# :in_quoted_value, :after_quoted_value
|
10
|
+
State = Struct.new(:context, :buffer, :in_escaped_ctx, :found_key, keyword_init: true)
|
11
|
+
|
12
|
+
TRANSITIONS = {
|
13
|
+
root: {
|
14
|
+
/\s/ => :root,
|
15
|
+
'/' => :maybe_comment_start,
|
16
|
+
'"' => :in_quoted_key
|
17
|
+
},
|
18
|
+
maybe_comment_start: {
|
19
|
+
'/' => :in_line_comment,
|
20
|
+
/\*/ => :in_block_comment
|
21
|
+
},
|
22
|
+
in_line_comment: {
|
23
|
+
"\n" => :root,
|
24
|
+
/./ => :in_line_comment
|
25
|
+
},
|
26
|
+
in_block_comment: {
|
27
|
+
/\*/ => :maybe_block_comment_end,
|
28
|
+
/./m => :in_block_comment
|
29
|
+
},
|
30
|
+
maybe_block_comment_end: {
|
31
|
+
'/' => :root,
|
32
|
+
/./m => :in_block_comment
|
33
|
+
},
|
34
|
+
in_quoted_key: {
|
35
|
+
'"' => lambda do |state, _|
|
36
|
+
state.found_key = state.buffer.string.dup
|
37
|
+
state.buffer.string = ''
|
38
|
+
:after_quoted_key_before_eq
|
39
|
+
end,
|
40
|
+
/./ => lambda do |state, c|
|
41
|
+
state.buffer.write(c)
|
42
|
+
:in_quoted_key
|
43
|
+
end
|
44
|
+
},
|
45
|
+
after_quoted_key_before_eq: {
|
46
|
+
/\s/ => :after_quoted_key_before_eq,
|
47
|
+
'=' => :after_quoted_key_and_eq
|
48
|
+
},
|
49
|
+
after_quoted_key_and_eq: {
|
50
|
+
/\s/ => :after_quoted_key_and_eq,
|
51
|
+
'"' => :in_quoted_value
|
52
|
+
},
|
53
|
+
in_quoted_value: {
|
54
|
+
'"' => :after_quoted_value,
|
55
|
+
/./m => :in_quoted_value
|
56
|
+
},
|
57
|
+
after_quoted_value: {
|
58
|
+
/\s/ => :after_quoted_value,
|
59
|
+
';' => :root
|
60
|
+
}
|
61
|
+
}.freeze
|
62
|
+
|
63
|
+
# Inspects the given `.strings` file for duplicated keys, returning them if any.
|
64
|
+
#
|
65
|
+
# @param [String] file The path to the file to inspect.
|
66
|
+
# @return [Hash<String, Array<Int>] Hash with the dublipcated keys.
|
67
|
+
# Each element has the duplicated key (from the `.strings`) as key and an array of line numbers where the key occurs as value.
|
68
|
+
def self.find_duplicated_keys(file:)
|
69
|
+
keys_with_lines = Hash.new([])
|
70
|
+
|
71
|
+
state = State.new(context: :root, buffer: StringIO.new, in_escaped_ctx: false, found_key: nil)
|
72
|
+
|
73
|
+
File.readlines(file).each_with_index do |line, line_no|
|
74
|
+
line.chars.each_with_index do |c, col_no|
|
75
|
+
# Handle escaped characters at a global level.
|
76
|
+
# This is more straightforward than having to account for it in the `TRANSITIONS` table.
|
77
|
+
if state.in_escaped_ctx || c == '\\'
|
78
|
+
# Just because we check for escaped characters at the global level, it doesn't mean we allow them in every context.
|
79
|
+
allowed_contexts_for_escaped_characters = %i[in_quoted_key in_quoted_value in_block_comment in_line_comment]
|
80
|
+
raise "Found escaped character outside of allowed contexts on line #{line_no + 1} (current context: #{state.context})" unless allowed_contexts_for_escaped_characters.include?(state.context)
|
81
|
+
|
82
|
+
state.buffer.write(c) if state.context == :in_quoted_key
|
83
|
+
state.in_escaped_ctx = !state.in_escaped_ctx
|
84
|
+
next
|
85
|
+
end
|
86
|
+
|
87
|
+
# Look at the transitions table for the current context, and find the first transition matching the current character
|
88
|
+
(_, next_context) = TRANSITIONS[state.context].find { |regex, _| c.match?(regex) } || [nil, nil]
|
89
|
+
raise "Invalid character `#{c}` found on line #{line_no + 1}, col #{col_no + 1}" if next_context.nil?
|
90
|
+
|
91
|
+
state.context = next_context.is_a?(Proc) ? next_context.call(state, c) : next_context
|
92
|
+
next unless state.found_key
|
93
|
+
|
94
|
+
# If we just exited the :in_quoted_key context and thus have found a new key, process it
|
95
|
+
key = state.found_key.dup
|
96
|
+
state.found_key = nil
|
97
|
+
|
98
|
+
keys_with_lines[key] += [line_no + 1]
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
keys_with_lines.keep_if { |_, lines| lines.count > 1 }
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -12,8 +12,7 @@ module Fastlane
|
|
12
12
|
@alternates = {}
|
13
13
|
end
|
14
14
|
|
15
|
-
# Downloads data from GlotPress,
|
16
|
-
# in JSON format
|
15
|
+
# Downloads data from GlotPress, in JSON format
|
17
16
|
def download(target_locale, glotpress_url, is_source)
|
18
17
|
uri = URI(glotpress_url)
|
19
18
|
response = Net::HTTP.get_response(uri)
|
@@ -80,8 +79,7 @@ module Fastlane
|
|
80
79
|
end
|
81
80
|
end
|
82
81
|
|
83
|
-
# Writes the downloaded content
|
84
|
-
# to the target file
|
82
|
+
# Writes the downloaded content to the target file
|
85
83
|
def save_metadata(locale, file_name, content)
|
86
84
|
file_path = get_target_file_path(locale, file_name)
|
87
85
|
|
@@ -117,8 +117,7 @@ module Fastlane
|
|
117
117
|
end
|
118
118
|
|
119
119
|
def draw_screenshot_to_canvas(entry, canvas, device)
|
120
|
-
# Don't require a screenshot to be present – we can just skip
|
121
|
-
# this function if one doesn't exist.
|
120
|
+
# Don't require a screenshot to be present – we can just skip this function if one doesn't exist.
|
122
121
|
return canvas if entry['screenshot'].nil?
|
123
122
|
|
124
123
|
device_mask = device['screenshot_mask']
|
@@ -9,9 +9,11 @@ module Fastlane
|
|
9
9
|
def self.add_new_section(path:, section_title:)
|
10
10
|
lines = File.readlines(path)
|
11
11
|
|
12
|
-
# Find the index of the first non-empty line that is also NOT a comment.
|
12
|
+
# Find the index of the first non-empty line that is also NOT a comment.
|
13
|
+
# That way we keep commment headers as the very top of the file
|
13
14
|
line_idx = lines.find_index { |l| !l.start_with?('***') && !l.start_with?('//') && !l.chomp.empty? }
|
14
|
-
# Put back the header, then the new entry, then the rest
|
15
|
+
# Put back the header, then the new entry, then the rest
|
16
|
+
# (note: '...' excludes the higher bound of the range, unlike '..')
|
15
17
|
new_lines = lines[0...line_idx] + ["#{section_title}\n", "-----\n", "\n", "\n"] + lines[line_idx..]
|
16
18
|
|
17
19
|
File.write(path, new_lines.join)
|
@@ -40,11 +40,9 @@ module Fastlane
|
|
40
40
|
end
|
41
41
|
|
42
42
|
# "Applies" the instruction described in the instance to the file system.
|
43
|
-
# That is, copies the content of the source `file` to the `destination`
|
44
|
-
# path.
|
43
|
+
# That is, copies the content of the source `file` to the `destination` path.
|
45
44
|
#
|
46
|
-
# @raise [StandardError] For security reasons, it will raise if
|
47
|
-
# `destination` is not ignored under Git.
|
45
|
+
# @raise [StandardError] For security reasons, it will raise if `destination` is not ignored under Git.
|
48
46
|
def apply
|
49
47
|
# Only decrypt the file if the destination is ignored in Git
|
50
48
|
unless Fastlane::Helper::GitHelper.is_ignored?(path: destination_file_path)
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: fastlane-plugin-wpmreleasetoolkit
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 4.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Lorenzo Mattei
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2022-
|
11
|
+
date: 2022-04-21 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: diffy
|
@@ -58,14 +58,14 @@ dependencies:
|
|
58
58
|
requirements:
|
59
59
|
- - "~>"
|
60
60
|
- !ruby/object:Gem::Version
|
61
|
-
version: '1.
|
61
|
+
version: '1.5'
|
62
62
|
type: :runtime
|
63
63
|
prerelease: false
|
64
64
|
version_requirements: !ruby/object:Gem::Requirement
|
65
65
|
requirements:
|
66
66
|
- - "~>"
|
67
67
|
- !ruby/object:Gem::Version
|
68
|
-
version: '1.
|
68
|
+
version: '1.5'
|
69
69
|
- !ruby/object:Gem::Dependency
|
70
70
|
name: git
|
71
71
|
requirement: !ruby/object:Gem::Requirement
|
@@ -401,6 +401,7 @@ files:
|
|
401
401
|
- lib/fastlane/plugin/wpmreleasetoolkit/actions/common/removebranchprotection_action.rb
|
402
402
|
- lib/fastlane/plugin/wpmreleasetoolkit/actions/common/setbranchprotection_action.rb
|
403
403
|
- lib/fastlane/plugin/wpmreleasetoolkit/actions/common/setfrozentag_action.rb
|
404
|
+
- lib/fastlane/plugin/wpmreleasetoolkit/actions/common/upload_to_s3.rb
|
404
405
|
- lib/fastlane/plugin/wpmreleasetoolkit/actions/configure/configure_add_files_to_copy_action.rb
|
405
406
|
- lib/fastlane/plugin/wpmreleasetoolkit/actions/configure/configure_apply_action.rb
|
406
407
|
- lib/fastlane/plugin/wpmreleasetoolkit/actions/configure/configure_download_action.rb
|
@@ -421,6 +422,7 @@ files:
|
|
421
422
|
- lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_completecodefreeze_prechecks.rb
|
422
423
|
- lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_current_branch_is_hotfix.rb
|
423
424
|
- lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_download_strings_files_from_glotpress.rb
|
425
|
+
- lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_extract_keys_from_strings_files.rb
|
424
426
|
- lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_final_tag.rb
|
425
427
|
- lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_finalize_prechecks.rb
|
426
428
|
- lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_generate_strings_file_from_code.rb
|
@@ -452,6 +454,7 @@ files:
|
|
452
454
|
- lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_git_helper.rb
|
453
455
|
- lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_l10n_helper.rb
|
454
456
|
- lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_l10n_linter_helper.rb
|
457
|
+
- lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_strings_file_validation_helper.rb
|
455
458
|
- lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_version_helper.rb
|
456
459
|
- lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata_download_helper.rb
|
457
460
|
- lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata_update_helper.rb
|