From ee18adb01ae4e8e39908eb93b6377404f5bbe162 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Mon, 22 Jun 2026 15:35:50 -0600 Subject: [PATCH] =?UTF-8?q?Add=20iOS=20stringsdict=20=E2=87=86=20pot=20con?= =?UTF-8?q?version=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-trip iOS `.stringsdict` plural files through gettext `.po`/`.pot` so plurals can be translated in a system like GlotPress — the existing `.strings` pipeline is key/value only and can't represent plurals. - `ios_generate_pot_from_stringsdict` (forward): English `.stringsdict` → `.pot`, one `msgid`/`msgid_plural` entry per plural variable (`msgctxt` is the key, or `key:variable` for multi-variable entries). - `ios_generate_stringsdict_from_po` (reverse): translated `.po` + the English `.stringsdict` as a structural template → per-locale `.stringsdict`, mapping indexed `msgstr[N]` back to CLDR category names and back-filling the iOS-required `other`. Returns the contexts left untranslated (kept as English). `Ios::PluralRules` maps a locale to its ordered CLDR categories. Because the pipeline consumes GlotPress `.po` exports and GlotPress lags CLDR for several locales, the table is generated (`rakelib/generate_ios_plural_rules.rb`) from GlotPress `GP_Locales` (form count) × CLDR (category names) rather than from CLDR alone; the reverse converter reads each `.po`'s `Plural-Forms` header and fails loud if the count drifts from the table. Locales whose GlotPress rule can't map to CLDR (e.g. Welsh) fail with a clean user error. New helpers `Ios::StringsdictHelper` and `Ios::PluralRules`; no new runtime dependencies (`plist`/`gettext` were already in the gemspec). [#739] --- CHANGELOG.md | 1 + .../ios/ios_generate_pot_from_stringsdict.rb | 95 +++++ .../ios/ios_generate_stringsdict_from_po.rb | 109 +++++ .../helper/ios/ios_plural_rules.rb | 135 +++++++ .../helper/ios/ios_stringsdict_helper.rb | 381 ++++++++++++++++++ rakelib/generate_ios_plural_rules.rb | 126 ++++++ rakelib/plural_rules_data/cldr_plurals.xml | 258 ++++++++++++ .../plural_rules_data/glotpress_nplurals.json | 252 ++++++++++++ .../ios_generate_pot_from_stringsdict_spec.rb | 49 +++ spec/ios_generate_stringsdict_from_po_spec.rb | 76 ++++ spec/ios_plural_rules_spec.rb | 89 ++++ spec/ios_stringsdict_helper_spec.rb | 378 +++++++++++++++++ .../stringsdict/Localizable.stringsdict | 49 +++ .../stringsdict/simple.stringsdict | 22 + 14 files changed, 2020 insertions(+) create mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_generate_pot_from_stringsdict.rb create mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_generate_stringsdict_from_po.rb create mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_plural_rules.rb create mode 100644 lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_stringsdict_helper.rb create mode 100644 rakelib/generate_ios_plural_rules.rb create mode 100644 rakelib/plural_rules_data/cldr_plurals.xml create mode 100644 rakelib/plural_rules_data/glotpress_nplurals.json create mode 100644 spec/ios_generate_pot_from_stringsdict_spec.rb create mode 100644 spec/ios_generate_stringsdict_from_po_spec.rb create mode 100644 spec/ios_plural_rules_spec.rb create mode 100644 spec/ios_stringsdict_helper_spec.rb create mode 100644 spec/test-data/translations/stringsdict/Localizable.stringsdict create mode 100644 spec/test-data/translations/stringsdict/simple.stringsdict diff --git a/CHANGELOG.md b/CHANGELOG.md index 420701b2f..310698760 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ _None_ ### New Features - Added `find_or_create_pull_request` action and `GithubHelper#find_pull_request`: returns the URL of the open Pull Request for a head branch, creating one only if none exists yet. Useful for "rolling" automations (e.g. a daily translations or dependency-update job) that force-push the same head branch on every run. [#733] +- Added `ios_generate_pot_from_stringsdict` and `ios_generate_stringsdict_from_po` actions to round-trip iOS `.stringsdict` plural files through a gettext `.po`/`.pot`-based translation system (e.g. GlotPress). The forward action turns an English `.stringsdict` into a `.pot` template (one `msgid`/`msgid_plural` per plural variable); the reverse rebuilds a per-locale `.stringsdict` from a translated `.po`, mapping gettext's indexed plural forms back to CLDR plural categories (`one`/`few`/`many`/…) via the new `Ios::PluralRules` table and using the English `.stringsdict` as a structural template. The forward action round-trips only the `one`/`other` forms; other CLDR categories in the source (e.g. a `zero` literal override) are dropped with a warning. The reverse action likewise warns when a locale's plural entry is only partially translated. [#739] ### Bug Fixes diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_generate_pot_from_stringsdict.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_generate_pot_from_stringsdict.rb new file mode 100644 index 000000000..18fd8887a --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_generate_pot_from_stringsdict.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require_relative '../../helper/ios/ios_stringsdict_helper' + +module Fastlane + module Actions + class IosGeneratePotFromStringsdictAction < Action + def self.run(params) + output_path = params[:output_path] + + UI.message "Generating `#{output_path}` from #{Array(params[:stringsdict_paths]).inspect}" + count = Fastlane::Helper::Ios::StringsdictHelper.generate_pot( + stringsdict_paths: params[:stringsdict_paths], + output_path: output_path + ) + + UI.success "Generated #{count} plural entr#{count == 1 ? 'y' : 'ies'} into `#{output_path}`." + count + end + + ##################################################### + # @!group Documentation + ##################################################### + + def self.description + 'Generate a gettext `.pot` template from one or more iOS `.stringsdict` plural files' + end + + def self.details + <<~DETAILS + Converts the plural rules declared in one or more (English source) `.stringsdict` + files into a gettext `.pot` template suitable for upload to a translation system + such as GlotPress. + + Each plural variable becomes one `msgid`/`msgid_plural` entry — the English `one` + form becomes the `msgid` and the `other` form becomes the `msgid_plural`. Entries + are keyed by a deterministic `msgctxt` (the `.stringsdict` key for single-variable + entries, or `key:variable` for entries that reference multiple plural variables). + + Only the `one` and `other` forms are converted. Any other CLDR category in the + source — including an explicit `zero` literal override (e.g. "No items" for a + count of 0) — has no gettext equivalent, so it is dropped from the `.pot` and a + warning is logged. Handle such count-specific messages as dedicated strings + selected in code (e.g. `if count == 0`) rather than as `zero`/`two`/… keys in a + `.stringsdict` bound for this pipeline. + + Use `ios_generate_stringsdict_from_po` to convert the translated `.po` files back + into per-locale `.stringsdict` files once translation is complete. + DETAILS + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new( + key: :stringsdict_paths, + env_name: 'FL_IOS_GENERATE_POT_FROM_STRINGSDICT_PATHS', + description: 'Path (String) or paths (Array of String) to the source `.stringsdict` file(s) to convert', + optional: false, + skip_type_validation: true, # Accept either a String or an Array of String + verify_block: proc do |value| + paths = Array(value) + UI.user_error!('You must provide at least one `.stringsdict` path') if paths.empty? + paths.each do |path| + UI.user_error!("Stringsdict file not found: #{path}") unless File.exist?(path) + end + end + ), + FastlaneCore::ConfigItem.new( + key: :output_path, + env_name: 'FL_IOS_GENERATE_POT_FROM_STRINGSDICT_OUTPUT_PATH', + description: 'The path of the `.pot` file to generate', + type: String, + optional: false + ), + ] + end + + def self.return_type + :int + end + + def self.return_value + 'The number of plural entries written to the `.pot` file' + end + + def self.authors + ['Automattic'] + end + + def self.is_supported?(platform) + %i[ios mac].include? platform + end + end + end +end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_generate_stringsdict_from_po.rb b/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_generate_stringsdict_from_po.rb new file mode 100644 index 000000000..382391b38 --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/actions/ios/ios_generate_stringsdict_from_po.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require_relative '../../helper/ios/ios_stringsdict_helper' + +module Fastlane + module Actions + class IosGenerateStringsdictFromPoAction < Action + def self.run(params) + output_path = params[:output_path] + + UI.message "Generating `#{output_path}` for locale `#{params[:locale]}` from `#{params[:po_path]}`" + missing = Fastlane::Helper::Ios::StringsdictHelper.generate_stringsdict_from_po( + po_path: params[:po_path], + template_path: params[:template_path], + locale: params[:locale], + output_path: output_path + ) + + missing.each do |context| + UI.important "No translation for `#{context}` in `#{params[:po_path]}` — kept the source (English) value." + end + UI.success "Generated `#{output_path}` for locale `#{params[:locale]}`." + missing + rescue Fastlane::Helper::Ios::PluralRules::UnknownLocaleError => e + # Locales with no vetted plural mapping (e.g. Welsh) surface as a clean + # user error — exclude them from the locales you convert. + UI.user_error!(e.message) + end + + ##################################################### + # @!group Documentation + ##################################################### + + def self.description + 'Generate a localized iOS `.stringsdict` plural file from a translated gettext `.po`' + end + + def self.details + <<~DETAILS + Converts a translated `.po` file (e.g. downloaded from GlotPress) back into an iOS + `.stringsdict` plural file for a single locale. + + The original English `.stringsdict` is required as a structural template: the `.po` + only carries the translated strings, while the format key, variable names and + format specifiers are copied from the template. The `.po`'s indexed `msgstr[N]` + plural forms are mapped back to CLDR plural-category names (`one`, `few`, `many`, …) + according to the locale's plural rules. + + This is the counterpart to `ios_generate_pot_from_stringsdict`. + DETAILS + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new( + key: :po_path, + env_name: 'FL_IOS_GENERATE_STRINGSDICT_FROM_PO_PO_PATH', + description: 'The path to the translated `.po` file for the locale', + type: String, + optional: false, + verify_block: proc do |value| + UI.user_error!("PO file not found: #{value}") unless File.exist?(value) + end + ), + FastlaneCore::ConfigItem.new( + key: :template_path, + env_name: 'FL_IOS_GENERATE_STRINGSDICT_FROM_PO_TEMPLATE_PATH', + description: 'The path to the original (English) `.stringsdict` to use as a structural template', + type: String, + optional: false, + verify_block: proc do |value| + UI.user_error!("Stringsdict template not found: #{value}") unless File.exist?(value) + end + ), + FastlaneCore::ConfigItem.new( + key: :locale, + env_name: 'FL_IOS_GENERATE_STRINGSDICT_FROM_PO_LOCALE', + description: "The locale code of the `.po` file (e.g. 'ru', 'pt-BR'), used to map plural indices to CLDR categories", + type: String, + optional: false + ), + FastlaneCore::ConfigItem.new( + key: :output_path, + env_name: 'FL_IOS_GENERATE_STRINGSDICT_FROM_PO_OUTPUT_PATH', + description: 'The path of the localized `.stringsdict` file to generate', + type: String, + optional: false + ), + ] + end + + def self.return_type + :array_of_strings + end + + def self.return_value + 'The list of translation contexts that had no translation in the `.po` (filled from the English source)' + end + + def self.authors + ['Automattic'] + end + + def self.is_supported?(platform) + %i[ios mac].include? platform + end + end + end +end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_plural_rules.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_plural_rules.rb new file mode 100644 index 000000000..8762753e3 --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_plural_rules.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +module Fastlane + module Helper + module Ios + # Maps a locale to the ordered list of CLDR plural categories that + # correspond to the indexed plural forms a **GlotPress** `.po` export + # carries for that locale (`msgstr[0]`, `msgstr[1]`, …). + # + # This is the bridge between two plural models: + # + # - **gettext** (`.po`/`.pot`, what GlotPress emits) addresses plural forms + # by numeric index. The number and meaning of those indices is defined by + # the locale's `Plural-Forms` formula and is *not* otherwise recorded. + # - **iOS `.stringsdict`** addresses plural forms by CLDR category *name* + # (`zero`, `one`, `two`, `few`, `many`, `other`). + # + # ## How this table is derived + # + # The pipeline consumes `.po` files produced *by GlotPress*, so the source + # of truth for how many forms exist (and in what order) is GlotPress's + # `GP_Locales` definition, **not** the latest CLDR — the two disagree for + # several locales (e.g. GlotPress keeps French/Spanish/Portuguese at two + # forms, while CLDR added a compact-number `many`). This table is generated + # by combining the two (see `rakelib/generate_ios_plural_rules.rb`): + # + # - **1 form** → `[other]` (the single category is always `other`). + # - **2 forms** → `[one, other]` (gettext's universal 2-form naming). + # - **3+ forms** → the locale's CLDR integer categories, in canonical + # order, **only when GlotPress's form count matches CLDR's**. When they + # disagree on a 3+-form locale (e.g. Welsh's legacy 4-form rule vs CLDR's + # 6 categories) the locale is omitted on purpose — see {INCOMPATIBLE_LOCALES} + # and {UnknownLocaleError}. Guessing would silently file a translation + # under the wrong category. + # + # Because the count comes from GlotPress, the reverse converter also reads + # `nplurals` from each `.po`'s own `Plural-Forms` header and asserts it + # matches this table — so if GlotPress ever changes a locale's plural rule, + # the run fails loudly (the signal to regenerate this table) rather than + # producing wrong output. See {StringsdictHelper.generate_stringsdict_from_po}. + # + # @note Plural categories are a property of the *language*, so region + # subtags are ignored (`pt-BR`/`pt-PT` → `pt`). Lookups fall back from the + # full code to the base language. + module PluralRules + # Raised when asked for the plural categories of a locale we don't have a + # vetted mapping for. Either the locale isn't in the table yet, or it's a + # known GlotPress/CLDR incompatibility (see {INCOMPATIBLE_LOCALES}). + class UnknownLocaleError < StandardError; end + + # Locales whose GlotPress plural rule cannot be honestly mapped to CLDR + # `.stringsdict` categories. Looked up before the table so we can raise a + # specific, actionable error instead of a generic "unknown locale". + INCOMPATIBLE_LOCALES = { + # GlotPress models Welsh with a legacy 4-form rule + # `(n==1)?0:(n==2)?1:(n!=8&&n!=11)?2:3` whose indices don't correspond + # to CLDR's six Welsh categories (zero/one/two/few/many/other), so there + # is no correct index→category mapping. + 'cy' => 'GlotPress uses a legacy 4-form Welsh plural rule that does not map to CLDR categories' + }.freeze + + # Locales grouped by their ordered category list, to keep the table + # compact and reviewable. Generated — do not hand-edit; regenerate via + # `rakelib/generate_ios_plural_rules.rb` (derived from GlotPress + # `GP_Locales` nplurals + Unicode CLDR `plurals.xml`). + CATEGORIES_BY_GROUP = { + %i[other].freeze => + %w[bo ja ka km ko lo ms su th uz vi zh].freeze, + %i[one other].freeze => + %w[af ak am an as ast az bal bg bho bm bn br brx ca ce ceb ckb cv da de dv ee el en eo es et eu + fa fi fo fr fur fy gl gsw gu ha haw he hi hu hy ia id is it jv kab kk kn ks lb lij mg mk ml + mn mr nb ne nl nn no nqo nso os pa pap pcm ps pt sah scn si so sq sv sw syr ta te tg tl tr + tzm ug ur vec wa yi].freeze, + %i[one few many].freeze => + %w[pl ru uk].freeze, + %i[one few other].freeze => + %w[bs cs hr lt ro sk sr].freeze, + %i[zero one other].freeze => + %w[lv].freeze, + %i[one two few other].freeze => + %w[dsb gd hsb sl].freeze, + %i[one two few many other].freeze => + %w[ga].freeze, + %i[zero one two few many other].freeze => + %w[ar].freeze + }.freeze + + # Locale (normalized) => ordered CLDR plural categories. + CATEGORIES_BY_LOCALE = CATEGORIES_BY_GROUP.each_with_object({}) do |(categories, locales), hash| + locales.each { |locale| hash[locale] = categories } + end.freeze + + # The CLDR plural categories for a locale, ordered to match the GlotPress + # `.po`'s `msgstr[N]` indices. + # + # @param [String] locale A locale code, e.g. `"en"`, `"pt-BR"`, `"ru"`. + # @return [Array] Ordered categories, e.g. `%i[one few many]`. + # @raise [UnknownLocaleError] if the locale (and its base language) has no + # vetted mapping, or is a known GlotPress/CLDR incompatibility. + def self.categories_for(locale) + key = normalize(locale) + base = key.split('-').first + + incompatible = INCOMPATIBLE_LOCALES[key] || INCOMPATIBLE_LOCALES[base] + raise(UnknownLocaleError, "No plural-category mapping for locale '#{locale}': #{incompatible}.") if incompatible + + CATEGORIES_BY_LOCALE[key] || + CATEGORIES_BY_LOCALE[base] || + raise(UnknownLocaleError, "No plural-category mapping for locale '#{locale}'. " \ + 'Add it to Fastlane::Helper::Ios::PluralRules by regenerating the table ' \ + '(rakelib/generate_ios_plural_rules.rb) from GlotPress GP_Locales + CLDR.') + end + + # Whether a vetted mapping exists for the given locale. + # @param [String] locale A locale code. + # @return [Boolean] + def self.supported?(locale) + key = normalize(locale) + base = key.split('-').first + return false if INCOMPATIBLE_LOCALES.key?(key) || INCOMPATIBLE_LOCALES.key?(base) + + CATEGORIES_BY_LOCALE.key?(key) || CATEGORIES_BY_LOCALE.key?(base) + end + + # Normalize a locale code to the table's key form: lowercase, with `_` + # treated as `-` (so `pt_BR` and `pt-BR` are equivalent). + # @param [String] locale + # @return [String] + def self.normalize(locale) + locale.to_s.strip.downcase.tr('_', '-') + end + end + end + end +end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_stringsdict_helper.rb b/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_stringsdict_helper.rb new file mode 100644 index 000000000..6e2c278c3 --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_stringsdict_helper.rb @@ -0,0 +1,381 @@ +# frozen_string_literal: true + +require 'plist' +require 'gettext/po' +require 'gettext/po_entry' +require 'gettext/po_parser' +require_relative 'ios_plural_rules' +require_relative '../../version' + +module Fastlane + module Helper + module Ios + # Converts between iOS `.stringsdict` plural files and gettext `.po`/`.pot` + # files, so that string pluralization can round-trip through a translation + # system (e.g. GlotPress) that speaks gettext but not `.stringsdict`. + # + # **Forward** ({generate_pot}): an English `.stringsdict` becomes a `.pot` + # template. Each plural variable becomes one `msgid`/`msgid_plural` entry + # (English `one` → `msgid`, English `other` → `msgid_plural`), keyed by a + # deterministic `msgctxt`. Only `one`/`other` map to gettext; any other CLDR + # category present in the source — most notably an explicit `zero` literal + # override (which iOS honors even for English, e.g. "No items" for a count + # of 0) — has no gettext slot and is dropped from the `.pot` with a warning. + # + # **Reverse** ({generate_stringsdict_from_po}): a translated `.po` for a + # locale, plus the original English `.stringsdict` as a structural template, + # become a localized `.stringsdict`. The `.po`'s indexed `msgstr[N]` forms + # are mapped back to CLDR category names (`one`, `few`, `many`, …) using + # {PluralRules}; everything that isn't a translatable plural form (the + # format key, variable names, spec/value types) is copied from the template. + # + # @note The reverse direction reuses the **same English `.stringsdict`** that + # produced the `.pot`. The `.po` only carries the translatable strings; the + # structure comes from the template (mirroring how the Android downloader + # uses the original XML as a template). + class StringsdictHelper + # `.stringsdict` plist keys + FORMAT_KEY = 'NSStringLocalizedFormatKey' + SPEC_TYPE_KEY = 'NSStringFormatSpecTypeKey' + VALUE_TYPE_KEY = 'NSStringFormatValueTypeKey' + PLURAL_RULE_TYPE = 'NSStringPluralRuleType' + + # CLDR plural categories, in canonical emit order. + CLDR_CATEGORIES = %w[zero one two few many other].freeze + + # gettext separates the plural forms of a single `POEntry#msgstr` with a + # NUL byte (`msgstr[0]\0msgstr[1]\0…`). + PLURAL_SEPARATOR = 0.chr + + # =================================================================== + # I/O + # =================================================================== + + # Read a `.stringsdict` file into its Hash representation. + # + # @param [String] path The path to the `.stringsdict` file. + # @return [Hash] The parsed plist dictionary. + # @raise [FastlaneCore::Interface::FastlaneError] If the file is missing, is not a plist + # dictionary, or contains an entry whose value is not a dictionary. + def self.read(path:) + UI.user_error!("Stringsdict file not found: #{path}") unless File.exist?(path) + + data = Plist.parse_xml(path) + UI.user_error!("Invalid stringsdict file (expected a plist dictionary at the root): #{path}") unless data.is_a?(Hash) + + data.each do |key, value| + UI.user_error!("Invalid stringsdict file (entry '#{key}' is not a dictionary): #{path}") unless value.is_a?(Hash) + end + + data + end + + # Write a Hash representation to a `.stringsdict` file in XML-plist format. + # + # @param [Hash] data The plist dictionary to serialize. + # @param [String] path The destination path. + def self.write(data:, path:) + File.write(path, Plist::Emit.dump(data)) + end + + # =================================================================== + # Forward: .stringsdict -> .pot + # =================================================================== + + # Generate a gettext `.pot` template from one or more English + # `.stringsdict` files. + # + # @param [String, Array] stringsdict_paths One or more paths to + # source `.stringsdict` files. + # @param [String] output_path The `.pot` file to write. + # @return [Integer] The number of plural entries written. + # @raise [FastlaneCore::Interface::FastlaneError] If two entries would produce the same `msgctxt`. + def self.generate_pot(stringsdict_paths:, output_path:) + po = GetText::PO.new + po.order = :none + add_header(po) + + entries = [] + seen_contexts = {} + Array(stringsdict_paths).each do |path| + read(path: path).each do |key, entry_dict| + variables = plural_variables(entry_dict) + single = variables.size == 1 + variables.each do |var_name, var_dict| + context = context_for(key: key, variable: var_name, single_variable: single) + if seen_contexts.key?(context) + UI.user_error!("Duplicate translation context '#{context}' generated from `#{path}` (also produced by " \ + "`#{seen_contexts[context]}`). Stringsdict keys must be unique across the files being converted.") + end + seen_contexts[context] = path + entries << build_pot_entry(context: context, var_dict: var_dict, key: key, variable: var_name) + end + end + end + + entries.sort_by(&:msgctxt).each { |entry| po[entry.msgctxt, entry.msgid] = entry } + + # GetText::PO#to_s does not add a trailing newline. + File.write(output_path, "#{po}\n") + entries.count + end + + # =================================================================== + # Reverse: .po + template -> localized .stringsdict + # =================================================================== + + # Generate a localized `.stringsdict` from a translated `.po` plus the + # English `.stringsdict` used as a structural template. + # + # @param [String] po_path The translated `.po` file for the locale. + # @param [String] template_path The original English `.stringsdict`. + # @param [String] locale The locale of the `.po` (e.g. `"ru"`, `"pt-BR"`), + # used to map `msgstr[N]` indices back to CLDR plural categories. + # @param [String] output_path The localized `.stringsdict` to write. + # @return [Array] Contexts present in the template for which the + # `.po` had no usable translation (filled from English as fallback). + # @raise [PluralRules::UnknownLocaleError] If the locale has no mapping. + # @raise [FastlaneCore::Interface::FastlaneError] If a translated entry's form count + # doesn't match the locale's expected plural-category count. + def self.generate_stringsdict_from_po(po_path:, template_path:, locale:, output_path:) + template = read(path: template_path) + po = parse_po(po_path) + categories = Fastlane::Helper::Ios::PluralRules.categories_for(locale) + + # GlotPress is the source of truth for how many plural forms a locale + # has. If the .po's own declared count disagrees with our mapping, + # GlotPress has changed its plural rule for this locale — fail loud + # (the signal to regenerate PluralRules) rather than silently mis-map. + guard_po_plural_count!(parsed_po: po, locale: locale, categories: categories) + + missing = [] + result = {} + template.each do |key, entry_dict| + variables = plural_variables(entry_dict) + if variables.empty? + # Not a plural entry — copy verbatim. + result[key] = entry_dict + next + end + + single = variables.size == 1 + localized = {} + localized[FORMAT_KEY] = entry_dict[FORMAT_KEY] if entry_dict.key?(FORMAT_KEY) + variables.each do |var_name, var_dict| + context = context_for(key: key, variable: var_name, single_variable: single) + forms = translated_forms(parsed_po: po, context: context, source_var: var_dict) + if forms.empty? + missing << context + localized[var_name] = english_variable(var_dict) + else + validate_form_count!(forms: forms, categories: categories, context: context, locale: locale) + warn_partial_translation(context: context, locale: locale, categories: categories, forms: forms) + localized[var_name] = localized_variable(source_var: var_dict, categories: categories, forms: forms) + end + end + result[key] = localized + end + + write(data: result, path: output_path) + missing + end + + # =================================================================== + # Helpers + # =================================================================== + + # The plural variables of a `.stringsdict` entry: every sub-dictionary + # (i.e. not the format-key string) whose spec type is the plural rule. + # + # @param [Hash] entry_dict A single `.stringsdict` entry. + # @return [Hash{String=>Hash}] variable name => variable dictionary. + def self.plural_variables(entry_dict) + entry_dict.select do |k, v| + k != FORMAT_KEY && v.is_a?(Hash) && v[SPEC_TYPE_KEY] == PLURAL_RULE_TYPE + end + end + + # The deterministic gettext `msgctxt` for a stringsdict key/variable. + # Single-variable entries use the bare key (cleaner for translators); + # multi-variable entries disambiguate with the variable name. Both the + # forward and reverse directions call this, so the exact format is an + # internal detail and never parsed back. + # + # @return [String] + def self.context_for(key:, variable:, single_variable:) + single_variable ? key : "#{key}:#{variable}" + end + + # The English string that becomes a variable's gettext `msgid`: the `one` + # form when it has content, otherwise `other`. Forward and reverse both + # use this, so the `msgid` written to the `.pot` matches the one later + # looked up in the translated `.po` — and an empty `one` no longer yields + # an empty `msgid` (which gettext drops, silently losing the entry). + def self.msgid_for(var_dict) + one = var_dict['one'] + one.nil? || one.empty? ? var_dict['other'] : one + end + + def self.build_pot_entry(context:, var_dict:, key:, variable:) + singular = msgid_for(var_dict) + plural = var_dict['other'] + if singular.nil? || singular.empty? || plural.nil? || plural.empty? + UI.user_error!("Stringsdict entry '#{key}' is missing a required plural form " \ + "(needs a non-empty 'other'; 'one' recommended for the singular).") + end + + warn_dropped_categories(key: key, variable: variable, var_dict: var_dict) + + entry = GetText::POEntry.new(:msgctxt_plural) + entry.msgctxt = context + entry.msgid = singular + entry.msgid_plural = plural + # A template carries empty translations; English source has 2 forms. + entry.msgstr = ['', ''].join(PLURAL_SEPARATOR) + entry + end + + # Only `one`/`other` survive the gettext round-trip (as `msgid`/ + # `msgid_plural`). Any other CLDR category in the source variable — most + # commonly a `zero` literal override, which iOS honors even for English + # (Apple's docs: an English `zero` returns "No homes found" for `0`) — has + # no gettext equivalent and is dropped from the `.pot`. Warn so it isn't + # lost silently. + def self.warn_dropped_categories(key:, variable:, var_dict:) + dropped = (CLDR_CATEGORIES - %w[one other]) & var_dict.keys + return if dropped.empty? + + UI.important( + "Stringsdict entry '#{key}' (variable '#{variable}') has plural form(s) " \ + "[#{dropped.join(', ')}] that gettext can't represent — they will be dropped " \ + "from the `.pot` and won't be translated. Only 'one' and 'other' round-trip; " \ + "handle a count-specific message (e.g. a 'zero'/empty-state string) as a dedicated " \ + 'string selected in code (`if count == 0`), not as a plural key in a `.stringsdict` ' \ + 'bound for this pipeline.' + ) + end + + # The translated plural forms for a context, or `[]` if untranslated. + def self.translated_forms(parsed_po:, context:, source_var:) + entry = parsed_po[context, msgid_for(source_var)] + return [] if entry.nil? || entry.msgstr.nil? + + forms = entry.msgstr.split(PLURAL_SEPARATOR, -1) + # An entry present but fully empty (no translation yet) counts as missing. + return [] if forms.all? { |f| f.nil? || f.empty? } + + forms + end + + def self.validate_form_count!(forms:, categories:, context:, locale:) + return if forms.size == categories.size + + UI.user_error!("Translation for '#{context}' has #{forms.size} plural form(s) but locale '#{locale}' " \ + "expects #{categories.size} (#{categories.join(', ')}). The translation system's plural " \ + 'configuration for this locale does not match the expected CLDR categories.') + end + + # A translated entry that has some — but not all — of its plural forms + # filled still passes the count check. The blank categories are omitted + # from the `.stringsdict`, so iOS falls back to another form for those + # counts, silently showing the wrong plural. Surface it the way the + # forward path surfaces dropped categories, rather than shipping it. + def self.warn_partial_translation(context:, locale:, categories:, forms:) + blank = categories.each_index.select { |i| forms[i].nil? || forms[i].to_s.empty? }.map { |i| categories[i] } + return if blank.empty? + + UI.important( + "Translation for '#{context}' (locale '#{locale}') is incomplete — the " \ + "#{blank.join(', ')} plural form(s) are untranslated; those counts will fall " \ + 'back to another form. Finish the translation so every count shows the right plural.' + ) + end + + # Build a localized variable dictionary by mapping indexed forms to CLDR + # categories and copying structure from the source variable. + def self.localized_variable(source_var:, categories:, forms:) + by_category = {} + categories.each_with_index do |category, index| + value = forms[index] + by_category[category.to_s] = value unless value.nil? || value.empty? + end + + # `.stringsdict` requires the `other` category. When the locale's + # gettext forms don't include it (e.g. ru/pl have one/few/many), fall + # back to the last (catch-all) translated form. + by_category['other'] ||= forms.reject { |f| f.nil? || f.empty? }.last + + variable = {} + variable[SPEC_TYPE_KEY] = source_var[SPEC_TYPE_KEY] || PLURAL_RULE_TYPE + variable[VALUE_TYPE_KEY] = source_var[VALUE_TYPE_KEY] if source_var.key?(VALUE_TYPE_KEY) + CLDR_CATEGORIES.each do |category| + variable[category] = by_category[category] if by_category.key?(category) + end + variable + end + + # A copy of the source (English) variable, used as a fallback when a + # translation is missing so the output `.stringsdict` stays valid. + def self.english_variable(source_var) + variable = {} + variable[SPEC_TYPE_KEY] = source_var[SPEC_TYPE_KEY] || PLURAL_RULE_TYPE + variable[VALUE_TYPE_KEY] = source_var[VALUE_TYPE_KEY] if source_var.key?(VALUE_TYPE_KEY) + CLDR_CATEGORIES.each do |category| + variable[category] = source_var[category] if source_var.key?(category) + end + variable + end + + def self.parse_po(path) + UI.user_error!("PO file not found: #{path}") unless File.exist?(path) + + po = GetText::PO.new + GetText::POParser.new.parse(File.read(path), po) + po + end + + # The `nplurals` value declared in the `.po`'s `Plural-Forms` header + # (GlotPress's authoritative form count for the locale), or `nil` if the + # header is absent. Read from the parsed header entry, whose `msgstr` + # holds only the header fields — so a stray `nplurals=` token elsewhere + # in the file (a comment, a translated string) can't be mistaken for it. + def self.declared_nplurals(parsed_po) + header = parsed_po[nil, ''] + return nil if header.nil? || header.msgstr.nil? + + match = header.msgstr.match(/nplurals\s*=\s*(\d+)/) + match && Integer(match[1]) + end + + # Fail loud if the `.po`'s declared plural-form count doesn't match the + # number of CLDR categories we expect for the locale — i.e. GlotPress's + # plural rule has drifted from {PluralRules}. + def self.guard_po_plural_count!(parsed_po:, locale:, categories:) + declared = declared_nplurals(parsed_po) + return if declared.nil? || declared == categories.size + + UI.user_error!("Locale '#{locale}': the .po declares nplurals=#{declared}, but the expected plural mapping has " \ + "#{categories.size} categor#{categories.size == 1 ? 'y' : 'ies'} (#{categories.join(', ')}). " \ + "GlotPress's plural rule for this locale no longer matches the PluralRules table — regenerate it " \ + '(rakelib/generate_ios_plural_rules.rb).') + end + + def self.add_header(po_data) + generator = "#{Fastlane::Wpmreleasetoolkit::NAME} #{Fastlane::Wpmreleasetoolkit::VERSION}" + header_content = <<~HEADER + MIME-Version: 1.0 + Content-Type: text/plain; charset=UTF-8 + Content-Transfer-Encoding: 8bit + Plural-Forms: nplurals=2; plural=n != 1; + X-Generator: #{generator} + HEADER + + header = GetText::POEntry.new(:normal) + header.msgid = '' + header.msgstr = header_content + po_data[header.msgctxt, header.msgid] = header + end + end + end + end +end diff --git a/rakelib/generate_ios_plural_rules.rb b/rakelib/generate_ios_plural_rules.rb new file mode 100644 index 000000000..6a57f3406 --- /dev/null +++ b/rakelib/generate_ios_plural_rules.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +# Regenerates the locale → CLDR-plural-category table baked into +# `lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_plural_rules.rb`. +# +# The `.stringsdict ⇆ .po` pipeline consumes `.po` files produced by GlotPress, +# so GlotPress's `GP_Locales` is the source of truth for *how many* plural forms +# a locale has; Unicode CLDR is the source of truth for the *names* of those +# categories. This script combines the two: +# +# - 1 form → [other] +# - 2 forms → [one, other] (gettext's universal 2-form naming) +# - 3+ forms → the locale's CLDR integer categories (canonical order), +# ONLY when GlotPress's form count equals CLDR's. When +# they disagree (e.g. Welsh's legacy 4-form rule vs CLDR's +# 6 categories) the locale is omitted on purpose — the +# converter then fails loud rather than guessing. +# +# A locale is also omitted when GlotPress addresses it under a different slug +# than CLDR (e.g. Belarusian is `bel` in GlotPress vs `be` in CLDR) — see +# `nplurals_for`. These too fail loud rather than mis-map; none are in SHIPPED. +# +# Inputs are vendored under rakelib/plural_rules_data/ for offline reproducibility: +# - cldr_plurals.xml — Unicode CLDR common/supplemental/plurals.xml +# - glotpress_nplurals.json — { slug => nplurals } from GlotPress GP_Locales +# +# Usage: +# bundle exec ruby rakelib/generate_ios_plural_rules.rb +# +# Review the printed CATEGORIES_BY_GROUP block, then paste it into +# ios_plural_rules.rb. Re-run whenever CLDR or GlotPress's plural data changes +# (the reverse converter's `.po` count-guard tells you when that has happened). + +require 'json' +require 'nokogiri' + +DATA_DIR = File.join(__dir__, 'plural_rules_data') +CANONICAL_ORDER = %w[zero one two few many other].freeze +# Locales the apps ship, for a coverage report. +SHIPPED = %w[ar bg cs cy da de en es fr he hr hu id is it ja ko nb nl pl pt ro ru sk sq sv th tr zh].freeze + +# A CLDR category is a "counting" category if it has an @integer sample whose +# smallest value is <= 100 — this excludes compact-only categories (Romance +# `many`, sampled only at 1000000/1c6) and decimal-only categories. +def counting_category?(rule_text) + m = rule_text.match(/@integer([^@]*)/) + return false unless m + + m[1].split(',').any? do |tok| + tok = tok.strip.chomp('…').strip + next false if tok.empty? || tok.include?('…') || tok.include?('c') || tok.include?('e') + + Integer(tok.split('~').first, exception: false).then { |v| v && v <= 100 } + end +end + +def cldr_integer_categories + doc = Nokogiri::XML(File.read(File.join(DATA_DIR, 'cldr_plurals.xml'))) + table = {} + doc.xpath('//plurals[@type="cardinal"]/pluralRules').each do |group| + cats = group.xpath('./pluralRule').select { |r| counting_category?(r.text) }.map { |r| r['count'] } + ordered = CANONICAL_ORDER.select { |c| cats.include?(c) } + group['locales'].split.each { |loc| table[loc] = ordered } + end + table +end + +def glotpress_nplurals + JSON.parse(File.read(File.join(DATA_DIR, 'glotpress_nplurals.json'))) +end + +# GlotPress's plural-form count for a CLDR locale code. CLDR and GlotPress +# usually agree on slugs, but not always: GlotPress addresses some languages by +# an ISO-639-2/3 code where CLDR uses the 639-1 code (Belarusian is `bel` in +# GlotPress vs `be` in CLDR; likewise `mya`/`my`, `dzo`/`dz`, `wol`/`wo`, …). We +# only try the CLDR code and its regional variants, so those locales return +# `nil` here and are omitted from the table — they then fail loud +# (UnknownLocaleError) at runtime rather than being mis-mapped. None are in the +# apps' shipped set today; to support one, add a vetted CLDR→GlotPress alias +# here — a literal mapping, NOT a prefix match (`sc`→`scn` is Sardinian vs +# Sicilian, `ve`→`vec` is Venda vs Venetian, so prefix-guessing is unsafe). +def nplurals_for(base, glotpress) + glotpress[base] || glotpress[glotpress.keys.sort.find { |slug| slug.start_with?("#{base}-") }] +end + +cldr = cldr_integer_categories +glotpress = glotpress_nplurals + +table = {} +omitted = [] +cldr.sort.each do |locale, cats| + next if locale.include?('_') || locale == 'root' + + n = nplurals_for(locale, glotpress) + if n.nil? + omitted << [locale, 'not in GlotPress', cats] + elsif n == 1 + table[locale] = %w[other] + elsif n == 2 + table[locale] = %w[one other] + elsif n == cats.size + table[locale] = cats + else + omitted << [locale, "GlotPress #{n} != CLDR #{cats.size}", cats] + end +end + +groups = table.group_by { |_locale, cats| cats }.transform_values { |pairs| pairs.map(&:first).sort } + +puts '# --- paste into ios_plural_rules.rb (CATEGORIES_BY_GROUP) ---' +puts 'CATEGORIES_BY_GROUP = {' +groups.keys.sort_by { |cats| [cats.size, cats] }.each do |cats| + syms = "%i[#{cats.join(' ')}].freeze" + locales = "%w[#{groups[cats].join(' ')}].freeze" + puts " #{syms} =>\n #{locales}," +end +puts '}.freeze' + +puts "\n# --- report ---" +puts "# generated #{table.size} locales, omitted #{omitted.size} (fail loud at runtime)" +shipped_omitted = omitted.map(&:first) & SHIPPED +puts "# shipped locales omitted: #{shipped_omitted.empty? ? '(none)' : shipped_omitted.join(', ')}" +SHIPPED.each do |loc| + mapped = table[loc] ? table[loc].join('/') : 'OMITTED (fail loud)' + puts "# #{loc.ljust(4)} #{mapped}" +end diff --git a/rakelib/plural_rules_data/cldr_plurals.xml b/rakelib/plural_rules_data/cldr_plurals.xml new file mode 100644 index 000000000..2c165ec36 --- /dev/null +++ b/rakelib/plural_rules_data/cldr_plurals.xml @@ -0,0 +1,258 @@ + + + + + + + + + + + + @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + + + + i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04 + @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + i = 0,1 @integer 0, 1 @decimal 0.0~1.5 + @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + i = 1 and v = 0 @integer 1 + @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n = 0,1 or i = 0 and f = 1 @integer 0, 1 @decimal 0.0, 0.1, 1.0, 0.00, 0.01, 1.00, 0.000, 0.001, 1.000, 0.0000, 0.0001, 1.0000 + @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.2~0.9, 1.1~1.8, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000 + @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n = 0..1 or n = 11..99 @integer 0, 1, 11~24 @decimal 0.0, 1.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 21.0, 22.0, 23.0, 24.0 + @integer 2~10, 100~106, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 + @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n = 1 or t != 0 and i = 0,1 @integer 1 @decimal 0.1~1.6 + @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 2.0~3.4, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + t = 0 and i % 10 = 1 and i % 100 != 11 or t % 10 = 1 and t % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.0, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, … + @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.2~0.9, 1.2~1.8, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, … + @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.2~1.0, 1.2~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + v = 0 and i = 1,2,3 or v = 0 and i % 10 != 4,6,9 or v != 0 and f % 10 != 4,6,9 @integer 0~3, 5, 7, 8, 10~13, 15, 17, 18, 20, 21, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.3, 0.5, 0.7, 0.8, 1.0~1.3, 1.5, 1.7, 1.8, 2.0, 2.1, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + @integer 4, 6, 9, 14, 16, 19, 24, 26, 104, 1004, … @decimal 0.4, 0.6, 0.9, 1.4, 1.6, 1.9, 2.4, 2.6, 10.4, 100.4, 1000.4, … + + + + + + n % 10 = 0 or n % 100 = 11..19 or v = 2 and f % 100 = 11..19 @integer 0, 10~20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + n % 10 = 1 and n % 100 != 11 or v = 2 and f % 10 = 1 and f % 100 != 11 or v != 2 and f % 10 = 1 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.0, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, … + @integer 2~9, 22~29, 102, 1002, … @decimal 0.2~0.9, 1.2~1.9, 10.2, 100.2, 1000.2, … + + + n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000 + i = 0,1 and n != 0 @integer 1 @decimal 0.1~1.6 + @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000 + n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 + @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + + + + i = 1 and v = 0 or i = 0 and v != 0 @integer 1 @decimal 0.0~0.9, 0.00~0.05 + i = 2 and v = 0 @integer 2 + @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.0~2.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 + n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000 + @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + + + + i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04 + n = 2..10 @integer 2~10 @decimal 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 2.00, 3.00, 4.00, 5.00, 6.00, 7.00, 8.00 + @integer 11~26, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~1.9, 2.1~2.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + i = 1 and v = 0 @integer 1 + v != 0 or n = 0 or n != 1 and n % 100 = 1..19 @integer 0, 2~16, 101, 1001, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + @integer 20~35, 100, 1000, 10000, 100000, 1000000, … + + + v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, … + v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … @decimal 0.2~0.4, 1.2~1.4, 2.2~2.4, 3.2~3.4, 4.2~4.4, 5.2, 10.2, 100.2, 1000.2, … + @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + + + + i = 0,1 @integer 0, 1 @decimal 0.0~1.5 + e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, … + @integer 2~17, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, … + + + i = 0..1 @integer 0, 1 @decimal 0.0~1.5 + e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, … + @integer 2~17, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, … + + + i = 1 and v = 0 @integer 1 + e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, … + @integer 0, 2~16, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, … + + + n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 + e = 0 and i != 0 and i % 1000000 = 0 and v = 0 or e != 0..5 @integer 1000000, 1c6, 2c6, 3c6, 4c6, 5c6, 6c6, … @decimal 1.0000001c6, 1.1c6, 2.0000001c6, 2.1c6, 3.0000001c6, 3.1c6, … + @integer 0, 2~16, 100, 1000, 10000, 100000, 1c3, 2c3, 3c3, 4c3, 5c3, 6c3, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, 1.0001c3, 1.1c3, 2.0001c3, 2.1c3, 3.0001c3, 3.1c3, … + + + + + + n = 1,11 @integer 1, 11 @decimal 1.0, 11.0, 1.00, 11.00, 1.000, 11.000, 1.0000 + n = 2,12 @integer 2, 12 @decimal 2.0, 12.0, 2.00, 12.00, 2.000, 12.000, 2.0000 + n = 3..10,13..19 @integer 3~10, 13~19 @decimal 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 3.00 + @integer 0, 20~34, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + v = 0 and i % 100 = 1 @integer 1, 101, 201, 301, 401, 501, 601, 701, 1001, … + v = 0 and i % 100 = 2 @integer 2, 102, 202, 302, 402, 502, 602, 702, 1002, … + v = 0 and i % 100 = 3..4 or v != 0 @integer 3, 4, 103, 104, 203, 204, 303, 304, 403, 404, 503, 504, 603, 604, 703, 704, 1003, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … + + + v = 0 and i % 100 = 1 or f % 100 = 1 @integer 1, 101, 201, 301, 401, 501, 601, 701, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, … + v = 0 and i % 100 = 2 or f % 100 = 2 @integer 2, 102, 202, 302, 402, 502, 602, 702, 1002, … @decimal 0.2, 1.2, 2.2, 3.2, 4.2, 5.2, 6.2, 7.2, 10.2, 100.2, 1000.2, … + v = 0 and i % 100 = 3..4 or f % 100 = 3..4 @integer 3, 4, 103, 104, 203, 204, 303, 304, 403, 404, 503, 504, 603, 604, 703, 704, 1003, … @decimal 0.3, 0.4, 1.3, 1.4, 2.3, 2.4, 3.3, 3.4, 4.3, 4.4, 5.3, 5.4, 6.3, 6.4, 7.3, 7.4, 10.3, 100.3, 1000.3, … + @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + + + + i = 1 and v = 0 @integer 1 + i = 2..4 and v = 0 @integer 2~4 + v != 0 @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … + + + i = 1 and v = 0 @integer 1 + v = 0 and i % 10 = 2..4 and i % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … + v = 0 and i != 1 and i % 10 = 0..1 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 12..14 @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … + @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n % 10 = 1 and n % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 71.0, 81.0, 101.0, 1001.0, … + n % 10 = 2..4 and n % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … @decimal 2.0, 3.0, 4.0, 22.0, 23.0, 24.0, 32.0, 33.0, 102.0, 1002.0, … + n % 10 = 0 or n % 10 = 5..9 or n % 100 = 11..14 @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.1, 1000.1, … + + + n % 10 = 1 and n % 100 != 11..19 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 71.0, 81.0, 101.0, 1001.0, … + n % 10 = 2..9 and n % 100 != 11..19 @integer 2~9, 22~29, 102, 1002, … @decimal 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 22.0, 102.0, 1002.0, … + f != 0 @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.1, 1000.1, … + @integer 0, 10~20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + v = 0 and i % 10 = 1 and i % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … + v = 0 and i % 10 = 2..4 and i % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … + v = 0 and i % 10 = 0 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 11..14 @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … + @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + + + + n % 10 = 1 and n % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 71.0, 81.0, 101.0, 1001.0, … + n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000 + n != 2 and n % 10 = 2..9 and n % 100 != 11..19 @integer 3~9, 22~29, 32, 102, 1002, … @decimal 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 22.0, 102.0, 1002.0, … + f != 0 @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.1, 1000.1, … + @integer 0, 10~20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n % 10 = 1 and n % 100 != 11,71,91 @integer 1, 21, 31, 41, 51, 61, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 81.0, 101.0, 1001.0, … + n % 10 = 2 and n % 100 != 12,72,92 @integer 2, 22, 32, 42, 52, 62, 82, 102, 1002, … @decimal 2.0, 22.0, 32.0, 42.0, 52.0, 62.0, 82.0, 102.0, 1002.0, … + n % 10 = 3..4,9 and n % 100 != 10..19,70..79,90..99 @integer 3, 4, 9, 23, 24, 29, 33, 34, 39, 43, 44, 49, 103, 1003, … @decimal 3.0, 4.0, 9.0, 23.0, 24.0, 29.0, 33.0, 34.0, 103.0, 1003.0, … + n != 0 and n % 1000000 = 0 @integer 1000000, … @decimal 1000000.0, 1000000.00, 1000000.000, 1000000.0000, … + @integer 0, 5~8, 10~20, 100, 1000, 10000, 100000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, … + + + n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 + n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000 + n = 0 or n % 100 = 3..10 @integer 0, 3~10, 103~109, 1003, … @decimal 0.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 103.0, 1003.0, … + n % 100 = 11..19 @integer 11~19, 111~117, 1011, … @decimal 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 111.0, 1011.0, … + @integer 20~35, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 + n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000 + n = 3..6 @integer 3~6 @decimal 3.0, 4.0, 5.0, 6.0, 3.00, 4.00, 5.00, 6.00, 3.000, 4.000, 5.000, 6.000, 3.0000, 4.0000, 5.0000, 6.0000 + n = 7..10 @integer 7~10 @decimal 7.0, 8.0, 9.0, 10.0, 7.00, 8.00, 9.00, 10.00, 7.000, 8.000, 9.000, 10.000, 7.0000, 8.0000, 9.0000, 10.0000 + @integer 0, 11~25, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + v = 0 and i % 10 = 1 @integer 1, 11, 21, 31, 41, 51, 61, 71, 101, 1001, … + v = 0 and i % 10 = 2 @integer 2, 12, 22, 32, 42, 52, 62, 72, 102, 1002, … + v = 0 and i % 100 = 0,20,40,60,80 @integer 0, 20, 40, 60, 80, 100, 120, 140, 1000, 10000, 100000, 1000000, … + v != 0 @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + @integer 3~10, 13~19, 23, 103, 1003, … + + + + + + n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000 + n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 + n % 100 = 2,22,42,62,82 or n % 1000 = 0 and n % 100000 = 1000..20000,40000,60000,80000 or n != 0 and n % 1000000 = 100000 @integer 2, 22, 42, 62, 82, 102, 122, 142, 1000, 10000, 100000, … @decimal 2.0, 22.0, 42.0, 62.0, 82.0, 102.0, 122.0, 142.0, 1000.0, 10000.0, 100000.0, … + n % 100 = 3,23,43,63,83 @integer 3, 23, 43, 63, 83, 103, 123, 143, 1003, … @decimal 3.0, 23.0, 43.0, 63.0, 83.0, 103.0, 123.0, 143.0, 1003.0, … + n != 1 and n % 100 = 1,21,41,61,81 @integer 21, 41, 61, 81, 101, 121, 141, 161, 1001, … @decimal 21.0, 41.0, 61.0, 81.0, 101.0, 121.0, 141.0, 161.0, 1001.0, … + @integer 4~19, 100, 1004, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.1, 1000000.0, … + + + n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000 + n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 + n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000 + n % 100 = 3..10 @integer 3~10, 103~110, 1003, … @decimal 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 103.0, 1003.0, … + n % 100 = 11..99 @integer 11~26, 111, 1011, … @decimal 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 111.0, 1011.0, … + @integer 100~102, 200~202, 300~302, 400~402, 500~502, 600, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000 + n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000 + n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000 + n = 3 @integer 3 @decimal 3.0, 3.00, 3.000, 3.0000 + n = 6 @integer 6 @decimal 6.0, 6.00, 6.000, 6.0000 + @integer 4, 5, 7~20, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, … + + + diff --git a/rakelib/plural_rules_data/glotpress_nplurals.json b/rakelib/plural_rules_data/glotpress_nplurals.json new file mode 100644 index 000000000..6cf1aa9a8 --- /dev/null +++ b/rakelib/plural_rules_data/glotpress_nplurals.json @@ -0,0 +1,252 @@ +{ +"aa": 2, +"ae": 2, +"af": 2, +"ak": 2, +"am": 2, +"an": 2, +"ar": 6, +"arq": 6, +"art-xemoji": 1, +"ary": 6, +"as": 2, +"ast": 2, +"av": 2, +"ay": 1, +"az": 2, +"az-tr": 2, +"azb": 2, +"ba": 2, +"bal": 2, +"bcc": 1, +"bel": 3, +"bg": 2, +"bgn": 2, +"bh": 2, +"bho": 2, +"bi": 2, +"bm": 2, +"bn": 2, +"bn-in": 2, +"bo": 1, +"br": 2, +"brx": 2, +"bs": 3, +"ca": 2, +"ca-val": 2, +"ce": 2, +"ceb": 2, +"ch": 2, +"ckb": 2, +"co": 2, +"cor": 6, +"cr": 2, +"cs": 3, +"csb": 3, +"cu": 2, +"cv": 2, +"cy": 4, +"da": 2, +"de": 2, +"de-at": 2, +"de-ch": 2, +"dsb": 4, +"dv": 2, +"dzo": 1, +"ee": 2, +"el": 2, +"el-po": 2, +"en": 2, +"en-au": 2, +"en-ca": 2, +"en-gb": 2, +"en-ie": 2, +"en-nz": 2, +"en-za": 2, +"eo": 2, +"es": 2, +"es-an": 2, +"es-ar": 2, +"es-cl": 2, +"es-co": 2, +"es-cr": 2, +"es-do": 2, +"es-ec": 2, +"es-gt": 2, +"es-hn": 2, +"es-mx": 2, +"es-pa": 2, +"es-pe": 2, +"es-pr": 2, +"es-us": 2, +"es-uy": 2, +"es-ve": 2, +"et": 2, +"eu": 2, +"fa": 2, +"fa-af": 2, +"fi": 2, +"fj": 2, +"fo": 2, +"fon": 2, +"fr": 2, +"fr-be": 2, +"fr-ca": 2, +"fr-ch": 2, +"frp": 2, +"fuc": 2, +"ful": 2, +"fur": 2, +"fy": 2, +"ga": 5, +"gax": 2, +"gd": 4, +"gl": 2, +"gn": 2, +"gsw": 2, +"gu": 2, +"ha": 2, +"hat": 2, +"hau": 2, +"haw": 2, +"haz": 2, +"he": 2, +"hi": 2, +"hr": 3, +"hsb": 4, +"hu": 2, +"hy": 2, +"ia": 2, +"ibo": 1, +"id": 2, +"ido": 2, +"ike": 3, +"ilo": 2, +"is": 2, +"it": 2, +"ja": 1, +"jv": 2, +"ka": 1, +"kaa": 2, +"kab": 2, +"kal": 2, +"kin": 2, +"kir": 2, +"kk": 2, +"km": 1, +"kmr": 2, +"kn": 2, +"ko": 1, +"ks": 2, +"la": 2, +"lb": 2, +"li": 2, +"lij": 2, +"lin": 2, +"lmo": 2, +"lo": 1, +"lt": 3, +"lug": 2, +"lv": 3, +"mai": 2, +"me": 3, +"mfe": 1, +"mg": 2, +"mhr": 2, +"mk": 2, +"ml": 2, +"mlt": 4, +"mn": 2, +"mr": 2, +"mri": 2, +"mrj": 2, +"ms": 1, +"mwl": 2, +"mya": 2, +"nb": 2, +"ne": 2, +"nl": 2, +"nl-be": 2, +"nn": 2, +"no": 2, +"nqo": 2, +"nso": 2, +"oci": 2, +"orm": 2, +"ory": 2, +"os": 2, +"pa": 2, +"pa-pk": 2, +"pap-aw": 2, +"pap-cw": 2, +"pcd": 2, +"pcm": 2, +"pirate": 2, +"pl": 3, +"ps": 2, +"pt": 2, +"pt-ao": 2, +"pt-ao90": 2, +"pt-br": 2, +"rhg": 1, +"rif": 2, +"ro": 3, +"roh": 2, +"ru": 3, +"rue": 3, +"rup": 2, +"sa-in": 2, +"sah": 2, +"scn": 2, +"si": 2, +"sk": 3, +"skr": 2, +"sl": 4, +"sna": 2, +"snd": 2, +"so": 2, +"sq": 2, +"sq-xk": 2, +"sr": 3, +"srd": 2, +"ssw": 2, +"su": 1, +"sv": 2, +"sw": 2, +"syr": 2, +"szl": 3, +"ta": 2, +"ta-lk": 2, +"tah": 2, +"te": 2, +"tg": 2, +"th": 1, +"tir": 1, +"tl": 2, +"tlh": 1, +"tr": 2, +"tt": 1, +"tuk": 2, +"twd": 2, +"tzm": 2, +"udm": 2, +"ug": 2, +"uk": 3, +"ur": 2, +"uz": 1, +"vec": 2, +"vi": 1, +"wa": 2, +"wol": 1, +"xho": 2, +"xmf": 2, +"yi": 2, +"yor": 2, +"zgh": 2, +"zh": 1, +"zh-cn": 1, +"zh-hk": 1, +"zh-sg": 1, +"zh-tw": 1, +"zul": 2 +} \ No newline at end of file diff --git a/spec/ios_generate_pot_from_stringsdict_spec.rb b/spec/ios_generate_pot_from_stringsdict_spec.rb new file mode 100644 index 000000000..a5c83d206 --- /dev/null +++ b/spec/ios_generate_pot_from_stringsdict_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +describe Fastlane::Actions::IosGeneratePotFromStringsdictAction do + let(:test_data_dir) { File.join(File.dirname(__FILE__), 'test-data', 'translations', 'stringsdict') } + + def fixture(name) + File.join(test_data_dir, name) + end + + it 'generates a .pot file and returns the number of plural entries' do + in_tmp_dir do |dir| + output = File.join(dir, 'out.pot') + result = run_described_fastlane_action( + stringsdict_paths: fixture('Localizable.stringsdict'), + output_path: output + ) + + expect(result).to eq(3) + expect(File).to exist(output) + content = File.read(output) + expect(content).to include('msgid_plural "%d files selected"') + expect(content).to include('msgctxt "photos_and_albums:photos"') + end + end + + it 'accepts an array of source paths' do + in_tmp_dir do |dir| + output = File.join(dir, 'out.pot') + result = run_described_fastlane_action( + stringsdict_paths: [fixture('simple.stringsdict'), fixture('Localizable.stringsdict')], + output_path: output + ) + expect(result).to eq(4) + end + end + + it 'raises a user error when a source file does not exist' do + in_tmp_dir do |dir| + expect do + run_described_fastlane_action( + stringsdict_paths: File.join(dir, 'missing.stringsdict'), + output_path: File.join(dir, 'out.pot') + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, /Stringsdict file not found/) + end + end +end diff --git a/spec/ios_generate_stringsdict_from_po_spec.rb b/spec/ios_generate_stringsdict_from_po_spec.rb new file mode 100644 index 000000000..d5a563980 --- /dev/null +++ b/spec/ios_generate_stringsdict_from_po_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +describe Fastlane::Actions::IosGenerateStringsdictFromPoAction do + let(:test_data_dir) { File.join(File.dirname(__FILE__), 'test-data', 'translations', 'stringsdict') } + let(:ru_po) do + <<~PO + msgid "" + msgstr "" + "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\\n" + + msgctxt "%d items" + msgid "%d item" + msgid_plural "%d items" + msgstr[0] "ru-one" + msgstr[1] "ru-few" + msgstr[2] "ru-many" + PO + end + + def fixture(name) + File.join(test_data_dir, name) + end + + it 'generates a localized .stringsdict and returns the (empty) missing list' do + in_tmp_dir do |dir| + po = File.join(dir, 'ru.po') + File.write(po, ru_po) + output = File.join(dir, 'ru.stringsdict') + + result = run_described_fastlane_action( + po_path: po, + template_path: fixture('simple.stringsdict'), + locale: 'ru', + output_path: output + ) + + expect(result).to eq([]) + data = Fastlane::Helper::Ios::StringsdictHelper.read(path: output) + expect(data['%d items']['count'].slice('one', 'few', 'many', 'other')).to eq( + 'one' => 'ru-one', 'few' => 'ru-few', 'many' => 'ru-many', 'other' => 'ru-many' + ) + end + end + + it 'raises a user error when the template does not exist' do + in_tmp_dir do |dir| + po = File.join(dir, 'ru.po') + File.write(po, ru_po) + expect do + run_described_fastlane_action( + po_path: po, + template_path: File.join(dir, 'missing.stringsdict'), + locale: 'ru', + output_path: File.join(dir, 'out.stringsdict') + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, /Stringsdict template not found/) + end + end + + it 'surfaces an unmappable locale (Welsh) as a clean user error' do + in_tmp_dir do |dir| + po = File.join(dir, 'cy.po') + File.write(po, ru_po) # content irrelevant — the locale is rejected before any forms are read + expect do + run_described_fastlane_action( + po_path: po, + template_path: fixture('simple.stringsdict'), + locale: 'cy', + output_path: File.join(dir, 'out.stringsdict') + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, /Welsh/) + end + end +end diff --git a/spec/ios_plural_rules_spec.rb b/spec/ios_plural_rules_spec.rb new file mode 100644 index 000000000..80f7aefe1 --- /dev/null +++ b/spec/ios_plural_rules_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +describe Fastlane::Helper::Ios::PluralRules do + describe '.categories_for' do + it 'maps single-form languages to [other]' do + expect(described_class.categories_for('ja')).to eq(%i[other]) + expect(described_class.categories_for('zh')).to eq(%i[other]) + end + + it 'maps two-form languages to [one, other]' do + expect(described_class.categories_for('en')).to eq(%i[one other]) + expect(described_class.categories_for('fr')).to eq(%i[one other]) + end + + it 'maps East-Slavic/Polish three-form languages to [one, few, many]' do + expect(described_class.categories_for('ru')).to eq(%i[one few many]) + expect(described_class.categories_for('uk')).to eq(%i[one few many]) + expect(described_class.categories_for('pl')).to eq(%i[one few many]) + end + + it 'maps West-Slavic/Baltic/Romanian three-form languages to [one, few, other]' do + expect(described_class.categories_for('cs')).to eq(%i[one few other]) + expect(described_class.categories_for('sk')).to eq(%i[one few other]) + expect(described_class.categories_for('ro')).to eq(%i[one few other]) + end + + it 'maps Slovenian to [one, two, few, other]' do + expect(described_class.categories_for('sl')).to eq(%i[one two few other]) + end + + it 'maps Arabic to all six categories' do + expect(described_class.categories_for('ar')).to eq(%i[zero one two few many other]) + end + + it 'maps GlotPress 2-form locales to [one, other], following GlotPress rather than CLDR' do + # GlotPress keeps Hebrew at two forms though CLDR has three (one/two/other), + # and Indonesian at two though CLDR has one. The `.po` we consume has two + # forms, so we map two — accepting the missing Hebrew dual. + expect(described_class.categories_for('he')).to eq(%i[one other]) + expect(described_class.categories_for('is')).to eq(%i[one other]) + expect(described_class.categories_for('id')).to eq(%i[one other]) + end + + it 'maps Croatian/Serbian/Bosnian to [one, few, other]' do + # Same gettext formula as Russian, but CLDR names the third form `other`, not `many`. + expect(described_class.categories_for('hr')).to eq(%i[one few other]) + expect(described_class.categories_for('sr')).to eq(%i[one few other]) + expect(described_class.categories_for('bs')).to eq(%i[one few other]) + end + + it 'raises a specific error for GlotPress/CLDR-incompatible locales (Welsh)' do + expect { described_class.categories_for('cy') } + .to raise_error(described_class::UnknownLocaleError, /legacy 4-form Welsh/) + end + + it 'falls back from a regional code to its base language' do + expect(described_class.categories_for('pt-BR')).to eq(%i[one other]) + expect(described_class.categories_for('pt-PT')).to eq(%i[one other]) + expect(described_class.categories_for('zh-Hans')).to eq(%i[other]) + end + + it 'normalizes case and underscores' do + expect(described_class.categories_for('RU')).to eq(%i[one few many]) + expect(described_class.categories_for('ru_RU')).to eq(%i[one few many]) + end + + it 'raises a clear error for an unmapped locale' do + expect { described_class.categories_for('xx') } + .to raise_error(described_class::UnknownLocaleError, /No plural-category mapping for locale 'xx'/) + end + end + + describe '.supported?' do + it 'is true for mapped locales and their regional variants' do + expect(described_class.supported?('ru')).to be true + expect(described_class.supported?('pt-BR')).to be true + end + + it 'is false for unmapped locales' do + expect(described_class.supported?('xx')).to be false + end + + it 'is false for known GlotPress/CLDR-incompatible locales (Welsh)' do + expect(described_class.supported?('cy')).to be false + end + end +end diff --git a/spec/ios_stringsdict_helper_spec.rb b/spec/ios_stringsdict_helper_spec.rb new file mode 100644 index 000000000..09432381a --- /dev/null +++ b/spec/ios_stringsdict_helper_spec.rb @@ -0,0 +1,378 @@ +# frozen_string_literal: true + +require_relative 'spec_helper' + +describe Fastlane::Helper::Ios::StringsdictHelper do + let(:test_data_dir) { File.join(File.dirname(__FILE__), 'test-data', 'translations', 'stringsdict') } + + def fixture(name) + File.join(test_data_dir, name) + end + + # Build a `.po` for the `simple.stringsdict` template (single context + # "%d items") with the given translated forms. + def simple_po(forms:, nplurals: forms.size, plural: '0') + msgstrs = forms.each_with_index.map { |form, i| %(msgstr[#{i}] "#{form}") }.join("\n") + <<~PO + msgid "" + msgstr "" + "MIME-Version: 1.0\\n" + "Content-Type: text/plain; charset=UTF-8\\n" + "Plural-Forms: nplurals=#{nplurals}; plural=#{plural};\\n" + + msgctxt "%d items" + msgid "%d item" + msgid_plural "%d items" + #{msgstrs} + PO + end + + # Run the reverse conversion against the given template, returning the parsed + # output stringsdict and the list of missing contexts. + def convert_po_to_stringsdict(po_content, template:, locale:) + in_tmp_dir do |dir| + po_path = File.join(dir, 'translations.po') + out_path = File.join(dir, 'out.stringsdict') + File.write(po_path, po_content) + missing = described_class.generate_stringsdict_from_po( + po_path: po_path, template_path: template, locale: locale, output_path: out_path + ) + [described_class.read(path: out_path), missing] + end + end + + describe '.read' do + it 'parses a stringsdict file into a Hash' do + data = described_class.read(path: fixture('simple.stringsdict')) + expect(data['%d items']['NSStringLocalizedFormatKey']).to eq('%#@count@') + expect(data['%d items']['count']['one']).to eq('%d item') + expect(data['%d items']['count']['other']).to eq('%d items') + end + + it 'raises if the file is missing' do + expect { described_class.read(path: 'does-not-exist.stringsdict') } + .to raise_error(FastlaneCore::Interface::FastlaneError, /Stringsdict file not found/) + end + + it 'raises a clear error when an entry is not a dictionary' do + in_tmp_dir do |dir| + bad = File.join(dir, 'bad.stringsdict') + File.write(bad, <<~XML) + + + oopsnot a dictionary + XML + expect { described_class.read(path: bad) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /entry 'oops' is not a dictionary/) + end + end + end + + describe 'round-trip write/read' do + it 'preserves the dictionary' do + original = described_class.read(path: fixture('Localizable.stringsdict')) + in_tmp_dir do |dir| + out = File.join(dir, 'rt.stringsdict') + described_class.write(data: original, path: out) + expect(described_class.read(path: out)).to eq(original) + end + end + end + + describe '.generate_pot' do + it 'generates a plural .pot entry per variable, sorted by context' do + in_tmp_dir do |dir| + pot = File.join(dir, 'out.pot') + count = described_class.generate_pot(stringsdict_paths: fixture('Localizable.stringsdict'), output_path: pot) + content = File.read(pot) + + expect(count).to eq(3) + # Single-variable entry uses the bare key as msgctxt. + expect(content).to include(<<~ENTRY.chomp) + msgctxt "%d files selected" + msgid "%d file selected" + msgid_plural "%d files selected" + msgstr[0] "" + msgstr[1] "" + ENTRY + # Multi-variable entries disambiguate with the variable name. + expect(content).to include('msgctxt "photos_and_albums:albums"') + expect(content).to include('msgctxt "photos_and_albums:photos"') + # Entries are sorted by msgctxt ("%…" sorts before "p…"). + expect(content.index('msgctxt "%d files selected"')).to be < content.index('msgctxt "photos_and_albums:albums"') + # Header advertises the (English source) plural form. + expect(content).to include('Plural-Forms: nplurals=2; plural=n != 1;') + end + end + + it 'merges multiple stringsdict files into one .pot' do + in_tmp_dir do |dir| + pot = File.join(dir, 'merged.pot') + count = described_class.generate_pot( + stringsdict_paths: [fixture('simple.stringsdict'), fixture('Localizable.stringsdict')], + output_path: pot + ) + expect(count).to eq(4) + expect(File.read(pot)).to include('msgctxt "%d items"') + end + end + + it 'raises on a key collision across files' do + in_tmp_dir do |dir| + pot = File.join(dir, 'dup.pot') + expect do + described_class.generate_pot( + stringsdict_paths: [fixture('simple.stringsdict'), fixture('simple.stringsdict')], + output_path: pot + ) + end.to raise_error(FastlaneCore::Interface::FastlaneError, /Duplicate translation context/) + end + end + + it 'raises when a source entry is missing a required plural form' do + in_tmp_dir do |dir| + bad = File.join(dir, 'bad.stringsdict') + described_class.write( + data: { + 'broken' => { + 'NSStringLocalizedFormatKey' => '%#@count@', + 'count' => { 'NSStringFormatSpecTypeKey' => 'NSStringPluralRuleType', 'one' => 'just one' } + } + }, + path: bad + ) + expect { described_class.generate_pot(stringsdict_paths: bad, output_path: File.join(dir, 'o.pot')) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /missing a required plural form/) + end + end + + it 'warns and drops source plural forms other than one/other (e.g. a `zero` override)' do + in_tmp_dir do |dir| + src = File.join(dir, 'zero.stringsdict') + described_class.write( + data: { + 'homes' => { + 'NSStringLocalizedFormatKey' => '%#@count@', + 'count' => { + 'NSStringFormatSpecTypeKey' => 'NSStringPluralRuleType', + # iOS honors an English `zero` override (Apple: returns "No homes found" for 0), + # but gettext has no slot for it — so it must be dropped *loudly*. + 'zero' => 'No homes found', + 'one' => '%d home found', + 'other' => '%d homes found' + } + } + }, + path: src + ) + pot = File.join(dir, 'out.pot') + + expect(FastlaneCore::UI).to receive(:important) do |message| + expect(message).to include('zero') + expect(message).to match(/drop/i) + end + + described_class.generate_pot(stringsdict_paths: src, output_path: pot) + + content = File.read(pot) + # `one`/`other` still convert… + expect(content).to include('msgid "%d home found"') + expect(content).to include('msgid_plural "%d homes found"') + # …but the `zero` literal override is dropped. + expect(content).not_to include('No homes found') + end + end + + it 'treats an empty `one` as absent — uses `other` as the singular rather than dropping the entry' do + in_tmp_dir do |dir| + src = File.join(dir, 'empty-one.stringsdict') + described_class.write( + data: { + 'k' => { + 'NSStringLocalizedFormatKey' => '%#@c@', + 'c' => { 'NSStringFormatSpecTypeKey' => 'NSStringPluralRuleType', 'one' => '', 'other' => '%d items' } + } + }, + path: src + ) + pot = File.join(dir, 'out.pot') + count = described_class.generate_pot(stringsdict_paths: src, output_path: pot) + expect(count).to eq(1) # entry is emitted, not silently dropped via an empty msgid + expect(File.read(pot)).to include('msgid "%d items"') # `other` becomes the msgid + end + end + + it 'raises when `other` is empty (an empty form counts as missing)' do + in_tmp_dir do |dir| + src = File.join(dir, 'empty-other.stringsdict') + described_class.write( + data: { + 'k' => { + 'NSStringLocalizedFormatKey' => '%#@c@', + 'c' => { 'NSStringFormatSpecTypeKey' => 'NSStringPluralRuleType', 'one' => '%d item', 'other' => '' } + } + }, + path: src + ) + expect { described_class.generate_pot(stringsdict_paths: src, output_path: File.join(dir, 'o.pot')) } + .to raise_error(FastlaneCore::Interface::FastlaneError, /missing a required plural form/) + end + end + end + + describe '.generate_stringsdict_from_po' do + let(:simple) { fixture('simple.stringsdict') } + + it 'maps a 2-form locale to one/other' do + data, missing = convert_po_to_stringsdict( + simple_po(forms: ['1 elemento', '%d elementos'], plural: 'n != 1'), + template: simple, locale: 'es' + ) + expect(missing).to be_empty + expect(data['%d items']['count']).to include('one' => '1 elemento', 'other' => '%d elementos') + expect(data['%d items']['count']).not_to have_key('few') + end + + it 'maps a single-form locale to other only' do + data, = convert_po_to_stringsdict(simple_po(forms: ['%d 件']), template: simple, locale: 'ja') + expect(data['%d items']['count'].slice('zero', 'one', 'two', 'few', 'many', 'other')) + .to eq('other' => '%d 件') + end + + it 'maps an East-Slavic 3-form locale and back-fills the mandatory `other`' do + data, = convert_po_to_stringsdict( + simple_po(forms: %w[ru-one ru-few ru-many], nplurals: 3), + template: simple, locale: 'ru' + ) + expect(data['%d items']['count'].slice('one', 'few', 'many', 'other')).to eq( + 'one' => 'ru-one', 'few' => 'ru-few', 'many' => 'ru-many', 'other' => 'ru-many' + ) + end + + it 'maps Arabic across all six categories' do + data, = convert_po_to_stringsdict( + simple_po(forms: %w[ar-zero ar-one ar-two ar-few ar-many ar-other], nplurals: 6), + template: simple, locale: 'ar' + ) + expect(data['%d items']['count'].slice('zero', 'one', 'two', 'few', 'many', 'other')).to eq( + 'zero' => 'ar-zero', 'one' => 'ar-one', 'two' => 'ar-two', + 'few' => 'ar-few', 'many' => 'ar-many', 'other' => 'ar-other' + ) + end + + it 'preserves the format key, spec type and value type from the template' do + data, = convert_po_to_stringsdict(simple_po(forms: %w[a b], plural: 'n != 1'), template: simple, locale: 'en') + entry = data['%d items'] + expect(entry['NSStringLocalizedFormatKey']).to eq('%#@count@') + expect(entry['count']['NSStringFormatSpecTypeKey']).to eq('NSStringPluralRuleType') + expect(entry['count']['NSStringFormatValueTypeKey']).to eq('d') + end + + it 'round-trips a multi-variable entry' do + po = <<~PO + msgid "" + msgstr "" + "Plural-Forms: nplurals=2; plural=n != 1;\\n" + + msgctxt "%d files selected" + msgid "%d file selected" + msgid_plural "%d files selected" + msgstr[0] "1 archivo" + msgstr[1] "%d archivos" + + msgctxt "photos_and_albums:photos" + msgid "%d photo" + msgid_plural "%d photos" + msgstr[0] "1 foto" + msgstr[1] "%d fotos" + + msgctxt "photos_and_albums:albums" + msgid "%d album" + msgid_plural "%d albums" + msgstr[0] "1 álbum" + msgstr[1] "%d álbumes" + PO + data, missing = convert_po_to_stringsdict(po, template: fixture('Localizable.stringsdict'), locale: 'es') + expect(missing).to be_empty + expect(data['photos_and_albums']['NSStringLocalizedFormatKey']).to eq('%#@photos@ in %#@albums@') + expect(data['photos_and_albums']['photos']).to include('one' => '1 foto', 'other' => '%d fotos') + expect(data['photos_and_albums']['albums']).to include('one' => '1 álbum', 'other' => '%d álbumes') + end + + it 'falls back to English and reports contexts with no translation' do + data, missing = convert_po_to_stringsdict(simple_po(forms: ['', '']), template: simple, locale: 'en') + expect(missing).to eq(['%d items']) + # Output stays valid by reusing the English source forms. + expect(data['%d items']['count']).to include('one' => '%d item', 'other' => '%d items') + end + + it 'warns when a locale entry is only partially translated' do + expect(FastlaneCore::UI).to receive(:important) do |message| + expect(message).to include('many') + expect(message).to match(/incomplete|untranslated/i) + end + # `ru` needs one/few/many; the translator left `many` blank. + data, missing = convert_po_to_stringsdict( + simple_po(forms: ['ru-one', 'ru-few', ''], nplurals: 3), + template: simple, locale: 'ru' + ) + # Not reported as fully-missing — it does have *some* translation… + expect(missing).to be_empty + # …but the untranslated `many` is absent (falls back), not silently wrong-but-present. + expect(data['%d items']['count']).not_to have_key('many') + expect(data['%d items']['count']).to include('one' => 'ru-one', 'few' => 'ru-few', 'other' => 'ru-few') + end + + it 'maps Hebrew to one/other — GlotPress is 2-form, so the CLDR dual is not represented' do + data, missing = convert_po_to_stringsdict( + simple_po(forms: ['פריט אחד', '%d פריטים'], plural: 'n != 1'), + template: simple, locale: 'he' + ) + expect(missing).to be_empty + # Accepted limitation: no `two` category — iOS falls back to `other` for n=2. + expect(data['%d items']['count'].slice('one', 'two', 'other')) + .to eq('one' => 'פריט אחד', 'other' => '%d פריטים') + end + + it 'raises for an unmapped locale' do + expect do + convert_po_to_stringsdict(simple_po(forms: ['x']), template: simple, locale: 'xx') + end.to raise_error(Fastlane::Helper::Ios::PluralRules::UnknownLocaleError) + end + + it 'fails loud for Welsh, whose GlotPress rule is incompatible with CLDR categories' do + expect do + convert_po_to_stringsdict(simple_po(forms: %w[a b c d], nplurals: 4), template: simple, locale: 'cy') + end.to raise_error(Fastlane::Helper::Ios::PluralRules::UnknownLocaleError, /Welsh/) + end + + it 'fails loud when the .po declares a different plural-form count than the table expects' do + # `ru` maps to 3 categories; a .po declaring nplurals=2 means GlotPress's + # plural rule has drifted from the table — the signal to regenerate it. + expect do + convert_po_to_stringsdict(simple_po(forms: %w[a b], nplurals: 2), template: simple, locale: 'ru') + end.to raise_error(FastlaneCore::Interface::FastlaneError, /declares nplurals=2.*3 categor/m) + end + + it 'reads nplurals from the header, not a stray `nplurals=` elsewhere in the .po' do + po = <<~PO + # A misleading comment that mentions nplurals=4 must not be picked up. + msgid "" + msgstr "" + "Plural-Forms: nplurals=3; plural=(n != 1);\\n" + + msgctxt "%d items" + msgid "%d item" + msgid_plural "%d items" + msgstr[0] "ru-one" + msgstr[1] "ru-few" + msgstr[2] "ru-many" + PO + data, missing = convert_po_to_stringsdict(po, template: simple, locale: 'ru') + expect(missing).to be_empty + expect(data['%d items']['count'].slice('one', 'few', 'many')).to eq( + 'one' => 'ru-one', 'few' => 'ru-few', 'many' => 'ru-many' + ) + end + end +end diff --git a/spec/test-data/translations/stringsdict/Localizable.stringsdict b/spec/test-data/translations/stringsdict/Localizable.stringsdict new file mode 100644 index 000000000..6a6845dbd --- /dev/null +++ b/spec/test-data/translations/stringsdict/Localizable.stringsdict @@ -0,0 +1,49 @@ + + + + + %d files selected + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d file selected + other + %d files selected + + + photos_and_albums + + NSStringLocalizedFormatKey + %#@photos@ in %#@albums@ + photos + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d photo + other + %d photos + + albums + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d album + other + %d albums + + + + diff --git a/spec/test-data/translations/stringsdict/simple.stringsdict b/spec/test-data/translations/stringsdict/simple.stringsdict new file mode 100644 index 000000000..085136bb8 --- /dev/null +++ b/spec/test-data/translations/stringsdict/simple.stringsdict @@ -0,0 +1,22 @@ + + + + + %d items + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %d item + other + %d items + + + +