@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,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
+ }