Skip to content

Commit a5af008

Browse files
committed
Add iOS stringsdict ⇆ pot conversion actions
Round-trip iOS `.stringsdict` plural files through a gettext `.po`/`.pot`-based translation system (e.g. GlotPress), which otherwise only carries key/value `.strings` and can't represent plurals. - `ios_generate_pot_from_stringsdict`: English `.stringsdict` → `.pot` template, one `msgid`/`msgid_plural` per plural variable. - `ios_generate_stringsdict_from_po`: translated `.po` + English `.stringsdict` template → per-locale `.stringsdict`, mapping gettext indexed plural forms to CLDR categories via the new `Ios::PluralRules` table and back-filling `other`. New helpers: `Ios::StringsdictHelper` (plist I/O + conversions) and `Ios::PluralRules` (locale → ordered CLDR categories). Unmapped locales raise rather than silently mis-mapping.
1 parent de7ff74 commit a5af008

11 files changed

Lines changed: 1094 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. [#XXX]
1415

1516
### Bug Fixes
1617

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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+
Use `ios_generate_stringsdict_from_po` to convert the translated `.po` files back
41+
into per-locale `.stringsdict` files once translation is complete.
42+
DETAILS
43+
end
44+
45+
def self.available_options
46+
[
47+
FastlaneCore::ConfigItem.new(
48+
key: :stringsdict_paths,
49+
env_name: 'FL_IOS_GENERATE_POT_FROM_STRINGSDICT_PATHS',
50+
description: 'Path (String) or paths (Array of String) to the source `.stringsdict` file(s) to convert',
51+
optional: false,
52+
skip_type_validation: true, # Accept either a String or an Array of String
53+
verify_block: proc do |value|
54+
paths = Array(value)
55+
UI.user_error!('You must provide at least one `.stringsdict` path') if paths.empty?
56+
paths.each do |path|
57+
UI.user_error!("Stringsdict file not found: #{path}") unless File.exist?(path)
58+
end
59+
end
60+
),
61+
FastlaneCore::ConfigItem.new(
62+
key: :output_path,
63+
env_name: 'FL_IOS_GENERATE_POT_FROM_STRINGSDICT_OUTPUT_PATH',
64+
description: 'The path of the `.pot` file to generate',
65+
type: String,
66+
optional: false
67+
),
68+
]
69+
end
70+
71+
def self.return_type
72+
:int
73+
end
74+
75+
def self.return_value
76+
'The number of plural entries written to the `.pot` file'
77+
end
78+
79+
def self.authors
80+
['Automattic']
81+
end
82+
83+
def self.is_supported?(platform)
84+
%i[ios mac].include? platform
85+
end
86+
end
87+
end
88+
end
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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+
end
25+
26+
#####################################################
27+
# @!group Documentation
28+
#####################################################
29+
30+
def self.description
31+
'Generate a localized iOS `.stringsdict` plural file from a translated gettext `.po`'
32+
end
33+
34+
def self.details
35+
<<~DETAILS
36+
Converts a translated `.po` file (e.g. downloaded from GlotPress) back into an iOS
37+
`.stringsdict` plural file for a single locale.
38+
39+
The original English `.stringsdict` is required as a structural template: the `.po`
40+
only carries the translated strings, while the format key, variable names and
41+
format specifiers are copied from the template. The `.po`'s indexed `msgstr[N]`
42+
plural forms are mapped back to CLDR plural-category names (`one`, `few`, `many`, …)
43+
according to the locale's plural rules.
44+
45+
This is the counterpart to `ios_generate_pot_from_stringsdict`.
46+
DETAILS
47+
end
48+
49+
def self.available_options
50+
[
51+
FastlaneCore::ConfigItem.new(
52+
key: :po_path,
53+
env_name: 'FL_IOS_GENERATE_STRINGSDICT_FROM_PO_PO_PATH',
54+
description: 'The path to the translated `.po` file for the locale',
55+
type: String,
56+
optional: false,
57+
verify_block: proc do |value|
58+
UI.user_error!("PO file not found: #{value}") unless File.exist?(value)
59+
end
60+
),
61+
FastlaneCore::ConfigItem.new(
62+
key: :template_path,
63+
env_name: 'FL_IOS_GENERATE_STRINGSDICT_FROM_PO_TEMPLATE_PATH',
64+
description: 'The path to the original (English) `.stringsdict` to use as a structural template',
65+
type: String,
66+
optional: false,
67+
verify_block: proc do |value|
68+
UI.user_error!("Stringsdict template not found: #{value}") unless File.exist?(value)
69+
end
70+
),
71+
FastlaneCore::ConfigItem.new(
72+
key: :locale,
73+
env_name: 'FL_IOS_GENERATE_STRINGSDICT_FROM_PO_LOCALE',
74+
description: "The locale code of the `.po` file (e.g. 'ru', 'pt-BR'), used to map plural indices to CLDR categories",
75+
type: String,
76+
optional: false
77+
),
78+
FastlaneCore::ConfigItem.new(
79+
key: :output_path,
80+
env_name: 'FL_IOS_GENERATE_STRINGSDICT_FROM_PO_OUTPUT_PATH',
81+
description: 'The path of the localized `.stringsdict` file to generate',
82+
type: String,
83+
optional: false
84+
),
85+
]
86+
end
87+
88+
def self.return_type
89+
:array_of_strings
90+
end
91+
92+
def self.return_value
93+
'The list of translation contexts that had no translation in the `.po` (filled from the English source)'
94+
end
95+
96+
def self.authors
97+
['Automattic']
98+
end
99+
100+
def self.is_supported?(platform)
101+
%i[ios mac].include? platform
102+
end
103+
end
104+
end
105+
end
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# frozen_string_literal: true
2+
3+
module Fastlane
4+
module Helper
5+
module Ios
6+
# Maps a locale to its ordered list of CLDR plural categories, in the **same
7+
# order** as the `msgstr[N]` indices that the standard GNU gettext
8+
# `Plural-Forms` expression produces for that locale.
9+
#
10+
# This is the bridge between two different plural models:
11+
#
12+
# - **gettext** (`.po`/`.pot`) addresses plural forms by a numeric index
13+
# (`msgstr[0]`, `msgstr[1]`, …). The meaning of each index is defined by
14+
# the per-locale `Plural-Forms` formula and is *not* otherwise recorded in
15+
# the file.
16+
# - **iOS `.stringsdict`** addresses plural forms by CLDR category *name*
17+
# (`zero`, `one`, `two`, `few`, `many`, `other`).
18+
#
19+
# To turn a translated `.po` back into a `.stringsdict` we therefore need to
20+
# know, for each locale, which CLDR category each gettext index corresponds
21+
# to. That mapping is what this module provides.
22+
#
23+
# The table below is derived from the Unicode CLDR cardinal plural rules
24+
# cross-referenced with the canonical gettext `Plural-Forms` expressions
25+
# (the same ones GlotPress emits). Only the categories gettext actually
26+
# produces for *integer* counts are listed — `.stringsdict`'s mandatory
27+
# `other` category is back-filled by the converter when a locale's gettext
28+
# forms don't already include it (see {StringsdictHelper}).
29+
#
30+
# @note Plural categories are a property of the *language*, so region
31+
# subtags are ignored (`pt-BR`/`pt-PT` → `pt`). Lookups fall back from the
32+
# full code to the base language.
33+
# @note Locales not present in the table raise {UnknownLocaleError} rather
34+
# than being guessed at — a wrong guess would silently file a translation
35+
# under the wrong plural category. Add new locales explicitly.
36+
module PluralRules
37+
# Raised when asked for the plural categories of a locale we don't have a
38+
# vetted CLDR mapping for.
39+
class UnknownLocaleError < StandardError; end
40+
41+
# Languages with a single plural form — no count distinction at all.
42+
# gettext: `nplurals=1; plural=0;`
43+
ONE_FORM = %i[other].freeze
44+
45+
# The common "one vs. everything else" languages.
46+
# gettext: `nplurals=2; plural=n != 1;` (or `plural=n > 1;` for e.g. `fr`)
47+
ONE_OTHER = %i[one other].freeze
48+
49+
# Ordered CLDR categories per locale, keyed by normalized (lowercase,
50+
# dash-separated) language code.
51+
CATEGORIES_BY_LOCALE = {
52+
# ---- 1 form (other) ----
53+
'ja' => ONE_FORM, 'ko' => ONE_FORM, 'vi' => ONE_FORM, 'th' => ONE_FORM,
54+
'id' => ONE_FORM, 'ms' => ONE_FORM, 'zh' => ONE_FORM, 'km' => ONE_FORM,
55+
'lo' => ONE_FORM, 'my' => ONE_FORM,
56+
57+
# ---- 2 forms (one, other) ----
58+
'en' => ONE_OTHER, 'de' => ONE_OTHER, 'nl' => ONE_OTHER, 'sv' => ONE_OTHER,
59+
'da' => ONE_OTHER, 'nb' => ONE_OTHER, 'nn' => ONE_OTHER, 'fi' => ONE_OTHER,
60+
'et' => ONE_OTHER, 'es' => ONE_OTHER, 'it' => ONE_OTHER, 'pt' => ONE_OTHER,
61+
'ca' => ONE_OTHER, 'gl' => ONE_OTHER, 'eu' => ONE_OTHER, 'fr' => ONE_OTHER,
62+
'el' => ONE_OTHER, 'bg' => ONE_OTHER, 'tr' => ONE_OTHER, 'hu' => ONE_OTHER,
63+
'fa' => ONE_OTHER, 'hi' => ONE_OTHER, 'sq' => ONE_OTHER, 'hy' => ONE_OTHER,
64+
'ka' => ONE_OTHER, 'az' => ONE_OTHER, 'kk' => ONE_OTHER, 'eo' => ONE_OTHER,
65+
66+
# ---- 3 forms: one, few, many (East-Slavic / Polish) ----
67+
# ru/uk/be: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12||n%100>14) ? 1 : 2)
68+
'ru' => %i[one few many].freeze,
69+
'uk' => %i[one few many].freeze,
70+
'be' => %i[one few many].freeze,
71+
# pl: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<12||n%100>14) ? 1 : 2)
72+
'pl' => %i[one few many].freeze,
73+
74+
# ---- 3 forms: one, few, other (West-Slavic / Baltic / Romanian) ----
75+
# cs/sk: nplurals=3; plural=((n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2) — CLDR `many` is fractions-only, so the integer catch-all is `other`
76+
'cs' => %i[one few other].freeze,
77+
'sk' => %i[one few other].freeze,
78+
# lt: integer catch-all is CLDR `other` (CLDR `many` is fractions-only)
79+
'lt' => %i[one few other].freeze,
80+
# ro: nplurals=3; plural=(n==1 ? 0 : (n==0 || (n%100>=1 && n%100<=19)) ? 1 : 2)
81+
'ro' => %i[one few other].freeze,
82+
83+
# ---- 4 forms (Slovenian): one, two, few, other ----
84+
# sl: nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100==3||n%100==4 ? 2 : 3)
85+
'sl' => %i[one two few other].freeze,
86+
87+
# ---- 6 forms (Arabic): zero, one, two, few, many, other ----
88+
# ar: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3&&n%100<=10 ? 3 : n%100>=11 ? 4 : 5)
89+
'ar' => %i[zero one two few many other].freeze
90+
}.freeze
91+
92+
# The CLDR plural categories for a locale, ordered to match gettext
93+
# `msgstr[N]` indices.
94+
#
95+
# @param [String] locale A locale code, e.g. `"en"`, `"pt-BR"`, `"ru"`.
96+
# @return [Array<Symbol>] Ordered categories, e.g. `%i[one few many]`.
97+
# @raise [UnknownLocaleError] if the locale (and its base language) has no
98+
# vetted mapping.
99+
def self.categories_for(locale)
100+
key = normalize(locale)
101+
CATEGORIES_BY_LOCALE[key] ||
102+
CATEGORIES_BY_LOCALE[key.split('-').first] ||
103+
raise(UnknownLocaleError, "No plural-category mapping for locale '#{locale}'. " \
104+
'Add it to Fastlane::Helper::Ios::PluralRules::CATEGORIES_BY_LOCALE ' \
105+
'(ordered to match the locale\'s gettext Plural-Forms indices).')
106+
end
107+
108+
# Whether a vetted mapping exists for the given locale.
109+
# @param [String] locale A locale code.
110+
# @return [Boolean]
111+
def self.supported?(locale)
112+
key = normalize(locale)
113+
CATEGORIES_BY_LOCALE.key?(key) || CATEGORIES_BY_LOCALE.key?(key.split('-').first)
114+
end
115+
116+
# Normalize a locale code to the table's key form: lowercase, with `_`
117+
# treated as `-` (so `pt_BR` and `pt-BR` are equivalent).
118+
# @param [String] locale
119+
# @return [String]
120+
def self.normalize(locale)
121+
locale.to_s.strip.downcase.tr('_', '-')
122+
end
123+
end
124+
end
125+
end
126+
end

0 commit comments

Comments
 (0)