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

379 lines
11 KiB
JavaScript
Raw Permalink Normal View History

2025-11-13 08:38:25 +00:00
/*
CommonJS runtime for applying translations from Excel to source code.
*/
2025-11-28 06:31:36 +00:00
const path = require('node:path')
const fs = require('node:fs')
const { Project, SyntaxKind, Node } = require('ts-morph')
const XLSX = require('xlsx')
2025-11-13 08:38:25 +00:00
2025-11-28 06:31:36 +00:00
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')
2025-11-13 08:38:25 +00:00
// 统计信息
const stats = {
total: 0,
success: 0,
conflicts: 0,
fileNotFound: 0,
textNotFound: 0,
2025-11-28 06:31:36 +00:00
multipleMatches: 0,
}
2025-11-13 08:38:25 +00:00
// 冲突列表
2025-11-28 06:31:36 +00:00
const conflicts = []
2025-11-13 08:38:25 +00:00
// 成功替换列表
2025-11-28 06:31:36 +00:00
const successfulReplacements = []
2025-11-13 08:38:25 +00:00
function loadTranslations() {
2025-11-28 06:31:36 +00:00
console.log('📖 读取翻译数据...')
const wb = XLSX.readFile(TRANSLATES_FILE)
const ws = wb.Sheets[wb.SheetNames[0]]
const data = XLSX.utils.sheet_to_json(ws, { defval: '' })
2025-11-13 08:38:25 +00:00
// 筛选出需要替换的条目
2025-11-28 06:31:36 +00:00
let translations = data.filter(
(row) => row.text && row.corrected_text && row.text !== row.corrected_text
)
2025-11-13 08:38:25 +00:00
// 去重:按 file + line + text 去重,保留第一个
2025-11-28 06:31:36 +00:00
const seen = new Set()
translations = translations.filter((row) => {
const key = `${row.file}:${row.line}:${row.text}`
2025-11-13 08:38:25 +00:00
if (seen.has(key)) {
2025-11-28 06:31:36 +00:00
return false
2025-11-13 08:38:25 +00:00
}
2025-11-28 06:31:36 +00:00
seen.add(key)
return true
})
console.log(`📊 找到 ${translations.length} 条需要替换的翻译(已去重)`)
stats.total = translations.length
return translations
2025-11-13 08:38:25 +00:00
}
function groupByFile(translations) {
2025-11-28 06:31:36 +00:00
const groups = new Map()
2025-11-13 08:38:25 +00:00
for (const translation of translations) {
2025-11-28 06:31:36 +00:00
const filePath = path.join(WORKDIR, translation.file)
2025-11-13 08:38:25 +00:00
if (!groups.has(filePath)) {
2025-11-28 06:31:36 +00:00
groups.set(filePath, [])
2025-11-13 08:38:25 +00:00
}
2025-11-28 06:31:36 +00:00
groups.get(filePath).push(translation)
2025-11-13 08:38:25 +00:00
}
2025-11-28 06:31:36 +00:00
return groups
2025-11-13 08:38:25 +00:00
}
function findTextInNode(node, targetText, kind) {
2025-11-28 06:31:36 +00:00
if (!node) return null
2025-11-13 08:38:25 +00:00
// 处理 JSX 文本节点
if (Node.isJsxText(node)) {
2025-11-28 06:31:36 +00:00
const text = node.getText().replace(/\s+/g, ' ').trim()
if (text === targetText) return node
2025-11-13 08:38:25 +00:00
}
2025-11-28 06:31:36 +00:00
2025-11-13 08:38:25 +00:00
// 处理字符串字面量
if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) {
2025-11-28 06:31:36 +00:00
const text = node.getLiteralText()
if (text === targetText) return node
2025-11-13 08:38:25 +00:00
}
2025-11-28 06:31:36 +00:00
2025-11-13 08:38:25 +00:00
// 处理 JSX 表达式中的字符串
if (Node.isJsxExpression(node)) {
2025-11-28 06:31:36 +00:00
const expr = node.getExpression()
2025-11-13 08:38:25 +00:00
if (expr && (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) {
2025-11-28 06:31:36 +00:00
const text = expr.getLiteralText()
if (text === targetText) return node
2025-11-13 08:38:25 +00:00
}
}
2025-11-28 06:31:36 +00:00
return null
2025-11-13 08:38:25 +00:00
}
function findTextInFile(sourceFile, translation) {
2025-11-28 06:31:36 +00:00
const { text, line, kind } = translation
const matches = []
2025-11-13 08:38:25 +00:00
sourceFile.forEachDescendant((node) => {
// 根据 kind 类型进行不同的匹配
if (kind === 'text') {
// 查找 JSX 文本节点
if (Node.isJsxText(node)) {
2025-11-28 06:31:36 +00:00
const nodeText = node.getText().replace(/\s+/g, ' ').trim()
2025-11-13 08:38:25 +00:00
if (nodeText === text) {
2025-11-28 06:31:36 +00:00
matches.push({ node, type: 'jsx-text', line: node.getStartLineNumber() })
2025-11-13 08:38:25 +00:00
}
}
// 查找 JSX 表达式中的字符串
if (Node.isJsxExpression(node)) {
2025-11-28 06:31:36 +00:00
const expr = node.getExpression()
2025-11-13 08:38:25 +00:00
if (expr && (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) {
2025-11-28 06:31:36 +00:00
const nodeText = expr.getLiteralText()
2025-11-13 08:38:25 +00:00
if (nodeText === text) {
2025-11-28 06:31:36 +00:00
matches.push({ node: expr, type: 'jsx-expression', line: node.getStartLineNumber() })
2025-11-13 08:38:25 +00:00
}
}
}
} else if (['placeholder', 'title', 'alt', 'label', 'aria'].includes(kind)) {
// 查找 JSX 属性
if (Node.isJsxAttribute(node)) {
2025-11-28 06:31:36 +00:00
const name = node.getNameNode().getText().toLowerCase()
const value = getStringFromInitializer(node)
2025-11-13 08:38:25 +00:00
if (value === text) {
2025-11-28 06:31:36 +00:00
matches.push({ node, type: 'jsx-attribute', line: node.getStartLineNumber() })
2025-11-13 08:38:25 +00:00
}
}
} else if (['toast', 'dialog', 'error', 'validation'].includes(kind)) {
// 查找函数调用中的字符串参数
if (Node.isCallExpression(node)) {
2025-11-28 06:31:36 +00:00
const args = node.getArguments()
2025-11-13 08:38:25 +00:00
for (const arg of args) {
if (Node.isStringLiteral(arg) || Node.isNoSubstitutionTemplateLiteral(arg)) {
2025-11-28 06:31:36 +00:00
const nodeText = arg.getLiteralText()
2025-11-13 08:38:25 +00:00
if (nodeText === text) {
2025-11-28 06:31:36 +00:00
matches.push({ node: arg, type: 'function-arg', line: node.getStartLineNumber() })
2025-11-13 08:38:25 +00:00
}
}
}
}
}
2025-11-28 06:31:36 +00:00
})
return matches
2025-11-13 08:38:25 +00:00
}
function getStringFromInitializer(attr) {
2025-11-28 06:31:36 +00:00
const init = attr.getInitializer()
if (!init) return undefined
2025-11-13 08:38:25 +00:00
if (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init)) {
2025-11-28 06:31:36 +00:00
return init.getLiteralText()
2025-11-13 08:38:25 +00:00
}
if (Node.isJsxExpression(init)) {
2025-11-28 06:31:36 +00:00
const expr = init.getExpression()
if (!expr) return undefined
2025-11-13 08:38:25 +00:00
if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) {
2025-11-28 06:31:36 +00:00
return expr.getLiteralText()
2025-11-13 08:38:25 +00:00
}
}
2025-11-28 06:31:36 +00:00
return undefined
2025-11-13 08:38:25 +00:00
}
function replaceText(node, newText, type) {
try {
if (type === 'jsx-text') {
// JSX 文本节点需要特殊处理,保持空白字符
2025-11-28 06:31:36 +00:00
const originalText = node.getText()
const newTextWithWhitespace = originalText.replace(/\S+/g, newText)
node.replaceWithText(newTextWithWhitespace)
2025-11-13 08:38:25 +00:00
} else if (type === 'jsx-expression' || type === 'function-arg') {
// 字符串字面量
if (Node.isStringLiteral(node)) {
2025-11-28 06:31:36 +00:00
node.replaceWithText(`"${newText}"`)
2025-11-13 08:38:25 +00:00
} else if (Node.isNoSubstitutionTemplateLiteral(node)) {
2025-11-28 06:31:36 +00:00
node.replaceWithText(`\`${newText}\``)
2025-11-13 08:38:25 +00:00
}
} else if (type === 'jsx-attribute') {
// JSX 属性值
2025-11-28 06:31:36 +00:00
const init = node.getInitializer()
2025-11-13 08:38:25 +00:00
if (init) {
if (Node.isStringLiteral(init)) {
2025-11-28 06:31:36 +00:00
init.replaceWithText(`"${newText}"`)
2025-11-13 08:38:25 +00:00
} else if (Node.isNoSubstitutionTemplateLiteral(init)) {
2025-11-28 06:31:36 +00:00
init.replaceWithText(`\`${newText}\``)
2025-11-13 08:38:25 +00:00
} else if (Node.isJsxExpression(init)) {
2025-11-28 06:31:36 +00:00
const expr = init.getExpression()
2025-11-13 08:38:25 +00:00
if (expr && (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) {
if (Node.isStringLiteral(expr)) {
2025-11-28 06:31:36 +00:00
expr.replaceWithText(`"${newText}"`)
2025-11-13 08:38:25 +00:00
} else {
2025-11-28 06:31:36 +00:00
expr.replaceWithText(`\`${newText}\``)
2025-11-13 08:38:25 +00:00
}
}
}
}
}
2025-11-28 06:31:36 +00:00
return true
2025-11-13 08:38:25 +00:00
} catch (error) {
2025-11-28 06:31:36 +00:00
console.error(`❌ 替换失败: ${error.message}`)
return false
2025-11-13 08:38:25 +00:00
}
}
function processFile(filePath, translations) {
if (!fs.existsSync(filePath)) {
2025-11-28 06:31:36 +00:00
console.log(`❌ 文件不存在: ${path.relative(WORKDIR, filePath)}`)
translations.forEach((t) => {
2025-11-13 08:38:25 +00:00
conflicts.push({
...t,
conflictType: 'FILE_NOT_FOUND',
2025-11-28 06:31:36 +00:00
conflictReason: '文件不存在',
})
})
stats.fileNotFound += translations.length
return
2025-11-13 08:38:25 +00:00
}
2025-11-28 06:31:36 +00:00
console.log(`📝 处理文件: ${path.relative(WORKDIR, filePath)}`)
2025-11-13 08:38:25 +00:00
try {
2025-11-28 06:31:36 +00:00
const project = new Project({
tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'),
skipAddingFilesFromTsConfig: true,
})
const sourceFile = project.addSourceFileAtPath(filePath)
2025-11-13 08:38:25 +00:00
for (const translation of translations) {
2025-11-28 06:31:36 +00:00
const { text, corrected_text, line, kind } = translation
2025-11-13 08:38:25 +00:00
// 首先在指定行附近查找
2025-11-28 06:31:36 +00:00
let matches = findTextInFile(sourceFile, translation)
2025-11-13 08:38:25 +00:00
// 如果没找到,在整个文件中搜索
if (matches.length === 0) {
2025-11-28 06:31:36 +00:00
matches = findTextInFile(sourceFile, translation)
2025-11-13 08:38:25 +00:00
}
2025-11-28 06:31:36 +00:00
2025-11-13 08:38:25 +00:00
if (matches.length === 0) {
conflicts.push({
...translation,
conflictType: 'TEXT_NOT_FOUND_IN_FILE',
2025-11-28 06:31:36 +00:00
conflictReason: '在文件中找不到匹配的文本',
})
stats.textNotFound++
continue
2025-11-13 08:38:25 +00:00
}
2025-11-28 06:31:36 +00:00
2025-11-13 08:38:25 +00:00
if (matches.length > 1) {
conflicts.push({
...translation,
conflictType: 'MULTIPLE_MATCHES',
2025-11-28 06:31:36 +00:00
conflictReason: `找到 ${matches.length} 个匹配项,需要人工确认`,
})
stats.multipleMatches++
continue
2025-11-13 08:38:25 +00:00
}
2025-11-28 06:31:36 +00:00
2025-11-13 08:38:25 +00:00
// 执行替换
2025-11-28 06:31:36 +00:00
const match = matches[0]
const success = replaceText(match.node, corrected_text, match.type)
2025-11-13 08:38:25 +00:00
if (success) {
successfulReplacements.push({
...translation,
actualLine: match.line,
2025-11-28 06:31:36 +00:00
replacementType: match.type,
})
stats.success++
console.log(`✅ 替换成功: "${text}" -> "${corrected_text}" (行 ${match.line})`)
2025-11-13 08:38:25 +00:00
} else {
conflicts.push({
...translation,
conflictType: 'REPLACEMENT_FAILED',
2025-11-28 06:31:36 +00:00
conflictReason: '替换操作失败',
})
stats.conflicts++
2025-11-13 08:38:25 +00:00
}
}
2025-11-28 06:31:36 +00:00
2025-11-13 08:38:25 +00:00
// 保存修改后的文件
2025-11-28 06:31:36 +00:00
sourceFile.saveSync()
2025-11-13 08:38:25 +00:00
} catch (error) {
2025-11-28 06:31:36 +00:00
console.error(`❌ 处理文件失败: ${filePath}`, error.message)
translations.forEach((t) => {
2025-11-13 08:38:25 +00:00
conflicts.push({
...t,
conflictType: 'PARSE_ERROR',
2025-11-28 06:31:36 +00:00
conflictReason: `文件解析失败: ${error.message}`,
})
})
stats.conflicts += translations.length
2025-11-13 08:38:25 +00:00
}
}
function generateReport() {
2025-11-28 06:31:36 +00:00
console.log('\n📊 生成报告...')
2025-11-13 08:38:25 +00:00
// 生成成功替换报告
const report = {
timestamp: new Date().toISOString(),
stats,
successfulReplacements,
2025-11-28 06:31:36 +00:00
conflicts: conflicts.map((c) => ({
2025-11-13 08:38:25 +00:00
file: c.file,
line: c.line,
text: c.text,
corrected_text: c.corrected_text,
conflictType: c.conflictType,
2025-11-28 06:31:36 +00:00
conflictReason: c.conflictReason,
})),
}
fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2))
console.log(`📄 成功替换报告已保存: ${REPORT_FILE}`)
2025-11-13 08:38:25 +00:00
// 生成冲突报告 Excel
if (conflicts.length > 0) {
2025-11-28 06:31:36 +00:00
const conflictRows = conflicts.map((c) => ({
2025-11-13 08:38:25 +00:00
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,
2025-11-28 06:31:36 +00:00
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}`)
2025-11-13 08:38:25 +00:00
}
}
function printSummary() {
2025-11-28 06:31:36 +00:00
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)}%`)
2025-11-13 08:38:25 +00:00
}
async function main() {
2025-11-28 06:31:36 +00:00
console.log('🚀 开始应用翻译...\n')
2025-11-13 08:38:25 +00:00
try {
// 1. 读取翻译数据
2025-11-28 06:31:36 +00:00
const translations = loadTranslations()
2025-11-13 08:38:25 +00:00
// 2. 按文件分组
2025-11-28 06:31:36 +00:00
const fileGroups = groupByFile(translations)
2025-11-13 08:38:25 +00:00
// 3. 处理每个文件
for (const [filePath, fileTranslations] of fileGroups) {
2025-11-28 06:31:36 +00:00
processFile(filePath, fileTranslations)
2025-11-13 08:38:25 +00:00
}
2025-11-28 06:31:36 +00:00
2025-11-13 08:38:25 +00:00
// 4. 生成报告
2025-11-28 06:31:36 +00:00
generateReport()
2025-11-13 08:38:25 +00:00
// 5. 打印总结
2025-11-28 06:31:36 +00:00
printSummary()
2025-11-13 08:38:25 +00:00
} catch (error) {
2025-11-28 06:31:36 +00:00
console.error('❌ 执行失败:', error)
process.exitCode = 1
2025-11-13 08:38:25 +00:00
}
}
2025-11-28 06:31:36 +00:00
main()