fastlane-plugin-wpmreleasetoolkit 2.3.0 → 4.0.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/an_localize_libs_action.rb +8 -3
  3. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/buildkite_trigger_build_action.rb +90 -0
  4. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/gp_downloadmetadata_action.rb +1 -1
  5. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/common/upload_to_s3.rb +112 -0
  6. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_download_strings_files_from_glotpress.rb +114 -0
  7. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_extract_keys_from_strings_files.rb +136 -0
  8. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_generate_strings_file_from_code.rb +1 -1
  9. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_lint_localizations.rb +5 -5
  10. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_localize_project.rb +2 -2
  11. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_merge_strings_files.rb +79 -0
  12. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/android/android_git_helper.rb +0 -20
  13. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/android/android_localize_helper.rb +8 -0
  14. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/git_helper.rb +2 -4
  15. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_git_helper.rb +3 -2
  16. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_l10n_helper.rb +120 -170
  17. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_l10n_linter_helper.rb +207 -0
  18. data/lib/fastlane/plugin/wpmreleasetoolkit/helper/promo_screenshots_helper.rb +3 -3
  19. data/lib/fastlane/plugin/wpmreleasetoolkit/models/file_reference.rb +1 -4
  20. data/lib/fastlane/plugin/wpmreleasetoolkit/version.rb +1 -1
  21. metadata +25 -39
  22. data/bin/drawText +0 -20
  23. data/ext/drawText/drawText/Assets/style.css +0 -1
  24. data/ext/drawText/drawText/CoreTextStack.swift +0 -113
  25. data/ext/drawText/drawText/Helpers/CommandLineHelpers.swift +0 -36
  26. data/ext/drawText/drawText/Helpers/Extensions.swift +0 -27
  27. data/ext/drawText/drawText/Helpers/FileSystemHelper.swift +0 -24
  28. data/ext/drawText/drawText/Stylesheet.swift +0 -48
  29. data/ext/drawText/drawText/TextImage.swift +0 -100
  30. data/ext/drawText/drawText/main.swift +0 -61
  31. data/ext/drawText/drawText Tests/DigitParsingTests.swift +0 -21
  32. data/ext/drawText/drawText Tests/ExtensionsTests.swift +0 -5
  33. data/ext/drawText/drawText Tests/Info.plist +0 -22
  34. data/ext/drawText/drawText Tests/StylesheetTests.swift +0 -31
  35. data/ext/drawText/drawText Tests/Test Cases/default-stylesheet.txt +0 -10
  36. data/ext/drawText/drawText Tests/Test Cases/external-styles-sample.css +0 -3
  37. data/ext/drawText/drawText Tests/Test Cases/external-styles-test.txt +0 -13
  38. data/ext/drawText/drawText Tests/Test Cases/large-text-block.txt +0 -1
  39. data/ext/drawText/drawText Tests/Test Cases/regular-text-block.txt +0 -2
  40. data/ext/drawText/drawText Tests/Test Cases/rtl-text-block.txt +0 -2
  41. data/ext/drawText/drawText Tests/Test Cases/text-size-adjustment-test.txt +0 -10
  42. data/ext/drawText/drawText Tests/TextImageTests.swift +0 -99
  43. data/ext/drawText/drawText Tests/drawText_Tests.swift +0 -14
  44. data/ext/drawText/drawText.xcodeproj/project.pbxproj +0 -508
  45. data/ext/drawText/drawText.xcodeproj/project.xcworkspace/contents.xcworkspacedata +0 -7
  46. data/ext/drawText/drawText.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +0 -8
  47. data/ext/drawText/drawText.xcodeproj/xcshareddata/xcschemes/drawText Tests.xcscheme +0 -57
  48. data/ext/drawText/drawText.xcodeproj/xcshareddata/xcschemes/drawText.xcscheme +0 -109
  49. data/ext/drawText/extconf.rb +0 -36
  50. data/ext/drawText/makefile.example +0 -8
  51. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_merge_translators_strings.rb +0 -106
  52. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/android/android_update_metadata.rb +0 -52
  53. data/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_merge_translators_strings.rb +0 -93
