i18ntk 4.0.0 → 4.2.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +116 -29
  2. package/README.md +83 -18
  3. package/SECURITY.md +13 -5
  4. package/main/i18ntk-analyze.js +10 -20
  5. package/main/i18ntk-backup.js +227 -111
  6. package/main/i18ntk-init.js +153 -157
  7. package/main/i18ntk-scanner.js +9 -7
  8. package/main/i18ntk-setup.js +36 -13
  9. package/main/i18ntk-sizing.js +18 -50
  10. package/main/i18ntk-translate.js +169 -21
  11. package/main/i18ntk-usage.js +298 -154
  12. package/main/i18ntk-validate.js +49 -37
  13. package/main/manage/commands/AnalyzeCommand.js +7 -17
  14. package/main/manage/commands/CommandRouter.js +6 -6
  15. package/main/manage/commands/TranslateCommand.js +65 -56
  16. package/main/manage/commands/ValidateCommand.js +34 -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 +244 -85
  21. package/package.json +55 -4
  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 +188 -97
  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/protection.js +17 -12
  46. package/utils/translate/report.js +3 -2
  47. package/utils/translate/safe-network.js +24 -4
  48. package/utils/usage-insights.js +435 -0
  49. package/utils/usage-source.js +50 -0
  50. package/utils/watch-locales.js +13 -9
@@ -57,14 +57,35 @@ function computeFileHash(filePath) {
57
57
  }
58
58
  }
59
59
 
60
- function computeContentHash(content) {
60
+ function computeContentHash(content) {
61
61
  if (typeof content === 'object' && content !== null) {
62
62
  const normalized = JSON.stringify(content);
63
63
  return crypto.createHash('sha256').update(normalized).digest('hex');
64
64
  }
65
65
  const str = String(content);
66
- return crypto.createHash('sha256').update(str).digest('hex');
67
- }
66
+ return crypto.createHash('sha256').update(str).digest('hex');
67
+ }
68
+
69
+ async function collectJsonFiles(rootDir, currentDir = rootDir) {
70
+ const entries = await fsp.readdir(currentDir, { withFileTypes: true });
71
+ const files = [];
72
+
73
+ for (const entry of entries) {
74
+ const fullPath = path.join(currentDir, entry.name);
75
+ if (entry.isDirectory()) {
76
+ files.push(...await collectJsonFiles(rootDir, fullPath));
77
+ continue;
78
+ }
79
+ if (!entry.isFile() || !entry.name.endsWith('.json')) {
80
+ continue;
81
+ }
82
+
83
+ const relativePath = path.relative(rootDir, fullPath).split(path.sep).join('/');
84
+ files.push({ relativePath, fullPath });
85
+ }
86
+
87
+ return files;
88
+ }
68
89
 
