Skip to content

Commit eefecfa

Browse files
authored
Merge pull request #500 from AnishSarkar22/feature/blocknote-editor
[Feature] Add BlockNote editor
2 parents 0e9efd6 + f92112a commit eefecfa

File tree

25 files changed

+2535
-14
lines changed

25 files changed

+2535
-14
lines changed
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""43_add_blocknote_fields_to_documents
2+
3+
Revision ID: 43
4+
Revises: 42
5+
Create Date: 2025-11-30
6+
7+
Adds fields for live document editing:
8+
- blocknote_document: JSONB editor state
9+
- content_needs_reindexing: Flag for regenerating chunks/summary
10+
- last_edited_at: Last edit timestamp
11+
"""
12+
13+
from collections.abc import Sequence
14+
15+
import sqlalchemy as sa
16+
from sqlalchemy.dialects import postgresql
17+
18+
from alembic import op
19+
20+
# revision identifiers, used by Alembic.
21+
revision: str = "43"
22+
down_revision: str | None = "42"
23+
branch_labels: str | Sequence[str] | None = None
24+
depends_on: str | Sequence[str] | None = None
25+
26+
27+
def upgrade() -> None:
28+
"""Upgrade schema - Add BlockNote fields and trigger population task."""
29+
30+
# Add the columns
31+
op.add_column(
32+
"documents",
33+
sa.Column(
34+
"blocknote_document", postgresql.JSONB(astext_type=sa.Text()), nullable=True
35+
),
36+
)
37+
op.add_column(
38+
"documents",
39+
sa.Column(
40+
"content_needs_reindexing",
41+
sa.Boolean(),
42+
nullable=False,
43+
server_default=sa.false(),
44+
),
45+
)
46+
op.add_column(
47+
"documents",
48+
sa.Column("last_edited_at", sa.TIMESTAMP(timezone=True), nullable=True),
49+
)
50+
51+
# Trigger the Celery task to populate blocknote_document for existing documents
52+
try:
53+
from app.tasks.celery_tasks.blocknote_migration_tasks import (
54+
populate_blocknote_for_documents_task,
55+
)
56+
57+
# Queue the task to run asynchronously
58+
populate_blocknote_for_documents_task.apply_async()
59+
print(
60+
"✓ Queued Celery task to populate blocknote_document for existing documents"
61+
)
62+
except Exception as e:
63+
# If Celery is not available or task queueing fails, log but don't fail the migration
64+
print(f"⚠ Warning: Could not queue blocknote population task: {e}")
65+
print(" You can manually trigger it later with:")
66+
print(
67+
" celery -A app.celery_app call app.tasks.celery_tasks.blocknote_migration_tasks.populate_blocknote_for_documents_task"
68+
)
69+
70+
71+
def downgrade() -> None:
72+
"""Downgrade schema - Remove BlockNote fields."""
73+
op.drop_column("documents", "last_edited_at")
74+
op.drop_column("documents", "content_needs_reindexing")
75+
op.drop_column("documents", "blocknote_document")

surfsense_backend/app/celery_app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ def parse_schedule_interval(interval: str) -> dict:
6363
"app.tasks.celery_tasks.podcast_tasks",
6464
"app.tasks.celery_tasks.connector_tasks",
6565
"app.tasks.celery_tasks.schedule_checker_task",
66+
"app.tasks.celery_tasks.blocknote_migration_tasks",
67+
"app.tasks.celery_tasks.document_reindex_tasks",
6668
],
6769
)
6870

surfsense_backend/app/db.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
UniqueConstraint,
2121
text,
2222
)
23-
from sqlalchemy.dialects.postgresql import UUID
23+
from sqlalchemy.dialects.postgresql import JSONB, UUID
2424
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
2525
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, relationship
2626

@@ -343,6 +343,17 @@ class Document(BaseModel, TimestampMixin):
343343
unique_identifier_hash = Column(String, nullable=True, index=True, unique=True)
344344
embedding = Column(Vector(config.embedding_model_instance.dimension))
345345

346+
# BlockNote live editing state (NULL when never edited)
347+
blocknote_document = Column(JSONB, nullable=True)
348+
349+
# blocknote background reindex flag
350+
content_needs_reindexing = Column(
351+
Boolean, nullable=False, default=False, server_default=text("false")
352+
)
353+
354+
# Track when blocknote document was last edited
355+
last_edited_at = Column(TIMESTAMP(timezone=True), nullable=True)
356+
346357
search_space_id = Column(
347358
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False
348359
)

surfsense_backend/app/routes/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
)
66
from .chats_routes import router as chats_router
77
from .documents_routes import router as documents_router
8+
from .editor_routes import router as editor_router
89
from .google_calendar_add_connector_route import (
910
router as google_calendar_add_connector_router,
1011
)
@@ -23,6 +24,7 @@
2324

2425
router.include_router(search_spaces_router)
2526
router.include_router(rbac_router) # RBAC routes for roles, members, invites
27+
router.include_router(editor_router)
2628
router.include_router(documents_router)
2729
router.include_router(podcasts_router)
2830
router.include_router(chats_router)
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""
2+
Editor routes for BlockNote document editing.
3+
"""
4+
5+
from datetime import UTC, datetime
6+
from typing import Any
7+
8+
from fastapi import APIRouter, Depends, HTTPException
9+
from sqlalchemy import select
10+
from sqlalchemy.ext.asyncio import AsyncSession
11+
12+
from app.db import Document, SearchSpace, User, get_async_session
13+
from app.users import current_active_user
14+
15+
router = APIRouter()
16+
17+
18+
@router.get("/documents/{document_id}/editor-content")
19+
async def get_editor_content(
20+
document_id: int,
21+
session: AsyncSession = Depends(get_async_session),
22+
user: User = Depends(current_active_user),
23+
):
24+
"""
25+
Get document content for editing.
26+
27+
Returns BlockNote JSON document. If blocknote_document is NULL,
28+
attempts to generate it from chunks (lazy migration).
29+
"""
30+
from sqlalchemy.orm import selectinload
31+
32+
result = await session.execute(
33+
select(Document)
34+
.options(selectinload(Document.chunks))
35+
.join(SearchSpace)
36+
.filter(Document.id == document_id, SearchSpace.user_id == user.id)
37+
)
38+
document = result.scalars().first()
39+
40+
if not document:
41+
raise HTTPException(status_code=404, detail="Document not found")
42+
43+
# If blocknote_document exists, return it
44+
if document.blocknote_document:
45+
return {
46+
"document_id": document.id,
47+
"title": document.title,
48+
"blocknote_document": document.blocknote_document,
49+
"last_edited_at": document.last_edited_at.isoformat()
50+
if document.last_edited_at
51+
else None,
52+
}
53+
54+
# Lazy migration: Try to generate blocknote_document from chunks
55+
from app.utils.blocknote_converter import convert_markdown_to_blocknote
56+
57+
chunks = sorted(document.chunks, key=lambda c: c.id)
58+
59+
if not chunks:
60+
raise HTTPException(
61+
status_code=400,
62+
detail="This document has no chunks and cannot be edited. Please re-upload to enable editing.",
63+
)
64+
65+
# Reconstruct markdown from chunks
66+
markdown_content = "\n\n".join(chunk.content for chunk in chunks)
67+
68+
if not markdown_content.strip():
69+
raise HTTPException(
70+
status_code=400,
71+
detail="This document has empty content and cannot be edited.",
72+
)
73+
74+
# Convert to BlockNote
75+
blocknote_json = await convert_markdown_to_blocknote(markdown_content)
76+
77+
if not blocknote_json:
78+
raise HTTPException(
79+
status_code=500,
80+
detail="Failed to convert document to editable format. Please try again later.",
81+
)
82+
83+
# Save the generated blocknote_document (lazy migration)
84+
document.blocknote_document = blocknote_json
85+
document.content_needs_reindexing = False
86+
document.last_edited_at = None
87+
await session.commit()
88+
89+
return {
90+
"document_id": document.id,
91+
"title": document.title,
92+
"blocknote_document": blocknote_json,
93+
"last_edited_at": None,
94+
}
95+
96+
97+
@router.post("/documents/{document_id}/save")
98+
async def save_document(
99+
document_id: int,
100+
data: dict[str, Any],
101+
session: AsyncSession = Depends(get_async_session),
102+
user: User = Depends(current_active_user),
103+
):
104+
"""
105+
Save BlockNote document and trigger reindexing.
106+
Called when user clicks 'Save & Exit'.
107+
"""
108+
from app.tasks.celery_tasks.document_reindex_tasks import reindex_document_task
109+
110+
# Verify ownership
111+
result = await session.execute(
112+
select(Document)
113+
.join(SearchSpace)
114+
.filter(Document.id == document_id, SearchSpace.user_id == user.id)
115+
)
116+
document = result.scalars().first()
117+
118+
if not document:
119+
raise HTTPException(status_code=404, detail="Document not found")
120+
121+
blocknote_document = data.get("blocknote_document")
122+
if not blocknote_document:
123+
raise HTTPException(status_code=400, detail="blocknote_document is required")
124+
125+
# Save BlockNote document
126+
document.blocknote_document = blocknote_document
127+
document.last_edited_at = datetime.now(UTC)
128+
document.content_needs_reindexing = True
129+
130+
await session.commit()
131+
132+
# Queue reindex task
133+
reindex_document_task.delay(document_id, str(user.id))
134+
135+
return {
136+
"status": "saved",
137+
"document_id": document_id,
138+
"message": "Document saved and will be reindexed in the background",
139+
"last_edited_at": document.last_edited_at.isoformat(),
140+
}

0 commit comments

Comments
 (0)