@cad0p/napkin 0.8.1-20260601.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.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +346 -0
  3. package/bin/napkin.js +19 -0
  4. package/package.json +77 -0
  5. package/src/commands/aliases.test.ts +64 -0
  6. package/src/commands/aliases.ts +31 -0
  7. package/src/commands/bases.test.ts +88 -0
  8. package/src/commands/bases.ts +174 -0
  9. package/src/commands/bookmarks.test.ts +103 -0
  10. package/src/commands/bookmarks.ts +78 -0
  11. package/src/commands/canvas.test.ts +257 -0
  12. package/src/commands/canvas.ts +255 -0
  13. package/src/commands/cli-positional.test.ts +208 -0
  14. package/src/commands/config.ts +71 -0
  15. package/src/commands/crud.test.ts +185 -0
  16. package/src/commands/crud.ts +251 -0
  17. package/src/commands/daily.test.ts +105 -0
  18. package/src/commands/daily.ts +74 -0
  19. package/src/commands/exit-codes.test.ts +235 -0
  20. package/src/commands/files.test.ts +78 -0
  21. package/src/commands/files.ts +167 -0
  22. package/src/commands/graph.ts +500 -0
  23. package/src/commands/init.test.ts +299 -0
  24. package/src/commands/init.ts +70 -0
  25. package/src/commands/links.test.ts +98 -0
  26. package/src/commands/links.ts +142 -0
  27. package/src/commands/outline.test.ts +48 -0
  28. package/src/commands/outline.ts +61 -0
  29. package/src/commands/overview.test.ts +201 -0
  30. package/src/commands/overview.ts +63 -0
  31. package/src/commands/properties.test.ts +108 -0
  32. package/src/commands/properties.ts +146 -0
  33. package/src/commands/search.test.ts +273 -0
  34. package/src/commands/search.ts +71 -0
  35. package/src/commands/tags.test.ts +76 -0
  36. package/src/commands/tags.ts +70 -0
  37. package/src/commands/tasks.test.ts +137 -0
  38. package/src/commands/tasks.ts +136 -0
  39. package/src/commands/templates.test.ts +99 -0
  40. package/src/commands/templates.ts +94 -0
  41. package/src/commands/vault.test.ts +63 -0
  42. package/src/commands/vault.ts +19 -0
  43. package/src/commands/wordcount.test.ts +53 -0
  44. package/src/commands/wordcount.ts +55 -0
  45. package/src/core/aliases.ts +34 -0
  46. package/src/core/bases.ts +93 -0
  47. package/src/core/bookmarks.ts +49 -0
  48. package/src/core/canvas.ts +226 -0
  49. package/src/core/config.ts +42 -0
  50. package/src/core/core.test.ts +326 -0
  51. package/src/core/crud.ts +198 -0
  52. package/src/core/daily.ts +135 -0
  53. package/src/core/files.ts +64 -0
  54. package/src/core/init.ts +205 -0
  55. package/src/core/links.ts +78 -0
  56. package/src/core/outline.ts +16 -0
  57. package/src/core/overview.ts +524 -0
  58. package/src/core/properties.ts +93 -0
  59. package/src/core/search.ts +208 -0
  60. package/src/core/tags.ts +56 -0
  61. package/src/core/tasks.ts +156 -0
  62. package/src/core/templates.ts +91 -0
  63. package/src/core/vault.ts +46 -0
  64. package/src/core/wordcount.ts +24 -0
  65. package/src/index.ts +28 -0
  66. package/src/main.ts +852 -0
  67. package/src/sdk.test.ts +392 -0
  68. package/src/sdk.ts +464 -0
  69. package/src/templates/coding.ts +106 -0
  70. package/src/templates/company.ts +123 -0
  71. package/src/templates/index.ts +21 -0
  72. package/src/templates/personal.ts +93 -0
  73. package/src/templates/product.ts +125 -0
  74. package/src/templates/research.ts +116 -0
  75. package/src/templates/types.ts +7 -0
  76. package/src/types/glimpseui.d.ts +18 -0
  77. package/src/types.d.ts +25 -0
  78. package/src/utils/bases.test.ts +646 -0
  79. package/src/utils/bases.ts +792 -0
  80. package/src/utils/config.ts +173 -0
  81. package/src/utils/exit-codes.ts +5 -0
  82. package/src/utils/files.test.ts +384 -0
  83. package/src/utils/files.ts +355 -0
  84. package/src/utils/formula.ts +511 -0
  85. package/src/utils/frontmatter.test.ts +70 -0
  86. package/src/utils/frontmatter.ts +47 -0
  87. package/src/utils/markdown.test.ts +95 -0
  88. package/src/utils/markdown.ts +115 -0
  89. package/src/utils/output.ts +65 -0
  90. package/src/utils/search-cache.test.ts +117 -0
  91. package/src/utils/search-cache.ts +71 -0
  92. package/src/utils/test-helpers.ts +49 -0
  93. package/src/utils/vault.test.ts +236 -0
  94. package/src/utils/vault.ts +186 -0
