filemayor 3.6.0 → 4.0.5

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/scanner.js CHANGED
@@ -1,466 +1,466 @@
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
- const validation = this._validate(dirPath);
134
- this.stats.startTime = Date.now();
135
- const files = [];
136
-
137
- this._scanRecursive(validation.resolved, validation.resolved, 0, files);
138
-
139
- this.stats.endTime = Date.now();
140
- this.stats.duration = this.stats.endTime - this.stats.startTime;
141
- this.stats.durationHuman = this._formatDuration(this.stats.duration);
142
-
143
- return {
144
- root: validation.resolved,
145
- files,
146
- stats: { ...this.stats }
147
- };
148
- }
149
-
150
- /**
151
- * Asynchronous scan for higher performance and non-blocking operation
152
- * @param {string} dirPath
153
- */
154
- async scanAsync(dirPath) {
155
- const validation = this._validate(dirPath);
156
- this.stats.startTime = Date.now();
157
- const files = [];
158
-
159
- await this._scanRecursiveAsync(validation.resolved, validation.resolved, 0, files);
160
-
161
- this.stats.endTime = Date.now();
162
- this.stats.duration = this.stats.endTime - this.stats.startTime;
163
- this.stats.durationHuman = this._formatDuration(this.stats.duration);
164
-
165
- return {
166
- root: validation.resolved,
167
- files,
168
- stats: { ...this.stats }
169
- };
170
- }
171
-
172
- /**
173
- * Shared validation logic
174
- */
175
- _validate(dirPath) {
176
- const validation = validatePath(dirPath);
177
- if (!validation.valid) throw new Error(`Invalid path: ${validation.error}`);
178
-
179
- const safeCheck = isDirSafe(validation.resolved);
180
- if (!safeCheck.safe) throw new Error(`Unsafe directory: ${safeCheck.reason}`);
181
-
182
- if (!canRead(validation.resolved)) {
183
- throw new Error(`Permission denied: cannot read "${validation.resolved}"`);
184
- }
185
-
186
- return validation;
187
- }
188
-
189
- /**
190
- * Internal recursive scan implementation
191
- */
192
- _scanRecursive(dir, rootPath, depth, results) {
193
- if (this._aborted || this.options.abortSignal?.aborted) {
194
- this._aborted = true;
195
- return;
196
- }
197
-
198
- if (depth > this.options.maxDepth) return;
199
- this.stats.dirsScanned++;
200
-
201
- if (this.options.onProgress) {
202
- this.options.onProgress({
203
- phase: 'scanning',
204
- currentDir: dir,
205
- filesFound: this.stats.filesFound,
206
- dirsScanned: this.stats.dirsScanned,
207
- depth
208
- });
209
- }
210
-
211
- let items;
212
- try {
213
- items = fs.readdirSync(dir, { withFileTypes: true });
214
- } catch (err) {
215
- this.stats.errors.push({ path: dir, code: err.code, message: err.message });
216
- if (this.options.onError) this.options.onError({ path: dir, error: err.message });
217
- return;
218
- }
219
-
220
- for (const item of items) {
221
- if (this._aborted) return;
222
- const fullPath = path.join(dir, item.name);
223
-
224
- if (!this.options.includeHidden && item.name.startsWith('.')) {
225
- this.stats.skipped++;
226
- continue;
227
- }
228
-
229
- if (item.isDirectory() && this.options.ignore.includes(item.name)) {
230
- this.stats.skipped++;
231
- continue;
232
- }
233
-
234
- if (this.options.ignorePatterns.length > 0) {
235
- const shouldSkip = this.options.ignorePatterns.some(p => matchGlob(p, item.name));
236
- if (shouldSkip) {
237
- this.stats.skipped++;
238
- continue;
239
- }
240
- }
241
-
242
- if (item.isSymbolicLink && item.isSymbolicLink() && !this.options.followSymlinks) {
243
- this.stats.skipped++;
244
- continue;
245
- }
246
-
247
- let stats;
248
- try {
249
- stats = this.options.followSymlinks ? fs.statSync(fullPath) : fs.lstatSync(fullPath);
250
- } catch (err) {
251
- this.stats.errors.push({ path: fullPath, code: err.code, message: err.message });
252
- continue;
253
- }
254
-
255
- if (stats.isFile()) {
256
- if (this._shouldIncludeFile(fullPath, stats)) {
257
- const fileInfo = buildFileInfo(fullPath, stats, rootPath);
258
- results.push(fileInfo);
259
- this.stats.filesFound++;
260
- this.stats.totalSize += stats.size;
261
- const cat = fileInfo.category;
262
- this.stats.categories[cat] = (this.stats.categories[cat] || 0) + 1;
263
- } else {
264
- this.stats.skipped++;
265
- }
266
- } else if (stats.isDirectory()) {
267
- if (this.options.includeDirectories) {
268
- results.push(buildFileInfo(fullPath, stats, rootPath));
269
- }
270
- this._scanRecursive(fullPath, rootPath, depth + 1, results);
271
- }
272
- }
273
- }
274
-
275
- /**
276
- * Async recursive scan using promises
277
- */
278
- async _scanRecursiveAsync(dir, rootPath, depth, results) {
279
- if (this._aborted || this.options.abortSignal?.aborted) {
280
- this._aborted = true;
281
- return;
282
- }
283
-
284
- if (depth > this.options.maxDepth) return;
285
- this.stats.dirsScanned++;
286
-
287
- let items;
288
- try {
289
- items = await fs.promises.readdir(dir, { withFileTypes: true });
290
- } catch (err) {
291
- this.stats.errors.push({ path: dir, code: err.code, message: err.message });
292
- return;
293
- }
294
-
295
- const promises = items.map(async (item) => {
296
- if (this._aborted) return;
297
- const fullPath = path.join(dir, item.name);
298
-
299
- // Filtering
300
- if (!this.options.includeHidden && item.name.startsWith('.')) return;
301
- if (item.isDirectory() && this.options.ignore.includes(item.name)) return;
302
-
303
- let stats;
304
- try {
305
- stats = await fs.promises.lstat(fullPath);
306
- } catch (err) { return; }
307
-
308
- if (stats.isFile()) {
309
- if (this._shouldIncludeFile(fullPath, stats)) {
310
- const fileInfo = buildFileInfo(fullPath, stats, rootPath);
311
- results.push(fileInfo);
312
- this.stats.filesFound++;
313
- this.stats.totalSize += stats.size;
314
- const cat = fileInfo.category;
315
- this.stats.categories[cat] = (this.stats.categories[cat] || 0) + 1;
316
- }
317
- } else if (stats.isDirectory()) {
318
- await this._scanRecursiveAsync(fullPath, rootPath, depth + 1, results);
319
- }
320
- });
321
-
322
- await Promise.all(promises);
323
- }
324
-
325
- /**
326
- * Check if a file passes all configured filters
327
- */
328
- _shouldIncludeFile(filePath, stats) {
329
- const ext = path.extname(filePath).toLowerCase();
330
-
331
- // Extension whitelist
332
- if (this.options.extensions) {
333
- const normalizedExts = this.options.extensions.map(e =>
334
- e.startsWith('.') ? e.toLowerCase() : `.${e.toLowerCase()}`
335
- );
336
- if (!normalizedExts.includes(ext)) return false;
337
- }
338
-
339
- // Extension blacklist
340
- if (this.options.excludeExtensions) {
341
- const normalizedExcludes = this.options.excludeExtensions.map(e =>
342
- e.startsWith('.') ? e.toLowerCase() : `.${e.toLowerCase()}`
343
- );
344
- if (normalizedExcludes.includes(ext)) return false;
345
- }
346
-
347
- // Size filters
348
- if (stats.size < this.options.minSize) return false;
349
- if (stats.size > this.options.maxSize) return false;
350
-
351
- // Date filters
352
- if (this.options.modifiedAfter) {
353
- const threshold = new Date(this.options.modifiedAfter).getTime();
354
- if (stats.mtime.getTime() < threshold) return false;
355
- }
356
- if (this.options.modifiedBefore) {
357
- const threshold = new Date(this.options.modifiedBefore).getTime();
358
- if (stats.mtime.getTime() > threshold) return false;
359
- }
360
-
361
- return true;
362
- }
363
-
364
- /**
365
- * Abort the current scan
366
- */
367
- abort() {
368
- this._aborted = true;
369
- }
370
-
371
- /**
372
- * Format milliseconds to human-readable duration
373
- */
374
- _formatDuration(ms) {
375
- if (ms < 1000) return `${ms}ms`;
376
- if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
377
- const mins = Math.floor(ms / 60000);
378
- const secs = ((ms % 60000) / 1000).toFixed(0);
379
- return `${mins}m ${secs}s`;
380
- }
381
- }
382
-
383
- // ─── Convenience Functions ────────────────────────────────────────
384
-
385
- /**
386
- * Quick scan a directory with default options
387
- * @param {string} dirPath - Directory to scan
388
- * @param {Object} options - Scan options
389
- * @returns {{ files: Object[], stats: Object }}
390
- */
391
- function scan(dirPath, options = {}) {
392
- const scanner = new Scanner(options);
393
- return scanner.scan(dirPath);
394
- }
395
-
396
- /**
397
- * Quick scan and group results by category
398
- * @param {string} dirPath - Directory to scan
399
- * @param {Object} options - Scan options
400
- * @returns {{ categories: Object, stats: Object }}
401
- */
402
- function scanByCategory(dirPath, options = {}) {
403
- const result = scan(dirPath, options);
404
- const grouped = {};
405
-
406
- for (const file of result.files) {
407
- if (!grouped[file.category]) {
408
- grouped[file.category] = {
409
- files: [],
410
- count: 0,
411
- totalSize: 0
412
- };
413
- }
414
- grouped[file.category].files.push(file);
415
- grouped[file.category].count++;
416
- grouped[file.category].totalSize += file.size;
417
- }
418
-
419
- // Sort each category's files by name
420
- for (const cat of Object.values(grouped)) {
421
- cat.files.sort((a, b) => a.name.localeCompare(b.name));
422
- cat.totalSizeHuman = formatBytes(cat.totalSize);
423
- }
424
-
425
- return {
426
- root: result.root,
427
- categories: grouped,
428
- stats: result.stats
429
- };
430
- }
431
-
432
- /**
433
- * Quick scan and get a size summary
434
- * @param {string} dirPath - Directory to scan
435
- * @param {Object} options - Scan options
436
- * @returns {{ totalSize: number, totalSizeHuman: string, fileCount: number, largestFiles: Object[] }}
437
- */
438
- function scanSummary(dirPath, options = {}) {
439
- const result = scan(dirPath, options);
440
-
441
- // Sort by size descending
442
- const sorted = [...result.files].sort((a, b) => b.size - a.size);
443
-
444
- return {
445
- root: result.root,
446
- totalSize: result.stats.totalSize,
447
- totalSizeHuman: formatBytes(result.stats.totalSize),
448
- fileCount: result.stats.filesFound,
449
- dirCount: result.stats.dirsScanned,
450
- largestFiles: sorted.slice(0, 20),
451
- categories: result.stats.categories,
452
- duration: result.stats.durationHuman,
453
- errors: result.stats.errors
454
- };
455
- }
456
-
457
- module.exports = {
458
- Scanner,
459
- scan,
460
- scanByCategory,
461
- scanSummary,
462
- buildFileInfo,
463
- formatBytes,
464
- matchGlob,
465
- DEFAULT_SCAN_OPTIONS
466
- };
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
+ const validation = this._validate(dirPath);
134
+ this.stats.startTime = Date.now();
135
+ const files = [];
136
+
137
+ this._scanRecursive(validation.resolved, validation.resolved, 0, files);
138
+
139
+ this.stats.endTime = Date.now();
140
+ this.stats.duration = this.stats.endTime - this.stats.startTime;
141
+ this.stats.durationHuman = this._formatDuration(this.stats.duration);
142
+
143
+ return {
144
+ root: validation.resolved,
145
+ files,
146
+ stats: { ...this.stats }
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Asynchronous scan for higher performance and non-blocking operation
152
+ * @param {string} dirPath
153
+ */
154
+ async scanAsync(dirPath) {
155
+ const validation = this._validate(dirPath);
156
+ this.stats.startTime = Date.now();
157
+ const files = [];
158
+
159
+ await this._scanRecursiveAsync(validation.resolved, validation.resolved, 0, files);
160
+
161
+ this.stats.endTime = Date.now();
162
+ this.stats.duration = this.stats.endTime - this.stats.startTime;
163
+ this.stats.durationHuman = this._formatDuration(this.stats.duration);
164
+
165
+ return {
166
+ root: validation.resolved,
167
+ files,
168
+ stats: { ...this.stats }
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Shared validation logic
174
+ */
175
+ _validate(dirPath) {
176
+ const validation = validatePath(dirPath);
177
+ if (!validation.valid) throw new Error(`Invalid path: ${validation.error}`);
178
+
179
+ const safeCheck = isDirSafe(validation.resolved);
180
+ if (!safeCheck.safe) throw new Error(`Unsafe directory: ${safeCheck.reason}`);
181
+
182
+ if (!canRead(validation.resolved)) {
183
+ throw new Error(`Permission denied: cannot read "${validation.resolved}"`);
184
+ }
185
+
186
+ return validation;
187
+ }
188
+
189
+ /**
190
+ * Internal recursive scan implementation
191
+ */
192
+ _scanRecursive(dir, rootPath, depth, results) {
193
+ if (this._aborted || this.options.abortSignal?.aborted) {
194
+ this._aborted = true;
195
+ return;
196
+ }
197
+
198
+ if (depth > this.options.maxDepth) return;
199
+ this.stats.dirsScanned++;
200
+
201
+ if (this.options.onProgress) {
202
+ this.options.onProgress({
203
+ phase: 'scanning',
204
+ currentDir: dir,
205
+ filesFound: this.stats.filesFound,
206
+ dirsScanned: this.stats.dirsScanned,
207
+ depth
208
+ });
209
+ }
210
+
211
+ let items;
212
+ try {
213
+ items = fs.readdirSync(dir, { withFileTypes: true });
214
+ } catch (err) {
215
+ this.stats.errors.push({ path: dir, code: err.code, message: err.message });
216
+ if (this.options.onError) this.options.onError({ path: dir, error: err.message });
217
+ return;
218
+ }
219
+
220
+ for (const item of items) {
221
+ if (this._aborted) return;
222
+ const fullPath = path.join(dir, item.name);
223
+
224
+ if (!this.options.includeHidden && item.name.startsWith('.')) {
225
+ this.stats.skipped++;
226
+ continue;
227
+ }
228
+
229
+ if (item.isDirectory() && this.options.ignore.includes(item.name)) {
230
+ this.stats.skipped++;
231
+ continue;
232
+ }
233
+
234
+ if (this.options.ignorePatterns.length > 0) {
235
+ const shouldSkip = this.options.ignorePatterns.some(p => matchGlob(p, item.name));
236
+ if (shouldSkip) {
237
+ this.stats.skipped++;
238
+ continue;
239
+ }
240
+ }
241
+
242
+ if (item.isSymbolicLink && item.isSymbolicLink() && !this.options.followSymlinks) {
243
+ this.stats.skipped++;
244
+ continue;
245
+ }
246
+
247
+ let stats;
248
+ try {
249
+ stats = this.options.followSymlinks ? fs.statSync(fullPath) : fs.lstatSync(fullPath);
250
+ } catch (err) {
251
+ this.stats.errors.push({ path: fullPath, code: err.code, message: err.message });
252
+ continue;
253
+ }
254
+
255
+ if (stats.isFile()) {
256
+ if (this._shouldIncludeFile(fullPath, stats)) {
257
+ const fileInfo = buildFileInfo(fullPath, stats, rootPath);
258
+ results.push(fileInfo);
259
+ this.stats.filesFound++;
260
+ this.stats.totalSize += stats.size;
261
+ const cat = fileInfo.category;
262
+ this.stats.categories[cat] = (this.stats.categories[cat] || 0) + 1;
263
+ } else {
264
+ this.stats.skipped++;
265
+ }
266
+ } else if (stats.isDirectory()) {
267
+ if (this.options.includeDirectories) {
268
+ results.push(buildFileInfo(fullPath, stats, rootPath));
269
+ }
270
+ this._scanRecursive(fullPath, rootPath, depth + 1, results);
271
+ }
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Async recursive scan using promises
277
+ */
278
+ async _scanRecursiveAsync(dir, rootPath, depth, results) {
279
+ if (this._aborted || this.options.abortSignal?.aborted) {
280
+ this._aborted = true;
281
+ return;
282
+ }
283
+
284
+ if (depth > this.options.maxDepth) return;
285
+ this.stats.dirsScanned++;
286
+
287
+ let items;
288
+ try {
289
+ items = await fs.promises.readdir(dir, { withFileTypes: true });
290
+ } catch (err) {
291
+ this.stats.errors.push({ path: dir, code: err.code, message: err.message });
292
+ return;
293
+ }
294
+
295
+ const promises = items.map(async (item) => {
296
+ if (this._aborted) return;
297
+ const fullPath = path.join(dir, item.name);
298
+
299
+ // Filtering
300
+ if (!this.options.includeHidden && item.name.startsWith('.')) return;
301
+ if (item.isDirectory() && this.options.ignore.includes(item.name)) return;
302
+
303
+ let stats;
304
+ try {
305
+ stats = await fs.promises.lstat(fullPath);
306
+ } catch (err) { return; }
307
+
308
+ if (stats.isFile()) {
309
+ if (this._shouldIncludeFile(fullPath, stats)) {
310
+ const fileInfo = buildFileInfo(fullPath, stats, rootPath);
311
+ results.push(fileInfo);
312
+ this.stats.filesFound++;
313
+ this.stats.totalSize += stats.size;
314
+ const cat = fileInfo.category;
315
+ this.stats.categories[cat] = (this.stats.categories[cat] || 0) + 1;
316
+ }
317
+ } else if (stats.isDirectory()) {
318
+ await this._scanRecursiveAsync(fullPath, rootPath, depth + 1, results);
319
+ }
320
+ });
321
+
322
+ await Promise.all(promises);
323
+ }
324
+
325
+ /**
326
+ * Check if a file passes all configured filters
327
+ */
328
+ _shouldIncludeFile(filePath, stats) {
329
+ const ext = path.extname(filePath).toLowerCase();
330
+
331
+ // Extension whitelist
332
+ if (this.options.extensions) {
333
+ const normalizedExts = this.options.extensions.map(e =>
334
+ e.startsWith('.') ? e.toLowerCase() : `.${e.toLowerCase()}`
335
+ );
336
+ if (!normalizedExts.includes(ext)) return false;
337
+ }
338
+
339
+ // Extension blacklist
340
+ if (this.options.excludeExtensions) {
341
+ const normalizedExcludes = this.options.excludeExtensions.map(e =>
342
+ e.startsWith('.') ? e.toLowerCase() : `.${e.toLowerCase()}`
343
+ );
344
+ if (normalizedExcludes.includes(ext)) return false;
345
+ }
346
+
347
+ // Size filters
348
+ if (stats.size < this.options.minSize) return false;
349
+ if (stats.size > this.options.maxSize) return false;
350
+
351
+ // Date filters
352
+ if (this.options.modifiedAfter) {
353
+ const threshold = new Date(this.options.modifiedAfter).getTime();
354
+ if (stats.mtime.getTime() < threshold) return false;
355
+ }
356
+ if (this.options.modifiedBefore) {
357
+ const threshold = new Date(this.options.modifiedBefore).getTime();
358
+ if (stats.mtime.getTime() > threshold) return false;
359
+ }
360
+
361
+ return true;
362
+ }
363
+
364
+ /**
365
+ * Abort the current scan
366
+ */
367
+ abort() {
368
+ this._aborted = true;
369
+ }
370
+
371
+ /**
372
+ * Format milliseconds to human-readable duration
373
+ */
374
+ _formatDuration(ms) {
375
+ if (ms < 1000) return `${ms}ms`;
376
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
377
+ const mins = Math.floor(ms / 60000);
378
+ const secs = ((ms % 60000) / 1000).toFixed(0);
379
+ return `${mins}m ${secs}s`;
380
+ }
381
+ }
382
+
383
+ // ─── Convenience Functions ────────────────────────────────────────
384
+
385
+ /**
386
+ * Quick scan a directory with default options
387
+ * @param {string} dirPath - Directory to scan
388
+ * @param {Object} options - Scan options
389
+ * @returns {{ files: Object[], stats: Object }}
390
+ */
391
+ function scan(dirPath, options = {}) {
392
+ const scanner = new Scanner(options);
393
+ return scanner.scan(dirPath);
394
+ }
395
+
396
+ /**
397
+ * Quick scan and group results by category
398
+ * @param {string} dirPath - Directory to scan
399
+ * @param {Object} options - Scan options
400
+ * @returns {{ categories: Object, stats: Object }}
401
+ */
402
+ function scanByCategory(dirPath, options = {}) {
403
+ const result = scan(dirPath, options);
404
+ const grouped = {};
405
+
406
+ for (const file of result.files) {
407
+ if (!grouped[file.category]) {
408
+ grouped[file.category] = {
409
+ files: [],
410
+ count: 0,
411
+ totalSize: 0
412
+ };
413
+ }
414
+ grouped[file.category].files.push(file);
415
+ grouped[file.category].count++;
416
+ grouped[file.category].totalSize += file.size;
417
+ }
418
+
419
+ // Sort each category's files by name
420
+ for (const cat of Object.values(grouped)) {
421
+ cat.files.sort((a, b) => a.name.localeCompare(b.name));
422
+ cat.totalSizeHuman = formatBytes(cat.totalSize);
423
+ }
424
+
425
+ return {
426
+ root: result.root,
427
+ categories: grouped,
428
+ stats: result.stats
429
+ };
430
+ }
431
+
432
+ /**
433
+ * Quick scan and get a size summary
434
+ * @param {string} dirPath - Directory to scan
435
+ * @param {Object} options - Scan options
436
+ * @returns {{ totalSize: number, totalSizeHuman: string, fileCount: number, largestFiles: Object[] }}
437
+ */
438
+ function scanSummary(dirPath, options = {}) {
439
+ const result = scan(dirPath, options);
440
+
441
+ // Sort by size descending
442
+ const sorted = [...result.files].sort((a, b) => b.size - a.size);
443
+
444
+ return {
445
+ root: result.root,
446
+ totalSize: result.stats.totalSize,
447
+ totalSizeHuman: formatBytes(result.stats.totalSize),
448
+ fileCount: result.stats.filesFound,
449
+ dirCount: result.stats.dirsScanned,
450
+ largestFiles: sorted.slice(0, 20),
451
+ categories: result.stats.categories,
452
+ duration: result.stats.durationHuman,
453
+ errors: result.stats.errors
454
+ };
455
+ }
456
+
457
+ module.exports = {
458
+ Scanner,
459
+ scan,
460
+ scanByCategory,
461
+ scanSummary,
462
+ buildFileInfo,
463
+ formatBytes,
464
+ matchGlob,
465
+ DEFAULT_SCAN_OPTIONS
466
+ };