@openparachute/vault 0.1.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 (103) hide show
  1. package/.claude/settings.local.json +31 -0
  2. package/.dockerignore +8 -0
  3. package/.env.example +9 -0
  4. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
  5. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
  6. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
  7. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
  8. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
  9. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
  10. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
  11. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
  12. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
  13. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
  14. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
  15. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
  16. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
  17. package/CLAUDE.md +115 -0
  18. package/Caddyfile +3 -0
  19. package/Dockerfile +22 -0
  20. package/LICENSE +661 -0
  21. package/README.md +356 -0
  22. package/bun.lock +219 -0
  23. package/bunfig.toml +2 -0
  24. package/core/package.json +7 -0
  25. package/core/src/core.test.ts +940 -0
  26. package/core/src/hooks.test.ts +361 -0
  27. package/core/src/hooks.ts +234 -0
  28. package/core/src/links.ts +352 -0
  29. package/core/src/mcp.ts +672 -0
  30. package/core/src/notes.ts +520 -0
  31. package/core/src/obsidian.test.ts +380 -0
  32. package/core/src/obsidian.ts +322 -0
  33. package/core/src/paths.test.ts +197 -0
  34. package/core/src/paths.ts +53 -0
  35. package/core/src/schema.ts +331 -0
  36. package/core/src/store.ts +303 -0
  37. package/core/src/tag-schemas.ts +104 -0
  38. package/core/src/test-preload.ts +8 -0
  39. package/core/src/types.ts +140 -0
  40. package/core/src/wikilinks.test.ts +277 -0
  41. package/core/src/wikilinks.ts +402 -0
  42. package/deploy/parachute-vault.service +20 -0
  43. package/docker-compose.yml +50 -0
  44. package/docs/HTTP_API.md +328 -0
  45. package/fly.toml +24 -0
  46. package/package.json +32 -0
  47. package/railway.json +14 -0
  48. package/religions-abrahamic-filter.png +0 -0
  49. package/religions-buddhism-v2.png +0 -0
  50. package/religions-buddhism.png +0 -0
  51. package/religions-final.png +0 -0
  52. package/religions-v1.png +0 -0
  53. package/religions-v2.png +0 -0
  54. package/religions-zen.png +0 -0
  55. package/scripts/migrate-audio-to-opus.test.ts +237 -0
  56. package/scripts/migrate-audio-to-opus.ts +499 -0
  57. package/src/auth.ts +170 -0
  58. package/src/cli.ts +1131 -0
  59. package/src/config-triggers.test.ts +83 -0
  60. package/src/config.test.ts +125 -0
  61. package/src/config.ts +716 -0
  62. package/src/db.ts +14 -0
  63. package/src/launchd.ts +109 -0
  64. package/src/mcp-http.ts +113 -0
  65. package/src/mcp-tools.ts +155 -0
  66. package/src/oauth.test.ts +1242 -0
  67. package/src/oauth.ts +729 -0
  68. package/src/owner-auth.ts +159 -0
  69. package/src/prompt.ts +141 -0
  70. package/src/published.test.ts +214 -0
  71. package/src/qrcode-terminal.d.ts +9 -0
  72. package/src/routes.ts +822 -0
  73. package/src/server.ts +450 -0
  74. package/src/systemd.ts +84 -0
  75. package/src/token-store.test.ts +174 -0
  76. package/src/token-store.ts +241 -0
  77. package/src/triggers.test.ts +397 -0
  78. package/src/triggers.ts +412 -0
  79. package/src/two-factor.test.ts +246 -0
  80. package/src/two-factor.ts +222 -0
  81. package/src/vault-store.ts +47 -0
  82. package/src/vault.test.ts +1309 -0
  83. package/tsconfig.json +29 -0
  84. package/web/README.md +73 -0
  85. package/web/bun.lock +827 -0
  86. package/web/eslint.config.js +23 -0
  87. package/web/index.html +15 -0
  88. package/web/package.json +36 -0
  89. package/web/public/favicon.svg +1 -0
  90. package/web/public/icons.svg +24 -0
  91. package/web/src/App.tsx +149 -0
  92. package/web/src/Graph.tsx +200 -0
  93. package/web/src/NoteView.tsx +155 -0
  94. package/web/src/Sidebar.tsx +186 -0
  95. package/web/src/api.ts +21 -0
  96. package/web/src/index.css +50 -0
  97. package/web/src/main.tsx +10 -0
  98. package/web/src/types.ts +37 -0
  99. package/web/src/utils.ts +107 -0
  100. package/web/tsconfig.app.json +25 -0
  101. package/web/tsconfig.json +7 -0
  102. package/web/tsconfig.node.json +24 -0
  103. package/web/vite.config.ts +15 -0