@@ -27,26 +27,6 @@ module Fastlane
27
27
  )
28
28
  end
29
29
  end
30
-
31
- # Calls the `tools/update-translations.sh` script from the project repo, then lint them using the provided gradle task
32
- #
33
- # Deprecated. Use the `android_download_translations` action instead.
34
- #
35
- # @env PROJECT_ROOT_FOLDER The path to the git root of the project
36
- # @env PROJECT_NAME The name of the directory containing the project code (especially containing the `build.gradle` file)
37
- #
38
- # @param [String] validate_translation_command The name of the gradle task to run to validate the translations
39
- #
40
- # @todo Remove this once every client has migrated to `android_download_translations` and we got rid of that legacy action.
41
- #
42
- def self.update_metadata(validate_translation_command)
43
- Action.sh('./tools/update-translations.sh')
44
- Action.sh('git', 'submodule', 'update', '--init', '--recursive')
45
- Action.sh('./gradlew', validate_translation_command)
46
-
47
- res_dir = File.join(ENV['PROJECT_ROOT_FOLDER'], ENV['PROJECT_NAME'], 'src', 'main', 'res')
48
- Fastlane::Helper::GitHelper.commit(message: 'Update translations', files: res_dir, push: true)
49
- end
50
30
  end
51
31
  end
52
32
  end
@@ -92,6 +92,14 @@ module Fastlane
92
92
  UI.user_error!("String #{string_name} [#{string_content}] was found in library #{library[:library]} but not in the main file.")
93
93
  end
94
94
 
95
+ # Merge strings from a library into the strings.xml of the main app
96
+ #
97
+ # @param [String] main Path to the main strings.xml file (something like `…/res/values/strings.xml`)
98
+ # @param [Hash] library Hash describing the library to merge. The Hash should contain the following keys:
99
+ # - `:library`: The human readable name of the library, used to display in console messages
100
+ # - `: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.
102
+ # @return [Boolean] True if at least one string from the library has been added to (or has updated) the main strings file.
95
103
  def self.merge_lib(main, library)
96
104
  UI.message("Merging #{library[:library]} strings into #{main}")
97
105
  main_strings = File.open(main) { |f| Nokogiri::XML(f, nil, Encoding::UTF_8.to_s) }
@@ -5,10 +5,8 @@ module Fastlane
5
5
  # Helper methods to execute git-related operations
6
6
  #
7
7
  module GitHelper
8
- # Fallback default branch of the client repository. It's currently set to 'develop' for
9
- # backwards compatibility.
10
- # TODO: Set to 'trunk' for the next major release.
11
- DEFAULT_GIT_BRANCH = 'develop'.freeze
8
+ # Fallback default branch of the client repository.
9
+ DEFAULT_GIT_BRANCH = 'trunk'.freeze
12
10
 
13
11
  # Checks if the given path, or current directory if no path is given, is
14
12
  # inside a Git repository
@@ -35,8 +35,9 @@ module Fastlane
35
35
  # @env PROJECT_ROOT_FOLDER The path to the git root of the project
36
36
  # @env PROJECT_NAME The name of the directory containing the project code (especially containing the `build.gradle` file)
37
37
  #
38
- # @todo Migrate the scripts, currently in each host repo and called by this method, to be helpers and actions
39
- # in the release-toolkit instead, and move this code away from `ios_git_helper`.
38
+ # @deprecated This method is only used by the `ios_localize_project` action, which is itself deprecated
39
+ # in favor of the new `ios_generate_strings_file_from_code` action
40
+ # @todo [Next Major] Remove this method once we fully remove `ios_localize_project`
40
41
  #
41
42
  def self.localize_project
42
43
  Action.sh("cd #{get_from_env!(key: 'PROJECT_ROOT_FOLDER')} && ./Scripts/localize.py")
@@ -1,205 +1,155 @@
1
- require 'yaml'
2
- require 'tmpdir'
1
+ require 'fileutils'
2
+ require 'json'
3
+ require 'nokogiri'
4
+ require 'open3'
5
+ require 'open-uri'
6
+ require 'tempfile'
3
7
 
