i18ntk 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -16
- package/README.md +20 -17
- package/main/i18ntk-sizing.js +471 -218
- package/main/i18ntk-translate.js +399 -68
- package/main/i18ntk-validate.js +26 -14
- package/main/manage/commands/TranslateCommand.js +273 -52
- package/main/manage/commands/ValidateCommand.js +25 -13
- package/package.json +3 -1
- package/settings/settings-cli.js +75 -29
- package/settings/settings-manager.js +109 -1
- package/ui-locales/de.json +2 -2
- package/ui-locales/en.json +2 -2
- package/ui-locales/es.json +2 -2
- package/ui-locales/fr.json +2 -2
- package/ui-locales/ja.json +2 -2
- package/ui-locales/ru.json +4 -3
- package/ui-locales/zh.json +2 -2
- package/utils/config-manager.js +20 -4
- package/utils/security.js +4 -3
- package/utils/translate/cli.js +20 -16
- package/utils/translate/placeholder.js +60 -0
- package/utils/translate/protection.js +243 -0
- package/utils/translate/report.js +49 -22
- package/utils/validation-risk.js +175 -0
package/main/i18ntk-translate.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Usage:
|
|
10
10
|
* i18ntk-translate <source-file> <target-lang> [options]
|
|
11
11
|
* i18ntk-translate locales/en/common.json de
|
|
12
|
-
* i18ntk-translate locales/en/common.json fr --no-confirm --
|
|
12
|
+
* i18ntk-translate locales/en/common.json fr --no-confirm --preserve-placeholders
|
|
13
13
|
* i18ntk-translate locales/en/common.json es --dry-run
|
|
14
14
|
*
|
|
15
15
|
* Options:
|
|
@@ -17,9 +17,14 @@
|
|
|
17
17
|
* --output-dir <dir> Output directory (default: ./locales/<lang>)
|
|
18
18
|
* --custom-regex <regex> Additional placeholder regex pattern
|
|
19
19
|
* --no-confirm Skip all confirmation dialogs
|
|
20
|
+
* --preserve-placeholders Translate text around placeholders and reinsert tokens
|
|
20
21
|
* --skip-placeholders Skip all strings containing placeholders
|
|
21
|
-
* --send-placeholders Translate all strings including placeholders
|
|
22
|
+
* --send-placeholders Translate all strings including masked placeholders
|
|
23
|
+
* --protection-file <path> JSON file with protected terms, keys, values, and patterns
|
|
24
|
+
* --create-protection-file Create the protection JSON file if it does not exist
|
|
25
|
+
* --no-protection Disable protected term/key/value handling for this run
|
|
22
26
|
* --concurrency <n> Max concurrent API requests (default: 3)
|
|
27
|
+
* --batch-size <n> Number of text segments per batch (default: 50)
|
|
23
28
|
* --dry-run Preview mode without API calls
|
|
24
29
|
* --report-file <path> Write report to file
|
|
25
30
|
* --report-stdout Print report to stdout
|
|
@@ -34,11 +39,27 @@
|
|
|
34
39
|
*/
|
|
35
40
|
|
|
36
41
|
const fs = require('fs');
|
|
42
|
+
const os = require('os');
|
|
37
43
|
const path = require('path');
|
|
38
44
|
const packageJson = require('../package.json');
|
|
39
45
|
const ExitCodes = require('../utils/exit-codes');
|
|
46
|
+
const SecurityUtils = require('../utils/security');
|
|
40
47
|
const { isInteractive } = require('../utils/prompt-helper');
|
|
41
|
-
const {
|
|
48
|
+
const {
|
|
49
|
+
detectPlaceholders,
|
|
50
|
+
maskPlaceholders,
|
|
51
|
+
splitByPlaceholders,
|
|
52
|
+
unmaskPlaceholders,
|
|
53
|
+
} = require('../utils/translate/placeholder');
|
|
54
|
+
const {
|
|
55
|
+
DEFAULT_PROTECTION_FILE,
|
|
56
|
+
createProtectionFile,
|
|
57
|
+
hasProtectionRules,
|
|
58
|
+
loadProtectionConfig,
|
|
59
|
+
protectText,
|
|
60
|
+
restoreText,
|
|
61
|
+
shouldPreserveWholeValue,
|
|
62
|
+
} = require('../utils/translate/protection');
|
|
42
63
|
const { translateBatch } = require('../utils/translate/api');
|
|
43
64
|
const { collectLeaves, setLeaf, deepClone } = require('../utils/translate/traverse');
|
|
44
65
|
const { generateReport, writeReport, formatSummaryLine } = require('../utils/translate/report');
|
|
@@ -63,7 +84,7 @@ function printHelp() {
|
|
|
63
84
|
' i18ntk-translate locales/en/common.json de',
|
|
64
85
|
' i18ntk-translate locales/en/common.json fr --dry-run',
|
|
65
86
|
' i18ntk-translate locales/en/ es --files "*.json"',
|
|
66
|
-
' i18ntk-translate locales/en/common.json ja --no-confirm --
|
|
87
|
+
' i18ntk-translate locales/en/common.json ja --no-confirm --preserve-placeholders',
|
|
67
88
|
' i18ntk-translate locales/en/common.json ko --report-file report.txt',
|
|
68
89
|
'',
|
|
69
90
|
'Options:',
|
|
@@ -72,9 +93,15 @@ function printHelp() {
|
|
|
72
93
|
' --source-lang <code> Source language code (default: en)',
|
|
73
94
|
' --custom-regex <regex> Additional placeholder regex pattern',
|
|
74
95
|
' --no-confirm Automate: skip confirmation dialogs',
|
|
96
|
+
' --preserve-placeholders Translate text around placeholders and reinsert tokens',
|
|
75
97
|
' --skip-placeholders Skip all strings with placeholder tokens',
|
|
76
|
-
' --send-placeholders Translate all strings including placeholders',
|
|
98
|
+
' --send-placeholders Translate all strings including masked placeholders',
|
|
99
|
+
' --protection-file <path> Protected terms/keys JSON file (default: i18ntk-auto-translate.json)',
|
|
100
|
+
' --create-protection-file Create the protection JSON file if missing',
|
|
101
|
+
' --no-protection Disable protected term/key/value handling',
|
|
77
102
|
' --concurrency <n> Max concurrent API requests (default: 3)',
|
|
103
|
+
' --batch-size <n> Number of text segments per batch (default: 50)',
|
|
104
|
+
' --progress-interval <n> Progress update interval (default: 10)',
|
|
78
105
|
' --dry-run Preview: show what would be skipped',
|
|
79
106
|
' --report-file <path> Write post-translation report to file',
|
|
80
107
|
' --report-stdout Print post-translation report to stdout',
|
|
@@ -96,9 +123,15 @@ function parseArgs(argv) {
|
|
|
96
123
|
sourceLang: 'en',
|
|
97
124
|
customRegex: [],
|
|
98
125
|
noConfirm: false,
|
|
126
|
+
preservePlaceholders: false,
|
|
99
127
|
skipPlaceholders: false,
|
|
100
128
|
sendPlaceholders: false,
|
|
129
|
+
protectionFile: DEFAULT_PROTECTION_FILE,
|
|
130
|
+
protectionEnabled: true,
|
|
131
|
+
createProtectionFile: false,
|
|
101
132
|
concurrency: 3,
|
|
133
|
+
batchSize: 50,
|
|
134
|
+
progressInterval: 10,
|
|
102
135
|
dryRun: false,
|
|
103
136
|
reportFile: null,
|
|
104
137
|
reportStdout: false,
|
|
@@ -117,8 +150,11 @@ function parseArgs(argv) {
|
|
|
117
150
|
const arg = argv[i];
|
|
118
151
|
if (arg === '-h' || arg === '--help') { args.help = true; }
|
|
119
152
|
else if (arg === '--no-confirm') { args.noConfirm = true; }
|
|
153
|
+
else if (arg === '--preserve-placeholders') { args.preservePlaceholders = true; }
|
|
120
154
|
else if (arg === '--skip-placeholders') { args.skipPlaceholders = true; }
|
|
121
155
|
else if (arg === '--send-placeholders') { args.sendPlaceholders = true; }
|
|
156
|
+
else if (arg === '--no-protection') { args.protectionEnabled = false; }
|
|
157
|
+
else if (arg === '--create-protection-file') { args.createProtectionFile = true; }
|
|
122
158
|
else if (arg === '--dry-run') { args.dryRun = true; }
|
|
123
159
|
else if (arg === '--report-stdout') { args.reportStdout = true; }
|
|
124
160
|
else if (arg === '--bom') { args.bom = true; }
|
|
@@ -126,7 +162,10 @@ function parseArgs(argv) {
|
|
|
126
162
|
else if (arg === '--output-dir' && i + 1 < argv.length) { args.outputDir = argv[++i]; }
|
|
127
163
|
else if (arg === '--source-lang' && i + 1 < argv.length) { args.sourceLang = argv[++i]; }
|
|
128
164
|
else if (arg === '--custom-regex' && i + 1 < argv.length) { args.customRegex.push(argv[++i]); }
|
|
165
|
+
else if (arg === '--protection-file' && i + 1 < argv.length) { args.protectionFile = argv[++i]; }
|
|
129
166
|
else if (arg === '--concurrency' && i + 1 < argv.length) { args.concurrency = parseInt(argv[++i], 10) || 3; }
|
|
167
|
+
else if (arg === '--batch-size' && i + 1 < argv.length) { args.batchSize = parseInt(argv[++i], 10) || 50; }
|
|
168
|
+
else if (arg === '--progress-interval' && i + 1 < argv.length) { args.progressInterval = parseInt(argv[++i], 10) || 10; }
|
|
130
169
|
else if (arg === '--report-file' && i + 1 < argv.length) { args.reportFile = argv[++i]; }
|
|
131
170
|
else if (arg === '--translate-fn' && i + 1 < argv.length) { args.translateFnPath = argv[++i]; }
|
|
132
171
|
else if (arg === '--retry-count' && i + 1 < argv.length) { args.retryCount = parseInt(argv[++i], 10) || 3; }
|
|
@@ -140,14 +179,29 @@ function parseArgs(argv) {
|
|
|
140
179
|
if (positional.length >= 1) args.sourceFile = positional[0];
|
|
141
180
|
if (positional.length >= 2) args.targetLang = positional[1];
|
|
142
181
|
|
|
143
|
-
|
|
144
|
-
|
|
182
|
+
const placeholderModeCount = [
|
|
183
|
+
args.preservePlaceholders,
|
|
184
|
+
args.skipPlaceholders,
|
|
185
|
+
args.sendPlaceholders,
|
|
186
|
+
].filter(Boolean).length;
|
|
187
|
+
if (placeholderModeCount > 1) {
|
|
188
|
+
console.error('Error: --preserve-placeholders, --skip-placeholders, and --send-placeholders are mutually exclusive.');
|
|
145
189
|
process.exit(1);
|
|
146
190
|
}
|
|
147
191
|
|
|
192
|
+
args.concurrency = clampInt(args.concurrency, 1, 25, 3);
|
|
193
|
+
args.batchSize = clampInt(args.batchSize, 1, 10000, 50);
|
|
194
|
+
args.progressInterval = clampInt(args.progressInterval, 1, 10000, 10);
|
|
195
|
+
|
|
148
196
|
return args;
|
|
149
197
|
}
|
|
150
198
|
|
|
199
|
+
function clampInt(value, min, max, fallback) {
|
|
200
|
+
const num = parseInt(value, 10);
|
|
201
|
+
if (!Number.isInteger(num)) return fallback;
|
|
202
|
+
return Math.min(Math.max(num, min), max);
|
|
203
|
+
}
|
|
204
|
+
|
|
151
205
|
function loadCustomTranslateFn(modulePath) {
|
|
152
206
|
if (!modulePath) return null;
|
|
153
207
|
try {
|
|
@@ -167,11 +221,12 @@ function loadCustomTranslateFn(modulePath) {
|
|
|
167
221
|
function resolveSourceFiles(sourceFile, sourceDir, filesPattern) {
|
|
168
222
|
if (sourceDir) {
|
|
169
223
|
const resolvedDir = path.resolve(process.cwd(), sourceDir);
|
|
170
|
-
|
|
224
|
+
const sourceDirBase = path.dirname(resolvedDir);
|
|
225
|
+
if (!SecurityUtils.safeExistsSync(resolvedDir, sourceDirBase)) {
|
|
171
226
|
console.error(`Error: Source directory "${resolvedDir}" does not exist.`);
|
|
172
227
|
process.exit(1);
|
|
173
228
|
}
|
|
174
|
-
const entries =
|
|
229
|
+
const entries = SecurityUtils.safeReaddirSync(resolvedDir, sourceDirBase);
|
|
175
230
|
const pattern = filesPattern || '*.json';
|
|
176
231
|
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$');
|
|
177
232
|
const files = entries.filter((f) => regex.test(f) && f.endsWith('.json')).sort();
|
|
@@ -184,7 +239,7 @@ function resolveSourceFiles(sourceFile, sourceDir, filesPattern) {
|
|
|
184
239
|
|
|
185
240
|
if (sourceFile) {
|
|
186
241
|
const resolved = path.resolve(process.cwd(), sourceFile);
|
|
187
|
-
if (!
|
|
242
|
+
if (!SecurityUtils.safeExistsSync(resolved, path.dirname(resolved))) {
|
|
188
243
|
console.error(`Error: Source file "${resolved}" does not exist.`);
|
|
189
244
|
process.exit(1);
|
|
190
245
|
}
|
|
@@ -214,6 +269,9 @@ function classifyLeaves(leaves, customRegex) {
|
|
|
214
269
|
async function resolvePlaceholderStrategy(args) {
|
|
215
270
|
const interactive = isInteractive({ noPrompt: args.noConfirm });
|
|
216
271
|
|
|
272
|
+
if (args.preservePlaceholders) {
|
|
273
|
+
return { strategy: 'preserve', interactiveMode: false };
|
|
274
|
+
}
|
|
217
275
|
if (args.sendPlaceholders) {
|
|
218
276
|
return { strategy: 'send', interactiveMode: false };
|
|
219
277
|
}
|
|
@@ -221,10 +279,10 @@ async function resolvePlaceholderStrategy(args) {
|
|
|
221
279
|
return { strategy: 'skip', interactiveMode: false };
|
|
222
280
|
}
|
|
223
281
|
if (args.noConfirm) {
|
|
224
|
-
return { strategy: '
|
|
282
|
+
return { strategy: 'preserve', interactiveMode: false };
|
|
225
283
|
}
|
|
226
284
|
if (!interactive) {
|
|
227
|
-
return { strategy: '
|
|
285
|
+
return { strategy: 'preserve', interactiveMode: false };
|
|
228
286
|
}
|
|
229
287
|
|
|
230
288
|
const choice = await confirmGlobalChoice();
|
|
@@ -236,7 +294,7 @@ async function resolvePerKeyDecisions(withPlaceholders, interactive) {
|
|
|
236
294
|
|
|
237
295
|
if (!interactive) {
|
|
238
296
|
for (const leaf of withPlaceholders) {
|
|
239
|
-
decisions[leaf.keyPath] = '
|
|
297
|
+
decisions[leaf.keyPath] = 'preserve';
|
|
240
298
|
}
|
|
241
299
|
return decisions;
|
|
242
300
|
}
|
|
@@ -251,6 +309,9 @@ async function resolvePerKeyDecisions(withPlaceholders, interactive) {
|
|
|
251
309
|
if (choice === 'skip-all') {
|
|
252
310
|
bulkDecision = 'skip';
|
|
253
311
|
decisions[leaf.keyPath] = 'skip';
|
|
312
|
+
} else if (choice === 'preserve-all') {
|
|
313
|
+
bulkDecision = 'preserve';
|
|
314
|
+
decisions[leaf.keyPath] = 'preserve';
|
|
254
315
|
} else if (choice === 'send-all') {
|
|
255
316
|
bulkDecision = 'send';
|
|
256
317
|
decisions[leaf.keyPath] = 'send';
|
|
@@ -267,18 +328,24 @@ function buildTranslateList(withPlaceholders, withoutPlaceholders, strategy, dec
|
|
|
267
328
|
const toSkip = [];
|
|
268
329
|
|
|
269
330
|
for (const leaf of withoutPlaceholders) {
|
|
270
|
-
toTranslate.push(leaf);
|
|
331
|
+
toTranslate.push({ ...leaf, placeholderMode: 'none' });
|
|
271
332
|
}
|
|
272
333
|
|
|
273
|
-
if (strategy === '
|
|
334
|
+
if (strategy === 'preserve') {
|
|
274
335
|
for (const leaf of withPlaceholders) {
|
|
275
|
-
toTranslate.push(leaf);
|
|
336
|
+
toTranslate.push({ ...leaf, placeholderMode: 'preserve' });
|
|
337
|
+
}
|
|
338
|
+
} else if (strategy === 'send') {
|
|
339
|
+
for (const leaf of withPlaceholders) {
|
|
340
|
+
toTranslate.push({ ...leaf, placeholderMode: 'mask' });
|
|
276
341
|
}
|
|
277
342
|
} else {
|
|
278
343
|
for (const leaf of withPlaceholders) {
|
|
279
344
|
const decision = decisions[leaf.keyPath] || 'skip';
|
|
280
|
-
if (decision === '
|
|
281
|
-
toTranslate.push(leaf);
|
|
345
|
+
if (decision === 'preserve') {
|
|
346
|
+
toTranslate.push({ ...leaf, placeholderMode: 'preserve' });
|
|
347
|
+
} else if (decision === 'send') {
|
|
348
|
+
toTranslate.push({ ...leaf, placeholderMode: 'mask' });
|
|
282
349
|
} else {
|
|
283
350
|
toSkip.push(leaf);
|
|
284
351
|
}
|
|
@@ -288,32 +355,213 @@ function buildTranslateList(withPlaceholders, withoutPlaceholders, strategy, dec
|
|
|
288
355
|
return { toTranslate, toSkip };
|
|
289
356
|
}
|
|
290
357
|
|
|
291
|
-
function
|
|
358
|
+
function prepareDirectBatch(toTranslate, customRegex, protection) {
|
|
292
359
|
return toTranslate.map((leaf) => {
|
|
293
|
-
const
|
|
294
|
-
|
|
360
|
+
const protectedText = protectText(leaf.value, protection);
|
|
361
|
+
if (leaf.placeholderMode !== 'mask') {
|
|
362
|
+
return {
|
|
363
|
+
...leaf,
|
|
364
|
+
masked: protectedText.value,
|
|
365
|
+
placeholderMap: new Map(),
|
|
366
|
+
protectionMap: protectedText.map,
|
|
367
|
+
needsUnmask: false,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
const { masked, map } = maskPlaceholders(protectedText.value, customRegex);
|
|
371
|
+
return {
|
|
372
|
+
...leaf,
|
|
373
|
+
masked,
|
|
374
|
+
placeholderMap: map,
|
|
375
|
+
protectionMap: protectedText.map,
|
|
376
|
+
needsUnmask: map.size > 0,
|
|
377
|
+
};
|
|
295
378
|
});
|
|
296
379
|
}
|
|
297
380
|
|
|
298
381
|
async function runTranslation(maskedBatch, targetLang, options) {
|
|
299
382
|
const batchItems = maskedBatch.map((item) => ({ value: item.masked, keyPath: item.keyPath }));
|
|
300
|
-
const results = await
|
|
383
|
+
const results = await translateBatchInChunks(batchItems, targetLang, options);
|
|
301
384
|
return results;
|
|
302
385
|
}
|
|
303
386
|
|
|
304
|
-
function
|
|
305
|
-
|
|
387
|
+
async function translateBatchInChunks(batch, targetLang, options) {
|
|
388
|
+
if (batch.length === 0) return [];
|
|
389
|
+
|
|
390
|
+
const batchSize = clampInt(options.batchSize, 1, 10000, 50);
|
|
391
|
+
const results = [];
|
|
392
|
+
const onProgress = options.onProgress;
|
|
393
|
+
let completed = 0;
|
|
394
|
+
|
|
395
|
+
for (let start = 0; start < batch.length; start += batchSize) {
|
|
396
|
+
const chunk = batch.slice(start, start + batchSize);
|
|
397
|
+
const chunkResults = await translateBatch(chunk, targetLang, {
|
|
398
|
+
...options,
|
|
399
|
+
onProgress: (info) => {
|
|
400
|
+
completed++;
|
|
401
|
+
if (typeof onProgress === 'function') {
|
|
402
|
+
onProgress({
|
|
403
|
+
...info,
|
|
404
|
+
completed,
|
|
405
|
+
total: batch.length,
|
|
406
|
+
chunkCompleted: info.completed,
|
|
407
|
+
chunkTotal: info.total,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
results.push(...chunkResults);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return results;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function containsAllPlaceholders(value, placeholders) {
|
|
419
|
+
if (typeof value !== 'string') return false;
|
|
420
|
+
return (placeholders || []).every((placeholder) => value.includes(placeholder));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function createPlaceholderManifest(sourcePath, targetLang, leaves) {
|
|
424
|
+
const records = leaves
|
|
425
|
+
.filter((leaf) => Array.isArray(leaf.placeholders) && leaf.placeholders.length > 0)
|
|
426
|
+
.map((leaf) => ({
|
|
427
|
+
keyPath: leaf.keyPath,
|
|
428
|
+
placeholders: leaf.placeholders,
|
|
429
|
+
}));
|
|
430
|
+
|
|
431
|
+
if (records.length === 0) return null;
|
|
432
|
+
|
|
433
|
+
const safeName = path.basename(sourcePath).replace(/[^a-z0-9_.-]/gi, '_');
|
|
434
|
+
const manifestPath = path.join(os.tmpdir(), `i18ntk-placeholders-${process.pid}-${Date.now()}-${targetLang}-${safeName}.json`);
|
|
435
|
+
SecurityUtils.safeWriteFileSync(manifestPath, JSON.stringify({
|
|
436
|
+
version: 1,
|
|
437
|
+
sourceFile: sourcePath,
|
|
438
|
+
targetLang,
|
|
439
|
+
createdAt: new Date().toISOString(),
|
|
440
|
+
records,
|
|
441
|
+
}, null, 2), os.tmpdir(), 'utf8');
|
|
442
|
+
return manifestPath;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function cleanupPlaceholderManifest(manifestPath) {
|
|
446
|
+
if (!manifestPath) return;
|
|
447
|
+
try {
|
|
448
|
+
fs.unlinkSync(manifestPath);
|
|
449
|
+
} catch (_) {
|
|
450
|
+
// Best-effort cleanup only.
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function makeTextJob(item, segment, segmentIndex, protection) {
|
|
455
|
+
const leading = segment.value.match(/^\s*/)[0];
|
|
456
|
+
const trailing = segment.value.match(/\s*$/)[0];
|
|
457
|
+
const core = segment.value.slice(leading.length, segment.value.length - trailing.length);
|
|
458
|
+
const protectedText = protectText(core, protection);
|
|
306
459
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
460
|
+
return {
|
|
461
|
+
value: protectedText.value,
|
|
462
|
+
leading,
|
|
463
|
+
trailing,
|
|
464
|
+
protectionMap: protectedText.map,
|
|
465
|
+
keyPath: `${item.keyPath}#segment${segmentIndex}`,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function translatePreservedItems(items, targetLang, options, customRegex, protection) {
|
|
470
|
+
const segmentJobs = [];
|
|
471
|
+
const plans = items.map((item) => {
|
|
472
|
+
const segments = splitByPlaceholders(item.value, customRegex);
|
|
473
|
+
const plan = { item, segments: [] };
|
|
474
|
+
|
|
475
|
+
segments.forEach((segment, index) => {
|
|
476
|
+
if (segment.type !== 'text' || !/[A-Za-z0-9]/.test(segment.value)) {
|
|
477
|
+
plan.segments.push({ type: segment.type, value: segment.value });
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const job = makeTextJob(item, segment, index, protection);
|
|
482
|
+
if (!job.value || !/[A-Za-z0-9]/.test(job.value)) {
|
|
483
|
+
plan.segments.push({ type: 'text', value: segment.value });
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const jobIndex = segmentJobs.length;
|
|
488
|
+
segmentJobs.push(job);
|
|
489
|
+
plan.segments.push({
|
|
490
|
+
type: 'translated-text',
|
|
491
|
+
jobIndex,
|
|
492
|
+
leading: job.leading,
|
|
493
|
+
trailing: job.trailing,
|
|
494
|
+
protectionMap: job.protectionMap,
|
|
495
|
+
fallback: segment.value,
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
return plan;
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const translatedSegments = await translateBatchInChunks(segmentJobs, targetLang, options);
|
|
503
|
+
|
|
504
|
+
return plans.map((plan) => {
|
|
505
|
+
const value = plan.segments.map((segment) => {
|
|
506
|
+
if (segment.type === 'translated-text') {
|
|
507
|
+
const translated = translatedSegments[segment.jobIndex];
|
|
508
|
+
const restored = restoreText(translated || segment.fallback.trim(), segment.protectionMap);
|
|
509
|
+
return `${segment.leading}${restored}${segment.trailing}`;
|
|
510
|
+
}
|
|
511
|
+
return segment.value;
|
|
512
|
+
}).join('');
|
|
513
|
+
|
|
514
|
+
return containsAllPlaceholders(value, plan.item.placeholders) ? value : plan.item.value;
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function translateItems(toTranslate, targetLang, options, customRegex, protection) {
|
|
519
|
+
const finalResults = new Array(toTranslate.length);
|
|
520
|
+
const directItems = [];
|
|
521
|
+
const directIndexes = [];
|
|
522
|
+
const preserveItems = [];
|
|
523
|
+
const preserveIndexes = [];
|
|
524
|
+
|
|
525
|
+
toTranslate.forEach((item, index) => {
|
|
526
|
+
if (item.placeholderMode === 'preserve') {
|
|
527
|
+
preserveItems.push(item);
|
|
528
|
+
preserveIndexes.push(index);
|
|
313
529
|
} else {
|
|
314
|
-
|
|
530
|
+
directItems.push(item);
|
|
531
|
+
directIndexes.push(index);
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
const preparedDirect = prepareDirectBatch(directItems, customRegex, protection);
|
|
536
|
+
const directResults = await runTranslation(preparedDirect, targetLang, options);
|
|
537
|
+
for (let i = 0; i < preparedDirect.length; i++) {
|
|
538
|
+
const item = preparedDirect[i];
|
|
539
|
+
let finalValue = item.needsUnmask
|
|
540
|
+
? unmaskPlaceholders(directResults[i], item.placeholderMap)
|
|
541
|
+
: directResults[i];
|
|
542
|
+
finalValue = restoreText(finalValue, item.protectionMap);
|
|
543
|
+
|
|
544
|
+
if (item.placeholderMode === 'mask' && !containsAllPlaceholders(finalValue, item.placeholders)) {
|
|
545
|
+
const fallback = await translatePreservedItems([item], targetLang, options, customRegex, protection);
|
|
546
|
+
finalValue = fallback[0];
|
|
315
547
|
}
|
|
316
|
-
|
|
548
|
+
|
|
549
|
+
finalResults[directIndexes[i]] = finalValue;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const preservedResults = await translatePreservedItems(preserveItems, targetLang, options, customRegex, protection);
|
|
553
|
+
for (let i = 0; i < preserveItems.length; i++) {
|
|
554
|
+
finalResults[preserveIndexes[i]] = preservedResults[i];
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return finalResults;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function applyResults(sourceData, translatedResults, toTranslate, toSkip) {
|
|
561
|
+
const output = deepClone(sourceData);
|
|
562
|
+
|
|
563
|
+
for (let i = 0; i < toTranslate.length; i++) {
|
|
564
|
+
setLeaf(output, toTranslate[i].keyPath, translatedResults[i]);
|
|
317
565
|
}
|
|
318
566
|
|
|
319
567
|
for (const leaf of toSkip) {
|
|
@@ -324,15 +572,16 @@ function applyResults(sourceData, translatedResults, maskedBatch, toSkip, bom) {
|
|
|
324
572
|
}
|
|
325
573
|
|
|
326
574
|
function writeOutput(outputData, outputPath, bom) {
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
575
|
+
const resolvedOutputPath = path.resolve(process.cwd(), outputPath);
|
|
576
|
+
const dir = path.dirname(resolvedOutputPath);
|
|
577
|
+
if (!SecurityUtils.safeExistsSync(dir, path.dirname(dir))) {
|
|
578
|
+
SecurityUtils.safeMkdirSync(dir, path.dirname(dir), { recursive: true });
|
|
330
579
|
}
|
|
331
580
|
let content = JSON.stringify(outputData, null, 2) + '\n';
|
|
332
581
|
if (bom) {
|
|
333
582
|
content = BOM + content;
|
|
334
583
|
}
|
|
335
|
-
|
|
584
|
+
SecurityUtils.safeWriteFileSync(resolvedOutputPath, content, dir, 'utf-8');
|
|
336
585
|
}
|
|
337
586
|
|
|
338
587
|
async function processFile(sourcePath, targetLang, args) {
|
|
@@ -342,7 +591,7 @@ async function processFile(sourcePath, targetLang, args) {
|
|
|
342
591
|
|
|
343
592
|
let sourceData;
|
|
344
593
|
try {
|
|
345
|
-
const raw =
|
|
594
|
+
const raw = SecurityUtils.safeReadFileSync(sourcePath, path.dirname(sourcePath), 'utf-8').replace(/^\uFEFF/, '');
|
|
346
595
|
sourceData = JSON.parse(raw);
|
|
347
596
|
} catch (e) {
|
|
348
597
|
console.error(`Error reading "${sourcePath}": ${e.message}`);
|
|
@@ -356,49 +605,85 @@ async function processFile(sourcePath, targetLang, args) {
|
|
|
356
605
|
return { total: 0, translated: 0, skipped: 0, skippedKeys: [] };
|
|
357
606
|
}
|
|
358
607
|
|
|
359
|
-
const
|
|
608
|
+
const protection = args.protection || loadProtectionConfig(args.protectionFile, {
|
|
609
|
+
enabled: args.protectionEnabled,
|
|
610
|
+
create: args.createProtectionFile,
|
|
611
|
+
});
|
|
612
|
+
const protectedLeaves = leaves
|
|
613
|
+
.filter((leaf) => shouldPreserveWholeValue(leaf.keyPath, leaf.value, protection))
|
|
614
|
+
.map((leaf) => ({ ...leaf, skipReason: 'protected' }));
|
|
615
|
+
const translatableLeaves = leaves.filter((leaf) => !shouldPreserveWholeValue(leaf.keyPath, leaf.value, protection));
|
|
616
|
+
const { withPlaceholders, withoutPlaceholders } = classifyLeaves(translatableLeaves, args.customRegex);
|
|
617
|
+
const { strategy, interactiveMode } = await resolvePlaceholderStrategy(args);
|
|
360
618
|
|
|
361
|
-
if (args.dryRun && withPlaceholders.length > 0) {
|
|
619
|
+
if (args.dryRun && strategy === 'skip' && withPlaceholders.length > 0) {
|
|
362
620
|
await previewSkipped(withPlaceholders);
|
|
621
|
+
const skippedKeys = withPlaceholders.concat(protectedLeaves);
|
|
363
622
|
return {
|
|
364
623
|
total: leaves.length,
|
|
365
624
|
translated: withoutPlaceholders.length,
|
|
366
|
-
skipped:
|
|
367
|
-
skippedKeys
|
|
625
|
+
skipped: skippedKeys.length,
|
|
626
|
+
skippedKeys,
|
|
627
|
+
placeholderProtected: 0,
|
|
628
|
+
protectedSkipped: protectedLeaves.length,
|
|
368
629
|
dryRun: true,
|
|
369
630
|
};
|
|
370
631
|
}
|
|
371
632
|
|
|
372
633
|
if (args.dryRun) {
|
|
373
|
-
|
|
634
|
+
const protectedCount = strategy === 'send' ? 0 : withPlaceholders.length;
|
|
635
|
+
console.log(`[${fileName}] Dry-run: ${leaves.length} strings would be translated.`);
|
|
636
|
+
if (protectedLeaves.length > 0) {
|
|
637
|
+
console.log(`[${fileName}] Dry-run: ${protectedLeaves.length} protected keys/values would be copied unchanged.`);
|
|
638
|
+
}
|
|
639
|
+
if (hasProtectionRules(protection)) {
|
|
640
|
+
console.log(`[${fileName}] Dry-run: protected terms would be masked from ${protection.filePath}.`);
|
|
641
|
+
}
|
|
642
|
+
if (protectedCount > 0) {
|
|
643
|
+
console.log(`[${fileName}] Dry-run: ${protectedCount} placeholder strings would use preserve mode.`);
|
|
644
|
+
}
|
|
374
645
|
return {
|
|
375
646
|
total: leaves.length,
|
|
376
|
-
translated: leaves.length,
|
|
377
|
-
skipped:
|
|
378
|
-
skippedKeys:
|
|
647
|
+
translated: leaves.length - protectedLeaves.length,
|
|
648
|
+
skipped: protectedLeaves.length,
|
|
649
|
+
skippedKeys: protectedLeaves,
|
|
650
|
+
placeholderProtected: protectedCount,
|
|
651
|
+
termProtected: hasProtectionRules(protection),
|
|
379
652
|
dryRun: true,
|
|
380
653
|
};
|
|
381
654
|
}
|
|
382
655
|
|
|
383
|
-
const { strategy, interactiveMode } = await resolvePlaceholderStrategy(args);
|
|
384
656
|
const decisions = await resolvePerKeyDecisions(withPlaceholders, interactiveMode);
|
|
385
657
|
const { toTranslate, toSkip } = buildTranslateList(withPlaceholders, withoutPlaceholders, strategy, decisions);
|
|
658
|
+
toSkip.push(...protectedLeaves);
|
|
659
|
+
const placeholderProtected = toTranslate.filter((leaf) => leaf.placeholderMode === 'preserve').length;
|
|
660
|
+
const placeholderSkipped = toSkip.filter((leaf) => leaf.skipReason !== 'protected').length;
|
|
386
661
|
|
|
387
|
-
if (
|
|
388
|
-
console.log(`[${fileName}] Skipping ${
|
|
662
|
+
if (placeholderSkipped > 0) {
|
|
663
|
+
console.log(`[${fileName}] Skipping ${placeholderSkipped} keys with placeholders.`);
|
|
664
|
+
}
|
|
665
|
+
if (placeholderProtected > 0) {
|
|
666
|
+
console.log(`[${fileName}] Preserving placeholders for ${placeholderProtected} keys.`);
|
|
667
|
+
}
|
|
668
|
+
if (protectedLeaves.length > 0) {
|
|
669
|
+
console.log(`[${fileName}] Copying ${protectedLeaves.length} protected keys/values unchanged.`);
|
|
670
|
+
}
|
|
671
|
+
if (hasProtectionRules(protection)) {
|
|
672
|
+
console.log(`[${fileName}] Protecting terms from: ${protection.filePath}`);
|
|
389
673
|
}
|
|
390
674
|
|
|
391
|
-
const
|
|
675
|
+
const manifestPath = createPlaceholderManifest(sourcePath, targetLang, toTranslate);
|
|
392
676
|
|
|
393
677
|
const translateOptions = {
|
|
394
678
|
sourceLang: args.sourceLang,
|
|
395
679
|
concurrency: args.concurrency,
|
|
680
|
+
batchSize: args.batchSize,
|
|
396
681
|
retryCount: args.retryCount,
|
|
397
682
|
retryDelay: args.retryDelay,
|
|
398
683
|
timeout: args.timeout,
|
|
399
684
|
customFn: args.translateFn,
|
|
400
685
|
onProgress: (info) => {
|
|
401
|
-
if (info.completed %
|
|
686
|
+
if (info.completed % args.progressInterval === 0 || info.completed === info.total) {
|
|
402
687
|
process.stdout.write(`\r[${fileName}] Translating... ${info.completed}/${info.total}`);
|
|
403
688
|
}
|
|
404
689
|
},
|
|
@@ -408,14 +693,18 @@ async function processFile(sourcePath, targetLang, args) {
|
|
|
408
693
|
};
|
|
409
694
|
|
|
410
695
|
let translatedResults;
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
696
|
+
try {
|
|
697
|
+
if (toTranslate.length > 0) {
|
|
698
|
+
translatedResults = await translateItems(toTranslate, targetLang, translateOptions, args.customRegex, protection);
|
|
699
|
+
process.stdout.write('\n');
|
|
700
|
+
} else {
|
|
701
|
+
translatedResults = [];
|
|
702
|
+
}
|
|
703
|
+
} finally {
|
|
704
|
+
cleanupPlaceholderManifest(manifestPath);
|
|
416
705
|
}
|
|
417
706
|
|
|
418
|
-
const output = applyResults(sourceData, translatedResults,
|
|
707
|
+
const output = applyResults(sourceData, translatedResults, toTranslate, toSkip);
|
|
419
708
|
writeOutput(output, targetPath, args.bom);
|
|
420
709
|
|
|
421
710
|
console.log(`[${fileName}] Written: ${targetPath}`);
|
|
@@ -425,39 +714,55 @@ async function processFile(sourcePath, targetLang, args) {
|
|
|
425
714
|
translated: translatedResults.length,
|
|
426
715
|
skipped: toSkip.length,
|
|
427
716
|
skippedKeys: toSkip,
|
|
717
|
+
placeholderProtected,
|
|
718
|
+
protectedSkipped: protectedLeaves.length,
|
|
428
719
|
};
|
|
429
720
|
}
|
|
430
721
|
|
|
431
|
-
async function
|
|
432
|
-
const args = parseArgs(process.argv);
|
|
433
|
-
|
|
722
|
+
async function run(args) {
|
|
434
723
|
if (args.help) {
|
|
435
724
|
printHelp();
|
|
436
|
-
|
|
725
|
+
return { success: true, exitCode: ExitCodes.SUCCESS };
|
|
437
726
|
}
|
|
438
727
|
|
|
439
728
|
if (args.unknown.length > 0) {
|
|
440
729
|
console.error(`Unknown options: ${args.unknown.join(', ')}`);
|
|
441
730
|
console.error('Use --help for usage information.');
|
|
442
|
-
|
|
731
|
+
return { success: false, exitCode: 1, error: 'Unknown options' };
|
|
443
732
|
}
|
|
444
733
|
|
|
445
734
|
if (!args.targetLang) {
|
|
446
735
|
console.error('Error: Target language code is required.');
|
|
447
736
|
console.error('Usage: i18ntk-translate <source-file> <target-lang> [options]');
|
|
448
|
-
|
|
737
|
+
return { success: false, exitCode: 1, error: 'Target language code is required' };
|
|
449
738
|
}
|
|
450
739
|
|
|
451
740
|
if (args.translateFnPath) {
|
|
452
741
|
args.translateFn = loadCustomTranslateFn(args.translateFnPath);
|
|
453
742
|
}
|
|
454
743
|
|
|
744
|
+
if (args.protectionEnabled !== false) {
|
|
745
|
+
if (args.createProtectionFile) {
|
|
746
|
+
const protectionPath = createProtectionFile(args.protectionFile);
|
|
747
|
+
console.log(`Protection file ready: ${protectionPath}`);
|
|
748
|
+
}
|
|
749
|
+
try {
|
|
750
|
+
args.protection = loadProtectionConfig(args.protectionFile, {
|
|
751
|
+
enabled: args.protectionEnabled,
|
|
752
|
+
});
|
|
753
|
+
} catch (error) {
|
|
754
|
+
return { success: false, exitCode: 1, error: error.message };
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
455
758
|
const sourceFiles = resolveSourceFiles(args.sourceFile, args.sourceDir, args.filesPattern);
|
|
456
759
|
|
|
457
760
|
const allSkippedKeys = [];
|
|
458
761
|
let grandTotal = 0;
|
|
459
762
|
let grandTranslated = 0;
|
|
460
763
|
let grandSkipped = 0;
|
|
764
|
+
let grandPlaceholderProtected = 0;
|
|
765
|
+
let grandProtectedSkipped = 0;
|
|
461
766
|
|
|
462
767
|
for (const srcPath of sourceFiles) {
|
|
463
768
|
const result = await processFile(srcPath, args.targetLang, args);
|
|
@@ -465,6 +770,8 @@ async function main() {
|
|
|
465
770
|
grandTotal += result.total;
|
|
466
771
|
grandTranslated += result.translated;
|
|
467
772
|
grandSkipped += result.skipped;
|
|
773
|
+
grandPlaceholderProtected += result.placeholderProtected || 0;
|
|
774
|
+
grandProtectedSkipped += result.protectedSkipped || 0;
|
|
468
775
|
if (result.skippedKeys && result.skippedKeys.length > 0) {
|
|
469
776
|
allSkippedKeys.push(...result.skippedKeys);
|
|
470
777
|
}
|
|
@@ -472,13 +779,15 @@ async function main() {
|
|
|
472
779
|
}
|
|
473
780
|
|
|
474
781
|
console.log('');
|
|
475
|
-
console.log(formatSummaryLine(grandSkipped, grandTranslated, grandTotal));
|
|
782
|
+
console.log(formatSummaryLine(grandSkipped, grandTranslated, grandTotal, grandPlaceholderProtected, grandProtectedSkipped));
|
|
476
783
|
|
|
477
784
|
if (allSkippedKeys.length > 0 || args.reportFile || args.reportStdout) {
|
|
478
785
|
const report = generateReport(allSkippedKeys, grandTranslated, grandTotal, {
|
|
479
786
|
sourceFile: sourceFiles.length === 1 ? sourceFiles[0] : `${sourceFiles.length} files`,
|
|
480
787
|
targetLang: args.targetLang,
|
|
481
788
|
dryRun: args.dryRun,
|
|
789
|
+
placeholderProtected: grandPlaceholderProtected,
|
|
790
|
+
protectedSkipped: grandProtectedSkipped,
|
|
482
791
|
});
|
|
483
792
|
|
|
484
793
|
if (args.reportStdout || (!args.reportFile && allSkippedKeys.length > 0)) {
|
|
@@ -493,10 +802,32 @@ async function main() {
|
|
|
493
802
|
}
|
|
494
803
|
}
|
|
495
804
|
|
|
496
|
-
|
|
805
|
+
return {
|
|
806
|
+
success: true,
|
|
807
|
+
exitCode: ExitCodes.SUCCESS,
|
|
808
|
+
total: grandTotal,
|
|
809
|
+
translated: grandTranslated,
|
|
810
|
+
skipped: grandSkipped,
|
|
811
|
+
placeholderProtected: grandPlaceholderProtected,
|
|
812
|
+
protectedSkipped: grandProtectedSkipped,
|
|
813
|
+
};
|
|
497
814
|
}
|
|
498
815
|
|
|
499
|
-
main()
|
|
500
|
-
|
|
501
|
-
process.exit(1);
|
|
502
|
-
}
|
|
816
|
+
async function main() {
|
|
817
|
+
const result = await run(parseArgs(process.argv));
|
|
818
|
+
process.exit(result.exitCode || (result.success ? ExitCodes.SUCCESS : 1));
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (require.main === module) {
|
|
822
|
+
main().catch((err) => {
|
|
823
|
+
console.error('Fatal error:', err.message);
|
|
824
|
+
process.exit(1);
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
module.exports = {
|
|
829
|
+
parseArgs,
|
|
830
|
+
resolveSourceFiles,
|
|
831
|
+
processFile,
|
|
832
|
+
run,
|
|
833
|
+
};
|