dsh-obsidian-notes 0.5.2 → 0.6.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/README.md +41 -10
- package/README.zh.md +19 -5
- package/lib/client.js +1535 -17
- package/package.json +1 -1
- package/src/client.js +876 -3
- package/src/graph.js +348 -0
- package/src/index.js +234 -2
- package/src/markdown.js +102 -14
- package/src/mindmap.js +207 -0
package/src/graph.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 关系图谱(Obsidian Graph view 的产品化版本,纯函数、零依赖):
|
|
3
|
+
* - `layoutGraph(nodes, edges, opts)`:确定性力导向布局(同一 seed 结果可复现,
|
|
4
|
+
* 便于截图/GIF 与测试);
|
|
5
|
+
* - `localSubgraph(graph, noteId, depth)`:以某篇笔记为中心截 N 跳子图(Obsidian 的 local graph)。
|
|
6
|
+
*
|
|
7
|
+
* 节点/连线由 host 侧 `/obsidian/graph` 计算(全库 .md + [[双链]] + 标签),
|
|
8
|
+
* 这里只负责「怎么摆」和「怎么截」。布局在 >maxNodes 时按度数保留最重要的节点,
|
|
9
|
+
* 避免一次渲染上千个圆点卡住 UI。
|
|
10
|
+
*/
|
|
11
|
+
const graphModule = (() => {
|
|
12
|
+
/** 可复现的伪随机数(mulberry32):同一 seed 得到同一布局。 */
|
|
13
|
+
function rng(seed) {
|
|
14
|
+
let state = seed >>> 0;
|
|
15
|
+
return () => {
|
|
16
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
17
|
+
let t = state;
|
|
18
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
19
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
20
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 邻接表(双向),用于度数、局部子图与渲染高亮。 */
|
|
25
|
+
function adjacency(nodes, edges) {
|
|
26
|
+
const map = new Map(nodes.map((node) => [node.id, []]));
|
|
27
|
+
for (const edge of edges) {
|
|
28
|
+
if (!map.has(edge.source) || !map.has(edge.target)) continue;
|
|
29
|
+
map.get(edge.source).push(edge.target);
|
|
30
|
+
map.get(edge.target).push(edge.source);
|
|
31
|
+
}
|
|
32
|
+
return map;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 力导向布局。
|
|
37
|
+
* @param nodes - `[{ id, kind, label, path? }]`。
|
|
38
|
+
* @param edges - `[{ source, target }]`(id 对)。
|
|
39
|
+
* @param options - width/height/iterations/maxNodes/seed。
|
|
40
|
+
* @returns `{ nodes: [{id,x,y,degree}], width, height, truncated }`。
|
|
41
|
+
*/
|
|
42
|
+
function layoutGraph(nodes, edges, options = {}) {
|
|
43
|
+
const width = options.width ?? 1400;
|
|
44
|
+
const height = options.height ?? 960;
|
|
45
|
+
const iterations = options.iterations ?? 220;
|
|
46
|
+
const maxNodes = options.maxNodes ?? 500;
|
|
47
|
+
const seed = options.seed ?? 1;
|
|
48
|
+
const padding = options.padding ?? 60;
|
|
49
|
+
const rand = rng(seed);
|
|
50
|
+
|
|
51
|
+
const adj = adjacency(nodes, edges);
|
|
52
|
+
let list = nodes;
|
|
53
|
+
let links = edges;
|
|
54
|
+
let truncated = false;
|
|
55
|
+
if (nodes.length > maxNodes) {
|
|
56
|
+
truncated = true;
|
|
57
|
+
list = [...nodes]
|
|
58
|
+
.sort((a, b) => (adj.get(b.id).length - adj.get(a.id).length) || String(a.id).localeCompare(String(b.id)))
|
|
59
|
+
.slice(0, maxNodes);
|
|
60
|
+
}
|
|
61
|
+
const index = new Map(list.map((node, i) => [node.id, i]));
|
|
62
|
+
links = links.filter((edge) => index.has(edge.source) && index.has(edge.target));
|
|
63
|
+
|
|
64
|
+
const n = list.length;
|
|
65
|
+
const xs = new Float64Array(n);
|
|
66
|
+
const ys = new Float64Array(n);
|
|
67
|
+
const vx = new Float64Array(n);
|
|
68
|
+
const vy = new Float64Array(n);
|
|
69
|
+
const cx = width / 2;
|
|
70
|
+
const cy = height / 2;
|
|
71
|
+
// 初始点:圆周分布 + 抖动,避免完全对称的力平衡(那样图形会挤成一团)
|
|
72
|
+
for (let i = 0; i < n; i += 1) {
|
|
73
|
+
const angle = (i / Math.max(1, n)) * Math.PI * 2;
|
|
74
|
+
const radius = Math.min(width, height) * 0.36 * (0.55 + rand() * 0.45);
|
|
75
|
+
xs[i] = cx + Math.cos(angle) * radius;
|
|
76
|
+
ys[i] = cy + Math.sin(angle) * radius;
|
|
77
|
+
}
|
|
78
|
+
const linkIndex = links.map((edge) => ({ a: index.get(edge.source), b: index.get(edge.target) }));
|
|
79
|
+
const degree = new Int32Array(n);
|
|
80
|
+
for (const link of linkIndex) {
|
|
81
|
+
degree[link.a] += 1;
|
|
82
|
+
degree[link.b] += 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const repulse = 26000;
|
|
86
|
+
const minDist = 26;
|
|
87
|
+
const springLength = 120;
|
|
88
|
+
const springK = 0.012;
|
|
89
|
+
const gravity = 0.0035;
|
|
90
|
+
for (let step = 0; step < iterations; step += 1) {
|
|
91
|
+
// 斥力(O(n²),n 已被 maxNodes 限制)
|
|
92
|
+
for (let i = 0; i < n; i += 1) {
|
|
93
|
+
for (let j = i + 1; j < n; j += 1) {
|
|
94
|
+
let dx = xs[i] - xs[j];
|
|
95
|
+
let dy = ys[i] - ys[j];
|
|
96
|
+
let dist2 = dx * dx + dy * dy;
|
|
97
|
+
if (dist2 < 0.01) {
|
|
98
|
+
dx = (rand() - 0.5) * 0.6;
|
|
99
|
+
dy = (rand() - 0.5) * 0.6;
|
|
100
|
+
dist2 = dx * dx + dy * dy;
|
|
101
|
+
}
|
|
102
|
+
if (dist2 < minDist * minDist) dist2 = minDist * minDist;
|
|
103
|
+
const force = repulse / dist2;
|
|
104
|
+
const dist = Math.sqrt(dist2);
|
|
105
|
+
const fx = (dx / dist) * force;
|
|
106
|
+
const fy = (dy / dist) * force;
|
|
107
|
+
vx[i] += fx;
|
|
108
|
+
vy[i] += fy;
|
|
109
|
+
vx[j] -= fx;
|
|
110
|
+
vy[j] -= fy;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// 弹簧(边)
|
|
114
|
+
for (const link of linkIndex) {
|
|
115
|
+
const dx = xs[link.b] - xs[link.a];
|
|
116
|
+
const dy = ys[link.b] - ys[link.a];
|
|
117
|
+
const dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
|
118
|
+
const force = (dist - springLength) * springK;
|
|
119
|
+
const fx = (dx / dist) * force;
|
|
120
|
+
const fy = (dy / dist) * force;
|
|
121
|
+
vx[link.a] += fx;
|
|
122
|
+
vy[link.a] += fy;
|
|
123
|
+
vx[link.b] -= fx;
|
|
124
|
+
vy[link.b] -= fy;
|
|
125
|
+
}
|
|
126
|
+
// 向心力 + 阻尼积分
|
|
127
|
+
for (let i = 0; i < n; i += 1) {
|
|
128
|
+
vx[i] += (cx - xs[i]) * gravity;
|
|
129
|
+
vy[i] += (cy - ys[i]) * gravity;
|
|
130
|
+
vx[i] *= 0.82;
|
|
131
|
+
vy[i] *= 0.82;
|
|
132
|
+
const maxStep = 28;
|
|
133
|
+
if (vx[i] > maxStep) vx[i] = maxStep;
|
|
134
|
+
if (vx[i] < -maxStep) vx[i] = -maxStep;
|
|
135
|
+
if (vy[i] > maxStep) vy[i] = maxStep;
|
|
136
|
+
if (vy[i] < -maxStep) vy[i] = -maxStep;
|
|
137
|
+
xs[i] += vx[i];
|
|
138
|
+
ys[i] += vy[i];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 归一化到画布(保留 padding),图形太小时水平居中撑开
|
|
143
|
+
let minX = Infinity;
|
|
144
|
+
let maxX = -Infinity;
|
|
145
|
+
let minY = Infinity;
|
|
146
|
+
let maxY = -Infinity;
|
|
147
|
+
for (let i = 0; i < n; i += 1) {
|
|
148
|
+
if (xs[i] < minX) minX = xs[i];
|
|
149
|
+
if (xs[i] > maxX) maxX = xs[i];
|
|
150
|
+
if (ys[i] < minY) minY = ys[i];
|
|
151
|
+
if (ys[i] > maxY) maxY = ys[i];
|
|
152
|
+
}
|
|
153
|
+
const spanX = maxX - minX || 1;
|
|
154
|
+
const spanY = maxY - minY || 1;
|
|
155
|
+
const usableW = width - padding * 2;
|
|
156
|
+
const usableH = height - padding * 2;
|
|
157
|
+
const scale = Math.min(usableW / spanX, usableH / spanY, 1.8);
|
|
158
|
+
const offsetX = padding + (usableW - spanX * scale) / 2;
|
|
159
|
+
const offsetY = padding + (usableH - spanY * scale) / 2;
|
|
160
|
+
const placed = list.map((node, i) => ({
|
|
161
|
+
...node,
|
|
162
|
+
x: offsetX + (xs[i] - minX) * scale,
|
|
163
|
+
y: offsetY + (ys[i] - minY) * scale,
|
|
164
|
+
degree: degree[i],
|
|
165
|
+
}));
|
|
166
|
+
return { nodes: placed, width, height, truncated };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 以某节点为中心截 N 跳子图(Obsidian 的 local graph)。
|
|
171
|
+
* @param graph - `{ nodes, edges }`(host 返回)。
|
|
172
|
+
* @param noteId - 中心节点 id(笔记节点是 `note:<rel>`)。
|
|
173
|
+
* @param depth - 跳数(默认 2)。
|
|
174
|
+
* @returns `{ nodes, edges, found }`。
|
|
175
|
+
*/
|
|
176
|
+
function localSubgraph(graph, noteId, depth = 2) {
|
|
177
|
+
const nodes = graph && Array.isArray(graph.nodes) ? graph.nodes : [];
|
|
178
|
+
const edges = graph && Array.isArray(graph.edges) ? graph.edges : [];
|
|
179
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
180
|
+
if (!byId.has(noteId)) return { nodes: [], edges: [], found: false };
|
|
181
|
+
const adj = adjacency(nodes, edges);
|
|
182
|
+
const seen = new Set([noteId]);
|
|
183
|
+
let frontier = [noteId];
|
|
184
|
+
for (let hop = 0; hop < depth; hop += 1) {
|
|
185
|
+
const next = [];
|
|
186
|
+
for (const id of frontier) {
|
|
187
|
+
for (const other of adj.get(id) ?? []) {
|
|
188
|
+
if (seen.has(other)) continue;
|
|
189
|
+
seen.add(other);
|
|
190
|
+
next.push(other);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
frontier = next;
|
|
194
|
+
if (frontier.length === 0) break;
|
|
195
|
+
}
|
|
196
|
+
const keep = new Set([...seen].filter((id) => byId.has(id)));
|
|
197
|
+
return {
|
|
198
|
+
nodes: nodes.filter((node) => keep.has(node.id)),
|
|
199
|
+
edges: edges.filter((edge) => keep.has(edge.source) && keep.has(edge.target)),
|
|
200
|
+
found: true,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
/** POSIX 风格拼接 + 折叠 `.`/`..`(客户端兜底构建时与 host 同一套链接解析)。 */
|
|
206
|
+
function joinPosix(dir, rel) {
|
|
207
|
+
const parts = dir === "" ? [] : dir.split("/");
|
|
208
|
+
for (const seg of rel.split("/")) {
|
|
209
|
+
if (seg === "" || seg === ".") continue;
|
|
210
|
+
if (seg === "..") parts.pop();
|
|
211
|
+
else parts.push(seg);
|
|
212
|
+
}
|
|
213
|
+
return parts.join("/");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* `[[链接目标]]` → 笔记相对路径(与 host 侧 / Obsidian 语义一致:精确路径 →
|
|
218
|
+
* 相对当前笔记目录 → 文件名,多个候选取层数最浅再字母序)。
|
|
219
|
+
* 客户端兜底构建图谱时用;解析不到返回 null。
|
|
220
|
+
*/
|
|
221
|
+
function resolveGraphLink(target, fromRel, byPath, byBase) {
|
|
222
|
+
let raw = String(target == null ? "" : target).trim();
|
|
223
|
+
if (raw === "" || /^[a-z]+:\/\//i.test(raw)) return null;
|
|
224
|
+
raw = raw.replace(/\\/g, "/");
|
|
225
|
+
const stripExt = (value) => value.replace(/\.md$/i, "");
|
|
226
|
+
const tryPath = (value) => byPath.get(stripExt(value).replace(/^\.\//, "").toLowerCase()) ?? null;
|
|
227
|
+
let hit = tryPath(raw);
|
|
228
|
+
if (hit !== null) return hit;
|
|
229
|
+
if (raw.includes("/") || raw.startsWith(".")) {
|
|
230
|
+
const dir = fromRel.includes("/") ? fromRel.slice(0, fromRel.lastIndexOf("/")) : "";
|
|
231
|
+
hit = tryPath(joinPosix(dir, raw));
|
|
232
|
+
if (hit !== null) return hit;
|
|
233
|
+
}
|
|
234
|
+
const base = stripExt(raw.split("/").pop()).toLowerCase();
|
|
235
|
+
const list = byBase.get(base);
|
|
236
|
+
if (list === undefined || list.length === 0) return null;
|
|
237
|
+
if (list.length === 1) return list[0];
|
|
238
|
+
return [...list].sort((a, b) => (a.split("/").length - b.split("/").length) || a.localeCompare(b))[0];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 客户端兜底:用「笔记列表 + 每篇的 links/tags」构建与 host 相同形状的图。
|
|
243
|
+
* 用途:插件升级后 host 还是旧版(没有 /obsidian/graph 路由)时,前端仍能
|
|
244
|
+
* 通过旧接口自建图谱 —— 用户无感知、不用重启 DSH。
|
|
245
|
+
* @param notes - `[{ path, links, tags }]`(path 为 vault 相对路径)。
|
|
246
|
+
* @returns `{ nodes, edges, stats }`。
|
|
247
|
+
*/
|
|
248
|
+
function buildGraphFromNotes(notes, options = {}) {
|
|
249
|
+
const maxNotes = options.maxNotes ?? 1200;
|
|
250
|
+
const maxEdges = options.maxEdges ?? 12000;
|
|
251
|
+
const maxTags = options.maxTags ?? 800;
|
|
252
|
+
const list = [...notes].slice(0, maxNotes);
|
|
253
|
+
const byPath = new Map();
|
|
254
|
+
const byBase = new Map();
|
|
255
|
+
for (const note of list) {
|
|
256
|
+
const noExt = note.path.replace(/\.md$/i, "");
|
|
257
|
+
byPath.set(noExt.toLowerCase(), note.path);
|
|
258
|
+
const base = (noExt.split("/").pop() || noExt).toLowerCase();
|
|
259
|
+
const bucket = byBase.get(base);
|
|
260
|
+
if (bucket === undefined) byBase.set(base, [note.path]);
|
|
261
|
+
else bucket.push(note.path);
|
|
262
|
+
}
|
|
263
|
+
const nodes = new Map();
|
|
264
|
+
for (const note of list) {
|
|
265
|
+
nodes.set(`note:${note.path}`, {
|
|
266
|
+
id: `note:${note.path}`,
|
|
267
|
+
kind: "note",
|
|
268
|
+
label: (note.path.split("/").pop() || note.path).replace(/\.md$/i, ""),
|
|
269
|
+
path: note.path,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
const edges = [];
|
|
273
|
+
const seen = new Set();
|
|
274
|
+
const addEdge = (source, target) => {
|
|
275
|
+
if (edges.length >= maxEdges) return;
|
|
276
|
+
const key = `${source}\u0000${target}`;
|
|
277
|
+
if (seen.has(key)) return;
|
|
278
|
+
seen.add(key);
|
|
279
|
+
edges.push({ source, target });
|
|
280
|
+
};
|
|
281
|
+
const tagIds = new Map();
|
|
282
|
+
let unresolved = 0;
|
|
283
|
+
for (const note of list) {
|
|
284
|
+
const self = `note:${note.path}`;
|
|
285
|
+
for (const link of note.links ?? []) {
|
|
286
|
+
const target = resolveGraphLink(link, note.path, byPath, byBase);
|
|
287
|
+
if (target === null) {
|
|
288
|
+
unresolved += 1;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (target !== note.path) addEdge(self, `note:${target}`);
|
|
292
|
+
}
|
|
293
|
+
for (const tag of note.tags ?? []) {
|
|
294
|
+
const key = String(tag).toLowerCase();
|
|
295
|
+
let tagId = tagIds.get(key);
|
|
296
|
+
if (tagId === undefined) {
|
|
297
|
+
if (tagIds.size >= maxTags) continue;
|
|
298
|
+
tagId = `tag:${key}`;
|
|
299
|
+
tagIds.set(key, tagId);
|
|
300
|
+
nodes.set(tagId, { id: tagId, kind: "tag", label: `#${tag}` });
|
|
301
|
+
}
|
|
302
|
+
addEdge(self, tagId);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return {
|
|
306
|
+
nodes: [...nodes.values()],
|
|
307
|
+
edges,
|
|
308
|
+
stats: {
|
|
309
|
+
notes: list.length,
|
|
310
|
+
tags: tagIds.size,
|
|
311
|
+
edges: edges.length,
|
|
312
|
+
unresolved,
|
|
313
|
+
truncated: notes.length > maxNotes,
|
|
314
|
+
source: "client",
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 从笔记内容 + frontmatter 提取标签(客户端兜底构建图谱时用;与 host 解析同语义)。
|
|
322
|
+
* @param content - 笔记正文。
|
|
323
|
+
* @param frontmatter - `{ tags: "..." }` 形态的 frontmatter 映射。
|
|
324
|
+
* @returns 标签名数组(不含 `#`、去重)。
|
|
325
|
+
*/
|
|
326
|
+
function extractTags(content, frontmatter) {
|
|
327
|
+
const tags = new Set();
|
|
328
|
+
const fm = frontmatter && frontmatter.tags;
|
|
329
|
+
if (typeof fm === "string" && fm.trim() !== "") {
|
|
330
|
+
for (const token of fm.replace(/^\[|\]$/g, "").split(/[,\s]+/)) {
|
|
331
|
+
const tag = token.replace(/^#/, "").replace(/^["']|["']$/g, "").trim();
|
|
332
|
+
if (tag !== "" && /^[\p{L}\p{N}_/-]{1,64}$/u.test(tag)) tags.add(tag);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const body = String(content ?? "")
|
|
336
|
+
.replace(/```[\s\S]*?```/g, " ")
|
|
337
|
+
.replace(/~~~[\s\S]*?~~~/g, " ")
|
|
338
|
+
.replace(/`[^`\n]*`/g, " ");
|
|
339
|
+
const re = /(^|[\s((\[【,,;;、>|])#([\p{L}\p{N}_/-]{1,64})/gu;
|
|
340
|
+
for (const match of body.matchAll(re)) {
|
|
341
|
+
const tag = match[2].replace(/\/+$/, "");
|
|
342
|
+
if (tag !== "" && !/^\d+$/.test(tag)) tags.add(tag);
|
|
343
|
+
}
|
|
344
|
+
return [...tags];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return { layoutGraph, localSubgraph, adjacency, rng, resolveGraphLink, buildGraphFromNotes, extractTags };
|
|
348
|
+
})();
|
package/src/index.js
CHANGED
|
@@ -19,6 +19,8 @@ const SEARCH_LIMIT = 4000; // 搜索最多扫描的文件数
|
|
|
19
19
|
const SEARCH_RESULT_LIMIT = 100;
|
|
20
20
|
const EMBED_SEARCH_LIMIT = 4000; // embed 目标「后缀匹配」最多扫描的文件数
|
|
21
21
|
const NOTE_EXT = ".md";
|
|
22
|
+
/** host 能力清单:客户端据此判断「新版 UI + 旧版 host」的版本错配,避免撞 404 裸报错。 */
|
|
23
|
+
export const HOST_FEATURES = ["graph"];
|
|
22
24
|
const IMAGE_MIME = {
|
|
23
25
|
".png": "image/png",
|
|
24
26
|
".jpg": "image/jpeg",
|
|
@@ -569,6 +571,230 @@ function parseNote(raw) {
|
|
|
569
571
|
return { frontmatter, frontmatterRaw, body, links };
|
|
570
572
|
}
|
|
571
573
|
|
|
574
|
+
|
|
575
|
+
// ── 关系图谱(全库 .md + [[双链]] + 标签)────────────────────────────────
|
|
576
|
+
const GRAPH_MAX_NOTES = 1200; // 单次图谱最多收录的笔记数(超出截断)
|
|
577
|
+
const GRAPH_MAX_EDGES = 12000; // 连线上限,避免超大 vault 传爆前端
|
|
578
|
+
const GRAPH_TAG_MAX = 800; // 标签节点上限
|
|
579
|
+
const GRAPH_CACHE_MAX_ROOTS = 2; // 只缓存最近用过的 vault 根
|
|
580
|
+
/** rootCanon → { files: Map<rel,{mtimeMs,size,links,tags}>, result } */
|
|
581
|
+
const graphCaches = new Map();
|
|
582
|
+
|
|
583
|
+
/** frontmatter 的 tags 值(" [a, b]" / "a b" / "a, b")→ 标签列表。 */
|
|
584
|
+
function parseFrontmatterTags(value) {
|
|
585
|
+
if (typeof value !== "string" || value.trim() === "") return [];
|
|
586
|
+
return value
|
|
587
|
+
.replace(/^\[|\]$/g, "")
|
|
588
|
+
.split(/[,\s]+/)
|
|
589
|
+
.map((tag) => tag.replace(/^#/, "").replace(/^["']|["']$/g, "").trim())
|
|
590
|
+
.filter((tag) => tag !== "" && /^[\p{L}\p{N}_/-]{1,64}$/u.test(tag));
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* 正文里的行内标签:先剥掉围栏代码块与行内代码,再按 Obsidian 语义扫描 `#tag`
|
|
595
|
+
* (前一个字符必须是行首/空白/常见分隔符;纯数字不算标签)。
|
|
596
|
+
* @param body - 笔记正文(frontmatter 已剥离)。
|
|
597
|
+
* @returns 标签名数组(不含 `#`)。
|
|
598
|
+
*/
|
|
599
|
+
function parseInlineTags(body) {
|
|
600
|
+
const visible = body
|
|
601
|
+
.replace(/```[\s\S]*?```/g, " ")
|
|
602
|
+
.replace(/~~~[\s\S]*?~~~/g, " ")
|
|
603
|
+
.replace(/`[^`\n]*`/g, " ");
|
|
604
|
+
const tags = new Set();
|
|
605
|
+
const re = /(^|[\s((\[【,,;;、>|])#([\p{L}\p{N}_/-]{1,64})/gu;
|
|
606
|
+
for (const match of visible.matchAll(re)) {
|
|
607
|
+
const tag = match[2].replace(/\/+$/, "");
|
|
608
|
+
if (tag === "" || /^\d+$/.test(tag)) continue;
|
|
609
|
+
tags.add(tag);
|
|
610
|
+
}
|
|
611
|
+
return [...tags];
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** 一篇笔记的图谱数据:双链目标(去重)+ 标签(frontmatter + 正文)。 */
|
|
615
|
+
function parseGraphNote(content) {
|
|
616
|
+
const { frontmatter, body, links } = parseNote(content);
|
|
617
|
+
const tags = new Set(parseFrontmatterTags(frontmatter && frontmatter.tags));
|
|
618
|
+
for (const tag of parseInlineTags(body)) tags.add(tag);
|
|
619
|
+
return { links, tags: [...tags] };
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** 递归列出 vault 里的 .md(跳过隐藏目录/IGNORED_DIRS/符号链接),返回 [{rel,mtimeMs,size}]。 */
|
|
623
|
+
async function listNoteFiles(rootCanon) {
|
|
624
|
+
const out = [];
|
|
625
|
+
const walk = async (dir, prefix) => {
|
|
626
|
+
if (out.length >= GRAPH_MAX_NOTES) return;
|
|
627
|
+
let names;
|
|
628
|
+
try {
|
|
629
|
+
names = await readdir(dir);
|
|
630
|
+
} catch {
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
for (const name of names) {
|
|
634
|
+
if (out.length >= GRAPH_MAX_NOTES) return;
|
|
635
|
+
if (name.startsWith(".")) continue;
|
|
636
|
+
const full = join(dir, name);
|
|
637
|
+
let st;
|
|
638
|
+
try {
|
|
639
|
+
st = await lstat(full);
|
|
640
|
+
} catch {
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
if (st.isDirectory()) {
|
|
644
|
+
if (!IGNORED_DIRS.has(name)) await walk(full, prefix === "" ? name : `${prefix}/${name}`);
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
if (!st.isFile() || extname(name).toLowerCase() !== NOTE_EXT) continue;
|
|
648
|
+
out.push({ rel: prefix === "" ? name : `${prefix}/${name}`, mtimeMs: st.mtimeMs, size: st.size });
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
await walk(rootCanon, "");
|
|
652
|
+
return out;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** POSIX 风格拼接 + 折叠 `.`/`..`(链接目标解析用)。 */
|
|
656
|
+
function joinPosix(dir, rel) {
|
|
657
|
+
const parts = dir === "" ? [] : dir.split("/");
|
|
658
|
+
for (const seg of rel.split("/")) {
|
|
659
|
+
if (seg === "" || seg === ".") continue;
|
|
660
|
+
if (seg === "..") parts.pop();
|
|
661
|
+
else parts.push(seg);
|
|
662
|
+
}
|
|
663
|
+
return parts.join("/");
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* `[[链接目标]]` → vault 内笔记 rel 路径(与 Obsidian 解析语义对齐):
|
|
668
|
+
* 1) 精确的 vault 根相对路径;2) 相对当前笔记目录;3) 文件名匹配(多个取层数最浅,再按字母序)。
|
|
669
|
+
* 解析不到返回 null(该链接不产生连线,计入 unresolved)。
|
|
670
|
+
*/
|
|
671
|
+
function resolveGraphLink(target, fromRel, byPath, byBase) {
|
|
672
|
+
let raw = String(target == null ? "" : target).trim();
|
|
673
|
+
if (raw === "" || /^[a-z]+:\/\//i.test(raw)) return null;
|
|
674
|
+
raw = raw.replace(/\\/g, "/");
|
|
675
|
+
const stripExt = (value) => value.replace(/\.md$/i, "");
|
|
676
|
+
const tryPath = (value) => byPath.get(stripExt(value).replace(/^\.\//, "").toLowerCase()) ?? null;
|
|
677
|
+
let hit = tryPath(raw);
|
|
678
|
+
if (hit !== null) return hit;
|
|
679
|
+
if (raw.includes("/") || raw.startsWith(".")) {
|
|
680
|
+
const dir = fromRel.includes("/") ? fromRel.slice(0, fromRel.lastIndexOf("/")) : "";
|
|
681
|
+
hit = tryPath(joinPosix(dir, raw));
|
|
682
|
+
if (hit !== null) return hit;
|
|
683
|
+
}
|
|
684
|
+
const base = stripExt(raw.split("/").pop()).toLowerCase();
|
|
685
|
+
const list = byBase.get(base);
|
|
686
|
+
if (list === undefined || list.length === 0) return null;
|
|
687
|
+
if (list.length === 1) return list[0];
|
|
688
|
+
return [...list].sort((a, b) => (a.split("/").length - b.split("/").length) || a.localeCompare(b))[0];
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* 构建(或复用缓存的)关系图谱。缓存按 vault 根保存:
|
|
693
|
+
* 每篇笔记只记住 mtimeMs+size 与解析结果,文件没变就不重读、图没变就不重算。
|
|
694
|
+
* @param rootCanon - vault 根的 realpath。
|
|
695
|
+
* @returns `{ nodes, edges, stats }`:note/tag 节点与 link/tag 连线。
|
|
696
|
+
*/
|
|
697
|
+
async function buildGraph(rootCanon) {
|
|
698
|
+
const listed = await listNoteFiles(rootCanon);
|
|
699
|
+
let cache = graphCaches.get(rootCanon);
|
|
700
|
+
if (cache === undefined) {
|
|
701
|
+
cache = { files: new Map(), result: null };
|
|
702
|
+
graphCaches.set(rootCanon, cache);
|
|
703
|
+
while (graphCaches.size > GRAPH_CACHE_MAX_ROOTS) {
|
|
704
|
+
const oldest = graphCaches.keys().next().value;
|
|
705
|
+
if (oldest === rootCanon) break;
|
|
706
|
+
graphCaches.delete(oldest);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
const files = new Map();
|
|
710
|
+
let dirty = cache.result === null;
|
|
711
|
+
for (const item of listed) {
|
|
712
|
+
const prev = cache.files.get(item.rel);
|
|
713
|
+
if (prev !== undefined && prev.mtimeMs === item.mtimeMs && prev.size === item.size) {
|
|
714
|
+
files.set(item.rel, prev);
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
let parsed = { links: [], tags: [] };
|
|
718
|
+
if (item.size <= MAX_READ) {
|
|
719
|
+
try {
|
|
720
|
+
parsed = parseGraphNote(await readFile(join(rootCanon, item.rel), "utf8"));
|
|
721
|
+
} catch {
|
|
722
|
+
parsed = { links: [], tags: [] };
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
files.set(item.rel, { mtimeMs: item.mtimeMs, size: item.size, links: parsed.links, tags: parsed.tags });
|
|
726
|
+
dirty = true;
|
|
727
|
+
}
|
|
728
|
+
if (!dirty && cache.result !== null) {
|
|
729
|
+
cache.files = files;
|
|
730
|
+
return cache.result;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const byPath = new Map();
|
|
734
|
+
const byBase = new Map();
|
|
735
|
+
for (const rel of files.keys()) {
|
|
736
|
+
const noExt = rel.replace(/\.md$/i, "");
|
|
737
|
+
byPath.set(noExt.toLowerCase(), rel);
|
|
738
|
+
const base = basename(noExt).toLowerCase();
|
|
739
|
+
const list = byBase.get(base);
|
|
740
|
+
if (list === undefined) byBase.set(base, [rel]);
|
|
741
|
+
else list.push(rel);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const nodes = new Map();
|
|
745
|
+
for (const rel of files.keys()) {
|
|
746
|
+
nodes.set(`note:${rel}`, { id: `note:${rel}`, kind: "note", label: basename(rel, NOTE_EXT), path: rel });
|
|
747
|
+
}
|
|
748
|
+
const edges = [];
|
|
749
|
+
const seenEdges = new Set();
|
|
750
|
+
const addEdge = (source, target) => {
|
|
751
|
+
if (edges.length >= GRAPH_MAX_EDGES) return;
|
|
752
|
+
const key = `${source}\u0000${target}`;
|
|
753
|
+
if (seenEdges.has(key)) return;
|
|
754
|
+
seenEdges.add(key);
|
|
755
|
+
edges.push({ source, target });
|
|
756
|
+
};
|
|
757
|
+
const tagIds = new Map();
|
|
758
|
+
let unresolved = 0;
|
|
759
|
+
for (const [rel, data] of files) {
|
|
760
|
+
const self = `note:${rel}`;
|
|
761
|
+
for (const link of data.links) {
|
|
762
|
+
const target = resolveGraphLink(link, rel, byPath, byBase);
|
|
763
|
+
if (target === null) {
|
|
764
|
+
unresolved += 1;
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
if (target !== rel) addEdge(self, `note:${target}`);
|
|
768
|
+
}
|
|
769
|
+
for (const tag of data.tags) {
|
|
770
|
+
const key = tag.toLowerCase();
|
|
771
|
+
let tagId = tagIds.get(key);
|
|
772
|
+
if (tagId === undefined) {
|
|
773
|
+
if (tagIds.size >= GRAPH_TAG_MAX) continue;
|
|
774
|
+
tagId = `tag:${key}`;
|
|
775
|
+
tagIds.set(key, tagId);
|
|
776
|
+
nodes.set(tagId, { id: tagId, kind: "tag", label: `#${tag}` });
|
|
777
|
+
}
|
|
778
|
+
addEdge(self, tagId);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const result = {
|
|
782
|
+
nodes: [...nodes.values()],
|
|
783
|
+
edges,
|
|
784
|
+
stats: {
|
|
785
|
+
notes: files.size,
|
|
786
|
+
tags: tagIds.size,
|
|
787
|
+
edges: edges.length,
|
|
788
|
+
unresolved,
|
|
789
|
+
truncated: listed.length >= GRAPH_MAX_NOTES,
|
|
790
|
+
},
|
|
791
|
+
generatedAt: Date.now(),
|
|
792
|
+
};
|
|
793
|
+
cache.files = files;
|
|
794
|
+
cache.result = result;
|
|
795
|
+
return result;
|
|
796
|
+
}
|
|
797
|
+
|
|
572
798
|
/**
|
|
573
799
|
* 构造 /obsidian/* 的请求处理器。
|
|
574
800
|
* @param options - 可选依赖注入。
|
|
@@ -633,9 +859,9 @@ export function createHandler(options = {}) {
|
|
|
633
859
|
const st = await stat(currentCanon);
|
|
634
860
|
if (!st.isDirectory()) throw new Error("not a directory");
|
|
635
861
|
} catch {
|
|
636
|
-
return send(res, 200, { root: current, exists: false, attachmentDir });
|
|
862
|
+
return send(res, 200, { root: current, exists: false, attachmentDir, features: HOST_FEATURES });
|
|
637
863
|
}
|
|
638
|
-
return send(res, 200, { root: currentCanon, exists: true, ...attachSettings });
|
|
864
|
+
return send(res, 200, { root: currentCanon, exists: true, ...attachSettings, features: HOST_FEATURES });
|
|
639
865
|
}
|
|
640
866
|
|
|
641
867
|
// `/session-delete` is vault-independent: deleting a session's log has
|
|
@@ -977,6 +1203,12 @@ export function createHandler(options = {}) {
|
|
|
977
1203
|
});
|
|
978
1204
|
}
|
|
979
1205
|
|
|
1206
|
+
// ── 关系图谱:全库笔记 + [[双链]] + 标签(只读,按 mtime 增量缓存)──
|
|
1207
|
+
case "graph": {
|
|
1208
|
+
if (req.method !== "GET") return send(res, 405, { error: "method not allowed" });
|
|
1209
|
+
return send(res, 200, await buildGraph(rootCanon));
|
|
1210
|
+
}
|
|
1211
|
+
|
|
980
1212
|
// ── 目录树(懒加载;仅目录与 .md 笔记)────────────────────────
|
|
981
1213
|
case "tree": {
|
|
982
1214
|
const abs = await guarded(resolveInside());
|