4
8
  module Fastlane
5
9
  module Helper
6
10
  module Ios
7
11
  class L10nHelper
8
- SWIFTGEN_VERSION = '6.4.0'
9
- DEFAULT_BASE_LANG = 'en'
10
- CONFIG_FILE_NAME = 'swiftgen-stringtypes.yml'
11
-
12
- attr_reader :install_path, :version
13
-
14
- # @param [String] install_path The path to install SwiftGen to. Usually something like "$PROJECT_DIR/vendor/swiftgen/#{SWIFTGEN_VERSION}".
15
- # It's recommended to provide an absolute path here rather than a relative one, to ensure it's not dependant on where the action is run from.
16
- # @param [String] version The version of SwiftGen to use. This will be used both:
17
- # - to check if the current version located in `install_path`, if it already exists, is the expected one
18
- # - to know which version to download if there is not one installed in `install_path` yet
12
+ # Returns the type of a `.strings` file (XML, binary or ASCII)
19
13
  #
20
- def initialize(install_path:, version: SWIFTGEN_VERSION)
21
- @install_path = install_path
22
- @version = version || SWIFTGEN_VERSION
23
- end
24
-
25
- # Check if SwiftGen is installed in the provided `install_path` and if so if the installed version matches the expected `version`
14
+ # @param [String] path The path to the `.strings` file to check
15
+ # @return [Symbol] The file format used by the `.strings` file. Can be one of:
16
+ # - `:text` for the ASCII-plist file format (containing typical `"key" = "value";` lines)
17
+ # - `:xml` for XML plist file format (can be used if machine-generated, especially since there's no official way/tool to generate the ASCII-plist file format as output)
18
+ # - `:binary` for binary plist file format (usually only true for `.strings` files converted by Xcode at compile time and included in the final `.app`/`.ipa`)
19
+ # - `nil` if the file does not exist or is neither of those format (e.g. not a `.strings` file at all)
26
20
  #
27
- def check_swiftgen_installed
28
- return false unless File.exist?(swiftgen_bin)
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
29
23
 
30
- vers_string = `#{swiftgen_bin} --version`
31
- # The SwiftGen version string has this format:
32
- #
33
- # SwiftGen v6.4.0 (Stencil v0.13.1, StencilSwiftKit v2.7.2, SwiftGenKit v6.4.0)
34
- return vers_string.include?("SwiftGen v#{version}")
35
- rescue
36
- return false
37
- end
24
+ # Start by checking it seems like a valid property-list file (and not e.g. an image or plain text file)
25
+ _, status = Open3.capture2('/usr/bin/plutil', '-lint', path)
26
+ return nil unless status.success?
38
27
 
39
- # Download the ZIP of SwiftGen for the requested `version` and install it in the `install_path`
40
- #
41
- # @note This action nukes anything at `install_path` – if something already exists – prior to install SwiftGen there
42
- #
43
- def install_swiftgen!
44
- UI.message "Installing SwiftGen #{version} into #{install_path}"
45
- Dir.mktmpdir do |tmpdir|
46
- zipfile = File.join(tmpdir, "swiftgen-#{version}.zip")
47
- Action.sh('curl', '--fail', '--location', '-o', zipfile, "https://github.com/SwiftGen/SwiftGen/releases/download/#{version}/swiftgen-#{version}.zip")
48
- extracted_dir = File.join(tmpdir, "swiftgen-#{version}")
49
- Action.sh('unzip', zipfile, '-d', extracted_dir)
28
+ # If it is a valid property-list file, determine the actual format used
29
+ format_desc, status = Open3.capture2('/usr/bin/file', path)
30
+ return nil unless status.success?
50
31
 
51
- FileUtils.rm_rf(install_path) if File.exist?(install_path)
52
- FileUtils.mkdir_p(install_path)
53
- FileUtils.cp_r("#{extracted_dir}/.", install_path)
32
+ case format_desc
33
+ when /Apple binary property list/ then return :binary
34
+ when /XML/ then return :xml
35
+ when /text/ then return :text
54
36
  end
