From 30c905adfe391c554746f7ba1c9c1874278bed79 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 31 Jul 2026 15:37:32 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=84(frontend)=20allowed=20partially=20?= =?UTF-8?q?export=20when=20MIT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When MIT, we were not able to export anything. But actually, the export HTML and Print are allowed for MIT, so we should allow them. We now allow partially export when MIT, but the AGPL export is still not allowed (pdf / odt / docx). --- CHANGELOG.md | 9 +- .../doc-export/__tests__/ExportMIT.test.tsx | 10 +- .../doc-export/components/ModalExport.tsx | 141 +++++------------- .../features/docs/doc-export/hooks/index.ts | 18 +++ .../docs/doc-export/hooks/useExportAGPL.tsx | 98 ++++++++++++ .../src/features/docs/doc-export/index.ts | 12 +- .../__tests__/DocToolBoxLicence.spec.tsx | 67 --------- .../__tests__/DocToolBoxLicenceMIT.spec.tsx | 67 --------- .../docs/doc-header/components/DocToolBox.tsx | 20 +-- 9 files changed, 170 insertions(+), 272 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-export/hooks/index.ts create mode 100644 src/frontend/apps/impress/src/features/docs/doc-export/hooks/useExportAGPL.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicence.spec.tsx delete mode 100644 src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicenceMIT.spec.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index ac490d64a..b4708a20f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,14 +11,15 @@ and this project adheres to - ♿️(frontend) restore skip to content link after header redesign #2510 - 🌐(i18n) rename cn_CN to zh_CN, add eo_PL and zh_TW locales #2486 -### Fixed - -- 🐛(frontend) redirect homepage to login when homepage feat is disabled #2521 - ### Changed - ♿️(frontend) use semantic `
` structure in document info card #2379 +### Fixed + +- 🐛(frontend) redirect homepage to login when homepage feat is disabled #2521 +- 📄(frontend) allowed partially export when MIT #2551 + ## [v5.4.1] - 2026-07-09 ### Changed diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/ExportMIT.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/ExportMIT.test.tsx index 3e5f3c3c0..b614a74cd 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/ExportMIT.test.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/__tests__/ExportMIT.test.tsx @@ -1,7 +1,7 @@ import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'; -vi.mock('@/docs/doc-export/components/ModalExport', () => ({ - ModalExport: vi.fn(), +vi.mock('@/docs/doc-export/hooks/useExportAGPL', () => ({ + useExportAGPL: vi.fn(), })); const originalEnv = process.env.NEXT_PUBLIC_PUBLISH_AS_MIT; @@ -18,15 +18,15 @@ describe('useModuleExport', () => { it('should return undefined when NEXT_PUBLIC_PUBLISH_AS_MIT is true', async () => { process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'true'; - const Export = await import('@/features/docs/doc-export/'); + const Export = await import('@/docs/doc-export/hooks'); expect(Export.default).toBeUndefined(); }); it('should load modules when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => { process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false'; - const Export = await import('@/features/docs/doc-export/'); + const Export = await import('@/docs/doc-export/hooks'); - expect(Export.default).toHaveProperty('ModalExport'); + expect(Export.default).toHaveProperty('useExportAGPL'); }); }); 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 b504c9fc9..f7a6cee02 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 @@ -1,6 +1,3 @@ -import { DOCXExporter } from '@blocknote/xl-docx-exporter'; -import { ODTExporter } from '@blocknote/xl-odt-exporter'; -import { PDFExporter } from '@blocknote/xl-pdf-exporter'; import { Button, Loader, @@ -10,30 +7,19 @@ import { VariantType, useToastProvider, } from '@gouvfr-lasuite/cunningham-react'; -import { DocumentProps, pdf } from '@react-pdf/renderer'; -import jsonemoji from 'emoji-datasource-apple' with { type: 'json' }; import i18next from 'i18next'; import JSZip from 'jszip'; -import { - cloneElement, - isValidElement, - useEffect, - useRef, - useState, -} from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; import { Box, ButtonCloseModal, Text } from '@/components'; import { useMediaUrl } from '@/core'; import { useEditorStore } from '@/docs/doc-editor/stores/useEditorStore'; -import { Doc, useTrans } from '@/docs/doc-management'; +import { type Doc, useTrans } from '@/docs/doc-management'; import { fallbackLng } from '@/i18n/config'; -import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl'; -import { docxDocsSchemaMappings } from '../mappingDocx'; -import { odtDocsSchemaMappings } from '../mappingODT'; -import { pdfDocsSchemaMappings } from '../mappingPDF'; +import ModulesExport from '../hooks/'; import { downloadFile } from '../utils'; import { addMediaFilesToZip, @@ -42,13 +28,7 @@ import { } from '../utils_html'; import { printDocumentWithStyles } from '../utils_print'; -enum DocDownloadFormat { - HTML = 'html', - PDF = 'pdf', - DOCX = 'docx', - ODT = 'odt', - PRINT = 'print', -} +const useExportAGPL = ModulesExport?.useExportAGPL; interface ModalExportProps { onClose: () => void; @@ -60,12 +40,13 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { const { toast } = useToastProvider(); const { editor } = useEditorStore(); const [isExporting, setIsExporting] = useState(false); - const [format, setFormat] = useState( - DocDownloadFormat.PDF, - ); const { untitledDocument } = useTrans(); const mediaUrl = useMediaUrl(); const selectRef = useRef(null); + const exportAGPL = useExportAGPL?.(doc, editor); + const [format, setFormat] = useState( + exportAGPL?.formats.find((opt) => opt.value === 'pdf')?.value || 'html', + ); useEffect(() => { const frameId = requestAnimationFrame(() => { @@ -77,22 +58,18 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { return () => cancelAnimationFrame(frameId); }, []); - const formatOptions = [ - { label: t('PDF'), value: DocDownloadFormat.PDF }, - { label: t('Docx'), value: DocDownloadFormat.DOCX }, - { label: t('ODT'), value: DocDownloadFormat.ODT }, - { label: t('HTML'), value: DocDownloadFormat.HTML }, - { label: t('Print'), value: DocDownloadFormat.PRINT }, - ]; + const formatSelect = useMemo(() => { + const formatOptions = (exportAGPL?.formats || []).concat([ + { label: t('HTML'), value: 'html' }, + { label: t('Print'), value: 'print' }, + ]); - const formatLabels = Object.fromEntries( - formatOptions.map((opt) => [opt.value, opt.label]), - ); + const formatLabels = Object.fromEntries( + formatOptions.map((opt) => [opt.value, opt.label]), + ); - const downloadButtonAriaLabel = - format === DocDownloadFormat.PRINT - ? t('Print') - : t('Download {{format}}', { format: formatLabels[format] }); + return { formatOptions, formatLabels }; + }, [t, exportAGPL?.formats]); async function onSubmit() { if (!editor) { @@ -103,7 +80,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { setIsExporting(true); // Handle print separately as it doesn't download a file - if (format === DocDownloadFormat.PRINT) { + if (format === 'print') { printDocumentWithStyles(); setIsExporting(false); onClose(); @@ -118,62 +95,9 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { const documentTitle = doc.title || untitledDocument; - const exportDocument = editor.document; - let blobExport: Blob; - if (format === DocDownloadFormat.PDF) { - const exporter = new PDFExporter(editor.schema, pdfDocsSchemaMappings, { - resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url), - emojiSource: { - format: 'png', - builder(code) { - const emojisFound = jsonemoji.filter( - (e) => - e.unified.split('-')[0].toLowerCase() === - code.split('-')[0].toLowerCase(), - ); + let blobExport = await exportAGPL?.docToBlob(format, documentTitle); - const emoji = emojisFound.find((e) => - e.unified.toLocaleLowerCase().includes(code.toLowerCase()), - ); - - if (emoji) { - return `/assets/fonts/emoji/${emoji.image}`; - } - - return '/assets/fonts/emoji/fallback.png'; - }, - }, - }); - const rawPdfDocument = (await exporter.toReactPDFDocument( - exportDocument, - )) as React.ReactElement; - - // Add language, title and outline properties to improve PDF accessibility and navigation - const pdfDocument = isValidElement(rawPdfDocument) - ? cloneElement(rawPdfDocument, { - language: i18next.language, - title: documentTitle, - pageMode: 'useOutlines', - }) - : rawPdfDocument; - - blobExport = await pdf(pdfDocument).toBlob(); - } else if (format === DocDownloadFormat.DOCX) { - const exporter = new DOCXExporter(editor.schema, docxDocsSchemaMappings, { - resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url), - }); - - blobExport = await exporter.toBlob(exportDocument, { - documentOptions: { title: documentTitle }, - sectionOptions: {}, - }); - } else if (format === DocDownloadFormat.ODT) { - const exporter = new ODTExporter(editor.schema, odtDocsSchemaMappings, { - resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url), - }); - - blobExport = await exporter.toODTDocument(exportDocument); - } else if (format === DocDownloadFormat.HTML) { + if (!blobExport && format === 'html') { // Use BlockNote "full HTML" export so that we stay closer to the editor rendering. const fullHtml = await editor.blocksToFullHTML(); @@ -206,14 +130,15 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { zip.file('styles.css', cssContent); blobExport = await zip.generateAsync({ type: 'blob' }); - } else { + } + + if (!blobExport) { toast(t('The export failed'), VariantType.ERROR); setIsExporting(false); return; } - const downloadExtension = - format === DocDownloadFormat.HTML ? 'zip' : format; + const downloadExtension = format === 'html' ? 'zip' : format; downloadFile(blobExport, `${filename}.${downloadExtension}`); @@ -250,13 +175,19 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { } @@ -303,11 +234,9 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { clearable={false} fullWidth label={t('Format')} - options={formatOptions} + options={formatSelect.formatOptions} value={format} - onChange={(options) => - setFormat(options.target.value as DocDownloadFormat) - } + onChange={(options) => setFormat(options.target.value as string)} /> diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/hooks/index.ts b/src/frontend/apps/impress/src/features/docs/doc-export/hooks/index.ts new file mode 100644 index 000000000..4cbea5953 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/hooks/index.ts @@ -0,0 +1,18 @@ +/** + * To import export modules you must import from the index file. + * This is to ensure that the export modules are only loaded when + * the application is not published as MIT. + */ + +import * as useExportAGPL from './useExportAGPL'; + +let modulesExport = undefined; +if (process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false') { + modulesExport = { + ...useExportAGPL, + }; +} + +type ModulesExport = typeof useExportAGPL; + +export default modulesExport as ModulesExport; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/hooks/useExportAGPL.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/hooks/useExportAGPL.tsx new file mode 100644 index 000000000..a803e3aeb --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/hooks/useExportAGPL.tsx @@ -0,0 +1,98 @@ +/** + * This exports modules are AGPL licensed and should only + * be used when the application is not published as MIT. + */ +import { DOCXExporter } from '@blocknote/xl-docx-exporter'; +import { ODTExporter } from '@blocknote/xl-odt-exporter'; +import { PDFExporter } from '@blocknote/xl-pdf-exporter'; +import { DocumentProps, pdf } from '@react-pdf/renderer'; +import jsonemoji from 'emoji-datasource-apple' with { type: 'json' }; +import i18next from 'i18next'; +import { cloneElement, isValidElement } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { DocsBlockNoteEditor } from '@/docs/doc-editor/types'; +import { Doc } from '@/docs/doc-management/types'; + +import { exportCorsResolveFileUrl } from '../api/exportResolveFileUrl'; +import { docxDocsSchemaMappings } from '../mappingDocx'; +import { odtDocsSchemaMappings } from '../mappingODT'; +import { pdfDocsSchemaMappings } from '../mappingPDF'; + +export const useExportAGPL = (doc: Doc, editor?: DocsBlockNoteEditor) => { + const { t } = useTranslation(); + + const docToBlob = async (format: string, documentTitle: string) => { + if (!editor) { + return; + } + + const exportDocument = editor.document; + let blobExport: Blob | undefined = undefined; + if (format === 'pdf') { + const exporter = new PDFExporter(editor.schema, pdfDocsSchemaMappings, { + resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url), + emojiSource: { + format: 'png', + builder(code) { + const emojisFound = jsonemoji.filter( + (e) => + e.unified.split('-')[0].toLowerCase() === + code.split('-')[0].toLowerCase(), + ); + + const emoji = emojisFound.find((e) => + e.unified.toLocaleLowerCase().includes(code.toLowerCase()), + ); + + if (emoji) { + return `/assets/fonts/emoji/${emoji.image}`; + } + + return '/assets/fonts/emoji/fallback.png'; + }, + }, + }); + const rawPdfDocument = (await exporter.toReactPDFDocument( + exportDocument, + )) as React.ReactElement; + + // Add language, title and outline properties to improve PDF accessibility and navigation + const pdfDocument = isValidElement(rawPdfDocument) + ? cloneElement(rawPdfDocument, { + language: i18next.language, + title: documentTitle, + pageMode: 'useOutlines', + }) + : rawPdfDocument; + + blobExport = await pdf(pdfDocument).toBlob(); + } else if (format === 'docx') { + const exporter = new DOCXExporter(editor.schema, docxDocsSchemaMappings, { + resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url), + }); + + blobExport = await exporter.toBlob(exportDocument, { + documentOptions: { title: documentTitle }, + sectionOptions: {}, + }); + } else if (format === 'odt') { + const exporter = new ODTExporter(editor.schema, odtDocsSchemaMappings, { + resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url), + }); + + blobExport = await exporter.toODTDocument(exportDocument); + } + + return blobExport; + }; + + return { + formats: [ + { label: t('PDF'), value: 'pdf' }, + { label: t('Docx'), value: 'docx' }, + { label: t('ODT'), value: 'odt' }, + ], + docToBlob, + }; +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/index.ts b/src/frontend/apps/impress/src/features/docs/doc-export/index.ts index 6306d9b08..2397d43bf 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-export/index.ts @@ -6,14 +6,4 @@ export * from './api'; export * from './utils'; export * from './utils_html'; - -import * as ModalExport from './components/ModalExport'; - -let modulesExport = undefined; -if (process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false') { - modulesExport = { - ...ModalExport, - }; -} - -export default modulesExport; +export * from './components'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicence.spec.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicence.spec.tsx deleted file mode 100644 index c6116ac5e..000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicence.spec.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { afterAll, beforeEach, describe, expect, vi } from 'vitest'; - -const originalEnv = process.env.NEXT_PUBLIC_PUBLISH_AS_MIT; - -vi.mock('next/router', async () => ({ - ...(await vi.importActual('next/router')), - useRouter: () => ({ - push: vi.fn(), - pathname: '/docs/doc-1', - }), -})); - -vi.mock('@gouvfr-lasuite/ui-kit', async () => { - const actual = await vi.importActual('@gouvfr-lasuite/ui-kit'); - return { - ...actual, - DropdownMenu: ({ options, children }: any) => ( - <> - {children} -
    - {options - .filter((o: any) => !o.isHidden) - .map((o: any) => ( -
  • {o.label}
  • - ))} -
- - ), - }; -}); - -vi.mock('../hooks/useCopyCurrentEditorToClipboard', () => ({ - useCopyCurrentEditorToClipboard: () => vi.fn(), -})); - -const doc = { - nb_accesses: 1, - abilities: { - versions_list: true, - destroy: true, - }, -}; - -describe('DocToolBox - Licence', () => { - afterAll(() => { - process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = originalEnv; - }); - - beforeEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - }); - - test('The export button is rendered when MIT version is deactivated', async () => { - process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false'; - - const { AppWrapper } = await import('@/tests/utils'); - const { DocToolBox } = await import('../components/DocToolBox'); - - render(, { - wrapper: AppWrapper, - }); - - expect(await screen.findByText('Download')).toBeInTheDocument(); - }, 15000); -}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicenceMIT.spec.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicenceMIT.spec.tsx deleted file mode 100644 index aab64de00..000000000 --- a/src/frontend/apps/impress/src/features/docs/doc-header/__tests__/DocToolBoxLicenceMIT.spec.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { afterAll, beforeEach, describe, expect, vi } from 'vitest'; - -const originalEnv = process.env.NEXT_PUBLIC_PUBLISH_AS_MIT; - -vi.mock('next/router', async () => ({ - ...(await vi.importActual('next/router')), - useRouter: () => ({ - push: vi.fn(), - pathname: '/docs/doc-1', - }), -})); - -vi.mock('@gouvfr-lasuite/ui-kit', async () => { - const actual = await vi.importActual('@gouvfr-lasuite/ui-kit'); - return { - ...actual, - DropdownMenu: ({ options, children }: any) => ( - <> - {children} -
    - {options - .filter((o: any) => !o.isHidden) - .map((o: any) => ( -
  • {o.label}
  • - ))} -
- - ), - }; -}); - -vi.mock('../hooks/useCopyCurrentEditorToClipboard', () => ({ - useCopyCurrentEditorToClipboard: () => vi.fn(), -})); - -const doc = { - nb_accesses: 1, - abilities: { - versions_list: true, - destroy: true, - }, -}; - -describe('DocToolBox - Licence MIT', () => { - afterAll(() => { - process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = originalEnv; - }); - - beforeEach(() => { - vi.clearAllMocks(); - vi.resetModules(); - }); - - test('The export button is not rendered when MIT version is activated', async () => { - process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'true'; - - const { AppWrapper } = await import('@/tests/utils'); - const { DocToolBox } = await import('../components/DocToolBox'); - - render(, { - wrapper: AppWrapper, - }); - - expect(screen.queryByText('Download')).not.toBeInTheDocument(); - }, 15000); -}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx index 8ad47dda4..7138fe9ed 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx @@ -72,16 +72,13 @@ const ConfirmationLeaveModal = dynamic( { ssr: false }, ); -const ModalExport = - process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false' - ? dynamic( - () => - import('@/docs/doc-export/components/ModalExport').then((mod) => ({ - default: mod.ModalExport, - })), - { ssr: false }, - ) - : null; +const ModalExport = dynamic( + () => + import('@/docs/doc-export/components/ModalExport').then((mod) => ({ + default: mod.ModalExport, + })), + { ssr: false }, +); interface DocToolBoxProps { doc: Doc; @@ -167,7 +164,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { callback: () => { setIsModalExportOpen(true); }, - isHidden: !ModalExport, }, { label: t('Copy as {{format}}', { format: 'Markdown' }), @@ -247,7 +243,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { /> - {isModalExportOpen && ModalExport && ( + {isModalExportOpen && ( { setIsModalExportOpen(false);