diff --git a/surfsense_backend/alembic/versions/38_add_blocknote_fields_to_documents.py b/surfsense_backend/alembic/versions/38_add_blocknote_fields_to_documents.py index d882f020b..742771322 100644 --- a/surfsense_backend/alembic/versions/38_add_blocknote_fields_to_documents.py +++ b/surfsense_backend/alembic/versions/38_add_blocknote_fields_to_documents.py @@ -4,6 +4,7 @@ Revision ID: 38 Revises: 37 """ + from collections.abc import Sequence import sqlalchemy as sa @@ -12,30 +13,38 @@ from sqlalchemy.dialects import postgresql from alembic import op # revision identifiers, used by Alembic. -revision: str = '38' -down_revision: str | None = '37' +revision: str = "38" +down_revision: str | None = "37" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None def upgrade() -> None: """Upgrade schema - Add BlockNote fields only.""" - + op.add_column( - 'documents', - sa.Column('blocknote_document', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + "documents", + sa.Column( + "blocknote_document", postgresql.JSONB(astext_type=sa.Text()), nullable=True + ), ) op.add_column( - 'documents', - sa.Column('content_needs_reindexing', sa.Boolean(), nullable=False, server_default=sa.false()), + "documents", + sa.Column( + "content_needs_reindexing", + sa.Boolean(), + nullable=False, + server_default=sa.false(), + ), ) op.add_column( - 'documents', - sa.Column('last_edited_at', sa.TIMESTAMP(timezone=True), nullable=True) + "documents", + sa.Column("last_edited_at", sa.TIMESTAMP(timezone=True), nullable=True), ) + def downgrade() -> None: """Downgrade schema - Remove BlockNote fields.""" - op.drop_column('documents', 'last_edited_at') - op.drop_column('documents', 'content_needs_reindexing') - op.drop_column('documents', 'blocknote_document') \ No newline at end of file + op.drop_column("documents", "last_edited_at") + op.drop_column("documents", "content_needs_reindexing") + op.drop_column("documents", "blocknote_document") diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 38505e931..890ea2473 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -178,15 +178,15 @@ class Document(BaseModel, TimestampMixin): content_hash = Column(String, nullable=False, index=True, unique=True) unique_identifier_hash = Column(String, nullable=True, index=True, unique=True) embedding = Column(Vector(config.embedding_model_instance.dimension)) - + # BlockNote live editing state (NULL when never edited) blocknote_document = Column(JSONB, nullable=True) - + # blocknote background reindex flag content_needs_reindexing = Column( Boolean, nullable=False, default=False, server_default=text("false") ) - + # Track when blocknote document was last edited last_edited_at = Column(TIMESTAMP(timezone=True), nullable=True) diff --git a/surfsense_backend/app/routes/editor_routes.py b/surfsense_backend/app/routes/editor_routes.py index 4579ec874..f4c4ca38e 100644 --- a/surfsense_backend/app/routes/editor_routes.py +++ b/surfsense_backend/app/routes/editor_routes.py @@ -1,6 +1,7 @@ """ Editor routes for BlockNote document editing. """ + from datetime import UTC, datetime from typing import Any @@ -26,7 +27,7 @@ async def get_editor_content( ): """ Get document content for editing. - + Returns BlockNote JSON document. If blocknote_document is NULL, attempts to convert from `content` - though this won't work well for old documents that only have summaries. @@ -37,24 +38,26 @@ async def get_editor_content( .filter(Document.id == document_id, SearchSpace.user_id == user.id) ) document = result.scalars().first() - + if not document: raise HTTPException(status_code=404, detail="Document not found") - + # If blocknote_document exists, return it if document.blocknote_document: return { "document_id": document.id, "title": document.title, "blocknote_document": document.blocknote_document, - "last_edited_at": document.last_edited_at.isoformat() if document.last_edited_at else None, + "last_edited_at": document.last_edited_at.isoformat() + if document.last_edited_at + else None, } - + # For old documents without blocknote_document, return error # (Can't convert summary back to full document) raise HTTPException( status_code=400, - detail="This document was uploaded before editing was enabled. Please re-upload to enable editing." + detail="This document was uploaded before editing was enabled. Please re-upload to enable editing.", ) @@ -75,21 +78,21 @@ async def update_blocknote_content( .filter(Document.id == document_id, SearchSpace.user_id == user.id) ) document = result.scalars().first() - + if not document: raise HTTPException(status_code=404, detail="Document not found") - + blocknote_document = data.get("blocknote_document") if not blocknote_document: raise HTTPException(status_code=400, detail="blocknote_document is required") - + # Update only blocknote_document and last_edited_at document.blocknote_document = blocknote_document document.last_edited_at = datetime.now(UTC) - + await session.commit() await session.refresh(document) - + return {"status": "saved", "last_edited_at": document.last_edited_at.isoformat()} @@ -110,52 +113,51 @@ async def update_blocknote_content( # .filter(Document.id == document_id, SearchSpace.user_id == user.id) # ) # document = result.scalars().first() - + # if not document: # raise HTTPException(status_code=404, detail="Document not found") - + # if not document.blocknote_document: # raise HTTPException( # status_code=400, # detail="Document has no BlockNote content to finalize" # ) - + # # 1. Convert BlockNote JSON → Markdown # full_markdown = await convert_blocknote_to_markdown(document.blocknote_document) - + # if not full_markdown: # raise HTTPException( # status_code=500, # detail="Failed to convert BlockNote document to markdown" # ) - + # # 2. Generate new summary from full markdown # from app.services.llm_service import get_user_long_context_llm # from app.utils.document_converters import generate_document_summary - + # user_llm = await get_user_long_context_llm(session, str(user.id), document.search_space_id) # if not user_llm: # raise HTTPException( # status_code=500, # detail="No LLM configured for summary generation" # ) - + # document_metadata = document.document_metadata or {} # summary_content, summary_embedding = await generate_document_summary( # full_markdown, user_llm, document_metadata # ) - + # # 3. Update document fields # document.content = summary_content # document.embedding = summary_embedding # document.content_needs_reindexing = True # Trigger chunk regeneration # document.last_edited_at = datetime.now(UTC) - + # await session.commit() - + # return { # "status": "finalized", # "message": "Document saved. Summary and chunks will be regenerated in the background.", # "content_needs_reindexing": True, # } - \ No newline at end of file diff --git a/surfsense_backend/app/tasks/document_processors/extension_processor.py b/surfsense_backend/app/tasks/document_processors/extension_processor.py index 640775372..48e3efe27 100644 --- a/surfsense_backend/app/tasks/document_processors/extension_processor.py +++ b/surfsense_backend/app/tasks/document_processors/extension_processor.py @@ -144,9 +144,9 @@ async def add_extension_received_document( # Process chunks chunks = await create_document_chunks(content.pageContent) - + from app.utils.blocknote_converter import convert_markdown_to_blocknote - + # Convert markdown to BlockNote JSON blocknote_json = await convert_markdown_to_blocknote(combined_document_string) if not blocknote_json: diff --git a/surfsense_backend/app/tasks/document_processors/file_processors.py b/surfsense_backend/app/tasks/document_processors/file_processors.py index c4b75b0bd..95cf1c462 100644 --- a/surfsense_backend/app/tasks/document_processors/file_processors.py +++ b/surfsense_backend/app/tasks/document_processors/file_processors.py @@ -99,14 +99,15 @@ async def add_received_file_document_using_unstructured( # Process chunks chunks = await create_document_chunks(file_in_markdown) - + from app.utils.blocknote_converter import convert_markdown_to_blocknote - + # Convert markdown to BlockNote JSON blocknote_json = await convert_markdown_to_blocknote(file_in_markdown) if not blocknote_json: - logging.warning(f"Failed to convert {file_name} to BlockNote JSON, document will not be editable") - + logging.warning( + f"Failed to convert {file_name} to BlockNote JSON, document will not be editable" + ) # Update or create document if existing_document: @@ -223,14 +224,15 @@ async def add_received_file_document_using_llamacloud( # Process chunks chunks = await create_document_chunks(file_in_markdown) - + from app.utils.blocknote_converter import convert_markdown_to_blocknote # Convert markdown to BlockNote JSON blocknote_json = await convert_markdown_to_blocknote(file_in_markdown) if not blocknote_json: - logging.warning(f"Failed to convert {file_name} to BlockNote JSON, document will not be editable") - + logging.warning( + f"Failed to convert {file_name} to BlockNote JSON, document will not be editable" + ) # Update or create document if existing_document: @@ -372,14 +374,15 @@ async def add_received_file_document_using_docling( # Process chunks chunks = await create_document_chunks(file_in_markdown) - + from app.utils.blocknote_converter import convert_markdown_to_blocknote - + # Convert markdown to BlockNote JSON blocknote_json = await convert_markdown_to_blocknote(file_in_markdown) if not blocknote_json: - logging.warning(f"Failed to convert {file_name} to BlockNote JSON, document will not be editable") - + logging.warning( + f"Failed to convert {file_name} to BlockNote JSON, document will not be editable" + ) # Update or create document if existing_document: diff --git a/surfsense_backend/app/tasks/document_processors/markdown_processor.py b/surfsense_backend/app/tasks/document_processors/markdown_processor.py index b63d2bdf2..3036071c9 100644 --- a/surfsense_backend/app/tasks/document_processors/markdown_processor.py +++ b/surfsense_backend/app/tasks/document_processors/markdown_processor.py @@ -109,14 +109,15 @@ async def add_received_markdown_file_document( # Process chunks chunks = await create_document_chunks(file_in_markdown) - + from app.utils.blocknote_converter import convert_markdown_to_blocknote - + # Convert to BlockNote JSON blocknote_json = await convert_markdown_to_blocknote(file_in_markdown) if not blocknote_json: - logging.warning(f"Failed to convert {file_name} to BlockNote JSON, document will not be editable") - + logging.warning( + f"Failed to convert {file_name} to BlockNote JSON, document will not be editable" + ) # Update or create document if existing_document: diff --git a/surfsense_backend/app/tasks/document_processors/url_crawler.py b/surfsense_backend/app/tasks/document_processors/url_crawler.py index d392031ea..1b516b5bf 100644 --- a/surfsense_backend/app/tasks/document_processors/url_crawler.py +++ b/surfsense_backend/app/tasks/document_processors/url_crawler.py @@ -247,7 +247,7 @@ async def add_crawled_url_document( f"Processing content chunks for URL: {url}", {"stage": "chunk_processing"}, ) - + from app.utils.blocknote_converter import convert_markdown_to_blocknote # Convert markdown to BlockNote JSON diff --git a/surfsense_backend/app/tasks/document_processors/youtube_processor.py b/surfsense_backend/app/tasks/document_processors/youtube_processor.py index e479ec773..332e775e1 100644 --- a/surfsense_backend/app/tasks/document_processors/youtube_processor.py +++ b/surfsense_backend/app/tasks/document_processors/youtube_processor.py @@ -290,7 +290,7 @@ async def add_youtube_video_document( f"Processing content chunks for video: {video_data.get('title', 'YouTube Video')}", {"stage": "chunk_processing"}, ) - + from app.utils.blocknote_converter import convert_markdown_to_blocknote # Convert transcript to BlockNote JSON diff --git a/surfsense_backend/app/utils/blocknote_converter.py b/surfsense_backend/app/utils/blocknote_converter.py index ce5cbf64b..b57a82996 100644 --- a/surfsense_backend/app/utils/blocknote_converter.py +++ b/surfsense_backend/app/utils/blocknote_converter.py @@ -11,17 +11,17 @@ logger = logging.getLogger(__name__) async def convert_markdown_to_blocknote(markdown: str) -> dict[str, Any] | None: """ Convert markdown to BlockNote JSON via Next.js API. - + Args: markdown: Markdown string to convert - + Returns: BlockNote document as dict, or None if conversion fails """ if not markdown or not markdown.strip(): logger.warning("Empty markdown provided for conversion") return None - + if not markdown or len(markdown) < 10: logger.warning("Markdown became too short after sanitization") # Return a minimal BlockNote document @@ -32,13 +32,13 @@ async def convert_markdown_to_blocknote(markdown: str) -> dict[str, Any] | None: { "type": "text", "text": "Document content could not be converted for editing.", - "styles": {} + "styles": {}, } ], - "children": [] + "children": [], } ] - + async with httpx.AsyncClient() as client: try: response = await client.post( @@ -49,19 +49,23 @@ async def convert_markdown_to_blocknote(markdown: str) -> dict[str, Any] | None: response.raise_for_status() data = response.json() blocknote_document = data.get("blocknote_document") - + if blocknote_document: - logger.info(f"Successfully converted markdown to BlockNote (original: {len(markdown)} chars, sanitized: {len(markdown)} chars)") + logger.info( + f"Successfully converted markdown to BlockNote (original: {len(markdown)} chars, sanitized: {len(markdown)} chars)" + ) return blocknote_document else: logger.warning("Next.js API returned empty blocknote_document") return None - + except httpx.TimeoutException: logger.error("Timeout converting markdown to BlockNote after 30s") return None except httpx.HTTPStatusError as e: - logger.error(f"HTTP error converting markdown to BlockNote: {e.response.status_code} - {e.response.text}") + logger.error( + f"HTTP error converting markdown to BlockNote: {e.response.status_code} - {e.response.text}" + ) # Log first 1000 chars of problematic markdown for debugging logger.debug(f"Problematic markdown sample: {markdown[:1000]}") return None @@ -69,20 +73,23 @@ async def convert_markdown_to_blocknote(markdown: str) -> dict[str, Any] | None: logger.error(f"Failed to convert markdown to BlockNote: {e}", exc_info=True) return None -async def convert_blocknote_to_markdown(blocknote_document: dict[str, Any] | list[dict[str, Any]]) -> str | None: + +async def convert_blocknote_to_markdown( + blocknote_document: dict[str, Any] | list[dict[str, Any]], +) -> str | None: """ Convert BlockNote JSON to markdown via Next.js API. - + Args: blocknote_document: BlockNote document as dict or list of blocks - + Returns: Markdown string, or None if conversion fails """ if not blocknote_document: logger.warning("Empty BlockNote document provided for conversion") return None - + async with httpx.AsyncClient() as client: try: response = await client.post( @@ -93,21 +100,24 @@ async def convert_blocknote_to_markdown(blocknote_document: dict[str, Any] | lis response.raise_for_status() data = response.json() markdown = data.get("markdown") - + if markdown: - logger.info(f"Successfully converted BlockNote to markdown ({len(markdown)} chars)") + logger.info( + f"Successfully converted BlockNote to markdown ({len(markdown)} chars)" + ) return markdown else: logger.warning("Next.js API returned empty markdown") return None - + except httpx.TimeoutException: logger.error("Timeout converting BlockNote to markdown after 30s") return None except httpx.HTTPStatusError as e: - logger.error(f"HTTP error converting BlockNote to markdown: {e.response.status_code} - {e.response.text}") + logger.error( + f"HTTP error converting BlockNote to markdown: {e.response.status_code} - {e.response.text}" + ) return None except Exception as e: logger.error(f"Failed to convert BlockNote to markdown: {e}", exc_info=True) return None - \ No newline at end of file diff --git a/surfsense_web/app/api/convert-to-blocknote/route.ts b/surfsense_web/app/api/convert-to-blocknote/route.ts index 2cb15fb31..e11c9cb47 100644 --- a/surfsense_web/app/api/convert-to-blocknote/route.ts +++ b/surfsense_web/app/api/convert-to-blocknote/route.ts @@ -2,42 +2,39 @@ import { ServerBlockNoteEditor } from "@blocknote/server-util"; import { type NextRequest, NextResponse } from "next/server"; export async function POST(request: NextRequest) { - try { - const { markdown } = await request.json(); - - if (!markdown || typeof markdown !== "string") { - return NextResponse.json( - { error: "Markdown string is required" }, - { status: 400 } - ); - } - - // Log raw markdown input before conversion - // console.log(`\n${"=".repeat(80)}`); - // console.log("RAW MARKDOWN INPUT (BEFORE CONVERSION):"); - // console.log("=".repeat(80)); - // console.log(markdown); - // console.log(`${"=".repeat(80)}\n`); - - // Create server-side editor instance - const editor = ServerBlockNoteEditor.create(); - - // Convert markdown directly to BlockNote blocks - const blocks = await editor.tryParseMarkdownToBlocks(markdown); - - if (!blocks || blocks.length === 0) { - throw new Error("Markdown parsing returned no blocks"); - } - - return NextResponse.json({ blocknote_document: blocks }); - } catch (error: any) { - console.error("Failed to convert markdown to BlockNote:", error); - return NextResponse.json( - { - error: "Failed to convert markdown to BlockNote blocks", - details: error.message - }, - { status: 500 } - ); - } + try { + const { markdown } = await request.json(); + + if (!markdown || typeof markdown !== "string") { + return NextResponse.json({ error: "Markdown string is required" }, { status: 400 }); + } + + // Log raw markdown input before conversion + // console.log(`\n${"=".repeat(80)}`); + // console.log("RAW MARKDOWN INPUT (BEFORE CONVERSION):"); + // console.log("=".repeat(80)); + // console.log(markdown); + // console.log(`${"=".repeat(80)}\n`); + + // Create server-side editor instance + const editor = ServerBlockNoteEditor.create(); + + // Convert markdown directly to BlockNote blocks + const blocks = await editor.tryParseMarkdownToBlocks(markdown); + + if (!blocks || blocks.length === 0) { + throw new Error("Markdown parsing returned no blocks"); + } + + return NextResponse.json({ blocknote_document: blocks }); + } catch (error: any) { + console.error("Failed to convert markdown to BlockNote:", error); + return NextResponse.json( + { + error: "Failed to convert markdown to BlockNote blocks", + details: error.message, + }, + { status: 500 } + ); + } } diff --git a/surfsense_web/app/api/convert-to-markdown/route.ts b/surfsense_web/app/api/convert-to-markdown/route.ts index 76faf45f3..7005a800f 100644 --- a/surfsense_web/app/api/convert-to-markdown/route.ts +++ b/surfsense_web/app/api/convert-to-markdown/route.ts @@ -2,30 +2,27 @@ import { ServerBlockNoteEditor } from "@blocknote/server-util"; import { type NextRequest, NextResponse } from "next/server"; export async function POST(request: NextRequest) { - try { - const { blocknote_document } = await request.json(); - - if (!blocknote_document || !Array.isArray(blocknote_document)) { - return NextResponse.json( - { error: "BlockNote document array is required" }, - { status: 400 } - ); - } - - // Create server-side editor instance - const editor = ServerBlockNoteEditor.create(); - - // Convert BlockNote blocks to markdown - const markdown = await editor.blocksToMarkdownLossy(blocknote_document); - - return NextResponse.json({ - markdown - }); - } catch (error) { - console.error("Failed to convert BlockNote to markdown:", error); - return NextResponse.json( - { error: "Failed to convert BlockNote blocks to markdown" }, - { status: 500 } - ); - } + try { + const { blocknote_document } = await request.json(); + + if (!blocknote_document || !Array.isArray(blocknote_document)) { + return NextResponse.json({ error: "BlockNote document array is required" }, { status: 400 }); + } + + // Create server-side editor instance + const editor = ServerBlockNoteEditor.create(); + + // Convert BlockNote blocks to markdown + const markdown = await editor.blocksToMarkdownLossy(blocknote_document); + + return NextResponse.json({ + markdown, + }); + } catch (error) { + console.error("Failed to convert BlockNote to markdown:", error); + return NextResponse.json( + { error: "Failed to convert BlockNote blocks to markdown" }, + { status: 500 } + ); + } } diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx index 5967e62ca..ea4c66228 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx @@ -1,6 +1,6 @@ "use client"; -import { MoreHorizontal, Pencil, FileText, Trash2 } from "lucide-react"; +import { FileText, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; diff --git a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx index 5371111f3..d408de44c 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/editor/[documentId]/page.tsx @@ -1,9 +1,9 @@ "use client"; +import { AlertCircle, FileText, Loader2, Save, X } from "lucide-react"; +import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; -import { motion } from "motion/react"; -import { Loader2, Save, X, FileText, AlertCircle } from "lucide-react"; import { toast } from "sonner"; import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor"; import { Button } from "@/components/ui/button"; @@ -12,286 +12,281 @@ import { Separator } from "@/components/ui/separator"; import { cn } from "@/lib/utils"; interface EditorContent { - document_id: number; - title: string; - blocknote_document: any; - last_edited_at: string | null; + document_id: number; + title: string; + blocknote_document: any; + last_edited_at: string | null; } export default function EditorPage() { - const params = useParams(); - const router = useRouter(); - const documentId = params.documentId as string; - - const [document, setDocument] = useState(null); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [editorContent, setEditorContent] = useState(null); - const [error, setError] = useState(null); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - - // Get auth token - const token = typeof window !== "undefined" - ? localStorage.getItem("surfsense_bearer_token") - : null; - - // Fetch document content - DIRECT CALL TO FASTAPI - useEffect(() => { - async function fetchDocument() { - if (!token) { - console.error("No auth token found"); - setError("Please login to access the editor"); - setLoading(false); - return; - } - - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}/editor-content`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ detail: "Failed to fetch document" })); - throw new Error(errorData.detail || "Failed to fetch document"); - } - - const data = await response.json(); - - // Check if blocknote_document exists - if (!data.blocknote_document) { - setError("This document does not have BlockNote content. Please re-upload the document to enable editing."); - setLoading(false); - return; - } - - setDocument(data); - setEditorContent(data.blocknote_document); - setError(null); - } catch (error) { - console.error("Error fetching document:", error); - setError(error instanceof Error ? error.message : "Failed to fetch document. Please try again."); - } finally { - setLoading(false); - } - } - - if (documentId && token) { - fetchDocument(); - } - }, [documentId, token]); - - // Track changes to mark as unsaved - useEffect(() => { - if (editorContent && document) { - setHasUnsavedChanges(true); - } - }, [editorContent, document]); - - // Auto-save every 30 seconds - DIRECT CALL TO FASTAPI - useEffect(() => { - if (!editorContent || !token || !hasUnsavedChanges) return; - - const interval = setInterval(async () => { - try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}/blocknote-content`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ blocknote_document: editorContent }), - } - ); - - if (response.ok) { - setHasUnsavedChanges(false); - toast.success("Auto-saved", { duration: 2000 }); - } - } catch (error) { - console.error("Auto-save failed:", error); - } - }, 30000); // 30 seconds - - return () => clearInterval(interval); - }, [editorContent, documentId, token, hasUnsavedChanges]); - - // Save and exit - DIRECT CALL TO FASTAPI - const handleSave = async () => { - if (!token) { - toast.error("Please login to save"); - return; - } - - if (!editorContent) { - toast.error("No content to save"); - return; - } - - setSaving(true); - try { - // Save blocknote_document to database (without finalizing/reindexing) - const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}/blocknote-content`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: JSON.stringify({ blocknote_document: editorContent }), - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({ detail: "Failed to save document" })); - throw new Error(errorData.detail || "Failed to save document"); - } - - setHasUnsavedChanges(false); - toast.success("Document saved successfully"); - - // Small delay before redirect to show success message - setTimeout(() => { - router.push(`/dashboard/${params.search_space_id}/documents`); - }, 500); - } catch (error) { - console.error("Error saving document:", error); - toast.error(error instanceof Error ? error.message : "Failed to save document. Please try again."); - } finally { - setSaving(false); - } - }; - - const handleCancel = () => { - if (hasUnsavedChanges) { - if (confirm("You have unsaved changes. Are you sure you want to leave?")) { - router.back(); - } - } else { - router.back(); - } - }; - - if (loading) { - return ( -
- - - -

