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/LICENSE +21 -0
- package/README.md +218 -0
- package/client.js +407 -0
- package/config.mjs +189 -0
- package/cordis.patch.yml +5 -0
- package/core/classify.mjs +102 -0
- package/core/format.mjs +40 -0
- package/core/run-scan.mjs +211 -0
- package/core/scan.mjs +208 -0
- package/core/schedule.mjs +71 -0
- package/core/search.mjs +217 -0
- package/db.mjs +336 -0
- package/index.mjs +577 -0
- package/package.json +82 -0
- package/screenshots.json +6 -0
package/config.mjs
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件快速寻回 · 配置与默认值
|
|
3
|
+
*
|
|
4
|
+
* 设计要点(与用户确认过的架构一致):
|
|
5
|
+
* - 用户配置的唯一真相来源是 DSH 设置命名空间 dsh-lost-and-found(设置页可改)。
|
|
6
|
+
* - 扫描在独立子进程里跑,子进程读不到 DSH 设置,因此 host 在派生它之前
|
|
7
|
+
* 会把一份「运行快照」写到 ~/.dsh-lost-and-found/run.json。
|
|
8
|
+
* - 用户数据目录只放运行快照/日志,索引库默认在 %LOCALAPPDATA%\dsh-lost-and-found\。
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
/** 每个扫描目录的内容深度策略 */
|
|
15
|
+
export const POLICY = Object.freeze({
|
|
16
|
+
FULL: "full", // 记元数据 + 抽正文 + 摘要 + 看图
|
|
17
|
+
SUMMARY: "summary", // 记元数据 + 摘要,不保留正文原文
|
|
18
|
+
META: "meta", // 只记元数据(文件名/类型/大小/时间/位置)
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const POLICY_LABEL = Object.freeze({
|
|
22
|
+
[POLICY.FULL]: "完整(正文+摘要+看图)",
|
|
23
|
+
[POLICY.SUMMARY]: "只摘要(不留正文原文)",
|
|
24
|
+
[POLICY.META]: "只记基本信息",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
28
|
+
enabled: true,
|
|
29
|
+
/** 空 = 用默认路径 %LOCALAPPDATA%\dsh-lost-and-found\index.db */
|
|
30
|
+
dbPath: "",
|
|
31
|
+
/** 自动扫描间隔(天)。0 = 关闭自动扫描,只能手动扫。 */
|
|
32
|
+
intervalDays: 1,
|
|
33
|
+
/** 首次安装时收录「最近 N 天」的文件。仅当 firstScanMode="window" 时生效。 */
|
|
34
|
+
firstRunWindowDays: 7,
|
|
35
|
+
/**
|
|
36
|
+
* 一个扫描目录「第一次被扫」时收多久的文件:
|
|
37
|
+
* full = 收录该目录里的全部历史文件(默认;否则后加入的目录会大面积漏收)
|
|
38
|
+
* window = 只收最近 firstRunWindowDays 天
|
|
39
|
+
* none = 只收今后新增
|
|
40
|
+
*/
|
|
41
|
+
firstScanMode: "full",
|
|
42
|
+
/** 单个文件超过该体积(MB)只记元数据,不抽正文 */
|
|
43
|
+
maxFileMB: 50,
|
|
44
|
+
/** 巡查白名单外的新目录(每天提示一次) */
|
|
45
|
+
patrolEnabled: true,
|
|
46
|
+
/** 每次看图配额(供「待描述图片」流程使用) */
|
|
47
|
+
imageQuotaPerRun: 20,
|
|
48
|
+
/** 每日备份保留份数,0 = 不备份 */
|
|
49
|
+
backupKeep: 7,
|
|
50
|
+
/** 扫描目录列表:[{ path, policy }] */
|
|
51
|
+
roots: [],
|
|
52
|
+
/** 追加的排除目录名(小写比较) */
|
|
53
|
+
extraExcludeDirs: [],
|
|
54
|
+
/** 追加的排除路径片段(不区分大小写包含匹配) */
|
|
55
|
+
extraExcludePatterns: [],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** 默认剪枝目录名:命中则整棵子树不下钻 */
|
|
59
|
+
export const DEFAULT_PRUNE_DIRS = [
|
|
60
|
+
"node_modules", ".git", ".svn", ".hg", ".pnpm-store", ".npm", ".cache", ".codex",
|
|
61
|
+
".gradle", ".m2", ".cargo", ".rustup", ".vscode-server", ".pnpm", ".backup", ".trash",
|
|
62
|
+
"appcache", "cache", "caches", "temp", "tmp", "logs", "apm_record",
|
|
63
|
+
"$recycle.bin", "system volume information",
|
|
64
|
+
"appdata", "windows", "winsxs", "program files", "program files (x86)", "programdata",
|
|
65
|
+
"__pycache__", ".venv", "venv", "site-packages", ".next", ".nuxt", "dist", "build", "obj",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/** 默认排除路径片段(包含匹配,不区分大小写):主要针对聊天软件/网盘的缓存区 */
|
|
69
|
+
export const DEFAULT_EXCLUDE_PATTERNS = [
|
|
70
|
+
"\\msg\\attach\\",
|
|
71
|
+
"\\msg\\media\\",
|
|
72
|
+
"\\filestorage\\cache\\",
|
|
73
|
+
"\\filestorage\\img\\",
|
|
74
|
+
"\\filestorage\\video\\",
|
|
75
|
+
"\\filestorage\\favorite\\",
|
|
76
|
+
"\\$recycle.bin\\",
|
|
77
|
+
"\\system volume information\\",
|
|
78
|
+
// re: 前缀 = 正则(不区分大小写)。聊天软件的「内部数据区」整棵剪掉:
|
|
79
|
+
// 这些目录里是会话数据库与缩略图缓存,会被反复改写,对「找回文件」毫无价值。
|
|
80
|
+
"re:\\\\xwechat_files\\\\[^\\\\]+\\\\(db_storage|msg\\\\attach|msg\\\\media|temp|crash|global_config|emoticon|sns|favorite|apm_record|config|log)\\\\",
|
|
81
|
+
"re:\\\\(wechat files|tencent files)\\\\[^\\\\]+\\\\(filecache|image|video|favorite|config|cache)\\\\",
|
|
82
|
+
"re:\\\\appdata\\\\(local|roaming)\\\\[^\\\\]+\\\\(cache|caches|temp|logs?)\\\\",
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
/** 默认跳过的文件名/后缀(临时件、系统件、会话库日志) */
|
|
86
|
+
export const SKIP_FILE_RE = [
|
|
87
|
+
/^desktop\.ini$/i,
|
|
88
|
+
/^thumbs\.db$/i,
|
|
89
|
+
/^\.ds_store$/i,
|
|
90
|
+
/^~\$/,
|
|
91
|
+
/\.tmp$/i,
|
|
92
|
+
/\.crdownload$/i,
|
|
93
|
+
/\.part$/i,
|
|
94
|
+
/\.partial$/i,
|
|
95
|
+
/\.download$/i,
|
|
96
|
+
/\.db-(wal|shm|journal)$/i,
|
|
97
|
+
/\.sqlite-(wal|shm|journal)$/i,
|
|
98
|
+
// 聊天/网盘的缩略图与哈希命名缓存(真正收到的原图/原文件不在此列)
|
|
99
|
+
/_thumb\.(jpg|jpeg|png|webp)$/i,
|
|
100
|
+
/^[0-9a-f]{16,}\.(jpg|jpeg|png|dat|mp4|webp)$/i,
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
/** 用户数据目录(运行快照、日志) */
|
|
104
|
+
export function dataDir() {
|
|
105
|
+
return join(homedir(), ".dsh-lost-and-found");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function runSnapshotPath() {
|
|
109
|
+
return join(dataDir(), "run.json");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function ensureDataDir() {
|
|
113
|
+
mkdirSync(dataDir(), { recursive: true });
|
|
114
|
+
return dataDir();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 默认索引库路径(发布版默认;不硬编码盘符) */
|
|
118
|
+
export function defaultDbPath() {
|
|
119
|
+
const base = process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local");
|
|
120
|
+
return join(base, "dsh-lost-and-found", "index.db");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function resolveDbPath(config) {
|
|
124
|
+
const p = (config && config.dbPath ? String(config.dbPath) : "").trim();
|
|
125
|
+
return p || defaultDbPath();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 规整扫描目录列表:去空、去重、补默认策略 */
|
|
129
|
+
export function normalizeRoots(roots) {
|
|
130
|
+
const out = [];
|
|
131
|
+
const seen = new Set();
|
|
132
|
+
for (const raw of Array.isArray(roots) ? roots : []) {
|
|
133
|
+
const path = typeof raw === "string" ? raw : raw && raw.path;
|
|
134
|
+
if (!path || typeof path !== "string") continue;
|
|
135
|
+
const clean = path.trim().replace(/[\\/]+$/, "");
|
|
136
|
+
if (!clean) continue;
|
|
137
|
+
const key = clean.toLowerCase();
|
|
138
|
+
if (seen.has(key)) continue;
|
|
139
|
+
seen.add(key);
|
|
140
|
+
const policy = raw && raw.policy && POLICY[String(raw.policy).toUpperCase()]
|
|
141
|
+
? raw.policy
|
|
142
|
+
: (raw && Object.values(POLICY).includes(raw.policy) ? raw.policy : POLICY.FULL);
|
|
143
|
+
out.push({ path: clean, policy });
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function writeRunSnapshot(payload) {
|
|
149
|
+
ensureDataDir();
|
|
150
|
+
const p = runSnapshotPath();
|
|
151
|
+
writeFileSync(p, JSON.stringify(payload, null, 2), "utf8");
|
|
152
|
+
return p;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function readRunSnapshot() {
|
|
156
|
+
try {
|
|
157
|
+
const p = runSnapshotPath();
|
|
158
|
+
if (!existsSync(p)) return null;
|
|
159
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 递归合并(设置页回填的扁平值覆盖默认值) */
|
|
166
|
+
export function mergeDeep(base, patch) {
|
|
167
|
+
if (patch === undefined || patch === null) return base;
|
|
168
|
+
if (typeof patch !== "object" || Array.isArray(patch)) return patch;
|
|
169
|
+
const out = { ...base };
|
|
170
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
171
|
+
out[k] = typeof v === "object" && v !== null && !Array.isArray(v) && typeof out[k] === "object" && out[k] !== null
|
|
172
|
+
? mergeDeep(out[k], v)
|
|
173
|
+
: v;
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 把配置里的排除规则合成为实际使用的两份清单 */
|
|
179
|
+
export function buildExcludes(config) {
|
|
180
|
+
const dirs = new Set(DEFAULT_PRUNE_DIRS);
|
|
181
|
+
for (const d of config.extraExcludeDirs || []) {
|
|
182
|
+
if (d && String(d).trim()) dirs.add(String(d).trim().toLowerCase());
|
|
183
|
+
}
|
|
184
|
+
const patterns = [...DEFAULT_EXCLUDE_PATTERNS];
|
|
185
|
+
for (const p of config.extraExcludePatterns || []) {
|
|
186
|
+
if (p && String(p).trim()) patterns.push(String(p).trim().toLowerCase());
|
|
187
|
+
}
|
|
188
|
+
return { dirs, patterns: patterns.map((p) => p.toLowerCase()) };
|
|
189
|
+
}
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件快速寻回 · 分类与来源标注
|
|
3
|
+
*
|
|
4
|
+
* 关于「是谁创建的文件」:Windows 不记录可信的作者信息(NTFS Owner 只反映系统账户,
|
|
5
|
+
* 你这台机器上几乎所有东西都是同一账户),因此这里不做「作者判定」,
|
|
6
|
+
* 而是做可判定的「来源推断」:文件是从哪类目录出现的。
|
|
7
|
+
* 这是与用户确认过的口径 —— 索引「最近出现在我地盘上的新文件」。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const CATEGORY_BY_EXT = new Map(Object.entries({
|
|
11
|
+
// 文档
|
|
12
|
+
".doc": "文档", ".docx": "文档", ".rtf": "文档", ".odt": "文档", ".wps": "文档",
|
|
13
|
+
".md": "文本", ".txt": "文本", ".log": "文本",
|
|
14
|
+
// 表格 / 演示
|
|
15
|
+
".xls": "表格", ".xlsx": "表格", ".xlsm": "表格", ".csv": "表格", ".et": "表格",
|
|
16
|
+
".ppt": "演示", ".pptx": "演示", ".pps": "演示", ".ppsx": "演示", ".dps": "演示",
|
|
17
|
+
// PDF / 电子书
|
|
18
|
+
".pdf": "PDF", ".epub": "电子书", ".mobi": "电子书", ".azw3": "电子书", ".caj": "电子书",
|
|
19
|
+
// 图片
|
|
20
|
+
".jpg": "图片", ".jpeg": "图片", ".png": "图片", ".gif": "图片", ".webp": "图片",
|
|
21
|
+
".bmp": "图片", ".heic": "图片", ".tif": "图片", ".tiff": "图片", ".svg": "图片",
|
|
22
|
+
".psd": "图片", ".ai": "图片", ".raw": "图片", ".cr2": "图片",
|
|
23
|
+
// 音视频
|
|
24
|
+
".mp3": "音频", ".wav": "音频", ".flac": "音频", ".m4a": "音频", ".aac": "音频", ".ogg": "音频",
|
|
25
|
+
".mp4": "视频", ".mkv": "视频", ".avi": "视频", ".mov": "视频", ".wmv": "视频", ".flv": "视频", ".webm": "视频",
|
|
26
|
+
// 压缩包
|
|
27
|
+
".zip": "压缩包", ".rar": "压缩包", ".7z": "压缩包", ".tar": "压缩包", ".gz": "压缩包", ".bz2": "压缩包", ".xz": "压缩包",
|
|
28
|
+
// 安装包 / 程序
|
|
29
|
+
".exe": "安装包", ".msi": "安装包", ".apk": "安装包", ".dmg": "安装包", ".pkg": "安装包", ".deb": "安装包",
|
|
30
|
+
".dll": "程序", ".sys": "程序", ".so": "程序", ".bat": "脚本", ".cmd": "脚本", ".ps1": "脚本", ".sh": "脚本",
|
|
31
|
+
// 代码
|
|
32
|
+
".js": "代码", ".mjs": "代码", ".cjs": "代码", ".ts": "代码", ".tsx": "代码", ".jsx": "代码",
|
|
33
|
+
".py": "代码", ".java": "代码", ".c": "代码", ".cpp": "代码", ".h": "代码", ".cs": "代码",
|
|
34
|
+
".go": "代码", ".rs": "代码", ".rb": "代码", ".php": "代码", ".html": "代码", ".css": "代码",
|
|
35
|
+
".json": "代码", ".xml": "代码", ".yml": "代码", ".yaml": "代码", ".sql": "代码", ".vue": "代码",
|
|
36
|
+
// 其他
|
|
37
|
+
".ttf": "字体", ".otf": "字体", ".woff": "字体", ".woff2": "字体",
|
|
38
|
+
".db": "数据库", ".sqlite": "数据库", ".sqlite3": "数据库", ".mdb": "数据库",
|
|
39
|
+
".lnk": "快捷方式", ".url": "快捷方式", ".iso": "镜像", ".vhd": "镜像", ".vhdx": "镜像",
|
|
40
|
+
".psd1": "代码", ".canvas": "其他",
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
export const CATEGORY_ORDER = [
|
|
44
|
+
"文档", "表格", "演示", "PDF", "文本", "电子书", "图片", "视频", "音频",
|
|
45
|
+
"压缩包", "代码", "脚本", "安装包", "程序", "字体", "数据库", "镜像", "快捷方式", "其他",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export function categoryOf(ext) {
|
|
49
|
+
if (!ext) return "其他";
|
|
50
|
+
return CATEGORY_BY_EXT.get(String(ext).toLowerCase()) || "其他";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 能读正文的类型(P1 内容抽取用) */
|
|
54
|
+
export const TEXT_EXTRACTABLE = new Set([
|
|
55
|
+
"文档", "表格", "演示", "PDF", "文本", "代码", "脚本",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
export const ORIGIN = Object.freeze({
|
|
59
|
+
DOWNLOAD: "我下载",
|
|
60
|
+
CHAT: "聊天收到",
|
|
61
|
+
TOOL: "工具/AI 产出",
|
|
62
|
+
DESKTOP: "桌面",
|
|
63
|
+
USER: "本地创建",
|
|
64
|
+
UNKNOWN: "未知",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const ORIGIN_RULES = [
|
|
68
|
+
[ORIGIN.CHAT, ["xwechat_files", "wechat", "微信", "\\qq\\", "tencent files", "\\msg\\file\\", "企业微信", "wxid_"]],
|
|
69
|
+
[ORIGIN.DOWNLOAD, ["download", "下载", "baidunetdiskdownload", "softboxdownload", "hrappstoredownload", "\\tmp\\download", "浏览器下载"]],
|
|
70
|
+
[ORIGIN.TOOL, ["deepseekharness", "\\.dsh\\", "错题本", "简历库", "openviking", "\\.agents\\", "dsh-plugin"]],
|
|
71
|
+
[ORIGIN.DESKTOP, ["\\desktop\\", "\\桌面", "desk\\"]],
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/** 路径 -> 来源推断(不区分大小写包含匹配) */
|
|
75
|
+
export function originOf(fullPath) {
|
|
76
|
+
const p = String(fullPath || "").toLowerCase();
|
|
77
|
+
for (const [origin, keys] of ORIGIN_RULES) {
|
|
78
|
+
for (const k of keys) if (p.includes(k)) return origin;
|
|
79
|
+
}
|
|
80
|
+
return ORIGIN.UNKNOWN;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 噪音判定:程序内部件、缓存件、无后缀的内部数据。
|
|
85
|
+
* 这类文件仍然入库(保持索引诚实),但默认不参与检索 —— 否则「其他」类会淹没真正要找的东西。
|
|
86
|
+
* 需要时可在 file_find 里用 includeNoise=true 显式包含。
|
|
87
|
+
*/
|
|
88
|
+
export const NOISE_EXT = new Set([
|
|
89
|
+
".node", ".ico", ".icns", ".pyc", ".pyo", ".class", ".obj", ".pdb", ".ilk", ".lib", ".exp",
|
|
90
|
+
".pak", ".bin", ".dat", ".db", ".sqlite", ".sqlite3", ".db-wal", ".db-shm", ".db-journal",
|
|
91
|
+
".tmp", ".bak", ".extra", ".log", ".lock", ".pid", ".cache", ".etl", ".dmp",
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
const HASH_NAME_RE = /^[0-9a-f]{16,}\./i;
|
|
95
|
+
|
|
96
|
+
export function isNoise({ ext, name } = {}) {
|
|
97
|
+
const e = String(ext || "").toLowerCase();
|
|
98
|
+
if (NOISE_EXT.has(e)) return 1;
|
|
99
|
+
if (!e) return 1; // 无后缀:绝大多数是程序内部数据
|
|
100
|
+
if (HASH_NAME_RE.test(String(name || ""))) return 1; // 哈希命名 = 缓存件
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
package/core/format.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** 文件快速寻回 · 展示层格式化(给人看的话,不出现术语) */
|
|
2
|
+
|
|
3
|
+
export function humanSize(bytes) {
|
|
4
|
+
const n = Number(bytes) || 0;
|
|
5
|
+
if (n < 1024) return `${n} B`;
|
|
6
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
7
|
+
if (n < 1024 * 1024 * 1024) return `${(n / 1048576).toFixed(1)} MB`;
|
|
8
|
+
return `${(n / 1073741824).toFixed(2)} GB`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function relTime(ms, now = Date.now()) {
|
|
12
|
+
if (!ms) return "时间未知";
|
|
13
|
+
const d = now - ms;
|
|
14
|
+
const min = Math.round(d / 60000);
|
|
15
|
+
if (min < 1) return "刚刚";
|
|
16
|
+
if (min < 60) return `${min} 分钟前`;
|
|
17
|
+
const hr = Math.round(min / 60);
|
|
18
|
+
if (hr < 24) return `${hr} 小时前`;
|
|
19
|
+
const day = Math.round(hr / 24);
|
|
20
|
+
if (day < 30) return `${day} 天前`;
|
|
21
|
+
const mon = Math.round(day / 30);
|
|
22
|
+
if (mon < 12) return `${mon} 个月前`;
|
|
23
|
+
return `${Math.round(mon / 12)} 年前`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function absTime(ms) {
|
|
27
|
+
if (!ms) return "-";
|
|
28
|
+
const d = new Date(ms);
|
|
29
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
30
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 中间省略的长路径 */
|
|
34
|
+
export function shortPath(p, max = 76) {
|
|
35
|
+
const s = String(p || "");
|
|
36
|
+
if (s.length <= max) return s;
|
|
37
|
+
const keepTail = Math.floor(max * 0.65);
|
|
38
|
+
const keepHead = max - keepTail - 3;
|
|
39
|
+
return `${s.slice(0, keepHead)}...${s.slice(s.length - keepTail)}`;
|
|
40
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件快速寻回 · 子进程入口(扫描 + 每周校验 + 野文件巡查)
|
|
3
|
+
*
|
|
4
|
+
* 为什么独立进程:扫描要遍历几万个文件、还要抽文档正文(P1),
|
|
5
|
+
* 放在 DSH 主进程里会拖慢界面。这里只做 IO,算完写库就退出。
|
|
6
|
+
* 同一时间只允许一个实例(PID 锁文件)。
|
|
7
|
+
*
|
|
8
|
+
* 用法:node core/run-scan.mjs [--trigger=auto|manual|settings] [--no-patrol]
|
|
9
|
+
* 运行快照来自 ~/.dsh-lost-and-found/run.json(由 host 在派生前写入)。
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
openDb, upsertFile, startScanRun, finishScanRun, syncRoots,
|
|
15
|
+
setMeta, backupDb, counts, verifyBatch, lastScanMs, markRootScanned,
|
|
16
|
+
} from "../db.mjs";
|
|
17
|
+
import { dataDir, readRunSnapshot, ensureDataDir, resolveDbPath, DEFAULT_CONFIG, mergeDeep } from "../config.mjs";
|
|
18
|
+
import { scanRoots, patrolDrives } from "./scan.mjs";
|
|
19
|
+
import { computeSinceMs, verifyDue, rootSinceMs } from "./schedule.mjs";
|
|
20
|
+
|
|
21
|
+
const args = Object.fromEntries(process.argv.slice(2).map((a) => {
|
|
22
|
+
const m = a.match(/^--([^=]+)(?:=(.*))?$/);
|
|
23
|
+
return m ? [m[1], m[2] === undefined ? true : m[2]] : [a, true];
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
function log(msg) {
|
|
27
|
+
try { process.stdout.write(`[lost-and-found] ${msg}\n`); } catch { /* ignore */ }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------- 单实例锁 ----------
|
|
31
|
+
const lockPath = join(dataDir(), "scan.lock");
|
|
32
|
+
ensureDataDir();
|
|
33
|
+
try {
|
|
34
|
+
if (existsSync(lockPath)) {
|
|
35
|
+
const pid = Number(readFileSync(lockPath, "utf8").trim());
|
|
36
|
+
if (pid && pid !== process.pid) {
|
|
37
|
+
try {
|
|
38
|
+
process.kill(pid, 0); // 还活着
|
|
39
|
+
log(`已有扫描在进行中(pid=${pid}),本次跳过`);
|
|
40
|
+
process.exit(0);
|
|
41
|
+
} catch {
|
|
42
|
+
rmSync(lockPath, { force: true }); // 陈旧锁
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} catch { /* ignore */ }
|
|
47
|
+
writeFileSync(lockPath, String(process.pid), "utf8");
|
|
48
|
+
|
|
49
|
+
let exitCode = 0;
|
|
50
|
+
let db = null;
|
|
51
|
+
let runId = null;
|
|
52
|
+
try {
|
|
53
|
+
const snap = (args.snapshot ? JSON.parse(readFileSync(String(args.snapshot), "utf8")) : readRunSnapshot()) || {};
|
|
54
|
+
const config = mergeDeep(structuredClone(DEFAULT_CONFIG), snap.config || {});
|
|
55
|
+
const dbPath = snap.dbPath || resolveDbPath(config);
|
|
56
|
+
const trigger = args.trigger || snap.trigger || "manual";
|
|
57
|
+
const roots = Array.isArray(snap.roots) ? snap.roots : [];
|
|
58
|
+
const excludes = snap.excludes || { dirs: [], patterns: [] };
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
|
|
61
|
+
db = openDb(dbPath);
|
|
62
|
+
syncRoots(db, roots);
|
|
63
|
+
setMeta(db, "db_path", dbPath);
|
|
64
|
+
runId = startScanRun(db, trigger, roots.map((r) => r.path));
|
|
65
|
+
setMeta(db, "run_state", "running");
|
|
66
|
+
setMeta(db, "run_started_ms", String(now));
|
|
67
|
+
setMeta(db, "run_trigger", String(trigger));
|
|
68
|
+
setMeta(db, "run_files", "0");
|
|
69
|
+
|
|
70
|
+
if (!roots.length) {
|
|
71
|
+
finishScanRun(db, runId, { note: "未配置扫描目录" });
|
|
72
|
+
setMeta(db, "run_state", "idle");
|
|
73
|
+
setMeta(db, "run_note", "还没设置要扫描的文件夹(请在设置页添加,或点「一键扫描」)");
|
|
74
|
+
log("未配置扫描目录,已退出");
|
|
75
|
+
rmSync(lockPath, { force: true });
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- 逐目录收录起点(修「按扫描按钮却收不全」的核心)----
|
|
80
|
+
// 绝不能用单一全局 sinceMs:那会让「后加入的目录」只收到全局锚点之后 6 小时的文件,
|
|
81
|
+
// 该目录更早的历史文件永久漏收。这里给每个目录算它自己的起点。
|
|
82
|
+
const forceFull = args.full === true || snap.forceFull === true;
|
|
83
|
+
const globalSince = Number(snap.sinceMs) || 0;
|
|
84
|
+
const scanList = roots.map((r) => {
|
|
85
|
+
let own;
|
|
86
|
+
try {
|
|
87
|
+
own = rootSinceMs({ db, root: r, config, now, forceFull });
|
|
88
|
+
} catch (e) {
|
|
89
|
+
log(`目录锚点计算失败,退回全局窗口 ${r.path}: ${e && e.message ? e.message : e}`);
|
|
90
|
+
own = globalSince || computeSinceMs(db, config, now);
|
|
91
|
+
}
|
|
92
|
+
return { ...r, sinceMs: own };
|
|
93
|
+
});
|
|
94
|
+
const sinceMs = scanList.length ? Math.min(...scanList.map((r) => Number(r.sinceMs) || 0)) : computeSinceMs(db, config, now);
|
|
95
|
+
log(`开始扫描 ${scanList.length} 个文件夹(逐目录锚点${forceFull ? "|本次强制全量" : ""})`);
|
|
96
|
+
for (const r of scanList) {
|
|
97
|
+
log(` · ${r.path} → ${r.sinceMs > 0 ? `收录 ${new Date(r.sinceMs).toLocaleString()} 之后` : "收录全部历史文件"}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const stats = { added: 0, updated: 0, errors: 0, skippedDirs: 0, candidates: 0 };
|
|
101
|
+
let sinceFlush = 0;
|
|
102
|
+
const result = await scanRoots({
|
|
103
|
+
roots: scanList,
|
|
104
|
+
excludes,
|
|
105
|
+
sinceMs,
|
|
106
|
+
logger: log,
|
|
107
|
+
onCandidate(rec) {
|
|
108
|
+
try {
|
|
109
|
+
const r = upsertFile(db, rec);
|
|
110
|
+
if (r === "added") stats.added++; else stats.updated++;
|
|
111
|
+
} catch (e) {
|
|
112
|
+
stats.errors++;
|
|
113
|
+
log(`入库失败 ${rec.path}: ${e && e.message ? e.message : e}`);
|
|
114
|
+
}
|
|
115
|
+
if (++sinceFlush >= 200) {
|
|
116
|
+
sinceFlush = 0;
|
|
117
|
+
setMeta(db, "run_files", String(stats.added + stats.updated));
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
stats.errors += result.errors;
|
|
122
|
+
stats.skippedDirs = result.skippedDirs;
|
|
123
|
+
stats.candidates = result.candidates;
|
|
124
|
+
|
|
125
|
+
// ---- 只给「真的走完」的目录推进锚点 ----
|
|
126
|
+
// 中断/根目录不可读的目录保持原锚点(first_scan_done 仍为 0),下次扫描会继续补,
|
|
127
|
+
// 不会再出现「扫了一半却把锚点推到最新、剩余文件永久漏收」。
|
|
128
|
+
const rootsDone = [];
|
|
129
|
+
const rootsSkipped = [];
|
|
130
|
+
for (const r of result.roots || []) {
|
|
131
|
+
if (!r.completed) {
|
|
132
|
+
rootsSkipped.push(r.path);
|
|
133
|
+
log(` ! 未扫完,本次不推进锚点:${r.path}`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (markRootScanned(db, r.path, now)) rootsDone.push(r.path);
|
|
137
|
+
}
|
|
138
|
+
log(`锚点已推进 ${rootsDone.length} 个目录${rootsSkipped.length ? `,保留 ${rootsSkipped.length} 个(未扫完)` : ""}`);
|
|
139
|
+
|
|
140
|
+
// ---- 每周一次的存在性校验(首次扫描不校验,那时还没有旧记录) ----
|
|
141
|
+
const isFirstScan = lastScanMs(db) === 0;
|
|
142
|
+
if (args.verify !== "false" && !isFirstScan && verifyDue(db, now)) {
|
|
143
|
+
const v = verifyBatch(db, Number(args.verifyLimit) || 800);
|
|
144
|
+
log(`校验扫描:检查 ${v.checked} 个已记录文件,其中 ${v.missing} 个已不在此处`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---- 野文件巡查(白名单外的新目录) ----
|
|
148
|
+
if (config.patrolEnabled && args.patrol !== "false") {
|
|
149
|
+
const drives = (snap.drives && snap.drives.length ? snap.drives : ["C:", "D:"]).map((d) => (d.endsWith(":") ? d + "\\" : d));
|
|
150
|
+
const patrolSince = Number(snap.patrolSinceMs) || now - 86400000;
|
|
151
|
+
const { suggestions, stats: pstats } = await patrolDrives({
|
|
152
|
+
drives,
|
|
153
|
+
excludes,
|
|
154
|
+
sinceMs: patrolSince,
|
|
155
|
+
coveredRoots: roots.map((r) => r.path),
|
|
156
|
+
maxDepth: Number(args.patrolDepth) || 4,
|
|
157
|
+
minFiles: 3,
|
|
158
|
+
});
|
|
159
|
+
const stmt = db.prepare(`INSERT INTO patrol (dir, files, sample, first_seen_ms, last_seen_ms, status)
|
|
160
|
+
VALUES (?, ?, ?, ?, ?, 'new')
|
|
161
|
+
ON CONFLICT(dir) DO UPDATE SET files = excluded.files, sample = excluded.sample,
|
|
162
|
+
last_seen_ms = excluded.last_seen_ms, status = 'new'`);
|
|
163
|
+
for (const s of suggestions) {
|
|
164
|
+
try { stmt.run(s.dir, s.files, s.sample ?? null, now, now); } catch { /* ignore */ }
|
|
165
|
+
}
|
|
166
|
+
log(`野文件巡查:看了 ${pstats.scannedDirs} 个目录,发现 ${suggestions.length} 个白名单外有新文件的目录`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
finishScanRun(db, runId, {
|
|
170
|
+
added: stats.added, updated: stats.updated, missing: 0,
|
|
171
|
+
skippedDirs: stats.skippedDirs, errors: stats.errors,
|
|
172
|
+
note: `扫描 ${result.total} 个文件,命中新增 ${result.candidates} 个;锚点推进 ${rootsDone.length} 个目录`
|
|
173
|
+
+ (rootsSkipped.length ? `,${rootsSkipped.length} 个未扫完` : ""),
|
|
174
|
+
});
|
|
175
|
+
setMeta(db, "last_scan_ms", String(Date.now()));
|
|
176
|
+
setMeta(db, "run_state", "idle");
|
|
177
|
+
setMeta(db, "run_note", null);
|
|
178
|
+
setMeta(db, "run_files", String(stats.added + stats.updated));
|
|
179
|
+
|
|
180
|
+
if (Number(config.backupKeep) > 0) {
|
|
181
|
+
const b = backupDb(dbPath, Number(config.backupKeep));
|
|
182
|
+
if (b) log(`已备份索引库到 ${b}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const c = counts(db);
|
|
186
|
+
log(`完成:新增 ${stats.added},更新 ${stats.updated},库内共 ${c.total} 个文件`);
|
|
187
|
+
console.log("===LAF_RUN_RESULT===");
|
|
188
|
+
console.log(JSON.stringify({
|
|
189
|
+
ok: true, trigger, scannedFiles: result.total, candidates: result.candidates,
|
|
190
|
+
added: stats.added, updated: stats.updated, skippedDirs: stats.skippedDirs,
|
|
191
|
+
errors: stats.errors, sinceMs, total: c.total, runId,
|
|
192
|
+
forceFull, rootsDone, rootsSkipped, roots: result.roots || [],
|
|
193
|
+
}, null, 2));
|
|
194
|
+
} catch (e) {
|
|
195
|
+
exitCode = 1;
|
|
196
|
+
const msg = e && e.stack ? e.stack : String(e);
|
|
197
|
+
log(`扫描失败:${msg}`);
|
|
198
|
+
try {
|
|
199
|
+
if (db) {
|
|
200
|
+
setMeta(db, "run_state", "error");
|
|
201
|
+
setMeta(db, "run_note", msg.slice(0, 500));
|
|
202
|
+
if (runId) finishScanRun(db, runId, { errors: 1, note: msg.slice(0, 300) });
|
|
203
|
+
}
|
|
204
|
+
} catch { /* ignore */ }
|
|
205
|
+
console.log("===LAF_RUN_RESULT===");
|
|
206
|
+
console.log(JSON.stringify({ ok: false, error: msg.slice(0, 500) }, null, 2));
|
|
207
|
+
} finally {
|
|
208
|
+
try { if (db) db.close(); } catch { /* ignore */ }
|
|
209
|
+
try { rmSync(lockPath, { force: true }); } catch { /* ignore */ }
|
|
210
|
+
}
|
|
211
|
+
process.exit(exitCode);
|