diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9d0edc8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +.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 0328e43..e0cf067 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=change-me-in-production +SECRET_KEY=replace-with-a-long-random-secret # -------------------------------------------- # Upload Limits @@ -40,9 +40,13 @@ COOKIE_SECURE=false # -------------------------------------------- # Networking # -------------------------------------------- -# Port the frontend container publishes. Your outer reverse proxy targets this. +# Port the PaperJet 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 deleted file mode 100644 index 4ed4ca5..0000000 --- a/.forgejo/workflows/build.yml +++ /dev/null @@ -1,25 +0,0 @@ -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 39f912e..994bcdc 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -69,3 +69,73 @@ 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 new file mode 100644 index 0000000..0282e40 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +# 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 e22db8e..2a934a5 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.1 +**Document version:** 1.2 **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.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**. +> **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. --- @@ -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's own containers 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 image 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 (frontend) | nginx | Serves the static React build; also the app-internal entry | -| ASGI server (backend) | uvicorn | Behind nginx | +| 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 | | Orchestration | Docker Compose | | | External programs / containers / APIs | **None** | Hard requirement | @@ -98,43 +98,44 @@ Because the app is WAN-exposed, auth is a real login screen backed by an Argon2i ┌─────────────▼─────────────────────────────┐ │ Docker Compose │ │ │ - │ ┌──────────────┐ ┌────────────────┐ │ - │ │ frontend │ │ backend │ │ - │ │ nginx │─────▶│ FastAPI │ │ - │ │ serves SPA │ /api │ uvicorn │ │ - │ │ proxies /api│ │ PyMuPDF │ │ - │ └──────────────┘ │ SQLAlchemy │ │ - │ └───────┬────────┘ │ - │ │ │ - │ ┌──────────▼────────┐ │ - │ │ volumes │ │ - │ │ pdf_storage/ │ │ - │ │ thumbnails/ │ │ - │ │ db/app.sqlite │ │ - │ └───────────────────┘ │ + │ ┌──────────────────────────────────────┐ │ + │ │ paperjet image │ │ + │ │ nginx :80 ── /api ──▶ uvicorn :8000 │ │ + │ │ React SPA FastAPI + PyMuPDF │ │ + │ └───────────────────┬──────────────────┘ │ + │ │ │ + │ ┌──────────▼────────┐ │ + │ │ volumes │ │ + │ │ pdf_storage/ │ │ + │ │ thumbnails/ │ │ + │ │ db/app.sqlite │ │ + │ └───────────────────┘ │ └────────────────────────────────────────────┘ ``` -- 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. +- 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. --- ## 5. Repository Structure -A single monorepo. Two deployable images. +A single monorepo with one deployable runtime image. The Dockerfile uses a +Node build stage and a Python/nginx runtime stage. ``` paperjet/ ├── README.md ├── ARCHITECTURE.md # symlink or copy of THIS plan; agents read first ├── docker-compose.yml +├── Dockerfile +├── docker-entrypoint.sh +├── .dockerignore ├── .env.example -├── .github/workflows/ci.yml # lint + typecheck + test on push +├── .forgejo/workflows/ci.yml # lint, tests, image build, and publish │ ├── frontend/ -│ ├── Dockerfile # multi-stage: build SPA, serve via nginx -│ ├── nginx.conf # SPA fallback + /api proxy +│ ├── nginx.conf # SPA fallback + loopback /api proxy │ ├── package.json │ ├── tsconfig.json # strict: true │ ├── vite.config.ts @@ -163,7 +164,6 @@ 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 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. +- 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. ### 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 (illustrative; agents finalize) +# docker-compose.yml services: - backend: - build: ./backend + paperjet: + image: ${PAPERJET_IMAGE:-paperjet:local} + build: + context: . + dockerfile: Dockerfile environment: - - 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) + 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} 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:-8080}:80" # user's outer reverse proxy targets this + - "${HTTP_PORT:-4982}:80" restart: unless-stopped volumes: @@ -530,10 +530,10 @@ volumes: db: ``` -- `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. +- `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. - 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. -- 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. +- 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. - 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 existing two images 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 application image 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, 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. +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. **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.1. Amendments are made to this document first, then to code.* +*End of plan v1.2. Amendments are made to this document first, then to code.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..f70815f --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# 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 deleted file mode 100644 index 0879421..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,25 +0,0 @@ -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 23ba9db..63a7402 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: F401, F403 — ensure all models are imported +from app.models import * # noqa: 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 500dc87..ce8bd15 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -2,9 +2,14 @@ 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") @@ -15,17 +20,18 @@ 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 2db3dcd..24f1f31 100644 --- a/backend/app/api/v1/annotations.py +++ b/backend/app/api/v1/annotations.py @@ -1,91 +1,95 @@ -from typing import Any +"""Opaque working-state annotation endpoints.""" + +import json +from datetime import UTC, datetime + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.orm import Session -from datetime import datetime, timezone -import json -from app.db import get_db -from app.models.document import Document -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 +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"]) -router = APIRouter(prefix="/documents", tags=["Annotations"]) def _now_iso() -> str: - return datetime.now(timezone.utc).isoformat() + return datetime.now(UTC).isoformat() -@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: + +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: raise HTTPException(status_code=404, detail="Document not found") - - state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id)) - + return document + + +def _read_data(state: AnnotationState | None) -> list[AnnotationPayload]: if not state: - return AnnotationStateResponse(data=[], updatedAt=datetime.now(timezone.utc)) - + return [] try: data = json.loads(state.data) except json.JSONDecodeError: - data = [] - - return AnnotationStateResponse( - data=data, - updatedAt=datetime.fromisoformat(state.updated_at) - ) + return [] + return data if isinstance(data, list) else [] -@router.put("/{document_id}/annotations", response_model=AnnotationStateUpdateResponse, dependencies=[Depends(verify_csrf)]) -async def update_annotations( + +@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( document_id: str, request: AnnotationStateUpdateRequest, 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") - + _user_id: int = Depends(get_current_user), +) -> AnnotationStateUpdateResponse: + document = _document_or_404(document_id, db) state = db.scalar(select(AnnotationState).where(AnnotationState.document_id == document_id)) - - # Check for stale tab + if state and 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 + current_updated_at = _utc(datetime.fromisoformat(state.updated_at)) + request_updated_at = _utc(request.baseUpdatedAt) 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() - - 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: + now = _now_iso() + data_json = json.dumps(request.data, separators=(",", ":")) + if state: state.data = data_json state.updated_at = now - - # Bump document updated_at - doc.updated_at = now - + else: + db.add(AnnotationState(document_id=document_id, data=data_json, updated_at=now)) + document.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 9f51004..0ae751b 100644 --- a/backend/app/api/v1/assets.py +++ b/backend/app/api/v1/assets.py @@ -1,58 +1,101 @@ -from fastapi import APIRouter, Depends, UploadFile, File, HTTPException -from fastapi.responses import FileResponse -from sqlalchemy.orm import Session +"""Authenticated binary assets used by annotations.""" + import uuid -import shutil from pathlib import Path -from app.db import get_db -from app.auth.dependencies import get_current_user +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 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 -@router.post("/{id}/assets") +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)]) async def upload_asset( id: str, file: UploadFile = File(...), user_id: int = Depends(get_current_user), - 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") + 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") ref = str(uuid.uuid4()) - filepath = get_asset_path(id, ref) - - try: - 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}") + filepath = get_asset_path(id, ref, create=True) + total_bytes = 0 + + 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}"} - return { - "ref": ref, - "url": f"/api/v1/documents/{id}/assets/{ref}" - } @router.get("/{id}/assets/{ref}") async def get_asset( id: str, ref: str, -): - """Serve a binary asset.""" + 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") + 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 c05c534..4d14cd2 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 4d081f7..ae0dc79 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 e: - raise HTTPException(status_code=500, detail=str(e)) + except Exception as error: + raise HTTPException(status_code=500, detail=str(error)) from error 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 7e572b2..f1556a4 100644 --- a/backend/app/api/v1/documents.py +++ b/backend/app/api/v1/documents.py @@ -1,28 +1,31 @@ """Document management endpoints.""" -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query +from datetime import UTC, datetime + +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status from fastapi.responses import FileResponse -from sqlalchemy.orm import Session from sqlalchemy import desc -from datetime import datetime, timezone +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.models.annotation_state import AnnotationState +from app.models.document import Document from app.models.version import Version from app.schemas.document import ( - DocumentListResponse, DocumentMeta, BulkDeleteRequest, - BulkRestoreRequest, DocumentUpdateRequest + BulkDeleteRequest, + BulkRestoreRequest, + DocumentListResponse, + DocumentMeta, + DocumentUpdateRequest, ) -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 +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 router = APIRouter( - prefix="/documents", - tags=["documents"], - dependencies=[Depends(get_current_user)] + prefix="/documents", tags=["documents"], dependencies=[Depends(get_current_user)] ) @@ -31,109 +34,135 @@ def list_documents( query: str = "", skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), - db: Session = Depends(get_db) -): + db: Session = Depends(get_db), +) -> DocumentListResponse: """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=items, total=total) + + return DocumentListResponse( + items=[DocumentMeta.model_validate(item) for item in 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) -): + db: Session = Depends(get_db), +) -> DocumentListResponse: """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=items, total=total) + return DocumentListResponse( + items=[DocumentMeta.model_validate(item) for item in items], total=total + ) -@router.post("", dependencies=[Depends(verify_csrf)], response_model=DocumentMeta) +@router.post( + "", + dependencies=[Depends(verify_csrf)], + response_model=DocumentMeta, + status_code=status.HTTP_201_CREATED, +) async def upload_document( - file: UploadFile = File(...), - db: Session = Depends(get_db) -): + file: UploadFile = File(...), db: Session = Depends(get_db) +) -> DocumentMeta: """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 - generate_thumbnail(file_id) - - now = datetime.now(timezone.utc).isoformat() + thumbnail_generated = generate_thumbnail(file_id) + + now = datetime.now(UTC).isoformat() + filename = safe_filename(file.filename) doc = Document( id=file_id, - title=file.filename or "Untitled", - original_filename=file.filename or "Untitled.pdf", + title=filename, + original_filename=filename, 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, ) - db.add(doc) - db.commit() + try: + db.add(doc) + db.commit() + except Exception: + delete_pdf_file(file_id) + delete_thumbnail(file_id) + delete_asset_directory(file_id) + raise db.refresh(doc) - return doc + return DocumentMeta.model_validate(doc) @router.get("/{id}", response_model=DocumentMeta) -def get_document(id: str, db: Session = Depends(get_db)): - doc = db.query(Document).filter(Document.id == id).first() +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 doc + 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)): +def update_document( + id: str, update: DocumentUpdateRequest, db: Session = Depends(get_db) +) -> DocumentMeta: 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: - doc.title = update.title - + title = update.title.strip() + if not title: + raise HTTPException(status_code=422, detail="Title cannot be empty") + doc.title = title + if update.in_trash is not None: if update.in_trash: - doc.deleted_at = datetime.now(timezone.utc).isoformat() + doc.deleted_at = datetime.now(UTC).isoformat() else: doc.deleted_at = None - - doc.updated_at = datetime.now(timezone.utc).isoformat() + + doc.updated_at = datetime.now(UTC).isoformat() db.commit() db.refresh(doc) - return doc + return DocumentMeta.model_validate(doc) @router.delete("/{id}", dependencies=[Depends(verify_csrf)]) -def delete_document(id: str, permanent: bool = False, db: Session = Depends(get_db)): +def delete_document( + id: str, permanent: bool = False, db: Session = Depends(get_db) +) -> dict[str, str]: """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) @@ -141,52 +170,57 @@ def delete_document(id: str, permanent: bool = False, db: Session = Depends(get_ return {"status": "deleted"} else: # Soft delete - doc.deleted_at = datetime.now(timezone.utc).isoformat() + doc.deleted_at = datetime.now(UTC).isoformat() + doc.updated_at = doc.deleted_at 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)): +def restore_document(id: str, db: Session = Depends(get_db)) -> DocumentMeta: 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(timezone.utc).isoformat() + doc.updated_at = datetime.now(UTC).isoformat() db.commit() db.refresh(doc) - return doc + return DocumentMeta.model_validate(doc) @router.post("/bulk-delete", dependencies=[Depends(verify_csrf)]) -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) +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) + ) db.commit() - return {"status": "ok", "count": len(data.ids)} + return {"status": "ok", "count": count} @router.post("/bulk-restore", dependencies=[Depends(verify_csrf)]) -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) +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) + ) db.commit() - return {"status": "ok", "count": len(data.ids)} + return {"status": "ok", "count": count} @router.post("/trash/empty", dependencies=[Depends(verify_csrf)]) -def empty_trash(db: Session = Depends(get_db)): +def empty_trash(db: Session = Depends(get_db)) -> dict[str, int | str]: 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) @@ -196,26 +230,26 @@ def empty_trash(db: Session = Depends(get_db)): @router.get("/{id}/file") -def get_document_file(id: str, db: Session = Depends(get_db)): +def get_document_file(id: str, db: Session = Depends(get_db)) -> FileResponse: 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)): +def get_document_thumbnail(id: str, db: Session = Depends(get_db)) -> FileResponse: 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 new file mode 100644 index 0000000..2153fb5 --- /dev/null +++ b/backend/app/api/v1/export.py @@ -0,0 +1,94 @@ +"""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 new file mode 100644 index 0000000..8757b94 --- /dev/null +++ b/backend/app/api/v1/versions.py @@ -0,0 +1,203 @@ +"""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 new file mode 100644 index 0000000..a1327ff Binary files /dev/null and b/backend/app/assets/fonts/GreatVibes-Regular.ttf differ diff --git a/backend/app/assets/fonts/OFL.txt b/backend/app/assets/fonts/OFL.txt new file mode 100644 index 0000000..7002592 --- /dev/null +++ b/backend/app/assets/fonts/OFL.txt @@ -0,0 +1,93 @@ +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 new file mode 100644 index 0000000..dd197a2 Binary files /dev/null and b/backend/app/assets/fonts/allura.ttf differ diff --git a/backend/app/assets/fonts/caveat.ttf b/backend/app/assets/fonts/caveat.ttf new file mode 100644 index 0000000..f84acf2 Binary files /dev/null and b/backend/app/assets/fonts/caveat.ttf differ diff --git a/backend/app/assets/fonts/dancing-script.ttf b/backend/app/assets/fonts/dancing-script.ttf new file mode 100644 index 0000000..9f521e7 Binary files /dev/null and b/backend/app/assets/fonts/dancing-script.ttf differ diff --git a/backend/app/assets/fonts/sacramento.ttf b/backend/app/assets/fonts/sacramento.ttf new file mode 100644 index 0000000..cfd2eab Binary files /dev/null and b/backend/app/assets/fonts/sacramento.ttf differ diff --git a/backend/app/auth/dependencies.py b/backend/app/auth/dependencies.py index 648ac19..12d1605 100644 --- a/backend/app/auth/dependencies.py +++ b/backend/app/auth/dependencies.py @@ -1,10 +1,11 @@ """FastAPI dependencies for authentication and security.""" -from fastapi import Request, HTTPException +from fastapi import HTTPException, Request 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. @@ -14,11 +15,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 @@ -27,9 +28,11 @@ 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"]: - if 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"] + and 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 31beb5d..1d4e62e 100644 --- a/backend/app/auth/hashing.py +++ b/backend/app/auth/hashing.py @@ -1,20 +1,22 @@ """Argon2id password hashing and verification.""" from argon2 import PasswordHasher -from argon2.exceptions import VerifyMismatchError +from argon2.exceptions import InvalidHashError, 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 VerifyMismatchError: + except (InvalidHashError, VerifyMismatchError): return False diff --git a/backend/app/auth/rate_limit.py b/backend/app/auth/rate_limit.py index fd25202..18d8c99 100644 --- a/backend/app/auth/rate_limit.py +++ b/backend/app/auth/rate_limit.py @@ -2,6 +2,7 @@ import time from collections import defaultdict + from fastapi import HTTPException from app.config import settings @@ -27,7 +28,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 837ad59..9b67f56 100644 --- a/backend/app/auth/session.py +++ b/backend/app/auth/session.py @@ -1,11 +1,13 @@ """Session token generation and validation.""" from typing import Any + from fastapi import Response -from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer from app.config import settings + def get_serializer() -> URLSafeTimedSerializer: """Return a configured URLSafeTimedSerializer.""" return URLSafeTimedSerializer(settings.SECRET_KEY) @@ -14,7 +16,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, @@ -41,7 +43,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 a960c20..01aec3b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -5,6 +5,7 @@ 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 cccde5b..cf8b5a6 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -4,11 +4,10 @@ Database engine, session management, and WAL mode setup. SQLite with WAL mode for single-user concurrent read/write safety. """ -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager +from collections.abc import Generator from typing import Any -from sqlalchemy import event, create_engine, Engine +from sqlalchemy import Engine, create_engine, event from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker from app.config import settings @@ -16,6 +15,7 @@ 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() -> Session: +def get_db() -> Generator[Session, None, None]: """FastAPI dependency that yields a database session.""" db = SessionLocal() try: - yield db # type: ignore[misc] + yield db finally: db.close() diff --git a/backend/app/main.py b/backend/app/main.py index 1d5a8e8..24269ee 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 contextlib import asynccontextmanager from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager from typing import Any from fastapi import FastAPI, Request @@ -14,7 +14,8 @@ from fastapi.responses import JSONResponse from app.api.v1 import router as v1_router from app.config import settings -from app.db import Base, engine +from app.db import Base, SessionLocal, engine +from app.services.trash_sweep import prune_auto_versions, sweep_trash @asynccontextmanager @@ -29,6 +30,12 @@ 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 @@ -48,6 +55,7 @@ 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 f51bd38..7015970 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.settings import Settings -from app.models.document import Document from app.models.annotation_state import AnnotationState +from app.models.document import Document +from app.models.settings import Settings from app.models.version import Version -__all__ = ["Settings", "Document", "AnnotationState", "Version"] +__all__ = ["AnnotationState", "Document", "Settings", "Version"] diff --git a/backend/app/models/annotation_state.py b/backend/app/models/annotation_state.py index 4078115..e9223e4 100644 --- a/backend/app/models/annotation_state.py +++ b/backend/app/models/annotation_state.py @@ -1,15 +1,19 @@ """AnnotationState model — current working annotation layer per document.""" -from datetime import datetime, timezone +from datetime import UTC, datetime +from typing import TYPE_CHECKING 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(timezone.utc).isoformat() + return datetime.now(UTC).isoformat() class AnnotationState(Base): diff --git a/backend/app/models/document.py b/backend/app/models/document.py index a60a695..eedbba7 100644 --- a/backend/app/models/document.py +++ b/backend/app/models/document.py @@ -1,20 +1,25 @@ """Document model — uploaded PDFs with soft-delete support.""" import uuid -from datetime import datetime, timezone +from datetime import UTC, datetime +from typing import TYPE_CHECKING 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(timezone.utc).isoformat() + return datetime.now(UTC).isoformat() class Document(Base): diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index e222907..4a5e04d 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 datetime, timezone +from datetime import UTC, datetime 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(timezone.utc).isoformat(), + default=lambda: datetime.now(UTC).isoformat(), ) updated_at: Mapped[str] = mapped_column( Text, - default=lambda: datetime.now(timezone.utc).isoformat(), - onupdate=lambda: datetime.now(timezone.utc).isoformat(), + default=lambda: datetime.now(UTC).isoformat(), + onupdate=lambda: datetime.now(UTC).isoformat(), ) diff --git a/backend/app/models/version.py b/backend/app/models/version.py index 35ab070..e33b85b 100644 --- a/backend/app/models/version.py +++ b/backend/app/models/version.py @@ -1,20 +1,24 @@ """Version model — annotation state snapshots for history/recovery.""" import uuid -from datetime import datetime, timezone +from datetime import UTC, datetime +from typing import TYPE_CHECKING 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(timezone.utc).isoformat() + return datetime.now(UTC).isoformat() class Version(Base): @@ -40,6 +44,4 @@ 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 a8b9aad..5aca730 100644 --- a/backend/app/schemas/annotations.py +++ b/backend/app/schemas/annotations.py @@ -1,14 +1,17 @@ from datetime import datetime -from typing import Literal, Union, List, Tuple, Optional -from pydantic import BaseModel, Field +from typing import Any, Literal from uuid import UUID +from pydantic import BaseModel, Field + + class Rect(BaseModel): x: float y: float width: float height: float + class AnnotationBase(BaseModel): id: UUID page: int = Field(ge=0) @@ -19,6 +22,7 @@ class AnnotationBase(BaseModel): createdAt: datetime updatedAt: datetime + class TextProps(BaseModel): text: str fontFamily: str = "Liberation Sans" @@ -28,82 +32,105 @@ class TextProps(BaseModel): bold: bool = False italic: bool = False lineHeight: float = 1.2 - highlightColor: Optional[str] = None - styles: Optional[dict] = None + highlightColor: str | None = None + styles: dict[str, Any] | None = None + class TextAnnotation(AnnotationBase): type: Literal["text"] props: TextProps + class DrawProps(BaseModel): - paths: List[Tuple[float, float]] = [] - svgPath: Optional[str] = None + paths: list[tuple[float, float]] = Field(default_factory=list) + svgPath: str | None = 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: Union[SignatureDrawProps, SignatureTypeProps] = Field(discriminator="mode") + props: 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 -Annotation = Union[ - TextAnnotation, - DrawAnnotation, - SignatureAnnotation, - ImageAnnotation, - HighlightAnnotation, - ShapeAnnotation -] + +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] + class AnnotationStateResponse(BaseModel): - data: List[Annotation] + data: list[AnnotationPayload] updatedAt: datetime + class AnnotationStateUpdateRequest(BaseModel): - data: List[Annotation] + data: list[AnnotationPayload] baseUpdatedAt: datetime | None = None + class AnnotationStateUpdateResponse(BaseModel): updatedAt: datetime diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 58bfb9d..247c776 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -2,6 +2,7 @@ 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 0479202..dbfb03a 100644 --- a/backend/app/schemas/document.py +++ b/backend/app/schemas/document.py @@ -1,7 +1,8 @@ """Pydantic schemas for Document APIs.""" + from pydantic import BaseModel -from typing import Optional + class DocumentMeta(BaseModel): id: str @@ -12,7 +13,7 @@ class DocumentMeta(BaseModel): in_trash: bool created_at: str updated_at: str - deleted_at: Optional[str] + deleted_at: str | None model_config = {"from_attributes": True} @@ -27,5 +28,5 @@ class BulkRestoreRequest(BaseModel): ids: list[str] class DocumentUpdateRequest(BaseModel): - title: Optional[str] = None - in_trash: Optional[bool] = None + title: str | None = None + in_trash: bool | None = None diff --git a/backend/app/schemas/export.py b/backend/app/schemas/export.py new file mode 100644 index 0000000..2eaa9c2 --- /dev/null +++ b/backend/app/schemas/export.py @@ -0,0 +1,8 @@ +"""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 new file mode 100644 index 0000000..4ba7343 --- /dev/null +++ b/backend/app/schemas/versions.py @@ -0,0 +1,35 @@ +"""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 new file mode 100644 index 0000000..665f077 --- /dev/null +++ b/backend/app/services/assets.py @@ -0,0 +1,12 @@ +"""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 new file mode 100644 index 0000000..019a571 --- /dev/null +++ b/backend/app/services/export/__init__.py @@ -0,0 +1 @@ +"""PDF export services.""" diff --git a/backend/app/services/export/renderer.py b/backend/app/services/export/renderer.py new file mode 100644 index 0000000..a6af6f0 --- /dev/null +++ b/backend/app/services/export/renderer.py @@ -0,0 +1,465 @@ +"""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 d500ac9..f2b4002 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -1,53 +1,71 @@ -"""Document storage service.""" +"""Document and binary storage helpers.""" -import shutil import uuid -import pymupdf from pathlib import Path -from fastapi import UploadFile, HTTPException + +import pymupdf +from fastapi import HTTPException, UploadFile from app.config import settings -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-": +_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-": raise ValueError("Not a valid PDF file (missing magic bytes)") try: - doc = pymupdf.open(filepath) - doc.close() - except Exception as e: - raise ValueError(f"Failed to open PDF with PyMuPDF: {e}") + 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" + async def save_upload_file(upload_file: UploadFile) -> tuple[str, int, int]: - """Save an uploaded file to disk and validate it.""" + """Stream an uploaded PDF to storage, enforcing the configured size limit.""" 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 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") + 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 - page_count = 0 try: - 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 + 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 + def delete_pdf_file(file_id: str) -> None: """Delete a PDF file from storage.""" filepath = settings.PDF_STORAGE_PATH / f"{file_id}.pdf" - if filepath.exists(): - filepath.unlink() + filepath.unlink(missing_ok=True) diff --git a/backend/app/services/thumbnails.py b/backend/app/services/thumbnails.py index fa50b92..cfd8da7 100644 --- a/backend/app/services/thumbnails.py +++ b/backend/app/services/thumbnails.py @@ -1,38 +1,40 @@ """Thumbnail generation service.""" import pymupdf -from pathlib import Path from app.config import settings -def generate_thumbnail(file_id: str) -> None: + +def generate_thumbnail(file_id: str) -> bool: """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 - + return False + try: - doc = pymupdf.open(pdf_path) - if len(doc) > 0: + with pymupdf.open(pdf_path) as doc: + if len(doc) == 0: + return False 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") - doc.close() - except Exception as e: - print(f"Failed to generate thumbnail for {file_id}: {e}") + return True + except Exception as err: + print(f"Failed to generate thumbnail for {file_id}: {err}") + return False + def delete_thumbnail(file_id: str) -> None: """Delete a thumbnail file.""" thumb_path = settings.THUMBNAILS_PATH / f"{file_id}.png" - if thumb_path.exists(): - thumb_path.unlink() + thumb_path.unlink(missing_ok=True) diff --git a/backend/app/services/trash_sweep.py b/backend/app/services/trash_sweep.py index 99c6079..13fc3b0 100644 --- a/backend/app/services/trash_sweep.py +++ b/backend/app/services/trash_sweep.py @@ -1,48 +1,50 @@ """Background tasks for sweeping trash and old versions.""" -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta + from sqlalchemy.orm import Session -from app.db import get_db -from app.models.document import Document +from app.config import settings from app.models.annotation_state import AnnotationState +from app.models.document import Document 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(timezone.utc) - timedelta(days=settings.TRASH_RETENTION_DAYS) + cutoff = datetime.now(UTC) - timedelta(days=settings.TRASH_RETENTION_DAYS) cutoff_iso = cutoff.isoformat() - - docs_to_delete = db.query(Document).filter( - Document.in_trash == True, - Document.deleted_at <= cutoff_iso - ).all() - + + docs_to_delete = ( + db.query(Document) + .filter(Document.deleted_at.is_not(None), 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(timezone.utc) - timedelta(days=settings.AUTO_VERSION_RETENTION_DAYS) + cutoff = datetime.now(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 new file mode 100644 index 0000000..5b48d97 --- /dev/null +++ b/backend/app/tests/test_api_workflows.py @@ -0,0 +1,121 @@ +"""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 new file mode 100644 index 0000000..16711f2 --- /dev/null +++ b/backend/app/tests/test_export.py @@ -0,0 +1,121 @@ +"""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 93831b7..6ec9e2d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -30,12 +30,18 @@ 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" @@ -43,6 +49,21 @@ 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 4be3bb5..4567c1f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,27 +1,23 @@ services: - backend: - build: ./backend + paperjet: + image: ${PAPERJET_IMAGE:-paperjet:local} + build: + context: . + dockerfile: Dockerfile environment: - - 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 + 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} 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 new file mode 100644 index 0000000..9d96914 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,36 @@ +#!/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 deleted file mode 100644 index e1c8fe5..0000000 --- a/frontend/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -# 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 7dbf7eb..44eb366 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,73 +1,20 @@ -# React + TypeScript + Vite +# PaperJet frontend -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +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. -Currently, two official plugins are available: +## Useful commands -- [@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... - }, - }, -]) +```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 ``` -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... - }, - }, -]) -``` +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. diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 7f1e76c..b8a0a62 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -2,10 +2,12 @@ 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 @@ -25,9 +27,9 @@ server { try_files $uri $uri/ /index.html; } - # Proxy /api/ to the backend container + # Proxy /api/ to the loopback-only FastAPI process in this container location /api/ { - proxy_pass http://backend:8000; + proxy_pass http://127.0.0.1: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 69665ce..10c3b3d 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 (err) { + } catch { // Error is handled by the store } } diff --git a/frontend/src/features/auth/SetupForm.tsx b/frontend/src/features/auth/SetupForm.tsx index 0bd814a..085f548 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 (err) { + } catch { // Error is handled by the store } } diff --git a/frontend/src/features/auth/useAuth.ts b/frontend/src/features/auth/useAuth.ts index 3f34898..6f6df03 100644 --- a/frontend/src/features/auth/useAuth.ts +++ b/frontend/src/features/auth/useAuth.ts @@ -16,6 +16,17 @@ 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, @@ -33,9 +44,9 @@ export const useAuth = create((set) => ({ isInitialized: true, isLoading: false }) - } catch (err: any) { + } catch (err: unknown) { set({ - error: err.error?.message || err.message || 'Failed to initialize', + error: errorMessage(err, 'Failed to initialize'), isLoading: false, isInitialized: true }) @@ -47,9 +58,9 @@ export const useAuth = create((set) => ({ set({ isLoading: true, error: null }) await authApi.login(data) set({ loggedIn: true, isLoading: false }) - } catch (err: any) { + } catch (err: unknown) { set({ - error: err.error?.message || err.message || 'Failed to login', + error: errorMessage(err, 'Failed to login'), isLoading: false }) throw err @@ -61,9 +72,9 @@ export const useAuth = create((set) => ({ set({ isLoading: true, error: null }) await authApi.setup(data) set({ setupRequired: false, loggedIn: true, isLoading: false }) - } catch (err: any) { + } catch (err: unknown) { set({ - error: err.error?.message || err.message || 'Failed to setup', + error: errorMessage(err, 'Failed to setup'), isLoading: false }) throw err @@ -76,9 +87,9 @@ export const useAuth = create((set) => ({ await authApi.logout() set({ loggedIn: false, isLoading: false }) window.location.href = '/login' - } catch (err: any) { + } catch (err: unknown) { set({ - error: err.error?.message || err.message || 'Failed to logout', + error: errorMessage(err, 'Failed to logout'), isLoading: false }) } diff --git a/frontend/src/features/editor/canvas/AnnotationLayer.tsx b/frontend/src/features/editor/canvas/AnnotationLayer.tsx index 5bca331..5b570ec 100644 --- a/frontend/src/features/editor/canvas/AnnotationLayer.tsx +++ b/frontend/src/features/editor/canvas/AnnotationLayer.tsx @@ -1,9 +1,9 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import * as fabric from 'fabric'; import { useEditorStore } from '../store'; import { getTool } from '../../../lib/annotations/registry'; import type { ViewportParams } from '../../../lib/coords'; -import { screenToPdf } from '../../../lib/coords'; +import { screenRectToPdf } from '../../../lib/coords'; import { TextFormatToolbar } from '../toolbar/TextFormatToolbar'; interface AnnotationLayerProps { @@ -13,208 +13,201 @@ 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 activeToolName = useEditorStore(state => state.activeTool); - - const annotations = useEditorStore(state => state.annotations); - + const renderingIds = useRef(new Set()); + const renderGeneration = useRef(0); 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, }); - - (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 } - }); - } - } + 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, + ), + }); }); - const updateSelection = (_e?: any) => { - const activeObj = canvas.getActiveObject() as any; - if (activeObj && activeObj.id) { - useEditorStore.getState().setSelection(activeObj.id); - } else { - useEditorStore.getState().setSelection(null); - } + const updateSelection = () => { + const activeObject = canvas.getActiveObject() as AnnotationObject | undefined; + useEditorStore.getState().setSelection(activeObject?.id ?? null); }; - canvas.on('selection:created', updateSelection); canvas.on('selection:updated', updateSelection); canvas.on('selection:cleared', updateSelection); - // 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(); - } + 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); } + 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); }; - }, []); // Run once on mount + // 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 + }, []); - const prevScaleRef = useRef(viewportParams.scale); - - // Handle dimensions and re-rendering annotations - useEffect(() => { - if (!fabricRef.current) return; - const canvas = fabricRef.current; - canvas.setDimensions({ width, height }); - - const activeObj = canvas.getActiveObject() as any; - const activeId = activeObj?.id; - - const pageAnns = annotations.filter(a => a.page === pageNumber); - const annIds = new Set(pageAnns.map(a => a.id)); - - 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); - } - } - }); - - 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]); - - // Handle tool activation/deactivation and event binding useEffect(() => { const canvas = fabricRef.current; if (!canvas) return; + const generation = ++renderGeneration.current; + renderingIds.current.clear(); + canvas.setDimensions({ width, height }); - // 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.selection = activeToolName === 'select'; + 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 tool = getTool(activeToolName); - 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)); + for (const object of canvas.getObjects() as AnnotationObject[]) { + if (!object.id || !annotationIds.has(object.id) || scaleChanged) { + canvas.remove(object); } } - return () => { - if (tool && tool.onDeactivate) { - tool.onDeactivate(canvas); - } - }; - }, [activeToolName]); + 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); + }); + } - 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; + 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]); + + useEffect(() => { + const canvas = fabricRef.current; + if (!canvas) return; + 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.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 ( -
+
- {toolbarAnnotationId && activeAnn && ( - <> - {activeAnn.type === 'text' && } - {/* We will add ShapeControls and DrawControls here once they are implemented */} - + {activeAnnotation?.type === 'text' && canvasInstance && ( + )}
); diff --git a/frontend/src/features/editor/pages/PageRenderer.tsx b/frontend/src/features/editor/pages/PageRenderer.tsx index 1a2a27d..f7d1798 100644 --- a/frontend/src/features/editor/pages/PageRenderer.tsx +++ b/frontend/src/features/editor/pages/PageRenderer.tsx @@ -1,87 +1,127 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist'; import { AnnotationLayer } from '../canvas/AnnotationLayer'; interface PageRendererProps { pdfDoc: PDFDocumentProxy; - pageNumber: number; + pageIndex: number; scale: number; dpr: number; } -export function PageRenderer({ pdfDoc, pageNumber, scale, dpr }: PageRendererProps) { - const canvasRef = useRef(null); +export function PageRenderer({ pdfDoc, pageIndex, scale, dpr }: PageRendererProps) { const [page, setPage] = useState(null); useEffect(() => { let active = true; - pdfDoc.getPage(pageNumber).then(p => { - if (active) setPage(p); + const pageNumber = pageIndex + 1; + pdfDoc.getPage(pageNumber).then((nextPage) => { + if (active) setPage(nextPage); }); - 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, - }; - - let renderTask = page.render(renderContext); - return () => { - renderTask.cancel(); + active = false; }; - }, [page, scale, dpr]); + }, [pdfDoc, pageIndex]); if (!page) { return ( -
- Loading page {pageNumber}... + Loading page {pageIndex + 1}...
); } - const baseViewport = page ? page.getViewport({ scale: 1, rotation: 0 }) : null; + 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; return ( -
- - - {baseViewport && ( - - )} - -
- {pageNumber} + + ); +} + +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}
); diff --git a/frontend/src/features/editor/pages/PageStack.tsx b/frontend/src/features/editor/pages/PageStack.tsx index 0dbcc4f..19d919f 100644 --- a/frontend/src/features/editor/pages/PageStack.tsx +++ b/frontend/src/features/editor/pages/PageStack.tsx @@ -8,12 +8,13 @@ interface PageStackProps { export function PageStack({ pdfDoc }: PageStackProps) { const numPages = pdfDoc.numPages; - const pages = Array.from({ length: numPages }, (_, i) => i + 1); + const pages = Array.from({ length: numPages }, (_, i) => i); const zoom = useEditorStore(state => state.zoom); - // For now, render all pages vertically. Virtualization comes in Phase 7. - const scale = zoom * 2.0; + // 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; const dpr = window.devicePixelRatio || 1; return ( @@ -22,7 +23,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 5caeb75..5d7d774 100644 --- a/frontend/src/features/editor/pages/PdfDocument.tsx +++ b/frontend/src/features/editor/pages/PdfDocument.tsx @@ -4,50 +4,61 @@ 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 [pdfDoc, setPdfDoc] = useState(null); - const [error, setError] = useState(null); - + const [loaded, setLoaded] = useState(null); + const [error, setError] = useState(null); + useEffect(() => { let active = true; - 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); + 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 }); + } }); - + return () => { active = false; - loadingTask.destroy(); + void loadingTask.destroy(); }; }, [documentId]); - - if (error) { + + const currentError = error?.id === documentId ? error.message : null; + const currentDocument = loaded?.id === documentId ? loaded.document : null; + + if (currentError) { return ( -
-
- Error loading PDF: {error} -
+
+
Error loading PDF: {currentError}
); } - - if (!pdfDoc) { + + if (!currentDocument) { 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 new file mode 100644 index 0000000..9b82b04 --- /dev/null +++ b/frontend/src/features/editor/store.test.ts @@ -0,0 +1,56 @@ +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 302e385..4e316c4 100644 --- a/frontend/src/features/editor/store.ts +++ b/frontend/src/features/editor/store.ts @@ -1,74 +1,171 @@ import { create } from 'zustand'; -import type { Annotation, TextProps } from '../../lib/annotations/types'; +import type { + Annotation, + SignatureDrawProps, + SignatureTypeProps, + TextProps, +} from '../../lib/annotations/types'; -export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; +export type SaveStatus = 'idle' | 'loading' | 'saving' | 'saved' | 'error'; +type SignatureProps = SignatureDrawProps | SignatureTypeProps; +type PendingImage = { ref: string; width: number; height: number }; interface EditorState { documentId: string | null; annotations: Annotation[]; - activeTool: string; + past: Annotation[][]; + future: Annotation[][]; selection: string | null; + activeTool: string; + activeShapeKind: 'rect' | 'ellipse' | 'line' | 'arrow'; zoom: number; saveStatus: SaveStatus; + annotationUpdatedAt: string | null; defaultTextProps: Partial; draftAnnotation: Annotation | null; isSignatureModalOpen: boolean; - pendingSignatureProps: any | null; - pendingImageRef: { ref: string, width: number, height: number } | null; - - // Actions + pendingSignatureProps: SignatureProps | null; + pendingImageRef: PendingImage | null; + 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: (ann: Annotation | null) => void; + setDraftAnnotation: (annotation: Annotation | null) => void; setIsSignatureModalOpen: (isOpen: boolean) => 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; + setPendingSignatureProps: (props: SignatureProps | null) => void; + setPendingImageRef: (image: PendingImage | null) => void; + setActiveShapeKind: (kind: EditorState['activeShapeKind']) => void; } -export const useEditorStore = create((set) => ({ +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) => ({ documentId: null, annotations: [], - activeTool: 'select', + past: [], + future: [], selection: null, + activeTool: 'select', + activeShapeKind: 'rect', zoom: 1, saveStatus: 'idle', + annotationUpdatedAt: null, defaultTextProps: {}, draftAnnotation: null, isSignatureModalOpen: false, pendingSignatureProps: null, pendingImageRef: null, - activeShapeKind: 'rect', - 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 - })), + 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, setActiveTool: (tool) => set({ activeTool: tool }), setSelection: (id) => set({ selection: id }), - setZoom: (zoom) => set({ zoom }), + setZoom: (zoom) => set({ zoom: Math.max(0.5, Math.min(3, zoom)) }), setSaveStatus: (status) => set({ saveStatus: status }), - setDefaultTextProps: (props) => set((state) => ({ defaultTextProps: { ...state.defaultTextProps, ...props } })), - setDraftAnnotation: (ann) => set({ draftAnnotation: ann }), + setDefaultTextProps: (props) => + set((state) => ({ defaultTextProps: { ...state.defaultTextProps, ...props } })), + setDraftAnnotation: (annotation) => set({ draftAnnotation: annotation }), setIsSignatureModalOpen: (isOpen) => set({ isSignatureModalOpen: isOpen }), setPendingSignatureProps: (props) => set({ pendingSignatureProps: props }), - setPendingImageRef: (imgRef) => set({ pendingImageRef: imgRef }), + setPendingImageRef: (image) => set({ pendingImageRef: image }), setActiveShapeKind: (kind) => set({ activeShapeKind: kind }), })); diff --git a/frontend/src/features/editor/toolbar/EditorToolbar.tsx b/frontend/src/features/editor/toolbar/EditorToolbar.tsx index aeec619..b67c0df 100644 --- a/frontend/src/features/editor/toolbar/EditorToolbar.tsx +++ b/frontend/src/features/editor/toolbar/EditorToolbar.tsx @@ -1,161 +1,162 @@ -import { useRef, useState } from 'react'; -import { useEditorStore } from '../store'; +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 { SignatureModal } from '../tools/SignatureModal'; import { api } from '../../../lib/api/client'; +import { useEditorStore } from '../store'; +import type { SignatureDrawProps, SignatureTypeProps } from '../../../lib/annotations/types'; -export function EditorToolbar() { - const { - activeTool, - setActiveTool, - saveStatus, - zoom, +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, setZoom, isSignatureModalOpen, setIsSignatureModalOpen, setPendingSignatureProps, setPendingImageRef, - documentId + documentId, + past, + future, + undo, + redo, } = useEditorStore(); const fileInputRef = useRef(null); const [isUploadingImage, setIsUploadingImage] = useState(false); - 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]; + const handleImageUpload = async (event: ChangeEvent) => { + const file = event.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); - - // 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. + const data = await api.upload(`/documents/${documentId}/assets`, formData); setPendingImageRef({ ref: data.ref, width: 0, height: 0 }); setActiveTool('image'); - } catch (err) { - console.error(err); - alert('Failed to upload image'); + } catch (reason) { + console.error('Failed to upload image', reason); + window.alert(reason instanceof Error ? reason.message : 'Failed to upload image.'); } finally { setIsUploadingImage(false); - // Reset input if (fileInputRef.current) fileInputRef.current.value = ''; } }; - const handleSignatureConfirm = (props: any) => { + const handleSignatureConfirm = (props: SignatureDrawProps | SignatureTypeProps) => { setPendingSignatureProps(props); setIsSignatureModalOpen(false); setActiveTool('signature'); }; + const isActive = (name: string) => activeTool === name ? active : inactive; + return ( <> -
- - - - - - - - - - - - - - -
- -
- - - {Math.round(zoom * 100)}% - - -
+ + + + + + )} + + + void handleImageUpload(event)} /> -
+ + + -
- {saveStatus === 'saving' && 'Saving...'} + + + {Math.round(zoom * 100)}% + + + + + + + + {saveStatus === 'loading' && 'Loading…'} + {saveStatus === 'saving' && 'Saving…'} {saveStatus === 'saved' && 'Saved'} - {saveStatus === 'error' && Error saving} - {saveStatus === 'idle' && ''} -
+ {saveStatus === 'error' && Save failed} +
{isSignatureModalOpen && ( - setIsSignatureModalOpen(false)} onConfirm={handleSignatureConfirm} /> diff --git a/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx b/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx index bfdaf27..3e7dbee 100644 --- a/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx +++ b/frontend/src/features/editor/toolbar/TextFormatToolbar.tsx @@ -1,4 +1,6 @@ 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'; @@ -17,6 +19,7 @@ import { interface TextFormatToolbarProps { annotationId: string; viewportParams: ViewportParams; + canvas: Canvas; } const FONTS = ['Liberation Sans', 'Outfit', 'Plus Jakarta Sans', 'Arial', 'Times New Roman', 'Courier New']; @@ -24,7 +27,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 }: TextFormatToolbarProps) { +export function TextFormatToolbar({ annotationId, viewportParams, canvas }: TextFormatToolbarProps) { const { annotations, updateAnnotation, deleteAnnotation, addAnnotation, setDefaultTextProps, draftAnnotation } = useEditorStore(); const [activeDropdown, setActiveDropdown] = useState<'font' | 'size' | 'color' | 'highlight' | null>(null); @@ -51,11 +54,9 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo const top = pt.y - 48; // 48px above const left = pt.x; - 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(); + 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) { if (activeObj && activeObj.id === annotationId) { const isStructural = styleName === 'fontSize' || styleName === 'fontFamily'; @@ -81,7 +82,7 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo activeObj.styles = {}; if (activeObj.hiddenTextarea) { if (styleName === 'fontSize') activeObj.hiddenTextarea.style.fontSize = `${value}px`; - if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = value; + if (styleName === 'fontFamily') activeObj.hiddenTextarea.style.fontFamily = String(value); } } } else { @@ -100,7 +101,7 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo for (const line in activeObj.styles) { for (const char in activeObj.styles[line]) { if (activeObj.styles[line][char]) { - delete activeObj.styles[line][char][styleName]; + delete (activeObj.styles[line][char] as Record)[styleName]; } } } @@ -110,8 +111,8 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo // Critical: Fabric 7 heavily caches text. We MUST mark it dirty to force a redraw! activeObj.dirty = true; - if ((activeObj as any)._forceClearCache !== undefined) { - (activeObj as any)._forceClearCache = true; + if ('_forceClearCache' in activeObj) { + (activeObj as typeof activeObj & { _forceClearCache?: boolean })._forceClearCache = true; } // Remove manual height constraint so the box can grow with the new font size @@ -123,7 +124,7 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo } const finalGlobalValue = globalValue !== undefined ? globalValue : value; - const newProps = { [globalPropName]: finalGlobalValue } as any; + const newProps = { [globalPropName]: finalGlobalValue } as Partial; setDefaultTextProps(newProps); // Always update store so the toolbar displays the new value @@ -154,12 +155,9 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo const handleDelete = () => { if (isDraft) { useEditorStore.getState().setDraftAnnotation(null); - const canvas = (window as any).__fabricCanvas as any; - if (canvas) { - const activeObj = canvas.getActiveObject(); - if (activeObj && activeObj.id === annotationId) { - canvas.remove(activeObj); - } + const activeObj = canvas.getActiveObject(); + if (activeObj && (activeObj as fabric.FabricObject & { id?: string }).id === annotationId) { + canvas.remove(activeObj); } } else { deleteAnnotation(annotationId); @@ -220,7 +218,7 @@ export function TextFormatToolbar({ annotationId, viewportParams }: TextFormatTo diff --git a/frontend/src/features/editor/tools/DrawTool.ts b/frontend/src/features/editor/tools/DrawTool.ts index ab94d10..1e1dfe8 100644 --- a/frontend/src/features/editor/tools/DrawTool.ts +++ b/frontend/src/features/editor/tools/DrawTool.ts @@ -6,6 +6,12 @@ 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', @@ -21,7 +27,7 @@ export const DrawTool: ToolHandler = { canvas.isDrawingMode = false; }, - onPathCreated: (e: any, _canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => { + onPathCreated: (e: fabric.CanvasEvents['path:created'], _canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => { const pathObj = e.path as fabric.Path; pathObj.set({ @@ -33,27 +39,16 @@ export const DrawTool: ToolHandler = { padding: 5, }); - (pathObj as any).id = uuidv4(); - (pathObj as any).annotationType = 'draw'; + const annotatedPath = pathObj as FabricAnnotationObject; + annotatedPath.id = uuidv4(); + annotatedPath.annotationType = 'draw'; - // 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 + // Keep the SVG path for faithful browser re-rendering and also store a + // canonical point list for the server export renderer. 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, @@ -61,9 +56,36 @@ 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: (pathObj as any).id, + id: annotatedPath.id as string, page: pageNumber, type: 'draw', rect: pdfRect, @@ -81,7 +103,7 @@ export const DrawTool: ToolHandler = { }, renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => { - if (annotation.type !== 'draw') return null; + if (annotation.type !== 'draw') return; const drawAnn = annotation as DrawAnnotation; const props = drawAnn.props as DrawProps; const screenRect = pdfRectToScreen(drawAnn.rect, vp); @@ -103,6 +125,8 @@ 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!, }); @@ -111,6 +135,8 @@ 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', @@ -118,17 +144,18 @@ 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 new file mode 100644 index 0000000..0827a51 --- /dev/null +++ b/frontend/src/features/editor/tools/HighlightTool.ts @@ -0,0 +1,120 @@ +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 1bb4da0..9d791a3 100644 --- a/frontend/src/features/editor/tools/ImageTool.ts +++ b/frontend/src/features/editor/tools/ImageTool.ts @@ -6,6 +6,12 @@ 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', @@ -18,7 +24,7 @@ export const ImageTool: ToolHandler = { canvas.defaultCursor = 'default'; }, - onPointerDown: async (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => { + onPointerDown: async (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => { if (e.target) return; const storeState = useEditorStore.getState(); @@ -45,8 +51,8 @@ export const ImageTool: ToolHandler = { img.set({ left: pointer.x, top: pointer.y, - originX: 'center', - originY: 'center', + originX: 'left', + originY: 'top', transparentCorners: false, cornerColor: '#3b82f6', cornerStrokeColor: '#3b82f6', @@ -55,15 +61,16 @@ export const ImageTool: ToolHandler = { padding: 5, }); - (img as any).id = uuidv4(); - (img as any).annotationType = 'image'; + const annotatedImage = img as FabricAnnotationObject; + annotatedImage.id = uuidv4(); + annotatedImage.annotationType = 'image'; const props: ImageProps = { ref: pendingImage.ref, naturalWidth: pendingImage.width, naturalHeight: pendingImage.height }; - (img as any).annotationProps = props; + annotatedImage.annotationProps = props; canvas.add(img); canvas.setActiveObject(img); @@ -78,7 +85,7 @@ export const ImageTool: ToolHandler = { }, vp); const annotation: ImageAnnotation = { - id: (img as any).id, + id: annotatedImage.id as string, page: pageNumber, type: 'image', rect: pdfRect, @@ -97,7 +104,7 @@ export const ImageTool: ToolHandler = { }, renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => { - if (annotation.type !== 'image') return null; + if (annotation.type !== 'image') return; const imgAnn = annotation as ImageAnnotation; const screenRect = pdfRectToScreen(imgAnn.rect, vp); @@ -107,24 +114,27 @@ export const ImageTool: ToolHandler = { img = await fabric.Image.fromURL(url); } catch (e) { console.error("Failed to load asset", e); - return null; + return; } + 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 new file mode 100644 index 0000000..0e0e628 --- /dev/null +++ b/frontend/src/features/editor/tools/ShapeTool.ts @@ -0,0 +1,210 @@ +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 e97ba3e..b9543e8 100644 --- a/frontend/src/features/editor/tools/SignatureModal.tsx +++ b/frontend/src/features/editor/tools/SignatureModal.tsx @@ -17,6 +17,10 @@ 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'); @@ -116,7 +120,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 4fd06e6..855c075 100644 --- a/frontend/src/features/editor/tools/SignatureTool.ts +++ b/frontend/src/features/editor/tools/SignatureTool.ts @@ -6,11 +6,18 @@ 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: any) => { +const createPreview = async (canvas: fabric.Canvas, vp: ViewportParams, props: SignatureProps) => { isCreatingPreview = true; const storeState = useEditorStore.getState(); if (props.mode === 'draw') { @@ -23,11 +30,13 @@ const createPreview = async (canvas: fabric.Canvas, vp: ViewportParams, props: a } img.set({ originX: 'center', originY: 'center', opacity: 0.5, evented: false }); previewObj = img; - } catch (e) {} + } catch { + previewObj = null; + } } else { previewObj = new fabric.Text(props.text, { fontFamily: props.fontFamily, - fontSize: 48 * vp.scale * (vp.dpr || 1), + fontSize: 48 * vp.scale, fill: props.color, originX: 'center', originY: 'center', @@ -61,7 +70,7 @@ export const SignatureTool: ToolHandler = { isCreatingPreview = false; }, - onPointerMove: (e: any, canvas: fabric.Canvas, vp: ViewportParams, _pageNumber: number) => { + onPointerMove: (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams) => { const props = useEditorStore.getState().pendingSignatureProps; if (!props) return; @@ -86,7 +95,7 @@ export const SignatureTool: ToolHandler = { } }, - onPointerDown: async (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => { + onPointerDown: async (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => { if (e.target && e.target !== previewObj) return; const storeState = useEditorStore.getState(); @@ -120,7 +129,7 @@ export const SignatureTool: ToolHandler = { originY: 'center', }); // We set id on the object so it can be identified - (img as any).id = uuidv4(); + (img as FabricSignatureObject).id = uuidv4(); fabricObj = img; } catch (err) { console.error("Failed to load signature image", err); @@ -132,12 +141,12 @@ export const SignatureTool: ToolHandler = { left: pointer.x, top: pointer.y, fontFamily: typeProps.fontFamily, - fontSize: 48 * vp.scale * (vp.dpr || 1), + fontSize: 48 * vp.scale, fill: typeProps.color, originX: 'center', originY: 'center', }); - (textObj as any).id = uuidv4(); + (textObj as FabricSignatureObject).id = uuidv4(); fabricObj = textObj; } @@ -150,8 +159,9 @@ export const SignatureTool: ToolHandler = { padding: 5, }); - (fabricObj as any).annotationType = 'signature'; - (fabricObj as any).annotationProps = props; + const annotatedObject = fabricObj as FabricSignatureObject; + annotatedObject.annotationType = 'signature'; + annotatedObject.annotationProps = props; canvas.add(fabricObj); canvas.setActiveObject(fabricObj); @@ -167,7 +177,7 @@ export const SignatureTool: ToolHandler = { }, vp); const annotation: SignatureAnnotation = { - id: (fabricObj as any).id, + id: annotatedObject.id as string, page: pageNumber, type: 'signature', rect: pdfRect, @@ -186,7 +196,7 @@ export const SignatureTool: ToolHandler = { }, renderToFabric: async (annotation: Annotation, canvas: fabric.Canvas, vp: ViewportParams) => { - if (annotation.type !== 'signature') return null; + if (annotation.type !== 'signature') return; const sigAnn = annotation as SignatureAnnotation; const screenRect = pdfRectToScreen(sigAnn.rect, vp); @@ -199,19 +209,23 @@ 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 null; + return; } } 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, }); @@ -222,17 +236,18 @@ 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 a5acf4e..4fd31e0 100644 --- a/frontend/src/features/editor/tools/TextTool.ts +++ b/frontend/src/features/editor/tools/TextTool.ts @@ -3,9 +3,14 @@ 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 { screenToPdf, pdfRectToScreen } from '../../../lib/coords'; +import { pdfRectToScreen, screenRectToPdf } from '../../../lib/coords'; import type { ViewportParams } from '../../../lib/coords'; +type FabricTextObject = fabric.Textbox & { + id?: string; + customHeight?: number; +}; + export const TextTool: ToolHandler = { name: 'text', @@ -18,7 +23,7 @@ export const TextTool: ToolHandler = { canvas.defaultCursor = 'default'; }, - onPointerDown: (e: any, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => { + onPointerDown: (e: fabric.TPointerEventInfo, canvas: fabric.Canvas, vp: ViewportParams, pageNumber: number) => { // If we clicked on an existing object, don't create a new one if (e.target) return; @@ -39,7 +44,7 @@ export const TextTool: ToolHandler = { top: pointer.y, width: 150 * vp.scale, fontFamily: fontFamily, - fontSize: fontSize * vp.scale * (vp.dpr || 1), + fontSize: fontSize * vp.scale, fontWeight: isBold ? 'bold' : 'normal', fontStyle: isItalic ? 'italic' : 'normal', fill: color, @@ -55,12 +60,13 @@ export const TextTool: ToolHandler = { }); // Allow manual height control - (textbox as any).customHeight = textbox.height; + const annotatedTextbox = textbox as FabricTextObject; + annotatedTextbox.customHeight = textbox.height; const originalInitDimensions = textbox.initDimensions.bind(textbox); textbox.initDimensions = function() { originalInitDimensions(); - if ((this as any).customHeight !== undefined) { - this.height = (this as any).customHeight; + if ((this as FabricTextObject).customHeight !== undefined) { + this.height = (this as FabricTextObject).customHeight as number; } }; @@ -94,7 +100,7 @@ export const TextTool: ToolHandler = { const w = textbox.width! * textbox.scaleX!; const h = textbox.height! * textbox.scaleY!; - (textbox as any).customHeight = h; + annotatedTextbox.customHeight = h; textbox.set({ width: w, @@ -106,24 +112,27 @@ export const TextTool: ToolHandler = { }); const newId = uuidv4(); - (textbox as any).id = newId; + annotatedTextbox.id = newId; canvas.add(textbox); canvas.setActiveObject(textbox); textbox.enterEditing(); // Add to store immediately so the toolbar shows up instantly - const pt = screenToPdf({ x: textbox.left!, y: textbox.top! }, vp); + const initialBounds = textbox.getBoundingRect(); const newAnn: TextAnnotation = { id: newId, page: pageNumber, type: 'text', - rect: { - x: pt.x, - y: pt.y, - width: textbox.width! / (vp.scale * (vp.dpr || 1)), - height: textbox.height! / (vp.scale * (vp.dpr || 1)) - }, + rect: screenRectToPdf( + { + x: initialBounds.left, + y: initialBounds.top, + width: initialBounds.width, + height: initialBounds.height, + }, + vp, + ), rotation: 0, z: 0, createdAt: new Date().toISOString(), @@ -143,41 +152,49 @@ 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; } - // 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; + 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(), + }; // Save inline styles if any exist - if ((textbox as any).styles && Object.keys((textbox as any).styles).length > 0) { - currentAnn.props.styles = JSON.parse(JSON.stringify((textbox as any).styles)); + if (textbox.styles && Object.keys(textbox.styles).length > 0) { + nextAnnotation.props.styles = JSON.parse(JSON.stringify(textbox.styles)); } - useEditorStore.getState().addAnnotation(currentAnn); + state.setDraftAnnotation(null); + state.addAnnotation(nextAnnotation); }); }, 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 * (vp.dpr || 1); + const boxHeight = textAnn.rect.height * vp.scale; const textbox = new fabric.Textbox(textAnn.props.text, { left: pt.x, top: pt.y, - width: textAnn.rect.width * vp.scale * (vp.dpr || 1), + width: textAnn.rect.width * vp.scale, height: boxHeight, fontFamily: textAnn.props.fontFamily, - fontSize: textAnn.props.fontSize * vp.scale * (vp.dpr || 1), + fontSize: textAnn.props.fontSize * vp.scale, fontWeight: textAnn.props.bold ? 'bold' : 'normal', fontStyle: textAnn.props.italic ? 'italic' : 'normal', fill: textAnn.props.color, @@ -194,12 +211,13 @@ export const TextTool: ToolHandler = { }); // Allow manual height control - (textbox as any).customHeight = boxHeight; + const annotatedTextbox = textbox as FabricTextObject; + annotatedTextbox.customHeight = boxHeight; const originalInitDimensions = textbox.initDimensions.bind(textbox); textbox.initDimensions = function() { originalInitDimensions(); - if ((this as any).customHeight !== undefined) { - this.height = (this as any).customHeight; + if ((this as FabricTextObject).customHeight !== undefined) { + this.height = (this as FabricTextObject).customHeight as number; } }; @@ -232,7 +250,7 @@ export const TextTool: ToolHandler = { const w = textbox.width! * textbox.scaleX!; const h = textbox.height! * textbox.scaleY!; - (textbox as any).customHeight = h; + annotatedTextbox.customHeight = h; textbox.set({ width: w, @@ -248,16 +266,16 @@ export const TextTool: ToolHandler = { const currentAnn = useEditorStore.getState().annotations.find(a => a.id === annotation.id) as TextAnnotation | undefined; if (!currentAnn) return; - 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)); + 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; } useEditorStore.getState().updateAnnotation(annotation.id, { props: { ...currentAnn.props, ...updates } }); }); - (textbox as any).id = annotation.id; + annotatedTextbox.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 8e7d44d..b2979cf 100644 --- a/frontend/src/features/editor/tools/index.ts +++ b/frontend/src/features/editor/tools/index.ts @@ -3,12 +3,16 @@ 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 5f060d6..b2d11c8 100644 --- a/frontend/src/features/editor/useAutosave.ts +++ b/frontend/src/features/editor/useAutosave.ts @@ -1,75 +1,135 @@ -import { useEffect, useRef } from 'react'; -import { useEditorStore } from './store'; +import { useCallback, useEffect, useRef } from 'react'; +import { api, ApiRequestError } from '../../lib/api/client'; import type { Annotation } from '../../lib/annotations/types'; +import { useEditorStore } from './store'; -export function useAutosave() { - const { documentId, annotations, setSaveStatus, setAnnotations } = useEditorStore(); +interface AnnotationResponse { + data: Annotation[]; + updatedAt: string; +} + +interface AnnotationUpdateResponse { + updatedAt: string; +} + +export function useAutosave(documentId: string | undefined) { + const loadedDocumentId = useRef(null); + const skipNextSave = useRef(false); const timeoutRef = useRef(null); - const isFirstLoad = useRef(true); + 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); - // Initial load useEffect(() => { - if (!documentId) return; + 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]); + useEffect(() => { + if (!documentId || storeDocumentId !== documentId) return; let active = true; + setSaveStatus('loading'); async function load() { try { - 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; + 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'); } - } catch (err) { - console.error('Error loading annotations:', err); - if (active) setSaveStatus('error'); } } - load(); - - return () => { active = false; }; - }, [documentId, setAnnotations, setSaveStatus]); - - // Debounced save - useEffect(() => { - // Don't save on the initial load! - if (isFirstLoad.current) return; - if (!documentId) return; + void load(); + return () => { + active = false; + }; + }, [documentId, setAnnotationUpdatedAt, setAnnotations, setSaveStatus, storeDocumentId]); + 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'); - 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 - }) + 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 (!res.ok) throw new Error('Failed to save'); + if (saveGeneration.current === generation && useEditorStore.getState().documentId === documentId) { + setAnnotationUpdatedAt(response.updatedAt); setSaveStatus('saved'); - } catch (err) { - console.error('Error saving annotations:', err); + } + return true; + } catch (error) { + if (saveGeneration.current === generation && useEditorStore.getState().documentId === documentId) { + console.error('Error saving annotations:', error); setSaveStatus('error'); } - }, 500); // 500ms debounce + 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); return () => { - if (timeoutRef.current) window.clearTimeout(timeoutRef.current); + saveGeneration.current += 1; + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } }; - }, [annotations, documentId, setSaveStatus]); + }, [annotations, documentId, flush, setSaveStatus, storeDocumentId]); + + useEffect(() => () => { + saveGeneration.current += 1; + if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current); + }, []); + + return { flush }; } diff --git a/frontend/src/features/editor/versions/VersionPanel.tsx b/frontend/src/features/editor/versions/VersionPanel.tsx new file mode 100644 index 0000000..51b044e --- /dev/null +++ b/frontend/src/features/editor/versions/VersionPanel.tsx @@ -0,0 +1,236 @@ +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 7d7604b..b940699 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, deleteDocument, restoreDocument, updateDocument } = useLibrary() + const { documents, selectedIds, isLoading, error, toggleSelection, deleteDocument, restoreDocument, updateDocument } = useLibrary() const navigate = useNavigate() const [contextMenu, setContextMenu] = useState<{ x: number, y: number, docId: string } | null>(null) @@ -61,6 +61,16 @@ 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 && (
@@ -127,9 +137,13 @@ export const DocumentGrid = ({ isTrash }: { isTrash: boolean }) => { } } }} - onDelete={() => deleteDocument(contextMenu.docId)} + onDelete={() => { + if (window.confirm('Move this document to Trash?')) void deleteDocument(contextMenu.docId) + }} onRestore={() => restoreDocument(contextMenu.docId)} - onHardDelete={() => deleteDocument(contextMenu.docId, true)} + onHardDelete={() => { + if (window.confirm('Delete this document permanently? This cannot be undone.')) void deleteDocument(contextMenu.docId, true) + }} /> )} diff --git a/frontend/src/features/library/LibraryHeader.tsx b/frontend/src/features/library/LibraryHeader.tsx index 27a503c..62fcaca 100644 --- a/frontend/src/features/library/LibraryHeader.tsx +++ b/frontend/src/features/library/LibraryHeader.tsx @@ -94,7 +94,9 @@ export const LibraryHeader = ({ currentTab, onTabChange }: { currentTab: 'librar Restore
- - - {/* Invisible overlay that appears when dragging to prevent flickering */} - {isDragging && ( -
-
-
- - - -
-

Drop PDFs here

-

Release to upload to your library

-
-
- )} + fileInputRef.current?.click()}> +
void handleDrop(event)}> + void handleFileChange(event)} className="hidden" multiple /> - {children} -
- ) -} + {isDragging && ( +
+
+
+ + + +
+

Drop PDFs here

+

Release to upload to your library

+
+
+ )} + + {children} +
+ + ); +}; diff --git a/frontend/src/features/library/upload-context.ts b/frontend/src/features/library/upload-context.ts new file mode 100644 index 0000000..c791a87 --- /dev/null +++ b/frontend/src/features/library/upload-context.ts @@ -0,0 +1,9 @@ +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 f429d95..38e2645 100644 --- a/frontend/src/features/library/useLibrary.ts +++ b/frontend/src/features/library/useLibrary.ts @@ -28,6 +28,17 @@ 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, @@ -56,8 +67,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: any) { - set({ error: err.error?.message || err.message, isLoading: false }) + } catch (err: unknown) { + set({ error: errorMessage(err), isLoading: false }) } }, @@ -66,8 +77,8 @@ export const useLibrary = create((set, get) => ({ try { const data = await libraryApi.getTrash() set({ documents: data.items, total: data.total, isLoading: false }) - } catch (err: any) { - set({ error: err.error?.message || err.message, isLoading: false }) + } catch (err: unknown) { + set({ error: errorMessage(err), isLoading: false }) } }, @@ -81,8 +92,8 @@ export const useLibrary = create((set, get) => ({ isLoading: false })) return newDoc - } catch (err: any) { - set({ error: err.error?.message || err.message, isLoading: false }) + } catch (err: unknown) { + set({ error: errorMessage(err), isLoading: false }) throw err } }, @@ -97,8 +108,8 @@ export const useLibrary = create((set, get) => ({ selectedIds: new Set(Array.from(state.selectedIds).filter(sid => sid !== id)), isLoading: false })) - } catch (err: any) { - set({ error: err.error?.message || err.message, isLoading: false }) + } catch (err: unknown) { + set({ error: errorMessage(err), isLoading: false }) throw err } }, @@ -113,8 +124,8 @@ export const useLibrary = create((set, get) => ({ selectedIds: new Set(Array.from(state.selectedIds).filter(sid => sid !== id)), isLoading: false })) - } catch (err: any) { - set({ error: err.error?.message || err.message, isLoading: false }) + } catch (err: unknown) { + set({ error: errorMessage(err), isLoading: false }) throw err } }, @@ -138,8 +149,8 @@ export const useLibrary = create((set, get) => ({ selectedIds: new Set(), isLoading: false })) - } catch (err: any) { - set({ error: err.error?.message || err.message, isLoading: false }) + } catch (err: unknown) { + set({ error: errorMessage(err), isLoading: false }) throw err } }, @@ -157,8 +168,8 @@ export const useLibrary = create((set, get) => ({ selectedIds: new Set(), isLoading: false })) - } catch (err: any) { - set({ error: err.error?.message || err.message, isLoading: false }) + } catch (err: unknown) { + set({ error: errorMessage(err), isLoading: false }) throw err } }, @@ -173,8 +184,8 @@ export const useLibrary = create((set, get) => ({ selectedIds: new Set(), isLoading: false }) - } catch (err: any) { - set({ error: err.error?.message || err.message, isLoading: false }) + } catch (err: unknown) { + set({ error: errorMessage(err), isLoading: false }) throw err } }, @@ -187,8 +198,8 @@ export const useLibrary = create((set, get) => ({ documents: state.documents.map(d => d.id === id ? updatedDoc : d), isLoading: false })) - } catch (err: any) { - set({ error: err.error?.message || err.message, isLoading: false }) + } catch (err: unknown) { + set({ error: errorMessage(err), isLoading: false }) throw err } } diff --git a/frontend/src/index.css b/frontend/src/index.css index 2848b5d..07e0507 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -6,6 +6,11 @@ @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 584752c..b50fde2 100644 --- a/frontend/src/lib/annotations/registry.ts +++ b/frontend/src/lib/annotations/registry.ts @@ -17,15 +17,19 @@ export interface ToolHandler { onDeactivate?: (canvas: Canvas) => void; /** Called when the user presses down on the canvas. */ - onPointerDown?: (e: any, canvas: Canvas, viewportParams: ViewportParams, pageNumber: number) => void; + onPointerDown?: (e: fabric.TPointerEventInfo, canvas: Canvas, viewportParams: ViewportParams, pageNumber: number) => void | Promise; /** Called when the user drags the pointer. */ - 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; + 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; // Fabric to/from Annotation serialization - renderToFabric?: (annotation: Annotation, canvas: Canvas, viewportParams: ViewportParams) => void; + renderToFabric?: ( + annotation: Annotation, + canvas: Canvas, + viewportParams: ViewportParams, + ) => void | Promise; } const registry = new Map(); diff --git a/frontend/src/lib/annotations/types.ts b/frontend/src/lib/annotations/types.ts index be65af5..4dd7531 100644 --- a/frontend/src/lib/annotations/types.ts +++ b/frontend/src/lib/annotations/types.ts @@ -1,28 +1,125 @@ -import type { components } from '../../types/api'; +/** + * 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. + */ -export type TextProps = components['schemas']['TextProps'] & { highlightColor?: string | null, styles?: Record | null }; +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} -export type TextAnnotation = Omit & { +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'; 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 type Annotation = +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 = | TextAnnotation | DrawAnnotation | SignatureAnnotation | ImageAnnotation | HighlightAnnotation - | 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']; + | ShapeAnnotation + | UnknownAnnotation; diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 5dc2e0c..e62504c 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -30,6 +30,21 @@ 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. */ @@ -75,23 +90,19 @@ async function apiFetch( } // Parse response - let data: any = {} + let data: unknown = {} try { const text = await response.text() if (text) { data = JSON.parse(text) } - } catch (err) { + } catch { // If it's not JSON, we'll just fall back to the empty object } // Handle error responses if (!response.ok) { - const error: ApiError = { - code: data.error?.code ?? 'unknown', - message: data.error?.message ?? data.detail ?? response.statusText, - } - throw new ApiRequestError(response.status, error) + throw new ApiRequestError(response.status, toApiError(data, response.statusText)) } return data as T @@ -158,17 +169,15 @@ export const api = { } if (!response.ok) { - let data: any = {} + let data: unknown = {} try { const text = await response.text() if (text) data = JSON.parse(text) - } catch (err) {} - - const error: ApiError = { - code: data.error?.code ?? 'unknown', - message: data.error?.message ?? data.detail ?? response.statusText, + } catch { + data = {} } - throw new ApiRequestError(response.status, error) + + throw new ApiRequestError(response.status, toApiError(data, response.statusText)) } return response.blob() diff --git a/frontend/src/lib/coords.test.ts b/frontend/src/lib/coords.test.ts index 0d4f37a..7e44e79 100644 --- a/frontend/src/lib/coords.test.ts +++ b/frontend/src/lib/coords.test.ts @@ -92,4 +92,16 @@ 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 701d530..90bb824 100644 --- a/frontend/src/lib/coords.ts +++ b/frontend/src/lib/coords.ts @@ -5,19 +5,18 @@ 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; // Device Pixel Ratio (defaults to 1) -} - -function getEffectiveScale(vp: ViewportParams): number { - return vp.scale * (vp.dpr || 1); + dpr?: number; // Backing-store density; never part of CSS geometry } export function pdfToScreen(p: PdfPoint, vp: ViewportParams): ScreenPoint { - const s = getEffectiveScale(vp); + // 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 { rotation, canonicalWidth: W, canonicalHeight: H } = vp; const { x, y } = p; @@ -33,7 +32,7 @@ export function pdfToScreen(p: PdfPoint, vp: ViewportParams): ScreenPoint { } export function screenToPdf(p: ScreenPoint, vp: ViewportParams): PdfPoint { - const s = getEffectiveScale(vp); + const s = vp.scale; 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 11579dd..a4f5094 100644 --- a/frontend/src/pages/EditorPage.tsx +++ b/frontend/src/pages/EditorPage.tsx @@ -1,61 +1,113 @@ -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' +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; +} -/** - * Editor page — PDF canvas workspace. - */ export function EditorPage() { - const { id } = useParams<{ id: string }>() - const { setDocumentId } = useEditorStore() - useAutosave() + 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); useEffect(() => { - if (id) { - setDocumentId(id) - } - }, [id, setDocumentId]) + 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]); - if (!id) return
Invalid document 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); + } + }, [flush, id, title]); + + if (!id) return
Invalid document ID.
; return ( -
- {/* Editor toolbar */} -
-
- +
+
+
+ ← Back - - Document {id} - +
+ {title}
-
- - - Saved - -
- {/* Main workspace area */} -
- +
+ void handleExport()} onOpenVersions={() => setIsVersionPanelOpen(true)} isExporting={isExporting} /> -
+ + + {isVersionPanelOpen && setIsVersionPanelOpen(false)} />}
- ) + ); } diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 11d9149..9b20c3c 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 } from '../features/library/UploadArea' +import { UploadArea, UploadTrigger } from '../features/library/UploadArea' import { DocumentGrid } from '../features/library/DocumentGrid' import { useLibrary } from '../features/library/useLibrary' @@ -38,26 +38,12 @@ export function HomePage() {

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

- +
{/* Visual Graphic */} diff --git a/frontend/src/types/api.d.ts b/frontend/src/types/api.d.ts index 2f33baf..0d449e2 100644 --- a/frontend/src/types/api.d.ts +++ b/frontend/src/types/api.d.ts @@ -272,6 +272,46 @@ 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; @@ -290,6 +330,79 @@ 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; @@ -310,28 +423,6 @@ 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 { @@ -339,7 +430,9 @@ export interface components { /** AnnotationStateResponse */ AnnotationStateResponse: { /** Data */ - data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[]; + data: { + [key: string]: unknown; + }[]; /** * Updatedat * Format: date-time @@ -349,7 +442,9 @@ export interface components { /** AnnotationStateUpdateRequest */ AnnotationStateUpdateRequest: { /** Data */ - data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[]; + data: { + [key: string]: unknown; + }[]; /** Baseupdatedat */ baseUpdatedAt?: string | null; }; @@ -368,6 +463,11 @@ 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 */ @@ -425,402 +525,31 @@ export interface components { /** In Trash */ in_trash?: boolean | null; }; - /** DrawAnnotation */ - DrawAnnotation: { + /** ExportRequest */ + ExportRequest: { + /** Versionid */ + versionId?: string | null; /** - * Id - * Format: uuid + * Flatten + * @default true */ - 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; + flatten: boolean; }; /** 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 */ @@ -834,20 +563,58 @@ export interface components { /** Context */ ctx?: Record; }; - /** VerifyCoordsRequest */ - VerifyCoordsRequest: { - /** Document Id */ - document_id: string; - /** Page */ - page: number; - /** X */ - x: number; - /** Y */ - y: number; - /** Width */ - width: number; - /** Height */ - height: number; + /** 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; }; }; responses: never; @@ -1052,7 +819,7 @@ export interface operations { }; responses: { /** @description Successful Response */ - 200: { + 201: { headers: { [name: string]: unknown; }; @@ -1381,6 +1148,75 @@ 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; @@ -1447,11 +1283,13 @@ export interface operations { }; }; }; - health_check_api_v1_health_get: { + list_versions_api_v1_documents__document_id__versions_get: { parameters: { query?: never; header?: never; - path?: never; + path: { + document_id: string; + }; cookie?: never; }; requestBody?: never; @@ -1462,23 +1300,161 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: string; - }; + "application/json": components["schemas"]["VersionListResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; }; }; }; }; - verify_coords_api_v1_debug_verify_coords_post: { + create_version_api_v1_documents__document_id__versions_post: { parameters: { query?: never; header?: never; - path?: never; + path: { + document_id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["VerifyCoordsRequest"]; + "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"]; }; }; responses: { @@ -1502,4 +1478,26 @@ 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 630a6de..0d449e2 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -272,6 +272,46 @@ 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; @@ -290,6 +330,79 @@ 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; @@ -310,28 +423,6 @@ 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 { @@ -339,7 +430,9 @@ export interface components { /** AnnotationStateResponse */ AnnotationStateResponse: { /** Data */ - data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[]; + data: { + [key: string]: unknown; + }[]; /** * Updatedat * Format: date-time @@ -349,7 +442,9 @@ export interface components { /** AnnotationStateUpdateRequest */ AnnotationStateUpdateRequest: { /** Data */ - data: (components["schemas"]["TextAnnotation"] | components["schemas"]["DrawAnnotation"] | components["schemas"]["SignatureAnnotation"] | components["schemas"]["ImageAnnotation"] | components["schemas"]["HighlightAnnotation"] | components["schemas"]["ShapeAnnotation"])[]; + data: { + [key: string]: unknown; + }[]; /** Baseupdatedat */ baseUpdatedAt?: string | null; }; @@ -368,6 +463,11 @@ 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 */ @@ -425,396 +525,31 @@ export interface components { /** In Trash */ in_trash?: boolean | null; }; - /** DrawAnnotation */ - DrawAnnotation: { + /** ExportRequest */ + ExportRequest: { + /** Versionid */ + versionId?: string | null; /** - * Id - * Format: uuid + * Flatten + * @default true */ - 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; + flatten: boolean; }; /** 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 */ @@ -828,20 +563,58 @@ export interface components { /** Context */ ctx?: Record; }; - /** VerifyCoordsRequest */ - VerifyCoordsRequest: { - /** Document Id */ - document_id: string; - /** Page */ - page: number; - /** X */ - x: number; - /** Y */ - y: number; - /** Width */ - width: number; - /** Height */ - height: number; + /** 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; }; }; responses: never; @@ -1046,7 +819,7 @@ export interface operations { }; responses: { /** @description Successful Response */ - 200: { + 201: { headers: { [name: string]: unknown; }; @@ -1375,6 +1148,75 @@ 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; @@ -1441,11 +1283,13 @@ export interface operations { }; }; }; - health_check_api_v1_health_get: { + list_versions_api_v1_documents__document_id__versions_get: { parameters: { query?: never; header?: never; - path?: never; + path: { + document_id: string; + }; cookie?: never; }; requestBody?: never; @@ -1456,23 +1300,161 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": { - [key: string]: string; - }; + "application/json": components["schemas"]["VersionListResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; }; }; }; }; - verify_coords_api_v1_debug_verify_coords_post: { + create_version_api_v1_documents__document_id__versions_post: { parameters: { query?: never; header?: never; - path?: never; + path: { + document_id: string; + }; cookie?: never; }; requestBody: { content: { - "application/json": components["schemas"]["VerifyCoordsRequest"]; + "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"]; }; }; responses: { @@ -1496,4 +1478,26 @@ 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 62b6e82..bf1facb 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":{"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 +{"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 diff --git a/openapi_pretty.json b/openapi_pretty.json index 896226d..e84200e 100644 --- a/openapi_pretty.json +++ b/openapi_pretty.json @@ -1,1893 +1,1738 @@ { - "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" - } - } - } - } + "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" } + } } + } } + } }, - "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" + "/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 diff --git a/shared/annotation-schema.json b/shared/annotation-schema.json index accf8c2..bd1e3fd 100644 --- a/shared/annotation-schema.json +++ b/shared/annotation-schema.json @@ -104,7 +104,9 @@ "kind": { "type": "string", "enum": ["rect", "ellipse", "line", "arrow"] }, "strokeColor": { "type": "string", "default": "#000000" }, "fillColor": { "type": "string", "default": "transparent" }, - "strokeWidth": { "type": "number", "default": 2 } + "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 } }, "required": ["kind"] }