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/db.mjs ADDED
@@ -0,0 +1,336 @@
1
+ /**
2
+ * 文件快速寻回 · SQLite 存储层(node:sqlite,零外部原生依赖)
3
+ *
4
+ * 说明:
5
+ * - 存储用 Node 内置 node:sqlite(零外部原生依赖)。
6
+ * - FTS5 可用性随 Node 版本而变(实测 v22.14.0 无、QClaw 自带 v22.22.3 有),
7
+ * 因此检索层不依赖 FTS5,统一走 LIKE + 命中位置加权。
8
+ * - 索引是派生数据,可随时重建;绝不在库里保存「唯一真相」以外的用户资产。
9
+ * - 本模块只读用户文件(stat),只写自己的库文件。
10
+ */
11
+ import { DatabaseSync } from "node:sqlite";
12
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
13
+ import { dirname, join } from "node:path";
14
+
15
+ export const SCHEMA_VERSION = 3;
16
+
17
+ const DDL = `
18
+ CREATE TABLE IF NOT EXISTS files (
19
+ id INTEGER PRIMARY KEY,
20
+ path TEXT NOT NULL UNIQUE,
21
+ root TEXT,
22
+ name TEXT NOT NULL,
23
+ ext TEXT,
24
+ size INTEGER DEFAULT 0,
25
+ birth_ms INTEGER,
26
+ mtime_ms INTEGER,
27
+ atime_ms INTEGER,
28
+ appeared_ms INTEGER,
29
+ category TEXT,
30
+ origin TEXT,
31
+ policy TEXT DEFAULT 'full',
32
+ summary TEXT,
33
+ excerpt TEXT,
34
+ image_desc TEXT,
35
+ tags TEXT,
36
+ enrich TEXT DEFAULT 'none',
37
+ retry_count INTEGER DEFAULT 0,
38
+ state TEXT DEFAULT 'ok',
39
+ missing_streak INTEGER DEFAULT 0,
40
+ hash TEXT,
41
+ noise INTEGER DEFAULT 0,
42
+ first_seen_ms INTEGER,
43
+ last_seen_ms INTEGER
44
+ );
45
+ CREATE INDEX IF NOT EXISTS idx_files_appeared ON files(appeared_ms);
46
+ CREATE INDEX IF NOT EXISTS idx_files_ext ON files(ext);
47
+ CREATE INDEX IF NOT EXISTS idx_files_root ON files(root);
48
+ CREATE INDEX IF NOT EXISTS idx_files_name ON files(name);
49
+ CREATE INDEX IF NOT EXISTS idx_files_queue ON files(state, enrich);
50
+
51
+ CREATE TABLE IF NOT EXISTS scan_runs (
52
+ id INTEGER PRIMARY KEY,
53
+ started_ms INTEGER,
54
+ finished_ms INTEGER,
55
+ trigger TEXT,
56
+ roots_json TEXT,
57
+ added INTEGER DEFAULT 0,
58
+ updated INTEGER DEFAULT 0,
59
+ missing INTEGER DEFAULT 0,
60
+ skipped_dirs INTEGER DEFAULT 0,
61
+ errors INTEGER DEFAULT 0,
62
+ note TEXT
63
+ );
64
+ CREATE INDEX IF NOT EXISTS idx_runs_started ON scan_runs(started_ms);
65
+
66
+ -- 扫描目录(与设置页同步,并保存「每个目录各自的增量锚点」)
67
+ -- 为什么锚点必须逐目录存:全局锚点会让「后加入的目录」只收到锚点之后 6 小时内的文件,
68
+ -- 该目录里更早的历史文件永远不会入库(实测 D:\读书 570 个文件只进了 13 个)。
69
+ CREATE TABLE IF NOT EXISTS roots (
70
+ path TEXT PRIMARY KEY,
71
+ policy TEXT,
72
+ added_ms INTEGER,
73
+ last_scan_ms INTEGER DEFAULT 0, -- 该目录上次「完整扫完」的起始时刻;0 = 从未扫完
74
+ first_scan_done INTEGER DEFAULT 0 -- 是否完成过首扫(老库升级后为 0 → 触发一次全量回填)
75
+ );
76
+
77
+ -- 白名单外的新目录巡查结果(每天提示一次)
78
+ CREATE TABLE IF NOT EXISTS patrol (
79
+ dir TEXT PRIMARY KEY,
80
+ files INTEGER DEFAULT 0,
81
+ sample TEXT,
82
+ first_seen_ms INTEGER,
83
+ last_seen_ms INTEGER,
84
+ status TEXT DEFAULT 'new'
85
+ );
86
+
87
+ -- 改名/移动识别预留(P2 补逻辑,不动表结构)
88
+ CREATE TABLE IF NOT EXISTS path_history (
89
+ id INTEGER PRIMARY KEY,
90
+ file_id INTEGER,
91
+ old_path TEXT,
92
+ new_path TEXT,
93
+ changed_ms INTEGER
94
+ );
95
+
96
+ CREATE TABLE IF NOT EXISTS meta (
97
+ k TEXT PRIMARY KEY,
98
+ v TEXT
99
+ );
100
+ `;
101
+
102
+ export function openDb(dbPath) {
103
+ mkdirSync(dirname(dbPath), { recursive: true });
104
+ const db = new DatabaseSync(dbPath);
105
+ try {
106
+ db.exec("PRAGMA journal_mode = WAL");
107
+ db.exec("PRAGMA synchronous = NORMAL");
108
+ } catch {
109
+ /* 非致命:部分环境不允许切 WAL */
110
+ }
111
+ db.exec(DDL);
112
+ migrate(db);
113
+ const cur = getMeta(db, "schema_version");
114
+ if (cur !== String(SCHEMA_VERSION)) {
115
+ setMeta(db, "schema_version", String(SCHEMA_VERSION));
116
+ }
117
+ return db;
118
+ }
119
+
120
+ /** 轻量迁移:老库补列(索引是可重建的派生数据,迁移失败也不致命) */
121
+ function migrate(db) {
122
+ try {
123
+ const cols = new Set(db.prepare("PRAGMA table_info(files)").all().map((r) => r.name));
124
+ if (!cols.has("noise")) db.exec("ALTER TABLE files ADD COLUMN noise INTEGER DEFAULT 0");
125
+ if (!cols.has("first_seen_ms")) db.exec("ALTER TABLE files ADD COLUMN first_seen_ms INTEGER");
126
+ if (!cols.has("last_seen_ms")) db.exec("ALTER TABLE files ADD COLUMN last_seen_ms INTEGER");
127
+ } catch {
128
+ /* ignore */
129
+ }
130
+ // roots 逐目录锚点(v3):老库补列,补出来的默认值 0 会让下次扫描对该目录做一次全量回填
131
+ try {
132
+ const rcols = new Set(db.prepare("PRAGMA table_info(roots)").all().map((r) => r.name));
133
+ if (!rcols.has("last_scan_ms")) db.exec("ALTER TABLE roots ADD COLUMN last_scan_ms INTEGER DEFAULT 0");
134
+ if (!rcols.has("first_scan_done")) db.exec("ALTER TABLE roots ADD COLUMN first_scan_done INTEGER DEFAULT 0");
135
+ } catch {
136
+ /* ignore */
137
+ }
138
+ }
139
+
140
+ export function getMeta(db, k) {
141
+ try {
142
+ const row = db.prepare("SELECT v FROM meta WHERE k = ?").get(k);
143
+ return row ? row.v : null;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ export function setMeta(db, k, v) {
150
+ db.prepare("INSERT INTO meta (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v").run(k, v === null || v === undefined ? null : String(v));
151
+ }
152
+
153
+ export function lastScanMs(db) {
154
+ const v = getMeta(db, "last_scan_ms");
155
+ const n = v ? Number(v) : 0;
156
+ return Number.isFinite(n) ? n : 0;
157
+ }
158
+
159
+ /** 插入或更新一个文件记录;返回 'added' | 'updated' */
160
+ export function upsertFile(db, rec) {
161
+ const stmt = db.prepare(`
162
+ INSERT INTO files (path, root, name, ext, size, birth_ms, mtime_ms, atime_ms, appeared_ms,
163
+ category, origin, policy, state, missing_streak, noise, first_seen_ms, last_seen_ms)
164
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'ok', 0, ?, ?, ?)
165
+ ON CONFLICT(path) DO UPDATE SET
166
+ root = excluded.root,
167
+ size = excluded.size,
168
+ mtime_ms = excluded.mtime_ms,
169
+ atime_ms = excluded.atime_ms,
170
+ appeared_ms = excluded.appeared_ms,
171
+ category = excluded.category,
172
+ origin = excluded.origin,
173
+ policy = excluded.policy,
174
+ noise = excluded.noise,
175
+ state = 'ok',
176
+ missing_streak = 0,
177
+ last_seen_ms = excluded.last_seen_ms
178
+ `);
179
+ const existed = db.prepare("SELECT id FROM files WHERE path = ?").get(rec.path);
180
+ stmt.run(
181
+ rec.path, rec.root ?? null, rec.name, rec.ext ?? null, rec.size ?? 0,
182
+ rec.birth_ms ?? null, rec.mtime_ms ?? null, rec.atime_ms ?? null, rec.appeared_ms ?? null,
183
+ rec.category ?? null, rec.origin ?? null, rec.policy ?? "full", rec.noise ?? 0,
184
+ rec.first_seen_ms ?? Date.now(), Date.now(),
185
+ );
186
+ return existed ? "updated" : "added";
187
+ }
188
+
189
+ export function startScanRun(db, trigger, roots) {
190
+ const r = db.prepare("INSERT INTO scan_runs (started_ms, trigger, roots_json) VALUES (?, ?, ?)")
191
+ .run(Date.now(), trigger ?? "manual", JSON.stringify(roots ?? []));
192
+ return Number(r.lastInsertRowid);
193
+ }
194
+
195
+ export function finishScanRun(db, runId, stats) {
196
+ db.prepare(`UPDATE scan_runs SET finished_ms = ?, added = ?, updated = ?, missing = ?,
197
+ skipped_dirs = ?, errors = ?, note = ? WHERE id = ?`)
198
+ .run(Date.now(), stats.added ?? 0, stats.updated ?? 0, stats.missing ?? 0,
199
+ stats.skippedDirs ?? 0, stats.errors ?? 0, stats.note ?? null, runId);
200
+ }
201
+
202
+ /**
203
+ * 同步扫描目录表。
204
+ * 关键:绝不能整表重建——那会抹掉每个目录的增量锚点,等价于"每次都当新目录"或"永远不重扫"。
205
+ * 这里只做两件事:补/更新配置里的目录(保留其锚点),删掉配置里已移除的目录。
206
+ */
207
+ export function syncRoots(db, roots) {
208
+ const list = (roots || []).filter((r) => r && r.path);
209
+ const keep = new Set(list.map((r) => String(r.path).toLowerCase()));
210
+ let existing = [];
211
+ try {
212
+ existing = db.prepare("SELECT path FROM roots").all();
213
+ } catch {
214
+ existing = [];
215
+ }
216
+ const del = db.prepare("DELETE FROM roots WHERE path = ?");
217
+ for (const row of existing) {
218
+ if (!keep.has(String(row.path).toLowerCase())) del.run(row.path);
219
+ }
220
+ const upsert = db.prepare(`
221
+ INSERT INTO roots (path, policy, added_ms, last_scan_ms, first_scan_done)
222
+ VALUES (?, ?, ?, 0, 0)
223
+ ON CONFLICT(path) DO UPDATE SET policy = excluded.policy`);
224
+ for (const r of list) upsert.run(r.path, r.policy ?? "full", Date.now());
225
+ }
226
+
227
+ /** 读某个扫描目录的锚点状态;没有该目录时返回 {last_scan_ms:0, first_scan_done:0}(视为从未扫过) */
228
+ export function rootState(db, path) {
229
+ try {
230
+ const row = db.prepare("SELECT last_scan_ms, first_scan_done FROM roots WHERE path = ?").get(String(path));
231
+ return {
232
+ last_scan_ms: row && row.last_scan_ms ? Number(row.last_scan_ms) : 0,
233
+ first_scan_done: row && row.first_scan_done ? 1 : 0,
234
+ };
235
+ } catch {
236
+ return { last_scan_ms: 0, first_scan_done: 0 };
237
+ }
238
+ }
239
+
240
+ /** 只有「完整走完」的目录才允许推进锚点,中断/不可读的目录保持原锚点,下次继续补 */
241
+ export function markRootScanned(db, path, at = Date.now()) {
242
+ try {
243
+ db.prepare("UPDATE roots SET last_scan_ms = ?, first_scan_done = 1 WHERE path = ?")
244
+ .run(Number(at) || Date.now(), String(path));
245
+ return true;
246
+ } catch {
247
+ return false;
248
+ }
249
+ }
250
+
251
+ export function counts(db) {
252
+ const one = (sql, ...p) => {
253
+ const row = db.prepare(sql).get(...p);
254
+ return row ? Number(Object.values(row)[0]) : 0;
255
+ };
256
+ return {
257
+ total: one("SELECT COUNT(*) FROM files"),
258
+ missing: one("SELECT COUNT(*) FROM files WHERE state = 'missing'"),
259
+ noise: one("SELECT COUNT(*) FROM files WHERE noise = 1"),
260
+ enrichPending: one("SELECT COUNT(*) FROM files WHERE noise = 0 AND (enrich = 'none' OR enrich = 'text_done')"),
261
+ enrichFailed: one("SELECT COUNT(*) FROM files WHERE enrich = 'failed'"),
262
+ };
263
+ }
264
+
265
+ export function rootCounts(db) {
266
+ return db.prepare("SELECT root, COUNT(*) AS n, SUM(state = 'missing') AS missing FROM files GROUP BY root ORDER BY n DESC").all();
267
+ }
268
+
269
+ /** 只删索引记录,绝不删用户文件 */
270
+ export function forgetPaths(db, paths) {
271
+ const stmt = db.prepare("DELETE FROM files WHERE path = ?");
272
+ let n = 0;
273
+ for (const p of paths || []) n += Number(stmt.run(String(p)).changes || 0);
274
+ return n;
275
+ }
276
+
277
+ /**
278
+ * 每周校验:抽查一批已索引文件是否还在(stat)。
279
+ * 连续 2 次不见才标 missing,避免网盘/移动盘未挂载造成误判。
280
+ */
281
+ export function verifyBatch(db, limit = 500) {
282
+ const rows = db.prepare("SELECT id, path FROM files WHERE state = 'ok' ORDER BY last_seen_ms ASC LIMIT ?").all(limit);
283
+ const bump = db.prepare("UPDATE files SET missing_streak = missing_streak + 1, state = CASE WHEN missing_streak + 1 >= 2 THEN 'missing' ELSE state END WHERE id = ?");
284
+ const touch = db.prepare("UPDATE files SET missing_streak = 0, last_seen_ms = ? WHERE id = ?");
285
+ let checked = 0;
286
+ let missing = 0;
287
+ for (const row of rows) {
288
+ checked++;
289
+ try {
290
+ statSync(row.path);
291
+ touch.run(Date.now(), row.id);
292
+ } catch {
293
+ bump.run(row.id);
294
+ missing++;
295
+ }
296
+ }
297
+ setMeta(db, "last_verify_ms", String(Date.now()));
298
+ return { checked, missing };
299
+ }
300
+
301
+ /** 每日备份轮转(库损坏时可回滚,索引属可重建数据,备份只为省去重新富化) */
302
+ export function backupDb(dbPath, keep = 7) {
303
+ if (!keep || keep < 1) return null;
304
+ if (!existsSync(dbPath)) return null;
305
+ const dir = join(dirname(dbPath), "backup");
306
+ mkdirSync(dir, { recursive: true });
307
+ const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
308
+ const target = join(dir, `index-${stamp}.db`);
309
+ try {
310
+ copyFileSync(dbPath, target);
311
+ } catch {
312
+ return null;
313
+ }
314
+ try {
315
+ const olds = readdirSync(dir).filter((f) => /^index-\d{8}\.db$/.test(f)).sort();
316
+ while (olds.length > keep) {
317
+ const victim = olds.shift();
318
+ try { rmSync(join(dir, victim), { force: true }); } catch { /* ignore */ }
319
+ }
320
+ } catch {
321
+ /* ignore */
322
+ }
323
+ return target;
324
+ }
325
+
326
+ export function dbFileSize(dbPath) {
327
+ try {
328
+ return statSync(dbPath).size;
329
+ } catch {
330
+ return 0;
331
+ }
332
+ }
333
+
334
+ export function recentRuns(db, limit = 5) {
335
+ return db.prepare("SELECT * FROM scan_runs ORDER BY id DESC LIMIT ?").all(limit);
336
+ }