UNPKG

@alauda-fe/i18n-tools

Version:

基于 Azure OpenAI 的 JSON i18n 文件翻译和英文语法检查工具集

1,139 lines (954 loc) 34.8 kB
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 };