Skip to content

Commit cd9b85f

Browse files
committed
Stage regular-string translations into Localizable.xcstrings
Adds the localize half of the String Catalog pipeline: a manual (non-CI) lane that folds current GlotPress translations for regular, non-plural strings into the existing Localizable.xcstrings as `human ?? AI ?? English`. The catalog isn't the runtime store yet (the app still ships Localizable.strings), so this only pre-populates it for the eventual cutover — nothing users see changes. - `localize_catalog` lane: download current GlotPress translations into a throwaway dir, fold them in (human => `translated`), then AI-fill the rest (=> `needs_review`). Manual only — it calls the translation API (cost) and commits a large catalog; never wired into `download_localized_strings` or CI. - `CatalogStrings.fold_translations!`: pure, reuse-aware fold. An existing valid cell is kept, so re-runs only translate genuinely-new gaps — the catalog's `needs_review` state IS the persistence (no side-store). A human translation from GlotPress supersedes a kept cell on the next fold. - Immutable-key enforcement replaces `reconcile_changed_sources`: reworded English in place is now a hard error, since `xcstringstool sync` silently keeps the old key's translations and would ship stale text. Rewording requires a new key. - `translate_all` keeps completed batches when one fails mid-run, so a partial API failure degrades to English for the remainder instead of dropping the lot. - docs/localization-pipeline.md documents the regular-string fold and the `human ?? AI ?? English` floor — any translation, human or machine, that fails the placeholder gate falls through to the next rung.
1 parent babe8c5 commit cd9b85f

11 files changed

Lines changed: 1027 additions & 93 deletions

docs/localization-pipeline.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
How user-facing strings get from English source into every shipped locale. This is the **release/tooling** view (the fastlane lanes under `fastlane/lanes/`); for how to *write* localizable strings in app code, see [localization.md](./localization.md).
44

5-
> The contract for every shipped string is **`human ?? AI ?? English`**: a human (GlotPress) translation if one exists, otherwise a machine translation, otherwise the English source. Nothing ships a broken placeholder — machine output that fails the format-specifier gate falls back to English.
5+
> The contract for every shipped string is **`human ?? AI ?? English`**: a human (GlotPress) translation if one exists, otherwise a machine translation, otherwise the English source. Nothing ships a broken placeholder — **any** translation, human or machine, that fails the format-specifier gate is rejected and falls through to the next rung.
66
77
## The round trip
88

