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.
@@ -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;
@@ -2,8 +2,13 @@
2
2
 
3
3
  /**
4
4
  * ═══════════════════════════════════════════════════════════════════
5
- * FILEMAYOR FS v3.0 — INTENT-READY FILESYSTEM ABSTRACTION
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: path.join(os.tmpdir(), 'filemayor_trash'),
26
- journalPath: path.join(os.homedir(), '.filemayor-master-journal.ndjson'),
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
- try {
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 with Trash
118
+ * Safe Delete Trash (journaled, undoable)
113
119
  * -------------------------*/
114
120
  async delete(target) {
115
- const check = isFileSafe(target);
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
- const trashFile = path.join(this.options.trashPath, path.basename(target) + '-' + Date.now());
123
- await fs.rename(target, trashFile);
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
- await this._logToJournal(target, trashFile, 'delete', snapshot);
150
+ return { target, trashFile, status: 'success' };
151
+ }
130
152
 
131
- return { target, status: 'success' };
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
- * Rollback
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 = new Map();
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
- const JOURNAL_PATH = path.join(os.homedir(), '.filemayor-master-journal.ndjson');
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') entries.set(rec.id, rec);
241
- if (rec.status === 'done') done.add(rec.id);
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
- // Rollback moves that completed successfully (status === 'done')
247
- const toRollback = [...entries.values()]
248
- .filter(e => done.has(e.id))
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;