55
37
  end
56
38
 
57
- # Install SwiftGen if necessary (if not installed yet with the expected version), then run the checks and returns the violations found, if any
39
+ # Merge the content of multiple `.strings` files into a new `.strings` text file.
58
40
  #
59
- # @param [String] input_dir The path (ideally absolute) to the directory containing the `.lproj` folders to parse
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.
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.
43
+ # @return [Array<String>] List of duplicate keys found while validating the merge.
63
44
  #
64
- def run(input_dir:, base_lang: DEFAULT_BASE_LANG, only_langs: nil)
65
- check_swiftgen_installed || install_swiftgen!
66
- find_diffs(input_dir: input_dir, base_lang: base_lang, only_langs: only_langs)
67
- end
68
-
69
- ##################
70
-
71
- private
72
-
73
- # Path to the swiftgen binary installed at install_path
74
- def swiftgen_bin
75
- "#{install_path}/bin/swiftgen"
76
- end
77
-
78
- # Name to use for the generated files / output files of SwiftGen for each locale. Those files will be generated in the temporary directory to then diff them.
79
- def output_filename(lang)
80
- "L10nParamsList.#{lang}.txt"
81
- end
82
-
83
- # The Stencil template that we want SwiftGen to use to generate the output.
84
- # It iterates on every "table" (`.strings` file, in most cases there's only one, `Localizable.strings`),
85
- # and for each, iterates on every entry found to print the key and the corresponding types parsed by SwiftGen from the placeholders found in that translation
86
- def template_content
87
- <<~TEMPLATE
88
- {% macro recursiveBlock table item %}
89
- {% for string in item.strings %}
90
- "{{string.key}}" => [{{string.types|join:","}}]
91
- {% endfor %}
92
- {% for child in item.children %}
93
- {% call recursiveBlock table child %}
94
- {% endfor %}
95
- {% endmacro %}
96
-
97
- {% for table in tables %}
98
- {% call recursiveBlock table.name table.levels %}
99
- {% endfor %}
100
- TEMPLATE
101
- end
102
-
103
- # Create the template file and the config file, in the temp dir, to be used by SwiftGen when parsing the input files.
45
+ # @note For now, this method only supports merging `.strings` file in `:text` format
46
+ # and basically concatenates the files (+ checking for duplicates in the process)
47
+ # @note The method is able to handle input files which are using different encodings,
48
+ # guessing the encoding of each input file using the BOM (and defaulting to UTF8).
49
+ # The generated file will always be in utf-8, by convention.
104
50
  #
105
- # @return [(String, Array<String>)] A tuple of (config_file_absolute_path, Array<langs>)
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.
106
52
  #