69
90
  async function findMostRecentBackup(backupDirPath) {
70
91
  try {
@@ -98,18 +119,24 @@ function getParentHashes(parentData) {
98
119
  return hashes;
99
120
  }
100
121
 
101
- async function buildRestoreChain(startPath, startData) {
122
+ async function buildRestoreChain(startPath, startData) {
102
123
  const chain = [{ path: startPath, data: startData }];
103
- let current = startData;
104
- let currentPath = startPath;
105
-
106
- while (current._meta && current._meta.parent) {
107
- if (chain.length >= 11) {
108
- throw new Error('Chain broken: incremental backup chain exceeds the maximum depth of 10');
109
- }
110
- const parentName = current._meta.parent;
124
+ const visited = new Set();
125
+ visited.add(path.basename(startPath));
126
+ let current = startData;
127
+ let currentPath = startPath;
128
+
129
+ while (current._meta && current._meta.parent) {
130
+ if (chain.length >= 11) {
131
+ throw new Error('Chain broken: incremental backup chain exceeds the maximum depth of 10');
132
+ }
133
+ const parentName = current._meta.parent;
134
+ if (visited.has(parentName)) {
135
+ throw new Error('circular chain reference');
136
+ }
137
+ visited.add(parentName);
111
138
  const parentDir = path.dirname(currentPath);
112
- const parentPath = path.join(parentDir, parentName);
139
+ const parentPath = path.join(parentDir, parentName);
113
140
  if (!SecurityUtils.safeExistsSync(parentPath, parentDir)) {
114
141
  throw new Error(`Chain broken: parent backup '${parentName}' not found in ${parentDir}`);
115
142
  }
@@ -122,10 +149,106 @@ async function buildRestoreChain(startPath, startData) {
122
149
  chain.push({ path: currentPath, data: current });
123
150
  }
124
151
 
125
- chain.reverse();
126
- return chain;
127
- }
128
-
152
+ chain.reverse();
153
+ return chain;
154
+ }
155
+
156
+ function readBackupData(backupPath, baseDir) {
157
+ const raw = SecurityUtils.safeReadFileSync(backupPath, baseDir, 'utf8');
158
+ if (raw === null) {
159
+ throw new Error(`Unable to read backup: ${path.basename(backupPath)}`);
160
+ }
161
+ return JSON.parse(raw);
162
+ }
163
+
164
+ function validateBackupEntryName(fileName) {
165
+ if (!fileName || typeof fileName !== 'string') {
166
+ throw new Error('Invalid backup entry name');
167
+ }
168
+ if (fileName === '_meta') {
169
+ return null;
170
+ }
171
+ if (fileName.includes('\0') || /^[a-zA-Z]:/.test(fileName)) {
172
+ throw new Error(`Unsafe backup entry name: ${fileName}`);
173
+ }
174
+
175
+ const normalizedSeparators = fileName.replace(/\\/g, '/');
176
+ const rawSegments = normalizedSeparators.split('/');
177
+ const normalized = path.posix.normalize(normalizedSeparators);
178
+ const segments = normalized.split('/');
179
+
180
+ if (
181
+ path.posix.isAbsolute(normalizedSeparators) ||
182
+ rawSegments.some(segment => !segment || segment === '.' || segment === '..') ||
183
+ normalized === '.' ||
184
+ normalized === '..' ||
185
+ normalized.startsWith('../') ||
186
+ !normalized.endsWith('.json') ||
187
+ segments.some(segment => !segment || segment === '.' || segment === '..')
188
+ ) {
189
+ throw new Error(`Unsafe backup entry name: ${fileName}`);
190
+ }
191
+
192
+ return normalized;
193
+ }
194
+
195
+ function restoreBackupEntry(outputDir, fileName, content) {
196
+ const safeName = validateBackupEntryName(fileName);
197
+ if (!safeName) return false;
198
+
199
+ const filePath = SecurityUtils.safeJoin(outputDir, safeName);
200
+ if (!filePath) {
201
+ throw new Error(`Backup entry escapes restore directory: ${fileName}`);
202
+ }
203
+ if (!SecurityUtils.safeWriteFileSync(filePath, JSON.stringify(content, null, 2), outputDir, 'utf8')) {
204
+ throw new Error(`Unable to restore backup entry: ${fileName}`);
205
+ }
206
+ return true;
207
+ }
208
+
209
+ function collectProtectedChainNames(backupDirPath, keptFiles) {
210
+ const protectedNames = new Set();
211
+ const byName = new Map();
212
+ for (const file of keptFiles) {
213
+ byName.set(file.name, file);
214
+ }
215
+
216
+ const queue = [];
217
+ for (const file of keptFiles) {
218
+ try {
219
+ const data = readBackupData(file.path, backupDirPath);
220
+ if (data._meta && data._meta.parent) {
221
+ queue.push(data._meta.parent);
222
+ }
223
+ } catch {}
224
+ }
225
+
226
+ while (queue.length > 0) {
227
+ const name = queue.shift();
228
+ if (protectedNames.has(name)) {
229
+ continue;
230
+ }
231
+ protectedNames.add(name);
232
+
233
+ const file = byName.get(name) || {
234
+ name,
235
+ path: path.join(backupDirPath, name)
236
+ };
237
+ if (!SecurityUtils.safeExistsSync(file.path, backupDirPath)) {
238
+ continue;
239
+ }
240
+
241
+ try {
242
+ const data = readBackupData(file.path, backupDirPath);
243
+ if (data._meta && data._meta.parent) {
244
+ queue.push(data._meta.parent);
245
+ }
246
+ } catch {}
247
+ }
248
+
249
+ return protectedNames;
250
+ }
251
+
129
252
  const configManager = require('../utils/config-manager');
130
253
  const { logger } = require('../utils/logger');
131
254
  const { colors } = require('../utils/logger');
@@ -210,7 +333,15 @@ async function cleanupOldBackups(backupDirPath) {
210
333
  .sort((a, b) => b.time - a.time);
211
334
 
212
335
  const toDelete = backupFiles.slice(maxBackups);
213
- for (const file of toDelete) {
336
+ const kept = backupFiles.slice(0, maxBackups);
337
+
338
+ const protectedChainNames = collectProtectedChainNames(backupDirPath, kept);
339
+
340
+ for (const file of toDelete) {
341
+ if (protectedChainNames.has(file.name)) {
342
+ logger.info(` Keeping ${file.name} (parent of a kept incremental backup)`);
343
+ continue;
344
+ }
214
345
  try {
215
346
  await fsp.unlink(file.path);
216
347
  } catch (err) {
@@ -230,7 +361,7 @@ async function handleCreate(args) {
230
361
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
231
362
  const backupName = `backup-${timestamp}.json`;
232
363
  const backupPath = path.join(outputDir, backupName);
233
- const isIncremental = !!args.incremental;
364
+ const isIncremental = args.incremental !== 'false' && args.incremental !== false;
234
365
 
235
366
  logger.debug(`Source directory: ${dir}`);
236
367
  logger.debug(`Backup will be saved to: ${backupPath}`);
@@ -262,31 +393,29 @@ async function handleCreate(args) {
262
393
 
263
394
  logger.info('\nCreating backup...');
264
395
 
265
- const files = (await fsp.readdir(sourceDir, { withFileTypes: true }))
266
- .filter(dirent => dirent.isFile() && dirent.name.endsWith('.json'))
267
- .map(dirent => dirent.name);
268
-
269
- if (files.length === 0) {
270
- logger.warn('No JSON files found in the specified directory');
271
- process.exit(0);
272
- }
273
-
274
- const translations = {};
275
- const hashes = {};
276
- for (const file of files) {
277
- const filePath = path.join(sourceDir, file);
278
- try {
279
- const rawContent = SecurityUtils.safeReadFileSync(filePath, path.dirname(filePath), 'utf8');
280
- if (rawContent === null) {
281
- logger.error(`Could not read file ${file}`);
282
- continue;
283
- }
284
- translations[file] = JSON.parse(rawContent);
285
- hashes[file] = computeFileHash(filePath);
286
- } catch (error) {
287
- logger.error(`Could not read file ${file}: ${error.message}`);
288
- }
289
- }
396
+ const files = await collectJsonFiles(sourceDir);
397
+
398
+ if (files.length === 0) {
399
+ logger.warn('No JSON files found in the specified directory');
400
+ process.exit(0);
401
+ }
402
+
403
+ const translations = {};
404
+ const hashes = {};
405
+ for (const file of files) {
406
+ const filePath = file.fullPath;
407
+ try {
408
+ const rawContent = SecurityUtils.safeReadFileSync(filePath, path.dirname(filePath), 'utf8');
409
+ if (rawContent === null) {
410
+ logger.error(`Could not read file ${file.relativePath}`);
411
+ continue;
412
+ }
413
+ translations[file.relativePath] = JSON.parse(rawContent);
414
+ hashes[file.relativePath] = computeFileHash(filePath);
415
+ } catch (error) {
416
+ logger.error(`Could not read file ${file.relativePath}: ${error.message}`);
417
+ }
418
+ }
290
419
 
291
420
  let meta = {
292
421
  type: 'full',
@@ -372,28 +501,26 @@ async function handleRestore(args) {
372
501
  const chain = await buildRestoreChain(backupPath, backupData);
373
502
  await fsp.mkdir(outputDir, { recursive: true });
374
503
 
375
- const restoredFiles = new Set();
376
- for (const entry of chain) {
377
- for (const [file, content] of Object.entries(entry.data)) {
378
- if (file === '_meta') continue;
379
- const filePath = path.join(outputDir, file);
380
- SecurityUtils.safeWriteFileSync(filePath, JSON.stringify(content, null, 2), path.dirname(filePath), 'utf8');
381
- restoredFiles.add(file);
382
- }
383
- }
504
+ const restoredFiles = new Set();
505
+ for (const entry of chain) {
506
+ for (const [file, content] of Object.entries(entry.data)) {
507
+ if (restoreBackupEntry(outputDir, file, content)) {
508
+ restoredFiles.add(file);
509
+ }
510
+ }
511
+ }
384
512
 
385
513
  logger.success('Incremental backup restored successfully');
386
514
  logger.info(` ${restoredFiles.size} files restored across ${chain.length} backup(s) to: ${outputDir}`);
387
515
  } else {
388
516
  await fsp.mkdir(outputDir, { recursive: true });
389
517
 
390
- let count = 0;
391
- for (const [file, content] of Object.entries(backupData)) {
392
- if (file === '_meta') continue;
393
- const filePath = path.join(outputDir, file);
394
- SecurityUtils.safeWriteFileSync(filePath, JSON.stringify(content, null, 2), path.dirname(filePath), 'utf8');
395
- count++;
396
- }
518
+ let count = 0;
519
+ for (const [file, content] of Object.entries(backupData)) {
520
+ if (restoreBackupEntry(outputDir, file, content)) {
521
+ count++;
522
+ }
523
+ }
397
524
 
398
525
  logger.success('Backup restored successfully');
399
526
  logger.info(` Restored ${count} files to: ${outputDir}`);
@@ -488,32 +615,11 @@ async function handleVerify(args) {
488
615
  if (data._meta && data._meta.hashes) {
489
616
  logger.info(' Performing hash chain verification...');
490
617
 
491
- const chain = [{ path: backupPath, data: data }];
492
- let current = data;
493
- let currentPath = backupPath;
494
- while (current._meta && current._meta.parent) {
495
- if (chain.length >= 11) {
496
- logger.warn(' Chain broken: maximum incremental depth of 10 exceeded');
497
- break;
498
- }
499
- const parentName = current._meta.parent;
500
- const parentDir = path.dirname(currentPath);
501
- const parentPath = path.join(parentDir, parentName);
502
- if (!SecurityUtils.safeExistsSync(parentPath, parentDir)) {
503
- logger.warn(` Chain broken: parent '${parentName}' not found`);
504
- break;
505
- }
506
- const parentRaw = SecurityUtils.safeReadFileSync(parentPath, parentDir, 'utf8');
507
- if (parentRaw === null) {
508
- logger.warn(` Chain broken: cannot read parent '${parentName}'`);
509
- break;
510
- }
511
- current = JSON.parse(parentRaw);
512
- currentPath = parentPath;
513
- chain.push({ path: currentPath, data: current });
514
- }
515
-
618
+ const chain = await buildRestoreChain(backupPath, data);
619
+
516
620
  let allValid = true;
621
+
622
+ // Rebuild full state oldest->newest and verify each manifest against it.
517
623
  const reconstructed = {};
518
624
  for (const entry of chain) {
519
625
  const entryMeta = entry.data._meta;
@@ -526,13 +632,13 @@ async function handleVerify(args) {
526
632
 
527
633
  if (!entryHashes) {
528
634
  logger.warn(` ${entryName}: no manifest hashes (legacy backup)`);
529
- continue;
530
- }
635
+ continue;
636
+ }
531
637
 
532
638
  let entryValid = true;
533
639
  for (const [file, expectedHash] of Object.entries(entryHashes)) {
534
640
  if (!Object.prototype.hasOwnProperty.call(reconstructed, file)) {
535
- logger.warn(` Missing file in manifest: ${file}`);
641
+ logger.warn(` Missing file in reconstructed backup state: ${file}`);
536
642
  entryValid = false;
537
643
  continue;
538
644
  }
@@ -542,19 +648,20 @@ async function handleVerify(args) {
542
648
  entryValid = false;
543
649
  }
544
650
  }
651
+
652
+ if (entryValid) {
653
+ logger.success(` ${entryName}: ${Object.keys(entryHashes).length} file(s) verified`);
654
+ } else {
655
+ allValid = false;
656
+ }
657
+ }
545
658
 
546
- if (entryValid) {
547
- logger.success(` ${entryName}: ${Object.keys(entryHashes).length} file(s) verified`);
548
- } else {
549
- allValid = false;
550
- }
551
- }
552
-
553
- if (allValid) {
554
- logger.success('\nBackup chain verification passed');
555
- } else {
556
- logger.error('\nBackup chain verification FAILED');
557
- }
659
+ if (allValid) {
660
+ logger.success('\nBackup chain verification passed');
661
+ } else {
662
+ logger.error('\nBackup chain verification FAILED');
663
+ process.exitCode = 1;
664
+ }
558
665
  } else {
559
666
  const fileCount = Object.keys(data).filter(k => k !== '_meta').length;
560
667
  logger.success('Backup is valid');
@@ -586,24 +693,33 @@ async function handleCleanup(args) {
586
693
 
587
694
  // Keep only the most recent 'keep' files
588
695
  const toDelete = backupFiles.slice(keep);
696
+ const kept = backupFiles.slice(0, keep);
589
697
 
590
698
  if (toDelete.length === 0) {
591
699
  logger.info('No old backups to delete.');
592
700
  return;
593
701
  }
594
702
 
595
- // Delete old backups
596
- for (const file of toDelete) {
597
- try {
703
+ const protectedChainNames = collectProtectedChainNames(backupDir, kept);
704
+ let deletedCount = 0;
705
+
706
+ // Delete old backups, skipping parents of kept backups
707
+ for (const file of toDelete) {
708
+ if (protectedChainNames.has(file.name)) {
709
+ logger.info(` Keeping ${file.name} (parent of a kept incremental backup)`);
710
+ continue;
711
+ }
712
+ try {
598
713
  await fsp.unlink(file.path);
599
- logger.info(` - Deleted: ${file.name}`);
600
- } catch (err) {
601
- logger.error(` - Failed to delete ${file.name}: ${err.message}`);
602
- }
603
- }
604
-
605
- logger.info(`\nRemoved ${toDelete.length} old backups`);
606
- logger.info(`Total backups kept: ${keep}`);
714
+ logger.info(` - Deleted: ${file.name}`);
715
+ deletedCount++;
716
+ } catch (err) {
717
+ logger.error(` - Failed to delete ${file.name}: ${err.message}`);
718
+ }
719
+ }
720
+
721
+ logger.info(`\nRemoved ${deletedCount} old backups`);
722
+ logger.info(`Total backups kept: ${keep}`);
607
723
 
608
724
  } catch (error) {
609
725
  logger.error('Error cleaning up backups:');