openclaw/scripts/control-ui-i18n.ts
Peter Steinberger 6f3a983c32
refactor(scripts): share locale report owners
## What Problem This Solves

Translation generation and its diagnostic report maintain duplicate language labels and copies of metadata types already owned by the producers.

## User Impact

No user-visible change. Report locales, language labels, unknown-locale handling, generated prompts, and metadata formats remain the same.

## Why This Change Was Made

The existing locale configuration module now owns the shared display-label lookup. The report derives its accepted locales from the same existing configuration, while English and Swedish remain generator-only labels. A private Map retains safe fallback for unknown and prototype-like names. Report data types are imported from their producers with type-only imports.

Measured reduction: **69 net production lines** across four script files. The report test only changes its type import.

## Evidence

Independent isolated Codex review completed with no P0–P2 findings.

Blacksmith Testbox `tbx_01m3v3q8tfefne2t0npghczhex`, pinned source base `8d99007436` plus the verified candidate diff:

- Actual report CLI before/after: 27 cases per phase (20 accepted and 7 rejected); stdout, stderr and exit status are byte-identical.
- Actual translation prompt capture before/after: 27 synthetic cases per phase; system prompts are byte-identical. This uses the existing test LLM mock, without live inference.
- All eight affected suites passed: 162 tests. The changed report suite took 1.95 seconds wall time with `--maxWorkers=1`.
- `node scripts/check-changed.mjs`: passed, including script/core test types, changed-file lint, dead exports and Docker boundaries.
- `node --max-old-space-size=8192 --import ./scripts/tsx.mjs scripts/plugin-sdk-surface-report.mts --check`: passed.
- `node --import ./scripts/tsx.mjs scripts/check-madge-import-cycles.ts`: 0 cycles.
- `node --import ./scripts/tsx.mjs scripts/check-import-cycles.ts`: 0 runtime value cycles.

The remote command completed successfully; its external portal synchronization emitted a timeout warning after proof completed. Hosted exact-head CI is pending.

No configuration keys, generated artifacts, CLI text, or visible UI states change.
2026-10-01 08:04:22 +00:00

1144 lines
38 KiB
TypeScript

