Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions src/vault-mcp/mcp-core/tools/vault-crud-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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: {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
},
Expand Down
212 changes: 206 additions & 6 deletions src/vault-mcp/obsidian-markdown/__tests__/links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,16 +144,23 @@ 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",
input: "[t](a/b.md#sec)",
expected: {
prefix: "[t](",
path: "a/b",
extension: ".md",
heading: "#sec",
closeParen: ")",
},
Expand All @@ -164,6 +171,7 @@ describe("splitMarkdownLink", () => {
expected: {
prefix: "[t](",
path: "My Note",
extension: ".md",
heading: "",
closeParen: ")",
},
Expand All @@ -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: ")",
},
Expand All @@ -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 ──────────────────────────────────────────────
Expand Down Expand Up @@ -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()
})
})
Loading
Loading