mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 21:51:39 +00:00
676 lines
23 KiB
TypeScript
676 lines
23 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { readdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { expectDefined } from "../packages/normalization-core/src/expect.js";
|
|
import { NATIVE_I18N_LOCALES } from "./native-app-i18n.ts";
|
|
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = path.resolve(HERE, "..");
|
|
const ANDROID_ROOT = path.join(ROOT, "apps", "android", "app", "src", "main");
|
|
const RESOURCE_ROOT = path.join(ANDROID_ROOT, "res");
|
|
const SOURCE_ROOT = path.join(ANDROID_ROOT, "java");
|
|
const INVENTORY_PATH = path.join(ROOT, "apps", ".i18n", "native-source.json");
|
|
const ARTIFACT_ROOT = path.join(ROOT, "apps", ".i18n", "native");
|
|
const TOOL_DISPLAY_PATH = path.join(
|
|
ROOT,
|
|
"apps",
|
|
"shared",
|
|
"OpenClawKit",
|
|
"Sources",
|
|
"OpenClawKit",
|
|
"Resources",
|
|
"tool-display.json",
|
|
);
|
|
const GENERATED_KOTLIN_PATH = path.join(
|
|
SOURCE_ROOT,
|
|
"ai",
|
|
"openclaw",
|
|
"app",
|
|
"i18n",
|
|
"NativeStringResources.kt",
|
|
);
|
|
const MANAGED_PREFIX = "native_";
|
|
const ANDROID_QUALIFIERS: Record<string, string> = {
|
|
id: "in",
|
|
"zh-CN": "zh-rCN",
|
|
"zh-TW": "zh-rTW",
|
|
"pt-BR": "pt-rBR",
|
|
"ja-JP": "ja",
|
|
};
|
|
const localeDirectory = (locale: string) => `values-${ANDROID_QUALIFIERS[locale] ?? locale}`;
|
|
const LOCALES = ["values", ...NATIVE_I18N_LOCALES.map(localeDirectory)] as const;
|
|
const STRING_RE = /<string\s+name="([A-Za-z0-9_]+)"([^>]*)>([\s\S]*?)<\/string>/gu;
|
|
const ARRAY_RE = /<string-array\s+name="([A-Za-z0-9_]+)"[^>]*>([\s\S]*?)<\/string-array>/gu;
|
|
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.";
|
|
|
|
type NativeInventoryEntry = {
|
|
id: string;
|
|
path: string;
|
|
source: string;
|
|
surface: "android" | "apple";
|
|
};
|
|
|
|
type NativeArtifactEntry = {
|
|
id: string;
|
|
source: string;
|
|
translated: string;
|
|
};
|
|
|
|
type ResourceString = {
|
|
attrs: string;
|
|
key: string;
|
|
rawValue: string;
|
|
value: string;
|
|
};
|
|
|
|
type TranslationContradiction = {
|
|
locale: string;
|
|
selected: string;
|
|
source: string;
|
|
translations: string[];
|
|
};
|
|
|
|
type GeneratedCatalog = {
|
|
contradictions: TranslationContradiction[];
|
|
kotlin: string;
|
|
resources: Map<string, string>;
|
|
sources: Set<string>;
|
|
};
|
|
|
|
export type AndroidUiLiteralFinding = {
|
|
line: number;
|
|
path: string;
|
|
source: string;
|
|
};
|
|
|
|
function compareText(left: string, right: string): number {
|
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
}
|
|
|
|
function decodeXml(value: string): string {
|
|
return value
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll(""", '"')
|
|
.replaceAll("&", "&")
|
|
.replaceAll("\\n", "\n")
|
|
.replaceAll("\\'", "'")
|
|
.replaceAll('\\"', '"')
|
|
.replaceAll("\\\\", "\\");
|
|
}
|
|
|
|
export function renderAndroidResourceValue(source: string, translated: string): string {
|
|
let rendered = translated;
|
|
const sourceTokens = [...source.matchAll(INTERPOLATION_RE)].map((match) => match[0]);
|
|
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) ?? [];
|
|
indices.push(index + 1);
|
|
sourceIndices.set(token, indices);
|
|
}
|
|
const translatedOccurrences = new Map<string, number>();
|
|
rendered = rendered.replace(INTERPOLATION_RE, (token) => {
|
|
const occurrence = translatedOccurrences.get(token) ?? 0;
|
|
translatedOccurrences.set(token, occurrence + 1);
|
|
const index = sourceIndices.get(token)?.[occurrence];
|
|
return index ? `%${index}$s` : token;
|
|
});
|
|
}
|
|
return rendered
|
|
.replaceAll("\\", "\\\\")
|
|
.replaceAll("\n", "\\n")
|
|
.replaceAll("'", "\\'")
|
|
.replaceAll('"', '\\"')
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">");
|
|
}
|
|
|
|
function escapeKotlin(value: string): string {
|
|
return value
|
|
.replaceAll("\\", "\\\\")
|
|
.replaceAll('"', '\\"')
|
|
.replaceAll("$", "\\$")
|
|
.replaceAll("\n", "\\n");
|
|
}
|
|
|
|
function resourceKey(source: string): string {
|
|
return `${MANAGED_PREFIX}${createHash("sha256").update(source).digest("hex").slice(0, 16)}`;
|
|
}
|
|
|
|
function parseStrings(source: string): ResourceString[] {
|
|
return [...source.matchAll(STRING_RE)].map((match) => ({
|
|
key: match[1] ?? "",
|
|
attrs: match[2] ?? "",
|
|
rawValue: match[3] ?? "",
|
|
value: decodeXml(match[3] ?? ""),
|
|
}));
|
|
}
|
|
|
|
function parseArrays(source: string): Map<string, string[]> {
|
|
return new Map(
|
|
[...source.matchAll(ARRAY_RE)].map((match) => [
|
|
match[1] ?? "",
|
|
[...(match[2] ?? "").matchAll(ARRAY_ITEM_RE)].map((item) =>
|
|
decodeXml((item[1] ?? "").trim()),
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
|
|
async function readStrings(locale: string): Promise<Map<string, ResourceString>> {
|
|
const source = await readFile(path.join(RESOURCE_ROOT, locale, "strings.xml"), "utf8");
|
|
return new Map(parseStrings(source).map((entry) => [entry.key, entry]));
|
|
}
|
|
|
|
async function readAndroidSource(
|
|
root = SOURCE_ROOT,
|
|
): Promise<Array<{ path: string; source: string }>> {
|
|
const entries = await readdir(root, { withFileTypes: true });
|
|
const sources: Array<{ path: string; source: string }> = [];
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(root, entry.name);
|
|
if (entry.isDirectory()) {
|
|
sources.push(...(await readAndroidSource(fullPath)));
|
|
continue;
|
|
}
|
|
if (entry.isFile() && entry.name.endsWith(".kt")) {
|
|
sources.push({
|
|
path: path.relative(ROOT, fullPath).split(path.sep).join("/"),
|
|
source: await readFile(fullPath, "utf8"),
|
|
});
|
|
}
|
|
}
|
|
return sources;
|
|
}
|
|
|
|
function lineNumber(source: string, offset: number): number {
|
|
return source.slice(0, offset).split("\n").length;
|
|
}
|
|
|
|
function decodeKotlinLiteral(value: string): string {
|
|
return value
|
|
.replaceAll("\\n", "\n")
|
|
.replaceAll('\\"', '"')
|
|
.replaceAll("\\$", "$")
|
|
.replaceAll("\\\\", "\\");
|
|
}
|
|
|
|
function collectExplicitRuntimeSources(
|
|
sourceFiles: readonly { path: string; source: string }[],
|
|
): Set<string> {
|
|
const sources = new Set<string>();
|
|
const callPattern = /\bnativeString(?:Resource)?\(\s*"((?:\\.|[^"\\])+)"/gu;
|
|
for (const file of sourceFiles) {
|
|
if (file.path.endsWith("/i18n/NativeStringResources.kt")) continue;
|
|
for (const match of file.source.matchAll(callPattern)) {
|
|
if (match[1]) sources.add(decodeKotlinLiteral(match[1]));
|
|
}
|
|
}
|
|
return sources;
|
|
}
|
|
|
|
function collectToolDisplaySources(value: unknown, sources = new Set<string>()): Set<string> {
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) collectToolDisplaySources(item, sources);
|
|
return sources;
|
|
}
|
|
if (value === null || typeof value !== "object") return sources;
|
|
for (const [key, item] of Object.entries(value)) {
|
|
if ((key === "title" || key === "label") && typeof item === "string") {
|
|
sources.add(item);
|
|
} else {
|
|
collectToolDisplaySources(item, sources);
|
|
}
|
|
}
|
|
return sources;
|
|
}
|
|
|
|
async function readToolDisplaySources(): Promise<Set<string>> {
|
|
return collectToolDisplaySources(JSON.parse(await readFile(TOOL_DISPLAY_PATH, "utf8")));
|
|
}
|
|
|
|
const DIRECT_UI_LITERAL_PATTERNS = [
|
|
/\bText\s*\(\s*(?:text\s*=\s*)?"((?:\\.|[^"\\])+)"/gu,
|
|
/\b(?:ClawPrimaryButton|ClawSecondaryButton|ClawDangerButton|ClawLinkButton)\s*\([^)]*?\btext\s*=\s*"((?:\\.|[^"\\])+)"/gsu,
|
|
/\b(?:title|subtitle|body|text|label|statusText|confirmLabel|dismissLabel|contentDescription|placeholder)\s*=\s*"((?:\\.|[^"\\])+)"/gu,
|
|
/\b(?:_[A-Za-z0-9_]*(?:ErrorText|StatusText)|errorText|statusText)(?:\.value)?\s*=\s*"((?:\\.|[^"\\])+)"/gu,
|
|
/\bSettingsMetric\s*\(\s*"((?:\\.|[^"\\])+)"/gu,
|
|
/\bToast\.makeText\s*\([^,]+,\s*"((?:\\.|[^"\\])+)"/gu,
|
|
/\bset(?:Title|Message|PositiveButton|NegativeButton|NeutralButton)\s*\(\s*"((?:\\.|[^"\\])+)"/gu,
|
|
] as const;
|
|
|
|
// These literals are either technical data or immutable source tokens localized at a typed render edge.
|
|
const ALLOWED_UI_LITERALS = new Map<string, ReadonlySet<string>>([
|
|
[
|
|
"*",
|
|
new Set([
|
|
"0 = exact",
|
|
"Cron expression, e.g. 0 9 * * *",
|
|
"D",
|
|
"ID",
|
|
"ISO time, e.g. 2026-07-09T09:30:00Z",
|
|
"OC",
|
|
"OPENCLAW",
|
|
"e.g. America/New_York",
|
|
"current-step-alpha",
|
|
"gateway-progress",
|
|
"main, isolated, current, or session:<id>",
|
|
"openclaw gateway",
|
|
"openclaw qr",
|
|
"PTT_BUSY: previous push-to-talk turn is still finishing",
|
|
]),
|
|
],
|
|
[
|
|
"apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt",
|
|
new Set([
|
|
"Connected",
|
|
"Continue",
|
|
"Checking approval…",
|
|
"Connecting Gateway",
|
|
"Connection issue",
|
|
"Go back",
|
|
"I have approved",
|
|
"Node Approval Pending",
|
|
"Pairing Gateway",
|
|
"Retry connection",
|
|
"Still connecting",
|
|
]),
|
|
],
|
|
[
|
|
"apps/android/app/src/main/java/ai/openclaw/app/ui/ShellScreen.kt",
|
|
new Set(["Chat", "Files", "Home", "Providers", "Sessions", "Settings", "Voice"]),
|
|
],
|
|
[
|
|
"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
|
new Set([
|
|
"Ask OpenClaw to use Android capabilities.",
|
|
"Catch me up",
|
|
"Plan the work",
|
|
"Summarize recent sessions and next steps.",
|
|
"Turn a goal into an actionable checklist.",
|
|
"Use this phone",
|
|
]),
|
|
],
|
|
]);
|
|
|
|
function isAllowedUiLiteral(repoPath: string, source: string): boolean {
|
|
return (
|
|
ALLOWED_UI_LITERALS.get("*")?.has(source) === true ||
|
|
ALLOWED_UI_LITERALS.get(repoPath)?.has(source) === true
|
|
);
|
|
}
|
|
|
|
function shouldScanUiLiterals(repoPath: string): boolean {
|
|
if (repoPath.endsWith("/i18n/NativeStringResources.kt")) return false;
|
|
if (repoPath.endsWith("/AndroidScreenshotFixture.kt")) return false;
|
|
if (repoPath.endsWith("/ui/design/ClawComponents.kt")) return false;
|
|
if (repoPath.endsWith("/ui/design/OpenClawMascot.kt")) return false;
|
|
return (
|
|
repoPath.includes("/ui/") ||
|
|
repoPath.endsWith("/MainActivity.kt") ||
|
|
repoPath.endsWith("/NodeRuntime.kt") ||
|
|
repoPath.endsWith("/PermissionRequester.kt") ||
|
|
repoPath.endsWith("/NodeForegroundService.kt") ||
|
|
repoPath.endsWith("/chat/ChatController.kt") ||
|
|
repoPath.endsWith("/voice/MicCaptureManager.kt") ||
|
|
repoPath.endsWith("/voice/TalkModeManager.kt") ||
|
|
repoPath.endsWith("/node/SystemHandler.kt")
|
|
);
|
|
}
|
|
|
|
export function findUnlocalizedAndroidUiLiterals(
|
|
source: string,
|
|
repoPath: string,
|
|
): AndroidUiLiteralFinding[] {
|
|
if (!shouldScanUiLiterals(repoPath)) return [];
|
|
const findings = new Map<string, AndroidUiLiteralFinding>();
|
|
for (const pattern of DIRECT_UI_LITERAL_PATTERNS) {
|
|
pattern.lastIndex = 0;
|
|
for (const match of source.matchAll(pattern)) {
|
|
const literal = match[1];
|
|
if (!literal) continue;
|
|
const matchText = match[0] ?? "";
|
|
if (
|
|
matchText.includes("nativeStringResource(") ||
|
|
matchText.includes("nativeString(") ||
|
|
isAllowedUiLiteral(repoPath, decodeKotlinLiteral(literal)) ||
|
|
literal.startsWith("http") ||
|
|
literal.startsWith("content://")
|
|
) {
|
|
continue;
|
|
}
|
|
const offset = (match.index ?? 0) + matchText.lastIndexOf(`"${literal}"`);
|
|
const finding = {
|
|
line: lineNumber(source, Math.max(0, offset)),
|
|
path: repoPath,
|
|
source: decodeKotlinLiteral(literal),
|
|
};
|
|
findings.set(`${finding.line}\u0000${finding.source}`, finding);
|
|
}
|
|
}
|
|
return [...findings.values()].toSorted(
|
|
(left, right) => left.line - right.line || compareText(left.source, right.source),
|
|
);
|
|
}
|
|
|
|
function findInvalidResourceSyntax(strings: Map<string, ResourceString>): string[] {
|
|
return [...strings]
|
|
.filter(([, entry]) => {
|
|
const trimmed = entry.rawValue.trim();
|
|
const isQuoted = trimmed.startsWith('"') && trimmed.endsWith('"');
|
|
return !isQuoted && INVALID_APOSTROPHE_RE.test(trimmed);
|
|
})
|
|
.map(([key]) => key);
|
|
}
|
|
|
|
export function selectDeterministicTranslation(values: readonly string[]): string {
|
|
const counts = new Map<string, number>();
|
|
for (const value of values) {
|
|
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
}
|
|
return (
|
|
[...counts]
|
|
.toSorted(
|
|
([left, leftCount], [right, rightCount]) =>
|
|
rightCount - leftCount || compareText(left, right),
|
|
)
|
|
.at(0)?.[0] ?? ""
|
|
);
|
|
}
|
|
|
|
async function readInventory(): Promise<NativeInventoryEntry[]> {
|
|
const parsed = JSON.parse(await readFile(INVENTORY_PATH, "utf8")) as {
|
|
entries?: NativeInventoryEntry[];
|
|
};
|
|
return (parsed.entries ?? []).filter((entry) => entry.surface === "android");
|
|
}
|
|
|
|
async function readArtifacts(): Promise<Map<string, NativeArtifactEntry[]>> {
|
|
return new Map(
|
|
await Promise.all(
|
|
NATIVE_I18N_LOCALES.map(async (locale) => {
|
|
const parsed = JSON.parse(
|
|
await readFile(path.join(ARTIFACT_ROOT, `${locale}.json`), "utf8"),
|
|
) as { entries?: NativeArtifactEntry[] };
|
|
return [locale, parsed.entries ?? []] as const;
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
function renderStringsXml(
|
|
manual: readonly ResourceString[],
|
|
generated: ReadonlyMap<string, { source: string; value: string }>,
|
|
): string {
|
|
const lines = ["<resources>"];
|
|
for (const entry of manual) {
|
|
lines.push(` <string name="${entry.key}"${entry.attrs}>${entry.rawValue}</string>`);
|
|
}
|
|
if (generated.size > 0) {
|
|
lines.push(GENERATED_HEADER);
|
|
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;
|
|
lines.push(
|
|
` <string name="${key}"${formatted}>"${renderAndroidResourceValue(entry.source, entry.value)}"</string>`,
|
|
);
|
|
}
|
|
}
|
|
lines.push("</resources>", "");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function renderAssistantXml(items: readonly string[]): string {
|
|
return [
|
|
"<resources>",
|
|
' <string-array name="ask_openclaw_query_patterns">',
|
|
...items.map((item) => ` <item>"${renderAndroidResourceValue(item, item)}"</item>`),
|
|
" </string-array>",
|
|
"</resources>",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
function renderKotlin(sourceToKey: ReadonlyMap<string, string>): string {
|
|
const entries = [...sourceToKey].toSorted(([left], [right]) => compareText(left, right));
|
|
return [
|
|
GENERATED_KOTLIN_HEADER,
|
|
"package ai.openclaw.app.i18n",
|
|
"",
|
|
"import ai.openclaw.app.R",
|
|
"",
|
|
"internal val nativeStringResourceIds: Map<String, Int> =",
|
|
" mapOf(",
|
|
...entries.map(([source, key]) => ` "${escapeKotlin(source)}" to R.string.${key},`),
|
|
" )",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
async function buildCatalog(): Promise<GeneratedCatalog> {
|
|
const [inventory, artifacts, localeStrings, sourceFiles, toolDisplaySources] = await Promise.all([
|
|
readInventory(),
|
|
readArtifacts(),
|
|
Promise.all(LOCALES.map(readStrings)),
|
|
readAndroidSource(),
|
|
readToolDisplaySources(),
|
|
]);
|
|
const [baseStrings, ...translatedStrings] = localeStrings;
|
|
const manualBase = [...baseStrings.values()].filter(
|
|
(entry) => !entry.key.startsWith(MANAGED_PREFIX),
|
|
);
|
|
const manualSourceToKey = new Map(manualBase.map((entry) => [entry.value, entry.key]));
|
|
const entriesBySource = new Map<string, NativeInventoryEntry[]>();
|
|
for (const entry of inventory) {
|
|
const group = entriesBySource.get(entry.source) ?? [];
|
|
group.push(entry);
|
|
entriesBySource.set(entry.source, group);
|
|
}
|
|
for (const source of collectExplicitRuntimeSources(sourceFiles)) {
|
|
if (!entriesBySource.has(source)) entriesBySource.set(source, []);
|
|
}
|
|
for (const source of toolDisplaySources) {
|
|
if (!entriesBySource.has(source)) entriesBySource.set(source, []);
|
|
}
|
|
const sourceToKey = new Map<string, string>();
|
|
for (const source of entriesBySource.keys()) {
|
|
sourceToKey.set(source, manualSourceToKey.get(source) ?? resourceKey(source));
|
|
}
|
|
const resources = new Map<string, string>();
|
|
const contradictions: TranslationContradiction[] = [];
|
|
for (const [localeIndex, locale] of NATIVE_I18N_LOCALES.entries()) {
|
|
const manualTranslations = translatedStrings[localeIndex] ?? new Map();
|
|
const artifactTranslationsBySource = new Map<string, string[]>();
|
|
for (const entry of artifacts.get(locale) ?? []) {
|
|
const values = artifactTranslationsBySource.get(entry.source) ?? [];
|
|
values.push(entry.translated);
|
|
artifactTranslationsBySource.set(entry.source, values);
|
|
}
|
|
const generated = new Map<string, { source: string; value: string }>();
|
|
for (const source of entriesBySource.keys()) {
|
|
const key = sourceToKey.get(source);
|
|
if (!key || !key.startsWith(MANAGED_PREFIX)) continue;
|
|
const translations = artifactTranslationsBySource.get(source) ?? [];
|
|
const selected = selectDeterministicTranslation(translations);
|
|
const unique = [...new Set(translations)].toSorted(compareText);
|
|
if (unique.length > 1) {
|
|
contradictions.push({ locale, selected, source, translations: unique });
|
|
}
|
|
generated.set(key, {
|
|
source,
|
|
value: selected || source,
|
|
});
|
|
}
|
|
const manual = [...manualTranslations.values()].filter(
|
|
(entry) => !entry.key.startsWith(MANAGED_PREFIX),
|
|
);
|
|
resources.set(
|
|
path.join(RESOURCE_ROOT, localeDirectory(locale), "strings.xml"),
|
|
renderStringsXml(manual, generated),
|
|
);
|
|
}
|
|
const generatedBase = new Map<string, { source: string; value: string }>();
|
|
for (const [source, key] of sourceToKey) {
|
|
if (!key.startsWith(MANAGED_PREFIX)) continue;
|
|
generatedBase.set(key, {
|
|
source,
|
|
value: source,
|
|
});
|
|
}
|
|
resources.set(
|
|
path.join(RESOURCE_ROOT, "values", "strings.xml"),
|
|
renderStringsXml(manualBase, generatedBase),
|
|
);
|
|
|
|
const assistantSource = await readFile(
|
|
path.join(RESOURCE_ROOT, "values", "assistant.xml"),
|
|
"utf8",
|
|
);
|
|
const assistantItems = parseArrays(assistantSource).get("ask_openclaw_query_patterns") ?? [];
|
|
for (const [locale, artifactEntries] of artifacts) {
|
|
const translatedBySource = new Map<string, string[]>();
|
|
for (const entry of artifactEntries) {
|
|
const values = translatedBySource.get(entry.source) ?? [];
|
|
values.push(entry.translated);
|
|
translatedBySource.set(entry.source, values);
|
|
}
|
|
const translatedItems = assistantItems.map(
|
|
(source) => selectDeterministicTranslation(translatedBySource.get(source) ?? []) || source,
|
|
);
|
|
resources.set(
|
|
path.join(RESOURCE_ROOT, localeDirectory(locale), "assistant.xml"),
|
|
renderAssistantXml(translatedItems),
|
|
);
|
|
}
|
|
|
|
return {
|
|
contradictions,
|
|
kotlin: renderKotlin(sourceToKey),
|
|
resources,
|
|
sources: new Set(sourceToKey.keys()),
|
|
};
|
|
}
|
|
|
|
function formatProblems(problems: Array<readonly [string, string[]]>): string {
|
|
return [
|
|
"Android app i18n resources are out of sync.",
|
|
...problems.map(([label, keys]) => `${label}=${keys.join(",") || "none"}`),
|
|
].join("\n");
|
|
}
|
|
|
|
export async function syncAndroidAppI18n(options: { check?: boolean } = {}) {
|
|
const catalog = await buildCatalog();
|
|
const drift: string[] = [];
|
|
for (const [filePath, expected] of catalog.resources) {
|
|
const current = await readFile(filePath, "utf8").catch(() => "");
|
|
if (current === expected) continue;
|
|
drift.push(path.relative(ROOT, filePath).split(path.sep).join("/"));
|
|
if (!options.check) {
|
|
await writeFile(filePath, expected);
|
|
}
|
|
}
|
|
const currentKotlin = await readFile(GENERATED_KOTLIN_PATH, "utf8").catch(() => "");
|
|
if (currentKotlin !== catalog.kotlin) {
|
|
drift.push(path.relative(ROOT, GENERATED_KOTLIN_PATH).split(path.sep).join("/"));
|
|
if (!options.check) {
|
|
await writeFile(GENERATED_KOTLIN_PATH, catalog.kotlin);
|
|
}
|
|
}
|
|
if (options.check && drift.length > 0) {
|
|
throw new Error(`Android generated localization drift:\n${drift.join("\n")}`);
|
|
}
|
|
if (catalog.contradictions.length > 0) {
|
|
const limit = 20;
|
|
const visible = catalog.contradictions.slice(0, limit);
|
|
const remaining = catalog.contradictions.length - visible.length;
|
|
process.stderr.write(
|
|
[
|
|
`android-app-i18n: contradictions=${catalog.contradictions.length}`,
|
|
...visible.map(
|
|
(finding) =>
|
|
`${finding.locale}: ${JSON.stringify(finding.source)} -> ${JSON.stringify(finding.selected)} (${finding.translations.map(JSON.stringify).join(", ")})`,
|
|
),
|
|
...(remaining > 0 ? [`android-app-i18n: ${remaining} more contradictions omitted`] : []),
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
return catalog;
|
|
}
|
|
|
|
export async function checkAndroidAppI18n() {
|
|
const [sourceFiles, localeStrings] = await Promise.all([
|
|
readAndroidSource(),
|
|
Promise.all(LOCALES.map(readStrings)),
|
|
]);
|
|
await syncAndroidAppI18n({ check: true });
|
|
const base = expectDefined(localeStrings[0], "English Android string resources");
|
|
const translations = localeStrings.slice(1);
|
|
const baseKeys = new Set(base.keys());
|
|
const problems: Array<readonly [string, string[]]> = translations.flatMap((strings, index) => {
|
|
const locale = NATIVE_I18N_LOCALES[index];
|
|
const keys = new Set(strings.keys());
|
|
const placeholderMismatches = [...base].flatMap(([key, sourceEntry]) => {
|
|
const translatedEntry = strings.get(key);
|
|
if (!translatedEntry) return [];
|
|
const expected = [...sourceEntry.rawValue.matchAll(FORMAT_RE)]
|
|
.map((match) => match[0])
|
|
.toSorted();
|
|
const actual = [...translatedEntry.rawValue.matchAll(FORMAT_RE)]
|
|
.map((match) => match[0])
|
|
.toSorted();
|
|
return expected.join("\u0000") === actual.join("\u0000") ? [] : [key];
|
|
});
|
|
return [
|
|
[`${locale} missing`, [...baseKeys].filter((key) => !keys.has(key))],
|
|
[`${locale} extra`, [...keys].filter((key) => !baseKeys.has(key))],
|
|
[`${locale} placeholders`, placeholderMismatches],
|
|
[`${locale} syntax`, findInvalidResourceSyntax(strings)],
|
|
] as const;
|
|
});
|
|
problems.push(["English syntax", findInvalidResourceSyntax(base)]);
|
|
const allSource = sourceFiles.map((file) => file.source).join("\n");
|
|
const manualBaseKeys = [...baseKeys].filter((key) => !key.startsWith(MANAGED_PREFIX));
|
|
problems.push([
|
|
"English unused",
|
|
manualBaseKeys.filter(
|
|
(key) => !allSource.includes(`R.string.${key}`) && !allSource.includes(`@string/${key}`),
|
|
),
|
|
]);
|
|
const uiFindings = sourceFiles.flatMap((file) =>
|
|
findUnlocalizedAndroidUiLiterals(file.source, file.path),
|
|
);
|
|
problems.push([
|
|
"Unlocalized UI literals",
|
|
uiFindings.map((finding) => `${finding.path}:${finding.line}:${finding.source}`),
|
|
]);
|
|
if (problems.some(([, keys]) => keys.length)) {
|
|
throw new Error(formatProblems(problems));
|
|
}
|
|
process.stdout.write(
|
|
`android-app-i18n: keys=${baseKeys.size} locales=${NATIVE_I18N_LOCALES.join(",")}\n`,
|
|
);
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === `file://${path.resolve(process.argv[1])}`) {
|
|
const [command] = process.argv.slice(2);
|
|
if (command === "sync") {
|
|
await syncAndroidAppI18n();
|
|
} else if (command === "check") {
|
|
await checkAndroidAppI18n();
|
|
} else {
|
|
throw new Error("usage: node --import tsx scripts/android-app-i18n.ts <sync|check>");
|
|
}
|
|
}
|