diff --git a/src/utils/editorDurableMarkdown.ts b/src/utils/editorDurableMarkdown.ts index 1c9a53f6..fb1f8a5a 100644 --- a/src/utils/editorDurableMarkdown.ts +++ b/src/utils/editorDurableMarkdown.ts @@ -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, diff --git a/src/utils/fileAttachmentMarkdown.test.ts b/src/utils/fileAttachmentMarkdown.test.ts index 9c3907be..2765c77c 100644 --- a/src/utils/fileAttachmentMarkdown.test.ts +++ b/src/utils/fileAttachmentMarkdown.test.ts @@ -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]()\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]()\n') + }) + it('rebuilds file blocks from standalone attachment links', () => { const preprocessed = preProcessDurableEditorMarkdown({ markdown: '[report.pdf](attachments/report.pdf)\n', diff --git a/src/utils/fileAttachmentMarkdown.ts b/src/utils/fileAttachmentMarkdown.ts index 7b05e2dc..a80f1acb 100644 --- a/src/utils/fileAttachmentMarkdown.ts +++ b/src/utils/fileAttachmentMarkdown.ts @@ -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, 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( diff --git a/src/utils/richEditorMarkdown.ts b/src/utils/richEditorMarkdown.ts index 21f22d62..b3c12c1d 100644 --- a/src/utils/richEditorMarkdown.ts +++ b/src/utils/richEditorMarkdown.ts @@ -7,9 +7,10 @@ import { restoreWikilinksInBlocks, splitFrontmatter } from './wikilinks' export function serializeRichEditorBodyToMarkdown( editor: ReturnType, + 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), diff --git a/src/utils/vaultAttachments.test.ts b/src/utils/vaultAttachments.test.ts index 32e2f5cf..5bd6b78a 100644 --- a/src/utils/vaultAttachments.test.ts +++ b/src/utils/vaultAttachments.test.ts @@ -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({ diff --git a/src/utils/vaultAttachments.ts b/src/utils/vaultAttachments.ts index a62fc818..bf1b49a8 100644 --- a/src/utils/vaultAttachments.ts +++ b/src/utils/vaultAttachments.ts @@ -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