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..fb64b37c 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,73 @@ 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: ")", + }, + }, + { + 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: ")", }, @@ -187,10 +262,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 +648,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..c1830d31 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,15 @@ 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 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 + extension: string heading: string closeParen: string } @@ -155,25 +159,34 @@ 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 (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. 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 heading = parts[3] ?? "" - const closeParen = parts[4] + const encodedExtension = parts[3] ?? "" + const heading = parts[4] ?? "" + const closeParen = parts[5] const hasRequiredGroups = prefix !== undefined && encodedPath !== undefined && closeParen !== undefined if (!hasRequiredGroups) return null + const decodedTarget = safeDecodeURIComponent( + `${encodedPath}${encodedExtension}`, + ) + const path = stripExtension(decodedTarget) return { prefix, - path: safeDecodeURIComponent(encodedPath), + path, + extension: decodedTarget.slice(path.length), heading, closeParen, } @@ -300,6 +313,104 @@ 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. Either way this is + // the last tier: shortestOf returns null on no match — the unresolved case. + 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 +432,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..45347cd3 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, }, @@ -297,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", ) @@ -597,6 +597,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 +1237,7 @@ describe("moveNote — concurrent write locking", () => { newPath: string backlinkSources: string[] allNotePaths: string[] + allAssetPaths?: string[] }): ReturnType => { const movePromise = noteMover.moveNote( { @@ -1025,6 +1247,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..1da71e40 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,52 @@ 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 () => { + await writeFile(join(vault, "readme.txt"), "visible", "utf8") + const files = await listAssets({ vaultPath: vault }, logger) + // 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 () => { + 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..6f989d09 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,29 @@ 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. 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 - allNotePathsAfter: string[] + keepExtension: boolean + resolveFromNewSource: (candidate: string) => string | null }): string => { - const { form, desiredTarget, newSourcePath, allNotePathsAfter } = params - const absoluteForm = withoutExtension(desiredTarget) + const { + form, + desiredTarget, + newSourcePath, + keepExtension, + resolveFromNewSource, + } = params + const absoluteForm = keepExtension + ? desiredTarget + : links.stripExtension(desiredTarget) const resolvesToDesired = (candidate: string): boolean => - links.resolve(candidate, allNotePathsAfter, newSourcePath) === desiredTarget + resolveFromNewSource(candidate) === desiredTarget if (form === "basename") { const basename = posix.basename(absoluteForm) @@ -130,32 +155,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. + // 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 = - 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: 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 keepExtension = + (targetKind === "asset" || grammar === "markdown") && + originalExtension === resolvedExtension + return buildReplacementTarget({ form: classifyLinkForm({ rawTarget, @@ -164,7 +228,8 @@ const rewriteTarget = ( }), desiredTarget, newSourcePath: context.newSourcePath, - allNotePathsAfter: context.allNotePathsAfter, + keepExtension, + resolveFromNewSource, }) } @@ -183,9 +248,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 +313,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 +482,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 +493,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 +591,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..2419cf9b 100644 --- a/src/vault-mcp/vault-operations/vault-filesystem.ts +++ b/src/vault-mcp/vault-operations/vault-filesystem.ts @@ -422,12 +422,16 @@ 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 of the requested kind — "note" + * (.md files) or "asset" (everything else). The .md extension is the + * single definition of that boundary. Follows valid symlinks; hidden + * segments (any path part starting with ".") are skipped. */ +const listVaultFilePaths = async ( params: { vaultPath: string folder?: string | undefined - glob?: string | undefined + fileKind: "note" | "asset" }, logger: Logger, ): Promise => { @@ -448,20 +452,38 @@ const listNotes = async ( logger, }) - const paths = entries - .filter( - (entry) => - (entry.isFile() || entry.isSymbolicLink()) && - entry.name.endsWith(".md"), - ) - .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. */ +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, + fileKind: "note", + }, + logger, + ) const isMatch = params.glob ? picomatch(params.glob) : undefined const result = isMatch ? paths.filter((notePath) => isMatch(notePath)) : paths @@ -469,6 +491,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, + fileKind: "asset", + }, + logger, + ) + logger.info("listed assets", { count: paths.length }) + return paths +} + export const vaultFs = { readNote, readNoteOutline, @@ -478,4 +518,5 @@ export const vaultFs = { updateProperties, deleteNote, listNotes, + listAssets, }