dsh-lost-and-found 0.1.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.
package/core/scan.mjs ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * 文件快速寻回 · 扫描器(遍历 + 剪枝 + 增量)
3
+ *
4
+ * 铁律:
5
+ * - 只读。stat/readdir 之外不碰用户文件;不写 ADS、不生成缩略图、不改时间戳。
6
+ * - 目录级剪枝:命中排除目录名或排除路径片段,整棵子树不下钻(不是逐文件过滤)。
7
+ * - 不跟随符号链接/junction(避免跨盘递归与死循环,也避免把 junction 后的数据重复计数)。
8
+ */
9
+ import { opendir, stat } from "node:fs/promises";
10
+ import { join, sep } from "node:path";
11
+ import { SKIP_FILE_RE } from "../config.mjs";
12
+ import { categoryOf, originOf, isNoise } from "./classify.mjs";
13
+
14
+ const MAX_DEPTH = 40;
15
+
16
+ /** 组装剪枝判定器;patterns 里 "re:" 前缀表示正则,其余为路径片段包含匹配 */
17
+ export function makeMatcher(excludes) {
18
+ const dirs = excludes?.dirs instanceof Set ? excludes.dirs : new Set(excludes?.dirs || []);
19
+ const raw = (excludes?.patterns || []).map((p) => String(p));
20
+ const plain = raw.filter((p) => !p.startsWith("re:")).map((p) => p.toLowerCase());
21
+ const regexes = raw.filter((p) => p.startsWith("re:"))
22
+ .map((p) => { try { return new RegExp(p.slice(3), "i"); } catch { return null; } })
23
+ .filter(Boolean);
24
+ return {
25
+ pruneDir(name, fullPathLower, isRoot) {
26
+ if (isRoot) return false;
27
+ if (dirs.has(String(name).toLowerCase())) return true;
28
+ for (const p of plain) if (fullPathLower.includes(p)) return true;
29
+ for (const re of regexes) if (re.test(fullPathLower)) return true;
30
+ return false;
31
+ },
32
+ skipFile(name) {
33
+ for (const re of SKIP_FILE_RE) if (re.test(name)) return true;
34
+ return false;
35
+ },
36
+ };
37
+ }
38
+
39
+ function isInside(childLower, parentLower) {
40
+ if (!parentLower) return false;
41
+ const a = childLower.replace(/[\\/]+$/, "");
42
+ const b = parentLower.replace(/[\\/]+$/, "");
43
+ if (a === b) return true;
44
+ return a.startsWith(b + sep) || a.startsWith(b + "/");
45
+ }
46
+
47
+ /**
48
+ * 遍历扫描目录,回调每个符合「出现时间 >= 该目录自己的 sinceMs」的文件。
49
+ *
50
+ * 两个关键点(都是修 BUG 的核心):
51
+ * 1. 每个目录带自己的 sinceMs(root.sinceMs),不再共用一个全局窗口——
52
+ * 否则后加入的目录只能看到全局锚点之后 6 小时的文件,历史文件永久漏收。
53
+ * 2. 逐目录回报 completed:只有真的走完的目录才允许推进锚点;
54
+ * 被中断/根目录读不到的目录保持原锚点,下次继续补。
55
+ *
56
+ * @returns {{total:number, candidates:number, skippedDirs:number, errors:number, roots:Array}}
57
+ */
58
+ export async function scanRoots({ roots, excludes, sinceMs, onCandidate, signal, logger }) {
59
+ const m = makeMatcher(excludes);
60
+ const stats = { total: 0, candidates: 0, skippedDirs: 0, errors: 0, roots: [] };
61
+ for (const root of roots || []) {
62
+ if (signal?.aborted) break;
63
+ const own = Number(root && root.sinceMs);
64
+ const since = Number.isFinite(own) ? own : (Number(sinceMs) || 0);
65
+ const rs = { total: 0, candidates: 0, skippedDirs: 0, errors: 0, readable: true };
66
+ await walk(root.path, root, 0, since, rs);
67
+ if (signal?.aborted || !rs.readable) rs.completed = false;
68
+ stats.total += rs.total;
69
+ stats.candidates += rs.candidates;
70
+ stats.skippedDirs += rs.skippedDirs;
71
+ stats.errors += rs.errors;
72
+ stats.roots.push({
73
+ path: root.path, sinceMs: since, total: rs.total, candidates: rs.candidates,
74
+ errors: rs.errors, completed: rs.completed !== false,
75
+ });
76
+ }
77
+ return stats;
78
+
79
+ async function walk(dir, root, depth, since, rs) {
80
+ if (signal?.aborted || depth > MAX_DEPTH) return;
81
+ let handle;
82
+ try {
83
+ handle = await opendir(dir);
84
+ } catch {
85
+ rs.errors++;
86
+ if (dir === root.path) rs.readable = false; // 根目录都打不开 → 不算扫完,别推进锚点
87
+ return;
88
+ }
89
+ try {
90
+ for await (const ent of handle) {
91
+ if (signal?.aborted) return;
92
+ const full = join(dir, ent.name);
93
+ const lower = full.toLowerCase();
94
+ if (ent.isSymbolicLink()) continue;
95
+ if (ent.isDirectory()) {
96
+ if (m.pruneDir(ent.name, lower, full === root.path)) {
97
+ rs.skippedDirs++;
98
+ continue;
99
+ }
100
+ await walk(full, root, depth + 1, since, rs);
101
+ } else if (ent.isFile()) {
102
+ if (m.skipFile(ent.name)) continue;
103
+ let st;
104
+ try {
105
+ st = await stat(full);
106
+ } catch {
107
+ rs.errors++;
108
+ continue;
109
+ }
110
+ rs.total++;
111
+ const birth = st.birthtimeMs || st.mtimeMs;
112
+ const appeared = Math.max(birth, st.mtimeMs);
113
+ if (appeared < since) continue;
114
+ rs.candidates++;
115
+ const dot = ent.name.lastIndexOf(".");
116
+ const ext = dot > 0 ? ent.name.slice(dot).toLowerCase() : "";
117
+ onCandidate({
118
+ path: full,
119
+ root: root.path,
120
+ name: ent.name,
121
+ ext,
122
+ size: st.size,
123
+ birth_ms: Math.round(birth),
124
+ mtime_ms: Math.round(st.mtimeMs),
125
+ atime_ms: Math.round(st.atimeMs || 0),
126
+ appeared_ms: Math.round(appeared),
127
+ category: categoryOf(ext),
128
+ origin: originOf(full),
129
+ noise: isNoise({ ext, name: ent.name }),
130
+ policy: root.policy || "full",
131
+ first_seen_ms: Date.now(),
132
+ });
133
+ }
134
+ }
135
+ } catch (e) {
136
+ rs.errors++;
137
+ logger?.(`遍历出错 ${dir}: ${e && e.message ? e.message : e}`);
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * 野文件巡查:在白名单之外,找出「最近有新文件出现」的目录,供用户决定是否纳入扫描。
144
+ * 只统计不索引;限制深度以控制开销。
145
+ */
146
+ export async function patrolDrives({ drives, excludes, sinceMs, coveredRoots, maxDepth = 4, minFiles = 3, signal }) {
147
+ const m = makeMatcher(excludes);
148
+ const covered = (coveredRoots || []).map((p) => String(p).toLowerCase());
149
+ const found = new Map();
150
+ const stats = { scannedDirs: 0, skippedDirs: 0, errors: 0 };
151
+
152
+ for (const drive of drives) {
153
+ if (signal?.aborted) break;
154
+ await walk(drive, 0);
155
+ }
156
+
157
+ async function walk(dir, depth) {
158
+ if (signal?.aborted || depth > maxDepth) return;
159
+ const dirLower = dir.toLowerCase();
160
+ if (covered.some((c) => isInside(dirLower, c))) return; // 已在白名单内,不再巡查
161
+ let handle;
162
+ try {
163
+ handle = await opendir(dir);
164
+ } catch {
165
+ stats.errors++;
166
+ return;
167
+ }
168
+ stats.scannedDirs++;
169
+ let localCount = 0;
170
+ let sample = null;
171
+ try {
172
+ for await (const ent of handle) {
173
+ if (signal?.aborted) return;
174
+ const full = join(dir, ent.name);
175
+ const lower = full.toLowerCase();
176
+ if (ent.isSymbolicLink()) continue;
177
+ if (ent.isDirectory()) {
178
+ if (m.pruneDir(ent.name, lower, false)) {
179
+ stats.skippedDirs++;
180
+ continue;
181
+ }
182
+ await walk(full, depth + 1);
183
+ } else if (ent.isFile()) {
184
+ if (m.skipFile(ent.name)) continue;
185
+ let st;
186
+ try {
187
+ st = await stat(full);
188
+ } catch {
189
+ stats.errors++;
190
+ continue;
191
+ }
192
+ const appeared = Math.max(st.birthtimeMs || st.mtimeMs, st.mtimeMs);
193
+ if (appeared >= sinceMs) {
194
+ localCount++;
195
+ if (!sample) sample = ent.name;
196
+ }
197
+ }
198
+ }
199
+ } catch {
200
+ stats.errors++;
201
+ }
202
+ if (localCount >= minFiles && depth > 0) {
203
+ found.set(dir, { dir, files: localCount, sample });
204
+ }
205
+ }
206
+
207
+ return { suggestions: [...found.values()].sort((a, b) => b.files - a.files), stats };
208
+ }
@@ -0,0 +1,71 @@
1
+ /** 文件快速寻回 · 排期与增量锚点 */
2
+ import { lastScanMs, getMeta, rootState } from "../db.mjs";
3
+
4
+ const DAY = 86400000;
5
+ /** 重叠窗口:防止时钟漂移/写入延迟造成边界漏收 */
6
+ const OVERLAP_MS = 6 * 3600 * 1000;
7
+
8
+ /** 本次扫描要看「从什么时候起出现」的文件(全局,仅作兼容兜底用) */
9
+ export function computeSinceMs(db, config, now = Date.now()) {
10
+ const last = lastScanMs(db);
11
+ if (last > 0) return Math.max(0, last - OVERLAP_MS);
12
+ const windowDays = Number(config.firstRunWindowDays) || 0;
13
+ return windowDays > 0 ? now - windowDays * DAY : now;
14
+ }
15
+
16
+ /**
17
+ * 单个扫描目录的收录起点(逐目录锚点,这是修掉「按扫描按钮却收不全」的核心)。
18
+ *
19
+ * 为什么不能只用全局锚点:全局锚点 = 上次扫描时刻 − 6h,跟"哪个目录"无关。
20
+ * 于是任何「后加入的目录」首扫只能看到锚点之后 6 小时的文件,它更早的历史全部漏收,
21
+ * 而且这个漏收是永久的(下次锚点又往后推了)。实测 D:\读书 570 个文件只入库 13 个。
22
+ *
23
+ * @param {object} p
24
+ * @param {object} p.db 已打开的库
25
+ * @param {{path:string}} p.root 目录
26
+ * @param {object} p.config 当前配置
27
+ * @param {number} [p.now]
28
+ * @param {boolean} [p.forceFull] 用户主动要求全量重扫
29
+ * @returns {number} sinceMs(0 = 不限时间,全量)
30
+ */
31
+ export function rootSinceMs({ db, root, config, now = Date.now(), forceFull = false }) {
32
+ if (forceFull) return 0;
33
+ const st = rootState(db, root.path);
34
+ // 从未完整扫过(含老库升级后 first_scan_done 仍为 0)→ 按首扫策略回填
35
+ if (!st.first_scan_done || !st.last_scan_ms) {
36
+ const mode = String((config && config.firstScanMode) || "full").toLowerCase();
37
+ if (mode === "none") return now; // 只收今后新增
38
+ if (mode === "window") {
39
+ const days = Number(config && config.firstRunWindowDays) || 0;
40
+ return days > 0 ? now - days * DAY : now;
41
+ }
42
+ return 0; // full:收录该目录全部历史
43
+ }
44
+ return Math.max(0, st.last_scan_ms - OVERLAP_MS);
45
+ }
46
+
47
+ /** 是否到了该自动扫描的时间 */
48
+ export function isDue(db, config, now = Date.now()) {
49
+ const days = Number(config.intervalDays);
50
+ if (!days || days <= 0) return false;
51
+ const last = lastScanMs(db);
52
+ if (!last) return true;
53
+ return now - last >= days * DAY;
54
+ }
55
+
56
+ export function nextDueMs(db, config) {
57
+ const days = Number(config.intervalDays);
58
+ if (!days || days <= 0) return null;
59
+ const last = lastScanMs(db);
60
+ return (last || Date.now()) + days * DAY;
61
+ }
62
+
63
+ export function lastVerifyMs(db) {
64
+ const v = getMeta(db, "last_verify_ms");
65
+ return v ? Number(v) : 0;
66
+ }
67
+
68
+ /** 每周一次校验扫描 */
69
+ export function verifyDue(db, now = Date.now()) {
70
+ return now - lastVerifyMs(db) >= 7 * DAY;
71
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * 文件快速寻回 · 检索层
3
+ *
4
+ * 检索策略(与用户确认过的架构一致):
5
+ * 1) SQL 硬过滤:时间范围 / 类型 / 目录 / 来源 / 状态
6
+ * 2) 多关键词 LIKE 取交(中文无需分词,子串匹配天然可用)
7
+ * 3) JS 侧命中位置加权打分:文件名 > 关键词标签 > 路径 > 摘要 > 图片描述 > 正文
8
+ * 4) 对最终结果逐个 stat,标注「还在 / 已不在此处」
9
+ * 5) 索引不够用时,对扫描目录做一次实时兜底扫描(不写库)
10
+ *
11
+ * 为什么不用 FTS5:是否可用取决于 Node 版本 —— 实测系统 node v22.14.0 报
12
+ * `no such module: fts5`,而 DSH 运行时的 QClaw 自带 node v22.22.3 是带 FTS5 的。
13
+ * 为在两种环境都能跑,这里不假设 FTS5 存在(将来检测到可用时可作为加速路径)。
14
+ */
15
+ import { stat } from "node:fs/promises";
16
+ import { scanRoots } from "./scan.mjs";
17
+ import { categoryOf, originOf } from "./classify.mjs";
18
+
19
+ const LIKE_COLUMNS = ["name", "path", "tags", "summary", "image_desc", "excerpt"];
20
+
21
+ const WEIGHT = {
22
+ name: 50,
23
+ tags: 30,
24
+ path: 20,
25
+ summary: 15,
26
+ image_desc: 10,
27
+ excerpt: 8,
28
+ };
29
+
30
+ function escapeLike(s) {
31
+ return String(s).replace(/[\\%_]/g, (c) => `\\${c}`);
32
+ }
33
+
34
+ function splitKeywords(input) {
35
+ if (Array.isArray(input)) return input.map((s) => String(s).trim()).filter(Boolean);
36
+ return String(input || "")
37
+ .split(/[\s,,、;;|]+/)
38
+ .map((s) => s.trim())
39
+ .filter(Boolean);
40
+ }
41
+
42
+ /** 兼容「字符串 / 逗号分隔字符串 / 数组」三种入参 */
43
+ function toArray(input) {
44
+ if (input === undefined || input === null || input === "") return [];
45
+ if (Array.isArray(input)) return input.map((s) => String(s).trim()).filter(Boolean);
46
+ return String(input).split(/[,,、;;\s]+/).map((s) => s.trim()).filter(Boolean);
47
+ }
48
+
49
+ /** 构造 SQL 与参数 */
50
+ function buildQuery(opts) {
51
+ const where = [];
52
+ const params = [];
53
+ const keywords = splitKeywords(opts.keywords);
54
+
55
+ if (!opts.includeMissing) where.push("state = 'ok'");
56
+ if (!opts.includeNoise) where.push("noise = 0");
57
+
58
+ if (opts.since) { where.push("appeared_ms >= ?"); params.push(Number(opts.since)); }
59
+ if (opts.until) { where.push("appeared_ms <= ?"); params.push(Number(opts.until)); }
60
+
61
+ const exts = toArray(opts.ext).map((e) => e.toLowerCase()).map((e) => (e.startsWith(".") ? e : `.${e}`));
62
+ if (exts.length) {
63
+ where.push(`ext IN (${exts.map(() => "?").join(",")})`);
64
+ params.push(...exts);
65
+ }
66
+
67
+ const cats = toArray(opts.categories);
68
+ if (cats.length) {
69
+ where.push(`category IN (${cats.map(() => "?").join(",")})`);
70
+ params.push(...cats);
71
+ }
72
+
73
+ const origins = toArray(opts.origins);
74
+ if (origins.length) {
75
+ where.push(`origin IN (${origins.map(() => "?").join(",")})`);
76
+ params.push(...origins);
77
+ }
78
+
79
+ if (opts.folder) {
80
+ const f = escapeLike(String(opts.folder).replace(/[\\/]+$/, ""));
81
+ where.push("(path LIKE ? ESCAPE '\\' OR root LIKE ? ESCAPE '\\')");
82
+ params.push(`${f}%`, `${f}%`);
83
+ }
84
+
85
+ for (const kw of keywords) {
86
+ const k = `%${escapeLike(kw)}%`;
87
+ where.push(`(${LIKE_COLUMNS.map((c) => `${c} LIKE ? ESCAPE '\\'`).join(" OR ")})`);
88
+ for (let i = 0; i < LIKE_COLUMNS.length; i++) params.push(k);
89
+ }
90
+
91
+ const sql = `SELECT * FROM files ${where.length ? `WHERE ${where.join(" AND ")}` : ""}
92
+ ORDER BY appeared_ms DESC LIMIT ?`;
93
+ return { sql, params, keywords };
94
+ }
95
+
96
+ function scoreRow(row, keywords) {
97
+ const lower = {
98
+ name: String(row.name || "").toLowerCase(),
99
+ path: String(row.path || "").toLowerCase(),
100
+ tags: String(row.tags || "").toLowerCase(),
101
+ summary: String(row.summary || "").toLowerCase(),
102
+ image_desc: String(row.image_desc || "").toLowerCase(),
103
+ excerpt: String(row.excerpt || "").toLowerCase(),
104
+ };
105
+ let score = 0;
106
+ const hitIn = new Set();
107
+ for (const kw of keywords) {
108
+ const k = kw.toLowerCase();
109
+ for (const col of LIKE_COLUMNS) {
110
+ if (lower[col] && lower[col].includes(k)) {
111
+ score += WEIGHT[col];
112
+ hitIn.add(col);
113
+ }
114
+ }
115
+ if (lower.name === k) score += 80;
116
+ }
117
+ const age = Date.now() - (row.appeared_ms || 0);
118
+ if (age < 7 * 86400000) score += 6;
119
+ else if (age < 30 * 86400000) score += 3;
120
+ return { score, hitIn: [...hitIn] };
121
+ }
122
+
123
+ function snippetOf(row, keywords) {
124
+ for (const col of ["excerpt", "summary", "image_desc"]) {
125
+ const text = String(row[col] || "");
126
+ if (!text) continue;
127
+ const lower = text.toLowerCase();
128
+ for (const kw of keywords) {
129
+ const i = lower.indexOf(kw.toLowerCase());
130
+ if (i >= 0) {
131
+ const from = Math.max(0, i - 50);
132
+ const to = Math.min(text.length, i + kw.length + 50);
133
+ return `${from > 0 ? "…" : ""}${text.slice(from, to).replace(/\s+/g, " ")}${to < text.length ? "…" : ""}`;
134
+ }
135
+ }
136
+ if (col === "excerpt") return text.replace(/\s+/g, " ").slice(0, 100);
137
+ }
138
+ return null;
139
+ }
140
+
141
+ /**
142
+ * 索引检索。
143
+ * @returns {Promise<{rows:Array, candidates:number, query:object}>}
144
+ */
145
+ export async function searchFiles(db, opts = {}) {
146
+ const limit = Math.min(Math.max(Number(opts.limit) || 20, 1), 100);
147
+ const candidateLimit = Math.min(Math.max(limit * 25, 300), 3000);
148
+ const { sql, params, keywords } = buildQuery(opts);
149
+ const rows = db.prepare(sql).all(...params, candidateLimit);
150
+
151
+ const scored = rows.map((row) => {
152
+ const { score, hitIn } = scoreRow(row, keywords);
153
+ return { ...row, _score: keywords.length ? score : (row.appeared_ms || 0) / 1e10, _hitIn: hitIn };
154
+ });
155
+ scored.sort((a, b) => b._score - a._score);
156
+ const top = scored.slice(0, limit);
157
+
158
+ const out = [];
159
+ for (const row of top) {
160
+ let exists = null;
161
+ try {
162
+ const st = await stat(row.path);
163
+ exists = st.isFile();
164
+ } catch {
165
+ exists = false;
166
+ }
167
+ out.push({ ...row, exists, snippet: snippetOf(row, keywords) });
168
+ }
169
+ return { rows: out, candidates: rows.length, query: { ...opts, keywords } };
170
+ }
171
+
172
+ /**
173
+ * 实时兜底扫描:不依赖索引、不写库,直接在扫描目录里按名字/时间找。
174
+ * 用于「索引没命中」或「找的是还没被收录的旧文件」。
175
+ */
176
+ export async function liveSearch({ roots, excludes, keywords, since, until, ext, limit = 30, includeNoise = false, signal, logger }) {
177
+ const kws = splitKeywords(keywords).map((s) => s.toLowerCase());
178
+ const exts = toArray(ext).map((e) => (String(e).startsWith(".") ? String(e) : `.${e}`).toLowerCase());
179
+ const hits = [];
180
+ const stats = await scanRoots({
181
+ roots,
182
+ excludes,
183
+ sinceMs: since || 0,
184
+ signal,
185
+ logger,
186
+ onCandidate(rec) {
187
+ if (until && rec.appeared_ms > until) return;
188
+ if (exts.length && !exts.includes(rec.ext)) return;
189
+ if (!includeNoise && rec.noise) return;
190
+ if (kws.length) {
191
+ const hay = `${rec.name} ${rec.path}`.toLowerCase();
192
+ if (!kws.every((k) => hay.includes(k))) return;
193
+ }
194
+ hits.push({
195
+ path: rec.path, name: rec.name, ext: rec.ext, size: rec.size,
196
+ appeared_ms: rec.appeared_ms, birth_ms: rec.birth_ms, mtime_ms: rec.mtime_ms,
197
+ category: rec.category || categoryOf(rec.ext), origin: rec.origin || originOf(rec.path),
198
+ root: rec.root, policy: rec.policy, state: "ok", enrich: "none", noise: rec.noise || 0,
199
+ summary: null, excerpt: null, image_desc: null, tags: null,
200
+ _live: true, exists: true, snippet: null, _score: rec.appeared_ms / 1e10,
201
+ });
202
+ },
203
+ });
204
+ hits.sort((a, b) => b.appeared_ms - a.appeared_ms);
205
+ return { rows: hits.slice(0, limit), scanned: stats, total: hits.length };
206
+ }
207
+
208
+ /** 把查询条件翻译成一句人话,便于在回答里说明搜了什么 */
209
+ export function describeQuery(q) {
210
+ const bits = [];
211
+ if (q.keywords && q.keywords.length) bits.push(`关键词「${[].concat(q.keywords).join(" ")}」`);
212
+ if (q.since || q.until) bits.push("时间范围");
213
+ if (q.ext && q.ext.length) bits.push(`类型 ${[].concat(q.ext).join("/")}`);
214
+ if (q.folder) bits.push(`范围 ${q.folder}`);
215
+ if (q.categories && q.categories.length) bits.push(`类别 ${[].concat(q.categories).join("/")}`);
216
+ return bits.length ? bits.join(" · ") : "全部文件";
217
+ }