368 lines
11 KiB
JavaScript
368 lines
11 KiB
JavaScript
|
|
/*
|
||
|
|
CommonJS runtime for resetting files and applying translations from Excel to source code.
|
||
|
|
This script first resets files to their original state, then applies translations.
|
||
|
|
*/
|
||
|
|
const path = require('node:path');
|
||
|
|
const fs = require('node:fs');
|
||
|
|
const { execSync } = require('child_process');
|
||
|
|
const { Project, SyntaxKind, Node } = require('ts-morph');
|
||
|
|
const XLSX = require('xlsx');
|
||
|
|
|
||
|
|
const WORKDIR = process.cwd();
|
||
|
|
const TRANSLATES_FILE = path.join(WORKDIR, 'scripts', 'translates.xlsx');
|
||
|
|
const REPORT_FILE = path.join(WORKDIR, 'scripts', 'translation-report.json');
|
||
|
|
const CONFLICTS_FILE = path.join(WORKDIR, 'scripts', 'translation-conflicts.xlsx');
|
||
|
|
|
||
|
|
// 统计信息
|
||
|
|
const stats = {
|
||
|
|
total: 0,
|
||
|
|
success: 0,
|
||
|
|
conflicts: 0,
|
||
|
|
fileNotFound: 0,
|
||
|
|
textNotFound: 0,
|
||
|
|
multipleMatches: 0
|
||
|
|
};
|
||
|
|
|
||
|
|
// 冲突列表
|
||
|
|
const conflicts = [];
|
||
|
|
|
||
|
|
// 成功替换列表
|
||
|
|
const successfulReplacements = [];
|
||
|
|
|
||
|
|
function resetFiles() {
|
||
|
|
console.log('🔄 重置文件到原始状态...');
|
||
|
|
try {
|
||
|
|
// 使用 git 重置所有修改的文件
|
||
|
|
execSync('git checkout -- .', { cwd: WORKDIR, stdio: 'inherit' });
|
||
|
|
console.log('✅ 文件重置完成');
|
||
|
|
} catch (error) {
|
||
|
|
console.error('❌ 重置文件失败:', error.message);
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function loadTranslations() {
|
||
|
|
console.log('📖 读取翻译数据...');
|
||
|
|
const wb = XLSX.readFile(TRANSLATES_FILE);
|
||
|
|
const ws = wb.Sheets[wb.SheetNames[0]];
|
||
|
|
const data = XLSX.utils.sheet_to_json(ws, { defval: '' });
|
||
|
|
|
||
|
|
// 筛选出需要替换的条目
|
||
|
|
let translations = data.filter(row =>
|
||
|
|
row.text &&
|
||
|
|
row.corrected_text &&
|
||
|
|
row.text !== row.corrected_text
|
||
|
|
);
|
||
|
|
|
||
|
|
// 去重:按 file + line + text 去重,保留第一个
|
||
|
|
const seen = new Set();
|
||
|
|
translations = translations.filter(row => {
|
||
|
|
const key = `${row.file}:${row.line}:${row.text}`;
|
||
|
|
if (seen.has(key)) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
seen.add(key);
|
||
|
|
return true;
|
||
|
|
});
|
||
|
|
|
||
|
|
console.log(`📊 找到 ${translations.length} 条需要替换的翻译(已去重)`);
|
||
|
|
stats.total = translations.length;
|
||
|
|
|
||
|
|
return translations;
|
||
|
|
}
|
||
|
|
|
||
|
|
function groupByFile(translations) {
|
||
|
|
const groups = new Map();
|
||
|
|
for (const translation of translations) {
|
||
|
|
const filePath = path.join(WORKDIR, translation.file);
|
||
|
|
if (!groups.has(filePath)) {
|
||
|
|
groups.set(filePath, []);
|
||
|
|
}
|
||
|
|
groups.get(filePath).push(translation);
|
||
|
|
}
|
||
|
|
return groups;
|
||
|
|
}
|
||
|
|
|
||
|
|
function findTextInFile(sourceFile, translation) {
|
||
|
|
const { text, kind } = translation;
|
||
|
|
const matches = [];
|
||
|
|
|
||
|
|
sourceFile.forEachDescendant((node) => {
|
||
|
|
// 根据 kind 类型进行不同的匹配
|
||
|
|
if (kind === 'text') {
|
||
|
|
// 查找 JSX 文本节点
|
||
|
|
if (Node.isJsxText(node)) {
|
||
|
|
const nodeText = node.getText().replace(/\s+/g, ' ').trim();
|
||
|
|
if (nodeText === text) {
|
||
|
|
matches.push({ node, type: 'jsx-text', line: node.getStartLineNumber() });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// 查找 JSX 表达式中的字符串
|
||
|
|
if (Node.isJsxExpression(node)) {
|
||
|
|
const expr = node.getExpression();
|
||
|
|
if (expr && (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) {
|
||
|
|
const nodeText = expr.getLiteralText();
|
||
|
|
if (nodeText === text) {
|
||
|
|
matches.push({ node: expr, type: 'jsx-expression', line: node.getStartLineNumber() });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else if (['placeholder', 'title', 'alt', 'label', 'aria'].includes(kind)) {
|
||
|
|
// 查找 JSX 属性
|
||
|
|
if (Node.isJsxAttribute(node)) {
|
||
|
|
const name = node.getNameNode().getText().toLowerCase();
|
||
|
|
const value = getStringFromInitializer(node);
|
||
|
|
if (value === text) {
|
||
|
|
matches.push({ node, type: 'jsx-attribute', line: node.getStartLineNumber() });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} else if (['toast', 'dialog', 'error', 'validation'].includes(kind)) {
|
||
|
|
// 查找函数调用中的字符串参数
|
||
|
|
if (Node.isCallExpression(node)) {
|
||
|
|
const args = node.getArguments();
|
||
|
|
for (const arg of args) {
|
||
|
|
if (Node.isStringLiteral(arg) || Node.isNoSubstitutionTemplateLiteral(arg)) {
|
||
|
|
const nodeText = arg.getLiteralText();
|
||
|
|
if (nodeText === text) {
|
||
|
|
matches.push({ node: arg, type: 'function-arg', line: node.getStartLineNumber() });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
return matches;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getStringFromInitializer(attr) {
|
||
|
|
const init = attr.getInitializer();
|
||
|
|
if (!init) return undefined;
|
||
|
|
if (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init)) {
|
||
|
|
return init.getLiteralText();
|
||
|
|
}
|
||
|
|
if (Node.isJsxExpression(init)) {
|
||
|
|
const expr = init.getExpression();
|
||
|
|
if (!expr) return undefined;
|
||
|
|
if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) {
|
||
|
|
return expr.getLiteralText();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function replaceText(node, newText, type) {
|
||
|
|
try {
|
||
|
|
if (type === 'jsx-text') {
|
||
|
|
// JSX 文本节点需要特殊处理,保持空白字符
|
||
|
|
const originalText = node.getText();
|
||
|
|
const newTextWithWhitespace = originalText.replace(/\S+/g, newText);
|
||
|
|
node.replaceWithText(newTextWithWhitespace);
|
||
|
|
} else if (type === 'jsx-expression' || type === 'function-arg') {
|
||
|
|
// 字符串字面量
|
||
|
|
if (Node.isStringLiteral(node)) {
|
||
|
|
node.replaceWithText(`"${newText}"`);
|
||
|
|
} else if (Node.isNoSubstitutionTemplateLiteral(node)) {
|
||
|
|
node.replaceWithText(`\`${newText}\``);
|
||
|
|
}
|
||
|
|
} else if (type === 'jsx-attribute') {
|
||
|
|
// JSX 属性值
|
||
|
|
const init = node.getInitializer();
|
||
|
|
if (init) {
|
||
|
|
if (Node.isStringLiteral(init)) {
|
||
|
|
init.replaceWithText(`"${newText}"`);
|
||
|
|
} else if (Node.isNoSubstitutionTemplateLiteral(init)) {
|
||
|
|
init.replaceWithText(`\`${newText}\``);
|
||
|
|
} else if (Node.isJsxExpression(init)) {
|
||
|
|
const expr = init.getExpression();
|
||
|
|
if (expr && (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) {
|
||
|
|
if (Node.isStringLiteral(expr)) {
|
||
|
|
expr.replaceWithText(`"${newText}"`);
|
||
|
|
} else {
|
||
|
|
expr.replaceWithText(`\`${newText}\``);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
} catch (error) {
|
||
|
|
console.error(`❌ 替换失败: ${error.message}`);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function processFile(filePath, translations) {
|
||
|
|
if (!fs.existsSync(filePath)) {
|
||
|
|
console.log(`❌ 文件不存在: ${path.relative(WORKDIR, filePath)}`);
|
||
|
|
translations.forEach(t => {
|
||
|
|
conflicts.push({
|
||
|
|
...t,
|
||
|
|
conflictType: 'FILE_NOT_FOUND',
|
||
|
|
conflictReason: '文件不存在'
|
||
|
|
});
|
||
|
|
});
|
||
|
|
stats.fileNotFound += translations.length;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(`📝 处理文件: ${path.relative(WORKDIR, filePath)}`);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const project = new Project({
|
||
|
|
tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'),
|
||
|
|
skipAddingFilesFromTsConfig: true
|
||
|
|
});
|
||
|
|
const sourceFile = project.addSourceFileAtPath(filePath);
|
||
|
|
|
||
|
|
for (const translation of translations) {
|
||
|
|
const { text, corrected_text, line, kind } = translation;
|
||
|
|
|
||
|
|
// 在文件中查找匹配的文本
|
||
|
|
const matches = findTextInFile(sourceFile, translation);
|
||
|
|
|
||
|
|
if (matches.length === 0) {
|
||
|
|
conflicts.push({
|
||
|
|
...translation,
|
||
|
|
conflictType: 'TEXT_NOT_FOUND_IN_FILE',
|
||
|
|
conflictReason: '在文件中找不到匹配的文本'
|
||
|
|
});
|
||
|
|
stats.textNotFound++;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (matches.length > 1) {
|
||
|
|
conflicts.push({
|
||
|
|
...translation,
|
||
|
|
conflictType: 'MULTIPLE_MATCHES',
|
||
|
|
conflictReason: `找到 ${matches.length} 个匹配项,需要人工确认`
|
||
|
|
});
|
||
|
|
stats.multipleMatches++;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 执行替换
|
||
|
|
const match = matches[0];
|
||
|
|
const success = replaceText(match.node, corrected_text, match.type);
|
||
|
|
|
||
|
|
if (success) {
|
||
|
|
successfulReplacements.push({
|
||
|
|
...translation,
|
||
|
|
actualLine: match.line,
|
||
|
|
replacementType: match.type
|
||
|
|
});
|
||
|
|
stats.success++;
|
||
|
|
console.log(`✅ 替换成功: "${text}" -> "${corrected_text}" (行 ${match.line})`);
|
||
|
|
} else {
|
||
|
|
conflicts.push({
|
||
|
|
...translation,
|
||
|
|
conflictType: 'REPLACEMENT_FAILED',
|
||
|
|
conflictReason: '替换操作失败'
|
||
|
|
});
|
||
|
|
stats.conflicts++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 保存修改后的文件
|
||
|
|
sourceFile.saveSync();
|
||
|
|
|
||
|
|
} catch (error) {
|
||
|
|
console.error(`❌ 处理文件失败: ${filePath}`, error.message);
|
||
|
|
translations.forEach(t => {
|
||
|
|
conflicts.push({
|
||
|
|
...t,
|
||
|
|
conflictType: 'PARSE_ERROR',
|
||
|
|
conflictReason: `文件解析失败: ${error.message}`
|
||
|
|
});
|
||
|
|
});
|
||
|
|
stats.conflicts += translations.length;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function generateReport() {
|
||
|
|
console.log('\n📊 生成报告...');
|
||
|
|
|
||
|
|
// 生成成功替换报告
|
||
|
|
const report = {
|
||
|
|
timestamp: new Date().toISOString(),
|
||
|
|
stats,
|
||
|
|
successfulReplacements,
|
||
|
|
conflicts: conflicts.map(c => ({
|
||
|
|
file: c.file,
|
||
|
|
line: c.line,
|
||
|
|
text: c.text,
|
||
|
|
corrected_text: c.corrected_text,
|
||
|
|
conflictType: c.conflictType,
|
||
|
|
conflictReason: c.conflictReason
|
||
|
|
}))
|
||
|
|
};
|
||
|
|
|
||
|
|
fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2));
|
||
|
|
console.log(`📄 成功替换报告已保存: ${REPORT_FILE}`);
|
||
|
|
|
||
|
|
// 生成冲突报告 Excel
|
||
|
|
if (conflicts.length > 0) {
|
||
|
|
const conflictRows = conflicts.map(c => ({
|
||
|
|
file: c.file,
|
||
|
|
line: c.line,
|
||
|
|
text: c.text,
|
||
|
|
corrected_text: c.corrected_text,
|
||
|
|
conflictType: c.conflictType,
|
||
|
|
conflictReason: c.conflictReason,
|
||
|
|
route: c.route,
|
||
|
|
componentOrFn: c.componentOrFn,
|
||
|
|
kind: c.kind,
|
||
|
|
keyOrLocator: c.keyOrLocator
|
||
|
|
}));
|
||
|
|
|
||
|
|
const ws = XLSX.utils.json_to_sheet(conflictRows);
|
||
|
|
const wb = XLSX.utils.book_new();
|
||
|
|
XLSX.utils.book_append_sheet(wb, ws, 'conflicts');
|
||
|
|
XLSX.writeFile(wb, CONFLICTS_FILE);
|
||
|
|
console.log(`📄 冲突报告已保存: ${CONFLICTS_FILE}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function printSummary() {
|
||
|
|
console.log('\n📈 处理完成!');
|
||
|
|
console.log(`总翻译条目: ${stats.total}`);
|
||
|
|
console.log(`✅ 成功替换: ${stats.success}`);
|
||
|
|
console.log(`❌ 文件不存在: ${stats.fileNotFound}`);
|
||
|
|
console.log(`❌ 文本未找到: ${stats.textNotFound}`);
|
||
|
|
console.log(`❌ 多处匹配: ${stats.multipleMatches}`);
|
||
|
|
console.log(`❌ 其他冲突: ${stats.conflicts}`);
|
||
|
|
console.log(`\n成功率: ${((stats.success / stats.total) * 100).toFixed(1)}%`);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function main() {
|
||
|
|
console.log('🚀 开始重置并应用翻译...\n');
|
||
|
|
|
||
|
|
try {
|
||
|
|
// 1. 重置文件到原始状态
|
||
|
|
resetFiles();
|
||
|
|
|
||
|
|
// 2. 读取翻译数据
|
||
|
|
const translations = loadTranslations();
|
||
|
|
|
||
|
|
// 3. 按文件分组
|
||
|
|
const fileGroups = groupByFile(translations);
|
||
|
|
|
||
|
|
// 4. 处理每个文件
|
||
|
|
for (const [filePath, fileTranslations] of fileGroups) {
|
||
|
|
processFile(filePath, fileTranslations);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 5. 生成报告
|
||
|
|
generateReport();
|
||
|
|
|
||
|
|
// 6. 打印总结
|
||
|
|
printSummary();
|
||
|
|
|
||
|
|
} catch (error) {
|
||
|
|
console.error('❌ 执行失败:', error);
|
||
|
|
process.exitCode = 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
main();
|