Loading editor...

-
-
-
- ); - } - - if (error) { - return ( -
- - - -
- - Error -
- {error} -
- - - -
-
-
- ); - } - - if (!document) { - return ( -
- - - -

Document not found

-
-
-
- ); - } - - return ( - - {/* Toolbar */} -
-
- -
-

{document.title}

- {hasUnsavedChanges && ( -

Unsaved changes

- )} -
-
- -
- - -
-
- - {/* Editor Container */} -
-
-
- -
-
-
-
- ); + const params = useParams(); + const router = useRouter(); + const documentId = params.documentId as string; + + const [document, setDocument] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [editorContent, setEditorContent] = useState(null); + const [error, setError] = useState(null); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + + // Get auth token + const token = + typeof window !== "undefined" ? localStorage.getItem("surfsense_bearer_token") : null; + + // Fetch document content - DIRECT CALL TO FASTAPI + useEffect(() => { + async function fetchDocument() { + if (!token) { + console.error("No auth token found"); + setError("Please login to access the editor"); + setLoading(false); + return; + } + + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}/editor-content`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ detail: "Failed to fetch document" })); + throw new Error(errorData.detail || "Failed to fetch document"); + } + + const data = await response.json(); + + // Check if blocknote_document exists + if (!data.blocknote_document) { + setError( + "This document does not have BlockNote content. Please re-upload the document to enable editing." + ); + setLoading(false); + return; + } + + setDocument(data); + setEditorContent(data.blocknote_document); + setError(null); + } catch (error) { + console.error("Error fetching document:", error); + setError( + error instanceof Error ? error.message : "Failed to fetch document. Please try again." + ); + } finally { + setLoading(false); + } + } + + if (documentId && token) { + fetchDocument(); + } + }, [documentId, token]); + + // Track changes to mark as unsaved + useEffect(() => { + if (editorContent && document) { + setHasUnsavedChanges(true); + } + }, [editorContent, document]); + + // Auto-save every 30 seconds - DIRECT CALL TO FASTAPI + useEffect(() => { + if (!editorContent || !token || !hasUnsavedChanges) return; + + const interval = setInterval(async () => { + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}/blocknote-content`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ blocknote_document: editorContent }), + } + ); + + if (response.ok) { + setHasUnsavedChanges(false); + toast.success("Auto-saved", { duration: 2000 }); + } + } catch (error) { + console.error("Auto-save failed:", error); + } + }, 30000); // 30 seconds + + return () => clearInterval(interval); + }, [editorContent, documentId, token, hasUnsavedChanges]); + + // Save and exit - DIRECT CALL TO FASTAPI + const handleSave = async () => { + if (!token) { + toast.error("Please login to save"); + return; + } + + if (!editorContent) { + toast.error("No content to save"); + return; + } + + setSaving(true); + try { + // Save blocknote_document to database (without finalizing/reindexing) + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/${documentId}/blocknote-content`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ blocknote_document: editorContent }), + } + ); + + if (!response.ok) { + const errorData = await response + .json() + .catch(() => ({ detail: "Failed to save document" })); + throw new Error(errorData.detail || "Failed to save document"); + } + + setHasUnsavedChanges(false); + toast.success("Document saved successfully"); + + // Small delay before redirect to show success message + setTimeout(() => { + router.push(`/dashboard/${params.search_space_id}/documents`); + }, 500); + } catch (error) { + console.error("Error saving document:", error); + toast.error( + error instanceof Error ? error.message : "Failed to save document. Please try again." + ); + } finally { + setSaving(false); + } + }; + + const handleCancel = () => { + if (hasUnsavedChanges) { + if (confirm("You have unsaved changes. Are you sure you want to leave?")) { + router.back(); + } + } else { + router.back(); + } + }; + + if (loading) { + return ( +
+ + + +

