filemayor 4.0.7 → 4.0.9
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/ai/planner.js +13 -2
- package/core/ai/strategist.js +15 -1
- package/core/ai/validator.js +9 -3
- package/core/cleaner.js +19 -14
- package/core/config.js +63 -2
- package/core/engine/dedupe-engine.js +111 -25
- package/core/engine/para-engine.js +370 -0
- package/core/fs-abstraction.js +147 -54
- package/core/index.js +2 -0
- package/core/watcher.js +85 -7
- package/index.js +152 -22
- package/package.json +1 -1
package/core/ai/planner.js
CHANGED
|
@@ -30,10 +30,21 @@ class CurativePlanner {
|
|
|
30
30
|
cwd: process.cwd()
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
// PARA (v4.0.9): give every provider the same hard contract so the
|
|
34
|
+
// plan lands in the four buckets and survives validation.
|
|
35
|
+
let prompt = rawPrompt;
|
|
36
|
+
if (intent.strategy === 'para_actionability') {
|
|
37
|
+
prompt = `${rawPrompt}\n\nSort by ACTIONABILITY using the PARA method. HARD RULES:\n` +
|
|
38
|
+
`- Every destination path MUST begin with exactly one of: Projects/, Areas/, Resources/, Archives/.\n` +
|
|
39
|
+
`- Projects = active efforts with an end; Areas = ongoing responsibilities (finances, health, home); ` +
|
|
40
|
+
`Resources = reference material; Archives = anything inactive or untouched for 12+ months.\n` +
|
|
41
|
+
`- Keep project folders intact (never split a code or media project).`;
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
// Standardized Bridge: Telemetry is passed as clusters
|
|
34
45
|
return await this.interpreter.interpret(
|
|
35
|
-
|
|
36
|
-
sentryData.clusters,
|
|
46
|
+
prompt,
|
|
47
|
+
sentryData.clusters,
|
|
37
48
|
context
|
|
38
49
|
);
|
|
39
50
|
}
|
package/core/ai/strategist.js
CHANGED
|
@@ -67,6 +67,14 @@ const ARCHETYPES = {
|
|
|
67
67
|
nameSignals: ['book', 'chapter', 'isbn', 'edition', 'vol_', 'author'],
|
|
68
68
|
strategy: 'ancestry_matching',
|
|
69
69
|
hierarchy: 'Genre / Subject → Author → Title'
|
|
70
|
+
},
|
|
71
|
+
para: {
|
|
72
|
+
label: 'PARA (actionability)',
|
|
73
|
+
extSignals: [],
|
|
74
|
+
nameSignals: ['para'],
|
|
75
|
+
strategy: 'para_actionability',
|
|
76
|
+
hierarchy: 'Projects / Areas / Resources / Archives',
|
|
77
|
+
folders: ['Projects', 'Areas', 'Resources', 'Archives']
|
|
70
78
|
}
|
|
71
79
|
};
|
|
72
80
|
|
|
@@ -83,7 +91,13 @@ class IntentStrategist {
|
|
|
83
91
|
|
|
84
92
|
const isRefine = p.includes('refine') || p.includes('structure') || p.includes('within');
|
|
85
93
|
|
|
86
|
-
|
|
94
|
+
// PARA must be detected FIRST — its prompts contain "projects",
|
|
95
|
+
// which the technical/codebase branch below would otherwise claim.
|
|
96
|
+
if (p.includes('para') || p.includes('actionability') || (p.includes('areas') && p.includes('archives'))) {
|
|
97
|
+
intent = 'para_sort';
|
|
98
|
+
strategy = 'para_actionability';
|
|
99
|
+
archetype = 'para';
|
|
100
|
+
} else if (p.includes('clean') || p.includes('space') || p.includes('junk')) {
|
|
87
101
|
intent = 'cleanup';
|
|
88
102
|
strategy = 'retention';
|
|
89
103
|
} else if (p.includes('tax') || p.includes('invoice') || p.includes('receipt')) {
|
package/core/ai/validator.js
CHANGED
|
@@ -29,6 +29,11 @@ class SecurityArchitect {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
const domainTriggerFolders = ['books', 'library', 'projects', 'music', 'work', 'downloads'];
|
|
32
|
+
// PARA buckets (v4.0.9): a deliberate re-filing INTO one of the four
|
|
33
|
+
// actionability buckets is not "domain scattering" — it is the point
|
|
34
|
+
// of a PARA plan. Destinations rooted in a bucket bypass only the
|
|
35
|
+
// domain-preservation rule; path-safety and bundle locks still apply.
|
|
36
|
+
const paraBuckets = new Set(['projects', 'areas', 'resources', 'archives']);
|
|
32
37
|
const clusters = sentryData?.clusters || {};
|
|
33
38
|
const allSamples = Object.values(clusters).flatMap(c => c.samples || []);
|
|
34
39
|
const homeParts = this._homePrefixParts;
|
|
@@ -54,14 +59,15 @@ class SecurityArchitect {
|
|
|
54
59
|
const destParts = normalize(step.destination);
|
|
55
60
|
|
|
56
61
|
const triggerFound = srcParts.find(p => domainTriggerFolders.includes(p));
|
|
62
|
+
const destIsParaBucket = destParts.some(p => paraBuckets.has(p));
|
|
57
63
|
|
|
58
|
-
if (triggerFound) {
|
|
64
|
+
if (triggerFound && !destIsParaBucket) {
|
|
59
65
|
// A move is valid if the EXACT trigger folder is present in the destination path
|
|
60
66
|
const preserved = destParts.includes(triggerFound);
|
|
61
67
|
|
|
62
|
-
if (!preserved && !step.reason.includes('COLLECTION_MOVE')) {
|
|
68
|
+
if (!preserved && !(step.reason || '').includes('COLLECTION_MOVE')) {
|
|
63
69
|
console.warn(`[ARCHITECTURE] Blocked domain scattering for ${step.source} -> ${step.destination}`);
|
|
64
|
-
return false;
|
|
70
|
+
return false;
|
|
65
71
|
}
|
|
66
72
|
}
|
|
67
73
|
|
package/core/cleaner.js
CHANGED
|
@@ -394,18 +394,24 @@ class Cleaner {
|
|
|
394
394
|
// ─── Deletion ─────────────────────────────────────────────────────
|
|
395
395
|
|
|
396
396
|
/**
|
|
397
|
-
* Delete junk files and directories
|
|
397
|
+
* Delete junk files and directories — trash-based and journaled, so
|
|
398
|
+
* `filemayor undo` restores anything removed by mistake (v4.0.9; this
|
|
399
|
+
* used to unlink/rmSync permanently).
|
|
398
400
|
* @param {Object[]} junkItems - Items from findJunk
|
|
399
401
|
* @param {Object} options - Deletion options
|
|
400
|
-
* @returns {{ deleted: number, freed: number, freedHuman: string, errors: Object[] }}
|
|
402
|
+
* @returns {Promise<{ deleted: number, freed: number, freedHuman: string, errors: Object[] }>}
|
|
401
403
|
*/
|
|
402
|
-
function deleteJunk(junkItems, options = {}) {
|
|
404
|
+
async function deleteJunk(junkItems, options = {}) {
|
|
403
405
|
const {
|
|
404
406
|
onProgress = null,
|
|
405
407
|
onError = null,
|
|
406
408
|
filterCategories = null, // Only delete these categories
|
|
409
|
+
fmfs = null, // Shared FileMayorFS (one undo session per clean run)
|
|
407
410
|
} = options;
|
|
408
411
|
|
|
412
|
+
const FileMayorFS = require('./fs-abstraction');
|
|
413
|
+
const trashFs = fmfs || new FileMayorFS();
|
|
414
|
+
|
|
409
415
|
let deleted = 0;
|
|
410
416
|
let freed = 0;
|
|
411
417
|
const errors = [];
|
|
@@ -437,7 +443,7 @@ function deleteJunk(junkItems, options = {}) {
|
|
|
437
443
|
if (!canWrite(path.dirname(item.path))) {
|
|
438
444
|
throw new Error('Permission denied');
|
|
439
445
|
}
|
|
440
|
-
|
|
446
|
+
await trashFs.delete(item.path);
|
|
441
447
|
deleted++;
|
|
442
448
|
freed += item.size;
|
|
443
449
|
} catch (err) {
|
|
@@ -447,7 +453,7 @@ function deleteJunk(junkItems, options = {}) {
|
|
|
447
453
|
}
|
|
448
454
|
}
|
|
449
455
|
|
|
450
|
-
//
|
|
456
|
+
// Trash directories (sort by depth descending — deepest first)
|
|
451
457
|
const sortedDirs = dirs.sort((a, b) => {
|
|
452
458
|
const depthA = a.path.split(path.sep).length;
|
|
453
459
|
const depthB = b.path.split(path.sep).length;
|
|
@@ -456,11 +462,7 @@ function deleteJunk(junkItems, options = {}) {
|
|
|
456
462
|
|
|
457
463
|
for (const dir of sortedDirs) {
|
|
458
464
|
try {
|
|
459
|
-
|
|
460
|
-
fs.rmdirSync(dir.path);
|
|
461
|
-
} else {
|
|
462
|
-
fs.rmSync(dir.path, { recursive: true, force: true });
|
|
463
|
-
}
|
|
465
|
+
await trashFs.delete(dir.path);
|
|
464
466
|
deleted++;
|
|
465
467
|
freed += dir.size;
|
|
466
468
|
} catch (err) {
|
|
@@ -473,7 +475,10 @@ function deleteJunk(junkItems, options = {}) {
|
|
|
473
475
|
freed,
|
|
474
476
|
freedHuman: formatBytes(freed),
|
|
475
477
|
errors,
|
|
476
|
-
total: toDelete.length
|
|
478
|
+
total: toDelete.length,
|
|
479
|
+
reversible: true,
|
|
480
|
+
trashPath: trashFs.options.trashPath,
|
|
481
|
+
sessionId: trashFs.sessionId
|
|
477
482
|
};
|
|
478
483
|
}
|
|
479
484
|
|
|
@@ -488,9 +493,9 @@ function findJunk(dirPath, options = {}) {
|
|
|
488
493
|
}
|
|
489
494
|
|
|
490
495
|
/**
|
|
491
|
-
* Quick clean: scan +
|
|
496
|
+
* Quick clean: scan + trash in one call (reversible with undo)
|
|
492
497
|
*/
|
|
493
|
-
function clean(dirPath, options = {}) {
|
|
498
|
+
async function clean(dirPath, options = {}) {
|
|
494
499
|
const { dryRun = false, filterCategories = null, ...scanOptions } = options;
|
|
495
500
|
|
|
496
501
|
const result = findJunk(dirPath, scanOptions);
|
|
@@ -504,7 +509,7 @@ function clean(dirPath, options = {}) {
|
|
|
504
509
|
};
|
|
505
510
|
}
|
|
506
511
|
|
|
507
|
-
const deleteResult = deleteJunk(result.junk, {
|
|
512
|
+
const deleteResult = await deleteJunk(result.junk, {
|
|
508
513
|
filterCategories,
|
|
509
514
|
onProgress: scanOptions.onProgress,
|
|
510
515
|
onError: scanOptions.onError,
|
package/core/config.js
CHANGED
|
@@ -532,18 +532,78 @@ security:
|
|
|
532
532
|
`;
|
|
533
533
|
}
|
|
534
534
|
|
|
535
|
+
/**
|
|
536
|
+
* Generate a PARA-method .filemayor.yml template.
|
|
537
|
+
* PARA (Projects, Areas, Resources, Archives) is an actionability-based
|
|
538
|
+
* organizing method; this preset tunes the sorter.
|
|
539
|
+
* @returns {string} YAML config template
|
|
540
|
+
*/
|
|
541
|
+
function generateParaTemplate() {
|
|
542
|
+
return `# ═══════════════════════════════════════════════════════════
|
|
543
|
+
# FileMayor Configuration — PARA preset
|
|
544
|
+
# PARA = Projects · Areas · Resources · Archives.
|
|
545
|
+
# FileMayor automates it by sorting files on actionability signals.
|
|
546
|
+
# Run: filemayor para . (preview the plan)
|
|
547
|
+
# filemayor apply (execute, fully reversible)
|
|
548
|
+
# ═══════════════════════════════════════════════════════════
|
|
549
|
+
|
|
550
|
+
version: 1
|
|
551
|
+
|
|
552
|
+
# ─── PARA Actionability Sorter ─────────────────────────────
|
|
553
|
+
para:
|
|
554
|
+
# Files not modified in this many months are moved to Archives.
|
|
555
|
+
archiveAfterMonths: 12
|
|
556
|
+
|
|
557
|
+
# Files modified within this window count as active → Projects.
|
|
558
|
+
activeWithinMonths: 3
|
|
559
|
+
|
|
560
|
+
# Where files with no clear signal land: projects|areas|resources|archives
|
|
561
|
+
defaultBucket: resources
|
|
562
|
+
|
|
563
|
+
# Keep the top-level subfolder name as a grouping inside each bucket.
|
|
564
|
+
preserveSubfolders: true
|
|
565
|
+
|
|
566
|
+
# Bucket folder names (rename or localize freely).
|
|
567
|
+
folders:
|
|
568
|
+
projects: Projects
|
|
569
|
+
areas: Areas
|
|
570
|
+
resources: Resources
|
|
571
|
+
archives: Archives
|
|
572
|
+
|
|
573
|
+
# Folder/name words that signal an ongoing responsibility (Areas).
|
|
574
|
+
areaKeywords: [finance, finances, tax, taxes, invoice, invoices, receipt, insurance, health, medical, home, car, contract, contracts, legal, admin, hr, utilities]
|
|
575
|
+
|
|
576
|
+
# File categories that are reference material by nature (Resources).
|
|
577
|
+
resourceCategories: [books, fonts, design]
|
|
578
|
+
|
|
579
|
+
# Folder/name words that signal reference material (Resources).
|
|
580
|
+
resourceKeywords: [reference, template, templates, manual, manuals, docs, research, notes, inspiration, assets, library, guide, guides]
|
|
581
|
+
|
|
582
|
+
# Folder/name words that signal active efforts (Projects).
|
|
583
|
+
projectKeywords: [project, projects, client, launch, campaign, sprint, deliverable, proposal, draft]
|
|
584
|
+
|
|
585
|
+
# ─── Security ──────────────────────────────────────────────
|
|
586
|
+
security:
|
|
587
|
+
confirmDestructive: true
|
|
588
|
+
journalEnabled: true
|
|
589
|
+
journalPath: .filemayor-journal.json
|
|
590
|
+
`;
|
|
591
|
+
}
|
|
592
|
+
|
|
535
593
|
/**
|
|
536
594
|
* Create a config file in the specified directory
|
|
537
595
|
* @param {string} dir - Directory to create config in
|
|
538
596
|
* @param {string} filename - Config filename
|
|
597
|
+
* @param {string} preset - Template preset: 'default' | 'para'
|
|
539
598
|
* @returns {string} Path to created file
|
|
540
599
|
*/
|
|
541
|
-
function createConfigFile(dir = process.cwd(), filename = '.filemayor.yml') {
|
|
600
|
+
function createConfigFile(dir = process.cwd(), filename = '.filemayor.yml', preset = 'default') {
|
|
542
601
|
const configPath = path.join(dir, filename);
|
|
543
602
|
if (fs.existsSync(configPath)) {
|
|
544
603
|
throw new Error(`Config file already exists: ${configPath}`);
|
|
545
604
|
}
|
|
546
|
-
|
|
605
|
+
const template = preset === 'para' ? generateParaTemplate() : generateTemplate();
|
|
606
|
+
fs.writeFileSync(configPath, template, 'utf8');
|
|
547
607
|
return configPath;
|
|
548
608
|
}
|
|
549
609
|
|
|
@@ -558,5 +618,6 @@ module.exports = {
|
|
|
558
618
|
expandVariables,
|
|
559
619
|
parseSimpleYaml,
|
|
560
620
|
generateTemplate,
|
|
621
|
+
generateParaTemplate,
|
|
561
622
|
createConfigFile
|
|
562
623
|
};
|
|
@@ -3,43 +3,125 @@
|
|
|
3
3
|
const { scan, formatBytes } = require('../scanner');
|
|
4
4
|
const crypto = require('crypto');
|
|
5
5
|
const fs = require('fs');
|
|
6
|
+
const fsp = require('fs').promises;
|
|
7
|
+
const FileMayorFS = require('../fs-abstraction');
|
|
8
|
+
|
|
9
|
+
const PARTIAL_BYTES = 64 * 1024; // head sample for the cheap pass
|
|
10
|
+
const HASH_CONCURRENCY = 8; // parallel file reads for hashing
|
|
11
|
+
|
|
12
|
+
/** Run an async mapper over items with bounded concurrency. */
|
|
13
|
+
async function pooled(items, limit, fn) {
|
|
14
|
+
const results = new Array(items.length);
|
|
15
|
+
let next = 0;
|
|
16
|
+
async function worker() {
|
|
17
|
+
while (next < items.length) {
|
|
18
|
+
const i = next++;
|
|
19
|
+
results[i] = await fn(items[i], i);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
23
|
+
return results;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** SHA-1 of the first PARTIAL_BYTES of a file (cheap collision filter). */
|
|
27
|
+
async function partialHash(filePath) {
|
|
28
|
+
const fd = await fsp.open(filePath, 'r');
|
|
29
|
+
try {
|
|
30
|
+
const buf = Buffer.alloc(PARTIAL_BYTES);
|
|
31
|
+
const { bytesRead } = await fd.read(buf, 0, PARTIAL_BYTES, 0);
|
|
32
|
+
return crypto.createHash('sha1').update(buf.subarray(0, bytesRead)).digest('hex');
|
|
33
|
+
} finally {
|
|
34
|
+
await fd.close();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Streaming SHA-1 of the whole file — constant memory at any size. */
|
|
39
|
+
function fullHash(filePath) {
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const hash = crypto.createHash('sha1');
|
|
42
|
+
fs.createReadStream(filePath)
|
|
43
|
+
.on('data', (chunk) => hash.update(chunk))
|
|
44
|
+
.on('end', () => resolve(hash.digest('hex')))
|
|
45
|
+
.on('error', reject);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
6
48
|
|
|
7
49
|
/**
|
|
8
|
-
* DedupeEngine —
|
|
50
|
+
* DedupeEngine — finds and removes duplicate files.
|
|
51
|
+
*
|
|
52
|
+
* v4.0.9: detection is genuinely content-based (it used to match on
|
|
53
|
+
* size+name, despite the docs saying "hash-based"). Three-stage pipeline
|
|
54
|
+
* keeps it fast on huge folders:
|
|
55
|
+
* 1. size buckets — only sizes shared by 2+ files go further
|
|
56
|
+
* 2. partial hash — first 64 KB, eliminates most collisions cheaply
|
|
57
|
+
* 3. full hash — streamed, only for files whose heads collide
|
|
58
|
+
* Removal is trash-based and journaled: `filemayor undo` restores.
|
|
9
59
|
*/
|
|
10
60
|
class DedupeEngine {
|
|
11
|
-
constructor() {
|
|
61
|
+
constructor(options = {}) {
|
|
62
|
+
this.fmfs = options.fmfs || new FileMayorFS(options.fsOptions || {});
|
|
63
|
+
}
|
|
12
64
|
|
|
13
65
|
/**
|
|
14
|
-
* Find duplicates in a directory
|
|
15
|
-
* @param {string} dirPath
|
|
16
|
-
* @returns {Object} Duplicate report
|
|
66
|
+
* Find duplicates in a directory (content-hash based).
|
|
67
|
+
* @param {string} dirPath
|
|
68
|
+
* @returns {Promise<Object>} Duplicate report
|
|
17
69
|
*/
|
|
18
|
-
find(dirPath) {
|
|
70
|
+
async find(dirPath) {
|
|
19
71
|
const result = scan(dirPath, { maxDepth: 10 });
|
|
20
|
-
const map = new Map();
|
|
21
|
-
const duplicates = [];
|
|
22
|
-
let totalWasted = 0;
|
|
23
72
|
|
|
73
|
+
// Stage 1 — size buckets. A file can only duplicate a same-sized file.
|
|
74
|
+
const bySize = new Map();
|
|
24
75
|
for (const file of result.files) {
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
76
|
+
if (!file.size) continue; // zero-byte files: skip (all "identical", rarely useful)
|
|
77
|
+
if (!bySize.has(file.size)) bySize.set(file.size, []);
|
|
78
|
+
bySize.get(file.size).push(file);
|
|
79
|
+
}
|
|
80
|
+
const candidates = [...bySize.values()].filter(g => g.length > 1).flat();
|
|
81
|
+
|
|
82
|
+
// Stage 2 — partial hash over candidates only.
|
|
83
|
+
const partials = await pooled(candidates, HASH_CONCURRENCY, async (f) => {
|
|
84
|
+
try { return `${f.size}:${await partialHash(f.path)}`; } catch { return null; }
|
|
85
|
+
});
|
|
86
|
+
const byPartial = new Map();
|
|
87
|
+
candidates.forEach((f, i) => {
|
|
88
|
+
const key = partials[i];
|
|
89
|
+
if (!key) return;
|
|
90
|
+
if (!byPartial.has(key)) byPartial.set(key, []);
|
|
91
|
+
byPartial.get(key).push(f);
|
|
92
|
+
});
|
|
93
|
+
const finalists = [...byPartial.values()].filter(g => g.length > 1).flat();
|
|
94
|
+
|
|
95
|
+
// Stage 3 — full streamed hash for head-colliders.
|
|
96
|
+
const fulls = await pooled(finalists, HASH_CONCURRENCY, async (f) => {
|
|
97
|
+
try { return `${f.size}:${await fullHash(f.path)}`; } catch { return null; }
|
|
98
|
+
});
|
|
99
|
+
const byFull = new Map();
|
|
100
|
+
finalists.forEach((f, i) => {
|
|
101
|
+
const key = fulls[i];
|
|
102
|
+
if (!key) return;
|
|
103
|
+
if (!byFull.has(key)) byFull.set(key, []);
|
|
104
|
+
byFull.get(key).push(f);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const duplicates = [];
|
|
108
|
+
let totalWasted = 0;
|
|
109
|
+
for (const group of byFull.values()) {
|
|
110
|
+
if (group.length < 2) continue;
|
|
111
|
+
// Keep the oldest copy as the original; the rest are duplicates.
|
|
112
|
+
group.sort((a, b) => new Date(a.modified) - new Date(b.modified));
|
|
113
|
+
const original = group[0];
|
|
114
|
+
for (const dupe of group.slice(1)) {
|
|
115
|
+
duplicates.push({ original, duplicate: dupe });
|
|
116
|
+
totalWasted += dupe.size;
|
|
38
117
|
}
|
|
39
118
|
}
|
|
40
119
|
|
|
41
120
|
return {
|
|
42
121
|
path: dirPath,
|
|
122
|
+
method: 'content-hash',
|
|
123
|
+
scanned: result.files.length,
|
|
124
|
+
hashed: finalists.length,
|
|
43
125
|
sets: duplicates.length,
|
|
44
126
|
totalWasted,
|
|
45
127
|
totalWastedHuman: formatBytes(totalWasted),
|
|
@@ -48,7 +130,8 @@ class DedupeEngine {
|
|
|
48
130
|
}
|
|
49
131
|
|
|
50
132
|
/**
|
|
51
|
-
* Remove duplicates based
|
|
133
|
+
* Remove duplicates from a report. Trash-based: every removal is
|
|
134
|
+
* journaled and reversible with `filemayor undo`.
|
|
52
135
|
*/
|
|
53
136
|
async clean(report) {
|
|
54
137
|
let deleted = 0;
|
|
@@ -57,7 +140,7 @@ class DedupeEngine {
|
|
|
57
140
|
|
|
58
141
|
for (const set of report.duplicates) {
|
|
59
142
|
try {
|
|
60
|
-
|
|
143
|
+
await this.fmfs.delete(set.duplicate.path);
|
|
61
144
|
deleted++;
|
|
62
145
|
freed += set.duplicate.size;
|
|
63
146
|
} catch (err) {
|
|
@@ -69,7 +152,10 @@ class DedupeEngine {
|
|
|
69
152
|
deleted,
|
|
70
153
|
freed,
|
|
71
154
|
freedHuman: formatBytes(freed),
|
|
72
|
-
errors
|
|
155
|
+
errors,
|
|
156
|
+
reversible: true,
|
|
157
|
+
trashPath: this.fmfs.options.trashPath,
|
|
158
|
+
sessionId: this.fmfs.sessionId
|
|
73
159
|
};
|
|
74
160
|
}
|
|
75
161
|
}
|