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,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
+ };