Loading editor...

+
+
+
+ ); + } + + if (error) { + return ( +
+ + + +
+ + Error +
+ {error} +
+ + + +
+
+
+ ); + } + + if (!document) { + return ( +
+ + + +

Document not found

+
+
+
+ ); + } + + return ( + + {/* Toolbar */} +
+
+ +
+

{document.title}

+ {hasUnsavedChanges &&

Unsaved changes

} +
+
+ +
+ + +
+
+ + {/* Editor Container */} +
+
+
+ +
+
+
+
+ ); } diff --git a/surfsense_web/components/BlockNoteEditor.tsx b/surfsense_web/components/BlockNoteEditor.tsx index 57a6d380a..8064a0dc4 100644 --- a/surfsense_web/components/BlockNoteEditor.tsx +++ b/surfsense_web/components/BlockNoteEditor.tsx @@ -1,71 +1,68 @@ "use client"; -import { useEffect, useRef, useMemo } from "react"; import { useTheme } from "next-themes"; +import { useEffect, useMemo, useRef } from "react"; import "@blocknote/core/fonts/inter.css"; import "@blocknote/mantine/style.css"; -import { useCreateBlockNote } from "@blocknote/react"; import { BlockNoteView } from "@blocknote/mantine"; +import { useCreateBlockNote } from "@blocknote/react"; interface BlockNoteEditorProps { - initialContent?: any; - onChange?: (content: any) => void; + initialContent?: any; + onChange?: (content: any) => void; } -export default function BlockNoteEditor({ - initialContent, - onChange, -}: BlockNoteEditorProps) { - const { resolvedTheme } = useTheme(); - - // Track the initial content to prevent re-initialization - const initialContentRef = useRef(null); - const isInitializedRef = useRef(false); - - // Creates a new editor instance - only use initialContent on first render - const editor = useCreateBlockNote({ - initialContent: initialContentRef.current === null ? (initialContent || undefined) : undefined, - }); - - // Store initial content on first render only - useEffect(() => { - if (initialContent && initialContentRef.current === null) { - initialContentRef.current = initialContent; - isInitializedRef.current = true; - } - }, [initialContent]); +export default function BlockNoteEditor({ initialContent, onChange }: BlockNoteEditorProps) { + const { resolvedTheme } = useTheme(); - // Call onChange when document changes (but don't update from props) - useEffect(() => { - if (!onChange || !editor || !isInitializedRef.current) return; - - const handleChange = () => { - onChange(editor.document); - }; - - // Subscribe to document changes - const unsubscribe = editor.onChange(handleChange); - - return () => { - unsubscribe(); - }; - }, [editor, onChange]); + // Track the initial content to prevent re-initialization + const initialContentRef = useRef(null); + const isInitializedRef = useRef(false); - // Determine theme for BlockNote with custom dark mode background - const blockNoteTheme = useMemo(() => { - if (resolvedTheme === "dark") { - // Custom dark theme - only override editor background, let BlockNote handle the rest - return { - colors: { - editor: { - background: "#0A0A0A", // Custom dark background - }, - }, - }; - } - return "light" as const; - }, [resolvedTheme]); + // Creates a new editor instance - only use initialContent on first render + const editor = useCreateBlockNote({ + initialContent: initialContentRef.current === null ? initialContent || undefined : undefined, + }); - // Renders the editor instance - return ; + // Store initial content on first render only + useEffect(() => { + if (initialContent && initialContentRef.current === null) { + initialContentRef.current = initialContent; + isInitializedRef.current = true; + } + }, [initialContent]); + + // Call onChange when document changes (but don't update from props) + useEffect(() => { + if (!onChange || !editor || !isInitializedRef.current) return; + + const handleChange = () => { + onChange(editor.document); + }; + + // Subscribe to document changes + const unsubscribe = editor.onChange(handleChange); + + return () => { + unsubscribe(); + }; + }, [editor, onChange]); + + // Determine theme for BlockNote with custom dark mode background + const blockNoteTheme = useMemo(() => { + if (resolvedTheme === "dark") { + // Custom dark theme - only override editor background, let BlockNote handle the rest + return { + colors: { + editor: { + background: "#0A0A0A", // Custom dark background + }, + }, + }; + } + return "light" as const; + }, [resolvedTheme]); + + // Renders the editor instance + return ; } diff --git a/surfsense_web/components/DynamicBlockNoteEditor.tsx b/surfsense_web/components/DynamicBlockNoteEditor.tsx index ceb678548..60fc6b11c 100644 --- a/surfsense_web/components/DynamicBlockNoteEditor.tsx +++ b/surfsense_web/components/DynamicBlockNoteEditor.tsx @@ -3,7 +3,4 @@ import dynamic from "next/dynamic"; // Dynamically import BlockNote editor with SSR disabled -export const BlockNoteEditor = dynamic( - () => import("./BlockNoteEditor"), - { ssr: false } -); +export const BlockNoteEditor = dynamic(() => import("./BlockNoteEditor"), { ssr: false }); diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx index d44c2a756..8fea39b16 100644 --- a/surfsense_web/components/dashboard-breadcrumb.tsx +++ b/surfsense_web/components/dashboard-breadcrumb.tsx @@ -41,9 +41,8 @@ export function DashboardBreadcrumb() { useEffect(() => { if (segments[2] === "editor" && segments[3] && searchSpaceId) { const documentId = segments[3]; - const token = typeof window !== "undefined" - ? localStorage.getItem("surfsense_bearer_token") - : null; + const token = + typeof window !== "undefined" ? localStorage.getItem("surfsense_bearer_token") : null; if (token) { fetch( @@ -110,7 +109,7 @@ export function DashboardBreadcrumb() { // Handle sub-sections if (segments[3]) { const subSection = segments[3]; - + // Handle editor sub-sections (document ID) if (section === "editor") { const documentLabel = documentTitle || subSection; diff --git a/surfsense_web/next.config.ts b/surfsense_web/next.config.ts index 0777d022b..da58fa10c 100644 --- a/surfsense_web/next.config.ts +++ b/surfsense_web/next.config.ts @@ -24,18 +24,13 @@ const nextConfig: NextConfig = { ], }, // Mark BlockNote server packages as external - serverExternalPackages: [ - '@blocknote/server-util', - ], + serverExternalPackages: ["@blocknote/server-util"], // Configure webpack to handle blocknote packages webpack: (config, { isServer }) => { if (isServer) { // Don't bundle these packages on the server - config.externals = [ - ...(config.externals || []), - '@blocknote/server-util', - ]; + config.externals = [...(config.externals || []), "@blocknote/server-util"]; } return config; },