obsidian-mcp-pro 2.0.0 → 3.0.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 +15 -7
- package/build/http-server.d.ts.map +1 -1
- package/build/http-server.js +68 -32
- package/build/http-server.js.map +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +22 -14
- package/build/index.js.map +1 -1
- package/build/install.d.ts.map +1 -1
- package/build/install.js +1 -1
- package/build/install.js.map +1 -1
- package/build/lib/bases.d.ts.map +1 -1
- package/build/lib/bases.js +26 -11
- package/build/lib/bases.js.map +1 -1
- package/build/lib/chunker.js +81 -2
- package/build/lib/chunker.js.map +1 -1
- package/build/lib/dates.d.ts +2 -0
- package/build/lib/dates.d.ts.map +1 -1
- package/build/lib/dates.js +24 -0
- package/build/lib/dates.js.map +1 -1
- package/build/lib/embedding-providers.js +1 -1
- package/build/lib/embedding-providers.js.map +1 -1
- package/build/lib/embedding-store.d.ts +1 -0
- package/build/lib/embedding-store.d.ts.map +1 -1
- package/build/lib/embedding-store.js +110 -18
- package/build/lib/embedding-store.js.map +1 -1
- package/build/lib/errors.d.ts.map +1 -1
- package/build/lib/errors.js +13 -1
- package/build/lib/errors.js.map +1 -1
- package/build/lib/fs-ops.d.ts +2 -0
- package/build/lib/fs-ops.d.ts.map +1 -0
- package/build/lib/fs-ops.js +26 -0
- package/build/lib/fs-ops.js.map +1 -0
- package/build/lib/index-cache.d.ts +10 -0
- package/build/lib/index-cache.d.ts.map +1 -1
- package/build/lib/index-cache.js +36 -10
- package/build/lib/index-cache.js.map +1 -1
- package/build/lib/link-rewriter.d.ts.map +1 -1
- package/build/lib/link-rewriter.js +35 -3
- package/build/lib/link-rewriter.js.map +1 -1
- package/build/lib/logger.d.ts.map +1 -1
- package/build/lib/logger.js +88 -7
- package/build/lib/logger.js.map +1 -1
- package/build/lib/markdown.js.map +1 -1
- package/build/lib/progress.js +1 -1
- package/build/lib/progress.js.map +1 -1
- package/build/lib/sections.d.ts.map +1 -1
- package/build/lib/sections.js +2 -0
- package/build/lib/sections.js.map +1 -1
- package/build/lib/tag-rewriter.d.ts +1 -0
- package/build/lib/tag-rewriter.d.ts.map +1 -1
- package/build/lib/tag-rewriter.js +6 -1
- package/build/lib/tag-rewriter.js.map +1 -1
- package/build/lib/tool-output.d.ts +2 -0
- package/build/lib/tool-output.d.ts.map +1 -0
- package/build/lib/tool-output.js +5 -0
- package/build/lib/tool-output.js.map +1 -0
- package/build/lib/vault.d.ts +32 -2
- package/build/lib/vault.d.ts.map +1 -1
- package/build/lib/vault.js +201 -91
- package/build/lib/vault.js.map +1 -1
- package/build/tools/attachments.d.ts.map +1 -1
- package/build/tools/attachments.js +117 -46
- package/build/tools/attachments.js.map +1 -1
- package/build/tools/bases.d.ts.map +1 -1
- package/build/tools/bases.js +14 -12
- package/build/tools/bases.js.map +1 -1
- package/build/tools/canvas.d.ts.map +1 -1
- package/build/tools/canvas.js +118 -59
- package/build/tools/canvas.js.map +1 -1
- package/build/tools/links.d.ts.map +1 -1
- package/build/tools/links.js +183 -124
- package/build/tools/links.js.map +1 -1
- package/build/tools/prompts.d.ts.map +1 -1
- package/build/tools/prompts.js +14 -12
- package/build/tools/prompts.js.map +1 -1
- package/build/tools/read.d.ts.map +1 -1
- package/build/tools/read.js +105 -87
- package/build/tools/read.js.map +1 -1
- package/build/tools/sections.d.ts.map +1 -1
- package/build/tools/sections.js +84 -20
- package/build/tools/sections.js.map +1 -1
- package/build/tools/semantic.d.ts.map +1 -1
- package/build/tools/semantic.js +45 -49
- package/build/tools/semantic.js.map +1 -1
- package/build/tools/tags.d.ts.map +1 -1
- package/build/tools/tags.js +100 -56
- package/build/tools/tags.js.map +1 -1
- package/build/tools/write.d.ts.map +1 -1
- package/build/tools/write.js +27 -23
- package/build/tools/write.js.map +1 -1
- package/package.json +22 -13
package/build/tools/links.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
+
import path from "path";
|
|
1
2
|
import { z } from "zod";
|
|
2
|
-
import { listNotes, getNoteStats } from "../lib/vault.js";
|
|
3
|
+
import { listNotes, getNoteStats, getVaultRootRealPath } from "../lib/vault.js";
|
|
3
4
|
import { readAllCached } from "../lib/index-cache.js";
|
|
4
|
-
import { extractWikilinks,
|
|
5
|
-
import { sanitizeError } from "../lib/errors.js";
|
|
5
|
+
import { extractWikilinks, extractAliases } from "../lib/markdown.js";
|
|
6
|
+
import { escapeControlChars, sanitizeError } from "../lib/errors.js";
|
|
6
7
|
import { log } from "../lib/logger.js";
|
|
7
8
|
const GRAPH_CACHE_TTL_MS = 30_000;
|
|
8
9
|
const GRAPH_CACHE_MAX_ENTRIES = 32;
|
|
10
|
+
const GRAPH_FINGERPRINT_CONCURRENCY = 64;
|
|
11
|
+
function displayLinkValue(value) {
|
|
12
|
+
return escapeControlChars(value);
|
|
13
|
+
}
|
|
9
14
|
const graphCache = new Map();
|
|
10
15
|
// Map iteration order = insertion order; delete+set to refresh recency.
|
|
11
16
|
function setGraphCache(key, entry) {
|
|
@@ -19,26 +24,122 @@ function setGraphCache(key, entry) {
|
|
|
19
24
|
graphCache.delete(oldest);
|
|
20
25
|
}
|
|
21
26
|
}
|
|
22
|
-
|
|
27
|
+
function fingerprintFromMtimes(notes, mtimes) {
|
|
23
28
|
// Accumulate a 32-bit FNV-1a hash over "<sortedPath>|<mtimeMs>;" per note.
|
|
24
29
|
// Catches add+delete churn and mtime-restoring edits that count+max-mtime
|
|
25
30
|
// alone would miss.
|
|
26
31
|
const sorted = [...notes].sort();
|
|
27
32
|
let hash = 0x811c9dc5;
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
33
|
+
for (const note of sorted) {
|
|
34
|
+
const mtime = mtimes.get(note) ?? 0;
|
|
35
|
+
const entry = `${note}|${mtime};`;
|
|
36
|
+
for (let k = 0; k < entry.length; k++) {
|
|
37
|
+
hash ^= entry.charCodeAt(k);
|
|
38
|
+
hash = Math.imul(hash, 0x01000193);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return `${sorted.length}:${(hash >>> 0).toString(16)}`;
|
|
42
|
+
}
|
|
43
|
+
async function fingerprintVault(vaultPath, notes) {
|
|
44
|
+
const mtimes = new Map();
|
|
45
|
+
const sorted = [...notes].sort();
|
|
46
|
+
const realVaultRoot = await getVaultRootRealPath(vaultPath);
|
|
47
|
+
for (let i = 0; i < sorted.length; i += GRAPH_FINGERPRINT_CONCURRENCY) {
|
|
48
|
+
const slice = sorted.slice(i, i + GRAPH_FINGERPRINT_CONCURRENCY);
|
|
49
|
+
const stats = await Promise.all(slice.map((n) => getNoteStats(vaultPath, n, { realVaultRoot }).catch(() => null)));
|
|
32
50
|
for (let j = 0; j < slice.length; j++) {
|
|
33
51
|
const mtime = stats[j]?.modified?.getTime() ?? 0;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
52
|
+
mtimes.set(slice[j], mtime);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return fingerprintFromMtimes(sorted, mtimes);
|
|
56
|
+
}
|
|
57
|
+
function buildSourceLookup(notes) {
|
|
58
|
+
const lookup = new Map();
|
|
59
|
+
for (const notePath of notes) {
|
|
60
|
+
const normalized = notePath.replace(/\.md$/i, "").toLowerCase();
|
|
61
|
+
const exactKey = `exact:${normalized}`;
|
|
62
|
+
if (!lookup.has(exactKey))
|
|
63
|
+
lookup.set(exactKey, notePath);
|
|
64
|
+
const basename = normalized.split("/").pop() ?? normalized;
|
|
65
|
+
if (!lookup.has(`base:${basename}`)) {
|
|
66
|
+
lookup.set(`base:${basename}`, notePath);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return lookup;
|
|
70
|
+
}
|
|
71
|
+
function sharedPathDepth(a, b) {
|
|
72
|
+
const as = a.toLowerCase().split("/");
|
|
73
|
+
const bs = b.toLowerCase().split("/");
|
|
74
|
+
let i = 0;
|
|
75
|
+
const max = Math.min(as.length, bs.length);
|
|
76
|
+
while (i < max && as[i] === bs[i])
|
|
77
|
+
i++;
|
|
78
|
+
return i;
|
|
79
|
+
}
|
|
80
|
+
function buildNotePathIndex(notes) {
|
|
81
|
+
const exact = new Map();
|
|
82
|
+
const basename = new Map();
|
|
83
|
+
for (const notePath of notes) {
|
|
84
|
+
const normalized = notePath.replace(/\.md$/i, "").toLowerCase();
|
|
85
|
+
if (!exact.has(normalized))
|
|
86
|
+
exact.set(normalized, notePath);
|
|
87
|
+
const noteBasename = path
|
|
88
|
+
.basename(notePath, path.extname(notePath))
|
|
89
|
+
.toLowerCase();
|
|
90
|
+
const matches = basename.get(noteBasename);
|
|
91
|
+
if (matches) {
|
|
92
|
+
matches.push(notePath);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
basename.set(noteBasename, [notePath]);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { exact, basename };
|
|
99
|
+
}
|
|
100
|
+
function resolveWikilinkWithIndex(link, currentNotePath, allNotePaths, index, aliasMap) {
|
|
101
|
+
const cleanLink = link.split("#")[0].split("^")[0].trim();
|
|
102
|
+
if (!cleanLink)
|
|
103
|
+
return null;
|
|
104
|
+
const normalizedLink = cleanLink.replace(/\.md$/i, "");
|
|
105
|
+
const normalizedLinkLower = normalizedLink.toLowerCase();
|
|
106
|
+
const exact = index.exact.get(normalizedLinkLower);
|
|
107
|
+
if (exact)
|
|
108
|
+
return exact;
|
|
109
|
+
if (normalizedLink.includes("/")) {
|
|
110
|
+
const suffixCandidates = [];
|
|
111
|
+
for (const notePath of allNotePaths) {
|
|
112
|
+
const withoutExt = notePath.replace(/\.md$/i, "").toLowerCase();
|
|
113
|
+
if (withoutExt.endsWith(normalizedLinkLower)) {
|
|
114
|
+
const prefix = withoutExt.slice(0, withoutExt.length - normalizedLinkLower.length);
|
|
115
|
+
if (prefix === "" || prefix.endsWith("/"))
|
|
116
|
+
suffixCandidates.push(notePath);
|
|
38
117
|
}
|
|
39
118
|
}
|
|
119
|
+
if (suffixCandidates.length === 1)
|
|
120
|
+
return suffixCandidates[0];
|
|
121
|
+
if (suffixCandidates.length > 1) {
|
|
122
|
+
return nearestNotePath(currentNotePath, suffixCandidates);
|
|
123
|
+
}
|
|
40
124
|
}
|
|
41
|
-
|
|
125
|
+
const linkBasename = path.basename(normalizedLink).toLowerCase();
|
|
126
|
+
const basenameCandidates = index.basename.get(linkBasename) ?? [];
|
|
127
|
+
if (basenameCandidates.length === 1)
|
|
128
|
+
return basenameCandidates[0];
|
|
129
|
+
if (basenameCandidates.length > 1) {
|
|
130
|
+
return nearestNotePath(currentNotePath, basenameCandidates);
|
|
131
|
+
}
|
|
132
|
+
return aliasMap.get(normalizedLinkLower) ?? null;
|
|
133
|
+
}
|
|
134
|
+
function nearestNotePath(currentNotePath, candidates) {
|
|
135
|
+
const sourceDir = path.dirname(currentNotePath).replace(/\\/g, "/");
|
|
136
|
+
return [...candidates].sort((a, b) => {
|
|
137
|
+
const da = sharedPathDepth(sourceDir, path.dirname(a).replace(/\\/g, "/"));
|
|
138
|
+
const db = sharedPathDepth(sourceDir, path.dirname(b).replace(/\\/g, "/"));
|
|
139
|
+
if (da !== db)
|
|
140
|
+
return db - da;
|
|
141
|
+
return a.length - b.length;
|
|
142
|
+
})[0];
|
|
42
143
|
}
|
|
43
144
|
async function buildLinkGraph(vaultPath, folder) {
|
|
44
145
|
const cacheKey = `${vaultPath}::${folder ?? ""}`;
|
|
@@ -56,6 +157,8 @@ async function buildLinkGraph(vaultPath, folder) {
|
|
|
56
157
|
const backlinks = new Map();
|
|
57
158
|
const rawLinks = new Map();
|
|
58
159
|
const noteLines = new Map();
|
|
160
|
+
const brokenLinks = new Map();
|
|
161
|
+
const outlinkDetails = new Map();
|
|
59
162
|
// Initialize sets for all notes
|
|
60
163
|
for (const notePath of allNotes) {
|
|
61
164
|
outlinks.set(notePath, new Set());
|
|
@@ -63,7 +166,7 @@ async function buildLinkGraph(vaultPath, folder) {
|
|
|
63
166
|
}
|
|
64
167
|
// Read notes via the shared mtime cache so repeated graph builds (and
|
|
65
168
|
// overlapping search_notes / get_tags scans) skip re-reads.
|
|
66
|
-
const { contents: noteContents } = await readAllCached(vaultPath, allNotes, (note, err) => {
|
|
169
|
+
const { contents: noteContents, mtimes } = await readAllCached(vaultPath, allNotes, (note, err) => {
|
|
67
170
|
log.warn("link graph: note read failed", { note, err });
|
|
68
171
|
});
|
|
69
172
|
// Build alias map first so any note can link to any other by alias
|
|
@@ -81,16 +184,18 @@ async function buildLinkGraph(vaultPath, folder) {
|
|
|
81
184
|
continue;
|
|
82
185
|
const prior = aliasMap.get(key);
|
|
83
186
|
if (prior && prior !== notePath) {
|
|
84
|
-
log.warn("Duplicate alias", { alias,
|
|
187
|
+
log.warn("Duplicate alias", { alias, notes: [prior, notePath] });
|
|
85
188
|
}
|
|
86
189
|
aliasMap.set(key, notePath);
|
|
87
190
|
}
|
|
88
191
|
}
|
|
192
|
+
const pathIndex = buildNotePathIndex(allNotes);
|
|
89
193
|
for (const notePath of allNotes) {
|
|
90
194
|
const content = noteContents.get(notePath);
|
|
91
195
|
if (content === undefined)
|
|
92
196
|
continue;
|
|
93
|
-
|
|
197
|
+
const lines = content.split("\n");
|
|
198
|
+
noteLines.set(notePath, lines);
|
|
94
199
|
const links = extractWikilinks(content);
|
|
95
200
|
// Fill in source for each link
|
|
96
201
|
for (const link of links) {
|
|
@@ -98,12 +203,19 @@ async function buildLinkGraph(vaultPath, folder) {
|
|
|
98
203
|
}
|
|
99
204
|
rawLinks.set(notePath, links);
|
|
100
205
|
const outSet = outlinks.get(notePath) ?? new Set();
|
|
206
|
+
const details = [];
|
|
101
207
|
for (const link of links) {
|
|
102
208
|
// Strip heading/block refs for resolution (e.g., "note#heading" -> "note")
|
|
103
209
|
const targetBase = link.target.split("#")[0].trim();
|
|
104
210
|
if (!targetBase)
|
|
105
211
|
continue;
|
|
106
|
-
const resolved =
|
|
212
|
+
const resolved = resolveWikilinkWithIndex(targetBase, notePath, allNotes, pathIndex, aliasMap);
|
|
213
|
+
details.push({
|
|
214
|
+
target: link.target,
|
|
215
|
+
resolvedPath: resolved,
|
|
216
|
+
isValid: resolved !== null,
|
|
217
|
+
isEmbed: link.isEmbed,
|
|
218
|
+
});
|
|
107
219
|
if (resolved) {
|
|
108
220
|
outSet.add(resolved);
|
|
109
221
|
// Ensure backlinks set exists for target
|
|
@@ -112,8 +224,21 @@ async function buildLinkGraph(vaultPath, folder) {
|
|
|
112
224
|
}
|
|
113
225
|
backlinks.get(resolved).add(notePath);
|
|
114
226
|
}
|
|
227
|
+
else {
|
|
228
|
+
const lineInfo = findLineWithLink(lines, link.target);
|
|
229
|
+
const broken = {
|
|
230
|
+
sourcePath: notePath,
|
|
231
|
+
targetLink: link.target,
|
|
232
|
+
line: lineInfo.line,
|
|
233
|
+
};
|
|
234
|
+
if (!brokenLinks.has(notePath)) {
|
|
235
|
+
brokenLinks.set(notePath, []);
|
|
236
|
+
}
|
|
237
|
+
brokenLinks.get(notePath).push(broken);
|
|
238
|
+
}
|
|
115
239
|
}
|
|
116
240
|
outlinks.set(notePath, outSet);
|
|
241
|
+
outlinkDetails.set(notePath, details);
|
|
117
242
|
}
|
|
118
243
|
const data = {
|
|
119
244
|
allNotes,
|
|
@@ -122,8 +247,12 @@ async function buildLinkGraph(vaultPath, folder) {
|
|
|
122
247
|
rawLinks,
|
|
123
248
|
noteLines,
|
|
124
249
|
aliasMap,
|
|
250
|
+
brokenLinks,
|
|
251
|
+
sourceLookup: buildSourceLookup(allNotes),
|
|
252
|
+
pathIndex,
|
|
253
|
+
outlinkDetails,
|
|
125
254
|
};
|
|
126
|
-
const fingerprint =
|
|
255
|
+
const fingerprint = fingerprintFromMtimes(allNotes, mtimes);
|
|
127
256
|
setGraphCache(cacheKey, { data, fingerprint, cachedAt: Date.now() });
|
|
128
257
|
return data;
|
|
129
258
|
}
|
|
@@ -212,7 +341,7 @@ export function registerLinkTools(server, vaultPath) {
|
|
|
212
341
|
}
|
|
213
342
|
}
|
|
214
343
|
if (!resolvedTarget) {
|
|
215
|
-
return errorResult(`No note found matching path: ${targetPath}`);
|
|
344
|
+
return errorResult(`No note found matching path: ${displayLinkValue(targetPath)}`);
|
|
216
345
|
}
|
|
217
346
|
const backlinkSources = graph.backlinks.get(resolvedTarget);
|
|
218
347
|
if (!backlinkSources || backlinkSources.size === 0) {
|
|
@@ -220,7 +349,7 @@ export function registerLinkTools(server, vaultPath) {
|
|
|
220
349
|
content: [
|
|
221
350
|
{
|
|
222
351
|
type: "text",
|
|
223
|
-
text: `No backlinks found for: ${resolvedTarget}`,
|
|
352
|
+
text: `No backlinks found for: ${displayLinkValue(resolvedTarget)}`,
|
|
224
353
|
},
|
|
225
354
|
],
|
|
226
355
|
};
|
|
@@ -237,9 +366,7 @@ export function registerLinkTools(server, vaultPath) {
|
|
|
237
366
|
// resolve here exactly as they did during graph build. Without
|
|
238
367
|
// it, the source slipped into the backlink set during build but
|
|
239
368
|
// produced an empty line/context in this display pass.
|
|
240
|
-
const resolved =
|
|
241
|
-
aliasMap: graph.aliasMap,
|
|
242
|
-
});
|
|
369
|
+
const resolved = resolveWikilinkWithIndex(base, sourcePath, graph.allNotes, graph.pathIndex, graph.aliasMap);
|
|
243
370
|
return resolved === resolvedTarget;
|
|
244
371
|
});
|
|
245
372
|
if (relevantLinks.length > 0) {
|
|
@@ -266,12 +393,12 @@ export function registerLinkTools(server, vaultPath) {
|
|
|
266
393
|
return true;
|
|
267
394
|
});
|
|
268
395
|
const output = [
|
|
269
|
-
`Backlinks to: ${resolvedTarget}`,
|
|
396
|
+
`Backlinks to: ${displayLinkValue(resolvedTarget)}`,
|
|
270
397
|
`Found: ${deduped.length} backlink(s)\n`,
|
|
271
398
|
...deduped.map((r) => {
|
|
272
399
|
const lineStr = r.line > 0 ? `:${r.line}` : "";
|
|
273
|
-
const contextStr = r.context ? ` → ${r.context}` : "";
|
|
274
|
-
return `- ${r.source}${lineStr}${contextStr}`;
|
|
400
|
+
const contextStr = r.context ? ` → ${displayLinkValue(r.context)}` : "";
|
|
401
|
+
return `- ${displayLinkValue(r.source)}${lineStr}${contextStr}`;
|
|
275
402
|
}),
|
|
276
403
|
].join("\n");
|
|
277
404
|
return { content: [{ type: "text", text: output }] };
|
|
@@ -309,47 +436,19 @@ export function registerLinkTools(server, vaultPath) {
|
|
|
309
436
|
// get_backlinks does).
|
|
310
437
|
const targetNormalized = notePath.replace(/\.md$/i, "").toLowerCase();
|
|
311
438
|
const targetBasename = targetNormalized.split("/").pop() ?? targetNormalized;
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
resolvedSource = p;
|
|
316
|
-
break;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
439
|
+
const resolvedSource = graph.sourceLookup.get(`exact:${targetNormalized}`) ??
|
|
440
|
+
graph.sourceLookup.get(`base:${targetBasename}`) ??
|
|
441
|
+
null;
|
|
319
442
|
if (!resolvedSource) {
|
|
320
|
-
|
|
321
|
-
const base = p.replace(/\.md$/i, "").split("/").pop()?.toLowerCase();
|
|
322
|
-
if (base === targetBasename) {
|
|
323
|
-
resolvedSource = p;
|
|
324
|
-
break;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
if (!resolvedSource) {
|
|
329
|
-
return errorResult(`No note found matching path: ${notePath}`);
|
|
330
|
-
}
|
|
331
|
-
const links = graph.rawLinks.get(resolvedSource) ?? [];
|
|
332
|
-
const results = [];
|
|
333
|
-
for (const link of links) {
|
|
334
|
-
const targetBase = link.target.split("#")[0].trim();
|
|
335
|
-
if (!targetBase)
|
|
336
|
-
continue;
|
|
337
|
-
const resolved = resolveWikilink(targetBase, resolvedSource, graph.allNotes, {
|
|
338
|
-
aliasMap: graph.aliasMap,
|
|
339
|
-
});
|
|
340
|
-
results.push({
|
|
341
|
-
target: link.target,
|
|
342
|
-
resolvedPath: resolved,
|
|
343
|
-
isValid: resolved !== null,
|
|
344
|
-
isEmbed: link.isEmbed,
|
|
345
|
-
});
|
|
443
|
+
return errorResult(`No note found matching path: ${displayLinkValue(notePath)}`);
|
|
346
444
|
}
|
|
445
|
+
const results = graph.outlinkDetails.get(resolvedSource) ?? [];
|
|
347
446
|
if (results.length === 0) {
|
|
348
447
|
return {
|
|
349
448
|
content: [
|
|
350
449
|
{
|
|
351
450
|
type: "text",
|
|
352
|
-
text: `No outgoing links found in: ${resolvedSource}`,
|
|
451
|
+
text: `No outgoing links found in: ${displayLinkValue(resolvedSource)}`,
|
|
353
452
|
},
|
|
354
453
|
],
|
|
355
454
|
};
|
|
@@ -357,21 +456,21 @@ export function registerLinkTools(server, vaultPath) {
|
|
|
357
456
|
const valid = results.filter((r) => r.isValid);
|
|
358
457
|
const broken = results.filter((r) => !r.isValid);
|
|
359
458
|
const lines = [
|
|
360
|
-
`Outgoing links from: ${resolvedSource}`,
|
|
459
|
+
`Outgoing links from: ${displayLinkValue(resolvedSource)}`,
|
|
361
460
|
`Total: ${results.length} (${valid.length} valid, ${broken.length} broken)\n`,
|
|
362
461
|
];
|
|
363
462
|
if (valid.length > 0) {
|
|
364
463
|
lines.push("Valid links:");
|
|
365
464
|
for (const r of valid) {
|
|
366
465
|
const embedPrefix = r.isEmbed ? "📎 " : "";
|
|
367
|
-
lines.push(` ${embedPrefix}[[${r.target}]] → ${r.resolvedPath}`);
|
|
466
|
+
lines.push(` ${embedPrefix}[[${displayLinkValue(r.target)}]] → ${displayLinkValue(r.resolvedPath ?? "")}`);
|
|
368
467
|
}
|
|
369
468
|
}
|
|
370
469
|
if (broken.length > 0) {
|
|
371
470
|
lines.push("\nBroken links:");
|
|
372
471
|
for (const r of broken) {
|
|
373
472
|
const embedPrefix = r.isEmbed ? "📎 " : "";
|
|
374
|
-
lines.push(` ${embedPrefix}[[${r.target}]] → (not found)`);
|
|
473
|
+
lines.push(` ${embedPrefix}[[${displayLinkValue(r.target)}]] → (not found)`);
|
|
375
474
|
}
|
|
376
475
|
}
|
|
377
476
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
@@ -436,14 +535,14 @@ export function registerLinkTools(server, vaultPath) {
|
|
|
436
535
|
];
|
|
437
536
|
lines.push(`Fully isolated (no links in or out): ${fullyIsolated.length}`);
|
|
438
537
|
for (const note of cappedIsolated) {
|
|
439
|
-
lines.push(` - ${note.path}`);
|
|
538
|
+
lines.push(` - ${displayLinkValue(note.path)}`);
|
|
440
539
|
}
|
|
441
540
|
if (cappedIsolated.length < fullyIsolated.length) {
|
|
442
541
|
lines.push(` ... and ${fullyIsolated.length - cappedIsolated.length} more`);
|
|
443
542
|
}
|
|
444
543
|
lines.push(`\nNo backlinks (not linked by any note): ${noBacklinks.length}`);
|
|
445
544
|
for (const note of cappedNoBacklinks) {
|
|
446
|
-
lines.push(` - ${note.path}`);
|
|
545
|
+
lines.push(` - ${displayLinkValue(note.path)}`);
|
|
447
546
|
}
|
|
448
547
|
if (cappedNoBacklinks.length < noBacklinks.length) {
|
|
449
548
|
lines.push(` ... and ${noBacklinks.length - cappedNoBacklinks.length} more`);
|
|
@@ -451,7 +550,7 @@ export function registerLinkTools(server, vaultPath) {
|
|
|
451
550
|
if (includeOutlinksCheck) {
|
|
452
551
|
lines.push(`\nNo outlinks (links to no other notes): ${noOutlinks.length}`);
|
|
453
552
|
for (const note of cappedNoOutlinks) {
|
|
454
|
-
lines.push(` - ${note.path}`);
|
|
553
|
+
lines.push(` - ${displayLinkValue(note.path)}`);
|
|
455
554
|
}
|
|
456
555
|
if (cappedNoOutlinks.length < noOutlinks.length) {
|
|
457
556
|
lines.push(` ... and ${noOutlinks.length - cappedNoOutlinks.length} more`);
|
|
@@ -492,60 +591,20 @@ export function registerLinkTools(server, vaultPath) {
|
|
|
492
591
|
},
|
|
493
592
|
}, async ({ folder, maxResults }) => {
|
|
494
593
|
try {
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
const allNotes = await listNotes(vaultPath);
|
|
501
|
-
const scanNotes = folder ? await listNotes(vaultPath, folder) : allNotes;
|
|
502
|
-
// Build the alias map from the full vault so cross-folder alias
|
|
503
|
-
// resolution still works when scoping to a single folder.
|
|
504
|
-
const { contents: allContents } = await readAllCached(vaultPath, allNotes, (note, err) => {
|
|
505
|
-
log.warn("find_broken_links: note read failed", { note, err });
|
|
506
|
-
});
|
|
507
|
-
const aliasMap = new Map();
|
|
508
|
-
for (const notePath of allNotes) {
|
|
509
|
-
const content = allContents.get(notePath);
|
|
510
|
-
if (content === undefined)
|
|
511
|
-
continue;
|
|
512
|
-
for (const alias of extractAliases(content)) {
|
|
513
|
-
const key = alias.toLowerCase();
|
|
514
|
-
if (!key)
|
|
515
|
-
continue;
|
|
516
|
-
aliasMap.set(key, notePath);
|
|
517
|
-
}
|
|
518
|
-
}
|
|
594
|
+
// Validate and capture folder scope before the whole-vault graph work
|
|
595
|
+
// so a missing folder fails fast. Resolution still uses the full graph.
|
|
596
|
+
const scanNotes = folder ? await listNotes(vaultPath, folder) : null;
|
|
597
|
+
const graph = await buildLinkGraph(vaultPath);
|
|
598
|
+
const notesToScan = scanNotes ?? graph.allNotes;
|
|
519
599
|
const brokenBySource = new Map();
|
|
520
|
-
for (const notePath of
|
|
521
|
-
const
|
|
522
|
-
if (
|
|
600
|
+
for (const notePath of notesToScan) {
|
|
601
|
+
const brokenLinks = graph.brokenLinks.get(notePath);
|
|
602
|
+
if (!brokenLinks || brokenLinks.length === 0)
|
|
523
603
|
continue;
|
|
524
|
-
|
|
525
|
-
const links = extractWikilinks(content);
|
|
526
|
-
for (const link of links) {
|
|
527
|
-
const targetBase = link.target.split("#")[0].trim();
|
|
528
|
-
if (!targetBase)
|
|
529
|
-
continue;
|
|
530
|
-
const resolved = resolveWikilink(targetBase, notePath, allNotes, {
|
|
531
|
-
aliasMap,
|
|
532
|
-
});
|
|
533
|
-
if (!resolved) {
|
|
534
|
-
const lineInfo = findLineWithLink(lines, link.target);
|
|
535
|
-
const broken = {
|
|
536
|
-
sourcePath: notePath,
|
|
537
|
-
targetLink: link.target,
|
|
538
|
-
line: lineInfo.line,
|
|
539
|
-
};
|
|
540
|
-
if (!brokenBySource.has(notePath)) {
|
|
541
|
-
brokenBySource.set(notePath, []);
|
|
542
|
-
}
|
|
543
|
-
brokenBySource.get(notePath).push(broken);
|
|
544
|
-
}
|
|
545
|
-
}
|
|
604
|
+
brokenBySource.set(notePath, brokenLinks);
|
|
546
605
|
}
|
|
547
606
|
if (brokenBySource.size === 0) {
|
|
548
|
-
const scopeStr = folder ? ` in folder: ${folder}` : "";
|
|
607
|
+
const scopeStr = folder ? ` in folder: ${displayLinkValue(folder)}` : "";
|
|
549
608
|
return {
|
|
550
609
|
content: [
|
|
551
610
|
{
|
|
@@ -560,18 +619,18 @@ export function registerLinkTools(server, vaultPath) {
|
|
|
560
619
|
totalBroken += brokenLinks.length;
|
|
561
620
|
}
|
|
562
621
|
const lines = [];
|
|
563
|
-
const scopeStr = folder ? ` (folder: ${folder})` : "";
|
|
622
|
+
const scopeStr = folder ? ` (folder: ${displayLinkValue(folder)})` : "";
|
|
564
623
|
lines.push(`Broken links report${scopeStr}\n`);
|
|
565
624
|
let shown = 0;
|
|
566
625
|
for (const [sourcePath, brokenLinks] of brokenBySource) {
|
|
567
626
|
if (shown >= maxResults)
|
|
568
627
|
break;
|
|
569
|
-
lines.push(`${sourcePath}:`);
|
|
628
|
+
lines.push(`${displayLinkValue(sourcePath)}:`);
|
|
570
629
|
for (const bl of brokenLinks) {
|
|
571
630
|
if (shown >= maxResults)
|
|
572
631
|
break;
|
|
573
632
|
const lineStr = bl.line > 0 ? ` (line ${bl.line})` : "";
|
|
574
|
-
lines.push(` - [[${bl.targetLink}]]${lineStr}`);
|
|
633
|
+
lines.push(` - [[${displayLinkValue(bl.targetLink)}]]${lineStr}`);
|
|
575
634
|
shown++;
|
|
576
635
|
}
|
|
577
636
|
lines.push("");
|
|
@@ -653,7 +712,7 @@ export function registerLinkTools(server, vaultPath) {
|
|
|
653
712
|
}
|
|
654
713
|
}
|
|
655
714
|
if (!resolvedStart) {
|
|
656
|
-
return errorResult(`No note found matching path: ${startPath}`);
|
|
715
|
+
return errorResult(`No note found matching path: ${displayLinkValue(startPath)}`);
|
|
657
716
|
}
|
|
658
717
|
// BFS traversal with maxResults cap to prevent explosion at higher depths
|
|
659
718
|
const visited = new Map();
|
|
@@ -715,7 +774,7 @@ export function registerLinkTools(server, vaultPath) {
|
|
|
715
774
|
content: [
|
|
716
775
|
{
|
|
717
776
|
type: "text",
|
|
718
|
-
text: `No neighbors found for: ${resolvedStart} (depth: ${depth}, direction: ${direction})`,
|
|
777
|
+
text: `No neighbors found for: ${displayLinkValue(resolvedStart)} (depth: ${depth}, direction: ${direction})`,
|
|
719
778
|
},
|
|
720
779
|
],
|
|
721
780
|
};
|
|
@@ -730,9 +789,9 @@ export function registerLinkTools(server, vaultPath) {
|
|
|
730
789
|
}
|
|
731
790
|
const truncatedStr = truncated ? " (TRUNCATED)" : "";
|
|
732
791
|
const lines = [
|
|
733
|
-
`Graph neighbors of: ${resolvedStart}`,
|
|
792
|
+
`Graph neighbors of: ${displayLinkValue(resolvedStart)}`,
|
|
734
793
|
`Direction: ${direction} | Max depth: ${depth} | Found: ${visited.size} note(s)${truncatedStr}\n`,
|
|
735
|
-
resolvedStart,
|
|
794
|
+
displayLinkValue(resolvedStart),
|
|
736
795
|
];
|
|
737
796
|
const sortedDepths = [...byDepth.keys()].sort((a, b) => a - b);
|
|
738
797
|
for (const d of sortedDepths) {
|
|
@@ -745,7 +804,7 @@ export function registerLinkTools(server, vaultPath) {
|
|
|
745
804
|
: neighbor.direction === "outbound"
|
|
746
805
|
? "→"
|
|
747
806
|
: "↔";
|
|
748
|
-
lines.push(`${indent}${arrow} ${neighbor.path} (depth ${d})`);
|
|
807
|
+
lines.push(`${indent}${arrow} ${displayLinkValue(neighbor.path)} (depth ${d})`);
|
|
749
808
|
}
|
|
750
809
|
}
|
|
751
810
|
if (truncated) {
|