fastlane-plugin-wpmreleasetoolkit 3.1.0 → 4.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (23) 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/android/{android_hotifx_prechecks.rb → android_hotfix_prechecks.rb} +0 -0
  4. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_update_metadata_source.rb +0 -1
  5. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/configure/configure_download_action.rb +1 -2
  6. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/configure/configure_validate_action.rb +2 -2
  7. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_download_strings_files_from_glotpress.rb +1 -0
  8. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_extract_keys_from_strings_files.rb +35 -17
  9. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/{ios_hotifx_prechecks.rb → ios_hotfix_prechecks.rb} +0 -0
  10. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_lint_localizations.rb +48 -9
  11. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_merge_strings_files.rb +19 -15
  12. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/android/android_localize_helper.rb +72 -49
  13. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/git_helper.rb +10 -18
  14. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_l10n_helper.rb +28 -9
  15. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_l10n_linter_helper.rb +20 -34
  16. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_strings_file_validation_helper.rb +107 -0
  17. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata_download_helper.rb +2 -4
  18. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/promo_screenshots_helper.rb +2 -3
  19. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/release_notes_helper.rb +4 -2
  20. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/user_agent.rb +5 -0
  21. data/lib/fastlane/plugin/wpmreleasetoolkit/models/file_reference.rb +2 -4
  22. data/lib/fastlane/plugin/wpmreleasetoolkit/version.rb +1 -1
  23. metadata +8 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 87b7167d8eeecefac13e31850447419c88d3e2ceca851a89782df561846f4b7b
4
- data.tar.gz: 72b18c319cfbac6d39ea939c33cd801f1e777b79996ee617ea0649c0c3bfb420
3
+ metadata.gz: f87869bd0429895cc6f445950c7e6fca7d778021656483f1b0f4cc232487e2a1
4
+ data.tar.gz: e478fc6f8cd0b3b4baa9431502b7ee6f95e94ab3553bae728d7077e7c9e39adf
5
5
  SHA512:
6
- metadata.gz: 96c46a423eb4eaf0868bc5a6cecb6558a99dcf9f5f7b788bdc4255a2380aea4ed8adbd92d81476feaad00fc394182c70257aa140e7281920348b3ba8878bafba
7
- data.tar.gz: 5329bbb8827fd617af2d43dd1c770e2cf9a23e6adce430bf658f2ee68fe11cda3ac1e5d52f76ced293c28f23df3a9315a882cce14ad7f7918c370ed1be07b2e0
6
+ metadata.gz: c62ae7d95211059423e61feb22558e79a42da9587f76a1ed0c7c5319de2650a0936d1daeb8f7db9c1a359369bab098b52dc3ea188834e6551bd3f1b479414aab
7
+ data.tar.gz: ed0fc3ef8429091aeb9c83d9e1efeb4909234aa47b733e574546f7297b9c0c7913829889f687cfab55d139f294b00de0eb1f5b4e9064e21c78144697b6683230
@@ -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
  ]
@@ -1,4 +1,3 @@
1
- require 'fastlane/action'
2
1
  require_relative '../../helper/metadata_update_helper'
3
2
 
4
3
  module Fastlane
@@ -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)
@@ -3,8 +3,14 @@ module Fastlane
3
3
  class IosExtractKeysFromStringsFilesAction < Action
4
4
  def self.run(params)
5
5
  source_parent_dir = params[:source_parent_dir]
6
- target_original_files = params[:target_original_files]
7
- keys_to_extract_per_target_file = keys_list_per_target_file(target_original_files)
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 = []
8
14
 
9
15
  # For each locale, extract the right translations from `<source_tablename>.strings` into each target `.strings` file
10
16
  Dir.glob('*.lproj', base: source_parent_dir).each do |lproj_dir_name|
@@ -12,21 +18,25 @@ module Fastlane
12
18
  translations = Fastlane::Helper::Ios::L10nHelper.read_strings_file_as_hash(path: source_strings_file)
13
19
 
14
20
  target_original_files.each do |target_original_file|
15
- target_strings_file = File.join(File.dirname(File.dirname(target_original_file)), lproj_dir_name, File.basename(target_original_file))
21
+ target_strings_file = replace_lproj_in_path(target_original_file, with_lproj: lproj_dir_name)
16
22
  next if target_strings_file == target_original_file # do not generate/overwrite the original locale itself
17
23
 
18
- keys_to_extract = keys_to_extract_per_target_file[target_original_file]
19
- UI.message("Extracting #{keys_to_extract.count} keys into #{target_strings_file}...")
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}...")
20
28
 
