Skip to content

Commit ee18adb

Browse files
committed
Add iOS stringsdict ⇆ pot conversion actions
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]
1 parent de7ff74 commit ee18adb

14 files changed

Lines changed: 2020 additions & 0 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ _None_
1111
### New Features
1212

1313
- 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]
14+
- 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]
1415

1516
### Bug Fixes
1617

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# frozen_string_literal: true
2+
3+
require_relative '../../helper/ios/ios_stringsdict_helper'
4+
5+
module Fastlane
6+
module Actions
7+
class IosGeneratePotFromStringsdictAction < Action
8+
def self.run(params)
9+
output_path = params[:output_path]
10+
11+
UI.message "Generating `#{output_path}` from #{Array(params[:stringsdict_paths]).inspect}"
12+
count = Fastlane::Helper::Ios::StringsdictHelper.generate_pot(
13+
stringsdict_paths: params[:stringsdict_paths],
14+
output_path: output_path
15+
)
16+
17+
UI.success "Generated #{count} plural entr#{count == 1 ? 'y' : 'ies'} into `#{output_path}`."
18+
count
19+
end
20+
21+
#####################################################
22+
# @!group Documentation
23+
#####################################################
24+
25+
def self.description
26+
'Generate a gettext `.pot` template from one or more iOS `.stringsdict` plural files'
27+
end
28+
29+
def self.details
30+
<<~DETAILS
31+
Converts the plural rules declared in one or more (English source) `.stringsdict`
32+
files into a gettext `.pot` template suitable for upload to a translation system
33+
such as GlotPress.
34+
35+
Each plural variable becomes one `msgid`/`msgid_plural` entry — the English `one`
36+
form becomes the `msgid` and the `other` form becomes the `msgid_plural`. Entries
37+
are keyed by a deterministic `msgctxt` (the `.stringsdict` key for single-variable
38+
entries, or `key:variable` for entries that reference multiple plural variables).
39+
40+
Only the `one` and `other` forms are converted. Any other CLDR category in the
41+
source — including an explicit `zero` literal override (e.g. "No items" for a
42+
count of 0) — has no gettext equivalent, so it is dropped from the `.pot` and a
43+
warning is logged. Handle such count-specific messages as dedicated strings
44+
selected in code (e.g. `if count == 0`) rather than as `zero`/`two`/… keys in a
45+
`.stringsdict` bound for this pipeline.
46+
47+
Use `ios_generate_stringsdict_from_po` to convert the translated `.po` files back
48+
into per-locale `.stringsdict` files once translation is complete.
49+
DETAILS
50+
end
51+
52+
def self.available_options
53+
[
54+
FastlaneCore::ConfigItem.new(
55+
key: :stringsdict_paths,
56+
env_name: 'FL_IOS_GENERATE_POT_FROM_STRINGSDICT_PATHS',
57+
description: 'Path (String) or paths (Array of String) to the source `.stringsdict` file(s) to convert',
58+
optional: false,
59+
skip_type_validation: true, # Accept either a String or an Array of String
60+
verify_block: proc do |value|
61+
paths = Array(value)
62+
UI.user_error!('You must provide at least one `.stringsdict` path') if paths.empty?
63+
paths.each do |path|
64+
UI.user_error!("Stringsdict file not found: #{path}") unless File.exist?(path)
65+
end
66+
end
67+
),
68+
FastlaneCore::ConfigItem.new(
69+
key: :output_path,
70+
env_name: 'FL_IOS_GENERATE_POT_FROM_STRINGSDICT_OUTPUT_PATH',
71+
description: 'The path of the `.pot` file to generate',
72+
type: String,
73+
optional: false
74+
),
75+
]
76+
end
77+
78+
def self.return_type
79+
:int
80+
end
81+
82+
def self.return_value
83+
'The number of plural entries written to the `.pot` file'
84+
end
85+
86+
def self.authors
87+
['Automattic']
88+
end
89+
90+
def self.is_supported?(platform)
91+
%i[ios mac].include? platform
92+
end
93+
end
94+
end
95+
end
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# frozen_string_literal: true
2+
3+
require_relative '../../helper/ios/ios_stringsdict_helper'
4+
5+
module Fastlane
6+
module Actions
7+
class IosGenerateStringsdictFromPoAction < Action
8+
def self.run(params)
9+
output_path = params[:output_path]
10+
11+
UI.message "Generating `#{output_path}` for locale `#{params[:locale]}` from `#{params[:po_path]}`"
12+
missing = Fastlane::Helper::Ios::StringsdictHelper.generate_stringsdict_from_po(
13+
po_path: params[:po_path],
14+
template_path: params[:template_path],
15+
locale: params[:locale],
16+
output_path: output_path
17+
)
18+
19+
missing.each do |context|
20+
UI.important "No translation for `#{context}` in `#{params[:po_path]}` — kept the source (English) value."
21+
end
22+
UI.success "Generated `#{output_path}` for locale `#{params[:locale]}`."
23+
missing
24+
rescue Fastlane::Helper::Ios::PluralRules::UnknownLocaleError => e
25+
# Locales with no vetted plural mapping (e.g. Welsh) surface as a clean
26+
# user error — exclude them from the locales you convert.
27+
UI.user_error!(e.message)
28+
end
29+
30+
#####################################################
31+
# @!group Documentation
32+
#####################################################
33+
34+
def self.description
35+
'Generate a localized iOS `.stringsdict` plural file from a translated gettext `.po`'
36+
end
37+
38+
def self.details
39+
<<~DETAILS
40+
Converts a translated `.po` file (e.g. downloaded from GlotPress) back into an iOS
41+
`.stringsdict` plural file for a single locale.
42+
43+
The original English `.stringsdict` is required as a structural template: the `.po`
44+
only carries the translated strings, while the format key, variable names and
45+
format specifiers are copied from the template. The `.po`'s indexed `msgstr[N]`
46+
plural forms are mapped back to CLDR plural-category names (`one`, `few`, `many`, …)
47+
according to the locale's plural rules.
48+
49+
This is the counterpart to `ios_generate_pot_from_stringsdict`.
50+
DETAILS
51+
end
52+
53+
def self.available_options
54+
[
55+
FastlaneCore::ConfigItem.new(
56+
key: :po_path,
57+
env_name: 'FL_IOS_GENERATE_STRINGSDICT_FROM_PO_PO_PATH',
58+
description: 'The path to the translated `.po` file for the locale',
59+
type: String,
60+
optional: false,
61+
verify_block: proc do |value|
62+
UI.user_error!("PO file not found: #{value}") unless File.exist?(value)
63+
end
64+
),
65+
FastlaneCore::ConfigItem.new(
66+
key: :template_path,
67+
env_name: 'FL_IOS_GENERATE_STRINGSDICT_FROM_PO_TEMPLATE_PATH',
68+
description: 'The path to the original (English) `.stringsdict` to use as a structural template',
69+
type: String,
70+
optional: false,
71+
verify_block: proc do |value|
72+
UI.user_error!("Stringsdict template not found: #{value}") unless File.exist?(value)
73+
end
74+
),
75+
FastlaneCore::ConfigItem.new(
76+
key: :locale,
77+
env_name: 'FL_IOS_GENERATE_STRINGSDICT_FROM_PO_LOCALE',
78+
description: "The locale code of the `.po` file (e.g. 'ru', 'pt-BR'), used to map plural indices to CLDR categories",
79+
type: String,
80+
optional: false
81+
),
82+
FastlaneCore::ConfigItem.new(
83+
key: :output_path,
84+
env_name: 'FL_IOS_GENERATE_STRINGSDICT_FROM_PO_OUTPUT_PATH',
85+
description: 'The path of the localized `.stringsdict` file to generate',
86+
type: String,
87+
optional: false
88+
),
89+
]
90+
end
91+
92+
def self.return_type
93+
:array_of_strings
94+
end
95+
96+
def self.return_value
97+
'The list of translation contexts that had no translation in the `.po` (filled from the English source)'
98+
end
99+
100+
def self.authors
101+
['Automattic']
102+
end
103+
104+
def self.is_supported?(platform)
105+
%i[ios mac].include? platform
106+
end
107+
end
108+
end
109+
end
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# frozen_string_literal: true
2+
3+
module Fastlane
4+
module Helper
5+
module Ios
6+
# Maps a locale to the ordered list of CLDR plural categories that
7+
# correspond to the indexed plural forms a **GlotPress** `.po` export
8+
# carries for that locale (`msgstr[0]`, `msgstr[1]`, …).
9+
#
10+
# This is the bridge between two plural models:
11+
#
12+
# - **gettext** (`.po`/`.pot`, what GlotPress emits) addresses plural forms
13+
# by numeric index. The number and meaning of those indices is defined by
14+
# the locale's `Plural-Forms` formula and is *not* otherwise recorded.
15+
# - **iOS `.stringsdict`** addresses plural forms by CLDR category *name*
16+
# (`zero`, `one`, `two`, `few`, `many`, `other`).
17+
#
18+
# ## How this table is derived
19+
#
20+
# The pipeline consumes `.po` files produced *by GlotPress*, so the source
21+
# of truth for how many forms exist (and in what order) is GlotPress's
22+
# `GP_Locales` definition, **not** the latest CLDR — the two disagree for
23+
# several locales (e.g. GlotPress keeps French/Spanish/Portuguese at two
24+
# forms, while CLDR added a compact-number `many`). This table is generated
25+
# by combining the two (see `rakelib/generate_ios_plural_rules.rb`):
26+
#
27+
# - **1 form** → `[other]` (the single category is always `other`).
28+
# - **2 forms** → `[one, other]` (gettext's universal 2-form naming).
29+
# - **3+ forms** → the locale's CLDR integer categories, in canonical
30+
# order, **only when GlotPress's form count matches CLDR's**. When they
31+
# disagree on a 3+-form locale (e.g. Welsh's legacy 4-form rule vs CLDR's
32+
# 6 categories) the locale is omitted on purpose — see {INCOMPATIBLE_LOCALES}
33+
# and {UnknownLocaleError}. Guessing would silently file a translation
34+
# under the wrong category.
35+
#
36+
# Because the count comes from GlotPress, the reverse converter also reads
37+
# `nplurals` from each `.po`'s own `Plural-Forms` header and asserts it
38+
# matches this table — so if GlotPress ever changes a locale's plural rule,
39+
# the run fails loudly (the signal to regenerate this table) rather than
40+
# producing wrong output. See {StringsdictHelper.generate_stringsdict_from_po}.
41+
#
42+
# @note Plural categories are a property of the *language*, so region
43+
# subtags are ignored (`pt-BR`/`pt-PT` → `pt`). Lookups fall back from the
44+
# full code to the base language.
45+
module PluralRules
46+
# Raised when asked for the plural categories of a locale we don't have a
47+
# vetted mapping for. Either the locale isn't in the table yet, or it's a
48+
# known GlotPress/CLDR incompatibility (see {INCOMPATIBLE_LOCALES}).
49+
class UnknownLocaleError < StandardError; end
50+
51+
# Locales whose GlotPress plural rule cannot be honestly mapped to CLDR
52+
# `.stringsdict` categories. Looked up before the table so we can raise a
53+
# specific, actionable error instead of a generic "unknown locale".
54+
INCOMPATIBLE_LOCALES = {
55+
# GlotPress models Welsh with a legacy 4-form rule
56+
# `(n==1)?0:(n==2)?1:(n!=8&&n!=11)?2:3` whose indices don't correspond
57+
# to CLDR's six Welsh categories (zero/one/two/few/many/other), so there
58+
# is no correct index→category mapping.
59+
'cy' => 'GlotPress uses a legacy 4-form Welsh plural rule that does not map to CLDR categories'
60+
}.freeze
61+
62+
# Locales grouped by their ordered category list, to keep the table
63+
# compact and reviewable. Generated — do not hand-edit; regenerate via
64+
# `rakelib/generate_ios_plural_rules.rb` (derived from GlotPress
65+
# `GP_Locales` nplurals + Unicode CLDR `plurals.xml`).
66+
CATEGORIES_BY_GROUP = {
67+
%i[other].freeze =>
68+
%w[bo ja ka km ko lo ms su th uz vi zh].freeze,
69+
%i[one other].freeze =>
70+
%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
71+
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
72+
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
73+
tzm ug ur vec wa yi].freeze,
74+
%i[one few many].freeze =>
75+
%w[pl ru uk].freeze,
76+
%i[one few other].freeze =>
77+
%w[bs cs hr lt ro sk sr].freeze,
78+
%i[zero one other].freeze =>
79+
%w[lv].freeze,
80+
%i[one two few other].freeze =>
81+
%w[dsb gd hsb sl].freeze,
82+
%i[one two few many other].freeze =>
83+
%w[ga].freeze,
84+
%i[zero one two few many other].freeze =>
85+
%w[ar].freeze
86+
}.freeze
87+
88+
# Locale (normalized) => ordered CLDR plural categories.
89+
CATEGORIES_BY_LOCALE = CATEGORIES_BY_GROUP.each_with_object({}) do |(categories, locales), hash|
90+
locales.each { |locale| hash[locale] = categories }
91+
end.freeze
92+
93+
# The CLDR plural categories for a locale, ordered to match the GlotPress
94+
# `.po`'s `msgstr[N]` indices.
95+
#
96+
# @param [String] locale A locale code, e.g. `"en"`, `"pt-BR"`, `"ru"`.
97+
# @return [Array<Symbol>] Ordered categories, e.g. `%i[one few many]`.
98+
# @raise [UnknownLocaleError] if the locale (and its base language) has no
99+
# vetted mapping, or is a known GlotPress/CLDR incompatibility.
100+
def self.categories_for(locale)
101+
key = normalize(locale)
102+
base = key.split('-').first
103+
104+
incompatible = INCOMPATIBLE_LOCALES[key] || INCOMPATIBLE_LOCALES[base]
105+
raise(UnknownLocaleError, "No plural-category mapping for locale '#{locale}': #{incompatible}.") if incompatible
106+
107+
CATEGORIES_BY_LOCALE[key] ||
108+
CATEGORIES_BY_LOCALE[base] ||
109+
raise(UnknownLocaleError, "No plural-category mapping for locale '#{locale}'. " \
110+
'Add it to Fastlane::Helper::Ios::PluralRules by regenerating the table ' \
111+
'(rakelib/generate_ios_plural_rules.rb) from GlotPress GP_Locales + CLDR.')
112+
end
113+
114+
# Whether a vetted mapping exists for the given locale.
115+
# @param [String] locale A locale code.
116+
# @return [Boolean]
117+
def self.supported?(locale)
118+
key = normalize(locale)
119+
base = key.split('-').first
120+
return false if INCOMPATIBLE_LOCALES.key?(key) || INCOMPATIBLE_LOCALES.key?(base)
121+
122+
CATEGORIES_BY_LOCALE.key?(key) || CATEGORIES_BY_LOCALE.key?(base)
123+
end
124+
125+
# Normalize a locale code to the table's key form: lowercase, with `_`
126+
# treated as `-` (so `pt_BR` and `pt-BR` are equivalent).
127+
# @param [String] locale
128+
# @return [String]
129+
def self.normalize(locale)
130+
locale.to_s.strip.downcase.tr('_', '-')
131+
end
132+
end
133+
end
134+
end
135+
end

0 commit comments

Comments
 (0)