@openparachute/vault 0.4.4-rc.14 → 0.4.5
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/core.test.ts +221 -0
- package/core/src/mcp.ts +56 -6
- package/core/src/notes.ts +185 -15
- package/core/src/portable-md.test.ts +531 -0
- package/core/src/portable-md.ts +508 -19
- package/core/src/schema.ts +61 -3
- package/core/src/store.ts +5 -4
- package/core/src/types.ts +27 -3
- package/core/src/wikilinks.ts +74 -14
- package/package.json +1 -1
- package/src/published.test.ts +17 -0
- package/src/routes.ts +121 -3
- package/src/vault.test.ts +175 -0
package/core/src/core.test.ts
CHANGED
|
@@ -322,6 +322,142 @@ describe("updated_at backfill on init", async () => {
|
|
|
322
322
|
});
|
|
323
323
|
});
|
|
324
324
|
|
|
325
|
+
// ---- Extension (vault#328 Phase 1: DB + Store) ----
|
|
326
|
+
//
|
|
327
|
+
// Three pinned behaviors:
|
|
328
|
+
// 1. Migrations and inserts default to "md" — every existing row keeps
|
|
329
|
+
// its meaning after the v17 → v18 ALTER TABLE.
|
|
330
|
+
// 2. Explicit extension on createNote persists end-to-end.
|
|
331
|
+
// 3. queryNotes filters by extension (single string + array shapes).
|
|
332
|
+
|
|
333
|
+
describe("notes.extension (vault#328)", async () => {
|
|
334
|
+
it("defaults to 'md' when not specified on createNote", async () => {
|
|
335
|
+
const note = await store.createNote("hello world");
|
|
336
|
+
expect(note.extension).toBe("md");
|
|
337
|
+
const fetched = await store.getNote(note.id);
|
|
338
|
+
expect(fetched!.extension).toBe("md");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("persists explicit extension on createNote", async () => {
|
|
342
|
+
const note = await store.createNote("month,income\n2026-01,12000", {
|
|
343
|
+
path: "Tabular/budget",
|
|
344
|
+
extension: "csv",
|
|
345
|
+
});
|
|
346
|
+
expect(note.extension).toBe("csv");
|
|
347
|
+
const fetched = await store.getNote(note.id);
|
|
348
|
+
expect(fetched!.extension).toBe("csv");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("backfills 'md' on existing rows after v17 → v18 migration", () => {
|
|
352
|
+
// Build a v17-shape vault by hand: create the notes table WITHOUT the
|
|
353
|
+
// `extension` column, insert a row, then run initSchema and assert
|
|
354
|
+
// the migration backfills "md".
|
|
355
|
+
const raw = new Database(":memory:");
|
|
356
|
+
raw.exec(`
|
|
357
|
+
CREATE TABLE notes (
|
|
358
|
+
id TEXT PRIMARY KEY,
|
|
359
|
+
content TEXT DEFAULT '',
|
|
360
|
+
path TEXT,
|
|
361
|
+
metadata TEXT DEFAULT '{}',
|
|
362
|
+
created_at TEXT NOT NULL,
|
|
363
|
+
updated_at TEXT
|
|
364
|
+
)
|
|
365
|
+
`);
|
|
366
|
+
raw.prepare(
|
|
367
|
+
"INSERT INTO notes (id, content, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
|
368
|
+
).run("legacy", "old content", "2024-01-01T00:00:00.000Z", "2024-01-01T00:00:00.000Z");
|
|
369
|
+
|
|
370
|
+
initSchema(raw); // applies v18 ALTER TABLE
|
|
371
|
+
|
|
372
|
+
const row = raw.prepare("SELECT extension FROM notes WHERE id = ?").get("legacy") as {
|
|
373
|
+
extension: string;
|
|
374
|
+
};
|
|
375
|
+
expect(row.extension).toBe("md");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("updateNote changes extension on existing note", async () => {
|
|
379
|
+
const note = await store.createNote("hello", { path: "Foo" });
|
|
380
|
+
expect(note.extension).toBe("md");
|
|
381
|
+
const updated = await store.updateNote(note.id, { extension: "mdx" });
|
|
382
|
+
expect(updated.extension).toBe("mdx");
|
|
383
|
+
const fetched = await store.getNote(note.id);
|
|
384
|
+
expect(fetched!.extension).toBe("mdx");
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("queryNotes filters by extension (single string)", async () => {
|
|
388
|
+
await store.createNote("md note A", { path: "a", extension: "md" });
|
|
389
|
+
await store.createNote("csv note", { path: "b", extension: "csv" });
|
|
390
|
+
await store.createNote("md note B", { path: "c" }); // default md
|
|
391
|
+
const csv = await store.queryNotes({ extension: "csv" });
|
|
392
|
+
expect(csv).toHaveLength(1);
|
|
393
|
+
expect(csv[0]!.path).toBe("b");
|
|
394
|
+
const md = await store.queryNotes({ extension: "md" });
|
|
395
|
+
expect(md).toHaveLength(2);
|
|
396
|
+
expect(md.map((n) => n.path).sort()).toEqual(["a", "c"]);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("queryNotes filters by extension (array — IN clause)", async () => {
|
|
400
|
+
await store.createNote("md note", { path: "a" });
|
|
401
|
+
await store.createNote("csv note", { path: "b", extension: "csv" });
|
|
402
|
+
await store.createNote("yaml note", { path: "c", extension: "yaml" });
|
|
403
|
+
await store.createNote("json note", { path: "d", extension: "json" });
|
|
404
|
+
const results = await store.queryNotes({ extension: ["csv", "yaml", "json"] });
|
|
405
|
+
expect(results).toHaveLength(3);
|
|
406
|
+
const paths = results.map((n) => n.path).sort();
|
|
407
|
+
expect(paths).toEqual(["b", "c", "d"]);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("queryNotes extension filter is case-insensitive", async () => {
|
|
411
|
+
await store.createNote("csv note", { path: "b", extension: "csv" });
|
|
412
|
+
// Caller-supplied case shouldn't matter — stored as "csv", looked up as "CSV".
|
|
413
|
+
const results = await store.queryNotes({ extension: "CSV" });
|
|
414
|
+
expect(results).toHaveLength(1);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("updateNote extension-only collision throws PathConflictError (vault#329 F1)", async () => {
|
|
418
|
+
// Two notes share `Foo` differing only by extension — legal under
|
|
419
|
+
// v18's composite (path, extension) uniqueness. Flip the md note's
|
|
420
|
+
// extension to "csv": that would collide with the existing csv
|
|
421
|
+
// row. The catch in updateNote must surface PATH_CONFLICT (not a
|
|
422
|
+
// raw SQLiteError) since the composite index fires UNIQUE on
|
|
423
|
+
// extension-only updates just like it does on path-only updates.
|
|
424
|
+
const md = await store.createNote("# md note", { path: "Foo", id: "foo-md" });
|
|
425
|
+
await store.createNote("a,b\n1,2", { path: "Foo", extension: "csv", id: "foo-csv" });
|
|
426
|
+
expect(
|
|
427
|
+
store.updateNote(md.id, { extension: "csv" }),
|
|
428
|
+
).rejects.toMatchObject({ code: "PATH_CONFLICT", path: "Foo" });
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("getNoteByPath throws AmbiguousPathError when path matches multiple extensions (vault#330 S1)", async () => {
|
|
432
|
+
await store.createNote("# md", { path: "Foo", id: "foo-md" });
|
|
433
|
+
await store.createNote("a,b\n1,2", { path: "Foo", extension: "csv", id: "foo-csv" });
|
|
434
|
+
expect(store.getNoteByPath("Foo")).rejects.toMatchObject({
|
|
435
|
+
code: "AMBIGUOUS_PATH",
|
|
436
|
+
path: "Foo",
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("getNoteByPath returns the right note when extension is passed (vault#330 S1)", async () => {
|
|
441
|
+
await store.createNote("# md", { path: "Foo", id: "foo-md" });
|
|
442
|
+
await store.createNote("a,b\n1,2", { path: "Foo", extension: "csv", id: "foo-csv" });
|
|
443
|
+
const md = await store.getNoteByPath("Foo", "md");
|
|
444
|
+
const csv = await store.getNoteByPath("Foo", "csv");
|
|
445
|
+
expect(md!.id).toBe("foo-md");
|
|
446
|
+
expect(csv!.id).toBe("foo-csv");
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("getNoteByPath returns single match unchanged when no ambiguity (vault#330 S1 back-compat)", async () => {
|
|
450
|
+
await store.createNote("# only", { path: "Foo", id: "only" });
|
|
451
|
+
const note = await store.getNoteByPath("Foo");
|
|
452
|
+
expect(note!.id).toBe("only");
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("getNoteByPath returns null for unknown path (vault#330 S1 back-compat)", async () => {
|
|
456
|
+
const note = await store.getNoteByPath("DoesNotExist");
|
|
457
|
+
expect(note).toBeNull();
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
325
461
|
// ---- Tags ----
|
|
326
462
|
|
|
327
463
|
describe("tags", async () => {
|
|
@@ -1589,6 +1725,91 @@ describe("MCP tools", async () => {
|
|
|
1589
1725
|
expect(result[1].tags).toContain("doc");
|
|
1590
1726
|
});
|
|
1591
1727
|
|
|
1728
|
+
it("create-note accepts extension field (vault#328)", async () => {
|
|
1729
|
+
const tools = generateMcpTools(store);
|
|
1730
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
1731
|
+
const result = await createNote.execute({
|
|
1732
|
+
content: "month,income\n2026-01,12000",
|
|
1733
|
+
path: "Tabular/budget",
|
|
1734
|
+
extension: "csv",
|
|
1735
|
+
}) as any;
|
|
1736
|
+
expect(result.extension).toBe("csv");
|
|
1737
|
+
});
|
|
1738
|
+
|
|
1739
|
+
it("create-note defaults extension to 'md' when omitted (vault#328)", async () => {
|
|
1740
|
+
const tools = generateMcpTools(store);
|
|
1741
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
1742
|
+
const result = await createNote.execute({ content: "plain markdown" }) as any;
|
|
1743
|
+
expect(result.extension).toBe("md");
|
|
1744
|
+
});
|
|
1745
|
+
|
|
1746
|
+
it("create-note rejects invalid extension (uppercase, dot, reserved) (vault#328)", async () => {
|
|
1747
|
+
const tools = generateMcpTools(store);
|
|
1748
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
1749
|
+
// Uppercase
|
|
1750
|
+
expect(createNote.execute({ content: "x", extension: "CSV" })).rejects.toThrow(/invalid extension/);
|
|
1751
|
+
// Dot
|
|
1752
|
+
expect(createNote.execute({ content: "x", extension: "csv.bak" })).rejects.toThrow(/invalid extension/);
|
|
1753
|
+
// Slash
|
|
1754
|
+
expect(createNote.execute({ content: "x", extension: "foo/bar" })).rejects.toThrow(/invalid extension/);
|
|
1755
|
+
// Reserved "parachute" prefix (lowercase — the pattern check passes,
|
|
1756
|
+
// so the reserved-prefix guard is what fires).
|
|
1757
|
+
expect(createNote.execute({ content: "x", extension: "parachute" })).rejects.toThrow(/reserved/);
|
|
1758
|
+
expect(createNote.execute({ content: "x", extension: "parachutex" })).rejects.toThrow(/reserved/);
|
|
1759
|
+
// Too long (>16)
|
|
1760
|
+
expect(createNote.execute({ content: "x", extension: "a".repeat(17) })).rejects.toThrow(/invalid extension/);
|
|
1761
|
+
// Empty
|
|
1762
|
+
expect(createNote.execute({ content: "x", extension: "" })).rejects.toThrow(/non-empty/);
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
it("update-note changes extension (vault#328)", async () => {
|
|
1766
|
+
const note = await store.createNote("hi", { path: "Foo" });
|
|
1767
|
+
const tools = generateMcpTools(store);
|
|
1768
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1769
|
+
const result = await updateNote.execute({ id: note.id, extension: "mdx", force: true }) as any;
|
|
1770
|
+
expect(result.extension).toBe("mdx");
|
|
1771
|
+
});
|
|
1772
|
+
|
|
1773
|
+
it("update-note validates extension on update branch (vault#328)", async () => {
|
|
1774
|
+
const note = await store.createNote("hi", { path: "Foo" });
|
|
1775
|
+
const tools = generateMcpTools(store);
|
|
1776
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1777
|
+
expect(
|
|
1778
|
+
updateNote.execute({ id: note.id, extension: "BAD", force: true }),
|
|
1779
|
+
).rejects.toThrow(/invalid extension/);
|
|
1780
|
+
});
|
|
1781
|
+
|
|
1782
|
+
it("update-note if_missing=create honors extension (vault#328)", async () => {
|
|
1783
|
+
const tools = generateMcpTools(store);
|
|
1784
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1785
|
+
const result = await updateNote.execute({
|
|
1786
|
+
id: "Tabular/new-budget",
|
|
1787
|
+
content: "month,total\n2026-02,9000",
|
|
1788
|
+
extension: "csv",
|
|
1789
|
+
if_missing: "create",
|
|
1790
|
+
}) as any;
|
|
1791
|
+
expect(result.created).toBe(true);
|
|
1792
|
+
expect(result.extension).toBe("csv");
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
it("query-notes filters by extension (vault#328)", async () => {
|
|
1796
|
+
await store.createNote("md note", { path: "a" });
|
|
1797
|
+
await store.createNote("csv note", { path: "b", extension: "csv" });
|
|
1798
|
+
await store.createNote("yaml note", { path: "c", extension: "yaml" });
|
|
1799
|
+
const tools = generateMcpTools(store);
|
|
1800
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
1801
|
+
|
|
1802
|
+
// Single extension
|
|
1803
|
+
const csv = await queryNotes.execute({ extension: "csv", include_content: true }) as any[];
|
|
1804
|
+
expect(csv).toHaveLength(1);
|
|
1805
|
+
expect(csv[0].path).toBe("b");
|
|
1806
|
+
|
|
1807
|
+
// Array shape
|
|
1808
|
+
const both = await queryNotes.execute({ extension: ["csv", "yaml"], include_content: true }) as any[];
|
|
1809
|
+
expect(both).toHaveLength(2);
|
|
1810
|
+
expect(both.map((n) => n.path).sort()).toEqual(["b", "c"]);
|
|
1811
|
+
});
|
|
1812
|
+
|
|
1592
1813
|
it("create-note with links resolves targets by path", async () => {
|
|
1593
1814
|
const tools = generateMcpTools(store);
|
|
1594
1815
|
const createNote = tools.find((t) => t.name === "create-note")!;
|
package/core/src/mcp.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import type { Store, Note } from "./types.js";
|
|
3
3
|
import * as noteOps from "./notes.js";
|
|
4
|
-
import { filterMetadata, MAX_BATCH_SIZE } from "./notes.js";
|
|
4
|
+
import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
|
|
5
5
|
import * as linkOps from "./links.js";
|
|
6
6
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
7
7
|
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
@@ -26,14 +26,33 @@ export interface McpToolDef {
|
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* Resolve a note identifier — tries ID first, then case-insensitive
|
|
30
|
-
* Works everywhere a note reference is accepted.
|
|
29
|
+
* Resolve a note identifier — tries ID first, then case-insensitive
|
|
30
|
+
* path match. Works everywhere a note reference is accepted.
|
|
31
|
+
*
|
|
32
|
+
* Path-with-extension form (vault#330 S1): a trailing `.<ext>` matching
|
|
33
|
+
* the extension pattern (`/^[a-z0-9]{1,16}$/i`) is parsed as
|
|
34
|
+
* `(path, extension)` to disambiguate notes that share a path
|
|
35
|
+
* differing only by extension. Mirrors the wikilink ambiguity policy
|
|
36
|
+
* from vault#328.
|
|
37
|
+
*
|
|
38
|
+
* On ambiguous path with no extension hint, `getNoteByPath` throws
|
|
39
|
+
* `AmbiguousPathError` — `resolveNote` propagates it so MCP / REST
|
|
40
|
+
* handlers can surface a clear 4xx rather than picking arbitrarily.
|
|
31
41
|
*/
|
|
32
42
|
function resolveNote(db: Database, idOrPath: string): Note | null {
|
|
33
43
|
// Try ID match first (fast, indexed)
|
|
34
44
|
const byId = noteOps.getNote(db, idOrPath);
|
|
35
45
|
if (byId) return byId;
|
|
36
|
-
//
|
|
46
|
+
// Path-with-extension form: `Tabular/budget.csv` → (path="Tabular/
|
|
47
|
+
// budget", extension="csv"). Only kicks in when the suffix looks
|
|
48
|
+
// like an extension AND a `(path, ext)` row exists. Fall through to
|
|
49
|
+
// the no-extension lookup if not (so `Recipe.v2` where `v2` isn't a
|
|
50
|
+
// real extension still finds Recipe.v2 by exact-path).
|
|
51
|
+
const extMatch = idOrPath.match(/^(.*)\.([a-z0-9]{1,16})$/i);
|
|
52
|
+
if (extMatch) {
|
|
53
|
+
const explicit = noteOps.getNoteByPath(db, extMatch[1]!, extMatch[2]!);
|
|
54
|
+
if (explicit) return explicit;
|
|
55
|
+
}
|
|
37
56
|
return noteOps.getNoteByPath(db, idOrPath);
|
|
38
57
|
}
|
|
39
58
|
|
|
@@ -133,6 +152,13 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
133
152
|
has_links: { type: "boolean", description: "Presence filter: true = only notes with at least one inbound or outbound link; false = only orphaned notes (no links in either direction)." },
|
|
134
153
|
path: { type: "string", description: "Exact path match (case-insensitive)" },
|
|
135
154
|
path_prefix: { type: "string", description: "Path prefix match (e.g., 'Projects/')" },
|
|
155
|
+
extension: {
|
|
156
|
+
oneOf: [
|
|
157
|
+
{ type: "string" },
|
|
158
|
+
{ type: "array", items: { type: "string" } },
|
|
159
|
+
],
|
|
160
|
+
description: "Filter by file extension (vault#328). Pass a single extension (e.g. \"csv\") or an array (e.g. [\"csv\", \"yaml\", \"json\"]). Notes default to \"md\"; case-insensitive match.",
|
|
161
|
+
},
|
|
136
162
|
search: { type: "string", description: "Full-text search query" },
|
|
137
163
|
metadata: {
|
|
138
164
|
type: "object",
|
|
@@ -264,6 +290,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
264
290
|
hasLinks: params.has_links as boolean | undefined,
|
|
265
291
|
path: params.path as string | undefined,
|
|
266
292
|
pathPrefix: params.path_prefix as string | undefined,
|
|
293
|
+
extension: params.extension as string | string[] | undefined,
|
|
267
294
|
// Push the near-scope into the SQL WHERE so that LIMIT and ORDER
|
|
268
295
|
// BY apply to the neighborhood. Without this, queryNotes would
|
|
269
296
|
// fetch the first `limit` notes by created_at and then post-
|
|
@@ -339,6 +366,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
339
366
|
// Single note fields
|
|
340
367
|
content: { type: "string", description: "Note content (markdown). Wikilinks like [[Target]] auto-resolve." },
|
|
341
368
|
path: { type: "string", description: "Note path (e.g., 'Projects/README')" },
|
|
369
|
+
extension: { type: "string", description: "File extension (vault#328). Default \"md\". Use \"csv\"/\"yaml\"/\"json\"/\"mdx\"/etc. for non-markdown notes. Lowercase alphanumeric, 1–16 chars; no '.' or '/'. The \"parachute\" prefix is reserved." },
|
|
342
370
|
metadata: { type: "object", description: "Metadata fields" },
|
|
343
371
|
tags: { type: "array", items: { type: "string" }, description: "Tags to apply" },
|
|
344
372
|
links: {
|
|
@@ -362,6 +390,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
362
390
|
properties: {
|
|
363
391
|
content: { type: "string" },
|
|
364
392
|
path: { type: "string" },
|
|
393
|
+
extension: { type: "string", description: "File extension (vault#328). See top-level docs." },
|
|
365
394
|
metadata: { type: "object" },
|
|
366
395
|
tags: { type: "array", items: { type: "string" } },
|
|
367
396
|
links: { type: "array" },
|
|
@@ -392,11 +421,19 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
392
421
|
if (batched) db.exec("BEGIN");
|
|
393
422
|
try {
|
|
394
423
|
for (const item of items) {
|
|
424
|
+
// Validate extension up front (vault#328). Throwing here while
|
|
425
|
+
// we're inside the BEGIN block on a batch rolls back the
|
|
426
|
+
// transaction in the outer catch — the same behavior as a
|
|
427
|
+
// path conflict mid-batch.
|
|
428
|
+
const extension = item.extension !== undefined
|
|
429
|
+
? validateExtension(item.extension)
|
|
430
|
+
: undefined;
|
|
395
431
|
const note = await store.createNote(item.content as string ?? "", {
|
|
396
432
|
path: item.path as string | undefined,
|
|
397
433
|
tags: item.tags as string[] | undefined,
|
|
398
434
|
metadata: item.metadata as Record<string, unknown> | undefined,
|
|
399
435
|
created_at: item.created_at as string | undefined,
|
|
436
|
+
...(extension !== undefined ? { extension } : {}),
|
|
400
437
|
});
|
|
401
438
|
|
|
402
439
|
// Create explicit links (not wikilinks — those are automatic)
|
|
@@ -467,6 +504,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
467
504
|
description: "Find-and-replace one occurrence. Errors if `old_text` is not found or matches multiple locations. Mutually exclusive with `content` and `append`/`prepend`.",
|
|
468
505
|
},
|
|
469
506
|
path: { type: "string", description: "New path" },
|
|
507
|
+
extension: { type: "string", description: "Change the note's file extension (vault#328). Allowed but caller-owned — you're responsible for content validity if you switch a non-empty note's extension. Lowercase alphanumeric, 1–16 chars; \"parachute\" prefix reserved." },
|
|
470
508
|
metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale)" },
|
|
471
509
|
created_at: { type: "string", description: "New created_at timestamp" },
|
|
472
510
|
if_updated_at: { type: "string", description: "Optimistic concurrency check: the updated_at value you last read. Rejects with a conflict error if the note has been modified since. Required unless `force: true` is set or the call is `append`/`prepend`-only." },
|
|
@@ -531,6 +569,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
531
569
|
required: ["old_text", "new_text"],
|
|
532
570
|
},
|
|
533
571
|
path: { type: "string" },
|
|
572
|
+
extension: { type: "string", description: "Change the note's file extension (vault#328). See top-level docs." },
|
|
534
573
|
metadata: { type: "object" },
|
|
535
574
|
created_at: { type: "string" },
|
|
536
575
|
if_updated_at: { type: "string", description: "Optimistic concurrency check for this item; rejects with a conflict error if the note has been modified since. Required unless `force: true` is set on this item or the item is `append`/`prepend`-only." },
|
|
@@ -617,6 +656,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
617
656
|
// caller's intent.
|
|
618
657
|
const idLooksLikePath = idOrPath.includes("/") || !/^[A-Za-z0-9_-]+$/.test(idOrPath);
|
|
619
658
|
const explicitPath = typeof item.path === "string" ? item.path as string : undefined;
|
|
659
|
+
// Validate extension before reaching the Store — same
|
|
660
|
+
// contract as the create-note tool.
|
|
661
|
+
const createExt = item.extension !== undefined
|
|
662
|
+
? validateExtension(item.extension)
|
|
663
|
+
: undefined;
|
|
620
664
|
const createOpts: Parameters<Store["createNote"]>[1] = {
|
|
621
665
|
...(idLooksLikePath ? { path: explicitPath ?? idOrPath } : { id: idOrPath, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
|
|
622
666
|
...(item.tags && Array.isArray((item.tags as any).add)
|
|
@@ -626,6 +670,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
626
670
|
: {}),
|
|
627
671
|
...(item.metadata !== undefined ? { metadata: item.metadata as Record<string, unknown> } : {}),
|
|
628
672
|
...(item.created_at !== undefined ? { created_at: item.created_at as string } : {}),
|
|
673
|
+
...(createExt !== undefined ? { extension: createExt } : {}),
|
|
629
674
|
};
|
|
630
675
|
const content = (item.content as string | undefined) ?? "";
|
|
631
676
|
const created = await store.createNote(content, createOpts);
|
|
@@ -755,6 +800,9 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
755
800
|
if (item.prepend !== undefined) updates.prepend = item.prepend;
|
|
756
801
|
}
|
|
757
802
|
if (item.path !== undefined) updates.path = item.path;
|
|
803
|
+
if (item.extension !== undefined) {
|
|
804
|
+
updates.extension = validateExtension(item.extension);
|
|
805
|
+
}
|
|
758
806
|
if (item.metadata !== undefined) {
|
|
759
807
|
// Merge metadata (don't replace wholesale)
|
|
760
808
|
const existing = (note.metadata as Record<string, unknown>) ?? {};
|
|
@@ -1233,8 +1281,10 @@ function normalizeTags(tag: unknown): string[] | undefined {
|
|
|
1233
1281
|
}
|
|
1234
1282
|
|
|
1235
1283
|
// Re-exported for backward compat; defined in notes.ts alongside the
|
|
1236
|
-
// conditional-UPDATE implementation that raises it.
|
|
1237
|
-
|
|
1284
|
+
// conditional-UPDATE implementation that raises it. AmbiguousPathError
|
|
1285
|
+
// joins the set (vault#331 N2) so external callers can `instanceof`
|
|
1286
|
+
// it without crossing module boundaries.
|
|
1287
|
+
export { ConflictError, PathConflictError, AmbiguousPathError, MAX_BATCH_SIZE } from "./notes.js";
|
|
1238
1288
|
|
|
1239
1289
|
/**
|
|
1240
1290
|
* Thrown by the `update-note` MCP tool (and the REST PATCH handler) when a
|