@@ -45,13 +45,20 @@ The plural reverse-fold (`PluralStrings.fold_translations!`) fills each `(key, l
4545

4646
> **This does not ship machine translations yet.** `Plurals.xcstrings` is built into the app but **not consumed at runtime** — nothing reads the catalog, and nothing references its keys yet; the app still renders plurals the legacy way. The fold *pre-populates* the catalog so it's ready when plurals cut over to it. Until that cutover, the AI plural translations sit in the catalog unused.
4747
48-
## What's deferred: regular strings
48+
## What's staged, not shipped: regular strings
4949

50-
Regular (non-plural) strings are **not** machine-translated, by design. The app still ships the legacy `WordPress/Resources/<locale>.lproj/Localizable.strings` for them — `Localizable.xcstrings` (`generate_strings_catalog`) is the designated future backing store, but today it's only generated transiently in CI as a coverage check — the file is gitignored, not committed, and nothing ships from it. A machine translation written into the legacy `.strings` would be **live immediately**, and we don't want machine-translated regular strings shipping before the catalog cutover.
50+
Regular (non-plural) strings still ship the legacy way — from `WordPress/Resources/<locale>.lproj/Localizable.strings`, with no machine translation. A machine translation written there would be **live immediately**, and we don't want machine-translated regular strings shipping before the catalog cutover. `Localizable.xcstrings` (`generate_strings_catalog`) is the designated future backing store; it's gitignored and not a build member, so nothing ships from it.
5151

52-
So regular-string MT waits for the same shape as plurals: once `Localizable.xcstrings` becomes the runtime store, a regular-string **catalog reverse-fold** folds the human translations in and AI-fills the `needs_review` gaps, staged in the catalog (not shipped) until cutover — exactly as the plural fold does today.
52+
The tooling to **stage** regular-string translations into that catalog now exists, the same shape as the plural fold. `CatalogStrings.fold_translations!` fills each `(key, locale)` cell of `Localizable.xcstrings` as `human ?? AI ?? English` — human ⇒ `translated`, machine / English ⇒ `needs_review` — and is reuse-aware in the same way: a kept machine cell isn't re-translated, and a human translation supersedes it on the next fold. It runs as two manual lanes: `generate_strings_catalog` (extract the English source) and `localize_catalog` (download GlotPress into a throwaway temp dir, fold the humans in, AI-fill the rest, commit the catalog). The download is a fresh temp dir every run, so no stale/partial translation state is ever carried between runs.
5353

54-
When that's built, two facts established here will carry over:
54+
Two things keep it from shipping anything today, and both set it apart from the plural fold:
55+
56+
- **Manual, not in the release path.** These lanes aren't wired into `download_localized_strings` or any beta/release step — a run extracts strings, calls the API (cost), and commits a large catalog, so it's run on demand. Only the unit tests run in CI.
57+
- **Staged, not shipped.** `Localizable.xcstrings` still isn't the runtime store, so the folded translations sit in the catalog unused until the cutover — exactly like the plural catalog.
58+
59+
**Keys are immutable.** `generate_strings_catalog` hard-fails if an explicit-key string's English is reworded in place — `xcstringstool sync` would silently keep that key's now-stale translations, and the fold can't tell a translation of the old English from a current one. Rewording requires a **new key**. Key-as-source strings are exempt: rewording one changes the key, which sync handles as new/stale. (Enforcement today fires where the catalog persists; extending it to the transient-catalog CI path is a follow-up — a lint on the committed English `.strings`.)
60+
61+
Two facts the fold relies on, both established by the reverse download:
5562

5663
- **"Undefined by GlotPress" = absent**, not empty. The export omits untranslated strings (`status: current`; verified no empty-valued entries), so absence is the untranslated signal.
5764
- **Humans always supersede MT**, and machine output never returns to GlotPress — so there's no translation-memory pollution and no manual reconciliation, as long as MT lives in a state-bearing store (the catalog's `needs_review`).
@@ -77,5 +84,5 @@ When that's built, two facts established here will carry over:
7784
| Brand do-not-translate + per-locale terms | `fastlane/lanes/translation_glossary.rb` |
7885
| Anthropic SDK glue + Message Batches | `fastlane/lanes/anthropic_batch.rb` |
7986
| Plural fold (`Localizable.strings``Plurals.xcstrings`) + AI wiring | `fastlane/lanes/plural_strings_helper.rb`, `fastlane/lanes/localization_plurals.rb` |
80-
| Catalog generation (future regular-string backing store) | `fastlane/lanes/localization_catalog.rb` |
87+
| Catalog generation + regular-string fold (staged, manual) | `fastlane/lanes/localization_catalog.rb`, `fastlane/lanes/catalog_strings_helper.rb` |
8188
| Download/upload orchestration | `fastlane/lanes/localization.rb` |

fastlane/lanes/ai_translator.rb

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,16 +160,26 @@ def translate_plural(english_forms:, categories:, locale:, note: nil, anchors: {
160160
# strings already sorted by key so each batch naturally groups one feature (reader.*, editor.*) — better
161161
# terminology consistency within a batch.
162162
#
163+
# A batch spanning many keys is many sequential requests. If one FAILS mid-run (network / rate limit / SDK
164+
# error), the batches that already succeeded are KEPT and the run stops — the remaining strings fall back to
165+
# English and are retried on the next run, rather than the whole locale's completed (and billed) work being
166+
# discarded. Stopping (vs. continuing) avoids hammering the API when the failure is systemic.
167+
#
163168
# @param strings [Array<Hash>] each { key:, source:, comment: } (string or symbol keys both accepted).
164169
# @param locale [String] target lproj code.
165170
# @param batch_size [Integer] strings per request.
166171
def translate_all(strings, locale:, batch_size: DEFAULT_BATCH_SIZE)
167172
items = batchable_items(strings)
168173
return {} if items.empty?
169174

170-
items.each_slice(batch_size).with_object({}) do |chunk, out|
171-
out.merge!(translate_batch(chunk, locale))
175+
translated = {}
176+
items.each_slice(batch_size).with_index do |chunk, index|
177+
translated.merge!(translate_batch(chunk, locale))
178+
rescue StandardError => e
179+
warn_batch_failure(locale: locale, index: index, kept: translated.size, error: e)
180+
break
172181
end
182+
translated
173183
end
174184

175185
# Builds Message Batch jobs for many strings across many locales (the async / cheaper bulk path). Returns
@@ -297,6 +307,14 @@ def to_string_keys(hash)
297307
(hash || {}).each_with_object({}) { |(key, value), acc| acc[key.to_s] = value }
298308
end
299309

310+
# A batch request failed mid-run. Keep the batches that already succeeded and STOP rather than hammer the rest:
311+
# the untranslated strings fall back to English and are retried next run (the fold's reuse-awareness means the
312+
# kept ones aren't re-billed). Surfaced on stderr — this class is deliberately fastlane-free, so no UI here.
313+
def warn_batch_failure(locale:, index:, kept:, error:)
314+
warn "AITranslator: batch #{index + 1} for '#{locale}' failed (#{error.message}); " \
315+
"kept #{kept} translation(s), the rest fall back to English (retry next run)."
316+
end
317+
300318
# One batched request: number the chunk, ask for a JSON {number => translation}, keep the validated ones.
301319
def translate_batch(chunk, locale)
302320
numbered = number_chunk(chunk)

fastlane/lanes/ai_translator_test.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# Uses a canned-reply lambda for `complete:`, so it exercises all of the prompt-building / validation logic
55
# without the `anthropic` gem or the network.
66
require 'minitest/autorun'
7+
require 'stringio'
78
require_relative 'ai_translator'
89

910
# Exercises prompt-building and the validator gate via a canned-reply `complete:` lambda (no gem / network).
@@ -17,6 +18,16 @@ def translator(reply:, prompts: nil)
1718
AITranslator.new(complete: complete)
1819
end
1920

21+
# Runs the block with $stderr captured, returning what it wrote (the class surfaces batch failures via warn).
22+
def capture_stderr
23+
original = $stderr
24+
$stderr = StringIO.new
25+
yield
26+
$stderr.string
27+
ensure
28+
$stderr = original
29+
end
30+
2031
def test_returns_cleaned_translation
2132
t = translator(reply: %("Réglages"\n)) # wrapped in quotes + trailing newline
2233
assert_equal 'Réglages', t.translate(source: 'Settings', locale: 'fr')
@@ -182,6 +193,37 @@ def test_translate_all_bad_json_batch_falls_back
182193
assert_empty out
183194
end
184195

196+
def test_translate_all_keeps_completed_batches_when_a_later_batch_fails
197+
calls = 0
198+
complete = lambda do |**|
199+
calls += 1
200+
raise 'rate limited' if calls == 2 # the second batch fails mid-run
201+
202+
'{"1":"x","2":"y"}'
203+
end
204+
out = nil
205+
warnings = capture_stderr do
206+
out = AITranslator.new(complete: complete).translate_all(
207+
[{ key: 'a', source: 'One' }, { key: 'b', source: 'Two' },
208+
{ key: 'c', source: 'Three' }, { key: 'd', source: 'Four' },
209+
{ key: 'e', source: 'Five' }, { key: 'f', source: 'Six' }],
210+
locale: 'fr', batch_size: 2
211+
)
212+
end
213+
214+
assert_equal 2, calls, 'must stop after the failing batch, not hammer the remaining ones'
215+
assert_equal({ 'a' => 'x', 'b' => 'y' }, out, 'the first completed batch is kept, not discarded')
216+
assert_match(/batch 2 for 'fr' failed \(rate limited\)/, warnings)
217+
end
218+
219+
def test_translate_all_returns_empty_without_raising_when_the_first_batch_fails
220+
complete = ->(**) { raise 'network down' }
221+
out = nil
222+
capture_stderr { out = AITranslator.new(complete: complete).translate_all([{ key: 'a', source: 'One' }], locale: 'fr') }
223+
224+
assert_empty out, 'a total failure degrades to English (empty), and must not propagate the exception'
225+
end
226+
185227
def test_translate_all_empty_input_makes_no_call
186228
called = false
187229
complete = lambda do |**|

fastlane/lanes/catalog_helper.rb

Lines changed: 18 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -29,70 +29,29 @@ def canonical(key)
2929
key.gsub(FORMAT_SPECIFIER, "\u0001")
3030
end
3131

32-
# --- needs_review reconciliation ----------------------------------------------------------------------
32+
# --- immutable-key enforcement -------------------------------------------------------------------------
3333

34-
35-
# `xcstringstool sync` does NOT reconcile an existing key whose English source VALUE changed: it leaves
36-
# both the stored English value and the affected translations untouched (verified — source "Settings" →
37-
# "Preferences" left en="Settings" and fr="translated"). The in-Xcode build does this reconciliation; the
38-
# standalone CLI does not. This closes that gap: where the freshly-extracted English differs from what the
39-
# catalog stores, it updates the English value and flips that key's translations from `translated` to
40-
# `needs_review` (so the AI/human pipeline re-checks them).
34+
# `xcstringstool sync` does NOT touch an existing key whose English source VALUE changed in place: it leaves
35+
# both the stored English and the affected translations as-is (verified — source "Settings" → "Preferences"
36+
# left en="Settings" and fr="translated"). The in-Xcode build reconciles this; the standalone CLI doesn't.
37+
#
38+
# Localization keys are IMMUTABLE, so an in-place reword is a rule violation, not something to reconcile:
39+
# rewording must mint a NEW key, otherwise the old key silently keeps translations of the OLD English — a
40+
# stale translation the fold can't distinguish from a current one. This reports the offending keys so the
41+
# lane can hard-fail (rename the key, or revert the English change).
4142
#
42-
# Out of scope here (handled elsewhere): English-as-key strings — editing their text changes the KEY, which
43-
# sync already handles as new/stale; and plural entries, whose English is itself a plural variation, so
44-
# `reconcile_entry!` bails (no flat English `stringUnit`) — those live in the separate plurals catalog.
45-
# Translation-side device/width variations of a regular string ARE reconciled (see `string_units`).
43+
# Naturally scoped to explicit-key strings: a key-as-source string has no stored `en` value (its key IS its
44+
# English), and rewording one changes the KEY — sync handles that as new/stale, not a value change — so it
45+
# never appears here. Plurals don't either (their English is a plural variation, not a flat `stringUnit`).
4646
#
47-
# @param catalog [Hash] parsed `.xcstrings`, mutated in place
47+
# @param catalog [Hash] parsed `.xcstrings`
4848
# @param current_en [Hash{String=>String}] key => freshly-extracted English value
49-
# @return [Array<String>] keys that were reconciled (English updated + translations re-flagged)
50-
def reconcile_source_changes!(catalog, current_en)
49+
# @return [Array<String>] keys whose stored English no longer matches the source (empty ⇒ nothing reworded)
50+
def reworded_keys(catalog, current_en)
5151
(catalog['strings'] || {}).filter_map do |key, entry|
52-
key if reconcile_entry!(entry, current_en[key])
53-
end
54-
end
55-
56-
# Reconcile one entry against its freshly-extracted English value. Returns the entry (truthy) if it
57-
# changed, nil otherwise — matching the Ruby bang-method convention (cf. String#gsub!).
58-
def reconcile_entry!(entry, new_value)
59-
return if new_value.nil?
60-
61-
english = entry.dig('localizations', 'en', 'stringUnit')
62-
return if english.nil? || english['value'] == new_value
63-
64-
english['value'] = new_value
65-
flag_translations_for_review!(entry['localizations'])
66-
entry
67-
end
68-
69-
def flag_translations_for_review!(localizations)
70-
localizations.each do |locale, body|
71-
next if locale == 'en' || body.nil?
72-
73-
string_units(body).each do |unit|
74-
unit['state'] = 'needs_review' if unit['state'] == 'translated'
75-
end
76-
end
77-
end
78-
79-
# All stringUnits in a localization body, whether stored flat (`stringUnit`) or nested under one or more
80-
# `variations` (a regular string's translation can be varied by device/width, and variations can nest).
81-
# Returns the unit hashes themselves so a caller can flip their `state` in place — a single top-level
82-
# `body['stringUnit']` lookup would miss the varied leaves entirely.
83-
def string_units(node)
84-
return [] unless node.is_a?(Hash)
85-
86-
units = []
87-
units << node['stringUnit'] if node['stringUnit'].is_a?(Hash)
88-
variations = node['variations']
89-
if variations.is_a?(Hash)
90-
variations.each_value do |cases|
91-
next unless cases.is_a?(Hash)
92-
93-
cases.each_value { |child| units.concat(string_units(child)) }
94-
end
52+
stored = entry.dig('localizations', 'en', 'stringUnit', 'value')
53+
fresh = current_en[key]
54+
key if stored && fresh && stored != fresh
9555
end
96-
units
9756
end
9857
end
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# frozen_string_literal: true
2+
3+
# Pure-Ruby unit suite for CatalogHelper.reworded_keys — the immutable-key guard that detects an explicit-key
4+
# string whose English source was reworded in place (which `xcstringstool sync` silently ignores, leaving stale
5+
# translations). Run directly: `ruby fastlane/lanes/catalog_helper_test.rb`. No bundle / network.
6+
require 'minitest/autorun'
7+
require_relative 'catalog_helper'
8+
9+
# Exercises the reword detector: an explicit-key string whose stored English differs from a fresh extraction is
10+
# flagged; key-as-source, variations/plural, unchanged, and removed keys are not.
11+
class CatalogHelperRewordedKeysTest < Minitest::Test
12+
def en(value)
13+
{ 'localizations' => { 'en' => { 'stringUnit' => { 'state' => 'translated', 'value' => value } } } }
14+
end
15+
16+
def reworded(strings, current_en)
17+
CatalogHelper.reworded_keys({ 'sourceLanguage' => 'en', 'version' => '1.0', 'strings' => strings }, current_en)
18+
end
19+
20+
def test_flags_an_explicit_key_whose_english_changed_in_place
21+
assert_equal ['common.save'], reworded({ 'common.save' => en('Save') }, { 'common.save' => 'Save all' })
22+
end
23+
24+
def test_does_not_flag_an_unchanged_key
25+
assert_empty reworded({ 'common.save' => en('Save') }, { 'common.save' => 'Save' })
26+
end
27+
28+
def test_does_not_flag_a_key_absent_from_the_fresh_extraction
29+
# The key was removed/renamed in code — sync handles that as stale; it is not an in-place reword.
30+
assert_empty reworded({ 'common.save' => en('Save') }, {})
31+
end
32+
33+
def test_does_not_flag_a_key_as_source_entry
34+
# No stored `en` value (the key IS the English); rewording it changes the key, so it can't appear here.
35+
assert_empty reworded({ '%1$@ on %2$@' => {} }, { '%1$@ on %2$@' => '%1$@ at %2$@' })
36+
end
37+
38+
def test_does_not_flag_a_variations_shaped_english
39+
# English stored under `variations` has no flat `en` stringUnit value, so it can never read as a reword.
40+
varied = { 'localizations' => { 'en' => { 'variations' => { 'device' => { 'iphone' => { 'stringUnit' => { 'state' => 'translated', 'value' => 'Tap' } } } } } } }
41+
assert_empty reworded({ 'app.banner' => varied }, { 'app.banner' => 'Click' })
42+
end
43+
44+
def test_reports_every_reworded_key
45+
strings = { 'a' => en('One'), 'b' => en('Two'), 'c' => en('Three') }
46+
assert_equal %w[b c], reworded(strings, { 'a' => 'One', 'b' => 'TWO', 'c' => 'THREE' }).sort
47+
end
48+
end

0 commit comments

Comments
 (0)