Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
135 changes: 135 additions & 0 deletions lib/fastlane/plugin/wpmreleasetoolkit/helper/ios/ios_plural_rules.rb
Original file line number Diff line number Diff line change
@@ -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<Symbol>] 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
Loading