21
- extracted_translations = translations.slice(*keys_to_extract)
22
29
  FileUtils.mkdir_p(File.dirname(target_strings_file)) # Ensure path up to parent dir exists, create it if not.
23
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)
24
32
  rescue StandardError => e
25
33
  UI.user_error!("Error while writing extracted translations to `#{target_strings_file}`: #{e.message}")
26
34
  end
27
35
  rescue StandardError => e
28
36
  UI.user_error!("Error while reading the translations from source file `#{source_strings_file}`: #{e.message}")
29
37
  end
38
+
39
+ updated_files_list
30
40
  end
31
41
 
32
42
  # Pre-load the list of keys to extract for each target file.
@@ -43,6 +53,15 @@ module Fastlane
43
53
  UI.user_error!("Failed to read the keys to extract from originals file: #{e.message}")
44
54
  end
45
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
+
46
65
  #####################################################
47
66
  # @!group Documentation
48
67
  #####################################################
@@ -82,28 +101,27 @@ module Fastlane
82
101
  default_value: 'Localizable'),
83
102
  FastlaneCore::ConfigItem.new(key: :target_original_files,
84
103
  env_name: 'FL_IOS_EXTRACT_KEYS_FROM_STRINGS_FILES_TARGET_ORIGINAL_FILES',
85
- description: 'The path(s) to the `<base-locale>.lproj/<target-tablename>.strings` file(s) for which we want to extract the keys to. ' \
86
- + 'Each of those files should containing the original strings (typically `en` or `Base` locale) and will be used to determine which keys to extract from the `source_tablename`. ' \
87
- + 'For each of those, the path(s) in which the translations will be extracted will be the files with the same basename in each of the other `*.lproj` sibling folders',
88
- type: Array,
104
+ description: 'The path(s) to the `<base-locale>.lproj/<target-tablename>.strings` file(s) for which we want to extract the keys to, and the prefix to remove from their keys. ' \
105
+ + 'Each key in the Hash should point to a file containing the original strings (typically `en` or `Base` locale), and will be used to determine which keys to extract from the `source_tablename`. ' \
106
+ + 'For each key, the associated value is an optional prefix to remove from the keys (which can be useful if you used a prefix during `ios_merge_strings_files` to avoid duplicates). Can be nil or empty if no prefix was used during merge for that file.' \
107
+ + 'Note: For each entry, the path(s) in which the translations will be extracted to will be the files with the same basename as the key in each of the other `*.lproj` sibling folders. ',
108
+ type: Hash,
89
109
  verify_block: proc do |values|
90
110
  UI.user_error!('`target_original_files` must contain at least one path to an original `.strings` file.') if values.empty?
91
- values.each do |v|
92
- UI.user_error!("Path `#{v}` (found in `target_original_files`) does not exist.") unless File.exist?(v)
93
- UI.user_error! "Expected `#{v}` (found in `target_original_files`) to be a path ending in a `*.lproj/*.strings`." unless File.extname(v) == '.strings' && File.extname(File.dirname(v)) == '.lproj'
111
+ values.each do |path, _|
112
+ UI.user_error!("Path `#{path}` (found in `target_original_files`) does not exist.") unless File.exist?(path)
113
+ UI.user_error! "Expected `#{path}` (found in `target_original_files`) to be a path ending in a `*.lproj/*.strings`." unless File.extname(path) == '.strings' && File.extname(File.dirname(path)) == '.lproj'
94
114
  end
95
115
  end),
96
116
  ]
97
117
  end
98
118
 
99
119
  def self.return_type
100
- # Describes what type of data is expected to be returned
101
- # see RETURN_TYPES in https://github.com/fastlane/fastlane/blob/master/fastlane/lib/fastlane/action.rb
102
- nil
120
+ :array_of_strings
103
121
  end
104
122
 
105
123
  def self.return_value
106
- # Freeform textual description of the return value
124
+ 'The list of files which have been generated and written to disk by the action'
107
125
  end
108
126
 
109
127
  def self.authors
@@ -2,10 +2,20 @@ module Fastlane
2
2
  module Actions
3
3
  class IosLintLocalizationsAction < Action
4
4
  def self.run(params)
5
- violations = {}
5
+ violations = Hash.new([])
6
6
 
7
7
  loop do
