fix(move-note): rewrite relative asset links inside a moved note#347
Conversation
moveNote resolved link targets only against .md note paths, so a relative-path link to an asset (![[../assets/photo.png]] or ) 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)
There was a problem hiding this comment.
Hey - I've left some high level feedback:
- The new asset resolution (
resolveAsset) and extension handling logic innote-mover.tshas grown quite complex; consider splitting note vs asset resolution and link-form classification into smaller, focused helpers to improve readability and maintainability. - Both
listAssetsandresolveAssetiterate over all non-markdown files for each move; for large vaults this may become expensive, so it could be worth introducing a lightweight in-memory index (e.g., by basename/stem) or reusing existing index data rather than repeatedly scanning arrays.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The new asset resolution (`resolveAsset`) and extension handling logic in `note-mover.ts` has grown quite complex; consider splitting note vs asset resolution and link-form classification into smaller, focused helpers to improve readability and maintainability.
- Both `listAssets` and `resolveAsset` iterate over all non-markdown files for each move; for large vaults this may become expensive, so it could be worth introducing a lightweight in-memory index (e.g., by basename/stem) or reusing existing index data rather than repeatedly scanning arrays.Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
…rget Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR Code Suggestions ✨Explore these optional code suggestions:
|
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 <noreply@anthropic.com>
…arget handling in rewriteTarget Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Responding to Sourcery's Overall Comments (review body, no inline threads): 1. Split note vs asset resolution / link-form classification into smaller helpers — already factored this way: asset resolution lives in 2. In-memory index instead of array scans for 🔍 ship-check · pr-monitor · claude-fable-5 |
…arget Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…riminator 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 <noreply@anthropic.com>
…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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…or-empty string 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 <noreply@anthropic.com>
|
@CodeRabbit review |
✅ Action performedReview finished.
|
📝 WalkthroughWalkthroughThe pull request adds vault-wide asset listing and resolution, preserves markdown link extensions, and updates note moves to rewrite relative asset links using the new asset paths. Tests cover parsing, resolution, filesystem listing, and moved-note link behavior. ChangesAsset-aware note moving
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/vault-mcp/obsidian-markdown/links.ts (1)
165-181: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winDecode the complete target before separating its extension.
Only the path group is decoded. A valid target such as
photo.p%6Egtherefore cannot resolvephoto.png, whilephoto%2Epngis incorrectly treated as extensionless during rewriting. Decode the reconstructed target once, then derivepathandextension.Proposed fix
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 = prefix !== undefined && encodedPath !== undefined && closeParen !== undefined if (!hasRequiredGroups) return null + const decodedTarget = safeDecodeURIComponent( + `${encodedPath}${encodedExtension}`, + ) + const extension = posix.extname(decodedTarget) return { prefix, - path: safeDecodeURIComponent(encodedPath), + path: + extension.length > 0 + ? decodedTarget.slice(0, -extension.length) + : decodedTarget, extension, heading, closeParen, }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/vault-mcp/obsidian-markdown/links.ts` around lines 165 - 181, Update splitMarkdownLink to decode the complete reconstructed target before separating its extension. Preserve the prefix and heading handling, but combine the encoded path with its extension, pass that target through safeDecodeURIComponent once, then derive the returned path and extension from the decoded result so encoded dots and extension characters are recognized correctly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@src/vault-mcp/obsidian-markdown/links.ts`:
- Around line 165-181: Update splitMarkdownLink to decode the complete
reconstructed target before separating its extension. Preserve the prefix and
heading handling, but combine the encoded path with its extension, pass that
target through safeDecodeURIComponent once, then derive the returned path and
extension from the decoded result so encoded dots and extension characters are
recognized correctly.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 3d7d2392-a792-4e30-957b-374c41059923
📒 Files selected for processing (9)
AGENTS.mdsrc/vault-mcp/mcp-core/tools/vault-crud-tools.tssrc/vault-mcp/obsidian-markdown/__tests__/links.test.tssrc/vault-mcp/obsidian-markdown/links.tssrc/vault-mcp/search/search-index.tssrc/vault-mcp/vault-operations/__tests__/note-mover.test.tssrc/vault-mcp/vault-operations/__tests__/vault-filesystem.test.tssrc/vault-mcp/vault-operations/note-mover.tssrc/vault-mcp/vault-operations/vault-filesystem.ts
… its extension 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGA157nZouoSUoYVX1WvFe
|
Responding to CodeRabbit's outside-diff finding (review body — decode the complete target before separating its extension in Valid — fixed in 3152c59. The path group was decoded while the extension group stayed raw, so 🔍 ship-check · pr-monitor · claude-fable-5 |
Summary
moveNoteresolved link targets only against.mdnote paths, so a relative-path link to an asset (![[../assets/photo.png]]or) inside a moved note was left untouched and broke — Obsidian rewrites these on move. The rewriter now resolves against non-md vault files too, matching Obsidian's on-move link updating.Follow-up to #336/#338, which fixed the indexing side
What changed
links.resolveAsset— pure, array-based twin of the indexer's SQLresolveNonMarkdownFile: same tiers (full-filename exact → relative → suffix, then stem exact → relative → suffix/basename), same family ordering, same shortest-then-lexicographic tiebreak. The rewriter and the index can no longer disagree about where an asset link points.MD_LINK_PARTSgeneralized — captures any extension (or none) instead of anchoring on.md. Bonus fix in the same class: extensionless markdown links to notes ([text](Extensionless%20Note)) are now rewritten on move instead of silently breaking.rewriteTarget— note tier first (a target matching both a note and an asset resolves to the note, matching the indexer's precedence), then the asset tier. Replacements keep the original link's form (basename/absolute/relative) and extension state: stem-form wikilinks stay extensionless (![[../../boards/Trip Route]]), markdown links keep their original extension.vaultFs.listAssets— lists non-md files with the same walk/filters aslistNotesvia a sharedlistVaultFilePathshelper; the move handler fetches both path lists from the filesystem (fresh — no index-lag dependency).stripExtensionconsolidated into thelinksnamespace and reused by the indexer — single home for the multi-dot stem semantics (photo.png.canvas→photo.png).Verification
resolveAsset/stripExtension, 5splitMarkdownLink(incl. updated.txtcontract), 4listAssets, 12 asset-rewrite move tests; all 59 pre-existing note-mover tests pass byte-identical (note behavior unchanged)..md-anchoring the parser fails the markdown-asset and extensionless tests — each for the intended reason.npm run lint0 errors (317 pre-existing warnings, none in changed files),npm run prettier:checkclean,npm run buildgreen.Test plan
New describe block
moveNote — asset link rewriting: relative wikilink + markdown rewrites, basename/vault-absolute left alone (with move-happened guards), backlink-source asset links byte-identical, stem-form preservation, multi-dot stem resolution, extensionless markdown rewrite, note/asset collision precedence, same-named-asset shadowing, frontmatter embed rewrite.Summary by CodeRabbit
New Features
Bug Fixes
Tests