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.
- package/core/categories.js +235 -0
- package/core/cleaner.js +527 -0
- package/core/config.js +562 -0
- package/core/index.js +79 -0
- package/core/organizer.js +528 -0
- package/core/reporter.js +572 -0
- package/core/scanner.js +436 -0
- package/core/security.js +317 -0
- package/core/sop-parser.js +565 -0
- package/core/watcher.js +478 -0
- package/index.js +536 -0
- package/package.json +55 -0
package/core/watcher.js
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ═══════════════════════════════════════════════════════════════════
|
|
5
|
+
* FILEMAYOR CORE — WATCHER
|
|
6
|
+
* Real-time file system watcher with rules engine, event logging,
|
|
7
|
+
* auto-organize on file drop, and daemon mode for servers.
|
|
8
|
+
* ═══════════════════════════════════════════════════════════════════
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { EventEmitter } = require('events');
|
|
16
|
+
const { categorize } = require('./categories');
|
|
17
|
+
const { organize } = require('./organizer');
|
|
18
|
+
const { validatePath, isDirSafe, isFileSafe } = require('./security');
|
|
19
|
+
const { formatBytes } = require('./scanner');
|
|
20
|
+
|
|
21
|
+
// ─── Watch Rules Engine ───────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} WatchRule
|
|
25
|
+
* @property {string} match - Glob pattern (e.g. "*.pdf")
|
|
26
|
+
* @property {string} action - Action: move, copy, delete, log, organize
|
|
27
|
+
* @property {string} [dest] - Destination directory (for move/copy)
|
|
28
|
+
* @property {string} [naming] - Naming convention to apply
|
|
29
|
+
* @property {boolean} [enabled] - Whether this rule is active
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if a filename matches a glob-like pattern
|
|
34
|
+
* @param {string} pattern - Glob pattern
|
|
35
|
+
* @param {string} filename - Filename to test
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
function matchRule(pattern, filename) {
|
|
39
|
+
// Support extension patterns: "*.pdf", "*.{jpg,png,gif}"
|
|
40
|
+
if (pattern.startsWith('*.')) {
|
|
41
|
+
const extPart = pattern.slice(1); // ".pdf" or ".{jpg,png,gif}"
|
|
42
|
+
|
|
43
|
+
if (extPart.startsWith('.{') && extPart.endsWith('}')) {
|
|
44
|
+
const exts = extPart.slice(2, -1).split(',').map(e => `.${e.trim()}`);
|
|
45
|
+
const fileExt = path.extname(filename).toLowerCase();
|
|
46
|
+
return exts.includes(fileExt);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return path.extname(filename).toLowerCase() === extPart.toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Support category patterns: "@documents", "@images"
|
|
53
|
+
if (pattern.startsWith('@')) {
|
|
54
|
+
const category = pattern.slice(1).toLowerCase();
|
|
55
|
+
const ext = path.extname(filename).toLowerCase();
|
|
56
|
+
return categorize(ext) === category;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Simple wildcard
|
|
60
|
+
const regex = pattern
|
|
61
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
62
|
+
.replace(/\*/g, '.*')
|
|
63
|
+
.replace(/\?/g, '.');
|
|
64
|
+
return new RegExp(`^${regex}$`, 'i').test(filename);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Watcher Class ────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
class FileWatcher extends EventEmitter {
|
|
70
|
+
constructor(options = {}) {
|
|
71
|
+
super();
|
|
72
|
+
|
|
73
|
+
this.options = {
|
|
74
|
+
directories: options.directories || [],
|
|
75
|
+
rules: options.rules || [],
|
|
76
|
+
debounceMs: options.debounceMs || 500,
|
|
77
|
+
recursive: options.recursive !== false,
|
|
78
|
+
ignoreHidden: options.ignoreHidden !== false,
|
|
79
|
+
logPath: options.logPath || null,
|
|
80
|
+
maxLogEntries: options.maxLogEntries || 10000,
|
|
81
|
+
autoOrganize: options.autoOrganize || false,
|
|
82
|
+
organizeOptions: options.organizeOptions || {},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
this._watchers = new Map();
|
|
86
|
+
this._debounceTimers = new Map();
|
|
87
|
+
this._running = false;
|
|
88
|
+
this._eventLog = [];
|
|
89
|
+
this._stats = {
|
|
90
|
+
eventsProcessed: 0,
|
|
91
|
+
filesActioned: 0,
|
|
92
|
+
errors: 0,
|
|
93
|
+
startTime: null,
|
|
94
|
+
uptime: 0
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Start watching configured directories
|
|
100
|
+
*/
|
|
101
|
+
start() {
|
|
102
|
+
if (this._running) {
|
|
103
|
+
this.emit('warn', 'Watcher is already running');
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (this.options.directories.length === 0) {
|
|
108
|
+
throw new Error('No directories configured to watch');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this._running = true;
|
|
112
|
+
this._stats.startTime = Date.now();
|
|
113
|
+
|
|
114
|
+
for (const dir of this.options.directories) {
|
|
115
|
+
this._watchDirectory(dir);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
this.emit('started', {
|
|
119
|
+
directories: this.options.directories,
|
|
120
|
+
rules: this.options.rules.length,
|
|
121
|
+
timestamp: new Date().toISOString()
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Load log from disk if path specified
|
|
125
|
+
if (this.options.logPath) {
|
|
126
|
+
this._loadLog();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Stop all watchers
|
|
132
|
+
*/
|
|
133
|
+
stop() {
|
|
134
|
+
this._running = false;
|
|
135
|
+
|
|
136
|
+
for (const [dir, watcher] of this._watchers) {
|
|
137
|
+
watcher.close();
|
|
138
|
+
this.emit('unwatched', { directory: dir });
|
|
139
|
+
}
|
|
140
|
+
this._watchers.clear();
|
|
141
|
+
|
|
142
|
+
// Clear debounce timers
|
|
143
|
+
for (const timer of this._debounceTimers.values()) {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
}
|
|
146
|
+
this._debounceTimers.clear();
|
|
147
|
+
|
|
148
|
+
// Save log to disk
|
|
149
|
+
if (this.options.logPath) {
|
|
150
|
+
this._saveLog();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this._stats.uptime = Date.now() - this._stats.startTime;
|
|
154
|
+
|
|
155
|
+
this.emit('stopped', {
|
|
156
|
+
stats: { ...this._stats },
|
|
157
|
+
timestamp: new Date().toISOString()
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Add a directory to watch
|
|
163
|
+
* @param {string} dir - Directory path
|
|
164
|
+
*/
|
|
165
|
+
addDirectory(dir) {
|
|
166
|
+
const validation = validatePath(dir);
|
|
167
|
+
if (!validation.valid) {
|
|
168
|
+
throw new Error(`Invalid path: ${validation.error}`);
|
|
169
|
+
}
|
|
170
|
+
if (!this.options.directories.includes(validation.resolved)) {
|
|
171
|
+
this.options.directories.push(validation.resolved);
|
|
172
|
+
}
|
|
173
|
+
if (this._running) {
|
|
174
|
+
this._watchDirectory(validation.resolved);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Remove a watched directory
|
|
180
|
+
* @param {string} dir - Directory path
|
|
181
|
+
*/
|
|
182
|
+
removeDirectory(dir) {
|
|
183
|
+
const resolved = path.resolve(dir);
|
|
184
|
+
const watcher = this._watchers.get(resolved);
|
|
185
|
+
if (watcher) {
|
|
186
|
+
watcher.close();
|
|
187
|
+
this._watchers.delete(resolved);
|
|
188
|
+
}
|
|
189
|
+
this.options.directories = this.options.directories.filter(d => d !== resolved);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Add a rule
|
|
194
|
+
* @param {WatchRule} rule
|
|
195
|
+
*/
|
|
196
|
+
addRule(rule) {
|
|
197
|
+
this.options.rules.push(rule);
|
|
198
|
+
this.emit('rule-added', rule);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Get current statistics
|
|
203
|
+
*/
|
|
204
|
+
getStats() {
|
|
205
|
+
return {
|
|
206
|
+
...this._stats,
|
|
207
|
+
uptime: this._running ? Date.now() - this._stats.startTime : this._stats.uptime,
|
|
208
|
+
watchedDirs: this._watchers.size,
|
|
209
|
+
rules: this.options.rules.length,
|
|
210
|
+
logEntries: this._eventLog.length,
|
|
211
|
+
running: this._running
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Get event log
|
|
217
|
+
* @param {number} limit - Max entries to return
|
|
218
|
+
*/
|
|
219
|
+
getLog(limit = 100) {
|
|
220
|
+
return this._eventLog.slice(-limit);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Internal: set up fs.watch on a directory
|
|
225
|
+
*/
|
|
226
|
+
_watchDirectory(dirPath) {
|
|
227
|
+
const resolved = path.resolve(dirPath);
|
|
228
|
+
|
|
229
|
+
// Validate
|
|
230
|
+
const safeCheck = isDirSafe(resolved);
|
|
231
|
+
if (!safeCheck.safe) {
|
|
232
|
+
this.emit('error', { directory: resolved, error: safeCheck.reason });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Check directory exists
|
|
237
|
+
try {
|
|
238
|
+
if (!fs.statSync(resolved).isDirectory()) {
|
|
239
|
+
this.emit('error', { directory: resolved, error: 'Not a directory' });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
} catch (err) {
|
|
243
|
+
this.emit('error', { directory: resolved, error: err.message });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Already watching?
|
|
248
|
+
if (this._watchers.has(resolved)) return;
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const watcher = fs.watch(resolved, {
|
|
252
|
+
recursive: this.options.recursive,
|
|
253
|
+
persistent: true
|
|
254
|
+
}, (eventType, filename) => {
|
|
255
|
+
if (!filename) return;
|
|
256
|
+
this._handleEvent(eventType, filename, resolved);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
watcher.on('error', (err) => {
|
|
260
|
+
this.emit('error', { directory: resolved, error: err.message });
|
|
261
|
+
this._watchers.delete(resolved);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
this._watchers.set(resolved, watcher);
|
|
265
|
+
|
|
266
|
+
this.emit('watching', {
|
|
267
|
+
directory: resolved,
|
|
268
|
+
recursive: this.options.recursive,
|
|
269
|
+
timestamp: new Date().toISOString()
|
|
270
|
+
});
|
|
271
|
+
} catch (err) {
|
|
272
|
+
this.emit('error', { directory: resolved, error: err.message });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Internal: handle a file system event with debouncing
|
|
278
|
+
*/
|
|
279
|
+
_handleEvent(eventType, filename, watchDir) {
|
|
280
|
+
if (this.options.ignoreHidden && filename.startsWith('.')) return;
|
|
281
|
+
|
|
282
|
+
const fullPath = path.join(watchDir, filename);
|
|
283
|
+
const debounceKey = fullPath;
|
|
284
|
+
|
|
285
|
+
// Debounce rapid events on the same file
|
|
286
|
+
if (this._debounceTimers.has(debounceKey)) {
|
|
287
|
+
clearTimeout(this._debounceTimers.get(debounceKey));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
this._debounceTimers.set(debounceKey, setTimeout(() => {
|
|
291
|
+
this._debounceTimers.delete(debounceKey);
|
|
292
|
+
this._processEvent(eventType, filename, fullPath, watchDir);
|
|
293
|
+
}, this.options.debounceMs));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Internal: process a debounced event
|
|
298
|
+
*/
|
|
299
|
+
_processEvent(eventType, filename, fullPath, watchDir) {
|
|
300
|
+
this._stats.eventsProcessed++;
|
|
301
|
+
|
|
302
|
+
// Check if file still exists (it might have been a delete event)
|
|
303
|
+
let exists = false;
|
|
304
|
+
let stats = null;
|
|
305
|
+
try {
|
|
306
|
+
stats = fs.statSync(fullPath);
|
|
307
|
+
exists = true;
|
|
308
|
+
} catch { /* file was deleted or moved */ }
|
|
309
|
+
|
|
310
|
+
const event = {
|
|
311
|
+
type: eventType,
|
|
312
|
+
filename,
|
|
313
|
+
path: fullPath,
|
|
314
|
+
watchDir,
|
|
315
|
+
exists,
|
|
316
|
+
size: stats ? stats.size : 0,
|
|
317
|
+
sizeHuman: stats ? formatBytes(stats.size) : '0 B',
|
|
318
|
+
category: exists ? categorize(path.extname(filename).toLowerCase()) : null,
|
|
319
|
+
timestamp: new Date().toISOString()
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// Log the event
|
|
323
|
+
this._logEvent(event);
|
|
324
|
+
|
|
325
|
+
// Emit raw event
|
|
326
|
+
this.emit('change', event);
|
|
327
|
+
|
|
328
|
+
// Only process rules for new/changed files
|
|
329
|
+
if (!exists || !stats.isFile()) return;
|
|
330
|
+
|
|
331
|
+
// Check rules
|
|
332
|
+
for (const rule of this.options.rules) {
|
|
333
|
+
if (rule.enabled === false) continue;
|
|
334
|
+
if (!matchRule(rule.match, filename)) continue;
|
|
335
|
+
|
|
336
|
+
this.emit('rule-matched', { rule, event });
|
|
337
|
+
this._executeRule(rule, event);
|
|
338
|
+
break; // First match wins
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Auto-organize if enabled
|
|
342
|
+
if (this.options.autoOrganize && eventType === 'rename') {
|
|
343
|
+
// A new file appeared — organize the directory
|
|
344
|
+
try {
|
|
345
|
+
organize(watchDir, {
|
|
346
|
+
dryRun: false,
|
|
347
|
+
...this.options.organizeOptions
|
|
348
|
+
});
|
|
349
|
+
this.emit('auto-organized', { directory: watchDir, trigger: filename });
|
|
350
|
+
} catch (err) {
|
|
351
|
+
this.emit('error', { action: 'auto-organize', error: err.message });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Execute a matched rule
|
|
358
|
+
*/
|
|
359
|
+
_executeRule(rule, event) {
|
|
360
|
+
const safeCheck = isFileSafe(event.path);
|
|
361
|
+
if (!safeCheck.safe) {
|
|
362
|
+
this.emit('rule-skipped', {
|
|
363
|
+
rule,
|
|
364
|
+
event,
|
|
365
|
+
reason: safeCheck.reason
|
|
366
|
+
});
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
switch (rule.action) {
|
|
372
|
+
case 'move':
|
|
373
|
+
if (!rule.dest) break;
|
|
374
|
+
const destDir = path.resolve(rule.dest);
|
|
375
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
376
|
+
const destPath = path.join(destDir, event.filename);
|
|
377
|
+
fs.renameSync(event.path, destPath);
|
|
378
|
+
this._stats.filesActioned++;
|
|
379
|
+
this.emit('action', {
|
|
380
|
+
action: 'move',
|
|
381
|
+
source: event.path,
|
|
382
|
+
destination: destPath,
|
|
383
|
+
rule
|
|
384
|
+
});
|
|
385
|
+
break;
|
|
386
|
+
|
|
387
|
+
case 'copy':
|
|
388
|
+
if (!rule.dest) break;
|
|
389
|
+
const copyDestDir = path.resolve(rule.dest);
|
|
390
|
+
fs.mkdirSync(copyDestDir, { recursive: true });
|
|
391
|
+
const copyDest = path.join(copyDestDir, event.filename);
|
|
392
|
+
fs.copyFileSync(event.path, copyDest);
|
|
393
|
+
this._stats.filesActioned++;
|
|
394
|
+
this.emit('action', {
|
|
395
|
+
action: 'copy',
|
|
396
|
+
source: event.path,
|
|
397
|
+
destination: copyDest,
|
|
398
|
+
rule
|
|
399
|
+
});
|
|
400
|
+
break;
|
|
401
|
+
|
|
402
|
+
case 'delete':
|
|
403
|
+
fs.unlinkSync(event.path);
|
|
404
|
+
this._stats.filesActioned++;
|
|
405
|
+
this.emit('action', {
|
|
406
|
+
action: 'delete',
|
|
407
|
+
source: event.path,
|
|
408
|
+
rule
|
|
409
|
+
});
|
|
410
|
+
break;
|
|
411
|
+
|
|
412
|
+
case 'log':
|
|
413
|
+
this.emit('action', {
|
|
414
|
+
action: 'log',
|
|
415
|
+
source: event.path,
|
|
416
|
+
message: `Matched rule: ${rule.match}`,
|
|
417
|
+
rule
|
|
418
|
+
});
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
} catch (err) {
|
|
422
|
+
this._stats.errors++;
|
|
423
|
+
this.emit('error', {
|
|
424
|
+
action: rule.action,
|
|
425
|
+
path: event.path,
|
|
426
|
+
error: err.message,
|
|
427
|
+
rule
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Log an event to memory and optionally disk
|
|
434
|
+
*/
|
|
435
|
+
_logEvent(event) {
|
|
436
|
+
this._eventLog.push(event);
|
|
437
|
+
|
|
438
|
+
// Trim log if too large
|
|
439
|
+
if (this._eventLog.length > this.options.maxLogEntries) {
|
|
440
|
+
this._eventLog = this._eventLog.slice(-this.options.maxLogEntries);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Save event log to disk
|
|
446
|
+
*/
|
|
447
|
+
_saveLog() {
|
|
448
|
+
if (!this.options.logPath) return;
|
|
449
|
+
try {
|
|
450
|
+
fs.writeFileSync(
|
|
451
|
+
this.options.logPath,
|
|
452
|
+
JSON.stringify(this._eventLog, null, 2),
|
|
453
|
+
'utf8'
|
|
454
|
+
);
|
|
455
|
+
} catch (err) {
|
|
456
|
+
this.emit('error', { action: 'save-log', error: err.message });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Load event log from disk
|
|
462
|
+
*/
|
|
463
|
+
_loadLog() {
|
|
464
|
+
if (!this.options.logPath) return;
|
|
465
|
+
try {
|
|
466
|
+
if (fs.existsSync(this.options.logPath)) {
|
|
467
|
+
this._eventLog = JSON.parse(
|
|
468
|
+
fs.readFileSync(this.options.logPath, 'utf8')
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
} catch { /* start fresh */ }
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
module.exports = {
|
|
476
|
+
FileWatcher,
|
|
477
|
+
matchRule
|
|
478
|
+
};
|