// Control Ui I18N script supports OpenClaw repository automation.
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createLlmRuntime, type AssistantMessage, type Model } from "@openclaw/ai";
import { registerBuiltInApiProviders } from "@openclaw/ai/providers";
import { formatErrorMessage } from "@openclaw/normalization-core/error-coercion";
import { expectDefined } from "../packages/normalization-core/src/expect.js";
import { formatDurationCompact } from "../src/infra/format-time/format-duration.ts";
import { isStrictAffirmativeValue } from "./lib/arg-utils.mts";
import {
hashControlUiTranslationText,
loadControlUiTranslationMemory,
materializeControlUiLocaleCatalog,
} from "./lib/control-ui-i18n-catalog-values.ts";
import { CONTROL_UI_LOCALE_ENTRIES, controlUiLanguageLabel } from "./lib/control-ui-i18n-config.ts";
import {
compareStringArrays,
createControlUiLocaleSyncPlan,
extractTranslationPlaceholders,
flattenTranslations,
type GlossaryEntry,
type LocaleEntry,
type LocaleMeta,
type TranslationBatchItem,
} from "./lib/control-ui-i18n-sync-plan.ts";
import { escapeRegExp } from "./lib/regexp.mjs";
import { sleep } from "./lib/sleep.mjs";
// Translation is standalone tooling: Gateway host hooks open operator state
// and log model identifiers before this script can redact provider failures.
const translationRuntime = createLlmRuntime();
registerBuiltInApiProviders(translationRuntime.registry);
const CONTROL_UI_I18N_WORKFLOW = 1;
const DEFAULT_OPENAI_MODEL = "gpt-6-astra";
const DEFAULT_ANTHROPIC_MODEL = "claude-opus-4-6";
const DEFAULT_PROVIDER = "openai";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(HERE, "..");
const I18N_ASSETS_DIR = path.join(ROOT, "ui", "src", "i18n", ".i18n");
const SOURCE_LOCALE = "en";
const MAX_BATCH_ITEMS = 20;
const DEFAULT_BATCH_CHAR_BUDGET = 2_000;
const TRANSLATE_MAX_ATTEMPTS = 2;
const TRANSLATE_BASE_DELAY_MS = 15_000;
const DEFAULT_PROMPT_TIMEOUT_MS = 120_000;
const PROGRESS_HEARTBEAT_MS = 30_000;
const ENV_PROVIDER = "OPENCLAW_CONTROL_UI_I18N_PROVIDER";
const ENV_MODEL = "OPENCLAW_CONTROL_UI_I18N_MODEL";
const ENV_FALLBACK_MODEL = "OPENCLAW_I18N_FALLBACK_MODEL";
const ENV_THINKING = "OPENCLAW_CONTROL_UI_I18N_THINKING";
const ENV_BATCH_CHAR_BUDGET = "OPENCLAW_CONTROL_UI_I18N_BATCH_CHAR_BUDGET";
const ENV_PROMPT_TIMEOUT = "OPENCLAW_CONTROL_UI_I18N_PROMPT_TIMEOUT";
const ENV_AUTH_OPTIONAL = "OPENCLAW_CONTROL_UI_I18N_AUTH_OPTIONAL";
type TranslationProvider = "openai" | "anthropic";
const TRANSLATION_PROVIDER_DEFAULTS: Record<TranslationProvider, Omit<Model, "id" | "name">> = {
openai: {
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 400_000,
maxTokens: 32_000,
},
anthropic: {
api: "anthropic-messages",
provider: "anthropic",
baseUrl: "https://api.anthropic.com",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 32_000,
},
};
const LOCALE_ENTRIES: readonly LocaleEntry[] = CONTROL_UI_LOCALE_ENTRIES;
const DEFAULT_GLOSSARY: readonly GlossaryEntry[] = [
{ source: "OpenClaw", target: "OpenClaw" },
{ source: "Gateway", target: "Gateway" },
{ source: "Control UI", target: "Control UI" },
{ source: "Skills", target: "Skills" },
{ source: "Tailscale", target: "Tailscale" },
{ source: "WhatsApp", target: "WhatsApp" },
{ source: "Telegram", target: "Telegram" },
{ source: "Discord", target: "Discord" },
{ source: "Signal", target: "Signal" },
{ source: "iMessage", target: "iMessage" },
];
function usage(): never {
console.error(
[
"Usage:",
" node --import tsx scripts/control-ui-i18n.ts check",
" node --import tsx scripts/control-ui-i18n.ts sync [--write] [--locale <code>] [--force]",
" node --import tsx scripts/control-ui-i18n.ts sync --write --locale <code> --refresh-key <key> [--refresh-key <key> ...]",
].join("\n"),
);
process.exit(2);
}
function parseArgs(argv: string[]) {
const [command, ...rest] = argv;
if (command !== "check" && command !== "sync") {
usage();
}
let localeFilter: string | null = null;
let write = false;
let force = false;
const refreshKeys = new Set<string>();
for (let index = 0; index < rest.length; index += 1) {
const part = rest[index];
switch (part) {
case "--locale":
localeFilter = rest[index + 1] ?? null;
index += 1;
break;
case "--write":
write = true;
break;
case "--force":
force = true;
break;
case "--refresh-key": {
const key = rest[index + 1];
if (!key || key.startsWith("--")) {
throw new Error("--refresh-key requires a catalog key");
}
refreshKeys.add(key);
if (refreshKeys.size > 64) {
throw new Error("--refresh-key accepts at most 64 distinct keys");
}
index += 1;
break;
}
default:
usage();
}
}
if (command === "check" && write) {
usage();
}
if (refreshKeys.size > 0 && (command !== "sync" || !write || !localeFilter || force)) {
throw new Error(
"--refresh-key requires sync --write --locale and cannot be combined with --force",
);
}
return {
command,
force,
localeFilter,
refreshKeys,
write,
};
}
function resolveConfiguredProvider(): string {
const configured = process.env[ENV_PROVIDER]?.trim();
if (configured) {
return configured;
}
if (process.env.OPENAI_API_KEY?.trim()) {
return "openai";
}
if (process.env.ANTHROPIC_API_KEY?.trim()) {
return "anthropic";
}
return DEFAULT_PROVIDER;
}
function resolveConfiguredModel(): string {
const configured = process.env[ENV_MODEL]?.trim();
if (configured) {
return configured;
}
return resolveConfiguredProvider() === "anthropic"
? DEFAULT_ANTHROPIC_MODEL
: DEFAULT_OPENAI_MODEL;
}
function hasTranslationProvider(): boolean {
return Boolean(process.env.OPENAI_API_KEY?.trim() || process.env.ANTHROPIC_API_KEY?.trim());
}
function resolveKnownTranslationProvider(): TranslationProvider {
const provider = resolveConfiguredProvider();
if (provider === "openai" || provider === "anthropic") {
return provider;
}
throw new Error(`Unsupported translation provider: ${provider}`);
}
function sha256(input: string | Uint8Array): string {
return createHash("sha256").update(input).digest("hex");
}
function cacheNamespace(): string {
return `wf=${CONTROL_UI_I18N_WORKFLOW}|engine=openclaw-llm`;
}
function cacheKey(segmentId: string, textHash: string, targetLocale: string): string {
return sha256([cacheNamespace(), SOURCE_LOCALE, targetLocale, segmentId, textHash].join("|"));
}
function glossaryPath(entry: LocaleEntry): string {
return path.join(I18N_ASSETS_DIR, `glossary.${entry.locale}.json`);
}
function metaPath(entry: LocaleEntry): string {
return path.join(I18N_ASSETS_DIR, `${entry.locale}.meta.json`);
}
function tmPath(entry: LocaleEntry): string {
return path.join(I18N_ASSETS_DIR, `${entry.locale}.tm.jsonl`);
}
type PlaceholderMismatch = {
key: string;
locale: string;
sourcePlaceholders: string[];
translatedPlaceholders: string[];
};
export function findPlaceholderMismatches(
sourceFlat: ReadonlyMap<string, string>,
translatedFlat: ReadonlyMap<string, string>,
locale: string,
): PlaceholderMismatch[] {
const mismatches: PlaceholderMismatch[] = [];
for (const [key, sourceText] of sourceFlat.entries()) {
const sourcePlaceholders = extractTranslationPlaceholders(sourceText);
const translatedPlaceholders = extractTranslationPlaceholders(translatedFlat.get(key) ?? "");
if (!compareStringArrays(sourcePlaceholders, translatedPlaceholders)) {
mismatches.push({
key,
locale,
sourcePlaceholders,
translatedPlaceholders,
});
}
}
return mismatches;
}
export function filterPlaceholderCompatibleTranslations(
sourceFlat: ReadonlyMap<string, string>,
translatedFlat: ReadonlyMap<string, string>,
): Map<string, string> {
return new Map(
[...translatedFlat].filter(([key, translated]) => {
const source = sourceFlat.get(key);
return (
source !== undefined &&
compareStringArrays(
extractTranslationPlaceholders(source),
extractTranslationPlaceholders(translated),
)
);
}),
);
}
function assertPlaceholderParity(
sourceFlat: ReadonlyMap<string, string>,
translatedFlat: ReadonlyMap<string, string>,
locale: string,
) {
const mismatches = findPlaceholderMismatches(sourceFlat, translatedFlat, locale);
if (mismatches.length === 0) {
return;
}
const details = mismatches
.slice(0, 20)
.map(
(mismatch) =>
`${mismatch.locale}:${mismatch.key} expected {${mismatch.sourcePlaceholders.join("},{")}} got {${mismatch.translatedPlaceholders.join("},{")}}`,
)
.join("\n");
throw new Error(
[
`control-ui-i18n placeholder mismatch detected for ${locale}.`,
details,
mismatches.length > 20 ? `...and ${mismatches.length - 20} more` : "",
]
.filter(Boolean)
.join("\n"),
);
}
async function loadGlossary(filePath: string): Promise<GlossaryEntry[]> {
if (!existsSync(filePath)) {
return [];
}
const raw = await readFile(filePath, "utf8");
const parsed = JSON.parse(raw) as GlossaryEntry[];
return Array.isArray(parsed) ? parsed : [];
}
async function loadMeta(filePath: string): Promise<LocaleMeta | null> {
if (!existsSync(filePath)) {
return null;
}
const raw = await readFile(filePath, "utf8");
return JSON.parse(raw) as LocaleMeta;
}
function buildGlossaryPrompt(glossary: readonly GlossaryEntry[]): string {
if (glossary.length === 0) {
return "";
}
return [
"Required terminology (use exactly when the source term matches):",
...glossary
.filter((entry) => entry.source.trim() && entry.target.trim())
.map((entry) => `- ${entry.source} -> ${entry.target}`),
].join("\n");
}
function buildSystemPrompt(targetLocale: string, glossary: readonly GlossaryEntry[]): string {
const glossaryBlock = buildGlossaryPrompt(glossary);
const lines = [
"You are a translation function, not a chat assistant.",
`Translate UI strings from ${controlUiLanguageLabel(SOURCE_LOCALE)} to ${controlUiLanguageLabel(targetLocale)}.`,
"",
"Rules:",
"- Output ONLY valid JSON.",
"- The JSON must be an object whose keys exactly match the provided ids.",
"- Translate all English prose; keep code, URLs, product names, CLI commands, config keys, and env vars in English.",
"- Preserve placeholders exactly, including {count}, {time}, {shown}, {total}, and similar tokens.",
"- Preserve Swift interpolation expressions such as \\(name) exactly, including the backslash and parentheses.",
"- Preserve Kotlin interpolation expressions such as $name and ${value} exactly.",
"- Use natural target-language punctuation and spacing in translated prose. Keep ellipses and arrows unchanged when they are UI indicators.",
"- Preserve exact syntax, punctuation, and casing in code, URLs, commands, placeholders, identifiers, and clearly identified literal third-party UI labels.",
"- Preserve Markdown, inline code, HTML tags, and slash commands when present.",
"- Use fluent, neutral product UI language.",
"- Do not add explanations, comments, or extra keys.",
"- Never return an empty string for a key; if unsure, return the source text unchanged.",
];
if (glossaryBlock) {
lines.push("", glossaryBlock);
}
return lines.join("\n");
}
function buildBatchPayload(items: readonly TranslationBatchItem[]) {
return Object.fromEntries(
items.map(
(item) =>
[
item.key,
item.sourcePath
? {
text: item.text,
sourcePath: item.sourcePath,
sourceContext: item.sourceContext,
}
: item.text,
] as const,
),
);
}
export function buildBatchPrompt(
items: readonly TranslationBatchItem[],
validationError?: string,
): string {
const payload = buildBatchPayload(items);
const lines = ["Translate this JSON object.", "Return ONLY a JSON object with the same keys."];
if (items.some((item) => item.sourcePath)) {
lines.push(
"For object values, translate only text. Use sourcePath and the bounded sourceContext excerpt to understand the native UI owner and disambiguate its meaning; these fields are context, not text to translate.",
"Preserve the source order and meaning of unnumbered printf arguments. Rephrase surrounding prose rather than swapping the roles of argument values. Preserve literal percent escapes exactly.",
"Return each id mapped directly to its translated string, without the context fields.",
);
}
if (validationError) {
lines.push(
"",
"Your previous response failed validation. Correct that exact failure in the new response:",
validationError,
);
}
lines.push("", JSON.stringify(payload, null, 2));
return lines.join("\n");
}
function formatDuration(ms: number): string {
return formatDurationCompact(ms, { spaced: true }) ?? "0ms";
}
function logProgress(message: string) {
process.stdout.write(`control-ui-i18n: ${message}\n`);
}
function isPromptTimeoutError(error: Error): boolean {
return error.message.toLowerCase().includes("timed out");
}
export function isProviderAuthError(error: Error): boolean {
const message = error.message.toLowerCase();
return (
message.includes("401") ||
message.includes("authentication_error") ||
message.includes("incorrect api key") ||
message.includes("invalid x-api-key")
);
}
function isProviderAuthOptional(): boolean {
return isStrictAffirmativeValue(process.env[ENV_AUTH_OPTIONAL]);
}
function resolvePromptTimeoutMs(): number {
const raw = process.env[ENV_PROMPT_TIMEOUT]?.trim();
if (!raw) {
return DEFAULT_PROMPT_TIMEOUT_MS;
}
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PROMPT_TIMEOUT_MS;
}
function resolveThinkingLevel(): "low" | "high" {
return process.env[ENV_THINKING]?.trim().toLowerCase() === "high" ? "high" : "low";
}
function resolveBatchCharBudget(): number {
const raw = process.env[ENV_BATCH_CHAR_BUDGET]?.trim();
if (!raw) {
return DEFAULT_BATCH_CHAR_BUDGET;
}
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_BATCH_CHAR_BUDGET;
}
function estimateBatchChars(items: readonly TranslationBatchItem[]): number {
return JSON.stringify(buildBatchPayload(items)).length;
}
type LocaleRunContext = {
localeCount: number;
localeIndex: number;
};
type TranslationBatchContext = LocaleRunContext & {
batchCount: number;
batchIndex: number;
locale: string;
splitDepth?: number;
segmentLabel?: string;
validateTranslation?: TranslationValidator;
};
type TranslationValidator = (source: string, target: string, key: string, locale: string) => void;
type ClientAccess = {
getClient: () => Promise<TranslationClient>;
resetClient: () => Promise<void>;
};
function createTranslationClientAccess(
targetLocale: string,
glossary: readonly GlossaryEntry[],
): ClientAccess {
let client: TranslationClient | null = null;
return {
async getClient() {
client ??= await TranslationClient.create(buildSystemPrompt(targetLocale, glossary));
return client;
},
async resetClient() {
await client?.close();
client = null;
},
};
}
function formatLocaleLabel(locale: string, context: LocaleRunContext): string {
return `[${context.localeIndex}/${context.localeCount}] ${locale}`;
}
function formatBatchLabel(context: TranslationBatchContext): string {
const suffix = context.segmentLabel ? `.${context.segmentLabel}` : "";
return `${formatLocaleLabel(context.locale, context)} batch ${context.batchIndex}/${context.batchCount}${suffix}`;
}
function buildTranslationBatches(items: readonly TranslationBatchItem[]): TranslationBatchItem[][] {
const batches: TranslationBatchItem[][] = [];
const budget = resolveBatchCharBudget();
let current: TranslationBatchItem[] = [];
let currentChars = 2;
for (const item of items) {
const itemChars = estimateBatchChars([item]);
const wouldOverflow = current.length > 0 && currentChars + itemChars > budget;
const reachedMaxItems = current.length >= MAX_BATCH_ITEMS;
if (wouldOverflow || reachedMaxItems) {
batches.push(current);
current = [];
currentChars = 2;
}
current.push(item);
currentChars += itemChars;
}
if (current.length > 0) {
batches.push(current);
}
return batches;
}
export function resolveTranslationModel(): Model {
const provider = resolveKnownTranslationProvider();
const modelId = resolveConfiguredModel();
return {
...TRANSLATION_PROVIDER_DEFAULTS[provider],
id: modelId,
name: modelId,
};
}
class TranslationClient {
private closed = false;
private sequence: Promise<unknown> = Promise.resolve();
private model: Model;
private readonly systemPrompt: string;
private constructor(systemPrompt: string) {
this.systemPrompt = systemPrompt;
this.model = resolveTranslationModel();
}
static async create(systemPrompt: string): Promise<TranslationClient> {
return new TranslationClient(systemPrompt);
}
async prompt(message: string, label: string): Promise<string> {
const result = this.sequence.then(async () => {
if (this.closed) {
throw new Error("translation runtime unavailable");
}
const timeoutMs = resolvePromptTimeoutMs();
const startedAt = Date.now();
const controller = new AbortController();
return await new Promise<string>((resolve, reject) => {
const heartbeat = setInterval(() => {
logProgress(
`${label}: still waiting (${formatDuration(Date.now() - startedAt)} / ${formatDuration(timeoutMs)})`,
);
}, PROGRESS_HEARTBEAT_MS);
const timer = setTimeout(() => {
clearInterval(heartbeat);
controller.abort();
reject(new Error(`${label}: translation prompt timed out after ${timeoutMs}ms`));
}, timeoutMs);
const complete = () =>
translationRuntime
.completeSimple(
this.model,
{
systemPrompt: this.systemPrompt,
messages: [{ role: "user", content: message, timestamp: Date.now() }],
},
{
maxTokens: 4096,
reasoning: resolveThinkingLevel(),
signal: controller.signal,
timeoutMs,
},
)
.then(extractTranslationResult);
complete()
.catch(async (error: unknown) => {
const fallback = process.env[ENV_FALLBACK_MODEL]?.trim();
if (
error instanceof TranslationProviderError &&
error.code === "model_not_found" &&
!controller.signal.aborted &&
!this.closed &&
this.model.provider === "openai" &&
fallback &&
fallback !== this.model.id
) {
logProgress(`${label}: primary model unavailable; using configured fallback`);
this.model = { ...this.model, id: fallback, name: fallback };
return await complete();
}
throw error;
})
.then((translation) => {
clearTimeout(timer);
clearInterval(heartbeat);
resolve(translation);
})
.catch((error: unknown) => {
clearTimeout(timer);
clearInterval(heartbeat);
reject(
error instanceof TranslationProviderError
? error
: new TranslationProviderError("provider_error"),
);
});
});
});
this.sequence = result.catch(() => undefined);
return await result;
}
async close() {
if (this.closed) {
return;
}
this.closed = true;
}
}
class TranslationProviderError extends Error {
readonly code: "model_not_found" | "authentication_error" | "provider_error";
constructor(code: TranslationProviderError["code"]) {
super(`translation provider failed (${code}); check the private provider configuration`);
this.code = code;
}
}
function extractTranslationResult(message: AssistantMessage): string {
if (message.errorMessage || message.stopReason === "error") {
// Provider prose can contain private model names. Only the explicit model
// availability code authorizes fallback; all public errors use fixed text.
throw new TranslationProviderError(
message.errorCode === "model_not_found"
? "model_not_found"
: isProviderAuthError(new Error(message.errorMessage))
? "authentication_error"
: "provider_error",
);
}
const text = message.content
.map((block) => (block.type === "text" ? block.text : ""))
.join("")
.trim();
if (!text) {
throw new Error("assistant translation not found");
}
return text;
}
// Models intermittently wrap the JSON reply in a Markdown code fence even
// when told not to; strip it instead of burning a retry on a parse error.
function parseTranslationReply(raw: string): Record<string, unknown> {
const trimmed = raw.trim();
const fenced = /^```(?:json)?\s*\n([\s\S]*?)\n```\s*$/.exec(trimmed);
const json = fenced ? expectDefined(fenced[1], "fenced translation JSON body") : trimmed;
try {
return JSON.parse(json);
} catch {
throw new Error("translation provider returned invalid JSON");
}
}
export function parseTranslationBatchReply(
raw: string,
items: readonly TranslationBatchItem[],
locale: string,
validateTranslation?: TranslationValidator,
): Map<string, string> {
const parsed = parseTranslationReply(raw);
const translated = new Map<string, string>();
for (const item of items) {
const value = parsed[item.key];
if (typeof value !== "string" || !value.trim()) {
throw new Error(`missing translation for ${item.key}`);
}
const privateModels = [process.env[ENV_MODEL], process.env[ENV_FALLBACK_MODEL]];
if (
privateModels.some(
(model) => model?.trim() && value.toLowerCase().includes(model.trim().toLowerCase()),
)
) {
throw new TranslationProviderError("provider_error");
}
validateTranslation?.(item.text, value, item.key, locale);
translated.set(item.key, value);
}
assertPlaceholderParity(new Map(items.map((item) => [item.key, item.text])), translated, locale);
return translated;
}
async function translateBatch(
clientAccess: ClientAccess,
items: readonly TranslationBatchItem[],
context: TranslationBatchContext,
): Promise<Map<string, string>> {
const batchLabel = formatBatchLabel(context);
const splitDepth = context.splitDepth ?? 0;
let lastError: Error | null = null;
let validationError: string | undefined;
for (let attempt = 0; attempt < TRANSLATE_MAX_ATTEMPTS; attempt += 1) {
const attemptNumber = attempt + 1;
const attemptLabel = `${batchLabel} attempt ${attemptNumber}/${TRANSLATE_MAX_ATTEMPTS}`;
const startedAt = Date.now();
logProgress(`${attemptLabel}: start keys=${items.length}`);
let promptCompleted = false;
try {
const raw = await (
await clientAccess.getClient()
).prompt(buildBatchPrompt(items, validationError), attemptLabel);
promptCompleted = true;
const translated = parseTranslationBatchReply(
raw,
items,
context.locale,
context.validateTranslation,
);
logProgress(`${attemptLabel}: done (${formatDuration(Date.now() - startedAt)})`);
return translated;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (promptCompleted) {
validationError = lastError.message;
}
await clientAccess.resetClient();
logProgress(
`${attemptLabel}: failed after ${formatDuration(Date.now() - startedAt)}: ${lastError.message}`,
);
if (isPromptTimeoutError(lastError) && items.length > 1) {
const midpoint = Math.ceil(items.length / 2);
logProgress(
`${batchLabel}: splitting timed out batch into ${midpoint} + ${items.length - midpoint} keys`,
);
const left = await translateBatch(clientAccess, items.slice(0, midpoint), {
...context,
splitDepth: splitDepth + 1,
segmentLabel: `${context.segmentLabel ?? ""}a`,
});
const right = await translateBatch(clientAccess, items.slice(midpoint), {
...context,
splitDepth: splitDepth + 1,
segmentLabel: `${context.segmentLabel ?? ""}b`,
});
return new Map([...left, ...right]);
}
if (isPromptTimeoutError(lastError)) {
break;
}
if (attempt + 1 < TRANSLATE_MAX_ATTEMPTS) {
const delayMs = TRANSLATE_BASE_DELAY_MS * attemptNumber;
logProgress(`${attemptLabel}: retrying in ${formatDuration(delayMs)}`);
await sleep(delayMs);
}
}
}
throw lastError ?? new Error("translation failed");
}
type NativeTranslationEntry = {
id: string;
source: string;
sourcePath: string;
sourceContext?: string;
};
export async function translateNativeEntries(
entries: readonly NativeTranslationEntry[],
targetLocale: string,
glossary: readonly GlossaryEntry[] = [],
validateTranslation?: TranslationValidator,
): Promise<Map<string, string>> {
if (!hasTranslationProvider()) {
throw new Error("native app translation requires OPENAI_API_KEY or ANTHROPIC_API_KEY");
}
const pending = entries.map((entry) => ({
cacheKey: cacheKey(entry.id, hashControlUiTranslationText(entry.source), targetLocale),
key: entry.id,
text: entry.source,
textHash: hashControlUiTranslationText(entry.source),
sourcePath: entry.sourcePath,
sourceContext: entry.sourceContext,
}));
const batches = buildTranslationBatches(pending);
const clientAccess = createTranslationClientAccess(targetLocale, glossary);
try {
const translated = new Map<string, string>();
for (const [batchIndex, batch] of batches.entries()) {
const result = await translateBatch(clientAccess, batch, {
locale: targetLocale,
localeCount: 1,
localeIndex: 1,
batchCount: batches.length,
batchIndex: batchIndex + 1,
validateTranslation,
});
for (const [id, value] of result) {
translated.set(id, value);
}
}
return translated;
} finally {
await clientAccess.resetClient();
}
}
type SyncOutcome = {
changed: boolean;
fallbackCount: number;
locale: string;
wrote: boolean;
};
export function assertNoControlUiFallbacks(
outcomes: ReadonlyArray<Pick<SyncOutcome, "fallbackCount" | "locale">>,
) {
const fallbackLocales = outcomes.filter((outcome) => outcome.fallbackCount > 0);
if (fallbackLocales.length === 0) {
return;
}
throw new Error(
[
"control-ui-i18n generated locales still contain English fallbacks.",
...fallbackLocales.map(
(outcome) => `${outcome.locale}: ${outcome.fallbackCount} fallback keys`,
),
].join("\n"),
);
}
async function syncLocale(
entry: LocaleEntry,
options: {
allowTranslate: boolean;
checkOnly: boolean;
force: boolean;
write: boolean;
refreshKeys: ReadonlySet<string>;
},
context: LocaleRunContext,
) {
const localeLabel = formatLocaleLabel(entry.locale, context);
const localeStartedAt = Date.now();
const { loadControlUiSourceCatalog, readControlUiSourceCatalog } =
await import("./lib/control-ui-i18n-catalog.ts");
const sourceRaw = await readControlUiSourceCatalog();
const sourceHash = sha256(sourceRaw);
const sourceMap = loadControlUiSourceCatalog();
const sourceFlat = flattenTranslations(sourceMap);
const tm = loadControlUiTranslationMemory(tmPath(entry));
const existingMap = materializeControlUiLocaleCatalog(sourceFlat, tm);
const existingFlat = flattenTranslations(existingMap);
// Placeholder changes invalidate the old translation even when the key stays
// stable. Treat it as pending so the locale bot can repair source-only PRs.
const reusableExistingFlat = filterPlaceholderCompatibleTranslations(sourceFlat, existingFlat);
const previousMeta = await loadMeta(metaPath(entry));
const glossaryFilePath = glossaryPath(entry);
const glossary = await loadGlossary(glossaryFilePath);
const allowTranslate = options.allowTranslate;
const plan = createControlUiLocaleSyncPlan({
allowTranslate,
cacheKeyFor: (key, textHash) => cacheKey(key, textHash, entry.locale),
entry,
existingFlat: reusableExistingFlat,
force: options.force,
refreshKeys: options.refreshKeys,
hashText: hashControlUiTranslationText,
previousMeta,
sourceFlat,
sourceHash,
translationMemory: tm,
});
if (options.refreshKeys.size > 0 && !allowTranslate) {
throw new Error("--refresh-key requires a configured translation provider");
}
// Writing NEW English fallbacks trips the shipped-fallback CI gate
// (test/scripts/control-ui-i18n.test.ts), and post-merge translation is owned
// by the control-ui-locale-refresh workflow. An unauthenticated local sync
// must fail here instead of silently recording fallback bundles; refreshing
// already-recorded fallback copy (force mode) stays allowed.
if (!allowTranslate && options.write && !options.checkOnly && !isProviderAuthOptional()) {
if (plan.newFallbackCount > 0) {
throw new Error(
`${localeLabel}: ${plan.newFallbackCount} new key(s) need translation but no provider is configured. ` +
`Commit only locales/en.ts and let the control-ui-locale-refresh workflow translate after merge, ` +
`or export ANTHROPIC_API_KEY/OPENAI_API_KEY and rerun. ` +
`Set ${ENV_AUTH_OPTIONAL}=1 to record English fallbacks anyway.`,
);
}
}
if (allowTranslate && plan.pending.length > 0) {
const batches = buildTranslationBatches(plan.pending);
const batchCount = batches.length;
logProgress(
`${localeLabel}: start keys=${sourceFlat.size} pending=${plan.pending.length} batches=${batchCount} thinking=${resolveThinkingLevel()} timeout=${formatDuration(resolvePromptTimeoutMs())} batch_chars=${resolveBatchCharBudget()}`,
);
const clientAccess = createTranslationClientAccess(entry.locale, glossary);
try {
for (const [batchIndex, batch] of batches.entries()) {
const translated = await translateBatch(clientAccess, batch, {
...context,
batchCount,
batchIndex: batchIndex + 1,
locale: entry.locale,
});
plan.recordTranslations(batch, translated, {
sourceLocale: SOURCE_LOCALE,
updatedAt: () => new Date().toISOString(),
});
}
} catch (error) {
const failure = error instanceof Error ? error : new Error(String(error));
if (
options.refreshKeys.size === 0 &&
isProviderAuthOptional() &&
isProviderAuthError(failure)
) {
logProgress(`${localeLabel}: translation provider auth failed; skipping refresh`);
return {
changed: false,
fallbackCount: previousMeta?.fallbackKeys.length ?? 0,
locale: entry.locale,
wrote: false,
} satisfies SyncOutcome;
}
throw failure;
} finally {
await clientAccess.resetClient();
}
} else if (allowTranslate) {
logProgress(
`${localeLabel}: no translation work needed (all keys reused from cache or existing files)`,
);
} else {
logProgress(`${localeLabel}: no provider configured, using English fallback for pending keys`);
}
// Do not infer fallback state from source-text equality alone.
// Product names, config keys, and other intentional carry-through strings may
// legitimately stay identical to English. Track fallback keys from actual
// fallback decisions and previous fallback metadata instead.
const artifacts = plan.render({
defaultGlossary: DEFAULT_GLOSSARY,
generatedAt: new Date().toISOString(),
glossary,
workflow: CONTROL_UI_I18N_WORKFLOW,
});
assertPlaceholderParity(sourceFlat, artifacts.nextFlat, entry.locale);
const expectedMeta = artifacts.meta;
const expectedGlossary = artifacts.glossary;
const expectedTm = artifacts.translationMemory;
const currentMeta = existsSync(metaPath(entry)) ? await readFile(metaPath(entry), "utf8") : "";
const currentGlossary = existsSync(glossaryFilePath)
? await readFile(glossaryFilePath, "utf8")
: "";
const currentTm = existsSync(tmPath(entry)) ? await readFile(tmPath(entry), "utf8") : "";
const changed =
currentMeta !== expectedMeta ||
currentGlossary !== expectedGlossary ||
currentTm !== expectedTm;
if (
!changed ||
(previousMeta?.sourceHash === sourceHash &&
!options.force &&
!options.checkOnly &&
!options.write)
) {
logProgress(
`${localeLabel}: done changed=${changed} fallbacks=${artifacts.fallbackCount} elapsed=${formatDuration(Date.now() - localeStartedAt)}`,
);
return {
changed,
fallbackCount: artifacts.fallbackCount,
locale: entry.locale,
wrote: false,
} satisfies SyncOutcome;
}
if (!options.checkOnly && options.write) {
await mkdir(I18N_ASSETS_DIR, { recursive: true });
await writeFile(metaPath(entry), expectedMeta, "utf8");
await writeFile(glossaryFilePath, expectedGlossary, "utf8");
if (expectedTm) {
await writeFile(tmPath(entry), expectedTm, "utf8");
} else if (existsSync(tmPath(entry))) {
await writeFile(tmPath(entry), "", "utf8");
}
}
logProgress(
`${localeLabel}: done changed=${changed} fallbacks=${artifacts.fallbackCount} elapsed=${formatDuration(Date.now() - localeStartedAt)}${!options.checkOnly && options.write && changed ? " wrote" : ""}`,
);
return {
changed,
fallbackCount: artifacts.fallbackCount,
locale: entry.locale,
wrote: !options.checkOnly && options.write && changed,
} satisfies SyncOutcome;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const {
syncControlUiCatalogFallbackBaseline,
verifyControlUiGeneratedCatalogs,
verifyRuntimeLocaleConfig,
} = await import("./control-ui-i18n-verify.ts");
if (args.command === "check") {
await verifyControlUiGeneratedCatalogs({
checkOnly: true,
write: false,
});
} else {
await verifyRuntimeLocaleConfig();
}
if (args.command === "sync" && args.write && !args.localeFilter) {
const { syncControlUiRawCopyBaseline } = await import("./lib/control-ui-i18n-raw-copy.ts");
await syncControlUiRawCopyBaseline({
checkOnly: false,
write: args.write,
});
}
const entries = args.localeFilter
? LOCALE_ENTRIES.filter((entry) => entry.locale === args.localeFilter)
: [...LOCALE_ENTRIES];
if (entries.length === 0) {
throw new Error(`unknown locale: ${args.localeFilter}`);
}
const allowTranslate = args.command === "sync" && hasTranslationProvider();
logProgress(
`command=${args.command} locales=${entries.length} translation=${allowTranslate ? "enabled" : "disabled"} thinking=${allowTranslate ? resolveThinkingLevel() : "n/a"} timeout=${formatDuration(resolvePromptTimeoutMs())} batch_chars=${resolveBatchCharBudget()}`,
);
const outcomes: SyncOutcome[] = [];
for (const [index, entry] of entries.entries()) {
const outcome = await syncLocale(
entry,
{
allowTranslate,
checkOnly: args.command === "check",
force: args.force,
refreshKeys: args.refreshKeys,
write: args.write,
},
{
localeCount: entries.length,
localeIndex: index + 1,
},
);
outcomes.push(outcome);
}
const changed = outcomes.filter((outcome) => outcome.changed);
const summary = outcomes
.map(
(outcome) =>
`${outcome.locale}: ${outcome.changed ? "dirty" : "clean"} (fallbacks=${outcome.fallbackCount}${outcome.wrote ? ", wrote" : ""})`,
)
.join("\n");
process.stdout.write(`${summary}\n`);
if (args.command === "sync" && args.write) {
await syncControlUiCatalogFallbackBaseline({
// A scoped matrix worker can observe unsynced sibling locales. The final
// aggregate sync still rebuilds and validates the complete catalog.
allowCatalogDrift: Boolean(args.localeFilter),
checkOnly: false,
write: true,
});
}
if (args.command === "check") {
assertNoControlUiFallbacks(outcomes);
if (changed.length > 0) {
throw new Error(
[
"control-ui-i18n drift detected.",
"Run `node --import tsx scripts/control-ui-i18n.ts sync --write` and commit the results.",
].join("\n"),
);
}
}
if (args.command === "sync" && !args.write && changed.length > 0) {
process.stdout.write(
"dry-run only. re-run with `node --import tsx scripts/control-ui-i18n.ts sync --write` to update files.\n",
);
}
}
function isCliEntrypoint() {
const entrypoint = process.argv[1];
return Boolean(entrypoint && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href);
}
if (isCliEntrypoint()) {
await main().catch((error: unknown) => {
console.error(
formatErrorMessage(error, {
// Keep failure reporting independent of Gateway logging configuration.
redact: (text) => {
let redacted = text;
for (const name of [
ENV_MODEL,
ENV_FALLBACK_MODEL,
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
]) {
const secret = process.env[name]?.trim();
if (secret) {
redacted = redacted.replaceAll(new RegExp(escapeRegExp(secret), "gi"), "[redacted]");
}
}
return redacted;
},
}),
);
process.exit(1);
});
}