fix: preserve embedded attachment paths

This commit is contained in:
lucaronin
2026-05-16 19:25:48 +02:00
parent 2616f87185
commit 7adecaea60
6 changed files with 129 additions and 20 deletions

View File

@@ -36,9 +36,14 @@ export function injectDurableEditorMarkdownBlocks(blocks: unknown[]): unknown[]
return injectFileAttachmentBlocks(withDurableBlocks)
}
export function serializeDurableEditorBlocks(editor: MarkdownSerializer, blocks: unknown[]): string {
export function serializeDurableEditorBlocks(
editor: MarkdownSerializer,
blocks: unknown[],
vaultPath?: string,
): string {
return serializeFileAttachmentBlocks({
blocks,
vaultPath,
serializeOrdinaryBlocks: ordinaryBlocks => serializeDurableMarkdownBlocks({
blocks: ordinaryBlocks,
codecs: EDITOR_DURABLE_MARKDOWN_CODECS,

View File

@@ -5,10 +5,10 @@ import {
} from './editorDurableMarkdown'
import { serializeRichEditorDocumentToMarkdown } from './richEditorMarkdown'
function makeEditor(document: unknown[]) {
function makeEditor(document: unknown[], markdownLossy = '') {
return {
document,
blocksToMarkdownLossy: vi.fn(() => ''),
blocksToMarkdownLossy: vi.fn(() => markdownLossy),
}
}
@@ -39,6 +39,45 @@ describe('file attachment Markdown roundtrip', () => {
)).toBe('---\ntitle: Note A\n---\n[report.pdf](attachments/report.pdf)\n')
})
it('normalizes embedded file paths from the current attachments folder', () => {
const pdfPath = '/vault/attachments/project brief.pdf'
const editor = makeEditor([{
type: 'file',
props: {
name: 'project brief.pdf',
url: pdfPath,
},
children: [],
}], 'unreachable lossy markdown')
expect(serializeRichEditorDocumentToMarkdown(
editor as never,
'---\ntitle: Note A\n---\n',
'/vault',
'a.md',
)).toBe('---\ntitle: Note A\n---\n[project brief.pdf](<attachments/project brief.pdf>)\n')
expect(editor.blocksToMarkdownLossy).not.toHaveBeenCalled()
})
it('keeps embedded file paths outside the current attachments folder untouched', () => {
const pdfPath = '/shared/attachments/project brief.pdf'
const editor = makeEditor([{
type: 'file',
props: {
name: 'project brief.pdf',
url: pdfPath,
},
children: [],
}], `[project brief.pdf](<${pdfPath}>)`)
expect(serializeRichEditorDocumentToMarkdown(
editor as never,
'---\ntitle: Note A\n---\n',
'/vault',
'a.md',
)).toBe('---\ntitle: Note A\n---\n[project brief.pdf](</shared/attachments/project brief.pdf>)\n')
})
it('rebuilds file blocks from standalone attachment links', () => {
const preprocessed = preProcessDurableEditorMarkdown({
markdown: '[report.pdf](attachments/report.pdf)\n',

View File

@@ -6,6 +6,7 @@ import {
import {
isVaultAttachmentUrl,
portableAttachmentPathFromCurrentVaultAssetUrl,
portableAttachmentPathFromCurrentVaultPath,
} from './vaultAttachments'
interface FileAttachmentPayload {
@@ -17,6 +18,7 @@ interface FileAttachmentPayload {
interface SerializeFileAttachmentBlocksOptions {
blocks: unknown[]
serializeOrdinaryBlocks: (blocks: unknown[]) => string
vaultPath?: VaultPath
}
interface FlushOrdinaryBlocksOptions {
@@ -26,6 +28,7 @@ interface FlushOrdinaryBlocksOptions {
}
type FileAttachmentLineTransform = (payload: FileAttachmentPayload) => string | null
type AttachmentUrlReader = (value: unknown) => AttachmentUrl | null
type MarkdownFence = { character: string; length: number }
type AttachmentUrl = string
type Markdown = string
@@ -39,12 +42,14 @@ interface PreProcessFileAttachmentMarkdownOptions {
interface TransformFileAttachmentLinksOptions {
markdown: Markdown
readUrl?: AttachmentUrlReader
transform: FileAttachmentLineTransform
}
interface TransformLineOptions {
fence: MarkdownFence | null
line: MarkdownLine
readUrl: AttachmentUrlReader
transform: FileAttachmentLineTransform
}
@@ -125,6 +130,10 @@ function readAttachmentUrl(value: unknown): AttachmentUrl | null {
return url && isVaultAttachmentUrl({ url }) ? url : null
}
function readAnyLinkUrl(value: unknown): AttachmentUrl | null {
return readNonEmptyText(value)
}
function readPayloadName(record: Record<string, unknown>, url: AttachmentUrl): MarkdownText {
return readNonEmptyText(record.name) ?? fileNameFromUrl(url)
}
@@ -168,11 +177,14 @@ function readFileAttachmentToken(text: MarkdownText): FileAttachmentPayload | nu
}
}
function readStandaloneFileAttachmentLink(text: MarkdownText): FileAttachmentPayload | null {
function readStandaloneFileAttachmentLink(
text: MarkdownText,
readUrl: AttachmentUrlReader = readAttachmentUrl,
): FileAttachmentPayload | null {
const match = STANDALONE_ATTACHMENT_LINK_PATTERN.exec(text)
if (!match) return null
const url = readAttachmentUrl(unescapeMarkdownDestination(match[3] ?? ''))
const url = readUrl(unescapeMarkdownDestination(match[3] ?? ''))
if (!url) return null
const name = unescapeMarkdownText(match[2] ?? '') || fileNameFromUrl(url)
@@ -194,9 +206,10 @@ function isFenceClosing(text: MarkdownText, fence: MarkdownFence): boolean {
function transformAttachmentLine(
line: MarkdownLine,
readUrl: AttachmentUrlReader,
transform: FileAttachmentLineTransform,
): MarkdownLine {
const payload = readStandaloneFileAttachmentLink(lineText({ line }))
const payload = readStandaloneFileAttachmentLink(lineText({ line }), readUrl)
if (!payload) return line
const transformed = transform(payload)
@@ -213,6 +226,7 @@ function transformFencedLine(line: MarkdownLine, text: MarkdownText, fence: Mark
function transformUnfencedLine(
line: MarkdownLine,
text: MarkdownText,
readUrl: AttachmentUrlReader,
transform: FileAttachmentLineTransform,
): TransformedLine {
const opening = readFenceOpening(text)
@@ -223,7 +237,7 @@ function transformUnfencedLine(
}
}
const transformedLine = transformAttachmentLine(line, transform)
const transformedLine = transformAttachmentLine(line, readUrl, transform)
return {
line: transformedLine,
fence: null,
@@ -236,15 +250,16 @@ function transformLine(options: TransformLineOptions): TransformedLine {
return transformFencedLine(options.line, text, options.fence)
}
return transformUnfencedLine(options.line, text, options.transform)
return transformUnfencedLine(options.line, text, options.readUrl, options.transform)
}
function transformStandaloneFileAttachmentLinks(options: TransformFileAttachmentLinksOptions): Markdown {
const lines: MarkdownLine[] = []
let fence: MarkdownFence | null = null
const readUrl = options.readUrl ?? readAttachmentUrl
for (const line of splitMarkdownLines(options.markdown)) {
const transformed = transformLine({ line, fence, transform: options.transform })
const transformed = transformLine({ line, fence, readUrl, transform: options.transform })
lines.push(transformed.line)
fence = transformed.fence
}
@@ -291,16 +306,23 @@ export function injectFileAttachmentBlocks(blocks: unknown[]): unknown[] {
return (blocks as BlockLike[]).map(injectFileAttachmentBlock)
}
function readBlockAttachmentUrl(block: BlockLike): AttachmentUrl | null {
return block.type === 'file' ? readAttachmentUrl(block.props?.url) : null
function readBlockAttachmentUrl(block: BlockLike, vaultPath?: VaultPath): AttachmentUrl | null {
if (block.type !== 'file') return null
const url = readAttachmentUrl(block.props?.url)
if (url) return url
const rawUrl = readNonEmptyText(block.props?.url)
if (!rawUrl || !vaultPath) return null
return portableAttachmentPathFromCurrentVaultPath({ path: rawUrl, vaultPath })
}
function readBlockAttachmentName(block: BlockLike, url: AttachmentUrl): MarkdownText {
return block.props?.name?.trim() || fileNameFromUrl(url)
}
function readFileAttachmentBlockPayload(block: BlockLike): FileAttachmentPayload | null {
const url = readBlockAttachmentUrl(block)
function readFileAttachmentBlockPayload(block: BlockLike, vaultPath?: VaultPath): FileAttachmentPayload | null {
const url = readBlockAttachmentUrl(block, vaultPath)
if (!url) return null
return fileAttachmentPayload({
@@ -325,12 +347,13 @@ function flushOrdinaryBlocks({
export function serializeFileAttachmentBlocks({
blocks,
serializeOrdinaryBlocks,
vaultPath,
}: SerializeFileAttachmentBlocksOptions): string {
const chunks: string[] = []
let pending: unknown[] = []
for (const block of blocks as BlockLike[]) {
const payload = readFileAttachmentBlockPayload(block)
const payload = readFileAttachmentBlockPayload(block, vaultPath)
if (!payload) {
pending.push(block)
continue
@@ -357,15 +380,20 @@ export function hasFileAttachmentBlocks(blocks: unknown[]): boolean {
function portableAttachmentUrl(payload: FileAttachmentPayload, vaultPath: VaultPath): AttachmentUrl | null {
return portableAttachmentPathFromCurrentVaultAssetUrl({ url: payload.url, vaultPath })
?? portableAttachmentPathFromCurrentVaultPath({ path: payload.url, vaultPath })
}
export function portableFileAttachmentUrls(markdown: Markdown, vaultPath: VaultPath): Markdown {
if (!vaultPath) return markdown
return transformStandaloneFileAttachmentLinks({ markdown, transform: (payload) => {
const url = portableAttachmentUrl(payload, vaultPath)
return url ? serializeMarkdownFileLink({ ...payload, url }) : null
} })
return transformStandaloneFileAttachmentLinks({
markdown,
readUrl: readAnyLinkUrl,
transform: (payload) => {
const url = portableAttachmentUrl(payload, vaultPath)
return url ? serializeMarkdownFileLink({ ...payload, url }) : null
},
})
}
export function preProcessFileAttachmentMarkdown(

View File

@@ -7,9 +7,10 @@ import { restoreWikilinksInBlocks, splitFrontmatter } from './wikilinks'
export function serializeRichEditorBodyToMarkdown(
editor: ReturnType<typeof useCreateBlockNote>,
vaultPath?: string,
): string {
const restored = restoreWikilinksInBlocks(editor.document)
return compactMarkdown(serializeDurableEditorBlocks(editor, restored))
return compactMarkdown(serializeDurableEditorBlocks(editor, restored, vaultPath))
}
export function serializeRichEditorDocumentToMarkdown(
@@ -18,7 +19,7 @@ export function serializeRichEditorDocumentToMarkdown(
vaultPath?: string,
notePath?: string,
): string {
const rawBodyMarkdown = serializeRichEditorBodyToMarkdown(editor)
const rawBodyMarkdown = serializeRichEditorBodyToMarkdown(editor, vaultPath)
const bodyMarkdown = vaultPath
? portableFileAttachmentUrls(
portableImageUrls(rawBodyMarkdown, vaultPath, notePath),

View File

@@ -4,6 +4,7 @@ import {
isVaultAttachmentUrl,
portableAttachmentPathFromAnyAssetUrl,
portableAttachmentPathFromCurrentVaultAssetUrl,
portableAttachmentPathFromCurrentVaultPath,
resolveVaultAttachmentPath,
vaultAttachmentAssetUrl,
} from './vaultAttachments'
@@ -89,6 +90,33 @@ describe('vault attachment URL/path conversions', () => {
).toBe('attachments/shot.png')
})
it('converts current-vault attachment paths back to portable attachment paths', () => {
expect(
portableAttachmentPathFromCurrentVaultPath({
path: '/vault/attachments/report.pdf',
vaultPath: '/vault',
}),
).toBe('attachments/report.pdf')
})
it('keeps Windows attachment path normalization case-insensitive', () => {
expect(
portableAttachmentPathFromCurrentVaultPath({
path: 'C:\\Vault\\attachments\\Report.pdf',
vaultPath: 'c:\\vault',
}),
).toBe('attachments/Report.pdf')
})
it('rejects non-attachment paths inside the current vault', () => {
expect(
portableAttachmentPathFromCurrentVaultPath({
path: '/vault/notes/report.pdf',
vaultPath: '/vault',
}),
).toBeNull()
})
it('extracts portable attachment paths from legacy asset URLs in another vault', () => {
expect(
portableAttachmentPathFromAnyAssetUrl({

View File

@@ -210,6 +210,14 @@ export function portableAttachmentPathFromCurrentVaultAssetUrl({
return currentVaultAttachmentPath({ path, vaultPath })
}
export function portableAttachmentPathFromCurrentVaultPath({
path,
vaultPath,
}: PathRequest & VaultPathRequest): AttachmentPath | null {
if (!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