From 9bc3a7bfe4a554b27632d2aea79522737cc4b5ef Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 12 Jul 2026 15:41:31 +0200 Subject: [PATCH] fix(i18n): bind plist translations to stable IDs --- scripts/apple-app-i18n.ts | 48 ++++++++++++++------ test/scripts/apple-app-i18n.test.ts | 68 +++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 13 deletions(-) diff --git a/scripts/apple-app-i18n.ts b/scripts/apple-app-i18n.ts index 992808fd9b4c..164c1f8b87c8 100644 --- a/scripts/apple-app-i18n.ts +++ b/scripts/apple-app-i18n.ts @@ -429,6 +429,10 @@ function stringsLiteral(value: string): string { return JSON.stringify(value); } +function compareCodeUnits(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + function serializeCatalog(catalog: Catalog): string { return `${JSON.stringify(catalog, null, 2)}\n`; } @@ -489,6 +493,18 @@ export function selectInfoPlistTranslation( return existing?.source === source && existing.value.trim() ? existing.value : source; } +export function infoPlistTranslationCandidates( + artifact: NativeTranslationArtifact | undefined, + sourceId: string, + source: string, +): string[] { + return ( + artifact?.entries + .filter((entry) => entry.id === sourceId && entry.source === source) + .map((entry) => entry.translated) ?? [] + ); +} + async function readOptionalFile(filePath: string): Promise { try { return await readFile(filePath, "utf8"); @@ -547,7 +563,7 @@ function chooseTranslation(source: string, translations: readonly string[]): str return ( [...counts].toSorted(([leftValue, leftCount], [rightValue, rightCount]) => { const sourcePenalty = Number(leftValue === source) - Number(rightValue === source); - return sourcePenalty || rightCount - leftCount || leftValue.localeCompare(rightValue, "en"); + return sourcePenalty || rightCount - leftCount || compareCodeUnits(leftValue, rightValue); })[0]?.[0] ?? source ); } @@ -561,8 +577,8 @@ export function buildIosCatalog( const catalogEntries = iosEntries.map( (entry) => [entry, appleCatalogValue(entry.source)] as const, ); - const sources = [...new Set(catalogEntries.map(([, source]) => source))].toSorted((left, right) => - left.localeCompare(right, "en"), + const sources = [...new Set(catalogEntries.map(([, source]) => source))].toSorted( + compareCodeUnits, ); const appleIdsBySource = new Map>(); for (const [entry, source] of catalogEntries) { @@ -598,9 +614,7 @@ export function buildIosCatalog( const localizations: NonNullable = {}; for (const locale of REQUIRED_LOCALES) { const candidates = translationsByLocale.get(locale)?.get(source) ?? []; - const distinct = [...new Set(candidates)].toSorted((left, right) => - left.localeCompare(right, "en"), - ); + const distinct = [...new Set(candidates)].toSorted(compareCodeUnits); if (distinct.length > 1) { contradictions.push({ locale, source, translations: distinct }); } @@ -637,8 +651,7 @@ export function buildIosCatalog( }, contradictions: contradictions.toSorted( (left, right) => - left.source.localeCompare(right.source, "en") || - left.locale.localeCompare(right.locale, "en"), + compareCodeUnits(left.source, right.source) || compareCodeUnits(left.locale, right.locale), ), }; } @@ -754,6 +767,14 @@ function validateCatalog(pathName: string, catalog: Catalog): number { async function syncIosInfoPlist(write: boolean): Promise { const translations = await readNativeTranslations(); + const nativeSource = JSON.parse( + await readFile(path.join(ROOT, NATIVE_SOURCE_PATH), "utf8"), + ) as NativeSourceArtifact; + const sourceIds = new Map( + nativeSource.entries + .filter((entry) => entry.kind === "plist-string") + .map((entry) => [[entry.path, entry.source].join("\u0000"), entry.id]), + ); let checked = 0; for (const target of IOS_INFO_PLIST_TARGETS) { const sourceEntries = parseInfoPlistStrings( @@ -774,10 +795,11 @@ async function syncIosInfoPlist(write: boolean): Promise { if (key === "CFBundleDisplayName") { return `${stringsLiteral(key)} = ${stringsLiteral(source)};`; } - const candidates = - artifact?.entries - .filter((entry) => entry.source === source) - .map((entry) => entry.translated) ?? []; + const sourceId = sourceIds.get([target.sourcePath, source].join("\u0000")); + if (!sourceId) { + throw new Error(`missing native InfoPlist source id for ${target.sourcePath}:${key}`); + } + const candidates = infoPlistTranslationCandidates(artifact, sourceId, source); const value = selectInfoPlistTranslation(source, candidates, existing.get(key)); return [ `/* OpenClaw source: ${stringsLiteral(source)} */`, @@ -905,7 +927,7 @@ export async function compileMacosLocalizations(outputDir: string) { const localeDir = APPLE_LOCALE_DIRECTORIES[locale] ?? locale; const lprojDir = path.join(outputDir, `${localeDir}.lproj`); const lines = Object.entries(catalog.strings) - .toSorted(([left], [right]) => left.localeCompare(right)) + .toSorted(([left], [right]) => compareCodeUnits(left, right)) .map(([key, entry]) => { const value = entry.localizations?.[locale]?.stringUnit?.value; if (!value) { diff --git a/test/scripts/apple-app-i18n.test.ts b/test/scripts/apple-app-i18n.test.ts index 0d42720e0498..d2b0ff6cea7f 100644 --- a/test/scripts/apple-app-i18n.test.ts +++ b/test/scripts/apple-app-i18n.test.ts @@ -8,6 +8,7 @@ import { checkAppleAppI18n, compileMacosLocalizations, findAmbiguousRuntimeInterpolations, + infoPlistTranslationCandidates, selectInfoPlistTranslation, } from "../../scripts/apple-app-i18n.ts"; import { NATIVE_I18N_LOCALES } from "../../scripts/native-app-i18n.ts"; @@ -178,6 +179,49 @@ describe("Apple app i18n catalogs", () => { }); }); + it("uses code-unit ordering for canonically equivalent translations", () => { + const source = "Resume"; + const decomposed = "Re\u0301sume\u0301"; + const composed = "Résumé"; + const build = buildIosCatalog( + { sourceLanguage: "en", strings: {} }, + { + version: 1, + entries: [ + { + id: "native.apple.resume-a", + kind: "ui-call", + line: 1, + path: "apps/ios/Sources/Example.swift", + source, + surface: "apple", + }, + { + id: "native.apple.resume-b", + kind: "ui-call", + line: 2, + path: "apps/ios/Sources/Other.swift", + source, + surface: "apple", + }, + ], + }, + [ + { + version: 1, + locale: "fr", + entries: [ + { id: "native.apple.resume-a", source, translated: composed }, + { id: "native.apple.resume-b", source, translated: decomposed }, + ], + }, + ], + ); + + expect(build.catalog.strings?.[source]?.localizations?.fr?.stringUnit?.value).toBe(decomposed); + expect(build.contradictions[0]?.translations).toEqual([decomposed, composed]); + }); + it("converts inflected Swift count resources into typed catalog placeholders", () => { const source = "^[\\(count) entry](inflect: true)"; const translated = "^[\\(count) Eintrag](inflect: true)"; @@ -381,6 +425,30 @@ describe("Apple app i18n catalogs", () => { ).toBe("Use the camera for video calls."); }); + it("selects InfoPlist candidates by stable ID instead of shared source text", () => { + const source = "Use the camera to scan setup codes."; + const artifact = { + version: 1, + locale: "fr", + entries: [ + { + id: "native.apple.camera", + source, + translated: "Utilisez l’appareil photo pour scanner les codes de configuration.", + }, + { + id: "native.apple.unrelated", + source, + translated: "Traduction pour un autre contexte.", + }, + ], + }; + + expect(infoPlistTranslationCandidates(artifact, "native.apple.camera", source)).toEqual([ + "Utilisez l’appareil photo pour scanner les codes de configuration.", + ]); + }); + it("compiles macOS catalogs into app-bundle localization directories", async () => { const outputDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-apple-i18n-")); try {