mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
The generated native inventory stored one line per string with every
extraction site inline, sorted alphabetically. Two PRs reusing a string
("Retry") both rewrote its line, and a feature's new strings ("Group by",
"Group chat") landed in the same insertion gap, so parallel native PRs
conflicted in apps/.i18n/native-source.json and needed a rebase plus a
full CI rerun.
Inventory v3 writes one line per extraction site, clustered by source
file, with clusters ordered by a hash of the path so new sibling files do
not share a gap. Each row's position depends only on its own content, so
independent edits merge cleanly (including GitHub's server-side merge)
and the merged text equals a fresh baseline. The new
scripts/native-i18n-inventory.ts owns serialization and parsing; the
generator, Android/Gradle and Apple readers migrate to it together and
receive the same entries in the same order as before.
1512 lines
52 KiB
TypeScript
1512 lines
52 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { expectDefined } from "../packages/normalization-core/src/expect.ts";
|
|
import { compareAscii as compareText } from "./lib/canonical-json.mjs";
|
|
import { isDirectRunUrl } from "./lib/direct-run.mjs";
|
|
import {
|
|
type NativeI18nInventoryEntry,
|
|
parseNativeI18nInventory,
|
|
} from "./native-i18n-inventory.ts";
|
|
import { NATIVE_I18N_LOCALES } from "./native-i18n-locales.ts";
|
|
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = path.resolve(HERE, "..");
|
|
const ANDROID_MAIN_ROOT = path.join(ROOT, "apps", "android", "app", "src", "main");
|
|
const RESOURCE_ROOT = path.join(ANDROID_MAIN_ROOT, "res");
|
|
const SOURCE_ROOT = path.join(ANDROID_MAIN_ROOT, "java");
|
|
const ANDROID_PLAY_SOURCE_ROOT = path.join(ROOT, "apps", "android", "app", "src", "play", "java");
|
|
const ANDROID_THIRD_PARTY_ROOT = path.join(ROOT, "apps", "android", "app", "src", "thirdParty");
|
|
const ANDROID_THIRD_PARTY_RESOURCE_ROOT = path.join(ANDROID_THIRD_PARTY_ROOT, "res");
|
|
const ANDROID_THIRD_PARTY_SOURCE_ROOT = path.join(ANDROID_THIRD_PARTY_ROOT, "java");
|
|
const ANDROID_WEAR_MAIN_ROOT = path.join(ROOT, "apps", "android", "wear", "src", "main");
|
|
const WEAR_RESOURCE_ROOT = path.join(ANDROID_WEAR_MAIN_ROOT, "res");
|
|
const WEAR_SOURCE_ROOT = path.join(ANDROID_WEAR_MAIN_ROOT, "java");
|
|
const ANDROID_SOURCE_ROOTS = [
|
|
SOURCE_ROOT,
|
|
ANDROID_PLAY_SOURCE_ROOT,
|
|
ANDROID_THIRD_PARTY_SOURCE_ROOT,
|
|
WEAR_SOURCE_ROOT,
|
|
] as const;
|
|
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_ROOT = path.join(
|
|
ROOT,
|
|
"apps",
|
|
"android",
|
|
"app",
|
|
"build",
|
|
"generated",
|
|
"native-i18n",
|
|
);
|
|
const GENERATED_KOTLIN_PATH = path.join(
|
|
GENERATED_ROOT,
|
|
"kotlin",
|
|
"ai",
|
|
"openclaw",
|
|
"app",
|
|
"i18n",
|
|
"NativeStringResources.kt",
|
|
);
|
|
const GENERATED_RESOURCE_ROOT = path.join(GENERATED_ROOT, "res");
|
|
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 ANDROID_REFERENCE_COMMENT_RE =
|
|
/("""[\s\S]*?"""|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|<!--[\s\S]*?-->|\/\*[\s\S]*?\*\/|\/\/[^\r\n]*/gu;
|
|
const XML_COMMENT_RE = /<!--[\s\S]*?-->/gu;
|
|
const FORMAT_RE = /%\d+\$[a-z]/giu;
|
|
const INVALID_APOSTROPHE_RE = /(?:'|(?<!\\)')/u;
|
|
const GENERATED_HEADER = " <!-- Generated by scripts/android-app-i18n.ts. -->";
|
|
const GENERATED_KOTLIN_HEADER = "// Generated by scripts/android-app-i18n.ts. Do not edit.";
|
|
const THIRD_PARTY_STRINGS_FILE = "accessibility_strings.xml";
|
|
const THIRD_PARTY_STRINGS_REPO_PATH =
|
|
"apps/android/app/src/thirdParty/res/values/accessibility_strings.xml";
|
|
const THIRD_PARTY_GENERATED_RESOURCE_RE =
|
|
/^apps\/android\/app\/src\/thirdParty\/res\/values-[^/]+\/accessibility_strings\.xml$/;
|
|
const WEAR_STRINGS_REPO_PATH = "apps/android/wear/src/main/res/values/strings.xml";
|
|
const WEAR_GENERATED_RESOURCE_RE =
|
|
/^apps\/android\/wear\/src\/main\/res\/values-[^/]+\/strings\.xml$/;
|
|
|
|
type NativeTranslations = Record<string, string>;
|
|
|
|
type ResourceString = {
|
|
attrs: string;
|
|
key: string;
|
|
rawValue: string;
|
|
value: string;
|
|
};
|
|
|
|
const GENERATED_TRANSLATION_LINT_IGNORES = [
|
|
"Typos",
|
|
"TypographyDashes",
|
|
"TypographyEllipsis",
|
|
] as const;
|
|
|
|
type TranslationContradiction = {
|
|
locale: string;
|
|
selected: string;
|
|
source: string;
|
|
translations: string[];
|
|
};
|
|
|
|
export type GeneratedCatalog = {
|
|
contradictions: TranslationContradiction[];
|
|
kotlin: string;
|
|
resources: Map<string, string>;
|
|
sources: Set<string>;
|
|
};
|
|
|
|
export type AndroidUiLiteralFinding = {
|
|
line: number;
|
|
path: string;
|
|
source: string;
|
|
};
|
|
|
|
function decodeXml(value: string): string {
|
|
return value
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll(""", '"')
|
|
.replaceAll("&", "&")
|
|
.replaceAll("\\n", "\n")
|
|
.replaceAll("\\'", "'")
|
|
.replaceAll('\\"', '"')
|
|
.replaceAll("\\\\", "\\");
|
|
}
|
|
|
|
export function decodeAndroidResourceValue(rawValue: string): string {
|
|
const trimmed = rawValue.trim();
|
|
const unquoted =
|
|
trimmed.startsWith('"') && trimmed.endsWith('"') ? trimmed.slice(1, -1) : trimmed;
|
|
return decodeXml(unquoted);
|
|
}
|
|
|
|
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 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) {
|
|
counts.set(token, (counts.get(token) ?? 0) + 1);
|
|
}
|
|
return [...counts].toSorted(([left], [right]) => compareText(left, right));
|
|
};
|
|
if (JSON.stringify(tokenCounts(sourceTokens)) !== JSON.stringify(tokenCounts(translatedTokens))) {
|
|
throw new Error(
|
|
`Android translation changed interpolation placeholders: ${JSON.stringify(source)} -> ${JSON.stringify(translated)}`,
|
|
);
|
|
}
|
|
if (sourceTokens.length > 0) {
|
|
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>();
|
|
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];
|
|
rendered += index ? `%${index}$s` : token;
|
|
cursor = interpolation.end;
|
|
}
|
|
rendered += translated.slice(cursor).replaceAll("%", "%%");
|
|
}
|
|
return escapeAndroidResourceValue(rendered);
|
|
}
|
|
|
|
export function escapeAndroidResourceValue(value: string): string {
|
|
return value
|
|
.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 withGeneratedTranslationLintIgnores(attrs: string): string {
|
|
const existing = attrs.match(/\btools:ignore\s*=\s*"([^"]*)"/u);
|
|
const ignores = [
|
|
...(existing?.[1]
|
|
?.split(",")
|
|
.map((value) => value.trim())
|
|
.filter(Boolean) ?? []),
|
|
...GENERATED_TRANSLATION_LINT_IGNORES,
|
|
];
|
|
const rendered = `tools:ignore="${[...new Set(ignores)].join(",")}"`;
|
|
return existing ? attrs.replace(existing[0], rendered) : `${attrs} ${rendered}`;
|
|
}
|
|
|
|
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,
|
|
resourceRoot = RESOURCE_ROOT,
|
|
fileName = "strings.xml",
|
|
): Promise<Map<string, ResourceString>> {
|
|
const source = await readFile(path.join(resourceRoot, locale, fileName), "utf8");
|
|
return new Map(parseStrings(source).map((entry) => [entry.key, entry]));
|
|
}
|
|
|
|
async function readAndroidSource(
|
|
root = SOURCE_ROOT,
|
|
extensionPattern = /\.kt$/u,
|
|
): 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, extensionPattern)));
|
|
continue;
|
|
}
|
|
if (entry.isFile() && extensionPattern.test(entry.name)) {
|
|
sources.push({
|
|
path: path.relative(ROOT, fullPath).split(path.sep).join("/"),
|
|
source: await readFile(fullPath, "utf8"),
|
|
});
|
|
}
|
|
}
|
|
return sources;
|
|
}
|
|
|
|
async function readAllAndroidSource(): Promise<Array<{ path: string; source: string }>> {
|
|
return (await Promise.all(ANDROID_SOURCE_ROOTS.map((root) => readAndroidSource(root)))).flat();
|
|
}
|
|
|
|
type AndroidResourceReferenceSource = {
|
|
path: string;
|
|
source: string;
|
|
};
|
|
|
|
function readAndroidResourceReferences(
|
|
root = ANDROID_MAIN_ROOT,
|
|
): Promise<AndroidResourceReferenceSource[]> {
|
|
// Source-set roots exclude Gradle build output.
|
|
return readAndroidSource(root, /\.(?:kt|kts|xml)$/u);
|
|
}
|
|
|
|
export function findUnusedAndroidResourceKeys(
|
|
keys: Iterable<string>,
|
|
referenceSources: readonly AndroidResourceReferenceSource[],
|
|
): string[] {
|
|
const references = new Set<string>();
|
|
for (const reference of referenceSources) {
|
|
const isXml = reference.path.endsWith(".xml");
|
|
const source = reference.source.replace(
|
|
isXml ? XML_COMMENT_RE : ANDROID_REFERENCE_COMMENT_RE,
|
|
(match) => match.replace(/[^\r\n]/gu, " "),
|
|
);
|
|
const pattern = isXml ? /@string\/([A-Za-z0-9_]+)\b/gu : /\bR\.string\.([A-Za-z0-9_]+)\b/gu;
|
|
for (const match of source.matchAll(pattern)) {
|
|
if (match[1]) {
|
|
references.add(match[1]);
|
|
}
|
|
}
|
|
}
|
|
return [...keys].filter((key) => !references.has(key));
|
|
}
|
|
|
|
export function lineNumber(source: string, offset: number): number {
|
|
return source.slice(0, offset).split("\n").length;
|
|
}
|
|
|
|
function decodeKotlinLiteral(value: string): string {
|
|
return value.replace(
|
|
/\\(?:u([0-9a-fA-F]{4})|[n"$\\])/gu,
|
|
(escape, codeUnit: string | undefined) => {
|
|
if (codeUnit) {
|
|
return String.fromCharCode(Number.parseInt(codeUnit, 16));
|
|
}
|
|
return escape === "\\n" ? "\n" : escape.slice(1);
|
|
},
|
|
);
|
|
}
|
|
|
|
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) {
|
|
for (const match of file.source.matchAll(callPattern)) {
|
|
if (match[1]) {
|
|
sources.add(decodeKotlinLiteral(match[1]));
|
|
}
|
|
}
|
|
}
|
|
return sources;
|
|
}
|
|
|
|
export 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|onClickLabel)\s*=\s*"((?:\\.|[^"\\])+)"/gu,
|
|
/\b(?:title|subtitle|body|text|label|statusText|confirmLabel|dismissLabel|contentDescription|placeholder|onClickLabel)\s*=\s*[^,\n]*\?:\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,
|
|
/\bnativeString(?:Resource)?\([^)]*\)\s*\+\s*"((?:\\.|[^"\\])+)"/gsu,
|
|
] as const;
|
|
const DIRECT_UI_RAW_LITERAL_PATTERNS = [
|
|
/\bText\s*\(\s*(?:text\s*=\s*)?"""([\s\S]*?)"""/gu,
|
|
/\b(?:ClawPrimaryButton|ClawSecondaryButton|ClawDangerButton|ClawLinkButton)\s*\([^)]*?\btext\s*=\s*"""([\s\S]*?)"""/gsu,
|
|
/\b(?:title|subtitle|body|text|label|statusText|confirmLabel|dismissLabel|contentDescription|placeholder|onClickLabel)\s*=\s*"""([\s\S]*?)"""/gu,
|
|
/\b(?:title|subtitle|body|text|label|statusText|confirmLabel|dismissLabel|contentDescription|placeholder|onClickLabel)\s*=\s*[^,\n]*\?:\s*"""([\s\S]*?)"""/gu,
|
|
/\b(?:_[A-Za-z0-9_]*(?:ErrorText|StatusText)|errorText|statusText)(?:\.value)?\s*=\s*"""([\s\S]*?)"""/gu,
|
|
/\bSettingsMetric\s*\(\s*"""([\s\S]*?)"""/gu,
|
|
/\bToast\.makeText\s*\([^,]+,\s*"""([\s\S]*?)"""/gu,
|
|
/\bset(?:Title|Message|PositiveButton|NegativeButton|NeutralButton)\s*\(\s*"""([\s\S]*?)"""/gu,
|
|
/\bnativeString(?:Resource)?\([^)]*\)\s*\+\s*"""([\s\S]*?)"""/gsu,
|
|
] as const;
|
|
const UI_STRING_HELPER_NAME_RE =
|
|
/(?:description|detail|error|label|message|nextRun|notice|report|status|subtitle|summary|text|title)$/iu;
|
|
const UI_STRING_FUNCTION_RE = /\bfun\s+(?:<[^>{}]*>\s*)?([A-Za-z_][A-Za-z0-9_]*)\s*/gu;
|
|
const UI_STRING_PROPERTY_RE =
|
|
/\bval\s+(?:[A-Za-z_][A-Za-z0-9_]*\.)?([A-Za-z_][A-Za-z0-9_]*)\s*:\s*String\s*(?:\n\s*)?get\(\)\s*=\s*/gu;
|
|
const UI_BRANCH_LITERAL_RE = /(?:->|return|\?:)\s*"((?:\\.|[^"\\])+)"/gu;
|
|
const UI_ELSE_BRANCH_LITERAL_RE = /\belse\s*\{?\s*"((?:\\.|[^"\\])+)"/gu;
|
|
const UI_BRANCH_RAW_LITERAL_RE = /(?:->|return|\?:)\s*"""([\s\S]*?)"""/gu;
|
|
const UI_ELSE_BRANCH_RAW_LITERAL_RE = /\belse\s*\{?\s*"""([\s\S]*?)"""/gu;
|
|
const UI_MODEL_STRING_NAME_RE =
|
|
/^(?:contentDescription|errorText|helperText|onClickLabel|statusText)$/u;
|
|
const UI_MODEL_CLASS_FIELDS = new Map<string, ReadonlySet<string>>([
|
|
["CommandItem", new Set(["subtitle", "title"])],
|
|
["HomeAttentionRow", new Set(["subtitle", "title"])],
|
|
["OverviewMetricCardSpec", new Set(["subtitle", "title", "value"])],
|
|
["SettingsToggleRow", new Set(["subtitle", "title"])],
|
|
]);
|
|
const KOTLIN_STRING_LITERAL_RE = /"""([\s\S]*?)"""|"((?:\\.|[^"\\])+?)"/gu;
|
|
|
|
// 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",
|
|
"Google Chat",
|
|
"ID",
|
|
"ISO time, e.g. 2026-07-09T09:30:00Z",
|
|
"LOG",
|
|
"O",
|
|
"OC",
|
|
"OK",
|
|
"OPENCLAW",
|
|
"OpenClaw",
|
|
"U",
|
|
"e.g. America/New_York",
|
|
"current-step-alpha",
|
|
"gateway-progress",
|
|
"iMessage",
|
|
"main, isolated, current, or session:<id>",
|
|
"n/a",
|
|
"openclaw gateway",
|
|
"openclaw qr",
|
|
"PTT_BUSY: previous push-to-talk turn is still finishing",
|
|
"WhatsApp",
|
|
]),
|
|
],
|
|
["apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", new Set(["Off"])],
|
|
[
|
|
"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt",
|
|
new Set(["all", "applied", "held", "pending", "rejected"]),
|
|
],
|
|
[
|
|
"apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt",
|
|
new Set(["$versionName-dev"]),
|
|
],
|
|
[
|
|
"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
|
// Plan checklist chrome: numeric done-counter and checkmark glyph.
|
|
new Set(["$completedCount/${steps.size}", "✓"]),
|
|
],
|
|
[
|
|
"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt",
|
|
// Discovered-gateway subtitles are host:port endpoints, not translatable copy.
|
|
new Set(["${endpoint.host}:${endpoint.port}"]),
|
|
],
|
|
[
|
|
"apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarShell.kt",
|
|
// Compose animation labels are tooling identifiers, not rendered copy.
|
|
new Set(["sidebar-content-translation"]),
|
|
],
|
|
[
|
|
"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatCommandControls.kt",
|
|
new Set(["/$name", "help"]),
|
|
],
|
|
[
|
|
"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt",
|
|
new Set([">", "> $line"]),
|
|
],
|
|
[
|
|
"apps/android/wear/src/main/java/ai/openclaw/wear/WearScreens.kt",
|
|
// Compose animation labels are tooling identifiers, not rendered copy.
|
|
new Set(["voice-swipe-hint", "voice-swipe-hint-offset"]),
|
|
],
|
|
]);
|
|
|
|
function isAllowedUiLiteral(repoPath: string, source: string): boolean {
|
|
return (
|
|
source.trim().length === 0 ||
|
|
ALLOWED_UI_LITERALS.get("*")?.has(source) === true ||
|
|
ALLOWED_UI_LITERALS.get(repoPath)?.has(source) === true
|
|
);
|
|
}
|
|
|
|
function shouldScanUiLiterals(repoPath: string): boolean {
|
|
if (
|
|
repoPath.endsWith("/AndroidScreenshotFixture.kt") ||
|
|
repoPath.endsWith("/WearScreenshotMode.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("/accessibility/AccessibilityDevActivity.kt") ||
|
|
repoPath.includes("/wear/src/main/java/ai/openclaw/wear/") ||
|
|
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")
|
|
);
|
|
}
|
|
|
|
function createDoubleQuoteScanner() {
|
|
let quoted = false;
|
|
let escaped = false;
|
|
return (character: string | undefined): boolean => {
|
|
if (escaped) {
|
|
escaped = false;
|
|
return true;
|
|
}
|
|
if (quoted && character === "\\") {
|
|
escaped = true;
|
|
return true;
|
|
}
|
|
if (character === '"') {
|
|
quoted = !quoted;
|
|
return true;
|
|
}
|
|
return quoted;
|
|
};
|
|
}
|
|
|
|
export function findClosingDelimiter(
|
|
source: string,
|
|
openingOffset: number,
|
|
opening: string,
|
|
closing: string,
|
|
): number | null {
|
|
let depth = 0;
|
|
const skipQuoted = createDoubleQuoteScanner();
|
|
for (let index = openingOffset; index < source.length; index += 1) {
|
|
const character = source[index];
|
|
if (skipQuoted(character)) {
|
|
continue;
|
|
}
|
|
if (character === opening) {
|
|
depth += 1;
|
|
} else if (character === closing) {
|
|
depth -= 1;
|
|
if (depth === 0) {
|
|
return index;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function expressionEnd(source: string, expressionStart: number): number {
|
|
const cursor = source.slice(expressionStart).search(/\S/u);
|
|
if (cursor < 0) {
|
|
return source.length;
|
|
}
|
|
const start = expressionStart + cursor;
|
|
const skipQuoted = createDoubleQuoteScanner();
|
|
let lineStart = start;
|
|
const depths = { "(": 0, "[": 0, "{": 0 };
|
|
const closingToOpening = { ")": "(", "]": "[", "}": "{" } as const;
|
|
for (let index = start; index < source.length; index += 1) {
|
|
const character = source[index];
|
|
if (skipQuoted(character)) {
|
|
continue;
|
|
}
|
|
if (character === "(" || character === "[" || character === "{") {
|
|
depths[character] += 1;
|
|
continue;
|
|
}
|
|
if (character === ")" || character === "]" || character === "}") {
|
|
depths[closingToOpening[character]] -= 1;
|
|
continue;
|
|
}
|
|
if (character !== "\n" || !Object.values(depths).every((depth) => depth === 0)) {
|
|
continue;
|
|
}
|
|
const line = source.slice(lineStart, index).trimEnd();
|
|
const nextLine =
|
|
source
|
|
.slice(index + 1)
|
|
.match(/^[^\S\n]*([^\n]*)/u)?.[1]
|
|
?.trimStart() ?? "";
|
|
const continues =
|
|
line.length === 0 ||
|
|
/(?:\?:|[+*/%&|=.,([{])$/u.test(line) ||
|
|
/^(?:else\b|\?:|[+*/%&|.])/u.test(nextLine);
|
|
if (!continues) {
|
|
return index;
|
|
}
|
|
lineStart = index + 1;
|
|
}
|
|
return source.length;
|
|
}
|
|
|
|
function splitTopLevelSegments(
|
|
source: string,
|
|
start: number,
|
|
end: number,
|
|
options: { trackTypeArguments?: boolean } = {},
|
|
): Array<{ end: number; start: number; value: string }> {
|
|
const segments: Array<{ end: number; start: number; value: string }> = [];
|
|
let segmentStart = start;
|
|
const skipQuoted = createDoubleQuoteScanner();
|
|
let typeArgumentDepth = 0;
|
|
let inDefaultValue = false;
|
|
const depths = { "(": 0, "[": 0, "{": 0 };
|
|
const closingToOpening = { ")": "(", "]": "[", "}": "{" } as const;
|
|
for (let index = start; index < end; index += 1) {
|
|
const character = source[index];
|
|
if (skipQuoted(character)) {
|
|
continue;
|
|
}
|
|
if (options.trackTypeArguments && !inDefaultValue && character === "<") {
|
|
typeArgumentDepth += 1;
|
|
continue;
|
|
}
|
|
if (options.trackTypeArguments && character === ">" && typeArgumentDepth > 0) {
|
|
typeArgumentDepth -= 1;
|
|
continue;
|
|
}
|
|
if (character === "(" || character === "[" || character === "{") {
|
|
depths[character] += 1;
|
|
continue;
|
|
}
|
|
if (character === ")" || character === "]" || character === "}") {
|
|
depths[closingToOpening[character]] -= 1;
|
|
continue;
|
|
}
|
|
if (
|
|
options.trackTypeArguments &&
|
|
character === "=" &&
|
|
typeArgumentDepth === 0 &&
|
|
Object.values(depths).every((depth) => depth === 0)
|
|
) {
|
|
inDefaultValue = true;
|
|
continue;
|
|
}
|
|
if (
|
|
character === "," &&
|
|
typeArgumentDepth === 0 &&
|
|
Object.values(depths).every((depth) => depth === 0)
|
|
) {
|
|
segments.push({ end: index, start: segmentStart, value: source.slice(segmentStart, index) });
|
|
segmentStart = index + 1;
|
|
inDefaultValue = false;
|
|
}
|
|
}
|
|
segments.push({ end, start: segmentStart, value: source.slice(segmentStart, end) });
|
|
return segments;
|
|
}
|
|
|
|
function isLocalizedLiteral(expression: string, literalOffset: number): boolean {
|
|
return /\bnativeString(?:Resource)?\(\s*$/u.test(expression.slice(0, literalOffset));
|
|
}
|
|
|
|
function isComparisonLiteral(expression: string, literalOffset: number): boolean {
|
|
return /(?:==|!=)\s*$/u.test(expression.slice(0, literalOffset));
|
|
}
|
|
|
|
function collectHelperLiteralFindings(source: string, repoPath: string): AndroidUiLiteralFinding[] {
|
|
const findings: AndroidUiLiteralFinding[] = [];
|
|
const collectRange = (name: string, start: number, end: number) => {
|
|
if (!UI_STRING_HELPER_NAME_RE.test(name)) {
|
|
return;
|
|
}
|
|
const body = source.slice(start, end);
|
|
const direct = body.match(/^\s*"((?:\\.|[^"\\])+)"/u);
|
|
const directRaw = body.match(/^\s*"""([\s\S]*?)"""/u);
|
|
const matches = [
|
|
...(direct ? [direct] : []),
|
|
...(directRaw ? [directRaw] : []),
|
|
...body.matchAll(UI_BRANCH_LITERAL_RE),
|
|
...body.matchAll(UI_ELSE_BRANCH_LITERAL_RE),
|
|
...body.matchAll(UI_BRANCH_RAW_LITERAL_RE),
|
|
...body.matchAll(UI_ELSE_BRANCH_RAW_LITERAL_RE),
|
|
];
|
|
const seenOffsets = new Set<number>();
|
|
const addLiteral = (literal: string, literalOffset: number) => {
|
|
if (seenOffsets.has(literalOffset)) {
|
|
return;
|
|
}
|
|
seenOffsets.add(literalOffset);
|
|
findings.push({
|
|
line: lineNumber(source, start + literalOffset),
|
|
path: repoPath,
|
|
source: decodeKotlinLiteral(literal),
|
|
});
|
|
};
|
|
for (const match of matches) {
|
|
const literal = match[1];
|
|
if (!literal) {
|
|
continue;
|
|
}
|
|
const delimiter = match[0]?.includes('"""') ? '"""' : '"';
|
|
const literalOffset = body.indexOf(`${delimiter}${literal}${delimiter}`, match.index ?? 0);
|
|
addLiteral(literal, Math.max(0, literalOffset));
|
|
}
|
|
for (const match of body.matchAll(/\bif\s*/gu)) {
|
|
let cursor = (match.index ?? 0) + match[0].length;
|
|
if (body[cursor] !== "(") {
|
|
continue;
|
|
}
|
|
const closingParen = findClosingDelimiter(body, cursor, "(", ")");
|
|
if (closingParen === null) {
|
|
continue;
|
|
}
|
|
cursor = closingParen + 1;
|
|
while (/\s/u.test(body[cursor] ?? "")) {
|
|
cursor += 1;
|
|
}
|
|
if (body[cursor] === "{") {
|
|
cursor += 1;
|
|
while (/\s/u.test(body[cursor] ?? "")) {
|
|
cursor += 1;
|
|
}
|
|
}
|
|
const expression = body.slice(cursor);
|
|
const literal =
|
|
expression.match(/^"""([\s\S]*?)"""/u)?.[1] ??
|
|
expression.match(/^"((?:\\.|[^"\\])+)"/u)?.[1];
|
|
if (literal) {
|
|
addLiteral(literal, cursor);
|
|
}
|
|
}
|
|
};
|
|
for (const match of source.matchAll(UI_STRING_FUNCTION_RE)) {
|
|
const name = match[1];
|
|
if (!name) {
|
|
continue;
|
|
}
|
|
let openingParen = (match.index ?? 0) + match[0].length;
|
|
while (/\s/u.test(source[openingParen] ?? "")) {
|
|
openingParen += 1;
|
|
}
|
|
if (source[openingParen] !== "(") {
|
|
continue;
|
|
}
|
|
const closingParen = findClosingDelimiter(source, openingParen, "(", ")");
|
|
if (closingParen === null) {
|
|
continue;
|
|
}
|
|
const returnType = source.slice(closingParen + 1).match(/^\s*:\s*String\s*(=|\{)/u);
|
|
const bodyKind = returnType?.[1];
|
|
if (!bodyKind || returnType?.index === undefined) {
|
|
continue;
|
|
}
|
|
const bodyStart = closingParen + 1 + returnType.index + returnType[0].length;
|
|
if (bodyKind === "{") {
|
|
const openingBrace = bodyStart - 1;
|
|
const closingBrace = findClosingDelimiter(source, openingBrace, "{", "}");
|
|
if (closingBrace !== null) {
|
|
collectRange(name, bodyStart, closingBrace);
|
|
}
|
|
} else {
|
|
collectRange(name, bodyStart, expressionEnd(source, bodyStart));
|
|
}
|
|
}
|
|
for (const match of source.matchAll(UI_STRING_PROPERTY_RE)) {
|
|
const name = match[1];
|
|
if (!name) {
|
|
continue;
|
|
}
|
|
const bodyStart = (match.index ?? 0) + match[0].length;
|
|
collectRange(name, bodyStart, expressionEnd(source, bodyStart));
|
|
}
|
|
return findings;
|
|
}
|
|
|
|
function collectTypedModelLiteralFindings(
|
|
source: string,
|
|
repoPath: string,
|
|
): AndroidUiLiteralFinding[] {
|
|
const findings: AndroidUiLiteralFinding[] = [];
|
|
const classPattern = /\b(?:data\s+class|class)\s+([A-Za-z_][A-Za-z0-9_]*)\s*/gu;
|
|
for (const declaration of source.matchAll(classPattern)) {
|
|
const className = declaration[1];
|
|
if (!className) {
|
|
continue;
|
|
}
|
|
const openingParen = findParameterOpening(
|
|
source,
|
|
(declaration.index ?? 0) + declaration[0].length,
|
|
);
|
|
if (openingParen === null) {
|
|
continue;
|
|
}
|
|
const closingParen = findClosingDelimiter(source, openingParen, "(", ")");
|
|
if (closingParen === null) {
|
|
continue;
|
|
}
|
|
const parameters = splitTopLevelSegments(source, openingParen + 1, closingParen, {
|
|
trackTypeArguments: true,
|
|
});
|
|
const userFacingParameters = new Map<string, number>();
|
|
const classFields = UI_MODEL_CLASS_FIELDS.get(className);
|
|
parameters.forEach((parameter, index) => {
|
|
const field = parameter.value.match(
|
|
/\bval\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*String\??(?=\s*(?:=|$))/u,
|
|
)?.[1];
|
|
if (field && (UI_MODEL_STRING_NAME_RE.test(field) || classFields?.has(field))) {
|
|
userFacingParameters.set(field, index);
|
|
}
|
|
});
|
|
if (userFacingParameters.size === 0) {
|
|
continue;
|
|
}
|
|
const callPattern = new RegExp(`\\b${className}\\b`, "gu");
|
|
for (const call of source.matchAll(callPattern)) {
|
|
const callOpening = findParameterOpening(source, (call.index ?? 0) + call[0].length);
|
|
if (callOpening === null || callOpening === openingParen) {
|
|
continue;
|
|
}
|
|
const callClosing = findClosingDelimiter(source, callOpening, "(", ")");
|
|
if (callClosing === null) {
|
|
continue;
|
|
}
|
|
const argumentsList = splitTopLevelSegments(source, callOpening + 1, callClosing);
|
|
const namedArguments = new Map<string, (typeof argumentsList)[number]>();
|
|
let positionalArgumentCount = argumentsList.length;
|
|
argumentsList.forEach((argument, index) => {
|
|
const name = argument.value.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(?!=)/u)?.[1];
|
|
if (!name) {
|
|
return;
|
|
}
|
|
namedArguments.set(name, argument);
|
|
positionalArgumentCount = Math.min(positionalArgumentCount, index);
|
|
});
|
|
for (const [field, parameterIndex] of userFacingParameters) {
|
|
const argument =
|
|
namedArguments.get(field) ??
|
|
(parameterIndex < positionalArgumentCount ? argumentsList[parameterIndex] : undefined);
|
|
if (!argument) {
|
|
continue;
|
|
}
|
|
for (const literal of argument.value.matchAll(KOTLIN_STRING_LITERAL_RE)) {
|
|
const literalValue = literal[1] ?? literal[2];
|
|
if (
|
|
!literalValue ||
|
|
isLocalizedLiteral(argument.value, literal.index ?? 0) ||
|
|
isComparisonLiteral(argument.value, literal.index ?? 0)
|
|
) {
|
|
continue;
|
|
}
|
|
findings.push({
|
|
line: lineNumber(source, argument.start + (literal.index ?? 0)),
|
|
path: repoPath,
|
|
source: literal[1] === undefined ? decodeKotlinLiteral(literalValue) : literalValue,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return findings;
|
|
}
|
|
|
|
function findParameterOpening(source: string, startOffset: number): number | null {
|
|
let offset = startOffset;
|
|
while (/\s/u.test(source[offset] ?? "")) {
|
|
offset += 1;
|
|
}
|
|
if (source[offset] === "<") {
|
|
const closingTypeArguments = findClosingDelimiter(source, offset, "<", ">");
|
|
if (closingTypeArguments === null) {
|
|
return null;
|
|
}
|
|
offset = closingTypeArguments + 1;
|
|
while (/\s/u.test(source[offset] ?? "")) {
|
|
offset += 1;
|
|
}
|
|
}
|
|
return source[offset] === "(" ? offset : null;
|
|
}
|
|
|
|
export function findUnlocalizedAndroidUiLiterals(
|
|
source: string,
|
|
repoPath: string,
|
|
): AndroidUiLiteralFinding[] {
|
|
if (!shouldScanUiLiterals(repoPath)) {
|
|
return [];
|
|
}
|
|
const findings = new Map<string, AndroidUiLiteralFinding>();
|
|
for (const [patterns, delimiter] of [
|
|
[DIRECT_UI_LITERAL_PATTERNS, '"'],
|
|
[DIRECT_UI_RAW_LITERAL_PATTERNS, '"""'],
|
|
] as const) {
|
|
for (const pattern of patterns) {
|
|
pattern.lastIndex = 0;
|
|
for (const match of source.matchAll(pattern)) {
|
|
const literal = match[1];
|
|
if (!literal) {
|
|
continue;
|
|
}
|
|
const findingSource = delimiter === '"' ? decodeKotlinLiteral(literal) : literal;
|
|
if (
|
|
isAllowedUiLiteral(repoPath, findingSource) ||
|
|
literal.startsWith("http") ||
|
|
literal.startsWith("content://")
|
|
) {
|
|
continue;
|
|
}
|
|
const matchText = match[0] ?? "";
|
|
const offset =
|
|
(match.index ?? 0) + matchText.lastIndexOf(`${delimiter}${literal}${delimiter}`);
|
|
const finding = {
|
|
line: lineNumber(source, Math.max(0, offset)),
|
|
path: repoPath,
|
|
source: findingSource,
|
|
};
|
|
findings.set(`${finding.line}\u0000${finding.source}`, finding);
|
|
}
|
|
}
|
|
}
|
|
for (const finding of [
|
|
...collectHelperLiteralFindings(source, repoPath),
|
|
...collectTypedModelLiteralFindings(source, repoPath),
|
|
]) {
|
|
if (!isAllowedUiLiteral(repoPath, finding.source)) {
|
|
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: ReadonlyMap<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(source: string, values: readonly string[]): string {
|
|
const translated = values.filter((value) => value !== source);
|
|
const candidates = translated.length > 0 ? translated : values;
|
|
const counts = new Map<string, number>();
|
|
for (const value of candidates) {
|
|
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<NativeI18nInventoryEntry[]> {
|
|
return parseNativeI18nInventory(await readFile(INVENTORY_PATH, "utf8")).filter(
|
|
(entry) => entry.surface === "android",
|
|
);
|
|
}
|
|
|
|
async function readArtifacts(): Promise<Map<string, NativeTranslations>> {
|
|
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 { translations?: NativeTranslations };
|
|
return [locale, parsed.translations ?? {}] as const;
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
function translationsBySource(
|
|
inventory: readonly NativeI18nInventoryEntry[],
|
|
translationsById: Readonly<NativeTranslations>,
|
|
): Map<string, string[]> {
|
|
const translations = new Map<string, string[]>();
|
|
for (const entry of inventory) {
|
|
const translated = translationsById[entry.id];
|
|
if (translated !== undefined) {
|
|
translations.set(entry.source, [translated]);
|
|
}
|
|
}
|
|
return translations;
|
|
}
|
|
|
|
function localizeManualStrings(
|
|
base: ReadonlyMap<string, ResourceString>,
|
|
inventoryBySource: ReadonlyMap<string, NativeI18nInventoryEntry>,
|
|
translations: Readonly<NativeTranslations>,
|
|
surface: string,
|
|
): ResourceString[] {
|
|
return [...base.values()].map((entry) => {
|
|
const source = decodeAndroidResourceValue(entry.rawValue);
|
|
const translatable = !/\btranslatable\s*=\s*"false"/u.test(entry.attrs);
|
|
const inventoryEntry = inventoryBySource.get(source);
|
|
if (translatable && !inventoryEntry) {
|
|
throw new Error(
|
|
`${surface} string is missing from native inventory: ${JSON.stringify(source)}`,
|
|
);
|
|
}
|
|
const value = (translatable && inventoryEntry && translations[inventoryEntry.id]) || source;
|
|
return {
|
|
attrs: translatable ? withGeneratedTranslationLintIgnores(entry.attrs) : entry.attrs,
|
|
key: entry.key,
|
|
rawValue: `"${escapeAndroidResourceValue(value)}"`,
|
|
value,
|
|
};
|
|
});
|
|
}
|
|
|
|
function renderStringsXml(
|
|
manual: readonly ResourceString[],
|
|
generated: ReadonlyMap<string, { source: string; value: string }>,
|
|
): string {
|
|
const usesToolsNamespace =
|
|
generated.size > 0 || manual.some((entry) => entry.attrs.includes("tools:"));
|
|
const lines = [
|
|
usesToolsNamespace
|
|
? '<resources xmlns:tools="http://schemas.android.com/tools">'
|
|
: "<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),
|
|
)) {
|
|
lines.push(renderGeneratedString(key, entry));
|
|
}
|
|
}
|
|
lines.push("</resources>", "");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function renderGeneratedString(key: string, entry: { source: string; value: string }): string {
|
|
const formatted =
|
|
(readKotlinInterpolations(entry.source)?.length ?? 0) > 0 ? "" : ' formatted="false"';
|
|
// Translation-memory text preserves technical tokens and source punctuation.
|
|
return ` <string name="${key}"${withGeneratedTranslationLintIgnores(formatted)}>"${renderAndroidResourceValue(entry.source, entry.value)}"</string>`;
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
export async function buildAndroidAppI18nCatalog(): Promise<GeneratedCatalog> {
|
|
const inventory = await readInventory();
|
|
const [
|
|
artifacts,
|
|
baseStrings,
|
|
thirdPartyBaseStrings,
|
|
wearBaseStrings,
|
|
sourceFiles,
|
|
toolDisplaySources,
|
|
] = await Promise.all([
|
|
readArtifacts(),
|
|
readStrings("values"),
|
|
readStrings("values", ANDROID_THIRD_PARTY_RESOURCE_ROOT, THIRD_PARTY_STRINGS_FILE),
|
|
readStrings("values", WEAR_RESOURCE_ROOT),
|
|
readAllAndroidSource(),
|
|
readToolDisplaySources(),
|
|
]);
|
|
const wearInventoryBySource = new Map(
|
|
inventory
|
|
.filter((entry) => entry.sites.some((site) => site.path === WEAR_STRINGS_REPO_PATH))
|
|
.map((entry) => [entry.source, entry]),
|
|
);
|
|
const thirdPartyInventoryBySource = new Map(
|
|
inventory
|
|
.filter((entry) => entry.sites.some((site) => site.path === THIRD_PARTY_STRINGS_REPO_PATH))
|
|
.map((entry) => [entry.source, entry]),
|
|
);
|
|
const manualBase = [...baseStrings.values()].filter(
|
|
(entry) => !entry.key.startsWith(MANAGED_PREFIX),
|
|
);
|
|
const manualSourceToKey = new Map(manualBase.map((entry) => [entry.value, entry.key]));
|
|
const sources = new Set<string>();
|
|
for (const entry of inventory) {
|
|
if (
|
|
entry.surface !== "android" ||
|
|
entry.sites.every((site) => site.kind === "resource-string")
|
|
) {
|
|
continue;
|
|
}
|
|
sources.add(entry.source);
|
|
}
|
|
for (const source of collectExplicitRuntimeSources(sourceFiles)) {
|
|
sources.add(source);
|
|
}
|
|
for (const source of toolDisplaySources) {
|
|
sources.add(source);
|
|
}
|
|
const sourceToKey = new Map<string, string>();
|
|
for (const source of sources) {
|
|
sourceToKey.set(source, manualSourceToKey.get(source) ?? resourceKey(source));
|
|
}
|
|
const resources = new Map<string, string>();
|
|
const contradictions: TranslationContradiction[] = [];
|
|
for (const locale of NATIVE_I18N_LOCALES) {
|
|
const localeTranslations = artifacts.get(locale) ?? {};
|
|
const artifactTranslationsBySource = translationsBySource(inventory, localeTranslations);
|
|
const generated = new Map<string, { source: string; value: string }>();
|
|
for (const source of sources) {
|
|
const key = sourceToKey.get(source);
|
|
if (!key || !key.startsWith(MANAGED_PREFIX)) {
|
|
continue;
|
|
}
|
|
const translations = artifactTranslationsBySource.get(source) ?? [];
|
|
const selected = selectDeterministicTranslation(source, translations);
|
|
if (selected === source && translations.some((translation) => translation !== source)) {
|
|
throw new Error(
|
|
`Android translation selection kept the source despite a translated candidate: ${locale} ${JSON.stringify(source)}`,
|
|
);
|
|
}
|
|
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,
|
|
});
|
|
}
|
|
resources.set(
|
|
path.join(GENERATED_RESOURCE_ROOT, localeDirectory(locale), "native_strings.xml"),
|
|
renderStringsXml([], generated),
|
|
);
|
|
const wearManual = localizeManualStrings(
|
|
wearBaseStrings,
|
|
wearInventoryBySource,
|
|
localeTranslations,
|
|
"Wear",
|
|
);
|
|
resources.set(
|
|
path.join(WEAR_RESOURCE_ROOT, localeDirectory(locale), "strings.xml"),
|
|
renderStringsXml(wearManual, new Map()),
|
|
);
|
|
const thirdPartyManual = localizeManualStrings(
|
|
thirdPartyBaseStrings,
|
|
thirdPartyInventoryBySource,
|
|
localeTranslations,
|
|
"Android third-party",
|
|
);
|
|
resources.set(
|
|
path.join(
|
|
ANDROID_THIRD_PARTY_RESOURCE_ROOT,
|
|
localeDirectory(locale),
|
|
THIRD_PARTY_STRINGS_FILE,
|
|
),
|
|
renderStringsXml(thirdPartyManual, new Map()),
|
|
);
|
|
}
|
|
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(GENERATED_RESOURCE_ROOT, "values", "native_strings.xml"),
|
|
renderStringsXml([], 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, localeTranslations] of artifacts) {
|
|
const translatedBySource = translationsBySource(inventory, localeTranslations);
|
|
const translatedItems = assistantItems.map(
|
|
(source) =>
|
|
selectDeterministicTranslation(source, translatedBySource.get(source) ?? []) || source,
|
|
);
|
|
resources.set(
|
|
path.join(RESOURCE_ROOT, localeDirectory(locale), "assistant.xml"),
|
|
renderAssistantXml(translatedItems),
|
|
);
|
|
}
|
|
|
|
return {
|
|
contradictions,
|
|
kotlin: renderKotlin(sourceToKey),
|
|
resources,
|
|
sources,
|
|
};
|
|
}
|
|
|
|
function formatProblems(problems: Array<readonly [string, string[]]>): string {
|
|
return [
|
|
"Android i18n resources are out of sync.",
|
|
...problems.map(([label, keys]) => `${label}=${keys.join(",") || "none"}`),
|
|
].join("\n");
|
|
}
|
|
|
|
async function generateAndroidAppI18n() {
|
|
const catalog = await buildAndroidAppI18nCatalog();
|
|
await rm(GENERATED_ROOT, { recursive: true, force: true });
|
|
await mkdir(path.dirname(GENERATED_KOTLIN_PATH), { recursive: true });
|
|
await writeFile(GENERATED_KOTLIN_PATH, catalog.kotlin);
|
|
for (const [filePath, content] of catalog.resources) {
|
|
if (filePath.startsWith(`${GENERATED_RESOURCE_ROOT}${path.sep}`)) {
|
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
await writeFile(filePath, content);
|
|
}
|
|
}
|
|
return catalog;
|
|
}
|
|
|
|
export async function syncAndroidAppI18n(
|
|
options: { check?: boolean; tolerateManagedPending?: boolean } = {},
|
|
) {
|
|
const catalog = await buildAndroidAppI18nCatalog();
|
|
const drift: string[] = [];
|
|
for (const [filePath, expected] of catalog.resources) {
|
|
// Build outputs are checked against their input contracts, never tracked projections.
|
|
if (filePath.startsWith(`${GENERATED_RESOURCE_ROOT}${path.sep}`)) {
|
|
continue;
|
|
}
|
|
const current = await readFile(filePath, "utf8").catch(() => "");
|
|
if (current === expected) {
|
|
continue;
|
|
}
|
|
const relativeFilePath = path.relative(ROOT, filePath).split(path.sep).join("/");
|
|
if (
|
|
options.check &&
|
|
options.tolerateManagedPending &&
|
|
(WEAR_GENERATED_RESOURCE_RE.test(relativeFilePath) ||
|
|
THIRD_PARTY_GENERATED_RESOURCE_RE.test(relativeFilePath))
|
|
) {
|
|
continue;
|
|
}
|
|
drift.push(relativeFilePath);
|
|
if (!options.check) {
|
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
await writeFile(filePath, expected);
|
|
}
|
|
}
|
|
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((translation) => JSON.stringify(translation)).join(", ")})`,
|
|
),
|
|
...(remaining > 0 ? [`android-app-i18n: ${remaining} more contradictions omitted`] : []),
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
}
|
|
return catalog;
|
|
}
|
|
|
|
export async function verifyAndroidAppI18n(catalog?: GeneratedCatalog) {
|
|
const generated = catalog ?? (await buildAndroidAppI18nCatalog());
|
|
const [
|
|
sourceFiles,
|
|
base,
|
|
referenceSource,
|
|
thirdPartyBase,
|
|
thirdPartyReferenceSource,
|
|
wearBase,
|
|
wearReferenceSource,
|
|
] = await Promise.all([
|
|
readAllAndroidSource(),
|
|
readStrings("values"),
|
|
readAndroidResourceReferences(),
|
|
readStrings("values", ANDROID_THIRD_PARTY_RESOURCE_ROOT, THIRD_PARTY_STRINGS_FILE),
|
|
readAndroidResourceReferences(ANDROID_THIRD_PARTY_ROOT),
|
|
readStrings("values", WEAR_RESOURCE_ROOT),
|
|
readAndroidResourceReferences(ANDROID_WEAR_MAIN_ROOT),
|
|
]);
|
|
const baseKeys = new Set(base.keys());
|
|
const thirdPartyBaseKeys = new Set(thirdPartyBase.keys());
|
|
const wearBaseKeys = new Set(wearBase.keys());
|
|
const problems: Array<readonly [string, string[]]> = [
|
|
["App English syntax", findInvalidResourceSyntax(base)],
|
|
["Third-party English syntax", findInvalidResourceSyntax(thirdPartyBase)],
|
|
["Wear English syntax", findInvalidResourceSyntax(wearBase)],
|
|
];
|
|
const manualBaseKeys = [...baseKeys].filter((key) => !key.startsWith(MANAGED_PREFIX));
|
|
problems.push([
|
|
"App English unused",
|
|
findUnusedAndroidResourceKeys(manualBaseKeys, [
|
|
...referenceSource,
|
|
{ path: GENERATED_KOTLIN_PATH, source: generated.kotlin },
|
|
]),
|
|
]);
|
|
problems.push([
|
|
"Third-party English unused",
|
|
findUnusedAndroidResourceKeys(thirdPartyBaseKeys, thirdPartyReferenceSource),
|
|
]);
|
|
problems.push([
|
|
"Wear English unused",
|
|
findUnusedAndroidResourceKeys(wearBaseKeys, wearReferenceSource),
|
|
]);
|
|
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: appSourceKeys=${baseKeys.size} thirdPartySourceKeys=${thirdPartyBaseKeys.size} wearSourceKeys=${wearBaseKeys.size}\n`,
|
|
);
|
|
}
|
|
|
|
export async function checkAndroidAppI18n(options: { tolerateManagedPending?: boolean } = {}) {
|
|
const catalog = await syncAndroidAppI18n({ check: true, ...options });
|
|
await verifyAndroidAppI18n(catalog);
|
|
const localeStrings = await Promise.all(
|
|
LOCALES.map(async (locale) => {
|
|
const manual = await readStrings(locale);
|
|
const generated = catalog.resources.get(
|
|
path.join(GENERATED_RESOURCE_ROOT, locale, "native_strings.xml"),
|
|
);
|
|
return new Map([
|
|
...manual,
|
|
...parseStrings(expectDefined(generated, `Android resources for ${locale}`)).map(
|
|
(entry) => [entry.key, entry] as const,
|
|
),
|
|
]);
|
|
}),
|
|
);
|
|
const wearLocaleStrings = options.tolerateManagedPending
|
|
? []
|
|
: await Promise.all(LOCALES.map((locale) => readStrings(locale, WEAR_RESOURCE_ROOT)));
|
|
const thirdPartyLocaleStrings = options.tolerateManagedPending
|
|
? []
|
|
: await Promise.all(
|
|
LOCALES.map((locale) =>
|
|
readStrings(locale, ANDROID_THIRD_PARTY_RESOURCE_ROOT, THIRD_PARTY_STRINGS_FILE),
|
|
),
|
|
);
|
|
const base = expectDefined(localeStrings[0], "English Android string resources");
|
|
const localeProblems = (
|
|
surface: string,
|
|
surfaceBase: ReadonlyMap<string, ResourceString>,
|
|
translations: ReadonlyArray<ReadonlyMap<string, ResourceString>>,
|
|
): Array<readonly [string, string[]]> => {
|
|
const surfaceBaseKeys = new Set(surfaceBase.keys());
|
|
return translations.flatMap((strings, index) => {
|
|
const locale = NATIVE_I18N_LOCALES[index];
|
|
const keys = new Set(strings.keys());
|
|
const placeholderMismatches = [...surfaceBase].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 [
|
|
[`${surface} ${locale} missing`, [...surfaceBaseKeys].filter((key) => !keys.has(key))],
|
|
[`${surface} ${locale} extra`, [...keys].filter((key) => !surfaceBaseKeys.has(key))],
|
|
[`${surface} ${locale} placeholders`, placeholderMismatches],
|
|
[`${surface} ${locale} syntax`, findInvalidResourceSyntax(strings)],
|
|
] as const;
|
|
});
|
|
};
|
|
const problems = localeProblems("App", base, localeStrings.slice(1));
|
|
if (thirdPartyLocaleStrings.length > 0) {
|
|
const thirdPartyBase = expectDefined(
|
|
thirdPartyLocaleStrings[0],
|
|
"English Android third-party string resources",
|
|
);
|
|
problems.push(
|
|
...localeProblems("Third-party", thirdPartyBase, thirdPartyLocaleStrings.slice(1)),
|
|
);
|
|
}
|
|
if (wearLocaleStrings.length > 0) {
|
|
const wearBase = expectDefined(wearLocaleStrings[0], "English Wear string resources");
|
|
problems.push(...localeProblems("Wear", wearBase, wearLocaleStrings.slice(1)));
|
|
}
|
|
if (problems.some(([, keys]) => keys.length)) {
|
|
throw new Error(formatProblems(problems));
|
|
}
|
|
process.stdout.write(
|
|
`android-app-i18n: appKeys=${base.size} locales=${NATIVE_I18N_LOCALES.join(",")}\n`,
|
|
);
|
|
}
|
|
|
|
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
|
|
const [command] = process.argv.slice(2);
|
|
if (command === "generate") {
|
|
await generateAndroidAppI18n();
|
|
} else if (command === "sync") {
|
|
await syncAndroidAppI18n();
|
|
} else if (command === "check") {
|
|
await checkAndroidAppI18n();
|
|
} else {
|
|
throw new Error("usage: node scripts/android-app-i18n.ts <generate|sync|check>");
|
|
}
|
|
}
|