Skip to content

Commit ebd153b

Browse files
committed
Stage regular-string translations into Localizable.xcstrings (manual lane)
Adds a regular-string reverse fold — the catalog analogue of the plural fold — that populates Localizable.xcstrings (the future regular-string backing store) with GlotPress human translations plus AI machine translations, as human ?? existing-machine ?? AI ?? English (human => translated; machine / English => needs_review). - CatalogStrings.fold_translations! (pure, 12 tests): reuse-aware — a valid existing needs_review machine cell is kept, so the catalog's needs_review state is the persistence (no side-store) and re-runs only translate genuinely-new gaps. Humans supersede machine cells on the next fold. - download_localized_catalog lane: generate the English catalog, fold the downloaded .strings humans + AI-fill (translate_all), commit. Gated on ANTHROPIC_API_KEY. STAGED, NOT SHIPPED: the catalog isn't the runtime store yet, so this changes nothing users see. MANUAL ONLY: deliberately not wired into download_localized_strings or CI — it runs xcstringstool extraction, calls the API, and commits a large catalog, so it's run on demand.
1 parent 7412850 commit ebd153b

3 files changed

Lines changed: 323 additions & 0 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# frozen_string_literal: true
2+
3+
require_relative 'translation_validator'
4+
5+
# Reverse fold for regular (non-plural) strings into a String Catalog (`Localizable.xcstrings`) — the catalog
6+
# analogue of `PluralStrings.fold_translations!`. For each translatable key and target locale it sets the
7+
# stringUnit to `human ?? existing-machine ?? AI ?? English` (human => `translated`; machine / English fallback
8+
# => `needs_review`). Plain Ruby with no fastlane / gem dependencies, so it's unit-testable directly — the lane
9+
# in `localization_catalog.rb` calls into it.
10+
#
11+
# REUSE-AWARE: a cell that already holds a valid machine translation (a `needs_review` value that isn't just the
12+
# English source and still passes the placeholder gate) is kept untouched. That is the whole point of folding
13+
# into the catalog rather than the legacy `.strings`: the catalog's `needs_review` state IS the persistence, so
14+
# re-runs only translate genuinely-new gaps — no side-store, and a human translation from GlotPress supersedes a
15+
# machine cell automatically on the next fold.
16+
module CatalogStrings
17+
module_function
18+
19+
# Mutates `catalog`; returns the count of (key, locale) cells written.
20+
#
21+
# @param translations_by_locale [Hash{String=>Hash{String=>String}}] locale => { key => human value }, from
22+
# the downloaded `.lproj/Localizable.strings`.
23+
# @param locales [Array<String>] target locales to fold (the source locale is skipped).
24+
# @param ai_translator [#call] `call(entries, locale) => { key => translation }`, entries being
25+
# `[{ key:, source:, comment: }]`. Optional; nil ⇒ the fill rung is skipped (English fallback).
26+
def fold_translations!(catalog, translations_by_locale:, locales:, ai_translator: nil)
27+
source = catalog['sourceLanguage'] || 'en'
28+
sources = translatable_sources(catalog, source)
29+
(locales - [source]).sum do |locale|
30+
fold_locale!(catalog, locale, sources, translations_by_locale[locale] || {}, ai_translator)
31+
end
32+
end
33+
34+
# { key => { source:, comment: } } for every translatable key — its explicit English value, or the key itself
35+
# for key-as-source strings (genstrings's convention, where the English text *is* the key). Entries flagged
36+
# `shouldTranslate: false` are skipped.
37+
def translatable_sources(catalog, source)
38+
(catalog['strings'] || {}).each_with_object({}) do |(key, body), acc|
39+
next if body['shouldTranslate'] == false
40+
41+
value = body.dig('localizations', source, 'stringUnit', 'value') || key
42+
acc[key] = { source: value, comment: body['comment'] } unless value.to_s.empty?
43+
end
44+
end
45+
private_class_method :translatable_sources
46+
47+
# Fold one locale: resolve the human/reused cells, translate only what's left, write them all. Returns the
48+
# number of cells written.
49+
def fold_locale!(catalog, locale, sources, human, ai_translator)
50+
plan = plan_locale(catalog, locale, sources, human)
51+
cells = plan[:cells].merge(machine_cells(plan[:fresh], translate(ai_translator, plan[:fresh], locale)))
52+
cells.each { |key, unit| set_cell!(catalog, key, locale, unit) }
53+
cells.size
54+
end
55+
private_class_method :fold_locale!
56+
57+
# { key => machine stringUnit } for the fresh entries: the validated AI translation, or the English source as
58+
# a flagged fallback where the model returned nothing. Disjoint from the human/reused cells.
59+
def machine_cells(fresh, ai_reply)
60+
fresh.to_h { |entry| [entry[:key], ai_cell(ai_reply[entry[:key]], entry[:source])] }
61+
end
62+
private_class_method :machine_cells
63+
64+
# Partition this locale's keys into ready `cells` ({ key => stringUnit }: human ⇒ translated, reusable machine
65+
# ⇒ kept) and `fresh` ([{ key:, source:, comment: }] needing the model).
66+
def plan_locale(catalog, locale, sources, human)
67+
cells = {}
68+
fresh = []
69+
sources.each do |key, info|
70+
human_value = human[key]
71+
if !human_value.to_s.empty?
72+
cells[key] = cell('translated', human_value)
73+
elsif (reused = reusable_cell(catalog, key, locale, info[:source]))
74+
cells[key] = reused
75+
else
76+
fresh << { key: key, source: info[:source], comment: info[:comment] }
77+
end
78+
end
79+
{ cells: cells, fresh: fresh }
80+
end
81+
private_class_method :plan_locale
82+
83+
# The existing machine cell to keep, or nil: a stringUnit whose value is present, isn't just the English
84+
# source (an unfilled English fallback we should retry), and still satisfies the placeholder gate.
85+
def reusable_cell(catalog, key, locale, source)
86+
unit = catalog.dig('strings', key, 'localizations', locale, 'stringUnit')
87+
return nil if unit.nil?
88+
89+
value = unit['value'].to_s
90+
return nil if value.empty? || value == source || !TranslationValidator.placeholders_match?(source, value)
91+
92+
unit
93+
end
94+
private_class_method :reusable_cell
95+
96+
def translate(ai_translator, fresh, locale)
97+
return {} if ai_translator.nil? || fresh.empty?
98+
99+
ai_translator.call(fresh, locale) || {}
100+
end
101+
private_class_method :translate
102+
103+
# A machine cell: the validated AI translation if present, else the English source as a flagged fallback.
104+
def ai_cell(translation, source)
105+
cell('needs_review', translation.to_s.empty? ? source : translation)
106+
end
107+
private_class_method :ai_cell
108+
109+
def set_cell!(catalog, key, locale, unit)
110+
localizations = (catalog['strings'][key]['localizations'] ||= {})
111+
localizations[locale] = { 'stringUnit' => unit }
112+
end
113+
private_class_method :set_cell!
114+
115+
def cell(state, value)
116+
{ 'state' => state, 'value' => value }
117+
end
118+
private_class_method :cell
119+
end
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# frozen_string_literal: true
2+
3+
# Pure-Ruby unit suite for CatalogStrings.fold_translations! — the regular-string reverse fold into
4+
# Localizable.xcstrings. Run directly: `ruby fastlane/lanes/catalog_strings_helper_test.rb`. No bundle / network
5+
# (the AI tier is a stub lambda).
6+
require 'minitest/autorun'
7+
require_relative 'catalog_strings_helper'
8+
9+
# Exercises provenance (human => translated; machine / English fallback => needs_review), the reuse rule (a
10+
# valid existing machine cell is kept and not re-translated; an English-fallback or placeholder-broken cell is
11+
# retried), key-as-source handling, shouldTranslate, and the batched per-locale AI call.
12+
class CatalogStringsFoldTest < Minitest::Test
13+
def unit(state, value)
14+
{ 'stringUnit' => { 'state' => state, 'value' => value } }
15+
end
16+
17+
# A catalog entry with an explicit English value, optional comment, and optional pre-existing localizations.
18+
def entry(english, comment: nil, locs: {})
19+
body = { 'localizations' => { 'en' => unit('translated', english) }.merge(locs) }
20+
body['comment'] = comment if comment
21+
body
22+
end
23+
24+
def catalog(strings)
25+
{ 'sourceLanguage' => 'en', 'version' => '1.0', 'strings' => strings }
26+
end
27+
28+
def cell(cat, key, locale)
29+
cat.dig('strings', key, 'localizations', locale, 'stringUnit')
30+
end
31+
32+
# An AI stub returning `reply` ({ key => translation }), recording each (entries, locale) call.
33+
def recording_translator(reply:, calls:)
34+
lambda do |entries, locale|
35+
calls << { entries: entries, locale: locale }
36+
reply
37+
end
38+
end
39+
40+
def fold(cat, translations: {}, locales: %w[en fr], ai_translator: nil)
41+
CatalogStrings.fold_translations!(cat, translations_by_locale: translations, locales: locales, ai_translator: ai_translator)
42+
end
43+
44+
def test_human_translation_is_used_and_marked_translated
45+
cat = catalog('a' => entry('Save'))
46+
written = fold(cat, translations: { 'fr' => { 'a' => 'Enregistrer' } })
47+
48+
assert_equal 1, written
49+
assert_equal({ 'state' => 'translated', 'value' => 'Enregistrer' }, cell(cat, 'a', 'fr'))
50+
end
51+
52+
def test_ai_fills_missing_and_marks_needs_review
53+
cat = catalog('a' => entry('Save'))
54+
fold(cat, ai_translator: recording_translator(reply: { 'a' => 'Enregistrer' }, calls: []))
55+
56+
assert_equal({ 'state' => 'needs_review', 'value' => 'Enregistrer' }, cell(cat, 'a', 'fr'))
57+
end
58+
59+
def test_english_fallback_when_no_human_and_no_ai
60+
cat = catalog('a' => entry('Save'))
61+
fold(cat)
62+
63+
assert_equal({ 'state' => 'needs_review', 'value' => 'Save' }, cell(cat, 'a', 'fr'))
64+
end
65+
66+
def test_existing_machine_cell_is_reused_without_calling_the_model
67+
cat = catalog('a' => entry('Save', locs: { 'fr' => unit('needs_review', 'Enregistrer') }))
68+
calls = []
69+
fold(cat, ai_translator: recording_translator(reply: {}, calls: calls))
70+
71+
assert_empty calls, 'a reusable machine cell must not trigger a model call'
72+
assert_equal({ 'state' => 'needs_review', 'value' => 'Enregistrer' }, cell(cat, 'a', 'fr'))
73+
end
74+
75+
def test_english_fallback_cell_is_retried_not_reused
76+
# A prior cell whose value is just the English source was an unfilled fallback — retry it.
77+
cat = catalog('a' => entry('Save', locs: { 'fr' => unit('needs_review', 'Save') }))
78+
calls = []
79+
fold(cat, ai_translator: recording_translator(reply: { 'a' => 'Enregistrer' }, calls: calls))
80+
81+
assert_equal(['a'], calls.first[:entries].map { |e| e[:key] })
82+
assert_equal({ 'state' => 'needs_review', 'value' => 'Enregistrer' }, cell(cat, 'a', 'fr'))
83+
end
84+
85+
def test_placeholder_broken_cell_is_retried
86+
cat = catalog('a' => entry('%1$d posts', locs: { 'fr' => unit('needs_review', 'articles') }))
87+
fold(cat, ai_translator: recording_translator(reply: { 'a' => '%1$d articles' }, calls: []))
88+
89+
assert_equal({ 'state' => 'needs_review', 'value' => '%1$d articles' }, cell(cat, 'a', 'fr'))
90+
end
91+
92+
def test_human_supersedes_existing_machine_cell
93+
cat = catalog('a' => entry('Save', locs: { 'fr' => unit('needs_review', 'old machine value') }))
94+
fold(cat, translations: { 'fr' => { 'a' => 'Enregistrer' } })
95+
96+
assert_equal({ 'state' => 'translated', 'value' => 'Enregistrer' }, cell(cat, 'a', 'fr'))
97+
end
98+
99+
def test_key_as_source_string_uses_the_key_as_english
100+
cat = catalog('%1$@ on %2$@' => {}) # no English localization: the key is the source
101+
calls = []
102+
fold(cat, ai_translator: recording_translator(reply: {}, calls: calls))
103+
104+
assert_equal '%1$@ on %2$@', calls.first[:entries].first[:source]
105+
assert_equal({ 'state' => 'needs_review', 'value' => '%1$@ on %2$@' }, cell(cat, '%1$@ on %2$@', 'fr'))
106+
end
107+
108+
def test_should_translate_false_is_skipped
109+
cat = catalog(
110+
'a' => entry('Save'),
111+
'b' => entry('WordPress').merge('shouldTranslate' => false)
112+
)
113+
written = fold(cat)
114+
115+
assert_equal 1, written
116+
assert_nil cell(cat, 'b', 'fr'), 'shouldTranslate:false entries get no translations'
117+
end
118+
119+
def test_source_locale_is_not_folded
120+
cat = catalog('a' => entry('Save'))
121+
original_en = cat.dig('strings', 'a', 'localizations', 'en')
122+
fold(cat, locales: %w[en fr])
123+
124+
assert_same original_en, cat.dig('strings', 'a', 'localizations', 'en')
125+
end
126+
127+
def test_ai_called_once_per_locale_with_batched_entries
128+
cat = catalog('a' => entry('Save'), 'b' => entry('Posts: %1$d', comment: 'count'))
129+
calls = []
130+
fold(cat, ai_translator: recording_translator(reply: { 'a' => 'Enregistrer', 'b' => 'Articles : %1$d' }, calls: calls))
131+
132+
assert_equal 1, calls.size
133+
assert_equal 'fr', calls.first[:locale]
134+
assert_equal(
135+
[{ key: 'a', source: 'Save', comment: nil }, { key: 'b', source: 'Posts: %1$d', comment: 'count' }],
136+
calls.first[:entries]
137+
)
138+
end
139+
140+
def test_counts_cells_across_locales
141+
cat = catalog('a' => entry('Save'))
142+
assert_equal 2, fold(cat, locales: %w[en fr de])
143+
end
144+
end

