crush-level-web/scripts/apply-translations.cjs

379 lines
11 KiB
JavaScript

/*
CommonJS runtime for applying translations from Excel to source code.
*/
const path = require('node:path')
const fs = require('node:fs')
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 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 findTextInNode(node, targetText, kind) {
if (!node) return null
// 处理 JSX 文本节点
if (Node.isJsxText(node)) {
const text = node.getText().replace(/\s+/g, ' ').trim()
if (text === targetText) return node
}
// 处理字符串字面量
if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) {
const text = node.getLiteralText()
if (text === targetText) return node
}
// 处理 JSX 表达式中的字符串
if (Node.isJsxExpression(node)) {
const expr = node.getExpression()
if (expr && (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) {
const text = expr.getLiteralText()
if (text === targetText) return node
}
}
return null
}
function findTextInFile(sourceFile, translation) {
const { text, line, 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
// 首先在指定行附近查找
let matches = findTextInFile(sourceFile, translation)
// 如果没找到,在整个文件中搜索
if (matches.length === 0) {
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. 读取翻译数据
const translations = loadTranslations()
// 2. 按文件分组
const fileGroups = groupByFile(translations)
// 3. 处理每个文件
for (const [filePath, fileTranslations] of fileGroups) {
processFile(filePath, fileTranslations)
}
// 4. 生成报告
generateReport()
// 5. 打印总结
printSummary()
} catch (error) {
console.error('❌ 执行失败:', error)
process.exitCode = 1
}
}
main()