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>
This commit is contained in:
0
server/badnote_server/routers/__init__.py
Normal file
0
server/badnote_server/routers/__init__.py
Normal file
78
server/badnote_server/routers/auth_router.py
Normal file
78
server/badnote_server/routers/auth_router.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Auth router for BadNote."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from ..auth import (
|
||||
create_access_token,
|
||||
dummy_verify,
|
||||
get_current_user,
|
||||
hash_password,
|
||||
verify_password,
|
||||
)
|
||||
from ..database import get_db
|
||||
from ..models import TokenResponse, UserCreate, UserLogin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(body: UserCreate) -> TokenResponse:
|
||||
"""Register a new user."""
|
||||
db = await get_db()
|
||||
existing = await db.execute(
|
||||
"SELECT id FROM users WHERE username = ?", (body.username,)
|
||||
)
|
||||
if await existing.fetchone() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Username already taken",
|
||||
)
|
||||
|
||||
user_id = str(uuid4())
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
await db.execute(
|
||||
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
||||
(user_id, body.username, hash_password(body.password), now),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
token = create_access_token(user_id)
|
||||
return TokenResponse(token=token, user_id=user_id)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: UserLogin) -> TokenResponse:
|
||||
"""Authenticate and return a token."""
|
||||
db = await get_db()
|
||||
row = await (
|
||||
await db.execute(
|
||||
"SELECT id, password_hash FROM users WHERE username = ?", (body.username,)
|
||||
)
|
||||
).fetchone()
|
||||
|
||||
if row is None:
|
||||
# Spend comparable time hashing so timing does not reveal whether the
|
||||
# username exists.
|
||||
dummy_verify()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid username or password",
|
||||
)
|
||||
if not verify_password(body.password, row["password_hash"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid username or password",
|
||||
)
|
||||
|
||||
token = create_access_token(row["id"])
|
||||
return TokenResponse(token=token, user_id=row["id"])
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
async def refresh(user_id: str = Depends(get_current_user)) -> TokenResponse:
|
||||
"""Refresh an existing valid token."""
|
||||
token = create_access_token(user_id)
|
||||
return TokenResponse(token=token, user_id=user_id)
|
||||
310
server/badnote_server/routers/documents_router.py
Normal file
310
server/badnote_server/routers/documents_router.py
Normal file
@@ -0,0 +1,310 @@
|
||||
"""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}
|
||||
117
server/badnote_server/routers/notes_router.py
Normal file
117
server/badnote_server/routers/notes_router.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Notes CRUD router for BadNote."""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..database import get_db
|
||||
from ..models import NoteCreate, NoteResponse, NoteUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _row_to_note(row) -> NoteResponse:
|
||||
return NoteResponse(
|
||||
id=row["id"],
|
||||
user_id=row["user_id"],
|
||||
title=row["title"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
tags=json.loads(row["tags"]),
|
||||
strokes_json=row["strokes_json"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[NoteResponse])
|
||||
async def list_notes(
|
||||
since: str | None = Query(None, description="ISO8601 timestamp filter"),
|
||||
user_id: str = Depends(get_current_user),
|
||||
) -> list[NoteResponse]:
|
||||
"""List notes, optionally filtered by updated_at > since."""
|
||||
db = await get_db()
|
||||
if since:
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM notes WHERE user_id = ? AND updated_at > ? ORDER BY updated_at",
|
||||
(user_id, since),
|
||||
)
|
||||
else:
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM notes WHERE user_id = ? ORDER BY updated_at",
|
||||
(user_id,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [_row_to_note(r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/{note_id}", response_model=NoteResponse)
|
||||
async def get_note(
|
||||
note_id: str,
|
||||
user_id: str = Depends(get_current_user),
|
||||
) -> NoteResponse:
|
||||
"""Get a single note by ID."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM notes WHERE id = ? AND user_id = ?", (note_id, user_id)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found")
|
||||
return _row_to_note(row)
|
||||
|
||||
|
||||
@router.post("", response_model=NoteResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def upsert_note(
|
||||
body: NoteCreate,
|
||||
user_id: str = Depends(get_current_user),
|
||||
) -> NoteResponse:
|
||||
"""Create or update a note (upsert by id)."""
|
||||
db = await get_db()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
tags_json = json.dumps(body.tags)
|
||||
|
||||
existing = await (
|
||||
await db.execute(
|
||||
"SELECT id FROM notes WHERE id = ? AND user_id = ?", (body.id, user_id)
|
||||
)
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
await db.execute(
|
||||
"""UPDATE notes SET title = ?, tags = ?, strokes_json = ?, updated_at = ?
|
||||
WHERE id = ? AND user_id = ?""",
|
||||
(body.title, tags_json, body.strokes_json, now, body.id, user_id),
|
||||
)
|
||||
else:
|
||||
await db.execute(
|
||||
"""INSERT INTO notes (id, user_id, title, created_at, updated_at, tags, strokes_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(body.id, user_id, body.title, now, now, tags_json, body.strokes_json),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
cursor = await db.execute(
|
||||
"SELECT * FROM notes WHERE id = ? AND user_id = ?", (body.id, user_id)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return _row_to_note(row)
|
||||
|
||||
|
||||
@router.delete("/{note_id}", status_code=status.HTTP_200_OK)
|
||||
async def delete_note(
|
||||
note_id: str,
|
||||
user_id: str = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Delete a note."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"SELECT id FROM notes WHERE id = ? AND user_id = ?", (note_id, user_id)
|
||||
)
|
||||
if await cursor.fetchone() is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found")
|
||||
|
||||
await db.execute("DELETE FROM notes WHERE id = ? AND user_id = ?", (note_id, user_id))
|
||||
await db.commit()
|
||||
return {"deleted": note_id}
|
||||
99
server/badnote_server/routers/ocr_router.py
Normal file
99
server/badnote_server/routers/ocr_router.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""OCR router for BadNote."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..database import get_db
|
||||
from ..models import OcrJobRequest, OcrJobStatus, OcrResult
|
||||
from ..ocr import queue as job_queue
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/process", status_code=status.HTTP_201_CREATED)
|
||||
async def submit_ocr_job(
|
||||
body: OcrJobRequest,
|
||||
user_id: str = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Enqueue an OCR job."""
|
||||
job_data: dict = {
|
||||
"user_id": user_id,
|
||||
"note_id": body.note_id,
|
||||
"document_id": body.document_id,
|
||||
"page_number": body.page_number,
|
||||
}
|
||||
job_id = job_queue.enqueue(job_data)
|
||||
return {"job_id": job_id}
|
||||
|
||||
|
||||
@router.get("/status/{job_id}", response_model=OcrJobStatus)
|
||||
async def get_job_status(
|
||||
job_id: str,
|
||||
user_id: str = Depends(get_current_user),
|
||||
) -> OcrJobStatus:
|
||||
"""Get OCR job status and result."""
|
||||
job = job_queue.get_status(job_id)
|
||||
# Treat jobs owned by another user as not found to avoid leaking their data.
|
||||
if job is None or job.get("user_id") != user_id:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Job not found")
|
||||
|
||||
return OcrJobStatus(
|
||||
id=job["id"],
|
||||
status=job["status"],
|
||||
result_text=job.get("result_text"),
|
||||
error_message=job.get("error_message"),
|
||||
created_at=job["created_at"],
|
||||
completed_at=job.get("completed_at"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/results/{note_id}", response_model=list[OcrResult])
|
||||
async def get_ocr_results(
|
||||
note_id: str,
|
||||
user_id: str = Depends(get_current_user),
|
||||
) -> list[OcrResult]:
|
||||
"""Get all OCR results for a note."""
|
||||
jobs = [
|
||||
j for j in job_queue.get_jobs_for_note(note_id) if j.get("user_id") == user_id
|
||||
]
|
||||
return [
|
||||
OcrResult(
|
||||
id=j["id"],
|
||||
note_id=j.get("note_id"),
|
||||
document_id=j.get("document_id"),
|
||||
page_number=j.get("page_number"),
|
||||
status=j["status"],
|
||||
result_text=j.get("result_text"),
|
||||
error_message=j.get("error_message"),
|
||||
created_at=j["created_at"],
|
||||
completed_at=j.get("completed_at"),
|
||||
)
|
||||
for j in jobs
|
||||
]
|
||||
|
||||
|
||||
@router.get("/results/document/{document_id}", response_model=list[OcrResult])
|
||||
async def get_document_ocr_results(
|
||||
document_id: str,
|
||||
user_id: str = Depends(get_current_user),
|
||||
) -> list[OcrResult]:
|
||||
"""Get all OCR results for a document."""
|
||||
jobs = [
|
||||
j
|
||||
for j in job_queue.get_jobs_for_document(document_id)
|
||||
if j.get("user_id") == user_id
|
||||
]
|
||||
return [
|
||||
OcrResult(
|
||||
id=j["id"],
|
||||
note_id=j.get("note_id"),
|
||||
document_id=j.get("document_id"),
|
||||
page_number=j.get("page_number"),
|
||||
status=j["status"],
|
||||
result_text=j.get("result_text"),
|
||||
error_message=j.get("error_message"),
|
||||
created_at=j["created_at"],
|
||||
completed_at=j.get("completed_at"),
|
||||
)
|
||||
for j in jobs
|
||||
]
|
||||
90
server/badnote_server/routers/sync_router.py
Normal file
90
server/badnote_server/routers/sync_router.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Sync router for BadNote — push/pull notes."""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..database import get_db
|
||||
from ..models import NoteResponse, SyncPullRequest, SyncPushRequest, SyncResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/push", response_model=SyncResponse, status_code=status.HTTP_200_OK)
|
||||
async def sync_push(
|
||||
body: SyncPushRequest,
|
||||
user_id: str = Depends(get_current_user),
|
||||
) -> SyncResponse:
|
||||
"""Upsert notes from client."""
|
||||
db = await get_db()
|
||||
synced = 0
|
||||
|
||||
for note in body.notes:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
tags_json = json.dumps(note.tags)
|
||||
|
||||
existing = await (
|
||||
await db.execute(
|
||||
"SELECT id FROM notes WHERE id = ? AND user_id = ?", (note.id, user_id)
|
||||
)
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
# Last-writer-wins by timestamp: only apply the client's version if
|
||||
# it is newer than what the server already has, so a stale client
|
||||
# cannot overwrite a more recent note (data loss).
|
||||
await db.execute(
|
||||
"""UPDATE notes SET title = ?, tags = ?, strokes_json = ?, updated_at = ?
|
||||
WHERE id = ? AND user_id = ? AND updated_at < ?""",
|
||||
(
|
||||
note.title,
|
||||
tags_json,
|
||||
note.strokes_json,
|
||||
note.updated_at,
|
||||
note.id,
|
||||
user_id,
|
||||
note.updated_at,
|
||||
),
|
||||
)
|
||||
else:
|
||||
await db.execute(
|
||||
"""INSERT INTO notes (id, user_id, title, created_at, updated_at, tags, strokes_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(note.id, user_id, note.title, now, note.updated_at, tags_json, note.strokes_json),
|
||||
)
|
||||
synced += 1
|
||||
|
||||
await db.commit()
|
||||
return SyncResponse(synced_count=synced)
|
||||
|
||||
|
||||
@router.post("/pull", status_code=status.HTTP_200_OK)
|
||||
async def sync_pull(
|
||||
body: SyncPullRequest,
|
||||
user_id: str = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Pull notes updated since a timestamp."""
|
||||
db = await get_db()
|
||||
cursor = await db.execute(
|
||||
"""SELECT * FROM notes WHERE user_id = ? AND updated_at > ? ORDER BY updated_at""",
|
||||
(user_id, body.since),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
notes = [
|
||||
NoteResponse(
|
||||
id=r["id"],
|
||||
user_id=r["user_id"],
|
||||
title=r["title"],
|
||||
created_at=r["created_at"],
|
||||
updated_at=r["updated_at"],
|
||||
tags=json.loads(r["tags"]),
|
||||
strokes_json=r["strokes_json"],
|
||||
).model_dump()
|
||||
for r in rows
|
||||
]
|
||||
|
||||
return {"notes": notes}
|
||||
Reference in New Issue
Block a user