Files
2026-07-13 13:09:14 +08:00

545 lines
18 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const LOCALES_DIR = "apps/desktop/src/i18n/locales";
const SOURCE_LOCALE = "zh-CN";
const TARGET_LOCALES = ["en", "es", "it", "ja", "pt-BR", "zh-TW"];
const TARGET_LABELS = {
en: "English",
es: "Spanish",
it: "Italian",
ja: "Japanese",
"pt-BR": "Brazilian Portuguese",
"zh-TW": "Traditional Chinese used in Taiwan",
};
const args = new Set(process.argv.slice(2));
const dryRun = args.has("--dry-run") || !args.has("--write");
const mockTranslations = args.has("--mock-translations") || process.env.I18N_MOCK_TRANSLATIONS === "1";
const baseRef = valueArg("--base-ref") || process.env.I18N_BASE_REF || "origin/main";
const apiKey = process.env.DEEPSEEK_API_KEY || "";
const apiUrl = process.env.DEEPSEEK_API_URL || "https://api.deepseek.com/chat/completions";
const model = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro";
async function main() {
const sourcePath = localePath(SOURCE_LOCALE);
const baseSourceText = readBaseFile(sourcePath);
const headSource = parseLocaleFile(readFileSync(sourcePath, "utf8"), sourcePath);
const baseSource = parseLocaleFile(baseSourceText, `${baseRef}:${sourcePath}`);
// Only PR-new Chinese source keys are eligible for auto-fill; historical
// locale gaps intentionally stay untouched to keep bot patches scoped.
const baseLeaves = flattenLeaves(baseSource.root);
const headLeaves = flattenLeaves(headSource.root);
const newSourceKeys = [...headLeaves.keys()].filter((key) => !baseLeaves.has(key));
if (newSourceKeys.length === 0) {
console.log(`No new ${SOURCE_LOCALE} i18n keys compared with ${baseRef}`);
return;
}
console.log(`Found ${newSourceKeys.length} new ${SOURCE_LOCALE} i18n key(s):`);
for (const key of newSourceKeys) console.log(`- ${key}`);
let patchedFiles = 0;
const summary = {
sourceLocale: SOURCE_LOCALE,
newKeys: newSourceKeys,
updatedLocales: [],
};
for (const locale of TARGET_LOCALES) {
const path = localePath(locale);
if (!existsSync(path)) throw new Error(`Missing locale file: ${path}`);
const text = readFileSync(path, "utf8");
const parsed = parseLocaleFile(text, path);
const targetLeaves = flattenLeaves(parsed.root);
const missingKeys = newSourceKeys.filter((key) => !targetLeaves.has(key));
if (missingKeys.length === 0) {
console.log(`${locale}: all new keys already present`);
continue;
}
console.log(`${locale}: missing ${missingKeys.length} new key(s)`);
for (const key of missingKeys) console.log(` - ${key}`);
const translations = await translateMissing(locale, missingKeys, headLeaves);
const updated = patchLocaleText(text, parsed, missingKeys, translations, headSource.root);
if (updated === text) {
throw new Error(`${path}: expected changes for missing keys, but patch was empty`);
}
parseLocaleFile(updated, path);
if (!dryRun) writeFileSync(path, updated);
patchedFiles += 1;
summary.updatedLocales.push({ locale, count: missingKeys.length });
}
if (patchedFiles === 0) {
console.log("No locale files needed changes");
} else if (dryRun) {
console.log(`Dry run completed; ${patchedFiles} locale file(s) would be patched`);
} else {
console.log(`Patched ${patchedFiles} locale file(s)`);
}
writeSummary(summary);
}
function valueArg(name) {
const index = process.argv.indexOf(name);
if (index === -1) return null;
const value = process.argv[index + 1];
if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
return value;
}
function localePath(locale) {
return join(LOCALES_DIR, `${locale}.ts`);
}
function readBaseFile(path) {
try {
return execFileSync("git", ["show", `${baseRef}:${path}`], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
} catch (error) {
throw new Error(`Unable to read ${path} from ${baseRef}: ${error.message}`);
}
}
function writeSummary(summary) {
const path = process.env.I18N_SUMMARY_FILE;
if (!path) return;
writeFileSync(path, `${JSON.stringify(summary, null, 2)}\n`);
}
async function translateMissing(locale, keys, sourceLeaves) {
if (mockTranslations) {
return Object.fromEntries(keys.map((key) => [key, `[${locale}] ${sourceLeaves.get(key)}`]));
}
if (!apiKey) {
throw new Error(`DEEPSEEK_API_KEY is required to translate ${keys.length} missing ${locale} i18n key(s)`);
}
const items = keys.map((key) => ({ key, source: sourceLeaves.get(key) }));
const response = await fetch(apiUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model,
response_format: { type: "json_object" },
temperature: 0.1,
max_tokens: Math.max(1024, keys.length * 120),
messages: [
{
role: "system",
content: [
`Translate DBX UI i18n strings from Simplified Chinese to ${TARGET_LABELS[locale]}.`,
'Return strict JSON only in this exact shape: {"translations":{"path.to.key":"translated text"}}.',
"Keep i18n placeholders such as {count}, {message}, and {name} exactly unchanged.",
"Keep product names, database names, SQL keywords, file extensions, shortcuts, and code-like terms unchanged unless the target language convention clearly translates them.",
"Do not add keys, remove keys, or translate key names.",
].join(" "),
},
{
role: "user",
content: JSON.stringify({ targetLocale: locale, items }),
},
],
}),
});
if (!response.ok) {
throw new Error(`DeepSeek API failed for ${locale}: HTTP ${response.status} ${await response.text()}`);
}
const data = await response.json();
const content = data?.choices?.[0]?.message?.content;
if (typeof content !== "string" || !content.trim()) {
throw new Error(`DeepSeek API returned no translation content for ${locale}`);
}
const parsed = parseJsonContent(content);
const translations = parsed.translations;
if (!translations || typeof translations !== "object" || Array.isArray(translations)) {
throw new Error(`DeepSeek response for ${locale} must contain a translations object`);
}
for (const key of keys) {
if (typeof translations[key] !== "string" || !translations[key].trim()) {
throw new Error(`DeepSeek response for ${locale} is missing translation for ${key}`);
}
assertSamePlaceholders(key, sourceLeaves.get(key), translations[key], locale);
}
return translations;
}
function parseJsonContent(content) {
const trimmed = content.trim();
const withoutFence = trimmed
.replace(/^```(?:json)?\s*/i, "")
.replace(/\s*```$/i, "")
.trim();
try {
return JSON.parse(withoutFence);
} catch (error) {
throw new Error(`Unable to parse DeepSeek JSON response: ${error.message}\n${content}`);
}
}
function assertSamePlaceholders(key, source, translated, locale) {
const sourcePlaceholders = placeholders(source);
const translatedPlaceholders = placeholders(translated);
if (sourcePlaceholders.join("\0") !== translatedPlaceholders.join("\0")) {
throw new Error(
`${locale}:${key} placeholder mismatch: expected [${sourcePlaceholders.join(", ")}], got [${translatedPlaceholders.join(", ")}]`,
);
}
}
function placeholders(value) {
return [...String(value).matchAll(/\{[^{}]+\}/g)].map((match) => match[0]).sort();
}
function patchLocaleText(text, parsed, missingKeys, translations, sourceRoot) {
const groups = groupMissingKeys(parsed.root, missingKeys, translations, sourceRoot);
const edits = [];
for (const group of groups) {
const insertText = buildInsertion(text, group.objectNode, group.entries, group.sourceObjectNode);
edits.push({ index: lineStartBefore(text, group.objectNode.closeBrace), text: insertText });
}
return applyEdits(text, edits);
}
function groupMissingKeys(targetRoot, missingKeys, translations, sourceRoot) {
const byParent = new Map();
for (const key of missingKeys) {
const segments = key.split(".");
const { node: parentNode, path: parentPath, remaining } = findInsertionParent(targetRoot, segments);
const parentKey = parentPath.join(".");
const sourceParent = findObjectNode(sourceRoot, parentPath);
if (!sourceParent) throw new Error(`${key}: insertion parent is missing in ${SOURCE_LOCALE}`);
const sourceOrder = sourceParent.properties.map((property) => property.key);
const entry = {
path: remaining,
value: translations[key],
order: sourceOrder.indexOf(remaining[0]),
};
if (!byParent.has(parentKey)) byParent.set(parentKey, { objectNode: parentNode, sourceObjectNode: sourceParent, entries: [] });
byParent.get(parentKey).entries.push(entry);
}
return [...byParent.values()].map((group) => ({
...group,
entries: group.entries.sort((left, right) => left.order - right.order || left.path.join(".").localeCompare(right.path.join("."))),
}));
}
function findInsertionParent(root, fullPath) {
let current = root;
const existingPath = [];
for (const segment of fullPath.slice(0, -1)) {
const property = current.properties.find((candidate) => candidate.key === segment);
if (!property) break;
if (property.value.type !== "object") {
throw new Error(`${fullPath.join(".")}: cannot insert under non-object locale key ${[...existingPath, segment].join(".")}`);
}
current = property.value;
existingPath.push(segment);
}
return { node: current, path: existingPath, remaining: fullPath.slice(existingPath.length) };
}
function findObjectNode(root, path) {
let current = root;
for (const segment of path) {
const property = current.properties.find((candidate) => candidate.key === segment);
if (!property || property.value.type !== "object") return null;
current = property.value;
}
return current;
}
function buildInsertion(text, objectNode, entries, sourceObjectNode) {
const parentIndent = lineIndentBefore(text, objectNode.closeBrace);
const childIndent = objectNode.properties.length > 0 ? lineIndentBefore(text, objectNode.properties[0].start) : `${parentIndent} `;
const lines = renderEntryTree(buildEntryTree(entries), sourceObjectNode, childIndent);
return `${lines.join("\n")}\n`;
}
function buildEntryTree(entries) {
const root = { children: new Map(), value: undefined };
for (const entry of entries) {
let current = root;
for (const segment of entry.path) {
if (!current.children.has(segment)) current.children.set(segment, { children: new Map(), value: undefined });
current = current.children.get(segment);
}
current.value = entry.value;
}
return root;
}
function renderEntryTree(tree, sourceObjectNode, indent) {
const lines = [];
const entries = [...tree.children.entries()].sort(([left], [right]) => compareSourceOrder(sourceObjectNode, left, right));
for (const [key, child] of entries) {
if (child.children.size === 0) {
lines.push(`${indent}${formatKey(key)}: ${JSON.stringify(child.value)},`);
continue;
}
const childSourceObject = sourceObjectNode?.properties.find((property) => property.key === key && property.value.type === "object")?.value;
lines.push(`${indent}${formatKey(key)}: {`);
lines.push(...renderEntryTree(child, childSourceObject, `${indent} `));
lines.push(`${indent}},`);
}
return lines;
}
function compareSourceOrder(sourceObjectNode, left, right) {
const order = sourceObjectNode?.properties.map((property) => property.key) || [];
const leftIndex = order.indexOf(left);
const rightIndex = order.indexOf(right);
if (leftIndex !== -1 || rightIndex !== -1) {
if (leftIndex === -1) return 1;
if (rightIndex === -1) return -1;
return leftIndex - rightIndex;
}
return left.localeCompare(right);
}
function formatKey(key) {
return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key);
}
function lineIndentBefore(text, index) {
const lineStart = lineStartBefore(text, index);
const match = text.slice(lineStart, index).match(/^[ \t]*/);
return match ? match[0] : "";
}
function lineStartBefore(text, index) {
return text.lastIndexOf("\n", Math.max(0, index - 1)) + 1;
}
function applyEdits(text, edits) {
let updated = text;
for (const edit of edits.sort((left, right) => right.index - left.index)) {
updated = `${updated.slice(0, edit.index)}${edit.text}${updated.slice(edit.index)}`;
}
return updated;
}
function flattenLeaves(root) {
const result = new Map();
flattenNode(root, [], result);
return result;
}
function flattenNode(node, path, result) {
for (const property of node.properties) {
const nextPath = [...path, property.key];
if (property.value.type === "object") {
flattenNode(property.value, nextPath, result);
} else {
result.set(nextPath.join("."), property.value.value);
}
}
}
function parseLocaleFile(text, file) {
const parser = new Parser(text, file);
const root = parser.parseLocaleRoot();
return { root };
}
class Parser {
constructor(text, file) {
this.text = text;
this.file = file;
this.index = 0;
}
parseLocaleRoot() {
const exportIndex = this.text.indexOf("export default");
if (exportIndex === -1) this.fail("Missing export default");
this.index = exportIndex + "export default".length;
this.skipSpace();
if (this.peek() === "{") return this.parseObject();
const callee = this.parseIdentifier();
this.skipSpace();
if (!callee || this.peek() !== "(") this.fail("Expected object literal or wrapper call after export default");
this.index += 1;
this.skipSpace();
const object = this.parseObject();
this.skipSpace();
if (this.peek() !== ")") this.fail("Expected closing wrapper call parenthesis");
return object;
}
parseObject() {
this.expect("{");
const openBrace = this.index - 1;
const properties = [];
while (true) {
this.skipSpace();
if (this.peek() === "}") {
const closeBrace = this.index;
this.index += 1;
return { type: "object", openBrace, closeBrace, end: this.index, properties };
}
const start = this.index;
const key = this.parseKey();
this.skipSpace();
this.expect(":");
this.skipSpace();
const value = this.parseValue();
this.skipSpace();
let hasComma = false;
if (this.peek() === ",") {
hasComma = true;
this.index += 1;
}
properties.push({ key, start, end: this.index, hasComma, value });
}
}
parseValue() {
const char = this.peek();
if (char === "{") return this.parseObject();
if (char === '"' || char === "'") {
const start = this.index;
const value = this.parseString();
return { type: "string", start, end: this.index, value };
}
this.fail(`Unsupported locale value starting with ${JSON.stringify(char)}`);
}
parseKey() {
const char = this.peek();
if (char === '"' || char === "'") return this.parseString();
const key = this.parseIdentifier();
if (!key) this.fail("Expected property key");
return key;
}
parseIdentifier() {
const match = /^[A-Za-z_$][\w$-]*/.exec(this.text.slice(this.index));
if (!match) return "";
this.index += match[0].length;
return match[0];
}
parseString() {
const quote = this.peek();
this.index += 1;
let value = "";
while (this.index < this.text.length) {
const char = this.text[this.index];
if (char === "\\") {
const escaped = this.text[this.index + 1];
if (!escaped) this.fail("Unterminated string literal");
this.index += 2;
if (escaped === "n") value += "\n";
else if (escaped === "r") value += "\r";
else if (escaped === "t") value += "\t";
else if (escaped === "b") value += "\b";
else if (escaped === "f") value += "\f";
else if (escaped === "v") value += "\v";
else if (escaped === "0") value += "\0";
else if (escaped === "u") {
const hex = this.text.slice(this.index, this.index + 4);
if (!/^[0-9a-fA-F]{4}$/.test(hex)) this.fail("Invalid unicode escape");
value += String.fromCharCode(Number.parseInt(hex, 16));
this.index += 4;
} else {
value += escaped;
}
continue;
}
if (char === quote) {
this.index += 1;
return value;
}
if (char === "\n" || char === "\r") this.fail("Unterminated string literal");
value += char;
this.index += 1;
}
this.fail("Unterminated string literal");
}
skipSpace() {
while (this.index < this.text.length) {
const char = this.text[this.index];
if (/\s/.test(char)) {
this.index += 1;
continue;
}
if (this.text.startsWith("//", this.index)) {
const next = this.text.indexOf("\n", this.index + 2);
this.index = next === -1 ? this.text.length : next + 1;
continue;
}
if (this.text.startsWith("/*", this.index)) {
const next = this.text.indexOf("*/", this.index + 2);
if (next === -1) this.fail("Unterminated block comment");
this.index = next + 2;
continue;
}
break;
}
}
expect(expected) {
if (this.peek() !== expected) this.fail(`Expected ${expected}`);
this.index += expected.length;
}
peek() {
return this.text[this.index] || "";
}
fail(message) {
const location = this.location();
throw new Error(`${this.file}:${location.line}:${location.column}: ${message}`);
}
location() {
const before = this.text.slice(0, this.index);
const lines = before.split(/\r?\n/);
return { line: lines.length, column: lines[lines.length - 1].length + 1 };
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});