Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ DTK_OFFSITE_BACKUP_INTERVAL=1800
# token is refused without it.
# DTK_FISQUA_TOKEN=
# DTK_FISQUA_ENV=
# When true, the capture manifest is exchange contract 1.5 and each image with
# a recorded rotation carries it as rotateDeg; when false, 1.1 with no
# rotateDeg. Fisqua staging reads 1.5 and production does not yet, so leave it
# false on a unit that delivers to production.
DTK_FISQUA_ROTATE_DEG=false

# Frontend Configuration
# The unit's mDNS name. setup.sh sets it to captua.local (or
Expand Down
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@ repository-code: "https://github.com/UCSB-AMPLab/digitization-toolkit-software"
url: "https://ampl.clair.ucsb.edu/project/digitization-toolkit"
abstract: "An open-source, modular digitization system for low-cost, high-quality scanning in low-resource environments and community archives, built on Raspberry Pi hardware."
license: AGPL-3.0-only
version: "0.1.0-beta"
date-released: "2026-10-25"
version: "0.2.0"
date-released: "2026-10-30"
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Captua

![Version](https://img.shields.io/badge/version-0.1.0--beta-orange)
![Version](https://img.shields.io/badge/version-0.2.0-orange)

**Captua** is the software of the **Digitization Toolkit**, an integrated toolkit of hardware, software, and documentation that allows lower-resourced institutions, collectives, and communities to digitize their archival collections at low cost.

Expand All @@ -15,7 +15,7 @@ The project builds on a decade of community-based digitization work in the Globa

## Status

> Captua is in active development. Current release: [0.1.0-beta](https://github.com/UCSB-AMPLab/digitization-toolkit-software/releases/tag/0.1.0-beta), the first release built to be tested in day-to-day operation in an archive. It is tested on one configuration: a Captua unit with two Canon EOS Rebel T7 cameras connected over gphoto2, and a monitor, keyboard and mouse.
> Captua is in active development. Current release: [0.2.0](https://github.com/UCSB-AMPLab/digitization-toolkit-software/releases/tag/0.2.0), which brings an archive's digitisation lists and the records waiting to be retaken onto the unit. [0.1.0-beta](https://github.com/UCSB-AMPLab/digitization-toolkit-software/releases/tag/0.1.0-beta) was the first release built to be tested in day-to-day operation in an archive. It is tested on one configuration: a Captua unit with two Canon EOS Rebel T7 cameras connected over gphoto2, and a monitor, keyboard and mouse.

## Release History

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.0-beta
0.2.0
12 changes: 11 additions & 1 deletion docs/developers/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ Refused identity changes and the delete refusal return `409` with `{message}`; c

**The capture manifest for Fisqua — `GET /collections/{id}/capture-manifest`.** The route returns the volume's capture manifest, artefact 2 of exchange contract 1.1, as a JSON download. Admins and operators may export it (`allow_contributor`); a volume that does not exist is `404` (`app/api/collections.py:366-413`). The manifest is built from the catalogue, the capture manifests and `offsite_objects` only, so no image file is read and the manifest of a freed volume is the same as that of one still on the card (`app/core/capture_manifest.py:1-7`).

- **The response.** `200` with `{contract: "fisqua-captua-exchange", version: "1.1", kind: "capture-manifest", id, generatedAt, generatedBy: {system: "captua", instance: <tenant>, systemVersion}, inReplyTo: null, project: {captuaProjectId, name}, transfer: {channel: "upload", root: "<tenant>/"}, volumes: [...]}`, with a new `id` on every export (`capture_manifest.py:282-303`). `systemVersion` is the release the unit runs, with the backend's commit appended when the code is not the tagged release (Version reporting, below). The one volume carries `externalId`, `captuaCollectionId`, `stem`, `title` and `records`; each record `captuaRecordId`, `sequence`, `status`, `rejectionNote` (a rejected record's latest comment, or its reason) and `images`; each image `captureId`, `pairId`, `filename`, `role`, `objectKey` and `sha256`, and for a RAW image `previewObjectKey` and `previewSha256` (`:259-265`, `:404-415`, `:158-164`). `externalId` is the volume's recorded `external_id`, stripped, or its stem when none is recorded (`reference_code`, `capture_manifest.py:306-309`; `:297`). An image is RAW when its file on disk has a suffix in `RAW_SUFFIXES`, or when the name in its capture key has one, so an adopted object whose key names a RAW is counted as RAW and needs a preview (`:364-365`; `:21-24`). Every record of the volume is listed with its status, and only current images (`:21-29`). A record still `captured` refuses the export (`records_captured`, `:214-215`). The keys are the frozen keys of the verified objects with the tenant prefix taken off, and `filename` is the last segment of the capture key (`:9-14`). The `sha256` values come from the capture manifests and are never computed here (`:15-20`, `_digest`, `:322-327`). The download name is `capture-manifest_<stem>_<YYYY-MM-DD>.json`, by the UTC date, in `Content-Disposition` (`download_name`, `:489-492`; `collections.py:396`, `:412-413`). Each export is logged in the activity log as `capture_manifest_exported`, at `INFO`, with the stem as subject and the detail `volume <id> id=<manifest id> records=<n> images=<n> previews=<n>` (`collections.py:408-411`).
- **The response.** `200` with `{contract: "fisqua-captua-exchange", version: "1.1", kind: "capture-manifest", id, generatedAt, generatedBy: {system: "captua", instance: <tenant>, systemVersion}, inReplyTo: null, project: {captuaProjectId, name}, transfer: {channel: "upload", root: "<tenant>/"}, volumes: [...]}`, with a new `id` on every export (`capture_manifest.py:282-303`). With `DTK_FISQUA_ROTATE_DEG` set, `version` is `"1.5"` and each image whose `rotate_deg` is `0`, `90`, `180` or `270` also carries `rotateDeg`, that value, counted clockwise from the stored pixels with the EXIF tag ignored; an image with no recorded rotation carries none (`capture_manifest.py:67-69`, `:299`, `:429-430`). `systemVersion` is the release the unit runs, with the backend's commit appended when the code is not the tagged release (Version reporting, below). The one volume carries `externalId`, `captuaCollectionId`, `stem`, `title` and `records`; each record `captuaRecordId`, `sequence`, `status`, `rejectionNote` (a rejected record's latest comment, or its reason) and `images`; each image `captureId`, `pairId`, `filename`, `role`, `objectKey` and `sha256`, and for a RAW image `previewObjectKey` and `previewSha256` (`:310-316`, `:273-279`, `:172-178`, `:418-428`). `externalId` is the volume's recorded `external_id`, stripped, or its stem when none is recorded (`reference_code`, `capture_manifest.py:320-323`; `:311`). An image is RAW when its file on disk has a suffix in `RAW_SUFFIXES`, or when the name in its capture key has one, so an adopted object whose key names a RAW is counted as RAW and needs a preview (`:375-379`; `:21-24`). Every record of the volume is listed with its status, and only current images (`:21-29`). A record still `captured` refuses the export (`records_captured`, `:228-229`). The keys are the frozen keys of the verified objects with the tenant prefix taken off, and `filename` is the last segment of the capture key (`:9-14`). The `sha256` values come from the capture manifests and are never computed here (`:15-20`, `_digest`, `:336-341`). The download name is `capture-manifest_<stem>_<YYYY-MM-DD>.json`, by the UTC date, in `Content-Disposition` (`download_name`, `:505-508`; `collections.py:397`, `:413-414`). Each export is logged in the activity log as `capture_manifest_exported`, at `INFO`, with the stem as subject and the detail `volume <id> id=<manifest id> records=<n> images=<n> previews=<n>` (`collections.py:408-411`).
- **`409` with problems.** When Fisqua would refuse the file, the route answers `409` with `detail: {message, problems}`. `message` is `"The capture manifest cannot be exported, because Fisqua would refuse it: "` followed by each problem's message, and each problem is `{rule, message, record_ids, image_ids}` (`collections.py:397-404`; `ManifestRefused`, `capture_manifest.py:110-121`; `Problem.as_dict`, `:105-107`). The rules, in the order they are reported, are `tenant_missing`, `tenant_invalid`, `no_stem`, `no_project`, `records_captured`, `sequence_missing`, `sequence_repeated`, `roles_invalid`, `capture_id_missing`, `not_uploaded`, `preview_not_uploaded`, `raw_without_preview`, `upload_refused`, `key_outside`, `filename_form`, `page_key_repeated`, `sha256_missing`, `preview_sha256_missing` and `no_pages` (`RULES`, `:67-87`). The messages of the record-level rules name the records concerned, at most twenty ids, and the fix (`_problems`, `:418-478`; `_ids`, `:89-95`). `tenant_missing`, `tenant_invalid`, `no_stem`, `no_project` and `no_pages` name the fix only (`:179-201`, `:272-276`). `no_pages` is reported when no record in review or approved has an image (`:272-276`).

**Delivery to Fisqua — the `fisqua` object of `GET /collections/{id}/offsite` and `GET /system/catalogue-backup`, and what a handover does.** A unit with Fisqua configured queues the capture manifest of each handed-over volume, sends it over HTTPS once every object of the volume is verified in the bucket, polls Fisqua for the volume's status, and applies Fisqua's QC decisions to the volume's records (`app/core/fisqua_outbox.py`, `app/core/fisqua_decisions.py`, `app/core/fisqua_http.py`). The names on Fisqua's side are in `app/core/fisqua_contract.py`, which names contract version `1.2` (`CONTRACT_VERSION`, `:25`). The manifest document itself keeps `version: "1.1"`, because contract 1.2 §9 sends artefact 2 unchanged; `CONTRACT_VERSION` is not sent (`VERSION`, `capture_manifest.py:55`). `GET /collections/{id}/capture-manifest` is not tied to the configuration. The tables are `fisqua_outbox` and `fisqua_status`, created by migration `9b7087bd0bc5` and extended by `a5f0306a8f8c` and `6df85ec6309b` (`app/models/fisqua.py`).
Expand Down Expand Up @@ -231,6 +231,15 @@ Refused identity changes and the delete refusal return `409` with `{message}`; c
- **Other refusals.** A record that does not exist is `404` with `"Record not found"`. A record whose status is not `in_review` is `422` with a string `detail` naming the status (`records.py:1327-1334`).
- **Downgrade.** `downgrade()` in `alembic/versions/a8c2e4f6b1d3_rejection_reasons_framing_obstruction_other.py` is refused while any rejection uses `framing`, `obstruction` or `other`, and no reason is rewritten; those rows are removed by hand first.

**Recaptures and the recapture turn — `GET /records/to-retake/summary`.** While records are pending anywhere on the unit, starting a new volume alternates with finishing recaptures (`app/core/recapture_turn.py:1-29`). A record is pending while its status is `rejected`, or while it is `captured` and a current image still carries a `rejection_id`, the state `PATCH /records/{id}/status` leaves a record prepared for retake in until a capture replaces the image (`pending_clause`, `record_is_pending`, `recapture_turn.py:74-89`). That one definition is used by the summary, the turn, the completion test and the promotion refusal.

- **The summary.** Any signed-in role (`allow_read_only`, `app/api/records.py:355-421`). The response is `200` with `{pendingRecords, volumes, turn}`. `pendingRecords` counts the unit's pending records. Each volume with at least one pending record is listed as `{collectionId, projectId, name, filenameStem, pending, total, firstPendingRecordId, pendingRecordIds}`, most pending first, then by name: `pending` is its pending records, `total` its records with at least one rejection whatever their state now, `pendingRecordIds` its pending records by sequence (a record with none last) and then id, and `firstPendingRecordId` the first of them. `projectId` is the project that owns the volume, through its parents for a nested one. `GET /records/to-retake/count` still counts `rejected` records only, for the side menu.
- **The turn.** `turn` is `{state, unlockedBy}` (`turn`, `recapture_turn.py:120-144`). `state` is `free` when no record is pending; otherwise `new` when the newest `recapture_volume_completed` event in the system log is newer than the newest `new_volume_started`, and `recaptures` when it is not or there is none. Newer means a higher `system_logs.id`. `unlockedBy`, for `new` only, is `{collectionId, name}` of the volume whose completion opened the turn, by its current name, or the name the event recorded when the volume has been deleted; otherwise `null`. Turns do not accumulate: two completions in a row open one turn.
- **The two events.** Both are system-log rows with category `capture`, the volume's name as subject and the detail `collection_id=<id> filename_stem=<stem>`, added to the capture's own transaction, so each stands or falls with its capture (`record_event`, `recapture_turn.py:147-164`; `_record_turn_events`, `app/api/cameras.py:111-131`). `new_volume_started` is written for a capture that creates the first record of a volume with none. `recapture_volume_completed` is written for a retake of a pending record that leaves its volume with no pending record, once each time the volume reaches zero, and never for a status change alone.
- **The capture refusal.** `POST /cameras/capture` and `POST /cameras/capture/dual` refuse a capture that is not a retake, into a volume with no record, while the turn is `recaptures`, unless the user is an admin: `409` with `detail` `{code: "recapture_turn", message: "The volume could not be started: it is the retake turn. Finish the retakes of one volume and try again."}` (`_refuse_a_new_volume_out_of_turn`, `cameras.py:91-108`; `RECAPTURE_TURN_DETAIL`, `recapture_turn.py:52-58`). It runs after the refusals listed under A capture needs a volume with a stem, before any capture number is taken or camera touched (`cameras.py:843`, `:1072`). Retakes, and captures into a volume that already has records, are never refused by it.
- **Locking.** A capture that starts a volume, and a retake of a pending record, take the unit's turn lock, a PostgreSQL transaction-level advisory lock (`pg_advisory_xact_lock`, key `0x43415054`), after the record and volume row locks they already hold, and keep it until the capture commits, so two operators cannot both start a new volume on one completion. The first takes it before reading the turn, the second before the completion count; any other capture takes none (`cameras.py:103-105`, `:121-127`; `lock_turn`, `recapture_turn.py:63-71`). On SQLite, used only by the test suite, no lock is taken.
- **The promotion refusal.** `POST /collections/{id}/promote`, which admins and operators may call (`allow_contributor`), refuses a volume with any pending record whoever calls it: `409` with `detail` `{code: "recaptures_pending", pending: <n>, message: "The volume could not be sent to review: <n> retakes left."}`. The count is taken after the record locks and before the idempotent return, so a volume whose only records left are rejected is refused too (`app/api/collections.py:917-933`). Per-record promotion is unchanged.

**An image's rotation — `rotate_deg`.** `RecordImageRead` carries `rotate_deg`, the clockwise rotation in degrees (`0`, `90`, `180` or `270`) that displays the image upright, as the capturing body was set. It is `null` for an upload and for every image captured before the column existed; nothing was backfilled, because a body's orientation can have changed since (`app/schemas/record.py:187-189`; `RecordImage.rotate_deg` and the check constraint `check_record_image_rotate_deg`, `app/models/record.py:147-153`, `:185-188`, added by migration `d7a1c3e5f9b2`). It is returned wherever the schema is: the images of a record, `GET /records/{id}/images`, `POST /records/{id}/images`, `GET /records/images/{img_id}`, `PATCH /records/images/{img_id}` and the images of a rejection (`app/api/records.py:439`, `:596`, `:617`, `:630`).

- **Where it is set.** `POST /cameras/capture` and `POST /cameras/capture/dual` write it from the capturing body's saved orientation, one value for each image of a pair (`recorded_rotation`, `capture/camera.py:77-95`; `app/api/cameras.py:942`, `:1182`). A saved orientation other than `0`, `90`, `180` or `270` is recorded as `null` (`capture/camera.py:81-94`). The capture request can override the saved orientation, `0` included: `rotate_deg` on `POST /cameras/capture`, and `rotate_deg_cam0` and `rotate_deg_cam1` on `POST /cameras/capture/dual` (`CaptureRequest` and `DualCaptureRequest`, `app/api/cameras.py:176-180`, `:197-201`; applied at `:827-828`, `:1057-1060`). A request value outside `0`, `90`, `180` and `270` is refused with the validation `422` (`:161-167`, `:185-188`, `:208-211`). Once recorded, no route changes it: `RecordImageUpdate` holds only `sequence` and `role`.
Expand Down Expand Up @@ -988,6 +997,7 @@ The offsite image upload, the catalogue dump, and restore (section 3 and section
| `DTK_FISQUA_RETRY_MAX` | `FISQUA_RETRY_MAX_SECONDS` | `21600` | The longest backoff between attempts; a longer `Retry-After` from Fisqua wins. |
| `DTK_FISQUA_POLL_INTERVAL` | `FISQUA_POLL_INTERVAL_SECONDS` | `600` | Seconds between status polls. |
| `DTK_FISQUA_TIMEOUT` | `FISQUA_TIMEOUT_SECONDS` | `60` | Read and write timeout of each call to Fisqua, in seconds; connecting has 10. |
| `DTK_FISQUA_ROTATE_DEG` | `FISQUA_ROTATE_DEG` | `false` | When `true`, the capture manifest is contract `1.5` and each image with a recorded `rotate_deg` carries it as `rotateDeg`; when `false`, contract `1.1` with no `rotateDeg`. Fisqua staging reads 1.5 and production does not yet, so it stays `false` on production units. Part of the configuration fingerprint (`app/core/fisqua_outbox.py:218-226`). |
| `APP_VERSION` | `app_version` | unset | Overrides the release read from `VERSION` (section 3, Version reporting). |
| `DTK_RCLONE_BIN` | `RCLONE_BIN` | `rclone` | The rclone executable. |
| `DTK_RCLONE_CONFIG` | `RCLONE_CONFIG` | empty | When set, passed to rclone as `--config`. |
Expand Down
2 changes: 1 addition & 1 deletion frontend
Submodule frontend updated 43 files
+2 −2 package-lock.json
+1 −1 package.json
+49 −1 src/app.css
+61 −0 src/lib/api-multipart.svelte.test.ts
+186 −2 src/lib/api.ts
+12 −1 src/lib/capture-errors.test.ts
+6 −1 src/lib/capture-errors.ts
+36 −4 src/lib/components/HandoverControls.svelte
+45 −1 src/lib/components/HandoverControls.svelte.test.ts
+32 −11 src/lib/components/RecordImageDisplay.svelte
+29 −1 src/lib/components/RecordImageDisplay.svelte.test.ts
+159 −1 src/lib/i18n/en.ts
+163 −1 src/lib/i18n/es.ts
+55 −0 src/lib/i18n/retake-turn-strings.test.ts
+64 −0 src/lib/queue-labels.ts
+249 −0 src/lib/queue.test.ts
+142 −0 src/lib/queue.ts
+168 −0 src/lib/recapture-turn.test.ts
+128 −0 src/lib/recapture-turn.ts
+28 −1 src/lib/rejectionReasons.ts
+13 −0 src/lib/test-support/SnippetHost.svelte
+102 −8 src/routes/(dashboard)/+layout.svelte
+21 −3 src/routes/(dashboard)/dashboard/+page.svelte
+104 −0 src/routes/(dashboard)/dashboard/RetakeBand.svelte
+76 −0 src/routes/(dashboard)/dashboard/RetakeBand.svelte.test.ts
+39 −16 src/routes/(dashboard)/dashboard/projects/[projectId]/+page.svelte
+79 −3 src/routes/(dashboard)/dashboard/projects/[projectId]/collections/[collectionId]/+page.svelte
+4 −27 src/routes/(dashboard)/dashboard/projects/[projectId]/collections/[collectionId]/LeftSidebar.svelte
+121 −0 src/routes/(dashboard)/dashboard/projects/[projectId]/collections/[collectionId]/retake-band.svelte.test.ts
+142 −0 ...es/(dashboard)/dashboard/projects/[projectId]/collections/[collectionId]/retake-entry-switch.svelte.test.ts
+48 −2 src/routes/(dashboard)/dashboard/projects/[projectId]/page-stem.svelte.test.ts
+761 −0 src/routes/(dashboard)/dashboard/queue/+page.svelte
+642 −0 src/routes/(dashboard)/dashboard/queue/queue.svelte.test.ts
+249 −0 src/routes/(dashboard)/dashboard/retakes/+page.svelte
+268 −0 src/routes/(dashboard)/dashboard/retakes/retakes.svelte.test.ts
+126 −0 src/routes/(dashboard)/nav-queue.svelte.test.ts
+464 −61 src/routes/live-preview/+page.svelte
+146 −0 src/routes/live-preview/CaptureInstruction.svelte
+82 −0 src/routes/live-preview/CaptureInstruction.svelte.test.ts
+104 −4 src/routes/live-preview/ImageViewerModal.svelte
+119 −0 src/routes/live-preview/ImageViewerModal.svelte.test.ts
+185 −0 src/routes/live-preview/capture-instruction-page.svelte.test.ts
+541 −0 src/routes/live-preview/retake-turn-page.svelte.test.ts
Loading