@@ -0,0 +1,277 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { SqliteStore } from "./store.js";
4
+ import { parseWikilinks, syncWikilinks, resolveWikilink, resolveUnresolvedWikilinks } from "./wikilinks.js";
5
+
6
+ let store: SqliteStore;
7
+ let db: Database;
8
+
9
+ beforeEach(() => {
10
+ db = new Database(":memory:");
11
+ store = new SqliteStore(db);
12
+ });
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Parser
16
+ // ---------------------------------------------------------------------------
17
+
18
+ describe("parseWikilinks", () => {
19
+ it("parses simple wikilinks", () => {
20
+ const links = parseWikilinks("Check out [[My Note]] for details.");
21
+ expect(links).toHaveLength(1);
22
+ expect(links[0].target).toBe("My Note");
23
+ expect(links[0].embed).toBe(false);
24
+ });
25
+
26
+ it("parses multiple wikilinks", () => {
27
+ const links = parseWikilinks("See [[Note A]] and [[Note B]].");
28
+ expect(links).toHaveLength(2);
29
+ expect(links[0].target).toBe("Note A");
30
+ expect(links[1].target).toBe("Note B");
31
+ });
32
+
33
+ it("parses aliased wikilinks", () => {
34
+ const links = parseWikilinks("See [[Real Name|display text]] here.");
35
+ expect(links).toHaveLength(1);
36
+ expect(links[0].target).toBe("Real Name");
37
+ expect(links[0].display).toBe("display text");
38
+ });
39
+
40
+ it("parses heading anchors", () => {
41
+ const links = parseWikilinks("See [[Note#Section One]].");
42
+ expect(links).toHaveLength(1);
43
+ expect(links[0].target).toBe("Note");
44
+ expect(links[0].anchor).toBe("Section One");
45
+ });
46
+
47
+ it("parses block references", () => {
48
+ const links = parseWikilinks("See [[Note#^abc123]].");
49
+ expect(links).toHaveLength(1);
50
+ expect(links[0].target).toBe("Note");
51
+ expect(links[0].blockRef).toBe("abc123");
52
+ });
53
+
54
+ it("parses heading + alias combo", () => {
55
+ const links = parseWikilinks("See [[Note#Heading|click here]].");
56
+ expect(links).toHaveLength(1);
57
+ expect(links[0].target).toBe("Note");
58
+ expect(links[0].anchor).toBe("Heading");
59
+ expect(links[0].display).toBe("click here");
60
+ });
61
+
62
+ it("parses embeds", () => {
63
+ const links = parseWikilinks("![[My Image]]");
64
+ expect(links).toHaveLength(1);
65
+ expect(links[0].target).toBe("My Image");
66
+ expect(links[0].embed).toBe(true);
67
+ });
68
+
69
+ it("parses nested paths", () => {
70
+ const links = parseWikilinks("See [[Projects/Parachute/README]].");
71
+ expect(links).toHaveLength(1);
72
+ expect(links[0].target).toBe("Projects/Parachute/README");
73
+ });
74
+
75
+ it("ignores wikilinks in code blocks", () => {
76
+ const content = `
77
+ Some text [[Real Link]]
78
+
79
+ \`\`\`
80
+ [[Not A Link]]
81
+ \`\`\`
82
+
83
+ More text
84
+ `;
85
+ const links = parseWikilinks(content);
86
+ expect(links).toHaveLength(1);
87
+ expect(links[0].target).toBe("Real Link");
88
+ });
89
+
90
+ it("ignores wikilinks in inline code", () => {
91
+ const links = parseWikilinks("Use `[[Not A Link]]` syntax for links.");
92
+ expect(links).toHaveLength(0);
93
+ });
94
+
95
+ it("handles empty content", () => {
96
+ expect(parseWikilinks("")).toHaveLength(0);
97
+ });
98
+
99
+ it("handles content with no wikilinks", () => {
100
+ expect(parseWikilinks("Just plain text.")).toHaveLength(0);
101
+ });
102
+
103
+ it("skips empty targets", () => {
104
+ const links = parseWikilinks("Empty [[]] link.");
105
+ expect(links).toHaveLength(0);
106
+ });
107
+ });
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Resolution
111
+ // ---------------------------------------------------------------------------
112
+
113
+ describe("resolveWikilink", () => {
114
+ it("resolves exact path match", () => {
115
+ store.createNote("Target note", { path: "My Note" });
116
+ const id = resolveWikilink(db, "My Note");
117
+ expect(id).toBeTruthy();
118
+ });
119
+
120
+ it("resolves case-insensitively", () => {
121
+ const note = store.createNote("Target", { path: "My Note" });
122
+ const id = resolveWikilink(db, "my note");
123
+ expect(id).toBe(note.id);
124
+ });
125
+
126
+ it("resolves basename match", () => {
127
+ const note = store.createNote("Deep note", { path: "Projects/Parachute/README" });
128
+ const id = resolveWikilink(db, "README");
129
+ expect(id).toBe(note.id);
130
+ });
131
+
132
+ it("returns null for ambiguous basename", () => {
133
+ store.createNote("A", { path: "Folder1/README" });
134
+ store.createNote("B", { path: "Folder2/README" });
135
+ const id = resolveWikilink(db, "README");
136
+ expect(id).toBeNull();
137
+ });
138
+
139
+ it("returns null for unresolvable target", () => {
140
+ const id = resolveWikilink(db, "Nonexistent Note");
141
+ expect(id).toBeNull();
142
+ });
143
+ });
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Sync
147
+ // ---------------------------------------------------------------------------
148
+
149
+ describe("syncWikilinks", () => {
150
+ it("creates links for resolved wikilinks", () => {
151
+ const target = store.createNote("Target", { path: "Target Note" });
152
+ const source = store.createNote("See [[Target Note]]");
153
+
154
+ const links = store.getLinks(source.id, { direction: "outbound" });
155
+ expect(links).toHaveLength(1);
156
+ expect(links[0].targetId).toBe(target.id);
157
+ expect(links[0].relationship).toBe("wikilink");
158
+ });
159
+
160
+ it("tracks unresolved wikilinks", () => {
161
+ const source = store.createNote("See [[Missing Note]]");
162
+
163
+ const links = store.getLinks(source.id, { direction: "outbound" });
164
+ expect(links).toHaveLength(0);
165
+
166
+ // Check unresolved table
167
+ const unresolved = db.prepare(
168
+ "SELECT * FROM unresolved_wikilinks WHERE source_id = ?",
169
+ ).all(source.id) as { source_id: string; target_path: string }[];
170
+ expect(unresolved).toHaveLength(1);
171
+ expect(unresolved[0].target_path).toBe("Missing Note");
172
+ });
173
+
174
+ it("resolves pending wikilinks when target note is created", () => {
175
+ const source = store.createNote("See [[Future Note]]");
176
+
177
+ // No link yet
178
+ expect(store.getLinks(source.id, { direction: "outbound" })).toHaveLength(0);
179
+
180
+ // Create the target note
181
+ const target = store.createNote("I exist now", { path: "Future Note" });
182
+
183
+ // Link should now exist
184
+ const links = store.getLinks(source.id, { direction: "outbound" });
185
+ expect(links).toHaveLength(1);
186
+ expect(links[0].targetId).toBe(target.id);
187
+ });
188
+
189
+ it("removes links when wikilinks are removed from content", () => {
190
+ const target = store.createNote("Target", { path: "Target" });
191
+ const source = store.createNote("See [[Target]]");
192
+
193
+ expect(store.getLinks(source.id, { direction: "outbound" })).toHaveLength(1);
194
+
195
+ // Update content to remove the wikilink
196
+ store.updateNote(source.id, { content: "No more links here." });
197
+
198
+ expect(store.getLinks(source.id, { direction: "outbound" })).toHaveLength(0);
199
+ });
200
+
201
+ it("adds new links when wikilinks are added to content", () => {
202
+ const a = store.createNote("A", { path: "Note A" });
203
+ const b = store.createNote("B", { path: "Note B" });
204
+ const source = store.createNote("See [[Note A]]");
205
+
206
+ expect(store.getLinks(source.id, { direction: "outbound" })).toHaveLength(1);
207
+
208
+ // Update to add another link
209
+ store.updateNote(source.id, { content: "See [[Note A]] and [[Note B]]" });
210
+
211
+ const links = store.getLinks(source.id, { direction: "outbound" });
212
+ expect(links).toHaveLength(2);
213
+ });
214
+
215
+ it("does not create self-links", () => {
216
+ const note = store.createNote("I link to [[Myself]]", { path: "Myself" });
217
+ const links = store.getLinks(note.id, { direction: "outbound" });
218
+ expect(links.filter((l) => l.relationship === "wikilink")).toHaveLength(0);
219
+ });
220
+
221
+ it("deduplicates multiple mentions of same target", () => {
222
+ const target = store.createNote("Target", { path: "Target" });
223
+ const source = store.createNote("See [[Target]] and again [[Target]]");
224
+
225
+ const links = store.getLinks(source.id, { direction: "outbound" })
226
+ .filter((l) => l.relationship === "wikilink");
227
+ expect(links).toHaveLength(1);
228
+ });
229
+
230
+ it("preserves non-wikilink links", () => {
231
+ const a = store.createNote("A", { id: "a", path: "Note A" });
232
+ const b = store.createNote("B", { id: "b", path: "Note B" });
233
+
234
+ // Manual semantic link
235
+ store.createLink("a", "b", "related-to");
236
+
237
+ // Create note with wikilink to B
238
+ const source = store.createNote("See [[Note B]]", { id: "source" });
239
+
240
+ // Update content to remove wikilink
241
+ store.updateNote("source", { content: "No links" });
242
+
243
+ // Semantic link between a and b should still exist
244
+ const links = store.getLinks("a", { direction: "outbound" });
245
+ expect(links.some((l) => l.relationship === "related-to")).toBe(true);
246
+ });
247
+
248
+ it("stores display text and anchor in link metadata", () => {
249
+ const target = store.createNote("Target", { path: "Target" });
250
+ const source = store.createNote("See [[Target#Introduction|intro]]");
251
+
252
+ const links = store.getLinks(source.id, { direction: "outbound" });
253
+ expect(links).toHaveLength(1);
254
+ expect(links[0].metadata?.display).toBe("intro");
255
+ expect(links[0].metadata?.anchor).toBe("Introduction");
256
+ });
257
+ });
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Integration with path changes
261
+ // ---------------------------------------------------------------------------
262
+
263
+ describe("path-based resolution", () => {
264
+ it("resolves pending links when a note gets a path", () => {
265
+ const source = store.createNote("See [[Named Note]]");
266
+ expect(store.getLinks(source.id, { direction: "outbound" })).toHaveLength(0);
267
+
268
+ // Create a note without a path, then give it one
269
+ const target = store.createNote("Unnamed");
270
+ store.updateNote(target.id, { path: "Named Note" });
271
+
272
+ // The pending link should be resolved
273
+ const links = store.getLinks(source.id, { direction: "outbound" });
274
+ expect(links).toHaveLength(1);
275
+ expect(links[0].targetId).toBe(target.id);
276
+ });
277
+ });
@@ -0,0 +1,402 @@
1
+ import { Database } from "bun:sqlite";
2
+ import * as linkOps from "./links.js";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Parser — extract [[wikilinks]] from markdown content
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export interface ParsedWikilink {
9
+ /** Raw match text (e.g., "[[Note Name|Display]]") */
10
+ raw: string;
11
+ /** Target path/name (e.g., "Note Name") */
12
+ target: string;
13
+ /** Display text if aliased (e.g., "Display") */
14
+ display?: string;
15
+ /** Section anchor (e.g., "Heading" from [[Note#Heading]]) */
16
+ anchor?: string;
17
+ /** Block reference (e.g., "block-id" from [[Note#^block-id]]) */
18
+ blockRef?: string;
19
+ /** Whether this is an embed (![[...]]) */
20
+ embed: boolean;
21
+ }
22
+
23
+ /**
24
+ * Parse all [[wikilinks]] from markdown content.
25
+ *
26
+ * Handles:
27
+ * [[Target]]
28
+ * [[Target|Display Text]]
29
+ * [[Target#Heading]]
30
+ * [[Target#^block-id]]
31
+ * [[Target#Heading|Display]]
32
+ * ![[Target]] (embeds)
33
+ *
34
+ * Ignores wikilinks inside code blocks and inline code.
35
+ */
36
+ export function parseWikilinks(content: string): ParsedWikilink[] {
37
+ // Strip code blocks and inline code to avoid false matches
38
+ const stripped = stripCode(content);
39
+
40
+ const results: ParsedWikilink[] = [];
41
+ // Match !?[[...]] — non-greedy, no newlines inside
42
+ const regex = /(!)?\[\[([^\[\]\n]+?)\]\]/g;
43
+ let match: RegExpExecArray | null;
44
+
45
+ while ((match = regex.exec(stripped)) !== null) {
46
+ const embed = match[1] === "!";
47
+ const inner = match[2];
48
+
49
+ // Split on | for display text: [[target|display]]
50
+ const pipeIdx = inner.indexOf("|");
51
+ let targetPart: string;
52
+ let display: string | undefined;
53
+ if (pipeIdx !== -1) {
54
+ targetPart = inner.slice(0, pipeIdx);
55
+ display = inner.slice(pipeIdx + 1);
56
+ } else {
57
+ targetPart = inner;
58
+ }
59
+
60
+ // Split on # for anchor: [[target#heading]] or [[target#^block-id]]
61
+ let target: string;
62
+ let anchor: string | undefined;
63
+ let blockRef: string | undefined;
64
+ const hashIdx = targetPart.indexOf("#");
65
+ if (hashIdx !== -1) {
66
+ target = targetPart.slice(0, hashIdx);
67
+ const fragment = targetPart.slice(hashIdx + 1);
68
+ if (fragment.startsWith("^")) {
69
+ blockRef = fragment.slice(1);
70
+ } else {
71
+ anchor = fragment;
72
+ }
73
+ } else {
74
+ target = targetPart;
75
+ }
76
+
77
+ target = target.trim();
78
+ if (!target) continue;
79
+
80
+ results.push({
81
+ raw: match[0],
82
+ target,
83
+ display: display?.trim(),
84
+ anchor,
85
+ blockRef,
86
+ embed,
87
+ });
88
+ }
89
+
90
+ return results;
91
+ }
92
+
93
+ /**
94
+ * Strip fenced code blocks and inline code from content.
95
+ * Replaces them with spaces to preserve string positions.
96
+ */
97
+ function stripCode(content: string): string {
98
+ // Replace fenced code blocks (``` ... ```)
99
+ let result = content.replace(/```[\s\S]*?```/g, (m) => " ".repeat(m.length));
100
+ // Replace inline code (` ... `)
101
+ result = result.replace(/`[^`\n]+`/g, (m) => " ".repeat(m.length));
102
+ return result;
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Resolution — match wikilink targets to notes by path
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Resolve a wikilink target to a note ID.
111
+ *
112
+ * Resolution order:
113
+ * 1. Exact path match (case-insensitive)
114
+ * 2. Basename match — target matches the last segment of a path
115
+ * (e.g., "README" matches "Projects/Parachute/README")
116
+ * Only if there's exactly one match (ambiguous = unresolved)
117
+ */
118
+ export function resolveWikilink(db: Database, target: string): string | null {
119
+ // 1. Exact match (case-insensitive)
120
+ const exact = db.prepare(
121
+ "SELECT id FROM notes WHERE path = ? COLLATE NOCASE",
122
+ ).get(target) as { id: string } | undefined;
123
+ if (exact) return exact.id;
124
+
125
+ // 2. Basename match — last path segment equals target
126
+ // e.g., target "README" matches path "Projects/Parachute/README"
127
+ const basename = db.prepare(`
128
+ SELECT id FROM notes
129
+ WHERE path IS NOT NULL
130
+ AND (
131
+ path = ? COLLATE NOCASE
132
+ OR path LIKE ? COLLATE NOCASE
133
+ )
134
+ `).all(target, `%/${target}`) as { id: string }[];
135
+
136
+ if (basename.length === 1) return basename[0].id;
137
+
138
+ // Ambiguous or no match
139
+ return null;
140
+ }
141
+
142
+ /** Result of a detailed wikilink resolution. */
143
+ export interface WikilinkResolution {
144
+ resolved: boolean;
145
+ note_id?: string;
146
+ path?: string;
147
+ ambiguous?: boolean;
148
+ candidates: { note_id: string; path: string }[];
149
+ }
150
+
151
+ /**
152
+ * Resolve a wikilink target with full detail — single match, ambiguous, or unresolved.
153
+ */
154
+ export function resolveWikilinkDetailed(db: Database, target: string): WikilinkResolution {
155
+ // 1. Exact match (case-insensitive)
156
+ const exact = db.prepare(
157
+ "SELECT id, path FROM notes WHERE path = ? COLLATE NOCASE",
158
+ ).get(target) as { id: string; path: string } | undefined;
159
+ if (exact) {
160
+ return { resolved: true, note_id: exact.id, path: exact.path, candidates: [] };
161
+ }
162
+
163
+ // 2. Basename match
164
+ const basename = db.prepare(`
165
+ SELECT id, path FROM notes
166
+ WHERE path IS NOT NULL
167
+ AND (
168
+ path = ? COLLATE NOCASE
169
+ OR path LIKE ? COLLATE NOCASE
170
+ )
171
+ `).all(target, `%/${target}`) as { id: string; path: string }[];
172
+
173
+ if (basename.length === 1) {
174
+ return { resolved: true, note_id: basename[0].id, path: basename[0].path, candidates: [] };
175
+ }
176
+
177
+ if (basename.length > 1) {
178
+ return {
179
+ resolved: false,
180
+ ambiguous: true,
181
+ candidates: basename.map((r) => ({ note_id: r.id, path: r.path })),
182
+ };
183
+ }
184
+
185
+ return { resolved: false, ambiguous: false, candidates: [] };
186
+ }
187
+
188
+ /** Entry from the unresolved_wikilinks table. */
189
+ export interface UnresolvedWikilink {
190
+ source_id: string;
191
+ source_path?: string;
192
+ target_path: string;
193
+ }
194
+
195
+ /**
196
+ * List unresolved wikilinks across the vault.
197
+ */
198
+ export function listUnresolvedWikilinks(db: Database, limit = 50): { unresolved: UnresolvedWikilink[]; count: number } {
199
+ let total: number;
200
+ let rows: { source_id: string; target_path: string }[];
201
+ try {
202
+ total = (db.prepare("SELECT COUNT(*) as c FROM unresolved_wikilinks").get() as { c: number }).c;
203
+ rows = db.prepare(
204
+ "SELECT source_id, target_path FROM unresolved_wikilinks ORDER BY source_id LIMIT ?",
205
+ ).all(limit) as { source_id: string; target_path: string }[];
206
+ } catch {
207
+ // Table doesn't exist yet
208
+ return { unresolved: [], count: 0 };
209
+ }
210
+
211
+ // Hydrate source paths
212
+ if (rows.length === 0) return { unresolved: [], count: total };
213
+
214
+ const sourceIds = [...new Set(rows.map((r) => r.source_id))];
215
+ const placeholders = sourceIds.map(() => "?").join(", ");
216
+ const pathRows = db.prepare(
217
+ `SELECT id, path FROM notes WHERE id IN (${placeholders})`,
218
+ ).all(...sourceIds) as { id: string; path: string | null }[];
219
+ const pathMap = new Map(pathRows.map((r) => [r.id, r.path]));
220
+
221
+ const unresolved: UnresolvedWikilink[] = rows.map((r) => ({
222
+ source_id: r.source_id,
223
+ source_path: pathMap.get(r.source_id) ?? undefined,
224
+ target_path: r.target_path,
225
+ }));
226
+
227
+ return { unresolved, count: total };
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Sync — maintain wikilink-based links for a note
232
+ // ---------------------------------------------------------------------------
233
+
234
+ const WIKILINK_REL = "wikilink";
235
+
236
+ /**
237
+ * Sync wikilink-based links for a note.
238
+ * Parses content for [[wikilinks]], resolves targets, creates/removes links.
239
+ *
240
+ * Returns counts of changes made.
241
+ */
242
+ export function syncWikilinks(
243
+ db: Database,
244
+ noteId: string,
245
+ content: string,
246
+ ): { added: number; removed: number; unresolved: string[] } {
247
+ const parsed = parseWikilinks(content);
248
+
249
+ // Deduplicate by target (same target mentioned multiple times = one link)
250
+ const targetMap = new Map<string, ParsedWikilink>();
251
+ for (const wl of parsed) {
252
+ const key = wl.target.toLowerCase();
253
+ if (!targetMap.has(key)) {
254
+ targetMap.set(key, wl);
255
+ }
256
+ }
257
+
258
+ // Resolve each unique target
259
+ const resolvedLinks = new Map<string, { targetId: string; wl: ParsedWikilink }>();
260
+ const unresolved: string[] = [];
261
+
262
+ for (const [key, wl] of targetMap) {
263
+ const targetId = resolveWikilink(db, wl.target);
264
+ if (targetId && targetId !== noteId) {
265
+ // Don't create self-links
266
+ resolvedLinks.set(targetId, { targetId, wl });
267
+ } else if (!targetId) {
268
+ unresolved.push(wl.target);
269
+ }
270
+ }
271
+
272
+ // Get existing wikilink links from this note
273
+ const existing = linkOps.getLinks(db, noteId, { direction: "outbound" })
274
+ .filter((l) => l.relationship === WIKILINK_REL);
275
+
276
+ const existingTargets = new Set(existing.map((l) => l.targetId));
277
+ const desiredTargets = new Set(resolvedLinks.keys());
278
+
279
+ // Add new links
280
+ let added = 0;
281
+ for (const [targetId, { wl }] of resolvedLinks) {
282
+ if (!existingTargets.has(targetId)) {
283
+ const metadata: Record<string, unknown> = {};
284
+ if (wl.display) metadata.display = wl.display;
285
+ if (wl.anchor) metadata.anchor = wl.anchor;
286
+ if (wl.blockRef) metadata.block_ref = wl.blockRef;
287
+ if (wl.embed) metadata.embed = true;
288
+
289
+ linkOps.createLink(
290
+ db,
291
+ noteId,
292
+ targetId,
293
+ WIKILINK_REL,
294
+ Object.keys(metadata).length > 0 ? metadata : undefined,
295
+ );
296
+ added++;
297
+ }
298
+ }
299
+
300
+ // Remove stale links (wikilinks that were removed from content)
301
+ let removed = 0;
302
+ for (const link of existing) {
303
+ if (!desiredTargets.has(link.targetId)) {
304
+ linkOps.deleteLink(db, noteId, link.targetId, WIKILINK_REL);
305
+ removed++;
306
+ }
307
+ }
308
+
309
+ // Store unresolved wikilinks for later resolution
310
+ syncUnresolvedWikilinks(db, noteId, unresolved);
311
+
312
+ return { added, removed, unresolved };
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // Unresolved wikilinks — pending resolution when target notes are created
317
+ // ---------------------------------------------------------------------------
318
+
319
+ /**
320
+ * Ensure the unresolved_wikilinks table exists.
321
+ * Called lazily — only when we actually have unresolved links.
322
+ */
323
+ export function ensureUnresolvedTable(db: Database): void {
324
+ db.exec(`
325
+ CREATE TABLE IF NOT EXISTS unresolved_wikilinks (
326
+ source_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
327
+ target_path TEXT NOT NULL COLLATE NOCASE,
328
+ PRIMARY KEY (source_id, target_path)
329
+ )
330
+ `);
331
+ }
332
+
333
+ /**
334
+ * Update unresolved wikilinks for a note.
335
+ */
336
+ function syncUnresolvedWikilinks(
337
+ db: Database,
338
+ noteId: string,
339
+ unresolvedPaths: string[],
340
+ ): void {
341
+ if (unresolvedPaths.length === 0) {
342
+ // Clean up any old unresolved entries for this note
343
+ try {
344
+ db.prepare("DELETE FROM unresolved_wikilinks WHERE source_id = ?").run(noteId);
345
+ } catch {
346
+ // Table may not exist yet — that's fine
347
+ }
348
+ return;
349
+ }
350
+
351
+ ensureUnresolvedTable(db);
352
+
353
+ // Replace all unresolved entries for this note
354
+ db.prepare("DELETE FROM unresolved_wikilinks WHERE source_id = ?").run(noteId);
355
+ const insert = db.prepare(
356
+ "INSERT OR IGNORE INTO unresolved_wikilinks (source_id, target_path) VALUES (?, ?)",
357
+ );
358
+ for (const path of unresolvedPaths) {
359
+ insert.run(noteId, path);
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Try to resolve pending wikilinks that point to a given path.
365
+ * Called when a note is created or its path changes.
366
+ *
367
+ * Returns the number of links resolved.
368
+ */
369
+ export function resolveUnresolvedWikilinks(
370
+ db: Database,
371
+ notePath: string,
372
+ noteId: string,
373
+ ): number {
374
+ let rows: { source_id: string }[];
375
+ try {
376
+ rows = db.prepare(`
377
+ SELECT source_id FROM unresolved_wikilinks
378
+ WHERE target_path = ? COLLATE NOCASE
379
+ OR ? LIKE '%/' || target_path
380
+ `).all(notePath, notePath) as { source_id: string }[];
381
+ } catch {
382
+ return 0; // Table doesn't exist
383
+ }
384
+
385
+ if (rows.length === 0) return 0;
386
+
387
+ let resolved = 0;
388
+ for (const row of rows) {
389
+ if (row.source_id === noteId) continue; // Skip self-links
390
+
391
+ // Create the wikilink
392
+ linkOps.createLink(db, row.source_id, noteId, WIKILINK_REL);
393
+ resolved++;
394
+
395
+ // Remove the unresolved entry
396
+ db.prepare(
397
+ "DELETE FROM unresolved_wikilinks WHERE source_id = ? AND (target_path = ? COLLATE NOCASE OR ? LIKE '%/' || target_path)",
398
+ ).run(row.source_id, notePath, notePath);
399
+ }
400
+
401
+ return resolved;
402
+ }
@@ -0,0 +1,20 @@
1
+ [Unit]
2
+ Description=Parachute Vault
3
+ After=network.target
4
+
5
+ [Service]
6
+ Type=simple
7
+ User=parachute
8
+ WorkingDirectory=/opt/parachute-vault
9
+ ExecStart=/usr/local/bin/bun src/server.ts
10
+ Restart=on-failure
11
+ RestartSec=5
12
+ EnvironmentFile=/opt/parachute-vault/.env
13
+
14
+ # Hardening
15
+ NoNewPrivileges=true
16
+ ProtectSystem=strict
17
+ ReadWritePaths=/home/parachute/.parachute
18
+
19
+ [Install]
20
+ WantedBy=multi-user.target