fix(i18n): bind plist translations to stable IDs

This commit is contained in:
Vincent Koc
2026-07-12 15:41:31 +02:00
parent b8c544b62b
commit 9bc3a7bfe4
2 changed files with 103 additions and 13 deletions
+35 -13
View File
@@ -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<string | null> {
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<string, Set<string>>();
for (const [entry, source] of catalogEntries) {
@@ -598,9 +614,7 @@ export function buildIosCatalog(
const localizations: NonNullable<CatalogEntry["localizations"]> = {};
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<number> {
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<number> {
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) {
+68
View File
@@ -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 lappareil 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 lappareil 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 {