@openparachute/vault 0.5.1-rc.1 → 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.
- package/core/src/core.test.ts +7 -0
- package/core/src/obsidian-alignment.test.ts +375 -0
- package/core/src/obsidian.ts +234 -14
- package/core/src/portable-md.ts +142 -16
- package/core/src/vault-projection.ts +20 -0
- package/package.json +1 -1
- package/src/cli.ts +8 -25
package/core/src/core.test.ts
CHANGED
|
@@ -5609,6 +5609,13 @@ describe("vault projection (vault#271)", async () => {
|
|
|
5609
5609
|
expect(md).toContain("#person");
|
|
5610
5610
|
expect(md).toContain("vault-info");
|
|
5611
5611
|
expect(md).toContain("list-tags { include_schema: true }");
|
|
5612
|
+
// Scripting pointer (closes the "points nowhere" gap): the brief routes an
|
|
5613
|
+
// agent to the HTTP API + the public guide, with the vault name baked into
|
|
5614
|
+
// the copy-paste mint command.
|
|
5615
|
+
expect(md).toContain("## Scripting & automation (beyond this session)");
|
|
5616
|
+
expect(md).toContain("https://parachute.computer/scripting/");
|
|
5617
|
+
expect(md).toContain("parachute auth mint-token --scope vault:test:read --ephemeral");
|
|
5618
|
+
expect(md).toContain("vault/test/api");
|
|
5612
5619
|
});
|
|
5613
5620
|
|
|
5614
5621
|
it("markdown brief degrades gracefully when no schemas declared", async () => {
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
package/core/src/portable-md.ts
CHANGED
|
@@ -2091,11 +2091,29 @@ export function parseFrontmatter(raw: string): {
|
|
|
2091
2091
|
frontmatter: Record<string, unknown>;
|
|
2092
2092
|
content: string;
|
|
2093
2093
|
} {
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
2380
|
-
*
|
|
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
|
-
|
|
2386
|
-
if (entry
|
|
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() &&
|
|
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 =
|
|
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
|
-
|
|
2553
|
+
const tag = normalizeTagValue(match[1]!);
|
|
2554
|
+
if (tag) tags.add(tag);
|
|
2429
2555
|
}
|
|
2430
2556
|
return [...tags];
|
|
2431
2557
|
}
|
|
@@ -305,5 +305,25 @@ export function projectionToMarkdown(args: {
|
|
|
305
305
|
lines.push("");
|
|
306
306
|
lines.push("If schema or tags change during this session, call `vault-info` to refresh the full projection. Call `list-tags { include_schema: true }` for tag-only details.");
|
|
307
307
|
|
|
308
|
+
// Scripting pointer block: the connect-time brief used to dead-end on
|
|
309
|
+
// querying — an agent had no path to "how do I script/automate against this
|
|
310
|
+
// vault." Point at the guide rather than inlining it, to keep this brief
|
|
311
|
+
// lean (token-budget note above). Uses the concrete vault name so the mint
|
|
312
|
+
// command is copy-paste ready.
|
|
313
|
+
lines.push("");
|
|
314
|
+
lines.push("## Scripting & automation (beyond this session)");
|
|
315
|
+
lines.push("");
|
|
316
|
+
lines.push(
|
|
317
|
+
"This vault is also a plain HTTP API — reach for it when the user wants a script, cron job, or CI step rather than an interactive session:",
|
|
318
|
+
);
|
|
319
|
+
lines.push(
|
|
320
|
+
`- Mint a scoped credential: \`parachute auth mint-token --scope vault:${vaultName}:read --ephemeral\` (\`--ephemeral\` = short-lived, ideal for scripts; use \`:write\` to create/edit).`,
|
|
321
|
+
);
|
|
322
|
+
lines.push(`- Call the REST API at \`<hub-origin>/vault/${vaultName}/api/...\`.`);
|
|
323
|
+
lines.push(
|
|
324
|
+
"- Full guide — copy-paste bash/Python/JS examples, plus how to design tags vs paths vs schemas: https://parachute.computer/scripting/",
|
|
325
|
+
);
|
|
326
|
+
lines.push("- For a prompt on a schedule with no code, see Parachute Runner.");
|
|
327
|
+
|
|
308
328
|
return lines.join("\n");
|
|
309
329
|
}
|
package/package.json
CHANGED
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
|
|
2764
|
-
//
|
|
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
|
-
|
|
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();
|