refactor: consolidate vault attachment paths

This commit is contained in:
lucaronin
2026-05-07 03:05:10 +02:00
parent 8b39eb92d6
commit c8cc9b1bf1
10 changed files with 421 additions and 290 deletions

View File

@@ -377,6 +377,8 @@ A `vault_health_check` command detects stray files in non-protected subfolders a
Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. If the active root itself cannot be canonicalized, the renderer treats `Active vault is not available` the same as no active vault: it clears stale vault state, drops prefetched note content, and shows the missing-vault recovery screen instead of continuing note/view requests against the disappeared path. Image attachment commands add the current vault root to the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately.
Renderer attachment paths are normalized through `src/utils/vaultAttachments.ts`. That module is the single owner for converting between portable markdown references such as `attachments/image.png`, Tauri asset URLs, and absolute active-vault filesystem paths. Editor markdown rendering, raw-mode serialization, image upload/drop handling, file-block open actions, and parsed image cleanup all call this primitive instead of carrying their own asset URL prefixes, Windows path normalization, or `attachments/` join rules.
UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder routes through the Tauri opener plugin, external-open routes through the `open_vault_file_external` command and active-vault boundary before invoking the native opener, and copy-path uses the browser clipboard API. Plain-text paste reads the desktop clipboard through `read_text_from_clipboard` in Tauri so macOS WKWebView clipboard permissions do not block the command; browser/mock mode falls back to the Web Clipboard API or mock handlers. None of those actions mutate vault contents or bypass the backend write boundary.
The local MCP WebSocket bridge follows the same active-vault boundary. `useVaultSwitcher` calls `sync_mcp_bridge_vault` after the persisted selection loads and after each vault switch; the desktop command starts/restarts the bridge with that vault's canonical path, or stops it when there is no selected vault. App exit uses the same child cleanup path and waits for the bridge process after killing it. MCP Node entrypoints require `VAULT_PATH` and fail clearly instead of falling back to `~/Laputa`. Manual MCP config export uses the same generated stdio entry as registration, so the copied snippet remains scoped to the active vault without writing third-party config files. Desktop snippet copy goes through the native `copy_text_to_clipboard` command, while browser/mock mode keeps using the Web Clipboard API. External-client stdio MCP processes also exit when stdin closes; their UI-bridge reconnect timers and WebSocket are canceled during shutdown so disconnected clients do not leave extra Node processes behind.

View File

@@ -3,7 +3,7 @@ import { normalizeExternalUrl, openExternalUrl, openLocalFile } from '../utils/u
import {
isVaultAttachmentUrl,
resolveVaultAttachmentPath,
} from '../utils/vaultAttachmentUrls'
} from '../utils/vaultAttachments'
const FILE_BLOCK_ACTION_SELECTOR = '[data-file-block] .bn-file-name-with-icon'
const FILE_BLOCK_CONTAINER_SELECTOR = '[data-node-type="blockContainer"][data-id]'

View File

