From 26130684d5330ab28b5602f2759906721c24166e Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:57:33 -0400 Subject: [PATCH 01/11] fix(move-note): rewrite relative asset links inside a moved note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit moveNote resolved link targets only against .md note paths, so a relative-path link to an asset (![[../assets/photo.png]] or ![img](../assets/photo.png)) inside a moved note was left untouched and broke — Obsidian rewrites these on move. The rewriter now resolves against non-md vault files too: - links.resolveAsset: pure array-based twin of the indexer's SQL resolveNonMarkdownFile (same tiers and family ordering — full-filename exact/relative/suffix, then stem exact/relative/suffix/basename), so the rewriter and the index agree on where an asset link points - MD_LINK_PARTS captures any extension (or none) instead of anchoring on .md; extensionless markdown links to notes are now rewritten too instead of silently breaking - rewriteTarget checks the note tier first (notes win on a collision, matching the indexer), then the asset tier; replacements keep the original link's form and extension state (stem-form wikilinks stay extensionless, markdown links keep their original extension) - vaultFs.listAssets lists non-md files with the same walk and filters as listNotes (shared listVaultFilePaths helper) - stripExtension consolidated into the links namespace and reused by the indexer (single home for the multi-dot stem semantics) --- AGENTS.md | 4 +- .../mcp-core/tools/vault-crud-tools.ts | 10 +- .../obsidian-markdown/__tests__/links.test.ts | 190 ++++++++++++++- src/vault-mcp/obsidian-markdown/links.ts | 134 +++++++++-- src/vault-mcp/search/search-index.ts | 17 +- .../__tests__/note-mover.test.ts | 226 +++++++++++++++++- .../__tests__/vault-filesystem.test.ts | 40 ++++ src/vault-mcp/vault-operations/note-mover.ts | 153 +++++++++--- .../vault-operations/vault-filesystem.ts | 56 ++++- 9 files changed, 751 insertions(+), 79 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3aa970b2..e114b3c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,12 +113,12 @@ src/ frontmatter.ts # gray-matter parse/stringify + frontmatter merge callouts.ts # Leading-callout parser (> [!type] blocks) headings.ts # Shared H1–H6 section-span parser (read + patch) - links.ts # Link grammar: parse, extract, resolve (wikilinks + md) + links.ts # Link grammar: parse, extract, resolve (wikilinks + md; notes + assets) tasks.ts # Tasks-plugin task-line grammar + mutation (emoji + Dataview fields) memory-entries.ts # Memory-entry grammar (dated bullets in About Me/ files) plaintext.ts # Strip Obsidian/Markdown syntax → plain text vault-operations/ # Vault content read/write/patch (filesystem I/O) - vault-filesystem.ts # Read/write/list/delete .md files; outline + section reads + vault-filesystem.ts # Read/write/list/delete .md files; list non-md assets; outline + section reads vault-patcher.ts # Surgical edits: heading-targeted patch + find-and-replace note-mover.ts # Move/rename a note + rewrite every vault-wide link to it memory-store.ts # About Me/ heading-aware read/append/delete diff --git a/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts b/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts index 970d71bb..6c5daef9 100644 --- a/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts +++ b/src/vault-mcp/mcp-core/tools/vault-crud-tools.ts @@ -701,7 +701,7 @@ Returns: Confirmation message, noting how many empty folders were pruned when an TOOL_NAMES.VAULT_MOVE_NOTE, { title: "Move Note", - description: `Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder. A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Without this tool a move silently breaks every backlink. + description: `Move or rename a note and rewrite every link across the vault that points to it, like Obsidian's built-in rename. Incoming links in other notes — [[wikilinks]], [[wikilink|aliases]], [[wikilink#headings]], ![[embeds]], [markdown](links.md), and frontmatter links (e.g. related:) — are updated to the new path; the moved note's own relative links are fixed so they still resolve from the new folder, including relative links to attachments (e.g. ![[../assets/photo.png]], ![img](../assets/photo.png)). A link is only rewritten when leaving it unchanged would break it, so a short [[Note]] that stays unambiguous after a folder move is left alone. Without this tool a move silently breaks every backlink. Example: vault_move_note({ old_path: "Inbox/Draft.md", new_path: "Inbox/Spec.md" }) — pure rename. Example: vault_move_note({ old_path: "Inbox/Spec.md", new_path: "Projects/Spec.md" }) — move to another folder, updating links and the note's own relative links. @@ -719,7 +719,7 @@ Errors: - "concurrent write in progress" — a write is in flight on the note, the destination, or one of its backlink sources (the move locks all of them as one unit); retry the move. - Mid-move I/O failure (rare, e.g. a permission or disk error while writing) — the move aborts and the original note is deleted only after the destination and all backlinks are written, so a failure never loses data. The error message names what failed and the resulting state: if a backlink write failed, new_path exists and the original is intact (re-run the move, deleting the partial new_path first, to finish); if the final delete failed, both old_path and new_path exist (delete old_path to finish). -Obsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its .md extension and link text. Only the target path is changed. +Obsidian syntax: Link rewrites preserve each link's existing form — embed marker (!), heading anchor (#…), and alias (|…) are kept; a markdown link keeps its original extension and link text. Only the target path is changed. Returns: JSON with moved_to (the new path), links_updated (count of link occurrences rewritten), updated_notes (sorted paths of the other notes that were edited; the moved note is implied by moved_to), and pruned_empty_folders (count of source folders removed — 0 unless prune_empty_folders was set).`, inputSchema: { @@ -775,7 +775,10 @@ Returns: JSON with moved_to (the new path), links_updated (count of link occurre { path: normalizedOldPath }, reqLogger, ) - const allNotePaths = await vaultFs.listNotes({ vaultPath }, reqLogger) + const [allNotePaths, allAssetPaths] = await Promise.all([ + vaultFs.listNotes({ vaultPath }, reqLogger), + vaultFs.listAssets({ vaultPath }, reqLogger), + ]) return noteMover.moveNote( { vaultPath, @@ -784,6 +787,7 @@ Returns: JSON with moved_to (the new path), links_updated (count of link occurre protectedPaths: config.protectedPaths, backlinkSources: backlinks.map((backlink) => backlink.path), allNotePaths, + allAssetPaths, pruneEmptyFolders, windowsBindMount: config.windowsBindMount, }, diff --git a/src/vault-mcp/obsidian-markdown/__tests__/links.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/links.test.ts index 03dd4dad..9ac6abd2 100644 --- a/src/vault-mcp/obsidian-markdown/__tests__/links.test.ts +++ b/src/vault-mcp/obsidian-markdown/__tests__/links.test.ts @@ -144,9 +144,15 @@ describe("splitWikilink", () => { describe("splitMarkdownLink", () => { const scenarios = [ { - name: "splits a plain markdown link, stripping .md", + name: "splits a plain markdown link, capturing the .md extension", input: "[t](a/b.md)", - expected: { prefix: "[t](", path: "a/b", heading: "", closeParen: ")" }, + expected: { + prefix: "[t](", + path: "a/b", + extension: ".md", + heading: "", + closeParen: ")", + }, }, { name: "splits a markdown link with a heading", @@ -154,6 +160,7 @@ describe("splitMarkdownLink", () => { expected: { prefix: "[t](", path: "a/b", + extension: ".md", heading: "#sec", closeParen: ")", }, @@ -164,6 +171,7 @@ describe("splitMarkdownLink", () => { expected: { prefix: "[t](", path: "My Note", + extension: ".md", heading: "", closeParen: ")", }, @@ -174,6 +182,51 @@ describe("splitMarkdownLink", () => { expected: { prefix: "[t](", path: "100%zzcomplete", + extension: ".md", + heading: "", + closeParen: ")", + }, + }, + { + name: "captures a non-.md asset extension", + input: "[alt](assets/photo.png)", + expected: { + prefix: "[alt](", + path: "assets/photo", + extension: ".png", + heading: "", + closeParen: ")", + }, + }, + { + name: "parses an extensionless target with an empty extension", + input: "[t](Extensionless%20Note)", + expected: { + prefix: "[t](", + path: "Extensionless Note", + extension: "", + heading: "", + closeParen: ")", + }, + }, + { + name: "does not read a dot in a folder name as the extension", + input: "[t](dir.v2/note)", + expected: { + prefix: "[t](", + path: "dir.v2/note", + extension: "", + heading: "", + closeParen: ")", + }, + }, + { + name: "splits at the last dot of a multi-dot target", + input: "[t](archive.tar.gz)", + expected: { + prefix: "[t](", + path: "archive.tar", + extension: ".gz", heading: "", closeParen: ")", }, @@ -187,10 +240,6 @@ describe("splitMarkdownLink", () => { it("returns null for malformed link text (missing closing paren)", () => { expect(links.splitMarkdownLink("[t](path.md")).toBeNull() }) - - it("returns null for a non-.md target — recognized links that moveNote does not rewrite", () => { - expect(links.splitMarkdownLink("[t](file.txt)")).toBeNull() - }) }) // ── extractFromBody ────────────────────────────────────────────── @@ -577,3 +626,132 @@ describe("resolve", () => { expect(links.resolve("../secret", ["secret.md"], "note.md")).toBeNull() }) }) + +// ── stripExtension ─────────────────────────────────────────────── + +describe("stripExtension", () => { + it("strips the extension after the last dot in the filename", () => { + expect(links.stripExtension("boards/Trip Route.canvas")).toBe( + "boards/Trip Route", + ) + }) + + it("keeps the inner dots of a multi-dot filename", () => { + expect(links.stripExtension("assets/photo.png.canvas")).toBe( + "assets/photo.png", + ) + }) + + it("returns the path unchanged when the filename has no dot", () => { + expect(links.stripExtension("assets/LICENSE")).toBe("assets/LICENSE") + }) + + it("treats a leading-dot file as having no extension", () => { + expect(links.stripExtension("config/.hidden")).toBe("config/.hidden") + }) + + it("ignores dots in folder names", () => { + expect(links.stripExtension("v1.2/note")).toBe("v1.2/note") + }) +}) + +// ── resolveAsset ───────────────────────────────────────────────── + +describe("resolveAsset", () => { + const allAssetPaths = [ + "assets/photo.png", + "boards/Trip Route.canvas", + "deep/nested/assets/photo.png", + "app/views/Inventory.base", + ] + + it("resolves an exact path with extension", () => { + expect( + links.resolveAsset({ target: "assets/photo.png", allAssetPaths }), + ).toBe("assets/photo.png") + }) + + it("resolves a path relative to the source note's directory", () => { + expect( + links.resolveAsset({ + target: "../assets/photo.png", + allAssetPaths, + sourcePath: "Notes/Note.md", + }), + ).toBe("assets/photo.png") + }) + + it("resolves a full-filename suffix to the shortest match", () => { + expect(links.resolveAsset({ target: "photo.png", allAssetPaths })).toBe( + "assets/photo.png", + ) + }) + + it("prefers the full-filename match over a stem match (family ordering)", () => { + // "photo.png" stem-matches "b/photo.png.canvas", but the full-filename + // family runs first and wins with "a/photo.png". + const paths = ["b/photo.png.canvas", "a/photo.png"] + expect( + links.resolveAsset({ target: "photo.png", allAssetPaths: paths }), + ).toBe("a/photo.png") + }) + + it("resolves an extensionless target by exact stem", () => { + expect( + links.resolveAsset({ target: "boards/Trip Route", allAssetPaths }), + ).toBe("boards/Trip Route.canvas") + }) + + it("resolves an extensionless target relative to the source by stem", () => { + expect( + links.resolveAsset({ + target: "../boards/Trip Route", + allAssetPaths, + sourcePath: "Notes/N.md", + }), + ).toBe("boards/Trip Route.canvas") + }) + + it("resolves a bare-name stem to the shortest basename match", () => { + const paths = ["deep/nested/photo.png", "a/photo.png"] + expect(links.resolveAsset({ target: "photo", allAssetPaths: paths })).toBe( + "a/photo.png", + ) + }) + + it("resolves a stem with folder segments as a suffix match", () => { + expect( + links.resolveAsset({ target: "views/Inventory", allAssetPaths }), + ).toBe("app/views/Inventory.base") + }) + + it("resolves a multi-dot stem when no full-filename match exists", () => { + expect( + links.resolveAsset({ + target: "photo.png", + allAssetPaths: ["assets/photo.png.canvas"], + }), + ).toBe("assets/photo.png.canvas") + }) + + it("breaks same-length stem ties lexicographically", () => { + // Matches the SQL resolver's ORDER BY length(path), path — deterministic + // regardless of array order. + const paths = ["a/photo.png", "a/photo.jpg"] + expect(links.resolveAsset({ target: "photo", allAssetPaths: paths })).toBe( + "a/photo.jpg", + ) + }) + + it("returns null when nothing matches", () => { + expect( + links.resolveAsset({ target: "missing.png", allAssetPaths }), + ).toBeNull() + }) + + it("cannot resolve a relative target without a source path", () => { + expect( + links.resolveAsset({ target: "../assets/photo.png", allAssetPaths }), + ).toBeNull() + }) +}) diff --git a/src/vault-mcp/obsidian-markdown/links.ts b/src/vault-mcp/obsidian-markdown/links.ts index 9ea70a6b..ee6d8ab9 100644 --- a/src/vault-mcp/obsidian-markdown/links.ts +++ b/src/vault-mcp/obsidian-markdown/links.ts @@ -47,12 +47,13 @@ const TEMPLATER_RE = /<%[-+*_~]?.*?%>/g * Anchored and non-global — safe for .exec(). */ const WIKILINK_PARTS = /^(!?)\[\[([^\]#|]+)(#[^\]|]*)?(\|[^\]]+)?\]\]$/ -/** Splits a matched markdown link into [, `[text](`, path-without-ext, `#heading`, - * `)`]. Deliberately .md-anchored while MD_LINK_RE recognizes any target: - * parsing-for-rewrite serves moveNote, which only relocates notes — so a - * non-.md match is a recognized link that is never rewritten. - * Anchored and non-global — safe for .exec(). */ -const MD_LINK_PARTS = /^(\[[^\]]*\]\()([^)#\s]+?)\.md(#[^)\s]*)?(\))$/ +/** Splits a matched markdown link into [, `[text](`, path-without-ext, + * extension-with-dot, `#heading`, `)`]. Any extension is captured + * ([text](Note.md), ![alt](image.png), [doc](file.pdf)); an extensionless + * target ([text](Extensionless%20Note)) parses with extension "". The + * extension group excludes "/" so a dot in a folder name is never misread + * as the extension. Anchored and non-global — safe for .exec(). */ +const MD_LINK_PARTS = /^(\[[^\]]*\]\()([^)#\s]+?)(\.[^/.#\s]+)?(#[^)\s]*)?(\))$/ /** Safely decodes a URI component, falling back to the raw string if the * percent-encoding is malformed (e.g. "100%complete"). */ @@ -98,12 +99,14 @@ type WikilinkParts = { alias: string } -/** The structural parts of a markdown link [text](path.md#heading). path is the - * DECODED target without the .md extension; prefix/closeParen/heading are - * verbatim literals for lossless reconstruction. */ +/** The structural parts of a markdown link [text](path.ext#heading). path is the + * DECODED target without its extension; extension keeps its leading dot ("" for + * an extensionless target); prefix/closeParen/heading are verbatim literals for + * lossless reconstruction. */ type MarkdownLinkParts = { prefix: string path: string + extension: string heading: string closeParen: string } @@ -155,17 +158,18 @@ const splitWikilink = (linkText: string): WikilinkParts | null => { return { embed, target, heading, alias } } -/** Splits a matched markdown link into its parts (path decoded, .md stripped), or - * null when the text is not a well-formed .md link — including recognized - * non-.md links (assets, extensionless targets), which moveNote deliberately - * does not rewrite. */ +/** Splits a matched markdown link into its parts (path decoded, extension + * captured separately), or null when the text is not a well-formed markdown + * link. Any vault target parses — notes, assets, and extensionless targets — + * so moveNote can rewrite all of them. */ const splitMarkdownLink = (linkText: string): MarkdownLinkParts | null => { const parts = MD_LINK_PARTS.exec(linkText) if (!parts) return null const prefix = parts[1] const encodedPath = parts[2] - const heading = parts[3] ?? "" - const closeParen = parts[4] + const extension = parts[3] ?? "" + const heading = parts[4] ?? "" + const closeParen = parts[5] const hasRequiredGroups = prefix !== undefined && encodedPath !== undefined && @@ -174,6 +178,7 @@ const splitMarkdownLink = (linkText: string): MarkdownLinkParts | null => { return { prefix, path: safeDecodeURIComponent(encodedPath), + extension, heading, closeParen, } @@ -300,6 +305,103 @@ const resolve = ( return null } +/** Strips the file extension from a path, or returns the path unchanged when + * the filename has no extension. Uses the last dot in the filename (not the + * path), so a multi-dot name keeps its inner dots ("photo.png.canvas" → + * "photo.png") and dots in folder names are ignored. A leading-dot file + * (".hidden") has no extension. */ +const stripExtension = (filePath: string): string => { + const fileName = posix.basename(filePath) + const dotIndex = fileName.lastIndexOf(".") + if (dotIndex <= 0) return filePath + return filePath.slice(0, filePath.length - (fileName.length - dotIndex)) +} + +/** Picks the winner among same-tier resolution matches: the shortest path, + * with a lexicographic tiebreak for determinism — mirroring the SQL + * resolver's ORDER BY length(path), path LIMIT 1. */ +const shortestOf = (paths: string[]): string | null => { + if (paths.length === 0) return null + return paths.reduce((shortest, candidatePath) => + candidatePath.length < shortest.length || + (candidatePath.length === shortest.length && candidatePath < shortest) + ? candidatePath + : shortest, + ) +} + +/** Resolves a link target to a known non-markdown vault file, or null when no + * match exists. Pure array-based twin of the indexer's SQL-backed + * resolveNonMarkdownFile (search/search-index.ts) — same tiers, same family + * ordering — so the move rewriter and the index agree on where an asset link + * points. Handles both extensionless targets ([[Trip Route]] → + * Trip Route.canvas) and explicit-extension targets ([[photo.png]], + * ![img](../assets/photo.png)) in every form Obsidian resolves — exact path, + * relative to the source note, and basename/shortest path. + * + * The full-filename tiers all run before any stem tier (extension-stripped + * paths): the families are NOT disjoint — a multi-dot filename's stem + * retains its inner dots ("photo.png.canvas" → stem "photo.png"), so a + * with-extension target can stem-match a different file. Family ordering + * makes the full-filename match win, while the stem tiers stay the fallback + * so [[photo.png]] with only photo.png.canvas in the vault still resolves — + * mirroring Obsidian's stem matching. Extensionless targets fall through the + * full-filename family unmatched (stored paths always carry an extension). */ +const resolveAsset = (params: { + target: string + allAssetPaths: readonly string[] + sourcePath?: string +}): string | null => { + const { target, allAssetPaths, sourcePath } = params + const relativeTarget = sourcePath + ? posix.join(posix.dirname(sourcePath), target) + : null + + // ── Full-filename family: exact → relative → path suffix ── + + if (allAssetPaths.includes(target)) return target + + if (relativeTarget && allAssetPaths.includes(relativeTarget)) { + return relativeTarget + } + + const fullPathSuffixMatch = shortestOf( + allAssetPaths.filter((assetPath) => assetPath.endsWith(`/${target}`)), + ) + if (fullPathSuffixMatch) return fullPathSuffixMatch + + // ── Stem family: exact → relative → suffix/basename ── + + const exactStemMatch = shortestOf( + allAssetPaths.filter((assetPath) => stripExtension(assetPath) === target), + ) + if (exactStemMatch) return exactStemMatch + + if (relativeTarget) { + const relativeStemMatch = shortestOf( + allAssetPaths.filter( + (assetPath) => stripExtension(assetPath) === relativeTarget, + ), + ) + if (relativeStemMatch) return relativeStemMatch + } + + // A target with folder segments keeps them in the match (suffix on the + // stem); a bare name matches on the filename stem only. + if (target.includes("/")) { + return shortestOf( + allAssetPaths.filter((assetPath) => + stripExtension(assetPath).endsWith(`/${target}`), + ), + ) + } + return shortestOf( + allAssetPaths.filter( + (assetPath) => stripExtension(posix.basename(assetPath)) === target, + ), + ) +} + /** A note's complete link set — body links unioned with frontmatter wikilinks, * deduplicated. Single source of truth for "what does this note link to", * shared by incremental upsert and full rebuild — must not diverge. */ @@ -321,4 +423,6 @@ export const links = { extractFromFrontmatter, extractAll, resolve, + stripExtension, + resolveAsset, } diff --git a/src/vault-mcp/search/search-index.ts b/src/vault-mcp/search/search-index.ts index 69b393ae..3039d74c 100644 --- a/src/vault-mcp/search/search-index.ts +++ b/src/vault-mcp/search/search-index.ts @@ -715,15 +715,6 @@ export const createSearchIndex = ( FROM notes WHERE path = ?`, ) - /** Strips the file extension from a path, or returns the path unchanged if - * it has no extension. Uses the last dot in the filename (not the path). */ - const stripExtension = (filePath: string): string => { - const fileName = basename(filePath) - const dotIndex = fileName.lastIndexOf(".") - if (dotIndex <= 0) return filePath - return filePath.slice(0, filePath.length - (fileName.length - dotIndex)) - } - /** Resolves a wikilink target to a known non-markdown file path, or null * when no match is found. Handles both extensionless targets ([[Trip Route]] * → Trip Route.canvas) and explicit-extension targets ([[photo.png]], @@ -825,8 +816,8 @@ export const createSearchIndex = ( const relativePath = relative(normalizedVault, absolutePath) if (relativePath.split("/").some((segment) => segment.startsWith("."))) continue - const basePath = stripExtension(relativePath) - const baseFilename = stripExtension(directoryEntry.name) + const basePath = links.stripExtension(relativePath) + const baseFilename = links.stripExtension(directoryEntry.name) upsertNonMdFileStmt.run(relativePath, basePath, baseFilename) filesIndexed += 1 } @@ -838,8 +829,8 @@ export const createSearchIndex = ( * add/change. Mirrors the note forward-reference re-resolution pattern: * updates the link target from the raw text to the resolved non-md path. */ const upsertNonMdFile = (filePath: string): void => { - const basePath = stripExtension(filePath) - const baseFilename = stripExtension(basename(filePath)) + const basePath = links.stripExtension(filePath) + const baseFilename = links.stripExtension(basename(filePath)) upsertNonMdFileStmt.run(filePath, basePath, baseFilename) // Re-resolve unresolved links that now match this non-md file — upgrade diff --git a/src/vault-mcp/vault-operations/__tests__/note-mover.test.ts b/src/vault-mcp/vault-operations/__tests__/note-mover.test.ts index 75ef7816..1412f794 100644 --- a/src/vault-mcp/vault-operations/__tests__/note-mover.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/note-mover.test.ts @@ -55,7 +55,7 @@ const setupVault = () => { /** True when a folder still exists in the vault — used to assert pruning. */ const folderExists = (path: string): Promise => noteExists(path) - /** Moves a note, snapshotting the pre-move path list the way the tool does. + /** Moves a note, snapshotting the pre-move path lists the way the tool does. * Named params mirror noteMover.moveNote so call sites are self-describing; * vault plumbing and the defaults (no backlink sources, no pruning, no * Windows mode) are filled in here. */ @@ -74,6 +74,7 @@ const setupVault = () => { protectedPaths: PROTECTED, backlinkSources: params.backlinkSources ?? [], allNotePaths: await vaultFs.listNotes({ vaultPath: vault }, logger), + allAssetPaths: await vaultFs.listAssets({ vaultPath: vault }, logger), pruneEmptyFolders: params.pruneEmptyFolders ?? false, windowsBindMount: params.windowsBindMount ?? false, }, @@ -597,6 +598,227 @@ describe("moveNote — selectivity (must-not-rewrite cases)", () => { }) }) +describe("moveNote — asset link rewriting", () => { + it("rewrites a relative wikilink embed to an asset when the note moves deeper", async () => { + const { writeFixture, moveNote, readNote } = setupVault() + await writeFixture("assets/photo.png", "img") + await writeFixture("Notes/Note.md", "Image: ![[../assets/photo.png]]\n") + + const result = await moveNote({ + oldPath: "Notes/Note.md", + newPath: "Notes/Sub/Note.md", + }) + + expect(await readNote("Notes/Sub/Note.md")).toBe( + "Image: ![[../../assets/photo.png]]\n", + ) + expect(result.links_updated).toBe(1) + }) + + it("rewrites a relative markdown link to an asset when the note moves deeper", async () => { + const { writeFixture, moveNote, readNote } = setupVault() + await writeFixture("assets/photo.png", "img") + await writeFixture("Notes/Note.md", "![img](../assets/photo.png)\n") + + const result = await moveNote({ + oldPath: "Notes/Note.md", + newPath: "Notes/Sub/Note.md", + }) + + expect(await readNote("Notes/Sub/Note.md")).toBe( + "![img](../../assets/photo.png)\n", + ) + expect(result.links_updated).toBe(1) + }) + + it("leaves a basename-form asset embed untouched — it still resolves after the move", async () => { + const { writeFixture, moveNote, readNote, noteExists } = setupVault() + await writeFixture("assets/photo.png", "img") + await writeFixture("Notes/Note.md", "Image: ![[photo.png]]\n") + + const result = await moveNote({ + oldPath: "Notes/Note.md", + newPath: "Archive/Deep/Note.md", + }) + + // The note provably moved, so the unchanged content is a deliberate + // leave-alone, not a silent no-op. + expect(await noteExists("Notes/Note.md")).toBe(false) + expect(await readNote("Archive/Deep/Note.md")).toBe( + "Image: ![[photo.png]]\n", + ) + expect(result.links_updated).toBe(0) + }) + + it("leaves a vault-absolute asset link untouched", async () => { + const { writeFixture, moveNote, readNote, noteExists } = setupVault() + await writeFixture("assets/photo.png", "img") + await writeFixture("Notes/Note.md", "Image: ![[assets/photo.png]]\n") + + const result = await moveNote({ + oldPath: "Notes/Note.md", + newPath: "Archive/Deep/Note.md", + }) + + expect(await noteExists("Notes/Note.md")).toBe(false) + expect(await readNote("Archive/Deep/Note.md")).toBe( + "Image: ![[assets/photo.png]]\n", + ) + expect(result.links_updated).toBe(0) + }) + + it("rewrites a backlink source's note links but leaves its asset links byte-identical", async () => { + const { writeFixture, moveNote, readNote } = setupVault() + await writeFixture("assets/photo.png", "img") + await writeFixture("Foo.md", "content\n") + await writeFixture( + "Docs/Hub.md", + "Links [[Foo]].\nImage: ![[../assets/photo.png]]\n", + ) + + const result = await moveNote({ + oldPath: "Foo.md", + newPath: "Bar.md", + backlinkSources: ["Docs/Hub.md"], + }) + + // The source note itself didn't move, so its relative asset link still + // resolves — only the link to the moved note is rewritten. + expect(await readNote("Docs/Hub.md")).toBe( + "Links [[Bar]].\nImage: ![[../assets/photo.png]]\n", + ) + expect(result.links_updated).toBe(1) + }) + + it("rewrites a stem-form asset embed, preserving the extensionless form", async () => { + const { writeFixture, moveNote, readNote } = setupVault() + await writeFixture("boards/Trip Route.canvas", "{}") + await writeFixture("Notes/Note.md", "Route: ![[../boards/Trip Route]]\n") + + const result = await moveNote({ + oldPath: "Notes/Note.md", + newPath: "Notes/Sub/Note.md", + }) + + expect(await readNote("Notes/Sub/Note.md")).toBe( + "Route: ![[../../boards/Trip Route]]\n", + ) + expect(result.links_updated).toBe(1) + }) + + it("resolves a multi-dot asset target to its full-filename file via the stem family", async () => { + const { writeFixture, moveNote, readNote } = setupVault() + // Only photo.png.canvas exists — ![[../assets/photo.png]] has no + // full-filename match, so the stem family resolves it (mirroring the + // indexer's resolveNonMarkdownFile). + await writeFixture("assets/photo.png.canvas", "{}") + await writeFixture("Notes/Note.md", "Image: ![[../assets/photo.png]]\n") + + const result = await moveNote({ + oldPath: "Notes/Note.md", + newPath: "Notes/Sub/Note.md", + }) + + expect(await readNote("Notes/Sub/Note.md")).toBe( + "Image: ![[../../assets/photo.png]]\n", + ) + expect(result.links_updated).toBe(1) + }) + + it("rewrites an extensionless markdown link to a note, preserving the extensionless form", async () => { + const { writeFixture, moveNote, readNote } = setupVault() + // "a/Target.md" defeats the basename fallback (shorter than + // deep/x/Target.md), so the link must be rewritten to keep pointing at + // the deep note — before this fix it was left to break. + await writeFixture("a/Target.md", "decoy\n") + await writeFixture("deep/x/Target.md", "content\n") + await writeFixture("deep/x/Note.md", "See [text](Target).\n") + + const result = await moveNote({ + oldPath: "deep/x/Note.md", + newPath: "deep/y/Note.md", + }) + + expect(await readNote("deep/y/Note.md")).toBe("See [text](../x/Target).\n") + expect(result.links_updated).toBe(1) + }) + + it("resolves a bare target to the note over a same-named asset", async () => { + const { writeFixture, moveNote, readNote } = setupVault() + await writeFixture("assets/icon.md", "note\n") + await writeFixture("assets/icon.png", "img") + // "other/icon.md" defeats the basename fallback, forcing a rewrite — had + // [[../assets/icon]] stem-matched the asset instead, it would still + // resolve after the move and stay byte-identical. + await writeFixture("other/icon.md", "decoy\n") + await writeFixture("Notes/Note.md", "Link: [[../assets/icon]]\n") + + const result = await moveNote({ + oldPath: "Notes/Note.md", + newPath: "Notes/Sub/Note.md", + }) + + expect(await readNote("Notes/Sub/Note.md")).toBe( + "Link: [[../../assets/icon]]\n", + ) + expect(result.links_updated).toBe(1) + }) + + it("resolves an explicit-extension target to the asset over a same-named note", async () => { + const { writeFixture, moveNote, readNote } = setupVault() + await writeFixture("assets/icon.md", "note\n") + await writeFixture("assets/icon.png", "img") + await writeFixture("Notes/Note.md", "Asset: ![[../assets/icon.png]]\n") + + const result = await moveNote({ + oldPath: "Notes/Note.md", + newPath: "Notes/Sub/Note.md", + }) + + expect(await readNote("Notes/Sub/Note.md")).toBe( + "Asset: ![[../../assets/icon.png]]\n", + ) + expect(result.links_updated).toBe(1) + }) + + it("keeps a basename asset embed pointing at the same file when a shorter same-named asset exists elsewhere", async () => { + const { writeFixture, moveNote, readNote } = setupVault() + // "a/photo.png" is the shorter suffix match, but the link resolved via + // the relative tier to "other/photo.png" — the rewrite must not switch + // files. + await writeFixture("a/photo.png", "img-a") + await writeFixture("other/photo.png", "img-other") + await writeFixture("other/Note.md", "Image: ![[photo.png]]\n") + + const result = await moveNote({ + oldPath: "other/Note.md", + newPath: "z/Note.md", + }) + + expect(await readNote("z/Note.md")).toBe("Image: ![[../other/photo.png]]\n") + expect(result.links_updated).toBe(1) + }) + + it("rewrites an asset embed stored in a frontmatter property", async () => { + const { writeFixture, moveNote, readNote } = setupVault() + await writeFixture("assets/photo.png", "img") + await writeFixture( + "Notes/Note.md", + '---\nbanner: "![[../assets/photo.png]]"\n---\nBody\n', + ) + + const result = await moveNote({ + oldPath: "Notes/Note.md", + newPath: "Notes/Sub/Note.md", + }) + + expect(await readNote("Notes/Sub/Note.md")).toBe( + '---\nbanner: "![[../../assets/photo.png]]"\n---\nBody\n', + ) + expect(result.links_updated).toBe(1) + }) +}) + describe("moveNote — counts and summary", () => { it("counts every rewritten occurrence and lists changed notes sorted", async () => { const { writeFixture, moveNote, readNote } = setupVault() @@ -1016,6 +1238,7 @@ describe("moveNote — concurrent write locking", () => { newPath: string backlinkSources: string[] allNotePaths: string[] + allAssetPaths?: string[] }): ReturnType => { const movePromise = noteMover.moveNote( { @@ -1025,6 +1248,7 @@ describe("moveNote — concurrent write locking", () => { protectedPaths: PROTECTED, backlinkSources: params.backlinkSources, allNotePaths: params.allNotePaths, + allAssetPaths: params.allAssetPaths ?? [], pruneEmptyFolders: false, windowsBindMount: false, }, diff --git a/src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts b/src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts index 1ff286f2..19236542 100644 --- a/src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts @@ -37,6 +37,7 @@ const { updateProperties, deleteNote, listNotes, + listAssets, } = vaultFs let vault: string @@ -805,6 +806,45 @@ describe("listNotes", () => { }) }) +describe("listAssets", () => { + beforeEach(async () => { + await mkdir(join(vault, "assets"), { recursive: true }) + await mkdir(join(vault, ".obsidian"), { recursive: true }) + await writeFile(join(vault, "assets/photo.png"), "img", "utf8") + await writeFile(join(vault, "assets/board.canvas"), "{}", "utf8") + await writeFile(join(vault, "root.md"), "r", "utf8") + await writeFile(join(vault, "notes.md"), "n", "utf8") + await writeFile(join(vault, ".obsidian/plugin.js"), "x", "utf8") + await writeFile(join(vault, ".DS_Store"), "x", "utf8") + }) + + it("lists non-.md files recursively, excluding notes", async () => { + const files = await listAssets({ vaultPath: vault }, logger) + expect(files).toEqual(["assets/board.canvas", "assets/photo.png"]) + }) + + it("skips hidden files and directories", async () => { + const files = await listAssets({ vaultPath: vault }, logger) + expect(files).toEqual(["assets/board.canvas", "assets/photo.png"]) + }) + + it("includes a symlinked asset in the listing", async () => { + await symlink("assets/photo.png", join(vault, "linked.png")) + const files = await listAssets({ vaultPath: vault }, logger) + expect(files).toEqual([ + "assets/board.canvas", + "assets/photo.png", + "linked.png", + ]) + }) + + it("returns an empty array when the vault has no assets", async () => { + await rm(join(vault, "assets"), { recursive: true }) + const files = await listAssets({ vaultPath: vault }, logger) + expect(files).toEqual([]) + }) +}) + describe("readNoteProperties", () => { it("returns parsed frontmatter as an object", async () => { await writeFile( diff --git a/src/vault-mcp/vault-operations/note-mover.ts b/src/vault-mcp/vault-operations/note-mover.ts index 6f580a22..d9d6794a 100644 --- a/src/vault-mcp/vault-operations/note-mover.ts +++ b/src/vault-mcp/vault-operations/note-mover.ts @@ -68,17 +68,26 @@ type RewriteContext = { newTargetPath: string allNotePaths: string[] allNotePathsAfter: string[] + /** Every non-.md path in the vault. Assets never move (moveNote is + * .md-only), so unlike the note lists there is no "after" variant. */ + allAssetPaths: readonly string[] } /** Which of Obsidian's link forms a raw target used to resolve, so the * replacement can be written back in the same style. */ type LinkForm = "basename" | "absolute" | "relative" -// ── Target classification + construction ──────────────────────── +/** Which side of the vault a link points at. Notes are checked first — a + * target matching both a note and an asset resolves to the note, matching + * the indexer's precedence. */ +type TargetKind = "note" | "asset" + +/** Which link grammar a replacement is written back into. Markdown links + * carry their extension ([text](Note.md), ![alt](photo.png)); wikilinks to + * notes drop it ([[Note]]). */ +type LinkGrammar = "wikilink" | "markdown" -/** Strips a trailing .md so a vault path can be used as a wikilink target. */ -const withoutExtension = (path: string): string => - path.endsWith(".md") ? path.slice(0, -".md".length) : path +// ── Target classification + construction ──────────────────────── /** Determines which link form (basename, absolute, relative) was used, so the * replacement can be written back in the same style. */ @@ -88,9 +97,14 @@ const classifyLinkForm = (params: { resolvedTarget: string }): LinkForm => { const { rawTarget, sourcePath, resolvedTarget } = params - const targetWithExtension = rawTarget.endsWith(".md") + // The resolved path carries the file's real extension — ".md" for notes, + // the asset's own otherwise — so one rule covers both kinds (a stem-form + // asset link gets its extension appended, exactly like an extensionless + // wikilink to a note). + const extension = posix.extname(resolvedTarget) + const targetWithExtension = rawTarget.endsWith(extension) ? rawTarget - : `${rawTarget}.md` + : `${rawTarget}${extension}` if (targetWithExtension === resolvedTarget) return "absolute" if ( posix.join(posix.dirname(sourcePath), targetWithExtension) === @@ -103,18 +117,31 @@ const classifyLinkForm = (params: { /** Builds a replacement target that resolves to desiredTarget from the new source * location, keeping the original link form. Falls back to vault-absolute if the - * shorter form would resolve elsewhere after the move. */ + * shorter form would resolve elsewhere after the move. writtenExtension is the + * extension the replacement is written with: the resolved file's extension + * when the original link carried it, "" when the original used a + * stem/extensionless form (the extension is stripped back off). */ const buildReplacementTarget = (params: { form: LinkForm desiredTarget: string newSourcePath: string - allNotePathsAfter: string[] + writtenExtension: string + resolveCandidate: (candidate: string) => string | null }): string => { - const { form, desiredTarget, newSourcePath, allNotePathsAfter } = params - const absoluteForm = withoutExtension(desiredTarget) + const { + form, + desiredTarget, + newSourcePath, + writtenExtension, + resolveCandidate, + } = params + const absoluteForm = + writtenExtension === "" + ? links.stripExtension(desiredTarget) + : desiredTarget const resolvesToDesired = (candidate: string): boolean => - links.resolve(candidate, allNotePathsAfter, newSourcePath) === desiredTarget + resolveCandidate(candidate) === desiredTarget if (form === "basename") { const basename = posix.basename(absoluteForm) @@ -130,32 +157,71 @@ const buildReplacementTarget = (params: { } /** Returns the replacement target for one link, or null to leave it unchanged. - * Null when unresolved, still pointing at the right note, or not affected. */ + * Null when unresolved, still pointing at the right file, or not affected. + * Notes are resolved before assets (a target matching both resolves to the + * note, matching the indexer's precedence); an asset target never follows + * the move — only the shift in the source note's location can break it. */ const rewriteTarget = ( - rawTarget: string, + params: { + rawTarget: string + originalExtension: string + grammar: LinkGrammar + }, context: RewriteContext, ): string | null => { - const resolvedBefore = links.resolve( + const { rawTarget, originalExtension, grammar } = params + + const resolvedNoteBefore = links.resolve( rawTarget, context.allNotePaths, context.oldSourcePath, ) + const targetKind: TargetKind = resolvedNoteBefore ? "note" : "asset" + const resolvedBefore = + resolvedNoteBefore ?? + links.resolveAsset({ + target: rawTarget, + allAssetPaths: context.allAssetPaths, + sourcePath: context.oldSourcePath, + }) if (resolvedBefore === null) return null // Follow the moved note to its new location; leave other targets as-is. const desiredTarget = - resolvedBefore === context.oldTargetPath + targetKind === "note" && resolvedBefore === context.oldTargetPath ? context.newTargetPath : resolvedBefore + // Resolver against the post-move vault from the source's new location — + // shared by the "already resolves" check and the candidate verifier. + const resolveFromNewSource = (candidate: string): string | null => + targetKind === "note" + ? links.resolve( + candidate, + context.allNotePathsAfter, + context.newSourcePath, + ) + : links.resolveAsset({ + target: candidate, + allAssetPaths: context.allAssetPaths, + sourcePath: context.newSourcePath, + }) + // Already resolves correctly from the new location — leave it alone. - const resolvedAfter = links.resolve( - rawTarget, - context.allNotePathsAfter, - context.newSourcePath, - ) + const resolvedAfter = resolveFromNewSource(rawTarget) if (resolvedAfter === desiredTarget) return null + // The replacement keeps the original link's extension state: an extension + // is written only when the original carried the resolved file's real + // extension (markdown links always keep theirs; a wikilink to a note never + // carries ".md"; a stem-form asset link stays extensionless). + const resolvedExtension = posix.extname(desiredTarget) + const writtenExtension = + (targetKind === "asset" || grammar === "markdown") && + originalExtension === resolvedExtension + ? resolvedExtension + : "" + return buildReplacementTarget({ form: classifyLinkForm({ rawTarget, @@ -164,7 +230,8 @@ const rewriteTarget = ( }), desiredTarget, newSourcePath: context.newSourcePath, - allNotePathsAfter: context.allNotePathsAfter, + writtenExtension, + resolveCandidate: resolveFromNewSource, }) } @@ -183,9 +250,14 @@ const encodeMarkdownLinkPath = (path: string): string => type LinkEdit = { start: number; end: number; replacement: string } -/** Takes a raw link target string and returns its replacement, or null to - * leave the link unchanged. Created by binding rewriteTarget to a context. */ -type RewriteLink = (rawTarget: string) => string | null +/** Takes a raw link target (with its original extension state and grammar) and + * returns its replacement, or null to leave the link unchanged. Created by + * binding rewriteTarget to a context. */ +type RewriteLink = (params: { + rawTarget: string + originalExtension: string + grammar: LinkGrammar +}) => string | null /** Splices link replacements into text in start order. Shared by body-line and * frontmatter-string rewriting. */ @@ -243,22 +315,31 @@ const rewriteWikilinkText = ( ): string | null => { const parts = links.splitWikilink(linkText) if (!parts) return null - const newTarget = rewriteLink(parts.target.trim()) + const rawTarget = parts.target.trim() + const newTarget = rewriteLink({ + rawTarget, + originalExtension: posix.extname(rawTarget), + grammar: "wikilink", + }) if (newTarget === null) return null return `${parts.embed}[[${newTarget}${parts.heading}${parts.alias}]]` } -/** Rewrites one matched markdown link, preserving the link text and heading; - * null when the target needs no change. */ +/** Rewrites one matched markdown link, preserving the link text, heading, and + * the original extension state; null when the target needs no change. */ const rewriteMarkdownLinkText = ( linkText: string, rewriteLink: RewriteLink, ): string | null => { const parts = links.splitMarkdownLink(linkText) if (!parts) return null - const newTarget = rewriteLink(parts.path) + const newTarget = rewriteLink({ + rawTarget: `${parts.path}${parts.extension}`, + originalExtension: parts.extension, + grammar: "markdown", + }) if (newTarget === null) return null - return `${parts.prefix}${encodeMarkdownLinkPath(newTarget)}.md${parts.heading}${parts.closeParen}` + return `${parts.prefix}${encodeMarkdownLinkPath(newTarget)}${parts.heading}${parts.closeParen}` } /** Rewrites every link in a note body, skipping fenced code blocks. */ @@ -403,6 +484,9 @@ const moveNote = async ( backlinkSources: readonly string[] /** Every .md path in the vault — links.resolve checks against this to determine where links point. */ allNotePaths: readonly string[] + /** Every non-.md path in the vault — links.resolveAsset checks against + * this for asset targets (images, canvases, PDFs, …). */ + allAssetPaths: readonly string[] /** When set, remove any source folders the move leaves empty. */ pruneEmptyFolders: boolean /** Windows-drive bind mount — write the destination via rename, not a hard @@ -411,7 +495,13 @@ const moveNote = async ( }, logger: Logger, ): Promise => { - const { vaultPath, protectedPaths, allNotePaths, pruneEmptyFolders } = params + const { + vaultPath, + protectedPaths, + allNotePaths, + allAssetPaths, + pruneEmptyFolders, + } = params // Normalize before any guard or comparison — see toVaultRelativePath. const oldPath = toVaultRelativePath(params.oldPath) const newPath = toVaultRelativePath(params.newPath) @@ -503,8 +593,9 @@ const moveNote = async ( newTargetPath: newPath, allNotePaths: allNotePathsBefore, allNotePathsAfter, + allAssetPaths, } - return (rawTarget) => rewriteTarget(rawTarget, context) + return (rewriteParams) => rewriteTarget(rewriteParams, context) } // ── Preflight: read every file and compute its rewrite, mutating nothing. ── diff --git a/src/vault-mcp/vault-operations/vault-filesystem.ts b/src/vault-mcp/vault-operations/vault-filesystem.ts index 49b49301..dfb3d0a5 100644 --- a/src/vault-mcp/vault-operations/vault-filesystem.ts +++ b/src/vault-mcp/vault-operations/vault-filesystem.ts @@ -422,12 +422,17 @@ const deleteNote = async ( }) } -/** Lists .md files under a folder (or vault root). Supports glob filtering. */ -const listNotes = async ( +/** Walks the vault (or a folder within it) and returns the sorted + * vault-relative paths of every file whose name passes keepFileName. + * Symlinks may point outside the vault (e.g. ARCHITECTURE.md → + * ~/Code/repo/ARCHITECTURE.md) — Obsidian supports this natively, so we + * follow suit. Only broken symlinks and non-file targets are excluded. + * Hidden segments (any path part starting with ".") are skipped. */ +const listVaultFilePaths = async ( params: { vaultPath: string folder?: string | undefined - glob?: string | undefined + keepFileName: (fileName: string) => boolean }, logger: Logger, ): Promise => { @@ -439,20 +444,17 @@ const listNotes = async ( const normalizedVault = resolve(params.vaultPath) - // Symlinks may point outside the vault (e.g. ARCHITECTURE.md → - // ~/Code/repo/ARCHITECTURE.md) — Obsidian supports this natively, so we - // follow suit. Only broken symlinks and non-file targets are excluded. const entries = await filterValidSymlinks({ entries: allEntries, normalizedRoot: normalizedVault, logger, }) - const paths = entries + return entries .filter( (entry) => (entry.isFile() || entry.isSymbolicLink()) && - entry.name.endsWith(".md"), + params.keepFileName(entry.name), ) .map((entry) => relative(normalizedVault, join(entry.parentPath, entry.name)), @@ -462,6 +464,25 @@ const listNotes = async ( !relativePath.split("/").some((segment) => segment.startsWith(".")), ) .sort() +} + +/** Lists .md files under a folder (or vault root). Supports glob filtering. */ +const listNotes = async ( + params: { + vaultPath: string + folder?: string | undefined + glob?: string | undefined + }, + logger: Logger, +): Promise => { + const paths = await listVaultFilePaths( + { + vaultPath: params.vaultPath, + folder: params.folder, + keepFileName: (fileName) => fileName.endsWith(".md"), + }, + logger, + ) const isMatch = params.glob ? picomatch(params.glob) : undefined const result = isMatch ? paths.filter((notePath) => isMatch(notePath)) : paths @@ -469,6 +490,24 @@ const listNotes = async ( return result } +/** Lists non-.md files (attachments — images, canvases, PDFs, …) under the + * vault root. Same walk and filters as listNotes; moveNote resolves asset + * links inside a moved note against the result. */ +const listAssets = async ( + params: { vaultPath: string }, + logger: Logger, +): Promise => { + const paths = await listVaultFilePaths( + { + vaultPath: params.vaultPath, + keepFileName: (fileName) => !fileName.endsWith(".md"), + }, + logger, + ) + logger.info("listed assets", { count: paths.length }) + return paths +} + export const vaultFs = { readNote, readNoteOutline, @@ -478,4 +517,5 @@ export const vaultFs = { updateProperties, deleteNote, listNotes, + listAssets, } From ab6d1baf37fa371996b5f0dd467ea74373f51b6f Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:26:04 -0400 Subject: [PATCH 02/11] style: extract multi-clause conditional to named boolean in rewriteTarget Co-Authored-By: Claude Fable 5 --- src/vault-mcp/vault-operations/note-mover.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/vault-mcp/vault-operations/note-mover.ts b/src/vault-mcp/vault-operations/note-mover.ts index d9d6794a..1a9e9f68 100644 --- a/src/vault-mcp/vault-operations/note-mover.ts +++ b/src/vault-mcp/vault-operations/note-mover.ts @@ -216,11 +216,10 @@ const rewriteTarget = ( // extension (markdown links always keep theirs; a wikilink to a note never // carries ".md"; a stem-form asset link stays extensionless). const resolvedExtension = posix.extname(desiredTarget) - const writtenExtension = + const originalCarriedExtension = (targetKind === "asset" || grammar === "markdown") && originalExtension === resolvedExtension - ? resolvedExtension - : "" + const writtenExtension = originalCarriedExtension ? resolvedExtension : "" return buildReplacementTarget({ form: classifyLinkForm({ From c50688fc8b396c5c07fd714f9f398da2b10ae237 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:34:38 -0400 Subject: [PATCH 03/11] test: fix duplicate listAssets test body and stale comment in note-mover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "skips hidden files and directories" test had an identical body to the preceding test — added a visible non-.md file so it independently proves hidden-path filtering. Updated a pre-PR comment in note-mover that still referenced the old "asset links are never rewritten" behavior. Co-Authored-By: Claude Fable 5 --- .../vault-operations/__tests__/note-mover.test.ts | 7 +++---- .../vault-operations/__tests__/vault-filesystem.test.ts | 9 ++++++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/vault-mcp/vault-operations/__tests__/note-mover.test.ts b/src/vault-mcp/vault-operations/__tests__/note-mover.test.ts index 1412f794..45347cd3 100644 --- a/src/vault-mcp/vault-operations/__tests__/note-mover.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/note-mover.test.ts @@ -298,10 +298,9 @@ describe("moveNote — link rewriting forms", () => { }) // The relative note link is rewritten to keep resolving from the new - // folder; the relative asset link passes through byte-identical even - // though its resolution changed — moveNote resolves targets against note - // paths only, so asset links are never rewritten (known Obsidian-parity - // gap, tracked as its own card). + // folder; the relative asset link stays unchanged because no asset file + // exists in this fixture — the link is unresolved, so moveNote leaves + // it alone. expect(await readNote("C/Deep/Draft.md")).toBe( "![img](../assets/photo.png) and [sib](../../A/Sibling.md).\n", ) diff --git a/src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts b/src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts index 19236542..1da71e40 100644 --- a/src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts +++ b/src/vault-mcp/vault-operations/__tests__/vault-filesystem.test.ts @@ -824,8 +824,15 @@ describe("listAssets", () => { }) it("skips hidden files and directories", async () => { + await writeFile(join(vault, "readme.txt"), "visible", "utf8") const files = await listAssets({ vaultPath: vault }, logger) - expect(files).toEqual(["assets/board.canvas", "assets/photo.png"]) + // readme.txt is listed; .DS_Store and .obsidian/plugin.js (from + // beforeEach) are excluded by the hidden-segment filter. + expect(files).toEqual([ + "assets/board.canvas", + "assets/photo.png", + "readme.txt", + ]) }) it("includes a symlinked asset in the listing", async () => { From 6157489fec7afe236c98c719182651782b383c04 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:46:13 -0400 Subject: [PATCH 04/11] docs(comments): clarify null fallback in resolveAsset and non-moved-target handling in rewriteTarget Co-Authored-By: Claude Fable 5 --- src/vault-mcp/obsidian-markdown/links.ts | 3 ++- src/vault-mcp/vault-operations/note-mover.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/vault-mcp/obsidian-markdown/links.ts b/src/vault-mcp/obsidian-markdown/links.ts index ee6d8ab9..58cfd2d8 100644 --- a/src/vault-mcp/obsidian-markdown/links.ts +++ b/src/vault-mcp/obsidian-markdown/links.ts @@ -387,7 +387,8 @@ const resolveAsset = (params: { } // A target with folder segments keeps them in the match (suffix on the - // stem); a bare name matches on the filename stem only. + // stem); a bare name matches on the filename stem only. Either way this is + // the last tier: shortestOf returns null on no match — the unresolved case. if (target.includes("/")) { return shortestOf( allAssetPaths.filter((assetPath) => diff --git a/src/vault-mcp/vault-operations/note-mover.ts b/src/vault-mcp/vault-operations/note-mover.ts index 1a9e9f68..6626983c 100644 --- a/src/vault-mcp/vault-operations/note-mover.ts +++ b/src/vault-mcp/vault-operations/note-mover.ts @@ -186,7 +186,9 @@ const rewriteTarget = ( }) if (resolvedBefore === null) return null - // Follow the moved note to its new location; leave other targets as-is. + // Follow the moved note to its new location; every other target (an + // unmoved note, or an asset — assets never move) keeps its resolved path, + // and the rewrite below only re-expresses how the link reaches it. const desiredTarget = targetKind === "note" && resolvedBefore === context.oldTargetPath ? context.newTargetPath From 5dfaaedff2d283a2051272c83f15f6fdaa3f6034 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:47:43 -0400 Subject: [PATCH 05/11] style: truthy check over empty-string comparison in buildReplacementTarget Co-Authored-By: Claude Fable 5 --- src/vault-mcp/vault-operations/note-mover.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/vault-mcp/vault-operations/note-mover.ts b/src/vault-mcp/vault-operations/note-mover.ts index 6626983c..8d748c80 100644 --- a/src/vault-mcp/vault-operations/note-mover.ts +++ b/src/vault-mcp/vault-operations/note-mover.ts @@ -135,10 +135,9 @@ const buildReplacementTarget = (params: { writtenExtension, resolveCandidate, } = params - const absoluteForm = - writtenExtension === "" - ? links.stripExtension(desiredTarget) - : desiredTarget + const absoluteForm = writtenExtension + ? desiredTarget + : links.stripExtension(desiredTarget) const resolvesToDesired = (candidate: string): boolean => resolveCandidate(candidate) === desiredTarget From 89461417bfbe270af00f63f690fa4805c72a66c8 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:52:39 -0400 Subject: [PATCH 06/11] refactor(vault-fs): replace keepFileName predicate with fileKind discriminator The listVaultFilePaths predicate had exactly two instantiations (.md and not-.md), duplicating the note/asset boundary as hand-inverted lambdas at each call site. A closed fileKind union names the domain distinction directly and keeps the .md test in one place. Co-Authored-By: Claude Fable 5 --- .../vault-operations/vault-filesystem.ts | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/vault-mcp/vault-operations/vault-filesystem.ts b/src/vault-mcp/vault-operations/vault-filesystem.ts index dfb3d0a5..0434dc47 100644 --- a/src/vault-mcp/vault-operations/vault-filesystem.ts +++ b/src/vault-mcp/vault-operations/vault-filesystem.ts @@ -423,16 +423,18 @@ const deleteNote = async ( } /** Walks the vault (or a folder within it) and returns the sorted - * vault-relative paths of every file whose name passes keepFileName. - * Symlinks may point outside the vault (e.g. ARCHITECTURE.md → - * ~/Code/repo/ARCHITECTURE.md) — Obsidian supports this natively, so we - * follow suit. Only broken symlinks and non-file targets are excluded. - * Hidden segments (any path part starting with ".") are skipped. */ + * vault-relative paths of every file of the requested kind — "note" + * (.md files) or "asset" (everything else). The .md extension is the + * single definition of that boundary. Symlinks may point outside the + * vault (e.g. ARCHITECTURE.md → ~/Code/repo/ARCHITECTURE.md) — Obsidian + * supports this natively, so we follow suit. Only broken symlinks and + * non-file targets are excluded. Hidden segments (any path part starting + * with ".") are skipped. */ const listVaultFilePaths = async ( params: { vaultPath: string folder?: string | undefined - keepFileName: (fileName: string) => boolean + fileKind: "note" | "asset" }, logger: Logger, ): Promise => { @@ -451,11 +453,11 @@ const listVaultFilePaths = async ( }) return entries - .filter( - (entry) => - (entry.isFile() || entry.isSymbolicLink()) && - params.keepFileName(entry.name), - ) + .filter((entry) => { + const isNoteFile = entry.name.endsWith(".md") + const matchesKind = params.fileKind === "note" ? isNoteFile : !isNoteFile + return (entry.isFile() || entry.isSymbolicLink()) && matchesKind + }) .map((entry) => relative(normalizedVault, join(entry.parentPath, entry.name)), ) @@ -479,7 +481,7 @@ const listNotes = async ( { vaultPath: params.vaultPath, folder: params.folder, - keepFileName: (fileName) => fileName.endsWith(".md"), + fileKind: "note", }, logger, ) @@ -500,7 +502,7 @@ const listAssets = async ( const paths = await listVaultFilePaths( { vaultPath: params.vaultPath, - keepFileName: (fileName) => !fileName.endsWith(".md"), + fileKind: "asset", }, logger, ) From 485d3d2f835da5be4604bd5746d4dc0b6e6aae60 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:57:27 -0400 Subject: [PATCH 07/11] docs(comments): restore symlink rationale at the filterValidSymlinks call site The inline comment was lifted into the listVaultFilePaths docstring when the walk was extracted; move it back beside the call it explains and keep the docstring to the outward contract. Co-Authored-By: Claude Fable 5 --- src/vault-mcp/vault-operations/vault-filesystem.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/vault-mcp/vault-operations/vault-filesystem.ts b/src/vault-mcp/vault-operations/vault-filesystem.ts index 0434dc47..186de99c 100644 --- a/src/vault-mcp/vault-operations/vault-filesystem.ts +++ b/src/vault-mcp/vault-operations/vault-filesystem.ts @@ -425,11 +425,8 @@ const deleteNote = async ( /** Walks the vault (or a folder within it) and returns the sorted * vault-relative paths of every file of the requested kind — "note" * (.md files) or "asset" (everything else). The .md extension is the - * single definition of that boundary. Symlinks may point outside the - * vault (e.g. ARCHITECTURE.md → ~/Code/repo/ARCHITECTURE.md) — Obsidian - * supports this natively, so we follow suit. Only broken symlinks and - * non-file targets are excluded. Hidden segments (any path part starting - * with ".") are skipped. */ + * single definition of that boundary. Follows valid symlinks; hidden + * segments (any path part starting with ".") are skipped. */ const listVaultFilePaths = async ( params: { vaultPath: string @@ -446,6 +443,9 @@ const listVaultFilePaths = async ( const normalizedVault = resolve(params.vaultPath) + // Symlinks may point outside the vault (e.g. ARCHITECTURE.md → + // ~/Code/repo/ARCHITECTURE.md) — Obsidian supports this natively, so we + // follow suit. Only broken symlinks and non-file targets are excluded. const entries = await filterValidSymlinks({ entries: allEntries, normalizedRoot: normalizedVault, From d373c3806be54c08b3e42fcbb5ee5c4a7bd6ac1c Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:01:33 -0400 Subject: [PATCH 08/11] style: name the listVaultFilePaths pipeline stages Co-Authored-By: Claude Fable 5 --- .../vault-operations/vault-filesystem.ts | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/vault-mcp/vault-operations/vault-filesystem.ts b/src/vault-mcp/vault-operations/vault-filesystem.ts index 186de99c..2419cf9b 100644 --- a/src/vault-mcp/vault-operations/vault-filesystem.ts +++ b/src/vault-mcp/vault-operations/vault-filesystem.ts @@ -452,20 +452,19 @@ const listVaultFilePaths = async ( logger, }) - return entries - .filter((entry) => { - const isNoteFile = entry.name.endsWith(".md") - const matchesKind = params.fileKind === "note" ? isNoteFile : !isNoteFile - return (entry.isFile() || entry.isSymbolicLink()) && matchesKind - }) - .map((entry) => - relative(normalizedVault, join(entry.parentPath, entry.name)), - ) - .filter( - (relativePath) => - !relativePath.split("/").some((segment) => segment.startsWith(".")), - ) - .sort() + const kindMatchingEntries = entries.filter((entry) => { + const isNoteFile = entry.name.endsWith(".md") + const matchesKind = params.fileKind === "note" ? isNoteFile : !isNoteFile + return (entry.isFile() || entry.isSymbolicLink()) && matchesKind + }) + const relativePaths = kindMatchingEntries.map((entry) => + relative(normalizedVault, join(entry.parentPath, entry.name)), + ) + const visiblePaths = relativePaths.filter( + (relativePath) => + !relativePath.split("/").some((segment) => segment.startsWith(".")), + ) + return visiblePaths.sort() } /** Lists .md files under a folder (or vault root). Supports glob filtering. */ From 12b8f89fa042f2aec95763261961751a5ad0b6ba Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:10:08 -0400 Subject: [PATCH 09/11] style: rename resolveCandidate param to resolveFromNewSource The resolver's vantage point (post-move vault, new source location) is the invariant buildReplacementTarget depends on; carry it in the param name and use property shorthand at the call site. Co-Authored-By: Claude Fable 5 --- src/vault-mcp/vault-operations/note-mover.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/vault-mcp/vault-operations/note-mover.ts b/src/vault-mcp/vault-operations/note-mover.ts index 8d748c80..b26bfbb9 100644 --- a/src/vault-mcp/vault-operations/note-mover.ts +++ b/src/vault-mcp/vault-operations/note-mover.ts @@ -126,21 +126,21 @@ const buildReplacementTarget = (params: { desiredTarget: string newSourcePath: string writtenExtension: string - resolveCandidate: (candidate: string) => string | null + resolveFromNewSource: (candidate: string) => string | null }): string => { const { form, desiredTarget, newSourcePath, writtenExtension, - resolveCandidate, + resolveFromNewSource, } = params const absoluteForm = writtenExtension ? desiredTarget : links.stripExtension(desiredTarget) const resolvesToDesired = (candidate: string): boolean => - resolveCandidate(candidate) === desiredTarget + resolveFromNewSource(candidate) === desiredTarget if (form === "basename") { const basename = posix.basename(absoluteForm) @@ -231,7 +231,7 @@ const rewriteTarget = ( desiredTarget, newSourcePath: context.newSourcePath, writtenExtension, - resolveCandidate: resolveFromNewSource, + resolveFromNewSource, }) } From 3e209f235caaa61460d593db13d98c1cf3fb3da1 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:14:56 -0400 Subject: [PATCH 10/11] refactor(move-note): pass keepExtension boolean instead of extension-or-empty string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit writtenExtension was only ever consulted for truthiness — the extension characters were never written from it (desiredTarget already carries them). Replace the empty-string sentinel with the boolean it encoded. Co-Authored-By: Claude Fable 5 --- src/vault-mcp/vault-operations/note-mover.ts | 22 +++++++++----------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/vault-mcp/vault-operations/note-mover.ts b/src/vault-mcp/vault-operations/note-mover.ts index b26bfbb9..6f989d09 100644 --- a/src/vault-mcp/vault-operations/note-mover.ts +++ b/src/vault-mcp/vault-operations/note-mover.ts @@ -117,25 +117,24 @@ const classifyLinkForm = (params: { /** Builds a replacement target that resolves to desiredTarget from the new source * location, keeping the original link form. Falls back to vault-absolute if the - * shorter form would resolve elsewhere after the move. writtenExtension is the - * extension the replacement is written with: the resolved file's extension - * when the original link carried it, "" when the original used a - * stem/extensionless form (the extension is stripped back off). */ + * shorter form would resolve elsewhere after the move. keepExtension controls + * the extension state: true keeps desiredTarget's extension (the original + * link carried it), false strips it back to the stem/extensionless form. */ const buildReplacementTarget = (params: { form: LinkForm desiredTarget: string newSourcePath: string - writtenExtension: string + keepExtension: boolean resolveFromNewSource: (candidate: string) => string | null }): string => { const { form, desiredTarget, newSourcePath, - writtenExtension, + keepExtension, resolveFromNewSource, } = params - const absoluteForm = writtenExtension + const absoluteForm = keepExtension ? desiredTarget : links.stripExtension(desiredTarget) @@ -212,15 +211,14 @@ const rewriteTarget = ( const resolvedAfter = resolveFromNewSource(rawTarget) if (resolvedAfter === desiredTarget) return null - // The replacement keeps the original link's extension state: an extension - // is written only when the original carried the resolved file's real + // The replacement keeps the original link's extension state: the extension + // is kept only when the original carried the resolved file's real // extension (markdown links always keep theirs; a wikilink to a note never // carries ".md"; a stem-form asset link stays extensionless). const resolvedExtension = posix.extname(desiredTarget) - const originalCarriedExtension = + const keepExtension = (targetKind === "asset" || grammar === "markdown") && originalExtension === resolvedExtension - const writtenExtension = originalCarriedExtension ? resolvedExtension : "" return buildReplacementTarget({ form: classifyLinkForm({ @@ -230,7 +228,7 @@ const rewriteTarget = ( }), desiredTarget, newSourcePath: context.newSourcePath, - writtenExtension, + keepExtension, resolveFromNewSource, }) } From 3152c59f00ce34fdae4a3edfe36c8edbf5fd9062 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:58:18 -0400 Subject: [PATCH 11/11] fix(links): decode the full markdown link target before splitting off its extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit splitMarkdownLink decoded only the path group, leaving the extension group raw — so percent-encoding that hides the dot ([alt](photo%2Epng)) parsed as extensionless, and encoding inside the extension ([alt](photo.p%6Eg)) never resolved, while extractFromBody and Obsidian both decode the complete target. Decode the reconstructed target once, then derive path/extension from the decoded result via stripExtension so the split semantics stay in one place. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe --- .../obsidian-markdown/__tests__/links.test.ts | 22 ++++++++++++++++ src/vault-mcp/obsidian-markdown/links.ts | 26 ++++++++++++------- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/vault-mcp/obsidian-markdown/__tests__/links.test.ts b/src/vault-mcp/obsidian-markdown/__tests__/links.test.ts index 9ac6abd2..fb64b37c 100644 --- a/src/vault-mcp/obsidian-markdown/__tests__/links.test.ts +++ b/src/vault-mcp/obsidian-markdown/__tests__/links.test.ts @@ -231,6 +231,28 @@ describe("splitMarkdownLink", () => { closeParen: ")", }, }, + { + name: "recognizes an extension hidden behind a percent-encoded dot", + input: "[alt](photo%2Epng)", + expected: { + prefix: "[alt](", + path: "photo", + extension: ".png", + heading: "", + closeParen: ")", + }, + }, + { + name: "decodes percent-encoding inside the extension", + input: "[alt](photo.p%6Eg)", + expected: { + prefix: "[alt](", + path: "photo", + extension: ".png", + heading: "", + closeParen: ")", + }, + }, ] it.each(scenarios)("$name", ({ input, expected }) => { diff --git a/src/vault-mcp/obsidian-markdown/links.ts b/src/vault-mcp/obsidian-markdown/links.ts index 58cfd2d8..c1830d31 100644 --- a/src/vault-mcp/obsidian-markdown/links.ts +++ b/src/vault-mcp/obsidian-markdown/links.ts @@ -99,10 +99,11 @@ type WikilinkParts = { alias: string } -/** The structural parts of a markdown link [text](path.ext#heading). path is the - * DECODED target without its extension; extension keeps its leading dot ("" for - * an extensionless target); prefix/closeParen/heading are verbatim literals for - * lossless reconstruction. */ +/** The structural parts of a markdown link [text](path.ext#heading). path and + * extension are both DECODED — path is the target without its extension; + * extension keeps its leading dot ("" for an extensionless target); + * prefix/closeParen/heading are verbatim literals for lossless + * reconstruction. */ type MarkdownLinkParts = { prefix: string path: string @@ -158,16 +159,19 @@ const splitWikilink = (linkText: string): WikilinkParts | null => { return { embed, target, heading, alias } } -/** Splits a matched markdown link into its parts (path decoded, extension +/** Splits a matched markdown link into its parts (target decoded, extension * captured separately), or null when the text is not a well-formed markdown * link. Any vault target parses — notes, assets, and extensionless targets — - * so moveNote can rewrite all of them. */ + * so moveNote can rewrite all of them. The full target is decoded before the + * path/extension split: percent-encoding can hide the dot (photo%2Epng) or + * span the extension (photo.p%6Eg), so a split on the raw text would disagree + * with how extraction and Obsidian read the same link. */ const splitMarkdownLink = (linkText: string): MarkdownLinkParts | null => { const parts = MD_LINK_PARTS.exec(linkText) if (!parts) return null const prefix = parts[1] const encodedPath = parts[2] - const extension = parts[3] ?? "" + const encodedExtension = parts[3] ?? "" const heading = parts[4] ?? "" const closeParen = parts[5] const hasRequiredGroups = @@ -175,10 +179,14 @@ const splitMarkdownLink = (linkText: string): MarkdownLinkParts | null => { encodedPath !== undefined && closeParen !== undefined if (!hasRequiredGroups) return null + const decodedTarget = safeDecodeURIComponent( + `${encodedPath}${encodedExtension}`, + ) + const path = stripExtension(decodedTarget) return { prefix, - path: safeDecodeURIComponent(encodedPath), - extension, + path, + extension: decodedTarget.slice(path.length), heading, closeParen, }