From ebd153b8c2c213efba06b05f570f9e317dc3d0e2 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:37:03 -0600 Subject: [PATCH 1/6] Stage regular-string translations into Localizable.xcstrings (manual lane) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a regular-string reverse fold — the catalog analogue of the plural fold — that populates Localizable.xcstrings (the future regular-string backing store) with GlotPress human translations plus AI machine translations, as human ?? existing-machine ?? AI ?? English (human => translated; machine / English => needs_review). - CatalogStrings.fold_translations! (pure, 12 tests): reuse-aware — a valid existing needs_review machine cell is kept, so the catalog's needs_review state is the persistence (no side-store) and re-runs only translate genuinely-new gaps. Humans supersede machine cells on the next fold. - download_localized_catalog lane: generate the English catalog, fold the downloaded .strings humans + AI-fill (translate_all), commit. Gated on ANTHROPIC_API_KEY. STAGED, NOT SHIPPED: the catalog isn't the runtime store yet, so this changes nothing users see. MANUAL ONLY: deliberately not wired into download_localized_strings or CI — it runs xcstringstool extraction, calls the API, and commits a large catalog, so it's run on demand. --- fastlane/lanes/catalog_strings_helper.rb | 119 +++++++++++++++ fastlane/lanes/catalog_strings_helper_test.rb | 144 ++++++++++++++++++ fastlane/lanes/localization_catalog.rb | 60 ++++++++ 3 files changed, 323 insertions(+) create mode 100644 fastlane/lanes/catalog_strings_helper.rb create mode 100644 fastlane/lanes/catalog_strings_helper_test.rb diff --git a/fastlane/lanes/catalog_strings_helper.rb b/fastlane/lanes/catalog_strings_helper.rb new file mode 100644 index 000000000000..ee78f5fc8535 --- /dev/null +++ b/fastlane/lanes/catalog_strings_helper.rb @@ -0,0 +1,119 @@ +# 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 machine translation (a `needs_review` value that isn't just the +# English source and still passes the placeholder gate) is kept untouched. That is the whole point of folding +# into the catalog rather than the legacy `.strings`: the catalog's `needs_review` state IS the persistence, so +# re-runs only translate genuinely-new gaps — no side-store, and a human translation from GlotPress supersedes a +# machine cell automatically on the next fold. +module CatalogStrings + 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 + + # { 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` are skipped. + def translatable_sources(catalog, source) + (catalog['strings'] || {}).each_with_object({}) do |(key, body), acc| + next if body['shouldTranslate'] == false + + value = body.dig('localizations', source, 'stringUnit', 'value') || key + acc[key] = { source: value, comment: body['comment'] } unless value.to_s.empty? + end + end + private_class_method :translatable_sources + + # 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| + human_value = human[key] + if !human_value.to_s.empty? + cells[key] = cell('translated', human_value) + elsif (reused = reusable_cell(catalog, key, locale, info[:source])) + cells[key] = reused + else + fresh << { key: key, source: info[:source], comment: info[:comment] } + end + end + { cells: cells, fresh: fresh } + end + private_class_method :plan_locale + + # The existing machine cell to keep, or nil: 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. + 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 + 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'] ||= {}) + localizations[locale] = { 'stringUnit' => unit } + end + private_class_method :set_cell! + + 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..9e872b4a9852 --- /dev/null +++ b/fastlane/lanes/catalog_strings_helper_test.rb @@ -0,0 +1,144 @@ +# 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). +require 'minitest/autorun' +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, shouldTranslate, and the batched per-locale AI call. +class CatalogStringsFoldTest < Minitest::Test + 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 + + 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 + + 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_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_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_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_counts_cells_across_locales + cat = catalog('a' => entry('Save')) + assert_equal 2, fold(cat, locales: %w[en fr de]) + end +end diff --git a/fastlane/lanes/localization_catalog.rb b/fastlane/lanes/localization_catalog.rb index 5417953f4203..8f405a12a656 100644 --- a/fastlane/lanes/localization_catalog.rb +++ b/fastlane/lanes/localization_catalog.rb @@ -4,6 +4,7 @@ require 'tmpdir' require 'fileutils' require_relative 'catalog_helper' +require_relative 'catalog_strings_helper' ################################################# # Catalog generation (forward / extraction) @@ -81,6 +82,34 @@ end end + # Folds the downloaded GlotPress translations (human) plus AI machine translations into Localizable.xcstrings, + # the future regular-string backing store, as `human ?? existing-machine ?? AI ?? English` (see CatalogStrings). + # + # STAGED, NOT SHIPPED: the catalog isn't the runtime store yet (the app still ships Localizable.strings), so + # this only pre-populates it for the eventual cutover — it changes nothing users see. + # + # MANUAL ONLY — deliberately NOT wired into download_localized_strings or any CI step: it runs xcstringstool + # extraction, calls the translation API (cost), and commits a large catalog, so it's run on demand, not on + # every release. Run `download_localized_strings` first (so the per-locale `.strings` exist) and, for the AI + # rung, set ANTHROPIC_API_KEY. + desc 'Folds GlotPress + AI translations into Localizable.xcstrings (staged backing store; run manually, not in CI)' + lane :download_localized_catalog do + generate_strings_catalog # refresh the English source + reconcile (creates the catalog on first run) + catalog = JSON.parse(File.read(LOCALIZABLE_CATALOG)) + + written = CatalogStrings.fold_translations!( + catalog, + translations_by_locale: catalog_translations_by_locale(File.join(PROJECT_ROOT_FOLDER, 'WordPress', 'Resources')), + locales: GLOTPRESS_TO_LPROJ_APP_LOCALE_CODES.values.uniq, + ai_translator: catalog_ai_translator + ) + File.write(LOCALIZABLE_CATALOG, "#{JSON.pretty_generate(catalog)}\n") + UI.success("Folded translations into #{File.basename(LOCALIZABLE_CATALOG)} (#{written} cells across locales).") + + git_add(path: LOCALIZABLE_CATALOG, shell_escape: false) + git_commit(path: [LOCALIZABLE_CATALOG], message: 'Update Localizable.xcstrings translations (staged)', allow_nothing_to_commit: true) + end + ################################################# # Helpers ################################################# @@ -199,4 +228,35 @@ def report_catalog(path, extracted_count:, reconciled_count:) message += " Re-flagged #{reconciled_count} for review (English source changed)." if reconciled_count.positive? UI.success(message) 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, + # degrading to {} on a per-locale API failure so one locale can't abort the whole fold. + 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 => e + UI.important("AI translation tier unavailable (#{e.message}); folding human + English only.") + nil + end end From b8887178a667f37331972f0e08a0a330d072da44 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:54:04 -0600 Subject: [PATCH 2/6] Restructure download_localized_catalog into explicit scan -> download -> AI-fill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the lane mirror the pipeline order: (1) generate_strings_catalog scans the code, (2) it now downloads the current GlotPress translations itself (ios_download_strings_files_from_glotpress) and folds them in, (3) CatalogStrings AI-fills the rest. Adds a locales:fr,de scope (and skip_download:true) so a run can be kept cheap. Uploading the AI drafts back to GlotPress (the eventual step 4) stays out — it builds on the existing GlotPress import integration (gp_update_metadata_source), not done here. --- fastlane/lanes/localization_catalog.rb | 68 ++++++++++++++++++++------ 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/fastlane/lanes/localization_catalog.rb b/fastlane/lanes/localization_catalog.rb index 8f405a12a656..7437a57bc1b0 100644 --- a/fastlane/lanes/localization_catalog.rb +++ b/fastlane/lanes/localization_catalog.rb @@ -82,29 +82,42 @@ end end - # Folds the downloaded GlotPress translations (human) plus AI machine translations into Localizable.xcstrings, - # the future regular-string backing store, as `human ?? existing-machine ?? AI ?? English` (see CatalogStrings). + # Builds Localizable.xcstrings, the future regular-string backing store, end to end: + # 1. scan the code into the English catalog (generate_strings_catalog), + # 2. download the current GlotPress translations and fold them in (human => translated), + # 3. AI-fill the cells GlotPress left empty (=> needs_review) — see CatalogStrings.fold_translations!. + # Uploading the AI drafts back to GlotPress as needs-review for human 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). For now the machine cells live in the catalog as needs_review. # - # STAGED, NOT SHIPPED: the catalog isn't the runtime store yet (the app still ships Localizable.strings), so - # this only pre-populates it for the eventual cutover — it changes nothing users see. + # 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 — deliberately NOT wired into download_localized_strings or any CI step: it runs xcstringstool - # extraction, calls the translation API (cost), and commits a large catalog, so it's run on demand, not on - # every release. Run `download_localized_strings` first (so the per-locale `.strings` exist) and, for the AI - # rung, set ANTHROPIC_API_KEY. - desc 'Folds GlotPress + AI translations into Localizable.xcstrings (staged backing store; run manually, not in CI)' - lane :download_localized_catalog do - generate_strings_catalog # refresh the English source + reconcile (creates the catalog on first run) - catalog = JSON.parse(File.read(LOCALIZABLE_CATALOG)) + # MANUAL ONLY — deliberately not wired into download_localized_strings or any CI step: it runs xcstringstool + # extraction, downloads from GlotPress, calls the translation API (cost), and commits a large catalog. Set + # ANTHROPIC_API_KEY for step 3; scope a cheap run with `locales:fr` (and add `skip_download:true` to reuse the + # committed .strings instead of re-downloading). + desc 'Builds Localizable.xcstrings: scan code, fold GlotPress translations, AI-fill the rest (staged; manual, not CI)' + lane :download_localized_catalog do |options| + resources_dir = File.join(PROJECT_ROOT_FOLDER, 'WordPress', 'Resources') + locales = catalog_target_locales(options[:locales]) # all ship locales, or the `locales:fr,de` subset + + # 1. Scan the code into the English catalog (create / update + reconcile changed sources). + generate_strings_catalog + + # 2. Download the current GlotPress translations for those locales (skip to reuse the committed .strings). + download_catalog_strings(resources_dir, locales) unless catalog_flag?(options[:skip_download]) + # 2b + 3. Fold the human translations in (=> translated), then AI-fill the cells GlotPress left empty. + catalog = JSON.parse(File.read(LOCALIZABLE_CATALOG)) written = CatalogStrings.fold_translations!( catalog, - translations_by_locale: catalog_translations_by_locale(File.join(PROJECT_ROOT_FOLDER, 'WordPress', 'Resources')), - locales: GLOTPRESS_TO_LPROJ_APP_LOCALE_CODES.values.uniq, + translations_by_locale: catalog_translations_by_locale(resources_dir), + locales: locales.values.uniq, ai_translator: catalog_ai_translator ) File.write(LOCALIZABLE_CATALOG, "#{JSON.pretty_generate(catalog)}\n") - UI.success("Folded translations into #{File.basename(LOCALIZABLE_CATALOG)} (#{written} cells across locales).") + UI.success("Built #{File.basename(LOCALIZABLE_CATALOG)}: folded #{written} cell(s) across #{locales.values.uniq.size} locale(s).") git_add(path: LOCALIZABLE_CATALOG, shell_escape: false) git_commit(path: [LOCALIZABLE_CATALOG], message: 'Update Localizable.xcstrings translations (staged)', allow_nothing_to_commit: true) @@ -229,6 +242,31 @@ def report_catalog(path, extracted_count:, reconciled_count:) UI.success(message) 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. + def catalog_target_locales(spec) + return GLOTPRESS_TO_LPROJ_APP_LOCALE_CODES if spec.to_s.strip.empty? + + wanted = spec.to_s.split(',').map(&:strip) + selected = GLOTPRESS_TO_LPROJ_APP_LOCALE_CODES.select { |_glotpress, lproj| wanted.include?(lproj) } + UI.user_error!("No known ship locales among #{spec.inspect} (use lproj codes, e.g. fr,de,pt-BR)") if selected.empty? + selected + end + + # Step 2: download the current GlotPress translations for the given locales into their `*.lproj` dirs. + def download_catalog_strings(resources_dir, locales) + ios_download_strings_files_from_glotpress( + project_url: GLOTPRESS_APP_STRINGS_PROJECT_URL, + locales: locales, + download_dir: resources_dir + ) + end + + # A fastlane CLI flag, which arrives as a string ("true"/"1"/"yes") or a real boolean. + def catalog_flag?(value) + %w[true 1 yes].include?(value.to_s.strip.downcase) + 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) From 768a7c51217ada567ae8568bf0ca7a3a27b17537 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:01:35 -0600 Subject: [PATCH 3/6] Split the catalog flow into download + localize lanes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run a download then a localize as separate fastlane invocations: - download_catalog_strings — pull GlotPress translations into the *.lproj dirs (scoped by locales:) - localize_catalog — scan + fold the downloaded strings + AI-fill into Localizable.xcstrings Replaces the single download_localized_catalog lane (and its skip_download flag): download once, re-localize as many times as you like without re-downloading. --- fastlane/lanes/localization_catalog.rb | 63 ++++++++++++-------------- 1 file changed, 29 insertions(+), 34 deletions(-) diff --git a/fastlane/lanes/localization_catalog.rb b/fastlane/lanes/localization_catalog.rb index 7437a57bc1b0..eddf392fbe98 100644 --- a/fastlane/lanes/localization_catalog.rb +++ b/fastlane/lanes/localization_catalog.rb @@ -82,37 +82,46 @@ end end - # Builds Localizable.xcstrings, the future regular-string backing store, end to end: - # 1. scan the code into the English catalog (generate_strings_catalog), - # 2. download the current GlotPress translations and fold them in (human => translated), - # 3. AI-fill the cells GlotPress left empty (=> needs_review) — see CatalogStrings.fold_translations!. - # Uploading the AI drafts back to GlotPress as needs-review for human 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). For now the machine cells live in the catalog as needs_review. + # STEP 2 (download) — pull the current GlotPress translations for the given locales into their `*.lproj` dirs, + # ready for `localize_catalog` to fold. A thin, scoped wrapper around the GlotPress download (the full + # `download_localized_strings` works too, if you want every locale plus the plural fold). Scope with + # `locales:fr,de`; default is all ship locales. Doesn't commit — the `.strings` are transient input. + desc 'Download step: pull GlotPress translations into the *.lproj dirs (then run localize_catalog)' + lane :download_catalog_strings do |options| + locales = catalog_target_locales(options[:locales]) + ios_download_strings_files_from_glotpress( + project_url: GLOTPRESS_APP_STRINGS_PROJECT_URL, + locales: locales, + download_dir: File.join(PROJECT_ROOT_FOLDER, 'WordPress', 'Resources') + ) + UI.success("Downloaded GlotPress translations for #{locales.values.uniq.size} locale(s). Next: run localize_catalog (same locales:).") + end + + # LOCALIZE (steps 1 + 3) — build Localizable.xcstrings from the ALREADY-DOWNLOADED strings: scan the code into + # the English catalog, fold the GlotPress translations in (human => translated), and AI-fill the cells they + # leave empty (=> needs_review). Run `download_catalog_strings` (or `download_localized_strings`) first. + # + # 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 — deliberately not wired into download_localized_strings or any CI step: it runs xcstringstool - # extraction, downloads from GlotPress, calls the translation API (cost), and commits a large catalog. Set - # ANTHROPIC_API_KEY for step 3; scope a cheap run with `locales:fr` (and add `skip_download:true` to reuse the - # committed .strings instead of re-downloading). - desc 'Builds Localizable.xcstrings: scan code, fold GlotPress translations, AI-fill the rest (staged; manual, not CI)' - lane :download_localized_catalog do |options| - resources_dir = File.join(PROJECT_ROOT_FOLDER, 'WordPress', 'Resources') - locales = catalog_target_locales(options[:locales]) # all ship locales, or the `locales:fr,de` subset + # MANUAL ONLY — not wired into download_localized_strings or any CI step: it runs xcstringstool extraction, + # calls the translation API (cost), and commits a large catalog. Set ANTHROPIC_API_KEY for the AI rung; scope + # a cheap run with `locales:fr`. + desc 'Localize step: build Localizable.xcstrings from downloaded strings — scan + fold + AI-fill (staged; manual, not CI)' + lane :localize_catalog do |options| + locales = catalog_target_locales(options[:locales]) # 1. Scan the code into the English catalog (create / update + reconcile changed sources). generate_strings_catalog - # 2. Download the current GlotPress translations for those locales (skip to reuse the committed .strings). - download_catalog_strings(resources_dir, locales) unless catalog_flag?(options[:skip_download]) - - # 2b + 3. Fold the human translations in (=> translated), then AI-fill the cells GlotPress left empty. + # Fold the downloaded human translations in (=> translated), then (3) AI-fill the cells they leave empty. catalog = JSON.parse(File.read(LOCALIZABLE_CATALOG)) written = CatalogStrings.fold_translations!( catalog, - translations_by_locale: catalog_translations_by_locale(resources_dir), + translations_by_locale: catalog_translations_by_locale(File.join(PROJECT_ROOT_FOLDER, 'WordPress', 'Resources')), locales: locales.values.uniq, ai_translator: catalog_ai_translator ) @@ -253,20 +262,6 @@ def catalog_target_locales(spec) selected end - # Step 2: download the current GlotPress translations for the given locales into their `*.lproj` dirs. - def download_catalog_strings(resources_dir, locales) - ios_download_strings_files_from_glotpress( - project_url: GLOTPRESS_APP_STRINGS_PROJECT_URL, - locales: locales, - download_dir: resources_dir - ) - end - - # A fastlane CLI flag, which arrives as a string ("true"/"1"/"yes") or a real boolean. - def catalog_flag?(value) - %w[true 1 yes].include?(value.to_s.strip.downcase) - 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) From 8f6850e0a0864ad8b2d6c9e675a33079b4b2865f Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:08:27 -0600 Subject: [PATCH 4/6] Separate the code scan from the AI fill in the catalog flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit localize_catalog no longer re-runs generate_strings_catalog — scanning the code is its own lane (generate_strings_catalog), so you can refresh the English catalog without ever invoking the AI. The three stages are now independent fastlane invocations: generate_strings_catalog (scan) -> download_catalog_strings (download) -> localize_catalog (fold + AI-fill) localize_catalog errors if the catalog hasn't been generated yet. --- fastlane/lanes/localization_catalog.rb | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/fastlane/lanes/localization_catalog.rb b/fastlane/lanes/localization_catalog.rb index eddf392fbe98..8016146f17e9 100644 --- a/fastlane/lanes/localization_catalog.rb +++ b/fastlane/lanes/localization_catalog.rb @@ -97,9 +97,11 @@ UI.success("Downloaded GlotPress translations for #{locales.values.uniq.size} locale(s). Next: run localize_catalog (same locales:).") end - # LOCALIZE (steps 1 + 3) — build Localizable.xcstrings from the ALREADY-DOWNLOADED strings: scan the code into - # the English catalog, fold the GlotPress translations in (human => translated), and AI-fill the cells they - # leave empty (=> needs_review). Run `download_catalog_strings` (or `download_localized_strings`) first. + # LOCALIZE (fold + AI-fill) — fill the per-locale translations into the EXISTING Localizable.xcstrings: fold + # the downloaded GlotPress translations in (human => translated), then AI-fill the cells they leave empty + # (=> needs_review). It does NOT scan the code and does NOT download — so each stage is its own invocation: + # generate_strings_catalog (scan) -> download_catalog_strings (download) -> localize_catalog (this). + # Running the scan separately means you can refresh the catalog without ever touching the AI. # # 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). @@ -107,17 +109,14 @@ # 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 runs xcstringstool extraction, - # calls the translation API (cost), and commits a large catalog. Set ANTHROPIC_API_KEY for the AI rung; scope - # a cheap run with `locales:fr`. - desc 'Localize step: build Localizable.xcstrings from downloaded strings — scan + fold + AI-fill (staged; manual, not CI)' + # MANUAL ONLY — not wired into download_localized_strings or any CI step: it calls the translation API (cost) + # and commits a large catalog. Set ANTHROPIC_API_KEY for the AI rung; scope a cheap run with `locales:fr`. + desc 'Localize step: fold downloaded 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]) - # 1. Scan the code into the English catalog (create / update + reconcile changed sources). - generate_strings_catalog - - # Fold the downloaded human translations in (=> translated), then (3) AI-fill the cells they leave empty. + # Fold the downloaded human translations in (=> translated), then AI-fill the cells they leave empty. catalog = JSON.parse(File.read(LOCALIZABLE_CATALOG)) written = CatalogStrings.fold_translations!( catalog, From 4b0814623f8906cbba9b4340de02d69216bfa268 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:05:58 -0600 Subject: [PATCH 5/6] Quiet xcstringstool output in generate_strings_catalog The extract/sync sh calls echo enormous commands (up to 400 file paths per extract batch; a --stringsdata pair per file on sync), burying the output. Pass log: false and emit concise progress lines instead. No fastlane/release-toolkit action wraps xcstringstool, so sh is the invocation; log: false is its clean-output option. --- fastlane/lanes/localization_catalog.rb | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/fastlane/lanes/localization_catalog.rb b/fastlane/lanes/localization_catalog.rb index 8016146f17e9..2945c49249a7 100644 --- a/fastlane/lanes/localization_catalog.rb +++ b/fastlane/lanes/localization_catalog.rb @@ -174,10 +174,13 @@ 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})") + # log: false — each command lists up to 400 file paths; echoing them buries the output. + sh('xcrun', 'xcstringstool', 'extract', *chunk, *flags, '--output-directory', chunk_dir, log: false) end end @@ -194,7 +197,9 @@ 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)}…") + # log: false — the command passes a `--stringsdata ` pair per file (thousands of args). + sh('xcrun', 'xcstringstool', 'sync', LOCALIZABLE_CATALOG, *stringsdata.flat_map { |f| ['--stringsdata', f] }, log: false) JSON.parse(File.read(LOCALIZABLE_CATALOG))['strings'].count end @@ -229,7 +234,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] }) + sh('xcrun', 'xcstringstool', 'sync', fresh, *stringsdata.flat_map { |f| ['--stringsdata', f] }, log: false) english_values(JSON.parse(File.read(fresh))) end end From d5697f8a26a486defcea4fcddfef2bf26352244b Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:21:45 -0600 Subject: [PATCH 6/6] Run xcstringstool via Open3 so the catalog scan output stays clean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fastlane's sh treats each call as an action: even with log: false it prints a '--- Step: shell command ---' banner per call and lists it in the run summary. extract is chunked into ~7 calls and sync runs twice, so that wrapped the real progress in a wall of banners. Run xcstringstool through Open3.capture2e instead (argv — safe for paths with spaces; captured output surfaced only on failure), via a run_xcstringstool helper. The scan now prints just its own progress lines. --- fastlane/lanes/localization_catalog.rb | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/fastlane/lanes/localization_catalog.rb b/fastlane/lanes/localization_catalog.rb index 2945c49249a7..6748d5fb6971 100644 --- a/fastlane/lanes/localization_catalog.rb +++ b/fastlane/lanes/localization_catalog.rb @@ -3,6 +3,7 @@ require 'json' require 'tmpdir' require 'fileutils' +require 'open3' require_relative 'catalog_helper' require_relative 'catalog_strings_helper' @@ -156,6 +157,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 @@ -179,8 +189,7 @@ def extract_stringsdata(files:, output_dir:, swiftui: false) chunk_dir = File.join(output_dir, "chunk-#{index}") FileUtils.mkdir_p(chunk_dir) UI.message("Extracting strings… (batch #{index + 1}/#{batches.size})") - # log: false — each command lists up to 400 file paths; echoing them buries the output. - sh('xcrun', 'xcstringstool', 'extract', *chunk, *flags, '--output-directory', chunk_dir, log: false) + run_xcstringstool('extract', *chunk, *flags, '--output-directory', chunk_dir) end end @@ -198,8 +207,7 @@ def sync_localizable_catalog(stringsdata_dir:) UI.user_error!('xcstringstool produced no .stringsdata') if stringsdata.empty? UI.message("Syncing #{stringsdata.count} extracted file(s) into #{File.basename(LOCALIZABLE_CATALOG)}…") - # log: false — the command passes a `--stringsdata ` pair per file (thousands of args). - sh('xcrun', 'xcstringstool', 'sync', LOCALIZABLE_CATALOG, *stringsdata.flat_map { |f| ['--stringsdata', f] }, log: false) + run_xcstringstool('sync', LOCALIZABLE_CATALOG, *stringsdata.flat_map { |f| ['--stringsdata', f] }) JSON.parse(File.read(LOCALIZABLE_CATALOG))['strings'].count end @@ -234,7 +242,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] }, log: false) + run_xcstringstool('sync', fresh, *stringsdata.flat_map { |f| ['--stringsdata', f] }) english_values(JSON.parse(File.read(fresh))) end end