i18ntk 3.3.0 → 4.1.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 +84 -16
- package/README.md +160 -15
- package/SECURITY.md +16 -8
- package/main/i18ntk-backup.js +370 -73
- package/main/i18ntk-scanner.js +190 -49
- package/main/i18ntk-sizing.js +241 -79
- package/main/i18ntk-usage.js +221 -46
- package/main/i18ntk-validate.js +114 -5
- package/main/manage/commands/FixerCommand.js +23 -21
- package/main/manage/index.js +13 -7
- package/main/manage/services/FileManagementService.js +12 -6
- package/package.json +46 -2
- package/runtime/i18ntk.d.ts +22 -16
- package/runtime/index.d.ts +9 -7
- package/runtime/index.js +246 -50
- package/ui-locales/en.json +1 -1
- package/utils/translate/protection.js +153 -7
- package/utils/watch-locales.js +194 -36
package/main/i18ntk-backup.js
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
'use strict';
|
|
4
4
|
|
|
5
|
-
const fs = require('fs');
|
|
6
|
-
const fsp = fs.promises;
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const fsp = fs.promises;
|
|
7
7
|
const path = require('path');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
const SecurityUtils = require('../utils/security');
|
|
8
10
|
|
|
9
11
|
// Simple CLI argument parser
|
|
10
12
|
function parseArgs(args) {
|
|
@@ -37,6 +39,150 @@ function parseArgs(args) {
|
|
|
37
39
|
|
|
38
40
|
return result;
|
|
39
41
|
}
|
|
42
|
+
function handleError(error) {
|
|
43
|
+
logger.error(error.message || 'Unknown error');
|
|
44
|
+
logger.debug(error.stack || error.message);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function computeFileHash(filePath) {
|
|
49
|
+
const content = SecurityUtils.safeReadFileSync(filePath, path.dirname(filePath));
|
|
50
|
+
if (content === null) return null;
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(content);
|
|
53
|
+
const normalized = JSON.stringify(parsed);
|
|
54
|
+
return crypto.createHash('sha256').update(normalized).digest('hex');
|
|
55
|
+
} catch {
|
|
56
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function computeContentHash(content) {
|
|
61
|
+
if (typeof content === 'object' && content !== null) {
|
|
62
|
+
const normalized = JSON.stringify(content);
|
|
63
|
+
return crypto.createHash('sha256').update(normalized).digest('hex');
|
|
64
|
+
}
|
|
65
|
+
const str = String(content);
|
|
66
|
+
return crypto.createHash('sha256').update(str).digest('hex');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function findMostRecentBackup(backupDirPath) {
|
|
70
|
+
try {
|
|
71
|
+
const files = await fsp.readdir(backupDirPath);
|
|
72
|
+
const backups = [];
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
if (file.startsWith('backup-') && file.endsWith('.json')) {
|
|
75
|
+
const filePath = path.join(backupDirPath, file);
|
|
76
|
+
const stats = await fsp.stat(filePath);
|
|
77
|
+
backups.push({ name: file, path: filePath, mtime: stats.mtime });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
backups.sort((a, b) => b.mtime - a.mtime);
|
|
81
|
+
return backups[0] || null;
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (err.code === 'ENOENT') return null;
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getParentHashes(parentData) {
|
|
89
|
+
if (parentData._meta && parentData._meta.hashes) {
|
|
90
|
+
return parentData._meta.hashes;
|
|
91
|
+
}
|
|
92
|
+
const hashes = {};
|
|
93
|
+
for (const [file, content] of Object.entries(parentData)) {
|
|
94
|
+
if (typeof content === 'object' && content !== null) {
|
|
95
|
+
hashes[file] = computeContentHash(content);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return hashes;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function buildRestoreChain(startPath, startData) {
|
|
102
|
+
const chain = [{ path: startPath, data: startData }];
|
|
103
|
+
const visited = new Set();
|
|
104
|
+
visited.add(path.basename(startPath));
|
|
105
|
+
let current = startData;
|
|
106
|
+
let currentPath = startPath;
|
|
107
|
+
|
|
108
|
+
while (current._meta && current._meta.parent) {
|
|
109
|
+
if (chain.length >= 11) {
|
|
110
|
+
throw new Error('Chain broken: incremental backup chain exceeds the maximum depth of 10');
|
|
111
|
+
}
|
|
112
|
+
const parentName = current._meta.parent;
|
|
113
|
+
if (visited.has(parentName)) {
|
|
114
|
+
throw new Error('circular chain reference');
|
|
115
|
+
}
|
|
116
|
+
visited.add(parentName);
|
|
117
|
+
const parentDir = path.dirname(currentPath);
|
|
118
|
+
const parentPath = path.join(parentDir, parentName);
|
|
119
|
+
if (!SecurityUtils.safeExistsSync(parentPath, parentDir)) {
|
|
120
|
+
throw new Error(`Chain broken: parent backup '${parentName}' not found in ${parentDir}`);
|
|
121
|
+
}
|
|
122
|
+
const parentRaw = SecurityUtils.safeReadFileSync(parentPath, parentDir, 'utf8');
|
|
123
|
+
if (parentRaw === null) {
|
|
124
|
+
throw new Error(`Chain broken: cannot read parent backup '${parentName}'`);
|
|
125
|
+
}
|
|
126
|
+
current = JSON.parse(parentRaw);
|
|
127
|
+
currentPath = parentPath;
|
|
128
|
+
chain.push({ path: currentPath, data: current });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
chain.reverse();
|
|
132
|
+
return chain;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function readBackupData(backupPath, baseDir) {
|
|
136
|
+
const raw = SecurityUtils.safeReadFileSync(backupPath, baseDir, 'utf8');
|
|
137
|
+
if (raw === null) {
|
|
138
|
+
throw new Error(`Unable to read backup: ${path.basename(backupPath)}`);
|
|
139
|
+
}
|
|
140
|
+
return JSON.parse(raw);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function collectProtectedChainNames(backupDirPath, keptFiles) {
|
|
144
|
+
const protectedNames = new Set();
|
|
145
|
+
const byName = new Map();
|
|
146
|
+
for (const file of keptFiles) {
|
|
147
|
+
byName.set(file.name, file);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const queue = [];
|
|
151
|
+
for (const file of keptFiles) {
|
|
152
|
+
try {
|
|
153
|
+
const data = readBackupData(file.path, backupDirPath);
|
|
154
|
+
if (data._meta && data._meta.parent) {
|
|
155
|
+
queue.push(data._meta.parent);
|
|
156
|
+
}
|
|
157
|
+
} catch {}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
while (queue.length > 0) {
|
|
161
|
+
const name = queue.shift();
|
|
162
|
+
if (protectedNames.has(name)) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
protectedNames.add(name);
|
|
166
|
+
|
|
167
|
+
const file = byName.get(name) || {
|
|
168
|
+
name,
|
|
169
|
+
path: path.join(backupDirPath, name)
|
|
170
|
+
};
|
|
171
|
+
if (!SecurityUtils.safeExistsSync(file.path, backupDirPath)) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
const data = readBackupData(file.path, backupDirPath);
|
|
177
|
+
if (data._meta && data._meta.parent) {
|
|
178
|
+
queue.push(data._meta.parent);
|
|
179
|
+
}
|
|
180
|
+
} catch {}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return protectedNames;
|
|
184
|
+
}
|
|
185
|
+
|
|
40
186
|
const configManager = require('../utils/config-manager');
|
|
41
187
|
const { logger } = require('../utils/logger');
|
|
42
188
|
const { colors } = require('../utils/logger');
|
|
@@ -103,25 +249,59 @@ Options:
|
|
|
103
249
|
--output <path> Output directory for backup/restore
|
|
104
250
|
--force Overwrite existing files without prompting
|
|
105
251
|
--keep <number> Number of backups to keep (default: 10)
|
|
252
|
+
--incremental Create an incremental backup (only changed files)
|
|
106
253
|
`);
|
|
107
254
|
}
|
|
108
255
|
|
|
109
256
|
// Command handlers
|
|
257
|
+
async function cleanupOldBackups(backupDirPath) {
|
|
258
|
+
try {
|
|
259
|
+
const files = await fsp.readdir(backupDirPath);
|
|
260
|
+
const backupFiles = files
|
|
261
|
+
.filter(file => file.startsWith('backup-') && file.endsWith('.json'))
|
|
262
|
+
.map(file => ({
|
|
263
|
+
name: file,
|
|
264
|
+
path: path.join(backupDirPath, file),
|
|
265
|
+
time: fs.statSync(path.join(backupDirPath, file)).mtime.getTime()
|
|
266
|
+
}))
|
|
267
|
+
.sort((a, b) => b.time - a.time);
|
|
268
|
+
|
|
269
|
+
const toDelete = backupFiles.slice(maxBackups);
|
|
270
|
+
const kept = backupFiles.slice(0, maxBackups);
|
|
271
|
+
|
|
272
|
+
const protectedChainNames = collectProtectedChainNames(backupDirPath, kept);
|
|
273
|
+
|
|
274
|
+
for (const file of toDelete) {
|
|
275
|
+
if (protectedChainNames.has(file.name)) {
|
|
276
|
+
logger.info(` Keeping ${file.name} (parent of a kept incremental backup)`);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
await fsp.unlink(file.path);
|
|
281
|
+
} catch (err) {
|
|
282
|
+
logger.warn(`Could not remove old backup ${file.name}: ${err.message}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
} catch (err) {
|
|
286
|
+
if (err.code !== 'ENOENT') {
|
|
287
|
+
logger.warn(`Error during cleanup: ${err.message}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
110
292
|
async function handleCreate(args) {
|
|
111
|
-
// Use absolute path for the locales directory
|
|
112
293
|
const dir = args._[1] || path.join(__dirname, '..', 'locales');
|
|
113
294
|
const outputDir = args.output || backupDir;
|
|
114
295
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
115
296
|
const backupName = `backup-${timestamp}.json`;
|
|
116
297
|
const backupPath = path.join(outputDir, backupName);
|
|
298
|
+
const isIncremental = args.incremental !== 'false' && args.incremental !== false;
|
|
117
299
|
|
|
118
|
-
// Log the paths for debugging
|
|
119
300
|
logger.debug(`Source directory: ${dir}`);
|
|
120
301
|
logger.debug(`Backup will be saved to: ${backupPath}`);
|
|
121
302
|
|
|
122
|
-
// Create backup directory if it doesn't exist
|
|
123
303
|
try {
|
|
124
|
-
await fsp.mkdir(outputDir, { recursive: true });
|
|
304
|
+
await fsp.mkdir(outputDir, { recursive: true });
|
|
125
305
|
logger.debug(`Created backup directory: ${outputDir}`);
|
|
126
306
|
} catch (err) {
|
|
127
307
|
if (err.code !== 'EEXIST') {
|
|
@@ -131,10 +311,9 @@ async function handleCreate(args) {
|
|
|
131
311
|
logger.debug(`Using existing backup directory: ${outputDir}`);
|
|
132
312
|
}
|
|
133
313
|
|
|
134
|
-
// Validate directory
|
|
135
314
|
const sourceDir = path.resolve(dir);
|
|
136
315
|
try {
|
|
137
|
-
const stats = await fsp.stat(sourceDir);
|
|
316
|
+
const stats = await fsp.stat(sourceDir);
|
|
138
317
|
if (!stats.isDirectory()) {
|
|
139
318
|
throw new Error(`Path exists but is not a directory: ${sourceDir}`);
|
|
140
319
|
}
|
|
@@ -147,41 +326,91 @@ async function handleCreate(args) {
|
|
|
147
326
|
}
|
|
148
327
|
|
|
149
328
|
logger.info('\nCreating backup...');
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const files = (await fsp.readdir(sourceDir, { withFileTypes: true }))
|
|
329
|
+
|
|
330
|
+
const files = (await fsp.readdir(sourceDir, { withFileTypes: true }))
|
|
153
331
|
.filter(dirent => dirent.isFile() && dirent.name.endsWith('.json'))
|
|
154
332
|
.map(dirent => dirent.name);
|
|
155
|
-
|
|
333
|
+
|
|
156
334
|
if (files.length === 0) {
|
|
157
335
|
logger.warn('No JSON files found in the specified directory');
|
|
158
336
|
process.exit(0);
|
|
159
337
|
}
|
|
160
|
-
|
|
161
|
-
// Read all translation files
|
|
338
|
+
|
|
162
339
|
const translations = {};
|
|
340
|
+
const hashes = {};
|
|
163
341
|
for (const file of files) {
|
|
164
342
|
const filePath = path.join(sourceDir, file);
|
|
165
343
|
try {
|
|
166
|
-
const
|
|
167
|
-
|
|
344
|
+
const rawContent = SecurityUtils.safeReadFileSync(filePath, path.dirname(filePath), 'utf8');
|
|
345
|
+
if (rawContent === null) {
|
|
346
|
+
logger.error(`Could not read file ${file}`);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
translations[file] = JSON.parse(rawContent);
|
|
350
|
+
hashes[file] = computeFileHash(filePath);
|
|
168
351
|
} catch (error) {
|
|
169
352
|
logger.error(`Could not read file ${file}: ${error.message}`);
|
|
170
353
|
}
|
|
171
354
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
355
|
+
|
|
356
|
+
let meta = {
|
|
357
|
+
type: 'full',
|
|
358
|
+
parent: null,
|
|
359
|
+
chainDepth: 0,
|
|
360
|
+
hashes: hashes
|
|
361
|
+
};
|
|
362
|
+
let filesToInclude = translations;
|
|
363
|
+
|
|
364
|
+
if (isIncremental) {
|
|
365
|
+
const parentBackup = await findMostRecentBackup(outputDir);
|
|
366
|
+
if (parentBackup) {
|
|
367
|
+
const parentRaw = SecurityUtils.safeReadFileSync(parentBackup.path, path.dirname(parentBackup.path), 'utf8');
|
|
368
|
+
const parentData = JSON.parse(parentRaw);
|
|
369
|
+
const parentDepth = (parentData._meta && parentData._meta.chainDepth) || 0;
|
|
370
|
+
if (parentDepth >= 10) {
|
|
371
|
+
logger.info(' Incremental chain depth limit reached; creating a new full backup');
|
|
372
|
+
} else {
|
|
373
|
+
const parentHashes = getParentHashes(parentData);
|
|
374
|
+
|
|
375
|
+
const changedFiles = {};
|
|
376
|
+
for (const [file, content] of Object.entries(translations)) {
|
|
377
|
+
if (!parentHashes[file] || hashes[file] !== parentHashes[file]) {
|
|
378
|
+
changedFiles[file] = content;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
filesToInclude = changedFiles;
|
|
382
|
+
|
|
383
|
+
meta = {
|
|
384
|
+
type: 'incremental',
|
|
385
|
+
parent: parentBackup.name,
|
|
386
|
+
chainDepth: parentDepth + 1,
|
|
387
|
+
hashes: hashes
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
logger.info(` Incremental mode: found ${Object.keys(filesToInclude).length} changed files (parent: ${parentBackup.name}, depth: ${meta.chainDepth})`);
|
|
391
|
+
}
|
|
392
|
+
} else {
|
|
393
|
+
logger.info(' No previous backup found; creating full backup');
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const backupData = { _meta: meta };
|
|
398
|
+
for (const [file, content] of Object.entries(filesToInclude)) {
|
|
399
|
+
backupData[file] = content;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
await fsp.writeFile(backupPath, JSON.stringify(backupData, null, 2));
|
|
403
|
+
const stats = await fsp.stat(backupPath);
|
|
404
|
+
|
|
177
405
|
logger.success('Backup created successfully');
|
|
178
406
|
logger.info(` Location: ${backupPath}`);
|
|
179
407
|
logger.info(` Size: ${(stats.size / 1024).toFixed(2)} KB`);
|
|
180
408
|
logger.info(` Timestamp: ${new Date().toLocaleString()}`);
|
|
181
|
-
|
|
182
|
-
|
|
409
|
+
logger.info(` Files included: ${Object.keys(filesToInclude).length}`);
|
|
410
|
+
logger.info(` Type: ${meta.type}`);
|
|
411
|
+
|
|
183
412
|
await cleanupOldBackups(outputDir);
|
|
184
|
-
}
|
|
413
|
+
}
|
|
185
414
|
|
|
186
415
|
async function handleRestore(args) {
|
|
187
416
|
const backupFile = args._[1];
|
|
@@ -190,41 +419,54 @@ async function handleRestore(args) {
|
|
|
190
419
|
}
|
|
191
420
|
|
|
192
421
|
const backupPath = path.resolve(process.cwd(), backupFile);
|
|
193
|
-
const outputDir = args.output
|
|
194
|
-
? path.resolve(process.cwd(), args.output)
|
|
422
|
+
const outputDir = args.output
|
|
423
|
+
? path.resolve(process.cwd(), args.output)
|
|
195
424
|
: path.join(process.cwd(), 'restored');
|
|
196
|
-
|
|
197
|
-
// Validate backup file
|
|
425
|
+
|
|
198
426
|
if (!SecurityUtils.safeExistsSync(backupPath, process.cwd())) {
|
|
199
427
|
throw new Error(`Backup file not found: ${backupPath}`);
|
|
200
428
|
}
|
|
201
|
-
|
|
429
|
+
|
|
202
430
|
logger.info('\nRestoring backup...');
|
|
203
|
-
|
|
431
|
+
|
|
204
432
|
try {
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
433
|
+
const backupData = JSON.parse(await fsp.readFile(backupPath, 'utf8'));
|
|
434
|
+
const isIncremental = backupData._meta && backupData._meta.type === 'incremental';
|
|
435
|
+
|
|
436
|
+
if (isIncremental) {
|
|
437
|
+
const chain = await buildRestoreChain(backupPath, backupData);
|
|
438
|
+
await fsp.mkdir(outputDir, { recursive: true });
|
|
439
|
+
|
|
440
|
+
const restoredFiles = new Set();
|
|
441
|
+
for (const entry of chain) {
|
|
442
|
+
for (const [file, content] of Object.entries(entry.data)) {
|
|
443
|
+
if (file === '_meta') continue;
|
|
444
|
+
const filePath = path.join(outputDir, file);
|
|
445
|
+
SecurityUtils.safeWriteFileSync(filePath, JSON.stringify(content, null, 2), path.dirname(filePath), 'utf8');
|
|
446
|
+
restoredFiles.add(file);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
logger.success('Incremental backup restored successfully');
|
|
451
|
+
logger.info(` ${restoredFiles.size} files restored across ${chain.length} backup(s) to: ${outputDir}`);
|
|
452
|
+
} else {
|
|
453
|
+
await fsp.mkdir(outputDir, { recursive: true });
|
|
454
|
+
|
|
455
|
+
let count = 0;
|
|
456
|
+
for (const [file, content] of Object.entries(backupData)) {
|
|
457
|
+
if (file === '_meta') continue;
|
|
458
|
+
const filePath = path.join(outputDir, file);
|
|
459
|
+
SecurityUtils.safeWriteFileSync(filePath, JSON.stringify(content, null, 2), path.dirname(filePath), 'utf8');
|
|
460
|
+
count++;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
logger.success('Backup restored successfully');
|
|
464
|
+
logger.info(` Restored ${count} files to: ${outputDir}`);
|
|
220
465
|
}
|
|
221
|
-
|
|
222
|
-
logger.success('Backup restored successfully');
|
|
223
|
-
logger.info(` Restored ${Object.keys(translations).length} files to: ${outputDir}`);
|
|
224
466
|
} catch (error) {
|
|
225
467
|
handleError(error);
|
|
226
468
|
}
|
|
227
|
-
}
|
|
469
|
+
}
|
|
228
470
|
|
|
229
471
|
async function handleList() {
|
|
230
472
|
try {
|
|
@@ -298,32 +540,78 @@ async function handleVerify(args) {
|
|
|
298
540
|
}
|
|
299
541
|
|
|
300
542
|
const backupPath = path.resolve(process.cwd(), backupFile);
|
|
301
|
-
|
|
302
|
-
// Validate backup file
|
|
543
|
+
|
|
303
544
|
if (!SecurityUtils.safeExistsSync(backupPath, process.cwd())) {
|
|
304
545
|
throw new Error(`Backup file not found: ${backupPath}`);
|
|
305
546
|
}
|
|
306
|
-
|
|
547
|
+
|
|
307
548
|
logger.info('\nVerifying backup...');
|
|
308
|
-
|
|
549
|
+
|
|
309
550
|
try {
|
|
310
|
-
const data = await fsp.readFile(backupPath, 'utf8');
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
551
|
+
const data = JSON.parse(await fsp.readFile(backupPath, 'utf8'));
|
|
552
|
+
|
|
553
|
+
if (data._meta && data._meta.hashes) {
|
|
554
|
+
logger.info(' Performing hash chain verification...');
|
|
555
|
+
|
|
556
|
+
const chain = await buildRestoreChain(backupPath, data);
|
|
557
|
+
|
|
558
|
+
let allValid = true;
|
|
559
|
+
|
|
560
|
+
// Rebuild full state oldest->newest and verify each manifest against it.
|
|
561
|
+
const reconstructed = {};
|
|
562
|
+
for (const entry of chain) {
|
|
563
|
+
const entryMeta = entry.data._meta;
|
|
564
|
+
const entryHashes = entryMeta ? entryMeta.hashes : null;
|
|
565
|
+
const entryName = path.basename(entry.path);
|
|
566
|
+
|
|
567
|
+
for (const [file, content] of Object.entries(entry.data)) {
|
|
568
|
+
if (file !== '_meta') reconstructed[file] = content;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (!entryHashes) {
|
|
572
|
+
logger.warn(` ${entryName}: no manifest hashes (legacy backup)`);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
let entryValid = true;
|
|
577
|
+
for (const [file, expectedHash] of Object.entries(entryHashes)) {
|
|
578
|
+
if (!Object.prototype.hasOwnProperty.call(reconstructed, file)) {
|
|
579
|
+
logger.warn(` Missing file in reconstructed backup state: ${file}`);
|
|
580
|
+
entryValid = false;
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
const computedHash = computeContentHash(reconstructed[file]);
|
|
584
|
+
if (computedHash !== expectedHash) {
|
|
585
|
+
logger.error(` Hash mismatch: ${file} (expected ${expectedHash.slice(0, 12)}..., got ${computedHash.slice(0, 12)}...)`);
|
|
586
|
+
entryValid = false;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (entryValid) {
|
|
591
|
+
logger.success(` ${entryName}: ${Object.keys(entryHashes).length} file(s) verified`);
|
|
592
|
+
} else {
|
|
593
|
+
allValid = false;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (allValid) {
|
|
598
|
+
logger.success('\nBackup chain verification passed');
|
|
599
|
+
} else {
|
|
600
|
+
logger.error('\nBackup chain verification FAILED');
|
|
601
|
+
process.exitCode = 1;
|
|
602
|
+
}
|
|
603
|
+
} else {
|
|
604
|
+
const fileCount = Object.keys(data).filter(k => k !== '_meta').length;
|
|
315
605
|
logger.success('Backup is valid');
|
|
316
606
|
logger.info(` Contains ${fileCount} translation files`);
|
|
317
|
-
logger.info(` Last modified: ${(await fsp.stat(backupPath)).mtime.toLocaleString()}`);
|
|
318
|
-
} else {
|
|
319
|
-
throw new Error('Invalid backup format');
|
|
607
|
+
logger.info(` Last modified: ${(await fsp.stat(backupPath)).mtime.toLocaleString()}`);
|
|
320
608
|
}
|
|
321
609
|
} catch (error) {
|
|
322
610
|
logger.error('Backup verification failed!');
|
|
323
611
|
logger.error(` Error: ${error.message}`);
|
|
324
612
|
process.exit(1);
|
|
325
613
|
}
|
|
326
|
-
}
|
|
614
|
+
}
|
|
327
615
|
|
|
328
616
|
async function handleCleanup(args) {
|
|
329
617
|
const keep = args.keep ? parseInt(args.keep, 10) : maxBackups;
|
|
@@ -343,24 +631,33 @@ async function handleCleanup(args) {
|
|
|
343
631
|
|
|
344
632
|
// Keep only the most recent 'keep' files
|
|
345
633
|
const toDelete = backupFiles.slice(keep);
|
|
634
|
+
const kept = backupFiles.slice(0, keep);
|
|
346
635
|
|
|
347
636
|
if (toDelete.length === 0) {
|
|
348
637
|
logger.info('No old backups to delete.');
|
|
349
638
|
return;
|
|
350
639
|
}
|
|
351
640
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
641
|
+
const protectedChainNames = collectProtectedChainNames(backupDir, kept);
|
|
642
|
+
let deletedCount = 0;
|
|
643
|
+
|
|
644
|
+
// Delete old backups, skipping parents of kept backups
|
|
645
|
+
for (const file of toDelete) {
|
|
646
|
+
if (protectedChainNames.has(file.name)) {
|
|
647
|
+
logger.info(` Keeping ${file.name} (parent of a kept incremental backup)`);
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
try {
|
|
355
651
|
await fsp.unlink(file.path);
|
|
356
|
-
logger.info(` - Deleted: ${file.name}`);
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
logger.info(
|
|
652
|
+
logger.info(` - Deleted: ${file.name}`);
|
|
653
|
+
deletedCount++;
|
|
654
|
+
} catch (err) {
|
|
655
|
+
logger.error(` - Failed to delete ${file.name}: ${err.message}`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
logger.info(`\nRemoved ${deletedCount} old backups`);
|
|
660
|
+
logger.info(`Total backups kept: ${keep}`);
|
|
364
661
|
|
|
365
662
|
} catch (error) {
|
|
366
663
|
logger.error('Error cleaning up backups:');
|