i18ntk 4.3.3 → 4.4.2
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 +151 -88
- package/README.md +56 -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 +20 -8
- package/main/i18ntk-usage.js +438 -127
- package/main/manage/commands/TranslateCommand.js +2 -2
- package/package.json +36 -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,
|
|
@@ -834,15 +834,19 @@ function writeOutput(outputData, outputPath, bom) {
|
|
|
834
834
|
}
|
|
835
835
|
|
|
836
836
|
async function processFile(sourcePath, targetLang, args) {
|
|
837
|
-
const
|
|
838
|
-
const
|
|
837
|
+
const resolvedSourcePath = path.resolve(process.cwd(), sourcePath);
|
|
838
|
+
const fileName = path.basename(resolvedSourcePath);
|
|
839
|
+
const targetDir = args.outputDir || path.join(path.dirname(path.dirname(resolvedSourcePath)), targetLang);
|
|
839
840
|
const targetPath = path.join(targetDir, fileName);
|
|
840
841
|
const runArgs = { ...args, targetLang };
|
|
841
842
|
|
|
842
843
|
let sourceData;
|
|
843
844
|
try {
|
|
844
|
-
const raw = SecurityUtils.safeReadFileSync(
|
|
845
|
-
|
|
845
|
+
const raw = SecurityUtils.safeReadFileSync(resolvedSourcePath, path.dirname(resolvedSourcePath), 'utf-8');
|
|
846
|
+
if (raw === null || raw === undefined) {
|
|
847
|
+
throw new Error('safe read returned no content');
|
|
848
|
+
}
|
|
849
|
+
sourceData = JSON.parse(raw.replace(/^\uFEFF/, ''));
|
|
846
850
|
} catch (e) {
|
|
847
851
|
console.error(`Error reading "${sourcePath}": ${e.message}`);
|
|
848
852
|
return null;
|
|
@@ -934,7 +938,7 @@ async function processFile(sourcePath, targetLang, args) {
|
|
|
934
938
|
console.log(`[${fileName}] Protecting terms from: ${protection.filePath}`);
|
|
935
939
|
}
|
|
936
940
|
|
|
937
|
-
const manifestPath = createPlaceholderManifest(
|
|
941
|
+
const manifestPath = createPlaceholderManifest(resolvedSourcePath, targetLang, toTranslate);
|
|
938
942
|
|
|
939
943
|
const translateOptions = {
|
|
940
944
|
sourceLang: runArgs.sourceLang,
|
|
@@ -996,7 +1000,7 @@ async function processFile(sourcePath, targetLang, args) {
|
|
|
996
1000
|
}
|
|
997
1001
|
|
|
998
1002
|
if (residualUntranslated.length > 0) {
|
|
999
|
-
console.warn(`[${fileName}] Warning: ${residualUntranslated.length} values still look untranslated after retry.
|
|
1003
|
+
console.warn(`[${fileName}] Warning: ${residualUntranslated.length} values still look untranslated after retry. A resume report will capture these keys.`);
|
|
1000
1004
|
}
|
|
1001
1005
|
|
|
1002
1006
|
console.log(`[${fileName}] Writing output.`);
|
|
@@ -1092,7 +1096,15 @@ async function run(args) {
|
|
|
1092
1096
|
|
|
1093
1097
|
if (allResidualUntranslated.length > 0) {
|
|
1094
1098
|
console.warn(`WARNING: ${allResidualUntranslated.length} values still look untranslated after Auto Translate.`);
|
|
1095
|
-
|
|
1099
|
+
const residualReportPath = writeResidualReport(allResidualUntranslated, {
|
|
1100
|
+
sourceFile: sourceFiles.length === 1 ? sourceFiles[0] : `${sourceFiles.length} files`,
|
|
1101
|
+
targetLang: args.targetLang,
|
|
1102
|
+
});
|
|
1103
|
+
if (residualReportPath) {
|
|
1104
|
+
console.warn(`Auto Translate resume report written: ${residualReportPath}`);
|
|
1105
|
+
} else {
|
|
1106
|
+
console.warn('Auto Translate could not write the resume report. Review the values listed in the report output.');
|
|
1107
|
+
}
|
|
1096
1108
|
}
|
|
1097
1109
|
|
|
1098
1110
|
if (allSkippedKeys.length > 0 || allResidualUntranslated.length > 0 || args.reportFile || args.reportStdout) {
|