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,436 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * ═══════════════════════════════════════════════════════════════════
5
+ * FILEMAYOR CORE — SCANNER
6
+ * Recursive directory scanner with configurable depth, filters,
7
+ * glob patterns, and category detection. Enterprise-grade.
8
+ * ═══════════════════════════════════════════════════════════════════
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { categorize } = require('./categories');
16
+ const { validatePath, isDirSafe, canRead } = require('./security');
17
+
18
+ // ─── Default Configuration ────────────────────────────────────────
19
+ const DEFAULT_SCAN_OPTIONS = {
20
+ maxDepth: 20, // Max recursion depth
21
+ followSymlinks: false, // Follow symbolic links
22
+ includeHidden: false, // Include dot files/dirs
23
+ includeDirectories: false, // Include directories in results
24
+ extensions: null, // Filter by extensions (null = all)
25
+ excludeExtensions: null, // Exclude specific extensions
26
+ ignore: [ // Directories to skip
27
+ 'node_modules', '.git', '__pycache__', '.venv',
28
+ 'venv', '.idea', '.vscode', 'dist', 'build',
29
+ '.svn', '.hg', 'bower_components', '.terraform',
30
+ '.next', '.nuxt', '.cache', 'coverage'
31
+ ],
32
+ ignorePatterns: [], // Glob-like name patterns to skip
33
+ minSize: 0, // Minimum file size in bytes
34
+ maxSize: Infinity, // Maximum file size in bytes
35
+ modifiedAfter: null, // Only files modified after this date
36
+ modifiedBefore: null, // Only files modified before this date
37
+ onProgress: null, // Progress callback
38
+ onError: null, // Error callback
39
+ abortSignal: null, // AbortController signal for cancellation
40
+ };
41
+
42
+ // ─── Glob Pattern Matching ────────────────────────────────────────
43
+
44
+ /**
45
+ * Simple glob matcher (supports * and ? wildcards)
46
+ * @param {string} pattern - Glob pattern
47
+ * @param {string} str - String to test
48
+ * @returns {boolean}
49
+ */
50
+ function matchGlob(pattern, str) {
51
+ const regex = pattern
52
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars
53
+ .replace(/\*/g, '.*') // * → .*
54
+ .replace(/\?/g, '.'); // ? → .
55
+ return new RegExp(`^${regex}$`, 'i').test(str);
56
+ }
57
+
58
+ // ─── File Info Construction ───────────────────────────────────────
59
+
60
+ /**
61
+ * Build a structured FileInfo object from a filesystem entry
62
+ * @param {string} fullPath - Absolute file path
63
+ * @param {fs.Stats} stats - File stats
64
+ * @param {string} rootPath - Original scan root for relative path
65
+ * @returns {Object} Structured file info
66
+ */
67
+ function buildFileInfo(fullPath, stats, rootPath) {
68
+ const ext = path.extname(fullPath).toLowerCase();
69
+ const name = path.basename(fullPath);
70
+ const relativePath = path.relative(rootPath, fullPath);
71
+ const category = categorize(ext);
72
+ const dir = path.dirname(fullPath);
73
+
74
+ return {
75
+ name,
76
+ path: fullPath,
77
+ relativePath,
78
+ directory: dir,
79
+ ext,
80
+ category,
81
+ size: stats.size,
82
+ sizeHuman: formatBytes(stats.size),
83
+ modified: stats.mtime,
84
+ created: stats.birthtime,
85
+ accessed: stats.atime,
86
+ isFile: stats.isFile(),
87
+ isDirectory: stats.isDirectory(),
88
+ isSymlink: stats.isSymbolicLink ? stats.isSymbolicLink() : false,
89
+ permissions: {
90
+ readable: true, // We got stats, so it's readable
91
+ mode: stats.mode
92
+ }
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Format bytes to human-readable string
98
+ * @param {number} bytes
99
+ * @returns {string}
100
+ */
101
+ function formatBytes(bytes) {
102
+ if (bytes === 0) return '0 B';
103
+ const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
104
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
105
+ const value = (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0);
106
+ return `${value} ${units[i]}`;
107
+ }
108
+
109
+ // ─── Scanner Class ────────────────────────────────────────────────
110
+
111
+ class Scanner {
112
+ constructor(options = {}) {
113
+ this.options = { ...DEFAULT_SCAN_OPTIONS, ...options };
114
+ this.stats = {
115
+ filesFound: 0,
116
+ dirsScanned: 0,
117
+ totalSize: 0,
118
+ errors: [],
119
+ skipped: 0,
120
+ startTime: null,
121
+ endTime: null,
122
+ categories: {}
123
+ };
124
+ this._aborted = false;
125
+ }
126
+
127
+ /**
128
+ * Scan a directory and return all matching files
129
+ * @param {string} dirPath - Directory to scan
130
+ * @returns {{ files: Object[], stats: Object }}
131
+ */
132
+ scan(dirPath) {
133
+ // Validate the path
134
+ const validation = validatePath(dirPath);
135
+ if (!validation.valid) {
136
+ throw new Error(`Invalid path: ${validation.error}`);
137
+ }
138
+
139
+ const safeCheck = isDirSafe(validation.resolved);
140
+ if (!safeCheck.safe) {
141
+ throw new Error(`Unsafe directory: ${safeCheck.reason}`);
142
+ }
143
+
144
+ if (!canRead(validation.resolved)) {
145
+ throw new Error(`Permission denied: cannot read "${validation.resolved}"`);
146
+ }
147
+
148
+ // Verify it's actually a directory
149
+ try {
150
+ const stat = fs.statSync(validation.resolved);
151
+ if (!stat.isDirectory()) {
152
+ throw new Error(`Not a directory: "${validation.resolved}"`);
153
+ }
154
+ } catch (err) {
155
+ if (err.code === 'ENOENT') {
156
+ throw new Error(`Directory not found: "${validation.resolved}"`);
157
+ }
158
+ throw err;
159
+ }
160
+
161
+ this.stats.startTime = Date.now();
162
+ const files = [];
163
+
164
+ this._scanRecursive(validation.resolved, validation.resolved, 0, files);
165
+
166
+ this.stats.endTime = Date.now();
167
+ this.stats.duration = this.stats.endTime - this.stats.startTime;
168
+ this.stats.durationHuman = this._formatDuration(this.stats.duration);
169
+
170
+ return {
171
+ root: validation.resolved,
172
+ files,
173
+ stats: { ...this.stats }
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Internal recursive scan implementation
179
+ */
180
+ _scanRecursive(dir, rootPath, depth, results) {
181
+ // Check abort signal
182
+ if (this._aborted || this.options.abortSignal?.aborted) {
183
+ this._aborted = true;
184
+ return;
185
+ }
186
+
187
+ // Depth limit
188
+ if (depth > this.options.maxDepth) return;
189
+
190
+ this.stats.dirsScanned++;
191
+
192
+ // Progress callback
193
+ if (this.options.onProgress) {
194
+ this.options.onProgress({
195
+ phase: 'scanning',
196
+ currentDir: dir,
197
+ filesFound: this.stats.filesFound,
198
+ dirsScanned: this.stats.dirsScanned,
199
+ depth
200
+ });
201
+ }
202
+
203
+ let items;
204
+ try {
205
+ items = fs.readdirSync(dir, { withFileTypes: true });
206
+ } catch (err) {
207
+ const errorInfo = {
208
+ path: dir,
209
+ code: err.code,
210
+ message: err.message,
211
+ depth
212
+ };
213
+ this.stats.errors.push(errorInfo);
214
+ if (this.options.onError) {
215
+ this.options.onError(errorInfo);
216
+ }
217
+ return;
218
+ }
219
+
220
+ for (const item of items) {
221
+ if (this._aborted) return;
222
+
223
+ const fullPath = path.join(dir, item.name);
224
+
225
+ // ─── Filtering ────────────────────────────────
226
+
227
+ // Skip hidden files/dirs
228
+ if (!this.options.includeHidden && item.name.startsWith('.')) {
229
+ this.stats.skipped++;
230
+ continue;
231
+ }
232
+
233
+ // Skip ignored directories
234
+ if (item.isDirectory() && this.options.ignore.includes(item.name)) {
235
+ this.stats.skipped++;
236
+ continue;
237
+ }
238
+
239
+ // Skip by glob patterns
240
+ if (this.options.ignorePatterns.length > 0) {
241
+ const shouldSkip = this.options.ignorePatterns.some(p => matchGlob(p, item.name));
242
+ if (shouldSkip) {
243
+ this.stats.skipped++;
244
+ continue;
245
+ }
246
+ }
247
+
248
+ // Handle symlinks
249
+ if (item.isSymbolicLink && item.isSymbolicLink() && !this.options.followSymlinks) {
250
+ this.stats.skipped++;
251
+ continue;
252
+ }
253
+
254
+ // ─── Process Entry ────────────────────────────
255
+
256
+ let stats;
257
+ try {
258
+ stats = this.options.followSymlinks
259
+ ? fs.statSync(fullPath)
260
+ : fs.lstatSync(fullPath);
261
+ } catch (err) {
262
+ this.stats.errors.push({
263
+ path: fullPath,
264
+ code: err.code,
265
+ message: err.message,
266
+ depth
267
+ });
268
+ continue;
269
+ }
270
+
271
+ if (stats.isFile()) {
272
+ // Apply file filters
273
+ if (this._shouldIncludeFile(fullPath, stats)) {
274
+ const fileInfo = buildFileInfo(fullPath, stats, rootPath);
275
+ results.push(fileInfo);
276
+ this.stats.filesFound++;
277
+ this.stats.totalSize += stats.size;
278
+
279
+ // Track category counts
280
+ const cat = fileInfo.category;
281
+ this.stats.categories[cat] = (this.stats.categories[cat] || 0) + 1;
282
+ } else {
283
+ this.stats.skipped++;
284
+ }
285
+ } else if (stats.isDirectory()) {
286
+ if (this.options.includeDirectories) {
287
+ const dirInfo = buildFileInfo(fullPath, stats, rootPath);
288
+ results.push(dirInfo);
289
+ }
290
+ this._scanRecursive(fullPath, rootPath, depth + 1, results);
291
+ }
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Check if a file passes all configured filters
297
+ */
298
+ _shouldIncludeFile(filePath, stats) {
299
+ const ext = path.extname(filePath).toLowerCase();
300
+
301
+ // Extension whitelist
302
+ if (this.options.extensions) {
303
+ const normalizedExts = this.options.extensions.map(e =>
304
+ e.startsWith('.') ? e.toLowerCase() : `.${e.toLowerCase()}`
305
+ );
306
+ if (!normalizedExts.includes(ext)) return false;
307
+ }
308
+
309
+ // Extension blacklist
310
+ if (this.options.excludeExtensions) {
311
+ const normalizedExcludes = this.options.excludeExtensions.map(e =>
312
+ e.startsWith('.') ? e.toLowerCase() : `.${e.toLowerCase()}`
313
+ );
314
+ if (normalizedExcludes.includes(ext)) return false;
315
+ }
316
+
317
+ // Size filters
318
+ if (stats.size < this.options.minSize) return false;
319
+ if (stats.size > this.options.maxSize) return false;
320
+
321
+ // Date filters
322
+ if (this.options.modifiedAfter) {
323
+ const threshold = new Date(this.options.modifiedAfter).getTime();
324
+ if (stats.mtime.getTime() < threshold) return false;
325
+ }
326
+ if (this.options.modifiedBefore) {
327
+ const threshold = new Date(this.options.modifiedBefore).getTime();
328
+ if (stats.mtime.getTime() > threshold) return false;
329
+ }
330
+
331
+ return true;
332
+ }
333
+
334
+ /**
335
+ * Abort the current scan
336
+ */
337
+ abort() {
338
+ this._aborted = true;
339
+ }
340
+
341
+ /**
342
+ * Format milliseconds to human-readable duration
343
+ */
344
+ _formatDuration(ms) {
345
+ if (ms < 1000) return `${ms}ms`;
346
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
347
+ const mins = Math.floor(ms / 60000);
348
+ const secs = ((ms % 60000) / 1000).toFixed(0);
349
+ return `${mins}m ${secs}s`;
350
+ }
351
+ }
352
+
353
+ // ─── Convenience Functions ────────────────────────────────────────
354
+
355
+ /**
356
+ * Quick scan a directory with default options
357
+ * @param {string} dirPath - Directory to scan
358
+ * @param {Object} options - Scan options
359
+ * @returns {{ files: Object[], stats: Object }}
360
+ */
361
+ function scan(dirPath, options = {}) {
362
+ const scanner = new Scanner(options);
363
+ return scanner.scan(dirPath);
364
+ }
365
+
366
+ /**
367
+ * Quick scan and group results by category
368
+ * @param {string} dirPath - Directory to scan
369
+ * @param {Object} options - Scan options
370
+ * @returns {{ categories: Object, stats: Object }}
371
+ */
372
+ function scanByCategory(dirPath, options = {}) {
373
+ const result = scan(dirPath, options);
374
+ const grouped = {};
375
+
376
+ for (const file of result.files) {
377
+ if (!grouped[file.category]) {
378
+ grouped[file.category] = {
379
+ files: [],
380
+ count: 0,
381
+ totalSize: 0
382
+ };
383
+ }
384
+ grouped[file.category].files.push(file);
385
+ grouped[file.category].count++;
386
+ grouped[file.category].totalSize += file.size;
387
+ }
388
+
389
+ // Sort each category's files by name
390
+ for (const cat of Object.values(grouped)) {
391
+ cat.files.sort((a, b) => a.name.localeCompare(b.name));
392
+ cat.totalSizeHuman = formatBytes(cat.totalSize);
393
+ }
394
+
395
+ return {
396
+ root: result.root,
397
+ categories: grouped,
398
+ stats: result.stats
399
+ };
400
+ }
401
+
402
+ /**
403
+ * Quick scan and get a size summary
404
+ * @param {string} dirPath - Directory to scan
405
+ * @param {Object} options - Scan options
406
+ * @returns {{ totalSize: number, totalSizeHuman: string, fileCount: number, largestFiles: Object[] }}
407
+ */
408
+ function scanSummary(dirPath, options = {}) {
409
+ const result = scan(dirPath, options);
410
+
411
+ // Sort by size descending
412
+ const sorted = [...result.files].sort((a, b) => b.size - a.size);
413
+
414
+ return {
415
+ root: result.root,
416
+ totalSize: result.stats.totalSize,
417
+ totalSizeHuman: formatBytes(result.stats.totalSize),
418
+ fileCount: result.stats.filesFound,
419
+ dirCount: result.stats.dirsScanned,
420
+ largestFiles: sorted.slice(0, 20),
421
+ categories: result.stats.categories,
422
+ duration: result.stats.durationHuman,
423
+ errors: result.stats.errors
424
+ };
425
+ }
426
+
427
+ module.exports = {
428
+ Scanner,
429
+ scan,
430
+ scanByCategory,
431
+ scanSummary,
432
+ buildFileInfo,
433
+ formatBytes,
434
+ matchGlob,
435
+ DEFAULT_SCAN_OPTIONS
436
+ };