Files
BadNote/server/badnote_server/routers/documents_router.py

311 lines
10 KiB
Python
Raw Normal View History

Fix bugs across app + server, optimize UI/UX, add Gitea CI Bug fixes (Flutter): - Wrap multi-statement DB writes (insert/update/delete note, deleteDocument, deletePageData, OCR FTS merge, migrations) in transactions to prevent data loss on interruption and a read-modify-write FTS race. - Fix PdfDocument leaks on exception (try/finally dispose) and preserve image aspect ratio when stamping images onto PDF pages. - Guard file-picker against empty selection (was .single -> crash). - Fix eraser ConcurrentModificationError and unmodifiable-list crash on PDF pages; capture page synchronously on save to stop wrong-page data loss. - Fix Riverpod DB-not-ready races, broken pull-to-refresh, settings load race, and search N+1; transform stored annotations on PDF page rotation. - Normalize pen pressure for devices without a pressure range. - PPT: single source of truth for slide strokes so ink displays and exports. UI/UX: - Material 3 typography, theme-aware colors (dark-mode fixes), hover cursors and right-click/visible actions on desktop, keyboard shortcuts (undo/redo/ save/find), toolbar overflow handling, friendlier empty states, semantic OCR status badges, relative timestamps, 1-based page indicators, large-deck PPT navigation, and a scratchpad-scope label in split view. Server (optional backend): - Persist JWT secret (was per-process random), block path traversal in storage, fix CORS '*'+credentials, add OCR job ownership checks, last-writer-wins sync guard, constant-time login, and split out heavy OCR deps so the API/tests run without them. CI: Gitea workflows for format+analyze+test (Linux, system sqlite) and a Windows release build; pristine `flutter analyze`, all Flutter and server tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 03:18:00 +08:00
"""Documents router for BadNote — upload, download, annotations, bookmarks."""
import json
from datetime import datetime, timezone
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, status
from fastapi.responses import FileResponse
from ..auth import get_current_user
from ..database import get_db
from ..models import (
AnnotationResponse,
AnnotationUpdate,
BookmarkCreate,
BookmarkResponse,
DocumentResponse,
)
from ..storage import delete_document, get_document_path, save_document
router = APIRouter()
def _row_to_doc(row) -> DocumentResponse:
return DocumentResponse(
id=row["id"],
user_id=row["user_id"],
filename=row["filename"],
doc_type=row["doc_type"],
page_count=row["page_count"],
created_at=row["created_at"],
updated_at=row["updated_at"],
)
# ── Documents ───────────────────────────────────────────────────────────────
@router.post("/upload", response_model=DocumentResponse, status_code=status.HTTP_201_CREATED)
async def upload_document(
file: UploadFile = File(...),
doc_type: str = Form("pdf"),
page_count: int = Form(0),
user_id: str = Depends(get_current_user),
) -> DocumentResponse:
"""Upload a document file."""
doc_id = str(uuid4())
now = datetime.now(timezone.utc).isoformat()
filename = file.filename or "document"
file_bytes = await file.read()
file_path = save_document(file_bytes, doc_id, filename)
db = await get_db()
await db.execute(
"""INSERT INTO documents (id, user_id, filename, doc_type, file_path, page_count, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(doc_id, user_id, filename, doc_type, file_path, page_count, now, now),
)
await db.commit()
cursor = await db.execute("SELECT * FROM documents WHERE id = ?", (doc_id,))
row = await cursor.fetchone()
return _row_to_doc(row)
@router.get("", response_model=list[DocumentResponse])
async def list_documents(
user_id: str = Depends(get_current_user),
) -> list[DocumentResponse]:
"""List all documents for the current user."""
db = await get_db()
cursor = await db.execute(
"SELECT * FROM documents WHERE user_id = ? ORDER BY created_at DESC",
(user_id,),
)
rows = await cursor.fetchall()
return [_row_to_doc(r) for r in rows]
@router.get("/{doc_id}", response_model=DocumentResponse)
async def get_document(
doc_id: str,
user_id: str = Depends(get_current_user),
) -> DocumentResponse:
"""Get document metadata."""
db = await get_db()
cursor = await db.execute(
"SELECT * FROM documents WHERE id = ? AND user_id = ?", (doc_id, user_id)
)
row = await cursor.fetchone()
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
return _row_to_doc(row)
@router.get("/{doc_id}/file")
async def download_document(
doc_id: str,
user_id: str = Depends(get_current_user),
) -> FileResponse:
"""Stream document file download."""
db = await get_db()
cursor = await db.execute(
"SELECT * FROM documents WHERE id = ? AND user_id = ?", (doc_id, user_id)
)
row = await cursor.fetchone()
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
file_path = row["file_path"]
return FileResponse(path=file_path, filename=row["filename"], media_type="application/octet-stream")
@router.delete("/{doc_id}", status_code=status.HTTP_200_OK)
async def delete_document_endpoint(
doc_id: str,
user_id: str = Depends(get_current_user),
) -> dict:
"""Delete document, its file, annotations, and bookmarks."""
db = await get_db()
cursor = await db.execute(
"SELECT id FROM documents WHERE id = ? AND user_id = ?", (doc_id, user_id)
)
if await cursor.fetchone() is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
delete_document(doc_id)
await db.execute("DELETE FROM documents WHERE id = ?", (doc_id,))
await db.commit()
return {"deleted": doc_id}
# ── Annotations ─────────────────────────────────────────────────────────────
@router.get("/{doc_id}/annotations", response_model=list[AnnotationResponse])
async def list_annotations(
doc_id: str,
user_id: str = Depends(get_current_user),
) -> list[AnnotationResponse]:
"""Get all annotations for a document."""
db = await get_db()
# Verify document ownership
cursor = await db.execute(
"SELECT id FROM documents WHERE id = ? AND user_id = ?", (doc_id, user_id)
)
if await cursor.fetchone() is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
cursor = await db.execute(
"SELECT * FROM annotations WHERE document_id = ? ORDER BY page_number",
(doc_id,),
)
rows = await cursor.fetchall()
return [
AnnotationResponse(
id=r["id"],
document_id=r["document_id"],
page_number=r["page_number"],
annotation_json=json.loads(r["annotation_json"]),
created_at=r["created_at"],
updated_at=r["updated_at"],
)
for r in rows
]
@router.put("/{doc_id}/annotations/{page}", response_model=AnnotationResponse, status_code=status.HTTP_200_OK)
async def update_annotation(
doc_id: str,
page: int,
body: AnnotationUpdate,
user_id: str = Depends(get_current_user),
) -> AnnotationResponse:
"""Create or replace annotations for a page."""
db = await get_db()
cursor = await db.execute(
"SELECT id FROM documents WHERE id = ? AND user_id = ?", (doc_id, user_id)
)
if await cursor.fetchone() is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
now = datetime.now(timezone.utc).isoformat()
annotation_json = json.dumps(body.annotation_json)
# Check if annotation for this page already exists
cursor = await db.execute(
"SELECT id FROM annotations WHERE document_id = ? AND page_number = ?",
(doc_id, page),
)
existing = await cursor.fetchone()
if existing:
ann_id = existing["id"]
await db.execute(
"UPDATE annotations SET annotation_json = ?, updated_at = ? WHERE id = ?",
(annotation_json, now, ann_id),
)
else:
ann_id = str(uuid4())
await db.execute(
"""INSERT INTO annotations (id, document_id, page_number, annotation_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(ann_id, doc_id, page, annotation_json, now, now),
)
await db.commit()
cursor = await db.execute("SELECT * FROM annotations WHERE id = ?", (ann_id,))
row = await cursor.fetchone()
return AnnotationResponse(
id=row["id"],
document_id=row["document_id"],
page_number=row["page_number"],
annotation_json=json.loads(row["annotation_json"]),
created_at=row["created_at"],
updated_at=row["updated_at"],
)
# ── Bookmarks ───────────────────────────────────────────────────────────────
@router.get("/{doc_id}/bookmarks", response_model=list[BookmarkResponse])
async def list_bookmarks(
doc_id: str,
user_id: str = Depends(get_current_user),
) -> list[BookmarkResponse]:
"""Get all bookmarks for a document."""
db = await get_db()
cursor = await db.execute(
"SELECT id FROM documents WHERE id = ? AND user_id = ?", (doc_id, user_id)
)
if await cursor.fetchone() is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
cursor = await db.execute(
"SELECT * FROM bookmarks WHERE document_id = ? ORDER BY page_number",
(doc_id,),
)
rows = await cursor.fetchall()
return [
BookmarkResponse(
id=r["id"],
document_id=r["document_id"],
page_number=r["page_number"],
label=r["label"],
color=r["color"],
created_at=r["created_at"],
)
for r in rows
]
@router.post("/{doc_id}/bookmarks", response_model=BookmarkResponse, status_code=status.HTTP_201_CREATED)
async def create_bookmark(
doc_id: str,
body: BookmarkCreate,
user_id: str = Depends(get_current_user),
) -> BookmarkResponse:
"""Add a bookmark to a document."""
db = await get_db()
cursor = await db.execute(
"SELECT id FROM documents WHERE id = ? AND user_id = ?", (doc_id, user_id)
)
if await cursor.fetchone() is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
bookmark_id = str(uuid4())
now = datetime.now(timezone.utc).isoformat()
await db.execute(
"""INSERT INTO bookmarks (id, document_id, page_number, label, color, created_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(bookmark_id, doc_id, body.page_number, body.label, body.color, now),
)
await db.commit()
return BookmarkResponse(
id=bookmark_id,
document_id=doc_id,
page_number=body.page_number,
label=body.label,
color=body.color,
created_at=now,
)
@router.delete("/{doc_id}/bookmarks/{bookmark_id}", status_code=status.HTTP_200_OK)
async def delete_bookmark(
doc_id: str,
bookmark_id: str,
user_id: str = Depends(get_current_user),
) -> dict:
"""Delete a bookmark."""
db = await get_db()
cursor = await db.execute(
"SELECT id FROM documents WHERE id = ? AND user_id = ?", (doc_id, user_id)
)
if await cursor.fetchone() is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Document not found")
cursor = await db.execute(
"SELECT id FROM bookmarks WHERE id = ? AND document_id = ?",
(bookmark_id, doc_id),
)
if await cursor.fetchone() is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
await db.execute("DELETE FROM bookmarks WHERE id = ?", (bookmark_id,))
await db.commit()
return {"deleted": bookmark_id}