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.
Files changed (21) hide show
  1. checksums.yaml +4 -4
  2. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/an_localize_libs_action.rb +9 -4
  3. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/upload_to_s3.rb +112 -0
  4. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/configure/configure_download_action.rb +1 -2
  5. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/configure/configure_validate_action.rb +2 -2
  6. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_download_strings_files_from_glotpress.rb +1 -0
  7. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_extract_keys_from_strings_files.rb +136 -0
  8. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_generate_strings_file_from_code.rb +1 -1
  9. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_lint_localizations.rb +7 -7
  10. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_merge_strings_files.rb +19 -15
  11. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/android/android_localize_helper.rb +66 -47
  12. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/git_helper.rb +10 -18
  13. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_l10n_helper.rb +22 -7
  14. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_l10n_linter_helper.rb +20 -34
  15. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_strings_file_validation_helper.rb +107 -0
  16. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata_download_helper.rb +2 -4
  17. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/promo_screenshots_helper.rb +1 -2
  18. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/release_notes_helper.rb +4 -2
  19. data/lib/fastlane/plugin/wpmreleasetoolkit/models/file_reference.rb +2 -4
  20. data/lib/fastlane/plugin/wpmreleasetoolkit/version.rb +1 -1
  21. metadata +7 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d2f92b2b90c9f077ad4ea0ae25c5c84f0345ca2fb5e186d1c0cdfab70ea40770
4
- data.tar.gz: 0c919f8e9ca2af18c3488898efe2fe48f4ca5a3a4c0464991e000681cc6c9546
3
+ metadata.gz: ee81192aaa403dc7878821f60017e13f9867c549ba84ba2912d1e3ae0623b7b8
4
+ data.tar.gz: a702bfd98e9134e8d3f4303a1bad07705e6b27c107ead1295f5583a78b40384e
5
5
  SHA512:
6
- metadata.gz: f0c0797c28da54495d995d856edb238631b70bf0126ae1516638a75e300717dd51a7f97f6fedd5cc3f9e7eef4e9cf328043c9fffcd34233a057338da8055307d
7
- data.tar.gz: 17c8536dc94ab2ea0f3b394d3d9768c248683ba78fa4558d7c10ed1cc7fe81fc0aebd1390691282db601297b6f48e67af2dbd546daa3b0a13d16ffacd8c34f4c
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: 'The list of libs to merge. ' \
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 – that's
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
@@ -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
@@ -36,7 +36,7 @@ module Fastlane
36
36
  #####################################################
37
37
 
38
38
  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'
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
- violations = helper.run(
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
- violations.each do |lang, diff|
32
- UI.error "Inconsistencies found between '#{params[:base_lang]}' and '#{lang}':\n\n#{diff}\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
- violations
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
- :hash_of_strings
151
+ :hash
152
152
  end
153
153
 
154
154
  def self.return_value
155
- 'A hash, keyed by language code, whose values are the diff found for said language'
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
- ['AliSoftware']
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
- 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])
6
8
 
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])
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 strings from both `Localizable.strings` from
27
- the app codetypically 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`.
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: :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,
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 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',
51
56
  type: String,
52
- optional: true,
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
- # Checks if string_line has the content_override flag set
13
- def self.skip_string_by_tag(string_line)
14
- skip = string_line.attr('content_override') == 'true' unless string_line.attr('content_override').nil?
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
- puts " - Skipping #{string_line.attr('name')} string"
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 excluesion list
24
- def self.skip_string_by_exclusion_list(library, string_name)
25
- return false unless library.key?(:exclusions)
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
- puts " - Skipping #{string_name} string"
31
+ UI.message " - Skipping #{string_name} string"
30
32
  return true
31
33
  end
32
34
  end
33
35
 
34
- # Merge string_line into main_string
35
- def self.merge_string(main_strings, library, string_line)
36
- string_name = string_line.attr('name')
37
- string_content = string_line.content
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
- main_strings.xpath('//string').each do |this_string|
45
- if this_string.attr('name') == string_name
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(this_string)
59
+ return :skipped if skip_string_by_tag?(main_string_node)
48
60
 
49
61
  # If nodes are equivalent, skip
50
- return :found if string_line =~ this_string
62
+ return :found if lib_string_node =~ main_string_node
51
63
 
52
64
  # The string needs an update
53
- result = :updated
54
- if this_string.attr('tools:ignore').nil?
55
- # It can be updated, so remove the current one and move ahead
56
- this_string.remove
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
- # It has the tools:ignore flag, so update the content without touching the other attributes
60
- this_string.content = string_content
61
- return result
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
- main_strings.xpath('//string').last().add_next_sibling("\n#{' ' * 4}#{string_line.to_xml().strip}")
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(main_strings, library, string_line)
73
- string_name = string_line.attr('name')
74
- string_content = string_line.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
- main_strings.xpath('//string').each do |this_string|
81
- if this_string.attr('name') == string_name
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(this_string)
96
+ return if skip_string_by_tag?(main_string_node)
84
97
 
85
- # Update if needed
86
- UI.user_error!("String #{string_name} [#{string_content}] has been updated in the main file but not in the library #{library[:library]}.") if this_string.content != string_content
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 library's `strings.xml` will be skipped and won't be merged into the main one.
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
- main_strings = File.open(main) { |f| Nokogiri::XML(f, nil, Encoding::UTF_8.to_s) }
106
- lib_strings = File.open(library[:strings_path]) { |f| Nokogiri::XML(f, nil, Encoding::UTF_8.to_s) }
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
- lib_strings.xpath('//string').each do |string_line|
113
- res = merge_string(main_strings, library, string_line)
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
- puts "#{string_line.attr('name')} updated."
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
- puts "#{string_line.attr('name')} added."
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(main_strings.to_xml(indent: 4))
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 |string_line|
148
- res = verify_string(main_strings, library, string_line) if string_line.attr('name') == diff_string
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. (Technically, there
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
- # 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.
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 [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.
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: nil)
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 { |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
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>] A hash whose keys are the language codes (basename of `.lproj` folders) for which violations were found,
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
- "{{string.key}}" => [{{string.types|join:","}}]
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
- # Because we use English copy verbatim as key names, some keys are the same except for the upper/lowercase.
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 sort_file_lines!(dir, lang)
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
- sorted_lines = File.readlines(file).sort
154
- File.write(file, sorted_lines.join)
155
- return file
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>] A hash of violations, keyed by language code, whose values are the diff output.
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
- base_file = sort_file_lines!(tmpdir, base_lang)
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
- file = sort_file_lines!(tmpdir, lang)
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 file.nil? || only_empty_lines?(file)
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
- # Returns true if the file only contains empty lines, i.e. lines that only contains whitespace (space, tab, CR, LF)
196
- def only_empty_lines?(file)
197
- File.open(file) do |f|
198
- while (line = f.gets)
199
- return false unless line.strip.empty?
200
- end
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. That way we keep commment headers as the very top of the file
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 (note: '...' excludes the higher bound of the range, unlike '..')
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)
@@ -1,5 +1,5 @@
1
1
  module Fastlane
2
2
  module Wpmreleasetoolkit
3
- VERSION = '3.0.0'
3
+ VERSION = '4.1.0'
4
4
  end
5
5
  end
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: 3.0.0
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-02-09 00:00:00.000000000 Z
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.4'
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.4'
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