Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions plugin/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,22 @@ export const deleteTemplate = async (templateUuid: string): Promise<void> => {
}
}

export const exportTemplateAsJson = async (templateUuid: string): Promise<Blob> => {
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,
Expand Down
43 changes: 41 additions & 2 deletions plugin/src/components/TemplateManager.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -36,6 +36,7 @@ export function TemplateManager({
const [isEditing, setIsEditing] = useState(false)
const [detail, setDetail] = useState<TemplateDetail | null>(null)
const [isLoadingDetail, setIsLoadingDetail] = useState(false)
const [isExporting, setIsExporting] = useState(false)

const selected = templates.find((template) => template.uuid === selectedUuid)
const canManageSelected = selected?.scope === 'personal'
Expand Down Expand Up @@ -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 ? (
Expand All @@ -158,11 +188,20 @@ export function TemplateManager({
type="button"
className="btn btn-outline-secondary with-icon"
onClick={startEditing}
disabled={isDeleting}
disabled={isDeleting || isExporting}
>
<i className="fas fa-pen" aria-hidden="true" />
Edit template
</button>
<button
type="button"
className="btn btn-outline-secondary with-icon"
onClick={() => void handleExport()}
disabled={isDeleting || isExporting}
>
<i className="fas fa-download" aria-hidden="true" />
{isExporting ? 'Exporting...' : 'Export template'}
</button>
<button
type="button"
className="btn btn-outline-danger with-icon"
Expand Down
37 changes: 37 additions & 0 deletions plugin/src/components/settings/TenantTemplateSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { toast } from 'sonner'

import { exportTemplateAsJson } from '@/client'
import { CustomTemplateSection, type EditingTemplate } from '@/components/CustomTemplateSection'
import styles from '@/components/settings/TenantTemplateSettings.module.css'
import { useTemplates } from '@/hooks/useTemplates'
Expand Down Expand Up @@ -60,6 +61,31 @@ export function TenantTemplateSection() {
setBusyUuid(null)
}

const handleExport = async (template: TemplateOption) => {
setBusyUuid(template.uuid)
try {
const blob = await exportTemplateAsJson(template.uuid)
const fileName = `${template.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 {
setBusyUuid(null)
}
}

if (isLoading) {
return (
<section className={styles.root}>
Expand Down Expand Up @@ -99,6 +125,17 @@ export function TenantTemplateSection() {
<i className="fas fa-pen" aria-hidden="true" />
Edit
</button>
<button
type="button"
className="btn btn-outline-secondary btn-sm with-icon"
onClick={() => void handleExport(template)}
disabled={busyUuid !== null}
>
<i className="fas fa-download" aria-hidden="true" />
{busyUuid === template.uuid
? 'Exporting...'
: 'Export template'}
</button>
<button
type="button"
className="btn btn-outline-danger btn-sm with-icon"
Expand Down
16 changes: 16 additions & 0 deletions service/src/ai_document_plugin_service/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions service/src/ai_document_plugin_service/service/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ def __init__(self, detail: str, *, status_code: int) -> None:

class NotFoundError(ServiceError):
PIPELINE_RUN_MESSAGE = 'Pipeline run not found'
TEMPLATE_MESSAGE = 'Template not found'

def __init__(self, detail: str = 'Not found') -> None:
super().__init__(detail, status_code=404)
Expand Down
34 changes: 32 additions & 2 deletions service/src/ai_document_plugin_service/service/export_service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import asyncio
import json
import logging
import re
from dataclasses import dataclass
from uuid import UUID
Expand All @@ -14,6 +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)
Expand All @@ -22,11 +27,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:
Expand Down Expand Up @@ -60,3 +79,14 @@ 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:
raise NotFoundError(NotFoundError.TEMPLATE_MESSAGE)

if record.user_uuid is not None and record.user_uuid != auth.user_uuid:
raise NotFoundError(NotFoundError.TEMPLATE_MESSAGE)

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))