311 lines
10 KiB
Python
311 lines
10 KiB
Python
|
|
"""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}
|