107
- def generate_swiftgen_config!(input_dir, output_dir, only_langs: nil)
108
- # Create the template file
109
- template_path = File.absolute_path(File.join(output_dir, 'strings-types.stencil'))
110
- File.write(template_path, template_content)
111
-
112
- # Dynamically create a SwiftGen config which will cover all supported languages
113
- langs = Dir.chdir(input_dir) do
114
- Dir.glob('*.lproj/Localizable.strings').map { |loc_file| File.basename(File.dirname(loc_file), '.lproj') }
115
- end.sort
116
- langs.select! { |lang| only_langs.include?(lang) } unless only_langs.nil?
117
-
118
- config = {
119
- 'input_dir' => input_dir,
120
- 'output_dir' => output_dir,
121
- 'strings' => langs.map do |lang|
122
- {
123
- 'inputs' => ["#{lang}.lproj/Localizable.strings"],
124
- # Choose an unlikely separator (instead of the default '.') to avoid creating needlessly complex Stencil Context nested
125
- # structure just because we have '.' in the English sentences we use (instead of structured reverse-dns notation) for the keys
126
- 'options' => { 'separator' => '____' },
127
- 'outputs' => [{
128
- 'templatePath' => template_path,
129
- 'output' => output_filename(lang)
130
- }]
131
- }
53
+ def self.merge_strings(paths:, output_path:)
54
+ duplicates = []
55
+ Tempfile.create('wpmrt-l10n-merge-', encoding: 'utf-8') do |tmp_file|
56
+ all_keys_found = []
57
+
58
+ tmp_file.write("/* Generated File. Do not edit. */\n\n")
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
+
62
+ fmt = strings_file_type(path: input_file)
63
+ raise "The file `#{input_file}` does not exist or is of unknown format." if fmt.nil?
64
+ raise "The file `#{input_file}` is in #{fmt} format but we currently only support merging `.strings` files in text format." unless fmt == :text
65
+
66
+ string_keys = read_strings_file_as_hash(path: input_file).keys.map { |k| "#{prefix}#{k}" }
67
+ duplicates += (string_keys & all_keys_found) # Find duplicates using Array intersection, and add those to duplicates list
68
+ all_keys_found += string_keys
69
+
70
+ tmp_file.write("/* MARK: - #{File.basename(input_file)} */\n\n")
71
+ # Read line-by-line to reduce memory footprint during content copy; Be sure to guess file encoding using the Byte-Order-Mark.
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
82
+ tmp_file.write("\n")
132
83
  end
133
- }
134
-
135
- # Write SwiftGen config file
136
- config_file = File.join(output_dir, CONFIG_FILE_NAME)
137
- File.write(config_file, config.to_yaml)
138
-
139
- return [config_file, langs]
84
+ tmp_file.close # ensure we flush the content to disk
85
+ FileUtils.cp(tmp_file.path, output_path)
86
+ end
87
+ duplicates
140
88
  end
141
89
 
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
90
+ # Return the list of translations in a `.strings` file.
145
91
  #
146
- # @param [String] dir The temporary directory in which the file to sort lines for is located
147
- # @param [String] lang The code for the locale we need to sort the output lines for
92
+ # @param [String] path The path to the `.strings` file to read
93
+ # @return [Hash<String,String>] A dictionary of key=>translation translations.
94
+ # @raise [RuntimeError] If the file is not a valid strings file or there was an error in parsing its content.
148
95
  #
149
- def sort_file_lines!(dir, lang)
150
- file = File.join(dir, output_filename(lang))
151
- return nil unless File.exist?(file)
96
+ def self.read_strings_file_as_hash(path:)
97
+ return {} if File.empty?(path) # Return empty hash if completely empty file
98
+
99
+ output, status = Open3.capture2e('/usr/bin/plutil', '-convert', 'json', '-o', '-', path)
100
+ raise output unless status.success?
152
101
 
153
- sorted_lines = File.readlines(file).sort
154
- File.write(file, sorted_lines.join)
155
- return file
102
+ JSON.parse(output)
156
103
  end
157
104
 
158
- # 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.
105
+ # Generate a `.strings` file from a dictionary of string translations.
159
106
  #
160
- # @param [String] input_dir The directory where the `.lproj` folders to scan are located
161
- # @param [String] base_lang The base language used as source of truth that all other languages will be compared against
162
- # @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.
107
+ # Especially useful to generate `.strings` files not from code, but from keys extracted from another source
108
+ # (like a JSON file export from GlotPress, or subset of keys extracted from the main `Localizable.strings` to generate an `InfoPlist.strings`)
164
109
  #
165
- # @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.
110
+ # @note The generated file will be in XML-plist format
111
+ # since ASCII plist is deprecated as an output format by every Apple tool so there's no **safe** way to generate ASCII format.
166
112
  #
