@openparachute/vault 0.7.0-rc.8 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/src/aggregate.test.ts +260 -0
- package/core/src/contract-taxonomy.test.ts +26 -2
- package/core/src/contract-typed-index.test.ts +4 -2
- package/core/src/core.test.ts +1151 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/doctor.ts +23 -1
- package/core/src/indexed-fields.test.ts +4 -1
- package/core/src/indexed-fields.ts +6 -1
- package/core/src/links.ts +22 -0
- package/core/src/mcp.ts +587 -79
- package/core/src/notes.ts +371 -18
- package/core/src/query-warnings.ts +60 -12
- package/core/src/schema-defaults.ts +7 -1
- package/core/src/seed-packs.test.ts +14 -0
- package/core/src/seed-packs.ts +42 -5
- package/core/src/store.ts +129 -5
- package/core/src/tag-schemas.ts +20 -7
- package/core/src/types.ts +98 -0
- package/core/src/ulid.test.ts +56 -0
- package/core/src/ulid.ts +116 -0
- package/core/src/vault-projection.ts +19 -1
- package/core/src/wikilinks.test.ts +205 -1
- package/core/src/wikilinks.ts +244 -35
- package/package.json +1 -1
- package/src/aggregate-routes.test.ts +230 -0
- package/src/contract-errors.test.ts +2 -1
- package/src/contract-search.test.ts +17 -0
- package/src/mcp-link-warnings-scope.test.ts +144 -0
- package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
- package/src/mcp-tools.ts +100 -19
- package/src/routes.ts +469 -39
- package/src/routing.test.ts +167 -0
- package/src/routing.ts +47 -11
- package/src/tag-integrity-mcp.test.ts +45 -6
- package/src/tag-scope.ts +10 -1
- package/src/vault.test.ts +923 -21
- package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
- package/web/ui/dist/index.html +1 -1
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
2
|
import { Database } from "bun:sqlite";
|
|
3
3
|
import { SqliteStore } from "./store.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
parseWikilinks,
|
|
6
|
+
syncWikilinks,
|
|
7
|
+
resolveWikilink,
|
|
8
|
+
resolveWikilinkDetailed,
|
|
9
|
+
resolveUnresolvedWikilinks,
|
|
10
|
+
listUnresolvedWikilinks,
|
|
11
|
+
getContentWikilinkWarnings,
|
|
12
|
+
resolveOrQueueLink,
|
|
13
|
+
} from "./wikilinks.js";
|
|
5
14
|
|
|
6
15
|
let store: SqliteStore;
|
|
7
16
|
let db: Database;
|
|
@@ -142,6 +151,81 @@ describe("resolveWikilink", async () => {
|
|
|
142
151
|
});
|
|
143
152
|
});
|
|
144
153
|
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Title fallback (additive — id/path/basename still win first)
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
describe("resolveWikilink — title fallback", async () => {
|
|
159
|
+
it("resolves via H1 title when path/basename both miss", async () => {
|
|
160
|
+
const note = await store.createNote("# My Real Title\n\nSome body text.", { path: "Inbox/2026-07-10-abc123" });
|
|
161
|
+
const id = resolveWikilink(db, "My Real Title");
|
|
162
|
+
expect(id).toBe(note.id);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("matches the H1 title case-insensitively", async () => {
|
|
166
|
+
const note = await store.createNote("# My Real Title\n\nBody.", { path: "Inbox/xyz" });
|
|
167
|
+
const id = resolveWikilink(db, "my real title");
|
|
168
|
+
expect(id).toBe(note.id);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("exact path match wins over a title match on a DIFFERENT note", async () => {
|
|
172
|
+
// A note literally path-named "My Real Title" ...
|
|
173
|
+
const byPath = await store.createNote("Path note body", { path: "My Real Title" });
|
|
174
|
+
// ... and an unrelated note whose H1 happens to be the same string.
|
|
175
|
+
await store.createNote("# My Real Title\n\nOther body.", { path: "Inbox/other" });
|
|
176
|
+
const id = resolveWikilink(db, "My Real Title");
|
|
177
|
+
expect(id).toBe(byPath.id);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("basename match wins over title fallback", async () => {
|
|
181
|
+
const byBasename = await store.createNote("Body", { path: "Projects/Parachute/README" });
|
|
182
|
+
await store.createNote("# README\n\nOther body.", { path: "Inbox/other-readme" });
|
|
183
|
+
const id = resolveWikilink(db, "README");
|
|
184
|
+
expect(id).toBe(byBasename.id);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("stays unresolved when two notes share the same H1 title (ambiguous)", async () => {
|
|
188
|
+
await store.createNote("# Shared Title\n\nA.", { path: "Inbox/a" });
|
|
189
|
+
await store.createNote("# Shared Title\n\nB.", { path: "Inbox/b" });
|
|
190
|
+
const id = resolveWikilink(db, "Shared Title");
|
|
191
|
+
expect(id).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("does not fall through to title when basename is itself ambiguous", async () => {
|
|
195
|
+
// Two notes share basename "README" (ambiguous at step 3) AND a third
|
|
196
|
+
// note has an H1 title of "README" too — the ambiguous basename step
|
|
197
|
+
// must not be rescued by the title step.
|
|
198
|
+
await store.createNote("A", { path: "Folder1/README" });
|
|
199
|
+
await store.createNote("B", { path: "Folder2/README" });
|
|
200
|
+
await store.createNote("# README\n\nC.", { path: "Inbox/c" });
|
|
201
|
+
const id = resolveWikilink(db, "README");
|
|
202
|
+
expect(id).toBeNull();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("ignores H2+ headings — only a literal single-# line counts", async () => {
|
|
206
|
+
await store.createNote("## Not An H1\n\nBody.", { path: "Inbox/h2" });
|
|
207
|
+
const id = resolveWikilink(db, "Not An H1");
|
|
208
|
+
expect(id).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("resolveWikilinkDetailed reports the title match as resolved", async () => {
|
|
212
|
+
const note = await store.createNote("# Detailed Title\n\nBody.", { path: "Inbox/detail" });
|
|
213
|
+
const result = resolveWikilinkDetailed(db, "Detailed Title");
|
|
214
|
+
expect(result.resolved).toBe(true);
|
|
215
|
+
expect(result.note_id).toBe(note.id);
|
|
216
|
+
expect(result.path).toBe("Inbox/detail");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("resolveWikilinkDetailed reports an ambiguous title match", async () => {
|
|
220
|
+
await store.createNote("# Dup Title\n\nA.", { path: "Inbox/dup-a" });
|
|
221
|
+
await store.createNote("# Dup Title\n\nB.", { path: "Inbox/dup-b" });
|
|
222
|
+
const result = resolveWikilinkDetailed(db, "Dup Title");
|
|
223
|
+
expect(result.resolved).toBe(false);
|
|
224
|
+
expect(result.ambiguous).toBe(true);
|
|
225
|
+
expect(result.candidates).toHaveLength(2);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
145
229
|
// ---------------------------------------------------------------------------
|
|
146
230
|
// Sync
|
|
147
231
|
// ---------------------------------------------------------------------------
|
|
@@ -157,6 +241,16 @@ describe("syncWikilinks", async () => {
|
|
|
157
241
|
expect(links[0].relationship).toBe("wikilink");
|
|
158
242
|
});
|
|
159
243
|
|
|
244
|
+
it("creates a link via the title fallback when path/basename miss", async () => {
|
|
245
|
+
const target = await store.createNote("# Weekly Review\n\nBody.", { path: "Inbox/2026-07-10-abc123" });
|
|
246
|
+
const source = await store.createNote("See [[Weekly Review]]");
|
|
247
|
+
|
|
248
|
+
const links = await store.getLinks(source.id, { direction: "outbound" });
|
|
249
|
+
expect(links).toHaveLength(1);
|
|
250
|
+
expect(links[0].targetId).toBe(target.id);
|
|
251
|
+
expect(links[0].relationship).toBe("wikilink");
|
|
252
|
+
});
|
|
253
|
+
|
|
160
254
|
it("tracks unresolved wikilinks", async () => {
|
|
161
255
|
const source = await store.createNote("See [[Missing Note]]");
|
|
162
256
|
|
|
@@ -254,6 +348,116 @@ describe("syncWikilinks", async () => {
|
|
|
254
348
|
expect(links[0].metadata?.display).toBe("intro");
|
|
255
349
|
expect(links[0].metadata?.anchor).toBe("Introduction");
|
|
256
350
|
});
|
|
351
|
+
|
|
352
|
+
// vault#570 — an ambiguous target (≥2 notes share the same basename/
|
|
353
|
+
// title) must be reported distinctly from a genuine miss, via
|
|
354
|
+
// `syncWikilinks`'s `ambiguous` array, and must NOT be linked or queued
|
|
355
|
+
// into `unresolved_wikilinks` (queuing implies "wait for this to be
|
|
356
|
+
// created", which doesn't describe an already-existing ambiguity).
|
|
357
|
+
it("returns ambiguous targets separately from unresolved, creates no link, and does not queue them", async () => {
|
|
358
|
+
const a = await store.createNote("A", { path: "Folder1/Dup" });
|
|
359
|
+
const b = await store.createNote("B", { path: "Folder2/Dup" });
|
|
360
|
+
// Empty content on create so the note exists (satisfying the
|
|
361
|
+
// `unresolved_wikilinks` FK) without triggering `syncWikilinks` yet —
|
|
362
|
+
// the manual call below is what's under test, and its return value
|
|
363
|
+
// isn't otherwise observable through the Store API.
|
|
364
|
+
const source = await store.createNote("");
|
|
365
|
+
|
|
366
|
+
const content = "See [[Dup]] and also [[Truly Missing]]";
|
|
367
|
+
const result = syncWikilinks(db, source.id, content);
|
|
368
|
+
expect(result.added).toBe(0);
|
|
369
|
+
expect(result.unresolved).toEqual(["Truly Missing"]);
|
|
370
|
+
expect(result.ambiguous).toEqual([{ target: "Dup", count: 2 }]);
|
|
371
|
+
|
|
372
|
+
const links = await store.getLinks(source.id, { direction: "outbound" });
|
|
373
|
+
expect(links.some((l) => l.targetId === a.id || l.targetId === b.id)).toBe(false);
|
|
374
|
+
|
|
375
|
+
const pending = db.prepare(
|
|
376
|
+
"SELECT target_path FROM unresolved_wikilinks WHERE source_id = ?",
|
|
377
|
+
).all(source.id) as { target_path: string }[];
|
|
378
|
+
// Only the genuinely-missing target is queued — "Dup" is absent.
|
|
379
|
+
expect(pending.map((r) => r.target_path)).toEqual(["Truly Missing"]);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// getContentWikilinkWarnings (vault#570) — read-only warning derivation
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
|
|
387
|
+
describe("getContentWikilinkWarnings", () => {
|
|
388
|
+
it("returns an unresolved_link warning for a content wikilink to a missing target", async () => {
|
|
389
|
+
const source = await store.createNote("See [[Nowhere]]");
|
|
390
|
+
const warnings = getContentWikilinkWarnings(db, source.id, "See [[Nowhere]]");
|
|
391
|
+
expect(warnings).toHaveLength(1);
|
|
392
|
+
expect(warnings[0]!.code).toBe("unresolved_link");
|
|
393
|
+
expect(warnings[0]!.target).toBe("Nowhere");
|
|
394
|
+
expect(warnings[0]!.relationship).toBe("wikilink");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("returns an ambiguous_link warning (with candidate_count) for a content wikilink matching 2 notes", async () => {
|
|
398
|
+
await store.createNote("A", { path: "Folder1/Same" });
|
|
399
|
+
await store.createNote("B", { path: "Folder2/Same" });
|
|
400
|
+
const source = await store.createNote("See [[Same]]");
|
|
401
|
+
const warnings = getContentWikilinkWarnings(db, source.id, "See [[Same]]");
|
|
402
|
+
expect(warnings).toHaveLength(1);
|
|
403
|
+
expect(warnings[0]!.code).toBe("ambiguous_link");
|
|
404
|
+
expect(warnings[0]!.target).toBe("Same");
|
|
405
|
+
expect(warnings[0]!.candidate_count).toBe(2);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("returns no warnings for a resolved wikilink, including a self-link", async () => {
|
|
409
|
+
const target = await store.createNote("Target", { path: "Resolvable" });
|
|
410
|
+
const source = await store.createNote("See [[Resolvable]]", { path: "Myself Again" });
|
|
411
|
+
expect(getContentWikilinkWarnings(db, source.id, "See [[Resolvable]]")).toEqual([]);
|
|
412
|
+
expect(getContentWikilinkWarnings(db, source.id, "See [[Myself Again]]")).toEqual([]);
|
|
413
|
+
void target;
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it("returns no warnings when content has no wikilinks", () => {
|
|
417
|
+
expect(getContentWikilinkWarnings(db, "some-id", "plain text, no brackets")).toEqual([]);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// resolveOrQueueLink (vault#555 + vault#570) — discriminated outcome
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
describe("resolveOrQueueLink", () => {
|
|
426
|
+
it("returns {status: 'resolved', note_id} for a resolvable target", async () => {
|
|
427
|
+
const target = await store.createNote("Target", { path: "Findable" });
|
|
428
|
+
const source = await store.createNote("source");
|
|
429
|
+
const outcome = resolveOrQueueLink(db, source.id, "Findable", "knows");
|
|
430
|
+
expect(outcome.status).toBe("resolved");
|
|
431
|
+
if (outcome.status === "resolved") expect(outcome.note_id).toBe(target.id);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("returns {status: 'queued'} and queues the pending row for a genuinely missing target", async () => {
|
|
435
|
+
const source = await store.createNote("source");
|
|
436
|
+
const outcome = resolveOrQueueLink(db, source.id, "Not There", "knows");
|
|
437
|
+
expect(outcome.status).toBe("queued");
|
|
438
|
+
const pending = db.prepare(
|
|
439
|
+
"SELECT target_path, relationship FROM unresolved_wikilinks WHERE source_id = ?",
|
|
440
|
+
).all(source.id) as { target_path: string; relationship: string }[];
|
|
441
|
+
expect(pending).toEqual([{ target_path: "Not There", relationship: "knows" }]);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("returns {status: 'ambiguous', candidates} and does NOT queue for a target matching 2 notes", async () => {
|
|
445
|
+
const a = await store.createNote("A", { path: "Folder1/Twice" });
|
|
446
|
+
const b = await store.createNote("B", { path: "Folder2/Twice" });
|
|
447
|
+
const source = await store.createNote("source");
|
|
448
|
+
const outcome = resolveOrQueueLink(db, source.id, "Twice", "knows");
|
|
449
|
+
expect(outcome.status).toBe("ambiguous");
|
|
450
|
+
if (outcome.status === "ambiguous") {
|
|
451
|
+
expect(outcome.candidates.map((c) => c.note_id).sort()).toEqual([a.id, b.id].sort());
|
|
452
|
+
}
|
|
453
|
+
const tableExists = (db.prepare("PRAGMA table_info(unresolved_wikilinks)").all() as unknown[]).length > 0;
|
|
454
|
+
if (tableExists) {
|
|
455
|
+
const pending = db.prepare(
|
|
456
|
+
"SELECT * FROM unresolved_wikilinks WHERE source_id = ?",
|
|
457
|
+
).all(source.id) as unknown[];
|
|
458
|
+
expect(pending).toHaveLength(0);
|
|
459
|
+
}
|
|
460
|
+
});
|
|
257
461
|
});
|
|
258
462
|
|
|
259
463
|
// ---------------------------------------------------------------------------
|
package/core/src/wikilinks.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import type { Note } from "./types.js";
|
|
3
3
|
import * as linkOps from "./links.js";
|
|
4
|
-
import { getNote } from "./notes.js";
|
|
4
|
+
import { getNote, findNotesByTitle } from "./notes.js";
|
|
5
5
|
import { chunkForInClause } from "./sql-in.js";
|
|
6
6
|
import { transaction } from "./txn.js";
|
|
7
|
+
import type { QueryWarning } from "./query-warnings.js";
|
|
7
8
|
|
|
8
9
|
// ---------------------------------------------------------------------------
|
|
9
10
|
// Parser — extract [[wikilinks]] from markdown content
|
|
@@ -128,6 +129,14 @@ function stripCode(content: string): string {
|
|
|
128
129
|
* 3. Basename match — target matches the last segment of a path
|
|
129
130
|
* (e.g., "README" matches "Projects/Parachute/README"). Same
|
|
130
131
|
* cross-extension ambiguity policy as #2.
|
|
132
|
+
* 4. **Title fallback** (additive) — only tried when #2/#3 found NO
|
|
133
|
+
* candidates at all (a clean miss, not an ambiguous one). Matches the
|
|
134
|
+
* note whose H1 title (first `# ` line in its content, see
|
|
135
|
+
* {@link findNotesByTitle}) equals the target, case-insensitively.
|
|
136
|
+
* Resolves only when EXACTLY one note has that title; 2+ matches is
|
|
137
|
+
* ambiguous and stays unresolved rather than guessing. Rescues links
|
|
138
|
+
* into a note whose displayed title differs from its path/basename —
|
|
139
|
+
* the top source of silently-broken wikilinks.
|
|
131
140
|
*/
|
|
132
141
|
export function resolveWikilink(db: Database, target: string): string | null {
|
|
133
142
|
// 1. Explicit extension form: `[[path.ext]]` where `.ext` is a
|
|
@@ -176,6 +185,12 @@ export function resolveWikilink(db: Database, target: string): string | null {
|
|
|
176
185
|
`).all(target, `%/${target}`) as { id: string }[];
|
|
177
186
|
|
|
178
187
|
if (basename.length === 1) return basename[0]!.id;
|
|
188
|
+
if (basename.length > 1) return null; // ambiguous basename — don't fall through to title
|
|
189
|
+
|
|
190
|
+
// 4. Title fallback — only reached on a clean miss (0 basename
|
|
191
|
+
// candidates). See the doc comment above for the ambiguity policy.
|
|
192
|
+
const byTitle = findNotesByTitle(db, target);
|
|
193
|
+
if (byTitle.length === 1) return byTitle[0]!.id;
|
|
179
194
|
|
|
180
195
|
// Ambiguous or no match
|
|
181
196
|
return null;
|
|
@@ -246,6 +261,21 @@ export function resolveWikilinkDetailed(db: Database, target: string): WikilinkR
|
|
|
246
261
|
};
|
|
247
262
|
}
|
|
248
263
|
|
|
264
|
+
// 4. Title fallback — only reached on a clean basename miss (0
|
|
265
|
+
// candidates), mirroring resolveWikilink.
|
|
266
|
+
const byTitle = findNotesByTitle(db, target);
|
|
267
|
+
if (byTitle.length === 1) {
|
|
268
|
+
const match = byTitle[0]!;
|
|
269
|
+
return { resolved: true, note_id: match.id, path: match.path ?? undefined, candidates: [] };
|
|
270
|
+
}
|
|
271
|
+
if (byTitle.length > 1) {
|
|
272
|
+
return {
|
|
273
|
+
resolved: false,
|
|
274
|
+
ambiguous: true,
|
|
275
|
+
candidates: byTitle.map((r) => ({ note_id: r.id, path: r.path ?? "" })),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
249
279
|
return { resolved: false, ambiguous: false, candidates: [] };
|
|
250
280
|
}
|
|
251
281
|
|
|
@@ -306,6 +336,50 @@ export function listUnresolvedWikilinks(db: Database, limit = 50): { unresolved:
|
|
|
306
336
|
return { unresolved, count: total };
|
|
307
337
|
}
|
|
308
338
|
|
|
339
|
+
/** One note's dangling outbound link, as surfaced on a note read (vault#555). */
|
|
340
|
+
export interface BrokenLink {
|
|
341
|
+
target: string;
|
|
342
|
+
relationship: string;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Batch-fetch each note's pending `unresolved_wikilinks` rows — the
|
|
347
|
+
* `include_broken_links` surfacing on `query-notes` / `GET /notes`. ONE
|
|
348
|
+
* query for the whole page (mirrors `getLinksHydratedForNotes`'s batching),
|
|
349
|
+
* not one per note. Returns a map with an entry (possibly `[]`) for every
|
|
350
|
+
* requested id whenever the table exists; when the table has never been
|
|
351
|
+
* created (no note in this vault has ever had a broken link) every id maps
|
|
352
|
+
* to `[]` without a query attempt.
|
|
353
|
+
*/
|
|
354
|
+
export function getUnresolvedLinksForNotes(db: Database, noteIds: string[]): Map<string, BrokenLink[]> {
|
|
355
|
+
const result = new Map<string, BrokenLink[]>(noteIds.map((id) => [id, []]));
|
|
356
|
+
if (noteIds.length === 0) return result;
|
|
357
|
+
|
|
358
|
+
const rows: { source_id: string; target_path: string; relationship: string }[] = [];
|
|
359
|
+
try {
|
|
360
|
+
ensureRelationshipColumn(db);
|
|
361
|
+
for (const chunk of chunkForInClause(noteIds)) {
|
|
362
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
363
|
+
rows.push(...db.prepare(
|
|
364
|
+
`SELECT source_id, target_path, relationship FROM unresolved_wikilinks WHERE source_id IN (${placeholders})`,
|
|
365
|
+
).all(...chunk) as typeof rows);
|
|
366
|
+
}
|
|
367
|
+
} catch {
|
|
368
|
+
// Table doesn't exist — every id already maps to [] above.
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
for (const row of rows) {
|
|
373
|
+
result.get(row.source_id)?.push({ target: row.target_path, relationship: row.relationship || WIKILINK_REL });
|
|
374
|
+
}
|
|
375
|
+
return result;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Single-note convenience wrapper around {@link getUnresolvedLinksForNotes}. */
|
|
379
|
+
export function getUnresolvedLinksForNote(db: Database, noteId: string): BrokenLink[] {
|
|
380
|
+
return getUnresolvedLinksForNotes(db, [noteId]).get(noteId) ?? [];
|
|
381
|
+
}
|
|
382
|
+
|
|
309
383
|
// ---------------------------------------------------------------------------
|
|
310
384
|
// Sync — maintain wikilink-based links for a note
|
|
311
385
|
// ---------------------------------------------------------------------------
|
|
@@ -313,19 +387,14 @@ export function listUnresolvedWikilinks(db: Database, limit = 50): { unresolved:
|
|
|
313
387
|
const WIKILINK_REL = "wikilink";
|
|
314
388
|
|
|
315
389
|
/**
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
390
|
+
* Deduplicate parsed wikilinks by target (case-insensitively) — the same
|
|
391
|
+
* target mentioned multiple times in a note's content resolves to ONE
|
|
392
|
+
* link/warning, not one per mention. Shared by {@link syncWikilinks} (which
|
|
393
|
+
* mutates links/the pending-resolution table) and
|
|
394
|
+
* {@link getContentWikilinkWarnings} (which only reads) so the two can't
|
|
395
|
+
* drift on which targets they consider.
|
|
320
396
|
*/
|
|
321
|
-
|
|
322
|
-
db: Database,
|
|
323
|
-
noteId: string,
|
|
324
|
-
content: string,
|
|
325
|
-
): { added: number; removed: number; unresolved: string[] } {
|
|
326
|
-
const parsed = parseWikilinks(content);
|
|
327
|
-
|
|
328
|
-
// Deduplicate by target (same target mentioned multiple times = one link)
|
|
397
|
+
function dedupeWikilinkTargets(parsed: ParsedWikilink[]): Map<string, ParsedWikilink> {
|
|
329
398
|
const targetMap = new Map<string, ParsedWikilink>();
|
|
330
399
|
for (const wl of parsed) {
|
|
331
400
|
const key = wl.target.toLowerCase();
|
|
@@ -333,17 +402,53 @@ export function syncWikilinks(
|
|
|
333
402
|
targetMap.set(key, wl);
|
|
334
403
|
}
|
|
335
404
|
}
|
|
405
|
+
return targetMap;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** One target's ambiguity — surfaced target string + how many notes it matched. */
|
|
409
|
+
export interface AmbiguousWikilinkTarget {
|
|
410
|
+
target: string;
|
|
411
|
+
count: number;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Sync wikilink-based links for a note.
|
|
416
|
+
* Parses content for [[wikilinks]], resolves targets, creates/removes links.
|
|
417
|
+
*
|
|
418
|
+
* Returns counts of changes made. `unresolved` is content wikilinks whose
|
|
419
|
+
* target matched NO note (queued into `unresolved_wikilinks` for lazy
|
|
420
|
+
* backfill — see {@link resolveUnresolvedWikilinks}). `ambiguous` (vault#570)
|
|
421
|
+
* is content wikilinks whose target matched ≥2 notes — these are NEITHER
|
|
422
|
+
* linked NOR queued: a future note being created can't retroactively
|
|
423
|
+
* resolve an ambiguity between two notes that ALREADY exist, and queuing it
|
|
424
|
+
* would risk the pending-resolution sweep later linking to an arbitrary
|
|
425
|
+
* THIRD same-named note rather than reporting the collision. The caller
|
|
426
|
+
* (MCP `create-note`/`update-note`, REST `POST`/`PATCH /notes`) surfaces
|
|
427
|
+
* `ambiguous` as an `ambiguous_link` warning naming the target + match
|
|
428
|
+
* count, distinct from `unresolved`'s `unresolved_link`.
|
|
429
|
+
*/
|
|
430
|
+
export function syncWikilinks(
|
|
431
|
+
db: Database,
|
|
432
|
+
noteId: string,
|
|
433
|
+
content: string,
|
|
434
|
+
): { added: number; removed: number; unresolved: string[]; ambiguous: AmbiguousWikilinkTarget[] } {
|
|
435
|
+
const targetMap = dedupeWikilinkTargets(parseWikilinks(content));
|
|
336
436
|
|
|
337
437
|
// Resolve each unique target
|
|
338
438
|
const resolvedLinks = new Map<string, { targetId: string; wl: ParsedWikilink }>();
|
|
339
439
|
const unresolved: string[] = [];
|
|
440
|
+
const ambiguous: AmbiguousWikilinkTarget[] = [];
|
|
340
441
|
|
|
341
442
|
for (const [key, wl] of targetMap) {
|
|
342
|
-
const
|
|
343
|
-
if (
|
|
344
|
-
// Don't create self-links
|
|
345
|
-
|
|
346
|
-
|
|
443
|
+
const detail = resolveWikilinkDetailed(db, wl.target);
|
|
444
|
+
if (detail.resolved) {
|
|
445
|
+
// Don't create self-links — silently skipped, same as before.
|
|
446
|
+
if (detail.note_id !== noteId) {
|
|
447
|
+
resolvedLinks.set(detail.note_id!, { targetId: detail.note_id!, wl });
|
|
448
|
+
}
|
|
449
|
+
} else if (detail.ambiguous) {
|
|
450
|
+
ambiguous.push({ target: wl.target, count: detail.candidates.length });
|
|
451
|
+
} else {
|
|
347
452
|
unresolved.push(wl.target);
|
|
348
453
|
}
|
|
349
454
|
}
|
|
@@ -385,10 +490,11 @@ export function syncWikilinks(
|
|
|
385
490
|
}
|
|
386
491
|
}
|
|
387
492
|
|
|
388
|
-
// Store unresolved wikilinks for later resolution
|
|
493
|
+
// Store unresolved wikilinks for later resolution. Ambiguous targets are
|
|
494
|
+
// deliberately NOT queued here — see the doc comment above.
|
|
389
495
|
syncUnresolvedWikilinks(db, noteId, unresolved);
|
|
390
496
|
|
|
391
|
-
return { added, removed, unresolved };
|
|
497
|
+
return { added, removed, unresolved, ambiguous };
|
|
392
498
|
}
|
|
393
499
|
|
|
394
500
|
// ---------------------------------------------------------------------------
|
|
@@ -572,14 +678,29 @@ export function resolveUnresolvedWikilinks(
|
|
|
572
678
|
/**
|
|
573
679
|
* Resolve a structured-link `target` — an ID or a path/title — to a note
|
|
574
680
|
* ID. ID lookup first (structured links accept "note ID or path" per the
|
|
575
|
-
* tool docs; wikilinks never carry a raw ID), then the SAME path/basename
|
|
576
|
-
* resolution `[[wikilinks]]` use ({@link resolveWikilink}: explicit
|
|
577
|
-
* extension, exact path, basename).
|
|
681
|
+
* tool docs; wikilinks never carry a raw ID), then the SAME path/basename/
|
|
682
|
+
* title resolution `[[wikilinks]]` use ({@link resolveWikilink}: explicit
|
|
683
|
+
* extension, exact path, basename, then H1-title fallback on a clean miss).
|
|
578
684
|
*/
|
|
579
685
|
export function resolveLinkTarget(db: Database, idOrPath: string): string | null {
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
|
|
686
|
+
const detail = resolveLinkTargetDetailed(db, idOrPath);
|
|
687
|
+
return detail.resolved ? detail.note_id! : null;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Detailed counterpart to {@link resolveLinkTarget} — same ID-then-path/
|
|
692
|
+
* basename resolution order, but surfaces AMBIGUOUS (≥2 path/basename
|
|
693
|
+
* matches, vault#570) as a distinct outcome instead of collapsing it into
|
|
694
|
+
* the same `null` a genuine miss returns. An ID lookup is never ambiguous
|
|
695
|
+
* (the `id` column is the primary key), so ambiguity can only arise from
|
|
696
|
+
* the path/basename fallback — {@link resolveWikilinkDetailed}.
|
|
697
|
+
*/
|
|
698
|
+
export function resolveLinkTargetDetailed(db: Database, idOrPath: string): WikilinkResolution {
|
|
699
|
+
const byId = db.prepare("SELECT id, path FROM notes WHERE id = ?").get(idOrPath) as { id: string; path: string | null } | null;
|
|
700
|
+
if (byId) {
|
|
701
|
+
return { resolved: true, note_id: byId.id, path: byId.path ?? undefined, candidates: [] };
|
|
702
|
+
}
|
|
703
|
+
return resolveWikilinkDetailed(db, idOrPath);
|
|
583
704
|
}
|
|
584
705
|
|
|
585
706
|
/**
|
|
@@ -610,24 +731,112 @@ export function queueUnresolvedLink(
|
|
|
610
731
|
).run(sourceId, targetPath, relationship);
|
|
611
732
|
}
|
|
612
733
|
|
|
734
|
+
/** Outcome of {@link resolveOrQueueLink} — a discriminated union so callers can't confuse "queued" with "ambiguous" (vault#570; both used to collapse to the same `null`). */
|
|
735
|
+
export type ResolveOrQueueOutcome =
|
|
736
|
+
| { status: "resolved"; note_id: string }
|
|
737
|
+
| { status: "ambiguous"; candidates: { note_id: string; path: string }[] }
|
|
738
|
+
| { status: "queued" };
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Drop any pending forward-ref queued for `sourceId` under `relationship`,
|
|
742
|
+
* regardless of the (stale) `target_path` it names. Used by the
|
|
743
|
+
* `reference`-field auto-link sync (core/src/store.ts's
|
|
744
|
+
* `syncReferenceFieldLinks`, vault#typed-reference-field) before re-resolving
|
|
745
|
+
* a changed field value — without this, a field that used to point at an
|
|
746
|
+
* unresolved target and is then changed to a different (or removed) value
|
|
747
|
+
* would leave the OLD forward-ref queued forever, since
|
|
748
|
+
* {@link queueUnresolvedLink}'s primary key includes `target_path` and a
|
|
749
|
+
* changed value queues under a NEW key rather than replacing the old row.
|
|
750
|
+
* Safe no-op when the table doesn't exist yet.
|
|
751
|
+
*/
|
|
752
|
+
export function clearQueuedLink(db: Database, sourceId: string, relationship: string): void {
|
|
753
|
+
try {
|
|
754
|
+
db.prepare(
|
|
755
|
+
"DELETE FROM unresolved_wikilinks WHERE source_id = ? AND relationship = ?",
|
|
756
|
+
).run(sourceId, relationship);
|
|
757
|
+
} catch {
|
|
758
|
+
// Table may not exist yet — nothing to clear.
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
613
762
|
/**
|
|
614
763
|
* Resolve a structured link NOW, or queue it for lazy resolution when the
|
|
615
764
|
* target doesn't exist yet — mirroring the wikilink forward-ref contract
|
|
616
765
|
* (a target created later, in this same batch or a future call, backfills
|
|
617
|
-
* the edge automatically via {@link resolveUnresolvedWikilinks}).
|
|
618
|
-
*
|
|
619
|
-
*
|
|
620
|
-
*
|
|
621
|
-
* (vault#
|
|
766
|
+
* the edge automatically via {@link resolveUnresolvedWikilinks}).
|
|
767
|
+
*
|
|
768
|
+
* Returns a {@link ResolveOrQueueOutcome}:
|
|
769
|
+
* - `"resolved"` — the edge should be created against `note_id` now.
|
|
770
|
+
* - `"ambiguous"` (vault#570) — the target matched ≥2 notes (e.g. two
|
|
771
|
+
* notes sharing an H1 title). NEITHER linked nor queued — see
|
|
772
|
+
* {@link syncWikilinks}'s doc comment for why queuing an ambiguous
|
|
773
|
+
* target would be wrong. Callers MUST surface a distinct
|
|
774
|
+
* `ambiguous_link` warning naming the target + `candidates.length`.
|
|
775
|
+
* - `"queued"` — the target matched NO note; queued for lazy backfill.
|
|
776
|
+
* Callers MUST surface an `unresolved_link` warning naming the target.
|
|
777
|
+
*
|
|
778
|
+
* The write itself never fails on either non-`"resolved"` outcome — silence
|
|
779
|
+
* is never the fallback (vault#555 — "the API should never silently do the
|
|
780
|
+
* wrong thing").
|
|
622
781
|
*/
|
|
623
782
|
export function resolveOrQueueLink(
|
|
624
783
|
db: Database,
|
|
625
784
|
sourceId: string,
|
|
626
785
|
target: string,
|
|
627
786
|
relationship: string,
|
|
628
|
-
):
|
|
629
|
-
const
|
|
630
|
-
if (
|
|
787
|
+
): ResolveOrQueueOutcome {
|
|
788
|
+
const detail = resolveLinkTargetDetailed(db, target);
|
|
789
|
+
if (detail.resolved) return { status: "resolved", note_id: detail.note_id! };
|
|
790
|
+
if (detail.ambiguous) return { status: "ambiguous", candidates: detail.candidates };
|
|
631
791
|
queueUnresolvedLink(db, sourceId, target, relationship);
|
|
632
|
-
return
|
|
792
|
+
return { status: "queued" };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Content-wikilink counterpart of the `unresolved_link`/`ambiguous_link`
|
|
797
|
+
* warnings structured `links` already produce via {@link resolveOrQueueLink}
|
|
798
|
+
* (vault#570 — content-parsed `[[wikilinks]]` to a missing target used to
|
|
799
|
+
* fire NO write-time warning at all, even though the equivalent structured
|
|
800
|
+
* `links` entry did). READ-ONLY: does not mutate `unresolved_wikilinks` or
|
|
801
|
+
* create/remove any link — that mutation already happened inside
|
|
802
|
+
* {@link syncWikilinks} (called from `Store.createNote`/`updateNote`).
|
|
803
|
+
* Callers invoke this AFTER the note's content is committed, passing the
|
|
804
|
+
* SAME `noteId`/`content` pair, to recompute the identical per-target
|
|
805
|
+
* classification for the response's `warnings` array — single source of
|
|
806
|
+
* truth is {@link resolveWikilinkDetailed}, so this can't drift from what
|
|
807
|
+
* `syncWikilinks` actually did. Mirrors `syncWikilinks`'s target selection
|
|
808
|
+
* exactly: same lowercase-target dedupe key (via
|
|
809
|
+
* {@link dedupeWikilinkTargets}), same self-link skip (a wikilink resolving
|
|
810
|
+
* to the note's own id is neither linked nor warned about).
|
|
811
|
+
*/
|
|
812
|
+
export function getContentWikilinkWarnings(
|
|
813
|
+
db: Database,
|
|
814
|
+
noteId: string,
|
|
815
|
+
content: string,
|
|
816
|
+
): QueryWarning[] {
|
|
817
|
+
const targetMap = dedupeWikilinkTargets(parseWikilinks(content));
|
|
818
|
+
const warnings: QueryWarning[] = [];
|
|
819
|
+
|
|
820
|
+
for (const [, wl] of targetMap) {
|
|
821
|
+
const detail = resolveWikilinkDetailed(db, wl.target);
|
|
822
|
+
if (detail.resolved) continue; // resolved (incl. self-link) — nothing to warn about
|
|
823
|
+
if (detail.ambiguous) {
|
|
824
|
+
warnings.push({
|
|
825
|
+
code: "ambiguous_link",
|
|
826
|
+
message: `wikilink target "${wl.target}" matched ${detail.candidates.length} notes — ambiguous, no link created. Use a more specific path, [[Target.ext]], or the note's ID to disambiguate.`,
|
|
827
|
+
target: wl.target,
|
|
828
|
+
relationship: WIKILINK_REL,
|
|
829
|
+
candidate_count: detail.candidates.length,
|
|
830
|
+
});
|
|
831
|
+
} else {
|
|
832
|
+
warnings.push({
|
|
833
|
+
code: "unresolved_link",
|
|
834
|
+
message: `wikilink target "${wl.target}" did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
|
|
835
|
+
target: wl.target,
|
|
836
|
+
relationship: WIKILINK_REL,
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
return warnings;
|
|
633
842
|
}
|