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,527 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * ═══════════════════════════════════════════════════════════════════
5
+ * FILEMAYOR CORE — CLEANER
6
+ * Junk file detection, size calculation, safe deletion with
7
+ * configurable patterns, category-based cleaning, and reports.
8
+ * ═══════════════════════════════════════════════════════════════════
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { validatePath, isDirSafe, canRead, canWrite } = require('./security');
16
+ const { formatBytes } = require('./scanner');
17
+
18
+ // ─── Junk Definitions ─────────────────────────────────────────────
19
+
20
+ const JUNK_CATEGORIES = {
21
+ temp: {
22
+ label: 'Temporary Files',
23
+ icon: 'Clock',
24
+ color: '#fb923c',
25
+ extensions: [
26
+ '.tmp', '.temp', '.swp', '.swo', '.swn',
27
+ '.bak', '.backup', '.old', '.orig', '.save',
28
+ '.crdownload', '.part', '.partial', '.download',
29
+ '~', '.~lock'
30
+ ],
31
+ names: []
32
+ },
33
+ cache: {
34
+ label: 'Cache Files',
35
+ icon: 'HardDrive',
36
+ color: '#38bdf8',
37
+ extensions: [
38
+ '.cache', '.cached'
39
+ ],
40
+ names: [],
41
+ directories: [
42
+ '__pycache__', '.cache', '.sass-cache',
43
+ '.parcel-cache', '.turbo', '.eslintcache',
44
+ '.stylelintcache', '.prettiercache'
45
+ ]
46
+ },
47
+ system: {
48
+ label: 'System Junk',
49
+ icon: 'Shield',
50
+ color: '#a78bfa',
51
+ extensions: [],
52
+ names: [
53
+ 'Thumbs.db', 'ehthumbs.db', 'ehthumbs_vista.db',
54
+ '.DS_Store', '._.DS_Store', '._*',
55
+ 'desktop.ini', 'Desktop.ini',
56
+ '.Spotlight-V100', '.Trashes', '.fseventsd',
57
+ '.TemporaryItems', '.apdisk',
58
+ 'Icon\r', '.directory',
59
+ '$RECYCLE.BIN', 'RECYCLER',
60
+ 'pagefile.sys', 'swapfile.sys', 'hiberfil.sys'
61
+ ]
62
+ },
63
+ logs: {
64
+ label: 'Log Files',
65
+ icon: 'FileText',
66
+ color: '#fbbf24',
67
+ extensions: [
68
+ '.log', '.logs'
69
+ ],
70
+ names: [
71
+ 'npm-debug.log', 'yarn-debug.log', 'yarn-error.log',
72
+ 'lerna-debug.log', 'pnpm-debug.log',
73
+ 'debug.log', 'error.log', 'access.log'
74
+ ],
75
+ patterns: [
76
+ 'npm-debug.log*', 'yarn-debug.log*', 'yarn-error.log*',
77
+ '*.log.*', 'crash-*.log'
78
+ ]
79
+ },
80
+ build: {
81
+ label: 'Build Artifacts',
82
+ icon: 'Package',
83
+ color: '#34d399',
84
+ extensions: [
85
+ '.o', '.obj', '.pyc', '.pyo', '.class',
86
+ '.elc', '.beam'
87
+ ],
88
+ names: [],
89
+ directories: [
90
+ 'dist', 'build', 'out', 'output', 'target',
91
+ '.next', '.nuxt', '.output', '.vercel',
92
+ 'coverage', '.nyc_output', '__snapshots__'
93
+ ]
94
+ },
95
+ dependencies: {
96
+ label: 'Dependencies',
97
+ icon: 'Layers',
98
+ color: '#f472b6',
99
+ extensions: [],
100
+ names: [],
101
+ directories: [
102
+ 'node_modules', '.venv', 'venv', '__pypackages__',
103
+ 'vendor', 'bower_components', '.bundle',
104
+ 'Pods', 'Carthage'
105
+ ]
106
+ },
107
+ ide: {
108
+ label: 'IDE Artifacts',
109
+ icon: 'Code',
110
+ color: '#94a3b8',
111
+ extensions: [],
112
+ names: [],
113
+ directories: [
114
+ '.idea', '.vscode', '.vs', '.eclipse',
115
+ '.settings', '.project', '.classpath',
116
+ '*.sublime-workspace', '.atom'
117
+ ]
118
+ }
119
+ };
120
+
121
+ // ─── Cleaner Class ────────────────────────────────────────────────
122
+
123
+ class Cleaner {
124
+ constructor(options = {}) {
125
+ this.options = {
126
+ maxDepth: options.maxDepth || 10,
127
+ categories: options.categories || Object.keys(JUNK_CATEGORIES),
128
+ includeDirectories: options.includeDirectories !== false,
129
+ customPatterns: options.customPatterns || {},
130
+ onProgress: options.onProgress || null,
131
+ onError: options.onError || null,
132
+ };
133
+
134
+ this.stats = {
135
+ filesFound: 0,
136
+ dirsFound: 0,
137
+ totalSize: 0,
138
+ dirsScanned: 0,
139
+ errors: [],
140
+ startTime: null,
141
+ endTime: null,
142
+ byCategory: {}
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Scan for junk files in a directory
148
+ * @param {string} dirPath - Directory to scan
149
+ * @returns {{ junk: Object[], stats: Object }}
150
+ */
151
+ findJunk(dirPath) {
152
+ const validation = validatePath(dirPath);
153
+ if (!validation.valid) {
154
+ throw new Error(`Invalid path: ${validation.error}`);
155
+ }
156
+
157
+ const safeCheck = isDirSafe(validation.resolved);
158
+ if (!safeCheck.safe) {
159
+ throw new Error(`Unsafe directory: ${safeCheck.reason}`);
160
+ }
161
+
162
+ if (!canRead(validation.resolved)) {
163
+ throw new Error(`Permission denied: cannot read "${validation.resolved}"`);
164
+ }
165
+
166
+ this.stats.startTime = Date.now();
167
+ const junk = [];
168
+
169
+ this._scanForJunk(validation.resolved, validation.resolved, 0, junk);
170
+
171
+ this.stats.endTime = Date.now();
172
+ this.stats.duration = this.stats.endTime - this.stats.startTime;
173
+ this.stats.totalSizeHuman = formatBytes(this.stats.totalSize);
174
+
175
+ return {
176
+ root: validation.resolved,
177
+ junk,
178
+ stats: { ...this.stats }
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Internal recursive junk scanner
184
+ */
185
+ _scanForJunk(dir, rootPath, depth, results) {
186
+ if (depth > this.options.maxDepth) return;
187
+
188
+ this.stats.dirsScanned++;
189
+
190
+ if (this.options.onProgress) {
191
+ this.options.onProgress({
192
+ phase: 'scanning',
193
+ currentDir: dir,
194
+ found: this.stats.filesFound + this.stats.dirsFound,
195
+ depth
196
+ });
197
+ }
198
+
199
+ let items;
200
+ try {
201
+ items = fs.readdirSync(dir, { withFileTypes: true });
202
+ } catch (err) {
203
+ this.stats.errors.push({
204
+ path: dir,
205
+ code: err.code,
206
+ message: err.message
207
+ });
208
+ return;
209
+ }
210
+
211
+ for (const item of items) {
212
+ const fullPath = path.join(dir, item.name);
213
+
214
+ if (item.isFile()) {
215
+ const junkCategory = this._classifyFile(item.name, fullPath);
216
+ if (junkCategory) {
217
+ try {
218
+ const stats = fs.statSync(fullPath);
219
+ const entry = {
220
+ name: item.name,
221
+ path: fullPath,
222
+ relativePath: path.relative(rootPath, fullPath),
223
+ size: stats.size,
224
+ sizeHuman: formatBytes(stats.size),
225
+ modified: stats.mtime,
226
+ category: junkCategory,
227
+ categoryLabel: JUNK_CATEGORIES[junkCategory]?.label || junkCategory,
228
+ type: 'file'
229
+ };
230
+ results.push(entry);
231
+ this.stats.filesFound++;
232
+ this.stats.totalSize += stats.size;
233
+
234
+ // Track by category
235
+ if (!this.stats.byCategory[junkCategory]) {
236
+ this.stats.byCategory[junkCategory] = { count: 0, size: 0 };
237
+ }
238
+ this.stats.byCategory[junkCategory].count++;
239
+ this.stats.byCategory[junkCategory].size += stats.size;
240
+ } catch (err) {
241
+ this.stats.errors.push({ path: fullPath, message: err.message });
242
+ }
243
+ }
244
+ } else if (item.isDirectory()) {
245
+ // Check if the directory itself is junk
246
+ const dirJunkCategory = this._classifyDirectory(item.name);
247
+ if (dirJunkCategory && this.options.includeDirectories) {
248
+ try {
249
+ const dirSize = this._getDirSize(fullPath);
250
+ const entry = {
251
+ name: item.name,
252
+ path: fullPath,
253
+ relativePath: path.relative(rootPath, fullPath),
254
+ size: dirSize.size,
255
+ sizeHuman: formatBytes(dirSize.size),
256
+ fileCount: dirSize.files,
257
+ category: dirJunkCategory,
258
+ categoryLabel: JUNK_CATEGORIES[dirJunkCategory]?.label || dirJunkCategory,
259
+ type: 'directory'
260
+ };
261
+ results.push(entry);
262
+ this.stats.dirsFound++;
263
+ this.stats.totalSize += dirSize.size;
264
+
265
+ if (!this.stats.byCategory[dirJunkCategory]) {
266
+ this.stats.byCategory[dirJunkCategory] = { count: 0, size: 0 };
267
+ }
268
+ this.stats.byCategory[dirJunkCategory].count++;
269
+ this.stats.byCategory[dirJunkCategory].size += dirSize.size;
270
+ } catch (err) {
271
+ this.stats.errors.push({ path: fullPath, message: err.message });
272
+ }
273
+ // Don't recurse into junk directories
274
+ continue;
275
+ }
276
+
277
+ // Normal recursion
278
+ if (!item.name.startsWith('.') || this.options.categories.includes('system')) {
279
+ this._scanForJunk(fullPath, rootPath, depth + 1, results);
280
+ }
281
+ }
282
+ }
283
+
284
+ // Check for empty directories
285
+ if (this.options.categories.includes('temp')) {
286
+ try {
287
+ const remaining = fs.readdirSync(dir);
288
+ if (remaining.length === 0 && dir !== rootPath) {
289
+ results.push({
290
+ name: path.basename(dir),
291
+ path: dir,
292
+ relativePath: path.relative(rootPath, dir),
293
+ size: 0,
294
+ sizeHuman: '0 B',
295
+ category: 'temp',
296
+ categoryLabel: 'Empty Directory',
297
+ type: 'empty_directory'
298
+ });
299
+ }
300
+ } catch { /* ignore */ }
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Classify a file as junk (or null if not junk)
306
+ * @returns {string|null} Category name or null
307
+ */
308
+ _classifyFile(filename, fullPath) {
309
+ const ext = path.extname(filename).toLowerCase();
310
+
311
+ for (const catName of this.options.categories) {
312
+ const cat = JUNK_CATEGORIES[catName];
313
+ if (!cat) continue;
314
+
315
+ // Check extensions
316
+ if (cat.extensions && cat.extensions.includes(ext)) return catName;
317
+
318
+ // Check exact names
319
+ if (cat.names && cat.names.includes(filename)) return catName;
320
+
321
+ // Check patterns
322
+ if (cat.patterns) {
323
+ for (const pattern of cat.patterns) {
324
+ if (this._matchPattern(pattern, filename)) return catName;
325
+ }
326
+ }
327
+ }
328
+
329
+ // Check custom patterns
330
+ for (const [catName, patterns] of Object.entries(this.options.customPatterns)) {
331
+ for (const pattern of patterns) {
332
+ if (this._matchPattern(pattern, filename)) return catName;
333
+ }
334
+ }
335
+
336
+ return null;
337
+ }
338
+
339
+ /**
340
+ * Classify a directory as junk
341
+ * @returns {string|null} Category name or null
342
+ */
343
+ _classifyDirectory(dirname) {
344
+ for (const catName of this.options.categories) {
345
+ const cat = JUNK_CATEGORIES[catName];
346
+ if (!cat || !cat.directories) continue;
347
+ if (cat.directories.includes(dirname)) return catName;
348
+ }
349
+ return null;
350
+ }
351
+
352
+ /**
353
+ * Simple pattern match
354
+ */
355
+ _matchPattern(pattern, str) {
356
+ const regex = pattern
357
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
358
+ .replace(/\*/g, '.*')
359
+ .replace(/\?/g, '.');
360
+ return new RegExp(`^${regex}$`, 'i').test(str);
361
+ }
362
+
363
+ /**
364
+ * Calculate total size of a directory
365
+ */
366
+ _getDirSize(dir, depth = 0) {
367
+ let size = 0;
368
+ let files = 0;
369
+
370
+ if (depth > 5) return { size, files };
371
+
372
+ try {
373
+ const items = fs.readdirSync(dir, { withFileTypes: true });
374
+ for (const item of items) {
375
+ const fullPath = path.join(dir, item.name);
376
+ if (item.isFile()) {
377
+ try {
378
+ const stats = fs.statSync(fullPath);
379
+ size += stats.size;
380
+ files++;
381
+ } catch { /* skip */ }
382
+ } else if (item.isDirectory()) {
383
+ const sub = this._getDirSize(fullPath, depth + 1);
384
+ size += sub.size;
385
+ files += sub.files;
386
+ }
387
+ }
388
+ } catch { /* skip */ }
389
+
390
+ return { size, files };
391
+ }
392
+ }
393
+
394
+ // ─── Deletion ─────────────────────────────────────────────────────
395
+
396
+ /**
397
+ * Delete junk files and directories
398
+ * @param {Object[]} junkItems - Items from findJunk
399
+ * @param {Object} options - Deletion options
400
+ * @returns {{ deleted: number, freed: number, freedHuman: string, errors: Object[] }}
401
+ */
402
+ function deleteJunk(junkItems, options = {}) {
403
+ const {
404
+ onProgress = null,
405
+ onError = null,
406
+ filterCategories = null, // Only delete these categories
407
+ } = options;
408
+
409
+ let deleted = 0;
410
+ let freed = 0;
411
+ const errors = [];
412
+
413
+ // Filter by category if specified
414
+ const toDelete = filterCategories
415
+ ? junkItems.filter(item => filterCategories.includes(item.category))
416
+ : junkItems;
417
+
418
+ // Delete files first, then directories (bottom-up)
419
+ const files = toDelete.filter(i => i.type === 'file');
420
+ const dirs = toDelete.filter(i => i.type === 'directory' || i.type === 'empty_directory');
421
+
422
+ // Delete files
423
+ for (let i = 0; i < files.length; i++) {
424
+ const item = files[i];
425
+
426
+ if (onProgress) {
427
+ onProgress({
428
+ current: i + 1,
429
+ total: toDelete.length,
430
+ percent: Math.round(((i + 1) / toDelete.length) * 100),
431
+ file: item.name,
432
+ phase: 'deleting'
433
+ });
434
+ }
435
+
436
+ try {
437
+ if (!canWrite(path.dirname(item.path))) {
438
+ throw new Error('Permission denied');
439
+ }
440
+ fs.unlinkSync(item.path);
441
+ deleted++;
442
+ freed += item.size;
443
+ } catch (err) {
444
+ const errorInfo = { path: item.path, message: err.message };
445
+ errors.push(errorInfo);
446
+ if (onError) onError(errorInfo);
447
+ }
448
+ }
449
+
450
+ // Delete directories (sort by depth descending — deepest first)
451
+ const sortedDirs = dirs.sort((a, b) => {
452
+ const depthA = a.path.split(path.sep).length;
453
+ const depthB = b.path.split(path.sep).length;
454
+ return depthB - depthA;
455
+ });
456
+
457
+ for (const dir of sortedDirs) {
458
+ try {
459
+ if (dir.type === 'empty_directory') {
460
+ fs.rmdirSync(dir.path);
461
+ } else {
462
+ fs.rmSync(dir.path, { recursive: true, force: true });
463
+ }
464
+ deleted++;
465
+ freed += dir.size;
466
+ } catch (err) {
467
+ errors.push({ path: dir.path, message: err.message });
468
+ }
469
+ }
470
+
471
+ return {
472
+ deleted,
473
+ freed,
474
+ freedHuman: formatBytes(freed),
475
+ errors,
476
+ total: toDelete.length
477
+ };
478
+ }
479
+
480
+ // ─── Convenience Functions ────────────────────────────────────────
481
+
482
+ /**
483
+ * Quick scan for junk files
484
+ */
485
+ function findJunk(dirPath, options = {}) {
486
+ const cleaner = new Cleaner(options);
487
+ return cleaner.findJunk(dirPath);
488
+ }
489
+
490
+ /**
491
+ * Quick clean: scan + delete in one call
492
+ */
493
+ function clean(dirPath, options = {}) {
494
+ const { dryRun = false, filterCategories = null, ...scanOptions } = options;
495
+
496
+ const result = findJunk(dirPath, scanOptions);
497
+
498
+ if (dryRun) {
499
+ return {
500
+ dryRun: true,
501
+ junk: result.junk,
502
+ stats: result.stats,
503
+ deleteResult: null
504
+ };
505
+ }
506
+
507
+ const deleteResult = deleteJunk(result.junk, {
508
+ filterCategories,
509
+ onProgress: scanOptions.onProgress,
510
+ onError: scanOptions.onError,
511
+ });
512
+
513
+ return {
514
+ dryRun: false,
515
+ junk: result.junk,
516
+ stats: result.stats,
517
+ deleteResult
518
+ };
519
+ }
520
+
521
+ module.exports = {
522
+ JUNK_CATEGORIES,
523
+ Cleaner,
524
+ findJunk,
525
+ deleteJunk,
526
+ clean
527
+ };