@openparachute/vault 0.4.3 → 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 CHANGED
@@ -139,7 +139,7 @@ Password and 2FA secrets live in `~/.parachute/vault/config.yaml` at mode 0600 (
139
139
 
140
140
  Where `{name}` is `default` on a fresh install, or whatever vault you pointed `vault init` at. **First MCP call after `vault init` requires no browser handoff — Claude Code uses the baked-in token and the vault's tools show up in your next session.** This is intentional: for an owner connecting their own machine's vault to their own Claude Code, the token is already there and OAuth would add friction.
141
141
 
142
- To re-point Claude Code at a different vault, change `default_vault` in `~/.parachute/vault/config.yaml` and re-run `parachute-vault init` — which re-mints an API token and re-writes the `~/.claude.json` entry end-to-end. To rotate the token only, edit `~/.claude.json` and replace the `Authorization` header value with a fresh token from `parachute-vault tokens create`. (Running `parachute-vault mcp-install` on its own overwrites the MCP entry *without* an `Authorization` header and is intended for the rare case where you want to drop the token and connect via OAuth instead.)
142
+ To re-point Claude Code at a different vault, change `default_vault` in `~/.parachute/vault/config.yaml` and re-run `parachute-vault init` — which re-mints an API token and re-writes the `~/.claude.json` entry end-to-end. To rotate the token only, run `parachute-vault mcp-install` (defaults to `--mint`, which mints a fresh scope-narrow hub JWT via `~/.parachute/operator.token` and writes it into `~/.claude.json` with an `Authorization: Bearer …` header). See the [cookbook](#install-vault-mcp-into-a-client-config) section below for the full flag surface token paste, scope narrowing, project-level install, multi-vault.
143
143
 
144
144
  ### Claude Desktop (OAuth)
145
145
 
@@ -190,7 +190,13 @@ parachute-vault uninstall --yes --wipe # scripted destructive wipe (prints a
190
190
  parachute-vault create work # create a new vault
191
191
  parachute-vault list # list all vaults (alias: `ls`)
192
192
  parachute-vault remove work --yes # delete a vault (alias: `rm`)
193
- parachute-vault mcp-install # (re)write the ~/.claude.json MCP entry for the default vault
193
+ parachute-vault mcp-install # (re)write the MCP client entry; defaults to --mint (hub-issued JWT) at local scope
194
+ parachute-vault mcp-install --token <t> # paste an existing bearer instead of minting
195
+ parachute-vault mcp-install --legacy-pat # mint a vault-DB pvt_* (self-hosted-without-hub)
196
+ parachute-vault mcp-install --install-scope user # write ~/.claude.json top-level (every project)
197
+ parachute-vault mcp-install --install-scope local # write ~/.claude.json projects[<cwd>] (this directory only — default)
198
+ parachute-vault mcp-install --install-scope project # write ./.mcp.json (checked into the repo)
199
+ parachute-vault mcp-install --vault work # target the "work" vault (keyed as parachute-vault-work)
194
200
 
195
201
  # OAuth — owner password + 2FA
196
202
  parachute-vault set-password # set/change the owner password (OAuth consent page)
@@ -565,6 +571,56 @@ Atomic at the SQL layer: two concurrent appends both land in some order, never c
565
571
 
566
572
  The shortest path to a public HTTPS URL for a vault you control — useful for SSG rebuilds running on GitHub Actions, Vercel, or any runner that isn't on your tailnet. See [Remote access via Tailscale Funnel](#remote-access-via-tailscale-funnel) below for the full setup.
567
573
 
574
+ ### Install vault MCP into a client config
575
+
576
+ Bare `parachute-vault mcp-install` from a terminal **walks you through a short contextual conversation** — picks defaults informed by your environment (how many vaults you have, whether the hub is reachable, whether you're in a project directory, whether vault is already installed somewhere), shows the JSON shape it will write before doing anything, and asks before each non-obvious choice. The patterns below are the non-interactive shapes — pass any flag (`--mint`, `--token`, `--scope`, `--install-scope`, `--vault`, `--legacy-pat`) and the walkthrough is skipped.
577
+
578
+ #### Install scopes
579
+
580
+ `--install-scope` accepts three values, matching Claude Code's own `claude mcp add --scope`:
581
+
582
+ | Scope | Where the entry lives | Visibility |
583
+ |---|---|---|
584
+ | `local` *(default)* | `~/.claude.json` under `projects[<absolute-cwd>].mcpServers` | private to your machine, scoped to this directory |
585
+ | `user` | `~/.claude.json` top-level `mcpServers` | every project, every directory on this machine |
586
+ | `project` | `<cwd>/.mcp.json` | checked into the repo, shared with anyone who clones it |
587
+
588
+ The default is **`local`** — Claude Code only loads the entry when launched from the directory you ran the install from. Pick `user` for a global install (every project sees the vault); pick `project` to commit the entry to the repo so collaborators get it on `git pull`.
589
+
590
+ #### Cookbook
591
+
592
+ ```bash
593
+ # 1. Default — mint a scope-narrow hub JWT (vault:<vault>:read) via your
594
+ # operator token; write it into ~/.claude.json under projects[<cwd>]
595
+ # (local scope, this directory only). Requires:
596
+ # - ~/.parachute/operator.token (run `parachute auth rotate-operator` if missing)
597
+ # - PARACHUTE_HUB_ORIGIN set OR an active `parachute expose` session
598
+ parachute-vault mcp-install --mint
599
+
600
+ # 2. Global install — write the entry at top-level ~/.claude.json so
601
+ # Claude Code loads it from every project, every directory.
602
+ parachute-vault mcp-install --install-scope user
603
+
604
+ # 3. Project-level install — write ./.mcp.json (committed to the repo,
605
+ # shared with the team). Pair with --scope vault:write when the
606
+ # project actually mutates the vault.
607
+ parachute-vault mcp-install --install-scope project --scope vault:write
608
+
609
+ # 4. Paste an existing token — useful when you already have a pvt_* in hand
610
+ # or want to re-use a long-lived bearer from another machine. Skips the
611
+ # mint step entirely.
612
+ parachute-vault mcp-install --token pvt_abc123...
613
+
614
+ # 5. Self-hosted-without-hub — mint a vault-DB pvt_* token (the legacy
615
+ # path; preserved so deployments without a hub keep working). Prints a
616
+ # deprecation notice.
617
+ parachute-vault mcp-install --legacy-pat
618
+ ```
619
+
620
+ **Multi-vault.** `--vault <name>` targets a specific vault and writes the entry under `parachute-vault-<name>` so multiple vaults coexist. Without `--vault`, the singular `parachute-vault` slot is used and one install clobbers another — that's intentional for the common single-vault case.
621
+
622
+ **Doctor.** `parachute-vault doctor` checks `~/.claude.json` (both top-level and `projects[<cwd>]`) and `./.mcp.json`, and reports which one holds the entry, plus port-match and reachability of the MCP URL.
623
+
568
624
  ## Data model
569
625
 
570
626
  ```
@@ -3678,6 +3678,122 @@ describe("schema validation (tags.fields)", async () => {
3678
3678
  const result = await create.execute({ content: "x", tags: ["task"] }) as any;
3679
3679
  expect(result.validation_status).toBeUndefined();
3680
3680
  });
3681
+
3682
+ // vault#310 — integer type validation. JSON has no separate integer
3683
+ // type, so a JSON number with zero fractional part (`5`, `5.0`) must
3684
+ // pass an `integer`-typed field. Pre-fix, the validator had no
3685
+ // `"integer"` case at all — falling through the switch returned
3686
+ // undefined and every integer-typed field warned `type_mismatch` on
3687
+ // legitimate values. Gitcoin Brain's drift detector emits JSON for
3688
+ // diffs; every `kpi: 3` triggered the false-positive and buried the
3689
+ // real warnings.
3690
+
3691
+ it("integer-typed field: JSON integer (5) passes (vault#310)", async () => {
3692
+ await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
3693
+ const tools = generateMcpTools(store);
3694
+ const create = tools.find((t) => t.name === "create-note")!;
3695
+ const result = await create.execute({
3696
+ content: "x",
3697
+ tags: ["kpi"],
3698
+ metadata: { count: 5 },
3699
+ }) as any;
3700
+ expect(result.validation_status?.warnings ?? []).toEqual([]);
3701
+ });
3702
+
3703
+ it("integer-typed field: JSON `5.0` (zero-fractional) passes (vault#310)", async () => {
3704
+ // 5.0 is the canonical Gitcoin shape — JSON.parse decodes the
3705
+ // emitted JSON number as a JS Number; the JS Number for `5.0` is
3706
+ // identical to `5` so Number.isInteger reports true. This is the
3707
+ // load-bearing assertion for the Gitcoin drift-detector use case.
3708
+ await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
3709
+ const tools = generateMcpTools(store);
3710
+ const create = tools.find((t) => t.name === "create-note")!;
3711
+ const result = await create.execute({
3712
+ content: "x",
3713
+ tags: ["kpi"],
3714
+ metadata: { count: 5.0 },
3715
+ }) as any;
3716
+ expect(result.validation_status?.warnings ?? []).toEqual([]);
3717
+ });
3718
+
3719
+ it("integer-typed field: JSON `5.5` (non-zero fractional) warns type_mismatch (vault#310)", async () => {
3720
+ await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
3721
+ const tools = generateMcpTools(store);
3722
+ const create = tools.find((t) => t.name === "create-note")!;
3723
+ const result = await create.execute({
3724
+ content: "x",
3725
+ tags: ["kpi"],
3726
+ metadata: { count: 5.5 },
3727
+ }) as any;
3728
+ expect(result.validation_status.warnings.length).toBe(1);
3729
+ expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
3730
+ expect(result.validation_status.warnings[0].field).toBe("count");
3731
+ });
3732
+
3733
+ it("integer-typed field: string `\"5\"` warns type_mismatch (no string→number coercion) (vault#310)", async () => {
3734
+ await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
3735
+ const tools = generateMcpTools(store);
3736
+ const create = tools.find((t) => t.name === "create-note")!;
3737
+ const result = await create.execute({
3738
+ content: "x",
3739
+ tags: ["kpi"],
3740
+ metadata: { count: "5" },
3741
+ }) as any;
3742
+ expect(result.validation_status.warnings.length).toBe(1);
3743
+ expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
3744
+ });
3745
+
3746
+ it("integer-typed field: edge `5.0000000000001` warns type_mismatch (vault#310)", async () => {
3747
+ await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
3748
+ const tools = generateMcpTools(store);
3749
+ const create = tools.find((t) => t.name === "create-note")!;
3750
+ const result = await create.execute({
3751
+ content: "x",
3752
+ tags: ["kpi"],
3753
+ metadata: { count: 5.0000000000001 },
3754
+ }) as any;
3755
+ expect(result.validation_status.warnings.length).toBe(1);
3756
+ expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
3757
+ });
3758
+
3759
+ it("integer-typed field: boolean warns type_mismatch (vault#310)", async () => {
3760
+ // Pin that boolean is rejected (Number.isInteger(true) returns
3761
+ // false, but extra coverage in case anyone "improves" the check
3762
+ // with a looser predicate later).
3763
+ await store.upsertTagSchema("kpi", { fields: { count: { type: "integer" } } });
3764
+ const tools = generateMcpTools(store);
3765
+ const create = tools.find((t) => t.name === "create-note")!;
3766
+ const result = await create.execute({
3767
+ content: "x",
3768
+ tags: ["kpi"],
3769
+ metadata: { count: true },
3770
+ }) as any;
3771
+ expect(result.validation_status.warnings.length).toBe(1);
3772
+ expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
3773
+ });
3774
+
3775
+ // Note on NaN/Infinity: those values pass through
3776
+ // JSON.stringify as `null`, then the validator's null short-circuit
3777
+ // (schema-defaults.ts:327 — "value === null → skip") filters them out
3778
+ // before reaching the type check. We can't observe them in
3779
+ // validation_status from this layer; a dedicated unit test against
3780
+ // `valueMatchesType` would catch the case at the inner boundary.
3781
+ // Pinned at the next layer down:
3782
+
3783
+ it("valueMatchesType('integer', ...) rejects NaN / Infinity (vault#310)", async () => {
3784
+ // Reach the unexported helper indirectly via validateNote on a
3785
+ // hand-built resolved-schemas + metadata where the value is the
3786
+ // actual NaN/Infinity (no JSON round-trip).
3787
+ const { validateNote, loadSchemaConfig } = await import("./schema-defaults.js");
3788
+ // Seed via the public surface, then load the resolved schemas
3789
+ // snapshot.
3790
+ await store.upsertTagSchema("k", { fields: { c: { type: "integer" } } });
3791
+ const resolved = loadSchemaConfig((store as any).db);
3792
+ expect(validateNote(resolved, { tags: ["k"], metadata: { c: Number.NaN } })?.warnings[0]?.reason)
3793
+ .toBe("type_mismatch");
3794
+ expect(validateNote(resolved, { tags: ["k"], metadata: { c: Number.POSITIVE_INFINITY } })?.warnings[0]?.reason)
3795
+ .toBe("type_mismatch");
3796
+ });
3681
3797
  });
