Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions fastlane/lanes/catalog_strings_helper.rb
Original file line number Diff line number Diff line change
@@ -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<String>] 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
144 changes: 144 additions & 0 deletions fastlane/lanes/catalog_strings_helper_test.rb
Original file line number Diff line number Diff line change
@@ -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
Loading