309 lines
10 KiB
JavaScript
309 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* i18n 多语言同步对比工具
|
||
*
|
||
* 对比平台导出的完整 i18n JSON 与本地 locale 文件,生成新增/修改的差异 JSON。
|
||
* 仅处理 Mobile.* 前缀的 key。支持 zh-CN / en-US / zh-TW 三种语言。
|
||
*
|
||
* 用法:
|
||
* node scripts/sync-i18n-diff.js --lang zh-CN --source ./i18n-config-zh-CN.json
|
||
* node scripts/sync-i18n-diff.js --lang en-US --source ./i18n-config-export.json --batch-size 100
|
||
*
|
||
* 输出文件(在 source 同目录):
|
||
* - i18n-diff-{lang}-added.json 新增的 key
|
||
* - i18n-diff-{lang}-modified.json 值有变化的 key
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
// ── 参数解析 ──────────────────────────────────────────────
|
||
|
||
function parseArgs() {
|
||
const args = process.argv.slice(2);
|
||
const opts = { lang: 'zh-CN', batchSize: 0 };
|
||
for (let i = 0; i < args.length; i++) {
|
||
switch (args[i]) {
|
||
case '--lang':
|
||
opts.lang = args[++i];
|
||
break;
|
||
case '--source':
|
||
opts.source = args[++i];
|
||
break;
|
||
case '--batch-size':
|
||
opts.batchSize = parseInt(args[++i], 10) || 0;
|
||
break;
|
||
case '--help':
|
||
case '-h':
|
||
console.log(`
|
||
i18n 多语言同步对比工具
|
||
|
||
对比平台导出的完整 i18n JSON 与本地 locale 文件,生成新增/修改的差异 JSON。
|
||
仅处理 Mobile.* 前缀的 key,支持 zh-CN / en-US / zh-TW 三种语言。
|
||
|
||
用法:
|
||
node scripts/sync-i18n-diff.js --lang <语言> --source <导出文件> [--batch-size <数量>]
|
||
|
||
参数:
|
||
--lang <lang> 目标语言: zh-CN | en-US | zh-TW (默认 zh-CN)
|
||
--source <file> 平台导出的 JSON 文件路径
|
||
--batch-size <n> 分批大小 (0 = 不分批,默认 0)
|
||
-h, --help 显示帮助
|
||
|
||
示例:
|
||
# zh-CN 对比(不分批)
|
||
node scripts/sync-i18n-diff.js --lang zh-CN --source .temp/i18n-platform/source/i18n-config-zh-CN.json
|
||
|
||
# en-US 对比,每批 100 个
|
||
node scripts/sync-i18n-diff.js --lang en-US --source .temp/i18n-platform/source/i18n-config-en-US.json --batch-size 100
|
||
|
||
# zh-TW 对比,每批 100 个
|
||
node scripts/sync-i18n-diff.js --lang zh-TW --source .temp/i18n-platform/source/i18n-config-zh-TW.json --batch-size 100
|
||
|
||
输出:
|
||
差异 JSON 输出到 source 文件同目录下:
|
||
i18n-diff-{lang}-added.json 新增的 key
|
||
i18n-diff-{lang}-modified.json 值有变化的 key
|
||
i18n-diff-{lang}-modified-N-of-M.json 分批输出(--batch-size > 0 时)
|
||
`);
|
||
process.exit(0);
|
||
}
|
||
}
|
||
if (!opts.source) {
|
||
console.error('Error: --source is required');
|
||
process.exit(1);
|
||
}
|
||
return opts;
|
||
}
|
||
|
||
const LANG_FILE_MAP = {
|
||
'zh-CN': 'zh-cn.uts',
|
||
'en-US': 'en-us.uts',
|
||
'zh-TW': 'zh-tw.uts',
|
||
};
|
||
|
||
// ── 解析平台导出 JSON ────────────────────────────────────
|
||
|
||
function parseExport(filePath, targetLang) {
|
||
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||
const configs = raw.configs || raw;
|
||
const map = {};
|
||
for (const c of configs) {
|
||
if (c.lang !== targetLang) continue;
|
||
const key = c.fieldKey;
|
||
if (!key || !key.startsWith('Mobile.')) continue;
|
||
map[key] = c.fieldValue;
|
||
}
|
||
return map;
|
||
}
|
||
|
||
// ── 解析本地 locale TS 文件 ──────────────────────────────
|
||
|
||
function parseLocaleFile(filePath) {
|
||
const content = fs.readFileSync(filePath, 'utf8');
|
||
const map = {};
|
||
// 匹配 "Mobile.xxx": "value" 或 "Mobile.xxx": 'value'
|
||
const re = /^[\s]*"(Mobile\.[^"]+)":\s*"((?:[^"\\]|\\.)*)"/gm;
|
||
let m;
|
||
while ((m = re.exec(content)) !== null) {
|
||
map[m[1]] = m[2];
|
||
}
|
||
return map;
|
||
}
|
||
|
||
// ── 对比 ──────────────────────────────────────────────────
|
||
|
||
function diffKeys(exportMap, localMap) {
|
||
const added = {};
|
||
const modified = {};
|
||
const localOnly = {};
|
||
|
||
for (const [key, value] of Object.entries(exportMap)) {
|
||
if (!(key in localMap)) {
|
||
added[key] = value;
|
||
} else if (localMap[key] !== value) {
|
||
modified[key] = { local: localMap[key], remote: value };
|
||
}
|
||
}
|
||
|
||
// 本地有但平台没有的 key
|
||
for (const [key, value] of Object.entries(localMap)) {
|
||
if (!(key in exportMap)) {
|
||
localOnly[key] = value;
|
||
}
|
||
}
|
||
|
||
return { added, modified, localOnly };
|
||
}
|
||
|
||
// ── 分批 ──────────────────────────────────────────────────
|
||
|
||
function splitBatches(obj, batchSize) {
|
||
const entries = Object.entries(obj);
|
||
if (!batchSize || batchSize <= 0 || entries.length <= batchSize) {
|
||
return [obj];
|
||
}
|
||
const batches = [];
|
||
for (let i = 0; i < entries.length; i += batchSize) {
|
||
const batch = {};
|
||
for (const [k, v] of entries.slice(i, i + batchSize)) {
|
||
batch[k] = v;
|
||
}
|
||
batches.push(batch);
|
||
}
|
||
return batches;
|
||
}
|
||
|
||
// ── 主流程 ────────────────────────────────────────────────
|
||
|
||
function main() {
|
||
const opts = parseArgs();
|
||
|
||
const localeFileName = LANG_FILE_MAP[opts.lang];
|
||
if (!localeFileName) {
|
||
console.error(
|
||
`Error: unsupported lang "${opts.lang}". Use zh-CN | en-US | zh-TW.`,
|
||
);
|
||
process.exit(1);
|
||
}
|
||
|
||
const sourcePath = path.resolve(opts.source);
|
||
if (!fs.existsSync(sourcePath)) {
|
||
console.error(`Error: source file not found: ${sourcePath}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
const localePath = path.resolve(
|
||
__dirname,
|
||
`../constants/i18n-locales/${localeFileName}`,
|
||
);
|
||
if (!fs.existsSync(localePath)) {
|
||
console.error(`Error: locale file not found: ${localePath}`);
|
||
process.exit(1);
|
||
}
|
||
|
||
console.log(`\n=== i18n Diff Report (${opts.lang}) ===`);
|
||
console.log(`Source: ${sourcePath}`);
|
||
console.log(`Local: ${localePath}\n`);
|
||
|
||
const exportMap = parseExport(sourcePath, opts.lang);
|
||
const localMap = parseLocaleFile(localePath);
|
||
const { added, modified, localOnly } = diffKeys(exportMap, localMap);
|
||
|
||
const addedCount = Object.keys(added).length;
|
||
const modifiedCount = Object.keys(modified).length;
|
||
const localOnlyCount = Object.keys(localOnly).length;
|
||
const unchangedCount =
|
||
Object.keys(exportMap).length - addedCount - modifiedCount;
|
||
|
||
console.log(`Export Mobile.* keys: ${Object.keys(exportMap).length}`);
|
||
console.log(`Local Mobile.* keys: ${Object.keys(localMap).length}`);
|
||
console.log(` Added (platform has, local missing): ${addedCount}`);
|
||
console.log(` Modified (value differs): ${modifiedCount}`);
|
||
console.log(` Local-only (local has, platform missing): ${localOnlyCount}`);
|
||
console.log(` Unchanged: ${unchangedCount}\n`);
|
||
|
||
if (addedCount > 0) {
|
||
console.log('--- Added Keys (sample) ---');
|
||
for (const [k, v] of Object.entries(added).slice(0, 5)) {
|
||
console.log(` + ${k}: ${v.slice(0, 60)}`);
|
||
}
|
||
if (addedCount > 5) console.log(` ... and ${addedCount - 5} more`);
|
||
console.log();
|
||
}
|
||
|
||
if (modifiedCount > 0) {
|
||
console.log('--- Modified Keys (sample) ---');
|
||
for (const [k, v] of Object.entries(modified).slice(0, 5)) {
|
||
console.log(` ~ ${k}:`);
|
||
console.log(` local: ${v.local.slice(0, 60)}`);
|
||
console.log(` remote: ${v.remote.slice(0, 60)}`);
|
||
}
|
||
if (modifiedCount > 5) console.log(` ... and ${modifiedCount - 5} more`);
|
||
console.log();
|
||
}
|
||
|
||
if (localOnlyCount > 0) {
|
||
console.log('--- Local-only Keys (sample) ---');
|
||
for (const [k, v] of Object.entries(localOnly).slice(0, 5)) {
|
||
console.log(` - ${k}: ${v.slice(0, 60)}`);
|
||
}
|
||
if (localOnlyCount > 5) console.log(` ... and ${localOnlyCount - 5} more`);
|
||
console.log();
|
||
}
|
||
|
||
// 写入差异 JSON — 输出到 .temp/i18n-platform/diff/{lang}/
|
||
const baseDir = path.resolve(__dirname, '../.temp/i18n-platform');
|
||
const outDir = path.join(baseDir, 'diff', opts.lang);
|
||
fs.mkdirSync(outDir, { recursive: true });
|
||
|
||
if (addedCount > 0) {
|
||
const addedBatches = splitBatches(added, opts.batchSize);
|
||
if (addedBatches.length === 1) {
|
||
const outFile = path.join(outDir, `i18n-diff-${opts.lang}-added.json`);
|
||
fs.writeFileSync(outFile, JSON.stringify(added, null, 2), 'utf8');
|
||
console.log(`Written: ${outFile} (${addedCount} keys)`);
|
||
} else {
|
||
addedBatches.forEach((batch, i) => {
|
||
const outFile = path.join(
|
||
outDir,
|
||
`i18n-diff-${opts.lang}-added-${i + 1}-of-${
|
||
addedBatches.length
|
||
}.json`,
|
||
);
|
||
fs.writeFileSync(outFile, JSON.stringify(batch, null, 2), 'utf8');
|
||
console.log(`Written: ${outFile} (${Object.keys(batch).length} keys)`);
|
||
});
|
||
}
|
||
}
|
||
|
||
if (modifiedCount > 0) {
|
||
const modifiedBatches = splitBatches(modified, opts.batchSize);
|
||
if (modifiedBatches.length === 1) {
|
||
const outFile = path.join(outDir, `i18n-diff-${opts.lang}-modified.json`);
|
||
fs.writeFileSync(outFile, JSON.stringify(modified, null, 2), 'utf8');
|
||
console.log(`Written: ${outFile} (${modifiedCount} keys)`);
|
||
} else {
|
||
modifiedBatches.forEach((batch, i) => {
|
||
const outFile = path.join(
|
||
outDir,
|
||
`i18n-diff-${opts.lang}-modified-${i + 1}-of-${
|
||
modifiedBatches.length
|
||
}.json`,
|
||
);
|
||
fs.writeFileSync(outFile, JSON.stringify(batch, null, 2), 'utf8');
|
||
console.log(`Written: ${outFile} (${Object.keys(batch).length} keys)`);
|
||
});
|
||
}
|
||
}
|
||
|
||
if (localOnlyCount > 0) {
|
||
const localOnlyBatches = splitBatches(localOnly, opts.batchSize);
|
||
if (localOnlyBatches.length === 1) {
|
||
const outFile = path.join(
|
||
outDir,
|
||
`i18n-diff-${opts.lang}-local-only.json`,
|
||
);
|
||
fs.writeFileSync(outFile, JSON.stringify(localOnly, null, 2), 'utf8');
|
||
console.log(`Written: ${outFile} (${localOnlyCount} keys)`);
|
||
} else {
|
||
localOnlyBatches.forEach((batch, i) => {
|
||
const outFile = path.join(
|
||
outDir,
|
||
`i18n-diff-${opts.lang}-local-only-${i + 1}-of-${
|
||
localOnlyBatches.length
|
||
}.json`,
|
||
);
|
||
fs.writeFileSync(outFile, JSON.stringify(batch, null, 2), 'utf8');
|
||
console.log(`Written: ${outFile} (${Object.keys(batch).length} keys)`);
|
||
});
|
||
}
|
||
}
|
||
|
||
if (addedCount === 0 && modifiedCount === 0 && localOnlyCount === 0) {
|
||
console.log('No differences found. Local and platform are in sync.');
|
||
}
|
||
}
|
||
|
||
main();
|