filemayor 3.6.0 → 4.0.7

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/core/organizer.js CHANGED
@@ -1,414 +1,414 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * ═══════════════════════════════════════════════════════════════════
5
- * FILEMAYOR CORE — ORGANIZER
6
- * Intelligent file organization with dry-run, rollback journal,
7
- * naming conventions, duplicate detection, and batch operations.
8
- * ═══════════════════════════════════════════════════════════════════
9
- */
10
-
11
- 'use strict';
12
-
13
- const fs = require('fs');
14
- const path = require('path');
15
- const { categorize, getCategories } = require('./categories');
16
- const { scan } = require('./scanner');
17
- const {
18
- validatePath, isFileSafe, sanitizeFilename, canWrite, createSnapshot,
19
- findNearestSubstrate
20
- } = require('./security');
21
- const { formatBytes } = require('./scanner');
22
-
23
- // ─── Naming Conventions ───────────────────────────────────────────
24
-
25
- const NAMING_CONVENTIONS = {
26
- /**
27
- * Category prefix: BIZ_001_Document.pdf
28
- */
29
- category_prefix: (file, category, index) => {
30
- const prefix = category.toUpperCase().slice(0, 4);
31
- const padded = String(index + 1).padStart(3, '0');
32
- const cleanName = cleanFilename(file.name);
33
- return `${prefix}_${padded}_${cleanName}`;
34
- },
35
-
36
- /**
37
- * Date prefix: 2024-01-15_Document.pdf
38
- */
39
- date_prefix: (file) => {
40
- const date = new Date(file.modified);
41
- const year = date.getFullYear();
42
- const month = String(date.getMonth() + 1).padStart(2, '0');
43
- const day = String(date.getDate()).padStart(2, '0');
44
- const cleanName = cleanFilename(file.name);
45
- return `${year}-${month}-${day}_${cleanName}`;
46
- },
47
-
48
- /**
49
- * Clean title only: Document.pdf (just sanitize)
50
- */
51
- clean: (file) => {
52
- return cleanFilename(file.name);
53
- },
54
-
55
- /**
56
- * Keep original: no renaming
57
- */
58
- original: (file) => {
59
- return file.name;
60
- }
61
- };
62
-
63
- /**
64
- * Clean a filename by removing junk patterns
65
- * @param {string} filename - Original filename
66
- * @returns {string} Cleaned filename
67
- */
68
- function cleanFilename(filename) {
69
- const ext = path.extname(filename);
70
- let base = path.basename(filename, ext);
71
-
72
- // Remove common junk patterns
73
- base = base
74
- .replace(/[\[\(]\s*\d+\s*[\]\)]/g, '') // [1], (2)
75
- .replace(/\s*-\s*Copy\s*(\(\d+\))?/gi, '') // - Copy, - Copy (2)
76
- .replace(/\s*\(\d+\)\s*$/g, '') // (1) at end
77
- .replace(/^\d{10,}_/g, '') // Unix timestamp prefix
78
- .replace(/^IMG_\d+/gi, 'Photo') // IMG_20240115
79
- .replace(/^DSC_?\d+/gi, 'Photo') // DSC_0001
80
- .replace(/^VID_?\d+/gi, 'Video') // VID_20240115
81
- .replace(/^Screenshot[_ ]\d{4}[-_]\d{2}[-_]\d{2}/gi, 'Screenshot') // Screenshot timestamps
82
- .replace(/_{2,}/g, '_') // Multiple underscores
83
- .replace(/\s{2,}/g, ' ') // Multiple spaces
84
- .replace(/^[_\s]+|[_\s]+$/g, ''); // Trim
85
-
86
- // Convert to Title Case if all lowercase or all uppercase
87
- if (base === base.toLowerCase() || base === base.toUpperCase()) {
88
- base = base.replace(/\b\w/g, c => c.toUpperCase());
89
- }
90
-
91
- return sanitizeFilename(`${base}${ext}`);
92
- }
93
-
94
- // ─── Duplicate Detection ──────────────────────────────────────────
95
-
96
- const DUPLICATE_STRATEGIES = {
97
- skip: 'skip', // Don't move if destination exists
98
- rename: 'rename', // Add (1), (2), etc.
99
- overwrite: 'overwrite', // Replace existing
100
- newest: 'newest', // Keep the newest file
101
- };
102
-
103
- /**
104
- * Resolve a destination path with duplicate handling
105
- * @param {string} destPath - Target destination path
106
- * @param {string} strategy - Duplicate resolution strategy
107
- * @returns {{ path: string, action: string }}
108
- */
109
- function resolveDuplicate(destPath, strategy = 'rename') {
110
- if (!fs.existsSync(destPath)) {
111
- return { path: destPath, action: 'move' };
112
- }
113
-
114
- switch (strategy) {
115
- case 'skip':
116
- return { path: destPath, action: 'skip' };
117
-
118
- case 'overwrite':
119
- return { path: destPath, action: 'overwrite' };
120
-
121
- case 'rename':
122
- default: {
123
- const dir = path.dirname(destPath);
124
- const ext = path.extname(destPath);
125
- const base = path.basename(destPath, ext);
126
- let counter = 1;
127
- let newPath;
128
-
129
- do {
130
- newPath = path.join(dir, `${base} (${counter})${ext}`);
131
- counter++;
132
- } while (fs.existsSync(newPath) && counter < 1000);
133
-
134
- return { path: newPath, action: 'rename' };
135
- }
136
- }
137
- }
138
-
139
- // ─── Move Plan ────────────────────────────────────────────────────
140
-
141
- /**
142
- * @typedef {Object} MovePlanItem
143
- * @property {string} source - Source file path
144
- * @property {string} destination - Target destination path
145
- * @property {string} category - File category
146
- * @property {string} originalName - Original filename
147
- * @property {string} newName - New filename (after convention applied)
148
- * @property {number} size - File size in bytes
149
- * @property {string} action - Action to take: move, skip, rename, overwrite
150
- */
151
-
152
- /**
153
- * Generate a move plan without executing (dry-run)
154
- * @param {string} srcDir - Source directory to organize
155
- * @param {Object} options - Organization options
156
- * @returns {{ plan: MovePlanItem[], summary: Object }}
157
- */
158
- function generatePlan(srcDir, options = {}) {
159
- const {
160
- naming = 'original',
161
- outputDir = null, // null = organize in-place (subdirs)
162
- flat = false, // Flat structure vs nested
163
- duplicateStrategy = 'rename',
164
- customCategories = null,
165
- scanOptions = {},
166
- } = options;
167
-
168
- // Scan the directory
169
- const scanResult = scan(srcDir, {
170
- ...scanOptions,
171
- includeDirectories: false
172
- });
173
-
174
- const categories = getCategories(customCategories);
175
- const plan = [];
176
- const categoryCounts = {};
177
- const namingFn = NAMING_CONVENTIONS[naming] || NAMING_CONVENTIONS.original;
178
-
179
- // Build the plan
180
- for (const file of scanResult.files) {
181
- // Substrate Protection (v3.5)
182
- // If file is inside a project substrate, DO NOT scatter it by default
183
- const projectRoot = findNearestSubstrate(file.path);
184
- if (projectRoot) {
185
- continue; // Skip project-locked files
186
- }
187
-
188
- const category = file.category;
189
- categoryCounts[category] = (categoryCounts[category] || 0);
190
-
191
- // Determine new filename
192
- const newName = namingFn(file, category, categoryCounts[category]);
193
- categoryCounts[category]++;
194
-
195
- // Determine destination directory
196
- const catDef = categories[category];
197
- const catLabel = catDef ? catDef.label : 'Other';
198
- const destDir = outputDir
199
- ? path.join(outputDir, catLabel)
200
- : path.join(srcDir, catLabel);
201
- const destPath = path.join(destDir, newName);
202
-
203
- // Skip if source and destination are the same
204
- if (path.resolve(file.path) === path.resolve(destPath)) {
205
- continue;
206
- }
207
-
208
- // Handle duplicates
209
- const resolved = resolveDuplicate(destPath, duplicateStrategy);
210
-
211
- plan.push({
212
- source: file.path,
213
- destination: resolved.path,
214
- category,
215
- categoryLabel: catLabel,
216
- originalName: file.name,
217
- newName: path.basename(resolved.path),
218
- size: file.size,
219
- sizeHuman: file.sizeHuman,
220
- action: resolved.action,
221
- ext: file.ext
222
- });
223
- }
224
-
225
- // Build summary
226
- const summary = {
227
- totalFiles: plan.length,
228
- totalSize: plan.reduce((sum, p) => sum + p.size, 0),
229
- totalSizeHuman: formatBytes(plan.reduce((sum, p) => sum + p.size, 0)),
230
- categories: {},
231
- actions: { move: 0, skip: 0, rename: 0, overwrite: 0 },
232
- scanStats: scanResult.stats
233
- };
234
-
235
- for (const item of plan) {
236
- summary.actions[item.action]++;
237
- if (!summary.categories[item.categoryLabel]) {
238
- summary.categories[item.categoryLabel] = { count: 0, size: 0 };
239
- }
240
- summary.categories[item.categoryLabel].count++;
241
- summary.categories[item.categoryLabel].size += item.size;
242
- }
243
-
244
- // Add human-readable size to each category
245
- for (const cat of Object.values(summary.categories)) {
246
- cat.sizeHuman = formatBytes(cat.size);
247
- }
248
-
249
- return { plan, summary };
250
- }
251
-
252
- // ─── Execution ────────────────────────────────────────────────────
253
-
254
- const FileMayorFS = require('./fs-abstraction');
255
- const fmfs = new FileMayorFS();
256
-
257
- /**
258
- * Execute a move plan with journal logging for rollback
259
- * @param {MovePlanItem[]} plan - The move plan to execute
260
- * @param {Object} options - Execution options
261
- * @returns {{ results: Object[], journal: Object[], summary: Object }}
262
- */
263
- async function executePlan(plan, options = {}) {
264
- const {
265
- onProgress = null,
266
- onError = null,
267
- abortOnError = false,
268
- } = options;
269
-
270
- const results = [];
271
- let succeeded = 0;
272
- let failed = 0;
273
- let skipped = 0;
274
-
275
- for (let i = 0; i < plan.length; i++) {
276
- const item = plan[i];
277
-
278
- if (onProgress) {
279
- onProgress({
280
- current: i + 1,
281
- total: plan.length,
282
- percent: Math.round(((i + 1) / plan.length) * 100),
283
- file: item.originalName,
284
- action: item.action,
285
- category: item.categoryLabel
286
- });
287
- }
288
-
289
- if (item.action === 'skip') {
290
- results.push({ ...item, status: 'skipped', error: null });
291
- skipped++;
292
- continue;
293
- }
294
-
295
- try {
296
- await fmfs.move(item.source, item.destination);
297
- results.push({ ...item, status: 'success', error: null });
298
- succeeded++;
299
- } catch (err) {
300
- results.push({ ...item, status: 'error', error: err.message });
301
- failed++;
302
- if (onError) onError({ item, error: err.message });
303
- if (abortOnError) break;
304
- }
305
- }
306
-
307
- const summary = {
308
- total: plan.length,
309
- succeeded,
310
- failed,
311
- skipped,
312
- totalSize: plan.reduce((sum, p) => sum + p.size, 0),
313
- totalSizeHuman: formatBytes(plan.reduce((sum, p) => sum + p.size, 0))
314
- };
315
-
316
- return { results, journal: fmfs.getJournal(), summary };
317
- }
318
-
319
- // ─── Rollback (Undo) ──────────────────────────────────────────────
320
-
321
- /**
322
- * Rollback operations using a journal
323
- * @param {Object[]} journal - Journal entries from executePlan
324
- * @param {Object} options - Rollback options
325
- */
326
- async function rollback(journal, options = {}) {
327
- // Fill the fmfs internal journal with the passed journal to use its logic
328
- fmfs.sessionJournal = journal;
329
- return await fmfs.rollback();
330
- }
331
-
332
- /**
333
- * Load a journal from disk
334
- * @param {string} journalPath - Path to journal file
335
- * @returns {Object[]} Journal entries
336
- */
337
- function loadJournal(journalPath) {
338
- try {
339
- if (!fs.existsSync(journalPath)) return [];
340
- return JSON.parse(fs.readFileSync(journalPath, 'utf8'));
341
- } catch {
342
- return [];
343
- }
344
- }
345
-
346
- // ─── Convenience Functions ────────────────────────────────────────
347
-
348
- /**
349
- * Organize a directory (scan, plan, execute) in one call
350
- * @param {string} dirPath - Directory to organize
351
- * @param {Object} options - Full options
352
- * @returns {{ results: Object[], journal: Object[], planSummary: Object, execSummary: Object }}
353
- */
354
- async function organize(dirPath, options = {}) {
355
- const {
356
- dryRun = false,
357
- naming = 'original',
358
- outputDir = null,
359
- duplicateStrategy = 'rename',
360
- onProgress = null,
361
- onError = null,
362
- abortOnError = false,
363
- scanOptions = {},
364
- customCategories = null,
365
- } = options;
366
-
367
- // Generate plan
368
- const { plan, summary: planSummary } = generatePlan(dirPath, {
369
- naming,
370
- outputDir,
371
- duplicateStrategy,
372
- scanOptions,
373
- customCategories,
374
- });
375
-
376
- if (dryRun) {
377
- return {
378
- dryRun: true,
379
- plan,
380
- planSummary,
381
- results: [],
382
- journal: [],
383
- execSummary: null
384
- };
385
- }
386
-
387
- // Execute
388
- const { results, journal, summary: execSummary } = await executePlan(plan, {
389
- onProgress,
390
- onError,
391
- abortOnError
392
- });
393
-
394
- return {
395
- dryRun: false,
396
- plan,
397
- planSummary,
398
- results,
399
- journal,
400
- execSummary
401
- };
402
- }
403
-
404
- module.exports = {
405
- NAMING_CONVENTIONS,
406
- DUPLICATE_STRATEGIES,
407
- cleanFilename,
408
- resolveDuplicate,
409
- generatePlan,
410
- executePlan,
411
- rollback,
412
- loadJournal,
413
- organize
414
- };
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * ═══════════════════════════════════════════════════════════════════
5
+ * FILEMAYOR CORE — ORGANIZER
6
+ * Intelligent file organization with dry-run, rollback journal,
7
+ * naming conventions, duplicate detection, and batch operations.
8
+ * ═══════════════════════════════════════════════════════════════════
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { categorize, getCategories } = require('./categories');
16
+ const { scan } = require('./scanner');
17
+ const {
18
+ validatePath, isFileSafe, sanitizeFilename, canWrite, createSnapshot,
19
+ findNearestSubstrate
20
+ } = require('./security');
21
+ const { formatBytes } = require('./scanner');
22
+
23
+ // ─── Naming Conventions ───────────────────────────────────────────
24
+
25
+ const NAMING_CONVENTIONS = {
26
+ /**
27
+ * Category prefix: BIZ_001_Document.pdf
28
+ */
29
+ category_prefix: (file, category, index) => {
30
+ const prefix = category.toUpperCase().slice(0, 4);
31
+ const padded = String(index + 1).padStart(3, '0');
32
+ const cleanName = cleanFilename(file.name);
33
+ return `${prefix}_${padded}_${cleanName}`;
34
+ },
35
+
36
+ /**
37
+ * Date prefix: 2024-01-15_Document.pdf
38
+ */
39
+ date_prefix: (file) => {
40
+ const date = new Date(file.modified);
41
+ const year = date.getFullYear();
42
+ const month = String(date.getMonth() + 1).padStart(2, '0');
43
+ const day = String(date.getDate()).padStart(2, '0');
44
+ const cleanName = cleanFilename(file.name);
45
+ return `${year}-${month}-${day}_${cleanName}`;
46
+ },
47
+
48
+ /**
49
+ * Clean title only: Document.pdf (just sanitize)
50
+ */
51
+ clean: (file) => {
52
+ return cleanFilename(file.name);
53
+ },
54
+
55
+ /**
56
+ * Keep original: no renaming
57
+ */
58
+ original: (file) => {
59
+ return file.name;
60
+ }
61
+ };
62
+
63
+ /**
64
+ * Clean a filename by removing junk patterns
65
+ * @param {string} filename - Original filename
66
+ * @returns {string} Cleaned filename
67
+ */
68
+ function cleanFilename(filename) {
69
+ const ext = path.extname(filename);
70
+ let base = path.basename(filename, ext);
71
+
72
+ // Remove common junk patterns
73
+ base = base
74
+ .replace(/[\[\(]\s*\d+\s*[\]\)]/g, '') // [1], (2)
75
+ .replace(/\s*-\s*Copy\s*(\(\d+\))?/gi, '') // - Copy, - Copy (2)
76
+ .replace(/\s*\(\d+\)\s*$/g, '') // (1) at end
77
+ .replace(/^\d{10,}_/g, '') // Unix timestamp prefix
78
+ .replace(/^IMG_\d+/gi, 'Photo') // IMG_20240115
79
+ .replace(/^DSC_?\d+/gi, 'Photo') // DSC_0001
80
+ .replace(/^VID_?\d+/gi, 'Video') // VID_20240115
81
+ .replace(/^Screenshot[_ ]\d{4}[-_]\d{2}[-_]\d{2}/gi, 'Screenshot') // Screenshot timestamps
82
+ .replace(/_{2,}/g, '_') // Multiple underscores
83
+ .replace(/\s{2,}/g, ' ') // Multiple spaces
84
+ .replace(/^[_\s]+|[_\s]+$/g, ''); // Trim
85
+
86
+ // Convert to Title Case if all lowercase or all uppercase
87
+ if (base === base.toLowerCase() || base === base.toUpperCase()) {
88
+ base = base.replace(/\b\w/g, c => c.toUpperCase());
89
+ }
90
+
91
+ return sanitizeFilename(`${base}${ext}`);
92
+ }
93
+
94
+ // ─── Duplicate Detection ──────────────────────────────────────────
95
+
96
+ const DUPLICATE_STRATEGIES = {
97
+ skip: 'skip', // Don't move if destination exists
98
+ rename: 'rename', // Add (1), (2), etc.
99
+ overwrite: 'overwrite', // Replace existing
100
+ newest: 'newest', // Keep the newest file
101
+ };
102
+
103
+ /**
104
+ * Resolve a destination path with duplicate handling
105
+ * @param {string} destPath - Target destination path
106
+ * @param {string} strategy - Duplicate resolution strategy
107
+ * @returns {{ path: string, action: string }}
108
+ */
109
+ function resolveDuplicate(destPath, strategy = 'rename') {
110
+ if (!fs.existsSync(destPath)) {
111
+ return { path: destPath, action: 'move' };
112
+ }
113
+
114
+ switch (strategy) {
115
+ case 'skip':
116
+ return { path: destPath, action: 'skip' };
117
+
118
+ case 'overwrite':
119
+ return { path: destPath, action: 'overwrite' };
120
+
121
+ case 'rename':
122
+ default: {
123
+ const dir = path.dirname(destPath);
124
+ const ext = path.extname(destPath);
125
+ const base = path.basename(destPath, ext);
126
+ let counter = 1;
127
+ let newPath;
128
+
129
+ do {
130
+ newPath = path.join(dir, `${base} (${counter})${ext}`);
131
+ counter++;
132
+ } while (fs.existsSync(newPath) && counter < 1000);
133
+
134
+ return { path: newPath, action: 'rename' };
135
+ }
136
+ }
137
+ }
138
+
139
+ // ─── Move Plan ────────────────────────────────────────────────────
140
+
141
+ /**
142
+ * @typedef {Object} MovePlanItem
143
+ * @property {string} source - Source file path
144
+ * @property {string} destination - Target destination path
145
+ * @property {string} category - File category
146
+ * @property {string} originalName - Original filename
147
+ * @property {string} newName - New filename (after convention applied)
148
+ * @property {number} size - File size in bytes
149
+ * @property {string} action - Action to take: move, skip, rename, overwrite
150
+ */
151
+
152
+ /**
153
+ * Generate a move plan without executing (dry-run)
154
+ * @param {string} srcDir - Source directory to organize
155
+ * @param {Object} options - Organization options
156
+ * @returns {{ plan: MovePlanItem[], summary: Object }}
157
+ */
158
+ function generatePlan(srcDir, options = {}) {
159
+ const {
160
+ naming = 'original',
161
+ outputDir = null, // null = organize in-place (subdirs)
162
+ flat = false, // Flat structure vs nested
163
+ duplicateStrategy = 'rename',
164
+ customCategories = null,
165
+ scanOptions = {},
166
+ } = options;
167
+
168
+ // Scan the directory
169
+ const scanResult = scan(srcDir, {
170
+ ...scanOptions,
171
+ includeDirectories: false
172
+ });
173
+
174
+ const categories = getCategories(customCategories);
175
+ const plan = [];
176
+ const categoryCounts = {};
177
+ const namingFn = NAMING_CONVENTIONS[naming] || NAMING_CONVENTIONS.original;
178
+
179
+ // Build the plan
180
+ for (const file of scanResult.files) {
181
+ // Substrate Protection (v3.5)
182
+ // If file is inside a project substrate, DO NOT scatter it by default
183
+ const projectRoot = findNearestSubstrate(file.path);
184
+ if (projectRoot) {
185
+ continue; // Skip project-locked files
186
+ }
187
+
188
+ const category = file.category;
189
+ categoryCounts[category] = (categoryCounts[category] || 0);
190
+
191
+ // Determine new filename
192
+ const newName = namingFn(file, category, categoryCounts[category]);
193
+ categoryCounts[category]++;
194
+
195
+ // Determine destination directory
196
+ const catDef = categories[category];
197
+ const catLabel = catDef ? catDef.label : 'Other';
198
+ const destDir = outputDir
199
+ ? path.join(outputDir, catLabel)
200
+ : path.join(srcDir, catLabel);
201
+ const destPath = path.join(destDir, newName);
202
+
203
+ // Skip if source and destination are the same
204
+ if (path.resolve(file.path) === path.resolve(destPath)) {
205
+ continue;
206
+ }
207
+
208
+ // Handle duplicates
209
+ const resolved = resolveDuplicate(destPath, duplicateStrategy);
210
+
211
+ plan.push({
212
+ source: file.path,
213
+ destination: resolved.path,
214
+ category,
215
+ categoryLabel: catLabel,
216
+ originalName: file.name,
217
+ newName: path.basename(resolved.path),
218
+ size: file.size,
219
+ sizeHuman: file.sizeHuman,
220
+ action: resolved.action,
221
+ ext: file.ext
222
+ });
223
+ }
224
+
225
+ // Build summary
226
+ const summary = {
227
+ totalFiles: plan.length,
228
+ totalSize: plan.reduce((sum, p) => sum + p.size, 0),
229
+ totalSizeHuman: formatBytes(plan.reduce((sum, p) => sum + p.size, 0)),
230
+ categories: {},
231
+ actions: { move: 0, skip: 0, rename: 0, overwrite: 0 },
232
+ scanStats: scanResult.stats
233
+ };
234
+
235
+ for (const item of plan) {
236
+ summary.actions[item.action]++;
237
+ if (!summary.categories[item.categoryLabel]) {
238
+ summary.categories[item.categoryLabel] = { count: 0, size: 0 };
239
+ }
240
+ summary.categories[item.categoryLabel].count++;
241
+ summary.categories[item.categoryLabel].size += item.size;
242
+ }
243
+
244
+ // Add human-readable size to each category
245
+ for (const cat of Object.values(summary.categories)) {
246
+ cat.sizeHuman = formatBytes(cat.size);
247
+ }
248
+
249
+ return { plan, summary };
250
+ }
251
+
252
+ // ─── Execution ────────────────────────────────────────────────────
253
+
254
+ const FileMayorFS = require('./fs-abstraction');
255
+ const fmfs = new FileMayorFS();
256
+
257
+ /**
258
+ * Execute a move plan with journal logging for rollback
259
+ * @param {MovePlanItem[]} plan - The move plan to execute
260
+ * @param {Object} options - Execution options
261
+ * @returns {{ results: Object[], journal: Object[], summary: Object }}
262
+ */
263
+ async function executePlan(plan, options = {}) {
264
+ const {
265
+ onProgress = null,
266
+ onError = null,
267
+ abortOnError = false,
268
+ } = options;
269
+
270
+ const results = [];
271
+ let succeeded = 0;
272
+ let failed = 0;
273
+ let skipped = 0;
274
+
275
+ for (let i = 0; i < plan.length; i++) {
276
+ const item = plan[i];
277
+
278
+ if (onProgress) {
279
+ onProgress({
280
+ current: i + 1,
281
+ total: plan.length,
282
+ percent: Math.round(((i + 1) / plan.length) * 100),
283
+ file: item.originalName,
284
+ action: item.action,
285
+ category: item.categoryLabel
286
+ });
287
+ }
288
+
289
+ if (item.action === 'skip') {
290
+ results.push({ ...item, status: 'skipped', error: null });
291
+ skipped++;
292
+ continue;
293
+ }
294
+
295
+ try {
296
+ await fmfs.move(item.source, item.destination);
297
+ results.push({ ...item, status: 'success', error: null });
298
+ succeeded++;
299
+ } catch (err) {
300
+ results.push({ ...item, status: 'error', error: err.message });
301
+ failed++;
302
+ if (onError) onError({ item, error: err.message });
303
+ if (abortOnError) break;
304
+ }
305
+ }
306
+
307
+ const summary = {
308
+ total: plan.length,
309
+ succeeded,
310
+ failed,
311
+ skipped,
312
+ totalSize: plan.reduce((sum, p) => sum + p.size, 0),
313
+ totalSizeHuman: formatBytes(plan.reduce((sum, p) => sum + p.size, 0))
314
+ };
315
+
316
+ return { results, journal: fmfs.getJournal(), summary };
317
+ }
318
+
319
+ // ─── Rollback (Undo) ──────────────────────────────────────────────
320
+
321
+ /**
322
+ * Rollback operations using a journal
323
+ * @param {Object[]} journal - Journal entries from executePlan
324
+ * @param {Object} options - Rollback options
325
+ */
326
+ async function rollback(journal, options = {}) {
327
+ // Fill the fmfs internal journal with the passed journal to use its logic
328
+ fmfs.sessionJournal = journal;
329
+ return await fmfs.rollback();
330
+ }
331
+
332
+ /**
333
+ * Load a journal from disk
334
+ * @param {string} journalPath - Path to journal file
335
+ * @returns {Object[]} Journal entries
336
+ */
337
+ function loadJournal(journalPath) {
338
+ try {
339
+ if (!fs.existsSync(journalPath)) return [];
340
+ return JSON.parse(fs.readFileSync(journalPath, 'utf8'));
341
+ } catch {
342
+ return [];
343
+ }
344
+ }
345
+
346
+ // ─── Convenience Functions ────────────────────────────────────────
347
+
348
+ /**
349
+ * Organize a directory (scan, plan, execute) in one call
350
+ * @param {string} dirPath - Directory to organize
351
+ * @param {Object} options - Full options
352
+ * @returns {{ results: Object[], journal: Object[], planSummary: Object, execSummary: Object }}
353
+ */
354
+ async function organize(dirPath, options = {}) {
355
+ const {
356
+ dryRun = false,
357
+ naming = 'original',
358
+ outputDir = null,
359
+ duplicateStrategy = 'rename',
360
+ onProgress = null,
361
+ onError = null,
362
+ abortOnError = false,
363
+ scanOptions = {},
364
+ customCategories = null,
365
+ } = options;
366
+
367
+ // Generate plan
368
+ const { plan, summary: planSummary } = generatePlan(dirPath, {
369
+ naming,
370
+ outputDir,
371
+ duplicateStrategy,
372
+ scanOptions,
373
+ customCategories,
374
+ });
375
+
376
+ if (dryRun) {
377
+ return {
378
+ dryRun: true,
379
+ plan,
380
+ planSummary,
381
+ results: [],
382
+ journal: [],
383
+ execSummary: null
384
+ };
385
+ }
386
+
387
+ // Execute
388
+ const { results, journal, summary: execSummary } = await executePlan(plan, {
389
+ onProgress,
390
+ onError,
391
+ abortOnError
392
+ });
393
+
394
+ return {
395
+ dryRun: false,
396
+ plan,
397
+ planSummary,
398
+ results,
399
+ journal,
400
+ execSummary
401
+ };
402
+ }
403
+
404
+ module.exports = {
405
+ NAMING_CONVENTIONS,
406
+ DUPLICATE_STRATEGIES,
407
+ cleanFilename,
408
+ resolveDuplicate,
409
+ generatePlan,
410
+ executePlan,
411
+ rollback,
412
+ loadJournal,
413
+ organize
414
+ };