filemayor 2.0.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.
@@ -0,0 +1,528 @@
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 { validatePath, isFileSafe, sanitizeFilename, canWrite, createSnapshot } = require('./security');
18
+ const { formatBytes } = require('./scanner');
19
+
20
+ // ─── Naming Conventions ───────────────────────────────────────────
21
+
22
+ const NAMING_CONVENTIONS = {
23
+ /**
24
+ * Category prefix: BIZ_001_Document.pdf
25
+ */
26
+ category_prefix: (file, category, index) => {
27
+ const prefix = category.toUpperCase().slice(0, 4);
28
+ const padded = String(index + 1).padStart(3, '0');
29
+ const cleanName = cleanFilename(file.name);
30
+ return `${prefix}_${padded}_${cleanName}`;
31
+ },
32
+
33
+ /**
34
+ * Date prefix: 2024-01-15_Document.pdf
35
+ */
36
+ date_prefix: (file) => {
37
+ const date = new Date(file.modified);
38
+ const year = date.getFullYear();
39
+ const month = String(date.getMonth() + 1).padStart(2, '0');
40
+ const day = String(date.getDate()).padStart(2, '0');
41
+ const cleanName = cleanFilename(file.name);
42
+ return `${year}-${month}-${day}_${cleanName}`;
43
+ },
44
+
45
+ /**
46
+ * Clean title only: Document.pdf (just sanitize)
47
+ */
48
+ clean: (file) => {
49
+ return cleanFilename(file.name);
50
+ },
51
+
52
+ /**
53
+ * Keep original: no renaming
54
+ */
55
+ original: (file) => {
56
+ return file.name;
57
+ }
58
+ };
59
+
60
+ /**
61
+ * Clean a filename by removing junk patterns
62
+ * @param {string} filename - Original filename
63
+ * @returns {string} Cleaned filename
64
+ */
65
+ function cleanFilename(filename) {
66
+ const ext = path.extname(filename);
67
+ let base = path.basename(filename, ext);
68
+
69
+ // Remove common junk patterns
70
+ base = base
71
+ .replace(/[\[\(]\s*\d+\s*[\]\)]/g, '') // [1], (2)
72
+ .replace(/\s*-\s*Copy\s*(\(\d+\))?/gi, '') // - Copy, - Copy (2)
73
+ .replace(/\s*\(\d+\)\s*$/g, '') // (1) at end
74
+ .replace(/^\d{10,}_/g, '') // Unix timestamp prefix
75
+ .replace(/^IMG_\d+/gi, 'Photo') // IMG_20240115
76
+ .replace(/^DSC_?\d+/gi, 'Photo') // DSC_0001
77
+ .replace(/^VID_?\d+/gi, 'Video') // VID_20240115
78
+ .replace(/^Screenshot[_ ]\d{4}[-_]\d{2}[-_]\d{2}/gi, 'Screenshot') // Screenshot timestamps
79
+ .replace(/_{2,}/g, '_') // Multiple underscores
80
+ .replace(/\s{2,}/g, ' ') // Multiple spaces
81
+ .replace(/^[_\s]+|[_\s]+$/g, ''); // Trim
82
+
83
+ // Convert to Title Case if all lowercase or all uppercase
84
+ if (base === base.toLowerCase() || base === base.toUpperCase()) {
85
+ base = base.replace(/\b\w/g, c => c.toUpperCase());
86
+ }
87
+
88
+ return sanitizeFilename(`${base}${ext}`);
89
+ }
90
+
91
+ // ─── Duplicate Detection ──────────────────────────────────────────
92
+
93
+ const DUPLICATE_STRATEGIES = {
94
+ skip: 'skip', // Don't move if destination exists
95
+ rename: 'rename', // Add (1), (2), etc.
96
+ overwrite: 'overwrite', // Replace existing
97
+ newest: 'newest', // Keep the newest file
98
+ };
99
+
100
+ /**
101
+ * Resolve a destination path with duplicate handling
102
+ * @param {string} destPath - Target destination path
103
+ * @param {string} strategy - Duplicate resolution strategy
104
+ * @returns {{ path: string, action: string }}
105
+ */
106
+ function resolveDuplicate(destPath, strategy = 'rename') {
107
+ if (!fs.existsSync(destPath)) {
108
+ return { path: destPath, action: 'move' };
109
+ }
110
+
111
+ switch (strategy) {
112
+ case 'skip':
113
+ return { path: destPath, action: 'skip' };
114
+
115
+ case 'overwrite':
116
+ return { path: destPath, action: 'overwrite' };
117
+
118
+ case 'rename':
119
+ default: {
120
+ const dir = path.dirname(destPath);
121
+ const ext = path.extname(destPath);
122
+ const base = path.basename(destPath, ext);
123
+ let counter = 1;
124
+ let newPath;
125
+
126
+ do {
127
+ newPath = path.join(dir, `${base} (${counter})${ext}`);
128
+ counter++;
129
+ } while (fs.existsSync(newPath) && counter < 1000);
130
+
131
+ return { path: newPath, action: 'rename' };
132
+ }
133
+ }
134
+ }
135
+
136
+ // ─── Move Plan ────────────────────────────────────────────────────
137
+
138
+ /**
139
+ * @typedef {Object} MovePlanItem
140
+ * @property {string} source - Source file path
141
+ * @property {string} destination - Target destination path
142
+ * @property {string} category - File category
143
+ * @property {string} originalName - Original filename
144
+ * @property {string} newName - New filename (after convention applied)
145
+ * @property {number} size - File size in bytes
146
+ * @property {string} action - Action to take: move, skip, rename, overwrite
147
+ */
148
+
149
+ /**
150
+ * Generate a move plan without executing (dry-run)
151
+ * @param {string} srcDir - Source directory to organize
152
+ * @param {Object} options - Organization options
153
+ * @returns {{ plan: MovePlanItem[], summary: Object }}
154
+ */
155
+ function generatePlan(srcDir, options = {}) {
156
+ const {
157
+ naming = 'original',
158
+ outputDir = null, // null = organize in-place (subdirs)
159
+ flat = false, // Flat structure vs nested
160
+ duplicateStrategy = 'rename',
161
+ customCategories = null,
162
+ scanOptions = {},
163
+ } = options;
164
+
165
+ // Scan the directory
166
+ const scanResult = scan(srcDir, {
167
+ ...scanOptions,
168
+ includeDirectories: false
169
+ });
170
+
171
+ const categories = getCategories(customCategories);
172
+ const plan = [];
173
+ const categoryCounts = {};
174
+ const namingFn = NAMING_CONVENTIONS[naming] || NAMING_CONVENTIONS.original;
175
+
176
+ // Build the plan
177
+ for (const file of scanResult.files) {
178
+ const category = file.category;
179
+ categoryCounts[category] = (categoryCounts[category] || 0);
180
+
181
+ // Determine new filename
182
+ const newName = namingFn(file, category, categoryCounts[category]);
183
+ categoryCounts[category]++;
184
+
185
+ // Determine destination directory
186
+ const catDef = categories[category];
187
+ const catLabel = catDef ? catDef.label : 'Other';
188
+ const destDir = outputDir
189
+ ? path.join(outputDir, catLabel)
190
+ : path.join(srcDir, catLabel);
191
+ const destPath = path.join(destDir, newName);
192
+
193
+ // Skip if source and destination are the same
194
+ if (path.resolve(file.path) === path.resolve(destPath)) {
195
+ continue;
196
+ }
197
+
198
+ // Handle duplicates
199
+ const resolved = resolveDuplicate(destPath, duplicateStrategy);
200
+
201
+ plan.push({
202
+ source: file.path,
203
+ destination: resolved.path,
204
+ category,
205
+ categoryLabel: catLabel,
206
+ originalName: file.name,
207
+ newName: path.basename(resolved.path),
208
+ size: file.size,
209
+ sizeHuman: file.sizeHuman,
210
+ action: resolved.action,
211
+ ext: file.ext
212
+ });
213
+ }
214
+
215
+ // Build summary
216
+ const summary = {
217
+ totalFiles: plan.length,
218
+ totalSize: plan.reduce((sum, p) => sum + p.size, 0),
219
+ totalSizeHuman: formatBytes(plan.reduce((sum, p) => sum + p.size, 0)),
220
+ categories: {},
221
+ actions: { move: 0, skip: 0, rename: 0, overwrite: 0 },
222
+ scanStats: scanResult.stats
223
+ };
224
+
225
+ for (const item of plan) {
226
+ summary.actions[item.action]++;
227
+ if (!summary.categories[item.categoryLabel]) {
228
+ summary.categories[item.categoryLabel] = { count: 0, size: 0 };
229
+ }
230
+ summary.categories[item.categoryLabel].count++;
231
+ summary.categories[item.categoryLabel].size += item.size;
232
+ }
233
+
234
+ // Add human-readable size to each category
235
+ for (const cat of Object.values(summary.categories)) {
236
+ cat.sizeHuman = formatBytes(cat.size);
237
+ }
238
+
239
+ return { plan, summary };
240
+ }
241
+
242
+ // ─── Execution ────────────────────────────────────────────────────
243
+
244
+ /**
245
+ * Execute a move plan with journal logging for rollback
246
+ * @param {MovePlanItem[]} plan - The move plan to execute
247
+ * @param {Object} options - Execution options
248
+ * @returns {{ results: Object[], journal: Object[], summary: Object }}
249
+ */
250
+ function executePlan(plan, options = {}) {
251
+ const {
252
+ onProgress = null,
253
+ onError = null,
254
+ journalPath = null, // Path to save rollback journal
255
+ abortOnError = false, // Stop on first error
256
+ } = options;
257
+
258
+ const results = [];
259
+ const journal = [];
260
+ let succeeded = 0;
261
+ let failed = 0;
262
+ let skipped = 0;
263
+
264
+ for (let i = 0; i < plan.length; i++) {
265
+ const item = plan[i];
266
+
267
+ // Progress callback
268
+ if (onProgress) {
269
+ onProgress({
270
+ current: i + 1,
271
+ total: plan.length,
272
+ percent: Math.round(((i + 1) / plan.length) * 100),
273
+ file: item.originalName,
274
+ action: item.action,
275
+ category: item.categoryLabel
276
+ });
277
+ }
278
+
279
+ // Skip items marked as skip
280
+ if (item.action === 'skip') {
281
+ results.push({ ...item, status: 'skipped', error: null });
282
+ skipped++;
283
+ continue;
284
+ }
285
+
286
+ // Safety check
287
+ const safeCheck = isFileSafe(item.source);
288
+ if (!safeCheck.safe) {
289
+ const error = `Safety check failed: ${safeCheck.reason}`;
290
+ results.push({ ...item, status: 'error', error });
291
+ failed++;
292
+ if (onError) onError({ item, error });
293
+ if (abortOnError) break;
294
+ continue;
295
+ }
296
+
297
+ // Create destination directory
298
+ const destDir = path.dirname(item.destination);
299
+ try {
300
+ fs.mkdirSync(destDir, { recursive: true });
301
+ } catch (err) {
302
+ const error = `Failed to create directory: ${err.message}`;
303
+ results.push({ ...item, status: 'error', error });
304
+ failed++;
305
+ if (onError) onError({ item, error });
306
+ if (abortOnError) break;
307
+ continue;
308
+ }
309
+
310
+ // Create pre-move snapshot for integrity
311
+ const snapshot = createSnapshot(item.source);
312
+
313
+ // Execute the move
314
+ try {
315
+ // Handle overwrite
316
+ if (item.action === 'overwrite' && fs.existsSync(item.destination)) {
317
+ fs.unlinkSync(item.destination);
318
+ }
319
+
320
+ // Perform the move
321
+ fs.renameSync(item.source, item.destination);
322
+
323
+ // Journal entry for rollback
324
+ const journalEntry = {
325
+ source: item.source,
326
+ destination: item.destination,
327
+ originalName: item.originalName,
328
+ newName: item.newName,
329
+ size: item.size,
330
+ timestamp: Date.now(),
331
+ snapshot
332
+ };
333
+ journal.push(journalEntry);
334
+
335
+ results.push({ ...item, status: 'success', error: null });
336
+ succeeded++;
337
+ } catch (err) {
338
+ // If rename fails (cross-device), try copy + delete
339
+ try {
340
+ fs.copyFileSync(item.source, item.destination);
341
+ fs.unlinkSync(item.source);
342
+
343
+ const journalEntry = {
344
+ source: item.source,
345
+ destination: item.destination,
346
+ originalName: item.originalName,
347
+ newName: item.newName,
348
+ size: item.size,
349
+ timestamp: Date.now(),
350
+ snapshot,
351
+ crossDevice: true
352
+ };
353
+ journal.push(journalEntry);
354
+
355
+ results.push({ ...item, status: 'success', error: null });
356
+ succeeded++;
357
+ } catch (copyErr) {
358
+ const error = `Move failed: ${copyErr.message}`;
359
+ results.push({ ...item, status: 'error', error });
360
+ failed++;
361
+ if (onError) onError({ item, error });
362
+ if (abortOnError) break;
363
+ }
364
+ }
365
+ }
366
+
367
+ // Save journal to disk if path provided
368
+ if (journalPath && journal.length > 0) {
369
+ try {
370
+ const existing = fs.existsSync(journalPath)
371
+ ? JSON.parse(fs.readFileSync(journalPath, 'utf8'))
372
+ : [];
373
+ const merged = [...existing, ...journal];
374
+ fs.writeFileSync(journalPath, JSON.stringify(merged, null, 2), 'utf8');
375
+ } catch (err) {
376
+ // Journal save failed — non-fatal
377
+ }
378
+ }
379
+
380
+ const summary = {
381
+ total: plan.length,
382
+ succeeded,
383
+ failed,
384
+ skipped,
385
+ totalSize: plan.reduce((sum, p) => sum + p.size, 0),
386
+ totalSizeHuman: formatBytes(plan.reduce((sum, p) => sum + p.size, 0))
387
+ };
388
+
389
+ return { results, journal, summary };
390
+ }
391
+
392
+ // ─── Rollback (Undo) ──────────────────────────────────────────────
393
+
394
+ /**
395
+ * Rollback operations using a journal
396
+ * @param {Object[]} journal - Journal entries from executePlan
397
+ * @param {Object} options - Rollback options
398
+ * @returns {{ undone: number, failed: number, errors: string[] }}
399
+ */
400
+ function rollback(journal, options = {}) {
401
+ const { onProgress = null } = options;
402
+ let undone = 0;
403
+ let failed = 0;
404
+ const errors = [];
405
+
406
+ // Process in reverse order (most recent first)
407
+ const reversed = [...journal].reverse();
408
+
409
+ for (let i = 0; i < reversed.length; i++) {
410
+ const entry = reversed[i];
411
+
412
+ if (onProgress) {
413
+ onProgress({
414
+ current: i + 1,
415
+ total: reversed.length,
416
+ percent: Math.round(((i + 1) / reversed.length) * 100),
417
+ file: entry.originalName
418
+ });
419
+ }
420
+
421
+ try {
422
+ // Ensure source directory exists
423
+ const sourceDir = path.dirname(entry.source);
424
+ fs.mkdirSync(sourceDir, { recursive: true });
425
+
426
+ // Move back
427
+ if (entry.crossDevice) {
428
+ fs.copyFileSync(entry.destination, entry.source);
429
+ fs.unlinkSync(entry.destination);
430
+ } else {
431
+ fs.renameSync(entry.destination, entry.source);
432
+ }
433
+
434
+ undone++;
435
+ } catch (err) {
436
+ failed++;
437
+ errors.push(`Failed to undo ${entry.originalName}: ${err.message}`);
438
+ }
439
+ }
440
+
441
+ return { undone, failed, errors };
442
+ }
443
+
444
+ /**
445
+ * Load a journal from disk
446
+ * @param {string} journalPath - Path to journal file
447
+ * @returns {Object[]} Journal entries
448
+ */
449
+ function loadJournal(journalPath) {
450
+ try {
451
+ if (!fs.existsSync(journalPath)) return [];
452
+ return JSON.parse(fs.readFileSync(journalPath, 'utf8'));
453
+ } catch {
454
+ return [];
455
+ }
456
+ }
457
+
458
+ // ─── Convenience Functions ────────────────────────────────────────
459
+
460
+ /**
461
+ * Organize a directory (scan, plan, execute) in one call
462
+ * @param {string} dirPath - Directory to organize
463
+ * @param {Object} options - Full options
464
+ * @returns {{ results: Object[], journal: Object[], planSummary: Object, execSummary: Object }}
465
+ */
466
+ function organize(dirPath, options = {}) {
467
+ const {
468
+ dryRun = false,
469
+ naming = 'original',
470
+ outputDir = null,
471
+ duplicateStrategy = 'rename',
472
+ journalPath = null,
473
+ onProgress = null,
474
+ onError = null,
475
+ abortOnError = false,
476
+ scanOptions = {},
477
+ customCategories = null,
478
+ } = options;
479
+
480
+ // Generate plan
481
+ const { plan, summary: planSummary } = generatePlan(dirPath, {
482
+ naming,
483
+ outputDir,
484
+ duplicateStrategy,
485
+ scanOptions,
486
+ customCategories,
487
+ });
488
+
489
+ if (dryRun) {
490
+ return {
491
+ dryRun: true,
492
+ plan,
493
+ planSummary,
494
+ results: [],
495
+ journal: [],
496
+ execSummary: null
497
+ };
498
+ }
499
+
500
+ // Execute
501
+ const { results, journal, summary: execSummary } = executePlan(plan, {
502
+ onProgress,
503
+ onError,
504
+ journalPath: journalPath || path.join(dirPath, '.filemayor-journal.json'),
505
+ abortOnError
506
+ });
507
+
508
+ return {
509
+ dryRun: false,
510
+ plan,
511
+ planSummary,
512
+ results,
513
+ journal,
514
+ execSummary
515
+ };
516
+ }
517
+
518
+ module.exports = {
519
+ NAMING_CONVENTIONS,
520
+ DUPLICATE_STRATEGIES,
521
+ cleanFilename,
522
+ resolveDuplicate,
523
+ generatePlan,
524
+ executePlan,
525
+ rollback,
526
+ loadJournal,
527
+ organize
528
+ };