167
- def find_diffs(input_dir:, base_lang:, only_langs: nil)
168
- Dir.mktmpdir('a8c-lint-translations-') do |tmpdir|
169
- # Run SwiftGen
170
- langs = only_langs.nil? ? nil : (only_langs + [base_lang]).uniq
171
- (config_file, langs) = generate_swiftgen_config!(input_dir, tmpdir, only_langs: langs)
172
- Action.sh(swiftgen_bin, 'config', 'run', '--config', config_file)
173
-
174
- # Run diffs
175
- base_file = sort_file_lines!(tmpdir, base_lang)
176
- langs.delete(base_lang)
177
- return langs.map do |lang|
178
- file = sort_file_lines!(tmpdir, lang)
179
- # 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
113
+ # @param [Hash<String,String>] translations The dictionary of key=>translation translations to put in the generated `.strings` file
114
+ # @param [String] output_path The path to the `.strings` file to generate
115
+ #
116
+ def self.generate_strings_file_from_hash(translations:, output_path:)
117
+ builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml|
118
+ xml.doc.create_internal_subset(
119
+ 'plist',
120
+ '-//Apple//DTD PLIST 1.0//EN',
121
+ 'http://www.apple.com/DTDs/PropertyList-1.0.dtd'
122
+ )
123
+ xml.comment('Warning: Auto-generated file, do not edit.')
124
+ xml.plist(version: '1.0') do
125
+ xml.dict do
126
+ translations.sort.each do |k, v| # NOTE: use `sort` just in order to be deterministic over various runs
127
+ xml.key(k.to_s)
128
+ xml.string(v.to_s)
129
+ end
130
+ end
131
+ end
192
132
  end
133
+ File.write(output_path, builder.to_xml)
193
134
  end
194
135
 
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
136
+ # Downloads the export from GlotPress for a given locale and given filters.
137
+ #
138
+ # @param [String] project_url The URL to the GlotPress project to export from, e.g. `"https://translate.wordpress.org/projects/apps/ios/dev"`
139
+ # @param [String] locale The GlotPress locale code to download strings for.
140
+ # @param [Hash{Symbol=>String}] filters The hash of filters to apply when exporting from GlotPress.
141
+ # Typical examples include `{ status: 'current' }` or `{ status: 'review' }`.
142
+ # @param [String, IO] destination The path or `IO`-like instance, where to write the downloaded file on disk.
143
+ #
144
+ def self.download_glotpress_export_file(project_url:, locale:, filters:, destination:)
145
+ query_params = (filters || {}).transform_keys { |k| "filters[#{k}]" }.merge(format: 'strings')
146
+ uri = URI.parse("#{project_url.chomp('/')}/#{locale}/default/export-translations?#{URI.encode_www_form(query_params)}")
147
+ begin
148
+ IO.copy_stream(uri.open, destination)
149
+ rescue StandardError => e
150
+ UI.error "Error downloading locale `#{locale}` — #{e.message}"
151
+ return nil
201
152
  end
202
- return true
203
153
  end
204
154
  end
205
155
  end