fastlane/lanes/localization_catalog.rb

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
require 'tmpdir'
55
require 'fileutils'
66
require_relative 'catalog_helper'
7+
require_relative 'catalog_strings_helper'
78

89
#################################################
910
# Catalog generation (forward / extraction)
@@ -81,6 +82,34 @@
8182
end
8283
end
8384

85+
# Folds the downloaded GlotPress translations (human) plus AI machine translations into Localizable.xcstrings,
86+
# the future regular-string backing store, as `human ?? existing-machine ?? AI ?? English` (see CatalogStrings).
87+
#
88+
# STAGED, NOT SHIPPED: the catalog isn't the runtime store yet (the app still ships Localizable.strings), so
89+
# this only pre-populates it for the eventual cutover — it changes nothing users see.
90+
#
91+
# MANUAL ONLY — deliberately NOT wired into download_localized_strings or any CI step: it runs xcstringstool
92+
# extraction, calls the translation API (cost), and commits a large catalog, so it's run on demand, not on
93+
# every release. Run `download_localized_strings` first (so the per-locale `.strings` exist) and, for the AI
94+
# rung, set ANTHROPIC_API_KEY.
95+
desc 'Folds GlotPress + AI translations into Localizable.xcstrings (staged backing store; run manually, not in CI)'
96+
lane :download_localized_catalog do
97+
generate_strings_catalog # refresh the English source + reconcile (creates the catalog on first run)
98+
catalog = JSON.parse(File.read(LOCALIZABLE_CATALOG))
99+
100+
written = CatalogStrings.fold_translations!(
101+
catalog,
102+
translations_by_locale: catalog_translations_by_locale(File.join(PROJECT_ROOT_FOLDER, 'WordPress', 'Resources')),
103+
locales: GLOTPRESS_TO_LPROJ_APP_LOCALE_CODES.values.uniq,
104+
ai_translator: catalog_ai_translator
105+
)
106+
File.write(LOCALIZABLE_CATALOG, "#{JSON.pretty_generate(catalog)}\n")
107+
UI.success("Folded translations into #{File.basename(LOCALIZABLE_CATALOG)} (#{written} cells across locales).")
108+
109+
git_add(path: LOCALIZABLE_CATALOG, shell_escape: false)
110+
git_commit(path: [LOCALIZABLE_CATALOG], message: 'Update Localizable.xcstrings translations (staged)', allow_nothing_to_commit: true)
111+
end
112+
84113
#################################################
85114
# Helpers
86115
#################################################
@@ -199,4 +228,35 @@ def report_catalog(path, extracted_count:, reconciled_count:)
199228
message += " Re-flagged #{reconciled_count} for review (English source changed)." if reconciled_count.positive?
200229
UI.success(message)
201230
end
231+
232+
# { lproj => { key => human value } } from the downloaded translation `.strings`. The flat plural keys present
233+
# in these files aren't catalog keys, so the fold ignores them (they belong to Plurals.xcstrings).
234+
def catalog_translations_by_locale(dir)
235+
Dir.glob(File.join(dir, '*.lproj', 'Localizable.strings')).each_with_object({}) do |path, acc|
236+
locale = File.basename(File.dirname(path), '.lproj')
237+
acc[locale] = Fastlane::Helper::Ios::L10nHelper.read_strings_file_as_hash(path: path)
238+
end
239+
end
240+
241+
# The AI tier for the catalog fold, or nil when ANTHROPIC_API_KEY isn't set (the fold then fills only human +
242+
# English). Returns `call(entries, locale) => { key => translation }` via AITranslator#translate_all,
243+
# degrading to {} on a per-locale API failure so one locale can't abort the whole fold.
244+
def catalog_ai_translator
245+
if ENV['ANTHROPIC_API_KEY'].to_s.empty?
246+
UI.important('ANTHROPIC_API_KEY not set — folding human + English only; undefined cells stay English (needs_review).')
247+
return nil
248+
end
249+
250+
require_relative 'ai_translator'
251+
translator = AITranslator.with_anthropic
252+
lambda do |entries, locale|
253+
translator.translate_all(entries, locale: locale)
254+
rescue StandardError => e
255+
UI.error("AI catalog translation failed for #{locale} (#{e.message}); leaving its undefined cells to English.")
256+
{}
257+
end
258+
rescue LoadError => e
259+
UI.important("AI translation tier unavailable (#{e.message}); folding human + English only.")
260+
nil
261+
end
202262
end

0 commit comments

Comments
 (0)