@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,380 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { SqliteStore } from "./store.js";
|
|
4
|
+
import {
|
|
5
|
+
parseFrontmatter,
|
|
6
|
+
extractInlineTags,
|
|
7
|
+
parseObsidianVault,
|
|
8
|
+
toObsidianMarkdown,
|
|
9
|
+
exportFilePath,
|
|
10
|
+
} from "./obsidian.js";
|
|
11
|
+
import { mkdirSync, writeFileSync, rmSync } from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { tmpdir } from "os";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Frontmatter parsing
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
describe("parseFrontmatter", () => {
|
|
20
|
+
it("parses simple key-value pairs", () => {
|
|
21
|
+
const raw = `---
|
|
22
|
+
title: My Note
|
|
23
|
+
author: Aaron
|
|
24
|
+
---
|
|
25
|
+
Note content here.`;
|
|
26
|
+
|
|
27
|
+
const { frontmatter, content } = parseFrontmatter(raw);
|
|
28
|
+
expect(frontmatter.title).toBe("My Note");
|
|
29
|
+
expect(frontmatter.author).toBe("Aaron");
|
|
30
|
+
expect(content).toBe("Note content here.");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("parses array tags", () => {
|
|
34
|
+
const raw = `---
|
|
35
|
+
tags:
|
|
36
|
+
- daily
|
|
37
|
+
- voice
|
|
38
|
+
---
|
|
39
|
+
Content`;
|
|
40
|
+
|
|
41
|
+
const { frontmatter } = parseFrontmatter(raw);
|
|
42
|
+
expect(frontmatter.tags).toEqual(["daily", "voice"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("parses inline array tags", () => {
|
|
46
|
+
const raw = `---
|
|
47
|
+
tags: [daily, voice, project]
|
|
48
|
+
---
|
|
49
|
+
Content`;
|
|
50
|
+
|
|
51
|
+
const { frontmatter } = parseFrontmatter(raw);
|
|
52
|
+
expect(frontmatter.tags).toEqual(["daily", "voice", "project"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("parses numbers and booleans", () => {
|
|
56
|
+
const raw = `---
|
|
57
|
+
priority: 3
|
|
58
|
+
draft: true
|
|
59
|
+
rating: 4.5
|
|
60
|
+
---
|
|
61
|
+
Content`;
|
|
62
|
+
|
|
63
|
+
const { frontmatter } = parseFrontmatter(raw);
|
|
64
|
+
expect(frontmatter.priority).toBe(3);
|
|
65
|
+
expect(frontmatter.draft).toBe(true);
|
|
66
|
+
expect(frontmatter.rating).toBe(4.5);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("handles quoted strings", () => {
|
|
70
|
+
const raw = `---
|
|
71
|
+
title: "My Title"
|
|
72
|
+
subtitle: 'Sub Title'
|
|
73
|
+
---
|
|
74
|
+
Content`;
|
|
75
|
+
|
|
76
|
+
const { frontmatter } = parseFrontmatter(raw);
|
|
77
|
+
expect(frontmatter.title).toBe("My Title");
|
|
78
|
+
expect(frontmatter.subtitle).toBe("Sub Title");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns empty frontmatter when none exists", () => {
|
|
82
|
+
const raw = "Just content, no frontmatter.";
|
|
83
|
+
const { frontmatter, content } = parseFrontmatter(raw);
|
|
84
|
+
expect(frontmatter).toEqual({});
|
|
85
|
+
expect(content).toBe("Just content, no frontmatter.");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("handles empty frontmatter block", () => {
|
|
89
|
+
const raw = `---
|
|
90
|
+
---
|
|
91
|
+
Content`;
|
|
92
|
+
|
|
93
|
+
const { frontmatter, content } = parseFrontmatter(raw);
|
|
94
|
+
expect(Object.keys(frontmatter)).toHaveLength(0);
|
|
95
|
+
expect(content).toBe("Content");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("treats empty values as empty strings, not arrays", () => {
|
|
99
|
+
const raw = `---
|
|
100
|
+
description:
|
|
101
|
+
title: My Note
|
|
102
|
+
---
|
|
103
|
+
Content`;
|
|
104
|
+
|
|
105
|
+
const { frontmatter } = parseFrontmatter(raw);
|
|
106
|
+
expect(frontmatter.description).toBe("");
|
|
107
|
+
expect(frontmatter.title).toBe("My Note");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("does not match keys with spaces", () => {
|
|
111
|
+
const raw = `---
|
|
112
|
+
valid-key: yes
|
|
113
|
+
another_key: also yes
|
|
114
|
+
---
|
|
115
|
+
Content`;
|
|
116
|
+
|
|
117
|
+
const { frontmatter } = parseFrontmatter(raw);
|
|
118
|
+
expect(frontmatter["valid-key"]).toBe("yes");
|
|
119
|
+
expect(frontmatter["another_key"]).toBe("also yes");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("handles date values as strings", () => {
|
|
123
|
+
const raw = `---
|
|
124
|
+
date: 2026-04-05
|
|
125
|
+
---
|
|
126
|
+
Content`;
|
|
127
|
+
|
|
128
|
+
const { frontmatter } = parseFrontmatter(raw);
|
|
129
|
+
// Date-like strings should be kept as strings
|
|
130
|
+
expect(typeof frontmatter.date).toBe("string");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Inline tag extraction
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
describe("extractInlineTags", () => {
|
|
139
|
+
it("extracts simple tags", () => {
|
|
140
|
+
const tags = extractInlineTags("Some text #daily and #voice here.");
|
|
141
|
+
expect(tags).toContain("daily");
|
|
142
|
+
expect(tags).toContain("voice");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("extracts nested tags", () => {
|
|
146
|
+
const tags = extractInlineTags("Tagged #projects/parachute here.");
|
|
147
|
+
expect(tags).toContain("projects/parachute");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("ignores tags in code blocks", () => {
|
|
151
|
+
const content = `
|
|
152
|
+
Some text #real-tag
|
|
153
|
+
|
|
154
|
+
\`\`\`
|
|
155
|
+
#not-a-tag
|
|
156
|
+
\`\`\`
|
|
157
|
+
`;
|
|
158
|
+
const tags = extractInlineTags(content);
|
|
159
|
+
expect(tags).toContain("real-tag");
|
|
160
|
+
expect(tags).not.toContain("not-a-tag");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("ignores tags in inline code", () => {
|
|
164
|
+
const tags = extractInlineTags("Use `#not-a-tag` for tagging. #real-tag");
|
|
165
|
+
expect(tags).not.toContain("not-a-tag");
|
|
166
|
+
expect(tags).toContain("real-tag");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("deduplicates tags", () => {
|
|
170
|
+
const tags = extractInlineTags("#daily notes #daily logs");
|
|
171
|
+
expect(tags.filter((t) => t === "daily")).toHaveLength(1);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("lowercases tags", () => {
|
|
175
|
+
const tags = extractInlineTags("#Daily #VOICE");
|
|
176
|
+
expect(tags).toContain("daily");
|
|
177
|
+
expect(tags).toContain("voice");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("handles tags at start of line", () => {
|
|
181
|
+
const tags = extractInlineTags("#first-tag\nSome text");
|
|
182
|
+
expect(tags).toContain("first-tag");
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Full vault parsing (with temp directory)
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
describe("parseObsidianVault", () => {
|
|
191
|
+
const tmpBase = join(tmpdir(), "parachute-test-obsidian");
|
|
192
|
+
|
|
193
|
+
beforeEach(() => {
|
|
194
|
+
try { rmSync(tmpBase, { recursive: true }); } catch {}
|
|
195
|
+
mkdirSync(tmpBase, { recursive: true });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("parses a simple vault", () => {
|
|
199
|
+
writeFileSync(join(tmpBase, "Note One.md"), `---
|
|
200
|
+
tags: [daily]
|
|
201
|
+
---
|
|
202
|
+
Hello world.`);
|
|
203
|
+
writeFileSync(join(tmpBase, "Note Two.md"), "Plain note. #voice");
|
|
204
|
+
|
|
205
|
+
const { notes, errors } = parseObsidianVault(tmpBase);
|
|
206
|
+
expect(errors).toHaveLength(0);
|
|
207
|
+
expect(notes).toHaveLength(2);
|
|
208
|
+
|
|
209
|
+
const one = notes.find((n) => n.path === "Note One");
|
|
210
|
+
expect(one).toBeTruthy();
|
|
211
|
+
expect(one!.tags).toContain("daily");
|
|
212
|
+
expect(one!.content).toBe("Hello world.");
|
|
213
|
+
|
|
214
|
+
const two = notes.find((n) => n.path === "Note Two");
|
|
215
|
+
expect(two).toBeTruthy();
|
|
216
|
+
expect(two!.tags).toContain("voice");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("handles nested directories", () => {
|
|
220
|
+
mkdirSync(join(tmpBase, "Projects", "Parachute"), { recursive: true });
|
|
221
|
+
writeFileSync(join(tmpBase, "Projects", "Parachute", "README.md"), "# Parachute");
|
|
222
|
+
|
|
223
|
+
const { notes } = parseObsidianVault(tmpBase);
|
|
224
|
+
expect(notes).toHaveLength(1);
|
|
225
|
+
expect(notes[0].path).toBe("Projects/Parachute/README");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("skips .obsidian directory", () => {
|
|
229
|
+
mkdirSync(join(tmpBase, ".obsidian"), { recursive: true });
|
|
230
|
+
writeFileSync(join(tmpBase, ".obsidian", "app.json"), "{}");
|
|
231
|
+
writeFileSync(join(tmpBase, "Real Note.md"), "Content");
|
|
232
|
+
|
|
233
|
+
const { notes } = parseObsidianVault(tmpBase);
|
|
234
|
+
expect(notes).toHaveLength(1);
|
|
235
|
+
expect(notes[0].path).toBe("Real Note");
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("merges frontmatter and inline tags", () => {
|
|
239
|
+
writeFileSync(join(tmpBase, "Mixed.md"), `---
|
|
240
|
+
tags: [project]
|
|
241
|
+
---
|
|
242
|
+
Some text #daily here.`);
|
|
243
|
+
|
|
244
|
+
const { notes } = parseObsidianVault(tmpBase);
|
|
245
|
+
expect(notes[0].tags).toContain("project");
|
|
246
|
+
expect(notes[0].tags).toContain("daily");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("preserves non-tag frontmatter as metadata", () => {
|
|
250
|
+
writeFileSync(join(tmpBase, "Rich.md"), `---
|
|
251
|
+
tags: [daily]
|
|
252
|
+
status: draft
|
|
253
|
+
priority: 3
|
|
254
|
+
---
|
|
255
|
+
Content`);
|
|
256
|
+
|
|
257
|
+
const { notes } = parseObsidianVault(tmpBase);
|
|
258
|
+
expect(notes[0].frontmatter.status).toBe("draft");
|
|
259
|
+
expect(notes[0].frontmatter.priority).toBe(3);
|
|
260
|
+
// tags should be removed from frontmatter (they go to tags table)
|
|
261
|
+
expect(notes[0].frontmatter.tags).toBeUndefined();
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Export
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
describe("toObsidianMarkdown", () => {
|
|
270
|
+
it("generates markdown with frontmatter", () => {
|
|
271
|
+
const md = toObsidianMarkdown({
|
|
272
|
+
id: "test-id",
|
|
273
|
+
path: "My Note",
|
|
274
|
+
content: "Hello world.",
|
|
275
|
+
tags: ["daily", "voice"],
|
|
276
|
+
metadata: { status: "draft" },
|
|
277
|
+
createdAt: "2026-04-05T12:00:00Z",
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
expect(md).toContain("---");
|
|
281
|
+
expect(md).toContain("tags:");
|
|
282
|
+
expect(md).toContain(" - daily");
|
|
283
|
+
expect(md).toContain(" - voice");
|
|
284
|
+
expect(md).toContain("status: draft");
|
|
285
|
+
expect(md).toContain("Hello world.");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("skips frontmatter when no metadata or tags", () => {
|
|
289
|
+
const md = toObsidianMarkdown({
|
|
290
|
+
id: "test-id",
|
|
291
|
+
content: "Just content.",
|
|
292
|
+
createdAt: "2026-04-05T12:00:00Z",
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
expect(md).not.toContain("---");
|
|
296
|
+
expect(md).toBe("Just content.");
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("exportFilePath", () => {
|
|
301
|
+
it("uses note path with .md extension", () => {
|
|
302
|
+
expect(exportFilePath({
|
|
303
|
+
id: "test", path: "Projects/README", content: "", createdAt: "2026-04-05T12:00:00Z",
|
|
304
|
+
})).toBe("Projects/README.md");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("generates path from date for pathless notes", () => {
|
|
308
|
+
expect(exportFilePath({
|
|
309
|
+
id: "abc123", content: "", createdAt: "2026-04-05T12:00:00Z",
|
|
310
|
+
})).toBe("2026-04-05/abc123.md");
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// Round-trip: import → export
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
describe("round-trip", () => {
|
|
319
|
+
const tmpBase = join(tmpdir(), "parachute-test-roundtrip");
|
|
320
|
+
let store: SqliteStore;
|
|
321
|
+
|
|
322
|
+
beforeEach(() => {
|
|
323
|
+
try { rmSync(tmpBase, { recursive: true }); } catch {}
|
|
324
|
+
mkdirSync(tmpBase, { recursive: true });
|
|
325
|
+
store = new SqliteStore(new Database(":memory:"));
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("preserves content through import → vault → export", () => {
|
|
329
|
+
// Create source files
|
|
330
|
+
writeFileSync(join(tmpBase, "Note.md"), `---
|
|
331
|
+
tags: [daily]
|
|
332
|
+
status: active
|
|
333
|
+
---
|
|
334
|
+
Hello world.`);
|
|
335
|
+
|
|
336
|
+
// Parse
|
|
337
|
+
const { notes } = parseObsidianVault(tmpBase);
|
|
338
|
+
expect(notes).toHaveLength(1);
|
|
339
|
+
|
|
340
|
+
// Import into vault
|
|
341
|
+
const note = store.createNote(notes[0].content, {
|
|
342
|
+
path: notes[0].path,
|
|
343
|
+
tags: notes[0].tags,
|
|
344
|
+
metadata: notes[0].frontmatter,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
expect(note.content).toBe("Hello world.");
|
|
348
|
+
expect(note.path).toBe("Note");
|
|
349
|
+
expect(note.tags).toContain("daily");
|
|
350
|
+
expect(note.metadata?.status).toBe("active");
|
|
351
|
+
|
|
352
|
+
// Export back
|
|
353
|
+
const md = toObsidianMarkdown(note);
|
|
354
|
+
expect(md).toContain("tags:");
|
|
355
|
+
expect(md).toContain(" - daily");
|
|
356
|
+
expect(md).toContain("status: active");
|
|
357
|
+
expect(md).toContain("Hello world.");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("resolves wikilinks during import", () => {
|
|
361
|
+
writeFileSync(join(tmpBase, "A.md"), "See [[B]] for details.");
|
|
362
|
+
writeFileSync(join(tmpBase, "B.md"), "I am note B.");
|
|
363
|
+
|
|
364
|
+
const { notes } = parseObsidianVault(tmpBase);
|
|
365
|
+
|
|
366
|
+
// Import all notes
|
|
367
|
+
for (const n of notes) {
|
|
368
|
+
store.createNote(n.content, {
|
|
369
|
+
path: n.path,
|
|
370
|
+
tags: n.tags.length > 0 ? n.tags : undefined,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Check that A links to B
|
|
375
|
+
const noteA = store.getNoteByPath("A")!;
|
|
376
|
+
const noteB = store.getNoteByPath("B")!;
|
|
377
|
+
const links = store.getLinks(noteA.id, { direction: "outbound" });
|
|
378
|
+
expect(links.some((l) => l.targetId === noteB.id && l.relationship === "wikilink")).toBe(true);
|
|
379
|
+
});
|
|
380
|
+
});
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Obsidian vault parser — reads .md files and extracts notes, tags, links.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - YAML frontmatter → note.metadata
|
|
6
|
+
* - Inline #tags and frontmatter tags → tags table
|
|
7
|
+
* - [[wikilinks]] → handled by wikilinks.ts on note creation
|
|
8
|
+
* - File path → note.path
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readdirSync, readFileSync, statSync } from "fs";
|
|
12
|
+
import { join, relative, extname, basename } from "path";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Types
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export interface ObsidianNote {
|
|
19
|
+
/** Relative path without .md extension (e.g., "Projects/Parachute/README") */
|
|
20
|
+
path: string;
|
|
21
|
+
/** Raw markdown content (frontmatter stripped) */
|
|
22
|
+
content: string;
|
|
23
|
+
/** Parsed YAML frontmatter */
|
|
24
|
+
frontmatter: Record<string, unknown>;
|
|
25
|
+
/** Tags from both frontmatter and inline #tags */
|
|
26
|
+
tags: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ImportStats {
|
|
30
|
+
files: number;
|
|
31
|
+
imported: number;
|
|
32
|
+
skipped: number;
|
|
33
|
+
tags: number;
|
|
34
|
+
errors: { path: string; error: string }[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Frontmatter parsing
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse YAML frontmatter from markdown content.
|
|
43
|
+
* Returns { frontmatter, content } where content has frontmatter stripped.
|
|
44
|
+
*
|
|
45
|
+
* Uses a simple parser — no dependency on a YAML library.
|
|
46
|
+
* Handles common frontmatter patterns: strings, arrays, numbers, booleans.
|
|
47
|
+
*/
|
|
48
|
+
export function parseFrontmatter(raw: string): {
|
|
49
|
+
frontmatter: Record<string, unknown>;
|
|
50
|
+
content: string;
|
|
51
|
+
} {
|
|
52
|
+
if (!raw.startsWith("---")) {
|
|
53
|
+
return { frontmatter: {}, content: raw };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const endIdx = raw.indexOf("\n---", 3);
|
|
57
|
+
if (endIdx === -1) {
|
|
58
|
+
return { frontmatter: {}, content: raw };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const yamlBlock = raw.slice(4, endIdx); // skip opening "---\n"
|
|
62
|
+
const content = raw.slice(endIdx + 4).replace(/^\n/, ""); // skip closing "---\n"
|
|
63
|
+
|
|
64
|
+
const frontmatter: Record<string, unknown> = {};
|
|
65
|
+
let currentKey = "";
|
|
66
|
+
let currentArray: string[] | null = null;
|
|
67
|
+
|
|
68
|
+
for (const line of yamlBlock.split("\n")) {
|
|
69
|
+
// Array item (continuation of previous key)
|
|
70
|
+
if (currentArray !== null && /^\s+-\s+/.test(line)) {
|
|
71
|
+
const val = line.replace(/^\s+-\s+/, "").trim();
|
|
72
|
+
currentArray.push(unquote(val));
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// If we were building an array, save it (or save empty string if no items found)
|
|
77
|
+
if (currentArray !== null) {
|
|
78
|
+
frontmatter[currentKey] = currentArray.length > 0 ? currentArray : "";
|
|
79
|
+
currentArray = null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Key: value pair — keys must be YAML-valid (word chars and hyphens, no spaces)
|
|
83
|
+
const kvMatch = line.match(/^([\w][\w-]*):\s*(.*)/);
|
|
84
|
+
if (kvMatch) {
|
|
85
|
+
const key = kvMatch[1];
|
|
86
|
+
const value = kvMatch[2].trim();
|
|
87
|
+
|
|
88
|
+
if (value === "[]") {
|
|
89
|
+
frontmatter[key] = [];
|
|
90
|
+
} else if (value === "") {
|
|
91
|
+
// Empty value: could be start of array (next lines are "- item")
|
|
92
|
+
// or genuinely empty string. We start array accumulation and
|
|
93
|
+
// handle the empty case when a non-array line follows.
|
|
94
|
+
currentKey = key;
|
|
95
|
+
currentArray = [];
|
|
96
|
+
} else if (value.startsWith("[") && value.endsWith("]")) {
|
|
97
|
+
// Inline array: [item1, item2]
|
|
98
|
+
const items = value.slice(1, -1).split(",").map((s) => unquote(s.trim())).filter(Boolean);
|
|
99
|
+
frontmatter[key] = items;
|
|
100
|
+
} else {
|
|
101
|
+
frontmatter[key] = parseValue(value);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Save any trailing array (or empty string if no items)
|
|
107
|
+
if (currentArray !== null) {
|
|
108
|
+
frontmatter[currentKey] = currentArray.length > 0 ? currentArray : "";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { frontmatter, content };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function unquote(s: string): string {
|
|
115
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
116
|
+
return s.slice(1, -1);
|
|
117
|
+
}
|
|
118
|
+
return s;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseValue(s: string): unknown {
|
|
122
|
+
s = unquote(s);
|
|
123
|
+
if (s === "true") return true;
|
|
124
|
+
if (s === "false") return false;
|
|
125
|
+
if (s === "null") return null;
|
|
126
|
+
if (/^-?\d+$/.test(s)) return parseInt(s, 10);
|
|
127
|
+
if (/^-?\d+\.\d+$/.test(s)) return parseFloat(s);
|
|
128
|
+
return s;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Tag extraction
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/** Extract inline #tags from markdown content. Excludes tags in code blocks. */
|
|
136
|
+
export function extractInlineTags(content: string): string[] {
|
|
137
|
+
// Strip code blocks and inline code
|
|
138
|
+
let stripped = content.replace(/```[\s\S]*?```/g, "");
|
|
139
|
+
stripped = stripped.replace(/`[^`\n]+`/g, "");
|
|
140
|
+
|
|
141
|
+
const tags = new Set<string>();
|
|
142
|
+
// Match #tag and #nested/tag — must be preceded by whitespace or start of line
|
|
143
|
+
const regex = /(?:^|\s)#([\w][\w/-]*[\w]|[\w])/gm;
|
|
144
|
+
let match: RegExpExecArray | null;
|
|
145
|
+
while ((match = regex.exec(stripped)) !== null) {
|
|
146
|
+
tags.add(match[1].toLowerCase());
|
|
147
|
+
}
|
|
148
|
+
return [...tags];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Extract tags from frontmatter (handles both array and string formats). */
|
|
152
|
+
function extractFrontmatterTags(frontmatter: Record<string, unknown>): string[] {
|
|
153
|
+
const raw = frontmatter.tags;
|
|
154
|
+
if (!raw) return [];
|
|
155
|
+
if (Array.isArray(raw)) return raw.map((t) => String(t).toLowerCase().trim()).filter(Boolean);
|
|
156
|
+
if (typeof raw === "string") return raw.split(",").map((t) => t.toLowerCase().trim()).filter(Boolean);
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Directory walking
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
/** Recursively list all .md files in a directory, excluding .obsidian/ and hidden dirs. */
|
|
165
|
+
export function walkMarkdownFiles(dir: string): string[] {
|
|
166
|
+
const results: string[] = [];
|
|
167
|
+
|
|
168
|
+
function walk(current: string) {
|
|
169
|
+
for (const entry of readdirSync(current)) {
|
|
170
|
+
// Skip hidden directories and .obsidian config
|
|
171
|
+
if (entry.startsWith(".")) continue;
|
|
172
|
+
if (entry === "node_modules") continue;
|
|
173
|
+
|
|
174
|
+
const full = join(current, entry);
|
|
175
|
+
const stat = statSync(full);
|
|
176
|
+
|
|
177
|
+
if (stat.isDirectory()) {
|
|
178
|
+
walk(full);
|
|
179
|
+
} else if (stat.isFile() && extname(entry).toLowerCase() === ".md") {
|
|
180
|
+
results.push(full);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
walk(dir);
|
|
186
|
+
return results.sort();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Parse a single file
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
export function parseObsidianFile(filePath: string, vaultRoot: string): ObsidianNote {
|
|
194
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
195
|
+
const { frontmatter, content } = parseFrontmatter(raw);
|
|
196
|
+
|
|
197
|
+
// Path: relative to vault root, without .md extension
|
|
198
|
+
const rel = relative(vaultRoot, filePath);
|
|
199
|
+
const path = rel.replace(/\.md$/i, "");
|
|
200
|
+
|
|
201
|
+
// Merge tags from frontmatter and inline
|
|
202
|
+
const fmTags = extractFrontmatterTags(frontmatter);
|
|
203
|
+
const inlineTags = extractInlineTags(content);
|
|
204
|
+
const allTags = [...new Set([...fmTags, ...inlineTags])];
|
|
205
|
+
|
|
206
|
+
// Remove tags from metadata (they go to the tags table)
|
|
207
|
+
const metadata = { ...frontmatter };
|
|
208
|
+
delete metadata.tags;
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
path,
|
|
212
|
+
content,
|
|
213
|
+
frontmatter: metadata,
|
|
214
|
+
tags: allTags,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Import an Obsidian vault
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
export interface ImportOptions {
|
|
223
|
+
/** Override vault name to import into */
|
|
224
|
+
vault?: string;
|
|
225
|
+
/** Dry run — don't actually import */
|
|
226
|
+
dryRun?: boolean;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Parse an entire Obsidian vault directory into ObsidianNote objects.
|
|
231
|
+
* Does not write to the database — caller handles that.
|
|
232
|
+
*/
|
|
233
|
+
export function parseObsidianVault(vaultPath: string): {
|
|
234
|
+
notes: ObsidianNote[];
|
|
235
|
+
errors: { path: string; error: string }[];
|
|
236
|
+
} {
|
|
237
|
+
const files = walkMarkdownFiles(vaultPath);
|
|
238
|
+
const notes: ObsidianNote[] = [];
|
|
239
|
+
const errors: { path: string; error: string }[] = [];
|
|
240
|
+
|
|
241
|
+
for (const file of files) {
|
|
242
|
+
try {
|
|
243
|
+
const note = parseObsidianFile(file, vaultPath);
|
|
244
|
+
notes.push(note);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
errors.push({
|
|
247
|
+
path: relative(vaultPath, file),
|
|
248
|
+
error: err instanceof Error ? err.message : "parse error",
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { notes, errors };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Export to Obsidian format
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
export interface ExportableNote {
|
|
261
|
+
path?: string;
|
|
262
|
+
id: string;
|
|
263
|
+
content: string;
|
|
264
|
+
metadata?: Record<string, unknown>;
|
|
265
|
+
tags?: string[];
|
|
266
|
+
createdAt: string;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Convert a vault note to Obsidian-compatible markdown with YAML frontmatter.
|
|
271
|
+
*/
|
|
272
|
+
export function toObsidianMarkdown(note: ExportableNote): string {
|
|
273
|
+
const fm: Record<string, unknown> = {};
|
|
274
|
+
|
|
275
|
+
// Add tags to frontmatter
|
|
276
|
+
if (note.tags && note.tags.length > 0) {
|
|
277
|
+
fm.tags = note.tags;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Add metadata fields (excluding internal ones)
|
|
281
|
+
if (note.metadata) {
|
|
282
|
+
for (const [key, value] of Object.entries(note.metadata)) {
|
|
283
|
+
if (key === "tags") continue; // already handled
|
|
284
|
+
fm[key] = value;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Build frontmatter string
|
|
289
|
+
let result = "";
|
|
290
|
+
if (Object.keys(fm).length > 0) {
|
|
291
|
+
result += "---\n";
|
|
292
|
+
for (const [key, value] of Object.entries(fm)) {
|
|
293
|
+
if (Array.isArray(value)) {
|
|
294
|
+
result += `${key}:\n`;
|
|
295
|
+
for (const item of value) {
|
|
296
|
+
result += ` - ${item}\n`;
|
|
297
|
+
}
|
|
298
|
+
} else if (typeof value === "object" && value !== null) {
|
|
299
|
+
result += `${key}: ${JSON.stringify(value)}\n`;
|
|
300
|
+
} else {
|
|
301
|
+
result += `${key}: ${value}\n`;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
result += "---\n";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
result += note.content;
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Determine the file path for an exported note.
|
|
313
|
+
* Notes with paths use the path; pathless notes use date/id.
|
|
314
|
+
*/
|
|
315
|
+
export function exportFilePath(note: ExportableNote): string {
|
|
316
|
+
if (note.path) {
|
|
317
|
+
return note.path + ".md";
|
|
318
|
+
}
|
|
319
|
+
// Fallback: use date prefix + truncated id
|
|
320
|
+
const date = note.createdAt.split("T")[0];
|
|
321
|
+
return `${date}/${note.id}.md`;
|
|
322
|
+
}
|