3682
3798
 
3683
3799
  // ---------------------------------------------------------------------------
package/core/src/mcp.ts CHANGED
@@ -469,6 +469,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
469
469
  - When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
470
470
  - For batch: pass a \`notes\` array, each with an \`id\` field.
471
471
  - **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design).
472
+ - **Idempotent upsert via \`if_missing: "create"\`** — when the note doesn't exist, create it from this same payload (content/path/tags/metadata become the create fields; OC precondition skipped — nothing to conflict with). Response carries \`created: true\`. Useful for nightly sync loops that don't know ahead of time whether the note exists. Default \`"fail"\` (current behavior — missing note errors). See vault#309.
472
473
  - \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present.`,
473
474
  inputSchema: {
474
475
  type: "object",
@@ -491,6 +492,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
491
492
  created_at: { type: "string", description: "New created_at timestamp" },
492
493
  if_updated_at: { type: "string", description: "Optimistic concurrency check: the updated_at value you last read. Rejects with a conflict error if the note has been modified since. Required unless `force: true` is set or the call is `append`/`prepend`-only." },
493
494
  force: { type: "boolean", description: "Override the required `if_updated_at` check and run the update unconditionally. Use only for bulk migrations or scripted writes where concurrency is known-safe." },
495
+ if_missing: { type: "string", enum: ["fail", "create"], description: "What to do when the note (by `id`/path) doesn't exist. `\"fail\"` (default) — error, current behavior. `\"create\"` — create the note from this same payload (content/path/tags/metadata become the create fields; the response carries `created: true`). Skips the `if_updated_at` precondition on the create branch (nothing to conflict with). Idempotent for sync loops that don't know ahead of time whether the note exists. See vault#309." },
494
496
  tags: {
495
497
  type: "object",
496
498
  properties: {
@@ -554,6 +556,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
554
556
  created_at: { type: "string" },
555
557
  if_updated_at: { type: "string", description: "Optimistic concurrency check for this item; rejects with a conflict error if the note has been modified since. Required unless `force: true` is set on this item or the item is `append`/`prepend`-only." },
556
558
  force: { type: "boolean", description: "Override the required `if_updated_at` check for this item." },
559
+ if_missing: { type: "string", enum: ["fail", "create"], description: "Per-item: see top-level `if_missing` docs. Each batch item carries its own setting." },
557
560
  tags: { type: "object" },
558
561
  links: { type: "object" },
559
562
  },
@@ -572,6 +575,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
572
575
  }
573
576
 
574
577
  const updated: Note[] = [];
578
+ // Track which note IDs were freshly created via `if_missing: "create"`
579
+ // so the response can carry `created: true|false` per-note. The
580
+ // sync-loop caller (Gitcoin Brain et al) reads this to know which
581
+ // path fired without doing a separate query. vault#309.
582
+ const createdIds = new Set<string>();
575
583
  // Wrap multi-item batches in a SQLite transaction so any mid-batch
576
584
  // failure (precondition error, content_edit miss, ConflictError, …)
577
585
  // rolls back every prior mutation in the batch — see #236.
@@ -581,7 +589,77 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
581
589
  if (batched) db.exec("BEGIN");
582
590
  try {
583
591
  for (const item of items) {
584
- const note = requireNote(db, item.id as string);
592
+ // Try ID-then-path resolve. If not found AND
593
+ // `if_missing: "create"` is set, fall through to the create
594
+ // branch using this same item's payload. Otherwise mirror the
595
+ // existing `requireNote` behavior (throw "Note not found").
596
+ // vault#309.
597
+ const resolved = resolveNote(db, item.id as string);
598
+ if (!resolved) {
599
+ if (item.if_missing === "create") {
600
+ // Treat the update payload as a create payload. Minimum:
601
+ // content OR a path/id (something the createNote-empty-row
602
+ // invariant accepts). createNote enforces its own
603
+ // not-both-empty check — we leave that to the Store and
604
+ // surface any error to the caller verbatim.
605
+ //
606
+ // Field mapping (mirrors the create-note tool surface):
607
+ // - `item.id` → both the note's `id` AND a fallback
608
+ // `path` when `item.path` isn't set. Treating `id` as
609
+ // the path-or-id lookup key matches Gitcoin's nightly
610
+ // sync shape where the canonical key is a path string
611
+ // like "Inbox/2026-05-13-meeting". If the caller
612
+ // supplied an opaque ULID as `id` and no `path`, we
613
+ // still create with that as `id` (path stays null).
614
+ // - `item.content` / `item.path` / `item.tags` /
615
+ // `item.metadata` / `item.created_at` → forwarded.
616
+ // - `if_updated_at` / `force` / `content_edit` /
617
+ // `append` / `prepend` / `links` are
618
+ // update-only — silently ignored on the create branch.
619
+ // (Content-edit on a non-existent note is a nonsense
620
+ // combination; the caller's intent on missing-note is
621
+ // "create the row", not "patch in this section".)
622
+ const idOrPath = item.id as string;
623
+ // Heuristic: if `path` isn't set AND the `id` looks like a
624
+ // path (contains "/" or doesn't match a typical opaque-id
625
+ // shape), use it as the path too. Otherwise treat it as a
626
+ // pure id. The shared `id` field for update is ID-or-path
627
+ // already (see `resolveNote`), so this preserves the
628
+ // caller's intent.
629
+ const idLooksLikePath = idOrPath.includes("/") || !/^[A-Za-z0-9_-]+$/.test(idOrPath);
630
+ const explicitPath = typeof item.path === "string" ? item.path as string : undefined;
631
+ const createOpts: Parameters<Store["createNote"]>[1] = {
632
+ ...(idLooksLikePath ? { path: explicitPath ?? idOrPath } : { id: idOrPath, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
633
+ ...(item.tags && Array.isArray((item.tags as any).add)
634
+ ? { tags: (item.tags as any).add as string[] }
635
+ : Array.isArray(item.tags)
636
+ ? { tags: item.tags as string[] }
637
+ : {}),
638
+ ...(item.metadata !== undefined ? { metadata: item.metadata as Record<string, unknown> } : {}),
639
+ ...(item.created_at !== undefined ? { created_at: item.created_at as string } : {}),
640
+ };
641
+ const content = (item.content as string | undefined) ?? "";
642
+ const created = await store.createNote(content, createOpts);
643
+ await applySchemaDefaults(store, db, [created.id], created.tags ?? []);
644
+ // Apply links.add if the caller declared any.
645
+ const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
646
+ if (linksAdd) {
647
+ for (const link of linksAdd) {
648
+ const target = resolveNote(db, link.target);
649
+ if (target) await store.createLink(created.id, target.id, link.relationship, link.metadata);
650
+ }
651
+ }
652
+ const fresh = noteOps.getNote(db, created.id) ?? created;
653
+ updated.push(fresh);
654
+ createdIds.add(fresh.id);
655
+ continue;
656
+ }
657
+ // Fallthrough: not-found + no if_missing → existing error
658
+ // contract. Match `requireNote`'s message shape so existing
659
+ // callers see no behavior change.
660
+ throw new Error(`Note not found: "${item.id}"`);
661
+ }
662
+ const note = resolved;
585
663
 
586
664
  // --- Validate mutual exclusion of content modes ---
587
665
  const hasContent = item.content !== undefined;
@@ -745,14 +823,21 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
745
823
  // Response shape: full Note (back-compat default) or lean NoteIndex
746
824
  // (#285 friction point 2.response — opt-out for callers making
747
825
  // frequent small edits to large notes). `validation_status` from
748
- // `tags.fields` is preserved across either shape.
826
+ // `tags.fields` is preserved across either shape. `created: true|false`
827
+ // (vault#309) is attached to every response so callers using
828
+ // `if_missing: "create"` can tell which branch fired without a
829
+ // separate query. `false` for the (overwhelmingly common) update
830
+ // path; `true` only when this call took the create-on-missing
831
+ // branch.
749
832
  const includeContent = params.include_content !== false;
750
833
  const final = updated.map((n) => {
751
834
  const validated = attachValidationStatus(store, db, n);
752
- if (includeContent) return validated;
835
+ const created = createdIds.has(n.id);
836
+ if (includeContent) return { ...validated, created } as Note & { created: boolean };
753
837
  const lean: any = noteOps.toNoteIndex(validated);
754
838
  const vs = (validated as any).validation_status;
755
839
  if (vs !== undefined) lean.validation_status = vs;
840
+ lean.created = created;
756
841
  return lean;
757
842
  });
758
843
  return batch ? final : final[0];
@@ -1127,8 +1212,13 @@ function defaultForField(field: { type: string; enum?: string[] }): unknown {
1127
1212
  *
1128
1213
  * Returns the note unchanged when no tag declares fields, so callers
1129
1214
  * without any tag schemas see no behavior change.
1215
+ *
1216
+ * Exported so both transports (MCP `update-note` here, HTTP `PATCH
1217
+ * /api/notes/:id` in `src/routes.ts`) attach the same status field by
1218
+ * the same recipe — see vault#287 for the asymmetry that motivated
1219
+ * exposing it.
1130
1220
  */
1131
- function attachValidationStatus(store: Store, _db: Database, note: Note): Note {
1221
+ export function attachValidationStatus(store: Store, _db: Database, note: Note): Note {
1132
1222
  // Short-circuit cheaply: when no tag declares fields, the resolver
1133
1223
  // returns null without us paying a re-read of the note.
1134
1224
  const status = store.validateNoteAgainstSchemas({
@@ -1,15 +1,41 @@
1
1
  /**
2
- * Obsidian vault parserreads .md files and extracts notes, tags, links.
2
+ * Obsidian-compatible markdown export/importback-compat shim.
3
3
  *
4
- * Handles:
5
- * - YAML frontmatter note.metadata
6
- * - Inline #tags and frontmatter tags tags table
7
- * - [[wikilinks]] handled by wikilinks.ts on note creation
8
- * - File path note.path
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 { readdirSync, readFileSync, statSync } from "fs";
12
- import { join, relative, extname, basename } from "path";
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
- // Export to Obsidian format
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 frontmatter.
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
- // Add tags to frontmatter
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; // already handled
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
- * Notes with paths use the path; pathless notes use date/id.
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
  }