@@ -0,0 +1,174 @@
1
+ import type { BaseQueryResult } from "../core/bases.js";
2
+ import { Napkin } from "../sdk.js";
3
+ import { EXIT_USER_ERROR } from "../utils/exit-codes.js";
4
+ import {
5
+ bold,
6
+ dim,
7
+ error,
8
+ type OutputOptions,
9
+ output,
10
+ success,
11
+ } from "../utils/output.js";
12
+
13
+ export async function bases(opts: OutputOptions & { vault?: string }) {
14
+ const n = new Napkin(opts.vault || process.cwd());
15
+ const files = n.bases();
16
+
17
+ output(opts, {
18
+ json: () => ({ bases: files }),
19
+ human: () => {
20
+ if (files.length === 0) {
21
+ console.log("No .base files found");
22
+ } else {
23
+ for (const f of files) console.log(f);
24
+ }
25
+ },
26
+ });
27
+ }
28
+
29
+ export async function baseViews(
30
+ opts: OutputOptions & { vault?: string; file?: string; path?: string },
31
+ ) {
32
+ const n = new Napkin(opts.vault || process.cwd());
33
+ let views: { name: string; type: string }[];
34
+ try {
35
+ views = n.baseViews(opts);
36
+ } catch (e: unknown) {
37
+ error((e as Error).message);
38
+ process.exit(EXIT_USER_ERROR);
39
+ }
40
+
41
+ output(opts, {
42
+ json: () => ({ views }),
43
+ human: () => {
44
+ for (const view of views) {
45
+ console.log(`${bold(view.name)} ${dim(view.type)}`);
46
+ }
47
+ },
48
+ });
49
+ }
50
+
51
+ export async function baseQuery(
52
+ opts: OutputOptions & {
53
+ vault?: string;
54
+ file?: string;
55
+ path?: string;
56
+ view?: string;
57
+ format?: string;
58
+ },
59
+ ) {
60
+ const n = new Napkin(opts.vault || process.cwd());
61
+ let result: BaseQueryResult;
62
+ try {
63
+ result = await n.baseQuery(opts, opts.view);
64
+ } catch (e: unknown) {
65
+ error((e as Error).message);
66
+ process.exit(EXIT_USER_ERROR);
67
+ }
68
+ const fmt = opts.format || "json";
69
+
70
+ const displayCols = result.columns.map((c) => result.displayNames?.[c] || c);
71
+
72
+ output(opts, {
73
+ json: () => {
74
+ if (fmt === "paths") {
75
+ const pathIdx = result.columns.indexOf("path");
76
+ return { paths: result.rows.map((r) => r[pathIdx]) };
77
+ }
78
+ // Convert to array of objects
79
+ const rows = result.rows.map((row) => {
80
+ const obj: Record<string, unknown> = {};
81
+ for (let i = 0; i < result.columns.length; i++) {
82
+ obj[result.columns[i]] = row[i];
83
+ }
84
+ return obj;
85
+ });
86
+ const out: Record<string, unknown> = { columns: result.columns, rows };
87
+ if (result.displayNames && Object.keys(result.displayNames).length > 0) {
88
+ out.displayNames = result.displayNames;
89
+ }
90
+ if (result.groups) {
91
+ out.groups = result.groups.map((g) => ({
92
+ key: g.key,
93
+ rows: g.rows.map((row) => {
94
+ const obj: Record<string, unknown> = {};
95
+ for (let i = 0; i < result.columns.length; i++) {
96
+ obj[result.columns[i]] = row[i];
97
+ }
98
+ return obj;
99
+ }),
100
+ }));
101
+ }
102
+ if (result.summaries) out.summaries = result.summaries;
103
+ return out;
104
+ },
105
+ human: () => {
106
+ if (result.rows.length === 0) {
107
+ console.log("No results");
108
+ return;
109
+ }
110
+
111
+ if (fmt === "paths") {
112
+ const pathIdx = result.columns.indexOf("path");
113
+ for (const row of result.rows) console.log(row[pathIdx]);
114
+ return;
115
+ }
116
+
117
+ if (fmt === "csv" || fmt === "tsv") {
118
+ const sep = fmt === "csv" ? "," : "\t";
119
+ console.log(displayCols.join(sep));
120
+ for (const row of result.rows) {
121
+ console.log(row.map((v) => (v === null ? "" : String(v))).join(sep));
122
+ }
123
+ return;
124
+ }
125
+
126
+ if (fmt === "md") {
127
+ console.log(`| ${displayCols.join(" | ")} |`);
128
+ console.log(`| ${displayCols.map(() => "---").join(" | ")} |`);
129
+ for (const row of result.rows) {
130
+ console.log(
131
+ `| ${row.map((v) => (v === null ? "" : String(v))).join(" | ")} |`,
132
+ );
133
+ }
134
+ return;
135
+ }
136
+
137
+ // Default: table-like
138
+ for (const row of result.rows) {
139
+ const obj: Record<string, unknown> = {};
140
+ for (let i = 0; i < result.columns.length; i++) {
141
+ if (row[i] !== null) obj[result.columns[i]] = row[i];
142
+ }
143
+ console.log(JSON.stringify(obj));
144
+ }
145
+ },
146
+ });
147
+ }
148
+
149
+ export async function baseCreate(
150
+ opts: OutputOptions & {
151
+ vault?: string;
152
+ file?: string;
153
+ path?: string;
154
+ name?: string;
155
+ content?: string;
156
+ },
157
+ ) {
158
+ const n = new Napkin(opts.vault || process.cwd());
159
+ if (!opts.name) {
160
+ error("No name specified. Use --name <name>");
161
+ process.exit(EXIT_USER_ERROR);
162
+ }
163
+
164
+ const result = n.baseCreate({
165
+ name: opts.name,
166
+ path: opts.path,
167
+ content: opts.content,
168
+ });
169
+
170
+ output(opts, {
171
+ json: () => result,
172
+ human: () => success(`Created ${result.path}`),
173
+ });
174
+ }
@@ -0,0 +1,103 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { createTempVault } from "../utils/test-helpers.js";
5
+ import { bookmark, bookmarks } from "./bookmarks.js";
6
+
7
+ let v: { path: string; vaultPath: string; cleanup: () => void };
8
+
9
+ async function captureJson(
10
+ fn: () => Promise<void>,
11
+ ): Promise<Record<string, unknown>> {
12
+ const orig = console.log;
13
+ const logs: string[] = [];
14
+ console.log = (...args: unknown[]) => logs.push(args.map(String).join(" "));
15
+ await fn();
16
+ console.log = orig;
17
+ return JSON.parse(logs.join(""));
18
+ }
19
+
20
+ beforeEach(() => {
21
+ v = createTempVault({});
22
+ // Write initial bookmarks
23
+ fs.writeFileSync(
24
+ path.join(v.vaultPath, ".obsidian", "bookmarks.json"),
25
+ JSON.stringify([
26
+ { type: "file", path: "note.md", title: "My Note" },
27
+ { type: "search", query: "TODO" },
28
+ ]),
29
+ );
30
+ });
31
+
32
+ afterEach(() => {
33
+ v.cleanup();
34
+ });
35
+
36
+ describe("bookmarks", () => {
37
+ test("lists bookmarks", async () => {
38
+ const data = await captureJson(() =>
39
+ bookmarks({ json: true, vault: v.path }),
40
+ );
41
+ const b = data.bookmarks as { type: string }[];
42
+ expect(b.length).toBe(2);
43
+ expect(b[0].type).toBe("file");
44
+ expect(b[1].type).toBe("search");
45
+ });
46
+
47
+ test("returns total", async () => {
48
+ const data = await captureJson(() =>
49
+ bookmarks({ json: true, vault: v.path, total: true }),
50
+ );
51
+ expect(data.total).toBe(2);
52
+ });
53
+
54
+ test("returns empty when no bookmarks file", async () => {
55
+ fs.unlinkSync(path.join(v.vaultPath, ".obsidian", "bookmarks.json"));
56
+ const data = await captureJson(() =>
57
+ bookmarks({ json: true, vault: v.path }),
58
+ );
59
+ expect((data.bookmarks as unknown[]).length).toBe(0);
60
+ });
61
+ });
62
+
63
+ describe("bookmark", () => {
64
+ test("adds a file bookmark", async () => {
65
+ await captureJson(() =>
66
+ bookmark({ json: true, vault: v.path, file: "new.md", title: "New" }),
67
+ );
68
+ const raw = fs.readFileSync(
69
+ path.join(v.vaultPath, ".obsidian", "bookmarks.json"),
70
+ "utf-8",
71
+ );
72
+ const items = JSON.parse(raw);
73
+ expect(items.length).toBe(3);
74
+ expect(items[2].type).toBe("file");
75
+ expect(items[2].path).toBe("new.md");
76
+ });
77
+
78
+ test("adds a search bookmark", async () => {
79
+ await captureJson(() =>
80
+ bookmark({ json: true, vault: v.path, search: "FIXME" }),
81
+ );
82
+ const raw = fs.readFileSync(
83
+ path.join(v.vaultPath, ".obsidian", "bookmarks.json"),
84
+ "utf-8",
85
+ );
86
+ const items = JSON.parse(raw);
87
+ expect(items[2].type).toBe("search");
88
+ expect(items[2].query).toBe("FIXME");
89
+ });
90
+
91
+ test("adds a URL bookmark", async () => {
92
+ await captureJson(() =>
93
+ bookmark({ json: true, vault: v.path, url: "https://example.com" }),
94
+ );
95
+ const raw = fs.readFileSync(
96
+ path.join(v.vaultPath, ".obsidian", "bookmarks.json"),
97
+ "utf-8",
98
+ );
99
+ const items = JSON.parse(raw);
100
+ expect(items[2].type).toBe("url");
101
+ expect(items[2].url).toBe("https://example.com");
102
+ });
103
+ });
@@ -0,0 +1,78 @@
1
+ import type { Bookmark } from "../core/bookmarks.js";
2
+ import { Napkin } from "../sdk.js";
3
+ import { EXIT_USER_ERROR } from "../utils/exit-codes.js";
4
+ import {
5
+ dim,
6
+ error,
7
+ type OutputOptions,
8
+ output,
9
+ success,
10
+ } from "../utils/output.js";
11
+
12
+ export async function bookmarks(
13
+ opts: OutputOptions & {
14
+ vault?: string;
15
+ total?: boolean;
16
+ verbose?: boolean;
17
+ },
18
+ ) {
19
+ const n = new Napkin(opts.vault || process.cwd());
20
+ const flat = n.bookmarks();
21
+
22
+ output(opts, {
23
+ json: () => (opts.total ? { total: flat.length } : { bookmarks: flat }),
24
+ human: () => {
25
+ if (opts.total) {
26
+ console.log(flat.length);
27
+ } else {
28
+ for (const b of flat) {
29
+ const label = b.title || b.path || b.query || b.url || "(untitled)";
30
+ console.log(opts.verbose ? `${label}\t${dim(b.type)}` : label);
31
+ }
32
+ }
33
+ },
34
+ });
35
+ }
36
+
37
+ export async function bookmark(
38
+ opts: OutputOptions & {
39
+ vault?: string;
40
+ file?: string;
41
+ subpath?: string;
42
+ folder?: string;
43
+ search?: string;
44
+ url?: string;
45
+ title?: string;
46
+ },
47
+ ) {
48
+ const n = new Napkin(opts.vault || process.cwd());
49
+
50
+ let entry: Bookmark;
51
+ if (opts.file) {
52
+ entry = {
53
+ type: "file",
54
+ path: opts.file,
55
+ title: opts.title,
56
+ subpath: opts.subpath,
57
+ };
58
+ } else if (opts.folder) {
59
+ entry = { type: "folder", path: opts.folder, title: opts.title };
60
+ } else if (opts.search) {
61
+ entry = { type: "search", query: opts.search, title: opts.title };
62
+ } else if (opts.url) {
63
+ entry = { type: "url", url: opts.url, title: opts.title };
64
+ } else {
65
+ error("Specify --file, --folder, --search, or --url to bookmark");
66
+ process.exit(EXIT_USER_ERROR);
67
+ }
68
+
69
+ const result = n.bookmarkAdd(entry);
70
+
71
+ output(opts, {
72
+ json: () => result,
73
+ human: () =>
74
+ success(
75
+ `Bookmarked ${result.added.path || result.added.query || result.added.url}`,
76
+ ),
77
+ });
78
+ }
@@ -0,0 +1,257 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { createTempVault } from "../utils/test-helpers.js";
5
+ import {
6
+ canvasAddEdge,
7
+ canvasAddNode,
8
+ canvasCreate,
9
+ canvases,
10
+ canvasNodes,
11
+ canvasRead,
12
+ canvasRemoveNode,
13
+ } from "./canvas.js";
14
+
15
+ let v: { path: string; vaultPath: string; cleanup: () => void };
16
+
17
+ async function captureJson(
18
+ fn: () => Promise<void>,
19
+ ): Promise<Record<string, unknown>> {
20
+ const orig = console.log;
21
+ const logs: string[] = [];
22
+ console.log = (...args: unknown[]) => logs.push(args.map(String).join(" "));
23
+ await fn();
24
+ console.log = orig;
25
+ return JSON.parse(logs.join(""));
26
+ }
27
+
28
+ beforeEach(() => {
29
+ v = createTempVault({
30
+ "board.canvas": JSON.stringify({
31
+ nodes: [
32
+ {
33
+ id: "aabb11223344",
34
+ type: "text",
35
+ x: 0,
36
+ y: 0,
37
+ width: 300,
38
+ height: 150,
39
+ text: "# Hello\nWorld",
40
+ },
41
+ {
42
+ id: "ccdd55667788",
43
+ type: "file",
44
+ x: 400,
45
+ y: 0,
46
+ width: 300,
47
+ height: 200,
48
+ file: "Notes/note.md",
49
+ },
50
+ {
51
+ id: "eeff99001122",
52
+ type: "group",
53
+ x: -50,
54
+ y: -50,
55
+ width: 800,
56
+ height: 400,
57
+ label: "My Group",
58
+ },
59
+ ],
60
+ edges: [
61
+ {
62
+ id: "edge00112233",
63
+ fromNode: "aabb11223344",
64
+ fromSide: "right",
65
+ toNode: "ccdd55667788",
66
+ toSide: "left",
67
+ label: "links to",
68
+ },
69
+ ],
70
+ }),
71
+ "Notes/note.md": "# Note\nSome content",
72
+ });
73
+ });
74
+
75
+ afterEach(() => {
76
+ v.cleanup();
77
+ });
78
+
79
+ describe("canvases", () => {
80
+ test("lists canvas files", async () => {
81
+ const data = await captureJson(() =>
82
+ canvases({ json: true, vault: v.path }),
83
+ );
84
+ expect(data.canvases).toContain("board.canvas");
85
+ });
86
+
87
+ test("returns total", async () => {
88
+ const data = await captureJson(() =>
89
+ canvases({ json: true, vault: v.path, total: true }),
90
+ );
91
+ expect(data.total).toBe(1);
92
+ });
93
+ });
94
+
95
+ describe("canvasRead", () => {
96
+ test("reads canvas with nodes and edges", async () => {
97
+ const data = await captureJson(() =>
98
+ canvasRead({ json: true, vault: v.path, file: "board" }),
99
+ );
100
+ expect((data.nodes as unknown[]).length).toBe(3);
101
+ expect((data.edges as unknown[]).length).toBe(1);
102
+ });
103
+ });
104
+
105
+ describe("canvasNodes", () => {
106
+ test("lists all nodes", async () => {
107
+ const data = await captureJson(() =>
108
+ canvasNodes({ json: true, vault: v.path, file: "board" }),
109
+ );
110
+ expect((data.nodes as unknown[]).length).toBe(3);
111
+ });
112
+
113
+ test("filters by type", async () => {
114
+ const data = await captureJson(() =>
115
+ canvasNodes({ json: true, vault: v.path, file: "board", type: "text" }),
116
+ );
117
+ expect((data.nodes as unknown[]).length).toBe(1);
118
+ });
119
+ });
120
+
121
+ describe("canvasCreate", () => {
122
+ test("creates empty canvas", async () => {
123
+ const data = await captureJson(() =>
124
+ canvasCreate({ json: true, vault: v.path, file: "new-board" }),
125
+ );
126
+ expect(data.created).toBe(true);
127
+ const content = JSON.parse(
128
+ fs.readFileSync(path.join(v.vaultPath, "new-board.canvas"), "utf-8"),
129
+ );
130
+ expect(content.nodes).toEqual([]);
131
+ expect(content.edges).toEqual([]);
132
+ });
133
+ });
134
+
135
+ describe("canvasAddNode", () => {
136
+ test("adds a text node", async () => {
137
+ const data = await captureJson(() =>
138
+ canvasAddNode({
139
+ json: true,
140
+ vault: v.path,
141
+ file: "board",
142
+ type: "text",
143
+ text: "New node",
144
+ }),
145
+ );
146
+ expect(data.added).toBe(true);
147
+ expect(data.type).toBe("text");
148
+
149
+ const content = JSON.parse(
150
+ fs.readFileSync(path.join(v.vaultPath, "board.canvas"), "utf-8"),
151
+ );
152
+ expect(content.nodes.length).toBe(4);
153
+ const newNode = content.nodes[3];
154
+ expect(newNode.text).toBe("New node");
155
+ });
156
+
157
+ test("adds a file node", async () => {
158
+ const data = await captureJson(() =>
159
+ canvasAddNode({
160
+ json: true,
161
+ vault: v.path,
162
+ file: "board",
163
+ type: "file",
164
+ noteFile: "Notes/note.md",
165
+ }),
166
+ );
167
+ expect(data.type).toBe("file");
168
+ });
169
+
170
+ test("adds a link node", async () => {
171
+ const data = await captureJson(() =>
172
+ canvasAddNode({
173
+ json: true,
174
+ vault: v.path,
175
+ file: "board",
176
+ type: "link",
177
+ url: "https://example.com",
178
+ }),
179
+ );
180
+ expect(data.type).toBe("link");
181
+ });
182
+
183
+ test("auto-positions new nodes", async () => {
184
+ await canvasAddNode({
185
+ json: true,
186
+ vault: v.path,
187
+ file: "board",
188
+ type: "text",
189
+ text: "Auto",
190
+ });
191
+ const content = JSON.parse(
192
+ fs.readFileSync(path.join(v.vaultPath, "board.canvas"), "utf-8"),
193
+ );
194
+ const newNode = content.nodes[content.nodes.length - 1];
195
+ // Should be placed after the rightmost existing node (group at x:-50 + width:800 = 750, +50 gap)
196
+ expect(newNode.x).toBe(800);
197
+ });
198
+ });
199
+
200
+ describe("canvasAddEdge", () => {
201
+ test("adds an edge between nodes", async () => {
202
+ const data = await captureJson(() =>
203
+ canvasAddEdge({
204
+ json: true,
205
+ vault: v.path,
206
+ file: "board",
207
+ from: "aabb",
208
+ to: "eeff",
209
+ label: "belongs to",
210
+ }),
211
+ );
212
+ expect(data.added).toBe(true);
213
+
214
+ const content = JSON.parse(
215
+ fs.readFileSync(path.join(v.vaultPath, "board.canvas"), "utf-8"),
216
+ );
217
+ expect(content.edges.length).toBe(2);
218
+ const newEdge = content.edges[1];
219
+ expect(newEdge.fromNode).toBe("aabb11223344");
220
+ expect(newEdge.toNode).toBe("eeff99001122");
221
+ expect(newEdge.label).toBe("belongs to");
222
+ });
223
+
224
+ test("matches node IDs by prefix", async () => {
225
+ const data = await captureJson(() =>
226
+ canvasAddEdge({
227
+ json: true,
228
+ vault: v.path,
229
+ file: "board",
230
+ from: "ccdd",
231
+ to: "eeff",
232
+ }),
233
+ );
234
+ expect(data.from).toBe("ccdd55667788");
235
+ expect(data.to).toBe("eeff99001122");
236
+ });
237
+ });
238
+
239
+ describe("canvasRemoveNode", () => {
240
+ test("removes node and connected edges", async () => {
241
+ const data = await captureJson(() =>
242
+ canvasRemoveNode({
243
+ json: true,
244
+ vault: v.path,
245
+ file: "board",
246
+ id: "aabb",
247
+ }),
248
+ );
249
+ expect(data.removed).toBe(true);
250
+
251
+ const content = JSON.parse(
252
+ fs.readFileSync(path.join(v.vaultPath, "board.canvas"), "utf-8"),
253
+ );
254
+ expect(content.nodes.length).toBe(2);
255
+ expect(content.edges.length).toBe(0); // Edge connected to removed node also gone
256
+ });
257
+ });