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.
- package/CHANGELOG.md +116 -29
- package/README.md +83 -18
- package/SECURITY.md +13 -5
- package/main/i18ntk-analyze.js +10 -20
- package/main/i18ntk-backup.js +227 -111
- package/main/i18ntk-init.js +153 -157
- package/main/i18ntk-scanner.js +9 -7
- package/main/i18ntk-setup.js +36 -13
- package/main/i18ntk-sizing.js +18 -50
- package/main/i18ntk-translate.js +169 -21
- package/main/i18ntk-usage.js +298 -154
- package/main/i18ntk-validate.js +49 -37
- package/main/manage/commands/AnalyzeCommand.js +7 -17
- package/main/manage/commands/CommandRouter.js +6 -6
- package/main/manage/commands/TranslateCommand.js +65 -56
- package/main/manage/commands/ValidateCommand.js +34 -26
- package/main/manage/index.js +11 -42
- package/main/manage/managers/InteractiveMenu.js +11 -40
- package/main/manage/services/InitService.js +114 -118
- package/main/manage/services/UsageService.js +244 -85
- package/package.json +55 -4
- package/runtime/enhanced.d.ts +5 -5
- package/runtime/enhanced.js +49 -25
- package/runtime/i18ntk.d.ts +30 -7
- package/runtime/index.d.ts +48 -19
- package/runtime/index.js +188 -97
- package/settings/settings-cli.js +115 -38
- package/settings/settings-manager.js +24 -6
- package/ui-locales/de.json +192 -11
- package/ui-locales/en.json +182 -8
- package/ui-locales/es.json +193 -12
- package/ui-locales/fr.json +189 -8
- package/ui-locales/ja.json +190 -8
- package/ui-locales/ru.json +191 -9
- package/ui-locales/zh.json +194 -9
- package/utils/cli-helper.js +8 -12
- package/utils/config-helper.js +1 -1
- package/utils/config-manager.js +8 -6
- package/utils/localized-confirm.js +55 -0
- package/utils/menu-layout.js +41 -0
- package/utils/report-writer.js +110 -0
- package/utils/security.js +15 -22
- package/utils/translate/api.js +31 -3
- package/utils/translate/placeholder.js +42 -1
- package/utils/translate/protection.js +17 -12
- package/utils/translate/report.js +3 -2
- package/utils/translate/safe-network.js +24 -4
- package/utils/usage-insights.js +435 -0
- package/utils/usage-source.js +50 -0
- package/utils/watch-locales.js +13 -9
package/main/i18ntk-backup.js
CHANGED
|
@@ -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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}
|
|
287
|
-
|
|
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
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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 =
|
|
492
|
-
|
|
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
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
logger.info(
|
|
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:');
|