@alauda-fe/i18n-tools
Version:
基于 Azure OpenAI 的 JSON i18n 文件翻译和英文语法检查工具集
1,139 lines (954 loc) • 34.8 kB
JavaScript
import fs from "node:fs/promises";
import path from "node:path";
import { AzureOpenAI } from "openai";
import { setTimeout } from "node:timers/promises";
import {
Logger,
Validator,
ErrorHandler,
ProgressTracker,
CONFIG,
} from "./utils.js";
import {
buildTranslationUserPrompt,
buildCustomTranslationSystemPrompt,
getFriendlyLanguageName,
} from "./prompts.js";
// ====================== 初始化工具 ======================
const logger = new Logger("Translate");
const errorHandler = new ErrorHandler(logger, CONFIG.LOGS.TRANSLATE);
// ====================== 转义/反转义 JSON key 中的点 ======================
function escapeKey(seg) {
return seg.replace(/\./g, CONFIG.DOT_ESC);
}
function unescapeKey(seg) {
return seg.replace(new RegExp(CONFIG.DOT_ESC, "g"), ".");
}
// ====================== 工具函数 ======================
// 递归收集所有叶子节点路径,并 escapeKey 转义 "."
// 过滤掉 key 为空字符串或者 value 为空字符串的条目
function collectKeys(obj, prefix = "") {
return Object.entries(obj).flatMap(([key, val]) => {
// 忽略空字符串的 key
if (key === "") {
return [];
}
const esc = escapeKey(key);
const p = prefix ? `${prefix}.${esc}` : esc;
if (val !== null && typeof val === "object") {
return collectKeys(val, p);
}
// 忽略空字符串的 value
if (val === "") {
return [];
}
return [p];
});
}
// 深度删除嵌套对象中的键
function deleteNestedKey(obj, pathStr) {
const segs = pathStr.split(".").map(unescapeKey);
let cur = obj;
for (let i = 0; i < segs.length - 1; i++) {
if (!cur[segs[i]] || typeof cur[segs[i]] !== "object") {
return; // 路径不存在,无需删除
}
cur = cur[segs[i]];
}
delete cur[segs[segs.length - 1]];
// 递归删除空对象
const parent = segs.slice(0, -1);
if (parent.length > 0 && Object.keys(cur).length === 0) {
deleteNestedKey(obj, parent.join("."));
}
}
// 比较两个JSON对象的差异
function compareJsonObjects(newObj, snapshotObj) {
const newKeys = new Set(collectKeys(newObj));
const snapshotKeys = new Set(collectKeys(snapshotObj));
const added = [...newKeys].filter((k) => !snapshotKeys.has(k));
const deleted = [...snapshotKeys].filter((k) => !newKeys.has(k));
const modified = [...newKeys].filter((k) => {
if (!snapshotKeys.has(k)) return false;
const newVal = nestedUtils.get(newObj, k);
const snapshotVal = nestedUtils.get(snapshotObj, k);
return newVal !== snapshotVal;
});
return { added, deleted, modified };
}
// 嵌套对象读写:路径分割后 unescapeKey
const nestedUtils = {
get(obj, pathStr) {
return pathStr
.split(".")
.map(unescapeKey)
.reduce((o, k) => (o || {})[k], obj);
},
set(obj, pathStr, value) {
const segs = pathStr.split(".").map(unescapeKey);
let cur = obj;
while (segs.length > 1) {
const k = segs.shift();
if (!cur[k] || typeof cur[k] !== "object") cur[k] = {};
cur = cur[k];
}
cur[segs[0]] = value;
},
};
// 将数组按指定大小分块
function chunkArray(arr, size) {
const out = [];
for (let i = 0; i < arr.length; i += size) {
out.push(arr.slice(i, i + size));
}
return out;
}
// ====================== 占位符处理 ======================
function placeholderize(text) {
const map = {};
let placeholderIndex = 0;
let workingText = text;
// 处理嵌套 ICU 格式的函数
function processICUFormats(str) {
// 从内到外处理嵌套的 ICU 格式
let hasChanges = true;
while (hasChanges) {
hasChanges = false;
// 匹配最内层的 ICU 格式(不包含其他ICU格式的)
const icuPattern = /\{[^{}]*,\s*(plural|select|number|date|time|duration)[^{}]*,([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g;
let match;
while ((match = icuPattern.exec(str)) !== null) {
// 检查匹配的内容是否不包含其他 ICU 格式
const content = match[2];
if (!content.match(/,\s*(plural|select|number|date|time|duration)[^{}]*,/)) {
const key = `__PH_${placeholderIndex}__`;
map[key] = match[0];
str = str.replace(match[0], key);
placeholderIndex++;
hasChanges = true;
break; // 重新开始匹配,因为字符串已改变
}
}
}
return str;
}
// 1. 首先处理 {{var}} 格式
const doubleBracePattern = /\{\{[^{}]+\}\}/g;
let match;
while ((match = doubleBracePattern.exec(workingText)) !== null) {
const key = `__PH_${placeholderIndex}__`;
map[key] = match[0];
workingText = workingText.replace(match[0], key);
placeholderIndex++;
}
// 2. 处理 ${var} 格式
const dollarBracePattern = /\$\{[^{}]+\}/g;
while ((match = dollarBracePattern.exec(workingText)) !== null) {
const key = `__PH_${placeholderIndex}__`;
map[key] = match[0];
workingText = workingText.replace(match[0], key);
placeholderIndex++;
}
// 3. 处理复杂的 ICU 格式(从内到外)
workingText = processICUFormats(workingText);
// 4. 处理简单的 {var} 格式和索引占位符
const simpleBracePattern = /\{[^{},]+\}|\{\d+\}/g;
while ((match = simpleBracePattern.exec(workingText)) !== null) {
const key = `__PH_${placeholderIndex}__`;
map[key] = match[0];
workingText = workingText.replace(match[0], key);
placeholderIndex++;
}
return { text: workingText, map };
}
// 提取文本中的所有模板变量
function extractAllTemplateVariables(text) {
if (typeof text !== "string") return "";
const variables = new Set();
let workingText = text;
// 1. 首先提取 {{var}} 格式
const doubleBraceMatches = text.match(/\{\{[^{}]+\}\}/g) || [];
doubleBraceMatches.forEach(match => {
variables.add(match);
workingText = workingText.replace(match, ''); // 从工作文本中移除
});
// 2. 提取 ${var} 格式
const dollarBraceMatches = text.match(/\$\{[^{}]+\}/g) || [];
dollarBraceMatches.forEach(match => {
variables.add(match);
workingText = workingText.replace(match, ''); // 从工作文本中移除
});
// 3. 提取 ICU Message Format 复杂格式
const extractIcuFormats = (str) => {
// 匹配完整的 ICU 格式,包括嵌套
const icuPattern = /\{[^{}]*,\s*(plural|select|number|date|time|duration)[^{}]*,[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g;
let match;
while ((match = icuPattern.exec(str)) !== null) {
variables.add(match[0]);
workingText = workingText.replace(match[0], ''); // 从工作文本中移除
}
};
extractIcuFormats(text);
// 4. 提取简单的 {var} 格式(从剩余文本中)
const simpleBraceMatches = workingText.match(/\{[^{},]+\}/g) || [];
simpleBraceMatches.forEach(match => variables.add(match));
// 5. 提取索引占位符 {0}, {1}, {2}
const indexMatches = text.match(/\{\d+\}/g) || [];
indexMatches.forEach(match => variables.add(match));
return Array.from(variables).sort().join();
}
function restorePlaceholders(text, map) {
// 按照占位符的依赖关系正确恢复
let restored = text;
// 获取所有占位符并按索引排序(从大到小,确保嵌套的先恢复)
const sortedPlaceholders = Object.keys(map).sort((a, b) => {
const aIndex = parseInt(a.match(/__PH_(\d+)__/)[1]);
const bIndex = parseInt(b.match(/__PH_(\d+)__/)[1]);
return bIndex - aIndex; // 倒序
});
// 逐步恢复占位符
sortedPlaceholders.forEach(key => {
const originalValue = map[key];
restored = restored.replace(new RegExp(key, 'g'), originalValue);
});
return restored;
}
function encodeBatch(batch) {
const maps = {};
const newBatch = {};
for (const [k, v] of Object.entries(batch)) {
// 忽略空字符串的 key 或 value
if (k === "" || v === "") {
continue;
}
if (typeof v === "string") {
const { text, map } = placeholderize(v);
newBatch[k] = text;
maps[k] = map;
} else {
newBatch[k] = v;
maps[k] = {};
}
}
return { newBatch, maps };
}
function decodeBatch(result, maps) {
const out = {};
for (const [k, v] of Object.entries(result)) {
// 忽略空字符串的 key 或 value
if (k === "" || v === "") {
continue;
}
out[k] = typeof v === "string" ? restorePlaceholders(v, maps[k] || {}) : v;
}
return out;
}
// ====================== 翻译器工厂 ======================
function createTranslator(
openai,
srcLang,
tgtLang,
customPromptPath,
extraRules
) {
// 获取友好的语言名称用于提示词
const srcLangName = getFriendlyLanguageName(srcLang);
const tgtLangName = getFriendlyLanguageName(tgtLang);
async function doRequest(batch) {
await setTimeout(CONFIG.REQUEST_INTERVAL);
// 构建自定义提示词
const systemPrompt = await buildCustomTranslationSystemPrompt(
customPromptPath,
extraRules
);
const payload = {
model: "gpt-4o-mini",
messages: [
{ role: "system", content: systemPrompt },
{
role: "user",
content: buildTranslationUserPrompt(srcLangName, tgtLangName),
},
{ role: "user", content: JSON.stringify(batch) },
],
temperature: 0.2, // 降低随机性,提高一致性
response_format: { type: "json_object" },
};
logger.apiRequest(batch, `翻译 (${srcLang} → ${tgtLang})`);
const resp = await openai.chat.completions.create(payload);
const content = resp.choices[0].message.content;
logger.apiResponse(content);
return content;
}
return async (batch, context = {}) => {
// 分离需要翻译的条目、注释条目和英文条目
const commentKeys = [];
const englishKeys = [];
const translateBatch = {};
Object.entries(batch).forEach(([k, v]) => {
// 忽略空字符串的 key 或 value
if (k === "" || v === "") {
return;
}
if (typeof v === "string" && v.startsWith("// ")) {
commentKeys.push(k);
} else if (k.endsWith("_en")) {
englishKeys.push(k);
} else {
translateBatch[k] = v;
}
});
// 如果没有需要翻译的条目,直接返回注释条目和英文条目
if (Object.keys(translateBatch).length === 0) {
const commentEntries = commentKeys.map(k => [k, batch[k]]);
const englishEntries = englishKeys.map(k => [k, batch[k]]);
return Object.fromEntries([...commentEntries, ...englishEntries]);
}
const { newBatch, maps } = encodeBatch(translateBatch);
try {
const content = await doRequest(newBatch);
const data = JSON.parse(content);
const decoded = decodeBatch(data, maps);
// 验证模板变量完整性(支持多种格式)
const translatedEntries = Object.entries(translateBatch).map(([k, v]) => {
if (typeof v !== "string") return [k, decoded[k]];
// 检查所有类型的模板变量
const originalVars = extractAllTemplateVariables(v);
const translatedVars = extractAllTemplateVariables(decoded[k] || "");
// 如果模板变量不匹配,返回 null 表示翻译失败
return [k, originalVars === translatedVars ? decoded[k] : null];
});
// 合并翻译结果、注释条目和英文条目
const commentEntries = commentKeys.map(k => [k, batch[k]]);
const englishEntries = englishKeys.map(k => [k, batch[k]]);
return Object.fromEntries([...translatedEntries, ...commentEntries, ...englishEntries]);
} catch (err) {
const errorResult = errorHandler.handleApiError(err, () =>
doRequest(newBatch)
);
if (errorResult.shouldRetry && errorResult.retryCallback) {
await setTimeout(errorResult.waitMs || 60000);
try {
const content = await errorResult.retryCallback();
const data = JSON.parse(content);
const decoded = decodeBatch(data, maps);
// 验证模板变量完整性(支持多种格式)
const translatedEntries = Object.entries(translateBatch).map(([k, v]) => {
if (typeof v !== "string") return [k, decoded[k]];
const originalVars = extractAllTemplateVariables(v);
const translatedVars = extractAllTemplateVariables(decoded[k] || "");
return [k, originalVars === translatedVars ? decoded[k] : null];
});
// 合并翻译结果、注释条目和英文条目
const commentEntries = commentKeys.map(k => [k, batch[k]]);
const englishEntries = englishKeys.map(k => [k, batch[k]]);
return Object.fromEntries([...translatedEntries, ...commentEntries, ...englishEntries]);
} catch {}
}
await errorHandler.logError(err, {
batch: translateBatch, // 记录完整的 batch 数据
srcLang,
tgtLang,
...context // 包含 targetFilePath 等
});
// 即使翻译失败,也返回注释条目和英文条目
const failedEntries = Object.keys(translateBatch).map(k => [k, null]);
const commentEntries = commentKeys.map(k => [k, batch[k]]);
const englishEntries = englishKeys.map(k => [k, batch[k]]);
return Object.fromEntries([...failedEntries, ...commentEntries, ...englishEntries]);
}
};
}
// ====================== 主翻译函数 ======================
async function translateFiles(
srcLang,
tgtLang,
token,
customPromptPath,
extraRules
) {
// 参数验证
const validatedToken = Validator.validateApiKey(token);
const validatedSrcLang = Validator.validateLanguage(srcLang, "源语言");
const validatedTgtLang = Validator.validateLanguage(tgtLang, "目标语言");
// 验证自定义提示词文件(如果提供)
if (customPromptPath) {
try {
await Validator.validateFile(customPromptPath);
logger.info(`使用自定义提示词: ${path.basename(customPromptPath)}`);
} catch (err) {
throw new Error(`自定义提示词文件无效: ${err.message}`);
}
}
if (extraRules) {
logger.info("应用额外处理规则");
}
const srcDir = await Validator.validateDirectory(validatedSrcLang);
const tgtDir = path.resolve(validatedTgtLang);
// 创建目标目录
try {
await fs.mkdir(tgtDir, { recursive: true });
logger.info(`目标目录已准备: ${path.basename(tgtDir)}`);
} catch (err) {
throw new Error(`无法创建目标目录: ${err.message}`);
}
// 初始化 OpenAI 客户端
const openai = new AzureOpenAI({
endpoint: CONFIG.ENDPOINT,
apiKey: validatedToken,
apiVersion: CONFIG.API_VERSION,
});
const translate = createTranslator(
openai,
validatedSrcLang,
validatedTgtLang,
customPromptPath,
extraRules
);
// 获取需要处理的文件
const files = (await fs.readdir(srcDir)).filter(
(f) => f.endsWith(".json") && f !== "_config.json"
);
if (files.length === 0) {
logger.skip("源目录中没有找到可翻译的 JSON 文件");
return;
}
logger.start(
`开始翻译 ${files.length} 个文件从 ${validatedSrcLang} 到 ${validatedTgtLang}`
);
let totalTranslated = 0;
let totalSkipped = 0;
for (const file of files) {
const srcPath = path.join(srcDir, file);
const tgtPath = path.join(tgtDir, file);
logger.file("read", srcPath);
const srcObj = JSON.parse(await fs.readFile(srcPath, "utf8"));
let tgtObj = {};
try {
tgtObj = JSON.parse(await fs.readFile(tgtPath, "utf8"));
} catch {
logger.info(`目标文件不存在,将创建新文件: ${tgtPath}`);
}
const allKeys = collectKeys(srcObj);
const todo = allKeys.filter(
(k) =>
nestedUtils.get(tgtObj, k) === undefined ||
nestedUtils.get(tgtObj, k) === null
);
if (!todo.length) {
logger.skip(`${file} 已完成翻译`);
totalSkipped++;
continue;
}
logger.progress(`处理 ${file}: 需翻译 ${todo.length} 条目`);
const chunks = chunkArray(todo, CONFIG.TRANSLATE_BATCH_SIZE);
const progress = new ProgressTracker(logger, chunks.length);
for (const keys of chunks) {
const batch = {};
keys.forEach((k) => {
batch[k] = nestedUtils.get(srcObj, k);
});
const result = await translate(batch, { targetFilePath: tgtPath });
const successCount = Object.values(result).filter(
(v) => v !== null
).length;
Object.entries(result).forEach(([k, v]) => nestedUtils.set(tgtObj, k, v));
logger.file("write", tgtPath);
await fs.writeFile(tgtPath, JSON.stringify(tgtObj, null, 2));
progress.update(successCount === Object.keys(batch).length);
totalTranslated += successCount;
}
progress.finish();
logger.success(`${file} 翻译完成`);
}
logger.stats("翻译任务完成统计", {
处理文件数: files.length,
跳过文件数: totalSkipped,
翻译条目数: totalTranslated,
源语言: validatedSrcLang,
目标语言: validatedTgtLang,
});
}
// ====================== 重试失败翻译函数 ======================
export async function retryFailedTranslations(
srcLang,
tgtLang,
token,
customPromptPath,
extraRules
) {
// 参数验证
const validatedToken = Validator.validateApiKey(token);
const validatedSrcLang = Validator.validateLanguage(srcLang, "源语言");
const validatedTgtLang = Validator.validateLanguage(tgtLang, "目标语言");
// 验证自定义提示词文件(如果提供)
if (customPromptPath) {
try {
await Validator.validateFile(customPromptPath);
logger.info(`使用自定义提示词: ${path.basename(customPromptPath)}`);
} catch (err) {
throw new Error(`自定义提示词文件无效: ${err.message}`);
}
}
if (extraRules) {
logger.info("应用额外处理规则");
}
let srcDir, tgtDir;
// 1. 尝试增量翻译结构: new/<srcLang> -> src/<tgtLang>
const incrementalSrc = path.resolve("new", validatedSrcLang);
const incrementalTgt = path.resolve("src", validatedTgtLang);
try {
await Validator.validateDirectory(incrementalSrc);
// 如果 new/<srcLang> 存在,我们假设是增量翻译结构
// 但是 tgtDir 必须也存在,否则无法重试
await Validator.validateDirectory(incrementalTgt);
srcDir = incrementalSrc;
tgtDir = incrementalTgt;
logger.info(`检测到增量翻译目录结构,使用: ${srcDir} -> ${tgtDir}`);
} catch (e) {
// 2. 回退到普通结构: <srcLang> -> <tgtLang> (相对于当前目录)
try {
srcDir = await Validator.validateDirectory(validatedSrcLang);
tgtDir = await Validator.validateDirectory(validatedTgtLang);
logger.info(`使用标准目录结构: ${srcDir} -> ${tgtDir}`);
} catch (err) {
throw new Error(
`无法确定目录结构。请确保目录存在。\n尝试了:\n1. new/${validatedSrcLang} -> src/${validatedTgtLang}\n2. ${validatedSrcLang} -> ${validatedTgtLang}\n错误: ${err.message}`
);
}
}
// 初始化 OpenAI 客户端
const openai = new AzureOpenAI({
endpoint: CONFIG.ENDPOINT,
apiKey: validatedToken,
apiVersion: CONFIG.API_VERSION,
});
const translate = createTranslator(
openai,
validatedSrcLang,
validatedTgtLang,
customPromptPath,
extraRules
);
const files = (await fs.readdir(tgtDir)).filter(
(f) => f.endsWith(".json") && f !== "_config.json"
);
if (files.length === 0) {
logger.skip("目标目录中没有找到可重试的 JSON 文件");
return;
}
logger.start(
`开始重试失败的翻译任务 (${validatedSrcLang} → ${validatedTgtLang})`
);
let totalRetried = 0;
let filesProcessed = 0;
for (const file of files) {
const srcPath = path.join(srcDir, file);
const tgtPath = path.join(tgtDir, file);
try {
logger.file("read", srcPath);
const srcObj = JSON.parse(await fs.readFile(srcPath, "utf8"));
let tgtObj = {};
try {
tgtObj = JSON.parse(await fs.readFile(tgtPath, "utf8"));
} catch {
logger.warn(`目标文件读取失败,跳过: ${file}`);
continue;
}
const allKeys = collectKeys(tgtObj);
const failed = allKeys.filter((k) => nestedUtils.get(tgtObj, k) === null);
if (!failed.length) {
logger.skip(`${file} 无失败条目`);
continue;
}
logger.progress(`处理 ${file}: 重试 ${failed.length} 条失败条目`);
const chunks = chunkArray(failed, CONFIG.TRANSLATE_BATCH_SIZE);
const progress = new ProgressTracker(logger, chunks.length);
for (const keys of chunks) {
const batch = {};
keys.forEach((k) => {
batch[k] = nestedUtils.get(srcObj, k);
});
const result = await translate(batch);
const successCount = Object.values(result).filter(
(v) => v !== null
).length;
Object.entries(result).forEach(([k, v]) =>
nestedUtils.set(tgtObj, k, v)
);
logger.file("write", tgtPath);
await fs.writeFile(tgtPath, JSON.stringify(tgtObj, null, 2));
progress.update(successCount === Object.keys(batch).length);
totalRetried += successCount;
}
progress.finish();
logger.success(`${file} 重试完成`);
filesProcessed++;
} catch (err) {
logger.error(`处理文件 ${file} 时出错: ${err.message}`);
await errorHandler.logError(err, { file, operation: "retry" });
}
}
logger.stats("重试任务完成统计", {
检查文件数: files.length,
处理文件数: filesProcessed,
重试成功条目: totalRetried,
源语言: validatedSrcLang,
目标语言: validatedTgtLang,
});
}
// ====================== 增量翻译函数 ======================
export async function incrementalTranslate(
srcLang,
tgtLang,
token,
customPromptPath,
extraRules
) {
// 参数验证
const validatedToken = Validator.validateApiKey(token);
const validatedSrcLang = Validator.validateLanguage(srcLang, "源语言");
const validatedTgtLang = Validator.validateLanguage(tgtLang, "目标语言");
// 验证自定义提示词文件(如果提供)
if (customPromptPath) {
try {
await Validator.validateFile(customPromptPath);
logger.info(`使用自定义提示词: ${path.basename(customPromptPath)}`);
} catch (err) {
throw new Error(`自定义提示词文件无效: ${err.message}`);
}
}
if (extraRules) {
logger.info("应用额外处理规则");
}
// 验证new和snapshot目录
const newDir = path.resolve("new", validatedSrcLang);
const snapshotDir = path.resolve("snapshot", validatedSrcLang);
const tgtDir = path.resolve("src", validatedTgtLang); // 默认翻译目标目录是src下的目标语言
try {
await Validator.validateDirectory(newDir);
} catch (err) {
throw new Error(`new目录不存在或无法访问: ${newDir}`);
}
let snapshotExists = true;
try {
await Validator.validateDirectory(snapshotDir);
} catch (err) {
logger.warn(`snapshot目录不存在,将进行全量翻译: ${snapshotDir}`);
snapshotExists = false;
}
// 创建目标目录
try {
await fs.mkdir(tgtDir, { recursive: true });
logger.info(`翻译目标目录已准备: ${tgtDir}`);
} catch (err) {
throw new Error(`无法创建翻译目标目录: ${err.message}`);
}
// 初始化 OpenAI 客户端
const openai = new AzureOpenAI({
endpoint: CONFIG.ENDPOINT,
apiKey: validatedToken,
apiVersion: CONFIG.API_VERSION,
});
const translate = createTranslator(
openai,
validatedSrcLang,
validatedTgtLang,
customPromptPath,
extraRules
);
// 获取新文件列表
const newFiles = (await fs.readdir(newDir)).filter(
(f) => f.endsWith(".json") && f !== "_config.json"
);
if (newFiles.length === 0) {
logger.skip("new目录中没有找到可翻译的 JSON 文件");
return;
}
logger.start(
`开始增量翻译 ${newFiles.length} 个文件从 ${validatedSrcLang} 到 ${validatedTgtLang}`
);
let totalAdded = 0;
let totalModified = 0;
let totalDeleted = 0;
let filesProcessed = 0;
for (const file of newFiles) {
const newPath = path.join(newDir, file);
const snapshotPath = path.join(snapshotDir, file);
const translationPath = path.join(tgtDir, file); // 翻译文件路径(src/目标语言/)
logger.file("read", newPath);
const newObj = JSON.parse(await fs.readFile(newPath, "utf8"));
let snapshotObj = {};
let translationObj = {};
// 读取snapshot文件(如果存在)
if (snapshotExists) {
try {
snapshotObj = JSON.parse(await fs.readFile(snapshotPath, "utf8"));
} catch {
logger.info(`snapshot文件不存在,视为新文件: ${snapshotPath}`);
}
}
// 读取翻译文件(如果存在)
try {
translationObj = JSON.parse(await fs.readFile(translationPath, "utf8"));
} catch {
logger.info(`翻译文件不存在,将创建新文件: ${translationPath}`);
}
// 比较new和snapshot的差异
const { added, deleted, modified } = compareJsonObjects(
newObj,
snapshotObj
);
if (added.length === 0 && deleted.length === 0 && modified.length === 0) {
logger.skip(`${file} 无变化`);
continue;
}
logger.progress(
`处理 ${file}: 新增 ${added.length}, 修改 ${modified.length}, 删除 ${deleted.length}`
);
// 处理删除的键
deleted.forEach((key) => {
deleteNestedKey(translationObj, key);
logger.info(`删除翻译条目: ${key}`);
});
// 处理新增和修改的键
const toTranslate = [...added, ...modified];
if (toTranslate.length > 0) {
const chunks = chunkArray(toTranslate, CONFIG.TRANSLATE_BATCH_SIZE);
const progress = new ProgressTracker(logger, chunks.length);
for (const keys of chunks) {
const batch = {};
keys.forEach((k) => {
batch[k] = nestedUtils.get(newObj, k);
});
const result = await translate(batch, { targetFilePath: translationPath });
const successCount = Object.values(result).filter(
(v) => v !== null
).length;
Object.entries(result).forEach(([k, v]) => {
if (v !== null) {
nestedUtils.set(translationObj, k, v);
}
});
progress.update(successCount === Object.keys(batch).length);
}
progress.finish();
}
// 保存更新后的翻译文件
logger.file("write", translationPath);
await fs.writeFile(
translationPath,
JSON.stringify(translationObj, null, 2)
);
// 🆕 更新snapshot文件夹
try {
await fs.mkdir(path.dirname(snapshotPath), { recursive: true });
logger.file("update", `快照: ${snapshotPath}`);
await fs.writeFile(snapshotPath, JSON.stringify(newObj, null, 2));
} catch (err) {
logger.warn(`更新快照文件失败: ${err.message}`);
}
totalAdded += added.length;
totalModified += modified.length;
totalDeleted += deleted.length;
filesProcessed++;
logger.success(`${file} 增量翻译完成`);
}
logger.stats("增量翻译任务完成统计", {
处理文件数: filesProcessed,
新增翻译: totalAdded,
修改翻译: totalModified,
删除翻译: totalDeleted,
源语言: validatedSrcLang,
目标语言: validatedTgtLang,
已更新翻译目录: `src/${validatedTgtLang}/`,
已更新快照目录: `snapshot/${validatedSrcLang}/`,
});
}
// ====================== 更新快照函数 ======================
export async function updateSnapshot(srcLang) {
const validatedSrcLang = Validator.validateLanguage(srcLang, "源语言");
const newDir = path.resolve("new", validatedSrcLang);
const snapshotDir = path.resolve("snapshot", validatedSrcLang);
try {
await Validator.validateDirectory(newDir);
} catch (err) {
throw new Error(`new目录不存在或无法访问: ${newDir}`);
}
// 创建snapshot目录
try {
await fs.mkdir(snapshotDir, { recursive: true });
logger.info(`快照目录已准备: ${snapshotDir}`);
} catch (err) {
throw new Error(`无法创建快照目录: ${err.message}`);
}
const files = (await fs.readdir(newDir)).filter(
(f) => f.endsWith(".json") && f !== "_config.json"
);
if (files.length === 0) {
logger.skip("new目录中没有找到可复制的 JSON 文件");
return;
}
logger.start(`更新快照文件: ${files.length} 个文件`);
for (const file of files) {
const srcPath = path.join(newDir, file);
const destPath = path.join(snapshotDir, file);
logger.file("copy", `${srcPath} → ${destPath}`);
const content = await fs.readFile(srcPath, "utf8");
await fs.writeFile(destPath, content);
}
logger.success(`快照更新完成: ${files.length} 个文件`);
}
// ====================== 导出命令函数 ======================
export async function translateCommand(options) {
const { source, target, token, customPrompt, extraRules } = options;
try {
logger.start(`开始翻译任务`);
// 解析 token(支持环境变量)
const { TokenResolver } = await import("./utils.js");
const resolvedToken = TokenResolver.resolveToken(token);
const tokenSource = TokenResolver.getTokenSource(token);
logger.info(`Token 来源: ${tokenSource}`);
await translateFiles(source, target, resolvedToken, customPrompt, extraRules);
logger.finish("翻译任务完成!");
} catch (err) {
logger.error(`翻译任务失败: ${err.message}`);
await errorHandler.logError(err, { command: "translate", options });
process.exit(1);
}
}
export async function retryFailedCommand(options) {
const { token, customPrompt, extraRules } = options;
try {
logger.start(`开始重试失败翻译任务`);
// 解析 token(支持环境变量)
const { TokenResolver } = await import("./utils.js");
const resolvedToken = TokenResolver.resolveToken(token);
const tokenSource = TokenResolver.getTokenSource(token);
logger.info(`Token 来源: ${tokenSource}`);
// 读取错误日志
const errors = await errorHandler.readErrors();
if (errors.length === 0) {
logger.finish("没有发现失败记录。");
return;
}
// 过滤出翻译相关的错误
const translationErrors = errors.filter(
(e) =>
e.context &&
e.context.batch &&
e.context.targetFilePath &&
e.context.srcLang &&
e.context.tgtLang
);
const otherErrors = errors.filter((e) => !translationErrors.includes(e));
if (translationErrors.length === 0) {
logger.finish("没有发现可重试的翻译失败记录。");
return;
}
logger.info(`发现 ${translationErrors.length} 条失败记录,开始重试...`);
// 清空日志文件,准备重新写入
await errorHandler.clear();
// 先把非翻译错误写回去
if (otherErrors.length > 0) {
await errorHandler.writeErrors(otherErrors);
}
// 初始化 OpenAI 客户端
const openai = new AzureOpenAI({
endpoint: CONFIG.ENDPOINT,
apiKey: resolvedToken,
apiVersion: CONFIG.API_VERSION,
});
// 按语言对分组创建 translator
const translators = {};
const getTranslator = (src, tgt) => {
const key = `${src}-${tgt}`;
if (!translators[key]) {
translators[key] = createTranslator(
openai,
src,
tgt,
customPrompt,
extraRules
);
}
return translators[key];
};
let successCount = 0;
// 逐条重试
for (const errorEntry of translationErrors) {
const { batch, srcLang, tgtLang, targetFilePath } = errorEntry.context;
const translate = getTranslator(srcLang, tgtLang);
try {
logger.progress(
`重试翻译: ${path.basename(targetFilePath)} (${
Object.keys(batch).length
} 条目)`
);
const result = await translate(batch, { targetFilePath });
// 将结果写入文件
let targetObj = {};
try {
const content = await fs.readFile(targetFilePath, "utf8");
targetObj = JSON.parse(content);
} catch (e) {
logger.warn(`无法读取目标文件: ${targetFilePath}`);
}
let hasUpdate = false;
Object.entries(result).forEach(([k, v]) => {
if (v !== null) {
nestedUtils.set(targetObj, k, v);
hasUpdate = true;
}
});
if (hasUpdate) {
await fs.writeFile(
targetFilePath,
JSON.stringify(targetObj, null, 2)
);
successCount++;
}
} catch (err) {
// translate 内部已经记录了日志,这里不需要做任何事
}
}
logger.finish(`重试任务完成!成功处理: ${successCount} 条记录`);
} catch (err) {
logger.error(`重试任务失败: ${err.message}`);
await errorHandler.logError(err, { command: "retry-failed", options });
process.exit(1);
}
}export async function incrementalTranslateCommand(options) {
const { source, target, token, customPrompt, extraRules } = options;
try {
logger.start(`开始增量翻译任务`);
// 解析 token(支持环境变量)
const { TokenResolver } = await import("./utils.js");
const resolvedToken = TokenResolver.resolveToken(token);
const tokenSource = TokenResolver.getTokenSource(token);
logger.info(`Token 来源: ${tokenSource}`);
await incrementalTranslate(source, target, resolvedToken, customPrompt, extraRules);
logger.finish("增量翻译任务完成!");
} catch (err) {
logger.error(`增量翻译任务失败: ${err.message}`);
await errorHandler.logError(err, {
command: "incremental-translate",
options,
});
process.exit(1);
}
}
export async function updateSnapshotCommand(options) {
const { source } = options;
try {
logger.start(`开始更新快照任务`);
await updateSnapshot(source);
logger.finish("快照更新任务完成!");
} catch (err) {
logger.error(`快照更新任务失败: ${err.message}`);
await errorHandler.logError(err, { command: "update-snapshot", options });
process.exit(1);
}
}
// ====================== 导出工具函数 ======================
export { extractAllTemplateVariables, placeholderize };