@openparachute/vault 0.5.1-rc.2 → 0.5.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
  // ---------------------------------------------------------------------------
@@ -2091,11 +2091,29 @@ export function parseFrontmatter(raw: string): {
2091
2091
  frontmatter: Record<string, unknown>;
2092
2092
  content: string;
2093
2093
  } {
2094
- if (!raw.startsWith("---")) return { frontmatter: {}, content: raw };
2095
- const endIdx = raw.indexOf("\n---", 3);
2096
- if (endIdx === -1) return { frontmatter: {}, content: raw };
2097
- const yamlBlock = raw.slice(4, endIdx); // skip opening "---\n"
2098
- const content = raw.slice(endIdx + 4).replace(/^\n/, "");
2094
+ // 1. Strip a leading UTF-8 BOM (U+FEFF) if present. Without this an
2095
+ // Obsidian export saved with a BOM (`---\n…`) fails the open
2096
+ // test and the whole file frontmatter included falls into the
2097
+ // body, silently losing id/tags/timestamps (contract FX-FENCE-BOM).
2098
+ const src = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
2099
+
2100
+ // 2. Line-scan close-fence (CRLF-aware). The opening line must be
2101
+ // EXACTLY `---`; the closing fence is the FIRST subsequent line that
2102
+ // is EXACTLY `---` — not `----`, not `---text`, not ` ---`. An
2103
+ // unclosed block means the whole file is body (never swallow). This
2104
+ // replaces the old `indexOf("\n---")` which wrongly matched `\n----`
2105
+ // and `\n---more` (contract §1.1, FX-FENCE-FOURDASH-OPEN).
2106
+ const lines = src.split(/\r?\n/);
2107
+ if (lines[0] !== "---") return { frontmatter: {}, content: src };
2108
+
2109
+ let closeIdx = -1;
2110
+ for (let i = 1; i < lines.length; i++) {
2111
+ if (lines[i] === "---") { closeIdx = i; break; }
2112
+ }
2113
+ if (closeIdx === -1) return { frontmatter: {}, content: src };
2114
+
2115
+ const yamlBlock = lines.slice(1, closeIdx).join("\n");
2116
+ const content = lines.slice(closeIdx + 1).join("\n");
2099
2117
  return { frontmatter: parseBlock(yamlBlock, 0).value, content };
2100
2118
  }
2101
2119
 
