@abdess76/i18nkit 1.0.3 → 1.0.5

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.
@@ -8,7 +8,7 @@
8
8
 
9
9
  const fs = require('./fs-adapter');
10
10
  const path = require('path');
11
- const { createBackup } = require('./backup');
11
+ const { withBackup } = require('./backup');
12
12
  const {
13
13
  groupFindingsByFile,
14
14
  confirmFileModifications,
@@ -70,13 +70,12 @@ function buildFileResult(ctx) {
70
70
  }
71
71
 
72
72
  function processFileReplacements(relativeFile, fileFindings, opts) {
73
- const { srcDir, backupDir, backup = true, dryRun = false, verbose = false, adapter } = opts;
73
+ const { srcDir, verbose = false, adapter } = opts;
74
74
  const filePath = path.join(srcDir, relativeFile);
75
75
  const originalContent = loadFileForReplacement(filePath, relativeFile, verbose);
76
76
  if (!originalContent) {
77
77
  return null;
78
78
  }
79
- createBackup(filePath, backupDir, { enabled: backup, dryRun });
80
79
  const { content: replaced, count } = applyReplacementsToContent(
81
80
  originalContent,
82
81
  fileFindings,
@@ -125,6 +124,90 @@ function processAllFiles(findingsByFile, opts) {
125
124
  return { totalFiles, totalReplacements, modifiedFiles };
126
125
  }
127
126
 
127
+ function getFilesToBackup(findingsByFile, opts) {
128
+ const { srcDir } = opts;
129
+ const files = [];
130
+ for (const [relativeFile] of findingsByFile) {
131
+ const filePath = path.join(srcDir, relativeFile);
132
+ if (fs.existsSync(filePath)) {
133
+ files.push(filePath);
134
+ }
135
+ }
136
+ return files;
137
+ }
138
+
139
+ function backupFilesForSession(filesToBackup, sessionHelpers) {
140
+ for (const filePath of filesToBackup) {
141
+ if (sessionHelpers?.backupFile) {
142
+ sessionHelpers.backupFile(filePath);
143
+ }
144
+ }
145
+ }
146
+
147
+ function saveReportIfNeeded(sessionHelpers, reportPath) {
148
+ if (reportPath && sessionHelpers?.saveReport) {
149
+ sessionHelpers.saveReport(reportPath);
150
+ }
151
+ }
152
+
153
+ function transitionSessionState(sessionHelpers) {
154
+ if (sessionHelpers?.markReady) {
155
+ sessionHelpers.markReady();
156
+ }
157
+ if (sessionHelpers?.beginModifications) {
158
+ sessionHelpers.beginModifications();
159
+ }
160
+ }
161
+
162
+ function prepareSession(sessionHelpers, reportPath) {
163
+ saveReportIfNeeded(sessionHelpers, reportPath);
164
+ transitionSessionState(sessionHelpers);
165
+ }
166
+
167
+ function createApplyExecutor(ctx) {
168
+ const { filesToBackup, reportPath, findingsByFile, opts } = ctx;
169
+ return sessionHelpers => {
170
+ backupFilesForSession(filesToBackup, sessionHelpers);
171
+ prepareSession(sessionHelpers, reportPath);
172
+ const results = processAllFiles(findingsByFile, opts);
173
+ logApplyResults(results, opts);
174
+ return { success: true, ...results };
175
+ };
176
+ }
177
+
178
+ function logApplyHeader(log) {
179
+ log('Auto-Apply Translations');
180
+ log('-'.repeat(50));
181
+ }
182
+
183
+ function buildApplyContext(findingsByFile, opts) {
184
+ const cwd = process.cwd();
185
+ const { reportDir } = opts;
186
+ const filesToBackup = getFilesToBackup(findingsByFile, opts);
187
+ const reportPath = reportDir ? path.join(reportDir, 'report.json') : null;
188
+ return { cwd, filesToBackup, reportPath, findingsByFile, opts };
189
+ }
190
+
191
+ async function confirmAndPrepare(findings, opts) {
192
+ const { log = console.log, interactive = false } = opts;
193
+ logApplyHeader(log);
194
+ const findingsByFile = groupFindingsByFile(findings);
195
+ const shouldProceed = await confirmFileModifications(findingsByFile, interactive, log);
196
+ return shouldProceed ? findingsByFile : null;
197
+ }
198
+
199
+ function executeApplyWithBackup(findingsByFile, opts) {
200
+ const { log = console.log, backup = true, dryRun = false } = opts;
201
+ const ctx = buildApplyContext(findingsByFile, opts);
202
+ const executeApply = createApplyExecutor(ctx);
203
+ return withBackup({
204
+ cwd: ctx.cwd,
205
+ command: 'apply',
206
+ operation: executeApply,
207
+ options: { log, backup, dryRun },
208
+ });
209
+ }
210
+
128
211
  /**
129
212
  * Applies translation replacements from findings array
130
213
  * @param {Finding[]} findings
@@ -132,17 +215,10 @@ function processAllFiles(findingsByFile, opts) {
132
215
  * @returns {Promise<ApplyResult>}
133
216
  */
134
217
  async function applyFindings(findings, opts = {}) {
135
- const { log = console.log, interactive = false } = opts;
136
- log('Auto-Apply Translations');
137
- log('-'.repeat(50));
138
- const findingsByFile = groupFindingsByFile(findings);
139
- const shouldProceed = await confirmFileModifications(findingsByFile, interactive, log);
140
- if (!shouldProceed) {
141
- return { success: false, aborted: true };
142
- }
143
- const results = processAllFiles(findingsByFile, opts);
144
- logApplyResults(results, opts);
145
- return { success: true, ...results };
218
+ const findingsByFile = await confirmAndPrepare(findings, opts);
219
+ return findingsByFile ?
220
+ executeApplyWithBackup(findingsByFile, opts)
221
+ : { success: false, aborted: true };
146
222
  }
147
223
 
148
224
  /**
package/bin/core/args.js CHANGED
@@ -98,6 +98,11 @@ const COMMAND_ALIASES = {
98
98
  '--find-orphans': 'find-orphans',
99
99
  '--check-sync': 'check-sync',
100
100
  '--watch': 'watch',
101
+ '--list-backups': 'backup',
102
+ '--restore': 'backup',
103
+ '--cleanup-backups': 'backup',
104
+ '--backup-info': 'backup',
105
+ '--init-backups': 'backup',
101
106
  };
102
107
 
103
108
  const POSITIONAL_COMMANDS = [
@@ -107,6 +112,15 @@ const POSITIONAL_COMMANDS = [
107
112
  'apply',
108
113
  'watch',
109
114
  'extract',
115
+ 'backup',
116
+ ];
117
+
118
+ const BACKUP_FLAGS = [
119
+ '--list-backups',
120
+ '--restore',
121
+ '--cleanup-backups',
122
+ '--backup-info',
123
+ '--init-backups',
110
124
  ];
111
125
 
112
126
  function detectFromFlags(args) {
@@ -116,6 +130,9 @@ function detectFromFlags(args) {
116
130
  if (args.some(a => a.startsWith('--apply'))) {
117
131
  return 'apply';
118
132
  }
133
+ if (args.some(a => BACKUP_FLAGS.includes(a))) {
134
+ return 'backup';
135
+ }
119
136
  const flagCmd = args.find(a => COMMAND_ALIASES[a]);
120
137
  return flagCmd ? COMMAND_ALIASES[flagCmd] : null;
121
138
  }
@@ -0,0 +1,176 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview Backup session cleanup and retention management.
5
+ * @module backup/cleanup
6
+ */
7
+
8
+ const { Session, parseSessionId } = require('./session');
9
+ const { removeDirSync } = require('./file-ops');
10
+ const { DEFAULT_CONFIG, getSessionDir, SessionStatus } = require('./constants');
11
+
12
+ /**
13
+ * Returns session age in days
14
+ * @param {string} sessionId
15
+ * @returns {number}
16
+ */
17
+ function getSessionAge(sessionId) {
18
+ const parsed = parseSessionId(sessionId);
19
+ if (!parsed) {
20
+ return Infinity;
21
+ }
22
+ const now = new Date();
23
+ const diff = now - parsed.timestamp;
24
+ return Math.floor(diff / (1000 * 60 * 60 * 24));
25
+ }
26
+
27
+ function shouldKeepSession(session) {
28
+ return (
29
+ session.status === SessionStatus.IN_PROGRESS || session.status === SessionStatus.BACKING_UP
30
+ );
31
+ }
32
+
33
+ function isWithinLimits(index, age, config) {
34
+ return index < config.maxSessions && age <= config.maxAgeDays;
35
+ }
36
+
37
+ function categorizeSession(session, index, config) {
38
+ const age = getSessionAge(session.id);
39
+ return { session, keep: isWithinLimits(index, age, config) };
40
+ }
41
+
42
+ /**
43
+ * @param {Object[]} sessions
44
+ * @param {Object} config
45
+ * @returns {{keep: Object[], delete: Object[]}}
46
+ */
47
+ function selectSessionsForCleanup(sessions, config) {
48
+ const protectedSessions = sessions.filter(shouldKeepSession);
49
+ const cleanableSessions = sessions.filter(s => !shouldKeepSession(s));
50
+ const categorized = cleanableSessions.map((s, i) => categorizeSession(s, i, config));
51
+ const toKeep = categorized.filter(c => c.keep).map(c => c.session);
52
+ const toDelete = categorized.filter(c => !c.keep).map(c => c.session);
53
+ return { keep: [...protectedSessions, ...toKeep], delete: toDelete };
54
+ }
55
+
56
+ function logVerbose(verbose, log, message) {
57
+ if (verbose) {
58
+ log(message);
59
+ }
60
+ }
61
+
62
+ function deleteSession(sessionDir, sessionId, ctx) {
63
+ const { dryRun, verbose, log } = ctx;
64
+ if (dryRun) {
65
+ log(`Would delete: ${sessionId}`);
66
+ return false;
67
+ }
68
+ try {
69
+ removeDirSync(sessionDir);
70
+ logVerbose(verbose, log, `Deleted: ${sessionId}`);
71
+ return true;
72
+ } catch (err) {
73
+ logVerbose(verbose, log, `Failed to delete ${sessionId}: ${err.message}`);
74
+ return false;
75
+ }
76
+ }
77
+
78
+ function processDeletions(cwd, toDelete, ctx) {
79
+ return toDelete.map(session => {
80
+ const sessionDir = getSessionDir(cwd, session.id);
81
+ return deleteSession(sessionDir, session.id, ctx);
82
+ });
83
+ }
84
+
85
+ function handleEmptySessions(verbose, log) {
86
+ logVerbose(verbose, log, 'No backup sessions found');
87
+ return { deleted: 0, kept: 0 };
88
+ }
89
+
90
+ function handleNoDeletions(keep, verbose, log) {
91
+ logVerbose(verbose, log, `Keeping all ${keep.length} sessions`);
92
+ return { deleted: 0, kept: keep.length };
93
+ }
94
+
95
+ function executeCleanup(ctx) {
96
+ const { cwd, sessions, config, dryRun, verbose, log } = ctx;
97
+ const { keep, delete: toDelete } = selectSessionsForCleanup(sessions, config);
98
+ if (toDelete.length === 0) {
99
+ return handleNoDeletions(keep, verbose, log);
100
+ }
101
+ processDeletions(cwd, toDelete, { dryRun, verbose, log });
102
+ return { deleted: dryRun ? 0 : toDelete.length, kept: keep.length };
103
+ }
104
+
105
+ function loadSessions(cwd) {
106
+ const sessions = Session.listAll(cwd);
107
+ return sessions.length === 0 ? null : sessions;
108
+ }
109
+
110
+ function buildCleanupContext(cwd, options) {
111
+ const config = { ...DEFAULT_CONFIG, ...options };
112
+ const { dryRun = false, verbose = false, log = console.log } = options;
113
+ return { cwd, config, dryRun, verbose, log };
114
+ }
115
+
116
+ /**
117
+ * Removes sessions exceeding retention limits
118
+ * @param {string} cwd
119
+ * @param {Object} [options]
120
+ * @returns {{deleted: number, kept: number}}
121
+ */
122
+ function cleanupOldSessions(cwd, options = {}) {
123
+ const ctx = buildCleanupContext(cwd, options);
124
+ const sessions = loadSessions(cwd);
125
+ if (!sessions) {
126
+ return handleEmptySessions(ctx.verbose, ctx.log);
127
+ }
128
+ return executeCleanup({ ...ctx, sessions });
129
+ }
130
+
131
+ function mapSessionForPreview(session) {
132
+ return {
133
+ id: session.id,
134
+ age: getSessionAge(session.id),
135
+ status: session.status,
136
+ fileCount: session.fileCount,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Returns cleanup preview without executing
142
+ * @param {string} cwd
143
+ * @param {Object} [options]
144
+ * @returns {{sessions: Object[], toDelete: Object[], toKeep: Object[]}}
145
+ */
146
+ function getCleanupPreview(cwd, options = {}) {
147
+ const config = { ...DEFAULT_CONFIG, ...options };
148
+ const sessions = Session.listAll(cwd);
149
+ if (sessions.length === 0) {
150
+ return { sessions: [], toDelete: [], toKeep: [] };
151
+ }
152
+ const { keep, delete: toDelete } = selectSessionsForCleanup(sessions, config);
153
+ return {
154
+ sessions,
155
+ toDelete: toDelete.map(mapSessionForPreview),
156
+ toKeep: keep.map(mapSessionForPreview),
157
+ };
158
+ }
159
+
160
+ /**
161
+ * @param {string} cwd
162
+ * @param {Object} config
163
+ * @returns {{deleted: number, kept: number}|null}
164
+ */
165
+ function autoCleanupIfEnabled(cwd, config) {
166
+ if (!config.autoCleanup) {
167
+ return null;
168
+ }
169
+ return cleanupOldSessions(cwd, { ...config, verbose: false });
170
+ }
171
+
172
+ module.exports = {
173
+ cleanupOldSessions,
174
+ getCleanupPreview,
175
+ autoCleanupIfEnabled,
176
+ };
@@ -0,0 +1,114 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview Backup system constants and path utilities.
5
+ * @module backup/constants
6
+ */
7
+
8
+ const path = require('path');
9
+
10
+ const BACKUP_ROOT = '.i18nkit';
11
+ const BACKUP_DIR = 'backups';
12
+ const LATEST_FILE = 'latest.txt';
13
+ const MANIFEST_FILE = 'manifest.json';
14
+ const STATUS_FILE = '.status';
15
+ const GITIGNORE_FILE = '.gitignore';
16
+
17
+ const SessionStatus = Object.freeze({
18
+ PENDING: 'pending',
19
+ BACKING_UP: 'backing-up',
20
+ READY: 'ready',
21
+ IN_PROGRESS: 'in-progress',
22
+ COMPLETED: 'completed',
23
+ ROLLED_BACK: 'rolled-back',
24
+ FAILED: 'failed',
25
+ });
26
+
27
+ const VALID_TRANSITIONS = Object.freeze({
28
+ [SessionStatus.PENDING]: [SessionStatus.BACKING_UP, SessionStatus.FAILED],
29
+ [SessionStatus.BACKING_UP]: [SessionStatus.READY, SessionStatus.FAILED],
30
+ [SessionStatus.READY]: [SessionStatus.IN_PROGRESS, SessionStatus.FAILED],
31
+ [SessionStatus.IN_PROGRESS]: [
32
+ SessionStatus.COMPLETED,
33
+ SessionStatus.ROLLED_BACK,
34
+ SessionStatus.FAILED,
35
+ ],
36
+ [SessionStatus.COMPLETED]: [],
37
+ [SessionStatus.ROLLED_BACK]: [],
38
+ [SessionStatus.FAILED]: [],
39
+ });
40
+
41
+ const DEFAULT_CONFIG = Object.freeze({
42
+ maxSessions: 10,
43
+ maxAgeDays: 30,
44
+ autoCleanup: true,
45
+ });
46
+
47
+ /**
48
+ * @param {string} cwd
49
+ * @returns {string}
50
+ */
51
+ function getBackupRoot(cwd) {
52
+ return path.join(cwd, BACKUP_ROOT);
53
+ }
54
+
55
+ /**
56
+ * @param {string} cwd
57
+ * @returns {string}
58
+ */
59
+ function getBackupsDir(cwd) {
60
+ return path.join(cwd, BACKUP_ROOT, BACKUP_DIR);
61
+ }
62
+
63
+ /**
64
+ * @param {string} cwd
65
+ * @returns {string}
66
+ */
67
+ function getLatestFile(cwd) {
68
+ return path.join(cwd, BACKUP_ROOT, BACKUP_DIR, LATEST_FILE);
69
+ }
70
+
71
+ /**
72
+ * @param {string} cwd
73
+ * @param {string} sessionId
74
+ * @returns {string}
75
+ */
76
+ function getSessionDir(cwd, sessionId) {
77
+ return path.join(cwd, BACKUP_ROOT, BACKUP_DIR, sessionId);
78
+ }
79
+
80
+ /**
81
+ * @param {string} cwd
82
+ * @param {string} sessionId
83
+ * @returns {string}
84
+ */
85
+ function getManifestPath(cwd, sessionId) {
86
+ return path.join(getSessionDir(cwd, sessionId), MANIFEST_FILE);
87
+ }
88
+
89
+ /**
90
+ * @param {string} cwd
91
+ * @param {string} sessionId
92
+ * @returns {string}
93
+ */
94
+ function getStatusPath(cwd, sessionId) {
95
+ return path.join(getSessionDir(cwd, sessionId), STATUS_FILE);
96
+ }
97
+
98
+ module.exports = {
99
+ BACKUP_ROOT,
100
+ BACKUP_DIR,
101
+ LATEST_FILE,
102
+ MANIFEST_FILE,
103
+ STATUS_FILE,
104
+ GITIGNORE_FILE,
105
+ SessionStatus,
106
+ VALID_TRANSITIONS,
107
+ DEFAULT_CONFIG,
108
+ getBackupRoot,
109
+ getBackupsDir,
110
+ getLatestFile,
111
+ getSessionDir,
112
+ getManifestPath,
113
+ getStatusPath,
114
+ };
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview Atomic file operations for backup system.
5
+ * @module backup/file-ops
6
+ */
7
+
8
+ const fs = require('../fs-adapter');
9
+ const nodeFsSync = require('fs');
10
+ const nodeFsPromises = require('fs').promises;
11
+ const path = require('path');
12
+ const crypto = require('crypto');
13
+
14
+ function generateTempPath(targetPath) {
15
+ const rand = crypto.randomBytes(4).toString('hex');
16
+ return `${targetPath}.${Date.now()}.${rand}.tmp`;
17
+ }
18
+
19
+ /**
20
+ * Writes content atomically via temp file + rename
21
+ * @param {string} targetPath
22
+ * @param {string} content
23
+ * @returns {Promise<void>}
24
+ */
25
+ async function atomicWrite(targetPath, content) {
26
+ const tempPath = generateTempPath(targetPath);
27
+ const dir = path.dirname(targetPath);
28
+ await fs.mkdir(dir, { recursive: true });
29
+ try {
30
+ await fs.writeFile(tempPath, content, 'utf-8');
31
+ await fs.rename(tempPath, targetPath);
32
+ } catch (err) {
33
+ try {
34
+ await fs.unlink(tempPath);
35
+ } catch {
36
+ // Cleanup failure is non-critical
37
+ }
38
+ throw err;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * @param {string} targetPath
44
+ * @param {string} content
45
+ */
46
+ function atomicWriteSync(targetPath, content) {
47
+ const tempPath = generateTempPath(targetPath);
48
+ const dir = path.dirname(targetPath);
49
+ fs.mkdirSync(dir, { recursive: true });
50
+ try {
51
+ fs.writeFileSync(tempPath, content, 'utf-8');
52
+ nodeFsSync.renameSync(tempPath, targetPath);
53
+ } catch (err) {
54
+ try {
55
+ nodeFsSync.unlinkSync(tempPath);
56
+ } catch {
57
+ // Cleanup failure is non-critical
58
+ }
59
+ throw err;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Copies file preserving directory structure relative to cwd
65
+ * @param {string} srcFile
66
+ * @param {string} destRoot
67
+ * @param {string} cwd
68
+ * @returns {Promise<{original: string, backup: string, size: number}>}
69
+ */
70
+ async function copyFilePreservingStructure(srcFile, destRoot, cwd) {
71
+ const relativePath = path.relative(cwd, srcFile);
72
+ const destPath = path.join(destRoot, relativePath);
73
+ const content = await fs.readFile(srcFile, 'utf-8');
74
+ await atomicWrite(destPath, content);
75
+ return {
76
+ original: relativePath,
77
+ backup: relativePath,
78
+ size: Buffer.byteLength(content, 'utf-8'),
79
+ };
80
+ }
81
+
82
+ /**
83
+ * @param {string} srcFile
84
+ * @param {string} destRoot
85
+ * @param {string} cwd
86
+ * @returns {{original: string, backup: string, size: number}}
87
+ */
88
+ function copyFilePreservingStructureSync(srcFile, destRoot, cwd) {
89
+ const relativePath = path.relative(cwd, srcFile);
90
+ const destPath = path.join(destRoot, relativePath);
91
+ const content = fs.readFileSync(srcFile, 'utf-8');
92
+ atomicWriteSync(destPath, content);
93
+ return {
94
+ original: relativePath,
95
+ backup: relativePath,
96
+ size: Buffer.byteLength(content, 'utf-8'),
97
+ };
98
+ }
99
+
100
+ /**
101
+ * @param {string} backupPath
102
+ * @param {string} originalPath
103
+ * @returns {Promise<void>}
104
+ */
105
+ async function restoreFile(backupPath, originalPath, _cwd) {
106
+ const content = await fs.readFile(backupPath, 'utf-8');
107
+ await atomicWrite(originalPath, content);
108
+ }
109
+
110
+ /**
111
+ * @param {string} backupPath
112
+ * @param {string} originalPath
113
+ */
114
+ function restoreFileSync(backupPath, originalPath) {
115
+ const content = fs.readFileSync(backupPath, 'utf-8');
116
+ atomicWriteSync(originalPath, content);
117
+ }
118
+
119
+ /**
120
+ * @param {string} dirPath
121
+ * @returns {Promise<void>}
122
+ */
123
+ async function ensureDir(dirPath) {
124
+ await fs.mkdir(dirPath, { recursive: true });
125
+ }
126
+
127
+ /**
128
+ * @param {string} dirPath
129
+ */
130
+ function ensureDirSync(dirPath) {
131
+ fs.mkdirSync(dirPath, { recursive: true });
132
+ }
133
+
134
+ /**
135
+ * @param {string} dirPath
136
+ * @returns {Promise<void>}
137
+ */
138
+ async function removeDir(dirPath) {
139
+ await nodeFsPromises.rm(dirPath, { recursive: true, force: true });
140
+ }
141
+
142
+ /**
143
+ * @param {string} dirPath
144
+ */
145
+ function removeDirSync(dirPath) {
146
+ nodeFsSync.rmSync(dirPath, { recursive: true, force: true });
147
+ }
148
+
149
+ /**
150
+ * @param {string} parentDir
151
+ * @returns {Promise<string[]>}
152
+ */
153
+ async function listDirs(parentDir) {
154
+ try {
155
+ const entries = await fs.readdir(parentDir, { withFileTypes: true });
156
+ return entries.filter(e => e.isDirectory()).map(e => e.name);
157
+ } catch {
158
+ return [];
159
+ }
160
+ }
161
+
162
+ /**
163
+ * @param {string} parentDir
164
+ * @returns {string[]}
165
+ */
166
+ function listDirsSync(parentDir) {
167
+ try {
168
+ const entries = fs.readdirSync(parentDir, { withFileTypes: true });
169
+ return entries.filter(e => e.isDirectory()).map(e => e.name);
170
+ } catch {
171
+ return [];
172
+ }
173
+ }
174
+
175
+ module.exports = {
176
+ atomicWrite,
177
+ atomicWriteSync,
178
+ copyFilePreservingStructure,
179
+ copyFilePreservingStructureSync,
180
+ restoreFile,
181
+ restoreFileSync,
182
+ ensureDir,
183
+ ensureDirSync,
184
+ removeDir,
185
+ removeDirSync,
186
+ listDirs,
187
+ listDirsSync,
188
+ };