@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.
- package/.claude/settings.local.json +31 -0
- package/.dockerignore +8 -0
- package/.env.example +9 -0
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
- package/CLAUDE.md +115 -0
- package/Caddyfile +3 -0
- package/Dockerfile +22 -0
- package/LICENSE +661 -0
- package/README.md +356 -0
- package/bun.lock +219 -0
- package/bunfig.toml +2 -0
- package/core/package.json +7 -0
- package/core/src/core.test.ts +940 -0
- package/core/src/hooks.test.ts +361 -0
- package/core/src/hooks.ts +234 -0
- package/core/src/links.ts +352 -0
- package/core/src/mcp.ts +672 -0
- package/core/src/notes.ts +520 -0
- package/core/src/obsidian.test.ts +380 -0
- package/core/src/obsidian.ts +322 -0
- package/core/src/paths.test.ts +197 -0
- package/core/src/paths.ts +53 -0
- package/core/src/schema.ts +331 -0
- package/core/src/store.ts +303 -0
- package/core/src/tag-schemas.ts +104 -0
- package/core/src/test-preload.ts +8 -0
- package/core/src/types.ts +140 -0
- package/core/src/wikilinks.test.ts +277 -0
- package/core/src/wikilinks.ts +402 -0
- package/deploy/parachute-vault.service +20 -0
- package/docker-compose.yml +50 -0
- package/docs/HTTP_API.md +328 -0
- package/fly.toml +24 -0
- package/package.json +32 -0
- package/railway.json +14 -0
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/scripts/migrate-audio-to-opus.test.ts +237 -0
- package/scripts/migrate-audio-to-opus.ts +499 -0
- package/src/auth.ts +170 -0
- package/src/cli.ts +1131 -0
- package/src/config-triggers.test.ts +83 -0
- package/src/config.test.ts +125 -0
- package/src/config.ts +716 -0
- package/src/db.ts +14 -0
- package/src/launchd.ts +109 -0
- package/src/mcp-http.ts +113 -0
- package/src/mcp-tools.ts +155 -0
- package/src/oauth.test.ts +1242 -0
- package/src/oauth.ts +729 -0
- package/src/owner-auth.ts +159 -0
- package/src/prompt.ts +141 -0
- package/src/published.test.ts +214 -0
- package/src/qrcode-terminal.d.ts +9 -0
- package/src/routes.ts +822 -0
- package/src/server.ts +450 -0
- package/src/systemd.ts +84 -0
- package/src/token-store.test.ts +174 -0
- package/src/token-store.ts +241 -0
- package/src/triggers.test.ts +397 -0
- package/src/triggers.ts +412 -0
- package/src/two-factor.test.ts +246 -0
- package/src/two-factor.ts +222 -0
- package/src/vault-store.ts +47 -0
- package/src/vault.test.ts +1309 -0
- package/tsconfig.json +29 -0
- package/web/README.md +73 -0
- package/web/bun.lock +827 -0
- package/web/eslint.config.js +23 -0
- package/web/index.html +15 -0
- package/web/package.json +36 -0
- package/web/public/favicon.svg +1 -0
- package/web/public/icons.svg +24 -0
- package/web/src/App.tsx +149 -0
- package/web/src/Graph.tsx +200 -0
- package/web/src/NoteView.tsx +155 -0
- package/web/src/Sidebar.tsx +186 -0
- package/web/src/api.ts +21 -0
- package/web/src/index.css +50 -0
- package/web/src/main.tsx +10 -0
- package/web/src/types.ts +37 -0
- package/web/src/utils.ts +107 -0
- package/web/tsconfig.app.json +25 -0
- package/web/tsconfig.json +7 -0
- package/web/tsconfig.node.json +24 -0
- 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
|