diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f79be1e4..7b8833461 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to - 🔧(backend) fine tune redis cache options #2658 - ✨(frontend) make the full last-update date available #1215 - 💄(frontend) redesign email confirmation standalone page #2601 +- ✨(frontend) add markdown download option #2608 ### Changed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts index 63cb3fb35..63bf75796 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts @@ -30,7 +30,7 @@ test.describe('Doc Export', () => { await expect(page.getByTestId('modal-export-title')).toBeVisible(); await expect( page.getByText( - 'Export your document to download in .pdf, .docx, .odt or .html(zip) format.', + 'Export your document to download in .pdf, .docx, .odt, .md(zip) or .html(zip) format.', ), ).toBeVisible(); await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible(); @@ -94,6 +94,98 @@ test.describe('Doc Export', () => { expect(download.suggestedFilename()).toBe(`${randomDoc}.odt`); }); + test('it exports a markdown-only document as md', async ({ + page, + browserName, + }) => { + const [randomDoc] = await createDoc( + page, + 'doc-editor-markdown-only', + browserName, + 1, + ); + + await verifyDocName(page, randomDoc); + await writeInEditor({ page, text: 'Hello Markdown export' }); + + await clickInEditorMenu(page, 'Download'); + + await page.getByRole('combobox', { name: 'Format' }).click(); + await page.getByRole('option', { name: 'Markdown' }).click(); + + const downloadPromise = page.waitForEvent('download'); + await page.getByTestId('doc-export-download-button').click(); + + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe(`${randomDoc}.md`); + + const markdownBuffer = await cs.toBuffer(await download.createReadStream()); + expect(markdownBuffer.toString('utf8')).toContain('Hello Markdown export'); + }); + + test('it exports markdown with media as a zip', async ({ + page, + browserName, + }) => { + const [randomDoc] = await createDoc( + page, + 'doc-editor-markdown', + browserName, + 1, + ); + + await verifyDocName(page, randomDoc); + await writeInEditor({ page, text: 'Hello Markdown export' }); + + await openSuggestionMenu({ + page, + suggestion: 'Resizable image with caption', + }); + + const fileChooserPromise = page.waitForEvent('filechooser'); + await page.getByText('Upload image').click(); + + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(path.join(__dirname, 'assets/test.svg')); + + const image = page + .locator('.--docs--editor-container img.bn-visual-media') + .first(); + await expect(image).toBeAttached({ timeout: 10000 }); + await expect(image).toHaveAttribute('src', /.*\.svg/); + await expect(image).not.toHaveAttribute('src', /media-check/, { + timeout: 10000, + }); + + await clickInEditorMenu(page, 'Download'); + + await page.getByRole('combobox', { name: 'Format' }).click(); + await page.getByRole('option', { name: 'Markdown' }).click(); + + const downloadPromise = page.waitForEvent('download'); + await page.getByTestId('doc-export-download-button').click(); + + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe(`${randomDoc}.zip`); + + const zipBuffer = await cs.toBuffer(await download.createReadStream()); + const zip = await JSZip.loadAsync(zipBuffer); + + const markdownFile = zip.file(`${randomDoc}.md`); + expect(markdownFile).not.toBeNull(); + + const markdown = await markdownFile!.async('string'); + expect(markdown).toContain('Hello Markdown export'); + + const mediaFiles = Object.keys(zip.files).filter( + (filename) => filename !== `${randomDoc}.md`, + ); + expect(mediaFiles).toHaveLength(1); + expect(mediaFiles[0]).toMatch(/\.svg$/); + expect(markdown).toContain(mediaFiles[0]); + expect(markdown).not.toContain('/media/'); + }); + test('it exports the doc to html zip', async ({ page, browserName }) => { const [randomDoc] = await createDoc( page, diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/ModalExport.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/ModalExport.test.tsx new file mode 100644 index 000000000..fa2e856b4 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/ModalExport.test.tsx @@ -0,0 +1,155 @@ +import { VariantType } from '@gouvfr-lasuite/ui-components'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { type Doc } from '@/docs/doc-management/types'; +import { AppWrapper } from '@/tests/utils'; + +import { ModalExport } from '../components/ModalExport'; + +const mocks = vi.hoisted(() => ({ + addMediaFilesToMarkdownZip: vi.fn(), + blocksToMarkdownLossy: vi.fn(), + downloadFile: vi.fn(), + docToBlob: vi.fn(), + toast: vi.fn(), +})); + +vi.mock('@gouvfr-lasuite/ui-components', async (importOriginal) => { + const actual = + await importOriginal(); + + return { + ...actual, + useToastProvider: () => ({ toast: mocks.toast }), + }; +}); + +vi.mock('@/core', () => ({ + useMediaUrl: () => 'https://media.test', +})); + +vi.mock('@/docs/doc-editor/stores/useEditorStore', () => ({ + useEditorStore: () => ({ + editor: { + document: [], + blocksToMarkdownLossy: mocks.blocksToMarkdownLossy, + }, + }), +})); + +vi.mock('@/docs/doc-management', () => ({ + useTrans: () => ({ untitledDocument: 'Untitled document' }), +})); + +vi.mock('../hooks/', () => ({ + default: { + useExportAGPL: () => ({ + formats: [{ label: 'PDF', value: 'pdf', labelDescription: '.pdf' }], + docToBlob: mocks.docToBlob, + }), + }, +})); + +vi.mock('../utils', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + downloadFile: mocks.downloadFile, + }; +}); + +vi.mock('../utils_markdown', () => ({ + addMediaFilesToMarkdownZip: mocks.addMediaFilesToMarkdownZip, +})); + +describe('ModalExport', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.addMediaFilesToMarkdownZip.mockResolvedValue(0); + mocks.blocksToMarkdownLossy.mockResolvedValue('# Roadmap'); + mocks.docToBlob.mockResolvedValue(undefined); + }); + + test('downloads Markdown directly when no media is archived', async () => { + const onClose = vi.fn(); + const user = userEvent.setup(); + + render( + , + { wrapper: AppWrapper }, + ); + + await user.click(screen.getByRole('combobox', { name: 'Format' })); + await user.click(screen.getByRole('option', { name: /Markdown/ })); + await user.click(screen.getByTestId('doc-export-download-button')); + + await waitFor(() => + expect(mocks.downloadFile).toHaveBeenCalledWith( + expect.objectContaining({ type: 'text/markdown;charset=utf-8' }), + 'roadmap.md', + ), + ); + expect(onClose).toHaveBeenCalledOnce(); + }); + + test('downloads a ZIP when Markdown contains archived media', async () => { + mocks.addMediaFilesToMarkdownZip.mockResolvedValue(1); + const onClose = vi.fn(); + const user = userEvent.setup(); + + render( + , + { wrapper: AppWrapper }, + ); + + await user.click(screen.getByRole('combobox', { name: 'Format' })); + await user.click(screen.getByRole('option', { name: /Markdown/ })); + await user.click(screen.getByTestId('doc-export-download-button')); + + await waitFor(() => + expect(mocks.downloadFile).toHaveBeenCalledWith( + expect.any(Blob), + 'roadmap.zip', + ), + ); + expect(onClose).toHaveBeenCalledOnce(); + }); + + test('clears the loading state when Markdown media export fails', async () => { + let rejectMediaExport: (reason: Error) => void = () => undefined; + mocks.addMediaFilesToMarkdownZip.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectMediaExport = reject; + }), + ); + const onClose = vi.fn(); + const user = userEvent.setup(); + + render( + , + { wrapper: AppWrapper }, + ); + + await user.click(screen.getByRole('combobox', { name: 'Format' })); + await user.click(screen.getByRole('option', { name: /Markdown/ })); + + const downloadButton = screen.getByTestId('doc-export-download-button'); + await user.click(downloadButton); + await waitFor(() => expect(downloadButton).toBeDisabled()); + + await act(async () => { + rejectMediaExport(new Error('Media export failed')); + }); + + await waitFor(() => expect(downloadButton).toBeEnabled()); + expect(mocks.toast).toHaveBeenCalledWith( + 'The export failed', + VariantType.ERROR, + ); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/utilsFilename.test.ts b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/utilsFilename.test.ts new file mode 100644 index 000000000..039cf41ee --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/utilsFilename.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'vitest'; + +import { getExportFilename } from '../utils'; + +describe('getExportFilename', () => { + test('normalizes document titles for export', () => { + expect(getExportFilename('R\u00e9sum\u00e9 Roadmap')).toBe( + 'resume-roadmap', + ); + }); + + test('replaces archive path separators', () => { + expect(getExportFilename('Roadmap/2026\\Final')).toBe('roadmap-2026-final'); + }); + + test('replaces characters rejected by Windows filesystems', () => { + expect(getExportFilename('Q1: Sales * Draft? | "Notes"')).toBe( + 'q1--sales---draft---final-----notes-', + ); + }); + + test.each(['CON', 'nul.txt', 'COM1', 'lpt9.log'])( + 'protects the reserved Windows device name %s', + (title) => { + expect(getExportFilename(title)).toBe(`_${title.toLowerCase()}`); + }, + ); + + test('trims trailing spaces and dots', () => { + expect(getExportFilename('Quarterly report... ')).toBe( + 'quarterly-report', + ); + }); + + test('uses a fallback when sanitization removes the title', () => { + expect(getExportFilename('...')).toBe('document'); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/utilsMarkdown.test.ts b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/utilsMarkdown.test.ts new file mode 100644 index 000000000..dc238d7c1 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/utilsMarkdown.test.ts @@ -0,0 +1,73 @@ +import JSZip from 'jszip'; +import { describe, expect, test, vi } from 'vitest'; + +import { addMediaFilesToMarkdownZip } from '../utils_markdown'; + +describe('addMediaFilesToMarkdownZip', () => { + test('localizes same-origin media without mutating unrelated URLs', async () => { + const blocks = [ + { + type: 'image', + props: { url: 'https://media.test/media/photo.png' }, + }, + { + type: 'image', + props: { url: 'https://external.test/photo.png' }, + }, + { + type: 'image', + props: { url: 'data:image/png;base64,aGVsbG8=' }, + }, + ]; + const zip = new JSZip(); + const imageBlob = new Blob(['image'], { type: 'image/png' }); + const resolveMedia = vi.fn().mockResolvedValue(imageBlob); + + const mediaFileCount = await addMediaFilesToMarkdownZip( + blocks, + zip, + 'https://media.test', + resolveMedia, + ); + + expect(mediaFileCount).toBe(1); + expect(resolveMedia).toHaveBeenCalledOnce(); + expect(resolveMedia).toHaveBeenCalledWith( + 'https://media.test/media/photo.png', + ); + expect(blocks[0].props.url).toBe('1-photo.png'); + expect(blocks[1].props.url).toBe('https://external.test/photo.png'); + expect(blocks[2].props.url).toBe('data:image/png;base64,aGVsbG8='); + expect(zip.file('1-photo.png')).not.toBeNull(); + }); + + test('finds nested media and keeps its URL when fetching fails', async () => { + const blocks = [ + { + type: 'columnList', + children: [ + { + type: 'image', + props: { url: '/media/nested.svg' }, + }, + ], + }, + ]; + const zip = new JSZip(); + const resolveMedia = vi.fn().mockResolvedValue('/media/nested.svg'); + + const mediaFileCount = await addMediaFilesToMarkdownZip( + blocks, + zip, + 'https://media.test', + resolveMedia, + ); + + expect(mediaFileCount).toBe(0); + expect(resolveMedia).toHaveBeenCalledWith( + 'https://media.test/media/nested.svg', + ); + expect(blocks[0].children[0].props.url).toBe('/media/nested.svg'); + expect(Object.keys(zip.files)).toHaveLength(0); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx index c14c54f6d..e04be65a7 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx @@ -20,12 +20,13 @@ import { useToast } from '@/hooks'; import { fallbackLng } from '@/i18n/config'; import ModulesExport from '../hooks/'; -import { downloadFile } from '../utils'; +import { downloadFile, getExportFilename } from '../utils'; import { addMediaFilesToZip, generateHtmlDocument, improveHtmlAccessibility, } from '../utils_html'; +import { addMediaFilesToMarkdownZip } from '../utils_markdown'; const useExportAGPL = ModulesExport?.useExportAGPL; @@ -59,6 +60,11 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { const formatSelect = useMemo(() => { const formatOptions = (exportAGPL?.formats || []).concat([ + { + label: t('Markdown'), + value: 'markdown', + labelDescription: t('.md(zip)'), + }, { label: t('HTML'), value: 'html', @@ -83,6 +89,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { return { formatOptions, formatLabels, allFormatsLabel }; }, [t, exportAGPL?.formats]); + /** Exports the selected format and always releases the loading state. */ async function onSubmit() { if (!editor) { toast(t('The export failed'), VariantType.ERROR); @@ -90,72 +97,101 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { } setIsExporting(true); + let shouldClose = false; - const filename = (doc.title || untitledDocument) - .toLowerCase() - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .replace(/\s/g, '-'); + try { + const documentTitle = doc.title || untitledDocument; + const filename = getExportFilename(documentTitle); + let downloadExtension = format === 'markdown' ? 'md' : format; - const documentTitle = doc.title || untitledDocument; + let blobExport = await exportAGPL?.docToBlob(format, documentTitle); - let blobExport = await exportAGPL?.docToBlob(format, documentTitle); + if (!blobExport && format === 'markdown') { + const zip = new JSZip(); + const blocks = structuredClone(editor.document); - if (!blobExport && format === 'html') { - // Use BlockNote "full HTML" export so that we stay closer to the editor rendering. - const fullHtml = await editor.blocksToFullHTML(); + const mediaFileCount = await addMediaFilesToMarkdownZip( + blocks, + zip, + mediaUrl, + ); - // Parse HTML and fetch media so that we can package a fully offline HTML document in a ZIP. - const domParser = new DOMParser(); - const parsedDocument = domParser.parseFromString(fullHtml, 'text/html'); + const markdown = await editor.blocksToMarkdownLossy(blocks); - const zip = new JSZip(); + if (mediaFileCount === 0) { + blobExport = new Blob([markdown], { + type: 'text/markdown;charset=utf-8', + }); + } else { + zip.file(`${filename}.md`, markdown); + blobExport = await zip.generateAsync({ type: 'blob' }); + downloadExtension = 'zip'; + } + } - improveHtmlAccessibility(parsedDocument, documentTitle); - await addMediaFilesToZip(parsedDocument, zip, mediaUrl); + if (!blobExport && format === 'html') { + // Use BlockNote "full HTML" export so that we stay closer to the editor rendering. + const fullHtml = await editor.blocksToFullHTML(); - const lang = i18next.language || fallbackLng; - const body = parsedDocument.body; - const editorHtmlWithLocalMedia = body ? body.innerHTML : ''; + // Parse HTML and fetch media so that we can package a fully offline HTML document in a ZIP. + const domParser = new DOMParser(); + const parsedDocument = domParser.parseFromString(fullHtml, 'text/html'); - const htmlContent = generateHtmlDocument( - documentTitle, - editorHtmlWithLocalMedia, - lang, + const zip = new JSZip(); + + improveHtmlAccessibility(parsedDocument, documentTitle); + await addMediaFilesToZip(parsedDocument, zip, mediaUrl); + + const lang = i18next.language || fallbackLng; + const body = parsedDocument.body; + const editorHtmlWithLocalMedia = body ? body.innerHTML : ''; + + const htmlContent = generateHtmlDocument( + documentTitle, + editorHtmlWithLocalMedia, + lang, + ); + + zip.file('index.html', htmlContent); + + // CSS Styles + const cssResponse = await fetch( + new URL( + '../assets/export-html-styles.txt', + import.meta.url, + ).toString(), + ); + const cssContent = await cssResponse.text(); + zip.file('styles.css', cssContent); + + blobExport = await zip.generateAsync({ type: 'blob' }); + downloadExtension = 'zip'; + } + + if (!blobExport) { + toast(t('The export failed'), VariantType.ERROR); + return; + } + + downloadFile(blobExport, `${filename}.${downloadExtension}`); + + toast( + t('Your {{format}} was downloaded succesfully', { + format, + }), + VariantType.SUCCESS, ); - zip.file('index.html', htmlContent); - - // CSS Styles - const cssResponse = await fetch( - new URL('../assets/export-html-styles.txt', import.meta.url).toString(), - ); - const cssContent = await cssResponse.text(); - zip.file('styles.css', cssContent); - - blobExport = await zip.generateAsync({ type: 'blob' }); - } - - if (!blobExport) { + shouldClose = true; + } catch { toast(t('The export failed'), VariantType.ERROR); + } finally { setIsExporting(false); - return; } - const downloadExtension = format === 'html' ? 'zip' : format; - - downloadFile(blobExport, `${filename}.${downloadExtension}`); - - toast( - t('Your {{format}} was downloaded succesfully', { - format, - }), - VariantType.SUCCESS, - ); - - setIsExporting(false); - - onClose(); + if (shouldClose) { + onClose(); + } } return ( diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts index af2b5ede6..6b44f8151 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-export/utils.ts @@ -7,6 +7,32 @@ import { Canvg } from 'canvg'; import { IParagraphOptions, ShadingType } from 'docx'; import React from 'react'; +const WINDOWS_RESERVED_FILENAME = + /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i; + +/** + * Converts a document title into a safe filename for exported files. + */ +export function getExportFilename(title: string): string { + const filename = title + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .trim() + .replace(/\s+/g, '-') + .replace(/[<>:"/\\|?*]/g, '-') + .split('') + .map((character) => (character.charCodeAt(0) < 32 ? '-' : character)) + .join('') + .replace(/[. ]+$/g, ''); + + if (!filename) { + return 'document'; + } + + return WINDOWS_RESERVED_FILENAME.test(filename) ? `_${filename}` : filename; +} + /** * Triggers a browser download of a Blob with the given filename. * diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/utils_markdown.ts b/src/frontend/apps/impress/src/features/docs/doc-export/utils_markdown.ts new file mode 100644 index 000000000..d225a6612 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/utils_markdown.ts @@ -0,0 +1,101 @@ +import JSZip from 'jszip'; + +import { isSafeUrl } from '@/utils/url'; + +import { exportResolveFileUrl } from './api'; +import { deriveMediaFilename } from './utils_html'; + +type MediaResolver = (url: string) => Promise; + +interface MediaReference { + props: Record; + src: string; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +/** Collects media URL properties from a nested editor block tree. */ +const collectMediaReferences = ( + blocks: unknown[], + references: MediaReference[], +) => { + blocks.forEach((block) => { + if (!isRecord(block)) { + return; + } + + const props = block.props; + if (isRecord(props) && typeof props.url === 'string' && props.url) { + references.push({ props, src: props.url }); + } + + if (Array.isArray(block.children)) { + collectMediaReferences(block.children, references); + } + }); +}; + +/** + * Adds resolvable same-origin media to a Markdown archive and rewrites the + * corresponding block URLs to archive-local filenames. + */ +export const addMediaFilesToMarkdownZip = async ( + blocks: unknown[], + zip: JSZip, + mediaUrl: string, + resolveMedia: MediaResolver = exportResolveFileUrl, +): Promise => { + const references: MediaReference[] = []; + collectMediaReferences(blocks, references); + + let mediaOrigin: string; + try { + mediaOrigin = new URL(mediaUrl).origin; + } catch { + return 0; + } + + const mediaFiles = await Promise.all( + references.map(async ({ props, src }, index) => { + if (src.startsWith('data:')) { + return null; + } + + let url: URL; + try { + url = new URL(src, mediaUrl); + } catch { + return null; + } + + if (url.origin !== mediaOrigin || !isSafeUrl(url.href)) { + return null; + } + + const blob = await resolveMedia(url.href); + if (!(blob instanceof Blob)) { + return null; + } + + const filename = deriveMediaFilename({ + src: url.href, + index, + blob, + }); + + props.url = filename; + return { filename, blob }; + }), + ); + + let mediaFileCount = 0; + mediaFiles.forEach((mediaFile) => { + if (mediaFile) { + zip.file(mediaFile.filename, mediaFile.blob); + mediaFileCount += 1; + } + }); + + return mediaFileCount; +};