filemayor 3.6.0 → 4.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.
- package/LICENSE +90 -90
- package/README.md +96 -96
- package/core/analyzer.js +163 -163
- package/core/categories.js +235 -235
- package/core/cleaner.js +527 -527
- package/core/config.js +562 -562
- package/core/fs-abstraction.js +110 -38
- package/core/index.js +135 -135
- package/core/intent-interpreter.js +209 -66
- package/core/license.js +403 -339
- package/core/organizer.js +414 -414
- package/core/reporter.js +783 -783
- package/core/scanner.js +466 -466
- package/core/security.js +370 -370
- package/core/sop-parser.js +564 -564
- package/core/telemetry.js +74 -0
- package/core/vault.js +83 -69
- package/core/watcher.js +478 -478
- package/index.js +895 -877
- package/package.json +2 -2
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
|
+
};
|