i18ntk 4.3.3 → 4.4.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.
- package/CHANGELOG.md +148 -95
- package/README.md +49 -51
- package/main/i18ntk-backup.js +77 -52
- package/main/i18ntk-complete.js +16 -5
- package/main/i18ntk-scanner.js +5 -0
- package/main/i18ntk-translate.js +11 -3
- package/main/i18ntk-usage.js +438 -127
- package/main/manage/commands/TranslateCommand.js +2 -2
- package/package.json +32 -19
- package/utils/config-helper.js +19 -3
- package/utils/english-placeholder-checker.js +15 -2
- package/utils/security.js +49 -6
- package/utils/translate/api.js +16 -1
- package/utils/translate/report.js +26 -2
- package/utils/usage-insights.js +254 -3
package/main/i18ntk-backup.js
CHANGED
|
@@ -356,25 +356,38 @@ async function cleanupOldBackups(backupDirPath) {
|
|
|
356
356
|
}
|
|
357
357
|
|
|
358
358
|
async function handleCreate(args) {
|
|
359
|
-
const
|
|
360
|
-
const
|
|
359
|
+
const rawSourceDir = args._[1] || path.join(__dirname, '..', 'locales');
|
|
360
|
+
const rawOutputDir = args.output || backupDir;
|
|
361
|
+
|
|
362
|
+
// Validate both paths against project root (cwd) for security
|
|
363
|
+
const sourceDir = path.resolve(rawSourceDir);
|
|
364
|
+
if (!SecurityUtils.validatePath(sourceDir, process.cwd())) {
|
|
365
|
+
throw new Error(`Source directory is outside the allowed project boundary.`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const outputDir = path.resolve(rawOutputDir);
|
|
369
|
+
const validatedOutputDir = SecurityUtils.validatePath(outputDir, process.cwd());
|
|
370
|
+
if (!validatedOutputDir) {
|
|
371
|
+
throw new Error(`Output directory is outside the allowed project boundary.`);
|
|
372
|
+
}
|
|
373
|
+
|
|
361
374
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
362
375
|
const backupName = `backup-${timestamp}.json`;
|
|
363
|
-
const backupPath = path.join(
|
|
376
|
+
const backupPath = path.join(validatedOutputDir, backupName);
|
|
364
377
|
const isIncremental = args.incremental !== 'false' && args.incremental !== false;
|
|
365
378
|
|
|
366
|
-
logger.debug(`Source directory: ${
|
|
379
|
+
logger.debug(`Source directory: ${sourceDir}`);
|
|
367
380
|
logger.debug(`Backup will be saved to: ${backupPath}`);
|
|
368
381
|
|
|
369
382
|
try {
|
|
370
|
-
|
|
371
|
-
logger.debug(`Created backup directory: ${
|
|
383
|
+
SecurityUtils.safeMkdirSync(validatedOutputDir, process.cwd());
|
|
384
|
+
logger.debug(`Created backup directory: ${validatedOutputDir}`);
|
|
372
385
|
} catch (err) {
|
|
373
386
|
if (err.code !== 'EEXIST') {
|
|
374
387
|
logger.error(`Failed to create backup directory: ${err.message}`);
|
|
375
388
|
throw err;
|
|
376
389
|
}
|
|
377
|
-
logger.debug(`Using existing backup directory: ${
|
|
390
|
+
logger.debug(`Using existing backup directory: ${validatedOutputDir}`);
|
|
378
391
|
}
|
|
379
392
|
|
|
380
393
|
const sourceDir = path.resolve(dir);
|
|
@@ -463,7 +476,7 @@ async function handleCreate(args) {
|
|
|
463
476
|
backupData[file] = content;
|
|
464
477
|
}
|
|
465
478
|
|
|
466
|
-
|
|
479
|
+
SecurityUtils.safeWriteFileSync(backupPath, JSON.stringify(backupData, null, 2), validatedOutputDir);
|
|
467
480
|
const stats = await fsp.stat(backupPath);
|
|
468
481
|
|
|
469
482
|
logger.success('Backup created successfully');
|
|
@@ -483,9 +496,11 @@ async function handleRestore(args) {
|
|
|
483
496
|
}
|
|
484
497
|
|
|
485
498
|
const backupPath = path.resolve(process.cwd(), backupFile);
|
|
486
|
-
const
|
|
487
|
-
|
|
488
|
-
|
|
499
|
+
const rawOutputDir = args.output || path.join(process.cwd(), 'restored');
|
|
500
|
+
const validatedOutputDir = SecurityUtils.validatePath(rawOutputDir, process.cwd());
|
|
501
|
+
if (!validatedOutputDir) {
|
|
502
|
+
throw new Error(`Restore output directory is outside the allowed project boundary.`);
|
|
503
|
+
}
|
|
489
504
|
|
|
490
505
|
if (!SecurityUtils.safeExistsSync(backupPath, process.cwd())) {
|
|
491
506
|
throw new Error(`Backup file not found: ${backupPath}`);
|
|
@@ -494,36 +509,38 @@ async function handleRestore(args) {
|
|
|
494
509
|
logger.info('\nRestoring backup...');
|
|
495
510
|
|
|
496
511
|
try {
|
|
497
|
-
const
|
|
512
|
+
const rawContent = SecurityUtils.safeReadFileSync(backupPath, process.cwd(), 'utf8');
|
|
513
|
+
if (!rawContent) throw new Error(`Could not read backup file: ${backupPath}`);
|
|
514
|
+
const backupData = JSON.parse(rawContent);
|
|
498
515
|
const isIncremental = backupData._meta && backupData._meta.type === 'incremental';
|
|
499
516
|
|
|
500
517
|
if (isIncremental) {
|
|
501
518
|
const chain = await buildRestoreChain(backupPath, backupData);
|
|
502
|
-
|
|
519
|
+
SecurityUtils.safeMkdirSync(validatedOutputDir, process.cwd());
|
|
503
520
|
|
|
504
|
-
const restoredFiles = new Set();
|
|
505
|
-
for (const entry of chain) {
|
|
506
|
-
for (const [file, content] of Object.entries(entry.data)) {
|
|
507
|
-
if (restoreBackupEntry(
|
|
508
|
-
restoredFiles.add(file);
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
}
|
|
521
|
+
const restoredFiles = new Set();
|
|
522
|
+
for (const entry of chain) {
|
|
523
|
+
for (const [file, content] of Object.entries(entry.data)) {
|
|
524
|
+
if (restoreBackupEntry(validatedOutputDir, file, content)) {
|
|
525
|
+
restoredFiles.add(file);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
512
529
|
|
|
513
530
|
logger.success('Incremental backup restored successfully');
|
|
514
|
-
logger.info(` ${restoredFiles.size} files restored across ${chain.length} backup(s) to: ${
|
|
531
|
+
logger.info(` ${restoredFiles.size} files restored across ${chain.length} backup(s) to: ${validatedOutputDir}`);
|
|
515
532
|
} else {
|
|
516
|
-
|
|
533
|
+
SecurityUtils.safeMkdirSync(validatedOutputDir, process.cwd());
|
|
517
534
|
|
|
518
|
-
let count = 0;
|
|
519
|
-
for (const [file, content] of Object.entries(backupData)) {
|
|
520
|
-
if (restoreBackupEntry(
|
|
521
|
-
count++;
|
|
522
|
-
}
|
|
523
|
-
}
|
|
535
|
+
let count = 0;
|
|
536
|
+
for (const [file, content] of Object.entries(backupData)) {
|
|
537
|
+
if (restoreBackupEntry(validatedOutputDir, file, content)) {
|
|
538
|
+
count++;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
524
541
|
|
|
525
542
|
logger.success('Backup restored successfully');
|
|
526
|
-
logger.info(` Restored ${count} files to: ${
|
|
543
|
+
logger.info(` Restored ${count} files to: ${validatedOutputDir}`);
|
|
527
544
|
}
|
|
528
545
|
} catch (error) {
|
|
529
546
|
handleError(error);
|
|
@@ -532,37 +549,43 @@ async function handleRestore(args) {
|
|
|
532
549
|
|
|
533
550
|
async function handleList() {
|
|
534
551
|
try {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
552
|
+
const validatedBackupDir = SecurityUtils.validatePath(backupDir, process.cwd());
|
|
553
|
+
if (!validatedBackupDir) {
|
|
554
|
+
logger.error('Backup directory is outside allowed project boundary.');
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (!SecurityUtils.safeExistsSync(validatedBackupDir, process.cwd())) {
|
|
559
|
+
logger.warn('No backups found. The backup directory does not exist yet.');
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const files = SecurityUtils.safeReaddirSync(validatedBackupDir, process.cwd());
|
|
564
|
+
if (!files || files.length === 0) {
|
|
565
|
+
logger.warn('No valid backup files found in the backup directory.');
|
|
544
566
|
return;
|
|
545
567
|
}
|
|
546
568
|
|
|
547
|
-
const files = await fsp.readdir(backupDir);
|
|
548
569
|
const backups = [];
|
|
549
|
-
|
|
570
|
+
|
|
550
571
|
for (const file of files) {
|
|
551
|
-
if (file.startsWith('backup-') && file.endsWith('.json')) {
|
|
572
|
+
if (Array.isArray(files) && file.startsWith('backup-') && file.endsWith('.json')) {
|
|
552
573
|
try {
|
|
553
|
-
const filePath = path.join(
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
574
|
+
const filePath = path.join(validatedBackupDir, file);
|
|
575
|
+
const stats = SecurityUtils.safeStatSync(filePath, process.cwd());
|
|
576
|
+
if (stats) {
|
|
577
|
+
backups.push({
|
|
578
|
+
name: file,
|
|
579
|
+
path: filePath,
|
|
580
|
+
size: stats.size,
|
|
581
|
+
createdAt: stats.mtime
|
|
582
|
+
});
|
|
583
|
+
}
|
|
561
584
|
} catch (err) {
|
|
562
585
|
logger.warn(`Skipping invalid backup file ${file}: ${err.message}`);
|
|
563
586
|
}
|
|
564
587
|
}
|
|
565
|
-
}
|
|
588
|
+
}
|
|
566
589
|
|
|
567
590
|
if (backups.length === 0) {
|
|
568
591
|
logger.warn('No valid backup files found in the backup directory.');
|
|
@@ -610,7 +633,9 @@ async function handleVerify(args) {
|
|
|
610
633
|
logger.info('\nVerifying backup...');
|
|
611
634
|
|
|
612
635
|
try {
|
|
613
|
-
const
|
|
636
|
+
const rawContent = SecurityUtils.safeReadFileSync(backupPath, process.cwd(), 'utf8');
|
|
637
|
+
if (!rawContent) throw new Error(`Could not read backup file: ${backupPath}`);
|
|
638
|
+
const data = JSON.parse(rawContent);
|
|
614
639
|
|
|
615
640
|
if (data._meta && data._meta.hashes) {
|
|
616
641
|
logger.info(' Performing hash chain verification...');
|
package/main/i18ntk-complete.js
CHANGED
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
const path = require('path');
|
|
16
|
+
const fs = require('fs');
|
|
16
17
|
const SecurityUtils = require('../utils/security');
|
|
17
|
-
const {
|
|
18
|
-
const { loadTranslations, t } = require('../utils/i18n-helper');
|
|
18
|
+
const { loadTranslations, t } = require('../utils/i18n-helper');
|
|
19
19
|
const { getGlobalReadline, closeGlobalReadline } = require('../utils/cli');
|
|
20
20
|
const SetupEnforcer = require('../utils/setup-enforcer');
|
|
21
21
|
|
|
@@ -559,12 +559,23 @@ class I18nCompletionTool {
|
|
|
559
559
|
await this.initialize();
|
|
560
560
|
|
|
561
561
|
if (args.sourceDir) {
|
|
562
|
-
|
|
563
|
-
|
|
562
|
+
const resolvedSourceDir = path.resolve(args.sourceDir);
|
|
563
|
+
const validatedSourceDir = SecurityUtils.validatePath(resolvedSourceDir, process.cwd());
|
|
564
|
+
if (!validatedSourceDir) {
|
|
565
|
+
console.error(t("complete.invalidSourceDir", { sourceDir: args.sourceDir }));
|
|
566
|
+
console.error(`Path validation failed — source directory is outside the allowed project boundary.`);
|
|
567
|
+
process.exit(1);
|
|
568
|
+
}
|
|
569
|
+
this.config.sourceDir = resolvedSourceDir;
|
|
570
|
+
this.sourceDir = validatedSourceDir;
|
|
564
571
|
}
|
|
565
572
|
|
|
566
573
|
if (args.sourceLanguage) {
|
|
567
|
-
this.config.sourceLanguage = args.sourceLanguage
|
|
574
|
+
this.config.sourceLanguage = SecurityUtils.sanitizeInput(args.sourceLanguage, {
|
|
575
|
+
allowedChars: /^[a-zA-Z0-9\-_]+$/,
|
|
576
|
+
maxLength: 10,
|
|
577
|
+
removeHTML: true
|
|
578
|
+
});
|
|
568
579
|
}
|
|
569
580
|
|
|
570
581
|
this.sourceLanguageDir = path.join(this.sourceDir, this.config.sourceLanguage);
|
package/main/i18ntk-scanner.js
CHANGED
package/main/i18ntk-translate.js
CHANGED
|
@@ -65,7 +65,7 @@ const {
|
|
|
65
65
|
} = require('../utils/translate/protection');
|
|
66
66
|
const { translateBatch, DEFAULT_CONCURRENCY, clampProviderConcurrency } = require('../utils/translate/api');
|
|
67
67
|
const { collectLeaves, getLeaf, setLeaf, deepClone } = require('../utils/translate/traverse');
|
|
68
|
-
const { generateReport, writeReport, formatSummaryLine } = require('../utils/translate/report');
|
|
68
|
+
const { generateReport, writeReport, writeResidualReport, formatSummaryLine } = require('../utils/translate/report');
|
|
69
69
|
const { analyzeEnglishContent } = require('../utils/validation-risk');
|
|
70
70
|
const {
|
|
71
71
|
confirmGlobalChoice,
|
|
@@ -996,7 +996,7 @@ async function processFile(sourcePath, targetLang, args) {
|
|
|
996
996
|
}
|
|
997
997
|
|
|
998
998
|
if (residualUntranslated.length > 0) {
|
|
999
|
-
console.warn(`[${fileName}] Warning: ${residualUntranslated.length} values still look untranslated after retry.
|
|
999
|
+
console.warn(`[${fileName}] Warning: ${residualUntranslated.length} values still look untranslated after retry. A resume report will capture these keys.`);
|
|
1000
1000
|
}
|
|
1001
1001
|
|
|
1002
1002
|
console.log(`[${fileName}] Writing output.`);
|
|
@@ -1092,7 +1092,15 @@ async function run(args) {
|
|
|
1092
1092
|
|
|
1093
1093
|
if (allResidualUntranslated.length > 0) {
|
|
1094
1094
|
console.warn(`WARNING: ${allResidualUntranslated.length} values still look untranslated after Auto Translate.`);
|
|
1095
|
-
|
|
1095
|
+
const residualReportPath = writeResidualReport(allResidualUntranslated, {
|
|
1096
|
+
sourceFile: sourceFiles.length === 1 ? sourceFiles[0] : `${sourceFiles.length} files`,
|
|
1097
|
+
targetLang: args.targetLang,
|
|
1098
|
+
});
|
|
1099
|
+
if (residualReportPath) {
|
|
1100
|
+
console.warn(`Auto Translate resume report written: ${residualReportPath}`);
|
|
1101
|
+
} else {
|
|
1102
|
+
console.warn('Auto Translate could not write the resume report. Review the values listed in the report output.');
|
|
1103
|
+
}
|
|
1096
1104
|
}
|
|
1097
1105
|
|
|
1098
1106
|
if (allSkippedKeys.length > 0 || allResidualUntranslated.length > 0 || args.reportFile || args.reportStdout) {
|