dsh-obsidian-notes 0.0.0-stage → 0.5.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.
- package/LICENSE +21 -0
- package/README.md +282 -2
- package/README.zh.md +198 -0
- package/cordis.patch.yml +11 -0
- package/lib/client.js +4316 -0
- package/lib/types/index.d.ts +3 -0
- package/package.json +70 -4
- package/src/client.js +3215 -0
- package/src/index.js +1293 -0
- package/src/markdown.js +1089 -0
- package/src/session-store.js +146 -0
- package/src/vault-store.js +141 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,1293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-obsidian-vault host 半边:
|
|
3
|
+
* 挂载在 DSH webserver 上的 /obsidian/* 文件路由,
|
|
4
|
+
* 所有读写被 realpath 包含性校验收窄到当前配置的 Vault 根内。
|
|
5
|
+
*/
|
|
6
|
+
import { readdir, readFile, writeFile, rename, unlink, stat, realpath, lstat, mkdir, rm, rmdir } from "node:fs/promises";
|
|
7
|
+
import { basename, dirname, join, resolve, sep, extname } from "node:path";
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { defaultVaultRoot, readSettings, resolveVaultRoot, setAttachmentDir, setVaultRoot, updateSettings, ATTACHMENT_MODES } from "./vault-store.js";
|
|
10
|
+
import { SESSION_DELETE_LIMIT, isValidSessionId, purgeSession, sessionArtifactsPresent } from "./session-store.js";
|
|
11
|
+
|
|
12
|
+
const MAX_READ = 1024 * 1024; // 单篇笔记读取上限 1MB
|
|
13
|
+
const MAX_WRITE = 1024 * 1024; // 单篇笔记写入上限 1MB
|
|
14
|
+
const MAX_BODY = MAX_WRITE + 64 * 1024;
|
|
15
|
+
const ATTACH_MAX = 20 * 1024 * 1024; // 附件写入上限 20MB(截图常超 1MB,独立上限)
|
|
16
|
+
const ATTACH_BODY = ATTACH_MAX + 64 * 1024;
|
|
17
|
+
const SESSION_BODY = 64 * 1024; // 会话删除请求体上限(id 列表)
|
|
18
|
+
const SEARCH_LIMIT = 4000; // 搜索最多扫描的文件数
|
|
19
|
+
const SEARCH_RESULT_LIMIT = 100;
|
|
20
|
+
const EMBED_SEARCH_LIMIT = 4000; // embed 目标「后缀匹配」最多扫描的文件数
|
|
21
|
+
const NOTE_EXT = ".md";
|
|
22
|
+
const IMAGE_MIME = {
|
|
23
|
+
".png": "image/png",
|
|
24
|
+
".jpg": "image/jpeg",
|
|
25
|
+
".jpeg": "image/jpeg",
|
|
26
|
+
".gif": "image/gif",
|
|
27
|
+
".webp": "image/webp",
|
|
28
|
+
};
|
|
29
|
+
// 「图片/附件目录」判定用的扩展名集合(图片 + svg)
|
|
30
|
+
const ASSET_EXT = new Set([...Object.keys(IMAGE_MIME), ".svg"]);
|
|
31
|
+
|
|
32
|
+
export const ROUTE_PREFIX = "/obsidian";
|
|
33
|
+
|
|
34
|
+
const IGNORED_DIRS = new Set([".git", "node_modules", ".obsidian", ".claudian", ".trash", ".DS_Store", "$RECYCLE.BIN", "__MACOSX"]);
|
|
35
|
+
|
|
36
|
+
function send(res, status, body) {
|
|
37
|
+
const text = JSON.stringify(body);
|
|
38
|
+
res.writeHead(status, {
|
|
39
|
+
"content-type": "application/json; charset=utf-8",
|
|
40
|
+
"cache-control": "no-store",
|
|
41
|
+
});
|
|
42
|
+
res.end(text);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readBody(req, cap) {
|
|
46
|
+
return new Promise((resolveBody, reject) => {
|
|
47
|
+
const chunks = [];
|
|
48
|
+
let size = 0;
|
|
49
|
+
req.on("data", (c) => {
|
|
50
|
+
size += c.length;
|
|
51
|
+
if (size > cap) {
|
|
52
|
+
// 超限:停止累积并暂停读取,让调用方**先**把 413 写回去。
|
|
53
|
+
// 这里不能 req.destroy():响应之前撕掉 socket,客户端只会看到
|
|
54
|
+
// `fetch failed`,413 分支永远不可达。
|
|
55
|
+
req.pause();
|
|
56
|
+
reject(Object.assign(new Error("request body too large"), { status: 413 }));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
chunks.push(c);
|
|
60
|
+
});
|
|
61
|
+
req.on("end", () => resolveBody(Buffer.concat(chunks).toString("utf8")));
|
|
62
|
+
req.on("error", reject);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 解析 JSON 请求体:语法错误归为 400(而不是 500)。 */
|
|
67
|
+
async function readJsonBody(req, cap) {
|
|
68
|
+
const text = await readBody(req, cap);
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(text);
|
|
71
|
+
} catch {
|
|
72
|
+
throw Object.assign(new Error("body must be valid JSON"), { status: 400 });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isInside(canonicalRoot, abs) {
|
|
77
|
+
return abs === canonicalRoot || abs.startsWith(canonicalRoot + sep);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 规范化 client 传来的 vault 相对路径("/" 分隔),拒绝绝对路径与 ".."。 */
|
|
81
|
+
function normalizeRel(rel) {
|
|
82
|
+
if (typeof rel !== "string") return null;
|
|
83
|
+
if (rel.includes("\\")) return null;
|
|
84
|
+
const parts = rel.split("/").filter((p) => p !== "" && p !== ".");
|
|
85
|
+
if (parts.some((p) => p === "..")) return null;
|
|
86
|
+
if (rel.startsWith("/") || /^[a-zA-Z]:/.test(rel)) return null;
|
|
87
|
+
return parts.join(sep);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isNoteName(name) {
|
|
91
|
+
return typeof name === "string"
|
|
92
|
+
&& name.trim() !== ""
|
|
93
|
+
&& !/[\\/:*?"<>|]/.test(name)
|
|
94
|
+
&& !name.startsWith(".");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 从字节流嗅探图片扩展名(png/jpg/gif/webp),非图片返回 null。 */
|
|
98
|
+
function sniffImageExt(buf) {
|
|
99
|
+
if (Buffer.isBuffer(buf) === false) return null;
|
|
100
|
+
if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return ".png";
|
|
101
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return ".jpg";
|
|
102
|
+
if (buf.length >= 4 && buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x38) return ".gif";
|
|
103
|
+
if (buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") return ".webp";
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 生成 Obsidian 风格时间戳文件名主干:Pasted image yyyyMMdd-HHmmss-fff。 */
|
|
108
|
+
function pastedNameStamp(now = new Date()) {
|
|
109
|
+
const pad = (n, w = 2) => String(n).padStart(w, "0");
|
|
110
|
+
return `Pasted image ${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
|
|
111
|
+
+ `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
|
112
|
+
+ `-${pad(now.getMilliseconds(), 3)}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 若附件目录为 Vault 根则返回 "",否则返回以 "/" 分隔的相对路径。 */
|
|
116
|
+
function toRelPosix(rootCanon, abs) {
|
|
117
|
+
if (abs === rootCanon) return "";
|
|
118
|
+
return abs.slice(rootCanon.length + 1).replaceAll(sep, "/");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 提取 markdown 中引用的图片 embed 相对路径(POSIX,去 |别名/ #章节 后缀)。 */
|
|
122
|
+
function imageRefs(content) {
|
|
123
|
+
const set = new Set();
|
|
124
|
+
if (typeof content !== "string") return set;
|
|
125
|
+
const re = /!\[\[([^\]\n]+)\]\]/g;
|
|
126
|
+
let m;
|
|
127
|
+
while ((m = re.exec(content)) !== null) {
|
|
128
|
+
const raw = m[1].split("|")[0].split("#")[0].trim();
|
|
129
|
+
if (IMAGE_MIME[extname(raw).toLowerCase()]) set.add(raw);
|
|
130
|
+
}
|
|
131
|
+
return set;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 全库扫描所有 .md 笔记引用的图片(**解析为绝对路径**),可排除指定笔记。 */
|
|
135
|
+
async function refsInVault(rootCanon, excludePosix, budgetLimit) {
|
|
136
|
+
const refs = new Set();
|
|
137
|
+
let budget = budgetLimit;
|
|
138
|
+
const walk = async (dir, prefix) => {
|
|
139
|
+
if (budget <= 0) return;
|
|
140
|
+
let names;
|
|
141
|
+
try {
|
|
142
|
+
names = await readdir(dir);
|
|
143
|
+
} catch {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
for (const name of names) {
|
|
147
|
+
if (budget <= 0) return;
|
|
148
|
+
if (name.startsWith(".")) continue;
|
|
149
|
+
const full = join(dir, name);
|
|
150
|
+
let st;
|
|
151
|
+
try {
|
|
152
|
+
st = await lstat(full);
|
|
153
|
+
} catch {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (st.isDirectory()) {
|
|
157
|
+
if (!IGNORED_DIRS.has(name)) await walk(full, prefix === "" ? name : `${prefix}/${name}`);
|
|
158
|
+
} else if (st.isFile() && extname(name).toLowerCase() === NOTE_EXT) {
|
|
159
|
+
const relPath = prefix === "" ? name : `${prefix}/${name}`;
|
|
160
|
+
budget -= 1;
|
|
161
|
+
if (excludePosix && relPath === excludePosix) continue;
|
|
162
|
+
try {
|
|
163
|
+
for (const abs of await resolvedImageRefs(rootCanon, relPath, await readFile(full, "utf8"))) refs.add(abs);
|
|
164
|
+
} catch { /* ignore */ }
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
await walk(rootCanon, "");
|
|
169
|
+
return refs;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 解析 embed 图片目标(**与 Obsidian 的 [[...]] 解析语义对齐**),返回 Vault 内绝对
|
|
174
|
+
* 路径;找不到返回 null。
|
|
175
|
+
*
|
|
176
|
+
* Obsidian 的 `![[...]]` 不是「相对当前笔记」,解析顺序为:
|
|
177
|
+
* 1) 精确的 Vault 根相对路径(`attachments/a.png`)
|
|
178
|
+
* 2) 相对「引用它的那篇笔记」所在目录(`./a.png`、`../img/a.png`)
|
|
179
|
+
* 3) 路径后缀 / 文件名匹配(短链接 `a.png`、部分路径 `img/a.png`)——
|
|
180
|
+
* 命中多个时取层级最浅的,与 Obsidian「shortest path when possible」一致
|
|
181
|
+
*
|
|
182
|
+
* 只做 1) 时,`![[附件/a.png]]`(笔记在子目录、图片在另一个子目录)会被解析成
|
|
183
|
+
* `<vault>/附件/a.png` → 403 → 页面里只剩一个断图占位。
|
|
184
|
+
*/
|
|
185
|
+
async function resolveEmbedPath(rootCanon, target, fromRel) {
|
|
186
|
+
const norm = normalizeRel(target);
|
|
187
|
+
if (norm === null) return null;
|
|
188
|
+
|
|
189
|
+
const candidates = [resolve(rootCanon, norm)];
|
|
190
|
+
const fromNorm = typeof fromRel === "string" && fromRel !== "" ? normalizeRel(fromRel) : null;
|
|
191
|
+
if (fromNorm !== null) {
|
|
192
|
+
const noteAbs = resolve(rootCanon, fromNorm);
|
|
193
|
+
if (isInside(rootCanon, noteAbs)) candidates.push(resolve(dirname(noteAbs), norm));
|
|
194
|
+
}
|
|
195
|
+
for (const c of candidates) {
|
|
196
|
+
if (!isInside(rootCanon, c)) continue;
|
|
197
|
+
try {
|
|
198
|
+
if ((await lstat(c)).isFile()) return c;
|
|
199
|
+
} catch { /* 该候选不存在,试下一个 */ }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 后缀匹配:逐段比较路径尾部(不做字符串 endsWith,否则 `x附件/a.png` 会误命中)。
|
|
203
|
+
const wantPosix = norm.split(sep).join("/");
|
|
204
|
+
const wantLen = wantPosix.split("/").length;
|
|
205
|
+
let best = null;
|
|
206
|
+
let bestDepth = Infinity;
|
|
207
|
+
let budget = EMBED_SEARCH_LIMIT;
|
|
208
|
+
const walk = async (dir, prefix) => {
|
|
209
|
+
if (budget <= 0) return;
|
|
210
|
+
let names;
|
|
211
|
+
try {
|
|
212
|
+
names = await readdir(dir);
|
|
213
|
+
} catch {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
for (const name of names) {
|
|
217
|
+
if (budget <= 0) return;
|
|
218
|
+
if (name.startsWith(".")) continue;
|
|
219
|
+
const full = join(dir, name);
|
|
220
|
+
let st;
|
|
221
|
+
try {
|
|
222
|
+
st = await lstat(full);
|
|
223
|
+
} catch {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (st.isDirectory()) {
|
|
227
|
+
if (!IGNORED_DIRS.has(name)) await walk(full, prefix === "" ? name : `${prefix}/${name}`);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (!st.isFile()) continue;
|
|
231
|
+
budget -= 1;
|
|
232
|
+
const relPath = prefix === "" ? name : `${prefix}/${name}`;
|
|
233
|
+
const parts = relPath.split("/");
|
|
234
|
+
if (parts.length < wantLen) continue;
|
|
235
|
+
if (parts.slice(parts.length - wantLen).join("/") !== wantPosix) continue;
|
|
236
|
+
if (parts.length < bestDepth) {
|
|
237
|
+
best = full;
|
|
238
|
+
bestDepth = parts.length;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
await walk(rootCanon, "");
|
|
243
|
+
return best;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 文件名在 Vault 内是否唯一。
|
|
248
|
+
* 用于按 Obsidian 的默认链接格式「shortest path when possible」决定 embed 目标:
|
|
249
|
+
* 唯一 → 裸文件名;重名 → 退回 Vault 相对路径(仍可被后缀匹配命中)。
|
|
250
|
+
*/
|
|
251
|
+
async function isNameUniqueInVault(rootCanon, fileName) {
|
|
252
|
+
let count = 0;
|
|
253
|
+
let budget = EMBED_SEARCH_LIMIT;
|
|
254
|
+
const walk = async (dir) => {
|
|
255
|
+
if (budget <= 0 || count > 1) return;
|
|
256
|
+
let names;
|
|
257
|
+
try {
|
|
258
|
+
names = await readdir(dir);
|
|
259
|
+
} catch {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
for (const name of names) {
|
|
263
|
+
if (budget <= 0 || count > 1) return;
|
|
264
|
+
if (name.startsWith(".")) continue;
|
|
265
|
+
const full = join(dir, name);
|
|
266
|
+
let st;
|
|
267
|
+
try {
|
|
268
|
+
st = await lstat(full);
|
|
269
|
+
} catch {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (st.isDirectory()) {
|
|
273
|
+
if (!IGNORED_DIRS.has(name)) await walk(full);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
budget -= 1;
|
|
277
|
+
if (name === fileName) count += 1;
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
await walk(rootCanon);
|
|
281
|
+
return count === 1;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** 附件在 Vault 内的最短引用形式(Obsidian 默认链接格式「shortest path when possible」)。 */
|
|
285
|
+
async function embedTargetFor(rootCanon, abs) {
|
|
286
|
+
const base = basename(abs);
|
|
287
|
+
if (await isNameUniqueInVault(rootCanon, base)) return base;
|
|
288
|
+
return abs.slice(rootCanon.length + 1).replaceAll(sep, "/");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* 一篇笔记里的图片引用 → Vault 内绝对路径集合(按 Obsidian 语义解析)。
|
|
293
|
+
* 孤儿清理与迁移都必须基于「解析后的真实路径」,否则换个链接写法(裸文件名 /
|
|
294
|
+
* 部分路径 / 相对路径)就会被误判成「已不再引用」而删掉,或判成「引用了」而漏改。
|
|
295
|
+
*/
|
|
296
|
+
async function resolvedImageRefs(rootCanon, noteRel, content) {
|
|
297
|
+
const out = new Set();
|
|
298
|
+
for (const r of imageRefs(content)) {
|
|
299
|
+
const abs = await resolveEmbedPath(rootCanon, r, noteRel);
|
|
300
|
+
if (abs !== null) out.add(abs);
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 按「位置模式」解析新附件的落盘目录(对齐 Obsidian 的 Default location for
|
|
307
|
+
* new attachments)。返回 { dir } 或 { error, status }。
|
|
308
|
+
*
|
|
309
|
+
* root → Vault 根
|
|
310
|
+
* fixed → 设置里的固定目录(attachmentDir)
|
|
311
|
+
* note → 与引用它的那篇笔记同目录
|
|
312
|
+
* sub → 笔记目录下的 attachmentSubfolder 子目录
|
|
313
|
+
*/
|
|
314
|
+
async function resolveAttachmentDir(rootCanon, settings, noteRelRaw) {
|
|
315
|
+
const mode = settings.attachmentMode;
|
|
316
|
+
if (mode === "note" || mode === "sub") {
|
|
317
|
+
const noteRel = normalizeRel(typeof noteRelRaw === "string" ? noteRelRaw : "");
|
|
318
|
+
if (noteRel === null || noteRel === "") {
|
|
319
|
+
return { error: "note path required for this attachment mode", status: 400 };
|
|
320
|
+
}
|
|
321
|
+
const noteAbs = resolve(rootCanon, noteRel);
|
|
322
|
+
if (!isInside(rootCanon, noteAbs)) return { error: "path escapes vault", status: 403 };
|
|
323
|
+
let dir = dirname(noteAbs);
|
|
324
|
+
if (mode === "sub") {
|
|
325
|
+
const subRel = normalizeRel(settings.attachmentSubfolder);
|
|
326
|
+
if (subRel === null || subRel === "") return { error: "invalid attachment subfolder", status: 400 };
|
|
327
|
+
dir = resolve(dir, subRel);
|
|
328
|
+
}
|
|
329
|
+
if (!isInside(rootCanon, dir)) return { error: "path escapes vault", status: 403 };
|
|
330
|
+
return { dir };
|
|
331
|
+
}
|
|
332
|
+
const attachRel = normalizeRel(mode === "root" ? "" : settings.attachmentDir);
|
|
333
|
+
if (attachRel === null) return { error: "invalid attachment dir", status: 403 };
|
|
334
|
+
const dir = resolve(rootCanon, attachRel);
|
|
335
|
+
if (!isInside(rootCanon, dir)) return { error: "invalid attachment dir", status: 403 };
|
|
336
|
+
return { dir };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* 扫描一个目录的子树,判断它是「笔记目录」还是「图片/附件目录」。
|
|
341
|
+
*
|
|
342
|
+
* 通用规则:**不含任何 .md 笔记、但含有图片附件的目录,一律不显示**。
|
|
343
|
+
* 不绑定「配置的附件目录」—— 附件目录设到哪儿、甚至用 note/sub 模式分散到各笔记
|
|
344
|
+
* 目录下,只要那个目录是纯图片目录就隐藏;反过来,含笔记的目录即使混着图片也照常显示。
|
|
345
|
+
* 空目录不算图片目录(用户刚建的空文件夹应该可见)。
|
|
346
|
+
*/
|
|
347
|
+
async function classifySubtree(dirAbs, budgetLeft = 5000) {
|
|
348
|
+
const state = { hasNote: false, hasAsset: false, budget: budgetLeft };
|
|
349
|
+
const walk = async (d) => {
|
|
350
|
+
if (state.hasNote || state.budget <= 0) return;
|
|
351
|
+
let names;
|
|
352
|
+
try {
|
|
353
|
+
names = await readdir(d);
|
|
354
|
+
} catch {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
for (const name of names) {
|
|
358
|
+
if (state.hasNote || state.budget <= 0) return;
|
|
359
|
+
if (name.startsWith(".")) continue;
|
|
360
|
+
const full = join(d, name);
|
|
361
|
+
let st;
|
|
362
|
+
try {
|
|
363
|
+
st = await lstat(full);
|
|
364
|
+
} catch {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
state.budget -= 1;
|
|
368
|
+
if (st.isDirectory()) {
|
|
369
|
+
if (!IGNORED_DIRS.has(name)) await walk(full);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (!st.isFile()) continue;
|
|
373
|
+
const ext = extname(name).toLowerCase();
|
|
374
|
+
if (ext === NOTE_EXT) {
|
|
375
|
+
state.hasNote = true;
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (ASSET_EXT.has(ext)) state.hasAsset = true;
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
await walk(dirAbs);
|
|
382
|
+
return { hasNote: state.hasNote, hasAsset: state.hasAsset, truncated: state.budget <= 0 };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** 把附件目录从 oldRel 迁移到 newRel(均为 POSIX,"" = Vault 根):
|
|
386
|
+
* 移动旧目录下所有图片文件到新目录(冲突递增改名),删除旧目录(若空且非根),
|
|
387
|
+
* 并同步改写所有笔记里的 ![[oldRel]] 引用为 ![[newRel]]。返回迁移结果。 */
|
|
388
|
+
async function migrateAttachments(rootCanon, oldRel, newRel) {
|
|
389
|
+
const result = { moved: [], notesUpdated: [], deletedOld: false };
|
|
390
|
+
const oldNorm = normalizeRel(oldRel);
|
|
391
|
+
const newNorm = normalizeRel(newRel);
|
|
392
|
+
if (oldNorm === null || newNorm === null) return result;
|
|
393
|
+
const oldAbs = resolve(rootCanon, oldNorm);
|
|
394
|
+
const newAbs = resolve(rootCanon, newNorm);
|
|
395
|
+
if (!isInside(rootCanon, oldAbs) || !isInside(rootCanon, newAbs)) return result;
|
|
396
|
+
|
|
397
|
+
let entries;
|
|
398
|
+
try {
|
|
399
|
+
entries = (await readdir(oldAbs, { withFileTypes: true }))
|
|
400
|
+
.filter((d) => d.isFile() && IMAGE_MIME[extname(d.name).toLowerCase()]);
|
|
401
|
+
} catch {
|
|
402
|
+
return result; // 旧目录不存在
|
|
403
|
+
}
|
|
404
|
+
if (entries.length === 0) return result;
|
|
405
|
+
|
|
406
|
+
// 新旧目录指向同一真实目录(不同 rel 表示同一处)→ 无需迁移
|
|
407
|
+
let sameDir = false;
|
|
408
|
+
try {
|
|
409
|
+
const rOld = await realpath(oldAbs);
|
|
410
|
+
const rNew = await realpath(newAbs).catch(() => null);
|
|
411
|
+
sameDir = rNew === rOld;
|
|
412
|
+
} catch { sameDir = false; }
|
|
413
|
+
if (sameDir) return result;
|
|
414
|
+
|
|
415
|
+
await mkdir(newAbs, { recursive: true });
|
|
416
|
+
const map = new Map(); // oldRelPosix -> newRelPosix
|
|
417
|
+
for (const d of entries) {
|
|
418
|
+
const base = d.name;
|
|
419
|
+
const oldRelPosix = oldNorm === "" ? base : `${oldNorm.replaceAll(sep, "/")}/${base}`;
|
|
420
|
+
const ext = extname(base).toLowerCase();
|
|
421
|
+
const stem = base.slice(0, -ext.length);
|
|
422
|
+
let finalName = base, i = 1;
|
|
423
|
+
while (true) {
|
|
424
|
+
try {
|
|
425
|
+
await stat(join(newAbs, finalName));
|
|
426
|
+
} catch (e) {
|
|
427
|
+
if (e && e.code === "ENOENT") break;
|
|
428
|
+
throw e;
|
|
429
|
+
}
|
|
430
|
+
finalName = `${stem} (${i})${ext}`;
|
|
431
|
+
i += 1;
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
await rename(join(oldAbs, base), join(newAbs, finalName));
|
|
435
|
+
} catch { continue; } // 单个移动失败跳过
|
|
436
|
+
const newRelPosix = newNorm === "" ? finalName : `${newNorm.replaceAll(sep, "/")}/${finalName}`;
|
|
437
|
+
// 映射以「绝对路径 → 绝对路径」记录:引用改写要按真实文件判断,而不是按
|
|
438
|
+
// `oldRel/前缀` 做字符串替换(裸文件名 / 相对路径等写法都不含该前缀)。
|
|
439
|
+
map.set(join(oldAbs, base), join(newAbs, finalName));
|
|
440
|
+
result.moved.push({ from: oldRelPosix, to: newRelPosix });
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (map.size > 0) result.notesUpdated = await updateNoteRefs(rootCanon, map);
|
|
444
|
+
|
|
445
|
+
// 删除旧目录(仅当为空,且不是 Vault 根);非空则保留(避免误删其它内容)
|
|
446
|
+
// 用 rmdir(空目录才成功,否则 ENOTEMPTY → 保留),fs.rm(recursive:false) 在 Windows 对目录返回 EISDIR。
|
|
447
|
+
if (oldNorm !== "" && oldAbs !== rootCanon) {
|
|
448
|
+
try {
|
|
449
|
+
await rmdir(oldAbs);
|
|
450
|
+
result.deletedOld = true;
|
|
451
|
+
} catch { /* 非空 / 失败 → 保留 */ }
|
|
452
|
+
}
|
|
453
|
+
return result;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* 按「旧绝对路径 → 新绝对路径」映射改写全库笔记里的图片引用(原子写),
|
|
458
|
+
* 返回被改写的笔记相对路径。
|
|
459
|
+
*
|
|
460
|
+
* 不按字符串前缀替换:`![[x.png]]`(裸文件名)、`![[img/x.png]]`(部分路径)、
|
|
461
|
+
* `![[./x.png]]`(相对当前笔记)指向同一个文件时写法各异,前缀法要么漏改、
|
|
462
|
+
* 要么在文件被改名为 `x (1).png` 后留下断链。这里逐条解析回真实路径再决定。
|
|
463
|
+
* 文件名没变的移动,裸文件名引用本就仍然有效,此时不产生改动。
|
|
464
|
+
*/
|
|
465
|
+
async function updateNoteRefs(rootCanon, map) {
|
|
466
|
+
const updated = [];
|
|
467
|
+
if (map.size === 0) return updated;
|
|
468
|
+
const REF_RE = /!\[\[([^\]|#\n]+)((?:[|#][^\]\n]*)?)\]\]/g;
|
|
469
|
+
const walk = async (dir, prefix) => {
|
|
470
|
+
let names;
|
|
471
|
+
try {
|
|
472
|
+
names = await readdir(dir);
|
|
473
|
+
} catch {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
for (const name of names) {
|
|
477
|
+
if (name.startsWith(".")) continue;
|
|
478
|
+
const full = join(dir, name);
|
|
479
|
+
let st;
|
|
480
|
+
try {
|
|
481
|
+
st = await lstat(full);
|
|
482
|
+
} catch {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (st.isDirectory()) {
|
|
486
|
+
if (!IGNORED_DIRS.has(name)) await walk(full, prefix === "" ? name : `${prefix}/${name}`);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (!st.isFile() || extname(name).toLowerCase() !== NOTE_EXT) continue;
|
|
490
|
+
const relPath = prefix === "" ? name : `${prefix}/${name}`;
|
|
491
|
+
let content;
|
|
492
|
+
try {
|
|
493
|
+
content = await readFile(full, "utf8");
|
|
494
|
+
} catch {
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
const hits = [...content.matchAll(REF_RE)];
|
|
498
|
+
if (hits.length === 0) continue;
|
|
499
|
+
|
|
500
|
+
let next = "";
|
|
501
|
+
let cursor = 0;
|
|
502
|
+
let changed = false;
|
|
503
|
+
for (const m of hits) {
|
|
504
|
+
next += content.slice(cursor, m.index);
|
|
505
|
+
cursor = m.index + m[0].length;
|
|
506
|
+
const target = m[1].trim();
|
|
507
|
+
const abs = await resolveEmbedPath(rootCanon, target, relPath);
|
|
508
|
+
let dest = abs === null ? undefined : map.get(abs);
|
|
509
|
+
if (dest === undefined) {
|
|
510
|
+
// 迁移是「先移动、后改写」,此时旧路径已经不存在,解析必然落空。
|
|
511
|
+
// 退回按「旧路径的文件名」匹配:一次迁移批次内旧 basename 唯一,
|
|
512
|
+
// 改名冲突只改新名(`x (1).png`),不会与旧名相撞。
|
|
513
|
+
const want = basename(normalizeRel(target) ?? "");
|
|
514
|
+
if (want !== "") {
|
|
515
|
+
for (const [oldAbs, newAbs] of map) {
|
|
516
|
+
if (basename(oldAbs) === want) {
|
|
517
|
+
dest = newAbs;
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (dest === undefined) {
|
|
524
|
+
next += m[0];
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const replacement = `![[${await embedTargetFor(rootCanon, dest)}${m[2] ?? ""}]]`;
|
|
528
|
+
if (replacement !== m[0]) changed = true;
|
|
529
|
+
next += replacement;
|
|
530
|
+
}
|
|
531
|
+
next += content.slice(cursor);
|
|
532
|
+
if (!changed) continue;
|
|
533
|
+
try {
|
|
534
|
+
const tmp = join(dirname(full), `.dsh-obsidian-${randomUUID()}.tmp`);
|
|
535
|
+
await writeFile(tmp, next, "utf8");
|
|
536
|
+
await rename(tmp, full);
|
|
537
|
+
updated.push(relPath);
|
|
538
|
+
} catch { /* 写入失败忽略 */ }
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
await walk(rootCanon, "");
|
|
542
|
+
return updated;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* 解析 frontmatter(--- 包裹的 key: value 行)并提取 [[双链]]。
|
|
547
|
+
* 额外返回 frontmatterRaw:开头 `---` 到闭 `---` 的原文(含其后的行尾换行,
|
|
548
|
+
* CRLF 原样保留)。保存接口拿它原样拼回,客户端只回写 body 也不会丢属性。
|
|
549
|
+
*/
|
|
550
|
+
function parseNote(raw) {
|
|
551
|
+
let body = raw;
|
|
552
|
+
let frontmatterRaw = "";
|
|
553
|
+
const frontmatter = {};
|
|
554
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw);
|
|
555
|
+
if (fm) {
|
|
556
|
+
frontmatterRaw = fm[0];
|
|
557
|
+
body = raw.slice(fm[0].length);
|
|
558
|
+
for (const line of fm[1].split(/\r?\n/)) {
|
|
559
|
+
const m = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
|
560
|
+
if (!m) continue;
|
|
561
|
+
let value = m[2].trim();
|
|
562
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
563
|
+
value = value.slice(1, -1);
|
|
564
|
+
}
|
|
565
|
+
frontmatter[m[1]] = value;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const links = [...new Set([...body.matchAll(/\[\[([^\]|#\n]+)(?:[|#][^\]]*)?\]\]/g)].map((m) => m[1].trim()))];
|
|
569
|
+
return { frontmatter, frontmatterRaw, body, links };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* 构造 /obsidian/* 的请求处理器。
|
|
574
|
+
* @param options - 可选依赖注入。
|
|
575
|
+
* @param options.getAgents - 惰性返回 host agent 注册表(`ctx.agents`,
|
|
576
|
+
* `AgentRegistry`)。删除会话的活性判据只用它:`agents.get(id)?.status
|
|
577
|
+
* === "running"` 与 Host 自己算 `running` 的口径一致。缺失或抛错时
|
|
578
|
+
* **拒绝删除**(fail closed)。
|
|
579
|
+
* @param options.getSessions - 惰性返回 host 活跃会话注册表(`ctx.sessions`,
|
|
580
|
+
* `SessionStore`)。只用于在响应里回执「删掉的 id 里还有哪些仍被进程持有
|
|
581
|
+
* 内存副本」(`attached`),不作为拒绝删除的依据。
|
|
582
|
+
* @param options.getSessionQuery - 惰性返回 host 会话查询服务(`ctx.sessionQuery`,
|
|
583
|
+
* `SessionQueryEngine`)。只读的 `/session-state` 用它判定「这些 id 在磁盘上
|
|
584
|
+
* 还有没有日志」:它的 `listSessions()` 记录自带 `persisted` 标记,正是
|
|
585
|
+
* 「durable 日志存在」的权威口径(不依赖本插件镜像的路径布局)。缺失时该路由
|
|
586
|
+
* 返回 `{ available: false }`,前端不做任何隐藏(fail soft:只读探测不该
|
|
587
|
+
* 因为服务缺失而误判)。
|
|
588
|
+
* @returns Node http 处理器。
|
|
589
|
+
*/
|
|
590
|
+
export function createHandler(options = {}) {
|
|
591
|
+
const getAgents = typeof options.getAgents === "function" ? options.getAgents : () => undefined;
|
|
592
|
+
const getSessions = typeof options.getSessions === "function" ? options.getSessions : () => undefined;
|
|
593
|
+
const getSessionQuery = typeof options.getSessionQuery === "function" ? options.getSessionQuery : () => undefined;
|
|
594
|
+
return async (req, res) => {
|
|
595
|
+
try {
|
|
596
|
+
const url = new URL(req.url ?? "/", "http://internal");
|
|
597
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
598
|
+
if (parts[0] !== "obsidian" || parts.length < 2) return send(res, 404, { error: "not found" });
|
|
599
|
+
const action = parts[1];
|
|
600
|
+
// 图片读取(file GET)由 <img> 标签直接加载,浏览器无法携带自定义头,故豁免 header 校验;
|
|
601
|
+
// 其余 /obsidian/* 写读操作仍强制要求 x-dsh-obsidian: 1。
|
|
602
|
+
const isImageRead = action === "file" && req.method === "GET";
|
|
603
|
+
if (!isImageRead && req.headers["x-dsh-obsidian"] !== "1") {
|
|
604
|
+
return send(res, 403, { error: "missing x-dsh-obsidian header" });
|
|
605
|
+
}
|
|
606
|
+
const rel = url.searchParams.get("path") ?? "";
|
|
607
|
+
|
|
608
|
+
// 只读附件设置;供 /root 回显与各分支共用。
|
|
609
|
+
const settings = await readSettings();
|
|
610
|
+
const attachmentDir = settings.attachmentDir ?? "attachments";
|
|
611
|
+
const attachSettings = {
|
|
612
|
+
attachmentDir,
|
|
613
|
+
attachmentMode: settings.attachmentMode,
|
|
614
|
+
attachmentSubfolder: settings.attachmentSubfolder,
|
|
615
|
+
hideAttachmentDir: settings.hideAttachmentDir,
|
|
616
|
+
deleteOrphanAttachments: settings.deleteOrphanAttachments,
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
// `/root` is self-managed: GET reports the resolved vault root, POST
|
|
620
|
+
// (re)sets it. Handle it before the existence short-circuit below so a
|
|
621
|
+
// missing current root cannot block setting a new one — the panel must be
|
|
622
|
+
// configurable before any vault directory exists yet.
|
|
623
|
+
if (action === "root") {
|
|
624
|
+
if (req.method === "POST") {
|
|
625
|
+
const body = JSON.parse(await readBody(req, 64 * 1024));
|
|
626
|
+
const next = await setVaultRoot(String(body && body.root ? body.root : ""));
|
|
627
|
+
return send(res, 200, { root: next, exists: true, ...attachSettings });
|
|
628
|
+
}
|
|
629
|
+
const current = await resolveVaultRoot();
|
|
630
|
+
let currentCanon;
|
|
631
|
+
try {
|
|
632
|
+
currentCanon = await realpath(current);
|
|
633
|
+
const st = await stat(currentCanon);
|
|
634
|
+
if (!st.isDirectory()) throw new Error("not a directory");
|
|
635
|
+
} catch {
|
|
636
|
+
return send(res, 200, { root: current, exists: false, attachmentDir });
|
|
637
|
+
}
|
|
638
|
+
return send(res, 200, { root: currentCanon, exists: true, ...attachSettings });
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// `/session-delete` is vault-independent: deleting a session's log has
|
|
642
|
+
// nothing to do with the configured note vault, so it must be handled
|
|
643
|
+
// before the vault-existence short-circuit below — otherwise a missing or
|
|
644
|
+
// misconfigured vault would silently disable deletion.
|
|
645
|
+
if (action === "session-delete") {
|
|
646
|
+
if (req.method !== "POST") return send(res, 405, { error: "method not allowed" });
|
|
647
|
+
const body = await readJsonBody(req, SESSION_BODY);
|
|
648
|
+
const raw = body && Array.isArray(body.ids) ? body.ids : null;
|
|
649
|
+
if (raw === null || raw.length === 0) return send(res, 400, { error: "body must be { ids: string[] }" });
|
|
650
|
+
if (raw.length > SESSION_DELETE_LIMIT) {
|
|
651
|
+
return send(res, 413, { error: `too many ids (limit ${SESSION_DELETE_LIMIT})` });
|
|
652
|
+
}
|
|
653
|
+
const ids = [...new Set(raw)];
|
|
654
|
+
const invalid = ids.filter((id) => !isValidSessionId(id));
|
|
655
|
+
if (invalid.length > 0) return send(res, 400, { error: `invalid session id: ${String(invalid[0])}` });
|
|
656
|
+
|
|
657
|
+
// 活性护栏:**只有真正在跑的会话**才拒绝删除。
|
|
658
|
+
//
|
|
659
|
+
// 判据不能是 `ctx.sessions.get(id) !== undefined`:那只说明会话被载入过
|
|
660
|
+
// 内存(打开一次就驻留到进程退出,Host 没有释放接口),与「在跑」无关。
|
|
661
|
+
// 用它当活性会让任何打开过的会话永远删不掉,提示却是「已被打开或正在
|
|
662
|
+
// 运行」——用户看到的是一条早已结束的会话记录无法删除。
|
|
663
|
+
// 唯一与 Host 一致的口径是它自己算 `running` 用的表达式:
|
|
664
|
+
// `ctx.agents.get(id)?.status === "running"`(见 api-session-controller
|
|
665
|
+
// 的 summaryFor)。在跑(含在跑的子代理,它们各自是独立 id)就拒绝;
|
|
666
|
+
// 只是驻留内存则放行。
|
|
667
|
+
//
|
|
668
|
+
// 判不准时仍然 fail closed:这是不可恢复的破坏性操作,fail-open 等于
|
|
669
|
+
// 拿用户的历史去赌一个 `undefined`。曾出现会话注册表 `get()` 抛错被判成
|
|
670
|
+
// 「不在跑」,活会话的日志被真删掉的情形。
|
|
671
|
+
const agents = getAgents();
|
|
672
|
+
if (agents === undefined || agents === null || typeof agents.get !== "function") {
|
|
673
|
+
return send(res, 503, {
|
|
674
|
+
error: "session liveness service unavailable; refusing to delete",
|
|
675
|
+
deleted: [],
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
const running = [];
|
|
679
|
+
const unknown = [];
|
|
680
|
+
for (const id of ids) {
|
|
681
|
+
try {
|
|
682
|
+
if (agents.get(id)?.status === "running") running.push(id);
|
|
683
|
+
} catch (error) {
|
|
684
|
+
unknown.push(id);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (running.length > 0 || unknown.length > 0) {
|
|
688
|
+
return send(res, 409, {
|
|
689
|
+
error: unknown.length > 0 ? "session liveness unknown; refusing to delete" : "session is running",
|
|
690
|
+
running,
|
|
691
|
+
unknown,
|
|
692
|
+
deleted: [],
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// 仅用于回执:删掉的(或磁盘上本来就没有日志的)id 里哪些仍被本进程持有
|
|
697
|
+
// 内存副本。这些行的日志已经从磁盘消失,但 Host 的会话列表仍会列出它们
|
|
698
|
+
// (列表优先用内存里的会话),直到 DSH 重启 —— 前端据此隐藏行并说明原因。
|
|
699
|
+
// `missing` 也要一并回执:日志早在别处删过时,行同样是靠内存副本撑着的。
|
|
700
|
+
const sessions = getSessions();
|
|
701
|
+
const isAttached = (id) => {
|
|
702
|
+
if (sessions === undefined || sessions === null || typeof sessions.get !== "function") return false;
|
|
703
|
+
try {
|
|
704
|
+
return sessions.get(id) !== undefined;
|
|
705
|
+
} catch {
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
const deleted = [];
|
|
711
|
+
const missing = [];
|
|
712
|
+
const failed = [];
|
|
713
|
+
const attached = [];
|
|
714
|
+
const cacheResidue = [];
|
|
715
|
+
let sessionsDir = "";
|
|
716
|
+
for (const id of ids) {
|
|
717
|
+
try {
|
|
718
|
+
const result = await purgeSession(id);
|
|
719
|
+
sessionsDir = result.root;
|
|
720
|
+
if (result.dirs.length === 0) {
|
|
721
|
+
missing.push(id);
|
|
722
|
+
if (isAttached(id)) attached.push(id);
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
// 报成功前必须回读确认**会话目录**真的不在了 —— 调用方(以及用户)
|
|
726
|
+
// 据此相信「彻底删除」,所以不能只看 rm 有没有抛错。
|
|
727
|
+
//
|
|
728
|
+
// 投影缓存单独存在不算失败:它是派生数据(DSH 按需重建),而且对一个
|
|
729
|
+
// 仍驻留内存的会话,删除后它随时可能被再写一次;列表来自日志枚举,
|
|
730
|
+
// 光有缓存不会让会话回到列表。只把残留如实回执,便于排查。
|
|
731
|
+
const left = await sessionArtifactsPresent(id);
|
|
732
|
+
if (left.present && left.reason === "session directory") {
|
|
733
|
+
failed.push({ id, error: `still present after removal: ${left.reason}` });
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
deleted.push(id);
|
|
737
|
+
if (left.present) cacheResidue.push(id);
|
|
738
|
+
if (isAttached(id)) attached.push(id);
|
|
739
|
+
} catch (error) {
|
|
740
|
+
failed.push({ id, error: error && error.message ? error.message : String(error) });
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return send(res, 200, {
|
|
744
|
+
deleted,
|
|
745
|
+
missing,
|
|
746
|
+
failed,
|
|
747
|
+
attached,
|
|
748
|
+
cacheResidue,
|
|
749
|
+
root: sessionsDir,
|
|
750
|
+
verified: failed.length === 0,
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// `/session-state` is read-only and vault-independent, exactly like
|
|
755
|
+
// `/session-delete` above: it answers "do these session ids still have a
|
|
756
|
+
// **durable** log, and is each one running?".
|
|
757
|
+
//
|
|
758
|
+
// 用途:Host 的会话列表优先用内存里的会话,所以日志早已被删掉的会话会一直
|
|
759
|
+
// 列在侧边栏里 —— 表现为「删过的记录还在前端显示,再点删除却说没有可删除
|
|
760
|
+
// 的会话」。前端本地墓碑只能记住「本浏览器、本插件、带墓碑之后」删掉的 id,
|
|
761
|
+
// 更早删的(或别的客户端删的)救不回来;这里用 Host 自己的权威口径补上:
|
|
762
|
+
// `sessionQuery.listSessions()` 的每条记录都带 `persisted`(磁盘上有
|
|
763
|
+
// durable 日志)与 `live`(内存里有副本)标记。
|
|
764
|
+
//
|
|
765
|
+
// 只读、且**必须 fail soft**:查询服务缺失或抛错时返回
|
|
766
|
+
// `{ available: false }`,前端据此什么都不隐藏(误隐藏真实会话远比多显示
|
|
767
|
+
// 一行严重)。活性判不准(agents 缺失/抛错)时按「在跑」处理,同样不隐藏。
|
|
768
|
+
if (action === "session-state") {
|
|
769
|
+
if (req.method !== "POST") return send(res, 405, { error: "method not allowed" });
|
|
770
|
+
const body = await readJsonBody(req, SESSION_BODY);
|
|
771
|
+
const raw = body && Array.isArray(body.ids) ? body.ids : null;
|
|
772
|
+
if (raw === null || raw.length === 0) return send(res, 400, { error: "body must be { ids: string[] }" });
|
|
773
|
+
if (raw.length > SESSION_DELETE_LIMIT) {
|
|
774
|
+
return send(res, 413, { error: `too many ids (limit ${SESSION_DELETE_LIMIT})` });
|
|
775
|
+
}
|
|
776
|
+
const ids = [...new Set(raw)];
|
|
777
|
+
const invalid = ids.filter((id) => !isValidSessionId(id));
|
|
778
|
+
if (invalid.length > 0) return send(res, 400, { error: `invalid session id: ${String(invalid[0])}` });
|
|
779
|
+
|
|
780
|
+
const query = getSessionQuery();
|
|
781
|
+
const agents = getAgents();
|
|
782
|
+
if (query === undefined || query === null || typeof query.listSessions !== "function"
|
|
783
|
+
|| agents === undefined || agents === null || typeof agents.get !== "function") {
|
|
784
|
+
return send(res, 200, { available: false, items: [] });
|
|
785
|
+
}
|
|
786
|
+
let records;
|
|
787
|
+
try {
|
|
788
|
+
records = await query.listSessions();
|
|
789
|
+
} catch {
|
|
790
|
+
return send(res, 200, { available: false, items: [] });
|
|
791
|
+
}
|
|
792
|
+
const known = new Map();
|
|
793
|
+
for (const record of Array.isArray(records) ? records : []) {
|
|
794
|
+
const id = record && record.header ? record.header.id : undefined;
|
|
795
|
+
if (typeof id === "string") known.set(id, record);
|
|
796
|
+
}
|
|
797
|
+
const items = ids.map((id) => {
|
|
798
|
+
const record = known.get(id);
|
|
799
|
+
let running = true;
|
|
800
|
+
try {
|
|
801
|
+
running = agents.get(id)?.status === "running";
|
|
802
|
+
} catch {
|
|
803
|
+
// 判不准 → 当作在跑:宁可多留一行,也不隐藏可能还活着的会话。
|
|
804
|
+
running = true;
|
|
805
|
+
}
|
|
806
|
+
return {
|
|
807
|
+
id,
|
|
808
|
+
// `known`:Host 的权威列表里是否还列着它。没列的话前端本来就不会渲染,
|
|
809
|
+
// 也就无所谓隐藏;带上它便于排查。
|
|
810
|
+
known: record !== undefined,
|
|
811
|
+
persisted: record !== undefined && record.persisted === true,
|
|
812
|
+
live: record !== undefined && record.live === true,
|
|
813
|
+
running,
|
|
814
|
+
};
|
|
815
|
+
});
|
|
816
|
+
return send(res, 200, { available: true, items });
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const root = await resolveVaultRoot();
|
|
820
|
+
let rootCanon;
|
|
821
|
+
try {
|
|
822
|
+
rootCanon = await realpath(root);
|
|
823
|
+
const st = await stat(rootCanon);
|
|
824
|
+
if (!st.isDirectory()) throw new Error("not a directory");
|
|
825
|
+
} catch {
|
|
826
|
+
return send(res, 200, { root, exists: false });
|
|
827
|
+
}
|
|
828
|
+
const relNorm = normalizeRel(rel);
|
|
829
|
+
const resolveInside = () => {
|
|
830
|
+
if (relNorm === null) return null;
|
|
831
|
+
return resolve(rootCanon, relNorm);
|
|
832
|
+
};
|
|
833
|
+
const ensureContained = async (abs) => {
|
|
834
|
+
try {
|
|
835
|
+
return isInside(rootCanon, await realpath(abs));
|
|
836
|
+
} catch (error) {
|
|
837
|
+
const code = error && error.code;
|
|
838
|
+
if (code === "ENOENT") {
|
|
839
|
+
try {
|
|
840
|
+
return isInside(rootCanon, await realpath(dirname(abs)));
|
|
841
|
+
} catch {
|
|
842
|
+
return false;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return false;
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
const guarded = async (abs) => {
|
|
849
|
+
if (abs === null || !(await ensureContained(abs))) {
|
|
850
|
+
throw Object.assign(new Error("path escapes vault"), { status: 403 });
|
|
851
|
+
}
|
|
852
|
+
return abs;
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
switch (action) {
|
|
856
|
+
// ── 上传粘贴的图片到附件目录并返回 embed 相对路径 ─────────────
|
|
857
|
+
case "attach": {
|
|
858
|
+
if (req.method !== "POST") return send(res, 405, { error: "method not allowed" });
|
|
859
|
+
const body = JSON.parse(await readBody(req, ATTACH_BODY));
|
|
860
|
+
if (!body || typeof body.data !== "string" || body.data === "") {
|
|
861
|
+
return send(res, 400, { error: "body must be { data }" });
|
|
862
|
+
}
|
|
863
|
+
let buf;
|
|
864
|
+
try {
|
|
865
|
+
buf = Buffer.from(body.data, "base64");
|
|
866
|
+
} catch {
|
|
867
|
+
return send(res, 400, { error: "invalid base64 data" });
|
|
868
|
+
}
|
|
869
|
+
if (buf.length === 0) return send(res, 400, { error: "empty image data" });
|
|
870
|
+
if (buf.length > ATTACH_MAX) return send(res, 413, { error: "image too large" });
|
|
871
|
+
const ext = sniffImageExt(buf);
|
|
872
|
+
if (ext === null) return send(res, 415, { error: "unsupported image type" });
|
|
873
|
+
// 定位附件目录(按位置模式;目录不存在则自动 mkdir,含多级)。
|
|
874
|
+
// 先做词法包含性校验 → mkdir recursive → 对真实路径再做一次 realpath
|
|
875
|
+
// 包含性校验(防 symlink 逃逸)。
|
|
876
|
+
const picked = await resolveAttachmentDir(rootCanon, settings, body.note);
|
|
877
|
+
if (picked.dir === undefined) return send(res, picked.status ?? 400, { error: picked.error });
|
|
878
|
+
const attachAbs = picked.dir;
|
|
879
|
+
const attachRel = toRelPosix(rootCanon, attachAbs);
|
|
880
|
+
await mkdir(attachAbs, { recursive: true });
|
|
881
|
+
await guarded(attachAbs);
|
|
882
|
+
// 生成文件名(时间戳 + 冲突递增后缀),不信任客户端给定名
|
|
883
|
+
const baseName = pastedNameStamp();
|
|
884
|
+
let fileName = `${baseName}${ext}`;
|
|
885
|
+
let counter = 1;
|
|
886
|
+
while (true) {
|
|
887
|
+
try {
|
|
888
|
+
await stat(join(attachAbs, fileName));
|
|
889
|
+
} catch (error) {
|
|
890
|
+
if (error && error.code === "ENOENT") break;
|
|
891
|
+
throw error;
|
|
892
|
+
}
|
|
893
|
+
fileName = `${baseName} (${counter})${ext}`;
|
|
894
|
+
counter += 1;
|
|
895
|
+
}
|
|
896
|
+
const target = join(attachAbs, fileName);
|
|
897
|
+
if (!isInside(rootCanon, target)) return send(res, 403, { error: "path escapes vault" });
|
|
898
|
+
const tmp = join(dirname(target), `.dsh-obsidian-${randomUUID()}.tmp`);
|
|
899
|
+
try {
|
|
900
|
+
await writeFile(tmp, buf);
|
|
901
|
+
await rename(tmp, target);
|
|
902
|
+
} catch (error) {
|
|
903
|
+
await unlink(tmp).catch(() => { /* ignore */ });
|
|
904
|
+
throw error;
|
|
905
|
+
}
|
|
906
|
+
// 返回给前端拼 `![[...]]` 的目标。按 Obsidian 默认链接格式
|
|
907
|
+
// 「shortest path when possible」:文件名在 Vault 内唯一就给最短形式(裸
|
|
908
|
+
// 文件名),重名才退回 Vault 相对路径。两种形式都能被 embed 解析命中,
|
|
909
|
+
// 且裸文件名在附件目录迁移后依然有效(不依赖旧目录前缀)。
|
|
910
|
+
const relFromRoot = attachRel === "" ? fileName : `${attachRel}/${fileName}`;
|
|
911
|
+
const unique = await isNameUniqueInVault(rootCanon, fileName);
|
|
912
|
+
return send(res, 200, { path: unique ? fileName : relFromRoot });
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// ── 设置附件目录(相对 / Vault 内绝对 / 空=回根);变更时自动迁移既有截图 ──
|
|
916
|
+
case "attach-dir": {
|
|
917
|
+
if (req.method !== "POST") return send(res, 405, { error: "method not allowed" });
|
|
918
|
+
const body = JSON.parse(await readBody(req, 64 * 1024));
|
|
919
|
+
const dir = typeof body === "object" && body !== null ? String(body.dir ?? "") : "";
|
|
920
|
+
let newRelPosix;
|
|
921
|
+
if (dir === "") {
|
|
922
|
+
newRelPosix = "";
|
|
923
|
+
} else if (/^[a-zA-Z]:/.test(dir) || dir.startsWith("/") || /^\\\\/.test(dir)) {
|
|
924
|
+
// Vault 内绝对路径 → 换算相对路径
|
|
925
|
+
const real = await realpath(dir).catch(() => null);
|
|
926
|
+
if (real === null || !isInside(rootCanon, real)) {
|
|
927
|
+
return send(res, 403, { error: "attachment dir must be inside vault" });
|
|
928
|
+
}
|
|
929
|
+
newRelPosix = toRelPosix(rootCanon, real);
|
|
930
|
+
} else {
|
|
931
|
+
// 相对路径:normalizeRel 校验(拒 .. / \ / 绝对)
|
|
932
|
+
const relN = normalizeRel(dir);
|
|
933
|
+
if (relN === null) return send(res, 403, { error: "invalid attachment dir" });
|
|
934
|
+
const abs = resolve(rootCanon, relN);
|
|
935
|
+
if (!isInside(rootCanon, abs)) return send(res, 403, { error: "attachment dir escapes vault" });
|
|
936
|
+
newRelPosix = relN === "" ? "" : relN.replaceAll(sep, "/");
|
|
937
|
+
}
|
|
938
|
+
// 迁移既有截图到新目录 + 更新笔记引用(仅当目录真的改变)
|
|
939
|
+
const migration = newRelPosix !== attachmentDir
|
|
940
|
+
? await migrateAttachments(rootCanon, attachmentDir, newRelPosix)
|
|
941
|
+
: { moved: [], notesUpdated: [], deletedOld: false };
|
|
942
|
+
await setAttachmentDir(newRelPosix);
|
|
943
|
+
return send(res, 200, { attachmentDir: newRelPosix, ...migration });
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// ── 附件位置模式 / 子目录名 / 隐藏目录 / 自动清理孤儿 ─────────────
|
|
947
|
+
// 对应 Obsidian「Files & Links」里的附件位置与链接相关选项。
|
|
948
|
+
case "attach-settings": {
|
|
949
|
+
if (req.method !== "POST") return send(res, 405, { error: "method not allowed" });
|
|
950
|
+
const body = await readJsonBody(req, 64 * 1024);
|
|
951
|
+
const patch = {};
|
|
952
|
+
if (body !== null && body.mode !== undefined) {
|
|
953
|
+
const mode = String(body.mode);
|
|
954
|
+
if (!ATTACHMENT_MODES.has(mode)) return send(res, 400, { error: `unknown attachment mode: ${mode}` });
|
|
955
|
+
patch.attachmentMode = mode;
|
|
956
|
+
}
|
|
957
|
+
if (body !== null && body.subfolder !== undefined) {
|
|
958
|
+
const sub = String(body.subfolder).trim();
|
|
959
|
+
if (sub === "" || normalizeRel(sub) === null) {
|
|
960
|
+
return send(res, 400, { error: "invalid attachment subfolder" });
|
|
961
|
+
}
|
|
962
|
+
patch.attachmentSubfolder = sub;
|
|
963
|
+
}
|
|
964
|
+
if (body !== null && body.hideAttachmentDir !== undefined) {
|
|
965
|
+
patch.hideAttachmentDir = body.hideAttachmentDir === true;
|
|
966
|
+
}
|
|
967
|
+
if (body !== null && body.deleteOrphanAttachments !== undefined) {
|
|
968
|
+
patch.deleteOrphanAttachments = body.deleteOrphanAttachments === true;
|
|
969
|
+
}
|
|
970
|
+
const next = await updateSettings(patch);
|
|
971
|
+
return send(res, 200, {
|
|
972
|
+
attachmentDir: next.attachmentDir,
|
|
973
|
+
attachmentMode: next.attachmentMode,
|
|
974
|
+
attachmentSubfolder: next.attachmentSubfolder,
|
|
975
|
+
hideAttachmentDir: next.hideAttachmentDir,
|
|
976
|
+
deleteOrphanAttachments: next.deleteOrphanAttachments,
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// ── 目录树(懒加载;仅目录与 .md 笔记)────────────────────────
|
|
981
|
+
case "tree": {
|
|
982
|
+
const abs = await guarded(resolveInside());
|
|
983
|
+
const st = await stat(abs);
|
|
984
|
+
if (!st.isDirectory()) return send(res, 400, { error: "not a directory" });
|
|
985
|
+
const entries = [];
|
|
986
|
+
const baseRel = rel === "" ? "" : rel.replaceAll(sep, "/");
|
|
987
|
+
const entryRel = (name) => (baseRel === "" ? name : `${baseRel}/${name}`);
|
|
988
|
+
for (const d of await readdir(abs, { withFileTypes: true })) {
|
|
989
|
+
const full = join(abs, d.name);
|
|
990
|
+
if (d.isDirectory()) {
|
|
991
|
+
if (IGNORED_DIRS.has(d.name)) continue;
|
|
992
|
+
// 把配置的附件目录从目录树隐藏(跳过其整棵子树)
|
|
993
|
+
// 通用规则:不含笔记、只含图片附件的目录不显示(与附件目录设置在哪无关)
|
|
994
|
+
if (settings.hideAttachmentDir === true) {
|
|
995
|
+
const kind = await classifySubtree(full);
|
|
996
|
+
if (!kind.hasNote && kind.hasAsset) continue;
|
|
997
|
+
}
|
|
998
|
+
let mtimeMs = 0;
|
|
999
|
+
try {
|
|
1000
|
+
mtimeMs = (await lstat(full)).mtimeMs;
|
|
1001
|
+
} catch { /* ignore */ }
|
|
1002
|
+
entries.push({ name: d.name, type: "dir", size: 0, mtimeMs });
|
|
1003
|
+
} else if (d.isFile() && extname(d.name).toLowerCase() === NOTE_EXT) {
|
|
1004
|
+
let size = 0;
|
|
1005
|
+
let mtimeMs = 0;
|
|
1006
|
+
try {
|
|
1007
|
+
const s = await lstat(full);
|
|
1008
|
+
size = s.size;
|
|
1009
|
+
mtimeMs = s.mtimeMs;
|
|
1010
|
+
} catch { /* ignore */ }
|
|
1011
|
+
entries.push({ name: d.name, type: "note", size, mtimeMs });
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
entries.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1));
|
|
1015
|
+
return send(res, 200, { path: rel, entries });
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// ── 读笔记(frontmatter + 双链)──────────────────────────────
|
|
1019
|
+
case "note": {
|
|
1020
|
+
if (req.method === "POST") {
|
|
1021
|
+
const body = JSON.parse(await readBody(req, MAX_BODY));
|
|
1022
|
+
const path = body && body.path;
|
|
1023
|
+
const content = body && body.content;
|
|
1024
|
+
if (typeof path !== "string" || typeof content !== "string") {
|
|
1025
|
+
return send(res, 400, { error: "body must be { path, content }" });
|
|
1026
|
+
}
|
|
1027
|
+
if (Buffer.byteLength(content, "utf8") > MAX_WRITE) return send(res, 413, { error: "content too large" });
|
|
1028
|
+
if (content.includes("\0")) return send(res, 400, { error: "binary content not allowed" });
|
|
1029
|
+
if (extname(path).toLowerCase() !== NOTE_EXT) return send(res, 400, { error: "only .md notes can be written" });
|
|
1030
|
+
const relN = normalizeRel(path);
|
|
1031
|
+
if (relN === null) return send(res, 403, { error: "invalid path" });
|
|
1032
|
+
const target = resolve(rootCanon, relN);
|
|
1033
|
+
if (!isInside(rootCanon, target)) return send(res, 403, { error: "path escapes vault" });
|
|
1034
|
+
const parentReal = await realpath(dirname(target)).catch(() => null);
|
|
1035
|
+
if (parentReal === null || !isInside(rootCanon, parentReal)) {
|
|
1036
|
+
return send(res, 403, { error: "path escapes vault" });
|
|
1037
|
+
}
|
|
1038
|
+
// 保存前读取旧内容:既用于比较移除的图片 embed(同步删除孤儿附件),
|
|
1039
|
+
// 也用于取回旧 frontmatter 原样拼回 —— 编辑器只显示 body、YAML 不进
|
|
1040
|
+
// 客户端;服务端拼装还能取到外部 Obsidian 刚改过的属性。
|
|
1041
|
+
let oldContent = "";
|
|
1042
|
+
try {
|
|
1043
|
+
oldContent = await readFile(target, "utf8");
|
|
1044
|
+
} catch { /* 新笔记:无旧 frontmatter 可拼 */ }
|
|
1045
|
+
const oldFrontmatter = parseNote(oldContent).frontmatterRaw;
|
|
1046
|
+
// frontmatterRaw 正常带闭 `---` 后的换行;文件恰好终止于闭 `---` 时
|
|
1047
|
+
// 补一个同风格的换行,别把旧属性行和新正文粘在同一行。
|
|
1048
|
+
const fmGap = oldFrontmatter !== "" && !/[\r\n]$/.test(oldFrontmatter)
|
|
1049
|
+
? (oldFrontmatter.includes("\r\n") ? "\r\n" : "\n")
|
|
1050
|
+
: "";
|
|
1051
|
+
const nextContent = oldFrontmatter + fmGap + content;
|
|
1052
|
+
// 拼回属性后再校验一次:body 本身没超限,加上旧属性仍可能超。
|
|
1053
|
+
if (Buffer.byteLength(nextContent, "utf8") > MAX_WRITE) {
|
|
1054
|
+
return send(res, 413, { error: "content too large" });
|
|
1055
|
+
}
|
|
1056
|
+
const tmp = join(dirname(target), `.dsh-obsidian-${randomUUID()}.tmp`);
|
|
1057
|
+
try {
|
|
1058
|
+
await writeFile(tmp, nextContent, "utf8");
|
|
1059
|
+
await rename(tmp, target);
|
|
1060
|
+
} catch (error) {
|
|
1061
|
+
await unlink(tmp).catch(() => { /* ignore */ });
|
|
1062
|
+
throw error;
|
|
1063
|
+
}
|
|
1064
|
+
// 同步删除:本次保存中被移除且全库不再被引用的图片附件。
|
|
1065
|
+
// 按**解析后的真实路径**比较,而不是链接字符串 —— 同一张图可能被写成
|
|
1066
|
+
// `x.png` / `img/x.png` / `./x.png`,字符串法会误判成「已不再引用」删掉。
|
|
1067
|
+
const deletedAttachments = [];
|
|
1068
|
+
const oldRefs = await resolvedImageRefs(rootCanon, path, oldContent);
|
|
1069
|
+
// 用拼回属性后的整篇内容算引用:属性里的嵌入(虽然少见)也算仍被引用。
|
|
1070
|
+
const newRefs = await resolvedImageRefs(rootCanon, path, nextContent);
|
|
1071
|
+
// 自动删除孤儿附件默认关闭(Obsidian 不自动删;误删不可恢复),
|
|
1072
|
+
// 需要时在附件设置里显式打开。
|
|
1073
|
+
const removedAbs = settings.deleteOrphanAttachments
|
|
1074
|
+
? [...oldRefs].filter((abs) => !newRefs.has(abs))
|
|
1075
|
+
: [];
|
|
1076
|
+
if (removedAbs.length > 0) {
|
|
1077
|
+
const usedElsewhere = await refsInVault(rootCanon, path, SEARCH_LIMIT);
|
|
1078
|
+
for (const abs of removedAbs) {
|
|
1079
|
+
if (usedElsewhere.has(abs)) continue; // 仍被其它笔记引用 → 保留
|
|
1080
|
+
if (!isInside(rootCanon, abs)) continue; // 包含性兜底
|
|
1081
|
+
if (!IMAGE_MIME[extname(abs).toLowerCase()]) continue;
|
|
1082
|
+
try {
|
|
1083
|
+
await unlink(abs);
|
|
1084
|
+
deletedAttachments.push(abs.slice(rootCanon.length + 1).replaceAll(sep, "/"));
|
|
1085
|
+
} catch { /* 文件不存在或删除失败,忽略 */ }
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
return send(res, 200, { ok: true, deletedAttachments });
|
|
1089
|
+
}
|
|
1090
|
+
const abs = await guarded(resolveInside());
|
|
1091
|
+
const st = await stat(abs);
|
|
1092
|
+
if (st.isDirectory()) return send(res, 400, { error: "is a directory" });
|
|
1093
|
+
if (st.size > MAX_READ) return send(res, 413, { error: "note too large to open" });
|
|
1094
|
+
const buf = await readFile(abs);
|
|
1095
|
+
if (buf.subarray(0, 8192).includes(0)) return send(res, 415, { error: "binary file" });
|
|
1096
|
+
const { frontmatter, body, links } = parseNote(buf.toString("utf8"));
|
|
1097
|
+
return send(res, 200, {
|
|
1098
|
+
path: rel,
|
|
1099
|
+
name: basename(abs),
|
|
1100
|
+
content: body,
|
|
1101
|
+
frontmatter,
|
|
1102
|
+
links,
|
|
1103
|
+
size: st.size,
|
|
1104
|
+
mtimeMs: st.mtimeMs,
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// ── 文件操作:new / rename / delete;GET = 读图片字节流供预览 ──
|
|
1109
|
+
case "file": {
|
|
1110
|
+
if (req.method === "GET") {
|
|
1111
|
+
const relN = normalizeRel(rel);
|
|
1112
|
+
if (relN === null) return send(res, 403, { error: "invalid path" });
|
|
1113
|
+
// `from` = 引用该图片的笔记相对路径;用于「相对当前笔记」的那一级解析。
|
|
1114
|
+
const fromRel = url.searchParams.get("from") ?? "";
|
|
1115
|
+
const abs = await resolveEmbedPath(rootCanon, rel, fromRel);
|
|
1116
|
+
if (abs === null) return send(res, 404, { error: "not found" });
|
|
1117
|
+
const ext = extname(abs).toLowerCase();
|
|
1118
|
+
if (!IMAGE_MIME[ext]) return send(res, 400, { error: "not an image" });
|
|
1119
|
+
let buf;
|
|
1120
|
+
try {
|
|
1121
|
+
buf = await readFile(abs);
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
if (error && error.code === "ENOENT") return send(res, 404, { error: "not found" });
|
|
1124
|
+
throw error;
|
|
1125
|
+
}
|
|
1126
|
+
res.writeHead(200, {
|
|
1127
|
+
"content-type": IMAGE_MIME[ext],
|
|
1128
|
+
"cache-control": "no-store",
|
|
1129
|
+
"content-length": String(buf.length),
|
|
1130
|
+
});
|
|
1131
|
+
res.end(buf);
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
const body = JSON.parse(await readBody(req, MAX_BODY));
|
|
1135
|
+
const op = body && body.action;
|
|
1136
|
+
if (op === "new") {
|
|
1137
|
+
const dirRel = normalizeRel(typeof body.path === "string" ? body.path : "");
|
|
1138
|
+
const name = isNoteName(String(body.name ?? "")) ? String(body.name).trim() : null;
|
|
1139
|
+
if (dirRel === null || name === null) return send(res, 400, { error: "invalid new note parameters" });
|
|
1140
|
+
const dirAbs = await guarded(resolve(rootCanon, dirRel));
|
|
1141
|
+
const st = await stat(dirAbs);
|
|
1142
|
+
if (!st.isDirectory()) return send(res, 400, { error: "not a directory" });
|
|
1143
|
+
const fileName = name.toLowerCase().endsWith(NOTE_EXT) ? name : `${name}${NOTE_EXT}`;
|
|
1144
|
+
const target = join(dirAbs, fileName);
|
|
1145
|
+
try {
|
|
1146
|
+
await stat(target);
|
|
1147
|
+
return send(res, 409, { error: "note already exists" });
|
|
1148
|
+
} catch { /* ENOENT: 可以创建 */ }
|
|
1149
|
+
const title = fileName.slice(0, -NOTE_EXT.length);
|
|
1150
|
+
await writeFile(target, `# ${title}\n\n`, "utf8");
|
|
1151
|
+
return send(res, 200, { ok: true, path: dirRel === "" ? fileName : `${dirRel.replaceAll(sep, "/")}/${fileName}` });
|
|
1152
|
+
}
|
|
1153
|
+
if (op === "mkdir") {
|
|
1154
|
+
const dirRel = normalizeRel(typeof body.path === "string" ? body.path : "");
|
|
1155
|
+
const name = isNoteName(String(body.name ?? "")) ? String(body.name).trim() : null;
|
|
1156
|
+
if (dirRel === null || name === null) return send(res, 400, { error: "invalid folder parameters" });
|
|
1157
|
+
const parentAbs = await guarded(resolve(rootCanon, dirRel));
|
|
1158
|
+
const st = await stat(parentAbs).catch(() => null);
|
|
1159
|
+
if (!st || !st.isDirectory()) return send(res, 400, { error: "not a directory" });
|
|
1160
|
+
const target = join(parentAbs, name);
|
|
1161
|
+
if (!isInside(rootCanon, target)) return send(res, 403, { error: "path escapes vault" });
|
|
1162
|
+
try {
|
|
1163
|
+
await mkdir(target);
|
|
1164
|
+
} catch (error) {
|
|
1165
|
+
if (error && error.code === "EEXIST") return send(res, 409, { error: "folder already exists" });
|
|
1166
|
+
throw error;
|
|
1167
|
+
}
|
|
1168
|
+
return send(res, 200, { ok: true, path: dirRel === "" ? name : `${dirRel.replaceAll(sep, "/")}/${name}` });
|
|
1169
|
+
}
|
|
1170
|
+
if (op === "rename") {
|
|
1171
|
+
const relN = normalizeRel(typeof body.path === "string" ? body.path : "");
|
|
1172
|
+
const newName = isNoteName(String(body.newName ?? "")) ? String(body.newName).trim() : null;
|
|
1173
|
+
if (relN === null || newName === null) return send(res, 400, { error: "invalid rename parameters" });
|
|
1174
|
+
const abs = await guarded(resolve(rootCanon, relN));
|
|
1175
|
+
const st = await stat(abs).catch(() => null);
|
|
1176
|
+
if (!st || st.isDirectory()) return send(res, 400, { error: "note not found" });
|
|
1177
|
+
const fileName = newName.toLowerCase().endsWith(NOTE_EXT) ? newName : `${newName}${NOTE_EXT}`;
|
|
1178
|
+
const target = join(dirname(abs), fileName);
|
|
1179
|
+
if (!isInside(rootCanon, target)) return send(res, 403, { error: "path escapes vault" });
|
|
1180
|
+
await rename(abs, target);
|
|
1181
|
+
return send(res, 200, { ok: true, name: fileName });
|
|
1182
|
+
}
|
|
1183
|
+
if (op === "delete") {
|
|
1184
|
+
const relN = normalizeRel(typeof body.path === "string" ? body.path : "");
|
|
1185
|
+
if (relN === null) return send(res, 400, { error: "invalid path" });
|
|
1186
|
+
if (relN === "") return send(res, 403, { error: "cannot delete vault root" });
|
|
1187
|
+
const abs = await guarded(resolve(rootCanon, relN));
|
|
1188
|
+
const st = await stat(abs).catch(() => null);
|
|
1189
|
+
if (!st) return send(res, 400, { error: "not found" });
|
|
1190
|
+
if (st.isDirectory()) {
|
|
1191
|
+
await rm(abs, { recursive: true, force: false });
|
|
1192
|
+
} else if (st.isFile()) {
|
|
1193
|
+
await unlink(abs);
|
|
1194
|
+
} else {
|
|
1195
|
+
return send(res, 400, { error: "unsupported file type" });
|
|
1196
|
+
}
|
|
1197
|
+
return send(res, 200, { ok: true, deleted: relN });
|
|
1198
|
+
}
|
|
1199
|
+
return send(res, 400, { error: `unknown action ${op}` });
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// ── 标题/正文模糊搜索 ────────────────────────────────────────
|
|
1203
|
+
case "search": {
|
|
1204
|
+
const q = (url.searchParams.get("q") ?? "").trim().toLowerCase();
|
|
1205
|
+
if (q === "") return send(res, 200, { results: [] });
|
|
1206
|
+
const results = [];
|
|
1207
|
+
const walk = async (dir, prefix, budget) => {
|
|
1208
|
+
if (budget.left <= 0) return;
|
|
1209
|
+
let names = [];
|
|
1210
|
+
try {
|
|
1211
|
+
names = await readdir(dir);
|
|
1212
|
+
} catch {
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
for (const name of names) {
|
|
1216
|
+
if (budget.left <= 0) return;
|
|
1217
|
+
const full = join(dir, name);
|
|
1218
|
+
if (name.startsWith(".")) continue;
|
|
1219
|
+
let st;
|
|
1220
|
+
try {
|
|
1221
|
+
st = await lstat(full);
|
|
1222
|
+
} catch {
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
if (st.isDirectory()) {
|
|
1226
|
+
if (!IGNORED_DIRS.has(name)) await walk(full, prefix === "" ? name : `${prefix}/${name}`, budget);
|
|
1227
|
+
} else if (st.isFile() && extname(name).toLowerCase() === NOTE_EXT) {
|
|
1228
|
+
budget.left -= 1;
|
|
1229
|
+
const relPath = prefix === "" ? name : `${prefix}/${name}`;
|
|
1230
|
+
if (name.toLowerCase().includes(q)) {
|
|
1231
|
+
results.push({ path: relPath, name, snippet: "" });
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
if (st.size > 0 && st.size <= MAX_READ) {
|
|
1235
|
+
try {
|
|
1236
|
+
const content = (await readFile(full, "utf8")).toLowerCase();
|
|
1237
|
+
const at = content.indexOf(q);
|
|
1238
|
+
if (at >= 0) {
|
|
1239
|
+
const start = Math.max(0, at - 60);
|
|
1240
|
+
const raw = content.slice(start, at + q.length + 80);
|
|
1241
|
+
const snippet = `${start > 0 ? "…" : ""}${raw.replace(/\s+/g, " ").trim()}`;
|
|
1242
|
+
results.push({ path: relPath, name, snippet });
|
|
1243
|
+
}
|
|
1244
|
+
} catch { /* ignore */ }
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
};
|
|
1249
|
+
await walk(rootCanon, "", { left: SEARCH_LIMIT });
|
|
1250
|
+
results.sort((a, b) => a.path.localeCompare(b.path));
|
|
1251
|
+
return send(res, 200, { results: results.slice(0, SEARCH_RESULT_LIMIT) });
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
default:
|
|
1255
|
+
return send(res, 404, { error: `unknown action ${action}` });
|
|
1256
|
+
}
|
|
1257
|
+
} catch (error) {
|
|
1258
|
+
const status = error && error.status
|
|
1259
|
+
? error.status
|
|
1260
|
+
: error && error.code === "ENOENT" ? 404
|
|
1261
|
+
: error && error.code === "ENOTDIR" ? 404
|
|
1262
|
+
: 500;
|
|
1263
|
+
send(res, status, { error: error && error.message ? error.message : String(error) });
|
|
1264
|
+
}
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
export function apply(ctx) {
|
|
1269
|
+
ctx.inject(["webServer"], (httpCtx) => {
|
|
1270
|
+
// `agents`(host agent 注册表)、`sessions`(host 活跃会话注册表)与
|
|
1271
|
+
// `sessionQuery`(会话查询:`persisted` = 磁盘上有 durable 日志)都按请求
|
|
1272
|
+
// 惰性解析,不写进 inject 列表:否则这些服务晚于本插件就位时整条路由都不会
|
|
1273
|
+
// 注册。会话删除的活性判据只用 `agents`(`agents.get(id)?.status ===
|
|
1274
|
+
// "running"`,与 Host 自己算 `running` 同源);`sessions` 只用于回执「删掉的
|
|
1275
|
+
// id 里哪些仍驻留内存」。服务缺失/异常时删除一律拒绝(503/409)—— 破坏性
|
|
1276
|
+
// 且不可恢复的操作不做 fail-open 降级。只读的 `/session-state` 则相反:
|
|
1277
|
+
// 判不准就回 `{ available: false }`,由前端保持现状(fail soft)。
|
|
1278
|
+
const resolveService = (name) => {
|
|
1279
|
+
if (typeof httpCtx.get !== "function") return undefined;
|
|
1280
|
+
const svc = httpCtx.get(name);
|
|
1281
|
+
return svc === undefined || svc === null ? undefined : svc;
|
|
1282
|
+
};
|
|
1283
|
+
const handler = createHandler({
|
|
1284
|
+
getAgents: () => resolveService("agents"),
|
|
1285
|
+
getSessions: () => resolveService("sessions"),
|
|
1286
|
+
getSessionQuery: () => resolveService("sessionQuery"),
|
|
1287
|
+
});
|
|
1288
|
+
httpCtx.effect(
|
|
1289
|
+
() => httpCtx.webServer.register({ kind: "prefix", path: ROUTE_PREFIX, handler }),
|
|
1290
|
+
"dsh-obsidian-vault: /obsidian file API routes",
|
|
1291
|
+
);
|
|
1292
|
+
});
|
|
1293
|
+
}
|