버전 업데이트에 대비한 번역 비교 도구와 유지 절차 추가

새 원문과 이전 한국어를 키별로 비교해 추가와 변경 및 삭제를 구분한다. 배포 파일 변경 비교와 기준 해시 및 후속 작업 문서를 추가했다. 자동 검사 4개와 UI 문구 1496개 비교 및 기존 배포 파일 492개 무결성 검사를 통과했다. 운영 앱과 DB는 변경하지 않았으며 기존 배포 커밋 eddda22를 보존한다. 코드와 도움말 이식 및 새 버전 실행 검증은 대상 버전 작업 때 수행한다.
This commit is contained in:
블리어
2026-09-20 20:30:00 +09:00
parent eddda22eb6
commit 426a416e7b
8 changed files with 226 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { compare } from './translation-upgrade.mjs';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
test('추가와 변경 및 삭제를 구분하고 기존 번역을 검토 후보로 보존한다', () => {
const result = compare({ same: 'Hello', changed: 'Old', gone: 'Gone' }, { same: '안녕하세요', changed: '이전', gone: '삭제' }, { same: 'Hello', changed: 'New', added: 'Add' });
assert.deepEqual(result.map(x => x.status), ['reusable', 'source-changed', 'added', 'removed']);
assert.equal(result[1].korean, '이전');
});
test('자리표시자 누락과 미번역을 자동 재사용에서 제외한다', () => {
const en = { a: 'Hi {{name}}', b: 'Name', c: 'Value', d: 'Brand' };
assert.deepEqual(compare(en, { a: '안녕', b: '', d: 'Brand' }, en).map(x => x.status), ['placeholder-mismatch', 'missing', 'missing', 'review-identical']);
});
test('점이 포함된 키와 중첩 키 및 배열을 구분한다', () => {
const en = { 'a.b': 'One', a: { b: 'Two' }, list: ['Three'] };
const ko = { 'a.b': '하나', a: { b: '둘' }, list: ['셋'] };
assert.equal(compare(en, ko, en).filter(x => x.status === 'reusable').length, 3);
});
test('파일 비교는 변경과 삭제 및 추가를 구분하고 기존 보고서를 덮어쓰지 않는다', () => {
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'trilium-translation-'));
try {
const old = path.join(temp, 'old'), next = path.join(temp, 'next');
fs.mkdirSync(old); fs.mkdirSync(next);
fs.writeFileSync(path.join(old, 'main.cjs'), 'old');
fs.writeFileSync(path.join(next, 'main.cjs'), 'next');
fs.mkdirSync(path.join(old, 'assets/db'), { recursive: true });
fs.writeFileSync(path.join(old, 'assets/db/demo.zip'), 'old-demo');
fs.mkdirSync(path.join(next, 'public/src'), { recursive: true });
fs.writeFileSync(path.join(next, 'public/src/ko.js'), 'next-ko');
const output = path.join(temp, 'report.json');
const tool = fileURLToPath(new URL('./translation-files.mjs', import.meta.url));
const run = () => spawnSync(process.execPath, [tool, old, next, output], { encoding: 'utf8' });
assert.equal(run().status, 0);
const saved = fs.readFileSync(output, 'utf8');
const entries = JSON.parse(saved).files;
assert.equal(entries.find(x => x.file === 'main.cjs').status, 'upstream-changed');
assert.equal(entries.find(x => x.file === 'assets/db/demo.zip').status, 'removed-or-moved');
assert.equal(entries.find(x => x.file === 'public/src/ko.js').status, 'new-upstream-path');
assert.notEqual(run().status, 0);
assert.equal(fs.readFileSync(output, 'utf8'), saved);
} finally {
// 이 검사에서 만든 임시 폴더만 정리한다. 사용자 자료 경로는 받지 않는다.
fs.rmSync(temp, { recursive: true, force: true });
}
});
+19
View File
@@ -0,0 +1,19 @@
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 [oldRoot, nextRoot, output] = process.argv.slice(2);
if (!oldRoot || !nextRoot || !output) throw new Error('사용법: node tools/translation-files.mjs 이전원본앱폴더 새원본앱폴더 새보고서JSON');
for (const dir of [oldRoot, nextRoot]) if (!fs.statSync(dir).isDirectory()) throw new Error(`앱 폴더가 아닙니다: ${dir}`);
const digest = file => fs.existsSync(file) && fs.statSync(file).isFile() ? crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') : null;
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'release/manifest.json'), 'utf8'));
// 배포판의 수정 대상만 검사한다. 새로 추가된 전체 파일은 원본 소스의 Git 비교로 별도 확인해야 한다.
const files = Object.keys(manifest.files).map(file => {
const before = digest(path.join(oldRoot, file)), after = digest(path.join(nextRoot, file));
const status = !before ? (after ? 'new-upstream-path' : 'local-only') : !after ? 'removed-or-moved' : before === after ? 'upstream-unchanged' : 'upstream-changed';
return { file, status, previousSha256: before, nextSha256: after, koreanSha256: manifest.files[file].sha256 };
});
fs.writeFileSync(output, JSON.stringify({ baselineVersion: manifest.appVersion, warning: '같은 경로의 변경 여부만 확인합니다. 번역 패치 적용 가능 여부와 실행 호환성을 보장하지 않습니다.', files }, null, 2) + '\n', { flag: 'wx' });
console.log(`수정 대상 ${files.length}개 비교 완료`);
+48
View File
@@ -0,0 +1,48 @@
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));
}