feat: add multi-format document export functionality to editor routes and UI components

- Implemented a new export endpoint in the backend to support exporting documents in various formats (PDF, DOCX, HTML, LaTeX, EPUB, ODT, plain text).
- Enhanced DocumentNode and FolderTreeView components to include export options in context and dropdown menus.
- Created shared ExportMenuItems component for consistent export options across the application.
- Integrated loading indicators for export actions to improve user experience.
This commit is contained in:
Anish Sarkar 2026-03-28 02:58:38 +05:30
parent 0aa9cd6dfc
commit b5ef7afb1c
6 changed files with 411 additions and 81 deletions

View file

@ -1,19 +1,43 @@
"""
Editor routes for document editing with markdown (Plate.js frontend).
Includes multi-format export (PDF, DOCX, HTML, LaTeX, EPUB, ODT, plain text).
"""
import asyncio
import io
import logging
import os
import re
import tempfile
from datetime import UTC, datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
import pypandoc
import typst
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.db import Document, DocumentType, Permission, User, get_async_session
from app.routes.reports_routes import (
ExportFormat,
_FILE_EXTENSIONS,
_MEDIA_TYPES,
_normalize_latex_delimiters,
_strip_wrapping_code_fences,
)
from app.templates.export_helpers import (
get_html_css_path,
get_reference_docx_path,
get_typst_template_path,
)
from app.users import current_active_user
from app.utils.rbac import check_permission
logger = logging.getLogger(__name__)
router = APIRouter()
@ -212,3 +236,153 @@ async def save_document(
"message": "Document saved and will be reindexed in the background",
"updated_at": document.updated_at.isoformat(),
}
@router.get(
"/search-spaces/{search_space_id}/documents/{document_id}/export"
)
async def export_document(
search_space_id: int,
document_id: int,
format: ExportFormat = Query(
ExportFormat.PDF,
description="Export format: pdf, docx, html, latex, epub, odt, or plain",
),
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""Export a document in the requested format (reuses the report export pipeline)."""
await check_permission(
session,
user,
search_space_id,
Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space",
)
result = await session.execute(
select(Document)
.options(selectinload(Document.chunks))
.filter(
Document.id == document_id,
Document.search_space_id == search_space_id,
)
)
document = result.scalars().first()
if not document:
raise HTTPException(status_code=404, detail="Document not found")
# Resolve markdown content (same priority as editor-content endpoint)
markdown_content: str | None = document.source_markdown
if markdown_content is None and document.blocknote_document:
from app.utils.blocknote_to_markdown import blocknote_to_markdown
markdown_content = blocknote_to_markdown(document.blocknote_document)
if markdown_content is None:
chunks = sorted(document.chunks, key=lambda c: c.id)
if chunks:
markdown_content = "\n\n".join(chunk.content for chunk in chunks)
if not markdown_content or not markdown_content.strip():
raise HTTPException(
status_code=400, detail="Document has no content to export"
)
markdown_content = _strip_wrapping_code_fences(markdown_content)
markdown_content = _normalize_latex_delimiters(markdown_content)
doc_title = document.title or "Document"
formatted_date = (
document.created_at.strftime("%B %d, %Y") if document.created_at else ""
)
input_fmt = "gfm+tex_math_dollars"
meta_args = ["-M", f"title:{doc_title}", "-M", f"date:{formatted_date}"]
def _convert_and_read() -> bytes:
if format == ExportFormat.PDF:
typst_template = str(get_typst_template_path())
typst_markup: str = pypandoc.convert_text(
markdown_content,
"typst",
format=input_fmt,
extra_args=[
"--standalone",
f"--template={typst_template}",
"-V", "mainfont:Libertinus Serif",
"-V", "codefont:DejaVu Sans Mono",
*meta_args,
],
)
return typst.compile(typst_markup.encode("utf-8"))
if format == ExportFormat.DOCX:
return _pandoc_to_tempfile(
format.value,
["--standalone", f"--reference-doc={get_reference_docx_path()}", *meta_args],
)
if format == ExportFormat.HTML:
html_str: str = pypandoc.convert_text(
markdown_content,
"html5",
format=input_fmt,
extra_args=[
"--standalone", "--embed-resources",
f"--css={get_html_css_path()}",
"--syntax-highlighting=pygments",
*meta_args,
],
)
return html_str.encode("utf-8")
if format == ExportFormat.EPUB:
return _pandoc_to_tempfile("epub3", ["--standalone", *meta_args])
if format == ExportFormat.ODT:
return _pandoc_to_tempfile("odt", ["--standalone", *meta_args])
if format == ExportFormat.LATEX:
tex_str: str = pypandoc.convert_text(
markdown_content, "latex", format=input_fmt,
extra_args=["--standalone", *meta_args],
)
return tex_str.encode("utf-8")
plain_str: str = pypandoc.convert_text(
markdown_content, "plain", format=input_fmt,
extra_args=["--wrap=auto", "--columns=80"],
)
return plain_str.encode("utf-8")
def _pandoc_to_tempfile(output_format: str, extra_args: list[str]) -> bytes:
fd, tmp_path = tempfile.mkstemp(suffix=f".{output_format}")
os.close(fd)
try:
pypandoc.convert_text(
markdown_content, output_format, format=input_fmt,
extra_args=extra_args, outputfile=tmp_path,
)
with open(tmp_path, "rb") as f:
return f.read()
finally:
os.unlink(tmp_path)
try:
loop = asyncio.get_running_loop()
output = await loop.run_in_executor(None, _convert_and_read)
except Exception as e:
logger.exception("Document export failed")
raise HTTPException(status_code=500, detail=f"Export failed: {e!s}") from e
safe_title = (
"".join(c if c.isalnum() or c in " -_" else "_" for c in doc_title)
.strip()[:80]
or "document"
)
ext = _FILE_EXTENSIONS[format]
return StreamingResponse(
io.BytesIO(output),
media_type=_MEDIA_TYPES[format],
headers={"Content-Disposition": f'attachment; filename="{safe_title}.{ext}"'},
)

View file

@ -1,21 +1,28 @@
"use client";
import { AlertCircle, Clock, Eye, MoreHorizontal, Move, PenLine, Trash2 } from "lucide-react";
import { AlertCircle, Clock, Download, Eye, MoreHorizontal, Move, PenLine, Trash2 } from "lucide-react";
import React, { useCallback, useRef, useState } from "react";
import { useDrag } from "react-dnd";
import { getDocumentTypeIcon } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { ExportContextItems, ExportDropdownItems } from "@/components/shared/ExportMenuItems";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
@ -41,6 +48,7 @@ interface DocumentNodeProps {
onEdit: (doc: DocumentNodeDoc) => void;
onDelete: (doc: DocumentNodeDoc) => void;
onMove: (doc: DocumentNodeDoc) => void;
onExport?: (doc: DocumentNodeDoc, format: string) => void;
contextMenuOpen?: boolean;
onContextMenuOpenChange?: (open: boolean) => void;
}
@ -54,6 +62,7 @@ export const DocumentNode = React.memo(function DocumentNode({
onEdit,
onDelete,
onMove,
onExport,
contextMenuOpen,
onContextMenuOpenChange,
}: DocumentNodeProps) {
@ -79,8 +88,19 @@ export const DocumentNode = React.memo(function DocumentNode({
const isProcessing = statusState === "pending" || statusState === "processing";
const [dropdownOpen, setDropdownOpen] = useState(false);
const [exporting, setExporting] = useState<string | null>(null);
const rowRef = useRef<HTMLButtonElement>(null);
const handleExport = useCallback(
(format: string) => {
if (!onExport) return;
setExporting(format);
onExport(doc, format);
setTimeout(() => setExporting(null), 2000);
},
[doc, onExport]
);
const attachRef = useCallback(
(node: HTMLButtonElement | null) => {
(rowRef as React.MutableRefObject<HTMLButtonElement | null>).current = node;
@ -167,7 +187,7 @@ export const DocumentNode = React.memo(function DocumentNode({
variant="ghost"
size="icon"
className={cn(
"hidden sm:inline-flex h-6 w-6 shrink-0 transition-opacity hover:bg-transparent",
"hidden sm:inline-flex h-6 w-6 shrink-0 hover:bg-transparent",
dropdownOpen ? "opacity-100 bg-accent hover:bg-accent" : "opacity-0 group-hover:opacity-100"
)}
onClick={(e) => e.stopPropagation()}
@ -190,6 +210,17 @@ export const DocumentNode = React.memo(function DocumentNode({
<Move className="mr-2 h-4 w-4" />
Move to...
</DropdownMenuItem>
{onExport && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="mr-2 h-4 w-4" />
Export
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[180px]">
<ExportDropdownItems onExport={handleExport} exporting={exporting} />
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={isProcessing}
@ -219,6 +250,17 @@ export const DocumentNode = React.memo(function DocumentNode({
<Move className="mr-2 h-4 w-4" />
Move to...
</ContextMenuItem>
{onExport && (
<ContextMenuSub>
<ContextMenuSubTrigger>
<Download className="mr-2 h-4 w-4" />
Export
</ContextMenuSubTrigger>
<ContextMenuSubContent className="min-w-[180px]">
<ExportContextItems onExport={handleExport} exporting={exporting} />
</ContextMenuSubContent>
</ContextMenuSub>
)}
<ContextMenuItem
className="text-destructive focus:text-destructive"
disabled={isProcessing}

View file

@ -28,6 +28,7 @@ interface FolderTreeViewProps {
onEditDocument: (doc: DocumentNodeDoc) => void;
onDeleteDocument: (doc: DocumentNodeDoc) => void;
onMoveDocument: (doc: DocumentNodeDoc) => void;
onExportDocument?: (doc: DocumentNodeDoc, format: string) => void;
activeTypes: DocumentTypeEnum[];
onDropIntoFolder?: (
itemType: "folder" | "document",
@ -62,6 +63,7 @@ export function FolderTreeView({
onEditDocument,
onDeleteDocument,
onMoveDocument,
onExportDocument,
activeTypes,
onDropIntoFolder,
onReorderFolder,
@ -181,6 +183,7 @@ export function FolderTreeView({
onEdit={onEditDocument}
onDelete={onDeleteDocument}
onMove={onMoveDocument}
onExport={onExportDocument}
contextMenuOpen={openContextMenuId === `doc-${d.id}`}
onContextMenuOpenChange={(open) => setOpenContextMenuId(open ? `doc-${d.id}` : null)}
/>

View file

@ -7,6 +7,7 @@ import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters";
import {
DocumentsTableShell,
@ -33,6 +34,7 @@ import { useDocumentSearch } from "@/hooks/use-document-search";
import { useDocuments } from "@/hooks/use-documents";
import { useMediaQuery } from "@/hooks/use-media-query";
import { foldersApiService } from "@/lib/apis/folders-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { queries } from "@/zero/queries/index";
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
@ -234,6 +236,43 @@ export function DocumentsSidebar({
setFolderPickerOpen(true);
}, []);
const handleExportDocument = useCallback(
async (doc: DocumentNodeDoc, format: string) => {
const safeTitle =
doc.title
.replace(/[^a-zA-Z0-9 _-]/g, "_")
.trim()
.slice(0, 80) || "document";
const ext = EXPORT_FILE_EXTENSIONS[format] ?? format;
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${doc.id}/export?format=${format}`,
{ method: "GET" }
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Export failed" }));
throw new Error(errorData.detail || "Export failed");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${safeTitle}.${ext}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error(`Export ${format} failed:`, err);
toast.error(err instanceof Error ? err.message : `Export failed`);
}
},
[searchSpaceId]
);
const handleFolderPickerSelect = useCallback(
async (targetFolderId: number | null) => {
if (!folderPickerTarget) return;
@ -606,6 +645,7 @@ export function DocumentsSidebar({
}}
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
onMoveDocument={handleMoveDocument}
onExportDocument={handleExportDocument}
activeTypes={activeTypes}
onDropIntoFolder={handleDropIntoFolder}
onReorderFolder={handleReorderFolder}

View file

@ -15,10 +15,9 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ExportDropdownItems, EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
import { useMediaQuery } from "@/hooks/use-media-query";
import { baseApiService } from "@/lib/apis/base-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
@ -198,19 +197,6 @@ export function ReportPanelContent({
}
}, [currentMarkdown]);
// Maps backend format values to download file extensions
const FILE_EXTENSIONS: Record<string, string> = {
pdf: "pdf",
docx: "docx",
html: "html",
latex: "tex",
epub: "epub",
odt: "odt",
plain: "txt",
md: "md",
};
// Export report
const handleExport = useCallback(
async (format: string) => {
setExporting(format);
@ -219,7 +205,7 @@ export function ReportPanelContent({
.replace(/[^a-zA-Z0-9 _-]/g, "_")
.trim()
.slice(0, 80) || "report";
const ext = FILE_EXTENSIONS[format] ?? format;
const ext = EXPORT_FILE_EXTENSIONS[format] ?? format;
try {
if (format === "md") {
if (!currentMarkdown) return;
@ -329,68 +315,11 @@ export function ReportPanelContent({
align="start"
className={`min-w-[200px] select-none${insideDrawer ? " z-[100]" : ""}`}
>
{!shareToken && (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Documents
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => handleExport("pdf")}
disabled={exporting !== null}
>
PDF (.pdf)
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExport("docx")}
disabled={exporting !== null}
>
Word (.docx)
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExport("odt")}
disabled={exporting !== null}
>
OpenDocument (.odt)
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
Web &amp; E-Book
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => handleExport("html")}
disabled={exporting !== null}
>
HTML (.html)
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExport("epub")}
disabled={exporting !== null}
>
EPUB (.epub)
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
Source &amp; Plain
</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => handleExport("latex")}
disabled={exporting !== null}
>
LaTeX (.tex)
</DropdownMenuItem>
</>
)}
<DropdownMenuItem onClick={() => handleExport("md")} disabled={exporting !== null}>
Markdown (.md)
</DropdownMenuItem>
{!shareToken && (
<DropdownMenuItem
onClick={() => handleExport("plain")}
disabled={exporting !== null}
>
Plain Text (.txt)
</DropdownMenuItem>
)}
<ExportDropdownItems
onExport={handleExport}
exporting={exporting}
showAllFormats={!shareToken}
/>
</DropdownMenuContent>
</DropdownMenu>

View file

@ -0,0 +1,142 @@
"use client";
import { Loader2 } from "lucide-react";
import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { ContextMenuItem } from "@/components/ui/context-menu";
export const EXPORT_FILE_EXTENSIONS: Record<string, string> = {
pdf: "pdf",
docx: "docx",
html: "html",
latex: "tex",
epub: "epub",
odt: "odt",
plain: "txt",
md: "md",
};
interface ExportMenuItemsProps {
onExport: (format: string) => void;
exporting: string | null;
/** Hide server-side formats (PDF, DOCX, etc.) — only show md */
showAllFormats?: boolean;
}
export function ExportDropdownItems({
onExport,
exporting,
showAllFormats = true,
}: ExportMenuItemsProps) {
const handle = (format: string) => (e: React.MouseEvent) => {
e.stopPropagation();
onExport(format);
};
return (
<>
{showAllFormats && (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Documents
</DropdownMenuLabel>
<DropdownMenuItem onClick={handle("pdf")} disabled={exporting !== null}>
{exporting === "pdf" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
PDF (.pdf)
</DropdownMenuItem>
<DropdownMenuItem onClick={handle("docx")} disabled={exporting !== null}>
{exporting === "docx" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
Word (.docx)
</DropdownMenuItem>
<DropdownMenuItem onClick={handle("odt")} disabled={exporting !== null}>
{exporting === "odt" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
OpenDocument (.odt)
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
Web &amp; E-Book
</DropdownMenuLabel>
<DropdownMenuItem onClick={handle("html")} disabled={exporting !== null}>
{exporting === "html" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
HTML (.html)
</DropdownMenuItem>
<DropdownMenuItem onClick={handle("epub")} disabled={exporting !== null}>
{exporting === "epub" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
EPUB (.epub)
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
Source &amp; Plain
</DropdownMenuLabel>
<DropdownMenuItem onClick={handle("latex")} disabled={exporting !== null}>
{exporting === "latex" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
LaTeX (.tex)
</DropdownMenuItem>
</>
)}
<DropdownMenuItem onClick={handle("md")} disabled={exporting !== null}>
{exporting === "md" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
Markdown (.md)
</DropdownMenuItem>
{showAllFormats && (
<DropdownMenuItem onClick={handle("plain")} disabled={exporting !== null}>
{exporting === "plain" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
Plain Text (.txt)
</DropdownMenuItem>
)}
</>
);
}
export function ExportContextItems({
onExport,
exporting,
showAllFormats = true,
}: ExportMenuItemsProps) {
const handle = (format: string) => (e: React.MouseEvent) => {
e.stopPropagation();
onExport(format);
};
return (
<>
{showAllFormats && (
<>
<ContextMenuItem onClick={handle("pdf")} disabled={exporting !== null}>
{exporting === "pdf" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
PDF (.pdf)
</ContextMenuItem>
<ContextMenuItem onClick={handle("docx")} disabled={exporting !== null}>
{exporting === "docx" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
Word (.docx)
</ContextMenuItem>
<ContextMenuItem onClick={handle("odt")} disabled={exporting !== null}>
{exporting === "odt" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
OpenDocument (.odt)
</ContextMenuItem>
<ContextMenuItem onClick={handle("html")} disabled={exporting !== null}>
{exporting === "html" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
HTML (.html)
</ContextMenuItem>
<ContextMenuItem onClick={handle("epub")} disabled={exporting !== null}>
{exporting === "epub" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
EPUB (.epub)
</ContextMenuItem>
<ContextMenuItem onClick={handle("latex")} disabled={exporting !== null}>
{exporting === "latex" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
LaTeX (.tex)
</ContextMenuItem>
</>
)}
<ContextMenuItem onClick={handle("md")} disabled={exporting !== null}>
{exporting === "md" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
Markdown (.md)
</ContextMenuItem>
{showAllFormats && (
<ContextMenuItem onClick={handle("plain")} disabled={exporting !== null}>
{exporting === "plain" && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
Plain Text (.txt)
</ContextMenuItem>
)}
</>
);
}