fastlane-plugin-wpmreleasetoolkit 3.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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