A web-based tool for digitizing, dating, and organizing a large collection of family photographs.
  • Python 73.4%
  • TypeScript 23.7%
  • CSS 1.8%
  • Dockerfile 0.5%
  • JavaScript 0.3%
  • Other 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
renovate-bot 47e8781783
Some checks failed
CI / frontend (push) Successful in 1m53s
CI / e2e (push) Failing after 2m54s
CI / backend (push) Successful in 38m48s
CI / publish (push) Has been skipped
Merge pull request 'chore(deps): update dependency react-router to v8.3.1' (#162) from renovate/react-router-monorepo into main
2026-09-07 06:46:50 +00:00
.forgejo/workflows Decide whether to publish in the shell, not in an expression 2026-08-06 12:48:14 -05:00
backend Release v0.3.3 2026-08-08 14:07:50 -05:00
docs Reshape the review panel, and give the interface a voice it can be read in 2026-08-03 23:34:45 -05:00
e2e chore(deps): update dependency @playwright/test to v1.62.1 2026-08-10 06:07:35 +00:00
frontend Merge pull request 'chore(deps): update dependency react-router to v8.3.1' (#162) from renovate/react-router-monorepo into main 2026-09-07 06:46:50 +00:00
.gitattributes Build Circa into images something can actually deploy 2026-08-05 19:50:37 -05:00
.gitignore Walk the reviewer journey in a real browser 2026-08-03 10:50:20 -05:00
AGENTS.md Move work tracking to Forgejo issues and milestones 2026-07-28 00:01:40 -05:00
CHANGELOG.md Stop a contended runner from failing a release 2026-08-08 15:11:37 -05:00
LICENSE Initial commit 2026-04-14 03:34:51 +00:00
README.md Let Authentik decide who gets in, and what they may do 2026-08-08 12:34:40 -05:00
renovate.json Add CI, lockfiles, ruff config, and Renovate configuration 2026-07-28 12:26:41 -05:00

Circa

A web-based tool for digitizing, dating, and organizing a large collection of family photographs.

Status

Phase 1 is implemented and running. The core review workflow is functional. Remaining Phase 1 gaps — the background worker that executes AI/OCR rerun jobs, the AI and OCR backends themselves, and the automated test suite — are tracked under the v0.2.0 milestone.

Stack

  • Backend: Python 3.11+ · FastAPI · SQLAlchemy 2 · Alembic · SQLite (WAL mode)
  • Frontend: React 19 · TypeScript · Vite · TanStack Query · React Router v7
  • Auth: OpenID Connect via Authlib — any OIDC provider via discovery (Authentik, Keycloak, Google) · itsdangerous signed session cookies
  • Storage: Local filesystem (content-addressed by SHA-256)

Repo Layout

backend/
  app/
    api/routes/     — HTTP endpoints (auth, photos, jobs, ingest, health)
    auth/           — OAuth client, session helpers
    db/             — SQLAlchemy engine and session
    models/         — ORM models and enums
    repositories/   — DB access layer
    services/       — business logic (ingest, projections, storage, parsers)
    workers/        — background worker: runner, handler registry, handlers
  alembic/versions/ — database migrations
frontend/
  src/
    api/            — typed fetch client
    components/     — AppShell
    hooks/          — useAuth
    pages/          — Dashboard, PhotoBrowser, ReviewWorkspace, Jobs, Login
    types/          — shared TypeScript types
docs/               — product, implementation, UI, wireframe, API, and planning docs

Running Locally

Backend

cd backend
python -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/python -m alembic upgrade head
.venv/bin/uvicorn app.main:app --reload

The backend starts at http://localhost:8000.

Configuration — copy .env.example to .env or set environment variables with the CIRCA_ prefix:

Variable Default Description
CIRCA_ENVIRONMENT production development or production. Defaults to production so an unconfigured deployment refuses to start rather than running insecurely — set development locally
CIRCA_DATABASE_URL sqlite:///./circa.db SQLAlchemy DB URL
CIRCA_SECRET_KEY change-me-in-production Session signing key. Development-only default — refused in production
CIRCA_DEV_LOGIN_ENABLED false Development-only login bypass. Refused in production
CIRCA_LOG_LEVEL INFO DEBUG/INFO/WARNING/ERROR/CRITICAL
CIRCA_LOG_FORMAT json json for shipping, text for a terminal
CIRCA_SQLITE_ECHO false Log every SQL statement. Off by default because the statements include note and comment bodies
CIRCA_OAUTH_PROVIDER oidc Provider mode. oidc for any OIDC provider; google enables the Google discovery fallback
CIRCA_OAUTH_SERVER_METADATA_URL (empty) Required for oidc. The provider's OpenID Connect discovery endpoint
CIRCA_OAUTH_CLIENT_ID (empty) OIDC client ID
CIRCA_OAUTH_CLIENT_SECRET (empty) OIDC client secret
CIRCA_OAUTH_REDIRECT_URI http://localhost:8000/api/auth/callback OIDC callback URL
CIRCA_OIDC_ADMIN_GROUP (empty) Provider group granting admin. Required in production
CIRCA_OIDC_REVIEWER_GROUP (empty) Provider group granting reviewer
CIRCA_OIDC_GROUPS_CLAIM groups The claim carrying group names
CIRCA_REQUIRE_VERIFIED_EMAIL false Refuse a login whose email_verified claim is not truthy
CIRCA_STORAGE_LOCAL_ROOT ./storage Directory for stored photo files
CIRCA_SQLITE_SYNCHRONOUS NORMAL SQLite durability level. NORMAL is the standard pairing with WAL
CIRCA_SQLITE_BUSY_TIMEOUT_MS 10000 How long a writer waits for the write lock before failing
CIRCA_SQLITE_STRICT_WRITE_INTENT (auto) Raise on a write outside a write transaction. On outside production

Set CIRCA_OAUTH_SERVER_METADATA_URL to your provider's discovery endpoint — for Authentik that is https://auth.example.com/application/o/<app-slug>/.well-known/openid-configuration. Leaving it empty raises at startup unless CIRCA_OAUTH_PROVIDER=google, which falls back to Google's well-known URL.

Startup refuses an insecure production configuration. With CIRCA_ENVIRONMENT=production (the default), the app will not start if CIRCA_SECRET_KEY is unset, still the default above, or shorter than 32 characters; if the OAuth client id or secret is missing; if CIRCA_OAUTH_REDIRECT_URI is not https; if CIRCA_OIDC_ADMIN_GROUP is unset; or if CIRCA_DEV_LOGIN_ENABLED is on. It reports every problem at once, names each variable, and does not warn-and-continue — that key signs the session cookies, and a warning in a log nobody reads is how a published default reaches production.

For local work set CIRCA_ENVIRONMENT=development, which permits all of the above deliberately and logs one line saying which concessions are in play.

Signing in locally without OAuth. Set CIRCA_DEV_LOGIN_ENABLED=true (development only) and the sign-in page grows a clearly labelled development panel: enter any email, pick a role, and you get a real session. It goes through the same session code the OAuth callback uses, so what you are holding afterwards is indistinguishable from a genuine login — including revocation and expiry — which is the point, since the end-to-end tests walk the reviewer journey with it. Every use is logged at warning level with the identity. The flag is off by default and a production deployment with it on refuses to start.

Access control comes from the identity provider. Who may authenticate is your provider's policy binding on the Circa application, and authenticating successfully is sufficient for a viewer account. The role is read from the groups claim on every login: a member of CIRCA_OIDC_ADMIN_GROUP is an admin, a member of CIRCA_OIDC_REVIEWER_GROUP is a reviewer, everyone else is a viewer. Removal from a group demotes at the next sign-in, which is what makes "manage it in the IdP" true in both directions.

Bind the Circa application to a group in your provider before you rely on this — otherwise "can authenticate" does mean "can read the photo archive." Because the provider is authoritative, PATCH /api/users/{id} refuses role changes while a group is configured rather than accepting a write the next login would overwrite; deactivating an account still works, since the provider does not set that.

An unset CIRCA_OIDC_ADMIN_GROUP refuses to start in production: with the role coming from groups and nothing else, there would be no route to an administrator at all. If the groups claim is missing from the token the callback logs a warning naming the claim it looked for — that case makes everyone a viewer including the intended admin, and it is not the same as a user who is genuinely in no groups.

Database concurrency. SQLite allows one writer at a time, so Circa declares write intent up front: a request with an unsafe HTTP method gets a session whose transactions BEGIN IMMEDIATE, and reads stay deferred so they neither wait for the write lock nor hold it. This is what lets a background worker write alongside the API — a deferred transaction that reads and then writes fails immediately with SQLITE_BUSY_SNAPSHOT if anyone commits in between, and no busy timeout can retry that. Code that writes without a request behind it (workers, the CLI tools below) must use app.db.session.write_session(). Outside production, writing on a deferred transaction raises rather than waiting to become a race.

Job handlers must never hold a write transaction across a network call: do the slow work first, then open a short transaction to record the result. A handler that calls an AI provider inside its transaction holds the write lock for the whole call and turns concurrent reviewer saves into timeouts.

Display derivatives

Ingest builds a thumbnail (400px) and a review-size image (2048px) for every scan, in the sandboxed subprocess Pillow already runs in. The grid serves /media/thumb, the workspace asks for ?variant=review, and a bare /media/front still returns the untouched original — the archival bytes are the default and the optimisation is opt-in.

Photographs ingested before this existed have no derivatives. Queue them:

cd backend
.venv/bin/python -m app.cli.backfill_derivatives --dry-run
.venv/bin/python -m app.cli.backfill_derivatives
.venv/bin/python -m app.workers.runner        # builds them

Until the backfill runs the grid falls back to serving originals, so nothing is broken — just slower than it should be.

Adding photographs

The browser is the way in (#142). Sign in as a reviewer or an admin, click Add photos in the rail, and drop a folder of scans onto the page or choose them with a button. Files go up one at a time, and the page says what happened to each one — added, already here, or needing a look. Re-adding the same folder is safe and adds nothing twice, so an interrupted run is finished by doing it again.

This is the intended path, not a convenience: the people doing the scanning have no shell on the server. POST /api/ingest takes one file per request, which is what gives per-file outcomes and resumability; there is no batch endpoint and no watch directory, and neither is planned.

The folder scan below stays for whoever does have a shell — a one-off import of a directory already sitting on the server, and the only sensible way to move tens of thousands of files.

Bulk ingest from the command line

A whole folder of scans goes in with one command, front and back scans pairing themselves by album and sequence:

cd backend
.venv/bin/python -m app.cli.ingest_folder ~/scans/redbook --dry-run
.venv/bin/python -m app.cli.ingest_folder ~/scans/redbook

It recurses by default (--no-recursive for the top level only), reports every file it found, and exits non-zero if any failed. Files are committed one at a time, so a run that dies partway keeps what it landed and a re-run picks up where it stopped — every file already in is reported already_ingested and nothing is written twice.

A _b scan attaches to the front already in the archive. If the front has not been ingested yet the back is still kept, as a photograph record with no front side, and the front folds onto that same row whenever it arrives — so a box scanned over two sessions ends up with one row per print regardless of which half arrived first. Where more than one candidate matches, nothing is guessed: the file is ingested unpaired and reported.

Every scan that lands a back side queues an OCR job for it, so the handwriting on the reverse is read without anyone asking per photograph. The reading is done by the worker, not by the import — so python -m app.workers.runner (below) has to be running, alongside the import or after it. Until it has, a folder of backs shows no OCR evidence, which looks exactly like OCR being broken. AI analysis is deliberately not queued by ingest: it is a paid call with a daily ceiling, and an import that spent it would permanently fail every job past the limit. Reruns of both stay a per-photograph decision in the review workspace.

Two things worth knowing before a large import. Derivatives are generated inline, in a subprocess, at roughly 300 ms a scan, and that happens inside the write transaction — so a long run holds SQLite's single write lock in short bursts back to back, and a reviewer saving through the web UI will queue behind whichever file is mid-write. Run a big import when nobody is reviewing. And --dry-run still reads every byte of every file, because predicting duplicates means hashing them.

End-to-end tests

The reviewer journey is walked in a real browser against a real backend — e2e/, Playwright, headless Chromium. It signs in through the development bypass, ingests a photograph through POST /api/ingest, browses and filters, opens the photo, comments, edits notes, approves, and queues a rerun; a second suite puts two reviewers on one photograph and asserts that the stale save is refused, explained, and does not overwrite; a third adds two scans through the Add photos page and checks the collection holds them afterwards.

cd e2e
npm ci
npx playwright test

Playwright starts the backend and a vite preview of the built bundle itself, on their own ports, against a temporary database and storage root that are removed afterwards — so a run touches neither your database nor backend/storage/. It needs Python, Node and a browser in one place; on a machine without all three, ./e2e/run-in-docker.sh runs the same suite in a container. CI runs it on every push.

Worker

Background jobs — AI analysis, OCR — are enqueued by the API and, for OCR on a back scan, by ingest itself, then executed by a separate process. Without it running, a rerun sits in queued and nothing happens — and so does the reading of every back a bulk import has just landed.

cd backend
python -m app.workers.runner        # or: circa-worker

It polls for the oldest claimable job, runs it, and records the outcome. Stop it with Ctrl-C or SIGTERM: the job in hand runs to completion and is recorded, then the process exits. Killing it harder is safe but not free — the job it was running stays running until its lease lapses (60 s by default), at which point any worker takes it back and charges one of its retries.

More than one worker may run at once; they will not execute the same job. Useful flags:

Flag Effect
--once Claim and run at most one job, then exit. Exits non-zero if the job did not succeed
--max-jobs N Exit after N jobs
-v Debug logging
Variable Default Description
CIRCA_WORKER_POLL_SECONDS 1.0 How long an idle worker waits before asking for work again
CIRCA_WORKER_LEASE_SECONDS 60 How long a claim is good for, and so how long a crashed worker's job stays stuck
CIRCA_WORKER_RETRY_BACKOFF_SECONDS 30 First retry delay; doubles per attempt
CIRCA_WORKER_RETRY_BACKOFF_MAX_SECONDS 900 Cap on that delay

A job that fails is retried up to max_retries (3 by default, so four attempts) with a growing delay, then lands in failed with the error on the Jobs page. A failure that retrying cannot fix — a photo that no longer exists, a job type with no handler — fails immediately instead of spending the budget to arrive at the same answer.

OCR needs Tesseract, a system package rather than a Python one — the backend calls the binary directly, so pip install will not provide it:

sudo apt-get install tesseract-ocr tesseract-ocr-eng   # Debian/Ubuntu
brew install tesseract                                  # macOS
winget install UB-Mannheim.TesseractOCR                 # Windows

Only the machine running the worker needs it. Without it, CIRCA_OCR_BACKEND defaults to auto and OCR jobs fail with a message saying the engine is missing, rather than the worker refusing to start — everything else keeps working.

Variable Default Description
CIRCA_OCR_BACKEND auto auto uses Tesseract when present, tesseract insists on it, disabled refuses OCR jobs
CIRCA_OCR_TESSERACT_BINARY tesseract Path or name of the executable
CIRCA_OCR_LANGUAGE eng Tesseract language data; needs the matching tesseract-ocr-<lang> package
CIRCA_OCR_TIMEOUT_SECONDS 120 Wall clock per image

AI dating estimates cost money. The ai_analysis handler sends the front scan to a vision model and records the estimate as evidence. It is off unless an API key is configured, and it refuses to run once a spend ceiling is reached.

Variable Default Description
CIRCA_AI_BACKEND auto auto uses Claude when a key is present, mock is a free deterministic backend, disabled refuses AI jobs
CIRCA_AI_API_KEY (empty) Falls back to ANTHROPIC_API_KEY
CIRCA_AI_MODEL claude-opus-5
CIRCA_AI_DAILY_BUDGET_USD 5.00 0 means unlimited
CIRCA_AI_MONTHLY_BUDGET_USD 30.00 0 means unlimited
CIRCA_AI_INPUT_PRICE_PER_MTOK 5.00 Must match the model's actual price, or the budget is computed from the wrong numbers
CIRCA_AI_OUTPUT_PRICE_PER_MTOK 25.00
CIRCA_AI_CONFIDENCE_THRESHOLD 0.6 At or above this the evidence is medium and queues the photo for review; below it, low

Every call is written to api_usage_log — succeeded or not, because the money is spent either way — and the budget is a sum over that table for the current UTC day and month. A job that would exceed a ceiling fails with a message saying which one and when it resets, rather than silently skipping.

Set CIRCA_AI_BACKEND=mock to exercise the whole path — job, evidence, projection, review UI — without spending anything.

Before the first real run, do one photo rather than a batch:

CIRCA_AI_API_KEY=sk-... python -m app.workers.runner --once

Then check the estimate on that photo and the row in api_usage_log before turning the worker loose.

Frontend

cd frontend
npm install
npm run dev

The frontend starts at http://localhost:5173 and proxies /api to http://localhost:8000.

Deployment

Two images are built and pushed by CI on a version tag (v*):

Image Contents
git.rhoving.com/rbrooks/circa-backend The FastAPI application, Alembic, and Tesseract
git.rhoving.com/rbrooks/circa-frontend The built Vite bundle, served by nginx, with /api proxied to the backend

The compose file that runs them lives in iac-repo (circa-ansible), not here — this repository builds the images and says how they expect to be run.

Three containers, two images

The worker is a separate process from the API, and it is the same image. circa-backend is started twice: once as the API and once as the worker, with different commands.

# API
docker run … git.rhoving.com/rbrooks/circa-backend            # default CMD: uvicorn
# Worker
docker run … git.rhoving.com/rbrooks/circa-backend python -m app.workers.runner

Without the worker container, OCR and AI jobs sit in queued and nothing happens — including the reading of every back scan an import has just landed (see Worker above). The circa-worker console script from pyproject.toml is deliberately not installed in the image; python -m app.workers.runner is the same entry point and needs no packaging step.

The API container should carry the health probe; the worker listens on nothing, so no probe is baked into the image — one that was would mark a perfectly healthy worker unhealthy forever. For the API service:

healthcheck:
  test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/health', timeout=3).status == 200 else 1)"]
  interval: 30s
  timeout: 5s
  retries: 3
  start_period: 10s

GET /api/health is exempt from rate limiting and needs no session.

Migrations are an explicit step

The image does not migrate at startup. Two processes start from it, so an implicit migration on boot is a race between them, and a rollback then has to undo a schema change nobody asked for. Run it as a one-off against the same image, before starting the new containers:

docker compose run --rm api alembic upgrade head

alembic is on PATH, the working directory holds alembic.ini, and the application is importable — so the command is exactly what it is in a checkout. The current head is 017.

OCR needs Tesseract, and it is in the image

tesseract-ocr and tesseract-ocr-eng are installed in circa-backend, and they must stay there. Ingest queues an ocr job for every back scan, and CIRCA_OCR_BACKEND=auto reacts to a missing binary by failing that job, not by refusing to start. An image built without it therefore looks entirely healthy — the API answers, the worker runs, the dashboard loads — while every back scan's reading fails permanently with "No OCR engine found", and the back of a print is where a date is most often written down. The failure is per job and silent; there is no crash to notice.

A collection in another language needs the matching tesseract-ocr-<lang> package added to backend/Dockerfile as well as CIRCA_OCR_LANGUAGE set.

Volumes, and the uid they expect

Two mount points, and they are not equivalent — neither is reproducible:

Path Holds Default
/data The SQLite database: every dating decision, every piece of evidence, the whole audit ledger CIRCA_DATABASE_URL=sqlite:////data/circa.db
/storage The scans themselves, content-addressed. Not replaceable by rescanning CIRCA_STORAGE_LOCAL_ROOT=/storage

Neither is declared as a VOLUME in the image, on purpose: an anonymous volume conjured for an operator who forgot to mount one is a place data goes to be lost quietly.

The container runs as uid 10001, gid 10001 (user circa). The entrypoint starts as root, claims the two mount points for that uid — non-recursively, so it does not walk an archive of tens of thousands of scans on every start — and then drops privileges with gosu. So a bind mount only needs

chown 10001:10001 /srv/circa/data /srv/circa/storage

and a named volume needs nothing at all. Two cases need more:

  • Overriding the uid. Setting user: "1001:1001" in compose (as the carriage role does, to keep host files owned by a service account) makes the entrypoint skip the handover entirely and exec the command as given — it logs one line saying so. The mounted directories must then already be writable by that uid.
  • An archive written by some other uid — a restored backup, or a volume from a deployment that ran as root. Set CIRCA_CHOWN_RECURSIVE=1 for one start to take ownership of everything under both paths, then remove it: it is O(archive) and should be a decision, not a cost paid on every boot.

Configuration

The backend refuses to start on an insecure production configuration (#14) and says every reason at once. At minimum it needs CIRCA_SECRET_KEY (≥32 characters), CIRCA_OAUTH_CLIENT_ID, CIRCA_OAUTH_CLIENT_SECRET, an https CIRCA_OAUTH_REDIRECT_URI and an https CIRCA_OAUTH_SERVER_METADATA_URL, with CIRCA_DEV_LOGIN_ENABLED off. See the table under Backend above; the same variables apply to the API and the worker, which share a database and a storage root and so must be configured identically.

Behind a reverse proxy, set CIRCA_TRUST_PROXY_HEADERS=true — otherwise every request appears to come from the proxy's address and the per-address rate limit becomes a global one. Only with a proxy in front that overwrites X-Forwarded-For, never without.

The frontend image takes two variables of its own:

Variable Default Description
CIRCA_BACKEND_ORIGIN http://backend:8000 Where /api is proxied
CIRCA_RESOLVER 127.0.0.11 DNS for that name, re-consulted at request time so replacing the backend container does not leave nginx 502ing against a cached address. 127.0.0.11 is Docker's embedded resolver and exists on a compose network; it does not on --network host

Phase 1 Capabilities

  • Ingest: Upload photos via POST /api/ingest; exact duplicate detection by SHA-256; filename and EXIF date evidence extracted automatically. A structured filename also files the scan into an album — redbk42_001_1983.jpg creates or reuses album redbk42 and records the photograph's position in it. Filing is deliberately conservative: a name must carry a sequence or a date past the slug, the slug must not be the whole filename, and camera prefixes (IMG_, DSC_, …) are not albums, because an unfiled scan is easy to revisit and an invented album has to be un-invented by hand. python -m app.cli.backfill_albums re-derives filing for photographs ingested before this existed (--dry-run supported; it never overwrites an album somebody set). The Add photos page is how a reviewer puts scans in from a browser, one request per file; a whole folder goes in at once from a shell with python -m app.cli.ingest_folder (see Adding photographs above), which also pairs _b back scans to their fronts by album and sequence — across separate runs, not just within one upload. Every file presented to the archive is recorded in an ingest ledger, including the ones that could not be read, so a re-run writes nothing twice and an unreadable original is a fact the archive can still state a year later rather than a line that scrolled off a terminal
  • Browse: Virtualized photo grid over the whole collection — scrolls past the first page by following meta.next_cursor, and remembers where a reviewer was when they come back from a photograph. Filter by status, album, date range, and the undated / rescan-requested / missing-page / duplicate flags, or switch to the excluded-only view; sort by ingest order, estimated date, or album position. The album dropdown lists each album with the number of photographs the grid will actually show, and is absent entirely from a collection whose filenames carry no album structure. Active filters are shown as chips with one-click removal and a clear-all. Confidence filtering and sort wait on confidence scoring
  • Review workspace: Large image view with front/back toggle; evidence panel, with each row's note shown beneath it and retired rows labelled as superseded rather than merely dimmed; decision form with optimistic concurrency (409 conflict detection); inline notes editing; comments; history tab; AI/OCR rerun job enqueueing; rescan flag
  • Attribution: Comments, decision history, note revisions and hand-recorded evidence are signed with the reviewer's display name, never a user id — the session cookie carries a user id, so handing one to a client hands out the material a forged cookie is built from. Nothing reviewer-facing renders a raw identifier: a duplicate names and links to the scan it duplicates, and the Jobs page links to the photograph by filename
  • Review panel: Evidence sits directly above the decision form it populates, with the decision controls docked to the panel bottom so Approve does not move as the evidence count changes; notes are a tab beside comments and history. Self-hosted Source Sans 3 and Source Serif Pro (49.1 KB, OFL), the serif reserved for dates so the effective date reads as the panel's most important value; tabular figures throughout, a real heading outline, keyboard-operable buttons and links with visible focus, and AA contrast verified by measurement
  • Adopt an evidence date: Click or keyboard-activate a row in the evidence panel to fill the decision form with its dates and precision, instead of retyping what is displayed two rows above. The decision records which evidence it was adopted from, as a foreign key rather than as prose, so the history can say what a date was based on; editing a value by hand clears the link, and a row with no parsed date is not activatable
  • Review queue: The browser's filter and sort become a queue the workspace walks — Prev/Next without returning to the grid, a N left count, auto-advance after a decision (on by default, remembered when switched off), and the next photograph's data and image prefetched. The queue is a frozen list of ids, so a photograph you have just approved does not vanish and renumber everything behind it; it is persisted, so closing the browser and returning resumes where you were
  • Jobs: Background job queue visible in the Jobs page (auto-refreshes), executed by the worker process above — claims are leased, failures retried with backoff, and a worker that dies has its jobs taken back
  • Exclude a scan that is not a photograph: A box of scans contains blank pages, the scanner lid, envelopes, album covers and accidental re-scans; none can be dated, so without this each one sits in the review queue forever. "Not a photograph…" in the review workspace takes one out in two clicks, recording why from a short list of reasons. Excluded scans leave the grid, the review queues and the status counts, and appear as their own dashboard card and an "Excluded only" view. Nothing is destroyed: it is a soft delete (deleted_at, per spec §6.4), the media stays served, the review status is kept rather than overwritten — so putting a photograph back returns it to exactly the state it was in — and both excluding and restoring are recorded in the audit ledger with actor and reason
  • Record evidence by hand: "Edit evidence…" in the evidence panel opens a dialog for adding what a person knows — a date range, a precision, and the sentence it came from ("Mom wrote 1972 on the back") — as manual evidence that sits beside the OCR and EXIF readings and can be weighed against them, rather than as prose in one decision's rationale. Existing evidence can be replaced, which retires the old row and records the new one in a single transaction so the photograph is never left with neither or both, or retired on its own when there is nothing to put in its place. Retired rows stay visible and are labelled as superseded: they are part of how the date was arrived at
  • Auth: OpenID Connect login against any provider supporting discovery; first-admin bootstrap via env var; allowlist-gated account creation; sign out from the nav rail, which revokes the session server-side and empties the client cache so nothing of one reviewer's work survives into the next person's session on a shared machine

Phase 1 Explicit Non-Goals

  • Near-duplicate resolution UI
  • Album drag reorder
  • Backup/restore/export flows
  • Full admin console
  • Event anchor UI

Docs

  • docs/circa-spec.md — product spec
  • docs/circa-implementation-spec.md — implementation guidance
  • docs/circa-wireframes.md — UI wireframes
  • docs/circa-api-spec.md — API spec

Work Tracking

Current and future work is tracked in Forgejo, not in this repository:

  • Issues
  • Milestones — semantic versions mapped to the phase roadmap in docs/circa-spec.md section 14