i18ntk 4.1.0 → 4.2.1

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +64 -5
  2. package/README.md +73 -17
  3. package/SECURITY.md +10 -4
  4. package/main/i18ntk-analyze.js +10 -20
  5. package/main/i18ntk-backup.js +106 -44
  6. package/main/i18ntk-init.js +153 -157
  7. package/main/i18ntk-setup.js +36 -13
  8. package/main/i18ntk-sizing.js +44 -27
  9. package/main/i18ntk-translate.js +311 -41
  10. package/main/i18ntk-usage.js +272 -103
  11. package/main/i18ntk-validate.js +38 -31
  12. package/main/manage/commands/AnalyzeCommand.js +7 -17
  13. package/main/manage/commands/CommandRouter.js +6 -6
  14. package/main/manage/commands/SizingCommand.js +5 -2
  15. package/main/manage/commands/TranslateCommand.js +73 -56
  16. package/main/manage/commands/ValidateCommand.js +58 -26
  17. package/main/manage/index.js +11 -42
  18. package/main/manage/managers/InteractiveMenu.js +11 -40
  19. package/main/manage/services/InitService.js +114 -118
  20. package/main/manage/services/UsageService.js +247 -96
  21. package/package.json +19 -14
  22. package/runtime/enhanced.d.ts +5 -5
  23. package/runtime/enhanced.js +49 -25
  24. package/runtime/i18ntk.d.ts +30 -7
  25. package/runtime/index.d.ts +48 -19
  26. package/runtime/index.js +175 -90
  27. package/settings/settings-cli.js +115 -38
  28. package/settings/settings-manager.js +24 -6
  29. package/ui-locales/de.json +192 -11
  30. package/ui-locales/en.json +182 -8
  31. package/ui-locales/es.json +193 -12
  32. package/ui-locales/fr.json +189 -8
  33. package/ui-locales/ja.json +190 -8
  34. package/ui-locales/ru.json +191 -9
  35. package/ui-locales/zh.json +194 -9
  36. package/utils/cli-helper.js +8 -12
  37. package/utils/config-helper.js +1 -1
  38. package/utils/config-manager.js +8 -6
  39. package/utils/localized-confirm.js +55 -0
  40. package/utils/menu-layout.js +41 -0
  41. package/utils/report-writer.js +110 -0
  42. package/utils/security.js +15 -22
  43. package/utils/translate/api.js +31 -3
  44. package/utils/translate/placeholder.js +42 -1
  45. package/utils/translate/report.js +32 -4
  46. package/utils/translate/safe-network.js +24 -4
  47. package/utils/usage-insights.js +435 -0
  48. package/utils/usage-source.js +50 -0
  49. package/utils/watch-locales.js +1 -8
@@ -24,9 +24,11 @@
24
24
  * --protection-file <path> JSON file with protected terms, keys, values, and patterns
25
25
  * --create-protection-file Create the protection JSON file if it does not exist
26
26
  * --no-protection Disable protected term/key/value handling for this run
27
- * --concurrency <n> Max concurrent API requests (default: 3)
27
+ * --concurrency <n> Max concurrent API requests (default: 12, Google max: 100)
28
28
  * --batch-size <n> Number of text segments per batch (default: 50)
29
29
  * --dry-run Preview mode without API calls
30
+ * --only-missing Translate only missing, marker, source-copy, or likely English target values
31
+ * --translate-all Re-translate every source string even when a target value already exists
30
32
  * --report-file <path> Write report to file
31
33
  * --report-stdout Print report to stdout
32
34
  * --bom Output UTF-8 with BOM
@@ -61,9 +63,10 @@ const {
61
63
  restoreText,
62
64
  shouldPreserveWholeValue,
63
65
  } = require('../utils/translate/protection');
64
- const { translateBatch } = require('../utils/translate/api');
65
- const { collectLeaves, setLeaf, deepClone } = require('../utils/translate/traverse');
66
+ const { translateBatch, DEFAULT_CONCURRENCY, clampProviderConcurrency } = require('../utils/translate/api');
67
+ const { collectLeaves, getLeaf, setLeaf, deepClone } = require('../utils/translate/traverse');
66
68
  const { generateReport, writeReport, formatSummaryLine } = require('../utils/translate/report');
