@openparachute/vault 0.5.1-rc.2 → 0.5.2-rc.1

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.
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Obsidian-importer alignment fixtures (vault#423).
3
+ *
4
+ * These assert the vault/CLI parser + write adapter against the SHARED
5
+ * canonical fixture set in the alignment contract. The parachute-surface
6
+ * web parser asserts the SAME fixtures (its own test runner) so both
7
+ * importers produce identical parsed results. Do NOT change expected
8
+ * values here without changing the contract + the web side in lockstep.
9
+ *
10
+ * Fixture inputs are inline strings (`srcPath` + content). Parse-tier
11
+ * fixtures write the file to a temp dir and parse via `parseObsidianFile`
12
+ * — the same code the CLI runs. Write-tier fixtures (FX-ID-COLLISION,
13
+ * FX-SAME-STEM-COLLISION, FX-CREATED-AT write assertion) exercise the
14
+ * real `importObsidianNotes` adapter against an in-memory SqliteStore.
15
+ */
16
+ import { describe, it, expect, beforeEach } from "bun:test";
17
+ import { Database } from "bun:sqlite";
18
+ import { SqliteStore } from "./store.js";
19
+ import {
20
+ parseObsidianFile,
21
+ importObsidianNotes,
22
+ isMarkdownExtension,
23
+ isExcludedPath,
24
+ type ObsidianNote,
25
+ } from "./obsidian.js";
26
+ import { mkdirSync, writeFileSync, rmSync } from "fs";
27
+ import { join, dirname } from "path";
28
+ import { tmpdir } from "os";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Parse-tier helper: write `content` at `srcPath` under a fresh temp root,
32
+ // parse it via the real `parseObsidianFile`, and return the ObsidianNote.
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const PARSE_ROOT = join(tmpdir(), "parachute-align-parse");
36
+
37
+ function parseFixture(srcPath: string, content: string): ObsidianNote {
38
+ rmSync(PARSE_ROOT, { recursive: true, force: true });
39
+ const full = join(PARSE_ROOT, srcPath);
40
+ mkdirSync(dirname(full), { recursive: true });
41
+ writeFileSync(full, content);
42
+ return parseObsidianFile(full, PARSE_ROOT);
43
+ }
44
+
45
+ /** Sorted tag array, for stable comparison against the contract's
46
+ * "(sorted)" expected tag lists. */
47
+ function tags(note: ObsidianNote): string[] {
48
+ return [...note.tags].sort();
49
+ }
50
+
51
+ describe("alignment: parse tier", () => {
52
+ it("FX-FENCE-BOM — strips BOM then parses frontmatter", () => {
53
+ const n = parseFixture("Note.md", "---\nid: bom1\ntags: [a]\n---\nhello");
54
+ expect(n.path).toBe("Note");
55
+ expect(tags(n)).toEqual(["a"]);
56
+ expect(n.id).toBe("bom1");
57
+ expect(n.createdAt).toBeUndefined();
58
+ expect(n.content).toBe("hello");
59
+ expect(n.frontmatter).toEqual({});
60
+ });
61
+
62
+ it("FX-FENCE-FOURDASH — ---- body line is not a close fence", () => {
63
+ const n = parseFixture("Doc.md", "---\nid: x9\n---\nbefore\n----\nafter");
64
+ expect(n.path).toBe("Doc");
65
+ expect(tags(n)).toEqual([]);
66
+ expect(n.id).toBe("x9");
67
+ expect(n.content).toBe("before\n----\nafter");
68
+ expect(n.frontmatter).toEqual({});
69
+ });
70
+
71
+ it("FX-FENCE-FOURDASH-OPEN — ---- after open, no real close → whole file is body", () => {
72
+ const n = parseFixture("D2.md", "---\nid: y\n----\nbody text");
73
+ expect(n.path).toBe("D2");
74
+ expect(tags(n)).toEqual([]);
75
+ expect(n.id).toBeUndefined();
76
+ expect(n.content).toBe("---\nid: y\n----\nbody text");
77
+ expect(n.frontmatter).toEqual({});
78
+ });
79
+
80
+ it("FX-FENCE-UNCLOSED — open never closed → whole file is body", () => {
81
+ const n = parseFixture("U.md", "---\nid: z\nbody no close");
82
+ expect(n.path).toBe("U");
83
+ expect(tags(n)).toEqual([]);
84
+ expect(n.id).toBeUndefined();
85
+ expect(n.content).toBe("---\nid: z\nbody no close");
86
+ expect(n.frontmatter).toEqual({});
87
+ });
88
+
89
+ it("FX-CODE-TAG — #tag inside fenced + inline code is dropped", () => {
90
+ const content = "#realtag at top\n\n`#inlinenope`\n\n```\n#fencednope\n```\n";
91
+ const n = parseFixture("C.md", content);
92
+ expect(n.path).toBe("C");
93
+ expect(tags(n)).toEqual(["realtag"]);
94
+ expect(n.content).toBe(content);
95
+ expect(n.frontmatter).toEqual({});
96
+ });
97
+
98
+ it("FX-NUMERIC-TAG — #2024 is not a tag, #q3/#v2 are", () => {
99
+ const n = parseFixture("N.md", "plan #2024 and #q3 and #v2 done");
100
+ expect(n.path).toBe("N");
101
+ expect(tags(n)).toEqual(["q3", "v2"]);
102
+ expect(n.frontmatter).toEqual({});
103
+ });
104
+
105
+ it("FX-HIER-TAG — hierarchical inline tag keeps the slash", () => {
106
+ const n = parseFixture("H.md", "see #area/subarea here");
107
+ expect(n.path).toBe("H");
108
+ expect(tags(n)).toEqual(["area/subarea"]);
109
+ expect(n.frontmatter).toEqual({});
110
+ });
111
+
112
+ it("FX-FM-TAGS-VALIDATE — slug-validate + normalize frontmatter tags", () => {
113
+ const n = parseFixture(
114
+ "T.md",
115
+ '---\ntags: [Foo, "bad tag!", 42, true, ok-1, "#hash"]\n---\nbody',
116
+ );
117
+ expect(n.path).toBe("T");
118
+ expect(tags(n)).toEqual(["42", "foo", "hash", "ok-1", "true"]);
119
+ expect(n.content).toBe("body");
120
+ expect(n.frontmatter).toEqual({});
121
+ });
122
+
123
+ it("FX-INLINE-ARRAY — quote-aware inline-array split (2 items, not 3)", () => {
124
+ const n = parseFixture("IA.md", '---\nkeywords: ["a, b", c]\n---\nx');
125
+ expect(n.path).toBe("IA");
126
+ expect(tags(n)).toEqual([]);
127
+ expect(n.content).toBe("x");
128
+ expect(n.frontmatter).toEqual({ keywords: ["a, b", "c"] });
129
+ });
130
+
131
+ it("FX-MARKDOWN-EXT — .markdown ingest + classifier", () => {
132
+ const n = parseFixture("Folder/Note.markdown", "---\nid: m1\n---\nbody");
133
+ expect(n.path).toBe("Folder/Note");
134
+ expect(tags(n)).toEqual([]);
135
+ expect(n.id).toBe("m1");
136
+ expect(n.content).toBe("body");
137
+ expect(n.frontmatter).toEqual({});
138
+
139
+ expect(isMarkdownExtension("x.markdown")).toBe(true);
140
+ expect(isMarkdownExtension("x.md")).toBe(true);
141
+ expect(isMarkdownExtension("x.mdx")).toBe(false);
142
+ });
143
+
144
+ it("FX-PATH-OVERRIDE — frontmatter path: wins, not in metadata", () => {
145
+ const n = parseFixture("deep/orig.md", "---\npath: Custom/Place\n---\nbody");
146
+ expect(n.path).toBe("Custom/Place");
147
+ expect(tags(n)).toEqual([]);
148
+ expect(n.id).toBeUndefined();
149
+ expect(n.content).toBe("body");
150
+ expect(n.frontmatter).toEqual({});
151
+ });
152
+
153
+ it("FX-PATH-OVERRIDE-EXT — frontmatter path: override is extension-stripped", () => {
154
+ // Pins contract §1.8: a `path:` override ending in .md/.markdown has
155
+ // its extension stripped (matches the web side). A real web-side
156
+ // divergence here stayed green because nothing pinned the invariant.
157
+ const n = parseFixture("deep/orig.md", "---\npath: My/Note.md\n---\nbody");
158
+ expect(n.path).toBe("My/Note");
159
+ expect(tags(n)).toEqual([]);
160
+ expect(n.id).toBeUndefined();
161
+ expect(n.content).toBe("body");
162
+ expect(n.frontmatter).toEqual({});
163
+ });
164
+
165
+ it("FX-PATH-NORMALIZE — backslash + collapse + case-preserve", () => {
166
+ // Frontmatter path value: \Win\Path\\x\ (double-quoted in YAML).
167
+ const n = parseFixture("X.md", '---\npath: "\\\\Win\\\\Path\\\\\\\\x\\\\"\n---\nb');
168
+ expect(n.path).toBe("Win/Path/x");
169
+ expect(n.content).toBe("b");
170
+ expect(n.frontmatter).toEqual({});
171
+ });
172
+
173
+ it("FX-CREATED-AT — created_at + updated_at hoisted verbatim, not in metadata", () => {
174
+ const n = parseFixture(
175
+ "CA.md",
176
+ "---\nid: t1\ncreated_at: 2024-05-01T10:00:00Z\nupdated_at: 2024-06-01T12:00:00Z\n---\nbody",
177
+ );
178
+ expect(n.path).toBe("CA");
179
+ expect(n.id).toBe("t1");
180
+ expect(n.createdAt).toBe("2024-05-01T10:00:00Z");
181
+ expect(n.updatedAt).toBe("2024-06-01T12:00:00Z");
182
+ expect(n.content).toBe("body");
183
+ expect(n.frontmatter).toEqual({});
184
+ });
185
+
186
+ it("FX-CREATED-AT-CAMEL — camelCase createdAt fallback", () => {
187
+ const n = parseFixture("CC.md", "---\ncreatedAt: 2024-05-01T10:00:00Z\n---\nx");
188
+ expect(n.path).toBe("CC");
189
+ expect(n.createdAt).toBe("2024-05-01T10:00:00Z");
190
+ expect(n.frontmatter).toEqual({});
191
+ });
192
+
193
+ it("FX-NO-ID — id absent → field omitted", () => {
194
+ const n = parseFixture("NI.md", "---\ntitle: Hello\n---\nbody");
195
+ expect(n.path).toBe("NI");
196
+ expect(n.id).toBeUndefined();
197
+ expect(tags(n)).toEqual([]);
198
+ expect(n.content).toBe("body");
199
+ expect(n.frontmatter).toEqual({ title: "Hello" });
200
+ });
201
+
202
+ it("FX-DOTDIR-EXCLUDE — intake exclusion classifier", () => {
203
+ expect(isExcludedPath(".obsidian/app.json")).toBe(true);
204
+ expect(isExcludedPath(".trash/x.md")).toBe(true);
205
+ expect(isExcludedPath(".git/config")).toBe(true);
206
+ expect(isExcludedPath(".parachute/vault.yaml")).toBe(true);
207
+ expect(isExcludedPath("__MACOSX/x")).toBe(true);
208
+ expect(isExcludedPath("node_modules/y/z.md")).toBe(true);
209
+ expect(isExcludedPath(".DS_Store")).toBe(true);
210
+ expect(isExcludedPath("sub/.hidden.md")).toBe(true);
211
+ expect(isExcludedPath("Notes/a.md")).toBe(false);
212
+ expect(isExcludedPath("Notes/Sub/b.markdown")).toBe(false);
213
+ });
214
+
215
+ it("FX-WIKILINK-PASSTHROUGH — wikilinks untouched, inline #tag still extracted", () => {
216
+ const content = "See [[Other]] and ![[Embed]] and #tag";
217
+ const n = parseFixture("WL.md", content);
218
+ expect(n.content).toContain("[[Other]]");
219
+ expect(n.content).toContain("![[Embed]]");
220
+ expect(tags(n)).toEqual(["tag"]);
221
+ expect(n.frontmatter).toEqual({});
222
+ });
223
+
224
+ it("FX-CRLF — CRLF frontmatter", () => {
225
+ const n = parseFixture("CR.md", "---\r\nid: cr1\r\ntags: [a]\r\n---\r\nbody");
226
+ expect(n.path).toBe("CR");
227
+ expect(n.id).toBe("cr1");
228
+ expect(tags(n)).toEqual(["a"]);
229
+ expect(n.content).toBe("body");
230
+ expect(n.frontmatter).toEqual({});
231
+ });
232
+
233
+ it("FX-DOTTED-KEY — dotted frontmatter key accepted, not confused with created_at", () => {
234
+ const n = parseFixture("DK.md", "---\ncreated.at: 2024\nid: dk1\n---\nx");
235
+ expect(n.path).toBe("DK");
236
+ expect(n.id).toBe("dk1");
237
+ expect(n.createdAt).toBeUndefined();
238
+ expect(n.frontmatter).toEqual({ "created.at": 2024 });
239
+ expect(n.content).toBe("x");
240
+ });
241
+
242
+ it("FX-COMMENT-LINE — comment inside frontmatter is skipped", () => {
243
+ const n = parseFixture("CM.md", "---\n# a yaml comment\nid: cm1\n---\nbody");
244
+ expect(n.path).toBe("CM");
245
+ expect(n.id).toBe("cm1");
246
+ expect(n.content).toBe("body");
247
+ expect(n.frontmatter).toEqual({});
248
+ });
249
+
250
+ it("FX-NO-FRONTMATTER — plain markdown, inline tag only", () => {
251
+ const content = "# Title\n\nbody with #tag";
252
+ const n = parseFixture("P.md", content);
253
+ expect(n.path).toBe("P");
254
+ expect(n.id).toBeUndefined();
255
+ expect(tags(n)).toEqual(["tag"]);
256
+ expect(n.content).toBe(content);
257
+ expect(n.frontmatter).toEqual({});
258
+ });
259
+
260
+ it("FX-METADATA-EXCLUSIONS — all seven hoisted keys excluded from metadata", () => {
261
+ const n = parseFixture(
262
+ "M.md",
263
+ "---\nid: i\npath: P/Q\ntags: [t]\ncreated_at: 2024\ncreatedAt: 2024\nupdated_at: 2024\nupdatedAt: 2024\nextra: keep\n---\nb",
264
+ );
265
+ expect(n.path).toBe("P/Q");
266
+ expect(n.id).toBe("i");
267
+ expect(tags(n)).toEqual(["t"]);
268
+ expect(n.createdAt).toBe("2024");
269
+ expect(n.updatedAt).toBe("2024");
270
+ expect(n.content).toBe("b");
271
+ expect(n.frontmatter).toEqual({ extra: "keep" });
272
+ });
273
+ });
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Write tier — real `importObsidianNotes` adapter against an in-memory Store.
277
+ // ---------------------------------------------------------------------------
278
+
279
+ describe("alignment: write tier (importObsidianNotes adapter)", () => {
280
+ let store: SqliteStore;
281
+
282
+ beforeEach(() => {
283
+ store = new SqliteStore(new Database(":memory:"));
284
+ });
285
+
286
+ it("FX-CREATED-AT — timestamps pegged on the id path (not new Date())", async () => {
287
+ const note = parseFixture(
288
+ "CA.md",
289
+ "---\nid: t1\ncreated_at: 2024-05-01T10:00:00Z\nupdated_at: 2024-06-01T12:00:00Z\n---\nbody",
290
+ );
291
+ const { imported, skipped } = await importObsidianNotes(store, [note]);
292
+ expect(imported).toBe(1);
293
+ expect(skipped).toBe(0);
294
+ const stored = (await store.getNote("t1"))!;
295
+ expect(stored).toBeTruthy();
296
+ expect(stored.createdAt).toBe("2024-05-01T10:00:00Z");
297
+ expect(stored.updatedAt).toBe("2024-06-01T12:00:00Z");
298
+ });
299
+
300
+ it("FX-CREATED-AT (no-id variant) — minted note still gets the frontmatter created_at", async () => {
301
+ const note = parseFixture(
302
+ "CA.md",
303
+ "---\ncreated_at: 2024-05-01T10:00:00Z\nupdated_at: 2024-06-01T12:00:00Z\n---\nbody",
304
+ );
305
+ expect(note.id).toBeUndefined();
306
+ const { imported } = await importObsidianNotes(store, [note]);
307
+ expect(imported).toBe(1);
308
+ const stored = (await store.getNoteByPath("CA"))!;
309
+ expect(stored).toBeTruthy();
310
+ expect(stored.createdAt).toBe("2024-05-01T10:00:00Z");
311
+ expect(stored.updatedAt).toBe("2024-06-01T12:00:00Z");
312
+ });
313
+
314
+ it("FX-ID-COLLISION — id-upsert path guard skips, does not overwrite", async () => {
315
+ // Pre-seed a note id=dup1 at path Existing/Place.
316
+ await store.createNoteRaw("original body", { id: "dup1", path: "Existing/Place" });
317
+ const before = (await store.getNote("dup1"))!;
318
+
319
+ const note = parseFixture("anything.md", "---\nid: dup1\npath: Different/Place\n---\nnew body");
320
+ expect(note.id).toBe("dup1");
321
+ expect(note.path).toBe("Different/Place");
322
+
323
+ const { imported, skipped } = await importObsidianNotes(store, [note]);
324
+ expect(imported).toBe(0);
325
+ expect(skipped).toBe(1);
326
+
327
+ // The pre-existing note is untouched.
328
+ const after = (await store.getNote("dup1"))!;
329
+ expect(after.content).toBe("original body");
330
+ expect(after.path).toBe("Existing/Place");
331
+ expect(before.content).toBe(after.content);
332
+ // No new note created at Different/Place.
333
+ expect(await store.getNoteByPath("Different/Place")).toBeNull();
334
+ });
335
+
336
+ it("FX-SAME-STEM-COLLISION — Foo.md + Foo.markdown → 1 created, 1 skipped, no throw", async () => {
337
+ const fooMd = parseFixture("Foo.md", "AAA");
338
+ const fooMarkdown = parseFixture("Foo.markdown", "BBB");
339
+ expect(fooMd.path).toBe("Foo");
340
+ expect(fooMarkdown.path).toBe("Foo");
341
+
342
+ // Sorted walk order: Foo.markdown sorts before Foo.md.
343
+ const { imported, skipped } = await importObsidianNotes(store, [fooMarkdown, fooMd]);
344
+ expect(imported).toBe(1);
345
+ expect(skipped).toBe(1);
346
+
347
+ const stored = (await store.getNoteByPath("Foo"))!;
348
+ expect(stored).toBeTruthy();
349
+ expect(["AAA", "BBB"]).toContain(stored.content);
350
+ });
351
+
352
+ it("intra-batch collision survives even when dedup is bypassed (try/catch isolation)", async () => {
353
+ // Two no-id notes at the same path but built so the second slips past
354
+ // the seenPaths guard would still be caught by the UNIQUE insert. Here
355
+ // we exercise the belt-and-suspenders try/catch by forcing a duplicate
356
+ // through with distinct objects (same normalized path).
357
+ const a: ObsidianNote = { path: "Dup", content: "one", frontmatter: {}, tags: [] };
358
+ const b: ObsidianNote = { path: "Dup", content: "two", frontmatter: {}, tags: [] };
359
+ const { imported, skipped } = await importObsidianNotes(store, [a, b]);
360
+ expect(imported).toBe(1);
361
+ expect(skipped).toBe(1);
362
+ expect((await store.getNoteByPath("Dup"))!.content).toBe("one");
363
+ });
364
+
365
+ it("id-upsert same-path updates content + replaces tags", async () => {
366
+ await store.createNoteRaw("v1", { id: "up1", path: "Same/Place", tags: ["old"] });
367
+ const note = parseFixture("x.md", "---\nid: up1\npath: Same/Place\ntags: [new]\n---\nv2");
368
+ const { imported, skipped } = await importObsidianNotes(store, [note]);
369
+ expect(imported).toBe(1);
370
+ expect(skipped).toBe(0);
371
+ const stored = (await store.getNote("up1"))!;
372
+ expect(stored.content).toBe("v2");
373
+ expect(stored.tags).toEqual(["new"]);
374
+ });
375
+ });
@@ -33,23 +33,41 @@ export {
33
33
  parseFrontmatter,
34
34
  extractInlineTags,
35
35
  walkMarkdownFiles,
36
+ normalizeTagValue,
37
+ isMarkdownExtension,
38
+ isExcludedPath,
36
39
  } from "./portable-md.js";
37
40
 
38
- import { parseFrontmatter, walkMarkdownFiles, extractInlineTags } from "./portable-md.js";
41
+ import {
42
+ parseFrontmatter,
43
+ walkMarkdownFiles,
44
+ extractInlineTags,
45
+ normalizeTagValue,
46
+ } from "./portable-md.js";
39
47
 
40
48
  // ---------------------------------------------------------------------------
41
49
  // Types
42
50
  // ---------------------------------------------------------------------------
43
51
 
44
52
  export interface ObsidianNote {
45
- /** Relative path without .md extension (e.g., "Projects/Parachute/README") */
53
+ /** Relative path without .md/.markdown extension (e.g.,
54
+ * "Projects/Parachute/README"). A frontmatter `path:` override wins. */
46
55
  path: string;
47
56
  /** Raw markdown content (frontmatter stripped) */
48
57
  content: string;
49
- /** Parsed YAML frontmatter */
58
+ /** Parsed YAML frontmatter with hoisted keys (id/path/tags/timestamps)
59
+ * removed — i.e. the metadata bag the importer persists. */
50
60
  frontmatter: Record<string, unknown>;
51
61
  /** Tags from both frontmatter and inline #tags */
52
62
  tags: string[];
63
+ /** Frontmatter `id` (string, trimmed, non-empty), if present. The write
64
+ * adapter upserts-by-id when set. Absent → field omitted. */
65
+ id?: string;
66
+ /** Frontmatter `created_at` / `createdAt` (verbatim string, no Date
67
+ * coercion), if present. Preserved on write. */
68
+ createdAt?: string;
69
+ /** Frontmatter `updated_at` / `updatedAt` (verbatim string), if present. */
70
+ updatedAt?: string;
53
71
  }
54
72
 
55
73
  export interface ImportStats {
@@ -60,13 +78,43 @@ export interface ImportStats {
60
78
  errors: { path: string; error: string }[];
61
79
  }
62
80
 
63
- /** Tags from frontmatter (handles both array and string formats). */
81
+ /**
82
+ * Tags from frontmatter (handles both array and string formats), routed
83
+ * through the canonical `normalizeTagValue` (contract C6 / §1.4) so they
84
+ * are slug-validated, lowercased, and `#`-stripped identically to the web
85
+ * parser. A string value is split on `/[,\s]+/` (comma OR whitespace).
86
+ * Invalid values (`My Tag!`, non-scalars) are dropped.
87
+ */
64
88
  function extractFrontmatterTags(frontmatter: Record<string, unknown>): string[] {
65
89
  const raw = frontmatter.tags;
66
- if (!raw) return [];
67
- if (Array.isArray(raw)) return raw.map((t) => String(t).toLowerCase().trim()).filter(Boolean);
68
- if (typeof raw === "string") return raw.split(",").map((t) => t.toLowerCase().trim()).filter(Boolean);
69
- return [];
90
+ if (raw === undefined || raw === null) return [];
91
+ const candidates: unknown[] = Array.isArray(raw)
92
+ ? raw
93
+ : typeof raw === "string"
94
+ ? raw.split(/[,\s]+/)
95
+ : [];
96
+ const out: string[] = [];
97
+ for (const c of candidates) {
98
+ const tag = normalizeTagValue(c);
99
+ if (tag) out.push(tag);
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /**
105
+ * Normalize an import path (contract §1.8). Case-PRESERVING: backslash →
106
+ * forward slash, strip trailing `.md`/`.markdown`, collapse repeated
107
+ * slashes, trim leading/trailing slashes. Empty result → "". Does not
108
+ * lowercase or slugify (friend-friendly paths). Agrees with the Store's
109
+ * `paths.ts::normalizePath` on the slug rules; additionally strips
110
+ * `.markdown` and returns "" (not null) for empty.
111
+ */
112
+ function normalizeImportPath(p: string): string {
113
+ return p
114
+ .replace(/\\/g, "/")
115
+ .replace(/\.(md|markdown)$/i, "")
116
+ .replace(/\/+/g, "/")
117
+ .replace(/^\/+|\/+$/g, "");
70
118
  }
71
119
 
72
120
  // ---------------------------------------------------------------------------
@@ -77,20 +125,59 @@ export function parseObsidianFile(filePath: string, vaultRoot: string): Obsidian
77
125
  const raw = readFileSync(filePath, "utf-8");
78
126
  const { frontmatter, content } = parseFrontmatter(raw);
79
127
 
80
- // Path: relative to vault root, without .md extension
81
- const rel = relative(vaultRoot, filePath);
82
- const path = rel.replace(/\.md$/i, "");
128
+ // Path: a frontmatter `path:` override wins (contract §1.8); otherwise
129
+ // derive from the source filename. Both routed through the
130
+ // case-preserving `normalizeImportPath` (strips .md/.markdown,
131
+ // backslash→/, collapse + trim slashes).
132
+ const fmPath = frontmatter.path;
133
+ const path =
134
+ typeof fmPath === "string" && fmPath.trim() !== ""
135
+ ? normalizeImportPath(fmPath.trim())
136
+ : normalizeImportPath(relative(vaultRoot, filePath));
137
+
138
+ // Hoist id (contract §1.5) — string, trimmed, non-empty.
139
+ const rawId = frontmatter.id;
140
+ const id =
141
+ typeof rawId === "string" && rawId.trim() !== "" ? rawId.trim() : undefined;
142
+
143
+ // Hoist created_at/updated_at (contract §1.6) — verbatim string, with
144
+ // camelCase fallback, NO Date coercion.
145
+ const createdAt = hoistTimestamp(frontmatter.created_at ?? frontmatter.createdAt);
146
+ const updatedAt = hoistTimestamp(frontmatter.updated_at ?? frontmatter.updatedAt);
83
147
 
84
- // Merge tags from frontmatter and inline
148
+ // Merge tags from frontmatter and inline.
85
149
  const fmTags = extractFrontmatterTags(frontmatter);
86
150
  const inlineTags = extractInlineTags(content);
87
151
  const allTags = [...new Set([...fmTags, ...inlineTags])];
88
152
 
89
- // Remove tags from metadata (they go to the tags table)
153
+ // Strip all hoisted keys from the metadata bag (contract C7) they
154
+ // become first-class note fields / the tags table, not metadata.
90
155
  const metadata = { ...frontmatter };
156
+ delete metadata.id;
157
+ delete metadata.path;
91
158
  delete metadata.tags;
159
+ delete metadata.created_at;
160
+ delete metadata.createdAt;
161
+ delete metadata.updated_at;
162
+ delete metadata.updatedAt;
163
+
164
+ const note: ObsidianNote = { path, content, frontmatter: metadata, tags: allTags };
165
+ if (id !== undefined) note.id = id;
166
+ if (createdAt !== undefined) note.createdAt = createdAt;
167
+ if (updatedAt !== undefined) note.updatedAt = updatedAt;
168
+ return note;
169
+ }
92
170
 
93
- return { path, content, frontmatter: metadata, tags: allTags };
171
+ /** Coerce a hoisted timestamp frontmatter value to a verbatim trimmed
172
+ * string (contract §1.6) — no Date coercion. Numbers (e.g. a bare year
173
+ * `2024`) stringify; non-scalars are dropped. */
174
+ function hoistTimestamp(v: unknown): string | undefined {
175
+ if (typeof v === "string") {
176
+ const t = v.trim();
177
+ return t === "" ? undefined : t;
178
+ }
179
+ if (typeof v === "number") return String(v);
180
+ return undefined;
94
181
  }
95
182
 
96
183
  // ---------------------------------------------------------------------------
@@ -131,6 +218,139 @@ export function parseObsidianVault(vaultPath: string): {
131
218
  return { notes, errors };
132
219
  }
133
220
 
221
+ // ---------------------------------------------------------------------------
222
+ // Legacy import write adapter (contract C10)
223
+ // ---------------------------------------------------------------------------
224
+
225
+ import type { Note } from "./types.js";
226
+
227
+ /**
228
+ * The Store surface `importObsidianNotes` needs. `SqliteStore` satisfies
229
+ * this structurally; defined locally (rather than importing the full
230
+ * `Store` interface) because `createNoteRaw` is a Store-implementation
231
+ * method, not on the public `Store` interface.
232
+ */
233
+ export interface ImportWriteStore {
234
+ getNote(id: string): Promise<Note | null>;
235
+ getNoteByPath(path: string, extension?: string): Promise<Note | null>;
236
+ createNoteRaw(
237
+ content: string,
238
+ opts?: {
239
+ id?: string;
240
+ path?: string;
241
+ tags?: string[];
242
+ metadata?: Record<string, unknown>;
243
+ created_at?: string;
244
+ extension?: string;
245
+ },
246
+ ): Promise<Note>;
247
+ updateNote(
248
+ id: string,
249
+ updates: { content?: string; path?: string; metadata?: Record<string, unknown> },
250
+ ): Promise<Note>;
251
+ tagNote(noteId: string, tags: string[]): Promise<void>;
252
+ untagNote(noteId: string, tags: string[]): Promise<void>;
253
+ restoreNoteTimestamps(id: string, createdAt: string, updatedAt: string): Promise<void>;
254
+ }
255
+
256
+ /**
257
+ * Write parsed Obsidian notes into the Store (legacy import path —
258
+ * contract C10). Behavior:
259
+ *
260
+ * - **id-aware upsert** (correction #4): a frontmatter `id` upserts by id.
261
+ * If that id already exists at a DIFFERENT path, the import does NOT
262
+ * overwrite the unrelated note — it skips with a warning. Same id +
263
+ * same/absent path updates in place.
264
+ * - **timestamp preservation** (correction #3): frontmatter
265
+ * `created_at`/`updated_at` are pegged via `restoreNoteTimestamps` on
266
+ * BOTH the id and no-id branches (the no-id path mints an id first).
267
+ * - **intra-batch path-dedup + write isolation** (correction #5):
268
+ * `Foo.md` + `Foo.markdown` both normalize to path `Foo`; the second is
269
+ * counted skipped (dedup OR a caught `PathConflictError`). Each note's
270
+ * write is wrapped in try/catch so one failure never aborts the batch.
271
+ *
272
+ * Does NOT run wikilink sync — the caller does a single post-batch pass
273
+ * (much faster for large vaults). Returns `{ imported, skipped }`.
274
+ */
275
+ export async function importObsidianNotes(
276
+ store: ImportWriteStore,
277
+ notes: ObsidianNote[],
278
+ ): Promise<{ imported: number; skipped: number }> {
279
+ let imported = 0;
280
+ let skipped = 0;
281
+
282
+ // `getNoteByPath` matches COLLATE NOCASE, so the dedup key is lowercased.
283
+ const seenPaths = new Set<string>();
284
+
285
+ for (const note of notes) {
286
+ const metadata = Object.keys(note.frontmatter).length > 0 ? note.frontmatter : undefined;
287
+
288
+ try {
289
+ if (note.id) {
290
+ const existing = await store.getNote(note.id);
291
+ if (existing && existing.path != null && note.path && existing.path !== note.path) {
292
+ console.warn(
293
+ `skip: id ${note.id} exists at "${existing.path}", incoming path "${note.path}" differs`,
294
+ );
295
+ skipped++;
296
+ continue;
297
+ }
298
+ if (existing) {
299
+ await store.updateNote(note.id, {
300
+ content: note.content,
301
+ ...(note.path ? { path: note.path } : {}),
302
+ ...(metadata ? { metadata: metadata as Record<string, unknown> } : {}),
303
+ });
304
+ if (existing.tags && existing.tags.length > 0) await store.untagNote(note.id, existing.tags);
305
+ if (note.tags.length > 0) await store.tagNote(note.id, note.tags);
306
+ } else {
307
+ await store.createNoteRaw(note.content, {
308
+ id: note.id,
309
+ path: note.path || undefined,
310
+ tags: note.tags.length > 0 ? note.tags : undefined,
311
+ metadata: metadata as Record<string, unknown>,
312
+ });
313
+ }
314
+ if (note.createdAt) {
315
+ await store.restoreNoteTimestamps(note.id, note.createdAt, note.updatedAt ?? note.createdAt);
316
+ }
317
+ if (note.path) seenPaths.add(note.path.toLowerCase());
318
+ imported++;
319
+ } else {
320
+ const key = (note.path || "").toLowerCase();
321
+ if (note.path && seenPaths.has(key)) {
322
+ skipped++;
323
+ continue;
324
+ }
325
+ const existing = note.path ? await store.getNoteByPath(note.path) : null;
326
+ if (existing) {
327
+ skipped++;
328
+ if (note.path) seenPaths.add(key);
329
+ continue;
330
+ }
331
+ const created = await store.createNoteRaw(note.content, {
332
+ path: note.path || undefined,
333
+ tags: note.tags.length > 0 ? note.tags : undefined,
334
+ metadata: metadata as Record<string, unknown>,
335
+ });
336
+ if (note.path) seenPaths.add(key);
337
+ if (note.createdAt) {
338
+ await store.restoreNoteTimestamps(created.id, note.createdAt, note.updatedAt ?? note.createdAt);
339
+ }
340
+ imported++;
341
+ }
342
+ } catch (err) {
343
+ // A collision that slips past the dedup (or a path-conflict on the
344
+ // id-upsert branch) throws from the UNIQUE insert. Collect + continue
345
+ // rather than aborting the whole import (correction #5).
346
+ console.warn(`skip: ${note.path}: ${err instanceof Error ? err.message : String(err)}`);
347
+ skipped++;
348
+ }
349
+ }
350
+
351
+ return { imported, skipped };
352
+ }
353
+
134
354
  // ---------------------------------------------------------------------------
135
355
  // Legacy export — kept for back-compat. New code: use `toPortableMarkdown`.
136
356
  // ---------------------------------------------------------------------------