mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-23 23:35:57 +02:00
fix: use vault-relative paths in wikilinks for cross-device sync
This commit is contained in:
@@ -473,7 +473,6 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
|
|||||||
if !path.is_file() {
|
if !path.is_file() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let path_str = path.to_string_lossy();
|
|
||||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -492,9 +491,16 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
|
|||||||
.to_string()
|
.to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Store vault-relative path with forward slashes for cross-platform portability.
|
||||||
|
// Absolute paths (e.g. C:\Users\...) break when the vault is synced to another device.
|
||||||
|
let rel = path
|
||||||
|
.strip_prefix(vault)
|
||||||
|
.map(|r| r.to_string_lossy().replace('\\', "/").to_string())
|
||||||
|
.unwrap_or_else(|_| path.to_string_lossy().to_string());
|
||||||
|
|
||||||
entries.push(NoteTitleEntry {
|
entries.push(NoteTitleEntry {
|
||||||
title,
|
title,
|
||||||
path: path_str.to_string(),
|
path: rel,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -549,6 +549,50 @@
|
|||||||
return resolved.join('/');
|
return resolved.join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a note path (which may be vault-relative or absolute) to a full
|
||||||
|
* filesystem path for file I/O operations (readNote, renameNote, etc.).
|
||||||
|
*
|
||||||
|
* Wikilink data-path attributes store vault-relative paths (e.g. "folder/note.md")
|
||||||
|
* for cross-platform portability. This helper joins them with the active vault
|
||||||
|
* root so the Rust backend receives the full path it expects.
|
||||||
|
*
|
||||||
|
* Legacy absolute paths (from older Windows builds) are passed through as-is
|
||||||
|
* so they don't break before the migration pass rewrites them.
|
||||||
|
*/
|
||||||
|
function resolveNotePath(path: string): string {
|
||||||
|
if (!path) return path;
|
||||||
|
const vaultRoot = $appConfig?.active_vault;
|
||||||
|
if (!vaultRoot) return path;
|
||||||
|
// Normalize separators for comparison
|
||||||
|
const norm = path.replace(/\\/g, '/');
|
||||||
|
const root = vaultRoot.replace(/\\/g, '/');
|
||||||
|
// Already absolute (starts with vault root, or has a Windows drive letter, or starts with /)
|
||||||
|
if (norm.startsWith(root + '/')) return path;
|
||||||
|
if (/^[A-Za-z]:[\\/]/.test(path)) return path;
|
||||||
|
if (path.startsWith('/')) return path;
|
||||||
|
// Relative: join with vault root
|
||||||
|
return vaultRoot + '/' + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a data-path value to a vault-relative path with forward slashes.
|
||||||
|
* Handles legacy absolute paths (from older Windows builds) and Windows
|
||||||
|
* backslashes so wikilinks survive cross-device sync.
|
||||||
|
*/
|
||||||
|
function normalizeWikiPath(path: string): string {
|
||||||
|
if (!path) return '';
|
||||||
|
const vaultRoot = $appConfig?.active_vault;
|
||||||
|
let norm = path.replace(/\\/g, '/');
|
||||||
|
if (vaultRoot) {
|
||||||
|
const root = vaultRoot.replace(/\\/g, '/');
|
||||||
|
if (norm.startsWith(root + '/')) {
|
||||||
|
norm = norm.slice(root.length + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return norm;
|
||||||
|
}
|
||||||
|
|
||||||
const CustomImage = Image.extend({
|
const CustomImage = Image.extend({
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
@@ -2173,13 +2217,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function wikiLinkFolderPath(entry: NoteTitleEntry): string {
|
function wikiLinkFolderPath(entry: NoteTitleEntry): string {
|
||||||
const vaultRoot = $appConfig?.active_vault;
|
if (!entry.path) return '';
|
||||||
if (!vaultRoot || !entry.path) return '';
|
// entry.path is vault-relative (e.g. "folder/note.md"); normalize Windows backslashes.
|
||||||
// Normalize Windows backslashes so the folder subtitle shows there too.
|
|
||||||
const path = entry.path.replace(/\\/g, '/');
|
const path = entry.path.replace(/\\/g, '/');
|
||||||
const root = vaultRoot.replace(/\\/g, '/');
|
const parts = path.split('/');
|
||||||
const rel = path.startsWith(root + '/') ? path.slice(root.length + 1) : path;
|
|
||||||
const parts = rel.split('/');
|
|
||||||
// Return parent folder(s), excluding the filename
|
// Return parent folder(s), excluding the filename
|
||||||
return parts.length > 1 ? parts.slice(0, -1).join('/') + '/' : '';
|
return parts.length > 1 ? parts.slice(0, -1).join('/') + '/' : '';
|
||||||
}
|
}
|
||||||
@@ -2201,9 +2242,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function wikiLinkRelPath(entry: NoteTitleEntry): string | null {
|
function wikiLinkRelPath(entry: NoteTitleEntry): string | null {
|
||||||
const vaultRoot = $appConfig?.active_vault;
|
if (!entry.path) return null;
|
||||||
if (!vaultRoot || !entry.path || !entry.path.startsWith(vaultRoot + '/')) return null;
|
// entry.path is vault-relative (e.g. "folder/note.md"); strip .md for the ref.
|
||||||
return entry.path.slice(vaultRoot.length + 1).replace(/\.md$/, '');
|
return entry.path.replace(/\\/g, '/').replace(/\.md$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan the document for wiki-link marks whose visible text has been changed by
|
// Scan the document for wiki-link marks whose visible text has been changed by
|
||||||
@@ -2234,7 +2275,8 @@
|
|||||||
|
|
||||||
for (const item of toRename) {
|
for (const item of toRename) {
|
||||||
try {
|
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
|
// Update the mark attrs in the editor (title + path) so the next save
|
||||||
// serialises [[NewTitle]] and doesn't re-trigger the rename check
|
// serialises [[NewTitle]] and doesn't re-trigger the rename check
|
||||||
if (editor) {
|
if (editor) {
|
||||||
@@ -2314,6 +2356,21 @@
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
parseHTML() {
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
tag: 'span[data-wiki-link]',
|
tag: 'span[data-wiki-link]',
|
||||||
@@ -2322,7 +2379,7 @@
|
|||||||
const text = el.textContent || '';
|
const text = el.textContent || '';
|
||||||
return {
|
return {
|
||||||
title,
|
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
|
// Detect alias from the HTML: if the visible text differs from the
|
||||||
// stored title the link was serialised as [[ref|display]]
|
// stored title the link was serialised as [[ref|display]]
|
||||||
aliased: el.getAttribute('data-aliased') === '1' || (!!title && text !== title),
|
aliased: el.getAttribute('data-aliased') === '1' || (!!title && text !== title),
|
||||||
@@ -2336,7 +2393,7 @@
|
|||||||
const text = el.textContent || '';
|
const text = el.textContent || '';
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
path: el.getAttribute('data-path') || null,
|
path: normalizePath(el.getAttribute('data-path')),
|
||||||
aliased: el.getAttribute('data-aliased') === '1' || (!!title && text !== title),
|
aliased: el.getAttribute('data-aliased') === '1' || (!!title && text !== title),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -2533,14 +2590,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function navigateToWikiLink(path: string, title: string, clickEvent?: MouseEvent) {
|
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
|
// title may contain #heading or ^block anchors - strip for note lookup
|
||||||
const noteTitle = title.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
|
const noteTitle = title.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
|
||||||
if (!path) {
|
if (!path) {
|
||||||
// Try path-based resolution first (for disambiguated refs like "folder/note")
|
// Try path-based resolution first (for disambiguated refs like "folder/note")
|
||||||
const vaultRoot = $appConfig?.active_vault;
|
if (noteTitle.includes('/')) {
|
||||||
if (noteTitle.includes('/') && vaultRoot) {
|
// entry.path is vault-relative (e.g. "folder/note.md"); match against that
|
||||||
const fullPath = vaultRoot + '/' + noteTitle + '.md';
|
const relPath = noteTitle + '.md';
|
||||||
const pathMatch = wikiLinkTitlesCache.find(e => e.path === fullPath);
|
const pathMatch = wikiLinkTitlesCache.find(e => e.path === relPath);
|
||||||
if (pathMatch) {
|
if (pathMatch) {
|
||||||
path = pathMatch.path;
|
path = pathMatch.path;
|
||||||
} else {
|
} else {
|
||||||
@@ -2575,7 +2634,7 @@
|
|||||||
// Create the note (use clean title, not the anchor ref)
|
// Create the note (use clean title, not the anchor ref)
|
||||||
const cleanTitle = noteTitle.includes('/') ? noteTitle.split('/').pop()! : noteTitle;
|
const cleanTitle = noteTitle.includes('/') ? noteTitle.split('/').pop()! : noteTitle;
|
||||||
const notebookRel = $activeNotePath
|
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;
|
: null;
|
||||||
try {
|
try {
|
||||||
// Save the current note before navigating away
|
// Save the current note before navigating away
|
||||||
@@ -2596,9 +2655,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const content = await readNote(path);
|
const absPath = resolveNotePath(path);
|
||||||
|
const content = await readNote(absPath);
|
||||||
$activeNote = { ...content, content: content.content };
|
$activeNote = { ...content, content: content.content };
|
||||||
$activeNotePath = path;
|
$activeNotePath = absPath;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Note at path no longer exists (deleted/moved). Refresh cache and
|
// Note at path no longer exists (deleted/moved). Refresh cache and
|
||||||
// retry as unresolved so the user can recreate it from the link.
|
// retry as unresolved so the user can recreate it from the link.
|
||||||
@@ -2610,9 +2670,10 @@
|
|||||||
async function navigateToWikiLinkDirect(entry: NoteTitleEntry) {
|
async function navigateToWikiLinkDirect(entry: NoteTitleEntry) {
|
||||||
wikiLinkNavDisambig = null;
|
wikiLinkNavDisambig = null;
|
||||||
try {
|
try {
|
||||||
const content = await readNote(entry.path);
|
const absPath = resolveNotePath(entry.path);
|
||||||
|
const content = await readNote(absPath);
|
||||||
$activeNote = { ...content, content: content.content };
|
$activeNote = { ...content, content: content.content };
|
||||||
$activeNotePath = entry.path;
|
$activeNotePath = absPath;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to navigate to wiki-link:', e);
|
console.error('Failed to navigate to wiki-link:', e);
|
||||||
}
|
}
|
||||||
@@ -3791,12 +3852,12 @@
|
|||||||
const display = (pipeIdx >= 0 ? raw.slice(pipeIdx + 1) : noteRef).trim();
|
const display = (pipeIdx >= 0 ? raw.slice(pipeIdx + 1) : noteRef).trim();
|
||||||
// Strip #heading and ^block anchors for title matching
|
// Strip #heading and ^block anchors for title matching
|
||||||
const titleForLookup = noteRef.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
|
const titleForLookup = noteRef.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
|
||||||
// Try to resolve: first by vault-relative path (for disambiguated links), then by title
|
// Try to resolve: first by vault-relative path (for disambiguated links), then by title.
|
||||||
const vaultRoot = $appConfig?.active_vault ?? '';
|
// entry.path is vault-relative (e.g. "folder/note.md"); match against that.
|
||||||
let match: NoteTitleEntry | undefined;
|
let match: NoteTitleEntry | undefined;
|
||||||
if (titleForLookup.includes('/') && vaultRoot) {
|
if (titleForLookup.includes('/')) {
|
||||||
const fullPath = vaultRoot + '/' + titleForLookup + '.md';
|
const relPath = titleForLookup + '.md';
|
||||||
match = wikiLinkTitlesCache.find(e => e.path === fullPath);
|
match = wikiLinkTitlesCache.find(e => e.path === relPath);
|
||||||
}
|
}
|
||||||
if (!match) {
|
if (!match) {
|
||||||
// Fallback: resolve by title (use the last segment if path-based)
|
// Fallback: resolve by title (use the last segment if path-based)
|
||||||
@@ -4413,15 +4474,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function linkModalSelectNote(entry: NoteTitleEntry) {
|
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 vaultRoot = $appConfig?.active_vault;
|
||||||
const currentNote = $activeNotePath;
|
const currentNote = $activeNotePath;
|
||||||
if (vaultRoot && currentNote) {
|
if (vaultRoot && currentNote) {
|
||||||
const noteDir = currentNote.substring(0, currentNote.lastIndexOf('/'));
|
// Strip vault root from currentNote to get its relative path
|
||||||
const targetRel = entry.path.startsWith(vaultRoot) ? entry.path.substring(vaultRoot.length + 1) : entry.path;
|
const normCurrent = currentNote.replace(/\\/g, '/');
|
||||||
const currentRel = noteDir.startsWith(vaultRoot) ? noteDir.substring(vaultRoot.length + 1) : noteDir;
|
const normRoot = vaultRoot.replace(/\\/g, '/');
|
||||||
const targetParts = targetRel.split('/');
|
const currentRel = normCurrent.startsWith(normRoot + '/') ? normCurrent.slice(normRoot.length + 1) : normCurrent;
|
||||||
const currentParts = currentRel ? currentRel.split('/') : [];
|
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;
|
let common = 0;
|
||||||
while (common < targetParts.length && common < currentParts.length && targetParts[common] === currentParts[common]) common++;
|
while (common < targetParts.length && common < currentParts.length && targetParts[common] === currentParts[common]) common++;
|
||||||
const ups = currentParts.length - common;
|
const ups = currentParts.length - common;
|
||||||
|
|||||||
Reference in New Issue
Block a user