@@ -1,5 +1,6 @@
import { splitFrontmatter } from '../utils/wikilinks'
import { slugifyNoteStem } from '../utils/noteSlug'
import { isTauriAssetUrl } from '../utils/vaultAttachments'
type MarkdownContent = string
type FilePath = string
@@ -16,7 +17,6 @@ type ParsedBlock = {
children?: unknown[]
}
const LOCAL_FILE_URL_PREFIXES = ['asset://localhost/', 'http://asset.localhost/']
const BROKEN_IMAGE_FALLBACK_MAX_WIDTH = 32
export function extractEditorBody(rawFileContent: MarkdownContent): MarkdownContent {
@@ -78,8 +78,7 @@ function isParsedBlock(value: unknown): value is ParsedBlock {
function hasLocalFileUrl(block: ParsedBlock): boolean {
const url = block.props?.url
return typeof url === 'string'
&& LOCAL_FILE_URL_PREFIXES.some(prefix => url.startsWith(prefix))
return typeof url === 'string' && isTauriAssetUrl({ url })
}
function hasSyntheticPreviewWidth(block: ParsedBlock): boolean {

View File

@@ -245,6 +245,35 @@ describe('useImageDrop — Tauri native drag-drop', () => {
expect(result.current.isDragOver).toBe(false)
})
it('copies native image drops into the vault and emits attachment asset URLs', async () => {
const onImageUrl = vi.fn()
const { invoke, convertFileSrc } = await import('@tauri-apps/api/core')
vi.mocked(invoke).mockClear()
vi.mocked(convertFileSrc).mockClear()
vi.mocked(invoke).mockResolvedValue('/vault/attachments/123-photo.png')
vi.mocked(convertFileSrc).mockReturnValue('asset://localhost/vault/attachments/123-photo.png')
renderImageDropTauri({ onImageUrl, vaultPath: '/vault' })
await waitForNativeDropListeners()
act(() => {
emitNativeDropEvent({
type: 'drop',
paths: ['/tmp/photo.png', '/tmp/readme.txt'],
position: { x: 100, y: 100 },
})
})
await waitFor(() => {
expect(onImageUrl).toHaveBeenCalledWith('asset://localhost/vault/attachments/123-photo.png')
})
expect(invoke).toHaveBeenCalledWith('copy_image_to_vault', {
vaultPath: '/vault',
sourcePath: '/tmp/photo.png',
})
expect(invoke).toHaveBeenCalledTimes(1)
})
it('resets isDragOver on Tauri leave event', async () => {
const { result } = renderImageDropTauri()

View File

@@ -1,8 +1,9 @@
import { useEffect, useRef, useState, type RefObject } from 'react'
import { invoke, convertFileSrc } from '@tauri-apps/api/core'
import { invoke } from '@tauri-apps/api/core'
import type { Event as TauriEvent, UnlistenFn } from '@tauri-apps/api/event'
import type { DragDropEvent as TauriDragDropPayload } from '@tauri-apps/api/webview'
import { isTauri } from '../mock-tauri'
import { attachmentAssetUrlFromPath } from '../utils/vaultAttachments'
const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'tiff']
@@ -11,6 +12,15 @@ const TAURI_DRAG_LEAVE_EVENT = 'tauri://drag-leave'
type ImageUrlHandler = (url: string) => void
type TauriDropEvent = TauriEvent<TauriDragDropPayload>
type CopyImageToVaultRequest = {
sourcePath: string
vaultPath: string
}
type DroppedImagesRequest = {
imagePaths: string[]
vaultPath: string | undefined
onImageUrl: ImageUrlHandler | undefined
}
function hasImageFiles(dt: DataTransfer): boolean {
for (let i = 0; i < dt.items.length; i++) {
@@ -37,7 +47,7 @@ export async function uploadImageFile(file: File, vaultPath?: string): Promise<s
filename: file.name,
data: base64,
})
return convertFileSrc(savedPath)
return attachmentAssetUrlFromPath({ path: savedPath })
}
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
@@ -48,21 +58,24 @@ export async function uploadImageFile(file: File, vaultPath?: string): Promise<s
}
/** Copy a dropped file (by OS path) into vault/attachments and return its asset URL. */
async function copyImageToVault(sourcePath: string, vaultPath: string): Promise<string> {
async function copyImageToVault({
sourcePath,
vaultPath,
}: CopyImageToVaultRequest): Promise<string> {
const savedPath = await invoke<string>('copy_image_to_vault', { vaultPath, sourcePath })
return convertFileSrc(savedPath)
return attachmentAssetUrlFromPath({ path: savedPath })
}
function insertDroppedImages(
imagePaths: string[],
vaultPath: string | undefined,
onImageUrl: ImageUrlHandler | undefined,
): void {
function insertDroppedImages({
imagePaths,
vaultPath,
onImageUrl,
}: DroppedImagesRequest): void {
if (imagePaths.length === 0) return
if (!vaultPath || !onImageUrl) return
for (const sourcePath of imagePaths) {
void copyImageToVault(sourcePath, vaultPath).then(onImageUrl)
void copyImageToVault({ sourcePath, vaultPath }).then(onImageUrl)
}
}
@@ -150,11 +163,11 @@ export function useImageDrop({ containerRef, onImageUrl, vaultPath }: UseImageDr
const nextUnlisteners = await registerNativeDropListeners((event) => {
if (event.payload.type === 'drop') {
setIsDragOver(false)
insertDroppedImages(
event.payload.paths.filter(isImagePath),
vaultPathRef.current,
onImageUrlRef.current,
)
insertDroppedImages({
imagePaths: event.payload.paths.filter(isImagePath),
vaultPath: vaultPathRef.current,
onImageUrl: onImageUrlRef.current,
})
return
}
setIsDragOver(false)

View File

@@ -1,56 +0,0 @@
import { describe, expect, it } from 'vitest'
import { isVaultAttachmentUrl, resolveVaultAttachmentPath } from './vaultAttachmentUrls'
describe('resolveVaultAttachmentPath', () => {
it('resolves relative attachment URLs inside the active vault', () => {
expect(resolveVaultAttachmentPath({ url: 'attachments/report.pdf', vaultPath: '/vault' })).toBe(
'/vault/attachments/report.pdf',
)
})
it('resolves encoded Tauri asset URLs inside the active vault', () => {
expect(
resolveVaultAttachmentPath({
url: 'asset://localhost/%2Fvault%2Fattachments%2Freport.pdf',
vaultPath: '/vault',
}),
).toBe('/vault/attachments/report.pdf')
})
it('rejects asset URLs outside the active vault', () => {
expect(
resolveVaultAttachmentPath({
url: 'asset://localhost/%2Fother%2Fattachments%2Freport.pdf',
vaultPath: '/vault',
}),
).toBeNull()
})
it('rejects unsafe relative attachment traversal', () => {
expect(
resolveVaultAttachmentPath({
url: 'attachments/../secret.pdf',
vaultPath: '/vault',
}),
).toBeNull()
})
it('keeps Windows vault paths case-insensitive and separator-safe', () => {
expect(
resolveVaultAttachmentPath({
url: 'asset://localhost/C%3A%5CVault%5Cattachments%5CReport.pdf',
vaultPath: 'c:\\vault',
}),
).toBe('C:\\Vault\\attachments\\Report.pdf')
})
it('identifies attachment URLs before falling back to external link handling', () => {
expect(isVaultAttachmentUrl({ url: 'attachments/report.pdf' })).toBe(true)
expect(
isVaultAttachmentUrl({
url: 'asset://localhost/%2Fvault%2Fattachments%2Freport.pdf',
}),
).toBe(true)
expect(isVaultAttachmentUrl({ url: 'https://example.com/report.pdf' })).toBe(false)
})
})

View File

@@ -1,119 +0,0 @@
import { canWritePathToVault } from './vaultPathContainment'
const ASSET_URL_PREFIXES = [
'asset://localhost/',
'http://asset.localhost/',
'asset://',
]
const RELATIVE_ATTACHMENTS_PREFIX = 'attachments/'
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/
type StringValueRequest = {
value: string
}
type PathRequest = {
path: string
}
type UrlRequest = {
url: string
}
export type VaultAttachmentPathRequest = UrlRequest & {
vaultPath?: string
}
export type VaultAttachmentUrlRequest = Pick<VaultAttachmentPathRequest, 'url'>
function safeDecode({ value }: StringValueRequest): string {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
function hasUnsafeRelativeSegment({ path }: PathRequest): boolean {
return path.split(/[\\/]/u).some(segment => segment === '..')
}
function usesWindowsSeparators({ path }: PathRequest): boolean {
return WINDOWS_DRIVE_PATH_PATTERN.test(path) || path.startsWith('\\\\')
}
function joinVaultPath({
relativePath,
vaultPath,
}: {
relativePath: string
vaultPath: string
}): string {
const separator = usesWindowsSeparators({ path: vaultPath }) ? '\\' : '/'
const normalizedRelativePath = usesWindowsSeparators({ path: vaultPath })
? relativePath.replace(/\//g, '\\')
: relativePath.replace(/\\/g, '/')
const joiner = vaultPath.endsWith('/') || vaultPath.endsWith('\\') ? '' : separator
return `${vaultPath}${joiner}${normalizedRelativePath}`
}
function assetUrlPrefix({ url }: UrlRequest): string | null {
return ASSET_URL_PREFIXES.find(prefix => url.startsWith(prefix)) ?? null
}
function restoreUnixRoot({ path }: PathRequest): string {
if (isRootedPath({ path })) return path
return `/${path}`
}
function resolveAssetPath({ url }: UrlRequest): string | null {
const prefix = assetUrlPrefix({ url })
if (!prefix) return null
return restoreUnixRoot({ path: safeDecode({ value: url.slice(prefix.length) }) })
}
function isRootedPath({ path }: PathRequest): boolean {
if (path.startsWith('/')) return true
if (WINDOWS_DRIVE_PATH_PATTERN.test(path)) return true
return path.startsWith('\\\\')
}
function resolveRelativeAttachmentPath({
url,
vaultPath,
}: {
url: string
vaultPath: string
}): string | null {
if (!url.startsWith(RELATIVE_ATTACHMENTS_PREFIX)) return null
const relativePath = safeDecode({ value: url })
if (hasUnsafeRelativeSegment({ path: relativePath })) return null
return joinVaultPath({ vaultPath, relativePath })
}
export function isVaultAttachmentUrl({ url }: VaultAttachmentUrlRequest): boolean {
const trimmedUrl = url.trim()
return trimmedUrl.startsWith(RELATIVE_ATTACHMENTS_PREFIX)
|| assetUrlPrefix({ url: trimmedUrl }) !== null
}
export function resolveVaultAttachmentPath({
url,
vaultPath,
}: VaultAttachmentPathRequest): string | null {
const trimmedVaultPath = vaultPath?.trim()
if (!trimmedVaultPath) return null
const trimmedUrl = url.trim()
if (!trimmedUrl) return null
const candidatePath = resolveRelativeAttachmentPath({
url: trimmedUrl,
vaultPath: trimmedVaultPath,
})
?? resolveAssetPath({ url: trimmedUrl })
?? trimmedUrl
return canWritePathToVault(candidatePath, trimmedVaultPath) ? candidatePath : null
}

View File

@@ -0,0 +1,106 @@
import { describe, expect, it, vi } from 'vitest'
import {
isTauriAssetUrl,
isVaultAttachmentUrl,
portableAttachmentPathFromAnyAssetUrl,
portableAttachmentPathFromCurrentVaultAssetUrl,
resolveVaultAttachmentPath,
vaultAttachmentAssetUrl,
} from './vaultAttachments'
vi.mock('@tauri-apps/api/core', () => ({
convertFileSrc: vi.fn((path: string) => `asset://localhost/${encodeURIComponent(path)}`),
}))
function assetUrl(path: string): string {
return `asset://localhost/${encodeURIComponent(path)}`
}
describe('resolveVaultAttachmentPath', () => {
it('resolves relative attachment URLs inside the active vault', () => {
expect(resolveVaultAttachmentPath({ url: 'attachments/report.pdf', vaultPath: '/vault' })).toBe(
'/vault/attachments/report.pdf',
)
})
it('resolves encoded Tauri asset URLs inside the active vault', () => {
expect(
resolveVaultAttachmentPath({
url: 'asset://localhost/%2Fvault%2Fattachments%2Freport.pdf',
vaultPath: '/vault',
}),
).toBe('/vault/attachments/report.pdf')
})
it('rejects asset URLs outside the active vault', () => {
expect(
resolveVaultAttachmentPath({
url: 'asset://localhost/%2Fother%2Fattachments%2Freport.pdf',
vaultPath: '/vault',
}),
).toBeNull()
})
it('rejects unsafe relative attachment traversal', () => {
expect(
resolveVaultAttachmentPath({
url: 'attachments/../secret.pdf',
vaultPath: '/vault',
}),
).toBeNull()
})
it('keeps Windows vault paths case-insensitive and separator-safe', () => {
expect(
resolveVaultAttachmentPath({
url: 'asset://localhost/C%3A%5CVault%5Cattachments%5CReport.pdf',
vaultPath: 'c:\\vault',
}),
).toBe('C:\\Vault\\attachments\\Report.pdf')
})
it('identifies attachment URLs before falling back to external link handling', () => {
expect(isVaultAttachmentUrl({ url: 'attachments/report.pdf' })).toBe(true)
expect(
isVaultAttachmentUrl({
url: 'asset://localhost/%2Fvault%2Fattachments%2Freport.pdf',
}),
).toBe(true)
expect(isVaultAttachmentUrl({ url: 'https://example.com/report.pdf' })).toBe(false)
})
})
describe('vault attachment URL/path conversions', () => {
it('builds asset URLs from portable attachment paths', () => {
expect(
vaultAttachmentAssetUrl({
attachmentPath: 'attachments/shot.png',
vaultPath: '/vault',
}),
).toBe(assetUrl('/vault/attachments/shot.png'))
})
it('converts current-vault asset URLs back to portable attachment paths', () => {
expect(
portableAttachmentPathFromCurrentVaultAssetUrl({
url: assetUrl('/vault/attachments/shot.png'),
vaultPath: '/vault',
}),
).toBe('attachments/shot.png')
})
it('extracts portable attachment paths from legacy asset URLs in another vault', () => {
expect(
portableAttachmentPathFromAnyAssetUrl({
url: assetUrl('/old-vault/attachments/shot.png'),
}),
).toBe('attachments/shot.png')
})
it('identifies every Tauri asset URL form used by editor media flows', () => {
expect(isTauriAssetUrl({ url: 'asset://localhost/%2Fvault%2Fattachments%2Fshot.png' })).toBe(true)
expect(isTauriAssetUrl({ url: 'http://asset.localhost/%2Fvault%2Fattachments%2Fshot.png' })).toBe(true)
expect(isTauriAssetUrl({ url: 'asset:///vault/attachments/shot.png' })).toBe(true)
expect(isTauriAssetUrl({ url: 'https://example.com/shot.png' })).toBe(false)
})
})

View File

@@ -0,0 +1,238 @@
import { convertFileSrc } from '@tauri-apps/api/core'
import { canWritePathToVault } from './vaultPathContainment'
const LOCALHOST_ASSET_URL_PREFIX = 'asset://localhost/'
const HTTP_ASSET_URL_PREFIX = 'http://asset.localhost/'
const GENERIC_ASSET_URL_PREFIX = 'asset://'
const ASSET_URL_PREFIXES = [
LOCALHOST_ASSET_URL_PREFIX,
HTTP_ASSET_URL_PREFIX,
GENERIC_ASSET_URL_PREFIX,
]
const ATTACHMENTS_SEGMENT = '/attachments/'
const RELATIVE_ATTACHMENTS_PREFIX = 'attachments/'
const WINDOWS_EXTENDED_PATH_PREFIX = '\\\\?\\'
const WINDOWS_EXTENDED_UNC_PREFIX = '\\\\?\\UNC\\'
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/
type AbsolutePath = string
type AttachmentPath = string
type VaultPath = string
type UrlRequest = {
url: string
}
type PathRequest = {
path: string
}
type VaultPathRequest = {
vaultPath: VaultPath
}
type AttachmentPathRequest = {
attachmentPath: AttachmentPath
}
export type VaultAttachmentPathRequest = UrlRequest & {
vaultPath?: VaultPath
}
export type VaultAttachmentUrlRequest = UrlRequest
function safeDecode(value: string): string {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
function hasUnsafeRelativeSegment({ path }: PathRequest): boolean {
return path.split(/[\\/]/u).some(segment => segment === '..')
}
function usesWindowsSeparators({ path }: PathRequest): boolean {
return WINDOWS_DRIVE_PATH_PATTERN.test(path) || path.startsWith('\\\\')
}
function relativePathForVault({
attachmentPath,
vaultPath,
}: AttachmentPathRequest & VaultPathRequest): AttachmentPath {
return usesWindowsSeparators({ path: vaultPath })
? attachmentPath.replace(/\//g, '\\')
: attachmentPath.replace(/\\/g, '/')
}
function removeWindowsExtendedPrefix(path: AbsolutePath): AbsolutePath {
if (path.startsWith(WINDOWS_EXTENDED_UNC_PREFIX)) {
return `\\\\${path.slice(WINDOWS_EXTENDED_UNC_PREFIX.length)}`
}
if (path.startsWith(WINDOWS_EXTENDED_PATH_PREFIX)) {
return path.slice(WINDOWS_EXTENDED_PATH_PREFIX.length)
}
return path
}
function normalizedFilesystemPath(path: AbsolutePath): AbsolutePath {
return removeWindowsExtendedPrefix(path).replace(/\\/g, '/')
}
function withoutTrailingSlash(path: AbsolutePath): AbsolutePath {
return path.replace(/\/+$/, '')
}
function normalizeForComparison(path: AbsolutePath, vaultPath: VaultPath): AbsolutePath {
const normalizedPath = withoutTrailingSlash(normalizedFilesystemPath(path))
const normalizedVaultPath = withoutTrailingSlash(normalizedFilesystemPath(vaultPath))
return WINDOWS_DRIVE_PATH_PATTERN.test(normalizedPath)
|| WINDOWS_DRIVE_PATH_PATTERN.test(normalizedVaultPath)
? normalizedPath.toLowerCase()
: normalizedPath
}
function assetUrlPrefix({ url }: UrlRequest): string | null {
return ASSET_URL_PREFIXES.find(prefix => url.startsWith(prefix)) ?? null
}
function isRootedPath({ path }: PathRequest): boolean {
if (path.startsWith('/')) return true
if (WINDOWS_DRIVE_PATH_PATTERN.test(path)) return true
return path.startsWith('\\\\')
}
function restoreUnixRoot({ path }: PathRequest): AbsolutePath {
return isRootedPath({ path }) ? path : `/${path}`
}
function resolveAssetPath({ url }: UrlRequest): AbsolutePath | null {
const prefix = assetUrlPrefix({ url })
if (!prefix) return null
return restoreUnixRoot({ path: safeDecode(url.slice(prefix.length)) })
}
function extractPortableAttachmentPath({ path }: PathRequest): AttachmentPath | null {
const normalizedPath = normalizedFilesystemPath(path)
const index = normalizedPath.lastIndexOf(ATTACHMENTS_SEGMENT)
if (index === -1) return null
const filename = normalizedPath.slice(index + ATTACHMENTS_SEGMENT.length)
return filename ? `${RELATIVE_ATTACHMENTS_PREFIX}${filename}` : null
}
function currentVaultAttachmentPath({
path,
vaultPath,
}: PathRequest & VaultPathRequest): AttachmentPath | null {
const normalizedPath = normalizedFilesystemPath(path)
const normalizedVaultPath = withoutTrailingSlash(normalizedFilesystemPath(vaultPath))
const attachmentsRoot = `${normalizedVaultPath}/${RELATIVE_ATTACHMENTS_PREFIX}`
const comparablePath = normalizeForComparison(normalizedPath, vaultPath)
const comparableRoot = normalizeForComparison(attachmentsRoot, vaultPath)
if (!comparablePath.startsWith(comparableRoot)) return null
const filename = normalizedPath.slice(attachmentsRoot.length)
return filename ? `${RELATIVE_ATTACHMENTS_PREFIX}${filename}` : null
}
function isPathInsideVault({
path,
vaultPath,
}: PathRequest & VaultPathRequest): boolean {
return canWritePathToVault(
removeWindowsExtendedPrefix(path),
removeWindowsExtendedPrefix(vaultPath),
)
}
function resolveRelativeAttachmentPath({
url,
vaultPath,
}: UrlRequest & VaultPathRequest): AbsolutePath | null {
if (!url.startsWith(RELATIVE_ATTACHMENTS_PREFIX)) return null
const attachmentPath = safeDecode(url)
if (hasUnsafeRelativeSegment({ path: attachmentPath })) return null
return vaultAttachmentPath({ vaultPath, attachmentPath })
}
export function vaultAttachmentPath({
attachmentPath,
vaultPath,
}: AttachmentPathRequest & VaultPathRequest): AbsolutePath {
const separator = usesWindowsSeparators({ path: vaultPath }) ? '\\' : '/'
const normalizedAttachmentPath = relativePathForVault({ vaultPath, attachmentPath })
const joiner = vaultPath.endsWith('/') || vaultPath.endsWith('\\') ? '' : separator
return `${vaultPath}${joiner}${normalizedAttachmentPath}`
}
export function attachmentAssetUrlFromPath({ path }: PathRequest): string {
return convertFileSrc(path)
}
export function vaultAttachmentAssetUrl({
attachmentPath,
vaultPath,
}: AttachmentPathRequest & VaultPathRequest): string {
return attachmentAssetUrlFromPath({
path: vaultAttachmentPath({ vaultPath, attachmentPath }),
})
}
export function isPortableAttachmentPath({ path }: PathRequest): boolean {
return path.startsWith(RELATIVE_ATTACHMENTS_PREFIX)
}
export function isTauriAssetUrl({ url }: UrlRequest): boolean {
return assetUrlPrefix({ url }) !== null
}
export function isCurrentVaultAssetUrl({
url,
vaultPath,
}: UrlRequest & VaultPathRequest): boolean {
const path = resolveAssetPath({ url })
return path ? isPathInsideVault({ path, vaultPath }) : false
}
export function portableAttachmentPathFromCurrentVaultAssetUrl({
url,
vaultPath,
}: UrlRequest & VaultPathRequest): AttachmentPath | null {
const path = resolveAssetPath({ url })
if (!path || !isPathInsideVault({ path, vaultPath })) return null
return currentVaultAttachmentPath({ path, vaultPath })
}
export function portableAttachmentPathFromAnyAssetUrl({ url }: UrlRequest): AttachmentPath | null {
const path = resolveAssetPath({ url })
return path ? extractPortableAttachmentPath({ path }) : null
}
export function isVaultAttachmentUrl({ url }: VaultAttachmentUrlRequest): boolean {
const trimmedUrl = url.trim()
return isPortableAttachmentPath({ path: trimmedUrl })
|| isTauriAssetUrl({ url: trimmedUrl })
}
export function resolveVaultAttachmentPath({
url,
vaultPath,
}: VaultAttachmentPathRequest): AbsolutePath | null {
const trimmedVaultPath = vaultPath?.trim()
if (!trimmedVaultPath) return null
const trimmedUrl = url.trim()
if (!trimmedUrl) return null
const candidatePath = resolveRelativeAttachmentPath({
url: trimmedUrl,
vaultPath: trimmedVaultPath,
})
?? resolveAssetPath({ url: trimmedUrl })
?? trimmedUrl
return isPathInsideVault({ path: candidatePath, vaultPath: trimmedVaultPath }) ? candidatePath : null
}

View File

@@ -1,101 +1,20 @@
import { convertFileSrc } from '@tauri-apps/api/core'
import { isTauri } from '../mock-tauri'
const ASSET_URL_PREFIX = 'asset://localhost/'
const HTTP_ASSET_URL_PREFIX = 'http://asset.localhost/'
const ASSET_URL_PREFIXES = [ASSET_URL_PREFIX, HTTP_ASSET_URL_PREFIX]
const ATTACHMENTS_SEGMENT = '/attachments/'
const RELATIVE_ATTACHMENTS_PREFIX = 'attachments/'
const WINDOWS_EXTENDED_PATH_PREFIX = '\\\\?\\'
const WINDOWS_EXTENDED_UNC_PREFIX = '\\\\?\\UNC\\'
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/
import {
isCurrentVaultAssetUrl,
isPortableAttachmentPath,
isTauriAssetUrl,
portableAttachmentPathFromAnyAssetUrl,
portableAttachmentPathFromCurrentVaultAssetUrl,
vaultAttachmentAssetUrl,
} from './vaultAttachments'
type Markdown = string
type VaultPath = string
type AttachmentPath = string
type AbsolutePath = string
type MarkdownImageUrl = string
// Matches markdown image syntax: ![alt](url) or ![alt](url "title").
const MD_IMAGE_PATTERN = /!\[([^\]]*)\]\(([^)\s"]+)(\s+"[^"]*")?\)/g
function assetUrl(path: AbsolutePath): MarkdownImageUrl {
return convertFileSrc(path)
}
function usesWindowsSeparators(path: string): boolean {
return WINDOWS_DRIVE_PATH_PATTERN.test(path) || path.startsWith('\\\\')
}
function relativePathForVault(vaultPath: VaultPath, attachmentPath: AttachmentPath): AttachmentPath {
return usesWindowsSeparators(vaultPath)
? attachmentPath.replace(/\//g, '\\')
: attachmentPath.replace(/\\/g, '/')
}
function vaultAttachmentPath(vaultPath: VaultPath, attachmentPath: AttachmentPath): AbsolutePath {
const separator = usesWindowsSeparators(vaultPath) ? '\\' : '/'
const normalizedAttachmentPath = relativePathForVault(vaultPath, attachmentPath)
const joiner = vaultPath.endsWith('/') || vaultPath.endsWith('\\') ? '' : separator
return `${vaultPath}${joiner}${normalizedAttachmentPath}`
}
function removeWindowsExtendedPrefix(path: AbsolutePath): AbsolutePath {
if (path.startsWith(WINDOWS_EXTENDED_UNC_PREFIX)) {
return `\\\\${path.slice(WINDOWS_EXTENDED_UNC_PREFIX.length)}`
}
if (path.startsWith(WINDOWS_EXTENDED_PATH_PREFIX)) {
return path.slice(WINDOWS_EXTENDED_PATH_PREFIX.length)
}
return path
}
function normalizedFilesystemPath(path: AbsolutePath): AbsolutePath {
return removeWindowsExtendedPrefix(path).replace(/\\/g, '/')
}
function withoutTrailingSlash(path: AbsolutePath): AbsolutePath {
return path.replace(/\/+$/, '')
}
function extractAttachmentPath(absolutePath: AbsolutePath): AttachmentPath | null {
const normalizedPath = normalizedFilesystemPath(absolutePath)
const index = normalizedPath.lastIndexOf(ATTACHMENTS_SEGMENT)
if (index === -1) return null
const filename = normalizedPath.slice(index + ATTACHMENTS_SEGMENT.length)
return filename ? `${RELATIVE_ATTACHMENTS_PREFIX}${filename}` : null
}
function assetUrlPrefix(url: MarkdownImageUrl): string | null {
return ASSET_URL_PREFIXES.find(prefix => url.startsWith(prefix)) ?? null
}
function decodeAssetPath(url: MarkdownImageUrl): AbsolutePath {
const prefix = assetUrlPrefix(url)
return prefix ? decodeURIComponent(url.slice(prefix.length)) : ''
}
function isAssetUrl(url: MarkdownImageUrl): boolean {
return assetUrlPrefix(url) !== null
}
function isCurrentVaultAsset(url: MarkdownImageUrl, vaultPath: VaultPath): boolean {
const absolutePath = withoutTrailingSlash(normalizedFilesystemPath(decodeAssetPath(url)))
const normalizedVaultPath = withoutTrailingSlash(normalizedFilesystemPath(vaultPath))
return absolutePath === normalizedVaultPath || absolutePath.startsWith(`${normalizedVaultPath}/`)
}
function currentVaultAttachmentPath(url: MarkdownImageUrl, vaultPath: VaultPath): AttachmentPath | null {
const absolutePath = normalizedFilesystemPath(decodeAssetPath(url))
const normalizedVaultPath = withoutTrailingSlash(normalizedFilesystemPath(vaultPath))
const attachmentsPrefix = `${normalizedVaultPath}/${RELATIVE_ATTACHMENTS_PREFIX}`
if (!absolutePath.startsWith(attachmentsPrefix)) return null
const filename = absolutePath.slice(attachmentsPrefix.length)
return filename ? `${RELATIVE_ATTACHMENTS_PREFIX}${filename}` : null
}
function rewriteMarkdownImages(
markdown: Markdown,
transformUrl: (url: MarkdownImageUrl) => MarkdownImageUrl | null,
@@ -110,16 +29,16 @@ export function resolveImageUrls(markdown: Markdown, vaultPath: VaultPath): Mark
if (!isTauri() || !vaultPath) return markdown
return rewriteMarkdownImages(markdown, (url) => {
if (url.startsWith(RELATIVE_ATTACHMENTS_PREFIX)) {
return assetUrl(vaultAttachmentPath(vaultPath, url))
if (isPortableAttachmentPath({ path: url })) {
return vaultAttachmentAssetUrl({ vaultPath, attachmentPath: url })
}
if (!isAssetUrl(url) || isCurrentVaultAsset(url, vaultPath)) {
if (!isTauriAssetUrl({ url }) || isCurrentVaultAssetUrl({ url, vaultPath })) {
return null
}
const attachmentPath = extractAttachmentPath(decodeAssetPath(url))
return attachmentPath ? assetUrl(vaultAttachmentPath(vaultPath, attachmentPath)) : null
const attachmentPath = portableAttachmentPathFromAnyAssetUrl({ url })
return attachmentPath ? vaultAttachmentAssetUrl({ vaultPath, attachmentPath }) : null
})
}
@@ -127,8 +46,8 @@ export function portableImageUrls(markdown: Markdown, vaultPath: VaultPath): Mar
if (!vaultPath) return markdown
return rewriteMarkdownImages(markdown, (url) => {
if (!isAssetUrl(url)) return null
if (!isTauriAssetUrl({ url })) return null
return currentVaultAttachmentPath(url, vaultPath)
return portableAttachmentPathFromCurrentVaultAssetUrl({ url, vaultPath })
})
}