|
| 1 | +require 'open3' |
| 2 | +require 'tempfile' |
| 3 | +require 'fileutils' |
| 4 | +require 'nokogiri' |
| 5 | +require 'json' |
| 6 | + |
| 7 | +module Fastlane |
| 8 | + module Helper |
| 9 | + module Ios |
| 10 | + class L10nHelper |
| 11 | + # Returns the type of a `.strings` file (XML, binary or ASCII) |
| 12 | + # |
| 13 | + # @param [String] path The path to the `.strings` file to check |
| 14 | + # @return [Symbol] The file format used by the `.strings` file. Can be one of: |
| 15 | + # - `:text` for the ASCII-plist file format (containing typical `"key" = "value";` lines) |
| 16 | + # - `: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) |
| 17 | + # - `: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`) |
| 18 | + # - `nil` if the file does not exist or is neither of those format (e.g. not a `.strings` file at all) |
| 19 | + # |
| 20 | + def self.strings_file_type(path:) |
| 21 | + # Start by checking it seems like a valid property-list file (and not e.g. an image or plain text file) |
| 22 | + _, status = Open3.capture2('/usr/bin/plutil', '-lint', path) |
| 23 | + return nil unless status.success? |
| 24 | + |
| 25 | + # If it is a valid property-list file, determine the actual format used |
| 26 | + format_desc, status = Open3.capture2('/usr/bin/file', path) |
| 27 | + return nil unless status.success? |
| 28 | + |
| 29 | + case format_desc |
| 30 | + when /Apple binary property list/ then return :binary |
| 31 | + when /XML/ then return :xml |
| 32 | + when /text/ then return :text |
| 33 | + end |
| 34 | + end |
| 35 | + |
| 36 | + # Merge the content of multiple `.strings` files into a new `.strings` text file. |
| 37 | + # |
| 38 | + # @param [Array<String>] paths The paths of the `.strings` files to merge together |
| 39 | + # @param [String] into The path to the merged `.strings` file to generate as a result. |
| 40 | + # @return [Array<String>] List of duplicate keys found while validating the merge. |
| 41 | + # |
| 42 | + # @note For now, this method only supports merging `.strings` file in `:text` format |
| 43 | + # and basically concatenates the files (+ checking for duplicates in the process) |
| 44 | + # @note The method is able to handle input files which are using different encodings, |
| 45 | + # guessing the encoding of each input file using the BOM (and defaulting to UTF8). |
| 46 | + # The generated file will always be in utf-8, by convention. |
| 47 | + # |
| 48 | + # @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. |
| 49 | + # |
| 50 | + def self.merge_strings(paths:, output_path: nil) |
| 51 | + duplicates = [] |
| 52 | + Tempfile.create('wpmrt-l10n-merge-', encoding: 'utf-8') do |tmp_file| |
| 53 | + all_keys_found = [] |
| 54 | + |
| 55 | + tmp_file.write("/* Generated File. Do not edit. */\n\n") |
| 56 | + paths.each do |input_file| |
| 57 | + fmt = strings_file_type(path: input_file) |
| 58 | + raise "The file `#{input_file}` does not exist or is of unknown format." if fmt.nil? |
| 59 | + raise "The file `#{input_file}` is in #{fmt} format but we currently only support merging `.strings` files in text format." unless fmt == :text |
| 60 | + |
| 61 | + string_keys = read_strings_file_as_hash(path: input_file).keys |
| 62 | + duplicates += (string_keys & all_keys_found) # Find duplicates using Array intersection, and add those to duplicates list |
| 63 | + all_keys_found += string_keys |
| 64 | + |
| 65 | + tmp_file.write("/* MARK: - #{File.basename(input_file)} */\n\n") |
| 66 | + # Read line-by-line to reduce memory footprint during content copy; Be sure to guess file encoding using the Byte-Order-Mark. |
| 67 | + File.readlines(input_file, mode: 'rb:BOM|UTF-8').each { |line| tmp_file.write(line) } |
| 68 | + tmp_file.write("\n") |
| 69 | + end |
| 70 | + tmp_file.close # ensure we flush the content to disk |
| 71 | + FileUtils.cp(tmp_file.path, output_path) |
| 72 | + end |
| 73 | + duplicates |
| 74 | + end |
| 75 | + |
| 76 | + # Return the list of translations in a `.strings` file. |
| 77 | + # |
| 78 | + # @param [String] path The path to the `.strings` file to read |
| 79 | + # @return [Hash<String,String>] A dictionary of key=>translation translations. |
| 80 | + # @raise [RuntimeError] If the file is not a valid strings file or there was an error in parsing its content. |
| 81 | + # |
| 82 | + def self.read_strings_file_as_hash(path:) |
| 83 | + output, status = Open3.capture2e('/usr/bin/plutil', '-convert', 'json', '-o', '-', path) |
| 84 | + raise output unless status.success? |
| 85 | + |
| 86 | + JSON.parse(output) |
| 87 | + end |
| 88 | + |
| 89 | + # Generate a `.strings` file from a dictionary of string translations. |
| 90 | + # |
| 91 | + # Especially useful to generate `.strings` files not from code, but from keys extracted from another source |
| 92 | + # (like a JSON file export from GlotPress, or subset of keys extracted from the main `Localizable.strings` to generate an `InfoPlist.strings`) |
| 93 | + # |
| 94 | + # @note The generated file will be in XML-plist format |
| 95 | + # since ASCII plist is deprecated as an output format by every Apple tool so there's no **safe** way to generate ASCII format. |
| 96 | + # |
| 97 | + # @param [Hash<String,String>] translations The dictionary of key=>translation translations to put in the generated `.strings` file |
| 98 | + # @param [String] output_path The path to the `.strings` file to generate |
| 99 | + # |
| 100 | + def self.generate_strings_file_from_hash(translations:, output_path:) |
| 101 | + builder = Nokogiri::XML::Builder.new(encoding: 'UTF-8') do |xml| |
| 102 | + xml.doc.create_internal_subset( |
| 103 | + 'plist', |
| 104 | + '-//Apple//DTD PLIST 1.0//EN', |
| 105 | + 'http://www.apple.com/DTDs/PropertyList-1.0.dtd' |
| 106 | + ) |
| 107 | + xml.comment('Warning: Auto-generated file, do not edit.') |
| 108 | + xml.plist(version: '1.0') do |
| 109 | + xml.dict do |
| 110 | + translations.each do |k, v| |
| 111 | + xml.key(k.to_s) |
| 112 | + xml.string(v.to_s) |
| 113 | + end |
| 114 | + end |
| 115 | + end |
| 116 | + end |
| 117 | + File.write(output_path, builder.to_xml) |
| 118 | + end |
| 119 | + end |
| 120 | + end |
| 121 | + end |
| 122 | +end |
0 commit comments