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
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ═══════════════════════════════════════════════════════════════════
|
|
3
|
+
* FILEMAYOR — PARA ENGINE
|
|
4
|
+
* Deterministic actionability sorter implementing Tiago Forte's PARA
|
|
5
|
+
* method (Projects · Areas · Resources · Archives).
|
|
6
|
+
*
|
|
7
|
+
* PARA organizes by *actionability*, not by file type. A pure semantic
|
|
8
|
+
* judgement (is this an active Project or an ongoing Area?) needs a
|
|
9
|
+
* human or an LLM — so this engine is deliberately honest: it sorts on
|
|
10
|
+
* the signals it can actually read (recency, category, folder names,
|
|
11
|
+
* project substrates) and labels every move with the reason it chose.
|
|
12
|
+
* Where it cannot tell, it says so (warnings) rather than guessing
|
|
13
|
+
* confidently. Semantic refinement is what the AI path is for.
|
|
14
|
+
*
|
|
15
|
+
* Output shape matches the Curative Plan consumed by ApplyEngine:
|
|
16
|
+
* { narrative, confidence, warnings, plan: [{ source, destination,
|
|
17
|
+
* reason, bucket }], summary }
|
|
18
|
+
* ═══════════════════════════════════════════════════════════════════
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
'use strict';
|
|
22
|
+
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const { scan } = require('../scanner');
|
|
25
|
+
const { findNearestSubstrate } = require('../security');
|
|
26
|
+
|
|
27
|
+
// ─── PARA Defaults ────────────────────────────────────────────────
|
|
28
|
+
// Mirrors config.para in cli/core/config.js. Anything passed to the
|
|
29
|
+
// constructor overrides these per-field.
|
|
30
|
+
const DEFAULT_PARA = {
|
|
31
|
+
archiveAfterMonths: 12, // not modified in ≥ this many months → Archives
|
|
32
|
+
activeWithinMonths: 3, // modified within this window → Projects (active work)
|
|
33
|
+
defaultBucket: 'resources', // where files with no actionability signal land
|
|
34
|
+
preserveSubfolders: true, // keep the top-level subfolder name as a grouping
|
|
35
|
+
folders: {
|
|
36
|
+
projects: 'Projects',
|
|
37
|
+
areas: 'Areas',
|
|
38
|
+
resources: 'Resources',
|
|
39
|
+
archives: 'Archives',
|
|
40
|
+
},
|
|
41
|
+
// Ongoing responsibilities you maintain indefinitely (Areas).
|
|
42
|
+
areaKeywords: [
|
|
43
|
+
'finance', 'finances', 'financial', 'tax', 'taxes', 'invoice', 'invoices',
|
|
44
|
+
'receipt', 'receipts', 'banking', 'payroll', 'payslip', 'insurance',
|
|
45
|
+
'health', 'medical', 'fitness', 'home', 'house', 'car', 'vehicle',
|
|
46
|
+
'contract', 'contracts', 'legal', 'admin', 'hr', 'utilities', 'pet', 'pets',
|
|
47
|
+
],
|
|
48
|
+
// File categories that are reference material by nature (Resources).
|
|
49
|
+
resourceCategories: ['books', 'fonts', 'design'],
|
|
50
|
+
// Folder names that signal reference/interest material (Resources).
|
|
51
|
+
resourceKeywords: [
|
|
52
|
+
'reference', 'references', 'template', 'templates', 'manual', 'manuals',
|
|
53
|
+
'docs', 'documentation', 'research', 'notes', 'inspiration', 'assets',
|
|
54
|
+
'library', 'resource', 'resources', 'guide', 'guides', 'cheatsheet',
|
|
55
|
+
],
|
|
56
|
+
// Folder/name signals for active efforts with an end (Projects).
|
|
57
|
+
projectKeywords: [
|
|
58
|
+
'project', 'projects', 'client', 'clients', 'launch', 'campaign',
|
|
59
|
+
'sprint', 'deliverable', 'proposal', 'wip', 'draft', 'milestone',
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const MONTH_MS = 1000 * 60 * 60 * 24 * 30.44; // average month
|
|
64
|
+
|
|
65
|
+
class PARAEngine {
|
|
66
|
+
/**
|
|
67
|
+
* @param {Object} paraConfig - Overrides for DEFAULT_PARA (typically config.para)
|
|
68
|
+
*/
|
|
69
|
+
constructor(paraConfig = {}) {
|
|
70
|
+
this.config = {
|
|
71
|
+
...DEFAULT_PARA,
|
|
72
|
+
...paraConfig,
|
|
73
|
+
folders: { ...DEFAULT_PARA.folders, ...(paraConfig.folders || {}) },
|
|
74
|
+
};
|
|
75
|
+
// The four bucket folder names, lowercased, for idempotency checks.
|
|
76
|
+
this._bucketNames = new Set(
|
|
77
|
+
Object.values(this.config.folders).map(f => f.toLowerCase())
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Generate a PARA reorganisation plan for a directory (read-only).
|
|
83
|
+
* @param {string} dirPath - Directory to organise
|
|
84
|
+
* @param {Object} [options]
|
|
85
|
+
* @param {number} [options.now] - Reference timestamp (ms) — injectable for tests
|
|
86
|
+
* @returns {Object} Curative-plan-shaped result
|
|
87
|
+
*/
|
|
88
|
+
plan(dirPath, options = {}) {
|
|
89
|
+
const root = path.resolve(dirPath);
|
|
90
|
+
const now = options.now || Date.now();
|
|
91
|
+
|
|
92
|
+
const scanResult = scan(root, { ...(options.scanOptions || {}), includeDirectories: false });
|
|
93
|
+
|
|
94
|
+
const plan = [];
|
|
95
|
+
const counts = { projects: 0, areas: 0, resources: 0, archives: 0 };
|
|
96
|
+
const seenDest = new Set();
|
|
97
|
+
const movedSubstrates = new Set();
|
|
98
|
+
let noSignalCount = 0;
|
|
99
|
+
let alreadySorted = 0;
|
|
100
|
+
|
|
101
|
+
for (const file of scanResult.files) {
|
|
102
|
+
const rel = path.relative(root, file.path);
|
|
103
|
+
const segments = rel.split(path.sep);
|
|
104
|
+
|
|
105
|
+
// Idempotency: skip anything already living under a PARA bucket.
|
|
106
|
+
if (segments.length > 1 && this._bucketNames.has(segments[0].toLowerCase())) {
|
|
107
|
+
alreadySorted++;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Project substrates (.git, package.json, .als, …) move as one
|
|
112
|
+
// atomic unit into Projects — never shredded file-by-file.
|
|
113
|
+
const substrate = findNearestSubstrate(file.path);
|
|
114
|
+
if (substrate) {
|
|
115
|
+
const substrateRoot = path.resolve(substrate);
|
|
116
|
+
// A substrate that IS the target dir can't be PARA-organised.
|
|
117
|
+
if (substrateRoot === root) continue;
|
|
118
|
+
if (movedSubstrates.has(substrateRoot)) continue;
|
|
119
|
+
movedSubstrates.add(substrateRoot);
|
|
120
|
+
|
|
121
|
+
const dest = this._dest(root, 'projects', [path.basename(substrateRoot)]);
|
|
122
|
+
plan.push({
|
|
123
|
+
source: substrateRoot,
|
|
124
|
+
destination: dest,
|
|
125
|
+
bucket: 'projects',
|
|
126
|
+
reason: `Active project (contains ${path.basename(substrate) === path.basename(substrateRoot) ? 'a project marker' : 'project files'}) — moved intact. COLLECTION_MOVE`,
|
|
127
|
+
});
|
|
128
|
+
counts.projects++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { bucket, reason, noSignal } = this._classify(file, rel, now);
|
|
133
|
+
if (noSignal) noSignalCount++;
|
|
134
|
+
counts[bucket]++;
|
|
135
|
+
|
|
136
|
+
// Preserve the top-level subfolder as a grouping inside the bucket.
|
|
137
|
+
const sub = (this.config.preserveSubfolders && segments.length > 1)
|
|
138
|
+
? [segments[0]]
|
|
139
|
+
: [];
|
|
140
|
+
let dest = this._dest(root, bucket, [...sub, file.name]);
|
|
141
|
+
|
|
142
|
+
// Avoid two sources colliding on one destination within the plan.
|
|
143
|
+
if (seenDest.has(dest.toLowerCase())) {
|
|
144
|
+
const parsed = path.parse(dest);
|
|
145
|
+
dest = path.join(parsed.dir, `${parsed.name}_${counts[bucket]}${parsed.ext}`);
|
|
146
|
+
}
|
|
147
|
+
seenDest.add(dest.toLowerCase());
|
|
148
|
+
|
|
149
|
+
plan.push({ source: file.path, destination: dest, bucket, reason });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const warnings = [];
|
|
153
|
+
if (noSignalCount > 0) {
|
|
154
|
+
warnings.push(
|
|
155
|
+
`${noSignalCount} file(s) had no clear actionability signal and were placed in ` +
|
|
156
|
+
`${this.config.folders[this.config.defaultBucket]} for you to review.`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (alreadySorted > 0) {
|
|
160
|
+
warnings.push(`${alreadySorted} file(s) were already inside a PARA folder and left untouched.`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
mode: 'deterministic',
|
|
165
|
+
method: 'PARA',
|
|
166
|
+
narrative: this._narrative(counts, plan.length),
|
|
167
|
+
confidence: 100, // deterministic — every move is rule-derived
|
|
168
|
+
warnings,
|
|
169
|
+
plan,
|
|
170
|
+
summary: {
|
|
171
|
+
totalMoves: plan.length,
|
|
172
|
+
buckets: counts,
|
|
173
|
+
scanned: scanResult.files.length,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Create and populate the four PARA folders with teaching READMEs.
|
|
180
|
+
* Idempotent — existing folders/READMEs are left in place.
|
|
181
|
+
* @param {string} dirPath - Where to scaffold
|
|
182
|
+
* @returns {{ created: string[], folders: Object }}
|
|
183
|
+
*/
|
|
184
|
+
scaffold(dirPath) {
|
|
185
|
+
const fs = require('fs');
|
|
186
|
+
const root = path.resolve(dirPath);
|
|
187
|
+
const created = [];
|
|
188
|
+
|
|
189
|
+
for (const key of ['projects', 'areas', 'resources', 'archives']) {
|
|
190
|
+
const folder = path.join(root, this.config.folders[key]);
|
|
191
|
+
if (!fs.existsSync(folder)) {
|
|
192
|
+
fs.mkdirSync(folder, { recursive: true });
|
|
193
|
+
created.push(folder);
|
|
194
|
+
}
|
|
195
|
+
const readme = path.join(folder, 'README.md');
|
|
196
|
+
if (!fs.existsSync(readme)) {
|
|
197
|
+
fs.writeFileSync(readme, PARA_READMES[key], 'utf8');
|
|
198
|
+
created.push(readme);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { created, folders: this.config.folders };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ─── Internals ────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Decide a file's PARA bucket from the signals we can actually read.
|
|
209
|
+
*
|
|
210
|
+
* Order is deliberate: EXPLICIT INTENT first (file category and the
|
|
211
|
+
* folder names the user themselves chose), then INFERRED TIME signals
|
|
212
|
+
* as the fallback for loose, unfiled files. Rationale — if you already
|
|
213
|
+
* filed something under finances/ or templates/, honour that meaning
|
|
214
|
+
* regardless of age; don't fragment a maintained folder by date. The
|
|
215
|
+
* "stale → Archives" automation then targets exactly the loose junk
|
|
216
|
+
* where it's most valuable. Every branch states its reason.
|
|
217
|
+
*/
|
|
218
|
+
_classify(file, rel, now) {
|
|
219
|
+
const hay = rel.toLowerCase();
|
|
220
|
+
const cat = file.category || 'other';
|
|
221
|
+
const ageMonths = this._ageMonths(file, now);
|
|
222
|
+
|
|
223
|
+
// ── Explicit intent ──────────────────────────────────────
|
|
224
|
+
// 1. Reference by nature — books, fonts, design assets.
|
|
225
|
+
if (this.config.resourceCategories.includes(cat)) {
|
|
226
|
+
return { bucket: 'resources', reason: `Reference material (${cat})` };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 2. Ongoing responsibility — a maintained Area (finances/, health/…).
|
|
230
|
+
const areaHit = this._keywordHit(hay, this.config.areaKeywords);
|
|
231
|
+
if (areaHit) {
|
|
232
|
+
return { bucket: 'areas', reason: `Ongoing responsibility (matched "${areaHit}")` };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 3. Reference by folder name (reference/, templates/, …).
|
|
236
|
+
const resourceHit = this._keywordHit(hay, this.config.resourceKeywords);
|
|
237
|
+
if (resourceHit) {
|
|
238
|
+
return { bucket: 'resources', reason: `Reference material (matched "${resourceHit}")` };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 4. Explicit active-effort signal (client-*, *-launch, project/…).
|
|
242
|
+
const projectHit = this._keywordHit(hay, this.config.projectKeywords);
|
|
243
|
+
if (projectHit) {
|
|
244
|
+
return { bucket: 'projects', reason: `Active project (matched "${projectHit}")` };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Inferred time signals (loose, unfiled files only) ─────
|
|
248
|
+
// 5. Inactive — not modified in a long time → Archives. The single
|
|
249
|
+
// most automatable PARA decision, and the one humans avoid.
|
|
250
|
+
if (ageMonths !== null && ageMonths >= this.config.archiveAfterMonths) {
|
|
251
|
+
return { bucket: 'archives', reason: `Not modified in ${Math.round(ageMonths)} months` };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 6. Recently active loose work → Projects.
|
|
255
|
+
if (ageMonths !== null && ageMonths <= this.config.activeWithinMonths) {
|
|
256
|
+
return { bucket: 'projects', reason: `Recently active (modified ${Math.round(ageMonths)} month(s) ago)` };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 7. No clear actionability signal — honest fallback, flagged for review.
|
|
260
|
+
const fallback = this.config.defaultBucket;
|
|
261
|
+
return {
|
|
262
|
+
bucket: fallback,
|
|
263
|
+
reason: 'No clear actionability signal — placed here for review',
|
|
264
|
+
noSignal: true,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
_ageMonths(file, now) {
|
|
269
|
+
const t = this._mtime(file);
|
|
270
|
+
if (t === null) return null;
|
|
271
|
+
return (now - t) / MONTH_MS;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
_mtime(file) {
|
|
275
|
+
const m = file.modified || file.created;
|
|
276
|
+
if (!m) return null;
|
|
277
|
+
const t = (m instanceof Date) ? m.getTime() : new Date(m).getTime();
|
|
278
|
+
return Number.isFinite(t) ? t : null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
_keywordHit(haystack, keywords) {
|
|
282
|
+
for (const kw of keywords) {
|
|
283
|
+
// Word-ish boundary so "car" doesn't match "scary".
|
|
284
|
+
const re = new RegExp(`(^|[^a-z0-9])${escapeRe(kw)}([^a-z0-9]|$)`, 'i');
|
|
285
|
+
if (re.test(haystack)) return kw;
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
_dest(root, bucketKey, parts) {
|
|
291
|
+
return path.join(root, this.config.folders[bucketKey], ...parts);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
_narrative(counts, total) {
|
|
295
|
+
if (total === 0) return 'Nothing to do — this folder is already PARA-organised or empty.';
|
|
296
|
+
const bits = [];
|
|
297
|
+
if (counts.projects) bits.push(`${counts.projects} to Projects`);
|
|
298
|
+
if (counts.areas) bits.push(`${counts.areas} to Areas`);
|
|
299
|
+
if (counts.resources) bits.push(`${counts.resources} to Resources`);
|
|
300
|
+
if (counts.archives) bits.push(`${counts.archives} to Archives`);
|
|
301
|
+
return `PARA plan: ${bits.join(', ')}. Every move is reversible after apply.`;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function escapeRe(s) {
|
|
306
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ─── Teaching READMEs (the democratization layer) ─────────────────
|
|
310
|
+
// Plain-English so a first-timer who has never heard of PARA gets it.
|
|
311
|
+
const PARA_READMES = {
|
|
312
|
+
projects: `# Projects
|
|
313
|
+
|
|
314
|
+
**Active efforts with a goal and an end.** One folder per project.
|
|
315
|
+
|
|
316
|
+
A project is anything you are *currently* working toward finishing:
|
|
317
|
+
a trip you're planning, a deliverable due next month, a room you're
|
|
318
|
+
renovating. When a project is done, move its folder to \`Archives\`.
|
|
319
|
+
|
|
320
|
+
FileMayor places recently-modified files and anything that looks like
|
|
321
|
+
active work here. You stay in control — review before applying.
|
|
322
|
+
|
|
323
|
+
_PARA is a method created by Tiago Forte (Building a Second Brain).
|
|
324
|
+
FileMayor automates it; it is not affiliated with or endorsed by him._
|
|
325
|
+
`,
|
|
326
|
+
areas: `# Areas
|
|
327
|
+
|
|
328
|
+
**Ongoing responsibilities you maintain — with no end date.**
|
|
329
|
+
|
|
330
|
+
Areas are the standing parts of your life and work: Finances, Health,
|
|
331
|
+
Home, a job role, a car you own. There's no "done" — you just keep them
|
|
332
|
+
in good order. One folder per area (e.g. \`Finances\`, \`Health\`).
|
|
333
|
+
|
|
334
|
+
FileMayor routes files whose names or folders match common areas
|
|
335
|
+
(taxes, invoices, insurance, health…) here.
|
|
336
|
+
|
|
337
|
+
_PARA method by Tiago Forte. FileMayor automates it; not affiliated._
|
|
338
|
+
`,
|
|
339
|
+
resources: `# Resources
|
|
340
|
+
|
|
341
|
+
**Topics and reference material you may want later.**
|
|
342
|
+
|
|
343
|
+
Things that aren't tied to a current project or a responsibility, but
|
|
344
|
+
are worth keeping: reference docs, templates, books, fonts, design
|
|
345
|
+
assets, inspiration. One folder per topic.
|
|
346
|
+
|
|
347
|
+
FileMayor places reference-type files (books, fonts, design) and
|
|
348
|
+
anything in \`reference/\`, \`templates/\`, etc. here.
|
|
349
|
+
|
|
350
|
+
_PARA method by Tiago Forte. FileMayor automates it; not affiliated._
|
|
351
|
+
`,
|
|
352
|
+
archives: `# Archives
|
|
353
|
+
|
|
354
|
+
**Inactive items from the other three categories.**
|
|
355
|
+
|
|
356
|
+
Nothing is deleted — Archives is cold storage. When a project finishes
|
|
357
|
+
or a resource stops being useful, it moves here, out of the way but
|
|
358
|
+
recoverable.
|
|
359
|
+
|
|
360
|
+
FileMayor automatically moves files you haven't touched in a long time
|
|
361
|
+
(12+ months by default) here. This is the chore PARA users hate doing
|
|
362
|
+
by hand — so FileMayor does it for you.
|
|
363
|
+
|
|
364
|
+
_PARA method by Tiago Forte. FileMayor automates it; not affiliated._
|
|
365
|
+
`,
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
module.exports = PARAEngine;
|
|
369
|
+
module.exports.DEFAULT_PARA = DEFAULT_PARA;
|
|
370
|
+
module.exports.PARA_READMES = PARA_READMES;
|
package/core/fs-abstraction.js
CHANGED
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* ═══════════════════════════════════════════════════════════════════
|
|
5
|
-
* FILEMAYOR FS
|
|
5
|
+
* FILEMAYOR FS v4.1 — INTENT-READY FILESYSTEM ABSTRACTION
|
|
6
6
|
* Async, safe, transactional, and fully rollbackable.
|
|
7
|
+
*
|
|
8
|
+
* v4.0.9: deletes are trash-based and journaled (undo restores them),
|
|
9
|
+
* the trash lives in the user's home dir (survives reboots — it used
|
|
10
|
+
* to sit in os.tmpdir()), and every journal entry carries a sessionId
|
|
11
|
+
* so `undo` can target a specific past session.
|
|
7
12
|
* ═══════════════════════════════════════════════════════════════════
|
|
8
13
|
*/
|
|
9
14
|
|
|
@@ -17,15 +22,23 @@ const crypto = require('crypto');
|
|
|
17
22
|
const { validatePath, isFileSafe, isDirSafe, canRead, canWrite } = require('./security');
|
|
18
23
|
const { formatBytes } = require('./scanner');
|
|
19
24
|
|
|
25
|
+
const JOURNAL_PATH = path.join(os.homedir(), '.filemayor-master-journal.ndjson');
|
|
26
|
+
// Durable, FileMayor-managed trash. NOT os.tmpdir() — that gets wiped on
|
|
27
|
+
// reboot, which would silently break the "deletions are reversible" promise.
|
|
28
|
+
const TRASH_PATH = path.join(os.homedir(), '.filemayor-trash');
|
|
29
|
+
|
|
20
30
|
class FileMayorFS {
|
|
21
31
|
constructor(options = {}) {
|
|
22
32
|
this.options = {
|
|
23
33
|
useJournal: true,
|
|
24
34
|
dryRun: false,
|
|
25
|
-
trashPath:
|
|
26
|
-
journalPath:
|
|
35
|
+
trashPath: TRASH_PATH,
|
|
36
|
+
journalPath: JOURNAL_PATH,
|
|
27
37
|
...options
|
|
28
38
|
};
|
|
39
|
+
// One session per FileMayorFS instance — every CLI/MCP/desktop
|
|
40
|
+
// operation batch gets its own id, so undo can address it later.
|
|
41
|
+
this.sessionId = crypto.randomUUID().slice(0, 8);
|
|
29
42
|
this.sessionJournal = [];
|
|
30
43
|
this.stats = {
|
|
31
44
|
opsSucceeded: 0,
|
|
@@ -81,14 +94,7 @@ class FileMayorFS {
|
|
|
81
94
|
const opId = crypto.randomUUID();
|
|
82
95
|
await this._journalPending(opId, source, destination, 'move', snapshot);
|
|
83
96
|
|
|
84
|
-
|
|
85
|
-
await fs.rename(source, destination);
|
|
86
|
-
} catch (err) {
|
|
87
|
-
if (err.code === 'EXDEV') {
|
|
88
|
-
await fs.copyFile(source, destination);
|
|
89
|
-
await fs.unlink(source);
|
|
90
|
-
} else throw err;
|
|
91
|
-
}
|
|
97
|
+
await this._rename(source, destination);
|
|
92
98
|
|
|
93
99
|
// WAL: record completion AFTER the operation
|
|
94
100
|
await this._journalDone(opId);
|
|
@@ -109,26 +115,56 @@ class FileMayorFS {
|
|
|
109
115
|
}
|
|
110
116
|
|
|
111
117
|
/** ------------------------
|
|
112
|
-
* Safe Delete
|
|
118
|
+
* Safe Delete → Trash (journaled, undoable)
|
|
113
119
|
* -------------------------*/
|
|
114
120
|
async delete(target) {
|
|
115
|
-
|
|
121
|
+
let isDir = false;
|
|
122
|
+
try {
|
|
123
|
+
isDir = (await fs.stat(target)).isDirectory();
|
|
124
|
+
} catch { /* stat below via snapshot; safety checks will surface errors */ }
|
|
125
|
+
|
|
126
|
+
const check = isDir ? isDirSafe(target) : isFileSafe(target);
|
|
116
127
|
if (!check.safe) throw new Error(`Target unsafe: ${check.reason}`);
|
|
117
128
|
|
|
118
129
|
const snapshot = await this.createSnapshot(target);
|
|
119
130
|
|
|
120
131
|
if (this.options.dryRun) return { target, status: 'dry-run' };
|
|
121
132
|
|
|
122
|
-
|
|
123
|
-
|
|
133
|
+
// Unique trash destination — timestamp + random slug prevents
|
|
134
|
+
// collisions when many same-named files are trashed in one batch.
|
|
135
|
+
const slug = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
|
|
136
|
+
const trashFile = path.join(this.options.trashPath, `${slug}-${path.basename(target)}`);
|
|
137
|
+
|
|
138
|
+
// WAL: journal BEFORE the move so a crash mid-delete is recoverable.
|
|
139
|
+
// Journaled as a normal source→destination pair: rollback's generic
|
|
140
|
+
// "rename(destination, source)" restores the file from the trash.
|
|
141
|
+
const opId = crypto.randomUUID();
|
|
142
|
+
await this._journalPending(opId, target, trashFile, 'delete', snapshot);
|
|
143
|
+
await this._rename(target, trashFile);
|
|
144
|
+
await this._journalDone(opId);
|
|
124
145
|
|
|
125
146
|
this.stats.opsSucceeded++;
|
|
126
147
|
this.stats.filesDeleted++;
|
|
127
148
|
this.stats.totalBytesProcessed += snapshot.size || 0;
|
|
128
149
|
|
|
129
|
-
|
|
150
|
+
return { target, trashFile, status: 'success' };
|
|
151
|
+
}
|
|
130
152
|
|
|
131
|
-
|
|
153
|
+
/** Rename with cross-device fallback (files and directories). */
|
|
154
|
+
async _rename(source, destination) {
|
|
155
|
+
try {
|
|
156
|
+
await fs.rename(source, destination);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (err.code !== 'EXDEV') throw err;
|
|
159
|
+
const st = await fs.stat(source);
|
|
160
|
+
if (st.isDirectory()) {
|
|
161
|
+
await fs.cp(source, destination, { recursive: true });
|
|
162
|
+
await fs.rm(source, { recursive: true, force: true });
|
|
163
|
+
} else {
|
|
164
|
+
await fs.copyFile(source, destination);
|
|
165
|
+
await fs.unlink(source);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
132
168
|
}
|
|
133
169
|
|
|
134
170
|
/** ------------------------
|
|
@@ -144,15 +180,10 @@ class FileMayorFS {
|
|
|
144
180
|
}
|
|
145
181
|
}
|
|
146
182
|
|
|
147
|
-
async _logToJournal(source, destination, action, snapshot) {
|
|
148
|
-
// WAL: record intent BEFORE operation (called pre-move),
|
|
149
|
-
// then update to 'done' AFTER (called post-move).
|
|
150
|
-
// This is handled by _journalPending / _journalDone.
|
|
151
|
-
}
|
|
152
|
-
|
|
153
183
|
async _journalPending(id, source, destination, action, snapshot) {
|
|
154
184
|
const entry = {
|
|
155
185
|
id,
|
|
186
|
+
sessionId: this.sessionId,
|
|
156
187
|
timestamp: new Date().toISOString(),
|
|
157
188
|
action,
|
|
158
189
|
source,
|
|
@@ -170,32 +201,20 @@ class FileMayorFS {
|
|
|
170
201
|
}
|
|
171
202
|
|
|
172
203
|
/** ------------------------
|
|
173
|
-
*
|
|
204
|
+
* Crash-recovery rollback: reverses operations that never completed
|
|
205
|
+
* (pending but not done). For undoing COMPLETED sessions, use the
|
|
206
|
+
* standalone rollback()/listSessions() below.
|
|
174
207
|
* -------------------------*/
|
|
175
208
|
async rollback() {
|
|
176
|
-
const entries =
|
|
177
|
-
const done = new Set();
|
|
178
|
-
|
|
179
|
-
if (fssync.existsSync(this.options.journalPath)) {
|
|
180
|
-
const lines = fssync.readFileSync(this.options.journalPath, 'utf8').split('\n');
|
|
181
|
-
for (const line of lines) {
|
|
182
|
-
if (!line.trim()) continue;
|
|
183
|
-
try {
|
|
184
|
-
const rec = JSON.parse(line);
|
|
185
|
-
if (rec.status === 'pending') entries.set(rec.id, rec);
|
|
186
|
-
if (rec.status === 'done') done.add(rec.id);
|
|
187
|
-
} catch { /* skip malformed lines */ }
|
|
188
|
-
}
|
|
189
|
-
}
|
|
209
|
+
const { entries, done, rolledBack } = _readJournal(this.options.journalPath);
|
|
190
210
|
|
|
191
211
|
// Add session-only pending entries not yet on disk
|
|
192
212
|
for (const e of this.sessionJournal) {
|
|
193
213
|
if (!entries.has(e.id)) entries.set(e.id, e);
|
|
194
214
|
}
|
|
195
215
|
|
|
196
|
-
// Reverse-order rollback of moves that never completed
|
|
197
216
|
const toRollback = [...entries.values()]
|
|
198
|
-
.filter(e => !done.has(e.id))
|
|
217
|
+
.filter(e => !done.has(e.id) && !rolledBack.has(e.id))
|
|
199
218
|
.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
200
219
|
|
|
201
220
|
console.log(`[SYS] Rolling back ${toRollback.length} incomplete operations...`);
|
|
@@ -220,16 +239,11 @@ class FileMayorFS {
|
|
|
220
239
|
}
|
|
221
240
|
}
|
|
222
241
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Standalone rollback function that reads the master journal on disk
|
|
227
|
-
* and reverses all completed operations (status === 'done').
|
|
228
|
-
* Returns { reversed: Array<entry>, count: number }.
|
|
229
|
-
*/
|
|
230
|
-
async function rollback(journalPath) {
|
|
242
|
+
/** Parse the NDJSON master journal into pending entries + status sets. */
|
|
243
|
+
function _readJournal(journalPath) {
|
|
231
244
|
const entries = new Map();
|
|
232
245
|
const done = new Set();
|
|
246
|
+
const rolledBack = new Set();
|
|
233
247
|
|
|
234
248
|
if (fssync.existsSync(journalPath)) {
|
|
235
249
|
const lines = fssync.readFileSync(journalPath, 'utf8').split('\n');
|
|
@@ -237,21 +251,76 @@ async function rollback(journalPath) {
|
|
|
237
251
|
if (!line.trim()) continue;
|
|
238
252
|
try {
|
|
239
253
|
const rec = JSON.parse(line);
|
|
240
|
-
if (rec.status === 'pending')
|
|
241
|
-
if (rec.status === 'done')
|
|
254
|
+
if (rec.status === 'pending') entries.set(rec.id, rec);
|
|
255
|
+
if (rec.status === 'done') done.add(rec.id);
|
|
256
|
+
if (rec.status === 'rolled-back') rolledBack.add(rec.id);
|
|
242
257
|
} catch { /* skip malformed lines */ }
|
|
243
258
|
}
|
|
244
259
|
}
|
|
260
|
+
return { entries, done, rolledBack };
|
|
261
|
+
}
|
|
245
262
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
263
|
+
/** Completed-and-not-yet-undone entries, newest first. */
|
|
264
|
+
function _completedEntries(journalPath) {
|
|
265
|
+
const { entries, done, rolledBack } = _readJournal(journalPath);
|
|
266
|
+
return [...entries.values()]
|
|
267
|
+
.filter(e => done.has(e.id) && !rolledBack.has(e.id))
|
|
249
268
|
.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* List undoable sessions from the master journal, newest first.
|
|
273
|
+
* Entries written before v4.0.9 (no sessionId) group under 'legacy'.
|
|
274
|
+
* @returns {Array<{sessionId, start, end, ops, moves, deletes}>}
|
|
275
|
+
*/
|
|
276
|
+
function listSessions(journalPath = JOURNAL_PATH) {
|
|
277
|
+
const sessions = new Map();
|
|
278
|
+
for (const e of _completedEntries(journalPath)) {
|
|
279
|
+
const sid = e.sessionId || 'legacy';
|
|
280
|
+
if (!sessions.has(sid)) {
|
|
281
|
+
sessions.set(sid, { sessionId: sid, start: e.timestamp, end: e.timestamp, ops: 0, moves: 0, deletes: 0 });
|
|
282
|
+
}
|
|
283
|
+
const s = sessions.get(sid);
|
|
284
|
+
s.ops++;
|
|
285
|
+
if (e.action === 'delete') s.deletes++;
|
|
286
|
+
else s.moves++;
|
|
287
|
+
if (e.timestamp < s.start) s.start = e.timestamp;
|
|
288
|
+
if (e.timestamp > s.end) s.end = e.timestamp;
|
|
289
|
+
}
|
|
290
|
+
return [...sessions.values()].sort((a, b) => b.end.localeCompare(a.end));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Undo completed operations from the master journal.
|
|
295
|
+
* @param {string} journalPath
|
|
296
|
+
* @param {Object} [options]
|
|
297
|
+
* @param {boolean} [options.all] Undo every completed session (pre-4.0.9 behaviour).
|
|
298
|
+
* @param {string} [options.sessionId] Undo one specific session.
|
|
299
|
+
* Default (no options): undo only the MOST RECENT session.
|
|
300
|
+
* Returns { reversed, count, sessionId }.
|
|
301
|
+
*/
|
|
302
|
+
async function rollback(journalPath = JOURNAL_PATH, options = {}) {
|
|
303
|
+
let toRollback = _completedEntries(journalPath);
|
|
304
|
+
let targetSession = null;
|
|
305
|
+
|
|
306
|
+
if (options.sessionId) {
|
|
307
|
+
targetSession = options.sessionId;
|
|
308
|
+
toRollback = toRollback.filter(e => (e.sessionId || 'legacy') === options.sessionId);
|
|
309
|
+
} else if (!options.all) {
|
|
310
|
+
// Latest session only — "undo" means "undo what just happened",
|
|
311
|
+
// not "unwind everything FileMayor has ever done".
|
|
312
|
+
const sessions = listSessions(journalPath);
|
|
313
|
+
if (sessions.length > 0) {
|
|
314
|
+
targetSession = sessions[0].sessionId;
|
|
315
|
+
toRollback = toRollback.filter(e => (e.sessionId || 'legacy') === targetSession);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
250
318
|
|
|
251
319
|
const reversed = [];
|
|
252
320
|
for (const entry of toRollback) {
|
|
253
321
|
try {
|
|
254
322
|
if (fssync.existsSync(entry.destination)) {
|
|
323
|
+
await fs.mkdir(path.dirname(entry.source), { recursive: true });
|
|
255
324
|
await fs.rename(entry.destination, entry.source);
|
|
256
325
|
fssync.appendFileSync(journalPath,
|
|
257
326
|
JSON.stringify({ id: entry.id, status: 'rolled-back', rolledBackAt: new Date().toISOString() }) + '\n',
|
|
@@ -263,9 +332,33 @@ async function rollback(journalPath) {
|
|
|
263
332
|
}
|
|
264
333
|
}
|
|
265
334
|
|
|
266
|
-
return { reversed, count: reversed.length };
|
|
335
|
+
return { reversed, count: reversed.length, sessionId: targetSession };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Permanently remove trashed files older than `olderThanDays`.
|
|
340
|
+
* Never wired to an automatic timer — explicit calls only.
|
|
341
|
+
*/
|
|
342
|
+
async function emptyTrash(olderThanDays = 30, trashPath = TRASH_PATH) {
|
|
343
|
+
let removed = 0;
|
|
344
|
+
const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
|
|
345
|
+
if (!fssync.existsSync(trashPath)) return { removed };
|
|
346
|
+
for (const name of await fs.readdir(trashPath)) {
|
|
347
|
+
const full = path.join(trashPath, name);
|
|
348
|
+
try {
|
|
349
|
+
const st = await fs.stat(full);
|
|
350
|
+
if (st.mtimeMs < cutoff) {
|
|
351
|
+
await fs.rm(full, { recursive: true, force: true });
|
|
352
|
+
removed++;
|
|
353
|
+
}
|
|
354
|
+
} catch { /* skip entries we cannot stat */ }
|
|
355
|
+
}
|
|
356
|
+
return { removed };
|
|
267
357
|
}
|
|
268
358
|
|
|
269
359
|
module.exports = FileMayorFS;
|
|
270
360
|
module.exports.JOURNAL_PATH = JOURNAL_PATH;
|
|
361
|
+
module.exports.TRASH_PATH = TRASH_PATH;
|
|
271
362
|
module.exports.rollback = rollback;
|
|
363
|
+
module.exports.listSessions = listSessions;
|
|
364
|
+
module.exports.emptyTrash = emptyTrash;
|