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:
52
server/README.md
Normal file
52
server/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# BadNote Server (Optional)
|
||||
|
||||
This directory contains an **optional** Python/FastAPI backend. The BadNote desktop app does **not** depend on it.
|
||||
|
||||
The Flutter client is local-first:
|
||||
|
||||
- Notes and documents are stored in SQLite on device
|
||||
- OCR runs locally via Windows built-in OCR
|
||||
- Full-text search uses on-device FTS5
|
||||
|
||||
## Why this exists
|
||||
|
||||
This server was an early experiment for:
|
||||
|
||||
- Multi-device note sync (push/pull)
|
||||
- Server-side OCR with EasyOCR
|
||||
- JWT authentication
|
||||
|
||||
These features are **not wired into the current client**. The client previously had incomplete sync/OCR scaffolding that has been removed in favor of local processing.
|
||||
|
||||
## Running (if you want to experiment)
|
||||
|
||||
```bash
|
||||
cd server
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn badnote_server.main:app --host 0.0.0.0 --port 8080
|
||||
```
|
||||
|
||||
API docs: http://localhost:8080/docs
|
||||
|
||||
The OCR worker has heavy extra dependencies (EasyOCR + torch). Install them only
|
||||
if you want to run it:
|
||||
|
||||
```bash
|
||||
pip install -r requirements-ocr.txt
|
||||
python -m badnote_server.ocr.worker
|
||||
```
|
||||
|
||||
### Security notes
|
||||
|
||||
- Set `BADNOTE_JWT_SECRET` in production. If unset, a secret is generated once
|
||||
and persisted to `<data>/.jwt_secret` so tokens survive restarts.
|
||||
- Restrict origins with `BADNOTE_CORS_ORIGINS` (comma-separated). The default is
|
||||
permissive (`*`, without credentials) for local development.
|
||||
|
||||
## Status
|
||||
|
||||
- Kept for reference and future optional sync work
|
||||
- Not part of the primary development path
|
||||
- No guarantee of API compatibility with future client versions
|
||||
0
server/badnote_server/__init__.py
Normal file
0
server/badnote_server/__init__.py
Normal file
73
server/badnote_server/auth.py
Normal file
73
server/badnote_server/auth.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""JWT authentication utilities for BadNote."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from .config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
bearer_scheme = HTTPBearer()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a plaintext password with bcrypt."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
"""Verify a password against its hash."""
|
||||
return pwd_context.verify(password, password_hash)
|
||||
|
||||
|
||||
# A precomputed hash used to spend roughly the same time verifying a password
|
||||
# for a non-existent user as for an existing one, so login response timing does
|
||||
# not leak whether a username exists.
|
||||
_DUMMY_HASH = pwd_context.hash("badnote-dummy-password")
|
||||
|
||||
|
||||
def dummy_verify() -> None:
|
||||
"""Run a throwaway bcrypt verification to equalise login timing."""
|
||||
pwd_context.verify("badnote-dummy-password", _DUMMY_HASH)
|
||||
|
||||
|
||||
def create_access_token(user_id: str) -> str:
|
||||
"""Create a JWT access token for the given user_id."""
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=settings.jwt_expiry_hours)
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"exp": expire,
|
||||
"iat": datetime.now(timezone.utc),
|
||||
"jti": str(uuid4()),
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict:
|
||||
"""Decode and validate a JWT token. Returns the payload dict."""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret, algorithms=["HS256"])
|
||||
return payload
|
||||
except JWTError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
) from exc
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
||||
) -> str:
|
||||
"""FastAPI dependency: extract user_id from Bearer token."""
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
user_id: str | None = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token missing subject",
|
||||
)
|
||||
return user_id
|
||||
73
server/badnote_server/config.py
Normal file
73
server/badnote_server/config.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""BadNote server configuration via environment variables."""
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import warnings
|
||||
|
||||
|
||||
def _resolve_jwt_secret() -> str:
|
||||
"""Resolve the JWT signing secret.
|
||||
|
||||
Priority:
|
||||
1. ``BADNOTE_JWT_SECRET`` environment variable (recommended for prod).
|
||||
2. A persisted secret file (so the secret survives restarts and is shared
|
||||
across worker processes).
|
||||
3. A freshly generated secret, persisted to that file.
|
||||
|
||||
A per-process random secret (the previous behaviour) invalidated every
|
||||
token on restart and gave each worker a different secret in multi-worker
|
||||
deployments, so tokens were rejected at random. We persist instead.
|
||||
"""
|
||||
env_secret = os.environ.get("BADNOTE_JWT_SECRET")
|
||||
if env_secret:
|
||||
return env_secret
|
||||
|
||||
db_path = os.environ.get("BADNOTE_DB_PATH", "./data/badnote_server.db")
|
||||
default_secret_file = os.path.join(os.path.dirname(db_path) or ".", ".jwt_secret")
|
||||
secret_path = os.environ.get("BADNOTE_JWT_SECRET_FILE", default_secret_file)
|
||||
|
||||
try:
|
||||
if os.path.exists(secret_path):
|
||||
with open(secret_path, "r", encoding="utf-8") as f:
|
||||
existing = f.read().strip()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
secret = secrets.token_urlsafe(48)
|
||||
os.makedirs(os.path.dirname(secret_path) or ".", exist_ok=True)
|
||||
# Restrictive permissions: only the owner may read the secret.
|
||||
fd = os.open(secret_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(secret)
|
||||
return secret
|
||||
except OSError:
|
||||
warnings.warn(
|
||||
"Could not persist a JWT secret; using an ephemeral one. "
|
||||
"Set BADNOTE_JWT_SECRET to keep tokens valid across restarts.",
|
||||
RuntimeWarning,
|
||||
)
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
|
||||
def _resolve_cors_origins() -> list[str]:
|
||||
"""Parse the allowed CORS origins from ``BADNOTE_CORS_ORIGINS``.
|
||||
|
||||
Comma-separated list of origins. Empty by default; the app falls back to a
|
||||
permissive ``*`` (without credentials) when none are configured.
|
||||
"""
|
||||
raw = os.environ.get("BADNOTE_CORS_ORIGINS", "")
|
||||
return [o.strip() for o in raw.split(",") if o.strip()]
|
||||
|
||||
|
||||
class Settings:
|
||||
host: str = os.environ.get("BADNOTE_HOST", "0.0.0.0")
|
||||
port: int = int(os.environ.get("BADNOTE_PORT", "8080"))
|
||||
db_path: str = os.environ.get("BADNOTE_DB_PATH", "./data/badnote_server.db")
|
||||
storage_path: str = os.environ.get("BADNOTE_STORAGE_PATH", "./data/storage")
|
||||
queue_path: str = os.environ.get("BADNOTE_QUEUE_PATH", "./data/queue")
|
||||
jwt_secret: str = _resolve_jwt_secret()
|
||||
jwt_expiry_hours: int = int(os.environ.get("BADNOTE_JWT_EXPIRY_HOURS", "720"))
|
||||
cors_origins: list[str] = _resolve_cors_origins()
|
||||
|
||||
|
||||
settings = Settings()
|
||||
90
server/badnote_server/database.py
Normal file
90
server/badnote_server/database.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Async SQLite database layer for BadNote."""
|
||||
|
||||
import aiosqlite
|
||||
from .config import settings
|
||||
|
||||
_db: aiosqlite.Connection | None = None
|
||||
|
||||
|
||||
async def get_db() -> aiosqlite.Connection:
|
||||
"""Return the global database connection."""
|
||||
global _db
|
||||
if _db is None:
|
||||
_db = await aiosqlite.connect(settings.db_path)
|
||||
_db.row_factory = aiosqlite.Row
|
||||
await _db.execute("PRAGMA journal_mode=WAL")
|
||||
await _db.execute("PRAGMA foreign_keys=ON")
|
||||
return _db
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
"""Close the global database connection."""
|
||||
global _db
|
||||
if _db is not None:
|
||||
await _db.close()
|
||||
_db = None
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Create all tables if they do not exist."""
|
||||
db = await get_db()
|
||||
await db.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
title TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
strokes_json TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
filename TEXT NOT NULL,
|
||||
doc_type TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
page_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS annotations (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
page_number INTEGER NOT NULL,
|
||||
annotation_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
page_number INTEGER NOT NULL,
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
color INTEGER NOT NULL DEFAULT 4283215696,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ocr_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
note_id TEXT,
|
||||
document_id TEXT,
|
||||
page_number INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
result_text TEXT,
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
);
|
||||
""")
|
||||
await db.commit()
|
||||
55
server/badnote_server/main.py
Normal file
55
server/badnote_server/main.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""BadNote FastAPI server — main application."""
|
||||
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import settings
|
||||
from .database import close_db, init_db
|
||||
from .routers.auth_router import router as auth_router
|
||||
from .routers.notes_router import router as notes_router
|
||||
from .routers.documents_router import router as documents_router
|
||||
from .routers.ocr_router import router as ocr_router
|
||||
from .routers.sync_router import router as sync_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Startup: create directories and init DB. Shutdown: close DB."""
|
||||
os.makedirs(settings.storage_path, exist_ok=True)
|
||||
for subdir in ("pending", "processing", "done", "failed"):
|
||||
os.makedirs(os.path.join(settings.queue_path, subdir), exist_ok=True)
|
||||
await init_db()
|
||||
yield
|
||||
await close_db()
|
||||
|
||||
|
||||
app = FastAPI(title="BadNote Server", version="1.0.0", lifespan=lifespan)
|
||||
|
||||
# Authentication is Bearer-token based, so cookies/credentials are not needed.
|
||||
# `allow_origins=["*"]` together with `allow_credentials=True` is an invalid and
|
||||
# insecure combination, so we keep credentials disabled. Set BADNOTE_CORS_ORIGINS
|
||||
# (comma-separated) to lock the API down to specific front-end origins.
|
||||
_cors_origins = settings.cors_origins or ["*"]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_cors_origins,
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(notes_router, prefix="/api/notes", tags=["notes"])
|
||||
app.include_router(documents_router, prefix="/api/documents", tags=["documents"])
|
||||
app.include_router(ocr_router, prefix="/api/ocr", tags=["ocr"])
|
||||
app.include_router(sync_router, prefix="/api/sync", tags=["sync"])
|
||||
|
||||
|
||||
@app.get("/api/ping")
|
||||
async def ping() -> dict:
|
||||
"""Health check endpoint."""
|
||||
return {"status": "ok"}
|
||||
136
server/badnote_server/models.py
Normal file
136
server/badnote_server/models.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Pydantic request/response models for BadNote."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str = Field(..., min_length=1, max_length=64)
|
||||
password: str = Field(..., min_length=4, max_length=128)
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
token: str
|
||||
user_id: str
|
||||
|
||||
|
||||
# ── Notes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class NoteCreate(BaseModel):
|
||||
id: str
|
||||
title: str = ""
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
strokes_json: str = "[]"
|
||||
|
||||
|
||||
class NoteUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
tags: list[str] | None = None
|
||||
strokes_json: str | None = None
|
||||
|
||||
|
||||
class NoteResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
title: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
tags: list[str]
|
||||
strokes_json: str
|
||||
|
||||
|
||||
# ── Documents ───────────────────────────────────────────────────────────────
|
||||
|
||||
class DocumentResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
filename: str
|
||||
doc_type: str
|
||||
page_count: int
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class AnnotationUpdate(BaseModel):
|
||||
annotation_json: list[dict] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AnnotationResponse(BaseModel):
|
||||
id: str
|
||||
document_id: str
|
||||
page_number: int
|
||||
annotation_json: list[dict]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class BookmarkCreate(BaseModel):
|
||||
page_number: int
|
||||
label: str = ""
|
||||
color: int = 4283215696
|
||||
|
||||
|
||||
class BookmarkResponse(BaseModel):
|
||||
id: str
|
||||
document_id: str
|
||||
page_number: int
|
||||
label: str
|
||||
color: int
|
||||
created_at: str
|
||||
|
||||
|
||||
# ── OCR ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
class OcrJobRequest(BaseModel):
|
||||
note_id: str | None = None
|
||||
document_id: str | None = None
|
||||
page_number: int | None = None
|
||||
|
||||
|
||||
class OcrJobStatus(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
result_text: str | None = None
|
||||
error_message: str | None = None
|
||||
created_at: str
|
||||
completed_at: str | None = None
|
||||
|
||||
|
||||
class OcrResult(BaseModel):
|
||||
id: str
|
||||
note_id: str | None
|
||||
document_id: str | None
|
||||
page_number: int | None
|
||||
status: str
|
||||
result_text: str | None
|
||||
error_message: str | None
|
||||
created_at: str
|
||||
completed_at: str | None
|
||||
|
||||
|
||||
# ── Sync ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class SyncNote(BaseModel):
|
||||
id: str
|
||||
title: str = ""
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
strokes_json: str = "[]"
|
||||
updated_at: str
|
||||
|
||||
|
||||
class SyncPushRequest(BaseModel):
|
||||
notes: list[SyncNote]
|
||||
|
||||
|
||||
class SyncPullRequest(BaseModel):
|
||||
since: str
|
||||
|
||||
|
||||
class SyncResponse(BaseModel):
|
||||
synced_count: int
|
||||
0
server/badnote_server/ocr/__init__.py
Normal file
0
server/badnote_server/ocr/__init__.py
Normal file
78
server/badnote_server/ocr/engine.py
Normal file
78
server/badnote_server/ocr/engine.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""OCR engine for BadNote using EasyOCR.
|
||||
|
||||
Lightweight handwriting-capable OCR using EasyOCR with CPU-only inference.
|
||||
Suitable for Zen2 APU 25W / 16GB RAM (~200MB memory once loaded).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# NOTE: `easyocr` (and its torch dependency) is heavy and optional. It is
|
||||
# imported lazily inside the engine so that importing the FastAPI app — and
|
||||
# running its test suite — does not require the OCR dependencies. Install them
|
||||
# with `pip install -r requirements-ocr.txt` when running the OCR worker.
|
||||
|
||||
|
||||
class OcrEngine:
|
||||
"""EasyOCR-based text recognition engine.
|
||||
|
||||
Lazy-loads the reader on first use to avoid startup overhead.
|
||||
Supports Chinese (simplified) + English. Runs on CPU only.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._reader = None
|
||||
|
||||
def _ensure_reader(self):
|
||||
"""Lazy-initialize the EasyOCR reader."""
|
||||
if self._reader is None:
|
||||
import easyocr # imported lazily; see module docstring note
|
||||
|
||||
logger.info("Loading EasyOCR reader (ch_sim + en, CPU)...")
|
||||
self._reader = easyocr.Reader(['ch_sim', 'en'], gpu=False)
|
||||
logger.info("EasyOCR reader loaded")
|
||||
|
||||
async def recognize(self, image_bytes: bytes) -> str:
|
||||
"""Recognize text from image bytes.
|
||||
|
||||
Args:
|
||||
image_bytes: Raw image file bytes (PNG, JPEG, etc.)
|
||||
|
||||
Returns:
|
||||
Recognized text as a single string, or empty string on failure.
|
||||
"""
|
||||
if not image_bytes:
|
||||
return ""
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
try:
|
||||
self._ensure_reader()
|
||||
results = await loop.run_in_executor(
|
||||
None, self._reader.readtext, image_bytes
|
||||
)
|
||||
# results is a list of (bbox, text, confidence) tuples
|
||||
text_parts = [text for _, text, _ in results if text.strip()]
|
||||
return ' '.join(text_parts)
|
||||
except Exception as exc:
|
||||
logger.error("OCR recognition failed: %s", exc)
|
||||
return ""
|
||||
|
||||
async def recognize_file(self, image_path: str) -> str:
|
||||
"""Recognize text from an image file path.
|
||||
|
||||
Args:
|
||||
image_path: Path to the image file on disk.
|
||||
|
||||
Returns:
|
||||
Recognized text as a single string, or empty string on failure.
|
||||
"""
|
||||
try:
|
||||
with open(image_path, 'rb') as f:
|
||||
image_bytes = f.read()
|
||||
return await self.recognize(image_bytes)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to read image file %s: %s", image_path, exc)
|
||||
return ""
|
||||
136
server/badnote_server/ocr/queue.py
Normal file
136
server/badnote_server/ocr/queue.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""File-based OCR job queue for BadNote."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from ..config import settings
|
||||
|
||||
|
||||
def _queue_dir(subdir: str) -> str:
|
||||
path = os.path.join(settings.queue_path, subdir)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _job_path(job_id: str, subdir: str) -> str:
|
||||
return os.path.join(_queue_dir(subdir), f"{job_id}.json")
|
||||
|
||||
|
||||
def _read_job(path: str) -> dict | None:
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def _write_job(path: str, data: dict) -> None:
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def enqueue(job_data: dict) -> str:
|
||||
"""Add a job to the pending queue. Returns job_id."""
|
||||
job_id = job_data.get("id", str(uuid4()))
|
||||
job_data["id"] = job_id
|
||||
job_data["status"] = "pending"
|
||||
job_data["created_at"] = datetime.now(timezone.utc).isoformat()
|
||||
_write_job(_job_path(job_id, "pending"), job_data)
|
||||
return job_id
|
||||
|
||||
|
||||
def dequeue() -> dict | None:
|
||||
"""Move the first pending job to processing. Returns job dict or None."""
|
||||
pending_dir = _queue_dir("pending")
|
||||
try:
|
||||
files = sorted(os.listdir(pending_dir))
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
for fname in files:
|
||||
if not fname.endswith(".json"):
|
||||
continue
|
||||
src = os.path.join(pending_dir, fname)
|
||||
job = _read_job(src)
|
||||
if job is None:
|
||||
continue
|
||||
job["status"] = "processing"
|
||||
dst = _job_path(job["id"], "processing")
|
||||
shutil.move(src, dst)
|
||||
return job
|
||||
return None
|
||||
|
||||
|
||||
def complete(job_id: str, result: str) -> None:
|
||||
"""Mark a job as done with result text."""
|
||||
src = _job_path(job_id, "processing")
|
||||
job = _read_job(src)
|
||||
if job is None:
|
||||
return
|
||||
job["status"] = "done"
|
||||
job["result_text"] = result
|
||||
job["completed_at"] = datetime.now(timezone.utc).isoformat()
|
||||
dst = _job_path(job_id, "done")
|
||||
if os.path.exists(src):
|
||||
os.remove(src)
|
||||
_write_job(dst, job)
|
||||
|
||||
|
||||
def fail(job_id: str, error: str) -> None:
|
||||
"""Mark a job as failed with error message."""
|
||||
src = _job_path(job_id, "processing")
|
||||
job = _read_job(src)
|
||||
if job is None:
|
||||
return
|
||||
job["status"] = "failed"
|
||||
job["error_message"] = error
|
||||
job["completed_at"] = datetime.now(timezone.utc).isoformat()
|
||||
dst = _job_path(job_id, "failed")
|
||||
if os.path.exists(src):
|
||||
os.remove(src)
|
||||
_write_job(dst, job)
|
||||
|
||||
|
||||
def get_status(job_id: str) -> dict | None:
|
||||
"""Check all directories for a job and return its data."""
|
||||
for subdir in ("pending", "processing", "done", "failed"):
|
||||
job = _read_job(_job_path(job_id, subdir))
|
||||
if job is not None:
|
||||
return job
|
||||
return None
|
||||
|
||||
|
||||
def get_jobs_for_note(note_id: str) -> list[dict]:
|
||||
"""Return all completed OCR jobs for a given note_id."""
|
||||
results = []
|
||||
for subdir in ("done", "pending", "processing", "failed"):
|
||||
dir_path = _queue_dir(subdir)
|
||||
try:
|
||||
for fname in os.listdir(dir_path):
|
||||
if not fname.endswith(".json"):
|
||||
continue
|
||||
job = _read_job(os.path.join(dir_path, fname))
|
||||
if job and job.get("note_id") == note_id:
|
||||
results.append(job)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return results
|
||||
|
||||
|
||||
def get_jobs_for_document(document_id: str) -> list[dict]:
|
||||
"""Return all OCR jobs for a given document_id."""
|
||||
results = []
|
||||
for subdir in ("done", "pending", "processing", "failed"):
|
||||
dir_path = _queue_dir(subdir)
|
||||
try:
|
||||
for fname in os.listdir(dir_path):
|
||||
if not fname.endswith(".json"):
|
||||
continue
|
||||
job = _read_job(os.path.join(dir_path, fname))
|
||||
if job and job.get("document_id") == document_id:
|
||||
results.append(job)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return results
|
||||
69
server/badnote_server/ocr/worker.py
Normal file
69
server/badnote_server/ocr/worker.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Background OCR worker for BadNote.
|
||||
|
||||
Polls the file-based queue and processes jobs using the OcrEngine.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
from ..config import settings
|
||||
from .engine import OcrEngine
|
||||
from . import queue as job_queue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_worker(poll_interval: int = 5) -> None:
|
||||
"""Poll the queue and process OCR jobs.
|
||||
|
||||
Loads images from the job's image_path and runs them through EasyOCR.
|
||||
"""
|
||||
# Ensure queue dirs exist
|
||||
for subdir in ("pending", "processing", "done", "failed"):
|
||||
os.makedirs(os.path.join(settings.queue_path, subdir), exist_ok=True)
|
||||
|
||||
engine = OcrEngine()
|
||||
logger.info("OCR worker started (poll_interval=%ds)", poll_interval)
|
||||
|
||||
while True:
|
||||
job = job_queue.dequeue()
|
||||
if job is not None:
|
||||
job_id = job["id"]
|
||||
logger.info("Processing OCR job %s", job_id)
|
||||
try:
|
||||
# Read image from the path specified in the job
|
||||
image_path = job.get("image_path", "")
|
||||
if image_path and os.path.exists(image_path):
|
||||
result = await engine.recognize_file(image_path)
|
||||
else:
|
||||
# Fall back to image_bytes if provided inline
|
||||
image_bytes = job.get("image_bytes", b"")
|
||||
if isinstance(image_bytes, str):
|
||||
import base64
|
||||
image_bytes = base64.b64decode(image_bytes)
|
||||
result = await engine.recognize(image_bytes)
|
||||
|
||||
job_queue.complete(job_id, result)
|
||||
logger.info("OCR job %s completed: %d chars", job_id, len(result))
|
||||
except Exception as exc:
|
||||
logger.error("OCR job %s failed: %s", job_id, exc)
|
||||
job_queue.fail(job_id, str(exc))
|
||||
else:
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point for `python -m badnote_server.ocr.worker`."""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
try:
|
||||
asyncio.run(run_worker())
|
||||
except KeyboardInterrupt:
|
||||
logger.info("OCR worker stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
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}
|
||||
56
server/badnote_server/storage.py
Normal file
56
server/badnote_server/storage.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""File-system document storage for BadNote."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
def _safe_filename(filename: str) -> str:
|
||||
"""Reduce a client-supplied filename to a safe basename.
|
||||
|
||||
Prevents path traversal (e.g. ``../../etc/passwd``) by stripping any
|
||||
directory components and parent references before the name is joined onto
|
||||
the storage path.
|
||||
"""
|
||||
name = os.path.basename(filename or "")
|
||||
name = name.replace("\\", "").replace("/", "").strip()
|
||||
if not name or name in (".", ".."):
|
||||
name = "document"
|
||||
return name
|
||||
|
||||
|
||||
def _resolve_within(base: str, *parts: str) -> str:
|
||||
"""Join ``parts`` onto ``base`` and ensure the result stays inside ``base``."""
|
||||
base_abs = os.path.abspath(base)
|
||||
target = os.path.abspath(os.path.join(base_abs, *parts))
|
||||
if os.path.commonpath([base_abs, target]) != base_abs:
|
||||
raise ValueError("Resolved path escapes the storage directory")
|
||||
return target
|
||||
|
||||
|
||||
def save_document(file_bytes: bytes, doc_id: str, filename: str) -> str:
|
||||
"""Save uploaded file bytes to storage. Returns the stored file path."""
|
||||
safe_doc_id = _safe_filename(doc_id)
|
||||
safe_name = _safe_filename(filename)
|
||||
doc_dir = _resolve_within(settings.storage_path, safe_doc_id)
|
||||
os.makedirs(doc_dir, exist_ok=True)
|
||||
file_path = _resolve_within(doc_dir, safe_name)
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(file_bytes)
|
||||
return file_path
|
||||
|
||||
|
||||
def get_document_path(doc_id: str, filename: str) -> str:
|
||||
"""Return the full path to a stored document file."""
|
||||
safe_doc_id = _safe_filename(doc_id)
|
||||
safe_name = _safe_filename(filename)
|
||||
return _resolve_within(settings.storage_path, safe_doc_id, safe_name)
|
||||
|
||||
|
||||
def delete_document(doc_id: str) -> None:
|
||||
"""Remove a document's directory and all its contents."""
|
||||
safe_doc_id = _safe_filename(doc_id)
|
||||
doc_dir = _resolve_within(settings.storage_path, safe_doc_id)
|
||||
if os.path.isdir(doc_dir):
|
||||
shutil.rmtree(doc_dir)
|
||||
8
server/requirements-ocr.txt
Normal file
8
server/requirements-ocr.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
# Optional OCR worker dependencies. Heavy (pulls in torch). Only required to
|
||||
# run `python -m badnote_server.ocr.worker`. The API server and tests do not
|
||||
# need these.
|
||||
-r requirements.txt
|
||||
|
||||
easyocr>=1.7.0
|
||||
opencv-python-headless>=4.10.0
|
||||
Pillow>=10.0.0
|
||||
17
server/requirements.txt
Normal file
17
server/requirements.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.34.0
|
||||
pydantic>=2.10.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
aiosqlite>=0.21.0
|
||||
python-multipart>=0.0.20
|
||||
aiofiles>=24.0.0
|
||||
httpx>=0.28.0
|
||||
|
||||
# Testing
|
||||
pytest>=8.0.0
|
||||
pytest-asyncio>=0.25.0
|
||||
|
||||
# OCR dependencies (easyocr, opencv, torch) are heavy and optional. They are
|
||||
# only needed to run the background OCR worker, not the API server or its tests.
|
||||
# Install them with: pip install -r requirements-ocr.txt
|
||||
4
server/scripts/run.sh
Executable file
4
server/scripts/run.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")/.."
|
||||
source .venv/bin/activate 2>/dev/null || { echo "Run setup.sh first"; exit 1; }
|
||||
uvicorn badnote_server.main:app --host 0.0.0.0 --port 8080 --workers 1
|
||||
10
server/scripts/setup.sh
Executable file
10
server/scripts/setup.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
cd "$(dirname "$0")/.."
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
mkdir -p data/storage data/queue/pending data/queue/processing data/queue/done data/queue/failed
|
||||
echo "BadNote server setup complete."
|
||||
echo "Run: source .venv/bin/activate && uvicorn badnote_server.main:app --reload"
|
||||
echo "To run the OCR worker, also install OCR deps: pip install -r requirements-ocr.txt"
|
||||
4
server/scripts/worker.sh
Executable file
4
server/scripts/worker.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")/.."
|
||||
source .venv/bin/activate 2>/dev/null || { echo "Run setup.sh first"; exit 1; }
|
||||
nice -n 10 python -m badnote_server.ocr.worker
|
||||
0
server/tests/__init__.py
Normal file
0
server/tests/__init__.py
Normal file
43
server/tests/conftest.py
Normal file
43
server/tests/conftest.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Shared test fixtures for BadNote tests."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
# Ensure the server package is importable
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
os.environ["BADNOTE_STORAGE_PATH"] = "/tmp/badnote_test_storage"
|
||||
os.environ["BADNOTE_QUEUE_PATH"] = "/tmp/badnote_test_queue"
|
||||
os.environ["BADNOTE_JWT_SECRET"] = "test-secret-key-for-testing-only"
|
||||
|
||||
from badnote_server.config import settings # noqa: E402
|
||||
from badnote_server.main import app # noqa: E402
|
||||
from badnote_server.database import get_db, close_db, init_db # noqa: E402
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def setup_db(tmp_path):
|
||||
"""Fresh DB and clean queue for each test."""
|
||||
db_path = str(tmp_path / "test.db")
|
||||
settings.db_path = db_path
|
||||
# Clean the queue directory before each test
|
||||
queue_path = settings.queue_path
|
||||
if os.path.exists(queue_path):
|
||||
shutil.rmtree(queue_path)
|
||||
await init_db()
|
||||
yield
|
||||
await close_db()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client():
|
||||
"""Async test client for the FastAPI app."""
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
18
server/tests/helpers.py
Normal file
18
server/tests/helpers.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Shared test helpers for BadNote tests."""
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
async def register_and_login(client: AsyncClient) -> tuple[str, str]:
|
||||
"""Helper: register a user and return (token, user_id)."""
|
||||
resp = await client.post(
|
||||
"/api/auth/register",
|
||||
json={"username": "testuser", "password": "testpass123"},
|
||||
)
|
||||
data = resp.json()
|
||||
return data["token"], data["user_id"]
|
||||
|
||||
|
||||
def auth_header(token: str) -> dict:
|
||||
"""Return Authorization header dict."""
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
82
server/tests/test_auth.py
Normal file
82
server/tests/test_auth.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Tests for auth endpoints."""
|
||||
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
from helpers import auth_header, register_and_login
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register(client: AsyncClient):
|
||||
resp = await client.post(
|
||||
"/api/auth/register",
|
||||
json={"username": "newuser", "password": "password123"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert "token" in data
|
||||
assert "user_id" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_duplicate(client: AsyncClient):
|
||||
await client.post(
|
||||
"/api/auth/register",
|
||||
json={"username": "dupuser", "password": "password123"},
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/auth/register",
|
||||
json={"username": "dupuser", "password": "password123"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login(client: AsyncClient):
|
||||
await client.post(
|
||||
"/api/auth/register",
|
||||
json={"username": "loginuser", "password": "mypassword"},
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "loginuser", "password": "mypassword"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "token" in data
|
||||
assert "user_id" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_wrong_password(client: AsyncClient):
|
||||
await client.post(
|
||||
"/api/auth/register",
|
||||
json={"username": "wrongpw", "password": "correct"},
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "wrongpw", "password": "incorrect"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh(client: AsyncClient):
|
||||
token, user_id = await register_and_login(client)
|
||||
resp = await client.post(
|
||||
"/api/auth/refresh",
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "token" in data
|
||||
assert data["user_id"] == user_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_no_token(client: AsyncClient):
|
||||
resp = await client.post("/api/auth/refresh")
|
||||
assert resp.status_code in (401, 403)
|
||||
146
server/tests/test_notes.py
Normal file
146
server/tests/test_notes.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Tests for notes CRUD and sync endpoints."""
|
||||
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
from helpers import auth_header, register_and_login
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_note(client: AsyncClient):
|
||||
token, _ = await register_and_login(client)
|
||||
resp = await client.post(
|
||||
"/api/notes",
|
||||
json={
|
||||
"id": "note-001",
|
||||
"title": "Test Note",
|
||||
"tags": ["tag1", "tag2"],
|
||||
"strokes_json": "[{\"x\":1,\"y\":2}]",
|
||||
},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["id"] == "note-001"
|
||||
assert data["title"] == "Test Note"
|
||||
assert data["tags"] == ["tag1", "tag2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_note(client: AsyncClient):
|
||||
token, _ = await register_and_login(client)
|
||||
await client.post(
|
||||
"/api/notes",
|
||||
json={"id": "note-002", "title": "Fetch Me", "tags": [], "strokes_json": "[]"},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
resp = await client.get("/api/notes/note-002", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["title"] == "Fetch Me"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_note_not_found(client: AsyncClient):
|
||||
token, _ = await register_and_login(client)
|
||||
resp = await client.get("/api/notes/nonexistent", headers=auth_header(token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_note(client: AsyncClient):
|
||||
token, _ = await register_and_login(client)
|
||||
await client.post(
|
||||
"/api/notes",
|
||||
json={"id": "note-003", "title": "Original", "tags": [], "strokes_json": "[]"},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/notes",
|
||||
json={"id": "note-003", "title": "Updated", "tags": ["new"], "strokes_json": "[1]"},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["title"] == "Updated"
|
||||
assert resp.json()["tags"] == ["new"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_note(client: AsyncClient):
|
||||
token, _ = await register_and_login(client)
|
||||
await client.post(
|
||||
"/api/notes",
|
||||
json={"id": "note-004", "title": "Delete Me", "tags": [], "strokes_json": "[]"},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
resp = await client.delete("/api/notes/note-004", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
resp = await client.get("/api/notes/note-004", headers=auth_header(token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_notes_with_since(client: AsyncClient):
|
||||
token, _ = await register_and_login(client)
|
||||
await client.post(
|
||||
"/api/notes",
|
||||
json={"id": "note-005", "title": "Old", "tags": [], "strokes_json": "[]"},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
resp = await client.get("/api/notes", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) >= 1
|
||||
|
||||
resp = await client.get(
|
||||
"/api/notes?since=2099-01-01T00:00:00+00:00",
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notes_require_auth(client: AsyncClient):
|
||||
resp = await client.get("/api/notes")
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
|
||||
# ── Sync ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_push(client: AsyncClient):
|
||||
token, _ = await register_and_login(client)
|
||||
resp = await client.post(
|
||||
"/api/sync/push",
|
||||
json={
|
||||
"notes": [
|
||||
{"id": "sync-1", "title": "Synced", "tags": [], "strokes_json": "[]", "updated_at": "2024-01-01T00:00:00+00:00"},
|
||||
{"id": "sync-2", "title": "Also", "tags": ["t"], "strokes_json": "[]", "updated_at": "2024-01-02T00:00:00+00:00"},
|
||||
]
|
||||
},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["synced_count"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_pull(client: AsyncClient):
|
||||
token, _ = await register_and_login(client)
|
||||
await client.post(
|
||||
"/api/sync/push",
|
||||
json={"notes": [{"id": "pull-1", "title": "Pull Me", "tags": [], "strokes_json": "[]", "updated_at": "2024-06-01T00:00:00+00:00"}]},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/sync/pull",
|
||||
json={"since": "2024-01-01T00:00:00+00:00"},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
notes = resp.json()["notes"]
|
||||
assert len(notes) >= 1
|
||||
assert any(n["id"] == "pull-1" for n in notes)
|
||||
82
server/tests/test_ocr.py
Normal file
82
server/tests/test_ocr.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Tests for OCR endpoints."""
|
||||
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
from helpers import auth_header, register_and_login
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_ocr_job(client: AsyncClient):
|
||||
token, _ = await register_and_login(client)
|
||||
resp = await client.post(
|
||||
"/api/ocr/process",
|
||||
json={"note_id": "note-ocr-1", "document_id": None, "page_number": None},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert "job_id" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_job_status(client: AsyncClient):
|
||||
token, _ = await register_and_login(client)
|
||||
resp = await client.post(
|
||||
"/api/ocr/process",
|
||||
json={"note_id": "note-ocr-2", "document_id": None, "page_number": None},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
job_id = resp.json()["job_id"]
|
||||
|
||||
resp = await client.get(f"/api/ocr/status/{job_id}", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == job_id
|
||||
assert data["status"] == "pending"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_job_status_not_found(client: AsyncClient):
|
||||
token, _ = await register_and_login(client)
|
||||
resp = await client.get("/api/ocr/status/nonexistent", headers=auth_header(token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_ocr_results_empty(client: AsyncClient):
|
||||
token, _ = await register_and_login(client)
|
||||
resp = await client.get("/api/ocr/results/note-no-jobs", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_ocr_results_with_jobs(client: AsyncClient):
|
||||
token, _ = await register_and_login(client)
|
||||
await client.post(
|
||||
"/api/ocr/process",
|
||||
json={"note_id": "note-ocr-3", "document_id": None, "page_number": None},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
await client.post(
|
||||
"/api/ocr/process",
|
||||
json={"note_id": "note-ocr-3", "document_id": None, "page_number": None},
|
||||
headers=auth_header(token),
|
||||
)
|
||||
|
||||
resp = await client.get("/api/ocr/results/note-ocr-3", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ocr_requires_auth(client: AsyncClient):
|
||||
resp = await client.post(
|
||||
"/api/ocr/process",
|
||||
json={"note_id": "x", "document_id": None, "page_number": None},
|
||||
)
|
||||
assert resp.status_code in (401, 403)
|
||||
Reference in New Issue
Block a user