mm_scanner 0.0.0-stage → 1.0.1

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,5 @@
1
+ {
2
+ "name": "demo_app",
3
+ "version": "1.0.0",
4
+ "description": "示例应用配置"
5
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "host": "127.0.0.1",
3
+ "port": 3306
4
+ }
@@ -0,0 +1,3 @@
1
+ module.exports = {
2
+ level: 'info'
3
+ };
@@ -0,0 +1,3 @@
1
+ {
2
+ "name": "build_artifact"
3
+ }
@@ -0,0 +1 @@
1
+ 演示文件,不会被默认配置扫描
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const { Scanner, scannerAdmin } = require('../index');
5
+
6
+ /**
7
+ * 使用示例:扫描 example/data 目录并按多种方式检索文件
8
+ */
9
+ function demo() {
10
+ const dir = path.join(__dirname, 'data');
11
+
12
+ // 1. 创建扫描器,默认只扫描 .json,并忽略 node_modules 与 .git
13
+ const scanner = new Scanner();
14
+ console.log('=== 默认配置 ===');
15
+ console.log(' 扩展名: ' + scanner.extensions.join(', '));
16
+ console.log(' 忽略目录: ' + scanner.config.ignore.join(', '));
17
+
18
+ // 2. 扫描目录,可重复调用(仅首次生效)
19
+ scanner.scan(dir);
20
+ console.log('=== 扫描结果 ===');
21
+ console.log(' 基目录: ' + scanner.baseDir);
22
+ console.log(' 文件数: ' + scanner.size);
23
+ for (const key of scanner.keys()) {
24
+ console.log(' ' + key);
25
+ }
26
+
27
+ // 3. 按 key 查询,key 可省略扩展名
28
+ console.log('=== 按 key 查询 ===');
29
+ console.log(' 元信息: ' + JSON.stringify(scanner.get('app/app')));
30
+ console.log(' 绝对路径: ' + scanner.getPath('config/db'));
31
+ console.log(' 是否存在 config/logger: ' + scanner.has('config/logger'));
32
+
33
+ // 4. 按扩展名、目录、层级筛选
34
+ console.log('=== 分类检索 ===');
35
+ console.log(' .json 文件: ' + scanner.getByExt('.json').length + ' 个');
36
+ console.log(' config 目录: ' + scanner.getByDir('config').map((f) => f.key).join(', '));
37
+ console.log(' 一级子目录: ' + scanner.dirTypes().join(', '));
38
+ console.log(' 扩展名类型: ' + scanner.extTypes().join(', '));
39
+
40
+ // 5. 自定义扩展名与忽略目录
41
+ const custom = new Scanner({ extensions: ['json', 'js'], ignore: ['dist'] });
42
+ custom.scan(dir);
43
+ console.log('=== 自定义配置 ===');
44
+ console.log(' 文件数: ' + custom.size);
45
+ console.log(' 文件列表: ' + custom.keys().join(', '));
46
+ console.log(' dist 目录已被忽略: ' + (custom.getByDir('dist').length === 0));
47
+
48
+ // 6. 文件变化后重新扫描
49
+ custom.rescan();
50
+ console.log('=== 重新扫描 ===');
51
+ console.log(' 文件数: ' + custom.size);
52
+
53
+ // 7. 通过 admin 按作用域复用扫描器实例
54
+ const os_scanner = scannerAdmin('os', { extensions: ['json'] });
55
+ const demo_scanner = scannerAdmin('demo');
56
+ console.log('=== 作用域实例 ===');
57
+ console.log(' os 实例类型正确: ' + (os_scanner instanceof Scanner));
58
+ console.log(' 重复获取 os 实例复用缓存: ' + (scannerAdmin('os') === os_scanner));
59
+ console.log(' demo 实例与 os 实例相互独立: ' + (demo_scanner !== os_scanner));
60
+ }
61
+
62
+ demo();
package/index.js ADDED
@@ -0,0 +1,16 @@
1
+ const Scanner = require('./lib/scanner');
2
+ const Admin = require('mm_corejs').Admin;
3
+
4
+ const admin = new Admin();
5
+ admin.setClass(Scanner);
6
+
7
+ module.exports = {
8
+ Scanner,
9
+ admin,
10
+ scannerAdmin(scope, config) {
11
+ if (!scope) {
12
+ scope = 'os';
13
+ }
14
+ return admin.get(scope, config);
15
+ }
16
+ };
package/lib/scanner.js ADDED
@@ -0,0 +1,915 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /* 扫描结果缓存:跨实例复用同一目录的遍历结果 */
7
+ const _scan_cache = new Map();
8
+ /* 目录监听器:缓存键 -> { watcher, timer, pending } */
9
+ const _watchers = new Map();
10
+ /* 处于监听状态的实例:缓存键 -> Set<Scanner> */
11
+ const _instances = new Map();
12
+ /* 缓存条目上限,超出后整体清空 */
13
+ const _CACHE_MAX = 200;
14
+ /* 短时间窗口内直接复用,避免每次重复校验目录与文件的时间戳 */
15
+ const _CACHE_TTL = 2000;
16
+ /* 文件事件防抖窗口,合并一次写入产生的多条事件 */
17
+ const _DEBOUNCE = 100;
18
+ /* 内部同步方法名,仅供监听器调用 */
19
+ const _SYNC = Symbol('mm_scanner.sync');
20
+ /* 内部回调方法名,仅供监听器调用 */
21
+ const _NOTIFY = Symbol('mm_scanner.notify');
22
+
23
+ /* key 规范化:只统一分隔符、去首尾 /,不再剥扩展名 */
24
+ function normalizeKey(p) {
25
+ return String(p)
26
+ .replace(/\\/g, '/')
27
+ .replace(/^\.\//, '')
28
+ .replace(/^\/+|\/+$/g, '');
29
+ }
30
+
31
+ /* 扩展名规范化:补点、转小写 */
32
+ function normalizeExt(ext) {
33
+ const e = String(ext).trim().toLowerCase();
34
+ return e.startsWith('.') ? e : '.' + e;
35
+ }
36
+
37
+ /**
38
+ * 生成扫描缓存键
39
+ * @param {string} dir 目录绝对路径
40
+ * @param {Array<string>} extensions 扩展名数组
41
+ * @param {Array<string>} ignore 忽略目录数组
42
+ * @returns {string} 缓存键
43
+ * @private
44
+ */
45
+ function _cacheKey(dir, extensions, ignore) {
46
+ return [dir, extensions.join(','), ignore.join(',')].join('\u0001');
47
+ }
48
+
49
+ /**
50
+ * 构造单个文件的条目元信息
51
+ * @param {string} base_dir 扫描根目录绝对路径
52
+ * @param {string} abs_path 文件绝对路径
53
+ * @returns {{ key: string, absPath: string, relPath: string, dir: string, name: string, ext: string }} 条目
54
+ * @private
55
+ */
56
+ function _createItem(base_dir, abs_path) {
57
+ const rel_path = path.relative(base_dir, abs_path).replace(/\\/g, '/');
58
+ const ext = path.extname(rel_path).toLowerCase();
59
+ const dir_part = path.posix.dirname(rel_path);
60
+ return {
61
+ key: normalizeKey(rel_path),
62
+ absPath: abs_path,
63
+ relPath: rel_path,
64
+ dir: dir_part === '.' ? '' : dir_part,
65
+ name: path.basename(rel_path, ext),
66
+ ext,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * 判断相对路径是否位于忽略目录内
72
+ * @param {string} rel_path 相对路径
73
+ * @param {Array<string>} ignore 忽略目录数组
74
+ * @returns {boolean} 命中忽略目录返回 true
75
+ * @private
76
+ */
77
+ function _isIgnored(rel_path, ignore) {
78
+ const parts = rel_path.split('/');
79
+ for (let i = 0; i < parts.length - 1; i++) {
80
+ if (ignore.includes(parts[i])) return true;
81
+ }
82
+ return false;
83
+ }
84
+
85
+ /**
86
+ * 递归遍历目录并写入条目
87
+ * @param {string} dir 当前目录绝对路径
88
+ * @param {string} base_dir 扫描根目录绝对路径
89
+ * @param {{ extensions: string[], ignore: string[] }} config 扫描配置
90
+ * @param {Map<string, object>} files 条目容器
91
+ * @param {Array<string>} dirs 遍历过的目录,用于记录与缓存校验
92
+ * @returns {void}
93
+ * @private
94
+ */
95
+ function _walkDir(dir, base_dir, config, files, dirs) {
96
+ dirs.push(dir);
97
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
98
+
99
+ for (const entry of entries) {
100
+ const full_path = path.join(dir, entry.name);
101
+
102
+ if (entry.isDirectory()) {
103
+ if (config.ignore.includes(entry.name)) continue;
104
+ _walkDir(full_path, base_dir, config, files, dirs);
105
+ continue;
106
+ }
107
+
108
+ if (!entry.isFile()) continue;
109
+
110
+ const item = _createItem(base_dir, full_path);
111
+ if (!config.extensions.includes(item.ext)) continue;
112
+ files.set(item.key, item);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * 获取路径的修改时间
118
+ * @param {string} target 文件或目录路径
119
+ * @returns {number} 毫秒时间戳,读取失败返回 -1
120
+ * @private
121
+ */
122
+ function _getMtime(target) {
123
+ try {
124
+ return fs.statSync(target).mtimeMs;
125
+ } catch {
126
+ return -1;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * 校验缓存是否有效
132
+ * @description 目录时间戳变化表示增删条目,文件时间戳变化表示内容修改
133
+ * @param {object} cache 缓存项
134
+ * @returns {boolean} 全部未变化返回 true
135
+ * @private
136
+ */
137
+ function _isCacheValid(cache) {
138
+ for (let i = 0; i < cache.dirs.length; i++) {
139
+ if (_getMtime(cache.dirs[i]) !== cache.dir_mtimes[i]) {
140
+ return false;
141
+ }
142
+ }
143
+ for (let i = 0; i < cache.items.length; i++) {
144
+ if (_getMtime(cache.items[i].absPath) !== cache.file_mtimes[i]) {
145
+ return false;
146
+ }
147
+ }
148
+ return true;
149
+ }
150
+
151
+ /**
152
+ * 读取仍然有效的缓存
153
+ * @param {string} key 缓存键
154
+ * @returns {object|null} 缓存项,不存在或已失效返回 null
155
+ * @private
156
+ */
157
+ function _getCache(key) {
158
+ let cache = _scan_cache.get(key);
159
+ if (!cache) {
160
+ return null;
161
+ }
162
+ if (cache.watched) {
163
+ return cache;
164
+ }
165
+ let now = Date.now();
166
+ if (now - cache.time <= _CACHE_TTL) {
167
+ return cache;
168
+ }
169
+ if (_isCacheValid(cache)) {
170
+ cache.time = now;
171
+ return cache;
172
+ }
173
+ _scan_cache.delete(key);
174
+ return null;
175
+ }
176
+
177
+ /**
178
+ * 读取缓存项,不做有效性校验
179
+ * @param {string} key 缓存键
180
+ * @returns {object|null} 缓存项,不存在返回 null
181
+ * @private
182
+ */
183
+ function _readCache(key) {
184
+ return _scan_cache.get(key) || null;
185
+ }
186
+
187
+ /**
188
+ * 记录缓存对应的目录与文件时间戳
189
+ * @param {object} cache 缓存项
190
+ * @returns {void}
191
+ * @private
192
+ */
193
+ function _captureMtimes(cache) {
194
+ cache.dir_mtimes = [];
195
+ for (let i = 0; i < cache.dirs.length; i++) {
196
+ cache.dir_mtimes.push(_getMtime(cache.dirs[i]));
197
+ }
198
+ cache.file_mtimes = [];
199
+ for (let i = 0; i < cache.items.length; i++) {
200
+ cache.file_mtimes.push(_getMtime(cache.items[i].absPath));
201
+ }
202
+ cache.time = Date.now();
203
+ }
204
+
205
+ /**
206
+ * 创建缓存项
207
+ * @param {{ dir: string, extensions: Array<string>, ignore: Array<string>, items: Array<object>, dirs: Array<string> }} options 缓存内容
208
+ * @param {boolean} watched 是否已开启监听
209
+ * @returns {object} 缓存项
210
+ * @private
211
+ */
212
+ function _createCache(options, watched) {
213
+ const items = [];
214
+ for (let i = 0; i < options.items.length; i++) {
215
+ items.push({ ...options.items[i] });
216
+ }
217
+ const cache = {
218
+ dir: options.dir,
219
+ extensions: [...options.extensions],
220
+ ignore: [...options.ignore],
221
+ items,
222
+ dirs: [...options.dirs],
223
+ watched: !!watched,
224
+ dir_mtimes: [],
225
+ file_mtimes: [],
226
+ time: Date.now(),
227
+ };
228
+ if (!cache.watched) {
229
+ _captureMtimes(cache);
230
+ }
231
+ return cache;
232
+ }
233
+
234
+ /**
235
+ * 写入扫描缓存
236
+ * @description 已有监听器时保留监听标记,避免监听期间退化回时间戳校验
237
+ * @param {string} key 缓存键
238
+ * @param {{ dir: string, extensions: Array<string>, ignore: Array<string>, items: Array<object>, dirs: Array<string> }} options 缓存内容
239
+ * @returns {void}
240
+ * @private
241
+ */
242
+ function _setCache(key, options) {
243
+ const old = _scan_cache.get(key);
244
+ const watched = !!(old && old.watched);
245
+ if (_scan_cache.size >= _CACHE_MAX && !_scan_cache.has(key)) {
246
+ _clearAll();
247
+ }
248
+ _scan_cache.set(key, _createCache(options, watched));
249
+ }
250
+
251
+ /**
252
+ * 向缓存中新增或更新一个文件条目
253
+ * @param {object} cache 缓存项
254
+ * @param {{ key: string, absPath: string, relPath: string, dir: string, name: string, ext: string }} item 文件条目
255
+ * @returns {boolean} 新增返回 true,覆盖已有条目返回 false
256
+ * @private
257
+ */
258
+ function _addItem(cache, item) {
259
+ const index = cache.items.findIndex((one) => one.key === item.key);
260
+ if (index >= 0) {
261
+ cache.items[index] = item;
262
+ return false;
263
+ }
264
+ cache.items.push(item);
265
+ return true;
266
+ }
267
+
268
+ /**
269
+ * 从缓存中移除指定条目并记录变更
270
+ * @param {object} cache 缓存项
271
+ * @param {string} key 条目键
272
+ * @param {Array<object>} changes 变更收集器
273
+ * @returns {boolean} 是否移除成功
274
+ * @private
275
+ */
276
+ function _delItem(cache, key, changes) {
277
+ const index = cache.items.findIndex((one) => one.key === key);
278
+ if (index < 0) return false;
279
+ const [item] = cache.items.splice(index, 1);
280
+ changes.push({ type: 'unlink', key, item: { ...item } });
281
+ return true;
282
+ }
283
+
284
+ /**
285
+ * 移除同目录同文件名但扩展名不同的条目
286
+ * @description 文件改名后扩展名不再受支持时,需要清掉旧条目
287
+ * @param {object} cache 缓存项
288
+ * @param {{ key: string, dir: string, name: string }} item 当前文件条目
289
+ * @param {Array<object>} changes 变更收集器
290
+ * @returns {void}
291
+ * @private
292
+ */
293
+ function _delSameName(cache, item, changes) {
294
+ for (let i = cache.items.length - 1; i >= 0; i--) {
295
+ const one = cache.items[i];
296
+ if (one.key === item.key || one.dir !== item.dir || one.name !== item.name) continue;
297
+ _delItem(cache, one.key, changes);
298
+ }
299
+ }
300
+
301
+ /**
302
+ * 按文件系统事件更新单个路径
303
+ * @param {object} cache 缓存项
304
+ * @param {string} rel_path 相对路径
305
+ * @param {Array<object>} changes 变更收集器
306
+ * @returns {boolean} 无法精确定位时返回 false,需要全量刷新
307
+ * @private
308
+ */
309
+ function _applyPath(cache, rel_path, changes) {
310
+ if (!rel_path) return false;
311
+ if (_isIgnored(rel_path, cache.ignore)) return true;
312
+
313
+ const abs_path = path.join(cache.dir, rel_path);
314
+ let stat = null;
315
+ try {
316
+ stat = fs.statSync(abs_path);
317
+ } catch {
318
+ stat = null;
319
+ }
320
+
321
+ if (stat && stat.isDirectory()) return false;
322
+
323
+ const item = _createItem(cache.dir, abs_path);
324
+
325
+ if (!stat) {
326
+ return _delItem(cache, item.key, changes);
327
+ }
328
+
329
+ if (!stat.isFile()) return true;
330
+
331
+ if (!cache.extensions.includes(item.ext)) {
332
+ _delSameName(cache, item, changes);
333
+ return true;
334
+ }
335
+
336
+ const added = _addItem(cache, item);
337
+ changes.push({ type: added ? 'add' : 'change', key: item.key, item: { ...item } });
338
+ return true;
339
+ }
340
+
341
+ /**
342
+ * 对比两份条目集合,生成增量变更
343
+ * @param {Array<object>} before 变更前的条目
344
+ * @param {Array<object>} after 变更后的条目
345
+ * @returns {Array<{ type: string, key: string, item: object }>} 变更列表
346
+ * @private
347
+ */
348
+ function _diffItems(before, after) {
349
+ const before_map = new Map();
350
+ for (let i = 0; i < before.length; i++) before_map.set(before[i].key, before[i]);
351
+
352
+ const after_map = new Map();
353
+ for (let i = 0; i < after.length; i++) after_map.set(after[i].key, after[i]);
354
+
355
+ const changes = [];
356
+ for (const [key, item] of after_map) {
357
+ const old = before_map.get(key);
358
+ if (!old) {
359
+ changes.push({ type: 'add', key, item: { ...item } });
360
+ continue;
361
+ }
362
+ if (old.absPath !== item.absPath || old.relPath !== item.relPath) {
363
+ changes.push({ type: 'change', key, item: { ...item } });
364
+ }
365
+ }
366
+ for (const [key, item] of before_map) {
367
+ if (!after_map.has(key)) changes.push({ type: 'unlink', key, item: { ...item } });
368
+ }
369
+ return changes;
370
+ }
371
+
372
+ /**
373
+ * 全量重扫缓存对应的目录
374
+ * @param {object} cache 缓存项
375
+ * @param {Array<object>} changes 变更收集器
376
+ * @returns {void}
377
+ * @private
378
+ */
379
+ function _refreshCache(cache, changes) {
380
+ const before = cache.items;
381
+ const files = new Map();
382
+ const dirs = [];
383
+ _walkDir(cache.dir, cache.dir, cache, files, dirs);
384
+ const after = [...files.values()];
385
+ cache.items = after;
386
+ cache.dirs = dirs;
387
+ cache.time = Date.now();
388
+
389
+ const diff = _diffItems(before, after);
390
+ for (let i = 0; i < diff.length; i++) changes.push(diff[i]);
391
+ }
392
+
393
+ /**
394
+ * 把缓存的最新条目与变更同步到监听中的实例
395
+ * @param {string} key 缓存键
396
+ * @param {Array<object>} changes 变更列表
397
+ * @returns {void}
398
+ * @private
399
+ */
400
+ function _syncInstances(key, changes) {
401
+ const cache = _readCache(key);
402
+ const set = _instances.get(key);
403
+ if (!cache || !set) return;
404
+ for (const instance of set) {
405
+ instance[_SYNC](cache.items, cache.dirs);
406
+ instance[_NOTIFY](changes);
407
+ }
408
+ }
409
+
410
+ /**
411
+ * 处理防抖窗口内累积的文件事件
412
+ * @param {string} key 缓存键
413
+ * @returns {void}
414
+ * @private
415
+ */
416
+ function _flushChanges(key) {
417
+ const entry = _watchers.get(key);
418
+ if (!entry || !entry.pending.size) return;
419
+
420
+ const list = [...entry.pending];
421
+ entry.pending.clear();
422
+
423
+ const cache = _readCache(key);
424
+ const changes = [];
425
+ let need_refresh = false;
426
+ for (let i = 0; i < list.length; i++) {
427
+ if (!_applyPath(cache, list[i], changes)) {
428
+ need_refresh = true;
429
+ }
430
+ }
431
+ if (need_refresh) {
432
+ _refreshCache(cache, changes);
433
+ }
434
+ _syncInstances(key, changes);
435
+ }
436
+
437
+ /**
438
+ * 记录文件系统事件,按防抖窗口合并后处理
439
+ * @param {string} key 缓存键
440
+ * @param {string|null} filename 事件对应的相对路径,为空表示无法定位
441
+ * @returns {void}
442
+ * @private
443
+ */
444
+ function _onWatchEvent(key, filename) {
445
+ const entry = _watchers.get(key);
446
+ if (!entry) return;
447
+
448
+ entry.pending.add(filename ? normalizeKey(filename) : '');
449
+ if (entry.timer) return;
450
+
451
+ entry.timer = setTimeout(() => {
452
+ entry.timer = null;
453
+ _flushChanges(key);
454
+ }, _DEBOUNCE);
455
+
456
+ if (typeof entry.timer.unref === 'function') {
457
+ entry.timer.unref();
458
+ }
459
+ }
460
+
461
+ /**
462
+ * 为缓存绑定目录监听器
463
+ * @param {string} key 缓存键
464
+ * @returns {boolean} 监听是否可用
465
+ * @private
466
+ */
467
+ function _bindWatcher(key) {
468
+ const cache = _readCache(key);
469
+ if (!cache) return false;
470
+
471
+ if (_watchers.has(key)) {
472
+ cache.watched = true;
473
+ cache.dir_mtimes = [];
474
+ cache.file_mtimes = [];
475
+ return true;
476
+ }
477
+
478
+ let watcher = null;
479
+ try {
480
+ watcher = fs.watch(cache.dir, { recursive: true }, (event_type, filename) => {
481
+ _onWatchEvent(key, filename);
482
+ });
483
+ } catch {
484
+ return false;
485
+ }
486
+
487
+ cache.watched = true;
488
+ cache.dir_mtimes = [];
489
+ cache.file_mtimes = [];
490
+
491
+ if (typeof watcher.unref === 'function') watcher.unref();
492
+ watcher.on('error', () => _unbindWatcher(key));
493
+ _watchers.set(key, { watcher, timer: null, pending: new Set() });
494
+ return true;
495
+ }
496
+
497
+ /**
498
+ * 解除目录监听并恢复时间戳校验
499
+ * @param {string} key 缓存键
500
+ * @returns {void}
501
+ * @private
502
+ */
503
+ function _unbindWatcher(key) {
504
+ const entry = _watchers.get(key);
505
+ if (entry) {
506
+ if (entry.timer) clearTimeout(entry.timer);
507
+ try {
508
+ entry.watcher.close();
509
+ } catch {
510
+ // 监听器已关闭时忽略
511
+ }
512
+ _watchers.delete(key);
513
+ }
514
+
515
+ _instances.delete(key);
516
+
517
+ const cache = _readCache(key);
518
+ if (cache) {
519
+ cache.watched = false;
520
+ _captureMtimes(cache);
521
+ }
522
+ }
523
+
524
+ /**
525
+ * 判断实例是否处于监听状态
526
+ * @param {string} key 缓存键
527
+ * @param {object} instance 扫描器实例
528
+ * @returns {boolean} 已监听返回 true
529
+ * @private
530
+ */
531
+ function _hasInstance(key, instance) {
532
+ const set = _instances.get(key);
533
+ return !!(set && set.has(instance));
534
+ }
535
+
536
+ /**
537
+ * 登记监听中的实例
538
+ * @param {string} key 缓存键
539
+ * @param {object} instance 扫描器实例
540
+ * @returns {void}
541
+ * @private
542
+ */
543
+ function _addInstance(key, instance) {
544
+ let set = _instances.get(key);
545
+ if (!set) {
546
+ set = new Set();
547
+ _instances.set(key, set);
548
+ }
549
+ set.add(instance);
550
+ }
551
+
552
+ /**
553
+ * 注销监听中的实例,最后一个实例注销时关闭监听器
554
+ * @param {string} key 缓存键
555
+ * @param {object} instance 扫描器实例
556
+ * @returns {void}
557
+ * @private
558
+ */
559
+ function _delInstance(key, instance) {
560
+ const set = _instances.get(key);
561
+ if (!set) return;
562
+ set.delete(instance);
563
+ if (set.size === 0) {
564
+ _unbindWatcher(key);
565
+ }
566
+ }
567
+
568
+ /**
569
+ * 清空全部缓存、监听器与实例登记
570
+ * @returns {void}
571
+ * @private
572
+ */
573
+ function _clearAll() {
574
+ const keys = [..._watchers.keys()];
575
+ for (let i = 0; i < keys.length; i++) {
576
+ _unbindWatcher(keys[i]);
577
+ }
578
+ _instances.clear();
579
+ _scan_cache.clear();
580
+ }
581
+
582
+ /**
583
+ * 合并默认配置与用户配置
584
+ * @param {{ extensions: string[], ignore: string[] }} default_config - 默认配置
585
+ * @param {{ extensions?: string[], ignore?: string[] }} config - 用户配置
586
+ * @returns {{ extensions: string[], ignore: string[] }} 规范化后的配置
587
+ */
588
+ function createConfig(default_config, config) {
589
+ const extensions =
590
+ Array.isArray(config.extensions) && config.extensions.length
591
+ ? config.extensions
592
+ : default_config.extensions;
593
+ const ignore = Array.isArray(config.ignore) ? config.ignore : default_config.ignore;
594
+
595
+ return {
596
+ extensions: extensions.map(normalizeExt),
597
+ ignore: [...ignore],
598
+ };
599
+ }
600
+
601
+ class Scanner {
602
+ /**
603
+ * 扫描器默认配置
604
+ */
605
+ static config = {
606
+ /** 支持的扩展名 */
607
+ extensions: ['.json'],
608
+ /** 忽略的目录 */
609
+ ignore: ['node_modules', '.git'],
610
+ };
611
+
612
+ /** key(含扩展名) -> { key, absPath, relPath, dir, name, ext } */
613
+ #files = new Map();
614
+ #baseDir = null;
615
+ #scanned = false;
616
+ /** 本次遍历过的目录,用于缓存有效性校验 */
617
+ #dirs = [];
618
+ /** 本次扫描对应的缓存键 */
619
+ #cacheKey = null;
620
+ /** 外部变更回调 */
621
+ #callback = null;
622
+
623
+ /**
624
+ * 创建扫描器实例
625
+ * @param {{ extensions?: string[], ignore?: string[] }} [config] - 扫描器配置
626
+ */
627
+ constructor(config = {}) {
628
+ this.config = createConfig(Scanner.config, config);
629
+ }
630
+
631
+ /* -------- 私有 -------- */
632
+
633
+ /**
634
+ * 用缓存条目填充扫描结果
635
+ * @param {Array<object>} items 缓存中的条目
636
+ * @param {Array<string>} dirs 缓存中的目录列表
637
+ * @returns {void}
638
+ */
639
+ #fill(items, dirs) {
640
+ this.#files.clear();
641
+ for (const item of items) {
642
+ this.#files.set(item.key, { ...item });
643
+ }
644
+ this.#dirs = [...dirs];
645
+ this.#scanned = true;
646
+ }
647
+
648
+ /**
649
+ * 遍历根目录并填充扫描结果
650
+ * @returns {void}
651
+ */
652
+ #walkAndFill() {
653
+ this.#files.clear();
654
+ this.#dirs = [];
655
+ _walkDir(this.#baseDir, this.#baseDir, this.config, this.#files, this.#dirs);
656
+ this.#scanned = true;
657
+ }
658
+
659
+ /**
660
+ * 构造写入缓存的内容
661
+ * @returns {{ dir: string, extensions: Array<string>, ignore: Array<string>, items: Array<object>, dirs: Array<string> }} 缓存内容
662
+ */
663
+ #snapshot() {
664
+ return {
665
+ dir: this.#baseDir,
666
+ extensions: this.config.extensions,
667
+ ignore: this.config.ignore,
668
+ items: [...this.#files.values()],
669
+ dirs: this.#dirs,
670
+ };
671
+ }
672
+
673
+ #ensureReady() {
674
+ if (!this.#scanned) {
675
+ throw new Error('[scanner] 尚未初始化,请先在入口调用 scanner.scan(dir)');
676
+ }
677
+ }
678
+
679
+ /** 宽松解析 key:精确命中 → 按扩展名顺序尝试补全 */
680
+ #resolveKey(input) {
681
+ const norm = normalizeKey(input);
682
+ if (this.#files.has(norm)) return norm;
683
+
684
+ for (const ext of this.config.extensions) {
685
+ const tryKey = norm + ext;
686
+ if (this.#files.has(tryKey)) return tryKey;
687
+ }
688
+ return null;
689
+ }
690
+
691
+ /**
692
+ * 判断文件的相对目录是否命中目标目录
693
+ * @param {string} dir_path - 文件所在相对目录
694
+ * @param {string} dir_key - 规范化后的目标目录
695
+ * @returns {boolean} 命中返回 true,目标目录为空时视为全部命中
696
+ */
697
+ #matchDir(dir_path, dir_key) {
698
+ if (!dir_key) return true;
699
+ if (dir_key.includes('/')) {
700
+ return dir_path === dir_key || dir_path.endsWith('/' + dir_key);
701
+ }
702
+ return dir_path.split('/').includes(dir_key);
703
+ }
704
+
705
+ /* -------- 公开 API -------- */
706
+
707
+ /**
708
+ * 扫描目录(重复调用只生效一次)
709
+ * @description 优先复用其他实例的扫描缓存,命中时不再遍历目录
710
+ * @param {string} dir
711
+ * @param {{ extensions?: string[], ignore?: string[] }} [options]
712
+ */
713
+ scan = (dir, options = {}) => {
714
+ const resolved = path.resolve(dir);
715
+
716
+ if (this.#scanned) {
717
+ if (this.#baseDir !== resolved) {
718
+ console.warn(`[scanner] 已使用 ${this.#baseDir} 扫描过,忽略本次 scan(${resolved})`);
719
+ }
720
+ return this;
721
+ }
722
+
723
+ if (!fs.existsSync(resolved)) throw new Error(`[scanner] 目录不存在: ${resolved}`);
724
+ if (!fs.statSync(resolved).isDirectory()) throw new Error(`[scanner] 不是目录: ${resolved}`);
725
+
726
+ if (Array.isArray(options.extensions) || Array.isArray(options.ignore)) {
727
+ this.config = createConfig(this.config, options);
728
+ }
729
+
730
+ this.#baseDir = resolved;
731
+ this.#cacheKey = _cacheKey(resolved, this.config.extensions, this.config.ignore);
732
+
733
+ const cache = _getCache(this.#cacheKey);
734
+ if (cache) {
735
+ this.#fill(cache.items, cache.dirs);
736
+ return this;
737
+ }
738
+
739
+ this.#walkAndFill();
740
+ _setCache(this.#cacheKey, this.#snapshot());
741
+ return this;
742
+ };
743
+
744
+ /** 拿条目元信息(含 absPath),key 可带可不带扩展名 */
745
+ get = (key) => {
746
+ this.#ensureReady();
747
+ const k = this.#resolveKey(key);
748
+ return k ? this.#files.get(k) : undefined;
749
+ };
750
+
751
+ /** 拿绝对路径(给 Loader 用的最常见接口) */
752
+ getPath = (key) => {
753
+ const info = this.get(key);
754
+ return info ? info.absPath : undefined;
755
+ };
756
+
757
+ has = (key) => {
758
+ this.#ensureReady();
759
+ return this.#resolveKey(key) !== null;
760
+ };
761
+
762
+ keys = () => {
763
+ this.#ensureReady();
764
+ return [...this.#files.keys()];
765
+ };
766
+
767
+ getAll = () => {
768
+ this.#ensureReady();
769
+ return [...this.#files.values()];
770
+ };
771
+
772
+ /** 按"扩展名类型"筛:getByExt('.json') / getByExt('json') 都行 */
773
+ getByExt = (ext) => {
774
+ this.#ensureReady();
775
+ const e = normalizeExt(ext);
776
+ return this.getAll().filter((f) => f.ext === e);
777
+ };
778
+
779
+ /** 所有出现过的扩展名类型 */
780
+ extTypes = () => {
781
+ this.#ensureReady();
782
+ const set = new Set();
783
+ for (const f of this.#files.values()) set.add(f.ext);
784
+ return [...set];
785
+ };
786
+
787
+ /** 按目录筛:getByDir('config') -> data/config/**.{json,yaml,...} */
788
+ getByDir = (dir) => {
789
+ this.#ensureReady();
790
+ const norm = normalizeKey(dir);
791
+ const prefix = norm ? norm + '/' : '';
792
+ return this.getAll().filter((f) => f.key.startsWith(prefix));
793
+ };
794
+
795
+ /**
796
+ * 判断文件是否命中指定类型
797
+ * 只写扩展名('.json' / 'json')按扩展名匹配,写文件名('event.json')按文件名结尾匹配
798
+ * @param {{ key: string, ext: string }} file - 文件条目
799
+ * @param {string} ext - 扩展名或文件名
800
+ * @returns {boolean} 命中返回 true
801
+ */
802
+ #matchExt(file, ext) {
803
+ const norm = String(ext).trim().toLowerCase();
804
+ const bare = norm.replace(/^\./, '');
805
+ if (!bare.includes('.')) return file.ext === '.' + bare;
806
+ return file.key === bare || file.key.endsWith('/' + bare);
807
+ }
808
+
809
+ /**
810
+ * 按目录名与文件类型筛选文件
811
+ * 目录参数可为目录名(匹配任意层级)或相对路径(匹配路径结尾)
812
+ * @param {string} dir - 目录名或相对目录,如 'event_api'、'ai/event_api'
813
+ * @param {string} ext - 扩展名或文件名,如 '.json'、'event.json'
814
+ * @returns {Array<{ key: string, absPath: string, relPath: string, dir: string, name: string, ext: string }>} 命中的文件条目
815
+ */
816
+ getFileByDir = (dir, ext) => {
817
+ this.#ensureReady();
818
+ const dir_key = normalizeKey(dir);
819
+ return this.getAll().filter((f) => this.#matchDir(f.dir, dir_key) && this.#matchExt(f, ext));
820
+ };
821
+
822
+ /** 所有一级子目录名(可当作业务分类) */
823
+ dirTypes = () => {
824
+ this.#ensureReady();
825
+ const set = new Set();
826
+ for (const f of this.#files.values()) {
827
+ const first = f.dir.split('/')[0];
828
+ if (first) set.add(first);
829
+ }
830
+ return [...set];
831
+ };
832
+
833
+ /** 当前支持的扩展名列表 */
834
+ get extensions() { return [...this.config.extensions]; }
835
+
836
+ /** 重新扫描(开发环境热更新用) */
837
+ rescan = () => {
838
+ if (!this.#baseDir) throw new Error('[scanner] 尚未初始化,无法 rescan');
839
+ const cache = _readCache(this.#cacheKey);
840
+ const before = cache ? cache.items : [];
841
+ this.#walkAndFill();
842
+ _setCache(this.#cacheKey, this.#snapshot());
843
+ _syncInstances(this.#cacheKey, _diffItems(before, this.getAll()));
844
+ return this;
845
+ };
846
+
847
+ /**
848
+ * 开启目录监听,文件增删改时增量维护缓存并同步到本实例
849
+ * @description 监听期间不再按时间戳校验缓存;不支持递归监听的环境会静默降级
850
+ * @param {(changes: Array<{ type: string, key: string, item: object }>) => void} [callback] 变更回调,type 为 add/change/unlink
851
+ * @returns {Scanner} 当前实例
852
+ */
853
+ watch = (callback) => {
854
+ this.#ensureReady();
855
+ if (typeof callback === 'function') this.#callback = callback;
856
+ if (_hasInstance(this.#cacheKey, this)) return this;
857
+ _addInstance(this.#cacheKey, this);
858
+ if (!_bindWatcher(this.#cacheKey)) {
859
+ _delInstance(this.#cacheKey, this);
860
+ this.#callback = null;
861
+ }
862
+ return this;
863
+ };
864
+
865
+ /**
866
+ * 关闭本实例的目录监听并注销变更回调,最后一个实例关闭后停止监听
867
+ * @returns {Scanner} 当前实例
868
+ */
869
+ unwatch = () => {
870
+ this.#callback = null;
871
+ if (!this.#cacheKey) return this;
872
+ _delInstance(this.#cacheKey, this);
873
+ return this;
874
+ };
875
+
876
+ /**
877
+ * 清空全部扫描缓存
878
+ * @returns {void}
879
+ */
880
+ static clearCache = () => {
881
+ _clearAll();
882
+ };
883
+
884
+ get size() { return this.#files.size; }
885
+ get baseDir() { return this.#baseDir; }
886
+ /** 当前实例是否处于监听状态 */
887
+ get watching() { return this.#cacheKey ? _hasInstance(this.#cacheKey, this) : false; }
888
+
889
+ /**
890
+ * 接收监听器同步的最新条目
891
+ * @param {Array<object>} items 缓存中的条目
892
+ * @param {Array<string>} dirs 缓存中的目录列表
893
+ * @returns {void}
894
+ */
895
+ [_SYNC](items, dirs) {
896
+ this.#fill(items, dirs);
897
+ }
898
+
899
+ /**
900
+ * 把变更列表交给外部回调
901
+ * @param {Array<{ type: string, key: string, item: object }>} changes 变更列表
902
+ * @returns {void}
903
+ */
904
+ [_NOTIFY](changes) {
905
+ if (!this.#callback || !changes.length) return;
906
+ try {
907
+ this.#callback(changes.map((one) => ({ ...one, item: { ...one.item } })));
908
+ } catch (error) {
909
+ console.error('[scanner] watch 回调执行失败:', error);
910
+ }
911
+ }
912
+ }
913
+
914
+ /* 导出单例 + 类 */
915
+ module.exports = Scanner;
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "mm_scanner",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "目录扫描器,按扩展名递归扫描目录并缓存文件元信息,支持按 key、扩展名、目录检索文件路径。",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://gitee.com/qiuwenwu91/mm_scanner.git"
8
+ },
9
+ "keywords": [
10
+ "mm_mod",
11
+ "mm_infra",
12
+ "mm_common",
13
+ "超级美眉",
14
+ "目录扫描",
15
+ "文件检索",
16
+ "scanner"
17
+ ],
18
+ "layer": "infra",
19
+ "group": "common",
20
+ "author": "邱文武",
21
+ "license": "ISC",
22
+ "main": "index.js",
23
+ "files": [
24
+ "index.js",
25
+ "lib",
26
+ "example"
27
+ ],
28
+ "directories": {
29
+ "example": "example",
30
+ "lib": "lib"
31
+ },
32
+ "scripts": {
33
+ "test": "jest --coverage",
34
+ "demo": "node example/demo.js",
35
+ "prepublishOnly": "npm test"
36
+ },
37
+ "dependencies": {
38
+ "mm_corejs": "^1.1.5"
39
+ },
40
+ "devDependencies": {
41
+ "jest": "^30.5.2"
42
+ }
43
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.