fix(paths): preserve cross-platform asset paths

This commit is contained in:
Yuri Karamian
2026-08-17 15:58:20 +02:00
parent c4a91b8f1c
commit b253ec4121
3 changed files with 90 additions and 125 deletions
+22 -98
View File
@@ -54,7 +54,7 @@
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists'; import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
import { clearFormatting } from '$lib/editor/clearFormatting'; import { clearFormatting } from '$lib/editor/clearFormatting';
import { serializeInlineMarkdown } from '$lib/editor/markdown'; import { serializeInlineMarkdown } from '$lib/editor/markdown';
import { relativePath, resolvePathFromFile } from '$lib/utils/paths'; import { assetSourceToMarkdown, assetUrlToLocalPath, normalizeLocalAssetPath, resolveVaultFilePath } from '$lib/utils/paths';
import GraphView from './GraphView.svelte'; import GraphView from './GraphView.svelte';
import TagSuggestInput from './TagSuggestInput.svelte'; import TagSuggestInput from './TagSuggestInput.svelte';
import ImageViewer from './ImageViewer.svelte'; import ImageViewer from './ImageViewer.svelte';
@@ -2974,13 +2974,11 @@
if (src.startsWith('http://') || src.startsWith('https://')) { if (src.startsWith('http://') || src.startsWith('https://')) {
return convertFileSrc(src, 'imgproxy'); return convertFileSrc(src, 'imgproxy');
} }
// Decode percent-encoding (%20 → space, etc.) for filesystem resolution // Decode URL encoding and undo the extra leading slash added to Windows drive paths.
let decoded = decodeURIComponent(src); let decoded = normalizeLocalAssetPath(decodeURIComponent(src));
// Fix multiple leading slashes (from broken saves) // Keep repairing legacy POSIX paths that were saved with duplicate leading slashes.
if (decoded.match(/^\/{2,}/)) { if (/^\/{2,}/.test(decoded)) decoded = decoded.replace(/^\/{2,}/, '/');
decoded = decoded.replace(/^\/{2,}/, '/'); if (decoded.startsWith('/') || /^[A-Za-z]:\//.test(decoded)) {
}
if (decoded.startsWith('/')) {
return convertFileSrc(normalizePath(decoded)); return convertFileSrc(normalizePath(decoded));
} }
// Paths containing .helixnotes/ are vault-root relative (our own attachments) // Paths containing .helixnotes/ are vault-root relative (our own attachments)
@@ -3925,42 +3923,7 @@
} }
function stripAssetSrc(src: string): string { function stripAssetSrc(src: string): string {
// blob: URLs are not persistable - they were temporary browser references return assetSourceToMarkdown(src, $activeNotePath, $appConfig?.active_vault ?? null);
if (src.startsWith('blob:')) return '';
// Convert imgproxy:// URLs back to original external URLs for saving
if (src.startsWith('imgproxy:') || src.startsWith('http://imgproxy.localhost') || src.startsWith('https://imgproxy.localhost')) {
try {
const url = new URL(src);
return decodeURIComponent(url.pathname.substring(1));
} catch {
return src;
}
}
// Convert asset:// URLs back to relative paths for saving
if (!src.startsWith('asset:') && !src.startsWith('http://asset.localhost') && !src.startsWith('https://asset.localhost')) return src;
let absPath = '';
try {
const url = new URL(src);
absPath = decodeURIComponent(url.pathname);
} catch {
return src;
}
// Clean up any leading double/triple slashes (URL parsing artifact)
absPath = absPath.replace(/^\/{2,}/, '/');
absPath = absPath.replace(/^\/([A-Za-z]:\/)/, '$1').replace(/\\/g, '/');
const notePath = $activeNotePath;
const vaultRoot = $appConfig?.active_vault?.replace(/\\/g, '/').replace(/\/$/, '');
if (vaultRoot && absPath.startsWith(vaultRoot + '/')) {
const vaultRelative = absPath.substring(vaultRoot.length + 1);
if (vaultRelative.startsWith('.helixnotes/')) return vaultRelative;
if (notePath) {
const normalizedNotePath = notePath.replace(/\\/g, '/');
const noteDir = normalizedNotePath.substring(0, normalizedNotePath.lastIndexOf('/'));
return relativePath(noteDir, absPath);
}
return vaultRelative;
}
return absPath;
} }
function htmlToMarkdown(html: string): string { function htmlToMarkdown(html: string): string {
@@ -4855,34 +4818,13 @@
} }
function getImageAbsPath(src: string): string { function getImageAbsPath(src: string): string {
// asset:// or http://asset.localhost → extract absolute path const assetPath = assetUrlToLocalPath(src);
if (src.startsWith('asset:') || src.startsWith('http://asset.localhost')) { if (assetPath !== null) return assetPath;
try { return resolveVaultFilePath(
const url = new URL(src); decodeURIComponent(src),
let absPath = decodeURIComponent(url.pathname); $activeNotePath,
absPath = absPath.replace(/^\/{2,}/, '/'); $appConfig?.active_vault ?? null,
return absPath; );
} catch { /* fall through */ }
}
// Relative path → resolve against note directory
let decoded = decodeURIComponent(src);
if (decoded.match(/^\/{2,}/)) decoded = decoded.replace(/^\/{2,}/, '/');
if (decoded.startsWith('/')) return decoded;
if (decoded.includes('.helixnotes/')) {
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) {
const idx = decoded.indexOf('.helixnotes/');
return `${vaultRoot}/${decoded.substring(idx)}`;
}
}
const notePath = $activeNotePath;
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
return normalizePath(`${noteDir}/${decoded}`);
}
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) return normalizePath(`${vaultRoot}/${decoded}`);
return src;
} }
async function copyImageToClipboard() { async function copyImageToClipboard() {
@@ -5535,17 +5477,8 @@
function resolveNoteHref(href: string): string | null { function resolveNoteHref(href: string): string | null {
const decoded = decodeURIComponent(href); const decoded = decodeURIComponent(href);
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(decoded)) return null; if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(decoded)) return null;
let absPath = decoded; const path = resolveVaultFilePath(decoded, $activeNotePath, $appConfig?.active_vault ?? null);
if (!decoded.startsWith('/')) { return path.endsWith('.md') ? path : null;
const notePath = $activeNotePath;
if (notePath) {
absPath = resolvePathFromFile(notePath, decoded);
} else {
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) absPath = normalizePath(`${vaultRoot}/${decoded}`);
}
}
return absPath.endsWith('.md') ? absPath : null;
} }
function linkMenuOpen() { function linkMenuOpen() {
@@ -5609,20 +5542,11 @@
} }
function resolveHrefToAbsPath(href: string): string { function resolveHrefToAbsPath(href: string): string {
const decoded = decodeURIComponent(href); return resolveVaultFilePath(
if (decoded.startsWith('/')) return decoded; decodeURIComponent(href),
// .helixnotes/ paths are always relative to vault root, not the note's directory $activeNotePath,
const vaultRoot = $appConfig?.active_vault; $appConfig?.active_vault ?? null,
if (decoded.startsWith('.helixnotes/') && vaultRoot) { );
return normalizePath(`${vaultRoot}/${decoded}`);
}
const notePath = $activeNotePath;
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
return normalizePath(`${noteDir}/${decoded}`);
}
if (vaultRoot) return normalizePath(`${vaultRoot}/${decoded}`);
return decoded;
} }
function isFileLink(href: string): boolean { function isFileLink(href: string): boolean {
@@ -7363,7 +7287,7 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 00-2 2v3M16 3h3a2 2 0 012 2v3M8 21H5a2 2 0 01-2-2v-3M16 21h3a2 2 0 002-2v-3"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 00-2 2v3M16 3h3a2 2 0 012 2v3M8 21H5a2 2 0 01-2-2v-3M16 21h3a2 2 0 002-2v-3"/></svg>
</button> </button>
{/if} {/if}
{#if !isMobile && !imageToolbar.src.startsWith('imgproxy:') && !imageToolbar.src.startsWith('http://imgproxy.localhost')} {#if !isMobile && !imageToolbar.src.startsWith('imgproxy:') && !imageToolbar.src.startsWith('http://imgproxy.localhost') && !imageToolbar.src.startsWith('https://imgproxy.localhost')}
<span class="img-toolbar-sep"></span> <span class="img-toolbar-sep"></span>
<button onclick={copyImageToClipboard} title="Copy image"> <button onclick={copyImageToClipboard} title="Copy image">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
+62
View File
@@ -27,6 +27,68 @@ export function relativePath(fromDirectory: string, targetPath: string): string
return result.join('/') || '.'; return result.join('/') || '.';
} }
export function normalizeLocalAssetPath(path: string): string {
return path
.replace(/\\/g, '/')
.replace(/^\/([A-Za-z]:\/)/, '$1');
}
export function assetUrlToLocalPath(source: string): string | null {
if (!source.startsWith('asset:') && !source.startsWith('http://asset.localhost') && !source.startsWith('https://asset.localhost')) {
return null;
}
try {
const path = normalizeLocalAssetPath(decodeURIComponent(new URL(source).pathname));
// Tauri prepends a URL-path slash to POSIX and UNC paths.
return path.startsWith('//') ? path.substring(1) : path;
} catch {
return null;
}
}
export function resolveVaultFilePath(
targetPath: string,
notePath: string | null,
vaultRoot: string | null,
): string {
const target = normalizeLocalAssetPath(targetPath);
if (target.startsWith('/') || /^[A-Za-z]:\//.test(target)) return target;
const root = vaultRoot?.replace(/\\/g, '/').replace(/\/$/, '') ?? '';
if (target.startsWith('.helixnotes/') && root) {
return resolvePathFromFile(`${root}/.vault-root`, target);
}
if (notePath) return resolvePathFromFile(notePath, target);
if (root) return resolvePathFromFile(`${root}/.vault-root`, target);
return target;
}
export function assetSourceToMarkdown(
source: string,
notePath: string | null,
vaultRoot: string | null,
): string {
if (source.startsWith('blob:')) return '';
if (source.startsWith('imgproxy:') || source.startsWith('http://imgproxy.localhost') || source.startsWith('https://imgproxy.localhost')) {
try {
return decodeURIComponent(new URL(source).pathname.substring(1));
} catch {
return source;
}
}
const absolutePath = assetUrlToLocalPath(source);
if (absolutePath === null) return source;
const normalizedRoot = vaultRoot?.replace(/\\/g, '/').replace(/\/$/, '');
if (!normalizedRoot || !absolutePath.startsWith(normalizedRoot + '/')) return absolutePath;
const vaultRelative = absolutePath.substring(normalizedRoot.length + 1);
if (vaultRelative.startsWith('.helixnotes/')) return vaultRelative;
if (!notePath) return vaultRelative;
const normalizedNotePath = notePath.replace(/\\/g, '/');
const noteDirectory = normalizedNotePath.substring(0, normalizedNotePath.lastIndexOf('/'));
return relativePath(noteDirectory, absolutePath);
}
export function resolvePathFromFile(filePath: string, targetPath: string): string { export function resolvePathFromFile(filePath: string, targetPath: string): string {
const normalizedFile = filePath.replace(/\\/g, '/'); const normalizedFile = filePath.replace(/\\/g, '/');
const normalizedTarget = targetPath.replace(/\\/g, '/'); const normalizedTarget = targetPath.replace(/\\/g, '/');
+6 -27
View File
@@ -7,6 +7,7 @@
import { darkThemes, isMobile, isAndroid } from '$lib/platform'; import { darkThemes, isMobile, isAndroid } from '$lib/platform';
import type { CustomTheme } from '$lib/types'; import type { CustomTheme } from '$lib/types';
import ResizeHandles from '$lib/components/ResizeHandles.svelte'; import ResizeHandles from '$lib/components/ResizeHandles.svelte';
import { resolveVaultFilePath } from '$lib/utils/paths';
let { children } = $props(); let { children } = $props();
@@ -73,16 +74,6 @@
} }
} }
function normalizePath(p: string): string {
const parts = p.split('/');
const resolved: string[] = [];
for (const seg of parts) {
if (seg === '..') resolved.pop();
else if (seg !== '.') resolved.push(seg);
}
return resolved.join('/');
}
function openLocalFile(path: string) { function openLocalFile(path: string) {
if (isAndroid) { if (isAndroid) {
const bridge = (window as any).Android; const bridge = (window as any).Android;
@@ -98,24 +89,12 @@
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('sms:')) { if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('sms:')) {
openUrl(href).catch((err) => console.error('Failed to open URL:', err)); openUrl(href).catch((err) => console.error('Failed to open URL:', err));
} else if (!href.startsWith('#')) { } else if (!href.startsWith('#')) {
const decoded = decodeURIComponent(href);
const config = get(appConfig); const config = get(appConfig);
const vaultRoot = config?.active_vault; const absPath = resolveVaultFilePath(
let absPath = decoded; decodeURIComponent(href),
if (!decoded.startsWith('/') && vaultRoot) { get(activeNotePath),
// .helixnotes/ paths are always relative to vault root, not the note's directory config?.active_vault ?? null,
if (decoded.startsWith('.helixnotes/')) { );
absPath = normalizePath(`${vaultRoot}/${decoded}`);
} else {
const notePath = get(activeNotePath);
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
absPath = normalizePath(`${noteDir}/${decoded}`);
} else {
absPath = normalizePath(`${vaultRoot}/${decoded}`);
}
}
}
// Internal .md note link - navigate within the app // Internal .md note link - navigate within the app
if (absPath.endsWith('.md')) { if (absPath.endsWith('.md')) {
readNote(absPath).then((content) => { readNote(absPath).then((content) => {