github coddingtonbear/obsidian-local-rest-api 5.0.0

8 hours ago

This is a major release. The headline change is a rewrite of the section-targeting and PATCH engine (markdown-patch 1.x → 2.x), which is now the default for targeted reads/writes, PATCH, and the document map. Riding alongside it are several smaller but user-visible additions that landed on main during the same cycle.

This document is organized by feature, but each section leads with what was frustrating before — the point isn't "here's a new capability," it's "here's what you no longer have to fight."


Markdown Patch 2.0: the PATCH instruction format was scattered across headers, and easy to get subtly wrong

The pain: Building a PATCH request meant assembling an instruction out of up to eight different headers (Operation, Target-Type, Target, Target-Delimiter, Target-Scope, Create-Target-If-Missing, Reject-If-Content-Preexists, Trim-Target-Whitespace) plus a body. Getting the delimiter wrong, forgetting to escape a heading that itself contained ::, or mixing up which header controlled what, produced confusing failures — or worse, a "successful" write to the wrong place. There was also no way to move a section, and deleting one required a read-modify-write round trip.

What's new: The instruction is now a single JSON object:

{ "targetType": "heading", "target": ["Heading 1", "Subheading 1:1"], "operation": "append", "content": "Hello" }
  • target is a real array — no delimiter, no escaping headings that contain :: or /.
  • operation gains delete (remove a section outright) alongside replace/prepend/append.
  • scope gains parent, which moves a heading elsewhere in the document tree (with heading levels rebased automatically) via a destination.
  • Frontmatter values are sent as typed JSON in value (a string, number, list, or dict) instead of a serialized string body.

If your client can't easily produce JSON bodies — templating tools, shell scripts building a body from a variable — raw markdown/plain-text bodies are still accepted; see the interactive docs for that mode's headers and URL-targeting details.

Everything else in this document builds on top of this rewrite.

ifMatch: there was no safe way to know a file hadn't changed out from under your edit

The pain: If a note changed between when you read it and when you patched it, your patch could silently land against stale content — no built-in way to detect the race.

What's new: The document map now returns a version token (a content hash). Pass it back as ifMatch in a PATCH instruction (or an If-Match header in raw-content mode); if the file changed in the meantime, the patch fails with 412 Precondition Failed and the file is left untouched, instead of silently applying against outdated content.

Foolproof Whitespace: appending or prepending content near a heading produced unpredictable whitespace

The pain: Whether a naive append merged into the tail of the previous paragraph, created a double blank line, or landed flush against the heading depended on details of the surrounding document that were hard to reason about from the client side. Padding your own \ns into content to compensate meant your output depended on guessing the engine's internals, and the guess could break on the next release.

What's new: Whitespace is now library-owned. Your content is reduced to a canonical trimmed form (leading/trailing blank lines are meaningless input), and the engine supplies exactly one blank-line separator wherever inserted content faces existing body text. A naive append/prepend always lands as its own distinct block and can never accidentally merge into an existing paragraph. This also makes replace byte-identical and idempotent under retries — replacing a section with its own content is now guaranteed to be a no-op, in both "spaced" and "flush" heading styles. The accepted trade-off: content-scope append/prepend can no longer continue an existing list or paragraph — see within, below, for how that's still possible.

Block-level Targeting: there was no way to append to a list or paragraph without pre-planting a block ID on it

The pain: Because content-scope writes always start a new block (see Foolproof Whitespace), continuing an existing list — adding one more - item under an existing bullet list — required either a full read-modify-write of the whole section, or asking users to manually add a ^block-id anchor to every list you might ever want to extend.

What's new: A new within field on heading instructions lets you address one of the section's existing top-level body blocks by position (0-based, negative counts from the end — -1 is "the last block"), and splice into it literally. (Not to be confused with the existing targetType: "block", which addresses a block by its ^id marker rather than by position — within is for the common case where you never anchored one.)

{ "targetType": "heading", "target": ["Log"], "within": -1, "operation": "append", "content": "\n- new item" }

Because this is a literal splice (you own the joint, including whitespace), it's the tool for "add one more line to this list" without restructuring the whole section. within is positional, so pair it with ifMatch to guard against the document shifting between your map read and your edit.

PATCH/GET Symmetry: reading a section and reading its heading/label were two different, inconsistent things

The pain: A targeted GET always returned just the body content under a heading — there was no way to also get the heading's own text/level, or to read a block's raw content in a shape you could turn around and replace with, without hand-reconstructing it.

