mirror of https://github.com/openclaw/openclaw.git
1322 lines
40 KiB
TypeScript
1322 lines
40 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { existsSync } from "node:fs";
|
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
import { homedir } from "node:os";
|
|
import path from "node:path";
|
|
import { createInterface } from "node:readline";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
|
interface TranslationMap {
|
|
[key: string]: string | TranslationMap;
|
|
}
|
|
|
|
type LocaleEntry = {
|
|
exportName: string;
|
|
fileName: string;
|
|
languageKey: string;
|
|
locale: string;
|
|
};
|
|
|
|
type GlossaryEntry = {
|
|
source: string;
|
|
target: string;
|
|
};
|
|
|
|
type TranslationMemoryEntry = {
|
|
cache_key: string;
|
|
model: string;
|
|
provider: string;
|
|
segment_id: string;
|
|
source_path: string;
|
|
src_lang: string;
|
|
text: string;
|
|
text_hash: string;
|
|
tgt_lang: string;
|
|
translated: string;
|
|
updated_at: string;
|
|
};
|
|
|
|
type LocaleMeta = {
|
|
fallbackKeys: string[];
|
|
generatedAt: string;
|
|
locale: string;
|
|
model: string;
|
|
provider: string;
|
|
sourceHash: string;
|
|
totalKeys: number;
|
|
translatedKeys: number;
|
|
workflow: number;
|
|
};
|
|
|
|
type TranslationBatchItem = {
|
|
cacheKey: string;
|
|
key: string;
|
|
text: string;
|
|
textHash: string;
|
|
};
|
|
|
|
const CONTROL_UI_I18N_WORKFLOW = 1;
|
|
const DEFAULT_OPENAI_MODEL = "gpt-5.4";
|
|
const DEFAULT_ANTHROPIC_MODEL = "claude-opus-4-6";
|
|
const DEFAULT_PROVIDER = "openai";
|
|
const DEFAULT_PI_PACKAGE_VERSION = "0.58.3";
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = path.resolve(HERE, "..");
|
|
const LOCALES_DIR = path.join(ROOT, "ui", "src", "i18n", "locales");
|
|
const I18N_ASSETS_DIR = path.join(ROOT, "ui", "src", "i18n", ".i18n");
|
|
const SOURCE_LOCALE_PATH = path.join(LOCALES_DIR, "en.ts");
|
|
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_THINKING = "OPENCLAW_CONTROL_UI_I18N_THINKING";
|
|
const ENV_PI_EXECUTABLE = "OPENCLAW_CONTROL_UI_I18N_PI_EXECUTABLE";
|
|
const ENV_PI_ARGS = "OPENCLAW_CONTROL_UI_I18N_PI_ARGS";
|
|
const ENV_PI_PACKAGE_VERSION = "OPENCLAW_CONTROL_UI_I18N_PI_PACKAGE_VERSION";
|
|
const ENV_BATCH_CHAR_BUDGET = "OPENCLAW_CONTROL_UI_I18N_BATCH_CHAR_BUDGET";
|
|
const ENV_PROMPT_TIMEOUT = "OPENCLAW_CONTROL_UI_I18N_PROMPT_TIMEOUT";
|
|
|
|
const LOCALE_ENTRIES: readonly LocaleEntry[] = [
|
|
{ locale: "zh-CN", fileName: "zh-CN.ts", exportName: "zh_CN", languageKey: "zhCN" },
|
|
{ locale: "zh-TW", fileName: "zh-TW.ts", exportName: "zh_TW", languageKey: "zhTW" },
|
|
{ locale: "pt-BR", fileName: "pt-BR.ts", exportName: "pt_BR", languageKey: "ptBR" },
|
|
{ locale: "de", fileName: "de.ts", exportName: "de", languageKey: "de" },
|
|
{ locale: "es", fileName: "es.ts", exportName: "es", languageKey: "es" },
|
|
{ locale: "ja-JP", fileName: "ja-JP.ts", exportName: "ja_JP", languageKey: "jaJP" },
|
|
{ locale: "ko", fileName: "ko.ts", exportName: "ko", languageKey: "ko" },
|
|
{ locale: "fr", fileName: "fr.ts", exportName: "fr", languageKey: "fr" },
|
|
{ locale: "tr", fileName: "tr.ts", exportName: "tr", languageKey: "tr" },
|
|
{ locale: "id", fileName: "id.ts", exportName: "id", languageKey: "id" },
|
|
{ locale: "pl", fileName: "pl.ts", exportName: "pl", languageKey: "pl" },
|
|
];
|
|
|
|
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]",
|
|
].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;
|
|
|
|
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;
|
|
default:
|
|
usage();
|
|
}
|
|
}
|
|
|
|
if (command === "check" && write) {
|
|
usage();
|
|
}
|
|
|
|
return {
|
|
command,
|
|
force,
|
|
localeFilter,
|
|
write,
|
|
};
|
|
}
|
|
|
|
function prettyLanguageLabel(locale: string): string {
|
|
switch (locale) {
|
|
case "en":
|
|
return "English";
|
|
case "zh-CN":
|
|
return "Simplified Chinese";
|
|
case "zh-TW":
|
|
return "Traditional Chinese";
|
|
case "pt-BR":
|
|
return "Brazilian Portuguese";
|
|
case "ja-JP":
|
|
return "Japanese";
|
|
case "ko":
|
|
return "Korean";
|
|
case "fr":
|
|
return "French";
|
|
case "tr":
|
|
return "Turkish";
|
|
case "id":
|
|
return "Indonesian";
|
|
case "pl":
|
|
return "Polish";
|
|
case "de":
|
|
return "German";
|
|
case "es":
|
|
return "Spanish";
|
|
default:
|
|
return locale;
|
|
}
|
|
}
|
|
|
|
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 normalizeText(text: string): string {
|
|
return text.trim().split(/\s+/).join(" ");
|
|
}
|
|
|
|
function sha256(input: string | Uint8Array): string {
|
|
return createHash("sha256").update(input).digest("hex");
|
|
}
|
|
|
|
function hashText(text: string): string {
|
|
return sha256(normalizeText(text));
|
|
}
|
|
|
|
function cacheNamespace(): string {
|
|
return [
|
|
`wf=${CONTROL_UI_I18N_WORKFLOW}`,
|
|
"engine=pi",
|
|
`provider=${resolveConfiguredProvider()}`,
|
|
`model=${resolveConfiguredModel()}`,
|
|
].join("|");
|
|
}
|
|
|
|
function cacheKey(segmentId: string, textHash: string, targetLocale: string): string {
|
|
return sha256([cacheNamespace(), SOURCE_LOCALE, targetLocale, segmentId, textHash].join("|"));
|
|
}
|
|
|
|
function localeFilePath(entry: LocaleEntry): string {
|
|
return path.join(LOCALES_DIR, entry.fileName);
|
|
}
|
|
|
|
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`);
|
|
}
|
|
|
|
async function importLocaleModule<T>(filePath: string): Promise<T> {
|
|
const stats = await stat(filePath);
|
|
const href = `${pathToFileURL(filePath).href}?ts=${stats.mtimeMs}`;
|
|
return (await import(href)) as T;
|
|
}
|
|
|
|
async function loadLocaleMap(filePath: string, exportName: string): Promise<TranslationMap | null> {
|
|
if (!existsSync(filePath)) {
|
|
return null;
|
|
}
|
|
const mod = await importLocaleModule<Record<string, TranslationMap>>(filePath);
|
|
return mod[exportName] ?? null;
|
|
}
|
|
|
|
function flattenTranslations(value: TranslationMap, prefix = "", out = new Map<string, string>()) {
|
|
for (const [key, nested] of Object.entries(value)) {
|
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
if (typeof nested === "string") {
|
|
out.set(fullKey, nested);
|
|
continue;
|
|
}
|
|
flattenTranslations(nested, fullKey, out);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function setNestedValue(root: TranslationMap, dottedKey: string, value: string) {
|
|
const parts = dottedKey.split(".");
|
|
let cursor: TranslationMap = root;
|
|
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
const key = parts[index];
|
|
const next = cursor[key];
|
|
if (!next || typeof next === "string") {
|
|
const replacement: TranslationMap = {};
|
|
cursor[key] = replacement;
|
|
cursor = replacement;
|
|
continue;
|
|
}
|
|
cursor = next;
|
|
}
|
|
cursor[parts.at(-1)!] = value;
|
|
}
|
|
|
|
function compareStringArrays(left: string[], right: string[]) {
|
|
if (left.length !== right.length) {
|
|
return false;
|
|
}
|
|
return left.every((value, index) => value === right[index]);
|
|
}
|
|
|
|
function isIdentifier(value: string): boolean {
|
|
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value);
|
|
}
|
|
|
|
function renderTranslationValue(value: TranslationValue, indent = 0): string {
|
|
if (typeof value === "string") {
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
const entries = Object.entries(value);
|
|
if (entries.length === 0) {
|
|
return "{}";
|
|
}
|
|
|
|
const pad = " ".repeat(indent);
|
|
const innerPad = " ".repeat(indent + 1);
|
|
return `{\n${entries
|
|
.map(([key, nested]) => {
|
|
const renderedKey = isIdentifier(key) ? key : JSON.stringify(key);
|
|
return `${innerPad}${renderedKey}: ${renderTranslationValue(nested, indent + 1)},`;
|
|
})
|
|
.join("\n")}\n${pad}}`;
|
|
}
|
|
|
|
function renderLocaleModule(entry: LocaleEntry, value: TranslationMap): string {
|
|
return [
|
|
'import type { TranslationMap } from "../lib/types.ts";',
|
|
"",
|
|
"// Generated by scripts/control-ui-i18n.ts.",
|
|
`export const ${entry.exportName}: TranslationMap = ${renderTranslationValue(value)};`,
|
|
"",
|
|
].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 : [];
|
|
}
|
|
|
|
function renderGlossary(entries: readonly GlossaryEntry[]): string {
|
|
return `${JSON.stringify(entries, null, 2)}\n`;
|
|
}
|
|
|
|
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 renderMeta(meta: LocaleMeta): string {
|
|
return `${JSON.stringify(meta, null, 2)}\n`;
|
|
}
|
|
|
|
async function loadTranslationMemory(
|
|
filePath: string,
|
|
): Promise<Map<string, TranslationMemoryEntry>> {
|
|
const entries = new Map<string, TranslationMemoryEntry>();
|
|
if (!existsSync(filePath)) {
|
|
return entries;
|
|
}
|
|
const raw = await readFile(filePath, "utf8");
|
|
for (const line of raw.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) {
|
|
continue;
|
|
}
|
|
const parsed = JSON.parse(trimmed) as TranslationMemoryEntry;
|
|
if (parsed.cache_key && parsed.translated.trim()) {
|
|
entries.set(parsed.cache_key, parsed);
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function renderTranslationMemory(entries: Map<string, TranslationMemoryEntry>): string {
|
|
const ordered = [...entries.values()].toSorted((left, right) =>
|
|
left.cache_key.localeCompare(right.cache_key),
|
|
);
|
|
if (ordered.length === 0) {
|
|
return "";
|
|
}
|
|
return `${ordered.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
|
|
}
|
|
|
|
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 ${prettyLanguageLabel(SOURCE_LOCALE)} to ${prettyLanguageLabel(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 punctuation, ellipses, arrows, and casing when they are part of literal UI text.",
|
|
"- 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 buildBatchPrompt(items: readonly TranslationBatchItem[]): string {
|
|
const payload = Object.fromEntries(items.map((item) => [item.key, item.text]));
|
|
return [
|
|
"Translate this JSON object.",
|
|
"Return ONLY a JSON object with the same keys.",
|
|
"",
|
|
JSON.stringify(payload, null, 2),
|
|
].join("\n");
|
|
}
|
|
|
|
function sleep(ms: number) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function formatDuration(ms: number): string {
|
|
if (ms < 1_000) {
|
|
return `${Math.round(ms)}ms`;
|
|
}
|
|
if (ms < 60_000) {
|
|
return `${(ms / 1_000).toFixed(ms < 10_000 ? 1 : 0)}s`;
|
|
}
|
|
const totalSeconds = Math.round(ms / 1_000);
|
|
const minutes = Math.floor(totalSeconds / 60);
|
|
const seconds = totalSeconds % 60;
|
|
return `${minutes}m ${seconds}s`;
|
|
}
|
|
|
|
function logProgress(message: string) {
|
|
process.stdout.write(`control-ui-i18n: ${message}\n`);
|
|
}
|
|
|
|
function isPromptTimeoutError(error: Error): boolean {
|
|
return error.message.toLowerCase().includes("timed out");
|
|
}
|
|
|
|
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 items.reduce((total, item) => total + item.key.length + item.text.length + 8, 2);
|
|
}
|
|
|
|
type PiCommand = {
|
|
args: string[];
|
|
executable: string;
|
|
};
|
|
|
|
function resolvePiPackageVersion(): string {
|
|
return process.env[ENV_PI_PACKAGE_VERSION]?.trim() || DEFAULT_PI_PACKAGE_VERSION;
|
|
}
|
|
|
|
function getPiRuntimeDir() {
|
|
return path.join(
|
|
homedir(),
|
|
".cache",
|
|
"openclaw",
|
|
"control-ui-i18n",
|
|
"pi-runtime",
|
|
resolvePiPackageVersion(),
|
|
);
|
|
}
|
|
|
|
async function resolvePiCommand(): Promise<PiCommand> {
|
|
const explicitExecutable = process.env[ENV_PI_EXECUTABLE]?.trim();
|
|
if (explicitExecutable) {
|
|
return {
|
|
executable: explicitExecutable,
|
|
args: process.env[ENV_PI_ARGS]?.trim().split(/\s+/).filter(Boolean) ?? [],
|
|
};
|
|
}
|
|
|
|
const pathEntries = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
|
|
for (const entry of pathEntries) {
|
|
const candidate = path.join(entry, process.platform === "win32" ? "pi.cmd" : "pi");
|
|
if (existsSync(candidate)) {
|
|
return { executable: candidate, args: [] };
|
|
}
|
|
}
|
|
|
|
const runtimeDir = getPiRuntimeDir();
|
|
const cliPath = path.join(
|
|
runtimeDir,
|
|
"node_modules",
|
|
"@mariozechner",
|
|
"pi-coding-agent",
|
|
"dist",
|
|
"cli.js",
|
|
);
|
|
if (!existsSync(cliPath)) {
|
|
await mkdir(runtimeDir, { recursive: true });
|
|
await runProcess(
|
|
"npm",
|
|
[
|
|
"install",
|
|
"--silent",
|
|
"--no-audit",
|
|
"--no-fund",
|
|
`@mariozechner/pi-coding-agent@${resolvePiPackageVersion()}`,
|
|
],
|
|
{
|
|
cwd: runtimeDir,
|
|
rejectOnFailure: true,
|
|
},
|
|
);
|
|
}
|
|
return { executable: "node", args: [cliPath] };
|
|
}
|
|
|
|
type RunProcessOptions = {
|
|
cwd?: string;
|
|
input?: string;
|
|
rejectOnFailure?: boolean;
|
|
};
|
|
|
|
async function runProcess(
|
|
executable: string,
|
|
args: string[],
|
|
options: RunProcessOptions = {},
|
|
): Promise<{ code: number; stderr: string; stdout: string }> {
|
|
return await new Promise((resolve, reject) => {
|
|
const child = spawn(executable, args, {
|
|
cwd: options.cwd ?? ROOT,
|
|
env: process.env,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += String(chunk);
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += String(chunk);
|
|
});
|
|
child.once("error", reject);
|
|
if (options.input !== undefined) {
|
|
child.stdin.end(options.input);
|
|
} else {
|
|
child.stdin.end();
|
|
}
|
|
child.once("close", (code) => {
|
|
if ((code ?? 1) !== 0 && options.rejectOnFailure) {
|
|
reject(
|
|
new Error(`${executable} ${args.join(" ")} failed: ${stderr.trim() || stdout.trim()}`),
|
|
);
|
|
return;
|
|
}
|
|
resolve({ code: code ?? 1, stderr, stdout });
|
|
});
|
|
});
|
|
}
|
|
|
|
async function formatGeneratedTypeScript(filePath: string, source: string): Promise<string> {
|
|
const result = await runProcess(
|
|
"pnpm",
|
|
["exec", "oxfmt", "--stdin-filepath", path.relative(ROOT, filePath)],
|
|
{
|
|
input: source,
|
|
rejectOnFailure: true,
|
|
},
|
|
);
|
|
return result.stdout;
|
|
}
|
|
|
|
type PendingPrompt = {
|
|
id: string;
|
|
reject: (reason?: unknown) => void;
|
|
resolve: (value: string) => void;
|
|
responseReceived: boolean;
|
|
};
|
|
|
|
type LocaleRunContext = {
|
|
localeCount: number;
|
|
localeIndex: number;
|
|
};
|
|
|
|
type TranslationBatchContext = LocaleRunContext & {
|
|
batchCount: number;
|
|
batchIndex: number;
|
|
locale: string;
|
|
splitDepth?: number;
|
|
segmentLabel?: string;
|
|
};
|
|
|
|
type ClientAccess = {
|
|
getClient: () => Promise<PiRpcClient>;
|
|
resetClient: () => Promise<void>;
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
class PiRpcClient {
|
|
private readonly stderrChunks: string[] = [];
|
|
private closed = false;
|
|
private pending: PendingPrompt | null = null;
|
|
private readonly process;
|
|
private readonly stdin;
|
|
private requestCount = 0;
|
|
private sequence = Promise.resolve();
|
|
|
|
private constructor(processHandle: ReturnType<typeof spawn>) {
|
|
this.process = processHandle;
|
|
this.stdin = processHandle.stdin;
|
|
}
|
|
|
|
static async create(systemPrompt: string): Promise<PiRpcClient> {
|
|
const command = await resolvePiCommand();
|
|
const args = [
|
|
...command.args,
|
|
"--mode",
|
|
"rpc",
|
|
"--provider",
|
|
resolveConfiguredProvider(),
|
|
"--model",
|
|
resolveConfiguredModel(),
|
|
"--thinking",
|
|
resolveThinkingLevel(),
|
|
"--no-session",
|
|
"--system-prompt",
|
|
systemPrompt,
|
|
];
|
|
const child = spawn(command.executable, args, {
|
|
cwd: ROOT,
|
|
env: process.env,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
|
|
const client = new PiRpcClient(child);
|
|
client.bindProcess();
|
|
await client.waitForBoot();
|
|
return client;
|
|
}
|
|
|
|
private bindProcess() {
|
|
const stderr = createInterface({ input: this.process.stderr });
|
|
stderr.on("line", (line) => {
|
|
this.stderrChunks.push(line);
|
|
});
|
|
|
|
const stdout = createInterface({ input: this.process.stdout });
|
|
stdout.on("line", (line) => {
|
|
void this.handleStdoutLine(line);
|
|
});
|
|
|
|
this.process.once("error", (error) => {
|
|
this.rejectPending(error);
|
|
});
|
|
|
|
this.process.once("close", () => {
|
|
this.closed = true;
|
|
this.rejectPending(
|
|
new Error(`pi process closed${this.stderr() ? ` (${this.stderr()})` : ""}`),
|
|
);
|
|
});
|
|
}
|
|
|
|
private async waitForBoot() {
|
|
await sleep(150);
|
|
}
|
|
|
|
private stderr() {
|
|
return this.stderrChunks.join("\n").trim();
|
|
}
|
|
|
|
private rejectPending(error: Error) {
|
|
const pending = this.pending;
|
|
this.pending = null;
|
|
if (pending) {
|
|
pending.reject(error);
|
|
}
|
|
}
|
|
|
|
private async handleStdoutLine(line: string) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) {
|
|
return;
|
|
}
|
|
let parsed: Record<string, unknown>;
|
|
try {
|
|
parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const pending = this.pending;
|
|
if (!pending) {
|
|
return;
|
|
}
|
|
|
|
switch (parsed.type) {
|
|
case "response": {
|
|
if (parsed.id !== pending.id) {
|
|
return;
|
|
}
|
|
const success = parsed.success === true;
|
|
if (!success) {
|
|
const errorText =
|
|
typeof parsed.error === "string" && parsed.error.trim()
|
|
? parsed.error.trim()
|
|
: "pi prompt failed";
|
|
this.pending = null;
|
|
pending.reject(new Error(errorText));
|
|
return;
|
|
}
|
|
pending.responseReceived = true;
|
|
return;
|
|
}
|
|
case "agent_end": {
|
|
try {
|
|
const result = extractTranslationResult(parsed);
|
|
this.pending = null;
|
|
pending.resolve(result);
|
|
} catch (error) {
|
|
this.pending = null;
|
|
pending.reject(error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async prompt(message: string, label: string): Promise<string> {
|
|
this.sequence = this.sequence.then(async () => {
|
|
if (this.closed) {
|
|
throw new Error(`pi process unavailable${this.stderr() ? ` (${this.stderr()})` : ""}`);
|
|
}
|
|
|
|
const id = `req-${++this.requestCount}`;
|
|
const payload = JSON.stringify({ type: "prompt", id, message });
|
|
const timeoutMs = resolvePromptTimeoutMs();
|
|
const startedAt = Date.now();
|
|
|
|
return await new Promise<string>((resolve, reject) => {
|
|
const heartbeat = setInterval(() => {
|
|
const responseState = this.pending?.responseReceived
|
|
? "response=received"
|
|
: "response=pending";
|
|
logProgress(
|
|
`${label}: still waiting (${formatDuration(Date.now() - startedAt)} / ${formatDuration(timeoutMs)}, ${responseState})`,
|
|
);
|
|
}, PROGRESS_HEARTBEAT_MS);
|
|
const timer = setTimeout(() => {
|
|
if (this.pending?.id === id) {
|
|
this.pending = null;
|
|
clearInterval(heartbeat);
|
|
void this.close();
|
|
const stderr = this.stderr();
|
|
reject(
|
|
new Error(
|
|
`${label}: translation prompt timed out after ${timeoutMs}ms${stderr ? ` (pi stderr: ${stderr})` : ""}`,
|
|
),
|
|
);
|
|
}
|
|
}, timeoutMs);
|
|
|
|
this.pending = {
|
|
id,
|
|
reject: (reason) => {
|
|
clearTimeout(timer);
|
|
clearInterval(heartbeat);
|
|
reject(reason);
|
|
},
|
|
resolve: (value) => {
|
|
clearTimeout(timer);
|
|
clearInterval(heartbeat);
|
|
resolve(value);
|
|
},
|
|
responseReceived: false,
|
|
};
|
|
|
|
this.stdin.write(`${payload}\n`, (error) => {
|
|
if (!error) {
|
|
return;
|
|
}
|
|
clearTimeout(timer);
|
|
clearInterval(heartbeat);
|
|
if (this.pending?.id === id) {
|
|
this.pending = null;
|
|
}
|
|
reject(error);
|
|
});
|
|
});
|
|
});
|
|
|
|
return (await this.sequence) as string;
|
|
}
|
|
|
|
async close() {
|
|
if (this.closed) {
|
|
return;
|
|
}
|
|
this.closed = true;
|
|
this.stdin.end();
|
|
this.process.kill("SIGTERM");
|
|
await sleep(150);
|
|
if (!this.process.killed) {
|
|
this.process.kill("SIGKILL");
|
|
}
|
|
}
|
|
}
|
|
|
|
function extractTranslationResult(payload: Record<string, unknown>): string {
|
|
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
const message = messages[index];
|
|
if (!message || typeof message !== "object") {
|
|
continue;
|
|
}
|
|
if ((message as { role?: string }).role !== "assistant") {
|
|
continue;
|
|
}
|
|
const errorMessage = (message as { errorMessage?: string }).errorMessage;
|
|
const stopReason = (message as { stopReason?: string }).stopReason;
|
|
if (errorMessage || stopReason === "error") {
|
|
throw new Error(errorMessage?.trim() || "pi error");
|
|
}
|
|
const content = (message as { content?: unknown }).content;
|
|
if (typeof content === "string") {
|
|
return content;
|
|
}
|
|
if (Array.isArray(content)) {
|
|
return content
|
|
.filter((block): block is { type?: string; text?: string } =>
|
|
Boolean(block && typeof block === "object"),
|
|
)
|
|
.map((block) => (block.type === "text" && typeof block.text === "string" ? block.text : ""))
|
|
.join("");
|
|
}
|
|
}
|
|
throw new Error("assistant translation not found");
|
|
}
|
|
|
|
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;
|
|
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}`);
|
|
try {
|
|
const raw = await (
|
|
await clientAccess.getClient()
|
|
).prompt(buildBatchPrompt(items), attemptLabel);
|
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
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}`);
|
|
}
|
|
translated.set(item.key, value);
|
|
}
|
|
logProgress(`${attemptLabel}: done (${formatDuration(Date.now() - startedAt)})`);
|
|
return translated;
|
|
} catch (error) {
|
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
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 SyncOutcome = {
|
|
changed: boolean;
|
|
fallbackCount: number;
|
|
locale: string;
|
|
wrote: boolean;
|
|
};
|
|
|
|
async function syncLocale(
|
|
entry: LocaleEntry,
|
|
options: { checkOnly: boolean; force: boolean; write: boolean },
|
|
context: LocaleRunContext,
|
|
) {
|
|
const localeLabel = formatLocaleLabel(entry.locale, context);
|
|
const localeStartedAt = Date.now();
|
|
const sourceRaw = await readFile(SOURCE_LOCALE_PATH, "utf8");
|
|
const sourceHash = sha256(sourceRaw);
|
|
const sourceMap = (await loadLocaleMap(SOURCE_LOCALE_PATH, "en")) ?? {};
|
|
const sourceFlat = flattenTranslations(sourceMap);
|
|
const existingPath = localeFilePath(entry);
|
|
const existingMap = (await loadLocaleMap(existingPath, entry.exportName)) ?? {};
|
|
const existingFlat = flattenTranslations(existingMap);
|
|
const previousMeta = await loadMeta(metaPath(entry));
|
|
const previousFallbackKeys = new Set(previousMeta?.fallbackKeys ?? []);
|
|
const glossaryFilePath = glossaryPath(entry);
|
|
const glossary = await loadGlossary(glossaryFilePath);
|
|
const tm = await loadTranslationMemory(tmPath(entry));
|
|
const allowTranslate = hasTranslationProvider();
|
|
|
|
const nextFlat = new Map<string, string>();
|
|
const pending: TranslationBatchItem[] = [];
|
|
const fallbackKeys: string[] = [];
|
|
|
|
for (const [key, text] of sourceFlat.entries()) {
|
|
const textHash = hashText(text);
|
|
const segmentCacheKey = cacheKey(key, textHash, entry.locale);
|
|
const cached = tm.get(segmentCacheKey);
|
|
const existing = existingFlat.get(key);
|
|
const shouldRefreshFallback = previousFallbackKeys.has(key);
|
|
|
|
if (cached && !(allowTranslate && shouldRefreshFallback)) {
|
|
nextFlat.set(key, cached.translated);
|
|
if (shouldRefreshFallback) {
|
|
fallbackKeys.push(key);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (existing !== undefined && !(allowTranslate && shouldRefreshFallback)) {
|
|
nextFlat.set(key, existing);
|
|
if (shouldRefreshFallback) {
|
|
fallbackKeys.push(key);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
pending.push({
|
|
cacheKey: segmentCacheKey,
|
|
key,
|
|
text,
|
|
textHash,
|
|
});
|
|
}
|
|
|
|
if (allowTranslate && pending.length > 0) {
|
|
const batches = buildTranslationBatches(pending);
|
|
const batchCount = batches.length;
|
|
logProgress(
|
|
`${localeLabel}: start keys=${sourceFlat.size} pending=${pending.length} batches=${batchCount} provider=${resolveConfiguredProvider()} model=${resolveConfiguredModel()} thinking=${resolveThinkingLevel()} timeout=${formatDuration(resolvePromptTimeoutMs())} batch_chars=${resolveBatchCharBudget()}`,
|
|
);
|
|
let client: PiRpcClient | null = null;
|
|
const clientAccess: ClientAccess = {
|
|
async getClient() {
|
|
if (!client) {
|
|
client = await PiRpcClient.create(buildSystemPrompt(entry.locale, glossary));
|
|
}
|
|
return client;
|
|
},
|
|
async resetClient() {
|
|
if (!client) {
|
|
return;
|
|
}
|
|
await client.close();
|
|
client = null;
|
|
},
|
|
};
|
|
try {
|
|
for (const [batchIndex, batch] of batches.entries()) {
|
|
const translated = await translateBatch(clientAccess, batch, {
|
|
...context,
|
|
batchCount,
|
|
batchIndex: batchIndex + 1,
|
|
locale: entry.locale,
|
|
});
|
|
for (const item of batch) {
|
|
const value = translated.get(item.key);
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
nextFlat.set(item.key, value);
|
|
tm.set(item.cacheKey, {
|
|
cache_key: item.cacheKey,
|
|
model: resolveConfiguredModel(),
|
|
provider: resolveConfiguredProvider(),
|
|
segment_id: item.key,
|
|
source_path: `ui/src/i18n/locales/${entry.fileName}`,
|
|
src_lang: SOURCE_LOCALE,
|
|
text: item.text,
|
|
text_hash: item.textHash,
|
|
tgt_lang: entry.locale,
|
|
translated: value,
|
|
updated_at: new Date().toISOString(),
|
|
});
|
|
}
|
|
}
|
|
} 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`);
|
|
}
|
|
|
|
for (const item of pending) {
|
|
if (nextFlat.has(item.key)) {
|
|
continue;
|
|
}
|
|
const existing = existingFlat.get(item.key);
|
|
if (existing !== undefined && !options.force) {
|
|
nextFlat.set(item.key, existing);
|
|
if (previousFallbackKeys.has(item.key)) {
|
|
fallbackKeys.push(item.key);
|
|
}
|
|
continue;
|
|
}
|
|
nextFlat.set(item.key, item.text);
|
|
fallbackKeys.push(item.key);
|
|
}
|
|
|
|
if (!allowTranslate) {
|
|
fallbackKeys.length = 0;
|
|
for (const [key, sourceText] of sourceFlat.entries()) {
|
|
if ((nextFlat.get(key) ?? sourceText) === sourceText) {
|
|
fallbackKeys.push(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
const nextMap: TranslationMap = {};
|
|
for (const [key, value] of sourceFlat.entries()) {
|
|
setNestedValue(nextMap, key, nextFlat.get(key) ?? value);
|
|
}
|
|
|
|
const nextProvider = allowTranslate ? resolveConfiguredProvider() : "";
|
|
const nextModel = allowTranslate ? resolveConfiguredModel() : "";
|
|
const sortedFallbackKeys = [...new Set(fallbackKeys)].toSorted((left, right) =>
|
|
left.localeCompare(right),
|
|
);
|
|
const translatedKeys = sourceFlat.size - sortedFallbackKeys.length;
|
|
const semanticMetaChanged =
|
|
!previousMeta ||
|
|
previousMeta.locale !== entry.locale ||
|
|
previousMeta.sourceHash !== sourceHash ||
|
|
previousMeta.provider !== nextProvider ||
|
|
previousMeta.model !== nextModel ||
|
|
previousMeta.totalKeys !== sourceFlat.size ||
|
|
previousMeta.translatedKeys !== translatedKeys ||
|
|
previousMeta.workflow !== CONTROL_UI_I18N_WORKFLOW ||
|
|
!compareStringArrays(previousMeta.fallbackKeys, sortedFallbackKeys);
|
|
|
|
const nextMeta: LocaleMeta = {
|
|
fallbackKeys: sortedFallbackKeys,
|
|
generatedAt: semanticMetaChanged ? new Date().toISOString() : previousMeta.generatedAt,
|
|
locale: entry.locale,
|
|
model: nextModel,
|
|
provider: nextProvider,
|
|
sourceHash,
|
|
totalKeys: sourceFlat.size,
|
|
translatedKeys,
|
|
workflow: CONTROL_UI_I18N_WORKFLOW,
|
|
};
|
|
|
|
const expectedLocale = await formatGeneratedTypeScript(
|
|
existingPath,
|
|
renderLocaleModule(entry, nextMap),
|
|
);
|
|
const expectedMeta = renderMeta(nextMeta);
|
|
const expectedGlossary = renderGlossary(glossary.length === 0 ? DEFAULT_GLOSSARY : glossary);
|
|
const expectedTm = renderTranslationMemory(tm);
|
|
|
|
const currentLocale = existsSync(existingPath) ? await readFile(existingPath, "utf8") : "";
|
|
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 =
|
|
currentLocale !== expectedLocale ||
|
|
currentMeta !== expectedMeta ||
|
|
currentGlossary !== expectedGlossary ||
|
|
currentTm !== expectedTm;
|
|
|
|
if (
|
|
!changed ||
|
|
(previousMeta?.sourceHash === sourceHash &&
|
|
!options.force &&
|
|
!options.checkOnly &&
|
|
!options.write)
|
|
) {
|
|
logProgress(
|
|
`${localeLabel}: done changed=${changed} fallbacks=${nextMeta.fallbackKeys.length} elapsed=${formatDuration(Date.now() - localeStartedAt)}`,
|
|
);
|
|
return {
|
|
changed,
|
|
fallbackCount: nextMeta.fallbackKeys.length,
|
|
locale: entry.locale,
|
|
wrote: false,
|
|
} satisfies SyncOutcome;
|
|
}
|
|
|
|
if (!options.checkOnly && options.write) {
|
|
await mkdir(LOCALES_DIR, { recursive: true });
|
|
await mkdir(I18N_ASSETS_DIR, { recursive: true });
|
|
await writeFile(existingPath, expectedLocale, "utf8");
|
|
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=${nextMeta.fallbackKeys.length} elapsed=${formatDuration(Date.now() - localeStartedAt)}${!options.checkOnly && options.write && changed ? " wrote" : ""}`,
|
|
);
|
|
return {
|
|
changed,
|
|
fallbackCount: nextMeta.fallbackKeys.length,
|
|
locale: entry.locale,
|
|
wrote: !options.checkOnly && options.write && changed,
|
|
} satisfies SyncOutcome;
|
|
}
|
|
|
|
async function verifyRuntimeLocaleConfig() {
|
|
const registryRaw = await readFile(
|
|
path.join(ROOT, "ui", "src", "i18n", "lib", "registry.ts"),
|
|
"utf8",
|
|
);
|
|
const typesRaw = await readFile(path.join(ROOT, "ui", "src", "i18n", "lib", "types.ts"), "utf8");
|
|
const expectedLocaleSnippets = LOCALE_ENTRIES.map((entry) => entry.locale);
|
|
for (const locale of expectedLocaleSnippets) {
|
|
if (!registryRaw.includes(`"${locale}"`) || !typesRaw.includes(`| "${locale}"`)) {
|
|
throw new Error(`runtime locale config is missing ${locale}`);
|
|
}
|
|
}
|
|
|
|
const enMap = (await loadLocaleMap(SOURCE_LOCALE_PATH, "en")) ?? {};
|
|
const languageMap = enMap.languages;
|
|
const languageKeys =
|
|
languageMap && typeof languageMap === "object"
|
|
? Object.keys(languageMap).toSorted((left, right) => left.localeCompare(right))
|
|
: [];
|
|
const expectedLanguageKeys = ["en", ...LOCALE_ENTRIES.map((entry) => entry.languageKey)].toSorted(
|
|
(left, right) => left.localeCompare(right),
|
|
);
|
|
if (!compareStringArrays(languageKeys, expectedLanguageKeys)) {
|
|
throw new Error(
|
|
`ui/src/i18n/locales/en.ts languages block is out of sync: expected ${expectedLanguageKeys.join(", ")}, got ${languageKeys.join(", ")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
await verifyRuntimeLocaleConfig();
|
|
|
|
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}`);
|
|
}
|
|
|
|
logProgress(
|
|
`command=${args.command} locales=${entries.length} provider=${hasTranslationProvider() ? resolveConfiguredProvider() : "fallback-only"} model=${hasTranslationProvider() ? resolveConfiguredModel() : "n/a"} thinking=${hasTranslationProvider() ? resolveThinkingLevel() : "n/a"} timeout=${formatDuration(resolvePromptTimeoutMs())} batch_chars=${resolveBatchCharBudget()}`,
|
|
);
|
|
const outcomes: SyncOutcome[] = [];
|
|
for (const [index, entry] of entries.entries()) {
|
|
const outcome = await syncLocale(
|
|
entry,
|
|
{
|
|
checkOnly: args.command === "check",
|
|
force: args.force,
|
|
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 === "check" && 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",
|
|
);
|
|
}
|
|
}
|
|
|
|
await main().catch((error) => {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exit(1);
|
|
});
|