From 259a9e73c225937adf154e468c2289c74b387fc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Frnka?= Date: Sun, 16 Aug 2026 11:32:15 +0200 Subject: [PATCH 1/7] Added custom docx generation --- plugin/src/client.ts | 26 ++ plugin/src/components/PipelineResultPanel.tsx | 55 +++- service/pyproject.toml | 2 + service/requirements.txt | 83 +++--- .../ai/persistence/database.py | 5 +- .../ai_document_plugin_service/api/routes.py | 26 +- .../ai_document_plugin_service/api/types.py | 6 + service/src/ai_document_plugin_service/di.py | 9 + .../service/errors.py | 2 + .../service/export_service.py | 62 +++++ .../utils/__init__.py | 0 .../utils/docx_export.py | 185 +++++++++++++ service/tests/service/test_export_service.py | 89 +++++++ service/tests/utils/test_docx_export.py | 248 ++++++++++++++++++ service/uv.lock | 126 +++++++++ 15 files changed, 880 insertions(+), 44 deletions(-) create mode 100644 service/src/ai_document_plugin_service/service/export_service.py rename plugin/src/components/DmpTemplateSection.tsx => service/src/ai_document_plugin_service/utils/__init__.py (100%) create mode 100644 service/src/ai_document_plugin_service/utils/docx_export.py create mode 100644 service/tests/service/test_export_service.py create mode 100644 service/tests/utils/test_docx_export.py diff --git a/plugin/src/client.ts b/plugin/src/client.ts index cfd9c30..2738d2c 100644 --- a/plugin/src/client.ts +++ b/plugin/src/client.ts @@ -268,6 +268,32 @@ export const deleteTemplate = async (templateUuid: string): Promise => { } } +export const exportPipelineResultAsDocx = async ( + runId: string, + resultMarkdown: string, +): Promise => { + const url = `${getApiBaseUrl()}/pipelines/status/${runId}/export/docx` + const response = await apiFetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + resultMarkdown, + }), + }) + + if (!response.ok) { + // Errors still come back as JSON; only the success path is binary. + const data = await readApiResponse<{ detail?: string }>(response, url).catch( + (): { detail?: string } => ({}), + ) + throw new Error(data.detail || 'Failed to export the result as a Word document.') + } + + return response.blob() +} + export const saveEditedPipelineResult = async ( runId: string, resultMarkdown: string, diff --git a/plugin/src/components/PipelineResultPanel.tsx b/plugin/src/components/PipelineResultPanel.tsx index 8c75847..3dd523a 100644 --- a/plugin/src/components/PipelineResultPanel.tsx +++ b/plugin/src/components/PipelineResultPanel.tsx @@ -1,11 +1,22 @@ import { useEffect, useState } from 'react' import { toast } from 'sonner' -import { saveEditedPipelineResult } from '@/client' +import { exportPipelineResultAsDocx, saveEditedPipelineResult } from '@/client' import styles from '@/components/PipelineResultPanel.module.css' import { MarkdownRenderer } from '@/markdown-utils' import type { PipelineStatusResponse, ResultRenderMode } from '@/types' +const triggerBlobDownload = (blob: Blob, fileName: string) => { + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = fileName + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(url) +} + type PipelineResultPanelProps = { resultMarkdown: string | null resultRunId: string | null @@ -23,6 +34,7 @@ export function PipelineResultPanel({ const [committedMarkdown, setCommittedMarkdown] = useState(null) const [resultRenderMode, setResultRenderMode] = useState('formatted') const [isSavingEditedVersion, setIsSavingEditedVersion] = useState(false) + const [isDownloadingDocx, setIsDownloadingDocx] = useState(false) useEffect(() => { setEditableResultMarkdown(resultMarkdown ?? '') @@ -52,17 +64,33 @@ export function PipelineResultPanel({ } const blob = new Blob([displayedResultMarkdown], { type: 'text/markdown;charset=utf-8' }) - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = `${downloadBaseName || 'pipeline-output'}.md` - document.body.appendChild(link) - link.click() - link.remove() - URL.revokeObjectURL(url) + triggerBlobDownload(blob, `${downloadBaseName || 'pipeline-output'}.md`) toast.success('Markdown download has started.') } + const onDownloadDocx = async () => { + if (!displayedResultMarkdown || !resultRunId) { + toast.error('There is no pipeline result to export yet.') + return + } + + setIsDownloadingDocx(true) + try { + // The editor's current text is sent along, so unsaved edits are exported too. + const blob = await exportPipelineResultAsDocx(resultRunId, displayedResultMarkdown) + triggerBlobDownload(blob, `${downloadBaseName || 'pipeline-output'}.docx`) + toast.success('Word download has started.') + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : 'Failed to export the result as a Word document.', + ) + } finally { + setIsDownloadingDocx(false) + } + } + const onSaveEditedVersion = async () => { if (!resultRunId || !resultMarkdown) { toast.error('There is no pipeline result to save yet.') @@ -140,6 +168,15 @@ export function PipelineResultPanel({ Download .md + + + + {isOpen ? ( +
+