@@ -2119,11 +2137,16 @@ function parseBlock(text: string, baseIndent: number): ParseResult {
2119
2137
  while (i < lines.length) {
2120
2138
  const line = lines[i]!;
2121
2139
  if (line.trim() === "") { i++; continue; }
2140
+ // Skip `#`-comment lines (contract C3 / §1.2). A comment at the
2141
+ // block's base level is not a key line; the parser must step over it.
2142
+ if (line.trimStart().startsWith("#")) { i++; continue; }
2122
2143
  const indent = countLeadingSpaces(line);
2123
2144
  if (indent < baseIndent) break;
2124
2145
  if (indent > baseIndent) { i++; continue; } // shouldn't happen at this level
2125
2146
 
2126
- const kv = line.slice(baseIndent).match(/^([\w][\w-]*):\s*(.*)$/);
2147
+ // Key regex (contract C2 / §1.2): dots allowed in keys, optional
2148
+ // whitespace before the colon (`created.at:` / `key :` both parse).
2149
+ const kv = line.slice(baseIndent).match(/^([\w][\w.-]*)\s*:\s*(.*)$/);
2127
2150
  if (!kv) { i++; continue; }
2128
2151
  const key = kv[1]!;
2129
2152
  const valueText = kv[2]!.trim();
@@ -2199,7 +2222,8 @@ function parseArrayBlock(lines: string[], start: number, arrayIndent: number): {
2199
2222
  // First content after `- `.
2200
2223
  const after = line.slice(indent + 2).trim();
2201
2224
  // Is this a scalar item (`- foo`) or an object item (`- key: value`)?
2202
- const objMatch = after.match(/^([\w][\w-]*):\s*(.*)$/);
2225
+ // Key regex matches parseBlock's (contract C2): dots + optional space.
2226
+ const objMatch = after.match(/^([\w][\w.-]*)\s*:\s*(.*)$/);
2203
2227
  if (!objMatch) {
2204
2228
  result.push(parseScalarOrInline(after));
2205
2229
  i++;
@@ -2251,7 +2275,7 @@ function parseArrayBlock(lines: string[], start: number, arrayIndent: number): {
2251
2275
  const sibIndent = countLeadingSpaces(sib);
2252
2276
  if (sibIndent !== itemIndent) break;
2253
2277
  if (sib.slice(sibIndent).startsWith("- ")) break;
2254
- const sibKv = sib.slice(sibIndent).match(/^([\w][\w-]*):\s*(.*)$/);
2278
+ const sibKv = sib.slice(sibIndent).match(/^([\w][\w.-]*)\s*:\s*(.*)$/);
2255
2279
  if (!sibKv) break;
2256
2280
  const sibKey = sibKv[1]!;
2257
2281
  const sibValue = sibKv[2]!.trim();
@@ -2287,6 +2311,36 @@ function parseArrayBlock(lines: string[], start: number, arrayIndent: number): {
2287
2311
  return { value: result, consumed: i - start };
2288
2312
  }
2289
2313
 
2314
+ /**
2315
+ * Quote-aware split of an inline-array body on top-level commas
2316
+ * (contract C4 / §1.2). A comma inside a single- or double-quoted string
2317
+ * does not separate items, so `"a, b", c` → `['"a, b"', 'c']`. Quote
2318
+ * chars are preserved in the parts; `parseScalarOrInline` strips them via
2319
+ * `unquote`. Mirrors the web parser's `splitInlineArray`.
2320
+ */
2321
+ function splitInlineArray(inner: string): string[] {
2322
+ const parts: string[] = [];
2323
+ let buf = "";
2324
+ let quote: '"' | "'" | null = null;
2325
+ for (let i = 0; i < inner.length; i++) {
2326
+ const ch = inner[i]!;
2327
+ if (quote) {
2328
+ buf += ch;
2329
+ if (ch === quote) quote = null;
2330
+ } else if (ch === '"' || ch === "'") {
2331
+ quote = ch;
2332
+ buf += ch;
2333
+ } else if (ch === ",") {
2334
+ parts.push(buf);
2335
+ buf = "";
2336
+ } else {
2337
+ buf += ch;
2338
+ }
2339
+ }
2340
+ parts.push(buf);
2341
+ return parts;
2342
+ }
2343
+
2290
2344
  /**
2291
2345
  * Parse a scalar or inline form (`[a, b]`, `{ k: v }`). Used for the
2292
2346
  * value portion of `key: value` lines.
@@ -2295,7 +2349,10 @@ function parseScalarOrInline(s: string): unknown {
2295
2349
  if (s.startsWith("[") && s.endsWith("]")) {
2296
2350
  const inner = s.slice(1, -1).trim();
2297
2351
  if (inner === "") return [];
2298
- return inner.split(",").map((part) => parseScalarOrInline(part.trim()));
2352
+ // Quote-aware split (contract C4 / §1.2): a comma inside a quoted
2353
+ // string is NOT an item separator, so `["a, b", c]` → 2 items, not 3.
2354
+ // Matches the web parser's `splitInlineArray`.
2355
+ return splitInlineArray(inner).map((part) => parseScalarOrInline(part.trim()));
2299
2356
  }
2300
2357
  if (s.startsWith("{") && s.endsWith("}")) {
2301
2358
  const inner = s.slice(1, -1).trim();
@@ -2376,18 +2433,59 @@ function unquote(s: string): unknown {
2376
2433
  // Directory walking — shared with obsidian.ts
2377
2434
  // ---------------------------------------------------------------------------
2378
2435
 
2379
- /** Recursively list all .md files in a directory, excluding hidden dirs
2380
- * (including `.parachute/` and `.obsidian/`). */
2436
+ /**
2437
+ * Markdown-file classification (contract §1.7). Case-insensitive `.md`
2438
+ * OR `.markdown`. `.mdx`, `.txt`, etc. are NOT markdown for the importer.
2439
+ * Identical to the web parser's classifier.
2440
+ */
2441
+ export function isMarkdownExtension(path: string): boolean {
2442
+ return /\.(md|markdown)$/i.test(path);
2443
+ }
2444
+
2445
+ /**
2446
+ * Named intake-excluded directory/entry segments (contract §1.9). The
2447
+ * generic `startsWith(".")` rule below subsumes the dot-prefixed ones;
2448
+ * the named set is kept explicit for readability + to cover
2449
+ * `__MACOSX`/`node_modules` (which do not start with ".").
2450
+ */
2451
+ const EXCLUDED_SEGMENTS = new Set([
2452
+ ".obsidian",
2453
+ ".trash",
2454
+ ".git",
2455
+ ".parachute",
2456
+ "__MACOSX",
2457
+ "node_modules",
2458
+ ]);
2459
+
2460
+ /**
2461
+ * Intake (file-selection) exclusion (contract §1.9). Excludes a source
2462
+ * path if ANY `/`-segment is a named-excluded entry OR starts with "."
2463
+ * (generic dotfile/dotdir). Applied identically by both parsers before
2464
+ * parsing. The generic dot rule means legit dot-prefixed user files
2465
+ * (`.daily-note.md`) ARE excluded — the chosen, consistent behavior.
2466
+ */
2467
+ export function isExcludedPath(sourcePath: string): boolean {
2468
+ for (const segment of sourcePath.split("/")) {
2469
+ if (segment === "") continue;
2470
+ if (EXCLUDED_SEGMENTS.has(segment)) return true;
2471
+ if (segment.startsWith(".")) return true;
2472
+ }
2473
+ return false;
2474
+ }
2475
+
2476
+ /** Recursively list all .md / .markdown files in a directory, excluding
2477
+ * hidden + named-internal dirs per the canonical `isExcludedPath` rule
2478
+ * (`.parachute/`, `.obsidian/`, `node_modules`, …). Legacy import path. */
2381
2479
  export function walkMarkdownFiles(dir: string): string[] {
2382
2480
  const results: string[] = [];
2383
2481
  function walk(current: string) {
2384
2482
  for (const entry of readdirSync(current)) {
2385
- if (entry.startsWith(".")) continue;
2386
- if (entry === "node_modules") continue;
2483
+ // Per-segment exclusion: the named set + generic dotfile rule.
2484
+ if (EXCLUDED_SEGMENTS.has(entry) || entry.startsWith(".")) continue;
2387
2485
  const full = join(current, entry);
2388
2486
  const stat = statSync(full);
2389
2487
  if (stat.isDirectory()) walk(full);
2390
- else if (stat.isFile() && extname(entry).toLowerCase() === ".md") results.push(full);
2488
+ else if (stat.isFile() && isMarkdownExtension(entry)) results.push(full);
2391
2489
  }
2392
2490
  }
2393
2491
  walk(dir);
@@ -2417,15 +2515,43 @@ export function walkContentFiles(dir: string): string[] {
2417
2515
  return results.sort();
2418
2516
  }
2419
2517
 
2518
+ /**
2519
+ * Canonical inline-tag regex (contract §1.3). `#` at line-start or after
2520
+ * whitespace; body chars `[A-Za-z0-9_/-]` (slash for hierarchy); the tag
2521
+ * MUST contain ≥1 non-numeric char (the middle `[A-Za-z_/-]`), so `#2024`
2522
+ * is NOT a tag but `#v2`, `#2024-plan`, `#area/sub` are. Identical to the
2523
+ * web parser's `INLINE_HASHTAG`.
2524
+ */
2525
+ const INLINE_TAG_REGEX = /(?:^|\s)#([A-Za-z0-9_/-]*[A-Za-z_/-][A-Za-z0-9_/-]*)/g;
2526
+
2527
+ /**
2528
+ * Normalize + slug-validate a tag value (contract §1.4). Shared by inline
2529
+ * tag extraction and frontmatter tag extraction (obsidian.ts re-exports
2530
+ * this) so both surfaces validate identically. Returns null when the
2531
+ * value is unusable (non-string non-scalar, empty, or fails slug rules).
2532
+ */
2533
+ export function normalizeTagValue(v: unknown): string | null {
2534
+ if (typeof v === "number" || typeof v === "boolean") {
2535
+ return String(v).toLowerCase();
2536
+ }
2537
+ if (typeof v !== "string") return null;
2538
+ const stripped = v.trim().replace(/^#/, "").toLowerCase();
2539
+ if (stripped === "") return null;
2540
+ // Slug-validate: lowercase alnum, underscore, hyphen, slash (hierarchy).
2541
+ if (!/^[a-z0-9_/-]+$/.test(stripped)) return null;
2542
+ return stripped;
2543
+ }
2544
+
2420
2545
  /** Extract inline #tags from markdown content. Excludes tags in code blocks. */
2421
2546
  export function extractInlineTags(content: string): string[] {
2422
2547
  let stripped = content.replace(/```[\s\S]*?```/g, "");
2423
2548
  stripped = stripped.replace(/`[^`\n]+`/g, "");
2424
2549
  const tags = new Set<string>();
2425
- const regex = /(?:^|\s)#([\w][\w/-]*[\w]|[\w])/gm;
2550
+ const regex = new RegExp(INLINE_TAG_REGEX.source, INLINE_TAG_REGEX.flags);
2426
2551
  let match: RegExpExecArray | null;
2427
2552
  while ((match = regex.exec(stripped)) !== null) {
2428
- tags.add(match[1]!.toLowerCase());
2553
+ const tag = normalizeTagValue(match[1]!);
2554
+ if (tag) tags.add(tag);
2429
2555
  }
2430
2556
  return [...tags];
2431
2557
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.5.1-rc.2",
3
+ "version": "0.5.1",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -2760,34 +2760,17 @@ async function cmdImport(args: string[]) {
2760
2760
  return;
2761
2761
  }
2762
2762
 
2763
- // Import into vault — use createNoteRaw to skip per-note wikilink sync,
2764
- // then do a single pass after all notes are imported (much faster for large vaults).
2763
+ // Import into vault — use the shared `importObsidianNotes` adapter
2764
+ // (obsidian.ts). It uses createNoteRaw to skip per-note wikilink sync,
2765
+ // id-aware upsert with a path-conflict guard, intra-batch collision
2766
+ // dedup, per-note error isolation, and timestamp preservation. The
2767
+ // single wikilink pass runs below, after all notes exist.
2768
+ const { importObsidianNotes } = await import("../core/src/obsidian.ts");
2765
2769
  const store = getVaultStore(vaultName);
2766
- let imported = 0;
2767
- let skipped = 0;
2770
+ const { imported, skipped } = await importObsidianNotes(store, notes);
2768
2771
 
2769
- for (const note of notes) {
2770
- // Skip if a note with this path already exists
2771
- const existing = await store.getNoteByPath(note.path);
2772
- if (existing) {
2773
- skipped++;
2774
- continue;
2775
- }
2776
-
2777
- // Build metadata from frontmatter (excluding tags, already extracted)
2778
- const metadata = Object.keys(note.frontmatter).length > 0 ? note.frontmatter : undefined;
2779
-
2780
- await store.createNoteRaw(note.content, {
2781
- path: note.path,
2782
- tags: note.tags.length > 0 ? note.tags : undefined,
2783
- metadata: metadata as Record<string, unknown>,
2784
- });
2785
- imported++;
2786
- }
2787
-
2788
- // Single-pass wikilink sync after all notes exist
2789
2772
  console.log(`\nImported ${imported} notes into vault "${vaultName}"`);
2790
- if (skipped > 0) console.log(`Skipped ${skipped} notes (path already exists)`);
2773
+ if (skipped > 0) console.log(`Skipped ${skipped} notes (path already exists or conflict)`);
2791
2774
 
2792
2775
  if (imported > 0) {
2793
2776
  const linkResult = await store.syncAllWikilinks();