diff --git a/docs/localization-pipeline.md b/docs/localization-pipeline.md index 8f0189c3ed8b..1b3141ed809a 100644 --- a/docs/localization-pipeline.md +++ b/docs/localization-pipeline.md @@ -2,7 +2,7 @@ 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). -> 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. +> 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. ## The round trip @@ -45,13 +45,20 @@ The plural reverse-fold (`PluralStrings.fold_translations!`) fills each `(key, l > **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. -## What's deferred: regular strings +## What's staged, not shipped: regular strings -Regular (non-plural) strings are **not** machine-translated, by design. The app still ships the legacy `WordPress/Resources/.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. +Regular (non-plural) strings still ship the legacy way — from `WordPress/Resources/.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. -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. +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. -When that's built, two facts established here will carry over: +Two things keep it from shipping anything today, and both set it apart from the plural fold: + +- **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. +- **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. + +**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`.) + +Two facts the fold relies on, both established by the reverse download: - **"Undefined by GlotPress" = absent**, not empty. The export omits untranslated strings (`status: current`; verified no empty-valued entries), so absence is the untranslated signal. - **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: | Brand do-not-translate + per-locale terms | `fastlane/lanes/translation_glossary.rb` | | Anthropic SDK glue + Message Batches | `fastlane/lanes/anthropic_batch.rb` | | Plural fold (`Localizable.strings` ⇄ `Plurals.xcstrings`) + AI wiring | `fastlane/lanes/plural_strings_helper.rb`, `fastlane/lanes/localization_plurals.rb` | -| Catalog generation (future regular-string backing store) | `fastlane/lanes/localization_catalog.rb` | +| Catalog generation + regular-string fold (staged, manual) | `fastlane/lanes/localization_catalog.rb`, `fastlane/lanes/catalog_strings_helper.rb` | | Download/upload orchestration | `fastlane/lanes/localization.rb` | diff --git a/fastlane/lanes/ai_translator.rb b/fastlane/lanes/ai_translator.rb index 7dd0c4d21d21..70cd0e370442 100644 --- a/fastlane/lanes/ai_translator.rb +++ b/fastlane/lanes/ai_translator.rb @@ -160,6 +160,11 @@ def translate_plural(english_forms:, categories:, locale:, note: nil, anchors: { # strings already sorted by key so each batch naturally groups one feature (reader.*, editor.*) — better # terminology consistency within a batch. # + # A batch spanning many keys is many sequential requests. If one FAILS mid-run (network / rate limit / SDK + # error), the batches that already succeeded are KEPT and the run stops — the remaining strings fall back to + # English and are retried on the next run, rather than the whole locale's completed (and billed) work being + # discarded. Stopping (vs. continuing) avoids hammering the API when the failure is systemic. + # # @param strings [Array] each { key:, source:, comment: } (string or symbol keys both accepted). # @param locale [String] target lproj code. # @param batch_size [Integer] strings per request. @@ -167,9 +172,14 @@ def translate_all(strings, locale:, batch_size: DEFAULT_BATCH_SIZE) items = batchable_items(strings) return {} if items.empty? - items.each_slice(batch_size).with_object({}) do |chunk, out| - out.merge!(translate_batch(chunk, locale)) + translated = {} + items.each_slice(batch_size).with_index do |chunk, index| + translated.merge!(translate_batch(chunk, locale)) + rescue StandardError => e + warn_batch_failure(locale: locale, index: index, kept: translated.size, error: e) + break end + translated end # 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) (hash || {}).each_with_object({}) { |(key, value), acc| acc[key.to_s] = value } end + # A batch request failed mid-run. Keep the batches that already succeeded and STOP rather than hammer the rest: + # the untranslated strings fall back to English and are retried next run (the fold's reuse-awareness means the + # kept ones aren't re-billed). Surfaced on stderr — this class is deliberately fastlane-free, so no UI here. + def warn_batch_failure(locale:, index:, kept:, error:) + warn "AITranslator: batch #{index + 1} for '#{locale}' failed (#{error.message}); " \ + "kept #{kept} translation(s), the rest fall back to English (retry next run)." + end + # One batched request: number the chunk, ask for a JSON {number => translation}, keep the validated ones. def translate_batch(chunk, locale) numbered = number_chunk(chunk) diff --git a/fastlane/lanes/ai_translator_test.rb b/fastlane/lanes/ai_translator_test.rb index e637248ccb2c..91636897f3f6 100644 --- a/fastlane/lanes/ai_translator_test.rb +++ b/fastlane/lanes/ai_translator_test.rb @@ -4,6 +4,7 @@ # Uses a canned-reply lambda for `complete:`, so it exercises all of the prompt-building / validation logic # without the `anthropic` gem or the network. require 'minitest/autorun' +require 'stringio' require_relative 'ai_translator' # 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) AITranslator.new(complete: complete) end + # Runs the block with $stderr captured, returning what it wrote (the class surfaces batch failures via warn). + def capture_stderr + original = $stderr + $stderr = StringIO.new + yield + $stderr.string + ensure + $stderr = original + end + def test_returns_cleaned_translation t = translator(reply: %("Réglages"\n)) # wrapped in quotes + trailing newline assert_equal 'Réglages', t.translate(source: 'Settings', locale: 'fr') @@ -182,6 +193,37 @@ def test_translate_all_bad_json_batch_falls_back assert_empty out end + def test_translate_all_keeps_completed_batches_when_a_later_batch_fails + calls = 0 + complete = lambda do |**| + calls += 1 + raise 'rate limited' if calls == 2 # the second batch fails mid-run + + '{"1":"x","2":"y"}' + end + out = nil + warnings = capture_stderr do + out = AITranslator.new(complete: complete).translate_all( + [{ key: 'a', source: 'One' }, { key: 'b', source: 'Two' }, + { key: 'c', source: 'Three' }, { key: 'd', source: 'Four' }, + { key: 'e', source: 'Five' }, { key: 'f', source: 'Six' }], + locale: 'fr', batch_size: 2 + ) + end + + assert_equal 2, calls, 'must stop after the failing batch, not hammer the remaining ones' + assert_equal({ 'a' => 'x', 'b' => 'y' }, out, 'the first completed batch is kept, not discarded') + assert_match(/batch 2 for 'fr' failed \(rate limited\)/, warnings) + end + + def test_translate_all_returns_empty_without_raising_when_the_first_batch_fails + complete = ->(**) { raise 'network down' } + out = nil + capture_stderr { out = AITranslator.new(complete: complete).translate_all([{ key: 'a', source: 'One' }], locale: 'fr') } + + assert_empty out, 'a total failure degrades to English (empty), and must not propagate the exception' + end + def test_translate_all_empty_input_makes_no_call called = false complete = lambda do |**| diff --git a/fastlane/lanes/catalog_helper.rb b/fastlane/lanes/catalog_helper.rb index fc2884821659..449fa2ab1915 100644 --- a/fastlane/lanes/catalog_helper.rb +++ b/fastlane/lanes/catalog_helper.rb @@ -29,70 +29,29 @@ def canonical(key) key.gsub(FORMAT_SPECIFIER, "\u0001") end - # --- needs_review reconciliation ---------------------------------------------------------------------- + # --- immutable-key enforcement ------------------------------------------------------------------------- - - # `xcstringstool sync` does NOT reconcile an existing key whose English source VALUE changed: it leaves - # both the stored English value and the affected translations untouched (verified — source "Settings" → - # "Preferences" left en="Settings" and fr="translated"). The in-Xcode build does this reconciliation; the - # standalone CLI does not. This closes that gap: where the freshly-extracted English differs from what the - # catalog stores, it updates the English value and flips that key's translations from `translated` to - # `needs_review` (so the AI/human pipeline re-checks them). + # `xcstringstool sync` does NOT touch an existing key whose English source VALUE changed in place: it leaves + # both the stored English and the affected translations as-is (verified — source "Settings" → "Preferences" + # left en="Settings" and fr="translated"). The in-Xcode build reconciles this; the standalone CLI doesn't. + # + # Localization keys are IMMUTABLE, so an in-place reword is a rule violation, not something to reconcile: + # rewording must mint a NEW key, otherwise the old key silently keeps translations of the OLD English — a + # stale translation the fold can't distinguish from a current one. This reports the offending keys so the + # lane can hard-fail (rename the key, or revert the English change). # - # Out of scope here (handled elsewhere): English-as-key strings — editing their text changes the KEY, which - # sync already handles as new/stale; and plural entries, whose English is itself a plural variation, so - # `reconcile_entry!` bails (no flat English `stringUnit`) — those live in the separate plurals catalog. - # Translation-side device/width variations of a regular string ARE reconciled (see `string_units`). + # Naturally scoped to explicit-key strings: a key-as-source string has no stored `en` value (its key IS its + # English), and rewording one changes the KEY — sync handles that as new/stale, not a value change — so it + # never appears here. Plurals don't either (their English is a plural variation, not a flat `stringUnit`). # - # @param catalog [Hash] parsed `.xcstrings`, mutated in place + # @param catalog [Hash] parsed `.xcstrings` # @param current_en [Hash{String=>String}] key => freshly-extracted English value - # @return [Array] keys that were reconciled (English updated + translations re-flagged) - def reconcile_source_changes!(catalog, current_en) + # @return [Array] keys whose stored English no longer matches the source (empty ⇒ nothing reworded) + def reworded_keys(catalog, current_en) (catalog['strings'] || {}).filter_map do |key, entry| - key if reconcile_entry!(entry, current_en[key]) - end - end - - # Reconcile one entry against its freshly-extracted English value. Returns the entry (truthy) if it - # changed, nil otherwise — matching the Ruby bang-method convention (cf. String#gsub!). - def reconcile_entry!(entry, new_value) - return if new_value.nil? - - english = entry.dig('localizations', 'en', 'stringUnit') - return if english.nil? || english['value'] == new_value - - english['value'] = new_value - flag_translations_for_review!(entry['localizations']) - entry - end - - def flag_translations_for_review!(localizations) - localizations.each do |locale, body| - next if locale == 'en' || body.nil? - - string_units(body).each do |unit| - unit['state'] = 'needs_review' if unit['state'] == 'translated' - end - end - end - - # All stringUnits in a localization body, whether stored flat (`stringUnit`) or nested under one or more - # `variations` (a regular string's translation can be varied by device/width, and variations can nest). - # Returns the unit hashes themselves so a caller can flip their `state` in place — a single top-level - # `body['stringUnit']` lookup would miss the varied leaves entirely. - def string_units(node) - return [] unless node.is_a?(Hash) - - units = [] - units << node['stringUnit'] if node['stringUnit'].is_a?(Hash) - variations = node['variations'] - if variations.is_a?(Hash) - variations.each_value do |cases| - next unless cases.is_a?(Hash) - - cases.each_value { |child| units.concat(string_units(child)) } - end + stored = entry.dig('localizations', 'en', 'stringUnit', 'value') + fresh = current_en[key] + key if stored && fresh && stored != fresh end - units end end diff --git a/fastlane/lanes/catalog_helper_test.rb b/fastlane/lanes/catalog_helper_test.rb new file mode 100644 index 000000000000..02e4470777ad --- /dev/null +++ b/fastlane/lanes/catalog_helper_test.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# Pure-Ruby unit suite for CatalogHelper.reworded_keys — the immutable-key guard that detects an explicit-key +# string whose English source was reworded in place (which `xcstringstool sync` silently ignores, leaving stale +# translations). Run directly: `ruby fastlane/lanes/catalog_helper_test.rb`. No bundle / network. +require 'minitest/autorun' +require_relative 'catalog_helper' + +# Exercises the reword detector: an explicit-key string whose stored English differs from a fresh extraction is +# flagged; key-as-source, variations/plural, unchanged, and removed keys are not. +class CatalogHelperRewordedKeysTest < Minitest::Test + def en(value) + { 'localizations' => { 'en' => { 'stringUnit' => { 'state' => 'translated', 'value' => value } } } } + end + + def reworded(strings, current_en) + CatalogHelper.reworded_keys({ 'sourceLanguage' => 'en', 'version' => '1.0', 'strings' => strings }, current_en) + end + + def test_flags_an_explicit_key_whose_english_changed_in_place + assert_equal ['common.save'], reworded({ 'common.save' => en('Save') }, { 'common.save' => 'Save all' }) + end + + def test_does_not_flag_an_unchanged_key + assert_empty reworded({ 'common.save' => en('Save') }, { 'common.save' => 'Save' }) + end + + def test_does_not_flag_a_key_absent_from_the_fresh_extraction + # The key was removed/renamed in code — sync handles that as stale; it is not an in-place reword. + assert_empty reworded({ 'common.save' => en('Save') }, {}) + end + + def test_does_not_flag_a_key_as_source_entry + # No stored `en` value (the key IS the English); rewording it changes the key, so it can't appear here. + assert_empty reworded({ '%1$@ on %2$@' => {} }, { '%1$@ on %2$@' => '%1$@ at %2$@' }) + end + + def test_does_not_flag_a_variations_shaped_english + # English stored under `variations` has no flat `en` stringUnit value, so it can never read as a reword. + varied = { 'localizations' => { 'en' => { 'variations' => { 'device' => { 'iphone' => { 'stringUnit' => { 'state' => 'translated', 'value' => 'Tap' } } } } } } } + assert_empty reworded({ 'app.banner' => varied }, { 'app.banner' => 'Click' }) + end + + def test_reports_every_reworded_key + strings = { 'a' => en('One'), 'b' => en('Two'), 'c' => en('Three') } + assert_equal %w[b c], reworded(strings, { 'a' => 'One', 'b' => 'TWO', 'c' => 'THREE' }).sort + end +end diff --git a/fastlane/lanes/catalog_strings_helper.rb b/fastlane/lanes/catalog_strings_helper.rb new file mode 100644 index 000000000000..8f4fd9b53a08 --- /dev/null +++ b/fastlane/lanes/catalog_strings_helper.rb @@ -0,0 +1,257 @@ +# frozen_string_literal: true + +require_relative 'translation_validator' + +# Reverse fold for regular (non-plural) strings into a String Catalog (`Localizable.xcstrings`) — the catalog +# analogue of `PluralStrings.fold_translations!`. For each translatable key and target locale it sets the +# stringUnit to `human ?? existing-machine ?? AI ?? English` (human => `translated`; machine / English fallback +# => `needs_review`). Plain Ruby with no fastlane / gem dependencies, so it's unit-testable directly — the lane +# in `localization_catalog.rb` calls into it. +# +# REUSE-AWARE: a cell that already holds a valid translation (value present, not just the English source, still +# passing the placeholder gate) is kept untouched, whatever its state. Primarily this persists MACHINE cells — +# the whole point of folding into the catalog rather than the legacy `.strings`: the `needs_review` state IS the +# store (no side-store), so re-runs only translate genuinely-new gaps, and a human translation from GlotPress +# supersedes a kept cell automatically on the next fold. The reuse is deliberately state-agnostic, not +# machine-only — see reusable_cell. +# +# This module holds the pure logic behind the localize lanes — the fold, the per-locale run summary, and the +# `locales:` spec resolution — so the lane keeps only the fastlane/UI/file-IO glue and the whole thing lifts +# cleanly into release-toolkit later. +module CatalogStrings # rubocop:disable Metrics/ModuleLength -- the fold, its summary, and locale resolution over one catalog structure + module_function + + # Mutates `catalog`; returns the count of (key, locale) cells written. + # + # @param translations_by_locale [Hash{String=>Hash{String=>String}}] locale => { key => human value }, from + # the downloaded `.lproj/Localizable.strings`. + # @param locales [Array] target locales to fold (the source locale is skipped). + # @param ai_translator [#call] `call(entries, locale) => { key => translation }`, entries being + # `[{ key:, source:, comment: }]`. Optional; nil ⇒ the fill rung is skipped (English fallback). + def fold_translations!(catalog, translations_by_locale:, locales:, ai_translator: nil) + source = catalog['sourceLanguage'] || 'en' + sources = translatable_sources(catalog, source) + (locales - [source]).sum do |locale| + fold_locale!(catalog, locale, sources, translations_by_locale[locale] || {}, ai_translator) + end + end + + # A per-locale summary of a folded catalog, so a run can be eyeballed from the log without opening the file. + # For each target locale, counts how many entries are human translations (`translated`), machine translations + # (`needs_review` differing from English), and still English (`needs_review` equal to the English source), + # plus up to `sample_limit` example machine translations to spot-check quality. Reads the catalog as-is, so it + # also summarizes a catalog from a prior run. + # + # Returns { locale => { human:, machine:, english:, samples: [{ key:, english:, translation: }] } }. + def summarize(catalog, locales:, sample_limit: 5) + source = catalog['sourceLanguage'] || 'en' + strings = catalog['strings'] || {} + (locales - [source]).to_h do |locale| + [locale, summarize_locale(strings, locale, source, sample_limit)] + end + end + + def summarize_locale(strings, locale, source, sample_limit) + tally = { human: 0, machine: 0, english: 0, samples: [] } + strings.each do |key, body| + english = body.dig('localizations', source, 'stringUnit', 'value') || key + classify_cell!(tally, body.dig('localizations', locale, 'stringUnit'), english, key, sample_limit) + end + tally + end + private_class_method :summarize_locale + + # Fold one cell into the running tally: human (`translated`), still-English (`needs_review` == English), or + # machine (`needs_review` differing from English, a few kept as samples). Cells with any other state, or none + # for this locale, don't count. + def classify_cell!(tally, unit, english, key, sample_limit) + return if unit.nil? + + case unit['state'] + when 'translated' + tally[:human] += 1 + when 'needs_review' + if unit['value'] == english + tally[:english] += 1 + else + tally[:machine] += 1 + tally[:samples] << { key: key, english: english, translation: unit['value'] } if tally[:samples].size < sample_limit + end + end + end + private_class_method :classify_cell! + + # Format a `summarize` result into log lines for eyeballing a run — one headline per locale plus a few + # indented sample machine translations. Pure; the lane just prints these. + def summary_lines(summary_by_locale) + summary_by_locale.flat_map { |locale, tally| locale_summary_lines(locale, tally) } + end + + def locale_summary_lines(locale, tally) + human, machine, english = tally.values_at(:human, :machine, :english) + headline = " #{locale}: #{human + machine + english} entries — #{human} human, #{machine} machine, #{english} still English" + [headline, *tally[:samples].map { |ex| " #{ex[:key]}: #{ex[:english].inspect} → #{ex[:translation].inspect}" }] + end + private_class_method :locale_summary_lines + + # Resolve a `locales:` spec (comma-separated lproj codes, e.g. "fr,de") against the full ship-locale map. A + # blank spec means all locales. Returns { selected: {glotpress=>lproj}, unknown: [codes matching nothing] } — + # the caller decides what to do (error if `selected` is empty, warn about `unknown`). Pure; no fastlane/UI. + def select_locales(spec, locale_map) + return { selected: locale_map, unknown: [] } if spec.to_s.strip.empty? + + # lproj codes are mixed-case (pt-BR, zh-Hans, en-GB); compare on downcased codes so a lowercased spec + # (e.g. `locales:pt-br`) resolves instead of being silently dropped as unknown. + wanted = spec.to_s.split(',').map { |code| code.strip.downcase }.reject(&:empty?) + selected = locale_map.select { |_glotpress, lproj| wanted.include?(lproj.downcase) } + { selected: selected, unknown: wanted - selected.values.map(&:downcase) } + end + + # { key => { source:, comment: } } for every translatable key — its explicit English value, or the key itself + # for key-as-source strings (genstrings's convention, where the English text *is* the key). Entries flagged + # `shouldTranslate: false`, and entries whose English is not a flat `stringUnit` (device/width variations or a + # plural), are skipped — see `source_value`. + def translatable_sources(catalog, source) + (catalog['strings'] || {}).each_with_object({}) do |(key, body), acc| + next if body['shouldTranslate'] == false + + value = source_value(body, source, key) + acc[key] = { source: value, comment: body['comment'] } unless value.to_s.empty? + end + end + private_class_method :translatable_sources + + # The English source text to translate for one entry, or nil to skip it. + # + # A key-as-source string (no `source` localization at all) uses the key itself as its English text — + # genstrings's convention. But an entry whose `source` localization DOES exist while storing its value under + # `variations` (a device/width-varied regular string) or a plural, rather than a flat `stringUnit`, is NOT + # key-as-source: falling back to the key there would ship the reverse-DNS key to the translator as if it were + # English and write the mangled result back as a real translation, corrupting a valid entry. We can't fold a + # varied/plural source into one flat translation, so we skip it — matching `CatalogHelper.reworded_keys`, + # which only reads the flat `en` `stringUnit` value (proper translation of varied regular strings is a + # separate feature). + def source_value(body, source, key) + localization = body.dig('localizations', source) + return key if localization.nil? # key-as-source: no explicit English, so the key IS the English text + + localization.dig('stringUnit', 'value') # flat English value, or nil to skip a non-flat (varied/plural) entry + end + private_class_method :source_value + + # Fold one locale: resolve the human/reused cells, translate only what's left, write them all. Returns the + # number of cells written. + def fold_locale!(catalog, locale, sources, human, ai_translator) + plan = plan_locale(catalog, locale, sources, human) + cells = plan[:cells].merge(machine_cells(plan[:fresh], translate(ai_translator, plan[:fresh], locale))) + cells.each { |key, unit| set_cell!(catalog, key, locale, unit) } + cells.size + end + private_class_method :fold_locale! + + # { key => machine stringUnit } for the fresh entries: the validated AI translation, or the English source as + # a flagged fallback where the model returned nothing. Disjoint from the human/reused cells. + def machine_cells(fresh, ai_reply) + fresh.to_h { |entry| [entry[:key], ai_cell(ai_reply[entry[:key]], entry[:source])] } + end + private_class_method :machine_cells + + # Partition this locale's keys into ready `cells` ({ key => stringUnit }: human ⇒ translated, reusable machine + # ⇒ kept) and `fresh` ([{ key:, source:, comment: }] needing the model). + def plan_locale(catalog, locale, sources, human) + cells = {} + fresh = [] + sources.each do |key, info| + source = info[:source] + if (value = trusted_human(human[key], source, key, locale)) + cells[key] = cell('translated', value) + elsif (reused = reusable_cell(catalog, key, locale, source)) + cells[key] = reused + else + fresh << { key: key, source: source, comment: info[:comment] } + end + end + { cells: cells, fresh: fresh } + end + private_class_method :plan_locale + + # The `human` rung of `human ?? AI ?? English`: the GlotPress value to ship as `translated`, or nil to fall + # through to the machine/English rungs. A blank value is dropped silently; a present-but-placeholder-broken one + # must NOT ship — it would send a format-argument mismatch to runtime (wrong vararg → crash) in a locale CI + # can't read — so it's rejected and surfaced. Uses the same TranslationValidator gate reusable_cell and the AI + # tier apply, not a second implementation. + def trusted_human(value, source, key, locale) + return nil if value.to_s.strip.empty? + return value if TranslationValidator.placeholders_match?(source, value) + + # Surface the rejection so a broken GlotPress string gets fixed rather than silently downgraded to machine. + warn "CatalogStrings: rejected #{locale} human translation for #{key.inspect} — " \ + "#{TranslationValidator.mismatch_reason(source, value)}; using machine/English instead." + nil + end + private_class_method :trusted_human + + # The existing cell to keep as-is, or nil to re-fill it: a stringUnit whose value is present, isn't just the + # English source (an unfilled English fallback we should retry), and still satisfies the placeholder gate. + # + # Intentionally state-agnostic — unlike the plural sibling PluralStrings.kept_ai_value, which gates on + # `needs_review`. Besides persisting machine (`needs_review`) cells across runs, this also keeps a human + # (`translated`) cell whose GlotPress source later vanishes (a translation rejected/reverted upstream with no + # replacement): the last-approved text is frozen rather than overwritten with a machine guess, and a current + # GlotPress value supersedes it via trusted_human on the next fold. The only cost is a stale `translated` + # label until then, in a catalog nothing ships yet. + def reusable_cell(catalog, key, locale, source) + unit = catalog.dig('strings', key, 'localizations', locale, 'stringUnit') + return nil if unit.nil? + + value = unit['value'].to_s + # On `value == source`: if the saved translation is just the English word again, we treat it as "not really + # translated yet" and translate it once more next time. One quirk falls out of this — a word that happens to + # be the same in the other language (like "OK", which stays "OK") looks exactly like that case, so it gets + # translated again on every run instead of being kept. It's a tiny bit of repeated work, harmless, and it + # goes away as soon as a human translates that word. + return nil if value.empty? || value == source || !TranslationValidator.placeholders_match?(source, value) + + unit + end + private_class_method :reusable_cell + + def translate(ai_translator, fresh, locale) + return {} if ai_translator.nil? || fresh.empty? + + ai_translator.call(fresh, locale) || {} + end + private_class_method :translate + + # A machine cell: the validated AI translation if present, else the English source as a flagged fallback. + def ai_cell(translation, source) + cell('needs_review', translation.to_s.empty? ? source : translation) + end + private_class_method :ai_cell + + def set_cell!(catalog, key, locale, unit) + localizations = (catalog['strings'][key]['localizations'] ||= {}) + reject_varied_target!(localizations[locale], key, locale) + localizations[locale] = { 'stringUnit' => unit } + end + private_class_method :set_cell! + + # The fold only ever produces flat `stringUnit` cells. If a target locale already holds a `variations` + # (device/width/plural) or `substitutions` structure, overwriting it with one flat value would silently + # destroy a translation the fold can't rebuild — and nothing in this pipeline should ever produce a varied + # target cell in the first place. So this isn't something to paper over: crash loudly, as a signal that an + # upstream assumption is wrong (a hand-edited catalog, or a new feature that outgrew the flat-only fold). + def reject_varied_target!(existing, key, locale) + return unless existing.is_a?(Hash) && (existing.key?('variations') || existing.key?('substitutions')) + + raise "CatalogStrings: the #{locale} cell for #{key.inspect} holds a variations/substitutions structure, but " \ + 'the fold only handles flat strings — refusing to overwrite it with a flat translation. This shape ' \ + 'should never occur here, so something upstream is wrong.' + end + private_class_method :reject_varied_target! + + def cell(state, value) + { 'state' => state, 'value' => value } + end + private_class_method :cell +end diff --git a/fastlane/lanes/catalog_strings_helper_test.rb b/fastlane/lanes/catalog_strings_helper_test.rb new file mode 100644 index 000000000000..8f093402c1f4 --- /dev/null +++ b/fastlane/lanes/catalog_strings_helper_test.rb @@ -0,0 +1,389 @@ +# frozen_string_literal: true + +# Pure-Ruby unit suite for CatalogStrings.fold_translations! — the regular-string reverse fold into +# Localizable.xcstrings. Run directly: `ruby fastlane/lanes/catalog_strings_helper_test.rb`. No bundle / network +# (the AI tier is a stub lambda). Fixtures under `fixtures/` are real `.xcstrings` documents validated with +# `xcstringstool print`, loaded here as plain JSON — the same parse the lane does. +require 'minitest/autorun' +require 'json' +require 'stringio' +require_relative 'catalog_strings_helper' + +# Exercises provenance (human => translated; machine / English fallback => needs_review), the reuse rule (a +# valid existing machine cell is kept and not re-translated; an English-fallback or placeholder-broken cell is +# retried), key-as-source handling, the variations/plural skip (English stored under `variations` is NOT +# translated from its key), shouldTranslate, and the batched per-locale AI call. +class CatalogStringsFoldTest < Minitest::Test # rubocop:disable Metrics/ClassLength -- exhaustive scenario coverage + def unit(state, value) + { 'stringUnit' => { 'state' => state, 'value' => value } } + end + + # A catalog entry with an explicit English value, optional comment, and optional pre-existing localizations. + def entry(english, comment: nil, locs: {}) + body = { 'localizations' => { 'en' => unit('translated', english) }.merge(locs) } + body['comment'] = comment if comment + body + end + + def catalog(strings) + { 'sourceLanguage' => 'en', 'version' => '1.0', 'strings' => strings } + end + + def cell(cat, key, locale) + cat.dig('strings', key, 'localizations', locale, 'stringUnit') + end + + # An AI stub returning `reply` ({ key => translation }), recording each (entries, locale) call. + def recording_translator(reply:, calls:) + lambda do |entries, locale| + calls << { entries: entries, locale: locale } + reply + end + end + + def fold(cat, translations: {}, locales: %w[en fr], ai_translator: nil) + CatalogStrings.fold_translations!(cat, translations_by_locale: translations, locales: locales, ai_translator: ai_translator) + end + + # Parse a real `.xcstrings` fixture from `fixtures/` (xcstringstool-validated) exactly as the lane does. + def load_fixture(name) + JSON.parse(File.read(File.join(__dir__, 'fixtures', name))) + end + + # The keys the AI stub was actually asked to translate, across all recorded calls. + def translated_keys(calls) + calls.flat_map { |c| c[:entries].map { |e| e[:key] } } + end + + # Runs the block with $stderr captured, returning what it wrote (the fold surfaces rejected humans via warn). + def capture_stderr + original = $stderr + $stderr = StringIO.new + yield + $stderr.string + ensure + $stderr = original + end + + def test_human_translation_is_used_and_marked_translated + cat = catalog('a' => entry('Save')) + written = fold(cat, translations: { 'fr' => { 'a' => 'Enregistrer' } }) + + assert_equal 1, written + assert_equal({ 'state' => 'translated', 'value' => 'Enregistrer' }, cell(cat, 'a', 'fr')) + end + + # A whitespace-only GlotPress value is not a real translation: it must not ship as `translated` — the key + # should still reach the model and land as `needs_review`, same as if GlotPress had no value at all. + def test_whitespace_only_human_value_is_treated_as_untranslated + cat = catalog('a' => entry('Save')) + calls = [] + fold(cat, translations: { 'fr' => { 'a' => ' ' } }, ai_translator: recording_translator(reply: { 'a' => 'Enregistrer' }, calls: calls)) + + assert_equal(['a'], calls.first[:entries].map { |e| e[:key] }, 'a blank human value must not be treated as translated') + assert_equal({ 'state' => 'needs_review', 'value' => 'Enregistrer' }, cell(cat, 'a', 'fr')) + end + + # A human value that drops/retypes a format specifier would crash at runtime if shipped as `translated`. It + # must be REJECTED (same gate as machine cells) and surfaced, then fall through to the model — landing as + # `needs_review`, never `translated`. + def test_placeholder_broken_human_value_is_rejected_and_surfaced_not_shipped + cat = catalog('a' => entry('%1$d posts')) + calls = [] + warnings = capture_stderr do + fold(cat, translations: { 'fr' => { 'a' => 'articles' } }, # %1$d dropped + ai_translator: recording_translator(reply: { 'a' => '%1$d articles' }, calls: calls)) + end + + assert_equal(['a'], calls.first[:entries].map { |e| e[:key] }, 'a placeholder-broken human must fall through to the model, not ship') + assert_equal({ 'state' => 'needs_review', 'value' => '%1$d articles' }, cell(cat, 'a', 'fr')) + assert_match(/rejected fr human translation for "a"/, warnings) + end + + # With the broken human rejected and no AI tier, the cell degrades to English (needs_review) — it must never + # ship the crash-inducing human string. + def test_placeholder_broken_human_value_with_no_ai_falls_back_to_english + cat = catalog('a' => entry('%1$d posts')) + capture_stderr { fold(cat, translations: { 'fr' => { 'a' => 'articles' } }) } + + assert_equal({ 'state' => 'needs_review', 'value' => '%1$d posts' }, cell(cat, 'a', 'fr'), 'a broken human degrades to English, never ships') + end + + def test_ai_fills_missing_and_marks_needs_review + cat = catalog('a' => entry('Save')) + fold(cat, ai_translator: recording_translator(reply: { 'a' => 'Enregistrer' }, calls: [])) + + assert_equal({ 'state' => 'needs_review', 'value' => 'Enregistrer' }, cell(cat, 'a', 'fr')) + end + + def test_english_fallback_when_no_human_and_no_ai + cat = catalog('a' => entry('Save')) + fold(cat) + + assert_equal({ 'state' => 'needs_review', 'value' => 'Save' }, cell(cat, 'a', 'fr')) + end + + def test_existing_machine_cell_is_reused_without_calling_the_model + cat = catalog('a' => entry('Save', locs: { 'fr' => unit('needs_review', 'Enregistrer') })) + calls = [] + fold(cat, ai_translator: recording_translator(reply: {}, calls: calls)) + + assert_empty calls, 'a reusable machine cell must not trigger a model call' + assert_equal({ 'state' => 'needs_review', 'value' => 'Enregistrer' }, cell(cat, 'a', 'fr')) + end + + def test_english_fallback_cell_is_retried_not_reused + # A prior cell whose value is just the English source was an unfilled fallback — retry it. + cat = catalog('a' => entry('Save', locs: { 'fr' => unit('needs_review', 'Save') })) + calls = [] + fold(cat, ai_translator: recording_translator(reply: { 'a' => 'Enregistrer' }, calls: calls)) + + assert_equal(['a'], calls.first[:entries].map { |e| e[:key] }) + assert_equal({ 'state' => 'needs_review', 'value' => 'Enregistrer' }, cell(cat, 'a', 'fr')) + end + + def test_placeholder_broken_cell_is_retried + cat = catalog('a' => entry('%1$d posts', locs: { 'fr' => unit('needs_review', 'articles') })) + fold(cat, ai_translator: recording_translator(reply: { 'a' => '%1$d articles' }, calls: [])) + + assert_equal({ 'state' => 'needs_review', 'value' => '%1$d articles' }, cell(cat, 'a', 'fr')) + end + + def test_present_but_empty_existing_cell_is_retried + # A stored cell with an empty value isn't a real translation — and this is a distinct branch from the + # no-localization first-fold path — so it must be retried, not passed through as-is. + cat = catalog('a' => entry('Save', locs: { 'fr' => unit('needs_review', '') })) + calls = [] + fold(cat, ai_translator: recording_translator(reply: { 'a' => 'Enregistrer' }, calls: calls)) + + assert_equal(['a'], calls.first[:entries].map { |e| e[:key] }, 'an empty-value cell must be retried') + assert_equal({ 'state' => 'needs_review', 'value' => 'Enregistrer' }, cell(cat, 'a', 'fr')) + end + + def test_human_supersedes_existing_machine_cell + cat = catalog('a' => entry('Save', locs: { 'fr' => unit('needs_review', 'old machine value') })) + fold(cat, translations: { 'fr' => { 'a' => 'Enregistrer' } }) + + assert_equal({ 'state' => 'translated', 'value' => 'Enregistrer' }, cell(cat, 'a', 'fr')) + end + + def test_key_as_source_string_uses_the_key_as_english + cat = catalog('%1$@ on %2$@' => {}) # no English localization: the key is the source + calls = [] + fold(cat, ai_translator: recording_translator(reply: {}, calls: calls)) + + assert_equal '%1$@ on %2$@', calls.first[:entries].first[:source] + assert_equal({ 'state' => 'needs_review', 'value' => '%1$@ on %2$@' }, cell(cat, '%1$@ on %2$@', 'fr')) + end + + def test_key_as_source_string_reuses_existing_machine_cell + # Key-as-source (source resolves to the key itself) combined with a valid pre-existing machine cell: + # exercises reusable_cell's `value == source` check with source threaded as the key, and confirms the cell + # is kept rather than re-translated. + cat = catalog('%1$@ on %2$@' => { 'localizations' => { 'fr' => unit('needs_review', '%1$@ le %2$@') } }) + calls = [] + fold(cat, ai_translator: recording_translator(reply: {}, calls: calls)) + + assert_empty calls, 'a reusable cell on a key-as-source string must not trigger a model call' + assert_equal({ 'state' => 'needs_review', 'value' => '%1$@ le %2$@' }, cell(cat, '%1$@ on %2$@', 'fr')) + end + + # Regression: a regular string whose English is stored under `variations` (here per-device) has no flat + # stringUnit. It must be SKIPPED — never translated from its reverse-DNS key — while the flat and + # key-as-source entries in the same (xcstringstool-validated) catalog still fold with the right sources. + def test_variations_shaped_english_is_skipped_not_translated_from_its_key + cat = load_fixture('catalog_with_variations.xcstrings') + original_en = cat.dig('strings', 'app.banner.tapToOpen', 'localizations', 'en') + calls = [] + fold(cat, ai_translator: recording_translator(reply: {}, calls: calls)) + + # The varied entry is absent; the flat and key-as-source entries fold with their correct English source. + translated = calls.flat_map { |c| c[:entries] }.to_h { |e| [e[:key], e[:source]] } + assert_equal({ 'app.button.save' => 'Save', '%1$@ on %2$@' => '%1$@ on %2$@' }, translated) + # No fr cell is fabricated for the varied entry, and its English variations are left untouched. + assert_nil cell(cat, 'app.banner.tapToOpen', 'fr'), 'a variations-shaped source must not get a folded cell' + assert_same original_en, cat.dig('strings', 'app.banner.tapToOpen', 'localizations', 'en') + end + + # The skip holds across re-runs, so a variations-shaped entry can never be re-submitted to the billable API + # (the "re-translated forever" failure a key-derived English fallback would cause). + def test_variations_shaped_english_is_never_rebilled_across_reruns + cat = load_fixture('catalog_with_variations.xcstrings') + calls = [] + translator = recording_translator(reply: { 'app.button.save' => 'Enregistrer' }, calls: calls) + 2.times { fold(cat, ai_translator: translator) } + + refute_includes translated_keys(calls), 'app.banner.tapToOpen' + end + + # A flat English source whose TARGET cell was somehow authored with device variations is a shape the fold + # can't handle — nothing in the pipeline produces it. It must CRASH loudly (a signal something is wrong), not + # silently flatten the per-device translations and re-bill it. + def test_fold_crashes_rather_than_clobber_a_varied_target_cell + varied_fr = { 'variations' => { 'device' => { 'iphone' => unit('translated', 'Appuyez') } } } + cat = catalog('a' => entry('Tap', locs: { 'fr' => varied_fr })) + error = assert_raises(RuntimeError) do + fold(cat, ai_translator: recording_translator(reply: { 'a' => 'Toucher' }, calls: [])) + end + + assert_match(%r{variations/substitutions}, error.message) + assert_match(/"a"/, error.message, 'the crash names the offending key') + end + + def test_should_translate_false_is_skipped + cat = catalog( + 'a' => entry('Save'), + 'b' => entry('WordPress').merge('shouldTranslate' => false) + ) + written = fold(cat) + + assert_equal 1, written + assert_nil cell(cat, 'b', 'fr'), 'shouldTranslate:false entries get no translations' + end + + def test_empty_catalog_folds_nothing + cat = catalog({}) + calls = [] + assert_equal 0, fold(cat, ai_translator: recording_translator(reply: {}, calls: calls)) + assert_empty calls, 'an empty catalog must not call the model' + end + + def test_all_untranslatable_catalog_folds_nothing + cat = catalog( + 'a' => entry('WordPress').merge('shouldTranslate' => false), + 'b' => entry('Jetpack').merge('shouldTranslate' => false) + ) + calls = [] + assert_equal 0, fold(cat, ai_translator: recording_translator(reply: {}, calls: calls)) + assert_empty calls, 'an all-untranslatable catalog must not call the model' + end + + def test_source_locale_is_not_folded + cat = catalog('a' => entry('Save')) + original_en = cat.dig('strings', 'a', 'localizations', 'en') + fold(cat, locales: %w[en fr]) + + assert_same original_en, cat.dig('strings', 'a', 'localizations', 'en') + end + + def test_ai_called_once_per_locale_with_batched_entries + cat = catalog('a' => entry('Save'), 'b' => entry('Posts: %1$d', comment: 'count')) + calls = [] + fold(cat, ai_translator: recording_translator(reply: { 'a' => 'Enregistrer', 'b' => 'Articles : %1$d' }, calls: calls)) + + assert_equal 1, calls.size + assert_equal 'fr', calls.first[:locale] + assert_equal( + [{ key: 'a', source: 'Save', comment: nil }, { key: 'b', source: 'Posts: %1$d', comment: 'count' }], + calls.first[:entries] + ) + end + + def test_partial_batched_ai_reply_falls_back_per_key + # The model answers one key of a batch but omits the other — the omitted key falls back to English + # (needs_review), the answered key is used. The most realistic live-AI failure mode for a large batch. + cat = catalog('a' => entry('Save'), 'b' => entry('Delete')) + fold(cat, ai_translator: recording_translator(reply: { 'a' => 'Enregistrer' }, calls: [])) + + assert_equal({ 'state' => 'needs_review', 'value' => 'Enregistrer' }, cell(cat, 'a', 'fr')) + assert_equal({ 'state' => 'needs_review', 'value' => 'Delete' }, cell(cat, 'b', 'fr'), 'an omitted key falls back to English') + end + + def test_counts_cells_across_locales + cat = catalog('a' => entry('Save')) + assert_equal 2, fold(cat, locales: %w[en fr de]) + end + + def test_locales_resolve_independently_in_one_call + # One fold across three target locales with divergent provenance each — fr via human, de via AI, es reuses + # an existing machine cell — and each locale's AI call receives only its own fresh keys. + cat = catalog('a' => entry('Save', locs: { 'es' => unit('needs_review', 'Guardar') })) + calls = [] + written = fold( + cat, + translations: { 'fr' => { 'a' => 'Enregistrer' } }, + locales: %w[en fr de es], + ai_translator: recording_translator(reply: { 'a' => 'Speichern' }, calls: calls) + ) + + assert_equal 3, written + assert_equal({ 'state' => 'translated', 'value' => 'Enregistrer' }, cell(cat, 'a', 'fr')) + assert_equal({ 'state' => 'needs_review', 'value' => 'Speichern' }, cell(cat, 'a', 'de')) + assert_equal({ 'state' => 'needs_review', 'value' => 'Guardar' }, cell(cat, 'a', 'es')) + assert_equal(['de'], calls.map { |c| c[:locale] }, 'only de needs the model; fr had a human, es reused') + end + + def test_summarize_counts_provenance_per_locale + cat = catalog( + 'a' => entry('Save', locs: { 'fr' => unit('translated', 'Enregistrer') }), # human + 'b' => entry('Delete', locs: { 'fr' => unit('needs_review', 'Supprimer') }), # machine (differs from English) + 'c' => entry('Post', locs: { 'fr' => unit('needs_review', 'Post') }), # still English (equals source) + 'd' => entry('Trash') # no fr cell — not counted + ) + summary = CatalogStrings.summarize(cat, locales: %w[en fr]) + + refute_includes summary.keys, 'en', 'the source locale is not summarized' + assert_equal({ human: 1, machine: 1, english: 1, samples: [{ key: 'b', english: 'Delete', translation: 'Supprimer' }] }, summary['fr']) + end + + def test_summarize_caps_machine_samples_at_the_limit + strings = (1..8).to_h { |i| ["k#{i}", entry("E#{i}", locs: { 'fr' => unit('needs_review', "T#{i}") })] } + summary = CatalogStrings.summarize(catalog(strings), locales: %w[en fr], sample_limit: 3)['fr'] + + assert_equal 8, summary[:machine] + assert_equal 3, summary[:samples].size, 'samples are capped at sample_limit' + assert_equal(%w[k1 k2 k3], summary[:samples].map { |ex| ex[:key] }) + end + + def test_summarize_uses_the_key_as_english_for_key_as_source_entries + cat = catalog('%1$@ on %2$@' => { 'localizations' => { 'fr' => unit('needs_review', '%1$@ on %2$@') } }) + # value equals the key (its English source), so it counts as still-English, not machine. + assert_equal({ human: 0, machine: 0, english: 1, samples: [] }, CatalogStrings.summarize(cat, locales: %w[en fr])['fr']) + end + + def test_summary_lines_formats_a_headline_and_indented_samples + summary = { 'fr' => { human: 3, machine: 1, english: 1, samples: [{ key: 'a', english: 'Save', translation: 'Enregistrer' }] } } + lines = CatalogStrings.summary_lines(summary) + + assert_equal ' fr: 5 entries — 3 human, 1 machine, 1 still English', lines[0] + assert_equal ' a: "Save" → "Enregistrer"', lines[1] + end + + def test_summary_lines_covers_every_locale + summary = { + 'fr' => { human: 1, machine: 0, english: 0, samples: [] }, + 'de' => { human: 0, machine: 1, english: 0, samples: [] } + } + assert_equal( + [' fr: 1 entries — 1 human, 0 machine, 0 still English', ' de: 1 entries — 0 human, 1 machine, 0 still English'], + CatalogStrings.summary_lines(summary) + ) + end + + def test_select_locales_blank_spec_returns_the_whole_map + map = { 'fr' => 'fr', 'de' => 'de' } + assert_equal({ selected: map, unknown: [] }, CatalogStrings.select_locales(' ', map)) + end + + def test_select_locales_filters_to_named_and_reports_unknown + result = CatalogStrings.select_locales('pt-BR,dee', { 'fr' => 'fr', 'de' => 'de', 'pt' => 'pt-BR' }) + + assert_equal({ 'pt' => 'pt-BR' }, result[:selected]) + assert_equal ['dee'], result[:unknown], 'a code matching no ship locale is reported, not silently dropped' + end + + def test_select_locales_reports_empty_when_nothing_matches + result = CatalogStrings.select_locales('zz', { 'fr' => 'fr' }) + + assert_empty result[:selected] + assert_equal ['zz'], result[:unknown] + end + + # lproj codes are mixed-case (pt-BR, zh-Hans); a spec typed in any case must resolve, not be dropped as unknown. + def test_select_locales_matches_lproj_codes_case_insensitively + result = CatalogStrings.select_locales('pt-br,ZH-HANS', { 'pt' => 'pt-BR', 'zh-cn' => 'zh-Hans', 'fr' => 'fr' }) + + assert_equal({ 'pt' => 'pt-BR', 'zh-cn' => 'zh-Hans' }, result[:selected], 'a lowercased/upper spec still resolves to the canonical lproj') + assert_empty result[:unknown] + end +end diff --git a/fastlane/lanes/fixtures/catalog_with_variations.xcstrings b/fastlane/lanes/fixtures/catalog_with_variations.xcstrings new file mode 100644 index 000000000000..df87eee0a00c --- /dev/null +++ b/fastlane/lanes/fixtures/catalog_with_variations.xcstrings @@ -0,0 +1,43 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "app.banner.tapToOpen" : { + "comment" : "Regular string whose English varies by device — stored under `variations`, with no flat `stringUnit`. The fold must SKIP this, not fall back to translating the key.", + "localizations" : { + "en" : { + "variations" : { + "device" : { + "ipad" : { + "stringUnit" : { + "state" : "translated", + "value" : "Click to open" + } + }, + "iphone" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap to open" + } + } + } + } + } + } + }, + "app.button.save" : { + "comment" : "A normal flat regular string — the control that must still fold.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save" + } + } + } + }, + "%1$@ on %2$@" : { + + } + }, + "version" : "1.0" +} diff --git a/fastlane/lanes/localization_catalog.rb b/fastlane/lanes/localization_catalog.rb index a4f45b614fcf..acf93bd19f1c 100644 --- a/fastlane/lanes/localization_catalog.rb +++ b/fastlane/lanes/localization_catalog.rb @@ -3,7 +3,9 @@ require 'json' require 'tmpdir' require 'fileutils' +require 'open3' require_relative 'catalog_helper' +require_relative 'catalog_strings_helper' ################################################# # Catalog generation (forward / extraction) @@ -54,8 +56,8 @@ Dir.mktmpdir do |stringsdata_dir| extract_stringsdata(files: files, output_dir: stringsdata_dir, swiftui: swiftui) synced = sync_localizable_catalog(stringsdata_dir: stringsdata_dir) - reconciled = reconcile_changed_sources(stringsdata_dir: stringsdata_dir) - report_catalog(LOCALIZABLE_CATALOG, extracted_count: synced, reconciled_count: reconciled) + enforce_immutable_source_keys(stringsdata_dir: stringsdata_dir) + report_catalog(LOCALIZABLE_CATALOG, extracted_count: synced) end end @@ -82,6 +84,47 @@ end end + # LOCALIZE (download + fold + AI-fill) — pull the current GlotPress translations for the given locales and fold + # them into the EXISTING Localizable.xcstrings (human => translated), then AI-fill the cells they leave empty + # (=> needs_review). The download goes into a throwaway temp dir each run, so no stale or partial translation + # state is ever carried between runs and the fold always reflects current GlotPress. Run generate_strings_catalog + # (the code scan) first — it stays a separate lane so you can refresh the catalog without touching the AI. Scope + # a cheap run with `locales:fr`; default is all ship locales. + # + # Uploading the AI drafts back to GlotPress as needs-review (the eventual "step 4") is a separate step, not + # done here — it builds on the existing GlotPress import integration (cf. gp_update_metadata_source). + # + # STAGED, NOT SHIPPED: Localizable.xcstrings isn't the runtime store yet (the app still ships + # Localizable.strings), so this only pre-populates it for the cutover — it changes nothing users see. + # + # MANUAL ONLY — not wired into download_localized_strings or any CI step: it downloads from GlotPress, calls the + # translation API (cost), and commits a large catalog. Set ANTHROPIC_API_KEY for the AI rung. + desc 'Download GlotPress translations + AI-fill into the existing Localizable.xcstrings (run generate_strings_catalog first)' + lane :localize_catalog do |options| + UI.user_error!("#{LOCALIZABLE_CATALOG} not found — run generate_strings_catalog first") unless File.exist?(LOCALIZABLE_CATALOG) + locales = catalog_target_locales(options[:locales]) + + # Download the current GlotPress translations (throwaway dir — no state carried between runs), fold them in + # (=> translated), then AI-fill the cells they leave empty. + catalog = JSON.parse(File.read(LOCALIZABLE_CATALOG)) + translations = download_catalog_translations(locales) + UI.important('GlotPress returned no translations for the requested locale(s) — folding AI + English only.') if translations.empty? + written = CatalogStrings.fold_translations!( + catalog, + translations_by_locale: translations, + locales: locales.values.uniq, + ai_translator: catalog_ai_translator + ) + File.write(LOCALIZABLE_CATALOG, "#{JSON.pretty_generate(catalog)}\n") + UI.success("Built #{File.basename(LOCALIZABLE_CATALOG)}: folded #{written} cell(s) across #{locales.values.uniq.size} locale(s).") + report_localized_catalog(catalog, locales.values.uniq) + + # force: the catalog is .gitignore'd (shelved until the runtime cutover), so a plain `git add` refuses it — + # this staging lane deliberately commits it anyway. Once tracked, the ignore no longer applies. + git_add(path: LOCALIZABLE_CATALOG, shell_escape: false, force: true) + git_commit(path: [LOCALIZABLE_CATALOG], message: 'Update Localizable.xcstrings translations (staged)', allow_nothing_to_commit: true) + end + ################################################# # Helpers ################################################# @@ -107,6 +150,15 @@ def catalog_excluded?(path) File.basename(path) == 'AppLocalizedString.swift' end + # Run `xcstringstool ` quietly via argv (no shell, so source paths with spaces are safe), capturing + # output and surfacing it only on failure. Used instead of fastlane's `sh` for these bulk calls: each passes + # hundreds of file paths (or a `--stringsdata` pair per file), so `sh` would echo a massive command line AND + # print a "Step: shell command" banner per call. Open3 keeps the run silent and banner-free. + def run_xcstringstool(*args) + output, status = Open3.capture2e('xcrun', 'xcstringstool', *args) + UI.user_error!("xcstringstool #{args.first} failed:\n#{output}") unless status.success? + end + # xcstringstool extract -> one .stringsdata per source file (basename-disambiguated). Chunked to stay under # the OS argument limit; each chunk gets its own output subdir (see below), which sync then consumes together. # `--SwiftUI-Text` (extract `Text("literal")`) is OFF by default and gated behind `swiftui:`. The app has @@ -125,10 +177,12 @@ def extract_stringsdata(files:, output_dir:, swiftui: false) # source basename and only disambiguates collisions WITHIN a single invocation — so two same-named files # in different chunks (e.g. the two NSDate+Helpers.swift / SupportDataProvider.swift) would otherwise # overwrite each other in a shared dir and silently drop strings. - files.each_slice(400).with_index do |chunk, index| + batches = files.each_slice(400).to_a + batches.each_with_index do |chunk, index| chunk_dir = File.join(output_dir, "chunk-#{index}") FileUtils.mkdir_p(chunk_dir) - sh('xcrun', 'xcstringstool', 'extract', *chunk, *flags, '--output-directory', chunk_dir) + UI.message("Extracting strings… (batch #{index + 1}/#{batches.size})") + run_xcstringstool('extract', *chunk, *flags, '--output-directory', chunk_dir) end end @@ -145,12 +199,14 @@ def sync_localizable_catalog(stringsdata_dir:) stringsdata = stringsdata_files(stringsdata_dir) UI.user_error!('xcstringstool produced no .stringsdata') if stringsdata.empty? - sh('xcrun', 'xcstringstool', 'sync', LOCALIZABLE_CATALOG, *stringsdata.flat_map { |f| ['--stringsdata', f] }) + UI.message("Syncing #{stringsdata.count} extracted file(s) into #{File.basename(LOCALIZABLE_CATALOG)}…") + run_xcstringstool('sync', LOCALIZABLE_CATALOG, *stringsdata.flat_map { |f| ['--stringsdata', f] }) JSON.parse(File.read(LOCALIZABLE_CATALOG))['strings'].count end # Create the catalog as an empty shell if it doesn't exist yet; leave an existing one untouched so its - # translations survive across runs — that persistence is what makes reconcile_changed_sources meaningful. + # translations survive across runs — that persistence is what lets the fold reuse machine cells and lets + # immutable-key enforcement compare the source against the previously-stored English. def ensure_catalog_exists(path) FileUtils.mkdir_p(File.dirname(path)) return if File.exist?(path) @@ -158,18 +214,21 @@ def ensure_catalog_exists(path) File.write(path, "#{JSON.pretty_generate('sourceLanguage' => 'en', 'strings' => {}, 'version' => '1.0')}\n") end - # `xcstringstool sync` leaves an existing key's English value (and its translations) untouched when the - # source text changes (verified). Re-derive the current English from a fresh extraction and, where it - # differs from the catalog, update the English and flip that key's translations to `needs_review`. - def reconcile_changed_sources(stringsdata_dir:) + # Localization keys are IMMUTABLE. `xcstringstool sync` silently keeps an existing key's translations when its + # English is reworded in place (verified) — which would ship stale translations of the OLD text — so any + # reworded (explicit-key) string is a hard error here: rewording requires a NEW key. Key-as-source strings are + # exempt (rewording one changes the key, which sync handles as new/stale). See CatalogHelper.reworded_keys. + def enforce_immutable_source_keys(stringsdata_dir:) current_en = current_english_values(stringsdata_dir) catalog = JSON.parse(File.read(LOCALIZABLE_CATALOG)) - reconciled = CatalogHelper.reconcile_source_changes!(catalog, current_en) - unless reconciled.empty? - File.write(LOCALIZABLE_CATALOG, "#{JSON.pretty_generate(catalog)}\n") - UI.important("Re-flagged #{reconciled.count} key(s) as needs_review — English source changed.") - end - reconciled.count + reworded = CatalogHelper.reworded_keys(catalog, current_en) + return if reworded.empty? + + UI.user_error!( + "Localization keys are immutable, but #{reworded.count} changed their English in place: #{reworded.join(', ')}. " \ + "Rewording requires a new key (rename it) so translations don't go stale — mint a new key for the new text, " \ + 'or revert the English change.' + ) end # Current English value per key, by syncing the extraction into a throwaway empty catalog (every key is @@ -180,7 +239,7 @@ def current_english_values(stringsdata_dir) fresh = File.join(tmp, 'Localizable.xcstrings') File.write(fresh, "#{JSON.pretty_generate('sourceLanguage' => 'en', 'strings' => {}, 'version' => '1.0')}\n") stringsdata = stringsdata_files(stringsdata_dir) - sh('xcrun', 'xcstringstool', 'sync', fresh, *stringsdata.flat_map { |f| ['--stringsdata', f] }) + run_xcstringstool('sync', fresh, *stringsdata.flat_map { |f| ['--stringsdata', f] }) english_values(JSON.parse(File.read(fresh))) end end @@ -193,11 +252,80 @@ def english_values(catalog) end end - def report_catalog(path, extracted_count:, reconciled_count:) + def report_catalog(path, extracted_count:) catalog = JSON.parse(File.read(path)) with_value = catalog['strings'].count { |_, v| v.dig('localizations', 'en', 'stringUnit', 'value') } - message = "Generated #{File.basename(path)} with #{extracted_count} keys (#{with_value} carry an explicit English value; the rest are key-as-source)." - message += " Re-flagged #{reconciled_count} for review (English source changed)." if reconciled_count.positive? - UI.success(message) + UI.success("Generated #{File.basename(path)} with #{extracted_count} keys (#{with_value} carry an explicit English value; the rest are key-as-source).") + end + + # Print a per-locale summary of the fold so a run can be eyeballed straight from the log (see + # CatalogStrings.summarize / .summary_lines for the counts and sample formatting). + def report_localized_catalog(catalog, locales) + CatalogStrings.summary_lines(CatalogStrings.summarize(catalog, locales: locales)).each { |line| UI.message(line) } + end + + # The { glotpress => lproj } locale map to operate on: all ship locales, or the subset named in `locales:` + # (a comma-separated list of lproj codes, e.g. `locales:fr,de`) for a cheap scoped run. Resolution is pure + # (CatalogStrings.select_locales); the lane only turns its result into user-facing errors/warnings. + def catalog_target_locales(spec) + selected, unknown = CatalogStrings.select_locales(spec, GLOTPRESS_TO_LPROJ_APP_LOCALE_CODES).values_at(:selected, :unknown) + UI.user_error!("No known ship locales among #{spec.inspect} (use lproj codes, e.g. fr,de,pt-BR)") if selected.empty? + UI.important("Ignoring unrecognized locale(s): #{unknown.join(', ')} (use lproj codes, e.g. fr,de,pt-BR)") unless unknown.empty? + selected + end + + # Download the current GlotPress translations for `locales` ({ glotpress => lproj }) into a throwaway dir and + # return them as { lproj => { key => human value } } for the fold. A fresh temp dir per run means no stale or + # partial translation state is ever carried between runs — the fold always reflects current GlotPress, and its + # scope can't silently diverge from what's folded. Never touches the tracked `WordPress/Resources/*.lproj` tree + # (download_localized_strings owns the shipping `.strings`); the catalog flow only consumes translations. + def download_catalog_translations(locales) + Dir.mktmpdir do |dir| + ios_download_strings_files_from_glotpress( + project_url: GLOTPRESS_APP_STRINGS_PROJECT_URL, + locales: locales, + download_dir: dir + ) + catalog_translations_by_locale(dir) + end + end + + # { lproj => { key => human value } } from the downloaded translation `.strings`. The flat plural keys present + # in these files aren't catalog keys, so the fold ignores them (they belong to Plurals.xcstrings). + def catalog_translations_by_locale(dir) + Dir.glob(File.join(dir, '*.lproj', 'Localizable.strings')).each_with_object({}) do |path, acc| + locale = File.basename(File.dirname(path), '.lproj') + acc[locale] = Fastlane::Helper::Ios::L10nHelper.read_strings_file_as_hash(path: path) + end + end + + # The AI tier for the catalog fold, or nil when ANTHROPIC_API_KEY isn't set (the fold then fills only human + + # English). Returns `call(entries, locale) => { key => translation }` via AITranslator#translate_all. + # + # Wrapped to DEGRADE, not crash — three non-fatal paths. A batch failing mid-locale is handled inside + # translate_all: it keeps the batches that already succeeded, warns to stderr, and returns the partial result + # (the unfilled cells fall back to English and retry next run), so the fold still proceeds and commits. The + # lambda's own rescue is only a backstop for anything that escapes translate_all (e.g. a failure before the + # batch loop starts) — it logs and returns {}, dropping that whole locale to English. A setup error — the gem + # missing (LoadError) or the client failing to construct (any StandardError, e.g. a malformed + # ANTHROPIC_BASE_URL) — logs and returns nil, disabling the AI tier for this run while the human/English fold + # still proceeds and commits (rather than dropping the whole localize_catalog lane). + def catalog_ai_translator + if ENV['ANTHROPIC_API_KEY'].to_s.empty? + UI.important('ANTHROPIC_API_KEY not set — folding human + English only; undefined cells stay English (needs_review).') + return nil + end + + require_relative 'ai_translator' + translator = AITranslator.with_anthropic + lambda do |entries, locale| + translator.translate_all(entries, locale: locale) + rescue StandardError => e + UI.error("AI catalog translation failed for #{locale} (#{e.message}); leaving its undefined cells to English.") + {} + end + rescue LoadError, StandardError => e + UI.important("AI translation tier unavailable (#{e.message}); folding human + English only.") + nil end end diff --git a/fastlane/lanes/plural_strings_helper.rb b/fastlane/lanes/plural_strings_helper.rb index 141111952fd5..925631682203 100644 --- a/fastlane/lanes/plural_strings_helper.rb +++ b/fastlane/lanes/plural_strings_helper.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'json' +require_relative 'translation_validator' # Logic for the String Catalog ⇄ GlotPress plural pipeline. Plain Ruby with no fastlane dependencies, so it's # unit-testable directly — the lanes in `localization_plurals.rb` call into it. @@ -162,7 +163,7 @@ def cldr_sort(categories) def plural_variation(entry, slot, ai_translator) cats = slot.cats english_forms = english_forms_for(entry.plural, cats) - human_forms = human_forms_for(entry.key, cats, slot.human) + human_forms = human_forms_for(entry.key, cats, slot.human, english_forms, slot.locale) kept_ai = kept_ai_forms(slot.existing, cats, human_forms, english_forms) anchors = human_forms.merge(kept_ai) ai_forms = kept_ai.merge(fresh_ai_forms(ai_translator, entry, slot, english_forms, anchors)) @@ -224,12 +225,23 @@ def english_forms_for(plural, cats) # Human (GlotPress) translations present for this key, keyed by CLDR category. These ship as `translated` and # double as the AI request's anchors so the machine-filled forms stay consistent with the human's word choice. - # A blank-or-whitespace-only value isn't a real translation — it's dropped, so it neither ships as `translated` - # nor anchors the request (the category falls through to AI / English instead). - def human_forms_for(key, cats, human) + # A blank-or-whitespace-only value isn't a real translation, and a placeholder-broken one would crash at + # runtime — both are dropped, so such a category neither ships as `translated` nor anchors the request (it + # falls through to AI / English instead). + def human_forms_for(key, cats, human, english_forms, locale) cats.each_with_object({}) do |cat, acc| value = human["#{key}#{INFIX}#{cat}"] - acc[cat] = value unless value.to_s.strip.empty? + next if value.to_s.strip.empty? + + # The same placeholder gate the AI forms pass: a broken human form (a dropped/retyped specifier) must not + # ship as `translated` — it would crash at runtime. Reject it (surfacing which category), so the cat falls + # through to AI / English and never anchors the request. + if TranslationValidator.placeholders_match?(english_forms[cat], value) + acc[cat] = value + else + warn "PluralStrings: rejected #{locale} human translation for #{key.inspect} (#{cat}) — " \ + "#{TranslationValidator.mismatch_reason(english_forms[cat], value)}; using machine/English instead." + end end end private_class_method :human_forms_for diff --git a/fastlane/lanes/plural_strings_helper_test.rb b/fastlane/lanes/plural_strings_helper_test.rb index 1a84463dbe3e..8c92a19d159f 100644 --- a/fastlane/lanes/plural_strings_helper_test.rb +++ b/fastlane/lanes/plural_strings_helper_test.rb @@ -4,6 +4,7 @@ # translations back into the String Catalog with the `human ?? AI ?? English` floor. Run directly: # `ruby fastlane/lanes/plural_strings_helper_test.rb`. No bundle / network (the AI tier is a stub lambda). require 'minitest/autorun' +require 'stringio' require_relative 'plural_strings_helper' # Exercises provenance (human => translated; AI / English fallback => needs_review) and the form-set contract: @@ -51,6 +52,16 @@ def fold(cat, categories_by_locale:, translations_by_locale: {}, ai_translator: PluralStrings.fold_translations!(cat, categories_by_locale: categories_by_locale, translations_by_locale: translations_by_locale, ai_translator: ai_translator) end + # Runs the block with $stderr captured, returning what it wrote (the fold surfaces rejected humans via warn). + def capture_stderr + original = $stderr + $stderr = StringIO.new + yield + $stderr.string + ensure + $stderr = original + end + # Polish needs four categories but only `one` is human-translated — the setup the form-set contract is about. # Folds with the supplied AI reply and returns [catalog, recorded_calls]. def fold_polish(reply:) @@ -76,6 +87,26 @@ def test_human_translation_wins_and_is_marked_translated assert_equal unit('translated', '%lld articles'), cell('other', catalog: cat, locale: 'fr') end + # A human form that drops/retypes a specifier would crash at runtime. It must be REJECTED (same gate the AI + # forms pass) and surfaced, then fall through to the model — never shipping as `translated` and never anchoring + # the request. A valid sibling human form in the same set still ships. + def test_placeholder_broken_human_form_is_rejected_and_surfaced_not_shipped + cat = catalog + calls = [] + ai = recording_translator(reply: { 'one' => '%lld article' }, calls: calls) + warnings = capture_stderr do + fold(cat, categories_by_locale: { 'fr' => %w[one other] }, + translations_by_locale: { 'fr' => { "#{KEY}#{INFIX}one" => 'article', # %lld dropped -> rejected + "#{KEY}#{INFIX}other" => '%lld articles' } }, # valid human + ai_translator: ai) + end + + assert_equal unit('needs_review', '%lld article'), cell('one', catalog: cat, locale: 'fr'), 'a broken human form falls through to AI' + assert_equal unit('translated', '%lld articles'), cell('other', catalog: cat, locale: 'fr'), 'the valid human form still ships' + assert_equal({ 'other' => '%lld articles' }, calls.first[:anchors], 'a rejected human form must not anchor the request') + assert_match(/rejected fr human translation for "#{KEY}" \(one\)/, warnings) + end + def test_english_fallback_when_no_human_and_no_ai cat = catalog fold(cat, categories_by_locale: { 'fr' => %w[one other] })