8
- violations = self.run_linter(params)
8
+ # If we did `violations = self.run...` we'd lose the default value for missing key being `[]` that we set above with `Hash.new`.
9
+ # We want that default value so that we can use `+=` when adding the duplicate keys violations below.
10
+ violations = violations.merge(self.run_linter(params))
11
+
12
+ if params[:check_duplicate_keys]
13
+ find_duplicated_keys(params).each do |language, duplicates|
14
+ violations[language] += duplicates
15
+ end
16
+ end
17
+
18
+ report(violations: violations, base_lang: params[:base_lang])
9
19
  break unless !violations.empty? && params[:allow_retry] && UI.confirm(RETRY_MESSAGE)
10
20
  end
11
21
 
@@ -22,17 +32,38 @@ module Fastlane
22
32
  install_path: resolve_path(params[:install_path]),
23
33
  version: params[:version]
24
34
  )
25
- violations = helper.run(
35
+
36
+ helper.run(
26
37
  input_dir: resolve_path(params[:input_dir]),
27
38
  base_lang: params[:base_lang],
28
39
  only_langs: params[:only_langs]
29
40
  )
41
+ end
30
42
 
31
- violations.each do |lang, diff|
32
- UI.error "Inconsistencies found between '#{params[:base_lang]}' and '#{lang}':\n\n#{diff}\n"
43
+ def self.report(violations:, base_lang:)
44
+ violations.each do |lang, lang_violations|
45
+ UI.error "Inconsistencies found between '#{base_lang}' and '#{lang}':\n\n#{lang_violations.join("\n")}\n"
33
46
  end
47
+ end
34
48
 
35
- violations
49
+ def self.find_duplicated_keys(params)
50
+ duplicate_keys = {}
51
+
52
+ files_to_lint = Dir.chdir(params[:input_dir]) do
53
+ Dir.glob('*.lproj/Localizable.strings').map do |file|
54
+ {
55
+ language: File.basename(File.dirname(file), '.lproj'),
56
+ path: File.join(params[:input_dir], file)
57
+ }
58
+ end
59
+ end
60
+
61
+ files_to_lint.each do |file|
62
+ duplicates = Fastlane::Helper::Ios::StringsFileValidationHelper.find_duplicated_keys(file: file[:path])
63
+ duplicate_keys[file[:language]] = duplicates.map { |key, value| "`#{key}` was found at multiple lines: #{value.join(', ')}" } unless duplicates.empty?
64
+ end
65
+
66
+ duplicate_keys
36
67
  end
37
68
 
38
69
  RETRY_MESSAGE = <<~MSG
@@ -140,6 +171,14 @@ module Fastlane
140
171
  default_value: false,
141
172
  is_string: false # https://docs.fastlane.tools/advanced/actions/#boolean-parameters
142
173
  ),
174
+ FastlaneCore::ConfigItem.new(
175
+ key: :check_duplicate_keys,
176
+ env_name: 'FL_IOS_LINT_TRANSLATIONS_CHECK_DUPLICATE_KEYS',
177
+ description: 'Checks the input files for duplicate keys',
178
+ optional: true,
179
+ default_value: true,
180
+ is_string: false # https://docs.fastlane.tools/advanced/actions/#boolean-parameters
181
+ ),
143
182
  ]
144
183
  end
145
184
 
@@ -148,15 +187,15 @@ module Fastlane
148
187
  end
149
188
 
150
189
  def self.return_type
151
- :hash_of_strings
190
+ :hash
152
191
  end
153
192
 
154
193
  def self.return_value
155
- 'A hash, keyed by language code, whose values are the diff found for said language'
194
+ 'A hash, keyed by language code, whose values are arrays of violations found for that language'
156
195
  end
157
196
 
158
197
  def self.authors
159
- ['AliSoftware']
198
+ ['Automattic']
160
199
  end
161
200
 
162
201
  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
@@ -263,9 +282,13 @@ module Fastlane
263
282
  #
264
283
  def self.download_glotpress_export_file(project_url:, locale:, filters:)
265
284
  query_params = filters.transform_keys { |k| "filters[#{k}]" }.merge(format: 'android')
266
- uri = URI.parse("#{project_url.chomp('/')}/#{locale}/default/export-translations?#{URI.encode_www_form(query_params)}")
285
+ uri = URI.parse("#{project_url.chomp('/')}/#{locale}/default/export-translations/?#{URI.encode_www_form(query_params)}")
286
+
287
+ # Set an unambiguous User Agent so GlotPress won't rate-limit us
288
+ options = { 'User-Agent' => Wpmreleasetoolkit::USER_AGENT }
289
+
267
290
  begin
268
- uri.open { |f| Nokogiri::XML(f.read.gsub("\t", ' '), nil, Encoding::UTF_8.to_s) }
291
+ uri.open(options) { |f| Nokogiri::XML(f.read.gsub("\t", ' '), nil, Encoding::UTF_8.to_s) }
269
292
  rescue StandardError => e
