@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.
- package/core/src/core.test.ts +183 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +77 -0
- package/core/src/mcp.ts +130 -22
- package/core/src/notes.ts +36 -0
- package/core/src/obsidian-alignment.test.ts +375 -0
- package/core/src/obsidian.ts +234 -14
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/portable-md.ts +142 -16
- package/core/src/schema.ts +7 -4
- package/core/src/store.ts +1 -1
- package/core/src/tag-schemas.ts +59 -44
- package/core/src/types.ts +31 -3
- package/package.json +1 -1
- package/src/auth.test.ts +37 -1
- package/src/auth.ts +29 -0
- package/src/cli.ts +133 -34
- package/src/config.test.ts +16 -0
- package/src/config.ts +39 -0
- package/src/mcp-tools.ts +60 -6
- package/src/routes.ts +486 -53
- package/src/routing.test.ts +185 -0
- package/src/routing.ts +32 -2
- package/src/server.ts +7 -0
- package/src/storage.test.ts +162 -0
- package/src/tag-scope.ts +68 -1
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +194 -2
- package/src/vault.test.ts +1064 -7
|
@@ -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
|
+
});
|
package/core/src/obsidian.ts
CHANGED
|
@@ -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 {
|
|
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.,
|
|
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
|
-
/**
|
|
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 (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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:
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|