From c4d1f12b2e9bffd627f1692ae94090e47f9872c3 Mon Sep 17 00:00:00 2001 From: Yuri Karamian Date: Fri, 10 Jul 2026 11:18:02 +0200 Subject: [PATCH] fix: use vault-relative paths in wikilinks for cross-device sync --- src-tauri/src/commands.rs | 10 ++- src/lib/components/Editor.svelte | 129 +++++++++++++++++++++++-------- 2 files changed, 105 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7999b17..d749f9c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -473,7 +473,6 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result) -> Result 1 ? parts.slice(0, -1).join('/') + '/' : ''; } @@ -2201,9 +2242,9 @@ } function wikiLinkRelPath(entry: NoteTitleEntry): string | null { - const vaultRoot = $appConfig?.active_vault; - if (!vaultRoot || !entry.path || !entry.path.startsWith(vaultRoot + '/')) return null; - return entry.path.slice(vaultRoot.length + 1).replace(/\.md$/, ''); + if (!entry.path) return null; + // entry.path is vault-relative (e.g. "folder/note.md"); strip .md for the ref. + return entry.path.replace(/\\/g, '/').replace(/\.md$/, ''); } // Scan the document for wiki-link marks whose visible text has been changed by @@ -2234,7 +2275,8 @@ for (const item of toRename) { try { - const newPath = await renameNote(item.mark.attrs.path, item.newTitle); + const absPath = resolveNotePath(item.mark.attrs.path); + const newPath = await renameNote(absPath, item.newTitle); // Update the mark attrs in the editor (title + path) so the next save // serialises [[NewTitle]] and doesn't re-trigger the rename check if (editor) { @@ -2314,6 +2356,21 @@ }; }, parseHTML() { + // Normalize legacy absolute paths in data-path to vault-relative. + // Older Windows builds wrote absolute paths (C:\Users\...) into wikilink + // data-path attributes, which break when the vault is synced to another device. + const normalizePath = (rawPath: string | null): string | null => { + if (!rawPath) return null; + const vaultRoot = $appConfig?.active_vault; + let norm = rawPath.replace(/\\/g, '/'); + if (vaultRoot) { + const root = vaultRoot.replace(/\\/g, '/'); + if (norm.startsWith(root + '/')) { + norm = norm.slice(root.length + 1); + } + } + return norm; + }; return [ { tag: 'span[data-wiki-link]', @@ -2322,7 +2379,7 @@ const text = el.textContent || ''; return { title, - path: el.getAttribute('data-path') || null, + path: normalizePath(el.getAttribute('data-path')), // Detect alias from the HTML: if the visible text differs from the // stored title the link was serialised as [[ref|display]] aliased: el.getAttribute('data-aliased') === '1' || (!!title && text !== title), @@ -2336,7 +2393,7 @@ const text = el.textContent || ''; return { title, - path: el.getAttribute('data-path') || null, + path: normalizePath(el.getAttribute('data-path')), aliased: el.getAttribute('data-aliased') === '1' || (!!title && text !== title), }; }, @@ -2533,14 +2590,16 @@ } async function navigateToWikiLink(path: string, title: string, clickEvent?: MouseEvent) { + // Normalize legacy absolute paths in data-path to vault-relative (issue: cross-device sync) + path = normalizeWikiPath(path); // title may contain #heading or ^block anchors - strip for note lookup const noteTitle = title.replace(/#.*$/, '').replace(/\^.*$/, '').trim(); if (!path) { // Try path-based resolution first (for disambiguated refs like "folder/note") - const vaultRoot = $appConfig?.active_vault; - if (noteTitle.includes('/') && vaultRoot) { - const fullPath = vaultRoot + '/' + noteTitle + '.md'; - const pathMatch = wikiLinkTitlesCache.find(e => e.path === fullPath); + if (noteTitle.includes('/')) { + // entry.path is vault-relative (e.g. "folder/note.md"); match against that + const relPath = noteTitle + '.md'; + const pathMatch = wikiLinkTitlesCache.find(e => e.path === relPath); if (pathMatch) { path = pathMatch.path; } else { @@ -2575,7 +2634,7 @@ // Create the note (use clean title, not the anchor ref) const cleanTitle = noteTitle.includes('/') ? noteTitle.split('/').pop()! : noteTitle; const notebookRel = $activeNotePath - ? $activeNotePath.substring(($appConfig?.active_vault?.length ?? 0) + 1).split('/').slice(0, -1).join('/') + ? $activeNotePath.replace(/\\/g, '/').replace(($appConfig?.active_vault ?? '').replace(/\\/g, '/') + '/', '').split('/').slice(0, -1).join('/') : null; try { // Save the current note before navigating away @@ -2596,9 +2655,10 @@ } } try { - const content = await readNote(path); + const absPath = resolveNotePath(path); + const content = await readNote(absPath); $activeNote = { ...content, content: content.content }; - $activeNotePath = path; + $activeNotePath = absPath; } catch (e) { // Note at path no longer exists (deleted/moved). Refresh cache and // retry as unresolved so the user can recreate it from the link. @@ -2610,9 +2670,10 @@ async function navigateToWikiLinkDirect(entry: NoteTitleEntry) { wikiLinkNavDisambig = null; try { - const content = await readNote(entry.path); + const absPath = resolveNotePath(entry.path); + const content = await readNote(absPath); $activeNote = { ...content, content: content.content }; - $activeNotePath = entry.path; + $activeNotePath = absPath; } catch (e) { console.error('Failed to navigate to wiki-link:', e); } @@ -3791,12 +3852,12 @@ const display = (pipeIdx >= 0 ? raw.slice(pipeIdx + 1) : noteRef).trim(); // Strip #heading and ^block anchors for title matching const titleForLookup = noteRef.replace(/#.*$/, '').replace(/\^.*$/, '').trim(); - // Try to resolve: first by vault-relative path (for disambiguated links), then by title - const vaultRoot = $appConfig?.active_vault ?? ''; + // Try to resolve: first by vault-relative path (for disambiguated links), then by title. + // entry.path is vault-relative (e.g. "folder/note.md"); match against that. let match: NoteTitleEntry | undefined; - if (titleForLookup.includes('/') && vaultRoot) { - const fullPath = vaultRoot + '/' + titleForLookup + '.md'; - match = wikiLinkTitlesCache.find(e => e.path === fullPath); + if (titleForLookup.includes('/')) { + const relPath = titleForLookup + '.md'; + match = wikiLinkTitlesCache.find(e => e.path === relPath); } if (!match) { // Fallback: resolve by title (use the last segment if path-based) @@ -4413,15 +4474,19 @@ } function linkModalSelectNote(entry: NoteTitleEntry) { - // Build a relative .md path from the selected note and confirm immediately + // Build a relative .md path from the selected note and confirm immediately. + // entry.path is vault-relative (e.g. "folder/note.md"); currentNote is absolute. const vaultRoot = $appConfig?.active_vault; const currentNote = $activeNotePath; if (vaultRoot && currentNote) { - const noteDir = currentNote.substring(0, currentNote.lastIndexOf('/')); - const targetRel = entry.path.startsWith(vaultRoot) ? entry.path.substring(vaultRoot.length + 1) : entry.path; - const currentRel = noteDir.startsWith(vaultRoot) ? noteDir.substring(vaultRoot.length + 1) : noteDir; - const targetParts = targetRel.split('/'); - const currentParts = currentRel ? currentRel.split('/') : []; + // Strip vault root from currentNote to get its relative path + const normCurrent = currentNote.replace(/\\/g, '/'); + const normRoot = vaultRoot.replace(/\\/g, '/'); + const currentRel = normCurrent.startsWith(normRoot + '/') ? normCurrent.slice(normRoot.length + 1) : normCurrent; + const noteDir = currentRel.substring(0, currentRel.lastIndexOf('/')); + // entry.path is already relative + const targetParts = entry.path.split('/'); + const currentParts = noteDir ? noteDir.split('/') : []; let common = 0; while (common < targetParts.length && common < currentParts.length && targetParts[common] === currentParts[common]) common++; const ups = currentParts.length - common;