69
+ const { analyzeEnglishContent } = require('../utils/validation-risk');
67
70
  const {
68
71
  confirmGlobalChoice,
69
72
  confirmPerKey,
@@ -101,10 +104,12 @@ function printHelp() {
101
104
  ' --protection-file <path> Protected terms/keys JSON file (default: i18ntk-auto-translate.json)',
102
105
  ' --create-protection-file Create the protection JSON file if missing',
103
106
  ' --no-protection Disable protected term/key/value handling',
104
- ' --concurrency <n> Max concurrent API requests (default: 3)',
107
+ ' --concurrency <n> Max concurrent API requests (default: 12, Google max: 100)',
105
108
  ' --batch-size <n> Number of text segments per batch (default: 50)',
106
109
  ' --progress-interval <n> Progress update interval (default: 10)',
107
110
  ' --dry-run Preview: show what would be skipped',
111
+ ' --only-missing Translate only missing, marker, source-copy, or likely English target values (default)',
112
+ ' --translate-all Re-translate every source string even when target values already exist',
108
113
  ' --report-file <path> Write post-translation report to file',
109
114
  ' --report-stdout Print post-translation report to stdout',
110
115
  ' --bom Write output files with UTF-8 BOM',
@@ -141,10 +146,11 @@ function parseArgs(argv) {
141
146
  protectionFile: DEFAULT_PROTECTION_FILE,
142
147
  protectionEnabled: true,
143
148
  createProtectionFile: false,
144
- concurrency: 3,
149
+ concurrency: DEFAULT_CONCURRENCY,
145
150
  batchSize: 50,
146
151
  progressInterval: 10,
147
152
  dryRun: false,
153
+ onlyMissingOrEnglish: true,
148
154
  reportFile: null,
149
155
  reportStdout: false,
150
156
  bom: false,
@@ -168,6 +174,8 @@ function parseArgs(argv) {
168
174
  else if (arg === '--no-protection') { args.protectionEnabled = false; }
169
175
  else if (arg === '--create-protection-file') { args.createProtectionFile = true; }
170
176
  else if (arg === '--dry-run') { args.dryRun = true; }
177
+ else if (arg === '--translate-all' || arg === '--force-translate') { args.onlyMissingOrEnglish = false; }
178
+ else if (arg === '--only-missing' || arg === '--only-missing-or-english') { args.onlyMissingOrEnglish = true; }
171
179
  else if (arg === '--report-stdout') { args.reportStdout = true; }
172
180
  else if (arg === '--bom') { args.bom = true; }
173
181
  else if (arg === '--source-dir' && i + 1 < argv.length) { args.sourceDir = argv[++i]; }
@@ -176,7 +184,7 @@ function parseArgs(argv) {
176
184
  else if (arg === '--provider' && i + 1 < argv.length) { args.provider = argv[++i]; }
177
185
  else if (arg === '--custom-regex' && i + 1 < argv.length) { args.customRegex.push(argv[++i]); }
178
186
  else if (arg === '--protection-file' && i + 1 < argv.length) { args.protectionFile = argv[++i]; }
179
- else if (arg === '--concurrency' && i + 1 < argv.length) { args.concurrency = parseInt(argv[++i], 10) || 3; }
187
+ else if (arg === '--concurrency' && i + 1 < argv.length) { args.concurrency = parseInt(argv[++i], 10) || DEFAULT_CONCURRENCY; }
180
188
  else if (arg === '--batch-size' && i + 1 < argv.length) { args.batchSize = parseInt(argv[++i], 10) || 50; }
181
189
  else if (arg === '--progress-interval' && i + 1 < argv.length) { args.progressInterval = parseInt(argv[++i], 10) || 10; }
182
190
  else if (arg === '--report-file' && i + 1 < argv.length) { args.reportFile = argv[++i]; }
@@ -202,13 +210,23 @@ function parseArgs(argv) {
202
210
  process.exit(1);
203
211
  }
204
212
 
205
- args.concurrency = clampInt(args.concurrency, 1, 25, 3);
213
+ args.concurrency = clampProviderConcurrency(args.concurrency, args.provider, DEFAULT_CONCURRENCY);
206
214
  args.batchSize = clampInt(args.batchSize, 1, 10000, 50);
207
215
  args.progressInterval = clampInt(args.progressInterval, 1, 10000, 10);
208
216
 
209
217
  return args;
210
218
  }
211
219
 
220
+ const UNTRANSLATED_MARKERS = new Set([
221
+ '',
222
+ '__not_translated__',
223
+ 'not_translated',
224
+ '[translate]',
225
+ '[not translated]',
226
+ 'todo',
227
+ 'tbd',
228
+ ]);
229
+
212
230
  function clampInt(value, min, max, fallback) {
213
231
  const num = parseInt(value, 10);
214
232
  if (!Number.isInteger(num)) return fallback;
@@ -289,6 +307,136 @@ function classifyLeaves(leaves, customRegex) {
289
307
  return { withPlaceholders, withoutPlaceholders };
290
308
  }
291
309
 
310
+ function readExistingTargetData(targetPath) {
311
+ if (!SecurityUtils.safeExistsSync(targetPath, path.dirname(targetPath))) {
312
+ return null;
313
+ }
314
+
315
+ try {
316
+ const raw = SecurityUtils.safeReadFileSync(targetPath, path.dirname(targetPath), 'utf-8').replace(/^\uFEFF/, '');
317
+ return JSON.parse(raw);
318
+ } catch (error) {
319
+ console.warn(`Warning: Could not read existing target file "${targetPath}": ${error.message}`);
320
+ return null;
321
+ }
322
+ }
323
+
324
+ function isUntranslatedMarker(value) {
325
+ const normalized = String(value ?? '').trim().toLowerCase();
326
+ return UNTRANSLATED_MARKERS.has(normalized);
327
+ }
328
+
329
+ function isLikelyEnglish(value, args) {
330
+ const text = String(value || '').trim();
331
+ if (!text) return false;
332
+ const analysis = analyzeEnglishContent(text, {
333
+ allowedEnglishTerms: args.allowedEnglishTerms,
334
+ });
335
+ const threshold = Number.isFinite(Number(args.englishThresholdPercent))
336
+ ? Number(args.englishThresholdPercent)
337
+ : 10;
338
+ return analysis.englishPercentage > threshold && analysis.englishWordCount >= 2;
339
+ }
340
+
341
+ function normalizeLanguageCode(language) {
342
+ return String(language || '').trim().toLowerCase().replace(/_/g, '-').split('-')[0];
343
+ }
344
+
345
+ function parseLanguagePrefix(value) {
346
+ if (typeof value !== 'string') return null;
347
+ const match = value.match(/^\s*\[([A-Z]{2,3}(?:[-_][A-Z0-9]{2,4})?)\]\s*(.+)$/);
348
+ if (!match) return null;
349
+ return {
350
+ raw: match[1],
351
+ language: normalizeLanguageCode(match[1]),
352
+ text: match[2].trim(),
353
+ };
354
+ }
355
+
356
+ function hasLatinWordContent(value, minWords = 2) {
357
+ const words = String(value || '').match(/[A-Za-z][A-Za-z'-]*/g) || [];
358
+ return words.filter(word => word.length > 1).length >= minWords;
359
+ }
360
+
361
+ function isLanguagePrefixedEnglish(value, args = {}) {
362
+ const prefix = parseLanguagePrefix(value);
363
+ if (!prefix || !prefix.text) return false;
364
+
365
+ const targetLanguage = normalizeLanguageCode(args.targetLang);
366
+ if (targetLanguage && prefix.language !== targetLanguage) return false;
367
+
368
+ return hasLatinWordContent(prefix.text, 2) || isLikelyEnglish(prefix.text, args);
369
+ }
370
+
371
+ function isBrokenTranslationValue(value) {
372
+ if (typeof value !== 'string') return false;
373
+ const text = value.trim();
374
+ if (!text) return false;
375
+ if (text.includes('\uFFFD')) return true;
376
+
377
+ const compact = text.replace(/[\s.,!;:()[\]{}'"`~_\-]/g, '');
378
+ if (compact.length >= 1 && /^\?+$/.test(compact)) return true;
379
+ if (/\?{3,}/.test(text)) return true;
380
+
381
+ const questionCount = (text.match(/\?/g) || []).length;
382
+ const visibleLength = Math.max(text.replace(/\s/g, '').length, 1);
383
+ if (questionCount >= 3 && questionCount / visibleLength >= 0.5) return true;
384
+
385
+ if (/[\u0080-\u009F]/.test(text) && /[ÃÂÐÑ]/.test(text)) return true;
386
+
387
+ const mojibakePatterns = [
388
+ /[ÃÂ][\u0080-\u00BF]/,
389
+ /Ð[\u0080-\u00BF]/,
390
+ /Ñ[\u0080-\u00BF]/,
391
+ /ã[‚ƒ€]/,
392
+ ];
393
+ return mojibakePatterns.some((pattern) => pattern.test(text));
394
+ }
395
+
396
+ function shouldTranslateTargetValue(sourceValue, targetValue, args) {
397
+ if (targetValue === undefined || targetValue === null) return true;
398
+ if (typeof targetValue !== 'string') return true;
399
+ if (isUntranslatedMarker(targetValue)) return true;
400
+ if (isBrokenTranslationValue(targetValue)) return true;
401
+ if (isLanguagePrefixedEnglish(targetValue, args)) return true;
402
+ if (targetValue.trim() === String(sourceValue ?? '').trim()) return true;
403
+ if (args.onlyMissingOrEnglish !== false && isLikelyEnglish(targetValue, args)) return true;
404
+ return args.onlyMissingOrEnglish === false;
405
+ }
406
+
407
+ function getResidualUntranslatedReason(sourceValue, targetValue, args) {
408
+ if (targetValue === undefined || targetValue === null) return 'missing';
409
+ if (typeof targetValue !== 'string') return 'non_string';
410
+ if (isUntranslatedMarker(targetValue)) return 'marker';
411
+ if (isBrokenTranslationValue(targetValue)) return 'broken';
412
+ if (isLanguagePrefixedEnglish(targetValue, args)) return 'language_prefix';
413
+ if (targetValue.trim() === String(sourceValue ?? '').trim()) return 'source_copy';
414
+ return null;
415
+ }
416
+
417
+ function planTargetAwareLeaves(sourceLeaves, targetData, args) {
418
+ if (!targetData || args.onlyMissingOrEnglish === false) {
419
+ return {
420
+ translatableLeaves: sourceLeaves,
421
+ existingLeaves: [],
422
+ };
423
+ }
424
+
425
+ const translatableLeaves = [];
426
+ const existingLeaves = [];
427
+
428
+ for (const leaf of sourceLeaves) {
429
+ const targetValue = getLeaf(targetData, leaf.keyPath);
430
+ if (shouldTranslateTargetValue(leaf.value, targetValue, args)) {
431
+ translatableLeaves.push(leaf);
432
+ } else {
433
+ existingLeaves.push({ ...leaf, value: targetValue, skipReason: 'existing' });
434
+ }
435
+ }
436
+
437
+ return { translatableLeaves, existingLeaves };
438
+ }
439
+
292
440
  async function resolvePlaceholderStrategy(args) {
293
441
  const interactive = isInteractive({ noPrompt: args.noConfirm });
294
442
 
@@ -403,7 +551,11 @@ function prepareDirectBatch(toTranslate, customRegex, protection) {
403
551
 
404
552
  async function runTranslation(maskedBatch, targetLang, options) {
405
553
  const batchItems = maskedBatch.map((item) => ({ value: item.masked, keyPath: item.keyPath }));
406
- const results = await translateBatchInChunks(batchItems, targetLang, options);
554
+ const results = await translateBatchInChunks(batchItems, targetLang, {
555
+ ...options,
556
+ stageLabel: 'Translating strings',
557
+ progressUnit: 'strings',
558
+ });
407
559
  return results;
408
560
  }
409
561
 
@@ -428,6 +580,9 @@ async function translateBatchInChunks(batch, targetLang, options) {
428
580
  total: batch.length,
429
581
  chunkCompleted: info.completed,
430
582
  chunkTotal: info.total,
583
+ keyPath: info.keyPath,
584
+ stage: options.stageLabel || 'Translating',
585
+ unit: options.progressUnit || 'items',
431
586
  });
432
587
  }
433
588
  },
@@ -522,7 +677,11 @@ async function translatePreservedItems(items, targetLang, options, customRegex,
522
677
  return plan;
523
678
  });
524
679
 
525
- const translatedSegments = await translateBatchInChunks(segmentJobs, targetLang, options);
680
+ const translatedSegments = await translateBatchInChunks(segmentJobs, targetLang, {
681
+ ...options,
682
+ stageLabel: 'Translating placeholder-safe text segments',
683
+ progressUnit: 'segments',
684
+ });
526
685
 
527
686
  return plans.map((plan) => {
528
687
  const value = plan.segments.map((segment) => {
@@ -580,8 +739,8 @@ async function translateItems(toTranslate, targetLang, options, customRegex, pro
580
739
  return finalResults;
581
740
  }
582
741
 
583
- function applyResults(sourceData, translatedResults, toTranslate, toSkip) {
584
- const output = deepClone(sourceData);
742
+ function applyResults(sourceData, translatedResults, toTranslate, toSkip, targetData = null) {
743
+ const output = deepClone(targetData || sourceData);
585
744
 
586
745
  for (let i = 0; i < toTranslate.length; i++) {
587
746
  setLeaf(output, toTranslate[i].keyPath, translatedResults[i]);
@@ -594,6 +753,47 @@ function applyResults(sourceData, translatedResults, toTranslate, toSkip) {
594
753
  return output;
595
754
  }
596
755
 
756
+ function findResidualUntranslatedLeaves(sourceLeaves, outputData, args, options = {}) {
757
+ const ignoredKeys = options.ignoredKeys || new Set();
758
+ const residual = [];
759
+
760
+ for (const leaf of sourceLeaves) {
761
+ if (ignoredKeys.has(leaf.keyPath)) continue;
762
+
763
+ const targetValue = getLeaf(outputData, leaf.keyPath);
764
+ const reason = getResidualUntranslatedReason(leaf.value, targetValue, args);
765
+ if (reason) {
766
+ residual.push({
767
+ keyPath: leaf.keyPath,
768
+ value: String(targetValue ?? ''),
769
+ sourceValue: leaf.value,
770
+ reason,
771
+ fileName: options.fileName,
772
+ });
773
+ }
774
+ }
775
+
776
+ return residual;
777
+ }
778
+
779
+ function buildResidualRetryItems(residualLeaves, customRegex) {
780
+ return residualLeaves.map((leaf) => {
781
+ const placeholders = detectPlaceholders(leaf.sourceValue, customRegex);
782
+ return {
783
+ keyPath: leaf.keyPath,
784
+ value: leaf.sourceValue,
785
+ placeholders,
786
+ placeholderMode: placeholders.length > 0 ? 'preserve' : 'none',
787
+ };
788
+ });
789
+ }
790
+
791
+ function formatProgressKey(keyPath) {
792
+ if (!keyPath) return '';
793
+ const value = String(keyPath);
794
+ return value.length > 72 ? `${value.slice(0, 69)}...` : value;
795
+ }
796
+
597
797
  function writeOutput(outputData, outputPath, bom) {
598
798
  const resolvedOutputPath = path.resolve(process.cwd(), outputPath);
599
799
  const dir = path.dirname(resolvedOutputPath);
@@ -611,6 +811,7 @@ async function processFile(sourcePath, targetLang, args) {
611
811
  const fileName = path.basename(sourcePath);
612
812
  const targetDir = args.outputDir || path.join(path.dirname(path.dirname(sourcePath)), targetLang);
613
813
  const targetPath = path.join(targetDir, fileName);
814
+ const runArgs = { ...args, targetLang };
614
815
 
615
816
  let sourceData;
616
817
  try {
@@ -628,16 +829,19 @@ async function processFile(sourcePath, targetLang, args) {
628
829
  return { total: 0, translated: 0, skipped: 0, skippedKeys: [] };
629
830
  }
630
831
 
631
- const protection = args.protection || loadProtectionConfig(args.protectionFile, {
632
- enabled: args.protectionEnabled,
633
- create: args.createProtectionFile,
832
+ const targetData = readExistingTargetData(targetPath);
833
+ const { translatableLeaves: candidateLeaves, existingLeaves } = planTargetAwareLeaves(leaves, targetData, runArgs);
834
+
835
+ const protection = runArgs.protection || loadProtectionConfig(runArgs.protectionFile, {
836
+ enabled: runArgs.protectionEnabled,
837
+ create: runArgs.createProtectionFile,
634
838
  });
635
- const protectedLeaves = leaves
839
+ const protectedLeaves = candidateLeaves
636
840
  .filter((leaf) => shouldPreserveWholeValue(leaf.keyPath, leaf.value, protection))
637
841
  .map((leaf) => ({ ...leaf, skipReason: 'protected' }));
638
- const translatableLeaves = leaves.filter((leaf) => !shouldPreserveWholeValue(leaf.keyPath, leaf.value, protection));
639
- const { withPlaceholders, withoutPlaceholders } = classifyLeaves(translatableLeaves, args.customRegex);
640
- const { strategy, interactiveMode } = await resolvePlaceholderStrategy(args);
842
+ const translatableLeaves = candidateLeaves.filter((leaf) => !shouldPreserveWholeValue(leaf.keyPath, leaf.value, protection));
843
+ const { withPlaceholders, withoutPlaceholders } = classifyLeaves(translatableLeaves, runArgs.customRegex);
844
+ const { strategy, interactiveMode } = await resolvePlaceholderStrategy(runArgs);
641
845
 
642
846
  if (args.dryRun && strategy === 'skip' && withPlaceholders.length > 0) {
643
847
  await previewSkipped(withPlaceholders);
@@ -649,13 +853,17 @@ async function processFile(sourcePath, targetLang, args) {
649
853
  skippedKeys,
650
854
  placeholderProtected: 0,
651
855
  protectedSkipped: protectedLeaves.length,
856
+ skippedExisting: existingLeaves.length,
652
857
  dryRun: true,
653
858
  };
654
859
  }
655
860
 
656
861
  if (args.dryRun) {
657
862
  const protectedCount = strategy === 'send' ? 0 : withPlaceholders.length;
658
- console.log(`[${fileName}] Dry-run: ${leaves.length} strings would be translated.`);
863
+ console.log(`[${fileName}] Dry-run: ${candidateLeaves.length} of ${leaves.length} strings would be translated.`);
864
+ if (existingLeaves.length > 0) {
865
+ console.log(`[${fileName}] Dry-run: ${existingLeaves.length} existing translated strings would be kept.`);
866
+ }
659
867
  if (protectedLeaves.length > 0) {
660
868
  console.log(`[${fileName}] Dry-run: ${protectedLeaves.length} protected keys/values would be copied unchanged.`);
661
869
  }
@@ -667,11 +875,12 @@ async function processFile(sourcePath, targetLang, args) {
667
875
  }
668
876
  return {
669
877
  total: leaves.length,
670
- translated: leaves.length - protectedLeaves.length,
878
+ translated: candidateLeaves.length - protectedLeaves.length,
671
879
  skipped: protectedLeaves.length,
672
880
  skippedKeys: protectedLeaves,
673
881
  placeholderProtected: protectedCount,
674
882
  termProtected: hasProtectionRules(protection),
883
+ skippedExisting: existingLeaves.length,
675
884
  dryRun: true,
676
885
  };
677
886
  }
@@ -679,8 +888,9 @@ async function processFile(sourcePath, targetLang, args) {
679
888
  const decisions = await resolvePerKeyDecisions(withPlaceholders, interactiveMode);
680
889
  const { toTranslate, toSkip } = buildTranslateList(withPlaceholders, withoutPlaceholders, strategy, decisions);
681
890
  toSkip.push(...protectedLeaves);
891
+ toSkip.push(...existingLeaves);
682
892
  const placeholderProtected = toTranslate.filter((leaf) => leaf.placeholderMode === 'preserve').length;
683
- const placeholderSkipped = toSkip.filter((leaf) => leaf.skipReason !== 'protected').length;
893
+ const placeholderSkipped = toSkip.filter((leaf) => leaf.skipReason !== 'protected' && leaf.skipReason !== 'existing').length;
684
894
 
685
895
  if (placeholderSkipped > 0) {
686
896
  console.log(`[${fileName}] Skipping ${placeholderSkipped} keys with placeholders.`);
@@ -691,6 +901,9 @@ async function processFile(sourcePath, targetLang, args) {
691
901
  if (protectedLeaves.length > 0) {
692
902
  console.log(`[${fileName}] Copying ${protectedLeaves.length} protected keys/values unchanged.`);
693
903
  }
904
+ if (existingLeaves.length > 0) {
905
+ console.log(`[${fileName}] Keeping ${existingLeaves.length} existing translated keys.`);
906
+ }
694
907
  if (hasProtectionRules(protection)) {
695
908
  console.log(`[${fileName}] Protecting terms from: ${protection.filePath}`);
696
909
  }
@@ -698,17 +911,21 @@ async function processFile(sourcePath, targetLang, args) {
698
911
  const manifestPath = createPlaceholderManifest(sourcePath, targetLang, toTranslate);
699
912
 
700
913
  const translateOptions = {
701
- sourceLang: args.sourceLang,
702
- provider: args.provider,
703
- concurrency: args.concurrency,
704
- batchSize: args.batchSize,
705
- retryCount: args.retryCount,
706
- retryDelay: args.retryDelay,
707
- timeout: args.timeout,
708
- customFn: args.translateFn,
914
+ sourceLang: runArgs.sourceLang,
915
+ provider: runArgs.provider,
916
+ concurrency: runArgs.concurrency,
917
+ batchSize: runArgs.batchSize,
918
+ retryCount: runArgs.retryCount,
919
+ retryDelay: runArgs.retryDelay,
920
+ timeout: runArgs.timeout,
921
+ customFn: runArgs.translateFn,
709
922
  onProgress: (info) => {
710
- if (info.completed % args.progressInterval === 0 || info.completed === info.total) {
711
- process.stdout.write(`\r[${fileName}] Translating... ${info.completed}/${info.total}`);
923
+ if (info.completed % runArgs.progressInterval === 0 || info.completed === info.total) {
924
+ const stage = info.stage || 'Translating';
925
+ const unit = info.unit || 'items';
926
+ const keyPath = formatProgressKey(info.keyPath);
927
+ const keySuffix = keyPath ? ` | ${keyPath}` : '';
928
+ process.stdout.write(`\r[${fileName}] ${stage}: ${info.completed}/${info.total} ${unit}${keySuffix}`);
712
929
  }
713
930
  },
714
931
  onError: (err) => {
@@ -719,8 +936,10 @@ async function processFile(sourcePath, targetLang, args) {
719
936
  let translatedResults;
720
937
  try {
721
938
  if (toTranslate.length > 0) {
722
- translatedResults = await translateItems(toTranslate, targetLang, translateOptions, args.customRegex, protection);
939
+ console.log(`[${fileName}] Preparing translation plan for ${toTranslate.length} keys.`);
940
+ translatedResults = await translateItems(toTranslate, targetLang, translateOptions, runArgs.customRegex, protection);
723
941
  process.stdout.write('\n');
942
+ console.log(`[${fileName}] Applying translated values.`);
724
943
  } else {
725
944
  translatedResults = [];
726
945
  }
@@ -728,18 +947,47 @@ async function processFile(sourcePath, targetLang, args) {
728
947
  cleanupPlaceholderManifest(manifestPath);
729
948
  }
730
949
 
731
- const output = applyResults(sourceData, translatedResults, toTranslate, toSkip);
732
- writeOutput(output, targetPath, args.bom);
950
+ const output = applyResults(sourceData, translatedResults, toTranslate, toSkip, targetData);
951
+ const ignoredResidualKeys = new Set(protectedLeaves.map((leaf) => leaf.keyPath));
952
+ let residualUntranslated = findResidualUntranslatedLeaves(leaves, output, runArgs, {
953
+ ignoredKeys: ignoredResidualKeys,
954
+ fileName,
955
+ });
956
+ let finalCheckRetried = 0;
957
+
958
+ if (residualUntranslated.length > 0) {
959
+ console.warn(`[${fileName}] Warning: Final check found ${residualUntranslated.length} values that still look untranslated. Retrying once before writing.`);
960
+ const retryItems = buildResidualRetryItems(residualUntranslated, runArgs.customRegex);
961
+ const retryResults = await translateItems(retryItems, targetLang, translateOptions, runArgs.customRegex, protection);
962
+ retryItems.forEach((item, index) => {
963
+ setLeaf(output, item.keyPath, retryResults[index]);
964
+ });
965
+ finalCheckRetried = retryItems.length;
966
+ residualUntranslated = findResidualUntranslatedLeaves(leaves, output, runArgs, {
967
+ ignoredKeys: ignoredResidualKeys,
968
+ fileName,
969
+ });
970
+ }
971
+
972
+ if (residualUntranslated.length > 0) {
973
+ console.warn(`[${fileName}] Warning: ${residualUntranslated.length} values still look untranslated after retry. Rerun Auto Translate to capture leftovers.`);
974
+ }
975
+
976
+ console.log(`[${fileName}] Writing output.`);
977
+ writeOutput(output, targetPath, runArgs.bom);
733
978
 
734
979
  console.log(`[${fileName}] Written: ${targetPath}`);
735
980
 
736
981
  return {
737
982
  total: leaves.length,
738
983
  translated: translatedResults.length,
739
- skipped: toSkip.length,
740
- skippedKeys: toSkip,
984
+ skipped: toSkip.filter((leaf) => leaf.skipReason !== 'existing').length,
985
+ skippedKeys: toSkip.filter((leaf) => leaf.skipReason !== 'existing'),
741
986
  placeholderProtected,
742
987
  protectedSkipped: protectedLeaves.length,
988
+ skippedExisting: existingLeaves.length,
989
+ finalCheckRetried,
990
+ residualUntranslated,
743
991
  };
744
992
  }
745
993
 
@@ -787,6 +1035,9 @@ async function run(args) {
787
1035
  let grandSkipped = 0;
788
1036
  let grandPlaceholderProtected = 0;
789
1037
  let grandProtectedSkipped = 0;
1038
+ let grandSkippedExisting = 0;
1039
+ let grandFinalCheckRetried = 0;
1040
+ const allResidualUntranslated = [];
790
1041
 
791
1042
  for (const srcPath of sourceFiles) {
792
1043
  const result = await processFile(srcPath, args.targetLang, args);
@@ -796,25 +1047,39 @@ async function run(args) {
796
1047
  grandSkipped += result.skipped;
797
1048
  grandPlaceholderProtected += result.placeholderProtected || 0;
798
1049
  grandProtectedSkipped += result.protectedSkipped || 0;
1050
+ grandSkippedExisting += result.skippedExisting || 0;
1051
+ grandFinalCheckRetried += result.finalCheckRetried || 0;
799
1052
  if (result.skippedKeys && result.skippedKeys.length > 0) {
800
1053
  allSkippedKeys.push(...result.skippedKeys);
801
1054
  }
1055
+ if (result.residualUntranslated && result.residualUntranslated.length > 0) {
1056
+ allResidualUntranslated.push(...result.residualUntranslated);
1057
+ }
802
1058
  }
803
1059
  }
804
1060
 
805
1061
  console.log('');
806
- console.log(formatSummaryLine(grandSkipped, grandTranslated, grandTotal, grandPlaceholderProtected, grandProtectedSkipped));
1062
+ console.log(formatSummaryLine(grandSkipped, grandTranslated, grandTotal, grandPlaceholderProtected, grandProtectedSkipped, grandSkippedExisting));
1063
+ if (grandFinalCheckRetried > 0) {
1064
+ console.log(`[translate] final check retried ${grandFinalCheckRetried} leftover values`);
1065
+ }
1066
+
1067
+ if (allResidualUntranslated.length > 0) {
1068
+ console.warn(`WARNING: ${allResidualUntranslated.length} values still look untranslated after Auto Translate.`);
1069
+ console.warn('Rerun Auto Translate to capture leftovers, or review the values listed in the report.');
1070
+ }
807
1071
 
808
- if (allSkippedKeys.length > 0 || args.reportFile || args.reportStdout) {
1072
+ if (allSkippedKeys.length > 0 || allResidualUntranslated.length > 0 || args.reportFile || args.reportStdout) {
809
1073
  const report = generateReport(allSkippedKeys, grandTranslated, grandTotal, {
810
1074
  sourceFile: sourceFiles.length === 1 ? sourceFiles[0] : `${sourceFiles.length} files`,
811
1075
  targetLang: args.targetLang,
812
1076
  dryRun: args.dryRun,
813
1077
  placeholderProtected: grandPlaceholderProtected,
814
1078
  protectedSkipped: grandProtectedSkipped,
1079
+ residualUntranslated: allResidualUntranslated,
815
1080
  });
816
1081
 
817
- if (args.reportStdout || (!args.reportFile && allSkippedKeys.length > 0)) {
1082
+ if (args.reportStdout || (!args.reportFile && (allSkippedKeys.length > 0 || allResidualUntranslated.length > 0))) {
818
1083
  console.log('');
819
1084
  console.log(report);
820
1085
  }
@@ -827,13 +1092,17 @@ async function run(args) {
827
1092
  }
828
1093
 
829
1094
  return {
830
- success: true,
831
- exitCode: ExitCodes.SUCCESS,
1095
+ success: allResidualUntranslated.length === 0,
1096
+ exitCode: allResidualUntranslated.length === 0 ? ExitCodes.SUCCESS : ExitCodes.VALIDATION_FAILED,
1097
+ error: allResidualUntranslated.length === 0 ? undefined : 'Auto Translate left untranslated placeholder values',
832
1098
  total: grandTotal,
833
1099
  translated: grandTranslated,
834
1100
  skipped: grandSkipped,
835
1101
  placeholderProtected: grandPlaceholderProtected,
836
1102
  protectedSkipped: grandProtectedSkipped,
1103
+ skippedExisting: grandSkippedExisting,
1104
+ finalCheckRetried: grandFinalCheckRetried,
1105
+ residualUntranslated: allResidualUntranslated.length,
837
1106
  };
838
1107
  }
839
1108
 
@@ -852,6 +1121,7 @@ if (require.main === module) {
852
1121
  module.exports = {
853
1122
  parseArgs,
854
1123
  resolveSourceFiles,
1124
+ isBrokenTranslationValue,
855
1125
  processFile,
856
1126
  run,
857
1127
  };