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.
@@ -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 --skip-placeholders
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 { detectPlaceholders, maskPlaceholders, unmaskPlaceholders } = require('../utils/translate/placeholder');
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 --skip-placeholders',
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
- if (args.sendPlaceholders && args.skipPlaceholders) {
144
- console.error('Error: --skip-placeholders and --send-placeholders are mutually exclusive.');
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
- if (!fs.existsSync(resolvedDir)) {
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 = fs.readdirSync(resolvedDir);
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 (!fs.existsSync(resolved)) {
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: 'skip', interactiveMode: false };
282
+ return { strategy: 'preserve', interactiveMode: false };
225
283
  }
226
284
  if (!interactive) {
227
- return { strategy: 'skip', interactiveMode: false };
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] = 'skip';
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 === 'send') {
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 === 'send') {
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 maskAllForTranslation(toTranslate, customRegex) {
358
+ function prepareDirectBatch(toTranslate, customRegex, protection) {
292
359
  return toTranslate.map((leaf) => {
293
- const { masked, map } = maskPlaceholders(leaf.value, customRegex);
294
- return { ...leaf, masked, placeholderMap: map, needsUnmask: map.size > 0 };
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 translateBatch(batchItems, targetLang, options);
383
+ const results = await translateBatchInChunks(batchItems, targetLang, options);
301
384
  return results;
302
385
  }
303
386
 
304
- function applyResults(sourceData, translatedResults, maskedBatch, toSkip, bom) {
305
- const output = deepClone(sourceData);
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
- for (let i = 0; i < maskedBatch.length; i++) {
308
- const item = maskedBatch[i];
309
- const translated = translatedResults[i];
310
- let finalValue;
311
- if (item.needsUnmask) {
312
- finalValue = unmaskPlaceholders(translated, item.placeholderMap);
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
- finalValue = translated;
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
- setLeaf(output, item.keyPath, finalValue);
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 dir = path.dirname(outputPath);
328
- if (!fs.existsSync(dir)) {
329
- fs.mkdirSync(dir, { recursive: true });
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
- fs.writeFileSync(outputPath, content, 'utf-8');
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 = fs.readFileSync(sourcePath, 'utf-8');
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 { withPlaceholders, withoutPlaceholders } = classifyLeaves(leaves, args.customRegex);
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: withPlaceholders.length,
367
- skippedKeys: withPlaceholders,
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
- console.log(`[${fileName}] Dry-run: ${leaves.length} strings, all would be translated.`);
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: 0,
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 (toSkip.length > 0) {
388
- console.log(`[${fileName}] Skipping ${toSkip.length} keys with placeholders.`);
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 maskedBatch = maskAllForTranslation(toTranslate, args.customRegex);
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 % 10 === 0 || info.completed === info.total) {
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
- if (maskedBatch.length > 0) {
412
- translatedResults = await runTranslation(maskedBatch, targetLang, translateOptions);
413
- process.stdout.write('\n');
414
- } else {
415
- translatedResults = [];
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, maskedBatch, toSkip, args.bom);
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 main() {
432
- const args = parseArgs(process.argv);
433
-
722
+ async function run(args) {
434
723
  if (args.help) {
435
724
  printHelp();
436
- process.exit(ExitCodes.SUCCESS);
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
- process.exit(1);
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
- process.exit(1);
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
- process.exit(ExitCodes.SUCCESS);
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().catch((err) => {
500
- console.error('Fatal error:', err.message);
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
+ };