@openparachute/vault 0.7.0-rc.9 → 0.7.2-rc.3
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 +737 -2
- package/core/src/cursor-keyset-ms.test.ts +537 -0
- package/core/src/cursor.ts +76 -6
- package/core/src/doctor.ts +23 -1
- package/core/src/hooks.ts +9 -0
- 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 +322 -53
- package/core/src/notes.ts +518 -67
- package/core/src/paths.ts +4 -0
- package/core/src/portable-md.test.ts +161 -0
- package/core/src/portable-md.ts +140 -9
- package/core/src/query-warnings.ts +60 -12
- package/core/src/schema-defaults.ts +7 -1
- package/core/src/schema.ts +128 -2
- package/core/src/seed-packs.ts +55 -15
- package/core/src/store.ts +141 -7
- package/core/src/tag-schemas.ts +20 -7
- package/core/src/txn.test.ts +100 -4
- package/core/src/txn.ts +119 -24
- package/core/src/types.ts +89 -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 +200 -35
- package/package.json +1 -1
- package/src/aggregate-routes.test.ts +230 -0
- package/src/cli.ts +6 -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 +76 -17
- package/src/mirror-import.ts +5 -0
- package/src/routes.ts +298 -24
- 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 +557 -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
|
|
|
@@ -357,19 +387,14 @@ export function getUnresolvedLinksForNote(db: Database, noteId: string): BrokenL
|
|
|
357
387
|
const WIKILINK_REL = "wikilink";
|
|
358
388
|
|
|
359
389
|
/**
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
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.
|
|
364
396
|
*/
|
|
365
|
-
|
|
366
|
-
db: Database,
|
|
367
|
-
noteId: string,
|
|
368
|
-
content: string,
|
|
369
|
-
): { added: number; removed: number; unresolved: string[] } {
|
|
370
|
-
const parsed = parseWikilinks(content);
|
|
371
|
-
|
|
372
|
-
// Deduplicate by target (same target mentioned multiple times = one link)
|
|
397
|
+
function dedupeWikilinkTargets(parsed: ParsedWikilink[]): Map<string, ParsedWikilink> {
|
|
373
398
|
const targetMap = new Map<string, ParsedWikilink>();
|
|
374
399
|
for (const wl of parsed) {
|
|
375
400
|
const key = wl.target.toLowerCase();
|
|
@@ -377,17 +402,53 @@ export function syncWikilinks(
|
|
|
377
402
|
targetMap.set(key, wl);
|
|
378
403
|
}
|
|
379
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));
|
|
380
436
|
|
|
381
437
|
// Resolve each unique target
|
|
382
438
|
const resolvedLinks = new Map<string, { targetId: string; wl: ParsedWikilink }>();
|
|
383
439
|
const unresolved: string[] = [];
|
|
440
|
+
const ambiguous: AmbiguousWikilinkTarget[] = [];
|
|
384
441
|
|
|
385
442
|
for (const [key, wl] of targetMap) {
|
|
386
|
-
const
|
|
387
|
-
if (
|
|
388
|
-
// Don't create self-links
|
|
389
|
-
|
|
390
|
-
|
|
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 {
|
|
391
452
|
unresolved.push(wl.target);
|
|
392
453
|
}
|
|
393
454
|
}
|
|
@@ -429,10 +490,11 @@ export function syncWikilinks(
|
|
|
429
490
|
}
|
|
430
491
|
}
|
|
431
492
|
|
|
432
|
-
// 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.
|
|
433
495
|
syncUnresolvedWikilinks(db, noteId, unresolved);
|
|
434
496
|
|
|
435
|
-
return { added, removed, unresolved };
|
|
497
|
+
return { added, removed, unresolved, ambiguous };
|
|
436
498
|
}
|
|
437
499
|
|
|
438
500
|
// ---------------------------------------------------------------------------
|
|
@@ -616,14 +678,29 @@ export function resolveUnresolvedWikilinks(
|
|
|
616
678
|
/**
|
|
617
679
|
* Resolve a structured-link `target` — an ID or a path/title — to a note
|
|
618
680
|
* ID. ID lookup first (structured links accept "note ID or path" per the
|
|
619
|
-
* tool docs; wikilinks never carry a raw ID), then the SAME path/basename
|
|
620
|
-
* resolution `[[wikilinks]]` use ({@link resolveWikilink}: explicit
|
|
621
|
-
* 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).
|
|
622
684
|
*/
|
|
623
685
|
export function resolveLinkTarget(db: Database, idOrPath: string): string | null {
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
|
|
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);
|
|
627
704
|
}
|
|
628
705
|
|
|
629
706
|
/**
|
|
@@ -654,24 +731,112 @@ export function queueUnresolvedLink(
|
|
|
654
731
|
).run(sourceId, targetPath, relationship);
|
|
655
732
|
}
|
|
656
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
|
+
|
|
657
762
|
/**
|
|
658
763
|
* Resolve a structured link NOW, or queue it for lazy resolution when the
|
|
659
764
|
* target doesn't exist yet — mirroring the wikilink forward-ref contract
|
|
660
765
|
* (a target created later, in this same batch or a future call, backfills
|
|
661
|
-
* the edge automatically via {@link resolveUnresolvedWikilinks}).
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
* (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").
|
|
666
781
|
*/
|
|
667
782
|
export function resolveOrQueueLink(
|
|
668
783
|
db: Database,
|
|
669
784
|
sourceId: string,
|
|
670
785
|
target: string,
|
|
671
786
|
relationship: string,
|
|
672
|
-
):
|
|
673
|
-
const
|
|
674
|
-
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 };
|
|
675
791
|
queueUnresolvedLink(db, sourceId, target, relationship);
|
|
676
|
-
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;
|
|
677
842
|
}
|