filemayor 2.0.0

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,572 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * ═══════════════════════════════════════════════════════════════════
5
+ * FILEMAYOR CORE — REPORTER
6
+ * Multi-format output (table, JSON, CSV, minimal) with colors,
7
+ * summary statistics, and machine-readable formats for piping.
8
+ * ═══════════════════════════════════════════════════════════════════
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const { formatBytes } = require('./scanner');
14
+
15
+ // ─── ANSI Color Codes ─────────────────────────────────────────────
16
+
17
+ const COLORS = {
18
+ reset: '\x1b[0m',
19
+ bold: '\x1b[1m',
20
+ dim: '\x1b[2m',
21
+ italic: '\x1b[3m',
22
+ underline: '\x1b[4m',
23
+
24
+ // Foreground
25
+ black: '\x1b[30m',
26
+ red: '\x1b[31m',
27
+ green: '\x1b[32m',
28
+ yellow: '\x1b[33m',
29
+ blue: '\x1b[34m',
30
+ magenta: '\x1b[35m',
31
+ cyan: '\x1b[36m',
32
+ white: '\x1b[37m',
33
+ gray: '\x1b[90m',
34
+
35
+ // Bright
36
+ brightRed: '\x1b[91m',
37
+ brightGreen: '\x1b[92m',
38
+ brightYellow: '\x1b[93m',
39
+ brightBlue: '\x1b[94m',
40
+ brightMagenta: '\x1b[95m',
41
+ brightCyan: '\x1b[96m',
42
+ brightWhite: '\x1b[97m',
43
+
44
+ // Background
45
+ bgRed: '\x1b[41m',
46
+ bgGreen: '\x1b[42m',
47
+ bgYellow: '\x1b[43m',
48
+ bgBlue: '\x1b[44m',
49
+ bgMagenta: '\x1b[45m',
50
+ bgCyan: '\x1b[46m',
51
+ };
52
+
53
+ let _colorsEnabled = true;
54
+
55
+ function c(color, text) {
56
+ if (!_colorsEnabled) return text;
57
+ return `${COLORS[color]}${text}${COLORS.reset}`;
58
+ }
59
+
60
+ function setColors(enabled) {
61
+ _colorsEnabled = enabled;
62
+ }
63
+
64
+ // ─── Symbols ──────────────────────────────────────────────────────
65
+
66
+ const SYMBOLS = {
67
+ check: '✓',
68
+ cross: '✗',
69
+ arrow: '→',
70
+ bullet: '•',
71
+ bar: '█',
72
+ halfBar: '▓',
73
+ lightBar: '░',
74
+ folder: '📂',
75
+ file: '📄',
76
+ trash: '🗑️',
77
+ sparkle: '✨',
78
+ warning: '⚠️',
79
+ error: '❌',
80
+ info: 'ℹ️',
81
+ clock: '⏱️',
82
+ shield: '🛡️',
83
+ eye: '👁️',
84
+ };
85
+
86
+ // ─── Table Formatter ──────────────────────────────────────────────
87
+
88
+ /**
89
+ * Format data as a table with aligned columns
90
+ * @param {Object[]} data - Array of objects
91
+ * @param {Object} options - Column config
92
+ * @returns {string} Formatted table
93
+ */
94
+ function formatTable(data, options = {}) {
95
+ if (!data || data.length === 0) return c('dim', ' (no data)');
96
+
97
+ const {
98
+ columns = null, // Column definitions [{key, label, width, align, format}]
99
+ maxWidth = null, // Max column width
100
+ indent = 2, // Left indent
101
+ } = options;
102
+
103
+ // Auto-detect columns from data
104
+ const cols = columns || Object.keys(data[0]).map(key => ({
105
+ key,
106
+ label: key.charAt(0).toUpperCase() + key.slice(1),
107
+ align: 'left'
108
+ }));
109
+
110
+ // Calculate column widths
111
+ for (const col of cols) {
112
+ if (!col.width) {
113
+ const maxDataLen = Math.max(
114
+ col.label.length,
115
+ ...data.map(row => {
116
+ const val = col.format ? col.format(row[col.key], row) : String(row[col.key] ?? '');
117
+ return val.length;
118
+ })
119
+ );
120
+ col.width = maxWidth ? Math.min(maxDataLen, maxWidth) : maxDataLen;
121
+ }
122
+ }
123
+
124
+ const lines = [];
125
+ const pad = ' '.repeat(indent);
126
+
127
+ // Header
128
+ const headerLine = cols.map(col => {
129
+ const label = col.label.padEnd(col.width).slice(0, col.width);
130
+ return c('bold', label);
131
+ }).join(c('dim', ' '));
132
+ lines.push(`${pad}${headerLine}`);
133
+
134
+ // Separator
135
+ const sep = cols.map(col => c('dim', '─'.repeat(col.width))).join(c('dim', '──'));
136
+ lines.push(`${pad}${sep}`);
137
+
138
+ // Rows
139
+ for (const row of data) {
140
+ const rowLine = cols.map(col => {
141
+ let val = col.format ? col.format(row[col.key], row) : String(row[col.key] ?? '');
142
+ const rawVal = val.replace(/\x1b\[[0-9;]*m/g, ''); // Strip ANSI for length calc
143
+
144
+ if (col.align === 'right') {
145
+ val = ' '.repeat(Math.max(0, col.width - rawVal.length)) + val;
146
+ } else {
147
+ val = val + ' '.repeat(Math.max(0, col.width - rawVal.length));
148
+ }
149
+ return val;
150
+ }).join(' ');
151
+ lines.push(`${pad}${rowLine}`);
152
+ }
153
+
154
+ return lines.join('\n');
155
+ }
156
+
157
+ // ─── Progress Bar ─────────────────────────────────────────────────
158
+
159
+ /**
160
+ * Render a progress bar string
161
+ * @param {number} percent - 0-100
162
+ * @param {number} width - Bar width in chars
163
+ * @returns {string}
164
+ */
165
+ function progressBar(percent, width = 30) {
166
+ const filled = Math.round((percent / 100) * width);
167
+ const empty = width - filled;
168
+ const bar = c('green', SYMBOLS.bar.repeat(filled)) +
169
+ c('dim', SYMBOLS.lightBar.repeat(empty));
170
+ return `${bar} ${c('bold', `${percent}%`)}`;
171
+ }
172
+
173
+ // ─── Spinner ──────────────────────────────────────────────────────
174
+
175
+ class Spinner {
176
+ constructor(message = 'Working...') {
177
+ this.message = message;
178
+ this.frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
179
+ this.frameIndex = 0;
180
+ this.interval = null;
181
+ this.stream = process.stderr;
182
+ }
183
+
184
+ start() {
185
+ this.interval = setInterval(() => {
186
+ const frame = c('cyan', this.frames[this.frameIndex]);
187
+ this.stream.write(`\r${frame} ${this.message}`);
188
+ this.frameIndex = (this.frameIndex + 1) % this.frames.length;
189
+ }, 80);
190
+ return this;
191
+ }
192
+
193
+ update(message) {
194
+ this.message = message;
195
+ }
196
+
197
+ succeed(message) {
198
+ this.stop();
199
+ this.stream.write(`\r${c('green', SYMBOLS.check)} ${message || this.message}\n`);
200
+ }
201
+
202
+ fail(message) {
203
+ this.stop();
204
+ this.stream.write(`\r${c('red', SYMBOLS.cross)} ${message || this.message}\n`);
205
+ }
206
+
207
+ stop() {
208
+ if (this.interval) {
209
+ clearInterval(this.interval);
210
+ this.interval = null;
211
+ this.stream.write('\r' + ' '.repeat(this.message.length + 10) + '\r');
212
+ }
213
+ }
214
+ }
215
+
216
+ // ─── Scan Report ──────────────────────────────────────────────────
217
+
218
+ /**
219
+ * Format a scan result for display
220
+ * @param {Object} scanResult - Result from scan()
221
+ * @param {string} format - Output format
222
+ * @returns {string}
223
+ */
224
+ function formatScanReport(scanResult, format = 'table') {
225
+ switch (format) {
226
+ case 'json':
227
+ return JSON.stringify(scanResult, null, 2);
228
+
229
+ case 'csv':
230
+ return formatScanCSV(scanResult);
231
+
232
+ case 'minimal':
233
+ return scanResult.files.map(f => f.path).join('\n');
234
+
235
+ case 'table':
236
+ default:
237
+ return formatScanTable(scanResult);
238
+ }
239
+ }
240
+
241
+ function formatScanTable(result) {
242
+ const lines = [];
243
+
244
+ // Header
245
+ lines.push('');
246
+ lines.push(c('bold', ` ${SYMBOLS.folder} Scan Report: `) + c('cyan', result.root));
247
+ lines.push(c('dim', ` ${'─'.repeat(60)}`));
248
+
249
+ // Category summary
250
+ const catData = Object.entries(result.stats.categories)
251
+ .sort((a, b) => b[1] - a[1])
252
+ .map(([cat, count]) => ({
253
+ category: cat.charAt(0).toUpperCase() + cat.slice(1),
254
+ count: String(count),
255
+ }));
256
+
257
+ if (catData.length > 0) {
258
+ lines.push('');
259
+ lines.push(c('bold', ' Categories'));
260
+ lines.push(formatTable(catData, {
261
+ columns: [
262
+ { key: 'category', label: 'Category', width: 20 },
263
+ { key: 'count', label: 'Files', width: 8, align: 'right' },
264
+ ]
265
+ }));
266
+ }
267
+
268
+ // File list (top 25)
269
+ const fileData = result.files.slice(0, 25).map(f => ({
270
+ name: f.name.length > 40 ? f.name.slice(0, 37) + '...' : f.name,
271
+ category: f.category,
272
+ size: f.sizeHuman,
273
+ }));
274
+
275
+ if (fileData.length > 0) {
276
+ lines.push('');
277
+ lines.push(c('bold', ' Files'));
278
+ lines.push(formatTable(fileData, {
279
+ columns: [
280
+ { key: 'name', label: 'Name', width: 42 },
281
+ { key: 'category', label: 'Category', width: 14 },
282
+ { key: 'size', label: 'Size', width: 10, align: 'right' },
283
+ ]
284
+ }));
285
+
286
+ if (result.files.length > 25) {
287
+ lines.push(c('dim', ` ... and ${result.files.length - 25} more files`));
288
+ }
289
+ }
290
+
291
+ // Summary
292
+ lines.push('');
293
+ lines.push(c('dim', ` ${'─'.repeat(60)}`));
294
+ lines.push(` ${c('bold', 'Total:')} ${result.stats.filesFound} files, ${formatBytes(result.stats.totalSize)}`);
295
+ lines.push(` ${c('bold', 'Scanned:')} ${result.stats.dirsScanned} directories in ${result.stats.durationHuman}`);
296
+ if (result.stats.errors.length > 0) {
297
+ lines.push(` ${c('yellow', SYMBOLS.warning)} ${result.stats.errors.length} errors`);
298
+ }
299
+ lines.push('');
300
+
301
+ return lines.join('\n');
302
+ }
303
+
304
+ function formatScanCSV(result) {
305
+ const header = 'name,path,category,size,modified,ext';
306
+ const rows = result.files.map(f =>
307
+ `"${f.name}","${f.path}","${f.category}",${f.size},"${f.modified.toISOString()}","${f.ext}"`
308
+ );
309
+ return [header, ...rows].join('\n');
310
+ }
311
+
312
+ // ─── Organize Report ──────────────────────────────────────────────
313
+
314
+ function formatOrganizeReport(result, format = 'table') {
315
+ switch (format) {
316
+ case 'json':
317
+ return JSON.stringify(result, null, 2);
318
+ case 'csv':
319
+ return formatOrganizeCSV(result);
320
+ case 'minimal':
321
+ return result.plan.map(p => `${p.source} ${SYMBOLS.arrow} ${p.destination}`).join('\n');
322
+ case 'table':
323
+ default:
324
+ return formatOrganizeTable(result);
325
+ }
326
+ }
327
+
328
+ function formatOrganizeTable(result) {
329
+ const lines = [];
330
+ const plan = result.plan;
331
+ const isDryRun = result.dryRun;
332
+
333
+ lines.push('');
334
+ if (isDryRun) {
335
+ lines.push(c('yellow', ` ${SYMBOLS.eye} DRY RUN — No files will be moved`));
336
+ } else {
337
+ lines.push(c('green', ` ${SYMBOLS.sparkle} Organization Complete`));
338
+ }
339
+ lines.push(c('dim', ` ${'─'.repeat(60)}`));
340
+
341
+ // Category breakdown
342
+ const catSummary = result.planSummary.categories;
343
+ const catData = Object.entries(catSummary)
344
+ .sort((a, b) => b[1].count - a[1].count)
345
+ .map(([cat, info]) => ({
346
+ category: cat,
347
+ files: String(info.count),
348
+ size: formatBytes(info.size)
349
+ }));
350
+
351
+ if (catData.length > 0) {
352
+ lines.push('');
353
+ lines.push(c('bold', ' Breakdown'));
354
+ lines.push(formatTable(catData, {
355
+ columns: [
356
+ { key: 'category', label: 'Category', width: 20 },
357
+ { key: 'files', label: 'Files', width: 8, align: 'right' },
358
+ { key: 'size', label: 'Size', width: 10, align: 'right' },
359
+ ]
360
+ }));
361
+ }
362
+
363
+ // Move preview (top 15)
364
+ const preview = plan.slice(0, 15).map(p => ({
365
+ from: p.originalName.length > 30 ? p.originalName.slice(0, 27) + '...' : p.originalName,
366
+ to: p.categoryLabel,
367
+ action: p.action === 'move' ? c('green', 'move') :
368
+ p.action === 'skip' ? c('yellow', 'skip') :
369
+ p.action === 'rename' ? c('cyan', 'rename') :
370
+ c('red', p.action)
371
+ }));
372
+
373
+ if (preview.length > 0) {
374
+ lines.push('');
375
+ lines.push(c('bold', ' Operations'));
376
+ lines.push(formatTable(preview, {
377
+ columns: [
378
+ { key: 'from', label: 'File', width: 32 },
379
+ { key: 'to', label: 'Destination', width: 16 },
380
+ { key: 'action', label: 'Action', width: 10 },
381
+ ]
382
+ }));
383
+
384
+ if (plan.length > 15) {
385
+ lines.push(c('dim', ` ... and ${plan.length - 15} more operations`));
386
+ }
387
+ }
388
+
389
+ // Summary
390
+ lines.push('');
391
+ lines.push(c('dim', ` ${'─'.repeat(60)}`));
392
+ lines.push(` ${c('bold', 'Total:')} ${result.planSummary.totalFiles} files, ${result.planSummary.totalSizeHuman}`);
393
+
394
+ if (!isDryRun && result.execSummary) {
395
+ const exec = result.execSummary;
396
+ lines.push(` ${c('green', SYMBOLS.check)} ${exec.succeeded} succeeded`);
397
+ if (exec.failed > 0) lines.push(` ${c('red', SYMBOLS.cross)} ${exec.failed} failed`);
398
+ if (exec.skipped > 0) lines.push(` ${c('yellow', '⊘')} ${exec.skipped} skipped`);
399
+ } else if (isDryRun) {
400
+ lines.push(c('dim', ` Run without --dry-run to execute these changes`));
401
+ }
402
+
403
+ lines.push('');
404
+ return lines.join('\n');
405
+ }
406
+
407
+ function formatOrganizeCSV(result) {
408
+ const header = 'source,destination,category,original_name,new_name,size,action';
409
+ const rows = result.plan.map(p =>
410
+ `"${p.source}","${p.destination}","${p.category}","${p.originalName}","${p.newName}",${p.size},"${p.action}"`
411
+ );
412
+ return [header, ...rows].join('\n');
413
+ }
414
+
415
+ // ─── Clean Report ─────────────────────────────────────────────────
416
+
417
+ function formatCleanReport(result, format = 'table') {
418
+ switch (format) {
419
+ case 'json':
420
+ return JSON.stringify(result, null, 2);
421
+ case 'csv':
422
+ return formatCleanCSV(result);
423
+ case 'minimal':
424
+ return result.junk.map(j => `${j.sizeHuman}\t${j.path}`).join('\n');
425
+ case 'table':
426
+ default:
427
+ return formatCleanTable(result);
428
+ }
429
+ }
430
+
431
+ function formatCleanTable(result) {
432
+ const lines = [];
433
+ const isDryRun = result.dryRun;
434
+
435
+ lines.push('');
436
+ if (isDryRun) {
437
+ lines.push(c('yellow', ` ${SYMBOLS.eye} DRY RUN — No files will be deleted`));
438
+ } else if (result.deleteResult) {
439
+ lines.push(c('green', ` ${SYMBOLS.trash} Cleanup Complete`));
440
+ } else {
441
+ lines.push(c('cyan', ` ${SYMBOLS.trash} Junk Scan Results`));
442
+ }
443
+ lines.push(c('dim', ` ${'─'.repeat(60)}`));
444
+
445
+ // Category breakdown
446
+ const byCategory = result.stats.byCategory;
447
+ const catData = Object.entries(byCategory)
448
+ .sort((a, b) => b[1].size - a[1].size)
449
+ .map(([cat, info]) => ({
450
+ category: cat.charAt(0).toUpperCase() + cat.slice(1),
451
+ items: String(info.count),
452
+ size: formatBytes(info.size)
453
+ }));
454
+
455
+ if (catData.length > 0) {
456
+ lines.push('');
457
+ lines.push(c('bold', ' Junk Categories'));
458
+ lines.push(formatTable(catData, {
459
+ columns: [
460
+ { key: 'category', label: 'Category', width: 20 },
461
+ { key: 'items', label: 'Items', width: 8, align: 'right' },
462
+ { key: 'size', label: 'Size', width: 10, align: 'right' },
463
+ ]
464
+ }));
465
+ }
466
+
467
+ // Largest items (top 10)
468
+ const sorted = [...result.junk].sort((a, b) => b.size - a.size).slice(0, 10);
469
+ const itemData = sorted.map(j => ({
470
+ name: j.name.length > 35 ? j.name.slice(0, 32) + '...' : j.name,
471
+ type: j.type === 'directory' ? c('blue', 'DIR') : c('dim', 'FILE'),
472
+ size: j.sizeHuman,
473
+ }));
474
+
475
+ if (itemData.length > 0) {
476
+ lines.push('');
477
+ lines.push(c('bold', ' Largest Junk Items'));
478
+ lines.push(formatTable(itemData, {
479
+ columns: [
480
+ { key: 'name', label: 'Name', width: 37 },
481
+ { key: 'type', label: 'Type', width: 6 },
482
+ { key: 'size', label: 'Size', width: 10, align: 'right' },
483
+ ]
484
+ }));
485
+ }
486
+
487
+ // Summary
488
+ lines.push('');
489
+ lines.push(c('dim', ` ${'─'.repeat(60)}`));
490
+ lines.push(` ${c('bold', 'Total junk:')} ${result.stats.filesFound + result.stats.dirsFound} items, ${result.stats.totalSizeHuman}`);
491
+
492
+ if (result.deleteResult) {
493
+ const dr = result.deleteResult;
494
+ lines.push(` ${c('green', SYMBOLS.check)} Deleted ${dr.deleted} items, freed ${dr.freedHuman}`);
495
+ if (dr.errors.length > 0) {
496
+ lines.push(` ${c('red', SYMBOLS.cross)} ${dr.errors.length} errors`);
497
+ }
498
+ } else if (isDryRun) {
499
+ lines.push(c('dim', ` Run without --dry-run to clean these files`));
500
+ }
501
+
502
+ lines.push('');
503
+ return lines.join('\n');
504
+ }
505
+
506
+ function formatCleanCSV(result) {
507
+ const header = 'name,path,category,type,size';
508
+ const rows = result.junk.map(j =>
509
+ `"${j.name}","${j.path}","${j.category}","${j.type}",${j.size}`
510
+ );
511
+ return [header, ...rows].join('\n');
512
+ }
513
+
514
+ // ─── Generic Utilities ────────────────────────────────────────────
515
+
516
+ /**
517
+ * Print a header banner
518
+ */
519
+ function banner() {
520
+ return [
521
+ '',
522
+ c('bold', ` ${SYMBOLS.sparkle} FileMayor`) + c('dim', ' — Your Digital Life Organizer'),
523
+ c('dim', ` ${'─'.repeat(50)}`),
524
+ ''
525
+ ].join('\n');
526
+ }
527
+
528
+ /**
529
+ * Print a success message
530
+ */
531
+ function success(msg) {
532
+ return `${c('green', SYMBOLS.check)} ${msg}`;
533
+ }
534
+
535
+ /**
536
+ * Print an error message
537
+ */
538
+ function error(msg) {
539
+ return `${c('red', SYMBOLS.cross)} ${msg}`;
540
+ }
541
+
542
+ /**
543
+ * Print a warning message
544
+ */
545
+ function warn(msg) {
546
+ return `${c('yellow', SYMBOLS.warning)} ${msg}`;
547
+ }
548
+
549
+ /**
550
+ * Print an info message
551
+ */
552
+ function info(msg) {
553
+ return `${c('cyan', SYMBOLS.info)} ${msg}`;
554
+ }
555
+
556
+ module.exports = {
557
+ COLORS,
558
+ SYMBOLS,
559
+ c,
560
+ setColors,
561
+ formatTable,
562
+ progressBar,
563
+ Spinner,
564
+ formatScanReport,
565
+ formatOrganizeReport,
566
+ formatCleanReport,
567
+ banner,
568
+ success,
569
+ error,
570
+ warn,
571
+ info,
572
+ };