@openparachute/vault 0.4.0 → 0.4.4-rc.11
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/README.md +191 -2
- package/core/src/core.test.ts +1295 -526
- package/core/src/mcp.ts +129 -428
- package/core/src/notes.ts +405 -32
- package/core/src/obsidian.ts +55 -177
- package/core/src/portable-md.test.ts +1001 -0
- package/core/src/portable-md.ts +1409 -0
- package/core/src/schema-defaults.ts +233 -171
- package/core/src/schema.ts +104 -32
- package/core/src/store.ts +103 -78
- package/core/src/tag-hierarchy.ts +36 -2
- package/core/src/types.ts +52 -42
- package/core/src/vault-projection.ts +309 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +142 -13
- package/src/auth.ts +29 -0
- package/src/cli.ts +699 -141
- package/src/doctor.test.ts +7 -6
- package/src/hub-jwt.test.ts +16 -5
- package/src/hub-jwt.ts +9 -0
- package/src/mcp-http.ts +4 -2
- package/src/mcp-install-interactive.test.ts +883 -0
- package/src/mcp-install-interactive.ts +412 -0
- package/src/mcp-install.test.ts +957 -5
- package/src/mcp-install.ts +580 -13
- package/src/mcp-tools.ts +101 -90
- package/src/routes.ts +330 -207
- package/src/routing.test.ts +12 -12
- package/src/routing.ts +0 -2
- package/src/tokens-routes.test.ts +11 -4
- package/src/vault.test.ts +1052 -333
- package/core/src/note-schemas.ts +0 -232
package/core/src/obsidian.ts
CHANGED
|
@@ -1,15 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Obsidian
|
|
2
|
+
* Obsidian-compatible markdown export/import — back-compat shim.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* @deprecated The canonical home for the markdown knowledge-base format
|
|
5
|
+
* is `portable-md.ts`. The format isn't Obsidian-specific (it's consumed
|
|
6
|
+
* unchanged by Logseq, Foam, Quartz, Dendron, and most markdown-shaped
|
|
7
|
+
* static-site generators) — anchoring the function name to the format
|
|
8
|
+
* keeps the door open as other consumers adopt the same shape. See
|
|
9
|
+
* vault#308.
|
|
10
|
+
*
|
|
11
|
+
* What lives here:
|
|
12
|
+
* - `toObsidianMarkdown` — the **legacy** lossy emitter (flat
|
|
13
|
+
* frontmatter, no IDs, no typed links, no attachments). Kept for
|
|
14
|
+
* existing callers; for round-trippable exports use
|
|
15
|
+
* `toPortableMarkdown` in `portable-md.ts`.
|
|
16
|
+
* - `parseObsidianVault` / `parseObsidianFile` — directory + file
|
|
17
|
+
* parsers. These delegate to `portable-md.ts`'s parser, which
|
|
18
|
+
* handles both the new lossless shape and the legacy flat
|
|
19
|
+
* frontmatter shape.
|
|
20
|
+
* - Re-exports of `parseFrontmatter`, `extractInlineTags`,
|
|
21
|
+
* `walkMarkdownFiles` from `portable-md.ts` so existing imports
|
|
22
|
+
* keep working without code-level churn.
|
|
23
|
+
*
|
|
24
|
+
* New code should import from `portable-md.ts` directly.
|
|
9
25
|
*/
|
|
10
26
|
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
27
|
+
import { readFileSync } from "fs";
|
|
28
|
+
import { relative } from "path";
|
|
29
|
+
|
|
30
|
+
// Re-export the canonical parser helpers so existing callers (and tests)
|
|
31
|
+
// keep working against the legacy import path.
|
|
32
|
+
export {
|
|
33
|
+
parseFrontmatter,
|
|
34
|
+
extractInlineTags,
|
|
35
|
+
walkMarkdownFiles,
|
|
36
|
+
} from "./portable-md.js";
|
|
37
|
+
|
|
38
|
+
import { parseFrontmatter, walkMarkdownFiles, extractInlineTags } from "./portable-md.js";
|
|
13
39
|
|
|
14
40
|
// ---------------------------------------------------------------------------
|
|
15
41
|
// Types
|
|
@@ -34,121 +60,7 @@ export interface ImportStats {
|
|
|
34
60
|
errors: { path: string; error: string }[];
|
|
35
61
|
}
|
|
36
62
|
|
|
37
|
-
|
|
38
|
-
// Frontmatter parsing
|
|
39
|
-
// ---------------------------------------------------------------------------
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Parse YAML frontmatter from markdown content.
|
|
43
|
-
* Returns { frontmatter, content } where content has frontmatter stripped.
|
|
44
|
-
*
|
|
45
|
-
* Uses a simple parser — no dependency on a YAML library.
|
|
46
|
-
* Handles common frontmatter patterns: strings, arrays, numbers, booleans.
|
|
47
|
-
*/
|
|
48
|
-
export function parseFrontmatter(raw: string): {
|
|
49
|
-
frontmatter: Record<string, unknown>;
|
|
50
|
-
content: string;
|
|
51
|
-
} {
|
|
52
|
-
if (!raw.startsWith("---")) {
|
|
53
|
-
return { frontmatter: {}, content: raw };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const endIdx = raw.indexOf("\n---", 3);
|
|
57
|
-
if (endIdx === -1) {
|
|
58
|
-
return { frontmatter: {}, content: raw };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const yamlBlock = raw.slice(4, endIdx); // skip opening "---\n"
|
|
62
|
-
const content = raw.slice(endIdx + 4).replace(/^\n/, ""); // skip closing "---\n"
|
|
63
|
-
|
|
64
|
-
const frontmatter: Record<string, unknown> = {};
|
|
65
|
-
let currentKey = "";
|
|
66
|
-
let currentArray: string[] | null = null;
|
|
67
|
-
|
|
68
|
-
for (const line of yamlBlock.split("\n")) {
|
|
69
|
-
// Array item (continuation of previous key)
|
|
70
|
-
if (currentArray !== null && /^\s+-\s+/.test(line)) {
|
|
71
|
-
const val = line.replace(/^\s+-\s+/, "").trim();
|
|
72
|
-
currentArray.push(unquote(val));
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// If we were building an array, save it (or save empty string if no items found)
|
|
77
|
-
if (currentArray !== null) {
|
|
78
|
-
frontmatter[currentKey] = currentArray.length > 0 ? currentArray : "";
|
|
79
|
-
currentArray = null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Key: value pair — keys must be YAML-valid (word chars and hyphens, no spaces)
|
|
83
|
-
const kvMatch = line.match(/^([\w][\w-]*):\s*(.*)/);
|
|
84
|
-
if (kvMatch) {
|
|
85
|
-
const key = kvMatch[1]!;
|
|
86
|
-
const value = kvMatch[2]!.trim();
|
|
87
|
-
|
|
88
|
-
if (value === "[]") {
|
|
89
|
-
frontmatter[key] = [];
|
|
90
|
-
} else if (value === "") {
|
|
91
|
-
// Empty value: could be start of array (next lines are "- item")
|
|
92
|
-
// or genuinely empty string. We start array accumulation and
|
|
93
|
-
// handle the empty case when a non-array line follows.
|
|
94
|
-
currentKey = key;
|
|
95
|
-
currentArray = [];
|
|
96
|
-
} else if (value.startsWith("[") && value.endsWith("]")) {
|
|
97
|
-
// Inline array: [item1, item2]
|
|
98
|
-
const items = value.slice(1, -1).split(",").map((s) => unquote(s.trim())).filter(Boolean);
|
|
99
|
-
frontmatter[key] = items;
|
|
100
|
-
} else {
|
|
101
|
-
frontmatter[key] = parseValue(value);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Save any trailing array (or empty string if no items)
|
|
107
|
-
if (currentArray !== null) {
|
|
108
|
-
frontmatter[currentKey] = currentArray.length > 0 ? currentArray : "";
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return { frontmatter, content };
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function unquote(s: string): string {
|
|
115
|
-
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
116
|
-
return s.slice(1, -1);
|
|
117
|
-
}
|
|
118
|
-
return s;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function parseValue(s: string): unknown {
|
|
122
|
-
s = unquote(s);
|
|
123
|
-
if (s === "true") return true;
|
|
124
|
-
if (s === "false") return false;
|
|
125
|
-
if (s === "null") return null;
|
|
126
|
-
if (/^-?\d+$/.test(s)) return parseInt(s, 10);
|
|
127
|
-
if (/^-?\d+\.\d+$/.test(s)) return parseFloat(s);
|
|
128
|
-
return s;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ---------------------------------------------------------------------------
|
|
132
|
-
// Tag extraction
|
|
133
|
-
// ---------------------------------------------------------------------------
|
|
134
|
-
|
|
135
|
-
/** Extract inline #tags from markdown content. Excludes tags in code blocks. */
|
|
136
|
-
export function extractInlineTags(content: string): string[] {
|
|
137
|
-
// Strip code blocks and inline code
|
|
138
|
-
let stripped = content.replace(/```[\s\S]*?```/g, "");
|
|
139
|
-
stripped = stripped.replace(/`[^`\n]+`/g, "");
|
|
140
|
-
|
|
141
|
-
const tags = new Set<string>();
|
|
142
|
-
// Match #tag and #nested/tag — must be preceded by whitespace or start of line
|
|
143
|
-
const regex = /(?:^|\s)#([\w][\w/-]*[\w]|[\w])/gm;
|
|
144
|
-
let match: RegExpExecArray | null;
|
|
145
|
-
while ((match = regex.exec(stripped)) !== null) {
|
|
146
|
-
tags.add(match[1]!.toLowerCase());
|
|
147
|
-
}
|
|
148
|
-
return [...tags];
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Extract tags from frontmatter (handles both array and string formats). */
|
|
63
|
+
/** Tags from frontmatter (handles both array and string formats). */
|
|
152
64
|
function extractFrontmatterTags(frontmatter: Record<string, unknown>): string[] {
|
|
153
65
|
const raw = frontmatter.tags;
|
|
154
66
|
if (!raw) return [];
|
|
@@ -157,35 +69,6 @@ function extractFrontmatterTags(frontmatter: Record<string, unknown>): string[]
|
|
|
157
69
|
return [];
|
|
158
70
|
}
|
|
159
71
|
|
|
160
|
-
// ---------------------------------------------------------------------------
|
|
161
|
-
// Directory walking
|
|
162
|
-
// ---------------------------------------------------------------------------
|
|
163
|
-
|
|
164
|
-
/** Recursively list all .md files in a directory, excluding .obsidian/ and hidden dirs. */
|
|
165
|
-
export function walkMarkdownFiles(dir: string): string[] {
|
|
166
|
-
const results: string[] = [];
|
|
167
|
-
|
|
168
|
-
function walk(current: string) {
|
|
169
|
-
for (const entry of readdirSync(current)) {
|
|
170
|
-
// Skip hidden directories and .obsidian config
|
|
171
|
-
if (entry.startsWith(".")) continue;
|
|
172
|
-
if (entry === "node_modules") continue;
|
|
173
|
-
|
|
174
|
-
const full = join(current, entry);
|
|
175
|
-
const stat = statSync(full);
|
|
176
|
-
|
|
177
|
-
if (stat.isDirectory()) {
|
|
178
|
-
walk(full);
|
|
179
|
-
} else if (stat.isFile() && extname(entry).toLowerCase() === ".md") {
|
|
180
|
-
results.push(full);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
walk(dir);
|
|
186
|
-
return results.sort();
|
|
187
|
-
}
|
|
188
|
-
|
|
189
72
|
// ---------------------------------------------------------------------------
|
|
190
73
|
// Parse a single file
|
|
191
74
|
// ---------------------------------------------------------------------------
|
|
@@ -207,12 +90,7 @@ export function parseObsidianFile(filePath: string, vaultRoot: string): Obsidian
|
|
|
207
90
|
const metadata = { ...frontmatter };
|
|
208
91
|
delete metadata.tags;
|
|
209
92
|
|
|
210
|
-
return {
|
|
211
|
-
path,
|
|
212
|
-
content,
|
|
213
|
-
frontmatter: metadata,
|
|
214
|
-
tags: allTags,
|
|
215
|
-
};
|
|
93
|
+
return { path, content, frontmatter: metadata, tags: allTags };
|
|
216
94
|
}
|
|
217
95
|
|
|
218
96
|
// ---------------------------------------------------------------------------
|
|
@@ -254,9 +132,13 @@ export function parseObsidianVault(vaultPath: string): {
|
|
|
254
132
|
}
|
|
255
133
|
|
|
256
134
|
// ---------------------------------------------------------------------------
|
|
257
|
-
//
|
|
135
|
+
// Legacy export — kept for back-compat. New code: use `toPortableMarkdown`.
|
|
258
136
|
// ---------------------------------------------------------------------------
|
|
259
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Note shape the legacy export accepts. Distinct from `PortableNote` —
|
|
140
|
+
* older + lossy by design (no IDs, no typed links, no attachments).
|
|
141
|
+
*/
|
|
260
142
|
export interface ExportableNote {
|
|
261
143
|
path?: string;
|
|
262
144
|
id: string;
|
|
@@ -267,34 +149,33 @@ export interface ExportableNote {
|
|
|
267
149
|
}
|
|
268
150
|
|
|
269
151
|
/**
|
|
270
|
-
* Convert a vault note to Obsidian-compatible markdown with YAML
|
|
152
|
+
* Convert a vault note to Obsidian-compatible markdown with YAML
|
|
153
|
+
* frontmatter — legacy flat-frontmatter shape (metadata keys at the
|
|
154
|
+
* top level, no IDs, no typed links, no attachments).
|
|
155
|
+
*
|
|
156
|
+
* @deprecated Prefer `toPortableMarkdown` in `portable-md.ts` for new
|
|
157
|
+
* code. This function is preserved for callers that intentionally want
|
|
158
|
+
* the legacy lossy shape — typically one-shot "give me an Obsidian
|
|
159
|
+
* copy" exports without round-trip concerns. See vault#308.
|
|
271
160
|
*/
|
|
272
161
|
export function toObsidianMarkdown(note: ExportableNote): string {
|
|
273
162
|
const fm: Record<string, unknown> = {};
|
|
274
163
|
|
|
275
|
-
|
|
276
|
-
if (note.tags && note.tags.length > 0) {
|
|
277
|
-
fm.tags = note.tags;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// Add metadata fields (excluding internal ones)
|
|
164
|
+
if (note.tags && note.tags.length > 0) fm.tags = note.tags;
|
|
281
165
|
if (note.metadata) {
|
|
282
166
|
for (const [key, value] of Object.entries(note.metadata)) {
|
|
283
|
-
if (key === "tags") continue;
|
|
167
|
+
if (key === "tags") continue;
|
|
284
168
|
fm[key] = value;
|
|
285
169
|
}
|
|
286
170
|
}
|
|
287
171
|
|
|
288
|
-
// Build frontmatter string
|
|
289
172
|
let result = "";
|
|
290
173
|
if (Object.keys(fm).length > 0) {
|
|
291
174
|
result += "---\n";
|
|
292
175
|
for (const [key, value] of Object.entries(fm)) {
|
|
293
176
|
if (Array.isArray(value)) {
|
|
294
177
|
result += `${key}:\n`;
|
|
295
|
-
for (const item of value) {
|
|
296
|
-
result += ` - ${item}\n`;
|
|
297
|
-
}
|
|
178
|
+
for (const item of value) result += ` - ${item}\n`;
|
|
298
179
|
} else if (typeof value === "object" && value !== null) {
|
|
299
180
|
result += `${key}: ${JSON.stringify(value)}\n`;
|
|
300
181
|
} else {
|
|
@@ -309,14 +190,11 @@ export function toObsidianMarkdown(note: ExportableNote): string {
|
|
|
309
190
|
}
|
|
310
191
|
|
|
311
192
|
/**
|
|
312
|
-
* Determine the file path for an exported note.
|
|
313
|
-
*
|
|
193
|
+
* Determine the file path for an exported note (legacy form).
|
|
194
|
+
* @deprecated Use `portableExportFilePath` from `portable-md.ts`.
|
|
314
195
|
*/
|
|
315
196
|
export function exportFilePath(note: ExportableNote): string {
|
|
316
|
-
if (note.path)
|
|
317
|
-
return note.path + ".md";
|
|
318
|
-
}
|
|
319
|
-
// Fallback: use date prefix + truncated id
|
|
197
|
+
if (note.path) return note.path + ".md";
|
|
320
198
|
const date = note.createdAt.split("T")[0];
|
|
321
199
|
return `${date}/${note.id}.md`;
|
|
322
200
|
}
|