import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const read = p => JSON.parse(fs.readFileSync(p, 'utf8')); const hash = p => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); // 경로를 배열로 보관하므로 점이 들어간 번역 키도 다른 키와 혼동하지 않는다. export function flatten(value, parts = [], result = new Map()) { if (typeof value === 'string') result.set(JSON.stringify(parts), value); else if (value && typeof value === 'object') { for (const [key, child] of Object.entries(value)) flatten(child, [...parts, key], result); } else throw new Error(`문자열이 아닌 번역 값: ${JSON.stringify(parts)}`); return result; } const tokens = text => (text.match(/\{\{[^}]+\}\}|\$\{[^}]+\}|%(?:\d+\$)?[sdif]/g) || []).sort(); export function compare(previous, korean, next) { const before = flatten(previous), translated = flatten(korean), after = flatten(next); const entries = []; for (const [key, source] of after) { const old = before.get(key), ko = translated.get(key); let status = old === undefined ? 'added' : old !== source ? 'source-changed' : !ko ? 'missing' : ko === source ? 'review-identical' : 'reusable'; if (status === 'reusable' && JSON.stringify(tokens(source)) !== JSON.stringify(tokens(ko))) status = 'placeholder-mismatch'; entries.push({ path: JSON.parse(key), source, previousSource: old ?? null, korean: ko ?? null, status }); } for (const [key, source] of before) if (!after.has(key)) entries.push({ path: JSON.parse(key), previousSource: source, korean: translated.get(key) ?? null, status: 'removed' }); return entries; } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const [version, nextFile, output, previousFile, koreanFile] = process.argv.slice(2); if (!version || !nextFile || !output) throw new Error('사용법: node tools/translation-upgrade.mjs 대상버전 새영문JSON 새출력폴더 [이전영문JSON 이전한국어JSON]'); if (!!previousFile !== !!koreanFile) throw new Error('이전 영문과 한국어 파일은 함께 지정해야 합니다.'); const baseline = read(path.join(root, 'localization/baseline.json')); const oldPath = previousFile || path.join(root, baseline.english); const koPath = koreanFile || path.join(root, baseline.korean); if (!previousFile && (hash(oldPath) !== baseline.englishSha256 || hash(koPath) !== baseline.koreanSha256)) throw new Error('기준 번역 파일이 변경됐습니다. 기준 메타데이터를 검토하세요.'); const entries = compare(read(oldPath), read(koPath), read(nextFile)); const counts = {}; for (const entry of entries) counts[entry.status] = (counts[entry.status] || 0) + 1; // 기존 검토 내용이 사라지지 않도록 출력 폴더는 새 경로만 허용한다. fs.mkdirSync(output, { recursive: false }); const metadata = { targetVersion: version, baselineVersion: previousFile ? '사용자 지정 기준' : baseline.appVersion, sourceSha256: hash(nextFile), previousSha256: hash(oldPath), koreanSha256: hash(koPath), counts }; fs.writeFileSync(path.join(output, 'review.json'), JSON.stringify({ ...metadata, entries }, null, 2) + '\n'); fs.writeFileSync(path.join(output, 'SUMMARY.ko.md'), `# ${version} 번역 비교\n\n${Object.entries(counts).map(([key, count]) => `- ${key}: ${count}`).join('\n')}\n\nreview.json의 한국어는 검토용 후보입니다. 원문이 바뀐 항목은 기존 번역을 그대로 적용하지 마세요. 이 도구는 배포 파일과 운영 데이터를 변경하지 않습니다.\n`); console.log(JSON.stringify(metadata, null, 2)); }