From 93a361b1f5fa9db19acdcc79d0a71af214435011 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hana=20Litavsk=C3=A1?= Date: Thu, 27 Aug 2026 14:45:09 +0200 Subject: [PATCH 1/4] Add template export endpoint --- .../ai_document_plugin_service/api/routes.py | 16 +++++++++ .../service/export_service.py | 35 +++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/service/src/ai_document_plugin_service/api/routes.py b/service/src/ai_document_plugin_service/api/routes.py index e4ff95d..d414bf3 100644 --- a/service/src/ai_document_plugin_service/api/routes.py +++ b/service/src/ai_document_plugin_service/api/routes.py @@ -23,6 +23,7 @@ TemplateServiceDI, ) from ai_document_plugin_service.service.errors import NotFoundError +from ai_document_plugin_service.service.export_service import JSON_MEDIA_TYPE from ai_document_plugin_service.utils.docx_export import DOCX_MEDIA_TYPE public_router = fastapi.APIRouter() @@ -66,6 +67,21 @@ async def delete_template(template_uuid: UUID, templates: TemplateServiceDI, aut await templates.delete(auth, template_uuid) +@protected_router.get( + '/templates/{template_uuid}/export', + response_class=fastapi.Response, + responses={200: {'content': {JSON_MEDIA_TYPE: {}}}}, +) +async def export_template_as_json( + template_uuid: UUID, exports: ExportServiceDI, auth: AuthenticatedDI +) -> fastapi.Response: + export = await exports.export_template_as_json(template_uuid, auth) + return fastapi.Response( + content=export.content, + media_type=JSON_MEDIA_TYPE + ) + + @protected_router.post('/pipelines/run') async def start_pipeline( payload: PipelineRunRequest, diff --git a/service/src/ai_document_plugin_service/service/export_service.py b/service/src/ai_document_plugin_service/service/export_service.py index 3ef663b..7eb717d 100644 --- a/service/src/ai_document_plugin_service/service/export_service.py +++ b/service/src/ai_document_plugin_service/service/export_service.py @@ -1,4 +1,6 @@ import asyncio +import json +import logging import re from dataclasses import dataclass from uuid import UUID @@ -14,7 +16,9 @@ DEFAULT_EXPORT_FILE_NAME = 'document' MAX_EXPORT_FILE_NAME_LENGTH = 80 _UNSAFE_FILE_NAME_CHARACTERS = re.compile(r'[^A-Za-z0-9._ -]+') +JSON_MEDIA_TYPE = 'application/json' +logger = logging.getLogger(__name__) @dataclass(frozen=True) class DocxExport: @@ -22,11 +26,25 @@ class DocxExport: file_name: str -def _docx_file_name(title: str) -> str: +@dataclass(frozen=True) +class JsonExport: + content: bytes + file_name: str + + +def _export_file_name(title: str, extension: str) -> str: """Build a filename safe to interpolate into a Content-Disposition header.""" cleaned = _UNSAFE_FILE_NAME_CHARACTERS.sub(' ', title).strip() collapsed = ' '.join(cleaned.split())[:MAX_EXPORT_FILE_NAME_LENGTH].strip() - return f'{collapsed or DEFAULT_EXPORT_FILE_NAME}.docx' + return f'{collapsed or DEFAULT_EXPORT_FILE_NAME}.{extension}' + + +def _docx_file_name(title: str) -> str: + return _export_file_name(title, 'docx') + + +def _json_file_name(title: str) -> str: + return _export_file_name(title, 'json') class ExportService: @@ -60,3 +78,16 @@ async def export_result_as_docx( title=record.title, ) return DocxExport(content=content, file_name=_docx_file_name(record.title)) + + async def export_template_as_json(self, template_uuid: UUID, auth: AuthenticatedUser) -> JsonExport: + record = await self.database.get_template(template_uuid, auth.tenant_uuid) + if record is None: + logger.error(f'Template {template_uuid} does not exist') + raise NotFoundError('Template not found') + + if record.user_uuid is not None and record.user_uuid != auth.user_uuid: + logger.error(f'User uuid {auth.user_uuid} does not belong to {template_uuid}') + raise NotFoundError('Template not found') + + content = json.dumps(record.content, ensure_ascii=False, indent=2).encode('utf-8') + b'\n' + return JsonExport(content=content, file_name=_json_file_name(record.title)) From c1c822845a6d0a36ef53912eef1f22a6a5f8400b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hana=20Litavsk=C3=A1?= Date: Thu, 27 Aug 2026 15:21:00 +0200 Subject: [PATCH 2/4] Add frontend for export --- plugin/src/client.ts | 14 ++++++ plugin/src/components/TemplateManager.tsx | 43 ++++++++++++++++++- .../settings/TenantTemplateSettings.tsx | 35 +++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/plugin/src/client.ts b/plugin/src/client.ts index 2738d2c..02c6974 100644 --- a/plugin/src/client.ts +++ b/plugin/src/client.ts @@ -268,6 +268,20 @@ export const deleteTemplate = async (templateUuid: string): Promise => { } } +export const exportTemplateAsJson = async (templateUuid: string): Promise => { + const url = `${getApiBaseUrl()}/templates/${encodeURIComponent(templateUuid)}/export` + const response = await apiFetch(url) + + if (!response.ok) { + const data = await readApiResponse<{ detail?: string }>(response, url).catch(() => ({})) + throw new Error( + 'detail' in data && data.detail ? data.detail : 'Failed to export the template as JSON.', + ) + } + + return response.blob() +} + export const exportPipelineResultAsDocx = async ( runId: string, resultMarkdown: string, diff --git a/plugin/src/components/TemplateManager.tsx b/plugin/src/components/TemplateManager.tsx index 8c1c535..791c920 100644 --- a/plugin/src/components/TemplateManager.tsx +++ b/plugin/src/components/TemplateManager.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' -import { getTemplate } from '@/client' +import { exportTemplateAsJson, getTemplate } from '@/client' import { CustomTemplateSection } from '@/components/CustomTemplateSection' import styles from '@/components/TemplateManager.module.css' import { TemplatePreview } from '@/components/TemplatePreview' @@ -36,6 +36,7 @@ export function TemplateManager({ const [isEditing, setIsEditing] = useState(false) const [detail, setDetail] = useState(null) const [isLoadingDetail, setIsLoadingDetail] = useState(false) + const [isExporting, setIsExporting] = useState(false) const selected = templates.find((template) => template.uuid === selectedUuid) const canManageSelected = selected?.scope === 'personal' @@ -143,6 +144,35 @@ export function TemplateManager({ } }, [selected, deleteByUuid, onSelectedUuidChange, resetDetail]) + const handleExport = useCallback(async () => { + if (!selected) { + return + } + + setIsExporting(true) + try { + const blob = await exportTemplateAsJson(selected.uuid) + const fileName = `${selected.title}.json` + 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) + toast.success('Template JSON download has started.') + } catch (exportError) { + toast.error( + exportError instanceof Error + ? exportError.message + : 'Failed to export the template as JSON.', + ) + } finally { + setIsExporting(false) + } + }, [selected]) + return ( <> {detail && !isEditingSelected ? ( @@ -158,11 +188,20 @@ export function TemplateManager({ type="button" className="btn btn-outline-secondary with-icon" onClick={startEditing} - disabled={isDeleting} + disabled={isDeleting || isExporting} >