270
293
  UI.error "Error downloading #{locale} - #{e.message}"
271
294
  return nil
@@ -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
 
@@ -128,11 +143,15 @@ module Fastlane
128
143
  #
129
144
  def self.download_glotpress_export_file(project_url:, locale:, filters:, destination:)
130
145
  query_params = (filters || {}).transform_keys { |k| "filters[#{k}]" }.merge(format: 'strings')
131
- uri = URI.parse("#{project_url.chomp('/')}/#{locale}/default/export-translations?#{URI.encode_www_form(query_params)}")
146
+ uri = URI.parse("#{project_url.chomp('/')}/#{locale}/default/export-translations/?#{URI.encode_www_form(query_params)}")
147
+
148
+ # Set an unambiguous User Agent so GlotPress won't rate-limit us
149
+ options = { 'User-Agent' => Wpmreleasetoolkit::USER_AGENT }
150
+
132
151
  begin
133
- IO.copy_stream(uri.open, destination)
152
+ IO.copy_stream(uri.open(options), destination)
134
153
  rescue StandardError => e
135
- UI.error "Error downloading locale `#{locale}` — #{e.message}"
154
+ UI.error "Error downloading locale `#{locale}` — #{e.message} (#{uri})"
136
155
  return nil
137
156
  end
138
157
  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']
@@ -235,7 +234,7 @@ module Fastlane
235
234
  begin
236
235
  tempTextFile = Tempfile.new()
237
236
 
238
- sh('drawText', "html=\"#{text}\"", "maxWidth=#{width}", "maxHeight=#{height}", "output=\"#{tempTextFile.path}\"", "fontSize=#{font_size}", "stylesheet=\"#{stylesheet_path}\"", "alignment=\"#{position}\"")
237
+ Action.sh('drawText', "html=#{text}", "maxWidth=#{width}", "maxHeight=#{height}", "output=#{tempTextFile.path}", "fontSize=#{font_size}", "stylesheet=#{stylesheet_path}", "alignment=#{position}")
239
238
 
240
239
  text_content = open_image(tempTextFile.path).trim
241
240
  text_frame = create_image(width, height)
@@ -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)
@@ -0,0 +1,5 @@
1
+ module Fastlane
2
+ module Wpmreleasetoolkit
3
+ USER_AGENT = 'Automattic App Release Automator; https://github.com/wordpress-mobile/release-toolkit/'.freeze
4
+ end
5
+ end
@@ -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.1.0'
3
+ VERSION = '4.2.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.1.0
4
+ version: 4.2.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-03-01 00:00:00.000000000 Z
11
+ date: 2022-05-31 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
@@ -382,7 +382,7 @@ files:
382
382
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_get_alpha_version.rb
383
383
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_get_app_version.rb
384
384
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_get_release_version.rb
385
- - lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_hotifx_prechecks.rb
385
+ - lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_hotfix_prechecks.rb
386
386
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_tag_build.rb
387
387
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_update_release_notes.rb
388
388
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/common/buildkite_trigger_build_action.rb
@@ -429,7 +429,7 @@ files:
429
429
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_get_app_version.rb
430
430
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_get_build_version.rb
431
431
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_get_store_app_sizes.rb
432
- - lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_hotifx_prechecks.rb
432
+ - lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_hotfix_prechecks.rb
433
433
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_lint_localizations.rb
434
434
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_localize_project.rb
435
435
  - lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_merge_strings_files.rb
@@ -454,11 +454,13 @@ files:
454
454
  - lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_git_helper.rb
455
455
  - lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_l10n_helper.rb
456
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
457
458
  - lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_version_helper.rb
458
459
  - lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata_download_helper.rb
459
460
  - lib/fastlane/plugin/wpmreleasetoolkit/helper/metadata_update_helper.rb
460
461
  - lib/fastlane/plugin/wpmreleasetoolkit/helper/promo_screenshots_helper.rb
461
462
  - lib/fastlane/plugin/wpmreleasetoolkit/helper/release_notes_helper.rb
463
+ - lib/fastlane/plugin/wpmreleasetoolkit/helper/user_agent.rb
462
464
  - lib/fastlane/plugin/wpmreleasetoolkit/models/configuration.rb
463
465
  - lib/fastlane/plugin/wpmreleasetoolkit/models/file_reference.rb
464
466
  - lib/fastlane/plugin/wpmreleasetoolkit/version.rb