What's new: Target-Scope (REST) / scope (MCP vault_read) now works symmetrically with PATCH's write scopes: content (default, unchanged), marker (just the label — heading text, block id, or frontmatter key), or markerAndContent (the whole node, in exactly the shape a replace at that scope expects back — a heading subtree reads as # Title with levels relative to its parent). The invariant this buys you: read at scope S, then replace at scope S, is a no-op. That makes "read a section, edit it locally, write it back" reliable for the first time.

Duplicate Addressing: ambiguous or duplicate headings/block IDs were either silently mishandled or caused a 500

The pain: Two sibling headings with the same text, or a repeated block id, meant the API had no consistent way to say which one you meant — targeting could hit the wrong one, and some duplicate-marker edge cases surfaced as an unhelpful 500 rather than a clear error.

What's new: Duplicates are now individually addressable. The document map gives the first occurrence its plain key/text, and each later occurrence a key carrying a non-printable marker suffix that you copy verbatim out of the map into your target — no guessing, no typing markers by hand. Genuinely ambiguous or reserved-marker cases now return a clean 400 instead of a 500.

Compact Document Map: the document map repeated every ancestor's full path in every entry, wasting space and context budget

The pain: The old map returned headings as a flat array of ::-joined path strings ("Heading 1::Subheading 1:1") — every entry re-stated the full chain of ancestor headings down to itself. Reconstructing hierarchy meant splitting on a delimiter that could itself appear inside heading text, and a deeply nested document repeated the same heading text over and over, bloating the response (and, for MCP/LLM clients, burning context budget on redundant text).

What's new: headings is now a nested tree — each heading's text appears exactly once, as a key mapping to its own children, however many descendants it has. A path through the tree is exactly the array you'd send as a heading target, and the whole map is smaller for anything but a flat, shallow document. The map also carries the new version hash (see ifMatch).

Frontmatter, Preserved: frontmatter keys with unusual formatting could be silently lost on write

The pain: Frontmatter rewrites used to be built off a naive line-scan. A key whose written form differed from its parsed form — a quoted key, or a key containing a colon — could be dropped entirely and silently destroyed the next time frontmatter was patched, with no error to indicate anything had gone wrong.

What's new: Frontmatter is now parsed and rebuilt from a positioned AST, so quoted keys and keys containing special characters survive a round trip. Unparseable YAML or a duplicate key now return a clear 400/409 instead of corrupting the file.

Cleaner Errors: some error conditions surfaced as unhelpful generic errors or crashes

The pain: A handful of edge cases — unparseable frontmatter, a frontmatter key collision, a mismatched table row, a table cell containing a line break, a malformed patch instruction — used to come back as a generic error or an opaque 500, giving you little to act on.

What's new: These all now map to specific, correctly-coded responses: 400 for unparseable frontmatter YAML, malformed instructions (with field-path-specific messages), and bad table cells; 409 for a frontmatter key collision; 412 for an ifMatch/If-Match mismatch. The JSON instruction body is now validated against the same schema the engine itself uses, so a malformed instruction tells you exactly which field is wrong instead of a generic complaint.

Trash by Default: deleting a file was permanent, with no recovery path

The pain: DELETE removed the file outright. A scripting mistake — wrong path, bad loop bound — meant the file was gone, full stop.

What's new: Deletes now move the file to Obsidian's system trash by default, so an accidental delete is recoverable the same way a manual delete in the Obsidian UI is.

COPY: copying a note required a manual read-then-write

The pain: There was no single API call to duplicate a file — you had to GET the content and metadata, then PUT it to a new path, handling binary files and frontmatter yourself.

What's new: A COPY method (REST) and vault_copy (MCP) duplicate a vault file to a new path in one call.

Rendered HTML: there was no way to get a rendered preview of a note without opening Obsidian

The pain: If you wanted the same rendering a human sees in Obsidian's preview pane — embeds, callouts, and other Obsidian-flavored Markdown resolved — your only option was to open the vault in the app. Useful for anything that displays notes outside Obsidian (a browser extension, a static export, a dashboard).

What's new: GET with Accept: text/html returns the note (or a targeted heading/block section) rendered to HTML using Obsidian's own renderer — the same one used in preview mode.

unresolvedLinks: no way to discover broken wiki-links via the API

The pain: Obsidian tracks which [[links]] in a note don't resolve to an existing file, but that information wasn't exposed — you'd only discover a broken link by opening the note in the app and looking for the "create note" prompt.

What's new: vault_read (MCP) and file reads now include unresolvedLinks — an array of link text in the file that doesn't resolve to an existing vault file — alongside the existing links/backlinks.

Periodic Notes, Natively: periodic-note support depended on an abandoned plugin and undocumented Obsidian internals

The pain: The /periodic/ endpoints resolved daily/weekly/monthly/quarterly/yearly notes through obsidian-daily-notes-interface, which reads undocumented internals of the core Daily Notes plugin and — for everything beyond daily — the community Periodic Notes plugin, which is effectively abandoned (last release April 2022). Weekly and coarser periods only worked if you had that plugin installed and configured, and there was no way to see or control any of this from this plugin's own settings.

What's new: Periodic notes are now implemented natively. A new "Periodic Notes" settings section configures each period's folder, filename date format, and creation template — and every period is always available: an unconfigured period just uses sensible defaults (notes at the vault root, standard formats like YYYY-MM-DD for daily and gggg-[W]ww for weekly). On first launch after upgrading, your existing Daily Notes / Periodic Notes plugin configuration is imported automatically, once.

Things to watch for:

  • Configuration no longer tracks the Daily Notes / Periodic Notes plugins after that one-time import. If you later change your daily-notes folder in Obsidian, update it in this plugin's settings too.
  • Template placeholders are a documented subset: {{date}}, {{time}}, and {{title}}. Templates using extended placeholders like {{date:FORMAT}} will render those literally.
  • Weekly-note configuration that lived only in the legacy Calendar plugin (not Periodic Notes) is not imported — set it up in the new settings section.

Searchable Settings: plugin options were one long page that Obsidian's settings search couldn't reach into

The pain: All of the plugin's options lived on a single long settings tab built by hand, invisible to Obsidian's settings search and increasingly unwieldy as options accumulated.

What's new: The settings tab was rebuilt on Obsidian's declarative settings framework. Options are indexed by Obsidian's built-in settings search, grouped into navigable sub-pages (Periodic Notes, Certificates, Advanced settings), and gained quality-of-life touches along the way: validated port inputs, a certificate-status warning row, and confirmation prompts before destructive actions like regenerating certificates or the API key.


Breaking changes and migration

The 1.x PATCH/targeting/document-map format is deprecated, not removed, in this release. Every response served the 1.x way carries a Deprecation: true; sunset-version="6.0" header. The stopgap for any client that isn't ready to migrate: send Markdown-Patch-Version: 1 on every affected request and behavior is unchanged. This buys time until 6.0, when the 1.x format is removed entirely.

You are affected if you currently:

  • Send PATCH requests using Operation/Target-Type/Target/Target-Delimiter/Target-Scope/Create-Target-If-Missing/Reject-If-Content-Preexists/Trim-Target-Whitespace headers.
  • Target a heading/block/frontmatter field on GET, PUT, or POST via Target-Type/Target headers.
  • Consume the document map (Accept: application/vnd.olrapi.document-map+json).
  • Parse the MD-Patch-Warnings response header (renamed to Markdown-Patch-Warnings, and now percent-encoded).
  • Use the MCP tools vault_patch, vault_read (with a heading target), or vault_get_document_map — these moved to the 2.x shape with no version escape hatch; MCP clients must migrate in this release.

Not affected: clients that only read/write whole files, search, or use active/periodic endpoints without section targeting.

Old-style requests sent without a version header now fail loudly (400) rather than being silently reinterpreted — see the full "Migrating from 1.x to 2.x" guide in the interactive docs for the complete request/response mapping table and worked examples.

What to try first

  • If you only read/write whole notes or search, this release changes nothing for you.
  • If you use targeted reads/writes/PATCH and want to keep existing behavior for now, add Markdown-Patch-Version: 1 to those requests and revisit before 6.0.
  • If you're ready to move: read the field-by-field mapping table in the interactive docs. If your client templates raw markdown into a request body, the interactive docs also cover a raw-body mode that avoids JSON escaping while still using the new engine.
  • MCP users: vault_patch and heading-targeted vault_read calls need their inputs updated to the array-based target shape immediately — there's no 1.x fallback on MCP.

Don't miss a new obsidian-local-rest-api release

NewReleases is sending notifications on new releases.