mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
fix(i18n): parse nested Kotlin interpolation
This commit is contained in:
@@ -45,7 +45,6 @@ const ARRAY_RE = /<string-array\s+name="([A-Za-z0-9_]+)"[^>]*>([\s\S]*?)<\/strin
|
||||
const ARRAY_ITEM_RE = /<item>([\s\S]*?)<\/item>/gu;
|
||||
const FORMAT_RE = /%\d+\$[a-z]/giu;
|
||||
const INVALID_APOSTROPHE_RE = /(?:'|(?<!\\)')/u;
|
||||
const INTERPOLATION_RE = /\$(?:[A-Za-z_][A-Za-z0-9_]*|\{[^{}]+\})/gu;
|
||||
const GENERATED_HEADER = " <!-- Generated by scripts/android-app-i18n.ts. -->";
|
||||
const GENERATED_KOTLIN_HEADER = "// Generated by scripts/android-app-i18n.ts. Do not edit.";
|
||||
|
||||
@@ -105,10 +104,75 @@ function decodeXml(value: string): string {
|
||||
.replaceAll("\\\\", "\\");
|
||||
}
|
||||
|
||||
type KotlinInterpolation = {
|
||||
end: number;
|
||||
start: number;
|
||||
value: string;
|
||||
};
|
||||
|
||||
function readKotlinInterpolations(source: string): KotlinInterpolation[] | null {
|
||||
const interpolations: KotlinInterpolation[] = [];
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
if (source[index] !== "$") {
|
||||
continue;
|
||||
}
|
||||
const next = source[index + 1];
|
||||
if (next !== "{" && !/[A-Za-z_]/u.test(next ?? "")) {
|
||||
continue;
|
||||
}
|
||||
const start = index;
|
||||
if (next !== "{") {
|
||||
index += 2;
|
||||
while (/[A-Za-z0-9_]/u.test(source[index] ?? "")) {
|
||||
index += 1;
|
||||
}
|
||||
interpolations.push({ start, end: index, value: source.slice(start, index) });
|
||||
index -= 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let depth = 1;
|
||||
let quote: '"' | "'" | null = null;
|
||||
let escaped = false;
|
||||
for (index += 2; index < source.length; index += 1) {
|
||||
const character = source[index];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (quote !== null && character === "\\") {
|
||||
escaped = true;
|
||||
} else if (character === quote) {
|
||||
quote = null;
|
||||
} else if (quote === null && (character === '"' || character === "'")) {
|
||||
quote = character;
|
||||
} else if (quote === null && character === "{") {
|
||||
depth += 1;
|
||||
} else if (quote === null && character === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
const end = index + 1;
|
||||
interpolations.push({ start, end, value: source.slice(start, end) });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (depth !== 0) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return interpolations;
|
||||
}
|
||||
|
||||
export function renderAndroidResourceValue(source: string, translated: string): string {
|
||||
let rendered = translated;
|
||||
const sourceTokens = [...source.matchAll(INTERPOLATION_RE)].map((match) => match[0]);
|
||||
const translatedTokens = [...translated.matchAll(INTERPOLATION_RE)].map((match) => match[0]);
|
||||
const sourceInterpolations = readKotlinInterpolations(source);
|
||||
const translatedInterpolations = readKotlinInterpolations(translated);
|
||||
if (sourceInterpolations === null || translatedInterpolations === null) {
|
||||
throw new Error(
|
||||
`Android translation has unbalanced interpolation placeholders: ${JSON.stringify(source)} -> ${JSON.stringify(translated)}`,
|
||||
);
|
||||
}
|
||||
const sourceTokens = sourceInterpolations.map((interpolation) => interpolation.value);
|
||||
const translatedTokens = translatedInterpolations.map((interpolation) => interpolation.value);
|
||||
const tokenCounts = (tokens: readonly string[]) => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const token of tokens) {
|
||||
@@ -122,7 +186,6 @@ export function renderAndroidResourceValue(source: string, translated: string):
|
||||
);
|
||||
}
|
||||
if (sourceTokens.length > 0) {
|
||||
rendered = rendered.replaceAll("%", "%%");
|
||||
const sourceIndices = new Map<string, number[]>();
|
||||
for (const [index, token] of sourceTokens.entries()) {
|
||||
const indices = sourceIndices.get(token) ?? [];
|
||||
@@ -130,12 +193,18 @@ export function renderAndroidResourceValue(source: string, translated: string):
|
||||
sourceIndices.set(token, indices);
|
||||
}
|
||||
const translatedOccurrences = new Map<string, number>();
|
||||
rendered = rendered.replace(INTERPOLATION_RE, (token) => {
|
||||
let cursor = 0;
|
||||
rendered = "";
|
||||
for (const interpolation of translatedInterpolations) {
|
||||
rendered += translated.slice(cursor, interpolation.start).replaceAll("%", "%%");
|
||||
const token = interpolation.value;
|
||||
const occurrence = translatedOccurrences.get(token) ?? 0;
|
||||
translatedOccurrences.set(token, occurrence + 1);
|
||||
const index = sourceIndices.get(token)?.[occurrence];
|
||||
return index ? `%${index}$s` : token;
|
||||
});
|
||||
rendered += index ? `%${index}$s` : token;
|
||||
cursor = interpolation.end;
|
||||
}
|
||||
rendered += translated.slice(cursor).replaceAll("%", "%%");
|
||||
}
|
||||
return rendered
|
||||
.replaceAll("\\", "\\\\")
|
||||
@@ -837,8 +906,8 @@ function renderStringsXml(
|
||||
for (const [key, entry] of [...generated].toSorted(([left], [right]) =>
|
||||
compareText(left, right),
|
||||
)) {
|
||||
const formatted = INTERPOLATION_RE.test(entry.source) ? "" : ' formatted="false"';
|
||||
INTERPOLATION_RE.lastIndex = 0;
|
||||
const formatted =
|
||||
(readKotlinInterpolations(entry.source)?.length ?? 0) > 0 ? "" : ' formatted="false"';
|
||||
// Translation-memory text intentionally preserves technical tokens and source punctuation,
|
||||
// so Android's English dictionary and typography suggestions do not apply to managed keys.
|
||||
lines.push(
|
||||
|
||||
@@ -53,6 +53,24 @@ describe("Android app i18n resources", () => {
|
||||
).toBe("%2$s Anbieter, davon %1$s bereit");
|
||||
});
|
||||
|
||||
it("formats nested Kotlin interpolations as single Android arguments", () => {
|
||||
expect(
|
||||
renderAndroidResourceValue(
|
||||
"${device.tokens.count { !it.revoked }}/${device.tokens.size} active tokens",
|
||||
"${device.tokens.size} Token, ${device.tokens.count { !it.revoked }} aktiv",
|
||||
),
|
||||
).toBe("%2$s Token, %1$s aktiv");
|
||||
});
|
||||
|
||||
it("balances braces inside nested interpolation strings", () => {
|
||||
expect(
|
||||
renderAndroidResourceValue(
|
||||
'${if (connected) "{" else "}"} $count',
|
||||
'$count · ${if (connected) "{" else "}"}',
|
||||
),
|
||||
).toBe("%2$s · %1$s");
|
||||
});
|
||||
|
||||
it("rejects repeated translation placeholders that do not match the source", () => {
|
||||
expect(() =>
|
||||
renderAndroidResourceValue("$item then $item", "$item, $item und noch einmal $item"),
|
||||
|
||||
Reference in New Issue
Block a user