@@ -0,0 +1,207 @@
1
+ require 'yaml'
2
+ require 'tmpdir'
3
+
4
+ module Fastlane
5
+ module Helper
6
+ module Ios
7
+ class L10nLinterHelper
8
+ SWIFTGEN_VERSION = '6.4.0'
9
+ DEFAULT_BASE_LANG = 'en'
10
+ CONFIG_FILE_NAME = 'swiftgen-stringtypes.yml'
11
+
12
+ attr_reader :install_path, :version
13
+
14
+ # @param [String] install_path The path to install SwiftGen to. Usually something like "$PROJECT_DIR/vendor/swiftgen/#{SWIFTGEN_VERSION}".
15
+ # It's recommended to provide an absolute path here rather than a relative one, to ensure it's not dependant on where the action is run from.
16
+ # @param [String] version The version of SwiftGen to use. This will be used both:
17
+ # - to check if the current version located in `install_path`, if it already exists, is the expected one
18
+ # - to know which version to download if there is not one installed in `install_path` yet
19
+ #
20
+ def initialize(install_path:, version: SWIFTGEN_VERSION)
21
+ @install_path = install_path
22
+ @version = version || SWIFTGEN_VERSION
23
+ end
24
+
25
+ # Check if SwiftGen is installed in the provided `install_path` and if so if the installed version matches the expected `version`
26
+ #
27
+ def check_swiftgen_installed
28
+ return false unless File.exist?(swiftgen_bin)
29
+
30
+ vers_string = `#{swiftgen_bin} --version`
31
+ # The SwiftGen version string has this format:
32
+ #
33
+ # SwiftGen v6.4.0 (Stencil v0.13.1, StencilSwiftKit v2.7.2, SwiftGenKit v6.4.0)
34
+ return vers_string.include?("SwiftGen v#{version}")
35
+ rescue
36
+ return false
37
+ end
38
+
39
+ # Download the ZIP of SwiftGen for the requested `version` and install it in the `install_path`
40
+ #
41
+ # @note This action nukes anything at `install_path` – if something already exists – prior to install SwiftGen there
42
+ #
43
+ def install_swiftgen!
44
+ UI.message "Installing SwiftGen #{version} into #{install_path}"
45
+ Dir.mktmpdir do |tmpdir|
46
+ zipfile = File.join(tmpdir, "swiftgen-#{version}.zip")
47
+ Action.sh('curl', '--fail', '--location', '-o', zipfile, "https://github.com/SwiftGen/SwiftGen/releases/download/#{version}/swiftgen-#{version}.zip")
48
+ extracted_dir = File.join(tmpdir, "swiftgen-#{version}")
49
+ Action.sh('unzip', zipfile, '-d', extracted_dir)
50
+
51
+ FileUtils.rm_rf(install_path) if File.exist?(install_path)
52
+ FileUtils.mkdir_p(install_path)
53
+ FileUtils.cp_r("#{extracted_dir}/.", install_path)
54
+ end
55
+ end
56
+
57
+ # Install SwiftGen if necessary (if not installed yet with the expected version), then run the checks and returns the violations found, if any
58
+ #
59
+ # @param [String] input_dir The path (ideally absolute) to the directory containing the `.lproj` folders to parse
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.
63
+ #
64
+ def run(input_dir:, base_lang: DEFAULT_BASE_LANG, only_langs: nil)
65
+ check_swiftgen_installed || install_swiftgen!
66
+ find_diffs(input_dir: input_dir, base_lang: base_lang, only_langs: only_langs)
67
+ end
68
+
69
+ ##################
70
+
71
+ private
72
+
73
+ # Path to the swiftgen binary installed at install_path
74
+ def swiftgen_bin
75
+ "#{install_path}/bin/swiftgen"
76
+ end
77
+
78
+ # Name to use for the generated files / output files of SwiftGen for each locale. Those files will be generated in the temporary directory to then diff them.
79
+ def output_filename(lang)
80
+ "L10nParamsList.#{lang}.txt"
81
+ end
82
+
83
+ # The Stencil template that we want SwiftGen to use to generate the output.
84
+ # It iterates on every "table" (`.strings` file, in most cases there's only one, `Localizable.strings`),
85
+ # and for each, iterates on every entry found to print the key and the corresponding types parsed by SwiftGen from the placeholders found in that translation
86
+ def template_content
87
+ <<~TEMPLATE
88
+ {% macro recursiveBlock table item %}
89
+ {% for string in item.strings %}
90
+ "{{string.key}}" => [{{string.types|join:","}}]
91
+ {% endfor %}
92
+ {% for child in item.children %}
93
+ {% call recursiveBlock table child %}
94
+ {% endfor %}
95
+ {% endmacro %}
96
+
97
+ {% for table in tables %}
98
+ {% call recursiveBlock table.name table.levels %}
99
+ {% endfor %}
100
+ TEMPLATE
101
+ end
102
+
103
+ # Create the template file and the config file, in the temp dir, to be used by SwiftGen when parsing the input files.
104
+ #
105
+ # @return [(String, Array<String>)] A tuple of (config_file_absolute_path, Array<langs>)
106
+ #
107
+ def generate_swiftgen_config!(input_dir, output_dir, only_langs: nil)
108
+ # Create the template file
109
+ template_path = File.absolute_path(File.join(output_dir, 'strings-types.stencil'))
110
+ File.write(template_path, template_content)
111
+
112
+ # Dynamically create a SwiftGen config which will cover all supported languages
113
+ langs = Dir.chdir(input_dir) do
114
+ Dir.glob('*.lproj/Localizable.strings').map { |loc_file| File.basename(File.dirname(loc_file), '.lproj') }
115
+ end.sort
116
+ langs.select! { |lang| only_langs.include?(lang) } unless only_langs.nil?
117
+
118
+ config = {
119
+ 'input_dir' => input_dir,
120
+ 'output_dir' => output_dir,
121
+ 'strings' => langs.map do |lang|
122
+ {
123
+ 'inputs' => ["#{lang}.lproj/Localizable.strings"],
124
+ # Choose an unlikely separator (instead of the default '.') to avoid creating needlessly complex Stencil Context nested
125
+ # structure just because we have '.' in the English sentences we use (instead of structured reverse-dns notation) for the keys
126
+ 'options' => { 'separator' => '____' },
127
+ 'outputs' => [{
128
+ 'templatePath' => template_path,
129
+ 'output' => output_filename(lang)
130
+ }]
131
+ }
132
+ end
133
+ }
134
+
135
+ # Write SwiftGen config file
136
+ config_file = File.join(output_dir, CONFIG_FILE_NAME)
137
+ File.write(config_file, config.to_yaml)
138
+
139
+ return [config_file, langs]
140
+ end
141
+
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
145
+ #
146
+ # @param [String] dir The temporary directory in which the file to sort lines for is located
147
+ # @param [String] lang The code for the locale we need to sort the output lines for
148
+ #
149
+ def sort_file_lines!(dir, lang)
150
+ file = File.join(dir, output_filename(lang))
151
+ return nil unless File.exist?(file)
152
+
153
+ sorted_lines = File.readlines(file).sort
154
+ File.write(file, sorted_lines.join)
155
+ return file
156
+ end
157
+
158
+ # 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.
159
+ #
160
+ # @param [String] input_dir The directory where the `.lproj` folders to scan are located
161
+ # @param [String] base_lang The base language used as source of truth that all other languages will be compared against
162
+ # @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.
164
+ #
165
+ # @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
+ #
167
+ def find_diffs(input_dir:, base_lang:, only_langs: nil)
168
+ Dir.mktmpdir('a8c-lint-translations-') do |tmpdir|
169
+ # Run SwiftGen
170
+ langs = only_langs.nil? ? nil : (only_langs + [base_lang]).uniq
171
+ (config_file, langs) = generate_swiftgen_config!(input_dir, tmpdir, only_langs: langs)
172
+ Action.sh(swiftgen_bin, 'config', 'run', '--config', config_file)
173
+
174
+ # Run diffs
175
+ base_file = sort_file_lines!(tmpdir, base_lang)
176
+ langs.delete(base_lang)
177
+ return langs.map do |lang|
178
+ file = sort_file_lines!(tmpdir, lang)
179
+ # 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
194
+
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
201
+ end
202
+ return true
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
@@ -28,6 +28,8 @@ module Fastlane
28
28
  message << 'Aborting.'
29
29
  UI.user_error!(message)
30
30
  end
31
+
32
+ UI.user_error!('`drawText` not found – install it using `brew install automattic/build-tools/drawText`.') unless system('command -v drawText')
31
33
  end
32
34
 
33
35
  def read_json(configFilePath)
@@ -233,9 +235,7 @@ module Fastlane
233
235
  begin
234
236
  tempTextFile = Tempfile.new()
235
237
 
236
- command = "bundle exec drawText html=\"#{text}\" maxWidth=#{width} maxHeight=#{height} output=#{tempTextFile.path} fontSize=#{font_size} stylesheet=\"#{stylesheet_path}\" alignment=\"#{position}\""
237
-
238
- UI.crash!('Unable to draw text') unless system(command)
238
+ sh('drawText', "html=\"#{text}\"", "maxWidth=#{width}", "maxHeight=#{height}", "output=\"#{tempTextFile.path}\"", "fontSize=#{font_size}", "stylesheet=\"#{stylesheet_path}\"", "alignment=\"#{position}\"")
239
239
 
240
240
  text_content = open_image(tempTextFile.path).trim
241
241
  text_frame = create_image(width, height)