fastlane-plugin-wpmreleasetoolkit 2.3.0 → 4.0.0

Sign up to get free protection for your applications and to get access to all the features.
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)