filemayor 4.0.7 → 4.0.8
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/config.js +63 -2
- package/core/engine/para-engine.js +370 -0
- package/core/index.js +2 -0
- package/index.js +84 -3
- package/package.json +1 -1
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 a method by Tiago Forte;
|
|
538
|
+
* FileMayor automates it — this preset tunes the actionability sorter.
|
|
539
|
+
* @returns {string} YAML config template
|
|
540
|
+
*/
|
|
541
|
+
function generateParaTemplate() {
|
|
542
|
+
return `# ═══════════════════════════════════════════════════════════
|
|
543
|
+
# FileMayor Configuration — PARA preset
|
|
544
|
+
# PARA = Projects · Areas · Resources · Archives (method by Tiago Forte).
|
|
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
|
};
|
|
@@ -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/index.js
CHANGED
|
@@ -27,6 +27,7 @@ const CureEngine = require('./engine/cure-engine');
|
|
|
27
27
|
const ApplyEngine = require('./engine/apply-engine');
|
|
28
28
|
const PreviewEngine = require('./engine/preview-engine');
|
|
29
29
|
const DedupeEngine = require('./engine/dedupe-engine');
|
|
30
|
+
const PARAEngine = require('./engine/para-engine');
|
|
30
31
|
const MetadataStore = require('./metadata-store');
|
|
31
32
|
const strategist = require('./ai/strategist');
|
|
32
33
|
const sentry = require('./ai/sentry');
|
|
@@ -119,6 +120,7 @@ module.exports = {
|
|
|
119
120
|
ApplyEngine,
|
|
120
121
|
PreviewEngine,
|
|
121
122
|
DedupeEngine,
|
|
123
|
+
PARAEngine,
|
|
122
124
|
MetadataStore,
|
|
123
125
|
|
|
124
126
|
// Hardened Runtime
|
package/index.js
CHANGED
|
@@ -51,13 +51,14 @@ const { formatBytes } = require('./core/scanner');
|
|
|
51
51
|
const { getCategories } = require('./core/categories');
|
|
52
52
|
const { checkPermissions } = require('./core/security');
|
|
53
53
|
const { activateLicense, deactivateLicense, getLicenseInfo, checkProFeature, checkBulkLimit } = require('./core/license');
|
|
54
|
-
const { ExplainEngine, CureEngine, ApplyEngine, DedupeEngine } = require('./core/index');
|
|
54
|
+
const { ExplainEngine, CureEngine, ApplyEngine, DedupeEngine, PARAEngine } = require('./core/index');
|
|
55
55
|
const { ping: telemetryPing } = require('./core/telemetry');
|
|
56
56
|
|
|
57
57
|
const { c, banner, Spinner, success, error, warn, info } = reporter;
|
|
58
58
|
|
|
59
59
|
// ─── Version ──────────────────────────────────────────────────────
|
|
60
|
-
|
|
60
|
+
// Derived from package.json so it can never drift out of sync.
|
|
61
|
+
const VERSION = require('./package.json').version;
|
|
61
62
|
|
|
62
63
|
// ─── Path Helpers ─────────────────────────────────────────────────
|
|
63
64
|
|
|
@@ -117,6 +118,10 @@ function parseArgs(argv) {
|
|
|
117
118
|
args.flags.depth = parseInt(raw[++i], 10);
|
|
118
119
|
} else if (arg === '--naming' && raw[i + 1]) {
|
|
119
120
|
args.flags.naming = raw[++i];
|
|
121
|
+
} else if (arg === '--archive-after' && raw[i + 1]) {
|
|
122
|
+
args.flags.archiveAfter = parseInt(raw[++i], 10);
|
|
123
|
+
} else if (arg === '--para') {
|
|
124
|
+
args.flags.para = true;
|
|
120
125
|
} else if (arg === '--output' && raw[i + 1]) {
|
|
121
126
|
args.flags.outputDir = raw[++i];
|
|
122
127
|
} else if (arg === '--config' && raw[i + 1]) {
|
|
@@ -182,9 +187,10 @@ ${banner()}
|
|
|
182
187
|
${c('yellow', 'scan')} ${c('dim', '<path>')} Scan directory and report contents
|
|
183
188
|
${c('yellow', 'analyze')} ${c('dim', '<path>')} Deep analysis (duplicates, bloat, savings)
|
|
184
189
|
${c('yellow', 'organize')} ${c('dim', '<path>')} Organize files into categories
|
|
190
|
+
${c('yellow', 'para')} ${c('dim', '<path>')} Sort by actionability — PARA method (then apply)
|
|
185
191
|
${c('yellow', 'clean')} ${c('dim', '<path>')} Find and remove junk files
|
|
186
192
|
${c('yellow', 'watch')} ${c('dim', '<path>')} Watch directory for changes
|
|
187
|
-
${c('yellow', 'init')}
|
|
193
|
+
${c('yellow', 'init')} ${c('dim', '[--para]')} Create .filemayor.yml (--para scaffolds PARA folders)
|
|
188
194
|
${c('yellow', 'undo')} ${c('dim', '<path>')} Undo last organization
|
|
189
195
|
${c('yellow', 'info')} System info and version
|
|
190
196
|
${c('yellow', 'license')} ${c('dim', '<action>')} Manage license (activate|status|deactivate)
|
|
@@ -201,6 +207,8 @@ ${banner()}
|
|
|
201
207
|
${c('dim', '--config <path>')} Custom config file
|
|
202
208
|
${c('dim', '--depth <n>')} Max recursion depth
|
|
203
209
|
${c('dim', '--naming <type>')} Naming: original|category_prefix|date_prefix|clean
|
|
210
|
+
${c('dim', '--archive-after <n>')} PARA: months of inactivity before a file → Archives
|
|
211
|
+
${c('dim', '--para')} init: scaffold PARA folders + preset config
|
|
204
212
|
${c('dim', '--duplicates <s>')} Duplicates: rename|skip|overwrite
|
|
205
213
|
${c('dim', '--extensions <e>')} Filter by extensions (comma-separated)
|
|
206
214
|
${c('dim', '--output <path>')} Output directory for organized files
|
|
@@ -214,6 +222,8 @@ ${banner()}
|
|
|
214
222
|
${c('dim', '$')} filemayor scan ~/Downloads
|
|
215
223
|
${c('dim', '$')} filemayor organize ~/Downloads --dry-run
|
|
216
224
|
${c('dim', '$')} filemayor organize ~/Downloads --naming category_prefix
|
|
225
|
+
${c('dim', '$')} filemayor init --para ~/Documents
|
|
226
|
+
${c('dim', '$')} filemayor para ~/Documents --archive-after 18
|
|
217
227
|
${c('dim', '$')} filemayor clean /var/tmp --yes
|
|
218
228
|
${c('dim', '$')} filemayor scan . --json | jq '.files | length'
|
|
219
229
|
${c('dim', '$')} filemayor watch ~/Downloads
|
|
@@ -454,6 +464,23 @@ async function cmdWatch(target, flags, config) {
|
|
|
454
464
|
|
|
455
465
|
async function cmdInit(flags) {
|
|
456
466
|
try {
|
|
467
|
+
if (flags.para) {
|
|
468
|
+
// PARA preset: write a tuned config AND scaffold the four folders
|
|
469
|
+
// with teaching READMEs so a first-timer understands the method.
|
|
470
|
+
const configPath = createConfigFile(process.cwd(), '.filemayor.yml', 'para');
|
|
471
|
+
console.log(success(`Created ${c('cyan', configPath)}`));
|
|
472
|
+
|
|
473
|
+
const engine = new PARAEngine();
|
|
474
|
+
const { created, folders } = engine.scaffold(process.cwd());
|
|
475
|
+
console.log(success(`Scaffolded PARA folders: ${c('cyan', Object.values(folders).join(', '))}`));
|
|
476
|
+
if (created.length) {
|
|
477
|
+
console.log(c('dim', ` ${created.length} item(s) created (folders + READMEs)`));
|
|
478
|
+
}
|
|
479
|
+
console.log('');
|
|
480
|
+
console.log(c('dim', ' Next: ') + c('cyan', 'filemayor para .') + c('dim', ' then ') + c('cyan', 'filemayor apply'));
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
457
484
|
const configPath = createConfigFile(process.cwd());
|
|
458
485
|
console.log(success(`Created ${c('cyan', configPath)}`));
|
|
459
486
|
console.log(c('dim', ' Edit this file to customize FileMayor settings'));
|
|
@@ -463,6 +490,55 @@ async function cmdInit(flags) {
|
|
|
463
490
|
}
|
|
464
491
|
}
|
|
465
492
|
|
|
493
|
+
// ─── PARA: sort by actionability (Projects/Areas/Resources/Archives) ──
|
|
494
|
+
async function cmdPara(target, flags, config) {
|
|
495
|
+
const targetPath = path.resolve(target || '.');
|
|
496
|
+
|
|
497
|
+
// Per-run override for the archive threshold.
|
|
498
|
+
const paraConfig = { ...(config.para || {}) };
|
|
499
|
+
if (Number.isFinite(flags.archiveAfter)) {
|
|
500
|
+
paraConfig.archiveAfterMonths = flags.archiveAfter;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const spinner = new Spinner(`Sorting ${c('cyan', targetPath)} by actionability (PARA)...`);
|
|
504
|
+
if (!flags.quiet) spinner.start();
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
const engine = new PARAEngine(paraConfig);
|
|
508
|
+
const result = engine.plan(targetPath);
|
|
509
|
+
spinner.stop();
|
|
510
|
+
|
|
511
|
+
if (flags.format === 'json') {
|
|
512
|
+
console.log(JSON.stringify(result, null, 2));
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Cache the plan so `filemayor apply` can execute it (same path as cure).
|
|
517
|
+
const cachePath = path.join(os.tmpdir(), 'filemayor-plan.json');
|
|
518
|
+
fs.writeFileSync(cachePath, JSON.stringify(result), 'utf8');
|
|
519
|
+
|
|
520
|
+
console.log(reporter.formatCureReport(result));
|
|
521
|
+
|
|
522
|
+
// PARA-specific breakdown — the bucket is the whole point.
|
|
523
|
+
const b = result.summary.buckets;
|
|
524
|
+
console.log(c('bold', ' Into PARA buckets:'));
|
|
525
|
+
console.log(` ${c('cyan', 'Projects')} ${b.projects} ${c('cyan', 'Areas')} ${b.areas} ${c('cyan', 'Resources')} ${b.resources} ${c('cyan', 'Archives')} ${b.archives}`);
|
|
526
|
+
for (const wn of (result.warnings || [])) {
|
|
527
|
+
console.log(c('dim', ` ⚠ ${wn}`));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (result.plan.length === 0) {
|
|
531
|
+
console.log(info('Nothing to reorganise — run with a messier folder, or it is already PARA-sorted.'));
|
|
532
|
+
} else {
|
|
533
|
+
console.log('');
|
|
534
|
+
console.log(c('dim', ' Review the plan above, then run ') + c('cyan', 'filemayor apply') + c('dim', ' to execute (reversible with ') + c('cyan', 'filemayor undo') + c('dim', ').'));
|
|
535
|
+
}
|
|
536
|
+
} catch (err) {
|
|
537
|
+
spinner.fail(err.message);
|
|
538
|
+
process.exit(1);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
466
542
|
async function cmdUndo(target, flags) {
|
|
467
543
|
const { JOURNAL_PATH, rollback: fsRollback } = require('./core/fs-abstraction');
|
|
468
544
|
|
|
@@ -837,6 +913,11 @@ async function main() {
|
|
|
837
913
|
await cmdOrganize(args.target, args.flags, config);
|
|
838
914
|
break;
|
|
839
915
|
|
|
916
|
+
case 'para':
|
|
917
|
+
case 'pm':
|
|
918
|
+
await cmdPara(args.target, args.flags, config);
|
|
919
|
+
break;
|
|
920
|
+
|
|
840
921
|
case 'clean':
|
|
841
922
|
case 'cl':
|
|
842
923
|
case 'c':
|