diff --git a/surfsense_backend/app/routes/editor_routes.py b/surfsense_backend/app/routes/editor_routes.py index 84846ef38..b7bbd5abb 100644 --- a/surfsense_backend/app/routes/editor_routes.py +++ b/surfsense_backend/app/routes/editor_routes.py @@ -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}"'}, + ) diff --git a/surfsense_web/components/documents/DocumentNode.tsx b/surfsense_web/components/documents/DocumentNode.tsx index 4acdcf662..57a12ab3a 100644 --- a/surfsense_web/components/documents/DocumentNode.tsx +++ b/surfsense_web/components/documents/DocumentNode.tsx @@ -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(null); const rowRef = useRef(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).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 to... + {onExport && ( + + + + Export + + + + + + )} Move to... + {onExport && ( + + + + Export + + + + + + )} 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)} /> diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 832a03942..1e1e8f982 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -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} diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index 62c89831d..f7d256a95 100644 --- a/surfsense_web/components/report-panel/report-panel.tsx +++ b/surfsense_web/components/report-panel/report-panel.tsx @@ -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 = { - 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 && ( - <> - - Documents - - handleExport("pdf")} - disabled={exporting !== null} - > - PDF (.pdf) - - handleExport("docx")} - disabled={exporting !== null} - > - Word (.docx) - - handleExport("odt")} - disabled={exporting !== null} - > - OpenDocument (.odt) - - - - Web & E-Book - - handleExport("html")} - disabled={exporting !== null} - > - HTML (.html) - - handleExport("epub")} - disabled={exporting !== null} - > - EPUB (.epub) - - - - Source & Plain - - handleExport("latex")} - disabled={exporting !== null} - > - LaTeX (.tex) - - - )} - handleExport("md")} disabled={exporting !== null}> - Markdown (.md) - - {!shareToken && ( - handleExport("plain")} - disabled={exporting !== null} - > - Plain Text (.txt) - - )} + diff --git a/surfsense_web/components/shared/ExportMenuItems.tsx b/surfsense_web/components/shared/ExportMenuItems.tsx new file mode 100644 index 000000000..69833a195 --- /dev/null +++ b/surfsense_web/components/shared/ExportMenuItems.tsx @@ -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 = { + 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 && ( + <> + + Documents + + + {exporting === "pdf" && } + PDF (.pdf) + + + {exporting === "docx" && } + Word (.docx) + + + {exporting === "odt" && } + OpenDocument (.odt) + + + + Web & E-Book + + + {exporting === "html" && } + HTML (.html) + + + {exporting === "epub" && } + EPUB (.epub) + + + + Source & Plain + + + {exporting === "latex" && } + LaTeX (.tex) + + + )} + + {exporting === "md" && } + Markdown (.md) + + {showAllFormats && ( + + {exporting === "plain" && } + Plain Text (.txt) + + )} + + ); +} + +export function ExportContextItems({ + onExport, + exporting, + showAllFormats = true, +}: ExportMenuItemsProps) { + const handle = (format: string) => (e: React.MouseEvent) => { + e.stopPropagation(); + onExport(format); + }; + + return ( + <> + {showAllFormats && ( + <> + + {exporting === "pdf" && } + PDF (.pdf) + + + {exporting === "docx" && } + Word (.docx) + + + {exporting === "odt" && } + OpenDocument (.odt) + + + {exporting === "html" && } + HTML (.html) + + + {exporting === "epub" && } + EPUB (.epub) + + + {exporting === "latex" && } + LaTeX (.tex) + + + )} + + {exporting === "md" && } + Markdown (.md) + + {showAllFormats && ( + + {exporting === "plain" && } + Plain Text (.txt) + + )} + + ); +}