diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 9d0edc8..0000000 --- a/.dockerignore +++ /dev/null @@ -1,20 +0,0 @@ -.git -.forgejo -.env -.env.* -!.env.example - -frontend/node_modules -frontend/dist - -**/__pycache__ -**/*.py[cod] -**/.pytest_cache -**/.mypy_cache -**/.ruff_cache -**/*.egg-info - -*.sqlite -*.sqlite-shm -*.sqlite-wal -*.log diff --git a/.env.example b/.env.example index e0cf067..0328e43 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,7 @@ # -------------------------------------------- # Secret key for signing session cookies. # Generate a strong random value: python -c "import secrets; print(secrets.token_urlsafe(32))" -SECRET_KEY=replace-with-a-long-random-secret +SECRET_KEY=change-me-in-production # -------------------------------------------- # Upload Limits @@ -40,13 +40,9 @@ COOKIE_SECURE=false # -------------------------------------------- # Networking # -------------------------------------------- -# Port the PaperJet container publishes. Your outer reverse proxy targets this. +# Port the frontend container publishes. Your outer reverse proxy targets this. HTTP_PORT=4982 -# Optional image reference for a pulled Forgejo build. -# Leave unset for local builds; set this before docker compose pull in production. -# PAPERJET_IMAGE=git.elijahkuntz.com/your-user/paperjet:latest - # -------------------------------------------- # Debug # -------------------------------------------- diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml new file mode 100644 index 0000000..4ed4ca5 --- /dev/null +++ b/.forgejo/workflows/build.yml @@ -0,0 +1,25 @@ +name: Automated Container Build + +on: + push: + branches: + - main + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Log into Local Registry + run: | + echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ gitea.actor }}" --password-stdin + + - name: Build and Push Image + run: | + # Force the entire image path string to lowercase dynamically + IMAGE_PATH=$(echo "git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}:latest" | tr '[:upper:]' '[:lower:]') + + docker build -t "$IMAGE_PATH" . + docker push "$IMAGE_PATH" \ No newline at end of file diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 994bcdc..39f912e 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -69,73 +69,3 @@ jobs: - name: Test (vitest) working-directory: frontend run: npx vitest run - - container: - name: Container (Docker) - runs-on: ubuntu-latest - needs: [backend, frontend] - - steps: - - uses: actions/checkout@v4 - - - name: Validate Compose configuration - run: docker compose --env-file .env.example config --quiet - - - name: Build container image - run: | - IMAGE_PATH=$(printf 'git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}' | tr '[:upper:]' '[:lower:]') - docker build \ - -t paperjet:ci \ - -t "$IMAGE_PATH:latest" \ - -t "$IMAGE_PATH:${{ github.sha }}" \ - . - - - name: Run container smoke test - run: | - set -Eeuo pipefail - - docker run \ - --detach \ - --name paperjet-ci \ - --publish 18080:80 \ - --env PAPERJET_SECRET_KEY=ci-only-secret \ - --env PAPERJET_COOKIE_SECURE=false \ - --env PAPERJET_DEBUG=false \ - paperjet:ci - - cleanup() { - docker rm --force paperjet-ci >/dev/null 2>&1 || true - } - trap cleanup EXIT - - curl --fail --retry 30 --retry-delay 1 --retry-connrefused \ - http://127.0.0.1:18080/ - curl --fail --retry 30 --retry-delay 1 --retry-connrefused \ - http://127.0.0.1:18080/api/v1/health - - for attempt in $(seq 1 30); do - status=$(docker inspect --format '{{.State.Health.Status}}' paperjet-ci) - if [ "$status" = "healthy" ]; then - exit 0 - fi - if [ "$status" = "unhealthy" ]; then - docker logs paperjet-ci - exit 1 - fi - sleep 1 - done - - docker logs paperjet-ci - exit 1 - - - name: Log into Local Registry - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: | - echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ gitea.actor }}" --password-stdin - - - name: Push main image tags - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: | - IMAGE_PATH=$(printf 'git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}' | tr '[:upper:]' '[:lower:]') - docker push "$IMAGE_PATH:latest" - docker push "$IMAGE_PATH:${{ github.sha }}" diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 0282e40..0000000 --- a/Dockerfile +++ /dev/null @@ -1,55 +0,0 @@ -# syntax=docker/dockerfile:1 - -# Build the React/Vite application first so the runtime image contains only -# the compiled frontend and the Python/nginx runtime. -FROM node:22-alpine AS frontend-build - -WORKDIR /frontend - -COPY frontend/package.json frontend/package-lock.json ./ -RUN npm ci --legacy-peer-deps - -COPY frontend/ ./ -RUN npm run build - -# The runtime image contains both public-facing nginx and the loopback-only -# FastAPI/uvicorn process. This keeps deployment to one container while -# preserving the existing nginx -> API boundary. -FROM python:3.12-slim AS runtime - -ENV PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 \ - PYTHONPATH=/app - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - bash \ - fontconfig \ - fonts-liberation \ - nginx \ - && rm -rf /var/lib/apt/lists/* \ - && fc-cache -fv \ - && rm -f /etc/nginx/sites-enabled/default /etc/nginx/conf.d/default.conf - -WORKDIR /app - -# Copy the source tree into /app before installing so the runtime can resolve -# the local package (including bundled signature fonts) from PYTHONPATH. -COPY backend/pyproject.toml ./ -COPY backend/app ./app -RUN pip install --no-cache-dir . - -COPY --from=frontend-build /frontend/dist /usr/share/nginx/html -COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf -COPY docker-entrypoint.sh /usr/local/bin/paperjet-entrypoint - -RUN chmod +x /usr/local/bin/paperjet-entrypoint \ - && nginx -t \ - && mkdir -p /data/pdfs /data/thumbnails /data/db - -EXPOSE 80 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ - CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1/api/v1/health', timeout=3)"] - -ENTRYPOINT ["paperjet-entrypoint"] diff --git a/Project Architecture.md b/Project Architecture.md index 2a934a5..e22db8e 100644 --- a/Project Architecture.md +++ b/Project Architecture.md @@ -1,10 +1,10 @@ # Self-Hosted PDF Editor — Implementation Plan **Project codename:** `paperjet` (rename freely) -**Document version:** 1.2 +**Document version:** 1.1 **Status:** Foundational specification — this document is the single source of truth. Every AI agent working on this project reads this file first, in full, before writing code. -> **v1.2 changes:** the runtime deployment is consolidated into one Docker image containing nginx, the compiled SPA, and FastAPI/uvicorn. The existing v1.1 decisions remain unchanged. +> **v1.1 changes:** resolved the four open questions — upload ceiling set to 200 MB; versioning reframed around accidental-loss recovery with a document-level **trash/restore** added as the primary safety net; cookie/proxy behavior pinned (direct HTTP for dev only, everything through the HTTPS proxy in production); signature capture specified as **draw + type-to-signature with live multi-font preview**. --- @@ -12,7 +12,7 @@ A self-hosted, single-user, browser-based PDF editor with a Sejda-style annotation workflow. The user adds text boxes, freehand drawing, signatures, images, highlights, and shapes onto a PDF, moves and resizes them freely, and exports a flattened PDF. The application persists work continuously (autosave), keeps a per-file version history, and supports undo/redo. -It runs on the user's own server via Docker Compose, is reachable over LAN and over WAN through an nginx reverse proxy, and is protected by a single password. It depends on **no external programs, no external containers, and no third-party APIs**. Libraries and packages bundled into the application image are permitted. +It runs on the user's own server via Docker Compose, is reachable over LAN and over WAN through an nginx reverse proxy, and is protected by a single password. It depends on **no external programs, no external containers, and no third-party APIs**. Libraries and packages bundled into the application's own containers are permitted. ### Non-goals (v1) - Multi-user collaboration / real-time co-editing @@ -78,8 +78,8 @@ Because the app is WAN-exposed, auth is a real login screen backed by an Argon2i | ORM | SQLAlchemy 2.x | With Alembic for migrations | | Database | SQLite (WAL mode) | Single-user scale; embedded, no extra container | | Password hashing | Argon2id (`argon2-cffi`) | | -| Web server | nginx | Serves the static React build and proxies /api to loopback uvicorn | -| ASGI server | uvicorn | Runs on loopback inside the same application image | +| Web server (frontend) | nginx | Serves the static React build; also the app-internal entry | +| ASGI server (backend) | uvicorn | Behind nginx | | Orchestration | Docker Compose | | | External programs / containers / APIs | **None** | Hard requirement | @@ -98,44 +98,43 @@ Because the app is WAN-exposed, auth is a real login screen backed by an Argon2i ┌─────────────▼─────────────────────────────┐ │ Docker Compose │ │ │ - │ ┌──────────────────────────────────────┐ │ - │ │ paperjet image │ │ - │ │ nginx :80 ── /api ──▶ uvicorn :8000 │ │ - │ │ React SPA FastAPI + PyMuPDF │ │ - │ └───────────────────┬──────────────────┘ │ - │ │ │ - │ ┌──────────▼────────┐ │ - │ │ volumes │ │ - │ │ pdf_storage/ │ │ - │ │ thumbnails/ │ │ - │ │ db/app.sqlite │ │ - │ └───────────────────┘ │ + │ ┌──────────────┐ ┌────────────────┐ │ + │ │ frontend │ │ backend │ │ + │ │ nginx │─────▶│ FastAPI │ │ + │ │ serves SPA │ /api │ uvicorn │ │ + │ │ proxies /api│ │ PyMuPDF │ │ + │ └──────────────┘ │ SQLAlchemy │ │ + │ └───────┬────────┘ │ + │ │ │ + │ ┌──────────▼────────┐ │ + │ │ volumes │ │ + │ │ pdf_storage/ │ │ + │ │ thumbnails/ │ │ + │ │ db/app.sqlite │ │ + │ └───────────────────┘ │ └────────────────────────────────────────────┘ ``` -- The single `paperjet` container serves the SPA and reverse-proxies `/api/*` to its loopback-only FastAPI process. The browser sees a single origin — no CORS complexity, cookies "just work." -- The user's outer reverse proxy points at the published port of the `paperjet` container. +- The `frontend` nginx serves the SPA and reverse-proxies `/api/*` to the `backend`. This means the browser sees a single origin — no CORS complexity, cookies "just work." +- The user's outer reverse proxy points at the `frontend` container's published port. --- ## 5. Repository Structure -A single monorepo with one deployable runtime image. The Dockerfile uses a -Node build stage and a Python/nginx runtime stage. +A single monorepo. Two deployable images. ``` paperjet/ ├── README.md ├── ARCHITECTURE.md # symlink or copy of THIS plan; agents read first ├── docker-compose.yml -├── Dockerfile -├── docker-entrypoint.sh -├── .dockerignore ├── .env.example -├── .forgejo/workflows/ci.yml # lint, tests, image build, and publish +├── .github/workflows/ci.yml # lint + typecheck + test on push │ ├── frontend/ -│ ├── nginx.conf # SPA fallback + loopback /api proxy +│ ├── Dockerfile # multi-stage: build SPA, serve via nginx +│ ├── nginx.conf # SPA fallback + /api proxy │ ├── package.json │ ├── tsconfig.json # strict: true │ ├── vite.config.ts @@ -164,6 +163,7 @@ paperjet/ │ └── types/ # generated from backend OpenAPI │ ├── backend/ +│ ├── Dockerfile │ ├── pyproject.toml │ ├── alembic/ # migrations │ └── app/ @@ -342,7 +342,7 @@ In-document accidental edits during a single session are also covered by the in- - The registry is a dict `{type: handler}`; adding a type adds a handler, nothing else. - Missing handler → log + skip, never crash (§7.3). - Export accepts an optional `versionId` to export a historical version instead of the working state. -- Fonts: bundle inside the application image (no external program) two font sets so server export matches the live editor exactly: (a) standard-metric fonts — the Liberation family for Helvetica/Times/Courier compatibility — for `text` annotations; and (b) a small curated set of **signature/script fonts** for typed signatures (§7.2, §12.5), the same faces offered in the editor's live preview. Embed used fonts into the output for portability. +- Fonts: bundle inside the backend image (no external program) two font sets so server export matches the live editor exactly: (a) standard-metric fonts — the Liberation family for Helvetica/Times/Courier compatibility — for `text` annotations; and (b) a small curated set of **signature/script fonts** for typed signatures (§7.2, §12.5), the same faces offered in the editor's live preview. Embed used fonts into the output for portability. ### 9.4 Assets (binary annotation payloads) - `POST /documents/{id}/assets` (multipart) stores a signature PNG or placed image on the `pdf_storage` volume, returns `{ ref }`. @@ -499,29 +499,29 @@ Target the Sejda feel: bright, airy, light mode, generous whitespace, a restrain ## 13. Docker Compose & Deployment ```yaml -# docker-compose.yml +# docker-compose.yml (illustrative; agents finalize) services: - paperjet: - image: ${PAPERJET_IMAGE:-paperjet:local} - build: - context: . - dockerfile: Dockerfile + backend: + build: ./backend environment: - PAPERJET_SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env before starting PaperJet} - PAPERJET_MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-200} - PAPERJET_TRASH_RETENTION_DAYS: ${TRASH_RETENTION_DAYS:-30} - PAPERJET_AUTO_VERSION_RETENTION_DAYS: ${AUTO_VERSION_RETENTION_DAYS:-30} - PAPERJET_COOKIE_SECURE: ${COOKIE_SECURE:-false} - PAPERJET_DATABASE_PATH: /data/db/app.sqlite - PAPERJET_PDF_STORAGE_PATH: /data/pdfs - PAPERJET_THUMBNAILS_PATH: /data/thumbnails - PAPERJET_DEBUG: ${DEBUG:-false} + - SECRET_KEY=${SECRET_KEY} + - MAX_UPLOAD_MB=${MAX_UPLOAD_MB:-200} + - TRASH_RETENTION_DAYS=${TRASH_RETENTION_DAYS:-30} + - AUTO_VERSION_RETENTION_DAYS=${AUTO_VERSION_RETENTION_DAYS:-30} + - COOKIE_SECURE=${COOKIE_SECURE:-false} # false for direct-HTTP dev; set true in production (behind the HTTPS proxy) volumes: - pdf_storage:/data/pdfs - thumbnails:/data/thumbnails - db:/data/db + expose: + - "8000" + restart: unless-stopped + + frontend: + build: ./frontend + depends_on: [backend] ports: - - "${HTTP_PORT:-4982}:80" + - "${HTTP_PORT:-8080}:80" # user's outer reverse proxy targets this restart: unless-stopped volumes: @@ -530,10 +530,10 @@ volumes: db: ``` -- `frontend/nginx.conf` serves the SPA with history-API fallback and proxies `/api/` to `127.0.0.1:8000`, passing through cookies and the `X-Requested-With` header. Set `client_max_body_size` to match `MAX_UPLOAD_MB` (≥200 MB) so large PDFs aren't rejected at the proxy layer — and remind the user to raise the same limit in their **outer** reverse proxy, since that's the other place a large upload can be silently truncated. +- `frontend/nginx.conf` serves the SPA with history-API fallback and proxies `/api/` to `backend:8000`, passing through cookies and the `X-Requested-With` header. Set `client_max_body_size` to match `MAX_UPLOAD_MB` (≥200 MB) so large PDFs aren't rejected at the proxy layer — and remind the user to raise the same limit in their **outer** reverse proxy, since that's the other place a large upload can be silently truncated. - A 200 MB ceiling comfortably covers 100–200 page documents with embedded images; PDF.js page virtualization (§12.2) keeps the editor responsive on documents that large. -- The root Dockerfile builds the SPA with Node, installs production Python dependencies and bundled fonts, and copies both into one nginx/Python runtime image. -- docker-entrypoint.sh supervises nginx and loopback uvicorn so a failed child process stops the container cleanly. +- Backend Dockerfile installs Python deps and the bundled fonts; PyMuPDF comes from its wheel (no system packages, no external programs). +- Frontend Dockerfile is multi-stage: Node builds the SPA, the result is copied into an nginx image. - All persistent state lives in named volumes mountable on the user's Unraid array. Document the volume → host-path mapping for backups. --- @@ -563,7 +563,7 @@ Every agent (Claude / Codex / Gemini) follows these. Paste this section (or the 3. **One coordinate-transform module.** All space conversions go through `lib/coords.ts`. Never compute transforms inline in a component or duplicate the logic. Canonical space is always PDF points, top-left origin (§8). 4. **Store coordinates only in canonical space.** Never persist screen pixels. 5. **Types are generated, not hand-written.** After any backend schema change, regenerate the OpenAPI types for the frontend. Keep the TS annotation union and the Pydantic models in sync with `shared/annotation-schema.json`. -6. **No external programs, containers, or third-party APIs.** Libraries/packages bundled into the application image are fine. If a task seems to need an external dependency, stop and flag it; do not add one. +6. **No external programs, containers, or third-party APIs.** Libraries/packages bundled into the existing two images are fine. If a task seems to need an external dependency, stop and flag it; do not add one. 7. **Extend via registries.** New annotation types/tools register themselves; never scatter `switch(type)` logic across the codebase. Unknown types must round-trip without crashing. 8. **Security code is review-gated.** Do not modify auth, session, hashing, upload validation, or path handling without explicitly calling it out for review. Never log secrets or password material. 9. **Tests are required for the risk areas:** the coordinate module (round-trip + cross-engine), the export renderer per handler, auth flows, and upload validation. A feature touching these isn't done until its tests pass. @@ -580,7 +580,7 @@ Every agent (Claude / Codex / Gemini) follows these. Paste this section (or the Sequenced so each phase is independently testable and the riskiest correctness work (coordinates, export) is validated early. **Phase 0 — Scaffolding** -Monorepo, the root Dockerfile and entrypoint, single-service Compose, `.env.example`, CI (lint/typecheck/test/container smoke), nginx config, empty FastAPI app with `/health`, SPA shell with routing. *Done when:* `docker compose up` serves a blank authenticated-shell app and `/api/v1/health` responds. +Monorepo, both Dockerfiles, compose, `.env.example`, CI (lint/typecheck/test), nginx config, empty FastAPI app with `/health`, SPA shell with routing. *Done when:* `docker compose up` serves a blank authenticated-shell app and `/api/v1/health` responds. **Phase 1 — Auth & Library core** First-run setup, login/logout, session cookie, rate limiting. Upload (button + drag-drop), document list, thumbnails, soft delete + **trash/restore** (single + bulk, empty trash, retention purge sweep), home page (recent + library + trash). *Done when:* a user can log in, upload, see, delete, and restore PDFs. @@ -616,4 +616,4 @@ All four open questions are now settled: --- -*End of plan v1.2. Amendments are made to this document first, then to code.* +*End of plan v1.1. Amendments are made to this document first, then to code.* diff --git a/README.md b/README.md deleted file mode 100644 index f70815f..0000000 --- a/README.md +++ /dev/null @@ -1,86 +0,0 @@ -# PaperJet - -PaperJet is a self-hosted, single-user PDF annotation editor. It keeps the uploaded PDF immutable, stores annotations separately, autosaves the working layer, and flattens supported annotations only when exporting. - -## Run with Docker - -1. Copy `.env.example` to `.env`. -2. Replace `SECRET_KEY` with a long random value. Compose refuses to start without it. -3. Build and start the single-container application: - -```sh -docker compose up --build -d -``` - -The container serves the React application through nginx and proxies `/api` -requests to the loopback-only FastAPI process. Open `http://localhost:4982` -(or the value of `HTTP_PORT`). The first visit asks you to create the single -master password. - -The `db`, `pdf_storage`, and `thumbnails` volumes contain application data. -Back them up before maintenance or upgrades. Put the frontend behind HTTPS -when exposing PaperJet beyond localhost and set `COOKIE_SECURE=true`. - -### Run a Forgejo-published image - -Set `PAPERJET_IMAGE` in `.env` to the desired registry tag, for example the -`latest` or commit-SHA tag published by Forgejo. Then pull and start without -building locally: - -```sh -docker compose pull -docker compose up -d --no-build -``` - -The published image exposes only port 80. Your outer reverse proxy should -target the Compose port configured by `HTTP_PORT`. - -## Development - -Backend: - -```sh -cd backend -pip install -e ".[dev]" -uvicorn app.main:app --reload -``` - -Frontend, in a second terminal: - -```sh -cd frontend -npm ci -npm run dev -``` - -The Vite server proxies `/api` to `http://localhost:8000`. - -## Validation - -```sh -# container -docker compose --env-file .env.example config --quiet -docker compose up --build -d -curl http://localhost:4982/api/v1/health -docker compose ps - -# frontend -cd frontend -npm test -- --run -npm run typecheck -npm run lint -npm run build - -# backend -cd ../backend -pytest -q -mypy app/ -ruff check . -``` - -Stop the local stack with `docker compose down`. Do not use `-v` unless you -intend to remove the local application volumes. - -The editor uses PDF.js for display and PyMuPDF for export. Stored annotation -coordinates are PDF points with a top-left origin relative to the unrotated -CropBox; this is the contract shared by the editor and export renderer. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..0879421 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12-slim AS backend + +# System dependencies: fonts for PDF export + fontconfig +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + fonts-liberation \ + fontconfig && \ + rm -rf /var/lib/apt/lists/* && \ + fc-cache -fv + +WORKDIR /app + +# Install Python dependencies +COPY pyproject.toml ./ +RUN pip install --no-cache-dir -e ".[dev]" + +# Copy application code +COPY . . + +# Create data directories (will be overridden by volume mounts) +RUN mkdir -p /data/pdfs /data/thumbnails /data/db + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 63a7402..23ba9db 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -2,11 +2,11 @@ from logging.config import fileConfig +from alembic import context from sqlalchemy import engine_from_config, pool -from alembic import context from app.db import Base -from app.models import * # noqa: F403 — ensure all models are imported +from app.models import * # noqa: F401, F403 — ensure all models are imported config = context.config if config.config_file_name is not None: diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index ce8bd15..500dc87 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -2,14 +2,9 @@ from fastapi import APIRouter -from app.api.v1.annotations import router as annotations_router -from app.api.v1.assets import router as assets_router from app.api.v1.auth import router as auth_router from app.api.v1.documents import router as documents_router -from app.api.v1.export import router as export_router from app.api.v1.health import router as health_router -from app.api.v1.versions import router as versions_router -from app.config import settings router = APIRouter(prefix="/api/v1") @@ -20,18 +15,17 @@ router.include_router(auth_router) router.include_router(documents_router) # Assets +from app.api.v1.assets import router as assets_router router.include_router(assets_router) # Annotations +from app.api.v1.annotations import router as annotations_router router.include_router(annotations_router) -# Versions and export -router.include_router(versions_router) -router.include_router(export_router) - # Health (unauthenticated) router.include_router(health_router) +from app.config import settings if settings.DEBUG: from app.api.v1.debug import router as debug_router router.include_router(debug_router) diff --git a/backend/app/api/v1/annotations.py b/backend/app/api/v1/annotations.py index 24f1f31..2db3dcd 100644 --- a/backend/app/api/v1/annotations.py +++ b/backend/app/api/v1/annotations.py @@ -1,95 +1,91 @@ -"""Opaque working-state annotation endpoints.""" - -import json -from datetime import UTC, datetime - +from typing import Any from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.orm import Session +from datetime import datetime, timezone +import json -from app.auth.dependencies import get_current_user, verify_csrf from app.db import get_db -from app.models.annotation_state import AnnotationState from app.models.document import Document -from app.schemas.annotations import ( - AnnotationPayload, - AnnotationStateResponse, - AnnotationStateUpdateRequest, - AnnotationStateUpdateResponse, -) - -router = APIRouter(prefix="/documents", tags=["annotations"]) +from app.models.annotation_state import AnnotationState +from app.auth.dependencies import get_current_user, verify_csrf +from app.schemas.annotations import AnnotationStateResponse, AnnotationStateUpdateRequest, AnnotationStateUpdateResponse +router = APIRouter(prefix="/documents", tags=["Annotations"]) def _now_iso() -> str: - return datetime.now(UTC).isoformat() + return datetime.now(timezone.utc).isoformat() - -def _utc(value: datetime) -> datetime: - return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) - - -def _document_or_404(document_id: str, db: Session) -> Document: - document = db.scalar( - select(Document).where(Document.id == document_id, Document.deleted_at.is_(None)) - ) - if not document: +@router.get("/{document_id}/annotations", response_model=AnnotationStateResponse) +async def get_annotations( + document_id: str, + db: Session = Depends(get_db), + user_id: Any = Depends(get_current_user) +): + doc = db.scalar(select(Document).where(Document.id == document_id, Document.deleted_at.is_(None))) + if not doc: raise HTTPException(status_code=404, detail="Document not found") - return document - - -def _read_data(state: AnnotationState | None) -> list[AnnotationPayload]: + + state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id)) + if not state: - return [] + return AnnotationStateResponse(data=[], updatedAt=datetime.now(timezone.utc)) + try: data = json.loads(state.data) except json.JSONDecodeError: - return [] - return data if isinstance(data, list) else [] + data = [] + + return AnnotationStateResponse( + data=data, + updatedAt=datetime.fromisoformat(state.updated_at) + ) - -@router.get("/{document_id}/annotations", response_model=AnnotationStateResponse) -def get_annotations( - document_id: str, - db: Session = Depends(get_db), - _user_id: int = Depends(get_current_user), -) -> AnnotationStateResponse: - _document_or_404(document_id, db) - state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id)) - updated_at = datetime.now(UTC) if not state else _utc(datetime.fromisoformat(state.updated_at)) - return AnnotationStateResponse(data=_read_data(state), updatedAt=updated_at) - - -@router.put( - "/{document_id}/annotations", - response_model=AnnotationStateUpdateResponse, - dependencies=[Depends(verify_csrf)], -) -def update_annotations( +@router.put("/{document_id}/annotations", response_model=AnnotationStateUpdateResponse, dependencies=[Depends(verify_csrf)]) +async def update_annotations( document_id: str, request: AnnotationStateUpdateRequest, db: Session = Depends(get_db), - _user_id: int = Depends(get_current_user), -) -> AnnotationStateUpdateResponse: - document = _document_or_404(document_id, db) + user_id: Any = Depends(get_current_user) +): + doc = db.scalar(select(Document).where(Document.id == document_id, Document.deleted_at.is_(None))) + if not doc: + raise HTTPException(status_code=404, detail="Document not found") + state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id)) - + + # Check for stale tab if state and request.baseUpdatedAt: - current_updated_at = _utc(datetime.fromisoformat(state.updated_at)) - request_updated_at = _utc(request.baseUpdatedAt) + current_updated_at = datetime.fromisoformat(state.updated_at).replace(tzinfo=timezone.utc) + request_updated_at = request.baseUpdatedAt.replace(tzinfo=timezone.utc) + # Allow a small grace period for timezone/parsing discrepancies if abs((current_updated_at - request_updated_at).total_seconds()) > 1.0: raise HTTPException( - status_code=409, - detail=f"Conflict: annotations have been modified since {request.baseUpdatedAt}", + status_code=409, + detail=f"Conflict: annotations have been modified since {request.baseUpdatedAt}" ) - + now = _now_iso() - data_json = json.dumps(request.data, separators=(",", ":")) - if state: + + import json + + # serialize request.data list of models to json string + data_json = json.dumps([a.model_dump(mode="json", by_alias=True) for a in request.data]) + + if not state: + state = AnnotationState( + document_id=document_id, + data=data_json, + updated_at=now + ) + db.add(state) + else: state.data = data_json state.updated_at = now - else: - db.add(AnnotationState(document_id=document_id, data=data_json, updated_at=now)) - document.updated_at = now + + # Bump document updated_at + doc.updated_at = now + db.commit() + return AnnotationStateUpdateResponse(updatedAt=datetime.fromisoformat(now)) diff --git a/backend/app/api/v1/assets.py b/backend/app/api/v1/assets.py index 0ae751b..9f51004 100644 --- a/backend/app/api/v1/assets.py +++ b/backend/app/api/v1/assets.py @@ -1,101 +1,58 @@ -"""Authenticated binary assets used by annotations.""" - +from fastapi import APIRouter, Depends, UploadFile, File, HTTPException +from fastapi.responses import FileResponse +from sqlalchemy.orm import Session import uuid +import shutil from pathlib import Path -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile -from fastapi.responses import FileResponse -from sqlalchemy import select -from sqlalchemy.orm import Session - -from app.auth.dependencies import get_current_user, verify_csrf -from app.config import settings from app.db import get_db -from app.models.document import Document +from app.auth.dependencies import get_current_user +from app.config import settings router = APIRouter(prefix="/documents", tags=["Assets"]) -_ASSET_CHUNK_SIZE = 1024 * 1024 -_MAX_ASSET_BYTES = 25 * 1024 * 1024 +def get_asset_path(document_id: str, ref: str) -> Path: + # Ensure directory exists + dir_path = settings.PDF_STORAGE_PATH / "assets" / document_id + dir_path.mkdir(parents=True, exist_ok=True) + return dir_path / ref -def get_asset_path(document_id: str, ref: str, *, create: bool = False) -> Path: - """Return a safe asset path; only writes are allowed to create directories.""" - try: - asset_ref = str(uuid.UUID(ref)) - except ValueError as err: - raise HTTPException(status_code=404, detail="Asset not found") from err - - directory = settings.PDF_STORAGE_PATH / "assets" / document_id - if create: - directory.mkdir(parents=True, exist_ok=True) - return directory / asset_ref - - -def _is_supported_image(filepath: Path) -> bool: - with filepath.open("rb") as file: - header = file.read(12) - return ( - header.startswith(b"\x89PNG\r\n\x1a\n") - or header.startswith(b"\xff\xd8\xff") - or header.startswith((b"GIF87a", b"GIF89a")) - or (header.startswith(b"RIFF") and header[8:12] == b"WEBP") - ) - - -@router.post("/{id}/assets", dependencies=[Depends(verify_csrf)]) +@router.post("/{id}/assets") async def upload_asset( id: str, file: UploadFile = File(...), user_id: int = Depends(get_current_user), - db: Session = Depends(get_db), -) -> dict[str, str]: - """Upload a PNG, JPEG, GIF, or WebP annotation asset.""" - document = db.scalar(select(Document).where(Document.id == id)) - if not document: - raise HTTPException(status_code=404, detail="Document not found") + db: Session = Depends(get_db) +): + """Upload a binary asset (like an image or signature) for a document.""" + # In a real app we'd verify the user owns the document here + # For this single-user app, we trust the ID + + if not file.content_type or not file.content_type.startswith("image/"): + raise HTTPException(status_code=400, detail="Only image assets are supported") ref = str(uuid.uuid4()) - filepath = get_asset_path(id, ref, create=True) - total_bytes = 0 - + filepath = get_asset_path(id, ref) + try: - with filepath.open("wb") as destination: - while chunk := file.file.read(_ASSET_CHUNK_SIZE): - total_bytes += len(chunk) - if total_bytes > _MAX_ASSET_BYTES: - raise HTTPException(status_code=413, detail="Image asset is too large") - destination.write(chunk) - except HTTPException: - filepath.unlink(missing_ok=True) - raise - except Exception as err: - filepath.unlink(missing_ok=True) - raise HTTPException(status_code=500, detail="Failed to save asset") from err - - if not _is_supported_image(filepath): - filepath.unlink(missing_ok=True) - raise HTTPException( - status_code=400, - detail="Only PNG, JPEG, GIF, and WebP images are supported", - ) - - return {"ref": ref, "url": f"/api/v1/documents/{id}/assets/{ref}"} + with open(filepath, "wb") as f: + shutil.copyfileobj(file.file, f) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to save asset: {e}") + return { + "ref": ref, + "url": f"/api/v1/documents/{id}/assets/{ref}" + } @router.get("/{id}/assets/{ref}") async def get_asset( id: str, ref: str, - db: Session = Depends(get_db), - user_id: int = Depends(get_current_user), -) -> FileResponse: - """Serve an annotation asset to an authenticated session.""" - document = db.scalar(select(Document).where(Document.id == id)) - if not document: - raise HTTPException(status_code=404, detail="Document not found") - +): + """Serve a binary asset.""" filepath = get_asset_path(id, ref) if not filepath.exists() or not filepath.is_file(): raise HTTPException(status_code=404, detail="Asset not found") - + return FileResponse(filepath) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 4d14cd2..c05c534 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -31,37 +31,37 @@ def get_auth_status(request: Request, db: Session = Depends(get_db)) -> AuthStat app_settings = Settings() db.add(app_settings) db.commit() - + setup_required = app_settings.password_hash is None - + token = request.cookies.get(settings.COOKIE_NAME) logged_in = False if token: user_id = verify_session(token) if user_id is not None: logged_in = True - + return AuthStatusResponse(setupRequired=setup_required, loggedIn=logged_in) @router.post("/setup", dependencies=[Depends(verify_csrf)]) def setup_password( - data: SetupRequest, - response: Response, + data: SetupRequest, + response: Response, db: Session = Depends(get_db) ) -> dict[str, str]: """First-run setup.""" app_settings = db.query(Settings).first() if app_settings and app_settings.password_hash: raise HTTPException(status_code=409, detail="Password already set") - + if not app_settings: app_settings = Settings() db.add(app_settings) - + app_settings.password_hash = hash_password(data.password) db.commit() - + # Log them in automatically create_session(response) return {"status": "ok"} @@ -77,16 +77,16 @@ def login( """Validate password and issue session cookie.""" ip = get_client_ip(request) check_rate_limit(ip) - + app_settings = db.query(Settings).first() if not app_settings or not app_settings.password_hash: record_failed_attempt(ip) raise HTTPException(status_code=401, detail="Invalid credentials") - + if not verify_password(app_settings.password_hash, data.password): record_failed_attempt(ip) raise HTTPException(status_code=401, detail="Invalid credentials") - + clear_attempts(ip) create_session(response) return {"status": "ok"} @@ -108,17 +108,17 @@ def change_password( """Change an existing password.""" ip = get_client_ip(request) check_rate_limit(ip) - + app_settings = db.query(Settings).first() if not app_settings or not app_settings.password_hash: raise HTTPException(status_code=400, detail="Setup required first") - + if not verify_password(app_settings.password_hash, data.current_password): record_failed_attempt(ip) raise HTTPException(status_code=401, detail="Invalid current password") - + clear_attempts(ip) app_settings.password_hash = hash_password(data.new_password) db.commit() - + return {"status": "ok"} diff --git a/backend/app/api/v1/debug.py b/backend/app/api/v1/debug.py index ae0dc79..4d081f7 100644 --- a/backend/app/api/v1/debug.py +++ b/backend/app/api/v1/debug.py @@ -28,28 +28,28 @@ async def verify_coords(req: VerifyCoordsRequest): try: doc = pymupdf.open(filepath) - except Exception as error: - raise HTTPException(status_code=500, detail=str(error)) from error + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) if req.page < 0 or req.page >= len(doc): doc.close() raise HTTPException(status_code=400, detail="Invalid page number") page = doc[req.page] - + # Canonical space is defined as relative to the unrotated CropBox. # PyMuPDF naturally draws relative to the current rotation. # By temporarily setting rotation to 0, we can draw directly using canonical coordinates. original_rotation = page.rotation if original_rotation != 0: page.set_rotation(0) - + rect = pymupdf.Rect(req.x, req.y, req.x + req.width, req.y + req.height) page.draw_rect(rect, color=(1, 0, 0), width=2, fill=(1, 0, 0), fill_opacity=0.3) - + if original_rotation != 0: page.set_rotation(original_rotation) - + pdf_bytes = doc.write() doc.close() diff --git a/backend/app/api/v1/documents.py b/backend/app/api/v1/documents.py index f1556a4..7e572b2 100644 --- a/backend/app/api/v1/documents.py +++ b/backend/app/api/v1/documents.py @@ -1,31 +1,28 @@ """Document management endpoints.""" -from datetime import UTC, datetime - -from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query from fastapi.responses import FileResponse -from sqlalchemy import desc from sqlalchemy.orm import Session +from sqlalchemy import desc +from datetime import datetime, timezone from app.auth.dependencies import get_current_user, verify_csrf -from app.config import settings from app.db import get_db -from app.models.annotation_state import AnnotationState from app.models.document import Document +from app.models.annotation_state import AnnotationState from app.models.version import Version from app.schemas.document import ( - BulkDeleteRequest, - BulkRestoreRequest, - DocumentListResponse, - DocumentMeta, - DocumentUpdateRequest, + DocumentListResponse, DocumentMeta, BulkDeleteRequest, + BulkRestoreRequest, DocumentUpdateRequest ) -from app.services.assets import delete_asset_directory -from app.services.storage import delete_pdf_file, safe_filename, save_upload_file -from app.services.thumbnails import delete_thumbnail, generate_thumbnail +from app.services.storage import save_upload_file, delete_pdf_file +from app.services.thumbnails import generate_thumbnail, delete_thumbnail +from app.config import settings router = APIRouter( - prefix="/documents", tags=["documents"], dependencies=[Depends(get_current_user)] + prefix="/documents", + tags=["documents"], + dependencies=[Depends(get_current_user)] ) @@ -34,135 +31,109 @@ def list_documents( query: str = "", skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), - db: Session = Depends(get_db), -) -> DocumentListResponse: + db: Session = Depends(get_db) +): """List non-trashed documents.""" q = db.query(Document).filter(Document.deleted_at.is_(None)) if query: q = q.filter(Document.title.ilike(f"%{query}%")) - + total = q.count() items = q.order_by(desc(Document.updated_at)).offset(skip).limit(limit).all() - - return DocumentListResponse( - items=[DocumentMeta.model_validate(item) for item in items], total=total - ) + + return DocumentListResponse(items=items, total=total) @router.get("/trash", response_model=DocumentListResponse) def list_trash( skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), - db: Session = Depends(get_db), -) -> DocumentListResponse: + db: Session = Depends(get_db) +): """List trashed documents.""" q = db.query(Document).filter(Document.deleted_at.is_not(None)) total = q.count() items = q.order_by(desc(Document.deleted_at)).offset(skip).limit(limit).all() - return DocumentListResponse( - items=[DocumentMeta.model_validate(item) for item in items], total=total - ) + return DocumentListResponse(items=items, total=total) -@router.post( - "", - dependencies=[Depends(verify_csrf)], - response_model=DocumentMeta, - status_code=status.HTTP_201_CREATED, -) +@router.post("", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta) async def upload_document( - file: UploadFile = File(...), db: Session = Depends(get_db) -) -> DocumentMeta: + file: UploadFile = File(...), + db: Session = Depends(get_db) +): """Upload a new PDF document.""" # Note: Starlette/FastAPI loads file into memory/spooled file. # To truly enforce max size before reading we rely on Nginx and logic in storage.py. # But checking size if available: if file.size and file.size > settings.MAX_UPLOAD_MB * 1024 * 1024: raise HTTPException(status_code=413, detail="File too large") - + file_id, size_bytes, page_count = await save_upload_file(file) - + # Generate thumbnail synchronously - thumbnail_generated = generate_thumbnail(file_id) - - now = datetime.now(UTC).isoformat() - filename = safe_filename(file.filename) + generate_thumbnail(file_id) + + now = datetime.now(timezone.utc).isoformat() doc = Document( id=file_id, - title=filename, - original_filename=filename, + title=file.filename or "Untitled", + original_filename=file.filename or "Untitled.pdf", file_path=str(settings.PDF_STORAGE_PATH / f"{file_id}.pdf"), - thumbnail_path=( - str(settings.THUMBNAILS_PATH / f"{file_id}.png") if thumbnail_generated else None - ), page_count=page_count, size_bytes=size_bytes, created_at=now, - updated_at=now, + updated_at=now ) - try: - db.add(doc) - db.commit() - except Exception: - delete_pdf_file(file_id) - delete_thumbnail(file_id) - delete_asset_directory(file_id) - raise + db.add(doc) + db.commit() db.refresh(doc) - return DocumentMeta.model_validate(doc) + return doc @router.get("/{id}", response_model=DocumentMeta) -def get_document(id: str, db: Session = Depends(get_db)) -> DocumentMeta: - doc = db.query(Document).filter(Document.id == id, Document.deleted_at.is_(None)).first() - if not doc: - raise HTTPException(status_code=404, detail="Document not found") - return DocumentMeta.model_validate(doc) - - -@router.patch("/{id}", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta) -def update_document( - id: str, update: DocumentUpdateRequest, db: Session = Depends(get_db) -) -> DocumentMeta: +def get_document(id: str, db: Session = Depends(get_db)): doc = db.query(Document).filter(Document.id == id).first() if not doc: raise HTTPException(status_code=404, detail="Document not found") + return doc + +@router.patch("/{id}", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta) +def update_document(id: str, update: DocumentUpdateRequest, db: Session = Depends(get_db)): + doc = db.query(Document).filter(Document.id == id).first() + if not doc: + raise HTTPException(status_code=404, detail="Document not found") + if update.title is not None: - title = update.title.strip() - if not title: - raise HTTPException(status_code=422, detail="Title cannot be empty") - doc.title = title - + doc.title = update.title + if update.in_trash is not None: if update.in_trash: - doc.deleted_at = datetime.now(UTC).isoformat() + doc.deleted_at = datetime.now(timezone.utc).isoformat() else: doc.deleted_at = None - - doc.updated_at = datetime.now(UTC).isoformat() + + doc.updated_at = datetime.now(timezone.utc).isoformat() db.commit() db.refresh(doc) - return DocumentMeta.model_validate(doc) + return doc @router.delete("/{id}", dependencies=[Depends(verify_csrf)]) -def delete_document( - id: str, permanent: bool = False, db: Session = Depends(get_db) -) -> dict[str, str]: +def delete_document(id: str, permanent: bool = False, db: Session = Depends(get_db)): """Soft delete by default. Hard delete if permanent=True AND already in trash.""" doc = db.query(Document).filter(Document.id == id).first() if not doc: raise HTTPException(status_code=404, detail="Document not found") - + if permanent: if doc.deleted_at is None: raise HTTPException(status_code=400, detail="Must be in trash to delete permanently") - + # Hard delete delete_pdf_file(doc.id) delete_thumbnail(doc.id) - delete_asset_directory(doc.id) db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete() db.query(Version).filter(Version.document_id == doc.id).delete() db.delete(doc) @@ -170,57 +141,52 @@ def delete_document( return {"status": "deleted"} else: # Soft delete - doc.deleted_at = datetime.now(UTC).isoformat() - doc.updated_at = doc.deleted_at + doc.deleted_at = datetime.now(timezone.utc).isoformat() db.commit() return {"status": "trashed"} @router.post("/{id}/restore", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta) -def restore_document(id: str, db: Session = Depends(get_db)) -> DocumentMeta: +def restore_document(id: str, db: Session = Depends(get_db)): doc = db.query(Document).filter(Document.id == id).first() if not doc: raise HTTPException(status_code=404, detail="Document not found") - + doc.deleted_at = None - doc.updated_at = datetime.now(UTC).isoformat() + doc.updated_at = datetime.now(timezone.utc).isoformat() db.commit() db.refresh(doc) - return DocumentMeta.model_validate(doc) + return doc @router.post("/bulk-delete", dependencies=[Depends(verify_csrf)]) -def bulk_delete(data: BulkDeleteRequest, db: Session = Depends(get_db)) -> dict[str, int | str]: - now = datetime.now(UTC).isoformat() - count = ( - db.query(Document) - .filter(Document.id.in_(data.ids), Document.deleted_at.is_(None)) - .update({Document.deleted_at: now}, synchronize_session=False) - ) +def bulk_delete(data: BulkDeleteRequest, db: Session = Depends(get_db)): + now = datetime.now(timezone.utc).isoformat() + db.query(Document).filter(Document.id.in_(data.ids)).update({ + Document.deleted_at: now + }, synchronize_session=False) db.commit() - return {"status": "ok", "count": count} + return {"status": "ok", "count": len(data.ids)} @router.post("/bulk-restore", dependencies=[Depends(verify_csrf)]) -def bulk_restore(data: BulkRestoreRequest, db: Session = Depends(get_db)) -> dict[str, int | str]: - now = datetime.now(UTC).isoformat() - count = ( - db.query(Document) - .filter(Document.id.in_(data.ids), Document.deleted_at.is_not(None)) - .update({Document.deleted_at: None, Document.updated_at: now}, synchronize_session=False) - ) +def bulk_restore(data: BulkRestoreRequest, db: Session = Depends(get_db)): + now = datetime.now(timezone.utc).isoformat() + db.query(Document).filter(Document.id.in_(data.ids)).update({ + Document.deleted_at: None, + Document.updated_at: now + }, synchronize_session=False) db.commit() - return {"status": "ok", "count": count} + return {"status": "ok", "count": len(data.ids)} @router.post("/trash/empty", dependencies=[Depends(verify_csrf)]) -def empty_trash(db: Session = Depends(get_db)) -> dict[str, int | str]: +def empty_trash(db: Session = Depends(get_db)): docs = db.query(Document).filter(Document.deleted_at.is_not(None)).all() count = 0 for doc in docs: delete_pdf_file(doc.id) delete_thumbnail(doc.id) - delete_asset_directory(doc.id) db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete() db.query(Version).filter(Version.document_id == doc.id).delete() db.delete(doc) @@ -230,26 +196,26 @@ def empty_trash(db: Session = Depends(get_db)) -> dict[str, int | str]: @router.get("/{id}/file") -def get_document_file(id: str, db: Session = Depends(get_db)) -> FileResponse: +def get_document_file(id: str, db: Session = Depends(get_db)): doc = db.query(Document).filter(Document.id == id).first() if not doc: raise HTTPException(status_code=404, detail="Document not found") - + path = settings.PDF_STORAGE_PATH / f"{doc.id}.pdf" if not path.exists(): raise HTTPException(status_code=404, detail="File missing") - + return FileResponse(path, media_type="application/pdf", filename=doc.filename) @router.get("/{id}/thumbnail") -def get_document_thumbnail(id: str, db: Session = Depends(get_db)) -> FileResponse: +def get_document_thumbnail(id: str, db: Session = Depends(get_db)): doc = db.query(Document).filter(Document.id == id).first() if not doc: raise HTTPException(status_code=404, detail="Document not found") - + path = settings.THUMBNAILS_PATH / f"{doc.id}.png" if not path.exists(): raise HTTPException(status_code=404, detail="Thumbnail missing") - + return FileResponse(path, media_type="image/png") diff --git a/backend/app/api/v1/export.py b/backend/app/api/v1/export.py deleted file mode 100644 index 2153fb5..0000000 --- a/backend/app/api/v1/export.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Flattened PDF export endpoint.""" - -import json - -from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import Response -from sqlalchemy import select -from sqlalchemy.orm import Session - -from app.auth.dependencies import get_current_user, verify_csrf -from app.config import settings -from app.db import get_db -from app.models.annotation_state import AnnotationState -from app.models.document import Document -from app.models.version import Version -from app.schemas.export import ExportRequest -from app.services.export.renderer import export_annotations -from app.services.storage import safe_filename - -router = APIRouter( - prefix="/documents", - tags=["export"], - dependencies=[Depends(get_current_user)], -) - - -def _document_or_404(document_id: str, db: Session) -> Document: - document = db.scalar( - select(Document).where(Document.id == document_id, Document.deleted_at.is_(None)) - ) - if not document: - raise HTTPException(status_code=404, detail="Document not found") - return document - - -def _state_data(document_id: str, db: Session) -> list[dict[str, object]]: - state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id)) - if not state: - return [] - try: - data = json.loads(state.data) - except json.JSONDecodeError: - return [] - return data if isinstance(data, list) else [] - - -@router.post("/{document_id}/export", dependencies=[Depends(verify_csrf)]) -def export_document( - document_id: str, - request: ExportRequest, - db: Session = Depends(get_db), -) -> Response: - """Export the working layer or a selected version as a downloadable PDF.""" - document = _document_or_404(document_id, db) - pdf_path = settings.PDF_STORAGE_PATH / f"{document.id}.pdf" - if not pdf_path.is_file(): - raise HTTPException(status_code=404, detail="Original PDF is missing") - - if not request.flatten: - content = pdf_path.read_bytes() - elif request.versionId: - version = db.scalar( - select(Version).where( - Version.id == request.versionId, - Version.document_id == document_id, - ) - ) - if not version: - raise HTTPException(status_code=404, detail="Version not found") - try: - version_data = json.loads(version.data) - except json.JSONDecodeError: - version_data = [] - content = export_annotations( - pdf_path, - version_data if isinstance(version_data, list) else [], - settings.PDF_STORAGE_PATH / "assets" / document.id, - ) - else: - content = export_annotations( - pdf_path, - _state_data(document_id, db), - settings.PDF_STORAGE_PATH / "assets" / document.id, - ) - - filename = safe_filename(document.original_filename) - if not filename.lower().endswith(".pdf"): - filename = f"{filename}.pdf" - filename = filename.replace('"', "'").replace("\r", "").replace("\n", "") - return Response( - content=content, - media_type="application/pdf", - headers={"Content-Disposition": f'attachment; filename="{filename}"'}, - ) diff --git a/backend/app/api/v1/versions.py b/backend/app/api/v1/versions.py deleted file mode 100644 index 8757b94..0000000 --- a/backend/app/api/v1/versions.py +++ /dev/null @@ -1,203 +0,0 @@ -"""Annotation checkpoint and restore endpoints.""" - -import json -from datetime import UTC, datetime -from typing import Literal, cast - -from fastapi import APIRouter, Depends, HTTPException, Response, status -from sqlalchemy import desc, select -from sqlalchemy.orm import Session - -from app.auth.dependencies import get_current_user, verify_csrf -from app.db import get_db -from app.models.annotation_state import AnnotationState -from app.models.document import Document -from app.models.version import Version -from app.schemas.annotations import AnnotationPayload -from app.schemas.versions import ( - VersionCreateRequest, - VersionDataResponse, - VersionListResponse, - VersionMeta, - VersionRestoreResponse, -) - -router = APIRouter( - prefix="/documents", - tags=["versions"], - dependencies=[Depends(get_current_user)], -) - - -def _now_iso() -> str: - return datetime.now(UTC).isoformat() - - -def _document_or_404(document_id: str, db: Session) -> Document: - document = db.scalar( - select(Document).where(Document.id == document_id, Document.deleted_at.is_(None)) - ) - if not document: - raise HTTPException(status_code=404, detail="Document not found") - return document - - -def _read_state(document_id: str, db: Session) -> list[AnnotationPayload]: - state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id)) - if not state: - return [] - try: - data = json.loads(state.data) - except json.JSONDecodeError: - return [] - return data if isinstance(data, list) else [] - - -def _version_meta(version: Version) -> VersionMeta: - try: - data = json.loads(version.data) - except json.JSONDecodeError: - data = [] - return VersionMeta( - id=version.id, - documentId=version.document_id, - label=version.label, - kind=cast( - Literal["manual", "auto"], - version.kind if version.kind in {"manual", "auto"} else "auto", - ), - createdAt=datetime.fromisoformat(version.created_at), - annotationCount=len(data) if isinstance(data, list) else 0, - ) - - -def _create_snapshot( - document_id: str, - db: Session, - *, - label: str | None, - kind: str, -) -> Version: - version = Version( - document_id=document_id, - label=label, - kind=kind, - data=json.dumps(_read_state(document_id, db), separators=(",", ":")), - ) - db.add(version) - db.flush() - return version - - -@router.get("/{document_id}/versions", response_model=VersionListResponse) -def list_versions(document_id: str, db: Session = Depends(get_db)) -> VersionListResponse: - _document_or_404(document_id, db) - versions = db.scalars( - select(Version).where(Version.document_id == document_id).order_by(desc(Version.created_at)) - ).all() - return VersionListResponse(items=[_version_meta(version) for version in versions]) - - -@router.post( - "/{document_id}/versions", - response_model=VersionMeta, - status_code=status.HTTP_201_CREATED, - dependencies=[Depends(verify_csrf)], -) -def create_version( - document_id: str, - request: VersionCreateRequest, - db: Session = Depends(get_db), -) -> VersionMeta: - _document_or_404(document_id, db) - version = _create_snapshot( - document_id, - db, - label=request.label.strip() if request.label and request.label.strip() else None, - kind=request.kind, - ) - db.commit() - return _version_meta(version) - - -@router.get("/{document_id}/versions/{version_id}", response_model=VersionDataResponse) -def get_version( - document_id: str, - version_id: str, - db: Session = Depends(get_db), -) -> VersionDataResponse: - _document_or_404(document_id, db) - version = db.scalar( - select(Version).where(Version.id == version_id, Version.document_id == document_id) - ) - if not version: - raise HTTPException(status_code=404, detail="Version not found") - try: - data = json.loads(version.data) - except json.JSONDecodeError: - data = [] - return VersionDataResponse( - data=data if isinstance(data, list) else [], - meta=_version_meta(version), - ) - - -@router.post( - "/{document_id}/versions/{version_id}/restore", - response_model=VersionRestoreResponse, - dependencies=[Depends(verify_csrf)], -) -def restore_version( - document_id: str, - version_id: str, - db: Session = Depends(get_db), -) -> VersionRestoreResponse: - document = _document_or_404(document_id, db) - target = db.scalar( - select(Version).where(Version.id == version_id, Version.document_id == document_id) - ) - if not target: - raise HTTPException(status_code=404, detail="Version not found") - - # Restoring is destructive to the current working layer, so make it undoable. - _create_snapshot(document_id, db, label="Before restore", kind="auto") - try: - restored_data = json.loads(target.data) - except json.JSONDecodeError: - restored_data = [] - if not isinstance(restored_data, list): - restored_data = [] - - state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id)) - now = _now_iso() - if state: - state.data = json.dumps(restored_data, separators=(",", ":")) - state.updated_at = now - else: - db.add( - AnnotationState( - document_id=document_id, - data=json.dumps(restored_data, separators=(",", ":")), - updated_at=now, - ) - ) - document.updated_at = now - db.commit() - return VersionRestoreResponse(updatedAt=datetime.fromisoformat(now)) - - -@router.delete( - "/{document_id}/versions/{version_id}", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(verify_csrf)], -) -def delete_version(document_id: str, version_id: str, db: Session = Depends(get_db)) -> Response: - _document_or_404(document_id, db) - version = db.scalar( - select(Version).where(Version.id == version_id, Version.document_id == document_id) - ) - if not version: - raise HTTPException(status_code=404, detail="Version not found") - db.delete(version) - db.commit() - return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/assets/fonts/GreatVibes-Regular.ttf b/backend/app/assets/fonts/GreatVibes-Regular.ttf deleted file mode 100644 index a1327ff..0000000 Binary files a/backend/app/assets/fonts/GreatVibes-Regular.ttf and /dev/null differ diff --git a/backend/app/assets/fonts/OFL.txt b/backend/app/assets/fonts/OFL.txt deleted file mode 100644 index 7002592..0000000 --- a/backend/app/assets/fonts/OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2015 The Great Vibes Pro Project Authors (https://github.com/googlefonts/great-vibes) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -https://openfontlicense.org - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/backend/app/assets/fonts/allura.ttf b/backend/app/assets/fonts/allura.ttf deleted file mode 100644 index dd197a2..0000000 Binary files a/backend/app/assets/fonts/allura.ttf and /dev/null differ diff --git a/backend/app/assets/fonts/caveat.ttf b/backend/app/assets/fonts/caveat.ttf deleted file mode 100644 index f84acf2..0000000 Binary files a/backend/app/assets/fonts/caveat.ttf and /dev/null differ diff --git a/backend/app/assets/fonts/dancing-script.ttf b/backend/app/assets/fonts/dancing-script.ttf deleted file mode 100644 index 9f521e7..0000000 Binary files a/backend/app/assets/fonts/dancing-script.ttf and /dev/null differ diff --git a/backend/app/assets/fonts/sacramento.ttf b/backend/app/assets/fonts/sacramento.ttf deleted file mode 100644 index cfd2eab..0000000 Binary files a/backend/app/assets/fonts/sacramento.ttf and /dev/null differ diff --git a/backend/app/auth/dependencies.py b/backend/app/auth/dependencies.py index 12d1605..648ac19 100644 --- a/backend/app/auth/dependencies.py +++ b/backend/app/auth/dependencies.py @@ -1,11 +1,10 @@ """FastAPI dependencies for authentication and security.""" -from fastapi import HTTPException, Request +from fastapi import Request, HTTPException from app.auth.session import verify_session from app.config import settings - def get_current_user(request: Request) -> int: """ Dependency that extracts and validates the session cookie. @@ -15,11 +14,11 @@ def get_current_user(request: Request) -> int: token = request.cookies.get(settings.COOKIE_NAME) if not token: raise HTTPException(status_code=401, detail="Not authenticated") - + user_id = verify_session(token) if not user_id: raise HTTPException(status_code=401, detail="Session expired or invalid") - + return user_id @@ -28,11 +27,9 @@ def verify_csrf(request: Request) -> None: Dependency to mitigate CSRF for cookie-based auth. Requires X-Requested-With header on all mutating requests. """ - if ( - request.method in ["POST", "PUT", "PATCH", "DELETE"] - and request.headers.get("X-Requested-With") != "XMLHttpRequest" - ): - raise HTTPException( - status_code=403, - detail="CSRF check failed: missing X-Requested-With header", - ) + if request.method in ["POST", "PUT", "PATCH", "DELETE"]: + if request.headers.get("X-Requested-With") != "XMLHttpRequest": + raise HTTPException( + status_code=403, + detail="CSRF check failed: missing X-Requested-With header" + ) diff --git a/backend/app/auth/hashing.py b/backend/app/auth/hashing.py index 1d4e62e..31beb5d 100644 --- a/backend/app/auth/hashing.py +++ b/backend/app/auth/hashing.py @@ -1,22 +1,20 @@ """Argon2id password hashing and verification.""" from argon2 import PasswordHasher -from argon2.exceptions import InvalidHashError, VerifyMismatchError +from argon2.exceptions import VerifyMismatchError # Argon2id is the default for PasswordHasher ph = PasswordHasher() - def hash_password(password: str) -> str: """Hash a plaintext password.""" return ph.hash(password) - def verify_password(hashed: str, password: str) -> bool: """Verify a password against a hash. Returns True if matched.""" try: ph.verify(hashed, password) # Note: We skip check_needs_rehash() for simplicity in this single-user app. return True - except (InvalidHashError, VerifyMismatchError): + except VerifyMismatchError: return False diff --git a/backend/app/auth/rate_limit.py b/backend/app/auth/rate_limit.py index 18d8c99..fd25202 100644 --- a/backend/app/auth/rate_limit.py +++ b/backend/app/auth/rate_limit.py @@ -2,7 +2,6 @@ import time from collections import defaultdict - from fastapi import HTTPException from app.config import settings @@ -28,7 +27,7 @@ def check_rate_limit(ip: str) -> None: detail=f"Too many failed attempts. Try again in {remaining} seconds.", headers={"Retry-After": str(remaining)}, ) - + # If lockout has expired, reset if record["lockout_until"] > 0 and record["lockout_until"] <= now: record["attempts"] = 0 diff --git a/backend/app/auth/session.py b/backend/app/auth/session.py index 9b67f56..837ad59 100644 --- a/backend/app/auth/session.py +++ b/backend/app/auth/session.py @@ -1,13 +1,11 @@ """Session token generation and validation.""" from typing import Any - from fastapi import Response -from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired from app.config import settings - def get_serializer() -> URLSafeTimedSerializer: """Return a configured URLSafeTimedSerializer.""" return URLSafeTimedSerializer(settings.SECRET_KEY) @@ -16,7 +14,7 @@ def create_session(response: Response, user_id: int = 1) -> None: """Create a new session token and set it as an HTTP-only cookie.""" serializer = get_serializer() token = serializer.dumps({"user_id": user_id}) - + response.set_cookie( key=settings.COOKIE_NAME, value=token, @@ -43,7 +41,7 @@ def verify_session(token: str) -> int | None: serializer = get_serializer() try: data: dict[str, Any] = serializer.loads( - token, + token, max_age=settings.COOKIE_MAX_AGE_SECONDS ) return data.get("user_id") diff --git a/backend/app/config.py b/backend/app/config.py index 01aec3b..a960c20 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -5,7 +5,6 @@ All settings are driven by environment variables (see .env.example). """ from pathlib import Path - from pydantic_settings import BaseSettings diff --git a/backend/app/db.py b/backend/app/db.py index cf8b5a6..cccde5b 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -4,10 +4,11 @@ Database engine, session management, and WAL mode setup. SQLite with WAL mode for single-user concurrent read/write safety. """ -from collections.abc import Generator +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager from typing import Any -from sqlalchemy import Engine, create_engine, event +from sqlalchemy import event, create_engine, Engine from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker from app.config import settings @@ -15,7 +16,6 @@ from app.config import settings class Base(DeclarativeBase): """SQLAlchemy declarative base for all models.""" - pass @@ -49,10 +49,10 @@ engine = create_db_engine() SessionLocal = sessionmaker(bind=engine, class_=Session, expire_on_commit=False) -def get_db() -> Generator[Session, None, None]: +def get_db() -> Session: """FastAPI dependency that yields a database session.""" db = SessionLocal() try: - yield db + yield db # type: ignore[misc] finally: db.close() diff --git a/backend/app/main.py b/backend/app/main.py index 24269ee..1d5a8e8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,8 +5,8 @@ This is the main entry point. The lifespan handler initializes the database and ensures required directories exist on startup. """ -from collections.abc import AsyncGenerator from contextlib import asynccontextmanager +from collections.abc import AsyncGenerator from typing import Any from fastapi import FastAPI, Request @@ -14,8 +14,7 @@ from fastapi.responses import JSONResponse from app.api.v1 import router as v1_router from app.config import settings -from app.db import Base, SessionLocal, engine -from app.services.trash_sweep import prune_auto_versions, sweep_trash +from app.db import Base, engine @asynccontextmanager @@ -30,12 +29,6 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: settings.THUMBNAILS_PATH.mkdir(parents=True, exist_ok=True) settings.DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True) - # Run retention cleanup on every startup. This keeps the single-container - # deployment self-maintaining without requiring a separate scheduler. - with SessionLocal() as db: - sweep_trash(db) - prune_auto_versions(db) - yield # Shutdown: dispose of the engine @@ -55,7 +48,6 @@ app = FastAPI( # --- Error handlers --- - @app.exception_handler(404) async def not_found_handler(request: Request, exc: Any) -> JSONResponse: """Consistent 404 error envelope.""" diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 7015970..f51bd38 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -5,9 +5,9 @@ All models use UUIDv4 string primary keys (except the singleton settings row). Timestamps are stored as ISO8601 TEXT columns. """ -from app.models.annotation_state import AnnotationState -from app.models.document import Document from app.models.settings import Settings +from app.models.document import Document +from app.models.annotation_state import AnnotationState from app.models.version import Version -__all__ = ["AnnotationState", "Document", "Settings", "Version"] +__all__ = ["Settings", "Document", "AnnotationState", "Version"] diff --git a/backend/app/models/annotation_state.py b/backend/app/models/annotation_state.py index e9223e4..4078115 100644 --- a/backend/app/models/annotation_state.py +++ b/backend/app/models/annotation_state.py @@ -1,19 +1,15 @@ """AnnotationState model — current working annotation layer per document.""" -from datetime import UTC, datetime -from typing import TYPE_CHECKING +from datetime import datetime, timezone from sqlalchemy import ForeignKey, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db import Base -if TYPE_CHECKING: - from app.models.document import Document - def _now_iso() -> str: - return datetime.now(UTC).isoformat() + return datetime.now(timezone.utc).isoformat() class AnnotationState(Base): diff --git a/backend/app/models/document.py b/backend/app/models/document.py index eedbba7..a60a695 100644 --- a/backend/app/models/document.py +++ b/backend/app/models/document.py @@ -1,25 +1,20 @@ """Document model — uploaded PDFs with soft-delete support.""" import uuid -from datetime import UTC, datetime -from typing import TYPE_CHECKING +from datetime import datetime, timezone from sqlalchemy import Index, Integer, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db import Base -if TYPE_CHECKING: - from app.models.annotation_state import AnnotationState - from app.models.version import Version - def _uuid() -> str: return str(uuid.uuid4()) def _now_iso() -> str: - return datetime.now(UTC).isoformat() + return datetime.now(timezone.utc).isoformat() class Document(Base): diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index 4a5e04d..e222907 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -1,6 +1,6 @@ """Settings model — singleton row for app-wide configuration.""" -from datetime import UTC, datetime +from datetime import datetime, timezone from sqlalchemy import Integer, Text from sqlalchemy.orm import Mapped, mapped_column @@ -22,10 +22,10 @@ class Settings(Base): password_hash: Mapped[str | None] = mapped_column(Text, nullable=True, default=None) created_at: Mapped[str] = mapped_column( Text, - default=lambda: datetime.now(UTC).isoformat(), + default=lambda: datetime.now(timezone.utc).isoformat(), ) updated_at: Mapped[str] = mapped_column( Text, - default=lambda: datetime.now(UTC).isoformat(), - onupdate=lambda: datetime.now(UTC).isoformat(), + default=lambda: datetime.now(timezone.utc).isoformat(), + onupdate=lambda: datetime.now(timezone.utc).isoformat(), ) diff --git a/backend/app/models/version.py b/backend/app/models/version.py index e33b85b..35ab070 100644 --- a/backend/app/models/version.py +++ b/backend/app/models/version.py @@ -1,24 +1,20 @@ """Version model — annotation state snapshots for history/recovery.""" import uuid -from datetime import UTC, datetime -from typing import TYPE_CHECKING +from datetime import datetime, timezone from sqlalchemy import ForeignKey, Index, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db import Base -if TYPE_CHECKING: - from app.models.document import Document - def _uuid() -> str: return str(uuid.uuid4()) def _now_iso() -> str: - return datetime.now(UTC).isoformat() + return datetime.now(timezone.utc).isoformat() class Version(Base): @@ -44,4 +40,6 @@ class Version(Base): # Relationships document: Mapped["Document"] = relationship("Document", back_populates="versions") - __table_args__ = (Index("ix_versions_document_created", "document_id", "created_at"),) + __table_args__ = ( + Index("ix_versions_document_created", "document_id", "created_at"), + ) diff --git a/backend/app/schemas/annotations.py b/backend/app/schemas/annotations.py index 5aca730..a8b9aad 100644 --- a/backend/app/schemas/annotations.py +++ b/backend/app/schemas/annotations.py @@ -1,9 +1,7 @@ from datetime import datetime -from typing import Any, Literal -from uuid import UUID - +from typing import Literal, Union, List, Tuple, Optional from pydantic import BaseModel, Field - +from uuid import UUID class Rect(BaseModel): x: float @@ -11,7 +9,6 @@ class Rect(BaseModel): width: float height: float - class AnnotationBase(BaseModel): id: UUID page: int = Field(ge=0) @@ -22,7 +19,6 @@ class AnnotationBase(BaseModel): createdAt: datetime updatedAt: datetime - class TextProps(BaseModel): text: str fontFamily: str = "Liberation Sans" @@ -32,105 +28,82 @@ class TextProps(BaseModel): bold: bool = False italic: bool = False lineHeight: float = 1.2 - highlightColor: str | None = None - styles: dict[str, Any] | None = None - + highlightColor: Optional[str] = None + styles: Optional[dict] = None class TextAnnotation(AnnotationBase): type: Literal["text"] props: TextProps - class DrawProps(BaseModel): - paths: list[tuple[float, float]] = Field(default_factory=list) - svgPath: str | None = None + paths: List[Tuple[float, float]] = [] + svgPath: Optional[str] = None strokeColor: str = "#000000" strokeWidth: float = 2 opacity: float = 1.0 - class DrawAnnotation(AnnotationBase): type: Literal["draw"] props: DrawProps - class SignatureDrawProps(BaseModel): mode: Literal["draw"] ref: str strokeColor: str = "#000000" - class SignatureTypeProps(BaseModel): mode: Literal["type"] text: str fontFamily: str color: str = "#000000" - class SignatureAnnotation(AnnotationBase): type: Literal["signature"] - props: SignatureDrawProps | SignatureTypeProps = Field(discriminator="mode") - + props: Union[SignatureDrawProps, SignatureTypeProps] = Field(discriminator="mode") class ImageProps(BaseModel): ref: str naturalWidth: float naturalHeight: float - class ImageAnnotation(AnnotationBase): type: Literal["image"] props: ImageProps - class HighlightProps(BaseModel): color: str = "#FFEB3B" opacity: float = 0.3 - class HighlightAnnotation(AnnotationBase): type: Literal["highlight"] props: HighlightProps - class ShapeProps(BaseModel): kind: Literal["rect", "ellipse", "line", "arrow"] strokeColor: str = "#000000" fillColor: str = "transparent" strokeWidth: float = 2 - start: tuple[float, float] | None = None - end: tuple[float, float] | None = None - class ShapeAnnotation(AnnotationBase): type: Literal["shape"] props: ShapeProps - -KnownAnnotation = ( - TextAnnotation - | DrawAnnotation - | SignatureAnnotation - | ImageAnnotation - | HighlightAnnotation - | ShapeAnnotation -) - -# Working state is intentionally opaque. Keeping this payload as dictionaries -# lets newer clients round-trip annotation types that this server cannot export -# yet; the export registry skips those types with a warning. -AnnotationPayload = dict[str, Any] - +Annotation = Union[ + TextAnnotation, + DrawAnnotation, + SignatureAnnotation, + ImageAnnotation, + HighlightAnnotation, + ShapeAnnotation +] class AnnotationStateResponse(BaseModel): - data: list[AnnotationPayload] + data: List[Annotation] updatedAt: datetime - class AnnotationStateUpdateRequest(BaseModel): - data: list[AnnotationPayload] + data: List[Annotation] baseUpdatedAt: datetime | None = None - class AnnotationStateUpdateResponse(BaseModel): updatedAt: datetime diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 247c776..58bfb9d 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -2,7 +2,6 @@ from pydantic import BaseModel, Field - class SetupRequest(BaseModel): password: str = Field(..., min_length=8) diff --git a/backend/app/schemas/document.py b/backend/app/schemas/document.py index dbfb03a..0479202 100644 --- a/backend/app/schemas/document.py +++ b/backend/app/schemas/document.py @@ -1,8 +1,7 @@ """Pydantic schemas for Document APIs.""" - from pydantic import BaseModel - +from typing import Optional class DocumentMeta(BaseModel): id: str @@ -13,7 +12,7 @@ class DocumentMeta(BaseModel): in_trash: bool created_at: str updated_at: str - deleted_at: str | None + deleted_at: Optional[str] model_config = {"from_attributes": True} @@ -28,5 +27,5 @@ class BulkRestoreRequest(BaseModel): ids: list[str] class DocumentUpdateRequest(BaseModel): - title: str | None = None - in_trash: bool | None = None + title: Optional[str] = None + in_trash: Optional[bool] = None diff --git a/backend/app/schemas/export.py b/backend/app/schemas/export.py deleted file mode 100644 index 2eaa9c2..0000000 --- a/backend/app/schemas/export.py +++ /dev/null @@ -1,8 +0,0 @@ -"""PDF export request schema.""" - -from pydantic import BaseModel - - -class ExportRequest(BaseModel): - versionId: str | None = None - flatten: bool = True diff --git a/backend/app/schemas/versions.py b/backend/app/schemas/versions.py deleted file mode 100644 index 4ba7343..0000000 --- a/backend/app/schemas/versions.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Version checkpoint schemas.""" - -from datetime import datetime -from typing import Literal - -from pydantic import BaseModel, Field - -from app.schemas.annotations import AnnotationPayload - - -class VersionCreateRequest(BaseModel): - label: str | None = Field(default=None, max_length=120) - kind: Literal["manual", "auto"] = "manual" - - -class VersionMeta(BaseModel): - id: str - documentId: str - label: str | None - kind: Literal["manual", "auto"] - createdAt: datetime - annotationCount: int - - -class VersionListResponse(BaseModel): - items: list[VersionMeta] - - -class VersionDataResponse(BaseModel): - data: list[AnnotationPayload] - meta: VersionMeta - - -class VersionRestoreResponse(BaseModel): - updatedAt: datetime diff --git a/backend/app/services/assets.py b/backend/app/services/assets.py deleted file mode 100644 index 665f077..0000000 --- a/backend/app/services/assets.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Storage helpers for binary annotation assets.""" - -import shutil - -from app.config import settings - - -def delete_asset_directory(document_id: str) -> None: - """Delete all uploaded annotation assets belonging to a document.""" - directory = settings.PDF_STORAGE_PATH / "assets" / document_id - if directory.exists(): - shutil.rmtree(directory) diff --git a/backend/app/services/export/__init__.py b/backend/app/services/export/__init__.py deleted file mode 100644 index 019a571..0000000 --- a/backend/app/services/export/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""PDF export services.""" diff --git a/backend/app/services/export/renderer.py b/backend/app/services/export/renderer.py deleted file mode 100644 index a6af6f0..0000000 --- a/backend/app/services/export/renderer.py +++ /dev/null @@ -1,465 +0,0 @@ -"""Flatten canonical annotation data into a PDF with PyMuPDF.""" - -import logging -import math -import re -import uuid -from collections.abc import Callable, Mapping, Sequence -from pathlib import Path -from typing import Any - -import pymupdf - -logger = logging.getLogger(__name__) -Annotation = Mapping[str, Any] -Handler = Callable[[pymupdf.Page, Annotation, Path], None] - - -def _rect(annotation: Annotation) -> pymupdf.Rect | None: - value = annotation.get("rect") - if not isinstance(value, Mapping): - return None - try: - x = float(value["x"]) - y = float(value["y"]) - width = float(value["width"]) - height = float(value["height"]) - except (KeyError, TypeError, ValueError): - return None - if width < 0 or height < 0: - return None - return pymupdf.Rect(x, y, x + width, y + height) - - -def _props(annotation: Annotation) -> Mapping[str, Any]: - value = annotation.get("props") - return value if isinstance(value, Mapping) else {} - - -def _color( - value: Any, - fallback: tuple[float, float, float] = (0.0, 0.0, 0.0), -) -> tuple[float, float, float]: - if not isinstance(value, str): - return fallback - text = value.strip().lstrip("#") - if len(text) == 3: - text = "".join(char * 2 for char in text) - if len(text) != 6: - return fallback - try: - return tuple(int(text[index : index + 2], 16) / 255 for index in (0, 2, 4)) # type: ignore[return-value] - except ValueError: - return fallback - - -def _opacity(value: Any, fallback: float = 1.0) -> float: - try: - return max(0.0, min(1.0, float(value))) - except (TypeError, ValueError): - return fallback - - -def _font_name(family: Any, bold: Any = False, italic: Any = False) -> str: - name = str(family or "Liberation Sans").lower() - is_serif = "times" in name or "serif" in name - is_mono = "courier" in name or "mono" in name - if is_serif: - normal, bold_name, italic_name, bold_italic_name = "tiro", "tibo", "tiit", "tibi" - elif is_mono: - normal, bold_name, italic_name, bold_italic_name = "cour", "cobo", "coit", "cobi" - else: - normal, bold_name, italic_name, bold_italic_name = "helv", "hebo", "heit", "hebi" - if bold and italic: - return bold_italic_name - if bold: - return bold_name - if italic: - return italic_name - return normal - - -_SIGNATURE_FONT_FILES = { - "great vibes": "GreatVibes-Regular.ttf", - "allura": "allura.ttf", - "sacramento": "sacramento.ttf", - "dancing script": "dancing-script.ttf", - "caveat": "caveat.ttf", -} - - -def _signature_font_file(family: Any) -> Path | None: - filename = _SIGNATURE_FONT_FILES.get(str(family or "").strip().lower()) - if not filename: - return None - path = Path(__file__).parents[2] / "assets" / "fonts" / filename - return path if path.is_file() else None - - -def _signature_font_size( - rect: pymupdf.Rect, text: str, font_file: Path | None, font_name: str -) -> float: - size = max(1.0, min(48.0, rect.height * 0.55)) - try: - font = ( - pymupdf.Font(fontfile=str(font_file)) if font_file else pymupdf.Font(fontname=font_name) - ) - text_width = font.text_length(text, fontsize=size) - if text_width > rect.width and text_width > 0: - size *= max(0.1, rect.width / text_width) * 0.95 - except Exception: - # A missing optional font must not prevent a PDF export. - pass - return max(1.0, size) - - -def _points_from_props(annotation: Annotation, rect: pymupdf.Rect) -> list[pymupdf.Point]: - props = _props(annotation) - raw_points = props.get("paths") - points: list[pymupdf.Point] = [] - if isinstance(raw_points, Sequence) and not isinstance(raw_points, (str, bytes)): - for raw_point in raw_points: - if isinstance(raw_point, Sequence) and len(raw_point) == 2: - try: - points.append( - pymupdf.Point( - rect.x0 + float(raw_point[0]), - rect.y0 + float(raw_point[1]), - ) - ) - except (TypeError, ValueError): - continue - if len(points) >= 2: - return points - - # Older clients stored a Fabric SVG path. Approximate its command end - # points and normalize them into the canonical annotation rectangle. - svg_path = props.get("svgPath") - if not isinstance(svg_path, str): - return [] - raw = [float(value) for value in re.findall(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?", svg_path)] - if len(raw) < 4: - return [] - pairs = list(zip(raw[::2], raw[1::2], strict=False)) - min_x = min(point[0] for point in pairs) - max_x = max(point[0] for point in pairs) - min_y = min(point[1] for point in pairs) - max_y = max(point[1] for point in pairs) - source_width = max(max_x - min_x, 1e-6) - source_height = max(max_y - min_y, 1e-6) - return [ - pymupdf.Point( - rect.x0 + (x - min_x) / source_width * rect.width, - rect.y0 + (y - min_y) / source_height * rect.height, - ) - for x, y in pairs - ] - - -def _draw_polyline( - page: pymupdf.Page, - points: list[pymupdf.Point], - *, - color: tuple[float, float, float], - width: float, - opacity: float, -) -> None: - if len(points) < 2: - return - shape = page.new_shape() - shape.draw_polyline(points) - shape.finish(color=color, width=max(0.1, width), stroke_opacity=opacity) - shape.commit(overlay=True) - - -def _style_at(styles: Any, line_number: int, character_number: int) -> Mapping[str, Any]: - if not isinstance(styles, Mapping): - return {} - line_styles = styles.get(str(line_number), styles.get(line_number)) - if not isinstance(line_styles, Mapping): - return {} - character_style = line_styles.get(str(character_number), line_styles.get(character_number)) - return character_style if isinstance(character_style, Mapping) else {} - - -def _render_styled_text( - page: pymupdf.Page, - rect: pymupdf.Rect, - text: str, - props: Mapping[str, Any], - styles: Any, -) -> bool: - if not isinstance(styles, Mapping) or not styles: - return False - - base_size = max(1.0, float(props.get("fontSize", 14))) - line_height = max(0.5, float(props.get("lineHeight", 1.2))) - lines = text.split("\n") - y = rect.y0 - for line_number, line in enumerate(lines): - measured: list[tuple[str, float, str, tuple[float, float, float], float, str | None]] = [] - for character_number, character in enumerate(line): - style = _style_at(styles, line_number, character_number) - font_size = max(1.0, float(style.get("fontSize", base_size))) - font_family = style.get("fontFamily", props.get("fontFamily")) - font_name = _font_name( - font_family, - style.get("fontWeight") == "bold", - style.get("fontStyle") == "italic", - ) - color = _color(style.get("fill", props.get("color"))) - font = pymupdf.Font(fontname=font_name) - width = float(font.text_length(character, fontsize=font_size)) - background = style.get("textBackgroundColor", props.get("highlightColor")) - measured.append((character, font_size, font_name, color, width, background)) - - total_width = sum(item[4] for item in measured) - align = str(props.get("align", "left")) - x = rect.x0 - if align == "center": - x += max(0.0, (rect.width - total_width) / 2) - elif align == "right": - x += max(0.0, rect.width - total_width) - - max_size = max((item[1] for item in measured), default=base_size) - for character, font_size, font_name, color, width, background in measured: - if ( - isinstance(background, str) - and background.lower() not in {"", "transparent", "none"} - ): - page.draw_rect( - pymupdf.Rect(x, y, x + width, y + max_size * 1.15), - color=None, - fill=_color(background), - fill_opacity=0.3, - overlay=True, - ) - if character != " ": - page.insert_text( - pymupdf.Point(x, y + font_size), - character, - fontsize=font_size, - fontname=font_name, - color=color, - overlay=True, - ) - x += width - y += max_size * line_height - - return True - - -def render_text(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None: - rect = _rect(annotation) - if rect is None: - return - props = _props(annotation) - text = str(props.get("text", "")) - if not text: - return - highlight = props.get("highlightColor") - if isinstance(highlight, str) and highlight and highlight.lower() != "transparent": - page.draw_rect(rect, color=None, fill=_color(highlight), fill_opacity=0.3, overlay=True) - if _render_styled_text(page, rect, text, props, props.get("styles")): - return - align = {"left": 0, "center": 1, "right": 2}.get(str(props.get("align", "left")), 0) - page.insert_textbox( - rect, - text, - fontsize=max(1.0, float(props.get("fontSize", 14))), - fontname=_font_name(props.get("fontFamily"), props.get("bold"), props.get("italic")), - color=_color(props.get("color")), - align=align, - overlay=True, - ) - - -def render_draw(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None: - rect = _rect(annotation) - if rect is None: - return - props = _props(annotation) - _draw_polyline( - page, - _points_from_props(annotation, rect), - color=_color(props.get("strokeColor")), - width=float(props.get("strokeWidth", 2)), - opacity=_opacity(props.get("opacity")), - ) - - -def _asset_path(assets_dir: Path, ref: Any) -> Path | None: - if not isinstance(ref, str): - return None - try: - safe_ref = str(uuid.UUID(ref)) - except ValueError: - return None - path = assets_dir / safe_ref - return path if path.is_file() else None - - -def render_image(page: pymupdf.Page, annotation: Annotation, assets_dir: Path) -> None: - rect = _rect(annotation) - asset = _asset_path(assets_dir, _props(annotation).get("ref")) - if rect is None or asset is None: - return - page.insert_image(rect, filename=str(asset), keep_proportion=False, overlay=True) - - -def render_signature(page: pymupdf.Page, annotation: Annotation, assets_dir: Path) -> None: - rect = _rect(annotation) - props = _props(annotation) - if rect is None: - return - if props.get("mode") == "draw": - asset = _asset_path(assets_dir, props.get("ref")) - if asset is not None: - page.insert_image(rect, filename=str(asset), keep_proportion=False, overlay=True) - return - text = str(props.get("text", "")) - if text: - font_file = _signature_font_file(props.get("fontFamily")) - font_name = _font_name(props.get("fontFamily"), False, False) - options: dict[str, Any] = { - # Script fonts have a taller ascender than the built-in PDF fonts; - # leave room inside the editor's saved bounding rectangle so a - # valid signature is never silently dropped for not fitting. - "fontsize": _signature_font_size(rect, text, font_file, font_name), - "color": _color(props.get("color")), - "overlay": True, - } - if font_file: - options["fontname"] = f"paperjet-{font_file.stem.lower()}" - options["fontfile"] = str(font_file) - else: - options["fontname"] = font_name - page.insert_textbox(rect, text, **options) - - -def render_highlight(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None: - rect = _rect(annotation) - if rect is None: - return - props = _props(annotation) - page.draw_rect( - rect, - color=None, - fill=_color(props.get("color"), (1.0, 0.92, 0.1)), - fill_opacity=_opacity(props.get("opacity"), 0.3), - overlay=True, - ) - - -def render_shape(page: pymupdf.Page, annotation: Annotation, _assets_dir: Path) -> None: - rect = _rect(annotation) - if rect is None: - return - props = _props(annotation) - kind = str(props.get("kind", "rect")) - stroke = _color(props.get("strokeColor")) - fill_value = props.get("fillColor") - fill = None if fill_value in (None, "", "transparent", "none") else _color(fill_value) - width = max(0.1, float(props.get("strokeWidth", 2))) - if kind == "ellipse": - page.draw_oval(rect, color=stroke, fill=fill, width=width, overlay=True) - elif kind in {"line", "arrow"}: - start = props.get("start") - end = props.get("end") - if ( - isinstance(start, Sequence) - and not isinstance(start, (str, bytes)) - and len(start) == 2 - and isinstance(end, Sequence) - and not isinstance(end, (str, bytes)) - and len(end) == 2 - ): - try: - start_point = pymupdf.Point( - rect.x0 + float(start[0]) * rect.width, - rect.y0 + float(start[1]) * rect.height, - ) - end_point = pymupdf.Point( - rect.x0 + float(end[0]) * rect.width, - rect.y0 + float(end[1]) * rect.height, - ) - except (TypeError, ValueError): - start_point, end_point = rect.tl, rect.br - else: - start_point, end_point = rect.tl, rect.br - page.draw_line(start_point, end_point, color=stroke, width=width, overlay=True) - if kind == "arrow": - angle = math.atan2(end_point.y - start_point.y, end_point.x - start_point.x) - length = min( - 12.0, - max( - 5.0, math.hypot(end_point.x - start_point.x, end_point.y - start_point.y) * 0.2 - ), - ) - left = pymupdf.Point( - end_point.x - length * math.cos(angle - math.pi / 6), - end_point.y - length * math.sin(angle - math.pi / 6), - ) - right = pymupdf.Point( - end_point.x - length * math.cos(angle + math.pi / 6), - end_point.y - length * math.sin(angle + math.pi / 6), - ) - page.draw_polyline([left, end_point, right], color=stroke, width=width, overlay=True) - else: - page.draw_rect(rect, color=stroke, fill=fill, width=width, overlay=True) - - -HANDLERS: dict[str, Handler] = { - "text": render_text, - "draw": render_draw, - "signature": render_signature, - "image": render_image, - "highlight": render_highlight, - "shape": render_shape, -} - - -def export_annotations( - pdf_path: Path, - annotations: Sequence[Annotation], - assets_dir: Path, -) -> bytes: - """Return a flattened PDF while preserving the source page rotations.""" - with pymupdf.open(pdf_path) as document: - rotations = [page.rotation for page in document] - try: - for page in document: - if page.rotation: - page.set_rotation(0) - page_annotations: dict[int, list[Annotation]] = {} - for annotation in annotations: - try: - page_number = int(annotation.get("page", -1)) - except (TypeError, ValueError): - logger.warning("Skipping annotation with invalid page: %r", annotation) - continue - if 0 <= page_number < len(document): - page_annotations.setdefault(page_number, []).append(annotation) - else: - logger.warning("Skipping annotation outside document pages: %r", annotation) - - for page_number, page in enumerate(document): - ordered = sorted( - page_annotations.get(page_number, []), - key=lambda item: int(item.get("z", 0)), - ) - for annotation in ordered: - annotation_type = annotation.get("type") - handler = HANDLERS.get(str(annotation_type)) - if handler is None: - logger.warning("Skipping unsupported annotation type %r", annotation_type) - continue - try: - handler(page, annotation, assets_dir) - except Exception: - logger.exception("Skipping malformed %s annotation", annotation_type) - finally: - for page, rotation in zip(document, rotations, strict=True): - if page.rotation != rotation: - page.set_rotation(rotation) - return document.tobytes(garbage=4, deflate=True) diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py index f2b4002..d500ac9 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -1,71 +1,53 @@ -"""Document and binary storage helpers.""" +"""Document storage service.""" +import shutil import uuid -from pathlib import Path - import pymupdf -from fastapi import HTTPException, UploadFile +from pathlib import Path +from fastapi import UploadFile, HTTPException from app.config import settings -_COPY_CHUNK_SIZE = 1024 * 1024 - - -def validate_pdf(filepath: Path) -> int: - """Validate a PDF's magic bytes and return its page count.""" - with filepath.open("rb") as file: - if file.read(5) != b"%PDF-": +def validate_pdf(filepath: Path) -> None: + """Validate PDF magic bytes and PyMuPDF openability.""" + with open(filepath, "rb") as f: + header = f.read(5) + if header != b"%PDF-": raise ValueError("Not a valid PDF file (missing magic bytes)") try: - with pymupdf.open(filepath) as document: - page_count = len(document) - except Exception as err: - raise ValueError(f"Failed to open PDF with PyMuPDF: {err}") from err - - if page_count == 0: - raise ValueError("PDF does not contain any pages") - return page_count - - -def safe_filename(filename: str | None) -> str: - """Return a display-safe basename without allowing path components.""" - name = Path(filename or "Untitled.pdf").name.strip() - return name or "Untitled.pdf" - + doc = pymupdf.open(filepath) + doc.close() + except Exception as e: + raise ValueError(f"Failed to open PDF with PyMuPDF: {e}") async def save_upload_file(upload_file: UploadFile) -> tuple[str, int, int]: - """Stream an uploaded PDF to storage, enforcing the configured size limit.""" + """Save an uploaded file to disk and validate it.""" file_id = str(uuid.uuid4()) - settings.PDF_STORAGE_PATH.mkdir(parents=True, exist_ok=True) filepath = settings.PDF_STORAGE_PATH / f"{file_id}.pdf" - max_bytes = settings.MAX_UPLOAD_MB * 1024 * 1024 - total_bytes = 0 try: - with filepath.open("wb") as destination: - while chunk := upload_file.file.read(_COPY_CHUNK_SIZE): - total_bytes += len(chunk) - if total_bytes > max_bytes: - raise HTTPException(status_code=413, detail="File too large") - destination.write(chunk) - except HTTPException: - filepath.unlink(missing_ok=True) - raise - except Exception as err: - filepath.unlink(missing_ok=True) - raise HTTPException(status_code=500, detail="Failed to save file") from err + with open(filepath, "wb") as f: + shutil.copyfileobj(upload_file.file, f) + except Exception as e: + if filepath.exists(): + filepath.unlink() + raise HTTPException(status_code=500, detail="Failed to save file") + page_count = 0 try: - page_count = validate_pdf(filepath) - except ValueError as err: - filepath.unlink(missing_ok=True) - raise HTTPException(status_code=400, detail=str(err)) from err - - return file_id, total_bytes, page_count - + doc = pymupdf.open(filepath) + page_count = len(doc) + doc.close() + except Exception as e: + filepath.unlink() + raise HTTPException(status_code=400, detail=f"Failed to open PDF with PyMuPDF: {e}") + + size = filepath.stat().st_size + return file_id, size, page_count def delete_pdf_file(file_id: str) -> None: """Delete a PDF file from storage.""" filepath = settings.PDF_STORAGE_PATH / f"{file_id}.pdf" - filepath.unlink(missing_ok=True) + if filepath.exists(): + filepath.unlink() diff --git a/backend/app/services/thumbnails.py b/backend/app/services/thumbnails.py index cfd8da7..fa50b92 100644 --- a/backend/app/services/thumbnails.py +++ b/backend/app/services/thumbnails.py @@ -1,40 +1,38 @@ """Thumbnail generation service.""" import pymupdf +from pathlib import Path from app.config import settings - -def generate_thumbnail(file_id: str) -> bool: +def generate_thumbnail(file_id: str) -> None: """Generate a PNG thumbnail for the first page of a PDF.""" pdf_path = settings.PDF_STORAGE_PATH / f"{file_id}.pdf" thumb_path = settings.THUMBNAILS_PATH / f"{file_id}.png" - + if not pdf_path.exists(): - return False - + return + try: - with pymupdf.open(pdf_path) as doc: - if len(doc) == 0: - return False + doc = pymupdf.open(pdf_path) + if len(doc) > 0: page = doc[0] # Zoom to approximately 600px width rect = page.rect zoom = 600.0 / rect.width if rect.width > 0 else 1.0 mat = pymupdf.Matrix(zoom, zoom) - + # Render pixmap pix = page.get_pixmap(matrix=mat, alpha=False) - + # Save as PNG pix.save(thumb_path, output="png") - return True - except Exception as err: - print(f"Failed to generate thumbnail for {file_id}: {err}") - return False - + doc.close() + except Exception as e: + print(f"Failed to generate thumbnail for {file_id}: {e}") def delete_thumbnail(file_id: str) -> None: """Delete a thumbnail file.""" thumb_path = settings.THUMBNAILS_PATH / f"{file_id}.png" - thumb_path.unlink(missing_ok=True) + if thumb_path.exists(): + thumb_path.unlink() diff --git a/backend/app/services/trash_sweep.py b/backend/app/services/trash_sweep.py index 13fc3b0..99c6079 100644 --- a/backend/app/services/trash_sweep.py +++ b/backend/app/services/trash_sweep.py @@ -1,50 +1,48 @@ """Background tasks for sweeping trash and old versions.""" -from datetime import UTC, datetime, timedelta - +from datetime import datetime, timedelta, timezone from sqlalchemy.orm import Session -from app.config import settings -from app.models.annotation_state import AnnotationState +from app.db import get_db from app.models.document import Document +from app.models.annotation_state import AnnotationState from app.models.version import Version -from app.services.assets import delete_asset_directory from app.services.storage import delete_pdf_file from app.services.thumbnails import delete_thumbnail - +from app.config import settings def sweep_trash(db: Session) -> None: """Permanently delete documents that have been in the trash past the retention period.""" - cutoff = datetime.now(UTC) - timedelta(days=settings.TRASH_RETENTION_DAYS) + cutoff = datetime.now(timezone.utc) - timedelta(days=settings.TRASH_RETENTION_DAYS) cutoff_iso = cutoff.isoformat() - - docs_to_delete = ( - db.query(Document) - .filter(Document.deleted_at.is_not(None), Document.deleted_at <= cutoff_iso) - .all() - ) - + + docs_to_delete = db.query(Document).filter( + Document.in_trash == True, + Document.deleted_at <= cutoff_iso + ).all() + for doc in docs_to_delete: # Delete files from disk delete_pdf_file(doc.id) delete_thumbnail(doc.id) - delete_asset_directory(doc.id) - + # Delete DB associations db.query(AnnotationState).filter(AnnotationState.document_id == doc.id).delete() db.query(Version).filter(Version.document_id == doc.id).delete() - + # Delete document record db.delete(doc) - + db.commit() - def prune_auto_versions(db: Session) -> None: """Delete automatic versions older than the retention period.""" - cutoff = datetime.now(UTC) - timedelta(days=settings.AUTO_VERSION_RETENTION_DAYS) + cutoff = datetime.now(timezone.utc) - timedelta(days=settings.AUTO_VERSION_RETENTION_DAYS) cutoff_iso = cutoff.isoformat() - - db.query(Version).filter(Version.kind == "auto", Version.created_at <= cutoff_iso).delete() - + + db.query(Version).filter( + Version.kind == "auto", + Version.created_at <= cutoff_iso + ).delete() + db.commit() diff --git a/backend/app/tests/test_api_workflows.py b/backend/app/tests/test_api_workflows.py deleted file mode 100644 index 5b48d97..0000000 --- a/backend/app/tests/test_api_workflows.py +++ /dev/null @@ -1,121 +0,0 @@ -"""End-to-end coverage for the user-critical backend workflows.""" - -import os -import shutil -import uuid -from pathlib import Path - -import pymupdf -from fastapi.testclient import TestClient - -_ROOT = Path.cwd() / ".pytest-paperjet-api" -shutil.rmtree(_ROOT, ignore_errors=True) -_ROOT.mkdir() -os.environ.update( - { - "PAPERJET_SECRET_KEY": "test-secret-key", - "PAPERJET_DATABASE_PATH": str(_ROOT / "db" / "app.sqlite"), - "PAPERJET_PDF_STORAGE_PATH": str(_ROOT / "pdfs"), - "PAPERJET_THUMBNAILS_PATH": str(_ROOT / "thumbnails"), - "PAPERJET_DEBUG": "false", - } -) - -from app.main import app # noqa: E402 - - -def _pdf_bytes() -> bytes: - document = pymupdf.open() - document.new_page(width=240, height=320) - content = document.tobytes() - document.close() - return content - - -def test_setup_upload_annotate_version_export_and_trash_restore() -> None: - csrf = {"X-Requested-With": "XMLHttpRequest"} - annotation_id = str(uuid.uuid4()) - annotation = { - "id": annotation_id, - "page": 0, - "type": "text", - "rect": {"x": 20, "y": 30, "width": 120, "height": 30}, - "rotation": 0, - "z": 0, - "props": { - "text": "Persisted note", - "fontFamily": "Liberation Sans", - "fontSize": 14, - "color": "#000000", - }, - "createdAt": "2026-08-14T00:00:00Z", - "updatedAt": "2026-08-14T00:00:00Z", - } - - with TestClient(app) as client: - assert client.get("/api/v1/health").json()["status"] == "ok" - assert client.get("/api/v1/auth/status").json()["setupRequired"] is True - - setup = client.post("/api/v1/auth/setup", json={"password": "correct horse"}, headers=csrf) - assert setup.status_code == 200 - assert client.get("/api/v1/auth/status").json()["loggedIn"] is True - - upload = client.post( - "/api/v1/documents", - files={"file": ("sample.pdf", _pdf_bytes(), "application/pdf")}, - headers=csrf, - ) - assert upload.status_code == 201 - document_id = upload.json()["id"] - - saved = client.put( - f"/api/v1/documents/{document_id}/annotations", - json={"data": [annotation]}, - headers=csrf, - ) - assert saved.status_code == 200 - assert client.get(f"/api/v1/documents/{document_id}/annotations").json()["data"] == [ - annotation - ] - - version = client.post( - f"/api/v1/documents/{document_id}/versions", - json={"label": "Before export"}, - headers=csrf, - ) - assert version.status_code == 201 - version_id = version.json()["id"] - - changed = {**annotation, "props": {**annotation["props"], "text": "Changed"}} - client.put( - f"/api/v1/documents/{document_id}/annotations", - json={"data": [changed]}, - headers=csrf, - ) - restored = client.post( - f"/api/v1/documents/{document_id}/versions/{version_id}/restore", - headers=csrf, - ) - assert restored.status_code == 200 - assert client.get(f"/api/v1/documents/{document_id}/annotations").json()["data"] == [ - annotation - ] - - exported = client.post( - f"/api/v1/documents/{document_id}/export", - json={"flatten": True}, - headers=csrf, - ) - assert exported.status_code == 200 - with pymupdf.open(stream=exported.content, filetype="pdf") as document: - assert "Persisted note" in document[0].get_text("text") - - assert client.delete(f"/api/v1/documents/{document_id}", headers=csrf).status_code == 200 - assert client.get("/api/v1/documents").json()["total"] == 0 - assert client.get("/api/v1/documents/trash").json()["total"] == 1 - assert ( - client.post(f"/api/v1/documents/{document_id}/restore", headers=csrf).status_code == 200 - ) - assert client.get("/api/v1/documents").json()["total"] == 1 - - shutil.rmtree(_ROOT, ignore_errors=True) diff --git a/backend/app/tests/test_export.py b/backend/app/tests/test_export.py deleted file mode 100644 index 16711f2..0000000 --- a/backend/app/tests/test_export.py +++ /dev/null @@ -1,121 +0,0 @@ -"""Regression coverage for canonical-coordinate PDF export.""" - -import json - -import pymupdf - -from app.services.export.renderer import export_annotations - - -def _source_pdf(path) -> None: - document = pymupdf.open() - page = document.new_page(width=720, height=936) - page.set_cropbox(pymupdf.Rect(36, 72, 648, 864)) - page.set_rotation(90) - document.save(path) - document.close() - - -def test_export_preserves_rotation_and_flattens_supported_annotations(tmp_path) -> None: - source = tmp_path / "source.pdf" - _source_pdf(source) - annotations = [ - { - "id": "text", - "page": 0, - "type": "text", - "rect": {"x": 40, "y": 50, "width": 180, "height": 40}, - "props": { - "text": "Exported text", - "fontFamily": "Liberation Sans", - "fontSize": 14, - "color": "#112233", - "styles": {"0": {"0": {"fill": "#ff0000", "fontWeight": "bold"}}}, - }, - }, - { - "id": "line", - "page": 0, - "type": "draw", - "rect": {"x": 40, "y": 110, "width": 80, "height": 40}, - "props": { - "paths": [[0, 0], [80, 40]], - "strokeColor": "#ff0000", - "strokeWidth": 2, - "opacity": 1, - }, - }, - { - "id": "highlight", - "page": 0, - "type": "highlight", - "rect": {"x": 40, "y": 170, "width": 100, "height": 20}, - "props": {"color": "#ffff00", "opacity": 0.4}, - }, - { - "id": "signature", - "page": 0, - "type": "signature", - "rect": {"x": 40, "y": 220, "width": 160, "height": 50}, - "props": { - "mode": "type", - "text": "Ava", - "fontFamily": "Great Vibes", - "color": "#000000", - }, - }, - { - "id": "shape", - "page": 0, - "type": "shape", - "rect": {"x": 180, "y": 170, "width": 60, "height": 30}, - "props": {"kind": "ellipse", "strokeColor": "#0000ff", "strokeWidth": 2}, - }, - { - "id": "future", - "page": 0, - "type": "future-stamp", - "rect": {"x": 0, "y": 0, "width": 10, "height": 10}, - "props": {}, - }, - ] - - exported = export_annotations(source, annotations, tmp_path / "assets") - output = tmp_path / "exported.pdf" - output.write_bytes(exported) - - with pymupdf.open(output) as document: - page = document[0] - assert page.rotation == 90 - assert page.rect.width == 792 - assert page.rect.height == 612 - assert "Exported text" in page.get_text("text") - assert "Ava" in page.get_text("text") - - # Normalize only for inspection: the exported PDF still retains the - # original page rotation above. - page.set_rotation(0) - words = page.get_text("words") - text_word = next(word for word in words if word[4] == "Exported") - assert 35 <= text_word[0] <= 45 - assert 45 <= text_word[1] <= 60 - assert len(page.get_drawings()) >= 3 - - -def test_export_does_not_mutate_annotation_input(tmp_path) -> None: - source = tmp_path / "source.pdf" - _source_pdf(source) - annotations = [ - { - "id": "unknown", - "page": 0, - "type": "not-yet-supported", - "rect": {"x": 1, "y": 2, "width": 3, "height": 4}, - "props": {"future": True}, - } - ] - before = json.loads(json.dumps(annotations)) - - export_annotations(source, annotations, tmp_path / "assets") - - assert annotations == before diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6ec9e2d..93831b7 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -30,18 +30,12 @@ dev = [ requires = ["setuptools>=75.0"] build-backend = "setuptools.build_meta" -[tool.setuptools.packages.find] -include = ["app*"] - [tool.ruff] target-version = "py312" line-length = 100 [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"] -# FastAPI requires dependency and multipart declarations in parameter defaults; -# B008 treats that framework idiom as a mutable-default hazard. -ignore = ["B008"] [tool.mypy] python_version = "3.12" @@ -49,21 +43,6 @@ strict = true warn_return_any = true warn_unused_configs = true -# PyMuPDF does not ship complete type information. Keep strict checking for -# application code while isolating its untyped boundary and test fixtures. -[[tool.mypy.overrides]] -module = [ - "app.services.export.renderer", - "app.services.storage", - "app.services.thumbnails", - "app.api.v1.debug", -] -disable_error_code = ["no-untyped-call", "no-any-return", "no-untyped-def"] - -[[tool.mypy.overrides]] -module = ["app.tests.*"] -disable_error_code = ["no-untyped-call", "no-any-return", "no-untyped-def", "dict-item"] - [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["app/tests"] diff --git a/docker-compose.yml b/docker-compose.yml index 4567c1f..4be3bb5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,23 +1,27 @@ services: - paperjet: - image: ${PAPERJET_IMAGE:-paperjet:local} - build: - context: . - dockerfile: Dockerfile + backend: + build: ./backend environment: - PAPERJET_SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env before starting PaperJet} - PAPERJET_MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-200} - PAPERJET_TRASH_RETENTION_DAYS: ${TRASH_RETENTION_DAYS:-30} - PAPERJET_AUTO_VERSION_RETENTION_DAYS: ${AUTO_VERSION_RETENTION_DAYS:-30} - PAPERJET_COOKIE_SECURE: ${COOKIE_SECURE:-false} - PAPERJET_DATABASE_PATH: /data/db/app.sqlite - PAPERJET_PDF_STORAGE_PATH: /data/pdfs - PAPERJET_THUMBNAILS_PATH: /data/thumbnails - PAPERJET_DEBUG: ${DEBUG:-false} + - PAPERJET_SECRET_KEY=${SECRET_KEY:-change-me-in-production} + - PAPERJET_MAX_UPLOAD_MB=${MAX_UPLOAD_MB:-200} + - PAPERJET_TRASH_RETENTION_DAYS=${TRASH_RETENTION_DAYS:-30} + - PAPERJET_AUTO_VERSION_RETENTION_DAYS=${AUTO_VERSION_RETENTION_DAYS:-30} + - PAPERJET_COOKIE_SECURE=${COOKIE_SECURE:-false} + - PAPERJET_DATABASE_PATH=/data/db/app.sqlite + - PAPERJET_PDF_STORAGE_PATH=/data/pdfs + - PAPERJET_THUMBNAILS_PATH=/data/thumbnails + - PAPERJET_DEBUG=true volumes: - pdf_storage:/data/pdfs - thumbnails:/data/thumbnails - db:/data/db + ports: + - "8000:8000" + restart: unless-stopped + + frontend: + build: ./frontend + depends_on: [backend] ports: - "${HTTP_PORT:-4982}:80" restart: unless-stopped diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh deleted file mode 100644 index 9d96914..0000000 --- a/docker-entrypoint.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash - -set -Eeuo pipefail - -uvicorn --app-dir /app app.main:app \ - --host 127.0.0.1 \ - --port 8000 \ - --workers 1 & -backend_pid=$! - -nginx -g "daemon off;" & -nginx_pid=$! - -stop_children() { - kill -TERM "$backend_pid" "$nginx_pid" 2>/dev/null || true - wait "$backend_pid" 2>/dev/null || true - wait "$nginx_pid" 2>/dev/null || true -} - -shutdown() { - trap - TERM INT - stop_children - exit 143 -} - -trap shutdown TERM INT - -# If either service exits, stop the other one and let Docker restart/report the -# container rather than leaving a partially working instance running. -set +e -wait -n "$backend_pid" "$nginx_pid" -exit_code=$? -set -e - -stop_children -exit "$exit_code" diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..e1c8fe5 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,24 @@ +# Stage 1: Build the SPA +FROM node:22-alpine AS build + +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm install --legacy-peer-deps +COPY . . +RUN npm run build + +# Stage 2: Serve via nginx +FROM nginx:alpine + +# Remove default nginx config +RUN rm /etc/nginx/conf.d/default.conf + +# Copy our nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Copy built SPA from build stage +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/README.md b/frontend/README.md index 44eb366..7dbf7eb 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,20 +1,73 @@ -# PaperJet frontend +# React + TypeScript + Vite -This directory contains the React/Vite client for PaperJet. Run the full stack -from the repository root; the root [README](../README.md) has Docker and local -development instructions. +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. -## Useful commands +Currently, two official plugins are available: -```sh -npm ci -npm run dev # Vite with /api proxied to localhost:8000 -npm test -- --run -npm run typecheck -npm run lint -npm run build +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) ``` -The editor renders PDF pages with PDF.js and uses Fabric.js for the annotation -layer. Annotation state is persisted through the backend API; the browser does -not write PDF bytes directly. +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/nginx.conf b/frontend/nginx.conf index b8a0a62..7f1e76c 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -2,12 +2,10 @@ server { listen 80; server_name _; - access_log /dev/stdout; - error_log /dev/stderr warn; - include /etc/nginx/mime.types; types { application/javascript mjs; + application/wasm wasm; } # Match MAX_UPLOAD_MB — raise in both this and the outer reverse proxy @@ -27,9 +25,9 @@ server { try_files $uri $uri/ /index.html; } - # Proxy /api/ to the loopback-only FastAPI process in this container + # Proxy /api/ to the backend container location /api/ { - proxy_pass http://127.0.0.1:8000; + proxy_pass http://backend:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; diff --git a/frontend/src/features/auth/LoginForm.tsx b/frontend/src/features/auth/LoginForm.tsx index 10c3b3d..69665ce 100644 --- a/frontend/src/features/auth/LoginForm.tsx +++ b/frontend/src/features/auth/LoginForm.tsx @@ -14,7 +14,7 @@ export const LoginForm = () => { try { await login({ password }) navigate('/') - } catch { + } catch (err) { // Error is handled by the store } } diff --git a/frontend/src/features/auth/SetupForm.tsx b/frontend/src/features/auth/SetupForm.tsx index 085f548..0bd814a 100644 --- a/frontend/src/features/auth/SetupForm.tsx +++ b/frontend/src/features/auth/SetupForm.tsx @@ -26,7 +26,7 @@ export const SetupForm = () => { try { await setup({ password }) navigate('/') - } catch { + } catch (err) { // Error is handled by the store } } diff --git a/frontend/src/features/auth/useAuth.ts b/frontend/src/features/auth/useAuth.ts index 6f6df03..3f34898 100644 --- a/frontend/src/features/auth/useAuth.ts +++ b/frontend/src/features/auth/useAuth.ts @@ -16,17 +16,6 @@ interface AuthState { clearError: () => void } -function errorMessage(reason: unknown, fallback: string) { - if (reason instanceof Error && reason.message) return reason.message; - if (typeof reason === 'object' && reason !== null && 'error' in reason) { - const error = reason.error; - if (typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string') { - return error.message; - } - } - return fallback; -} - export const useAuth = create((set) => ({ isInitialized: false, setupRequired: false, @@ -44,9 +33,9 @@ export const useAuth = create((set) => ({ isInitialized: true, isLoading: false }) - } catch (err: unknown) { + } catch (err: any) { set({ - error: errorMessage(err, 'Failed to initialize'), + error: err.error?.message || err.message || 'Failed to initialize', isLoading: false, isInitialized: true }) @@ -58,9 +47,9 @@ export const useAuth = create((set) => ({ set({ isLoading: true, error: null }) await authApi.login(data) set({ loggedIn: true, isLoading: false }) - } catch (err: unknown) { + } catch (err: any) { set({ - error: errorMessage(err, 'Failed to login'), + error: err.error?.message || err.message || 'Failed to login', isLoading: false }) throw err @@ -72,9 +61,9 @@ export const useAuth = create((set) => ({ set({ isLoading: true, error: null }) await authApi.setup(data) set({ setupRequired: false, loggedIn: true, isLoading: false }) - } catch (err: unknown) { + } catch (err: any) { set({ - error: errorMessage(err, 'Failed to setup'), + error: err.error?.message || err.message || 'Failed to setup', isLoading: false }) throw err @@ -87,9 +76,9 @@ export const useAuth = create((set) => ({ await authApi.logout() set({ loggedIn: false, isLoading: false }) window.location.href = '/login' - } catch (err: unknown) { + } catch (err: any) { set({ - error: errorMessage(err, 'Failed to logout'), + error: err.error?.message || err.message || 'Failed to logout', isLoading: false }) } diff --git a/frontend/src/features/editor/canvas/AnnotationLayer.tsx b/frontend/src/features/editor/canvas/AnnotationLayer.tsx index 5b570ec..5bca331 100644 --- a/frontend/src/features/editor/canvas/AnnotationLayer.tsx +++ b/frontend/src/features/editor/canvas/AnnotationLayer.tsx @@ -1,9 +1,9 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef } from 'react'; import * as fabric from 'fabric'; import { useEditorStore } from '../store'; import { getTool } from '../../../lib/annotations/registry'; import type { ViewportParams } from '../../../lib/coords'; -import { screenRectToPdf } from '../../../lib/coords'; +import { screenToPdf } from '../../../lib/coords'; import { TextFormatToolbar } from '../toolbar/TextFormatToolbar'; interface AnnotationLayerProps { @@ -13,201 +13,208 @@ interface AnnotationLayerProps { viewportParams: ViewportParams; } -type AnnotationObject = fabric.FabricObject & { id?: string }; - export function AnnotationLayer({ pageNumber, width, height, viewportParams }: AnnotationLayerProps) { const canvasRef = useRef(null); const fabricRef = useRef(null); - const renderingIds = useRef(new Set()); - const renderGeneration = useRef(0); + + const activeToolName = useEditorStore(state => state.activeTool); + + const annotations = useEditorStore(state => state.annotations); + const latestViewportParams = useRef(viewportParams); - const previousDimensions = useRef({ width, height }); - const [canvasInstance, setCanvasInstance] = useState(null); - - const activeToolName = useEditorStore((state) => state.activeTool); - const annotations = useEditorStore((state) => state.annotations); - const selection = useEditorStore((state) => state.selection); - const draftAnnotation = useEditorStore((state) => state.draftAnnotation); - useEffect(() => { latestViewportParams.current = viewportParams; }, [viewportParams]); + // Initial setup and event binding useEffect(() => { if (!canvasRef.current) return; - const pendingRenderIds = renderingIds.current; + const canvas = new fabric.Canvas(canvasRef.current, { width, height, selection: activeToolName === 'select', - enableRetinaScaling: true, }); - fabricRef.current = canvas; - setCanvasInstance(canvas); - - canvas.on('object:modified', (event) => { - const object = event.target as AnnotationObject | undefined; - if (!object?.id) return; - const annotation = useEditorStore.getState().annotations.find((item) => item.id === object.id); - if (!annotation) return; - const bounds = object.getBoundingRect(); - useEditorStore.getState().updateAnnotation(object.id, { - rect: screenRectToPdf( - { x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height }, - latestViewportParams.current, - ), - }); + + (window as any).__fabricCanvas = canvas; + + // Sync fabric modifications back to Zustand + canvas.on('object:modified', (e) => { + const obj = e.target as any; + if (obj && obj.id) { + // Find existing annotation + const ann = useEditorStore.getState().annotations.find(a => a.id === obj.id); + if (ann) { + const currentVp = latestViewportParams.current; + const pt = screenToPdf({ x: obj.left, y: obj.top }, currentVp); + + const scaleX = obj.scaleX || 1; + const scaleY = obj.scaleY || 1; + + // Box width/height in PDF space + const newWidth = (obj.width * scaleX) / (currentVp.scale * (currentVp.dpr || 1)); + const newHeight = (obj.height * scaleY) / (currentVp.scale * (currentVp.dpr || 1)); + + useEditorStore.getState().updateAnnotation(obj.id, { + rect: { ...ann.rect, x: pt.x, y: pt.y, width: newWidth, height: newHeight } + }); + } + } }); - const updateSelection = () => { - const activeObject = canvas.getActiveObject() as AnnotationObject | undefined; - useEditorStore.getState().setSelection(activeObject?.id ?? null); + const updateSelection = (_e?: any) => { + const activeObj = canvas.getActiveObject() as any; + if (activeObj && activeObj.id) { + useEditorStore.getState().setSelection(activeObj.id); + } else { + useEditorStore.getState().setSelection(null); + } }; + canvas.on('selection:created', updateSelection); canvas.on('selection:updated', updateSelection); canvas.on('selection:cleared', updateSelection); - const handleKeyDown = (event: KeyboardEvent) => { - if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return; - if (event.key !== 'Delete' && event.key !== 'Backspace') return; - const activeObject = canvas.getActiveObject() as AnnotationObject | undefined; - if (activeObject && 'isEditing' in activeObject && activeObject.isEditing) return; - const activeObjects = canvas.getActiveObjects() as AnnotationObject[]; - if (!activeObjects.length) return; - for (const object of activeObjects) { - if (object.id) useEditorStore.getState().deleteAnnotation(object.id); + // Keyboard shortcut for delete + const handleKeyDown = (e: KeyboardEvent) => { + // Don't intercept if user is typing in an input field outside of canvas + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return; + } + if (e.key === 'Delete' || e.key === 'Backspace') { + const activeObj = canvas.getActiveObject() as any; + if (activeObj && activeObj.isEditing === true) { + return; + } + + const activeObjects = canvas.getActiveObjects(); + if (activeObjects && activeObjects.length > 0) { + activeObjects.forEach((obj: any) => { + if (obj.id) { + useEditorStore.getState().deleteAnnotation(obj.id); + } + }); + canvas.discardActiveObject(); + canvas.requestRenderAll(); + e.preventDefault(); + } } - canvas.discardActiveObject(); - canvas.requestRenderAll(); - event.preventDefault(); }; + document.addEventListener('keydown', handleKeyDown); + fabricRef.current = canvas; + return () => { document.removeEventListener('keydown', handleKeyDown); - pendingRenderIds.clear(); - renderGeneration.current += 1; canvas.dispose(); fabricRef.current = null; - setCanvasInstance(null); }; - // The canvas is created once for this page. Dimension and tool changes are - // handled by the effects below. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, []); // Run once on mount + const prevScaleRef = useRef(viewportParams.scale); + + // Handle dimensions and re-rendering annotations useEffect(() => { + if (!fabricRef.current) return; const canvas = fabricRef.current; - if (!canvas) return; - const generation = ++renderGeneration.current; - renderingIds.current.clear(); canvas.setDimensions({ width, height }); + + const activeObj = canvas.getActiveObject() as any; + const activeId = activeObj?.id; - const pageAnnotations = annotations.filter((annotation) => annotation.page === pageNumber); - const annotationIds = new Set(pageAnnotations.map((annotation) => annotation.id)); - const activeObject = canvas.getActiveObject() as AnnotationObject | undefined; - const activeId = activeObject?.id; - const scaleChanged = previousDimensions.current.width !== width || previousDimensions.current.height !== height; - previousDimensions.current = { width, height }; + const pageAnns = annotations.filter(a => a.page === pageNumber); + const annIds = new Set(pageAnns.map(a => a.id)); - for (const object of canvas.getObjects() as AnnotationObject[]) { - if (!object.id || !annotationIds.has(object.id) || scaleChanged) { - canvas.remove(object); + const scaleChanged = prevScaleRef.current !== viewportParams.scale; + prevScaleRef.current = viewportParams.scale; + + // Remove objects that are no longer in annotations, OR if the zoom scale changed + canvas.getObjects().forEach((obj: any) => { + // Don't remove the active object unless it was deleted from the store, + // UNLESS the scale changed (we must recreate everything on zoom) + if (!scaleChanged && obj.id === activeId && annIds.has(activeId)) return; + + if (scaleChanged || !annIds.has(obj.id)) { + canvas.remove(obj); } - } + }); + + // Add missing annotations + const canvasObjIds = new Set(canvas.getObjects().map((o: any) => o.id)); + pageAnns.forEach(ann => { + if (!scaleChanged && ann.id === activeId) return; // Skip rendering the active object + + if (!canvasObjIds.has(ann.id)) { + const tool = getTool(ann.type); + if (tool && tool.renderToFabric) { + tool.renderToFabric(ann, canvas, viewportParams); + } + } + }); - for (const annotation of pageAnnotations) { - if (annotation.id === activeId && !scaleChanged) continue; - if (canvas.getObjects().some((object) => (object as AnnotationObject).id === annotation.id)) continue; - if (renderingIds.current.has(annotation.id)) continue; - const tool = getTool(annotation.type); - if (!tool?.renderToFabric) continue; - renderingIds.current.add(annotation.id); - const objectsBeforeRender = new Set(canvas.getObjects()); - void Promise.resolve() - .then(() => tool.renderToFabric?.(annotation, canvas, viewportParams)) - .then(() => { - const addedObjects = canvas.getObjects().filter( - (object) => !objectsBeforeRender.has(object) && (object as AnnotationObject).id === annotation.id, - ); - if (generation !== renderGeneration.current) { - for (const object of addedObjects) canvas.remove(object); - return; - } - if (activeId) { - const renderedActive = canvas.getObjects().find( - (object) => (object as AnnotationObject).id === activeId, - ); - if (renderedActive) canvas.setActiveObject(renderedActive); - } - canvas.requestRenderAll(); - }) - .catch((error: unknown) => { - console.error(`Failed to render ${annotation.type} annotation`, error); - }) - .finally(() => { - renderingIds.current.delete(annotation.id); - }); + if (scaleChanged && activeId && annIds.has(activeId)) { + const newlyRenderedActiveObj = canvas.getObjects().find((o: any) => o.id === activeId); + if (newlyRenderedActiveObj) { + canvas.setActiveObject(newlyRenderedActiveObj); + } } + }, [width, height, annotations, pageNumber, viewportParams.scale]); - if (activeId) { - const renderedActive = canvas.getObjects().find( - (object) => (object as AnnotationObject).id === activeId, - ); - if (renderedActive) canvas.setActiveObject(renderedActive); - } - canvas.requestRenderAll(); - }, [annotations, height, pageNumber, viewportParams, width]); - + // Handle tool activation/deactivation and event binding useEffect(() => { const canvas = fabricRef.current; if (!canvas) return; + + // First deactivate any previous tool logic + // This is a bit tricky if we don't remember the previous tool, + // but we can just clear event listeners. canvas.off('mouse:down'); canvas.off('mouse:move'); canvas.off('mouse:up'); - canvas.off('path:created'); + canvas.selection = activeToolName === 'select'; const tool = getTool(activeToolName); - if (!tool) return; - tool.onActivate?.(canvas); - if (tool.onPointerDown) { - canvas.on('mouse:down', (event) => - tool.onPointerDown?.(event, canvas, latestViewportParams.current, pageNumber), - ); + if (tool) { + if (tool.onActivate) tool.onActivate(canvas); + + if (tool.onPointerDown) { + canvas.on('mouse:down', (e) => tool.onPointerDown!(e, canvas, latestViewportParams.current, pageNumber)); + } + if (tool.onPointerMove) { + canvas.on('mouse:move', (e) => tool.onPointerMove!(e, canvas, latestViewportParams.current, pageNumber)); + } + if (tool.onPointerUp) { + canvas.on('mouse:up', (e) => tool.onPointerUp!(e, canvas, latestViewportParams.current, pageNumber)); + } + if (tool.onPathCreated) { + canvas.on('path:created', (e) => tool.onPathCreated!(e, canvas, latestViewportParams.current, pageNumber)); + } } - if (tool.onPointerMove) { - canvas.on('mouse:move', (event) => - tool.onPointerMove?.(event, canvas, latestViewportParams.current, pageNumber), - ); - } - if (tool.onPointerUp) { - canvas.on('mouse:up', (event) => - tool.onPointerUp?.(event, canvas, latestViewportParams.current, pageNumber), - ); - } - if (tool.onPathCreated) { - canvas.on('path:created', (event) => - tool.onPathCreated?.(event, canvas, latestViewportParams.current, pageNumber), - ); - } - return () => tool.onDeactivate?.(canvas); - }, [activeToolName, pageNumber]); - const activeAnnotation = - draftAnnotation?.page === pageNumber - ? draftAnnotation - : annotations.find((annotation) => annotation.id === selection && annotation.page === pageNumber); + return () => { + if (tool && tool.onDeactivate) { + tool.onDeactivate(canvas); + } + }; + }, [activeToolName]); + + const selection = useEditorStore(state => state.selection); + const draftAnnotation = useEditorStore(state => state.draftAnnotation); + const isSelectedOnThisPage = annotations.find(a => a.id === selection && a.page === pageNumber); + const isDraftOnThisPage = draftAnnotation?.page === pageNumber; + const activeAnn = isDraftOnThisPage ? draftAnnotation : isSelectedOnThisPage; + const toolbarAnnotationId = activeAnn ? activeAnn.id : null; return ( -
+
- {activeAnnotation?.type === 'text' && canvasInstance && ( - + {toolbarAnnotationId && activeAnn && ( + <> + {activeAnn.type === 'text' && } + {/* We will add ShapeControls and DrawControls here once they are implemented */} + )}
); diff --git a/frontend/src/features/editor/pages/PageRenderer.tsx b/frontend/src/features/editor/pages/PageRenderer.tsx index f7d1798..1a2a27d 100644 --- a/frontend/src/features/editor/pages/PageRenderer.tsx +++ b/frontend/src/features/editor/pages/PageRenderer.tsx @@ -1,127 +1,87 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist'; import { AnnotationLayer } from '../canvas/AnnotationLayer'; interface PageRendererProps { pdfDoc: PDFDocumentProxy; - pageIndex: number; + pageNumber: number; scale: number; dpr: number; } -export function PageRenderer({ pdfDoc, pageIndex, scale, dpr }: PageRendererProps) { +export function PageRenderer({ pdfDoc, pageNumber, scale, dpr }: PageRendererProps) { + const canvasRef = useRef(null); const [page, setPage] = useState(null); useEffect(() => { let active = true; - const pageNumber = pageIndex + 1; - pdfDoc.getPage(pageNumber).then((nextPage) => { - if (active) setPage(nextPage); + pdfDoc.getPage(pageNumber).then(p => { + if (active) setPage(p); }); - return () => { - active = false; + return () => { active = false; }; + }, [pdfDoc, pageNumber]); + + useEffect(() => { + if (!page || !canvasRef.current) return; + + const viewport = page.getViewport({ scale: scale * dpr }); + + const canvas = canvasRef.current; + const context = canvas.getContext('2d'); + if (!context) return; + + canvas.width = viewport.width; + canvas.height = viewport.height; + + canvas.style.width = `${viewport.width / dpr}px`; + canvas.style.height = `${viewport.height / dpr}px`; + + const renderContext = { + canvas: canvas, + viewport: viewport, }; - }, [pdfDoc, pageIndex]); + + let renderTask = page.render(renderContext); + + return () => { + renderTask.cancel(); + }; + }, [page, scale, dpr]); if (!page) { return ( -
- Loading page {pageIndex + 1}... + Loading page {pageNumber}...
); } - const canonicalViewport = page.getViewport({ scale: 1, rotation: 0 }); - const renderedViewport = page.getViewport({ - scale: scale * dpr, - rotation: page.rotate, - }); - const cssWidth = renderedViewport.width / dpr; - const cssHeight = renderedViewport.height / dpr; + const baseViewport = page ? page.getViewport({ scale: 1, rotation: 0 }) : null; return ( - - ); -} - -interface RenderedPageProps { - page: PDFPageProxy; - pageIndex: number; - scale: number; - dpr: number; - canonicalWidth: number; - canonicalHeight: number; - renderedWidth: number; - renderedHeight: number; - cssWidth: number; - cssHeight: number; -} - -function RenderedPage({ - page, - pageIndex, - scale, - dpr, - canonicalWidth, - canonicalHeight, - renderedWidth, - renderedHeight, - cssWidth, - cssHeight, -}: RenderedPageProps) { - const viewportParams = useMemo(() => ({ - scale, - rotation: page.rotate, - canonicalWidth, - canonicalHeight, - dpr, - }), [canonicalHeight, canonicalWidth, dpr, page.rotate, scale]); - - useEffect(() => { - const canvas = document.querySelector( - `[data-paperjet-page="${pageIndex}"]`, - ); - if (!canvas) return; - - const context = canvas.getContext('2d'); - if (!context) return; - canvas.width = renderedWidth; - canvas.height = renderedHeight; - canvas.style.width = `${cssWidth}px`; - canvas.style.height = `${cssHeight}px`; - - const renderTask = page.render({ canvas, viewport: page.getViewport({ scale: scale * dpr, rotation: page.rotate }) }); - renderTask.promise.catch(() => undefined); - return () => { - renderTask.cancel(); - }; - }, [page, pageIndex, scale, dpr, renderedWidth, renderedHeight, cssWidth, cssHeight]); - - return ( -
- - -
- {pageIndex + 1} +
+ + + {baseViewport && ( + + )} + +
+ {pageNumber}
); diff --git a/frontend/src/features/editor/pages/PageStack.tsx b/frontend/src/features/editor/pages/PageStack.tsx index 19d919f..0dbcc4f 100644 --- a/frontend/src/features/editor/pages/PageStack.tsx +++ b/frontend/src/features/editor/pages/PageStack.tsx @@ -8,13 +8,12 @@ interface PageStackProps { export function PageStack({ pdfDoc }: PageStackProps) { const numPages = pdfDoc.numPages; - const pages = Array.from({ length: numPages }, (_, i) => i); + const pages = Array.from({ length: numPages }, (_, i) => i + 1); const zoom = useEditorStore(state => state.zoom); - // PDF points map directly to CSS pixels at 100%. Device pixel ratio is - // applied only to the PDF.js backing canvas, not the annotation overlay. - const scale = zoom; + // For now, render all pages vertically. Virtualization comes in Phase 7. + const scale = zoom * 2.0; const dpr = window.devicePixelRatio || 1; return ( @@ -23,7 +22,7 @@ export function PageStack({ pdfDoc }: PageStackProps) { diff --git a/frontend/src/features/editor/pages/PdfDocument.tsx b/frontend/src/features/editor/pages/PdfDocument.tsx index 5d7d774..5caeb75 100644 --- a/frontend/src/features/editor/pages/PdfDocument.tsx +++ b/frontend/src/features/editor/pages/PdfDocument.tsx @@ -4,61 +4,50 @@ import { PageStack } from './PageStack'; pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.mjs', - import.meta.url, + import.meta.url ).toString(); -interface LoadedDocument { - id: string; - document: pdfjsLib.PDFDocumentProxy; -} - -interface DocumentError { - id: string; - message: string; -} - export function PdfDocument({ documentId }: { documentId: string }) { - const [loaded, setLoaded] = useState(null); - const [error, setError] = useState(null); - + const [pdfDoc, setPdfDoc] = useState(null); + const [error, setError] = useState(null); + useEffect(() => { let active = true; - const loadingTask = pdfjsLib.getDocument({ url: `/api/v1/documents/${documentId}/file` }); - - loadingTask.promise.then((document) => { - if (active) setLoaded({ id: documentId, document }); - }).catch((reason: unknown) => { - if (active) { - const message = reason instanceof Error ? reason.message : 'Unable to load this PDF.'; - setError({ id: documentId, message }); - } + const url = `/api/v1/documents/${documentId}/file`; + + const loadingTask = pdfjsLib.getDocument({ url }); + + loadingTask.promise.then((doc) => { + if (active) setPdfDoc(doc); + }).catch(err => { + console.error('Failed to load PDF', err); + if (active) setError(err.message); }); - + return () => { active = false; - void loadingTask.destroy(); + loadingTask.destroy(); }; }, [documentId]); - - const currentError = error?.id === documentId ? error.message : null; - const currentDocument = loaded?.id === documentId ? loaded.document : null; - - if (currentError) { + + if (error) { return ( -
-
Error loading PDF: {currentError}
+
+
+ Error loading PDF: {error} +
); } - - if (!currentDocument) { + + if (!pdfDoc) { return ( -
-
-
Loading document…
+
+
+
Loading document...
); } - - return ; + + return ; } diff --git a/frontend/src/features/editor/store.test.ts b/frontend/src/features/editor/store.test.ts deleted file mode 100644 index 9b82b04..0000000 --- a/frontend/src/features/editor/store.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { useEditorStore } from './store'; -import type { TextAnnotation } from '../../lib/annotations/types'; - -const annotation: TextAnnotation = { - id: 'note-1', - page: 0, - type: 'text', - rect: { x: 10, y: 20, width: 100, height: 30 }, - rotation: 0, - z: 0, - props: { - text: 'Original', - fontFamily: 'Liberation Sans', - fontSize: 14, - color: '#000000', - align: 'left', - bold: false, - italic: false, - lineHeight: 1.2, - }, - createdAt: '2026-08-14T00:00:00Z', - updatedAt: '2026-08-14T00:00:00Z', -}; - -beforeEach(() => { - useEditorStore.getState().setDocumentId(null); -}); - -describe('editor history', () => { - it('undoes and redoes annotation mutations without sharing mutable state', () => { - const store = useEditorStore.getState(); - store.setDocumentId('document-1'); - store.addAnnotation(annotation); - store.updateAnnotation('note-1', { props: { ...annotation.props, text: 'Updated' } }); - - expect(useEditorStore.getState().annotations[0].props).toMatchObject({ text: 'Updated' }); - useEditorStore.getState().undo(); - expect(useEditorStore.getState().annotations[0].props).toMatchObject({ text: 'Original' }); - useEditorStore.getState().redo(); - expect(useEditorStore.getState().annotations[0].props).toMatchObject({ text: 'Updated' }); - }); - - it('clears working state and history when switching documents', () => { - const store = useEditorStore.getState(); - store.setDocumentId('document-1'); - store.addAnnotation(annotation); - store.setDocumentId('document-2'); - - const next = useEditorStore.getState(); - expect(next.documentId).toBe('document-2'); - expect(next.annotations).toEqual([]); - expect(next.past).toEqual([]); - expect(next.future).toEqual([]); - }); -}); diff --git a/frontend/src/features/editor/store.ts b/frontend/src/features/editor/store.ts index 4e316c4..302e385 100644 --- a/frontend/src/features/editor/store.ts +++ b/frontend/src/features/editor/store.ts @@ -1,171 +1,74 @@ import { create } from 'zustand'; -import type { - Annotation, - SignatureDrawProps, - SignatureTypeProps, - TextProps, -} from '../../lib/annotations/types'; +import type { Annotation, TextProps } from '../../lib/annotations/types'; -export type SaveStatus = 'idle' | 'loading' | 'saving' | 'saved' | 'error'; -type SignatureProps = SignatureDrawProps | SignatureTypeProps; -type PendingImage = { ref: string; width: number; height: number }; +export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; interface EditorState { documentId: string | null; annotations: Annotation[]; - past: Annotation[][]; - future: Annotation[][]; - selection: string | null; activeTool: string; - activeShapeKind: 'rect' | 'ellipse' | 'line' | 'arrow'; + selection: string | null; zoom: number; saveStatus: SaveStatus; - annotationUpdatedAt: string | null; defaultTextProps: Partial; draftAnnotation: Annotation | null; isSignatureModalOpen: boolean; - pendingSignatureProps: SignatureProps | null; - pendingImageRef: PendingImage | null; - + pendingSignatureProps: any | null; + pendingImageRef: { ref: string, width: number, height: number } | null; + + // Actions setDocumentId: (id: string | null) => void; setAnnotations: (annotations: Annotation[]) => void; - setAnnotationUpdatedAt: (updatedAt: string | null) => void; addAnnotation: (annotation: Annotation) => void; updateAnnotation: (id: string, updates: Partial) => void; deleteAnnotation: (id: string) => void; - undo: () => void; - redo: () => void; - canUndo: () => boolean; - canRedo: () => boolean; setActiveTool: (tool: string) => void; setSelection: (id: string | null) => void; setZoom: (zoom: number) => void; setSaveStatus: (status: SaveStatus) => void; setDefaultTextProps: (props: Partial) => void; - setDraftAnnotation: (annotation: Annotation | null) => void; + setDraftAnnotation: (ann: Annotation | null) => void; setIsSignatureModalOpen: (isOpen: boolean) => void; - setPendingSignatureProps: (props: SignatureProps | null) => void; - setPendingImageRef: (image: PendingImage | null) => void; - setActiveShapeKind: (kind: EditorState['activeShapeKind']) => void; + setPendingSignatureProps: (props: any | null) => void; + setPendingImageRef: (imgRef: { ref: string, width: number, height: number } | null) => void; + activeShapeKind: 'rect' | 'ellipse' | 'line'; + setActiveShapeKind: (kind: 'rect' | 'ellipse' | 'line') => void; } -const MAX_HISTORY = 100; - -function cloneAnnotations(annotations: Annotation[]): Annotation[] { - return annotations.map((annotation) => ({ - ...annotation, - rect: { ...annotation.rect }, - props: structuredClone(annotation.props), - })) as Annotation[]; -} - -function withHistory(state: EditorState, annotations: Annotation[]): Partial { - return { - annotations, - past: [...state.past, cloneAnnotations(state.annotations)].slice(-MAX_HISTORY), - future: [], - }; -} - -export const useEditorStore = create((set, get) => ({ +export const useEditorStore = create((set) => ({ documentId: null, annotations: [], - past: [], - future: [], - selection: null, activeTool: 'select', - activeShapeKind: 'rect', + selection: null, zoom: 1, saveStatus: 'idle', - annotationUpdatedAt: null, defaultTextProps: {}, draftAnnotation: null, isSignatureModalOpen: false, pendingSignatureProps: null, pendingImageRef: null, + activeShapeKind: 'rect', - setDocumentId: (id) => - set({ - documentId: id, - annotations: [], - past: [], - future: [], - selection: null, - saveStatus: id ? 'loading' : 'idle', - annotationUpdatedAt: null, - defaultTextProps: {}, - draftAnnotation: null, - pendingSignatureProps: null, - pendingImageRef: null, - }), - setAnnotations: (annotations) => - set({ - annotations: cloneAnnotations(annotations), - past: [], - future: [], - selection: null, - draftAnnotation: null, - }), - setAnnotationUpdatedAt: (updatedAt) => set({ annotationUpdatedAt: updatedAt }), - addAnnotation: (annotation) => - set((state) => withHistory(state, cloneAnnotations([...state.annotations, annotation]))), - updateAnnotation: (id, updates) => - set((state) => { - const current = state.annotations.find((annotation) => annotation.id === id); - if (!current) return state; - const next = state.annotations.map((annotation) => - annotation.id === id - ? ({ - ...annotation, - ...updates, - props: updates.props ? { ...annotation.props, ...updates.props } : annotation.props, - updatedAt: new Date().toISOString(), - } as Annotation) - : annotation, - ); - return withHistory(state, next); - }), - deleteAnnotation: (id) => - set((state) => { - if (!state.annotations.some((annotation) => annotation.id === id)) return state; - return { - ...withHistory(state, state.annotations.filter((annotation) => annotation.id !== id)), - selection: state.selection === id ? null : state.selection, - }; - }), - undo: () => - set((state) => { - const previous = state.past.at(-1); - if (!previous) return state; - return { - annotations: cloneAnnotations(previous), - past: state.past.slice(0, -1), - future: [cloneAnnotations(state.annotations), ...state.future].slice(0, MAX_HISTORY), - selection: null, - }; - }), - redo: () => - set((state) => { - const next = state.future[0]; - if (!next) return state; - return { - annotations: cloneAnnotations(next), - past: [...state.past, cloneAnnotations(state.annotations)].slice(-MAX_HISTORY), - future: state.future.slice(1), - selection: null, - }; - }), - canUndo: () => get().past.length > 0, - canRedo: () => get().future.length > 0, + setDocumentId: (id) => set({ documentId: id, defaultTextProps: {}, draftAnnotation: null }), + setAnnotations: (annotations) => set({ annotations }), + addAnnotation: (annotation) => set((state) => ({ + annotations: [...state.annotations, annotation] + })), + updateAnnotation: (id, updates) => set((state) => ({ + annotations: state.annotations.map(a => a.id === id ? { ...a, ...updates } as Annotation : a) + })), + deleteAnnotation: (id) => set((state) => ({ + annotations: state.annotations.filter(a => a.id !== id), + selection: state.selection === id ? null : state.selection + })), setActiveTool: (tool) => set({ activeTool: tool }), setSelection: (id) => set({ selection: id }), - setZoom: (zoom) => set({ zoom: Math.max(0.5, Math.min(3, zoom)) }), + setZoom: (zoom) => set({ zoom }), setSaveStatus: (status) => set({ saveStatus: status }), - setDefaultTextProps: (props) => - set((state) => ({ defaultTextProps: { ...state.defaultTextProps, ...props } })), - setDraftAnnotation: (annotation) => set({ draftAnnotation: annotation }), + setDefaultTextProps: (props) => set((state) => ({ defaultTextProps: { ...state.defaultTextProps, ...props } })), + setDraftAnnotation: (ann) => set({ draftAnnotation: ann }), setIsSignatureModalOpen: (isOpen) => set({ isSignatureModalOpen: isOpen }), setPendingSignatureProps: (props) => set({ pendingSignatureProps: props }), - setPendingImageRef: (image) => set({ pendingImageRef: image }), + setPendingImageRef: (imgRef) => set({ pendingImageRef: imgRef }), setActiveShapeKind: (kind) => set({ activeShapeKind: kind }), })); diff --git a/frontend/src/features/editor/toolbar/EditorToolbar.tsx b/frontend/src/features/editor/toolbar/EditorToolbar.tsx index b67c0df..aeec619 100644 --- a/frontend/src/features/editor/toolbar/EditorToolbar.tsx +++ b/frontend/src/features/editor/toolbar/EditorToolbar.tsx @@ -1,162 +1,161 @@ -import { useRef, useState, type ChangeEvent } from 'react'; -import { - Download, - History, - Highlighter, - ImagePlus, - Minus, - MousePointer2, - PenLine, - Redo2, - Signature, - Square, - Type, - Undo2, - ZoomIn, -} from 'lucide-react'; +import { useRef, useState } from 'react'; +import { useEditorStore } from '../store'; import { SignatureModal } from '../tools/SignatureModal'; import { api } from '../../../lib/api/client'; -import { useEditorStore } from '../store'; -import type { SignatureDrawProps, SignatureTypeProps } from '../../../lib/annotations/types'; -interface EditorToolbarProps { - onExport: () => void; - onOpenVersions: () => void; - isExporting: boolean; -} - -interface AssetUploadResponse { - ref: string; -} - -const toolButton = 'inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-semibold transition-colors'; -const inactive = 'text-neutral-600 hover:bg-neutral-100'; -const active = 'bg-accent-100 text-accent-700'; - -export function EditorToolbar({ onExport, onOpenVersions, isExporting }: EditorToolbarProps) { - const { - activeTool, - setActiveTool, - activeShapeKind, - setActiveShapeKind, - saveStatus, - zoom, +export function EditorToolbar() { + const { + activeTool, + setActiveTool, + saveStatus, + zoom, setZoom, isSignatureModalOpen, setIsSignatureModalOpen, setPendingSignatureProps, setPendingImageRef, - documentId, - past, - future, - undo, - redo, + documentId } = useEditorStore(); const fileInputRef = useRef(null); const [isUploadingImage, setIsUploadingImage] = useState(false); - const handleImageUpload = async (event: ChangeEvent) => { - const file = event.target.files?.[0]; + const handleZoomIn = () => setZoom(Math.min(zoom + 0.25, 3)); + const handleZoomOut = () => setZoom(Math.max(zoom - 0.25, 0.5)); + + const handleImageUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; if (!file || !documentId) return; + setIsUploadingImage(true); try { const formData = new FormData(); formData.append('file', file); - const data = await api.upload(`/documents/${documentId}/assets`, formData); + + const data = await api.upload(`/documents/${documentId}/assets`, formData); + + // We don't have natural width/height immediately, we'll let the tool figure it out or pass placeholders + // A better way is to read image locally to get dimensions, but we can also just let fabric.Image.fromURL do it. + // We'll pass 0 for now and let the tool set scale to max-width. setPendingImageRef({ ref: data.ref, width: 0, height: 0 }); setActiveTool('image'); - } catch (reason) { - console.error('Failed to upload image', reason); - window.alert(reason instanceof Error ? reason.message : 'Failed to upload image.'); + } catch (err) { + console.error(err); + alert('Failed to upload image'); } finally { setIsUploadingImage(false); + // Reset input if (fileInputRef.current) fileInputRef.current.value = ''; } }; - const handleSignatureConfirm = (props: SignatureDrawProps | SignatureTypeProps) => { + const handleSignatureConfirm = (props: any) => { setPendingSignatureProps(props); setIsSignatureModalOpen(false); setActiveTool('signature'); }; - const isActive = (name: string) => activeTool === name ? active : inactive; - return ( <> -
- - - - - - {activeTool === 'shape' && ( - + +
+ +
+ - - void handleImageUpload(event)} /> + - + + + {Math.round(zoom * 100)}% + + +
- - - +
- - - {Math.round(zoom * 100)}% - - - - - - - - {saveStatus === 'loading' && 'Loading…'} - {saveStatus === 'saving' && 'Saving…'} +
+ {saveStatus === 'saving' && 'Saving...'} {saveStatus === 'saved' && 'Saved'} - {saveStatus === 'error' && Save failed} - + {saveStatus === 'error' && Error saving} + {saveStatus === 'idle' && ''} +
{isSignatureModalOpen && ( - setIsSignatureModalOpen(false)} onConfirm={handleSignatureConfirm} /> diff --git a/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx b/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx index 3e7dbee..bfdaf27 100644 --- a/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx +++ b/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx @@ -1,6 +1,4 @@ import { useState, useRef, useEffect } from 'react'; -import * as fabric from 'fabric'; -import type { Canvas } from 'fabric'; import { useEditorStore } from '../store'; import type { TextAnnotation, TextProps } from '../../../lib/annotations/types'; import type { ViewportParams } from '../../../lib/coords'; @@ -19,7 +17,6 @@ import { interface TextFormatToolbarProps { annotationId: string; viewportParams: ViewportParams; - canvas: Canvas; } const FONTS = ['Liberation Sans', 'Outfit', 'Plus Jakarta Sans', 'Arial', 'Times New Roman', 'Courier New']; @@ -27,7 +24,7 @@ const SIZES = [6, 7, 8, 10, 12, 14, 16, 18, 24, 36, 48, 72]; const COLORS = ['#000000', '#EF4444', '#3B82F6', '#10B981', '#F59E0B', '#ffffff']; const HIGHLIGHTS = ['transparent', '#FEF08A', '#BBF7D0', '#BFDBFE', '#FBCFE8', '#000000']; -export function TextFormatToolbar({ annotationId, viewportParams, canvas }: TextFormatToolbarProps) { +export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatToolbarProps) { const { annotations, updateAnnotation, deleteAnnotation, addAnnotation, setDefaultTextProps, draftAnnotation } = useEditorStore(); const [activeDropdown, setActiveDropdown] = useState<'font' | 'size' | 'color' | 'highlight' | null>(null); @@ -54,9 +51,11 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text const top = pt.y - 48; // 48px above const left = pt.x; - const applyStyle = (styleName: string, value: unknown, globalPropName: keyof TextProps, globalValue?: unknown) => { - const activeObj = canvas.getActiveObject() as (fabric.Textbox & { id?: string; customHeight?: number }) | undefined; - if (activeObj) { + const applyStyle = (styleName: string, value: any, globalPropName: keyof TextProps, globalValue?: any) => { + const canvas = (window as any).__fabricCanvas as any; + + if (canvas) { + const activeObj = canvas.getActiveObject(); if (activeObj && activeObj.id === annotationId) { const isStructural = styleName === 'fontSize' || styleName === 'fontFamily'; @@ -82,7 +81,7 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text activeObj.styles = {}; if (activeObj.hiddenTextarea) { if (styleName === 'fontSize') activeObj.hiddenTextarea.style.fontSize = `${value}px`; - if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = String(value); + if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = value; } } } else { @@ -101,7 +100,7 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text for (const line in activeObj.styles) { for (const char in activeObj.styles[line]) { if (activeObj.styles[line][char]) { - delete (activeObj.styles[line][char] as Record)[styleName]; + delete activeObj.styles[line][char][styleName]; } } } @@ -111,8 +110,8 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text // Critical: Fabric 7 heavily caches text. We MUST mark it dirty to force a redraw! activeObj.dirty = true; - if ('_forceClearCache' in activeObj) { - (activeObj as typeof activeObj & { _forceClearCache?: boolean })._forceClearCache = true; + if ((activeObj as any)._forceClearCache !== undefined) { + (activeObj as any)._forceClearCache = true; } // Remove manual height constraint so the box can grow with the new font size @@ -124,7 +123,7 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text } const finalGlobalValue = globalValue !== undefined ? globalValue : value; - const newProps = { [globalPropName]: finalGlobalValue } as Partial; + const newProps = { [globalPropName]: finalGlobalValue } as any; setDefaultTextProps(newProps); // Always update store so the toolbar displays the new value @@ -155,9 +154,12 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text const handleDelete = () => { if (isDraft) { useEditorStore.getState().setDraftAnnotation(null); - const activeObj = canvas.getActiveObject(); - if (activeObj && (activeObj as fabric.FabricObject & { id?: string }).id === annotationId) { - canvas.remove(activeObj); + const canvas = (window as any).__fabricCanvas as any; + if (canvas) { + const activeObj = canvas.getActiveObject(); + if (activeObj && activeObj.id === annotationId) { + canvas.remove(activeObj); + } } } else { deleteAnnotation(annotationId); @@ -218,7 +220,7 @@ export function TextFormatToolbar({ annotationId, viewportParams, canvas }: Text diff --git a/frontend/src/features/editor/tools/DrawTool.ts b/frontend/src/features/editor/tools/DrawTool.ts index 1e1dfe8..ab94d10 100644 --- a/frontend/src/features/editor/tools/DrawTool.ts +++ b/frontend/src/features/editor/tools/DrawTool.ts @@ -6,12 +6,6 @@ import type { Annotation, DrawAnnotation, DrawProps } from '../../../lib/annotat import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords'; import type { ViewportParams } from '../../../lib/coords'; -type FabricAnnotationObject = fabric.FabricObject & { - id?: string; - annotationType?: string; - annotationProps?: DrawProps; -}; - export const DrawTool: ToolHandler = { name: 'draw', @@ -27,7 +21,7 @@ export const DrawTool: ToolHandler = { canvas.isDrawingMode = false; }, - onPathCreated: (e: fabric.CanvasEvents['path:created'], _canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => { + onPathCreated: (e: any, _canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => { const pathObj = e.path as fabric.Path; pathObj.set({ @@ -39,16 +33,27 @@ export const DrawTool: ToolHandler = { padding: 5, }); - const annotatedPath = pathObj as FabricAnnotationObject; - annotatedPath.id = uuidv4(); - annotatedPath.annotationType = 'draw'; + (pathObj as any).id = uuidv4(); + (pathObj as any).annotationType = 'draw'; - // Keep the SVG path for faithful browser re-rendering and also store a - // canonical point list for the server export renderer. + // The path data is stored in `pathObj.path` array of commands, e.g. [['M', x, y], ['Q', cx, cy, x, y]] + // We can serialize it via pathObj.toObject().path or complexPathToString(pathObj.path) + // fabric 7 has toObject().path or you can just rely on the object's serialization + + // Convert paths to string representation const pathStr = Array.isArray(pathObj.path) ? pathObj.path.map(cmd => cmd.join(' ')).join(' ') : pathObj.path; + const props: DrawProps = { + paths: [], // we ignore points array for now and use svgPath + svgPath: pathStr as string, + strokeColor: pathObj.stroke as string, + strokeWidth: pathObj.strokeWidth, + opacity: pathObj.opacity + }; + (pathObj as any).annotationProps = props; + const bounds = pathObj.getBoundingRect(); const pdfRect = screenRectToPdf({ x: bounds.left, @@ -56,36 +61,9 @@ export const DrawTool: ToolHandler = { width: bounds.width, height: bounds.height }, vp); - const rawPoints = Array.isArray(pathObj.path) - ? pathObj.path.flatMap((command) => { - const values = command.slice(1).filter((value): value is number => typeof value === 'number'); - const points: [number, number][] = []; - for (let index = 0; index + 1 < values.length; index += 2) { - points.push([values[index], values[index + 1]]); - } - return points; - }) - : []; - const minX = rawPoints.length ? Math.min(...rawPoints.map(([x]) => x)) : 0; - const maxX = rawPoints.length ? Math.max(...rawPoints.map(([x]) => x)) : 1; - const minY = rawPoints.length ? Math.min(...rawPoints.map(([, y]) => y)) : 0; - const maxY = rawPoints.length ? Math.max(...rawPoints.map(([, y]) => y)) : 1; - const sourceWidth = Math.max(maxX - minX, 1e-6); - const sourceHeight = Math.max(maxY - minY, 1e-6); - const props: DrawProps = { - paths: rawPoints.map(([x, y]) => [ - ((x - minX) / sourceWidth) * pdfRect.width, - ((y - minY) / sourceHeight) * pdfRect.height, - ]), - svgPath: pathStr as string, - strokeColor: pathObj.stroke as string, - strokeWidth: pathObj.strokeWidth, - opacity: pathObj.opacity, - }; - annotatedPath.annotationProps = props; const annotation: DrawAnnotation = { - id: annotatedPath.id as string, + id: (pathObj as any).id, page: pageNumber, type: 'draw', rect: pdfRect, @@ -103,7 +81,7 @@ export const DrawTool: ToolHandler = { }, renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => { - if (annotation.type !== 'draw') return; + if (annotation.type !== 'draw') return null; const drawAnn = annotation as DrawAnnotation; const props = drawAnn.props as DrawProps; const screenRect = pdfRectToScreen(drawAnn.rect, vp); @@ -125,8 +103,6 @@ export const DrawTool: ToolHandler = { pathObj.set({ left: screenRect.x, top: screenRect.y, - originX: 'left', - originY: 'top', scaleX: screenRect.width / pathObj.width!, scaleY: screenRect.height / pathObj.height!, }); @@ -135,8 +111,6 @@ export const DrawTool: ToolHandler = { pathObj = new fabric.Path('M 0 0', { left: screenRect.x, top: screenRect.y, - originX: 'left', - originY: 'top', width: screenRect.width, height: screenRect.height, stroke: props.strokeColor || '#000000', @@ -144,18 +118,17 @@ export const DrawTool: ToolHandler = { }); } - const annotatedPath = pathObj as FabricAnnotationObject; - annotatedPath.id = drawAnn.id; - annotatedPath.annotationType = 'draw'; - annotatedPath.annotationProps = drawAnn.props; pathObj.set({ + id: drawAnn.id, + annotationType: 'draw', + annotationProps: drawAnn.props, transparentCorners: false, cornerColor: '#3b82f6', cornerStrokeColor: '#3b82f6', borderColor: '#3b82f6', cornerSize: 8, padding: 5, - }); + } as any); canvas.add(pathObj); } diff --git a/frontend/src/features/editor/tools/HighlightTool.ts b/frontend/src/features/editor/tools/HighlightTool.ts deleted file mode 100644 index 0827a51..0000000 --- a/frontend/src/features/editor/tools/HighlightTool.ts +++ /dev/null @@ -1,120 +0,0 @@ -import * as fabric from 'fabric'; -import { v4 as uuidv4 } from 'uuid'; -import { useEditorStore } from '../store'; -import type { ToolHandler } from '../../../lib/annotations/registry'; -import type { Annotation, HighlightAnnotation, HighlightProps } from '../../../lib/annotations/types'; -import { pdfRectToScreen, screenRectToPdf } from '../../../lib/coords'; -import type { ViewportParams } from '../../../lib/coords'; - -type PointerEvent = fabric.TPointerEventInfo; -type Draft = { object: fabric.Rect; start: { x: number; y: number } }; -const drafts = new WeakMap(); - -function point(event: PointerEvent, canvas: fabric.Canvas) { - return event.scenePoint ?? canvas.getScenePoint(event.e); -} - -function configure(object: fabric.Rect) { - object.set({ - originX: 'left', - originY: 'top', - fill: '#facc15', - opacity: 0.35, - stroke: undefined, - transparentCorners: false, - cornerColor: '#3b82f6', - cornerStrokeColor: '#3b82f6', - borderColor: '#3b82f6', - cornerSize: 8, - }); -} - -export const HighlightTool: ToolHandler = { - name: 'highlight', - onActivate: (canvas) => { - canvas.defaultCursor = 'crosshair'; - canvas.selection = false; - }, - onDeactivate: (canvas) => { - const draft = drafts.get(canvas); - if (draft) canvas.remove(draft.object); - drafts.delete(canvas); - canvas.defaultCursor = 'default'; - }, - onPointerDown: (event: PointerEvent, canvas) => { - if (event.target) return; - const start = point(event, canvas); - const object = new fabric.Rect({ left: start.x, top: start.y, width: 0, height: 0 }); - configure(object); - drafts.set(canvas, { object, start }); - canvas.add(object); - }, - onPointerMove: (event: PointerEvent, canvas) => { - const draft = drafts.get(canvas); - if (!draft) return; - const current = point(event, canvas); - draft.object.set({ - left: Math.min(draft.start.x, current.x), - top: Math.min(draft.start.y, current.y), - width: Math.abs(current.x - draft.start.x), - height: Math.abs(current.y - draft.start.y), - }); - draft.object.setCoords(); - canvas.requestRenderAll(); - }, - onPointerUp: (event: PointerEvent, canvas, vp: ViewportParams, pageNumber) => { - const draft = drafts.get(canvas); - if (!draft) return; - const current = point(event, canvas); - draft.object.set({ - left: Math.min(draft.start.x, current.x), - top: Math.min(draft.start.y, current.y), - width: Math.abs(current.x - draft.start.x), - height: Math.abs(current.y - draft.start.y), - }); - draft.object.setCoords(); - const bounds = draft.object.getBoundingRect(); - drafts.delete(canvas); - if (bounds.width < 2 || bounds.height < 2) { - canvas.remove(draft.object); - return; - } - const id = uuidv4(); - draft.object.set({ id }); - const props: HighlightProps = { color: '#facc15', opacity: 0.35 }; - useEditorStore.getState().addAnnotation({ - id, - page: pageNumber, - type: 'highlight', - rect: screenRectToPdf( - { x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height }, - vp, - ), - rotation: 0, - z: 0, - props, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as HighlightAnnotation); - canvas.setActiveObject(draft.object); - canvas.requestRenderAll(); - }, - renderToFabric: (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => { - if (annotation.type !== 'highlight') return; - const highlight = annotation as HighlightAnnotation; - const rect = pdfRectToScreen(highlight.rect, vp); - const object = new fabric.Rect({ - left: rect.x, - top: rect.y, - width: rect.width, - height: rect.height, - }); - configure(object); - object.set({ - id: highlight.id, - fill: highlight.props.color, - opacity: highlight.props.opacity, - }); - canvas.add(object); - }, -}; diff --git a/frontend/src/features/editor/tools/ImageTool.ts b/frontend/src/features/editor/tools/ImageTool.ts index 9d791a3..1bb4da0 100644 --- a/frontend/src/features/editor/tools/ImageTool.ts +++ b/frontend/src/features/editor/tools/ImageTool.ts @@ -6,12 +6,6 @@ import type { Annotation, ImageAnnotation, ImageProps } from '../../../lib/annot import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords'; import type { ViewportParams } from '../../../lib/coords'; -type FabricAnnotationObject = fabric.FabricObject & { - id?: string; - annotationType?: string; - annotationProps?: ImageProps; -}; - export const ImageTool: ToolHandler = { name: 'image', @@ -24,7 +18,7 @@ export const ImageTool: ToolHandler = { canvas.defaultCursor = 'default'; }, - onPointerDown: async (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => { + onPointerDown: async (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => { if (e.target) return; const storeState = useEditorStore.getState(); @@ -51,8 +45,8 @@ export const ImageTool: ToolHandler = { img.set({ left: pointer.x, top: pointer.y, - originX: 'left', - originY: 'top', + originX: 'center', + originY: 'center', transparentCorners: false, cornerColor: '#3b82f6', cornerStrokeColor: '#3b82f6', @@ -61,16 +55,15 @@ export const ImageTool: ToolHandler = { padding: 5, }); - const annotatedImage = img as FabricAnnotationObject; - annotatedImage.id = uuidv4(); - annotatedImage.annotationType = 'image'; + (img as any).id = uuidv4(); + (img as any).annotationType = 'image'; const props: ImageProps = { ref: pendingImage.ref, naturalWidth: pendingImage.width, naturalHeight: pendingImage.height }; - annotatedImage.annotationProps = props; + (img as any).annotationProps = props; canvas.add(img); canvas.setActiveObject(img); @@ -85,7 +78,7 @@ export const ImageTool: ToolHandler = { }, vp); const annotation: ImageAnnotation = { - id: annotatedImage.id as string, + id: (img as any).id, page: pageNumber, type: 'image', rect: pdfRect, @@ -104,7 +97,7 @@ export const ImageTool: ToolHandler = { }, renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => { - if (annotation.type !== 'image') return; + if (annotation.type !== 'image') return null; const imgAnn = annotation as ImageAnnotation; const screenRect = pdfRectToScreen(imgAnn.rect, vp); @@ -114,27 +107,24 @@ export const ImageTool: ToolHandler = { img = await fabric.Image.fromURL(url); } catch (e) { console.error("Failed to load asset", e); - return; + return null; } - const annotatedImage = img as FabricAnnotationObject; - annotatedImage.id = imgAnn.id; - annotatedImage.annotationType = 'image'; - annotatedImage.annotationProps = imgAnn.props; img.set({ left: screenRect.x, top: screenRect.y, - originX: 'left', - originY: 'top', scaleX: screenRect.width / img.width!, scaleY: screenRect.height / img.height!, + id: imgAnn.id, + annotationType: 'image', + annotationProps: imgAnn.props, transparentCorners: false, cornerColor: '#3b82f6', cornerStrokeColor: '#3b82f6', borderColor: '#3b82f6', cornerSize: 8, padding: 5, - }); + } as any); canvas.add(img); } diff --git a/frontend/src/features/editor/tools/ShapeTool.ts b/frontend/src/features/editor/tools/ShapeTool.ts deleted file mode 100644 index 0e0e628..0000000 --- a/frontend/src/features/editor/tools/ShapeTool.ts +++ /dev/null @@ -1,210 +0,0 @@ -import * as fabric from 'fabric'; -import { v4 as uuidv4 } from 'uuid'; -import { useEditorStore } from '../store'; -import type { ToolHandler } from '../../../lib/annotations/registry'; -import type { Annotation, ShapeAnnotation, ShapeProps } from '../../../lib/annotations/types'; -import { pdfRectToScreen, screenRectToPdf } from '../../../lib/coords'; -import type { ViewportParams } from '../../../lib/coords'; - -type PointerEvent = fabric.TPointerEventInfo; -type Draft = { object: fabric.FabricObject; start: { x: number; y: number }; kind: ShapeProps['kind'] }; -const drafts = new WeakMap(); - -function pointer(event: PointerEvent, canvas: fabric.Canvas) { - return event.scenePoint ?? canvas.getScenePoint(event.e); -} - -function style(object: fabric.FabricObject) { - object.set({ - originX: 'left', - originY: 'top', - transparentCorners: false, - cornerColor: '#3b82f6', - cornerStrokeColor: '#3b82f6', - borderColor: '#3b82f6', - cornerSize: 8, - }); -} - -function arrowGeometry(start: { x: number; y: number }, end: { x: number; y: number }) { - const angle = Math.atan2(end.y - start.y, end.x - start.x); - const length = Math.min(12, Math.max(5, Math.hypot(end.x - start.x, end.y - start.y) * 0.2)); - const left = { - x: end.x - length * Math.cos(angle - Math.PI / 6), - y: end.y - length * Math.sin(angle - Math.PI / 6), - }; - const right = { - x: end.x - length * Math.cos(angle + Math.PI / 6), - y: end.y - length * Math.sin(angle + Math.PI / 6), - }; - const minX = Math.min(start.x, end.x, left.x, right.x); - const minY = Math.min(start.y, end.y, left.y, right.y); - const point = (value: { x: number; y: number }) => `${value.x - minX} ${value.y - minY}`; - return { - left: minX, - top: minY, - path: `M ${point(start)} L ${point(end)} M ${point(end)} L ${point(left)} M ${point(end)} L ${point(right)}`, - }; -} - -function createArrow(start: { x: number; y: number }, end: { x: number; y: number }) { - const geometry = arrowGeometry(start, end); - return new fabric.Path(geometry.path, { - left: geometry.left, - top: geometry.top, - fill: 'transparent', - stroke: '#111827', - strokeWidth: 2, - originX: 'left', - originY: 'top', - }); -} - -function createObject(kind: ShapeProps['kind'], start: { x: number; y: number }) { - const common = { left: start.x, top: start.y, fill: 'transparent', stroke: '#111827', strokeWidth: 2 }; - if (kind === 'ellipse') return new fabric.Ellipse({ ...common, rx: 0, ry: 0 }); - if (kind === 'arrow') return createArrow(start, start); - if (kind === 'line') return new fabric.Line([0, 0, 0, 0], common); - return new fabric.Rect({ ...common, width: 0, height: 0 }); -} - -function updateObject(draft: Draft, current: { x: number; y: number }, canvas: fabric.Canvas) { - const { object, start, kind } = draft; - if (kind === 'arrow') { - const replacement = createArrow(start, current); - style(replacement); - canvas.remove(object); - canvas.add(replacement); - draft.object = replacement; - return; - } - const width = current.x - start.x; - const height = current.y - start.y; - if (kind === 'line') { - object.set({ x2: width, y2: height }); - } else { - object.set({ - left: Math.min(start.x, current.x), - top: Math.min(start.y, current.y), - ...(kind === 'ellipse' - ? { rx: Math.abs(width) / 2, ry: Math.abs(height) / 2 } - : { width: Math.abs(width), height: Math.abs(height) }), - }); - } - object.setCoords(); -} - -export const ShapeTool: ToolHandler = { - name: 'shape', - onActivate: (canvas) => { - canvas.defaultCursor = 'crosshair'; - canvas.selection = false; - }, - onDeactivate: (canvas) => { - const draft = drafts.get(canvas); - if (draft) canvas.remove(draft.object); - drafts.delete(canvas); - canvas.defaultCursor = 'default'; - }, - onPointerDown: (event: PointerEvent, canvas) => { - if (event.target) return; - const start = pointer(event, canvas); - const kind = useEditorStore.getState().activeShapeKind; - const object = createObject(kind, start); - style(object); - drafts.set(canvas, { object, start, kind }); - canvas.add(object); - }, - onPointerMove: (event: PointerEvent, canvas) => { - const draft = drafts.get(canvas); - if (!draft) return; - updateObject(draft, pointer(event, canvas), canvas); - canvas.requestRenderAll(); - }, - onPointerUp: (event: PointerEvent, canvas, vp: ViewportParams, pageNumber) => { - const draft = drafts.get(canvas); - if (!draft) return; - updateObject(draft, pointer(event, canvas), canvas); - const bounds = draft.object.getBoundingRect(); - drafts.delete(canvas); - if (bounds.width < 2 && bounds.height < 2) { - canvas.remove(draft.object); - return; - } - const id = uuidv4(); - draft.object.set({ id }); - const props: ShapeProps = { - kind: draft.kind, - strokeColor: '#111827', - fillColor: 'transparent', - strokeWidth: 2, - }; - if (draft.kind === 'line' || draft.kind === 'arrow') { - const width = Math.max(bounds.width, 1); - const height = Math.max(bounds.height, 1); - const current = pointer(event, canvas); - props.start = [ - (draft.start.x - bounds.left) / width, - (draft.start.y - bounds.top) / height, - ]; - props.end = [ - (current.x - bounds.left) / width, - (current.y - bounds.top) / height, - ]; - } - useEditorStore.getState().addAnnotation({ - id, - page: pageNumber, - type: 'shape', - rect: screenRectToPdf( - { x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height }, - vp, - ), - rotation: 0, - z: 0, - props, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as ShapeAnnotation); - canvas.setActiveObject(draft.object); - canvas.requestRenderAll(); - }, - renderToFabric: (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => { - if (annotation.type !== 'shape') return; - const shape = annotation as ShapeAnnotation; - const rect = pdfRectToScreen(shape.rect, vp); - const props = shape.props; - const common = { - fill: props.fillColor === 'transparent' ? 'transparent' : props.fillColor, - stroke: props.strokeColor, - strokeWidth: props.strokeWidth, - }; - let object: fabric.FabricObject; - if (props.kind === 'ellipse') { - object = new fabric.Ellipse({ ...common, left: rect.x, top: rect.y, rx: rect.width / 2, ry: rect.height / 2 }); - } else if (props.kind === 'line') { - const start = props.start ?? [0, 0]; - const end = props.end ?? [1, 1]; - const startPoint = { x: rect.x + start[0] * rect.width, y: rect.y + start[1] * rect.height }; - const endPoint = { x: rect.x + end[0] * rect.width, y: rect.y + end[1] * rect.height }; - const left = Math.min(startPoint.x, endPoint.x); - const top = Math.min(startPoint.y, endPoint.y); - object = new fabric.Line( - [startPoint.x - left, startPoint.y - top, endPoint.x - left, endPoint.y - top], - { ...common, left, top }, - ); - } else if (props.kind === 'arrow') { - const start = props.start ?? [0, 0]; - const end = props.end ?? [1, 1]; - const startPoint = { x: rect.x + start[0] * rect.width, y: rect.y + start[1] * rect.height }; - const endPoint = { x: rect.x + end[0] * rect.width, y: rect.y + end[1] * rect.height }; - object = createArrow(startPoint, endPoint); - object.set(common); - } else { - object = new fabric.Rect({ ...common, left: rect.x, top: rect.y, width: rect.width, height: rect.height }); - } - style(object); - object.set({ id: shape.id }); - canvas.add(object); - }, -}; diff --git a/frontend/src/features/editor/tools/SignatureModal.tsx b/frontend/src/features/editor/tools/SignatureModal.tsx index b9543e8..e97ba3e 100644 --- a/frontend/src/features/editor/tools/SignatureModal.tsx +++ b/frontend/src/features/editor/tools/SignatureModal.tsx @@ -17,10 +17,6 @@ const FONTS = [ 'Caveat' ]; -interface AssetUploadResponse { - ref: string; -} - export function SignatureModal({ onClose, onConfirm }: SignatureModalProps) { const [tab, setTab] = useState<'type' | 'draw'>('type'); const [text, setText] = useState('John Doe'); @@ -120,7 +116,7 @@ export function SignatureModal({ onClose, onConfirm }: SignatureModalProps) { const formData = new FormData(); formData.append('file', blob, 'signature.png'); - const data = await api.upload(`/documents/${documentId}/assets`, formData); + const data = await api.upload(`/documents/${documentId}/assets`, formData); onConfirm({ mode: 'draw', diff --git a/frontend/src/features/editor/tools/SignatureTool.ts b/frontend/src/features/editor/tools/SignatureTool.ts index 855c075..4fd06e6 100644 --- a/frontend/src/features/editor/tools/SignatureTool.ts +++ b/frontend/src/features/editor/tools/SignatureTool.ts @@ -6,18 +6,11 @@ import type { Annotation, SignatureAnnotation, SignatureDrawProps, SignatureType import { screenRectToPdf, pdfRectToScreen } from '../../../lib/coords'; import type { ViewportParams } from '../../../lib/coords'; -type SignatureProps = SignatureDrawProps | SignatureTypeProps; -type FabricSignatureObject = fabric.FabricObject & { - id?: string; - annotationType?: string; - annotationProps?: SignatureProps; -}; - let previewObj: fabric.FabricObject | null = null; let currentPreviewCanvas: fabric.Canvas | null = null; let isCreatingPreview = false; -const createPreview = async (canvas: fabric.Canvas, vp: ViewportParams, props: SignatureProps) => { +const createPreview = async (canvas: fabric.Canvas, vp: ViewportParams, props: any) => { isCreatingPreview = true; const storeState = useEditorStore.getState(); if (props.mode === 'draw') { @@ -30,13 +23,11 @@ const createPreview = async (canvas: fabric.Canvas, vp: ViewportParams, props: S } img.set({ originX: 'center', originY: 'center', opacity: 0.5, evented: false }); previewObj = img; - } catch { - previewObj = null; - } + } catch (e) {} } else { previewObj = new fabric.Text(props.text, { fontFamily: props.fontFamily, - fontSize: 48 * vp.scale, + fontSize: 48 * vp.scale * (vp.dpr || 1), fill: props.color, originX: 'center', originY: 'center', @@ -70,7 +61,7 @@ export const SignatureTool: ToolHandler = { isCreatingPreview = false; }, - onPointerMove: (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams) => { + onPointerMove: (e: any, canvas: fabric.Canvas, vp: ViewportParams, _pageNumber: number) => { const props = useEditorStore.getState().pendingSignatureProps; if (!props) return; @@ -95,7 +86,7 @@ export const SignatureTool: ToolHandler = { } }, - onPointerDown: async (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => { + onPointerDown: async (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => { if (e.target && e.target !== previewObj) return; const storeState = useEditorStore.getState(); @@ -129,7 +120,7 @@ export const SignatureTool: ToolHandler = { originY: 'center', }); // We set id on the object so it can be identified - (img as FabricSignatureObject).id = uuidv4(); + (img as any).id = uuidv4(); fabricObj = img; } catch (err) { console.error("Failed to load signature image", err); @@ -141,12 +132,12 @@ export const SignatureTool: ToolHandler = { left: pointer.x, top: pointer.y, fontFamily: typeProps.fontFamily, - fontSize: 48 * vp.scale, + fontSize: 48 * vp.scale * (vp.dpr || 1), fill: typeProps.color, originX: 'center', originY: 'center', }); - (textObj as FabricSignatureObject).id = uuidv4(); + (textObj as any).id = uuidv4(); fabricObj = textObj; } @@ -159,9 +150,8 @@ export const SignatureTool: ToolHandler = { padding: 5, }); - const annotatedObject = fabricObj as FabricSignatureObject; - annotatedObject.annotationType = 'signature'; - annotatedObject.annotationProps = props; + (fabricObj as any).annotationType = 'signature'; + (fabricObj as any).annotationProps = props; canvas.add(fabricObj); canvas.setActiveObject(fabricObj); @@ -177,7 +167,7 @@ export const SignatureTool: ToolHandler = { }, vp); const annotation: SignatureAnnotation = { - id: annotatedObject.id as string, + id: (fabricObj as any).id, page: pageNumber, type: 'signature', rect: pdfRect, @@ -196,7 +186,7 @@ export const SignatureTool: ToolHandler = { }, renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => { - if (annotation.type !== 'signature') return; + if (annotation.type !== 'signature') return null; const sigAnn = annotation as SignatureAnnotation; const screenRect = pdfRectToScreen(sigAnn.rect, vp); @@ -209,23 +199,19 @@ export const SignatureTool: ToolHandler = { img.set({ left: screenRect.x, top: screenRect.y, - originX: 'left', - originY: 'top', scaleX: screenRect.width / img.width!, scaleY: screenRect.height / img.height!, }); fabricObj = img; } catch (e) { console.error("Failed to load signature asset", e); - return; + return null; } } else { const typeProps = sigAnn.props as SignatureTypeProps; fabricObj = new fabric.Text(typeProps.text, { left: screenRect.x, top: screenRect.y, - originX: 'left', - originY: 'top', fontFamily: typeProps.fontFamily, fill: typeProps.color, }); @@ -236,18 +222,17 @@ export const SignatureTool: ToolHandler = { }); } - const annotatedObject = fabricObj as FabricSignatureObject; - annotatedObject.id = sigAnn.id; - annotatedObject.annotationType = 'signature'; - annotatedObject.annotationProps = sigAnn.props; fabricObj.set({ + id: sigAnn.id, + annotationType: 'signature', + annotationProps: sigAnn.props, transparentCorners: false, cornerColor: '#3b82f6', cornerStrokeColor: '#3b82f6', borderColor: '#3b82f6', cornerSize: 8, padding: 5, - }); + } as any); canvas.add(fabricObj); } diff --git a/frontend/src/features/editor/tools/TextTool.ts b/frontend/src/features/editor/tools/TextTool.ts index 4fd31e0..a5acf4e 100644 --- a/frontend/src/features/editor/tools/TextTool.ts +++ b/frontend/src/features/editor/tools/TextTool.ts @@ -3,14 +3,9 @@ import { v4 as uuidv4 } from 'uuid'; import { useEditorStore } from '../store'; import type { ToolHandler } from '../../../lib/annotations/registry'; import type { Annotation, TextAnnotation } from '../../../lib/annotations/types'; -import { pdfRectToScreen, screenRectToPdf } from '../../../lib/coords'; +import { screenToPdf, pdfRectToScreen } from '../../../lib/coords'; import type { ViewportParams } from '../../../lib/coords'; -type FabricTextObject = fabric.Textbox & { - id?: string; - customHeight?: number; -}; - export const TextTool: ToolHandler = { name: 'text', @@ -23,7 +18,7 @@ export const TextTool: ToolHandler = { canvas.defaultCursor = 'default'; }, - onPointerDown: (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => { + onPointerDown: (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => { // If we clicked on an existing object, don't create a new one if (e.target) return; @@ -44,7 +39,7 @@ export const TextTool: ToolHandler = { top: pointer.y, width: 150 * vp.scale, fontFamily: fontFamily, - fontSize: fontSize * vp.scale, + fontSize: fontSize * vp.scale * (vp.dpr || 1), fontWeight: isBold ? 'bold' : 'normal', fontStyle: isItalic ? 'italic' : 'normal', fill: color, @@ -60,13 +55,12 @@ export const TextTool: ToolHandler = { }); // Allow manual height control - const annotatedTextbox = textbox as FabricTextObject; - annotatedTextbox.customHeight = textbox.height; + (textbox as any).customHeight = textbox.height; const originalInitDimensions = textbox.initDimensions.bind(textbox); textbox.initDimensions = function() { originalInitDimensions(); - if ((this as FabricTextObject).customHeight !== undefined) { - this.height = (this as FabricTextObject).customHeight as number; + if ((this as any).customHeight !== undefined) { + this.height = (this as any).customHeight; } }; @@ -100,7 +94,7 @@ export const TextTool: ToolHandler = { const w = textbox.width! * textbox.scaleX!; const h = textbox.height! * textbox.scaleY!; - annotatedTextbox.customHeight = h; + (textbox as any).customHeight = h; textbox.set({ width: w, @@ -112,27 +106,24 @@ export const TextTool: ToolHandler = { }); const newId = uuidv4(); - annotatedTextbox.id = newId; + (textbox as any).id = newId; canvas.add(textbox); canvas.setActiveObject(textbox); textbox.enterEditing(); // Add to store immediately so the toolbar shows up instantly - const initialBounds = textbox.getBoundingRect(); + const pt = screenToPdf({ x: textbox.left!, y: textbox.top! }, vp); const newAnn: TextAnnotation = { id: newId, page: pageNumber, type: 'text', - rect: screenRectToPdf( - { - x: initialBounds.left, - y: initialBounds.top, - width: initialBounds.width, - height: initialBounds.height, - }, - vp, - ), + rect: { + x: pt.x, + y: pt.y, + width: textbox.width! / (vp.scale * (vp.dpr || 1)), + height: textbox.height! / (vp.scale * (vp.dpr || 1)) + }, rotation: 0, z: 0, createdAt: new Date().toISOString(), @@ -152,49 +143,41 @@ export const TextTool: ToolHandler = { storeState.setDraftAnnotation(newAnn); textbox.on('editing:exited', () => { + // Clear draft + useEditorStore.getState().setDraftAnnotation(null); + if (!textbox.text || textbox.text.trim() === '') { - useEditorStore.getState().setDraftAnnotation(null); canvas.remove(textbox); return; } - const state = useEditorStore.getState(); - const currentAnn = (state.annotations.find(a => a.id === newId) as TextAnnotation | undefined) - ?? state.draftAnnotation as TextAnnotation | null - ?? newAnn; - const bounds = textbox.getBoundingRect(); - const nextAnnotation: TextAnnotation = { - ...currentAnn, - rect: screenRectToPdf( - { x: bounds.left, y: bounds.top, width: bounds.width, height: bounds.height }, - vp, - ), - props: { ...currentAnn.props, text: textbox.text }, - updatedAt: new Date().toISOString(), - }; + // Get fresh annotation in case it was modified (e.g. bold/italic via toolbar) + const currentAnn = (useEditorStore.getState().annotations.find(a => a.id === newId) as TextAnnotation) || newAnn; + + // Update the text and push to main store + currentAnn.props.text = textbox.text; // Save inline styles if any exist - if (textbox.styles && Object.keys(textbox.styles).length > 0) { - nextAnnotation.props.styles = JSON.parse(JSON.stringify(textbox.styles)); + if ((textbox as any).styles && Object.keys((textbox as any).styles).length > 0) { + currentAnn.props.styles = JSON.parse(JSON.stringify((textbox as any).styles)); } - state.setDraftAnnotation(null); - state.addAnnotation(nextAnnotation); + useEditorStore.getState().addAnnotation(currentAnn); }); }, renderToFabric: (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => { const textAnn = annotation as TextAnnotation; const pt = pdfRectToScreen(textAnn.rect, vp); - const boxHeight = textAnn.rect.height * vp.scale; + const boxHeight = textAnn.rect.height * vp.scale * (vp.dpr || 1); const textbox = new fabric.Textbox(textAnn.props.text, { left: pt.x, top: pt.y, - width: textAnn.rect.width * vp.scale, + width: textAnn.rect.width * vp.scale * (vp.dpr || 1), height: boxHeight, fontFamily: textAnn.props.fontFamily, - fontSize: textAnn.props.fontSize * vp.scale, + fontSize: textAnn.props.fontSize * vp.scale * (vp.dpr || 1), fontWeight: textAnn.props.bold ? 'bold' : 'normal', fontStyle: textAnn.props.italic ? 'italic' : 'normal', fill: textAnn.props.color, @@ -211,13 +194,12 @@ export const TextTool: ToolHandler = { }); // Allow manual height control - const annotatedTextbox = textbox as FabricTextObject; - annotatedTextbox.customHeight = boxHeight; + (textbox as any).customHeight = boxHeight; const originalInitDimensions = textbox.initDimensions.bind(textbox); textbox.initDimensions = function() { originalInitDimensions(); - if ((this as FabricTextObject).customHeight !== undefined) { - this.height = (this as FabricTextObject).customHeight as number; + if ((this as any).customHeight !== undefined) { + this.height = (this as any).customHeight; } }; @@ -250,7 +232,7 @@ export const TextTool: ToolHandler = { const w = textbox.width! * textbox.scaleX!; const h = textbox.height! * textbox.scaleY!; - annotatedTextbox.customHeight = h; + (textbox as any).customHeight = h; textbox.set({ width: w, @@ -266,16 +248,16 @@ export const TextTool: ToolHandler = { const currentAnn = useEditorStore.getState().annotations.find(a => a.id === annotation.id) as TextAnnotation | undefined; if (!currentAnn) return; - const updates: Partial = { text: textbox.text }; - if (textbox.styles && Object.keys(textbox.styles).length > 0) { - updates.styles = JSON.parse(JSON.stringify(textbox.styles)) as Record; + const updates: any = { text: textbox.text }; + if ((textbox as any).styles && Object.keys((textbox as any).styles).length > 0) { + updates.styles = JSON.parse(JSON.stringify((textbox as any).styles)); } useEditorStore.getState().updateAnnotation(annotation.id, { props: { ...currentAnn.props, ...updates } }); }); - annotatedTextbox.id = annotation.id; + (textbox as any).id = annotation.id; canvas.add(textbox); } }; diff --git a/frontend/src/features/editor/tools/index.ts b/frontend/src/features/editor/tools/index.ts index b2979cf..8e7d44d 100644 --- a/frontend/src/features/editor/tools/index.ts +++ b/frontend/src/features/editor/tools/index.ts @@ -3,16 +3,12 @@ import { TextTool } from './TextTool'; import { SignatureTool } from './SignatureTool'; import { ImageTool } from './ImageTool'; import { DrawTool } from './DrawTool'; -import { HighlightTool } from './HighlightTool'; -import { ShapeTool } from './ShapeTool'; // Register all tools here registerTool(TextTool); registerTool(SignatureTool); registerTool(ImageTool); registerTool(DrawTool); -registerTool(HighlightTool); -registerTool(ShapeTool); // SelectTool implementation registerTool({ diff --git a/frontend/src/features/editor/useAutosave.ts b/frontend/src/features/editor/useAutosave.ts index b2d11c8..5f060d6 100644 --- a/frontend/src/features/editor/useAutosave.ts +++ b/frontend/src/features/editor/useAutosave.ts @@ -1,135 +1,75 @@ -import { useCallback, useEffect, useRef } from 'react'; -import { api, ApiRequestError } from '../../lib/api/client'; -import type { Annotation } from '../../lib/annotations/types'; +import { useEffect, useRef } from 'react'; import { useEditorStore } from './store'; +import type { Annotation } from '../../lib/annotations/types'; -interface AnnotationResponse { - data: Annotation[]; - updatedAt: string; -} - -interface AnnotationUpdateResponse { - updatedAt: string; -} - -export function useAutosave(documentId: string | undefined) { - const loadedDocumentId = useRef(null); - const skipNextSave = useRef(false); +export function useAutosave() { + const { documentId, annotations, setSaveStatus, setAnnotations } = useEditorStore(); const timeoutRef = useRef(null); - const saveGeneration = useRef(0); - const setDocumentId = useEditorStore((state) => state.setDocumentId); - const setAnnotations = useEditorStore((state) => state.setAnnotations); - const setAnnotationUpdatedAt = useEditorStore((state) => state.setAnnotationUpdatedAt); - const setSaveStatus = useEditorStore((state) => state.setSaveStatus); - const storeDocumentId = useEditorStore((state) => state.documentId); - const annotations = useEditorStore((state) => state.annotations); + const isFirstLoad = useRef(true); + // Initial load useEffect(() => { - saveGeneration.current += 1; - if (timeoutRef.current !== null) { - window.clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - setDocumentId(documentId ?? null); - loadedDocumentId.current = null; - skipNextSave.current = true; - }, [documentId, setDocumentId]); + if (!documentId) return; - useEffect(() => { - if (!documentId || storeDocumentId !== documentId) return; let active = true; - setSaveStatus('loading'); async function load() { try { - const response = await api.get(`/documents/${documentId}/annotations`); - if (!active || useEditorStore.getState().documentId !== documentId) return; - skipNextSave.current = true; - setAnnotations(response.data); - setAnnotationUpdatedAt(response.updatedAt); - loadedDocumentId.current = documentId; - setSaveStatus('saved'); - } catch (error) { - if (active && useEditorStore.getState().documentId === documentId) { - console.error('Error loading annotations:', error); - setSaveStatus('error'); + const res = await fetch(`/api/v1/documents/${documentId}/annotations`); + if (!res.ok) throw new Error('Failed to load annotations'); + const data = await res.json(); + + if (active) { + setAnnotations(data.data as Annotation[]); + setSaveStatus('saved'); + isFirstLoad.current = false; } + } catch (err) { + console.error('Error loading annotations:', err); + if (active) setSaveStatus('error'); } } - void load(); - return () => { - active = false; - }; - }, [documentId, setAnnotationUpdatedAt, setAnnotations, setSaveStatus, storeDocumentId]); + load(); + + return () => { active = false; }; + }, [documentId, setAnnotations, setSaveStatus]); + + // Debounced save + useEffect(() => { + // Don't save on the initial load! + if (isFirstLoad.current) return; + if (!documentId) return; - const flush = useCallback(async () => { - if (!documentId || useEditorStore.getState().documentId !== documentId || loadedDocumentId.current !== documentId) { - return false; - } - if (timeoutRef.current !== null) { - window.clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - const generation = ++saveGeneration.current; - const snapshot = useEditorStore.getState(); - const payload = { - data: snapshot.annotations, - baseUpdatedAt: snapshot.annotationUpdatedAt ?? undefined, - }; setSaveStatus('saving'); - try { - const response = await api.put(`/documents/${documentId}/annotations`, payload) - .catch(async (error: unknown) => { - if (error instanceof ApiRequestError && error.status === 409) { - return api.put(`/documents/${documentId}/annotations`, { - data: snapshot.annotations, - }); - } - throw error; + if (timeoutRef.current) { + window.clearTimeout(timeoutRef.current); + } + + timeoutRef.current = window.setTimeout(async () => { + try { + const res = await fetch(`/api/v1/documents/${documentId}/annotations`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: JSON.stringify({ + data: annotations + }) }); - if (saveGeneration.current === generation && useEditorStore.getState().documentId === documentId) { - setAnnotationUpdatedAt(response.updatedAt); + + if (!res.ok) throw new Error('Failed to save'); setSaveStatus('saved'); - } - return true; - } catch (error) { - if (saveGeneration.current === generation && useEditorStore.getState().documentId === documentId) { - console.error('Error saving annotations:', error); + } catch (err) { + console.error('Error saving annotations:', err); setSaveStatus('error'); } - return false; - } - }, [documentId, setAnnotationUpdatedAt, setSaveStatus]); - - useEffect(() => { - if (!documentId || storeDocumentId !== documentId || loadedDocumentId.current !== documentId) return; - if (skipNextSave.current) { - skipNextSave.current = false; - return; - } - - setSaveStatus('saving'); - if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current); - timeoutRef.current = window.setTimeout(() => { - timeoutRef.current = null; - void flush(); - }, 500); + }, 500); // 500ms debounce return () => { - saveGeneration.current += 1; - if (timeoutRef.current !== null) { - window.clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } + if (timeoutRef.current) window.clearTimeout(timeoutRef.current); }; - }, [annotations, documentId, flush, setSaveStatus, storeDocumentId]); - - useEffect(() => () => { - saveGeneration.current += 1; - if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current); - }, []); - - return { flush }; + }, [annotations, documentId, setSaveStatus]); } diff --git a/frontend/src/features/editor/versions/VersionPanel.tsx b/frontend/src/features/editor/versions/VersionPanel.tsx deleted file mode 100644 index 51b044e..0000000 --- a/frontend/src/features/editor/versions/VersionPanel.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { Clock3, RotateCcw, Save, Trash2, X } from 'lucide-react'; -import { api } from '../../../lib/api/client'; -import type { Annotation } from '../../../lib/annotations/types'; -import { useEditorStore } from '../store'; - -interface VersionMeta { - id: string; - documentId: string; - label: string | null; - kind: 'manual' | 'auto'; - createdAt: string; - annotationCount: number; -} - -interface VersionListResponse { - items: VersionMeta[]; -} - -interface AnnotationResponse { - data: Annotation[]; - updatedAt: string; -} - -interface VersionPanelProps { - documentId: string; - onClose: () => void; -} - -function formatVersionDate(value: string) { - return new Intl.DateTimeFormat(undefined, { - dateStyle: 'medium', - timeStyle: 'short', - }).format(new Date(value)); -} - -export function VersionPanel({ documentId, onClose }: VersionPanelProps) { - const [versions, setVersions] = useState([]); - const [label, setLabel] = useState(''); - const [isLoading, setIsLoading] = useState(true); - const [busyId, setBusyId] = useState(null); - const [error, setError] = useState(null); - const setAnnotations = useEditorStore((state) => state.setAnnotations); - const setAnnotationUpdatedAt = useEditorStore((state) => state.setAnnotationUpdatedAt); - - const loadVersions = useCallback(async () => { - setIsLoading(true); - setError(null); - try { - const response = await api.get(`/documents/${documentId}/versions`); - setVersions(response.items); - } catch (reason) { - setError(reason instanceof Error ? reason.message : 'Unable to load checkpoints.'); - } finally { - setIsLoading(false); - } - }, [documentId]); - - useEffect(() => { - const timer = window.setTimeout(() => void loadVersions(), 0); - return () => window.clearTimeout(timer); - }, [loadVersions]); - - const createCheckpoint = async () => { - setBusyId('create'); - setError(null); - try { - await api.post(`/documents/${documentId}/versions`, { - label: label.trim() || null, - kind: 'manual', - }); - setLabel(''); - await loadVersions(); - } catch (reason) { - setError(reason instanceof Error ? reason.message : 'Unable to create checkpoint.'); - } finally { - setBusyId(null); - } - }; - - const restoreVersion = async (version: VersionMeta) => { - if (!window.confirm(`Restore “${version.label || 'Automatic checkpoint'}”? Your current work will be saved first.`)) { - return; - } - setBusyId(version.id); - setError(null); - try { - const restored = await api.post<{ updatedAt: string }>( - `/documents/${documentId}/versions/${version.id}/restore`, - ); - const current = await api.get(`/documents/${documentId}/annotations`); - setAnnotations(current.data); - setAnnotationUpdatedAt(restored.updatedAt || current.updatedAt); - await loadVersions(); - } catch (reason) { - setError(reason instanceof Error ? reason.message : 'Unable to restore checkpoint.'); - } finally { - setBusyId(null); - } - }; - - const deleteVersion = async (version: VersionMeta) => { - if (!window.confirm('Delete this checkpoint permanently?')) return; - setBusyId(version.id); - setError(null); - try { - await api.delete(`/documents/${documentId}/versions/${version.id}`); - setVersions((current) => current.filter((item) => item.id !== version.id)); - } catch (reason) { - setError(reason instanceof Error ? reason.message : 'Unable to delete checkpoint.'); - } finally { - setBusyId(null); - } - }; - - return ( - - ); -} diff --git a/frontend/src/features/library/DocumentGrid.tsx b/frontend/src/features/library/DocumentGrid.tsx index b940699..7d7604b 100644 --- a/frontend/src/features/library/DocumentGrid.tsx +++ b/frontend/src/features/library/DocumentGrid.tsx @@ -6,7 +6,7 @@ import { formatDistanceToNow } from 'date-fns' import { ContextMenu } from './ContextMenu' export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => { - const { documents, selectedIds, isLoading, error, toggleSelection, deleteDocument, restoreDocument, updateDocument } = useLibrary() + const { documents, selectedIds, isLoading, error, deleteDocument, restoreDocument, updateDocument } = useLibrary() const navigate = useNavigate() const [contextMenu, setContextMenu] = useState<{ x: number, y: number, docId: string } | null>(null) @@ -61,16 +61,6 @@ export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => { isSelected ? 'border-accent-500 shadow-md ring-2 ring-accent-500/50 scale-[1.02]' : 'hover:border-accent-300 dark:hover:border-accent-700' }`} > - {isSelected && (
@@ -137,13 +127,9 @@ export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => { } } }} - onDelete={() => { - if (window.confirm('Move this document to Trash?')) void deleteDocument(contextMenu.docId) - }} + onDelete={() => deleteDocument(contextMenu.docId)} onRestore={() => restoreDocument(contextMenu.docId)} - onHardDelete={() => { - if (window.confirm('Delete this document permanently? This cannot be undone.')) void deleteDocument(contextMenu.docId, true) - }} + onHardDelete={() => deleteDocument(contextMenu.docId, true)} /> )} diff --git a/frontend/src/features/library/LibraryHeader.tsx b/frontend/src/features/library/LibraryHeader.tsx index 62fcaca..27a503c 100644 --- a/frontend/src/features/library/LibraryHeader.tsx +++ b/frontend/src/features/library/LibraryHeader.tsx @@ -94,9 +94,7 @@ export const LibraryHeader = ({ currentTab, onTabChange }: { currentTab: 'librar Restore
void handleDrop(event)}> - void handleFileChange(event)} className="hidden" multiple /> - - {isDragging && ( -
-
-
- - - -
-

Drop PDFs here

-

Release to upload to your library

+
+ + + {/* Invisible overlay that appears when dragging to prevent flickering */} + {isDragging && ( +
+
+
+ + +
+

Drop PDFs here

+

Release to upload to your library

- )} +
+ )} - {children} -
- - ); -}; + {children} +
+ ) +} diff --git a/frontend/src/features/library/upload-context.ts b/frontend/src/features/library/upload-context.ts deleted file mode 100644 index c791a87..0000000 --- a/frontend/src/features/library/upload-context.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createContext, useContext } from 'react'; - -export const UploadPickerContext = createContext<(() => void) | null>(null); - -export function useUploadPicker() { - const openPicker = useContext(UploadPickerContext); - if (!openPicker) throw new Error('useUploadPicker must be used inside UploadArea'); - return openPicker; -} diff --git a/frontend/src/features/library/useLibrary.ts b/frontend/src/features/library/useLibrary.ts index 38e2645..f429d95 100644 --- a/frontend/src/features/library/useLibrary.ts +++ b/frontend/src/features/library/useLibrary.ts @@ -28,17 +28,6 @@ interface LibraryState { updateDocument: (id: string, data: DocumentUpdateRequest) => Promise } -function errorMessage(reason: unknown, fallback = 'Something went wrong.') { - if (reason instanceof Error && reason.message) return reason.message; - if (typeof reason === 'object' && reason !== null && 'error' in reason) { - const error = reason.error; - if (typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string') { - return error.message; - } - } - return fallback; -} - export const useLibrary = create((set, get) => ({ documents: [], total: 0, @@ -67,8 +56,8 @@ export const useLibrary = create((set, get) => ({ try { const data = await libraryApi.getDocuments(query) set({ documents: data.items, total: data.total, isLoading: false }) - } catch (err: unknown) { - set({ error: errorMessage(err), isLoading: false }) + } catch (err: any) { + set({ error: err.error?.message || err.message, isLoading: false }) } }, @@ -77,8 +66,8 @@ export const useLibrary = create((set, get) => ({ try { const data = await libraryApi.getTrash() set({ documents: data.items, total: data.total, isLoading: false }) - } catch (err: unknown) { - set({ error: errorMessage(err), isLoading: false }) + } catch (err: any) { + set({ error: err.error?.message || err.message, isLoading: false }) } }, @@ -92,8 +81,8 @@ export const useLibrary = create((set, get) => ({ isLoading: false })) return newDoc - } catch (err: unknown) { - set({ error: errorMessage(err), isLoading: false }) + } catch (err: any) { + set({ error: err.error?.message || err.message, isLoading: false }) throw err } }, @@ -108,8 +97,8 @@ export const useLibrary = create((set, get) => ({ selectedIds: new Set(Array.from(state.selectedIds).filter(sid => sid !== id)), isLoading: false })) - } catch (err: unknown) { - set({ error: errorMessage(err), isLoading: false }) + } catch (err: any) { + set({ error: err.error?.message || err.message, isLoading: false }) throw err } }, @@ -124,8 +113,8 @@ export const useLibrary = create((set, get) => ({ selectedIds: new Set(Array.from(state.selectedIds).filter(sid => sid !== id)), isLoading: false })) - } catch (err: unknown) { - set({ error: errorMessage(err), isLoading: false }) + } catch (err: any) { + set({ error: err.error?.message || err.message, isLoading: false }) throw err } }, @@ -149,8 +138,8 @@ export const useLibrary = create((set, get) => ({ selectedIds: new Set(), isLoading: false })) - } catch (err: unknown) { - set({ error: errorMessage(err), isLoading: false }) + } catch (err: any) { + set({ error: err.error?.message || err.message, isLoading: false }) throw err } }, @@ -168,8 +157,8 @@ export const useLibrary = create((set, get) => ({ selectedIds: new Set(), isLoading: false })) - } catch (err: unknown) { - set({ error: errorMessage(err), isLoading: false }) + } catch (err: any) { + set({ error: err.error?.message || err.message, isLoading: false }) throw err } }, @@ -184,8 +173,8 @@ export const useLibrary = create((set, get) => ({ selectedIds: new Set(), isLoading: false }) - } catch (err: unknown) { - set({ error: errorMessage(err), isLoading: false }) + } catch (err: any) { + set({ error: err.error?.message || err.message, isLoading: false }) throw err } }, @@ -198,8 +187,8 @@ export const useLibrary = create((set, get) => ({ documents: state.documents.map(d => d.id === id ? updatedDoc : d), isLoading: false })) - } catch (err: unknown) { - set({ error: errorMessage(err), isLoading: false }) + } catch (err: any) { + set({ error: err.error?.message || err.message, isLoading: false }) throw err } } diff --git a/frontend/src/index.css b/frontend/src/index.css index 07e0507..2848b5d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -6,11 +6,6 @@ @import "@fontsource/plus-jakarta-sans/500.css"; @import "@fontsource/plus-jakarta-sans/600.css"; @import "@fontsource/plus-jakarta-sans/700.css"; -@import "@fontsource/allura/400.css"; -@import "@fontsource/caveat/400.css"; -@import "@fontsource/dancing-script/400.css"; -@import "@fontsource/great-vibes/400.css"; -@import "@fontsource/sacramento/400.css"; @import "tailwindcss"; diff --git a/frontend/src/lib/annotations/registry.ts b/frontend/src/lib/annotations/registry.ts index b50fde2..584752c 100644 --- a/frontend/src/lib/annotations/registry.ts +++ b/frontend/src/lib/annotations/registry.ts @@ -17,19 +17,15 @@ export interface ToolHandler { onDeactivate?: (canvas: Canvas) => void; /** Called when the user presses down on the canvas. */ - onPointerDown?: (e: fabric.TPointerEventInfo, canvas: Canvas, viewportParams: ViewportParams, pageNumber: number) => void | Promise; + onPointerDown?: (e: any, canvas: Canvas, viewportParams: ViewportParams, pageNumber: number) => void; /** Called when the user drags the pointer. */ - onPointerMove?: (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => void | Promise; - onPointerUp?: (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => void | Promise; - onPathCreated?: (e: fabric.CanvasEvents['path:created'], canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => void | Promise; + onPointerMove?: (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => void; + onPointerUp?: (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => void; + onPathCreated?: (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => void; // Fabric to/from Annotation serialization - renderToFabric?: ( - annotation: Annotation, - canvas: Canvas, - viewportParams: ViewportParams, - ) => void | Promise; + renderToFabric?: (annotation: Annotation, canvas: Canvas, viewportParams: ViewportParams) => void; } const registry = new Map(); diff --git a/frontend/src/lib/annotations/types.ts b/frontend/src/lib/annotations/types.ts index 4dd7531..be65af5 100644 --- a/frontend/src/lib/annotations/types.ts +++ b/frontend/src/lib/annotations/types.ts @@ -1,125 +1,28 @@ -/** - * Client annotation model. - * - * The API intentionally exposes the working layer as an opaque JSON array so - * newer annotation types can round-trip through an older server. These types - * describe the v1 tools used by this client; the generated API types still - * describe the transport envelope and endpoint shapes. - */ +import type { components } from '../../types/api'; -export interface Rect { - x: number; - y: number; - width: number; - height: number; -} +export type TextProps = components['schemas']['TextProps'] & { highlightColor?: string | null, styles?: Record | null }; -export interface AnnotationBase { - id: string; - page: number; - type: string; - rect: Rect; - rotation?: number; - z?: number; - createdAt: string; - updatedAt: string; -} - -export interface TextProps { - text: string; - fontFamily: string; - fontSize: number; - color: string; - align: 'left' | 'center' | 'right'; - bold: boolean; - italic: boolean; - lineHeight: number; - highlightColor?: string | null; - styles?: Record | null; -} - -export interface TextAnnotation extends AnnotationBase { - type: 'text'; +export type TextAnnotation = Omit & { props: TextProps; -} +}; +export type DrawAnnotation = components['schemas']['DrawAnnotation']; +export type SignatureAnnotation = components['schemas']['SignatureAnnotation']; +export type ImageAnnotation = components['schemas']['ImageAnnotation']; +export type HighlightAnnotation = components['schemas']['HighlightAnnotation']; +export type ShapeAnnotation = components['schemas']['ShapeAnnotation']; -export interface DrawProps { - paths: [number, number][]; - svgPath?: string; - strokeColor: string; - strokeWidth: number; - opacity: number; -} - -export interface DrawAnnotation extends AnnotationBase { - type: 'draw'; - props: DrawProps; -} - -export interface SignatureDrawProps { - mode: 'draw'; - ref: string; - strokeColor?: string; -} - -export interface SignatureTypeProps { - mode: 'type'; - text: string; - fontFamily: string; - color: string; -} - -export interface SignatureAnnotation extends AnnotationBase { - type: 'signature'; - props: SignatureDrawProps | SignatureTypeProps; -} - -export interface ImageProps { - ref: string; - naturalWidth: number; - naturalHeight: number; -} - -export interface ImageAnnotation extends AnnotationBase { - type: 'image'; - props: ImageProps; -} - -export interface HighlightProps { - color: string; - opacity: number; -} - -export interface HighlightAnnotation extends AnnotationBase { - type: 'highlight'; - props: HighlightProps; -} - -export interface ShapeProps { - kind: 'rect' | 'ellipse' | 'line' | 'arrow'; - strokeColor: string; - fillColor: string; - strokeWidth: number; - /** Normalized endpoints, retained for line/arrow direction. */ - start?: [number, number]; - end?: [number, number]; -} - -export interface ShapeAnnotation extends AnnotationBase { - type: 'shape'; - props: ShapeProps; -} - -export interface UnknownAnnotation extends AnnotationBase { - type: string; - props: Record; -} - -export type Annotation = +export type Annotation = | TextAnnotation | DrawAnnotation | SignatureAnnotation | ImageAnnotation | HighlightAnnotation - | ShapeAnnotation - | UnknownAnnotation; + | ShapeAnnotation; + +export type Rect = components['schemas']['Rect']; +export type DrawProps = components['schemas']['DrawProps'] & { svgPath?: string }; +export type SignatureDrawProps = components['schemas']['SignatureDrawProps']; +export type SignatureTypeProps = components['schemas']['SignatureTypeProps']; +export type ImageProps = components['schemas']['ImageProps']; +export type HighlightProps = components['schemas']['HighlightProps']; +export type ShapeProps = components['schemas']['ShapeProps']; diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index e62504c..5dc2e0c 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -30,21 +30,6 @@ export class ApiRequestError extends Error { } } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -function toApiError(data: unknown, statusText: string): ApiError { - const record = isRecord(data) ? data : {}; - const nested = isRecord(record.error) ? record.error : {}; - return { - code: typeof nested.code === 'string' ? nested.code : 'unknown', - message: typeof nested.message === 'string' - ? nested.message - : typeof record.detail === 'string' ? record.detail : statusText, - }; -} - /** * Internal fetch wrapper with shared behavior. */ @@ -90,19 +75,23 @@ async function apiFetch( } // Parse response - let data: unknown = {} + let data: any = {} try { const text = await response.text() if (text) { data = JSON.parse(text) } - } catch { + } catch (err) { // If it's not JSON, we'll just fall back to the empty object } // Handle error responses if (!response.ok) { - throw new ApiRequestError(response.status, toApiError(data, response.statusText)) + const error: ApiError = { + code: data.error?.code ?? 'unknown', + message: data.error?.message ?? data.detail ?? response.statusText, + } + throw new ApiRequestError(response.status, error) } return data as T @@ -169,15 +158,17 @@ export const api = { } if (!response.ok) { - let data: unknown = {} + let data: any = {} try { const text = await response.text() if (text) data = JSON.parse(text) - } catch { - data = {} + } catch (err) {} + + const error: ApiError = { + code: data.error?.code ?? 'unknown', + message: data.error?.message ?? data.detail ?? response.statusText, } - - throw new ApiRequestError(response.status, toApiError(data, response.statusText)) + throw new ApiRequestError(response.status, error) } return response.blob() diff --git a/frontend/src/lib/coords.test.ts b/frontend/src/lib/coords.test.ts index 7e44e79..0d4f37a 100644 --- a/frontend/src/lib/coords.test.ts +++ b/frontend/src/lib/coords.test.ts @@ -92,16 +92,4 @@ describe('Coordinate Transforms', () => { expect(blS.x).toBeCloseTo(0, 2); expect(blS.y).toBeCloseTo(0, 2); }); - - it('keeps CSS geometry independent from device pixel ratio', () => { - const standard = pdfToScreen( - { x: 120, y: 180 }, - { scale: 1.5, rotation: 0, canonicalWidth: W, canonicalHeight: H, dpr: 1 }, - ); - const retina = pdfToScreen( - { x: 120, y: 180 }, - { scale: 1.5, rotation: 0, canonicalWidth: W, canonicalHeight: H, dpr: 2 }, - ); - expect(retina).toEqual(standard); - }); }); diff --git a/frontend/src/lib/coords.ts b/frontend/src/lib/coords.ts index 90bb824..701d530 100644 --- a/frontend/src/lib/coords.ts +++ b/frontend/src/lib/coords.ts @@ -5,18 +5,19 @@ export interface PdfRect { x: number; y: number; width: number; height: number } export interface ScreenRect { x: number; y: number; width: number; height: number } export interface ViewportParams { - scale: number; // CSS zoom level (1.0 = 100%) + scale: number; // CSS Zoom level (1.0 = 100%) rotation: number; // 0, 90, 180, 270 (clockwise) canonicalWidth: number; // Unrotated CropBox width in PDF points canonicalHeight: number;// Unrotated CropBox height in PDF points - dpr?: number; // Backing-store density; never part of CSS geometry + dpr?: number; // Device Pixel Ratio (defaults to 1) +} + +function getEffectiveScale(vp: ViewportParams): number { + return vp.scale * (vp.dpr || 1); } export function pdfToScreen(p: PdfPoint, vp: ViewportParams): ScreenPoint { - // Screen points are CSS pixels. Device-pixel-ratio only controls the PDF.js - // backing canvas; including it here would make Fabric objects overflow the - // CSS overlay on retina displays. - const s = vp.scale; + const s = getEffectiveScale(vp); const { rotation, canonicalWidth: W, canonicalHeight: H } = vp; const { x, y } = p; @@ -32,7 +33,7 @@ export function pdfToScreen(p: PdfPoint, vp: ViewportParams): ScreenPoint { } export function screenToPdf(p: ScreenPoint, vp: ViewportParams): PdfPoint { - const s = vp.scale; + const s = getEffectiveScale(vp); const { rotation, canonicalWidth: W, canonicalHeight: H } = vp; const sx = p.x / s; const sy = p.y / s; diff --git a/frontend/src/pages/EditorPage.tsx b/frontend/src/pages/EditorPage.tsx index a4f5094..11579dd 100644 --- a/frontend/src/pages/EditorPage.tsx +++ b/frontend/src/pages/EditorPage.tsx @@ -1,113 +1,61 @@ -import { useCallback, useEffect, useState } from 'react'; -import { Link, useParams } from 'react-router-dom'; -import { api } from '../lib/api/client'; -import { PdfDocument } from '../features/editor/pages/PdfDocument'; -import { EditorToolbar } from '../features/editor/toolbar/EditorToolbar'; -import { VersionPanel } from '../features/editor/versions/VersionPanel'; -import { useEditorStore } from '../features/editor/store'; -import { useAutosave } from '../features/editor/useAutosave'; -import '../features/editor/tools'; - -interface DocumentSummary { - title: string; -} +import { useParams, Link } from 'react-router-dom' +import { useEffect } from 'react' +import { PdfDocument } from '../features/editor/pages/PdfDocument' +import { EditorToolbar } from '../features/editor/toolbar/EditorToolbar' +import { useEditorStore } from '../features/editor/store' +import { useAutosave } from '../features/editor/useAutosave' +import '../features/editor/tools' +/** + * Editor page — PDF canvas workspace. + */ export function EditorPage() { - const { id } = useParams<{ id: string }>(); - const [title, setTitle] = useState('Untitled document'); - const [isExporting, setIsExporting] = useState(false); - const [exportError, setExportError] = useState(null); - const [isVersionPanelOpen, setIsVersionPanelOpen] = useState(false); - const undo = useEditorStore((state) => state.undo); - const redo = useEditorStore((state) => state.redo); - - const { flush } = useAutosave(id); + const { id } = useParams<{ id: string }>() + const { setDocumentId } = useEditorStore() + useAutosave() useEffect(() => { - if (!id) return; - let active = true; - void api.get(`/documents/${id}`).then((document) => { - if (active) setTitle(document.title); - }).catch(() => { - if (active) setTitle('Untitled document'); - }); - return () => { - active = false; - }; - }, [id]); - - useEffect(() => { - const handleHistoryShortcut = (event: KeyboardEvent) => { - const target = event.target; - if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || (target instanceof HTMLElement && target.isContentEditable)) { - return; - } - if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'z') return; - event.preventDefault(); - if (event.shiftKey) redo(); - else undo(); - }; - document.addEventListener('keydown', handleHistoryShortcut); - return () => document.removeEventListener('keydown', handleHistoryShortcut); - }, [redo, undo]); - - const handleExport = useCallback(async () => { - if (!id) return; - setIsExporting(true); - setExportError(null); - try { - if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); - await new Promise((resolve) => window.setTimeout(resolve, 0)); - const saved = await flush(); - if (!saved && useEditorStore.getState().saveStatus === 'loading') { - throw new Error('Please wait for the annotation layer to finish loading.'); - } - const blob = await api.fetchBlob(`/documents/${id}/export`, { - method: 'POST', - body: JSON.stringify({ flatten: true }), - }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = title.toLowerCase().endsWith('.pdf') ? title : `${title}.pdf`; - document.body.appendChild(link); - link.click(); - link.remove(); - window.setTimeout(() => URL.revokeObjectURL(url), 1000); - } catch (reason) { - const message = reason instanceof Error ? reason.message : 'Unable to export this PDF.'; - setExportError(message); - } finally { - setIsExporting(false); + if (id) { + setDocumentId(id) } - }, [flush, id, title]); + }, [id, setDocumentId]) - if (!id) return
Invalid document ID.
; + if (!id) return
Invalid document ID
return ( -
-
-
- +
+ {/* Editor toolbar */} +
+
+ ← Back -
- {title} + + Document {id} +
-
- {exportError && {exportError}} -
-
- void handleExport()} onOpenVersions={() => setIsVersionPanelOpen(true)} isExporting={isExporting} /> + {/* Main workspace area */} +
+ -
- - {isVersionPanelOpen && setIsVersionPanelOpen(false)} />} +
- ); + ) } diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 9b20c3c..11d9149 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { LibraryHeader } from '../features/library/LibraryHeader' -import { UploadArea, UploadTrigger } from '../features/library/UploadArea' +import { UploadArea } from '../features/library/UploadArea' import { DocumentGrid } from '../features/library/DocumentGrid' import { useLibrary } from '../features/library/useLibrary' @@ -38,12 +38,26 @@ export function HomePage() {

Upload any PDF to instantly annotate, sign, and modify it. Drop your files right here to get started.

- + + { + const uploader = document.querySelector('input[type="file"][multiple]') as HTMLInputElement + if (uploader && uploader !== e.target) { + uploader.files = e.target.files + const event = new Event('change', { bubbles: true }) + uploader.dispatchEvent(event) + } + }} + /> +
{/* Visual Graphic */} diff --git a/frontend/src/types/api.d.ts b/frontend/src/types/api.d.ts index 0d449e2..2f33baf 100644 --- a/frontend/src/types/api.d.ts +++ b/frontend/src/types/api.d.ts @@ -272,46 +272,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/documents/{id}/assets": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Upload Asset - * @description Upload a PNG, JPEG, GIF, or WebP annotation asset. - */ - post: operations["upload_asset_api_v1_documents__id__assets_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/documents/{id}/assets/{ref}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Asset - * @description Serve an annotation asset to an authenticated session. - */ - get: operations["get_asset_api_v1_documents__id__assets__ref__get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/v1/documents/{document_id}/annotations": { parameters: { query?: never; @@ -330,79 +290,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/documents/{document_id}/versions": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** List Versions */ - get: operations["list_versions_api_v1_documents__document_id__versions_get"]; - put?: never; - /** Create Version */ - post: operations["create_version_api_v1_documents__document_id__versions_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/documents/{document_id}/versions/{version_id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get Version */ - get: operations["get_version_api_v1_documents__document_id__versions__version_id__get"]; - put?: never; - post?: never; - /** Delete Version */ - delete: operations["delete_version_api_v1_documents__document_id__versions__version_id__delete"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/documents/{document_id}/versions/{version_id}/restore": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Restore Version */ - post: operations["restore_version_api_v1_documents__document_id__versions__version_id__restore_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/documents/{document_id}/export": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Export Document - * @description Export the working layer or a selected version as a downloadable PDF. - */ - post: operations["export_document_api_v1_documents__document_id__export_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/v1/health": { parameters: { query?: never; @@ -423,6 +310,28 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/debug/verify-coords": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Verify Coords + * @description Test endpoint for cross-engine coordinate verification. + * Takes a canonical rect, draws it on the PDF using PyMuPDF, + * and returns the flattened PDF. + */ + post: operations["verify_coords_api_v1_debug_verify_coords_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -430,9 +339,7 @@ export interface components { /** AnnotationStateResponse */ AnnotationStateResponse: { /** Data */ - data: { - [key: string]: unknown; - }[]; + data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[]; /** * Updatedat * Format: date-time @@ -442,9 +349,7 @@ export interface components { /** AnnotationStateUpdateRequest */ AnnotationStateUpdateRequest: { /** Data */ - data: { - [key: string]: unknown; - }[]; + data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[]; /** Baseupdatedat */ baseUpdatedAt?: string | null; }; @@ -463,11 +368,6 @@ export interface components { /** Loggedin */ loggedIn: boolean; }; - /** Body_upload_asset_api_v1_documents__id__assets_post */ - Body_upload_asset_api_v1_documents__id__assets_post: { - /** File */ - file: string; - }; /** Body_upload_document_api_v1_documents_post */ Body_upload_document_api_v1_documents_post: { /** File */ @@ -525,31 +425,402 @@ export interface components { /** In Trash */ in_trash?: boolean | null; }; - /** ExportRequest */ - ExportRequest: { - /** Versionid */ - versionId?: string | null; + /** DrawAnnotation */ + DrawAnnotation: { /** - * Flatten - * @default true + * Id + * Format: uuid */ - flatten: boolean; + id: string; + /** Page */ + page: number; + /** + * Type + * @constant + */ + type: "draw"; + rect: components["schemas"]["Rect"]; + /** + * Rotation + * @default 0 + */ + rotation: number; + /** + * Z + * @default 0 + */ + z: number; + /** + * Createdat + * Format: date-time + */ + createdAt: string; + /** + * Updatedat + * Format: date-time + */ + updatedAt: string; + props: components["schemas"]["DrawProps"]; + }; + /** DrawProps */ + DrawProps: { + /** Paths */ + paths: [ + number, + number + ][]; + /** + * Strokecolor + * @default #000000 + */ + strokeColor: string; + /** + * Strokewidth + * @default 2 + */ + strokeWidth: number; + /** + * Opacity + * @default 1 + */ + opacity: number; }; /** HTTPValidationError */ HTTPValidationError: { /** Detail */ detail?: components["schemas"]["ValidationError"][]; }; + /** HighlightAnnotation */ + HighlightAnnotation: { + /** + * Id + * Format: uuid + */ + id: string; + /** Page */ + page: number; + /** + * Type + * @constant + */ + type: "highlight"; + rect: components["schemas"]["Rect"]; + /** + * Rotation + * @default 0 + */ + rotation: number; + /** + * Z + * @default 0 + */ + z: number; + /** + * Createdat + * Format: date-time + */ + createdAt: string; + /** + * Updatedat + * Format: date-time + */ + updatedAt: string; + props: components["schemas"]["HighlightProps"]; + }; + /** HighlightProps */ + HighlightProps: { + /** + * Color + * @default #FFEB3B + */ + color: string; + /** + * Opacity + * @default 0.3 + */ + opacity: number; + }; + /** ImageAnnotation */ + ImageAnnotation: { + /** + * Id + * Format: uuid + */ + id: string; + /** Page */ + page: number; + /** + * Type + * @constant + */ + type: "image"; + rect: components["schemas"]["Rect"]; + /** + * Rotation + * @default 0 + */ + rotation: number; + /** + * Z + * @default 0 + */ + z: number; + /** + * Createdat + * Format: date-time + */ + createdAt: string; + /** + * Updatedat + * Format: date-time + */ + updatedAt: string; + props: components["schemas"]["ImageProps"]; + }; + /** ImageProps */ + ImageProps: { + /** Ref */ + ref: string; + /** Naturalwidth */ + naturalWidth: number; + /** Naturalheight */ + naturalHeight: number; + }; /** LoginRequest */ LoginRequest: { /** Password */ password: string; }; + /** Rect */ + Rect: { + /** X */ + x: number; + /** Y */ + y: number; + /** Width */ + width: number; + /** Height */ + height: number; + }; /** SetupRequest */ SetupRequest: { /** Password */ password: string; }; + /** ShapeAnnotation */ + ShapeAnnotation: { + /** + * Id + * Format: uuid + */ + id: string; + /** Page */ + page: number; + /** + * Type + * @constant + */ + type: "shape"; + rect: components["schemas"]["Rect"]; + /** + * Rotation + * @default 0 + */ + rotation: number; + /** + * Z + * @default 0 + */ + z: number; + /** + * Createdat + * Format: date-time + */ + createdAt: string; + /** + * Updatedat + * Format: date-time + */ + updatedAt: string; + props: components["schemas"]["ShapeProps"]; + }; + /** ShapeProps */ + ShapeProps: { + /** + * Kind + * @enum {string} + */ + kind: "rect" | "ellipse" | "line" | "arrow"; + /** + * Strokecolor + * @default #000000 + */ + strokeColor: string; + /** + * Fillcolor + * @default transparent + */ + fillColor: string; + /** + * Strokewidth + * @default 2 + */ + strokeWidth: number; + }; + /** SignatureAnnotation */ + SignatureAnnotation: { + /** + * Id + * Format: uuid + */ + id: string; + /** Page */ + page: number; + /** + * Type + * @constant + */ + type: "signature"; + rect: components["schemas"]["Rect"]; + /** + * Rotation + * @default 0 + */ + rotation: number; + /** + * Z + * @default 0 + */ + z: number; + /** + * Createdat + * Format: date-time + */ + createdAt: string; + /** + * Updatedat + * Format: date-time + */ + updatedAt: string; + /** Props */ + props: components["schemas"]["SignatureDrawProps"] | components["schemas"]["SignatureTypeProps"]; + }; + /** SignatureDrawProps */ + SignatureDrawProps: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + mode: "draw"; + /** Ref */ + ref: string; + /** + * Strokecolor + * @default #000000 + */ + strokeColor: string; + }; + /** SignatureTypeProps */ + SignatureTypeProps: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + mode: "type"; + /** Text */ + text: string; + /** Fontfamily */ + fontFamily: string; + /** + * Color + * @default #000000 + */ + color: string; + }; + /** TextAnnotation */ + TextAnnotation: { + /** + * Id + * Format: uuid + */ + id: string; + /** Page */ + page: number; + /** + * Type + * @constant + */ + type: "text"; + rect: components["schemas"]["Rect"]; + /** + * Rotation + * @default 0 + */ + rotation: number; + /** + * Z + * @default 0 + */ + z: number; + /** + * Createdat + * Format: date-time + */ + createdAt: string; + /** + * Updatedat + * Format: date-time + */ + updatedAt: string; + props: components["schemas"]["TextProps"]; + }; + /** TextProps */ + TextProps: { + /** Text */ + text: string; + /** + * Fontfamily + * @default Liberation Sans + */ + fontFamily: string; + /** + * Fontsize + * @default 14 + */ + fontSize: number; + /** + * Color + * @default #000000 + */ + color: string; + /** + * Align + * @default left + * @enum {string} + */ + align: "left" | "center" | "right"; + /** + * Bold + * @default false + */ + bold: boolean; + /** + * Italic + * @default false + */ + italic: boolean; + /** + * Lineheight + * @default 1.2 + */ + lineHeight: number; + /** Highlightcolor */ + highlightColor?: string | null; + /** Styles */ + styles?: { + [key: string]: unknown; + } | null; + }; /** ValidationError */ ValidationError: { /** Location */ @@ -563,58 +834,20 @@ export interface components { /** Context */ ctx?: Record; }; - /** VersionCreateRequest */ - VersionCreateRequest: { - /** Label */ - label?: string | null; - /** - * Kind - * @default manual - * @enum {string} - */ - kind: "manual" | "auto"; - }; - /** VersionDataResponse */ - VersionDataResponse: { - /** Data */ - data: { - [key: string]: unknown; - }[]; - meta: components["schemas"]["VersionMeta"]; - }; - /** VersionListResponse */ - VersionListResponse: { - /** Items */ - items: components["schemas"]["VersionMeta"][]; - }; - /** VersionMeta */ - VersionMeta: { - /** Id */ - id: string; - /** Documentid */ - documentId: string; - /** Label */ - label: string | null; - /** - * Kind - * @enum {string} - */ - kind: "manual" | "auto"; - /** - * Createdat - * Format: date-time - */ - createdAt: string; - /** Annotationcount */ - annotationCount: number; - }; - /** VersionRestoreResponse */ - VersionRestoreResponse: { - /** - * Updatedat - * Format: date-time - */ - updatedAt: string; + /** VerifyCoordsRequest */ + VerifyCoordsRequest: { + /** Document Id */ + document_id: string; + /** Page */ + page: number; + /** X */ + x: number; + /** Y */ + y: number; + /** Width */ + width: number; + /** Height */ + height: number; }; }; responses: never; @@ -819,7 +1052,7 @@ export interface operations { }; responses: { /** @description Successful Response */ - 201: { + 200: { headers: { [name: string]: unknown; }; @@ -1148,75 +1381,6 @@ export interface operations { }; }; }; - upload_asset_api_v1_documents__id__assets_post: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "multipart/form-data": components["schemas"]["Body_upload_asset_api_v1_documents__id__assets_post"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - [key: string]: string; - }; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - get_asset_api_v1_documents__id__assets__ref__get: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - ref: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; get_annotations_api_v1_documents__document_id__annotations_get: { parameters: { query?: never; @@ -1283,13 +1447,11 @@ export interface operations { }; }; }; - list_versions_api_v1_documents__document_id__versions_get: { + health_check_api_v1_health_get: { parameters: { query?: never; header?: never; - path: { - document_id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -1300,161 +1462,23 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["VersionListResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; + "application/json": { + [key: string]: string; + }; }; }; }; }; - create_version_api_v1_documents__document_id__versions_post: { + verify_coords_api_v1_debug_verify_coords_post: { parameters: { query?: never; header?: never; - path: { - document_id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["VersionCreateRequest"]; - }; - }; - responses: { - /** @description Successful Response */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["VersionMeta"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - get_version_api_v1_documents__document_id__versions__version_id__get: { - parameters: { - query?: never; - header?: never; - path: { - document_id: string; - version_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["VersionDataResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - delete_version_api_v1_documents__document_id__versions__version_id__delete: { - parameters: { - query?: never; - header?: never; - path: { - document_id: string; - version_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - restore_version_api_v1_documents__document_id__versions__version_id__restore_post: { - parameters: { - query?: never; - header?: never; - path: { - document_id: string; - version_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["VersionRestoreResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - export_document_api_v1_documents__document_id__export_post: { - parameters: { - query?: never; - header?: never; - path: { - document_id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["ExportRequest"]; + "application/json": components["schemas"]["VerifyCoordsRequest"]; }; }; responses: { @@ -1478,26 +1502,4 @@ export interface operations { }; }; }; - health_check_api_v1_health_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - [key: string]: string; - }; - }; - }; - }; - }; } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 0d449e2..630a6de 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -272,46 +272,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/documents/{id}/assets": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Upload Asset - * @description Upload a PNG, JPEG, GIF, or WebP annotation asset. - */ - post: operations["upload_asset_api_v1_documents__id__assets_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/documents/{id}/assets/{ref}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Asset - * @description Serve an annotation asset to an authenticated session. - */ - get: operations["get_asset_api_v1_documents__id__assets__ref__get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/v1/documents/{document_id}/annotations": { parameters: { query?: never; @@ -330,79 +290,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/v1/documents/{document_id}/versions": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** List Versions */ - get: operations["list_versions_api_v1_documents__document_id__versions_get"]; - put?: never; - /** Create Version */ - post: operations["create_version_api_v1_documents__document_id__versions_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/documents/{document_id}/versions/{version_id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get Version */ - get: operations["get_version_api_v1_documents__document_id__versions__version_id__get"]; - put?: never; - post?: never; - /** Delete Version */ - delete: operations["delete_version_api_v1_documents__document_id__versions__version_id__delete"]; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/documents/{document_id}/versions/{version_id}/restore": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Restore Version */ - post: operations["restore_version_api_v1_documents__document_id__versions__version_id__restore_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/api/v1/documents/{document_id}/export": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Export Document - * @description Export the working layer or a selected version as a downloadable PDF. - */ - post: operations["export_document_api_v1_documents__document_id__export_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/v1/health": { parameters: { query?: never; @@ -423,6 +310,28 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/debug/verify-coords": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Verify Coords + * @description Test endpoint for cross-engine coordinate verification. + * Takes a canonical rect, draws it on the PDF using PyMuPDF, + * and returns the flattened PDF. + */ + post: operations["verify_coords_api_v1_debug_verify_coords_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -430,9 +339,7 @@ export interface components { /** AnnotationStateResponse */ AnnotationStateResponse: { /** Data */ - data: { - [key: string]: unknown; - }[]; + data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[]; /** * Updatedat * Format: date-time @@ -442,9 +349,7 @@ export interface components { /** AnnotationStateUpdateRequest */ AnnotationStateUpdateRequest: { /** Data */ - data: { - [key: string]: unknown; - }[]; + data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[]; /** Baseupdatedat */ baseUpdatedAt?: string | null; }; @@ -463,11 +368,6 @@ export interface components { /** Loggedin */ loggedIn: boolean; }; - /** Body_upload_asset_api_v1_documents__id__assets_post */ - Body_upload_asset_api_v1_documents__id__assets_post: { - /** File */ - file: string; - }; /** Body_upload_document_api_v1_documents_post */ Body_upload_document_api_v1_documents_post: { /** File */ @@ -525,31 +425,396 @@ export interface components { /** In Trash */ in_trash?: boolean | null; }; - /** ExportRequest */ - ExportRequest: { - /** Versionid */ - versionId?: string | null; + /** DrawAnnotation */ + DrawAnnotation: { /** - * Flatten - * @default true + * Id + * Format: uuid */ - flatten: boolean; + id: string; + /** Page */ + page: number; + /** + * Type + * @constant + */ + type: "draw"; + rect: components["schemas"]["Rect"]; + /** + * Rotation + * @default 0 + */ + rotation: number; + /** + * Z + * @default 0 + */ + z: number; + /** + * Createdat + * Format: date-time + */ + createdAt: string; + /** + * Updatedat + * Format: date-time + */ + updatedAt: string; + props: components["schemas"]["DrawProps"]; + }; + /** DrawProps */ + DrawProps: { + /** Paths */ + paths: [ + number, + number + ][]; + /** + * Strokecolor + * @default #000000 + */ + strokeColor: string; + /** + * Strokewidth + * @default 2 + */ + strokeWidth: number; + /** + * Opacity + * @default 1 + */ + opacity: number; }; /** HTTPValidationError */ HTTPValidationError: { /** Detail */ detail?: components["schemas"]["ValidationError"][]; }; + /** HighlightAnnotation */ + HighlightAnnotation: { + /** + * Id + * Format: uuid + */ + id: string; + /** Page */ + page: number; + /** + * Type + * @constant + */ + type: "highlight"; + rect: components["schemas"]["Rect"]; + /** + * Rotation + * @default 0 + */ + rotation: number; + /** + * Z + * @default 0 + */ + z: number; + /** + * Createdat + * Format: date-time + */ + createdAt: string; + /** + * Updatedat + * Format: date-time + */ + updatedAt: string; + props: components["schemas"]["HighlightProps"]; + }; + /** HighlightProps */ + HighlightProps: { + /** + * Color + * @default #FFEB3B + */ + color: string; + /** + * Opacity + * @default 0.3 + */ + opacity: number; + }; + /** ImageAnnotation */ + ImageAnnotation: { + /** + * Id + * Format: uuid + */ + id: string; + /** Page */ + page: number; + /** + * Type + * @constant + */ + type: "image"; + rect: components["schemas"]["Rect"]; + /** + * Rotation + * @default 0 + */ + rotation: number; + /** + * Z + * @default 0 + */ + z: number; + /** + * Createdat + * Format: date-time + */ + createdAt: string; + /** + * Updatedat + * Format: date-time + */ + updatedAt: string; + props: components["schemas"]["ImageProps"]; + }; + /** ImageProps */ + ImageProps: { + /** Ref */ + ref: string; + /** Naturalwidth */ + naturalWidth: number; + /** Naturalheight */ + naturalHeight: number; + }; /** LoginRequest */ LoginRequest: { /** Password */ password: string; }; + /** Rect */ + Rect: { + /** X */ + x: number; + /** Y */ + y: number; + /** Width */ + width: number; + /** Height */ + height: number; + }; /** SetupRequest */ SetupRequest: { /** Password */ password: string; }; + /** ShapeAnnotation */ + ShapeAnnotation: { + /** + * Id + * Format: uuid + */ + id: string; + /** Page */ + page: number; + /** + * Type + * @constant + */ + type: "shape"; + rect: components["schemas"]["Rect"]; + /** + * Rotation + * @default 0 + */ + rotation: number; + /** + * Z + * @default 0 + */ + z: number; + /** + * Createdat + * Format: date-time + */ + createdAt: string; + /** + * Updatedat + * Format: date-time + */ + updatedAt: string; + props: components["schemas"]["ShapeProps"]; + }; + /** ShapeProps */ + ShapeProps: { + /** + * Kind + * @enum {string} + */ + kind: "rect" | "ellipse" | "line" | "arrow"; + /** + * Strokecolor + * @default #000000 + */ + strokeColor: string; + /** + * Fillcolor + * @default transparent + */ + fillColor: string; + /** + * Strokewidth + * @default 2 + */ + strokeWidth: number; + }; + /** SignatureAnnotation */ + SignatureAnnotation: { + /** + * Id + * Format: uuid + */ + id: string; + /** Page */ + page: number; + /** + * Type + * @constant + */ + type: "signature"; + rect: components["schemas"]["Rect"]; + /** + * Rotation + * @default 0 + */ + rotation: number; + /** + * Z + * @default 0 + */ + z: number; + /** + * Createdat + * Format: date-time + */ + createdAt: string; + /** + * Updatedat + * Format: date-time + */ + updatedAt: string; + /** Props */ + props: components["schemas"]["SignatureDrawProps"] | components["schemas"]["SignatureTypeProps"]; + }; + /** SignatureDrawProps */ + SignatureDrawProps: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + mode: "draw"; + /** Ref */ + ref: string; + /** + * Strokecolor + * @default #000000 + */ + strokeColor: string; + }; + /** SignatureTypeProps */ + SignatureTypeProps: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + mode: "type"; + /** Text */ + text: string; + /** Fontfamily */ + fontFamily: string; + /** + * Color + * @default #000000 + */ + color: string; + }; + /** TextAnnotation */ + TextAnnotation: { + /** + * Id + * Format: uuid + */ + id: string; + /** Page */ + page: number; + /** + * Type + * @constant + */ + type: "text"; + rect: components["schemas"]["Rect"]; + /** + * Rotation + * @default 0 + */ + rotation: number; + /** + * Z + * @default 0 + */ + z: number; + /** + * Createdat + * Format: date-time + */ + createdAt: string; + /** + * Updatedat + * Format: date-time + */ + updatedAt: string; + props: components["schemas"]["TextProps"]; + }; + /** TextProps */ + TextProps: { + /** Text */ + text: string; + /** + * Fontfamily + * @default Liberation Sans + */ + fontFamily: string; + /** + * Fontsize + * @default 14 + */ + fontSize: number; + /** + * Color + * @default #000000 + */ + color: string; + /** + * Align + * @default left + * @enum {string} + */ + align: "left" | "center" | "right"; + /** + * Bold + * @default false + */ + bold: boolean; + /** + * Italic + * @default false + */ + italic: boolean; + /** + * Lineheight + * @default 1.2 + */ + lineHeight: number; + }; /** ValidationError */ ValidationError: { /** Location */ @@ -563,58 +828,20 @@ export interface components { /** Context */ ctx?: Record; }; - /** VersionCreateRequest */ - VersionCreateRequest: { - /** Label */ - label?: string | null; - /** - * Kind - * @default manual - * @enum {string} - */ - kind: "manual" | "auto"; - }; - /** VersionDataResponse */ - VersionDataResponse: { - /** Data */ - data: { - [key: string]: unknown; - }[]; - meta: components["schemas"]["VersionMeta"]; - }; - /** VersionListResponse */ - VersionListResponse: { - /** Items */ - items: components["schemas"]["VersionMeta"][]; - }; - /** VersionMeta */ - VersionMeta: { - /** Id */ - id: string; - /** Documentid */ - documentId: string; - /** Label */ - label: string | null; - /** - * Kind - * @enum {string} - */ - kind: "manual" | "auto"; - /** - * Createdat - * Format: date-time - */ - createdAt: string; - /** Annotationcount */ - annotationCount: number; - }; - /** VersionRestoreResponse */ - VersionRestoreResponse: { - /** - * Updatedat - * Format: date-time - */ - updatedAt: string; + /** VerifyCoordsRequest */ + VerifyCoordsRequest: { + /** Document Id */ + document_id: string; + /** Page */ + page: number; + /** X */ + x: number; + /** Y */ + y: number; + /** Width */ + width: number; + /** Height */ + height: number; }; }; responses: never; @@ -819,7 +1046,7 @@ export interface operations { }; responses: { /** @description Successful Response */ - 201: { + 200: { headers: { [name: string]: unknown; }; @@ -1148,75 +1375,6 @@ export interface operations { }; }; }; - upload_asset_api_v1_documents__id__assets_post: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "multipart/form-data": components["schemas"]["Body_upload_asset_api_v1_documents__id__assets_post"]; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - [key: string]: string; - }; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - get_asset_api_v1_documents__id__assets__ref__get: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - ref: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": unknown; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; get_annotations_api_v1_documents__document_id__annotations_get: { parameters: { query?: never; @@ -1283,13 +1441,11 @@ export interface operations { }; }; }; - list_versions_api_v1_documents__document_id__versions_get: { + health_check_api_v1_health_get: { parameters: { query?: never; header?: never; - path: { - document_id: string; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -1300,161 +1456,23 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["VersionListResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; + "application/json": { + [key: string]: string; + }; }; }; }; }; - create_version_api_v1_documents__document_id__versions_post: { + verify_coords_api_v1_debug_verify_coords_post: { parameters: { query?: never; header?: never; - path: { - document_id: string; - }; + path?: never; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["VersionCreateRequest"]; - }; - }; - responses: { - /** @description Successful Response */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["VersionMeta"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - get_version_api_v1_documents__document_id__versions__version_id__get: { - parameters: { - query?: never; - header?: never; - path: { - document_id: string; - version_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["VersionDataResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - delete_version_api_v1_documents__document_id__versions__version_id__delete: { - parameters: { - query?: never; - header?: never; - path: { - document_id: string; - version_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - restore_version_api_v1_documents__document_id__versions__version_id__restore_post: { - parameters: { - query?: never; - header?: never; - path: { - document_id: string; - version_id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["VersionRestoreResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; - export_document_api_v1_documents__document_id__export_post: { - parameters: { - query?: never; - header?: never; - path: { - document_id: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["ExportRequest"]; + "application/json": components["schemas"]["VerifyCoordsRequest"]; }; }; responses: { @@ -1478,26 +1496,4 @@ export interface operations { }; }; }; - health_check_api_v1_health_get: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - [key: string]: string; - }; - }; - }; - }; - }; } diff --git a/openapi.json b/openapi.json index bf1facb..62b6e82 100644 --- a/openapi.json +++ b/openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"PaperJet","description":"Self-hosted PDF editor API","version":"0.1.0"},"paths":{"/api/v1/auth/status":{"get":{"tags":["auth"],"summary":"Get Auth Status","description":"Check if app requires setup and if user is logged in.","operationId":"get_auth_status_api_v1_auth_status_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthStatusResponse"}}}}}}},"/api/v1/auth/setup":{"post":{"tags":["auth"],"summary":"Setup Password","description":"First-run setup.","operationId":"setup_password_api_v1_auth_setup_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object","title":"Response Setup Password Api V1 Auth Setup Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/auth/login":{"post":{"tags":["auth"],"summary":"Login","description":"Validate password and issue session cookie.","operationId":"login_api_v1_auth_login_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object","title":"Response Login Api V1 Auth Login Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/auth/logout":{"post":{"tags":["auth"],"summary":"Logout","description":"Clear session cookie.","operationId":"logout_api_v1_auth_logout_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object","title":"Response Logout Api V1 Auth Logout Post"}}}}}}},"/api/v1/auth/password":{"put":{"tags":["auth"],"summary":"Change Password","description":"Change an existing password.","operationId":"change_password_api_v1_auth_password_put","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangePasswordRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object","title":"Response Change Password Api V1 Auth Password Put"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents":{"get":{"tags":["documents"],"summary":"List Documents","description":"List non-trashed documents.","operationId":"list_documents_api_v1_documents_get","parameters":[{"name":"query","in":"query","required":false,"schema":{"type":"string","default":"","title":"Query"}},{"name":"skip","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Skip"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":50,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["documents"],"summary":"Upload Document","description":"Upload a new PDF document.","operationId":"upload_document_api_v1_documents_post","requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_upload_document_api_v1_documents_post"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentMeta"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/trash":{"get":{"tags":["documents"],"summary":"List Trash","description":"List trashed documents.","operationId":"list_trash_api_v1_documents_trash_get","parameters":[{"name":"skip","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Skip"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":50,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{id}":{"get":{"tags":["documents"],"summary":"Get Document","operationId":"get_document_api_v1_documents__id__get","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentMeta"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["documents"],"summary":"Update Document","operationId":"update_document_api_v1_documents__id__patch","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","title":"Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentUpdateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentMeta"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["documents"],"summary":"Delete Document","description":"Soft delete by default. Hard delete if permanent=True AND already in trash.","operationId":"delete_document_api_v1_documents__id__delete","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","title":"Id"}},{"name":"permanent","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Permanent"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{id}/restore":{"post":{"tags":["documents"],"summary":"Restore Document","operationId":"restore_document_api_v1_documents__id__restore_post","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentMeta"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/bulk-delete":{"post":{"tags":["documents"],"summary":"Bulk Delete","operationId":"bulk_delete_api_v1_documents_bulk_delete_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkDeleteRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/bulk-restore":{"post":{"tags":["documents"],"summary":"Bulk Restore","operationId":"bulk_restore_api_v1_documents_bulk_restore_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkRestoreRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/trash/empty":{"post":{"tags":["documents"],"summary":"Empty Trash","operationId":"empty_trash_api_v1_documents_trash_empty_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/v1/documents/{id}/file":{"get":{"tags":["documents"],"summary":"Get Document File","operationId":"get_document_file_api_v1_documents__id__file_get","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{id}/thumbnail":{"get":{"tags":["documents"],"summary":"Get Document Thumbnail","operationId":"get_document_thumbnail_api_v1_documents__id__thumbnail_get","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{id}/assets":{"post":{"tags":["Assets"],"summary":"Upload Asset","description":"Upload a PNG, JPEG, GIF, or WebP annotation asset.","operationId":"upload_asset_api_v1_documents__id__assets_post","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","title":"Id"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_upload_asset_api_v1_documents__id__assets_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"string"},"title":"Response Upload Asset Api V1 Documents Id Assets Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{id}/assets/{ref}":{"get":{"tags":["Assets"],"summary":"Get Asset","description":"Serve an annotation asset to an authenticated session.","operationId":"get_asset_api_v1_documents__id__assets__ref__get","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","title":"Id"}},{"name":"ref","in":"path","required":true,"schema":{"type":"string","title":"Ref"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{document_id}/annotations":{"get":{"tags":["annotations"],"summary":"Get Annotations","operationId":"get_annotations_api_v1_documents__document_id__annotations_get","parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","title":"Document Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationStateResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["annotations"],"summary":"Update Annotations","operationId":"update_annotations_api_v1_documents__document_id__annotations_put","parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","title":"Document Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationStateUpdateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationStateUpdateResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{document_id}/versions":{"get":{"tags":["versions"],"summary":"List Versions","operationId":"list_versions_api_v1_documents__document_id__versions_get","parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","title":"Document Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VersionListResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["versions"],"summary":"Create Version","operationId":"create_version_api_v1_documents__document_id__versions_post","parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","title":"Document Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VersionCreateRequest"}}}},"responses":{"201":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VersionMeta"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{document_id}/versions/{version_id}":{"get":{"tags":["versions"],"summary":"Get Version","operationId":"get_version_api_v1_documents__document_id__versions__version_id__get","parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","title":"Document Id"}},{"name":"version_id","in":"path","required":true,"schema":{"type":"string","title":"Version Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VersionDataResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["versions"],"summary":"Delete Version","operationId":"delete_version_api_v1_documents__document_id__versions__version_id__delete","parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","title":"Document Id"}},{"name":"version_id","in":"path","required":true,"schema":{"type":"string","title":"Version Id"}}],"responses":{"204":{"description":"Successful Response"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{document_id}/versions/{version_id}/restore":{"post":{"tags":["versions"],"summary":"Restore Version","operationId":"restore_version_api_v1_documents__document_id__versions__version_id__restore_post","parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","title":"Document Id"}},{"name":"version_id","in":"path","required":true,"schema":{"type":"string","title":"Version Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VersionRestoreResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{document_id}/export":{"post":{"tags":["export"],"summary":"Export Document","description":"Export the working layer or a selected version as a downloadable PDF.","operationId":"export_document_api_v1_documents__document_id__export_post","parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","title":"Document Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExportRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/health":{"get":{"tags":["health"],"summary":"Health Check","description":"Return service health status and version.","operationId":"health_check_api_v1_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object","title":"Response Health Check Api V1 Health Get"}}}}}}}},"components":{"schemas":{"AnnotationStateResponse":{"properties":{"data":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Data"},"updatedAt":{"type":"string","format":"date-time","title":"Updatedat"}},"type":"object","required":["data","updatedAt"],"title":"AnnotationStateResponse"},"AnnotationStateUpdateRequest":{"properties":{"data":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Data"},"baseUpdatedAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Baseupdatedat"}},"type":"object","required":["data"],"title":"AnnotationStateUpdateRequest"},"AnnotationStateUpdateResponse":{"properties":{"updatedAt":{"type":"string","format":"date-time","title":"Updatedat"}},"type":"object","required":["updatedAt"],"title":"AnnotationStateUpdateResponse"},"AuthStatusResponse":{"properties":{"setupRequired":{"type":"boolean","title":"Setuprequired"},"loggedIn":{"type":"boolean","title":"Loggedin"}},"type":"object","required":["setupRequired","loggedIn"],"title":"AuthStatusResponse"},"Body_upload_asset_api_v1_documents__id__assets_post":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File"}},"type":"object","required":["file"],"title":"Body_upload_asset_api_v1_documents__id__assets_post"},"Body_upload_document_api_v1_documents_post":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File"}},"type":"object","required":["file"],"title":"Body_upload_document_api_v1_documents_post"},"BulkDeleteRequest":{"properties":{"ids":{"items":{"type":"string"},"type":"array","title":"Ids"}},"type":"object","required":["ids"],"title":"BulkDeleteRequest"},"BulkRestoreRequest":{"properties":{"ids":{"items":{"type":"string"},"type":"array","title":"Ids"}},"type":"object","required":["ids"],"title":"BulkRestoreRequest"},"ChangePasswordRequest":{"properties":{"current_password":{"type":"string","title":"Current Password"},"new_password":{"type":"string","minLength":8,"title":"New Password"}},"type":"object","required":["current_password","new_password"],"title":"ChangePasswordRequest"},"DocumentListResponse":{"properties":{"items":{"items":{"$ref":"#/components/schemas/DocumentMeta"},"type":"array","title":"Items"},"total":{"type":"integer","title":"Total"}},"type":"object","required":["items","total"],"title":"DocumentListResponse"},"DocumentMeta":{"properties":{"id":{"type":"string","title":"Id"},"title":{"type":"string","title":"Title"},"filename":{"type":"string","title":"Filename"},"mime_type":{"type":"string","title":"Mime Type"},"size_bytes":{"type":"integer","title":"Size Bytes"},"in_trash":{"type":"boolean","title":"In Trash"},"created_at":{"type":"string","title":"Created At"},"updated_at":{"type":"string","title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Deleted At"}},"type":"object","required":["id","title","filename","mime_type","size_bytes","in_trash","created_at","updated_at","deleted_at"],"title":"DocumentMeta"},"DocumentUpdateRequest":{"properties":{"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title"},"in_trash":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"In Trash"}},"type":"object","title":"DocumentUpdateRequest"},"ExportRequest":{"properties":{"versionId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Versionid"},"flatten":{"type":"boolean","title":"Flatten","default":true}},"type":"object","title":"ExportRequest"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"LoginRequest":{"properties":{"password":{"type":"string","title":"Password"}},"type":"object","required":["password"],"title":"LoginRequest"},"SetupRequest":{"properties":{"password":{"type":"string","minLength":8,"title":"Password"}},"type":"object","required":["password"],"title":"SetupRequest"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"VersionCreateRequest":{"properties":{"label":{"anyOf":[{"type":"string","maxLength":120},{"type":"null"}],"title":"Label"},"kind":{"type":"string","enum":["manual","auto"],"title":"Kind","default":"manual"}},"type":"object","title":"VersionCreateRequest"},"VersionDataResponse":{"properties":{"data":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Data"},"meta":{"$ref":"#/components/schemas/VersionMeta"}},"type":"object","required":["data","meta"],"title":"VersionDataResponse"},"VersionListResponse":{"properties":{"items":{"items":{"$ref":"#/components/schemas/VersionMeta"},"type":"array","title":"Items"}},"type":"object","required":["items"],"title":"VersionListResponse"},"VersionMeta":{"properties":{"id":{"type":"string","title":"Id"},"documentId":{"type":"string","title":"Documentid"},"label":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Label"},"kind":{"type":"string","enum":["manual","auto"],"title":"Kind"},"createdAt":{"type":"string","format":"date-time","title":"Createdat"},"annotationCount":{"type":"integer","title":"Annotationcount"}},"type":"object","required":["id","documentId","label","kind","createdAt","annotationCount"],"title":"VersionMeta"},"VersionRestoreResponse":{"properties":{"updatedAt":{"type":"string","format":"date-time","title":"Updatedat"}},"type":"object","required":["updatedAt"],"title":"VersionRestoreResponse"}}}} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"PaperJet","description":"Self-hosted PDF editor API","version":"0.1.0"},"paths":{"/api/v1/auth/status":{"get":{"tags":["auth"],"summary":"Get Auth Status","description":"Check if app requires setup and if user is logged in.","operationId":"get_auth_status_api_v1_auth_status_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthStatusResponse"}}}}}}},"/api/v1/auth/setup":{"post":{"tags":["auth"],"summary":"Setup Password","description":"First-run setup.","operationId":"setup_password_api_v1_auth_setup_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetupRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object","title":"Response Setup Password Api V1 Auth Setup Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/auth/login":{"post":{"tags":["auth"],"summary":"Login","description":"Validate password and issue session cookie.","operationId":"login_api_v1_auth_login_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object","title":"Response Login Api V1 Auth Login Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/auth/logout":{"post":{"tags":["auth"],"summary":"Logout","description":"Clear session cookie.","operationId":"logout_api_v1_auth_logout_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object","title":"Response Logout Api V1 Auth Logout Post"}}}}}}},"/api/v1/auth/password":{"put":{"tags":["auth"],"summary":"Change Password","description":"Change an existing password.","operationId":"change_password_api_v1_auth_password_put","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangePasswordRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object","title":"Response Change Password Api V1 Auth Password Put"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents":{"get":{"tags":["documents"],"summary":"List Documents","description":"List non-trashed documents.","operationId":"list_documents_api_v1_documents_get","parameters":[{"name":"query","in":"query","required":false,"schema":{"type":"string","default":"","title":"Query"}},{"name":"skip","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Skip"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":50,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["documents"],"summary":"Upload Document","description":"Upload a new PDF document.","operationId":"upload_document_api_v1_documents_post","requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_upload_document_api_v1_documents_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentMeta"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/trash":{"get":{"tags":["documents"],"summary":"List Trash","description":"List trashed documents.","operationId":"list_trash_api_v1_documents_trash_get","parameters":[{"name":"skip","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Skip"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":50,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{id}":{"get":{"tags":["documents"],"summary":"Get Document","operationId":"get_document_api_v1_documents__id__get","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentMeta"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["documents"],"summary":"Update Document","operationId":"update_document_api_v1_documents__id__patch","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","title":"Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentUpdateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentMeta"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["documents"],"summary":"Delete Document","description":"Soft delete by default. Hard delete if permanent=True AND already in trash.","operationId":"delete_document_api_v1_documents__id__delete","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","title":"Id"}},{"name":"permanent","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Permanent"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{id}/restore":{"post":{"tags":["documents"],"summary":"Restore Document","operationId":"restore_document_api_v1_documents__id__restore_post","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentMeta"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/bulk-delete":{"post":{"tags":["documents"],"summary":"Bulk Delete","operationId":"bulk_delete_api_v1_documents_bulk_delete_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkDeleteRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/bulk-restore":{"post":{"tags":["documents"],"summary":"Bulk Restore","operationId":"bulk_restore_api_v1_documents_bulk_restore_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkRestoreRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/trash/empty":{"post":{"tags":["documents"],"summary":"Empty Trash","operationId":"empty_trash_api_v1_documents_trash_empty_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/v1/documents/{id}/file":{"get":{"tags":["documents"],"summary":"Get Document File","operationId":"get_document_file_api_v1_documents__id__file_get","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{id}/thumbnail":{"get":{"tags":["documents"],"summary":"Get Document Thumbnail","operationId":"get_document_thumbnail_api_v1_documents__id__thumbnail_get","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","title":"Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/documents/{document_id}/annotations":{"get":{"tags":["Annotations"],"summary":"Get Annotations","operationId":"get_annotations_api_v1_documents__document_id__annotations_get","parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","title":"Document Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationStateResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["Annotations"],"summary":"Update Annotations","operationId":"update_annotations_api_v1_documents__document_id__annotations_put","parameters":[{"name":"document_id","in":"path","required":true,"schema":{"type":"string","title":"Document Id"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationStateUpdateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnnotationStateUpdateResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/health":{"get":{"tags":["health"],"summary":"Health Check","description":"Return service health status and version.","operationId":"health_check_api_v1_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object","title":"Response Health Check Api V1 Health Get"}}}}}}},"/api/v1/debug/verify-coords":{"post":{"tags":["Debug"],"summary":"Verify Coords","description":"Test endpoint for cross-engine coordinate verification.\nTakes a canonical rect, draws it on the PDF using PyMuPDF,\nand returns the flattened PDF.","operationId":"verify_coords_api_v1_debug_verify_coords_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/VerifyCoordsRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"AnnotationStateResponse":{"properties":{"data":{"items":{"anyOf":[{"$ref":"#/components/schemas/TextAnnotation"},{"$ref":"#/components/schemas/DrawAnnotation"},{"$ref":"#/components/schemas/SignatureAnnotation"},{"$ref":"#/components/schemas/ImageAnnotation"},{"$ref":"#/components/schemas/HighlightAnnotation"},{"$ref":"#/components/schemas/ShapeAnnotation"}]},"type":"array","title":"Data"},"updatedAt":{"type":"string","format":"date-time","title":"Updatedat"}},"type":"object","required":["data","updatedAt"],"title":"AnnotationStateResponse"},"AnnotationStateUpdateRequest":{"properties":{"data":{"items":{"anyOf":[{"$ref":"#/components/schemas/TextAnnotation"},{"$ref":"#/components/schemas/DrawAnnotation"},{"$ref":"#/components/schemas/SignatureAnnotation"},{"$ref":"#/components/schemas/ImageAnnotation"},{"$ref":"#/components/schemas/HighlightAnnotation"},{"$ref":"#/components/schemas/ShapeAnnotation"}]},"type":"array","title":"Data"},"baseUpdatedAt":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Baseupdatedat"}},"type":"object","required":["data"],"title":"AnnotationStateUpdateRequest"},"AnnotationStateUpdateResponse":{"properties":{"updatedAt":{"type":"string","format":"date-time","title":"Updatedat"}},"type":"object","required":["updatedAt"],"title":"AnnotationStateUpdateResponse"},"AuthStatusResponse":{"properties":{"setupRequired":{"type":"boolean","title":"Setuprequired"},"loggedIn":{"type":"boolean","title":"Loggedin"}},"type":"object","required":["setupRequired","loggedIn"],"title":"AuthStatusResponse"},"Body_upload_document_api_v1_documents_post":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File"}},"type":"object","required":["file"],"title":"Body_upload_document_api_v1_documents_post"},"BulkDeleteRequest":{"properties":{"ids":{"items":{"type":"string"},"type":"array","title":"Ids"}},"type":"object","required":["ids"],"title":"BulkDeleteRequest"},"BulkRestoreRequest":{"properties":{"ids":{"items":{"type":"string"},"type":"array","title":"Ids"}},"type":"object","required":["ids"],"title":"BulkRestoreRequest"},"ChangePasswordRequest":{"properties":{"current_password":{"type":"string","title":"Current Password"},"new_password":{"type":"string","minLength":8,"title":"New Password"}},"type":"object","required":["current_password","new_password"],"title":"ChangePasswordRequest"},"DocumentListResponse":{"properties":{"items":{"items":{"$ref":"#/components/schemas/DocumentMeta"},"type":"array","title":"Items"},"total":{"type":"integer","title":"Total"}},"type":"object","required":["items","total"],"title":"DocumentListResponse"},"DocumentMeta":{"properties":{"id":{"type":"string","title":"Id"},"title":{"type":"string","title":"Title"},"filename":{"type":"string","title":"Filename"},"mime_type":{"type":"string","title":"Mime Type"},"size_bytes":{"type":"integer","title":"Size Bytes"},"in_trash":{"type":"boolean","title":"In Trash"},"created_at":{"type":"string","title":"Created At"},"updated_at":{"type":"string","title":"Updated At"},"deleted_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Deleted At"}},"type":"object","required":["id","title","filename","mime_type","size_bytes","in_trash","created_at","updated_at","deleted_at"],"title":"DocumentMeta"},"DocumentUpdateRequest":{"properties":{"title":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Title"},"in_trash":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"In Trash"}},"type":"object","title":"DocumentUpdateRequest"},"DrawAnnotation":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"page":{"type":"integer","minimum":0.0,"title":"Page"},"type":{"type":"string","const":"draw","title":"Type"},"rect":{"$ref":"#/components/schemas/Rect"},"rotation":{"type":"number","title":"Rotation","default":0.0},"z":{"type":"integer","title":"Z","default":0},"createdAt":{"type":"string","format":"date-time","title":"Createdat"},"updatedAt":{"type":"string","format":"date-time","title":"Updatedat"},"props":{"$ref":"#/components/schemas/DrawProps"}},"type":"object","required":["id","page","type","rect","createdAt","updatedAt","props"],"title":"DrawAnnotation"},"DrawProps":{"properties":{"paths":{"items":{"prefixItems":[{"type":"number"},{"type":"number"}],"type":"array","maxItems":2,"minItems":2},"type":"array","title":"Paths"},"strokeColor":{"type":"string","title":"Strokecolor","default":"#000000"},"strokeWidth":{"type":"number","title":"Strokewidth","default":2},"opacity":{"type":"number","title":"Opacity","default":1.0}},"type":"object","required":["paths"],"title":"DrawProps"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HighlightAnnotation":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"page":{"type":"integer","minimum":0.0,"title":"Page"},"type":{"type":"string","const":"highlight","title":"Type"},"rect":{"$ref":"#/components/schemas/Rect"},"rotation":{"type":"number","title":"Rotation","default":0.0},"z":{"type":"integer","title":"Z","default":0},"createdAt":{"type":"string","format":"date-time","title":"Createdat"},"updatedAt":{"type":"string","format":"date-time","title":"Updatedat"},"props":{"$ref":"#/components/schemas/HighlightProps"}},"type":"object","required":["id","page","type","rect","createdAt","updatedAt","props"],"title":"HighlightAnnotation"},"HighlightProps":{"properties":{"color":{"type":"string","title":"Color","default":"#FFEB3B"},"opacity":{"type":"number","title":"Opacity","default":0.3}},"type":"object","title":"HighlightProps"},"ImageAnnotation":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"page":{"type":"integer","minimum":0.0,"title":"Page"},"type":{"type":"string","const":"image","title":"Type"},"rect":{"$ref":"#/components/schemas/Rect"},"rotation":{"type":"number","title":"Rotation","default":0.0},"z":{"type":"integer","title":"Z","default":0},"createdAt":{"type":"string","format":"date-time","title":"Createdat"},"updatedAt":{"type":"string","format":"date-time","title":"Updatedat"},"props":{"$ref":"#/components/schemas/ImageProps"}},"type":"object","required":["id","page","type","rect","createdAt","updatedAt","props"],"title":"ImageAnnotation"},"ImageProps":{"properties":{"ref":{"type":"string","title":"Ref"},"naturalWidth":{"type":"number","title":"Naturalwidth"},"naturalHeight":{"type":"number","title":"Naturalheight"}},"type":"object","required":["ref","naturalWidth","naturalHeight"],"title":"ImageProps"},"LoginRequest":{"properties":{"password":{"type":"string","title":"Password"}},"type":"object","required":["password"],"title":"LoginRequest"},"Rect":{"properties":{"x":{"type":"number","title":"X"},"y":{"type":"number","title":"Y"},"width":{"type":"number","title":"Width"},"height":{"type":"number","title":"Height"}},"type":"object","required":["x","y","width","height"],"title":"Rect"},"SetupRequest":{"properties":{"password":{"type":"string","minLength":8,"title":"Password"}},"type":"object","required":["password"],"title":"SetupRequest"},"ShapeAnnotation":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"page":{"type":"integer","minimum":0.0,"title":"Page"},"type":{"type":"string","const":"shape","title":"Type"},"rect":{"$ref":"#/components/schemas/Rect"},"rotation":{"type":"number","title":"Rotation","default":0.0},"z":{"type":"integer","title":"Z","default":0},"createdAt":{"type":"string","format":"date-time","title":"Createdat"},"updatedAt":{"type":"string","format":"date-time","title":"Updatedat"},"props":{"$ref":"#/components/schemas/ShapeProps"}},"type":"object","required":["id","page","type","rect","createdAt","updatedAt","props"],"title":"ShapeAnnotation"},"ShapeProps":{"properties":{"kind":{"type":"string","enum":["rect","ellipse","line","arrow"],"title":"Kind"},"strokeColor":{"type":"string","title":"Strokecolor","default":"#000000"},"fillColor":{"type":"string","title":"Fillcolor","default":"transparent"},"strokeWidth":{"type":"number","title":"Strokewidth","default":2}},"type":"object","required":["kind"],"title":"ShapeProps"},"SignatureAnnotation":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"page":{"type":"integer","minimum":0.0,"title":"Page"},"type":{"type":"string","const":"signature","title":"Type"},"rect":{"$ref":"#/components/schemas/Rect"},"rotation":{"type":"number","title":"Rotation","default":0.0},"z":{"type":"integer","title":"Z","default":0},"createdAt":{"type":"string","format":"date-time","title":"Createdat"},"updatedAt":{"type":"string","format":"date-time","title":"Updatedat"},"props":{"oneOf":[{"$ref":"#/components/schemas/SignatureDrawProps"},{"$ref":"#/components/schemas/SignatureTypeProps"}],"title":"Props","discriminator":{"propertyName":"mode","mapping":{"draw":"#/components/schemas/SignatureDrawProps","type":"#/components/schemas/SignatureTypeProps"}}}},"type":"object","required":["id","page","type","rect","createdAt","updatedAt","props"],"title":"SignatureAnnotation"},"SignatureDrawProps":{"properties":{"mode":{"type":"string","const":"draw","title":"Mode"},"ref":{"type":"string","title":"Ref"},"strokeColor":{"type":"string","title":"Strokecolor","default":"#000000"}},"type":"object","required":["mode","ref"],"title":"SignatureDrawProps"},"SignatureTypeProps":{"properties":{"mode":{"type":"string","const":"type","title":"Mode"},"text":{"type":"string","title":"Text"},"fontFamily":{"type":"string","title":"Fontfamily"},"color":{"type":"string","title":"Color","default":"#000000"}},"type":"object","required":["mode","text","fontFamily"],"title":"SignatureTypeProps"},"TextAnnotation":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"page":{"type":"integer","minimum":0.0,"title":"Page"},"type":{"type":"string","const":"text","title":"Type"},"rect":{"$ref":"#/components/schemas/Rect"},"rotation":{"type":"number","title":"Rotation","default":0.0},"z":{"type":"integer","title":"Z","default":0},"createdAt":{"type":"string","format":"date-time","title":"Createdat"},"updatedAt":{"type":"string","format":"date-time","title":"Updatedat"},"props":{"$ref":"#/components/schemas/TextProps"}},"type":"object","required":["id","page","type","rect","createdAt","updatedAt","props"],"title":"TextAnnotation"},"TextProps":{"properties":{"text":{"type":"string","title":"Text"},"fontFamily":{"type":"string","title":"Fontfamily","default":"Liberation Sans"},"fontSize":{"type":"number","title":"Fontsize","default":14},"color":{"type":"string","title":"Color","default":"#000000"},"align":{"type":"string","enum":["left","center","right"],"title":"Align","default":"left"},"bold":{"type":"boolean","title":"Bold","default":false},"italic":{"type":"boolean","title":"Italic","default":false},"lineHeight":{"type":"number","title":"Lineheight","default":1.2}},"type":"object","required":["text"],"title":"TextProps"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"VerifyCoordsRequest":{"properties":{"document_id":{"type":"string","title":"Document Id"},"page":{"type":"integer","title":"Page"},"x":{"type":"number","title":"X"},"y":{"type":"number","title":"Y"},"width":{"type":"number","title":"Width"},"height":{"type":"number","title":"Height"}},"type":"object","required":["document_id","page","x","y","width","height"],"title":"VerifyCoordsRequest"}}}} \ No newline at end of file diff --git a/openapi_pretty.json b/openapi_pretty.json index e84200e..896226d 100644 --- a/openapi_pretty.json +++ b/openapi_pretty.json @@ -1,1738 +1,1893 @@ { - "openapi": "3.1.0", - "info": { - "title": "PaperJet", - "description": "Self-hosted PDF editor API", - "version": "0.1.0" - }, - "paths": { - "/api/v1/auth/status": { - "get": { - "tags": [ - "auth" - ], - "summary": "Get Auth Status", - "description": "Check if app requires setup and if user is logged in.", - "operationId": "get_auth_status_api_v1_auth_status_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuthStatusResponse" - } - } - } - } - } - } + "openapi": "3.1.0", + "info": { + "title": "PaperJet", + "description": "Self-hosted PDF editor API", + "version": "0.1.0" }, - "/api/v1/auth/setup": { - "post": { - "tags": [ - "auth" - ], - "summary": "Setup Password", - "description": "First-run setup.", - "operationId": "setup_password_api_v1_auth_setup_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetupRequest" - } + "paths": { + "/api/v1/auth/status": { + "get": { + "tags": [ + "auth" + ], + "summary": "Get Auth Status", + "description": "Check if app requires setup and if user is logged in.", + "operationId": "get_auth_status_api_v1_auth_status_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthStatusResponse" + } + } + } + } + } } - }, - "required": true }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": { - "type": "string" - }, - "type": "object", - "title": "Response Setup Password Api V1 Auth Setup Post" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/auth/login": { - "post": { - "tags": [ - "auth" - ], - "summary": "Login", - "description": "Validate password and issue session cookie.", - "operationId": "login_api_v1_auth_login_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LoginRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": { - "type": "string" - }, - "type": "object", - "title": "Response Login Api V1 Auth Login Post" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/auth/logout": { - "post": { - "tags": [ - "auth" - ], - "summary": "Logout", - "description": "Clear session cookie.", - "operationId": "logout_api_v1_auth_logout_post", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": { - "type": "string" - }, - "type": "object", - "title": "Response Logout Api V1 Auth Logout Post" - } - } - } - } - } - } - }, - "/api/v1/auth/password": { - "put": { - "tags": [ - "auth" - ], - "summary": "Change Password", - "description": "Change an existing password.", - "operationId": "change_password_api_v1_auth_password_put", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ChangePasswordRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": { - "type": "string" - }, - "type": "object", - "title": "Response Change Password Api V1 Auth Password Put" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/documents": { - "get": { - "tags": [ - "documents" - ], - "summary": "List Documents", - "description": "List non-trashed documents.", - "operationId": "list_documents_api_v1_documents_get", - "parameters": [ - { - "name": "query", - "in": "query", - "required": false, - "schema": { - "type": "string", - "default": "", - "title": "Query" - } - }, - { - "name": "skip", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "minimum": 0, - "default": 0, - "title": "Skip" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "maximum": 100, - "minimum": 1, - "default": 50, - "title": "Limit" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DocumentListResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "post": { - "tags": [ - "documents" - ], - "summary": "Upload Document", - "description": "Upload a new PDF document.", - "operationId": "upload_document_api_v1_documents_post", - "requestBody": { - "required": true, - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Body_upload_document_api_v1_documents_post" - } - } - } - }, - "responses": { - "201": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DocumentMeta" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/documents/trash": { - "get": { - "tags": [ - "documents" - ], - "summary": "List Trash", - "description": "List trashed documents.", - "operationId": "list_trash_api_v1_documents_trash_get", - "parameters": [ - { - "name": "skip", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "minimum": 0, - "default": 0, - "title": "Skip" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "maximum": 100, - "minimum": 1, - "default": 50, - "title": "Limit" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DocumentListResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/documents/{id}": { - "get": { - "tags": [ - "documents" - ], - "summary": "Get Document", - "operationId": "get_document_api_v1_documents__id__get", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DocumentMeta" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "patch": { - "tags": [ - "documents" - ], - "summary": "Update Document", - "operationId": "update_document_api_v1_documents__id__patch", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DocumentUpdateRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DocumentMeta" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "delete": { - "tags": [ - "documents" - ], - "summary": "Delete Document", - "description": "Soft delete by default. Hard delete if permanent=True AND already in trash.", - "operationId": "delete_document_api_v1_documents__id__delete", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Id" - } - }, - { - "name": "permanent", - "in": "query", - "required": false, - "schema": { - "type": "boolean", - "default": false, - "title": "Permanent" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/documents/{id}/restore": { - "post": { - "tags": [ - "documents" - ], - "summary": "Restore Document", - "operationId": "restore_document_api_v1_documents__id__restore_post", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DocumentMeta" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/documents/bulk-delete": { - "post": { - "tags": [ - "documents" - ], - "summary": "Bulk Delete", - "operationId": "bulk_delete_api_v1_documents_bulk_delete_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkDeleteRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/documents/bulk-restore": { - "post": { - "tags": [ - "documents" - ], - "summary": "Bulk Restore", - "operationId": "bulk_restore_api_v1_documents_bulk_restore_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BulkRestoreRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/documents/trash/empty": { - "post": { - "tags": [ - "documents" - ], - "summary": "Empty Trash", - "operationId": "empty_trash_api_v1_documents_trash_empty_post", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - } - } - } - }, - "/api/v1/documents/{id}/file": { - "get": { - "tags": [ - "documents" - ], - "summary": "Get Document File", - "operationId": "get_document_file_api_v1_documents__id__file_get", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/documents/{id}/thumbnail": { - "get": { - "tags": [ - "documents" - ], - "summary": "Get Document Thumbnail", - "operationId": "get_document_thumbnail_api_v1_documents__id__thumbnail_get", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/documents/{id}/assets": { - "post": { - "tags": [ - "Assets" - ], - "summary": "Upload Asset", - "description": "Upload a PNG, JPEG, GIF, or WebP annotation asset.", - "operationId": "upload_asset_api_v1_documents__id__assets_post", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "multipart/form-data": { - "schema": { - "$ref": "#/components/schemas/Body_upload_asset_api_v1_documents__id__assets_post" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "title": "Response Upload Asset Api V1 Documents Id Assets Post" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/documents/{id}/assets/{ref}": { - "get": { - "tags": [ - "Assets" - ], - "summary": "Get Asset", - "description": "Serve an annotation asset to an authenticated session.", - "operationId": "get_asset_api_v1_documents__id__assets__ref__get", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Id" - } - }, - { - "name": "ref", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Ref" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/documents/{document_id}/annotations": { - "get": { - "tags": [ - "annotations" - ], - "summary": "Get Annotations", - "operationId": "get_annotations_api_v1_documents__document_id__annotations_get", - "parameters": [ - { - "name": "document_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Document Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AnnotationStateResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "put": { - "tags": [ - "annotations" - ], - "summary": "Update Annotations", - "operationId": "update_annotations_api_v1_documents__document_id__annotations_put", - "parameters": [ - { - "name": "document_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Document Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AnnotationStateUpdateRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AnnotationStateUpdateResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/documents/{document_id}/versions": { - "get": { - "tags": [ - "versions" - ], - "summary": "List Versions", - "operationId": "list_versions_api_v1_documents__document_id__versions_get", - "parameters": [ - { - "name": "document_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Document Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VersionListResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "post": { - "tags": [ - "versions" - ], - "summary": "Create Version", - "operationId": "create_version_api_v1_documents__document_id__versions_post", - "parameters": [ - { - "name": "document_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Document Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VersionCreateRequest" - } - } - } - }, - "responses": { - "201": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VersionMeta" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/documents/{document_id}/versions/{version_id}": { - "get": { - "tags": [ - "versions" - ], - "summary": "Get Version", - "operationId": "get_version_api_v1_documents__document_id__versions__version_id__get", - "parameters": [ - { - "name": "document_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Document Id" - } - }, - { - "name": "version_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Version Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VersionDataResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - }, - "delete": { - "tags": [ - "versions" - ], - "summary": "Delete Version", - "operationId": "delete_version_api_v1_documents__document_id__versions__version_id__delete", - "parameters": [ - { - "name": "document_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Document Id" - } - }, - { - "name": "version_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Version Id" - } - } - ], - "responses": { - "204": { - "description": "Successful Response" - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/documents/{document_id}/versions/{version_id}/restore": { - "post": { - "tags": [ - "versions" - ], - "summary": "Restore Version", - "operationId": "restore_version_api_v1_documents__document_id__versions__version_id__restore_post", - "parameters": [ - { - "name": "document_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Document Id" - } - }, - { - "name": "version_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Version Id" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/VersionRestoreResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/documents/{document_id}/export": { - "post": { - "tags": [ - "export" - ], - "summary": "Export Document", - "description": "Export the working layer or a selected version as a downloadable PDF.", - "operationId": "export_document_api_v1_documents__document_id__export_post", - "parameters": [ - { - "name": "document_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Document Id" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ExportRequest" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/health": { - "get": { - "tags": [ - "health" - ], - "summary": "Health Check", - "description": "Return service health status and version.", - "operationId": "health_check_api_v1_health_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "additionalProperties": { - "type": "string" - }, - "type": "object", - "title": "Response Health Check Api V1 Health Get" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "AnnotationStateResponse": { - "properties": { - "data": { - "items": { - "additionalProperties": true, - "type": "object" - }, - "type": "array", - "title": "Data" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "title": "Updatedat" - } - }, - "type": "object", - "required": [ - "data", - "updatedAt" - ], - "title": "AnnotationStateResponse" - }, - "AnnotationStateUpdateRequest": { - "properties": { - "data": { - "items": { - "additionalProperties": true, - "type": "object" - }, - "type": "array", - "title": "Data" - }, - "baseUpdatedAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time" - }, - { - "type": "null" - } - ], - "title": "Baseupdatedat" - } - }, - "type": "object", - "required": [ - "data" - ], - "title": "AnnotationStateUpdateRequest" - }, - "AnnotationStateUpdateResponse": { - "properties": { - "updatedAt": { - "type": "string", - "format": "date-time", - "title": "Updatedat" - } - }, - "type": "object", - "required": [ - "updatedAt" - ], - "title": "AnnotationStateUpdateResponse" - }, - "AuthStatusResponse": { - "properties": { - "setupRequired": { - "type": "boolean", - "title": "Setuprequired" - }, - "loggedIn": { - "type": "boolean", - "title": "Loggedin" - } - }, - "type": "object", - "required": [ - "setupRequired", - "loggedIn" - ], - "title": "AuthStatusResponse" - }, - "Body_upload_asset_api_v1_documents__id__assets_post": { - "properties": { - "file": { - "type": "string", - "contentMediaType": "application/octet-stream", - "title": "File" - } - }, - "type": "object", - "required": [ - "file" - ], - "title": "Body_upload_asset_api_v1_documents__id__assets_post" - }, - "Body_upload_document_api_v1_documents_post": { - "properties": { - "file": { - "type": "string", - "contentMediaType": "application/octet-stream", - "title": "File" - } - }, - "type": "object", - "required": [ - "file" - ], - "title": "Body_upload_document_api_v1_documents_post" - }, - "BulkDeleteRequest": { - "properties": { - "ids": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Ids" - } - }, - "type": "object", - "required": [ - "ids" - ], - "title": "BulkDeleteRequest" - }, - "BulkRestoreRequest": { - "properties": { - "ids": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Ids" - } - }, - "type": "object", - "required": [ - "ids" - ], - "title": "BulkRestoreRequest" - }, - "ChangePasswordRequest": { - "properties": { - "current_password": { - "type": "string", - "title": "Current Password" - }, - "new_password": { - "type": "string", - "minLength": 8, - "title": "New Password" - } - }, - "type": "object", - "required": [ - "current_password", - "new_password" - ], - "title": "ChangePasswordRequest" - }, - "DocumentListResponse": { - "properties": { - "items": { - "items": { - "$ref": "#/components/schemas/DocumentMeta" - }, - "type": "array", - "title": "Items" - }, - "total": { - "type": "integer", - "title": "Total" - } - }, - "type": "object", - "required": [ - "items", - "total" - ], - "title": "DocumentListResponse" - }, - "DocumentMeta": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "title": { - "type": "string", - "title": "Title" - }, - "filename": { - "type": "string", - "title": "Filename" - }, - "mime_type": { - "type": "string", - "title": "Mime Type" - }, - "size_bytes": { - "type": "integer", - "title": "Size Bytes" - }, - "in_trash": { - "type": "boolean", - "title": "In Trash" - }, - "created_at": { - "type": "string", - "title": "Created At" - }, - "updated_at": { - "type": "string", - "title": "Updated At" - }, - "deleted_at": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Deleted At" - } - }, - "type": "object", - "required": [ - "id", - "title", - "filename", - "mime_type", - "size_bytes", - "in_trash", - "created_at", - "updated_at", - "deleted_at" - ], - "title": "DocumentMeta" - }, - "DocumentUpdateRequest": { - "properties": { - "title": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Title" - }, - "in_trash": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "null" - } - ], - "title": "In Trash" - } - }, - "type": "object", - "title": "DocumentUpdateRequest" - }, - "ExportRequest": { - "properties": { - "versionId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Versionid" - }, - "flatten": { - "type": "boolean", - "title": "Flatten", - "default": true - } - }, - "type": "object", - "title": "ExportRequest" - }, - "HTTPValidationError": { - "properties": { - "detail": { - "items": { - "$ref": "#/components/schemas/ValidationError" - }, - "type": "array", - "title": "Detail" - } - }, - "type": "object", - "title": "HTTPValidationError" - }, - "LoginRequest": { - "properties": { - "password": { - "type": "string", - "title": "Password" - } - }, - "type": "object", - "required": [ - "password" - ], - "title": "LoginRequest" - }, - "SetupRequest": { - "properties": { - "password": { - "type": "string", - "minLength": 8, - "title": "Password" - } - }, - "type": "object", - "required": [ - "password" - ], - "title": "SetupRequest" - }, - "ValidationError": { - "properties": { - "loc": { - "items": { - "anyOf": [ - { - "type": "string" + "/api/v1/auth/setup": { + "post": { + "tags": [ + "auth" + ], + "summary": "Setup Password", + "description": "First-run setup.", + "operationId": "setup_password_api_v1_auth_setup_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetupRequest" + } + } + }, + "required": true }, - { - "type": "integer" + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Response Setup Password Api V1 Auth Setup Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "tags": [ + "auth" + ], + "summary": "Login", + "description": "Validate password and issue session cookie.", + "operationId": "login_api_v1_auth_login_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Response Login Api V1 Auth Login Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/auth/logout": { + "post": { + "tags": [ + "auth" + ], + "summary": "Logout", + "description": "Clear session cookie.", + "operationId": "logout_api_v1_auth_logout_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Response Logout Api V1 Auth Logout Post" + } + } + } + } + } + } + }, + "/api/v1/auth/password": { + "put": { + "tags": [ + "auth" + ], + "summary": "Change Password", + "description": "Change an existing password.", + "operationId": "change_password_api_v1_auth_password_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChangePasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Response Change Password Api V1 Auth Password Put" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/documents": { + "get": { + "tags": [ + "documents" + ], + "summary": "List Documents", + "description": "List non-trashed documents.", + "operationId": "list_documents_api_v1_documents_get", + "parameters": [ + { + "name": "query", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "", + "title": "Query" + } + }, + { + "name": "skip", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0, + "title": "Skip" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "default": 50, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } } - ] }, - "type": "array", - "title": "Location" - }, - "msg": { - "type": "string", - "title": "Message" - }, - "type": { - "type": "string", - "title": "Error Type" - }, - "input": { - "title": "Input" - }, - "ctx": { - "type": "object", - "title": "Context" - } + "post": { + "tags": [ + "documents" + ], + "summary": "Upload Document", + "description": "Upload a new PDF document.", + "operationId": "upload_document_api_v1_documents_post", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_upload_document_api_v1_documents_post" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentMeta" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } }, - "type": "object", - "required": [ - "loc", - "msg", - "type" - ], - "title": "ValidationError" - }, - "VersionCreateRequest": { - "properties": { - "label": { - "anyOf": [ - { - "type": "string", - "maxLength": 120 - }, - { - "type": "null" - } - ], - "title": "Label" - }, - "kind": { - "type": "string", - "enum": [ - "manual", - "auto" - ], - "title": "Kind", - "default": "manual" - } + "/api/v1/documents/trash": { + "get": { + "tags": [ + "documents" + ], + "summary": "List Trash", + "description": "List trashed documents.", + "operationId": "list_trash_api_v1_documents_trash_get", + "parameters": [ + { + "name": "skip", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0, + "title": "Skip" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "minimum": 1, + "default": 50, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } }, - "type": "object", - "title": "VersionCreateRequest" - }, - "VersionDataResponse": { - "properties": { - "data": { - "items": { - "additionalProperties": true, - "type": "object" + "/api/v1/documents/{id}": { + "get": { + "tags": [ + "documents" + ], + "summary": "Get Document", + "operationId": "get_document_api_v1_documents__id__get", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentMeta" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } }, - "type": "array", - "title": "Data" - }, - "meta": { - "$ref": "#/components/schemas/VersionMeta" - } - }, - "type": "object", - "required": [ - "data", - "meta" - ], - "title": "VersionDataResponse" - }, - "VersionListResponse": { - "properties": { - "items": { - "items": { - "$ref": "#/components/schemas/VersionMeta" + "patch": { + "tags": [ + "documents" + ], + "summary": "Update Document", + "operationId": "update_document_api_v1_documents__id__patch", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentMeta" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } }, - "type": "array", - "title": "Items" - } + "delete": { + "tags": [ + "documents" + ], + "summary": "Delete Document", + "description": "Soft delete by default. Hard delete if permanent=True AND already in trash.", + "operationId": "delete_document_api_v1_documents__id__delete", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + }, + { + "name": "permanent", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Permanent" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } }, - "type": "object", - "required": [ - "items" - ], - "title": "VersionListResponse" - }, - "VersionMeta": { - "properties": { - "id": { - "type": "string", - "title": "Id" - }, - "documentId": { - "type": "string", - "title": "Documentid" - }, - "label": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Label" - }, - "kind": { - "type": "string", - "enum": [ - "manual", - "auto" - ], - "title": "Kind" - }, - "createdAt": { - "type": "string", - "format": "date-time", - "title": "Createdat" - }, - "annotationCount": { - "type": "integer", - "title": "Annotationcount" - } + "/api/v1/documents/{id}/restore": { + "post": { + "tags": [ + "documents" + ], + "summary": "Restore Document", + "operationId": "restore_document_api_v1_documents__id__restore_post", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DocumentMeta" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } }, - "type": "object", - "required": [ - "id", - "documentId", - "label", - "kind", - "createdAt", - "annotationCount" - ], - "title": "VersionMeta" - }, - "VersionRestoreResponse": { - "properties": { - "updatedAt": { - "type": "string", - "format": "date-time", - "title": "Updatedat" - } + "/api/v1/documents/bulk-delete": { + "post": { + "tags": [ + "documents" + ], + "summary": "Bulk Delete", + "operationId": "bulk_delete_api_v1_documents_bulk_delete_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkDeleteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } }, - "type": "object", - "required": [ - "updatedAt" - ], - "title": "VersionRestoreResponse" - } + "/api/v1/documents/bulk-restore": { + "post": { + "tags": [ + "documents" + ], + "summary": "Bulk Restore", + "operationId": "bulk_restore_api_v1_documents_bulk_restore_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkRestoreRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/documents/trash/empty": { + "post": { + "tags": [ + "documents" + ], + "summary": "Empty Trash", + "operationId": "empty_trash_api_v1_documents_trash_empty_post", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/v1/documents/{id}/file": { + "get": { + "tags": [ + "documents" + ], + "summary": "Get Document File", + "operationId": "get_document_file_api_v1_documents__id__file_get", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/documents/{id}/thumbnail": { + "get": { + "tags": [ + "documents" + ], + "summary": "Get Document Thumbnail", + "operationId": "get_document_thumbnail_api_v1_documents__id__thumbnail_get", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/documents/{document_id}/annotations": { + "get": { + "tags": [ + "Annotations" + ], + "summary": "Get Annotations", + "operationId": "get_annotations_api_v1_documents__document_id__annotations_get", + "parameters": [ + { + "name": "document_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Document Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnnotationStateResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "Annotations" + ], + "summary": "Update Annotations", + "operationId": "update_annotations_api_v1_documents__document_id__annotations_put", + "parameters": [ + { + "name": "document_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Document Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnnotationStateUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnnotationStateUpdateResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/health": { + "get": { + "tags": [ + "health" + ], + "summary": "Health Check", + "description": "Return service health status and version.", + "operationId": "health_check_api_v1_health_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Response Health Check Api V1 Health Get" + } + } + } + } + } + } + }, + "/api/v1/debug/verify-coords": { + "post": { + "tags": [ + "Debug" + ], + "summary": "Verify Coords", + "description": "Test endpoint for cross-engine coordinate verification.\nTakes a canonical rect, draws it on the PDF using PyMuPDF,\nand returns the flattened PDF.", + "operationId": "verify_coords_api_v1_debug_verify_coords_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VerifyCoordsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AnnotationStateResponse": { + "properties": { + "data": { + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/TextAnnotation" + }, + { + "$ref": "#/components/schemas/DrawAnnotation" + }, + { + "$ref": "#/components/schemas/SignatureAnnotation" + }, + { + "$ref": "#/components/schemas/ImageAnnotation" + }, + { + "$ref": "#/components/schemas/HighlightAnnotation" + }, + { + "$ref": "#/components/schemas/ShapeAnnotation" + } + ] + }, + "type": "array", + "title": "Data" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "title": "Updatedat" + } + }, + "type": "object", + "required": [ + "data", + "updatedAt" + ], + "title": "AnnotationStateResponse" + }, + "AnnotationStateUpdateRequest": { + "properties": { + "data": { + "items": { + "anyOf": [ + { + "$ref": "#/components/schemas/TextAnnotation" + }, + { + "$ref": "#/components/schemas/DrawAnnotation" + }, + { + "$ref": "#/components/schemas/SignatureAnnotation" + }, + { + "$ref": "#/components/schemas/ImageAnnotation" + }, + { + "$ref": "#/components/schemas/HighlightAnnotation" + }, + { + "$ref": "#/components/schemas/ShapeAnnotation" + } + ] + }, + "type": "array", + "title": "Data" + }, + "baseUpdatedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Baseupdatedat" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "AnnotationStateUpdateRequest" + }, + "AnnotationStateUpdateResponse": { + "properties": { + "updatedAt": { + "type": "string", + "format": "date-time", + "title": "Updatedat" + } + }, + "type": "object", + "required": [ + "updatedAt" + ], + "title": "AnnotationStateUpdateResponse" + }, + "AuthStatusResponse": { + "properties": { + "setupRequired": { + "type": "boolean", + "title": "Setuprequired" + }, + "loggedIn": { + "type": "boolean", + "title": "Loggedin" + } + }, + "type": "object", + "required": [ + "setupRequired", + "loggedIn" + ], + "title": "AuthStatusResponse" + }, + "Body_upload_document_api_v1_documents_post": { + "properties": { + "file": { + "type": "string", + "contentMediaType": "application/octet-stream", + "title": "File" + } + }, + "type": "object", + "required": [ + "file" + ], + "title": "Body_upload_document_api_v1_documents_post" + }, + "BulkDeleteRequest": { + "properties": { + "ids": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Ids" + } + }, + "type": "object", + "required": [ + "ids" + ], + "title": "BulkDeleteRequest" + }, + "BulkRestoreRequest": { + "properties": { + "ids": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Ids" + } + }, + "type": "object", + "required": [ + "ids" + ], + "title": "BulkRestoreRequest" + }, + "ChangePasswordRequest": { + "properties": { + "current_password": { + "type": "string", + "title": "Current Password" + }, + "new_password": { + "type": "string", + "minLength": 8, + "title": "New Password" + } + }, + "type": "object", + "required": [ + "current_password", + "new_password" + ], + "title": "ChangePasswordRequest" + }, + "DocumentListResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/DocumentMeta" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + } + }, + "type": "object", + "required": [ + "items", + "total" + ], + "title": "DocumentListResponse" + }, + "DocumentMeta": { + "properties": { + "id": { + "type": "string", + "title": "Id" + }, + "title": { + "type": "string", + "title": "Title" + }, + "filename": { + "type": "string", + "title": "Filename" + }, + "mime_type": { + "type": "string", + "title": "Mime Type" + }, + "size_bytes": { + "type": "integer", + "title": "Size Bytes" + }, + "in_trash": { + "type": "boolean", + "title": "In Trash" + }, + "created_at": { + "type": "string", + "title": "Created At" + }, + "updated_at": { + "type": "string", + "title": "Updated At" + }, + "deleted_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Deleted At" + } + }, + "type": "object", + "required": [ + "id", + "title", + "filename", + "mime_type", + "size_bytes", + "in_trash", + "created_at", + "updated_at", + "deleted_at" + ], + "title": "DocumentMeta" + }, + "DocumentUpdateRequest": { + "properties": { + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Title" + }, + "in_trash": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "In Trash" + } + }, + "type": "object", + "title": "DocumentUpdateRequest" + }, + "DrawAnnotation": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "page": { + "type": "integer", + "minimum": 0.0, + "title": "Page" + }, + "type": { + "type": "string", + "const": "draw", + "title": "Type" + }, + "rect": { + "$ref": "#/components/schemas/Rect" + }, + "rotation": { + "type": "number", + "title": "Rotation", + "default": 0.0 + }, + "z": { + "type": "integer", + "title": "Z", + "default": 0 + }, + "createdAt": { + "type": "string", + "format": "date-time", + "title": "Createdat" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "title": "Updatedat" + }, + "props": { + "$ref": "#/components/schemas/DrawProps" + } + }, + "type": "object", + "required": [ + "id", + "page", + "type", + "rect", + "createdAt", + "updatedAt", + "props" + ], + "title": "DrawAnnotation" + }, + "DrawProps": { + "properties": { + "paths": { + "items": { + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "type": "array", + "maxItems": 2, + "minItems": 2 + }, + "type": "array", + "title": "Paths" + }, + "strokeColor": { + "type": "string", + "title": "Strokecolor", + "default": "#000000" + }, + "strokeWidth": { + "type": "number", + "title": "Strokewidth", + "default": 2 + }, + "opacity": { + "type": "number", + "title": "Opacity", + "default": 1.0 + } + }, + "type": "object", + "required": [ + "paths" + ], + "title": "DrawProps" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "type": "array", + "title": "Detail" + } + }, + "type": "object", + "title": "HTTPValidationError" + }, + "HighlightAnnotation": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "page": { + "type": "integer", + "minimum": 0.0, + "title": "Page" + }, + "type": { + "type": "string", + "const": "highlight", + "title": "Type" + }, + "rect": { + "$ref": "#/components/schemas/Rect" + }, + "rotation": { + "type": "number", + "title": "Rotation", + "default": 0.0 + }, + "z": { + "type": "integer", + "title": "Z", + "default": 0 + }, + "createdAt": { + "type": "string", + "format": "date-time", + "title": "Createdat" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "title": "Updatedat" + }, + "props": { + "$ref": "#/components/schemas/HighlightProps" + } + }, + "type": "object", + "required": [ + "id", + "page", + "type", + "rect", + "createdAt", + "updatedAt", + "props" + ], + "title": "HighlightAnnotation" + }, + "HighlightProps": { + "properties": { + "color": { + "type": "string", + "title": "Color", + "default": "#FFEB3B" + }, + "opacity": { + "type": "number", + "title": "Opacity", + "default": 0.3 + } + }, + "type": "object", + "title": "HighlightProps" + }, + "ImageAnnotation": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "page": { + "type": "integer", + "minimum": 0.0, + "title": "Page" + }, + "type": { + "type": "string", + "const": "image", + "title": "Type" + }, + "rect": { + "$ref": "#/components/schemas/Rect" + }, + "rotation": { + "type": "number", + "title": "Rotation", + "default": 0.0 + }, + "z": { + "type": "integer", + "title": "Z", + "default": 0 + }, + "createdAt": { + "type": "string", + "format": "date-time", + "title": "Createdat" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "title": "Updatedat" + }, + "props": { + "$ref": "#/components/schemas/ImageProps" + } + }, + "type": "object", + "required": [ + "id", + "page", + "type", + "rect", + "createdAt", + "updatedAt", + "props" + ], + "title": "ImageAnnotation" + }, + "ImageProps": { + "properties": { + "ref": { + "type": "string", + "title": "Ref" + }, + "naturalWidth": { + "type": "number", + "title": "Naturalwidth" + }, + "naturalHeight": { + "type": "number", + "title": "Naturalheight" + } + }, + "type": "object", + "required": [ + "ref", + "naturalWidth", + "naturalHeight" + ], + "title": "ImageProps" + }, + "LoginRequest": { + "properties": { + "password": { + "type": "string", + "title": "Password" + } + }, + "type": "object", + "required": [ + "password" + ], + "title": "LoginRequest" + }, + "Rect": { + "properties": { + "x": { + "type": "number", + "title": "X" + }, + "y": { + "type": "number", + "title": "Y" + }, + "width": { + "type": "number", + "title": "Width" + }, + "height": { + "type": "number", + "title": "Height" + } + }, + "type": "object", + "required": [ + "x", + "y", + "width", + "height" + ], + "title": "Rect" + }, + "SetupRequest": { + "properties": { + "password": { + "type": "string", + "minLength": 8, + "title": "Password" + } + }, + "type": "object", + "required": [ + "password" + ], + "title": "SetupRequest" + }, + "ShapeAnnotation": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "page": { + "type": "integer", + "minimum": 0.0, + "title": "Page" + }, + "type": { + "type": "string", + "const": "shape", + "title": "Type" + }, + "rect": { + "$ref": "#/components/schemas/Rect" + }, + "rotation": { + "type": "number", + "title": "Rotation", + "default": 0.0 + }, + "z": { + "type": "integer", + "title": "Z", + "default": 0 + }, + "createdAt": { + "type": "string", + "format": "date-time", + "title": "Createdat" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "title": "Updatedat" + }, + "props": { + "$ref": "#/components/schemas/ShapeProps" + } + }, + "type": "object", + "required": [ + "id", + "page", + "type", + "rect", + "createdAt", + "updatedAt", + "props" + ], + "title": "ShapeAnnotation" + }, + "ShapeProps": { + "properties": { + "kind": { + "type": "string", + "enum": [ + "rect", + "ellipse", + "line", + "arrow" + ], + "title": "Kind" + }, + "strokeColor": { + "type": "string", + "title": "Strokecolor", + "default": "#000000" + }, + "fillColor": { + "type": "string", + "title": "Fillcolor", + "default": "transparent" + }, + "strokeWidth": { + "type": "number", + "title": "Strokewidth", + "default": 2 + } + }, + "type": "object", + "required": [ + "kind" + ], + "title": "ShapeProps" + }, + "SignatureAnnotation": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "page": { + "type": "integer", + "minimum": 0.0, + "title": "Page" + }, + "type": { + "type": "string", + "const": "signature", + "title": "Type" + }, + "rect": { + "$ref": "#/components/schemas/Rect" + }, + "rotation": { + "type": "number", + "title": "Rotation", + "default": 0.0 + }, + "z": { + "type": "integer", + "title": "Z", + "default": 0 + }, + "createdAt": { + "type": "string", + "format": "date-time", + "title": "Createdat" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "title": "Updatedat" + }, + "props": { + "oneOf": [ + { + "$ref": "#/components/schemas/SignatureDrawProps" + }, + { + "$ref": "#/components/schemas/SignatureTypeProps" + } + ], + "title": "Props", + "discriminator": { + "propertyName": "mode", + "mapping": { + "draw": "#/components/schemas/SignatureDrawProps", + "type": "#/components/schemas/SignatureTypeProps" + } + } + } + }, + "type": "object", + "required": [ + "id", + "page", + "type", + "rect", + "createdAt", + "updatedAt", + "props" + ], + "title": "SignatureAnnotation" + }, + "SignatureDrawProps": { + "properties": { + "mode": { + "type": "string", + "const": "draw", + "title": "Mode" + }, + "ref": { + "type": "string", + "title": "Ref" + }, + "strokeColor": { + "type": "string", + "title": "Strokecolor", + "default": "#000000" + } + }, + "type": "object", + "required": [ + "mode", + "ref" + ], + "title": "SignatureDrawProps" + }, + "SignatureTypeProps": { + "properties": { + "mode": { + "type": "string", + "const": "type", + "title": "Mode" + }, + "text": { + "type": "string", + "title": "Text" + }, + "fontFamily": { + "type": "string", + "title": "Fontfamily" + }, + "color": { + "type": "string", + "title": "Color", + "default": "#000000" + } + }, + "type": "object", + "required": [ + "mode", + "text", + "fontFamily" + ], + "title": "SignatureTypeProps" + }, + "TextAnnotation": { + "properties": { + "id": { + "type": "string", + "format": "uuid", + "title": "Id" + }, + "page": { + "type": "integer", + "minimum": 0.0, + "title": "Page" + }, + "type": { + "type": "string", + "const": "text", + "title": "Type" + }, + "rect": { + "$ref": "#/components/schemas/Rect" + }, + "rotation": { + "type": "number", + "title": "Rotation", + "default": 0.0 + }, + "z": { + "type": "integer", + "title": "Z", + "default": 0 + }, + "createdAt": { + "type": "string", + "format": "date-time", + "title": "Createdat" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "title": "Updatedat" + }, + "props": { + "$ref": "#/components/schemas/TextProps" + } + }, + "type": "object", + "required": [ + "id", + "page", + "type", + "rect", + "createdAt", + "updatedAt", + "props" + ], + "title": "TextAnnotation" + }, + "TextProps": { + "properties": { + "text": { + "type": "string", + "title": "Text" + }, + "fontFamily": { + "type": "string", + "title": "Fontfamily", + "default": "Liberation Sans" + }, + "fontSize": { + "type": "number", + "title": "Fontsize", + "default": 14 + }, + "color": { + "type": "string", + "title": "Color", + "default": "#000000" + }, + "align": { + "type": "string", + "enum": [ + "left", + "center", + "right" + ], + "title": "Align", + "default": "left" + }, + "bold": { + "type": "boolean", + "title": "Bold", + "default": false + }, + "italic": { + "type": "boolean", + "title": "Italic", + "default": false + }, + "lineHeight": { + "type": "number", + "title": "Lineheight", + "default": 1.2 + } + }, + "type": "object", + "required": [ + "text" + ], + "title": "TextProps" + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "type": "array", + "title": "Location" + }, + "msg": { + "type": "string", + "title": "Message" + }, + "type": { + "type": "string", + "title": "Error Type" + }, + "input": { + "title": "Input" + }, + "ctx": { + "type": "object", + "title": "Context" + } + }, + "type": "object", + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError" + }, + "VerifyCoordsRequest": { + "properties": { + "document_id": { + "type": "string", + "title": "Document Id" + }, + "page": { + "type": "integer", + "title": "Page" + }, + "x": { + "type": "number", + "title": "X" + }, + "y": { + "type": "number", + "title": "Y" + }, + "width": { + "type": "number", + "title": "Width" + }, + "height": { + "type": "number", + "title": "Height" + } + }, + "type": "object", + "required": [ + "document_id", + "page", + "x", + "y", + "width", + "height" + ], + "title": "VerifyCoordsRequest" + } + } } - } -} \ No newline at end of file +} diff --git a/shared/annotation-schema.json b/shared/annotation-schema.json index bd1e3fd..accf8c2 100644 --- a/shared/annotation-schema.json +++ b/shared/annotation-schema.json @@ -104,9 +104,7 @@ "kind": { "type": "string", "enum": ["rect", "ellipse", "line", "arrow"] }, "strokeColor": { "type": "string", "default": "#000000" }, "fillColor": { "type": "string", "default": "transparent" }, - "strokeWidth": { "type": "number", "default": 2 }, - "start": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 }, - "end": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 } + "strokeWidth": { "type": "number", "default": 2 } }, "required": ["kind"] }