diff --git a/.github/instructions/documentation-sync.instructions.md b/.github/instructions/documentation-sync.instructions.md
new file mode 100644
index 0000000..6f38df4
--- /dev/null
+++ b/.github/instructions/documentation-sync.instructions.md
@@ -0,0 +1,35 @@
+---
+description: Require documentation updates whenever code changes alter contracts, behavior, or scope.
+applyTo: 'src/transcription/**/*.py'
+---
+
+# Documentation Sync Requirements
+
+Keep docs in sync in the same change whenever implementation alters a documented contract, behavior, or roadmap decision.
+
+## Update documentation when any of these change
+
+1. **Schema/Data contract**
+ - Models, fields, enums, constraints, indexes, relationships, loading semantics.
+ - **Required doc update:** `docs/schema.md`.
+
+2. **Configuration contract**
+ - `Settings` keys, defaults, required/optional environment values.
+ - **Required doc update:** `.env.example` and any directly related setup docs.
+
+3. **User-visible UI behavior**
+ - Page flow, routes, button/action behavior, labels, status wording, empty/error states.
+ - **Required doc update:** relevant `docs/ui/pages/*.md` docs and feature docs when applicable.
+
+4. **Error handling semantics**
+ - Error categories, retry behavior, envelope structure, translation boundaries.
+ - **Required doc update:** `docs/error_handling.md` and `docs/invariant/error_handling.md`.
+
+5. **Roadmap/scope decisions**
+ - Version targets, sequencing, deferrals, and accepted alternatives.
+ - **Required doc update:** `docs/roadmap_plan.md` and related backlog docs (for example `docs/ver4.8/feature_backlog_v4_8.md`).
+
+## Working rule
+
+If none of the categories above changed, documentation edits are optional.
+If any category changed, update docs in the same PR/change set rather than deferring.
diff --git a/.github/instructions/services.instructions.md b/.github/instructions/services.instructions.md
index 4c05fa2..fb555fa 100644
--- a/.github/instructions/services.instructions.md
+++ b/.github/instructions/services.instructions.md
@@ -134,6 +134,13 @@ Atomicity rules:
- If model fields, enums, constraints, indexes, or relationship-loading semantics change, update `docs/schema.md` in the same change.
- If `Settings` fields or defaults change in `src/transcription/config.py`, update `.env.example` in the same change so keys/defaults remain synchronized and no stale settings remain documented.
+## Schema Drift and Legacy Compatibility Policy
+
+- Prefer schema migration or startup reconciliation over runtime compatibility paths in service writes.
+- Do not add legacy read/write compatibility code in service workflows by default.
+- If drift is discovered and a migration decision is ambiguous (for example, one-way destructive DDL, uncertain data retention impact, or unknown deployment sequence), pause and ask the user to choose migration vs compatibility before coding.
+- If a temporary compatibility path is explicitly approved, document an expiration/removal plan in the same change.
+
# Service Composition
A service method may read across models it does not own, using eager loads from its own
diff --git a/docs/roadmap_plan.md b/docs/roadmap_plan.md
new file mode 100644
index 0000000..9aa5ee5
--- /dev/null
+++ b/docs/roadmap_plan.md
@@ -0,0 +1,212 @@
+# Roadmap Review: Recommendations & Version Plan
+
+This is a review/consulting deliverable. It organizes your near-term changes,
+nice-to-haves, and long-range goals into version buckets, building on
+`./docs/ver4.8/feature_backlog_v4_8.md` (already-scoped image work) and
+folding in the additional items from your original list.
+
+Versioning convention (per your direction): **4.8–4.11** are incremental
+feature/fix releases; **5.x and 6.x are reserved for major changes** to the
+app (a shared data-model overhaul, and the hosting migration, respectively).
+
+Final deliverable will be saved to `./docs` in the repo per your instruction.
+
+---
+
+## V4.8 — Bug fixes + Image Experience
+
+**Bug fixes (do first within this release — data correctness, not features):**
+1. **Stale Error Detail after resubmit.** Root cause found in `db/models.py`:
+ `Source.latest_error_detail` sorts all `execution_attempts` on the latest
+ job by `attempt_number` descending and returns the **first attempt with
+ any `error_detail`**, even if that's an older attempt and the latest
+ attempt succeeded. Fix: only report error detail from the latest attempt,
+ don't fall through to earlier ones.
+2. **"View Jobs" ≠ "View Sources" behavior.** `/documents/{id}/sources`
+ already redirects to the filtered Transcription Pipeline Jobs page;
+ `/documents/{id}/jobs` renders its own bare two-field custom page instead.
+ Fix: make `/documents/{id}/jobs` redirect the same way, filtered by document.
+
+**Image Experience (Track A from the V4.8 backlog doc — already scoped, high
+value/low risk, storage layer already exists):**
+3. **Homepage Image Gallery** (recommended first feature in that doc):
+ `list_homepage_images()` already returns every stored image; only the
+ multi-image carousel UI and optional slideshow rotation are missing.
+4. **Pan and Zoom on Source Detail**: recover `document_panzoom.py` from git
+ history (deleted in V4.6 Phase 5, `6a3ee26`), but vendor the Panzoom
+ library locally instead of the unpkg CDN, and reuse `resolve_media_url`
+ from `media_urls.py` instead of recreating its old helper. Scope to Source
+ Detail only — do not add it to the shared `dark_room_viewer`.
+
+**Note:** the backlog doc's model-performance/telemetry item still applies as
+stated there — it must wait for V4.7 Phase 4 to land first, since `duration_ms`
+today mixes provider latency with preprocessing/DB commit time.
+
+---
+
+## V4.9 — Detail Page Parity Pass
+
+Small, low-risk structural-parity fixes across Person/Job/Document detail pages:
+- Hide "Maiden Name" on Person detail when empty.
+- Job Record detail "Document Links" box → mirror the Document detail page's
+ "Sources & Pipeline Jobs" box (clickable name, Sources count, single
+ "View Sources" button, no "+Add Job").
+- Transcription Pipeline Jobs page: add a Document Name column; resolve
+ Created vs. Updated by keeping **Updated** as the primary/visible field
+ (more actionable) and de-emphasizing Created rather than deleting it outright.
+- Person detail "Linked Documents" → 3-column table (Document Name, Role,
+ Number of Pages), with clickable document names, dropping the "Open" button.
+
+---
+
+## V4.10 — Settings Consolidation & Small Enhancements
+
+- **Settings page → tabs**, not sub-pages (per your confirmation). Convert the
+ existing stacked cards (Document Types, Person Roles, Prompts) into
+ `ui.tabs`/`ui.tab_panels`, and add a new **Home Page Text** tab. One route,
+ no navigation overhead, scales cleanly as more settings are added.
+- **Google Maps links** for Person birth/death locations — cheap, no
+ scraping, a formatted place string becomes a Maps search link.
+- **FamilySearch ID lookup** (scope confirmed as narrow): on the Person
+ create/edit form, add a FamilySearch ID field and an "Auto-fill from
+ FamilySearch" button that fetches only birth date/place, death date/place,
+ and marriage date/spouse for that specific ID — a single-record lookup, not
+ a crawler. This keeps FamilySearch.org as a companion reference rather than
+ a data source the app tries to replace. Because it's ID-driven and
+ single-record, it's a much smaller, safer feature than open-ended scraping —
+ worth doing at this scope, revisit if FamilySearch's page structure changes
+ and breaks the parser.
+
+---
+
+## V4.11 — Bigger UI Features
+
+- **Tags** (supersedes "collections"): many-to-many tagging for documents,
+ reusing the Settings registry pattern (autocomplete against existing tags,
+ managed like Document Types/Person Roles). Since you want dedicated UI to
+ browse/filter by tag, include a tag-filter view (e.g. a "Tags" entry point
+ showing documents grouped/filterable by tag) as part of this version, not
+ deferred — that's the UI surface that makes tags actually replace collections
+ day-to-day, not just a data field.
+- **UI theme selection**: kept simple, per your confirmation — 2–4 curated
+ themes (e.g. Archival/Sepia, Light, Dark, High-contrast), a single stored
+ preference, swapped via existing CSS variables/Tailwind tokens in
+ `theme.py`. No open-ended theme builder.
+- **Edit Transcription sub-page**: source image + transcription text +
+ editable revision side-by-side, keeping the existing Save Revision/Reset
+ buttons. You noted the exact interaction/layout needs to be clarified when
+ this is scoped — treat this as a design-first item: confirm the two/three
+ column breakpoint behavior and what happens on narrow windows before
+ building.
+- **Source detail page reflow** (move Candidate Machine Transcriptions to the
+ bottom of the image column; align Source Metadata's top edge with
+ Transcription Text): do this *after* the Edit Transcription sub-page above,
+ since that page will reuse/rearrange the same boxes — avoids reflowing twice.
+
+---
+
+## V5.0 — Unified Photos Table (major data-model change)
+
+Per your feedback, this replaces the earlier "Track B" idea with a single
+shared model: a **`photos` table** holding an image reference plus a text
+`description` field, used by **both** the homepage gallery and Person
+portraits — rather than building two separate, one-off metadata layers.
+
+Rough shape (subject to the further discussion you flagged before any
+implementation):
+- `photos`: id, image path/reference, description, and whatever
+ owner/context linkage is needed (e.g. a polymorphic or nullable
+ `person_id` plus a `context`/`kind` discriminator such as `"homepage"` vs
+ `"portrait"` — exact shape is an open design question, not decided here).
+- Person gets **multiple portraits** instead of the current single
+ `portrait_path` string column — needs a migration to move existing
+ single-portrait data into the new table.
+- Homepage images gain **descriptions**, using the same table instead of a
+ separate sidecar/caption mechanism.
+- Because this is a genuine shared-schema decision (one table, two
+ consumers, plus a migration of existing single-portrait data), this is
+ correctly a 5.x "major change" rather than a small add-on — **flagged for
+ a follow-up design discussion before implementation starts**, specifically
+ on the exact `photos` table shape and how "which photos belong to which
+ person/context" should be modeled.
+
+---
+
+## V6.0 — Server Hosting Migration
+
+Your stated approach (Postgres in Docker, app in Docker, Cloudflare Tunnel) is
+the standard, low-maintenance way to get secure remote access without exposing
+ports or running your own VPN/reverse-proxy TLS setup. Sequential stages
+(each de-risks the next):
+
+**Stage 0 — Prerequisites**
+- Confirm data access already goes through the service layer (it does, per
+ `.github/instructions/services.instructions.md`) — this is what makes the
+ DB swap and later auth additive rather than a rewrite.
+- Confirm upload/file storage path (`settings.upload_dir`) is Docker-volume-friendly.
+- Confirm DB URL and upload dir are both env-overridable (`.env`/`.env.example`
+ already exist).
+
+**Stage 1 — Containerize** against the *existing* SQLite file first (smallest
+possible change) to validate the container boundary (networking, volumes,
+permissions) before also switching databases. `Dockerfile`/`docker-compose.yml`
+already exist in the repo — confirm current intent vs. extend for production.
+
+**Stage 2 — Migrate to PostgreSQL.** Since the app uses SQLModel/SQLAlchemy,
+this should mostly be a connection string/dialect change plus a data migration
+script. Do this as its own isolated step so a regression is attributable to
+the DB swap alone. Resolve the Postgres connection through a small
+factory/indirection point rather than a single global engine constant — this
+costs nothing now and keeps the door open for per-user databases later (see
+Beyond, below).
+
+**Stage 3 — Expose via Cloudflare Tunnel.** Add a `cloudflared` container
+pointed at the app's internal port (no public port exposure needed). Put the
+tunnel hostname behind Cloudflare Access (free tier, email OTP/SSO) as the
+**first layer of remote-access auth**, even before the app has its own login
+system — gets secure remote access working quickly and buys time to do
+Stage 4 and later user-auth properly instead of rushed.
+
+**Stage 4 — Backups & operational hygiene.** Automated Postgres backups
+(scheduled `pg_dump`) and uploaded-file backups *before* exposing this to the
+internet — this is the point where a data-loss incident would be most
+damaging. Basic container health checks/restart policies in compose.
+
+---
+
+## V6.1 — Reporting Features
+
+Independent of hosting — can be built any time, including in parallel with
+V6.0:
+- **Person timelines**: a query/aggregation feature (documents by person,
+ ordered by document date), no hosting dependency.
+- **AI-written biographies/family histories**: likely reuses the existing
+ prompt/provider abstraction (`services/prompts.py`) already built for
+ transcription, rather than needing new infrastructure.
+
+---
+
+## Beyond / Pie-in-the-sky (informational only — no version assigned)
+
+Not being built now, but the plan above is checked against these so nothing
+forecloses them later:
+
+- **Individual user logins + roles** (admin/editor/contributor/view-only):
+ Cloudflare Access (V6.0 Stage 3) is a stopgap, not this. When you get here,
+ add a real `User`/`Role` model and auth middleware. Because the codebase
+ already funnels data access through the service layer, adding a
+ "current user + role check" later is additive — this is the reason to keep
+ service-layer discipline intact through all the versions above, so
+ authorization can be bolted on without touching every page.
+- **Per-user databases**: protected for by the Stage 2 connection-factory
+ recommendation above (schema-per-tenant or database-per-tenant becomes
+ much easier if the DB connection is already resolved through an
+ indirection point rather than hardcoded at startup).
+
+---
+
+## Open items for you
+- V5.0 unified `photos` table: needs the follow-up design discussion you
+ flagged (exact schema, how photos link to homepage vs. person context)
+ before implementation.
+- Confirm this version numbering/grouping matches your intent before work starts.
diff --git a/docs/schema.md b/docs/schema.md
index 0afc4d4..6d1ade0 100644
--- a/docs/schema.md
+++ b/docs/schema.md
@@ -177,6 +177,9 @@ Index:
Constraint:
- `UniqueConstraint(job_id, source_id)` named `uq_job_source_job_source`
+Runtime reconciliation:
+- Startup database operations remove retired V4.6 `job_source` evidence columns (`raw_transcription`, `ai_metadata`, `raw_api_response`, `error_detail`, `executed_at`) when present so persisted schema matches this contract.
+
### `ExecutionAttempt`
| Field | Type | Notes |
diff --git a/docs/ui/pages/home.md b/docs/ui/pages/home.md
index bc381d5..7ac5351 100644
--- a/docs/ui/pages/home.md
+++ b/docs/ui/pages/home.md
@@ -21,6 +21,7 @@ The application root and `/ui` redirect to `/ui/homepage`.
- Missing text displays `No homepage text saved yet.`
- Missing image displays the viewer's empty state.
- **Edit Home Page** opens the edit route.
+- The same Home Text content is also editable from **Settings → Home Page Text**.
## Edit Behavior
diff --git a/docs/ui/pages/jobs.md b/docs/ui/pages/jobs.md
index 64ea9b0..46696d8 100644
--- a/docs/ui/pages/jobs.md
+++ b/docs/ui/pages/jobs.md
@@ -19,8 +19,9 @@ Jobs manages transcription processing runs. A Job belongs to one Document, links
- The title is **Transcription Pipeline Jobs**.
- **Create job** opens Job creation and **Refresh** reloads the table.
-- Columns are Job ID, Status, Source Filename, Retries, Created, and Updated.
-- Search covers Job ID, filename, and status.
+- Columns are Job ID, Status, Document Name, Retries, and Updated.
+- Updated is the primary date/sort field.
+- Search covers Job ID, document name, and status.
- Status is displayed as a semantic status chip.
- Selecting a row opens Job Detail.
- No records displays `No job records found in repository.`
@@ -43,7 +44,7 @@ Jobs manages transcription processing runs. A Job belongs to one Document, links
- The heading shows Job ID and a status badge.
- Execution Logistics shows provider, model, prompt, retry count, and last update.
-- Document Links open the parent Document and Job-filtered Sources.
+- Document Links show a clickable Document Name, Sources count, and a single **View Sources** action using document filtering.
- Queued and processing Jobs show an auto-refresh notice and reload every four seconds.
- Polling stops when the Job becomes terminal or a refresh fails.
- Queued and processing Jobs expose **Cancel**.
diff --git a/docs/ui/pages/people.md b/docs/ui/pages/people.md
index 3b0c909..3da3c07 100644
--- a/docs/ui/pages/people.md
+++ b/docs/ui/pages/people.md
@@ -54,9 +54,12 @@ Rules:
- The header provides **New Document**, **Edit Person**, and **Delete**.
- **New Document** opens Document creation with this Person requested for author preselection.
- The portrait viewer resolves supported relative upload paths and absolute HTTP/data URLs.
-- Biographical Record shows names, compact birth/death dates, places, and an **Open in FamilySearch** link when an ID exists.
+- Biographical Record shows names, compact birth/death dates, and places.
+- Birth and death place values are clickable links to Google Maps when present.
+- FamilySearch ID is shown as a metadata value and is clickable to the FamilySearch person details route when present.
+- Maiden Name is only shown in Biographical Record when a value exists.
- Biography has an explicit empty value.
-- Linked Documents show Document name, relationship role, and an action to open Document Detail.
+- Linked Documents render as a table with **Document Name**, **Role**, and **Number of Pages**; selecting a row opens Document Detail.
- No links shows both an empty state and guidance to link from a Document workflow.
- System Logistics shows created and updated timestamps.
diff --git a/docs/ui/pages/settings.md b/docs/ui/pages/settings.md
new file mode 100644
index 0000000..03f9ff4
--- /dev/null
+++ b/docs/ui/pages/settings.md
@@ -0,0 +1,35 @@
+# Settings Page Contract
+
+## Purpose
+
+Settings manages installation-local registries and editable text assets from one route.
+
+## Route
+
+| Route | Purpose |
+| --- | --- |
+| `/settings` | Manage Document Types, Person Roles, Prompts, and Home Page Text. |
+
+## Behavior
+
+- The page title is **Settings**.
+- Configuration surfaces are grouped as tabs:
+ - **Document Types**
+ - **Person Roles**
+ - **Prompts**
+ - **Home Page Text**
+- Document Types and Person Roles support Add/Edit/Delete with existing guardrails.
+- Prompts exposes only `transcribe_document.md` for editing and restore-from-backup.
+- Home Page Text edits the same Markdown content rendered on `/homepage`.
+
+## Acceptance Checklist
+
+- `/ui/settings` renders all four tabs.
+- Registry and prompt workflows keep existing validation and error handling.
+- Saving Home Page Text persists content for the homepage view.
+
+## Implementation Anchors
+
+- `src/transcription/ui/pages/settings_page.py`
+- `src/transcription/ui/homepage_store.py`
+- `tests/ui/test_pages_registration.py`
diff --git a/docs/ver4.8/feature_backlog_v4_8.md b/docs/ver4.8/feature_backlog_v4_8.md
index 4c25491..840eb0a 100644
--- a/docs/ver4.8/feature_backlog_v4_8.md
+++ b/docs/ver4.8/feature_backlog_v4_8.md
@@ -64,24 +64,6 @@ Homepage images are **filesystem-only with no metadata store**, so a caption has
This is cheap **only if** item 3 is being done at the same time, since both need the same metadata layer. Designing that layer twice would be wasteful; design it once or not at all.
-### 5. Model-Performance Rollup (V4.6 review log [54])
-
-**Practicality: high, but blocked. Effort: M.**
-
-Run-time telemetry is already captured and is per page: `execution_attempt.duration_ms` is a required non-null field written on all three paths in `workflows.py` (success 278, `TimeoutError` 295, general failure 330), with failures using a monotonic clock. Verified against the live database: 80 rows across 80 distinct (job, source, attempt) combinations, one row per page - the largest job has 60 attempts across 60 distinct pages - and zero nulls. Token counts live on the same row in `normalized_metadata.usage`, so tokens-per-second is already derivable without a join.
-
-What is missing is **aggregation**. The figure is visible only for the latest attempt of one source at a time (`sources_page.py:400`), rendered raw as `"27612 ms"`. There is no rollup by model, prompt, or document.
-
-The gap is concrete: calibrating the provider timeout on 2026-08-18 required hand-written SQL against the database, because the application could not answer "which model is slow."
-
-Proposed shape: median / p95 / max duration, tokens per second, and a timeout rate, grouped by model. **Blocked on V4.7 Phase 4.**
-
-### 6. Desaturated Background Wallpaper
-
-**Practicality: low. Recommendation: do not build, or gate behind a setting defaulted off.**
-
-Trivial to implement (`ui.add_css` with a CSS `filter`), but this is a dense archival data application - transcripts, JSON evidence panels, data tables. A background image behind all of that costs contrast and legibility on every page, for aesthetic gain only.
-
## Suggested Grouping
If V4.8 is scoped as one release, the natural split is:
@@ -98,12 +80,5 @@ Item 6 is not recommended.
- Should Track B happen at all, or is one portrait per person sufficient?
- Should the slideshow interval be configurable, or fixed?
-- Should the model-performance rollup be its own page, or a panel on an existing one?
- Should vendored Panzoom be committed to the repository, or fetched at build time?
-## Related Local References
-
-- [Architecture & Code Review Report](../architecture_code_review_2026-08-17.md)
-- `.github/instructions/ui.instructions.md`
-- `src/transcription/ui/homepage_store.py` - existing multi-image storage
-- `src/transcription/ui/components/media_urls.py` - canonical URL resolution
diff --git a/src/transcription/app.py b/src/transcription/app.py
index c67330a..e1bbcfe 100644
--- a/src/transcription/app.py
+++ b/src/transcription/app.py
@@ -25,6 +25,7 @@ from .db import create_all
from .db import dispose_database_runtime
from .db import initialize_database_runtime
from .db import normalize_legacy_status_spellings
+from .db import reconcile_legacy_job_source_columns
from .services import ServiceBundle
from .ui import register_pages
from .worker import worker_consumer_lifespan
@@ -43,6 +44,7 @@ async def _lifespan(app: FastAPI):
if settings.should_bootstrap_schema:
await create_all(engine=app.state.runtime.engine)
+ await reconcile_legacy_job_source_columns(engine=app.state.runtime.engine)
await normalize_legacy_status_spellings(engine=app.state.runtime.engine)
settings.upload_dir.mkdir(parents=True, exist_ok=True)
diff --git a/src/transcription/db/__init__.py b/src/transcription/db/__init__.py
index 5d4dcd2..0b7756c 100644
--- a/src/transcription/db/__init__.py
+++ b/src/transcription/db/__init__.py
@@ -1,5 +1,6 @@
from .operations import create_all
from .operations import normalize_legacy_status_spellings
+from .operations import reconcile_legacy_job_source_columns
from .runtime import dispose_database_runtime
from .runtime import initialize_database_runtime
from .session import session_scope
@@ -10,6 +11,7 @@ __all__ = [
"dispose_database_runtime",
"initialize_database_runtime",
"normalize_legacy_status_spellings",
+ "reconcile_legacy_job_source_columns",
"session_scope",
"transaction_scope",
]
diff --git a/src/transcription/db/models.py b/src/transcription/db/models.py
index 9d23c17..182cf91 100644
--- a/src/transcription/db/models.py
+++ b/src/transcription/db/models.py
@@ -345,10 +345,10 @@ class Source(SQLModel, table=True):
if latest is None:
return None
attempts = _loaded_attribute(latest, "execution_attempts") or ()
- for attempt in sorted(attempts, key=lambda item: item.attempt_number, reverse=True):
- if attempt.error_detail:
- return attempt.error_detail
- return None
+ if not attempts:
+ return None
+ latest_attempt = max(attempts, key=lambda item: item.attempt_number)
+ return latest_attempt.error_detail
@property
def document_name(self) -> str | None:
diff --git a/src/transcription/db/operations.py b/src/transcription/db/operations.py
index a120adc..e499c85 100644
--- a/src/transcription/db/operations.py
+++ b/src/transcription/db/operations.py
@@ -73,6 +73,47 @@ async def normalize_legacy_status_spellings(*, engine: AsyncEngine | None = None
return fixed_rows
+LEGACY_JOB_SOURCE_COLUMNS = (
+ "raw_transcription",
+ "ai_metadata",
+ "raw_api_response",
+ "error_detail",
+ "executed_at",
+)
+
+
+async def reconcile_legacy_job_source_columns(*, engine: AsyncEngine | None = None) -> int:
+ """Remove stale V4.6 ``job_source`` evidence columns from existing databases.
+
+ Runtime models define ``job_source`` as a queue/projection table only. If an
+ older database still carries the retired evidence columns, writes can fail
+ on stale constraints (for example ``executed_at NOT NULL``).
+ """
+ active_engine = engine or resolve_engine()
+ if not hasattr(active_engine, "begin"):
+ return 0
+
+ def _reconcile(sync_connection) -> int:
+ inspector = sqlalchemy_inspect(sync_connection)
+ table_names = set(inspector.get_table_names())
+ if "job_source" not in table_names:
+ return 0
+ present_columns = {column["name"] for column in inspector.get_columns("job_source")}
+ dropped = 0
+ for column_name in LEGACY_JOB_SOURCE_COLUMNS:
+ if column_name not in present_columns:
+ continue
+ sync_connection.execute(text(f'alter table "job_source" drop column "{column_name}"'))
+ dropped += 1
+ return dropped
+
+ async with active_engine.begin() as connection:
+ dropped_columns = await connection.run_sync(_reconcile)
+ if dropped_columns:
+ logger.warning("Dropped %s legacy job_source column(s) during startup reconciliation", dropped_columns)
+ return dropped_columns
+
+
async def seed_registry_defaults(*, engine: AsyncEngine | None = None) -> None:
"""Seed default registry rows for role and document type taxonomies."""
active_engine = engine or resolve_engine()
diff --git a/src/transcription/services/jobs.py b/src/transcription/services/jobs.py
index 2801ed7..d9abd6b 100644
--- a/src/transcription/services/jobs.py
+++ b/src/transcription/services/jobs.py
@@ -13,6 +13,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from ..db.loading import orm_attribute
from ..db.loading import selectinload
from ..db.models import ExecutionAttempt
+from ..db.models import Document
from ..db.models import Job
from ..db.models import JobSource
from ..db.models import JobSourceStatus
@@ -65,7 +66,7 @@ class JobService(ServiceBase):
query = (
select(Job)
.options(
- selectinload(Job.document),
+ selectinload(Job.document).selectinload(orm_attribute(Document.sources)),
selectinload(Job.job_sources).selectinload(orm_attribute(JobSource.source)),
)
.where(Job.id == job_id)
diff --git a/src/transcription/services/people.py b/src/transcription/services/people.py
index 6cda488..7817eea 100644
--- a/src/transcription/services/people.py
+++ b/src/transcription/services/people.py
@@ -202,7 +202,9 @@ class PeopleService(ServiceBase):
query = (
select(Person)
.options(
- selectinload(Person.document_people).selectinload(orm_attribute(DocumentPerson.document)),
+ selectinload(Person.document_people)
+ .selectinload(orm_attribute(DocumentPerson.document))
+ .selectinload(orm_attribute(Document.sources)),
selectinload(Person.document_people).selectinload(orm_attribute(DocumentPerson.role_ref)),
)
.where(Person.id == person_id)
diff --git a/src/transcription/ui/components/data_display.py b/src/transcription/ui/components/data_display.py
index a05e98b..b180f0a 100644
--- a/src/transcription/ui/components/data_display.py
+++ b/src/transcription/ui/components/data_display.py
@@ -9,6 +9,13 @@ def metadata_row(label: str, value: str):
ui.label(value).classes("font-semibold ui-text-primary")
+def metadata_link_row(label: str, value: str, url: str, *, new_tab: bool = True):
+ """Render a metadata row where the value is a clickable link."""
+ with ui.row().classes("justify-between w-full border-b ui-border-subtle pb-1 text-xs"):
+ ui.label(label).classes("ui-text-muted")
+ ui.link(value, url, new_tab=new_tab).classes("font-semibold ui-link-primary")
+
+
def archival_badge(text: str):
"""Standardized Aged Sepia badge."""
return ui.badge(text).classes("text-[10px] ui-badge-secondary")
diff --git a/src/transcription/ui/components/document_panzoom.py b/src/transcription/ui/components/document_panzoom.py
new file mode 100644
index 0000000..435b062
--- /dev/null
+++ b/src/transcription/ui/components/document_panzoom.py
@@ -0,0 +1,113 @@
+"""Panzoom-backed media preview component for source detail."""
+
+from __future__ import annotations
+
+from functools import lru_cache
+from pathlib import Path
+from uuid import uuid4
+
+from nicegui import ui
+
+from transcription.ui.resources import read_js
+
+
+def render_document_panzoom(*, media_url: str | None, filename: str, count_label: str = "1 Source Linked") -> None:
+ """Render source media with pan/zoom interactions."""
+ del count_label
+ if not media_url:
+ with ui.column().classes(
+ "w-full items-center justify-center border ui-border-viewer "
+ "ui-bg-viewer-overlay-soft rounded-sm p-8 ui-media-placeholder"
+ ):
+ ui.label("No source media available for inspection.").classes("ui-text-muted text-xs italic")
+ return
+
+ _register_panzoom_assets()
+ host_id = f"document-panzoom-{uuid4().hex}"
+ media_kind = "pdf" if Path(filename).suffix.lower() == ".pdf" else "image"
+
+ with ui.column().classes("w-full gap-2"):
+ with ui.row().classes("w-full items-center justify-between no-wrap"):
+ ui.label(filename).classes("text-xs ui-text-muted ellipsis document-panzoom-filename")
+ ui.label("Use mouse wheel to zoom and drag to pan.").classes("text-xs ui-text-muted")
+
+ with ui.element("div").classes("w-full document-panzoom-host") as host:
+ host.props(f"id={host_id}")
+ with ui.element("div").classes("document-panzoom-surface"):
+ if media_kind == "pdf":
+ ui.html(
+ f'"
+ )
+ else:
+ ui.html(
+ f'
"
+ )
+
+ _attach_panzoom(host_id=host_id)
+
+
+@lru_cache(maxsize=1)
+def _register_panzoom_assets() -> None:
+ ui.add_head_html(f"", shared=True)
+
+
+def _attach_panzoom(*, host_id: str) -> None:
+ ui.run_javascript(
+ f"""
+ (function() {{
+ if (!window.Panzoom) return;
+ window.__transcriptionPanzoom = window.__transcriptionPanzoom || {{}};
+ const host = document.getElementById({host_id!r});
+ if (!host) return;
+ const target = host.querySelector('[data-panzoom-target]');
+ const media = host.querySelector('[data-panzoom-media]');
+ if (!target) return;
+
+ const cleanup = () => {{
+ const existing = window.__transcriptionPanzoom[{host_id!r}];
+ if (existing?.resizeObserver) existing.resizeObserver.disconnect();
+ if (existing?.wheelHandler) host.removeEventListener('wheel', existing.wheelHandler);
+ if (existing?.instance) existing.instance.destroy();
+ }};
+
+ const buildInstance = () => {{
+ cleanup();
+ if (media && media.naturalWidth > 0 && media.naturalHeight > 0) {{
+ host.style.setProperty('--panzoom-media-aspect', `${{media.naturalWidth}} / ${{media.naturalHeight}}`);
+ }}
+ const instance = Panzoom(target, {{
+ maxScale: 256,
+ minScale: 1,
+ step: 0.2,
+ roundPixels: false,
+ panOnlyWhenZoomed: true,
+ overflow: 'hidden',
+ }});
+
+ const wheelHandler = (event) => instance.zoomWithWheel(event);
+ host.addEventListener('wheel', wheelHandler, {{ passive: false }});
+
+ const resizeObserver = new ResizeObserver(() => instance.reset({{ animate: false }}));
+ resizeObserver.observe(host);
+
+ window.__transcriptionPanzoom[{host_id!r}] = {{
+ instance,
+ wheelHandler,
+ resizeObserver,
+ }};
+ }};
+
+ const initWhenReady = () => {{
+ if (media && media.tagName === 'IMG' && !media.complete) {{
+ media.addEventListener('load', buildInstance, {{ once: true }});
+ return;
+ }}
+ buildInstance();
+ }};
+
+ initWhenReady();
+ }})();
+ """
+ )
diff --git a/src/transcription/ui/components/formatters.py b/src/transcription/ui/components/formatters.py
index 0e409ad..da0a940 100644
--- a/src/transcription/ui/components/formatters.py
+++ b/src/transcription/ui/components/formatters.py
@@ -1,6 +1,7 @@
"""Presentation-only formatting shared by archival UI surfaces."""
import re
+from urllib.parse import quote_plus
from datetime import date
from uuid import UUID
@@ -60,3 +61,8 @@ def person_selector_label(person: Person) -> str:
def family_search_url(family_search_id: str) -> str:
"""Build the fixed FamilySearch details URL for a validated identifier."""
return f"https://www.familysearch.org/tree/person/details/{family_search_id}"
+
+
+def google_maps_search_url(place: str) -> str:
+ """Build a Google Maps search URL for a place label."""
+ return f"https://www.google.com/maps/search/?api=1&query={quote_plus(place)}"
diff --git a/src/transcription/ui/components/table/jobs.py b/src/transcription/ui/components/table/jobs.py
index ff6bba2..eee7130 100644
--- a/src/transcription/ui/components/table/jobs.py
+++ b/src/transcription/ui/components/table/jobs.py
@@ -23,7 +23,7 @@ class JobTableRow:
id: UUID
status: str
- filename: str
+ document_name: str
retry_count: int
date_created: str
date_updated: str
@@ -44,7 +44,7 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
{
"id": str(row.id),
"status": row.status.lower(),
- "filename": row.filename,
+ "document_name": row.document_name,
"retry_count": row.retry_count,
"date_created": _format_timestamp(row.date_created),
"date_updated": _format_timestamp(row.date_updated),
@@ -71,6 +71,7 @@ def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
"field": "id",
"sortable": True,
"classes": "font-mono text-xs",
+ "style": "width: 30%;",
},
{
"name": "status",
@@ -78,36 +79,35 @@ def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
"field": "status",
"sortable": True,
"classes": "font-mono",
+ "style": "width: 15%;",
},
{
- "name": "filename",
- "label": "Source Filename",
- "field": "filename",
+ "name": "document_name",
+ "label": "Document Name",
+ "field": "document_name",
"sortable": True,
- "classes": "font-mono text-xs",
+ "classes": "font-serif text-left ui-table-cell-wrap",
+ "align": "left",
+ "style": "width: 35%;",
},
{
"name": "retry_count",
"label": "Retries",
"field": "retry_count",
"sortable": True,
- },
- {
- "name": "date_created",
- "label": "Created",
- "field": "created_sort",
- "sortable": True,
+ "style": "width: 8%;",
},
{
"name": "date_updated",
"label": "Updated",
"field": "updated_sort",
"sortable": True,
+ "style": "width: 12%;",
},
],
- default_sort_by="created_sort",
+ default_sort_by="updated_sort",
default_descending=True,
- search_placeholder="Search jobs by ID, filename, or status...",
+ search_placeholder="Search jobs by ID, document, or status...",
on_row_click_id=lambda job_id: ui.navigate.to(f"/jobs/{job_id}"),
)
diff --git a/src/transcription/ui/pages/documents_page.py b/src/transcription/ui/pages/documents_page.py
index 99bb8e5..d05230b 100644
--- a/src/transcription/ui/pages/documents_page.py
+++ b/src/transcription/ui/pages/documents_page.py
@@ -242,58 +242,14 @@ def register_page() -> None: # noqa: PLR0915
_render_bento_relations_zone(document)
@ui.page("/documents/{document_id}/jobs")
- async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> None:
- document_service = DocumentService(session_factory=session_factory)
- render_navigation_header(current_path="/documents")
-
- parsed_doc_id = parsed_record_id(document_id, noun="Document")
- if parsed_doc_id is None:
- return
-
- try:
- document = await document_service.read_document_detail(document_id=parsed_doc_id)
- except DocumentError:
- render_record_not_found("Document")
- return
- except Exception as exc: # noqa: BLE001
- show_error(exc, title="Load failed", operation="documents.jobs")
- return
-
- with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
- with section_header_row():
- page_header(f"Jobs for {document.name}")
- with ui.row().classes("gap-2"):
- ui.button(
- "Back to Document",
- on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
- icon="arrow_back",
- ).props("flat")
- ui.button(
- "Create Job",
- on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
- icon="add",
- ).classes("ui-btn-primary")
-
- if not document.jobs:
- with archival_card(extra_classes="p-6 text-center"):
- render_empty_state("No transcription processing jobs created yet.")
- return
-
- for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
- with archival_card(extra_classes="p-3"), ui.row().classes("w-full items-center justify-between"):
- with ui.row().classes("items-center gap-2"):
- archival_badge(job.status.value)
- ui.label(f"Job ID: {job.id}").classes("text-xs font-mono ui-text-primary")
- ui.button(
- "Open Job",
- on_click=lambda _=None, jid=job.id: ui.navigate.to(f"/jobs/{jid}"),
- icon="open_in_new",
- ).props("flat dense").classes("text-xs ui-link-primary")
+ async def document_jobs_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse:
+ _ = session_factory
+ return RedirectResponse(url=f"/ui/jobs?document_id={document_id}")
@ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse:
_ = session_factory
- return RedirectResponse(url=f"/sources?document_id={document_id}")
+ return RedirectResponse(url=f"/ui/sources?document_id={document_id}")
@ui.page("/documents/{document_id}/edit")
async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None:
diff --git a/src/transcription/ui/pages/home_page.py b/src/transcription/ui/pages/home_page.py
index 5deb678..98e930f 100644
--- a/src/transcription/ui/pages/home_page.py
+++ b/src/transcription/ui/pages/home_page.py
@@ -2,6 +2,9 @@
from __future__ import annotations
+from collections.abc import Callable
+from pathlib import Path
+
from nicegui import events
from nicegui import ui
@@ -12,17 +15,78 @@ from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.upload_panel import IMAGE_UPLOAD_EXTENSIONS
from transcription.ui.components.upload_panel import render_upload_picker
from transcription.ui.components.viewers import dark_room_viewer
-from transcription.ui.homepage_store import latest_homepage_image
+from transcription.ui.homepage_store import list_homepage_images
from transcription.ui.homepage_store import read_homepage_markdown
from transcription.ui.homepage_store import save_homepage_markdown
from transcription.ui.homepage_store import store_homepage_image
from transcription.ui.theme import page_header
-def _render_homepage_view(*, markdown_text: str, image_path) -> None:
+def _shift_gallery_index(*, image_paths: list[Path], active_index: list[int], step: int) -> None:
+ if len(image_paths) < 2:
+ active_index[0] = 0
+ return
+ active_index[0] = (active_index[0] + step) % len(image_paths)
+
+
+def _render_homepage_gallery(
+ *,
+ image_paths: list[Path],
+ active_index: list[int],
+ enable_rotation: bool = False,
+ rotate_enabled: list[bool] | None = None,
+ on_change: Callable[[], None] | None = None,
+) -> None:
+ if not image_paths:
+ render_empty_state("No homepage image uploaded yet.")
+ return
+
+ if active_index[0] >= len(image_paths):
+ active_index[0] = len(image_paths) - 1
+ if active_index[0] < 0:
+ active_index[0] = 0
+
+ current_path = image_paths[active_index[0]]
+ dark_room_viewer(str(current_path), count_label="Homepage Image")
+
+ def move(step: int) -> None:
+ _shift_gallery_index(image_paths=image_paths, active_index=active_index, step=step)
+ if on_change is not None:
+ on_change()
+
+ with ui.row().classes("w-full items-center justify-between mt-2"):
+ previous = ui.button(
+ "Previous",
+ on_click=lambda: move(-1),
+ icon="chevron_left",
+ ).props("flat dense")
+ following = ui.button(
+ "Next",
+ on_click=lambda: move(1),
+ icon="chevron_right",
+ ).props("flat dense icon-right")
+ if len(image_paths) < 2:
+ previous.props("disable")
+ following.props("disable")
+ ui.label(f"{active_index[0] + 1} of {len(image_paths)}").classes("text-xs ui-text-muted")
+
+ if enable_rotation and rotate_enabled is not None:
+ def set_rotation(enabled: bool) -> None:
+ rotate_enabled[0] = enabled
+ if on_change is not None:
+ on_change()
+
+ ui.checkbox(
+ "Rotate every 10 minutes",
+ value=rotate_enabled[0],
+ on_change=lambda event: set_rotation(bool(event.value)),
+ ).classes("text-xs")
+
+
+def _render_homepage_view(*, markdown_text: str, render_image_panel: Callable[[], None]) -> None:
with ui.grid().classes("w-full grid-cols-12 gap-4"):
with ui.column().classes("col-span-12 lg:col-span-4"):
- dark_room_viewer(str(image_path) if image_path else None, count_label="Homepage Image")
+ render_image_panel()
with ui.column().classes("col-span-12 lg:col-span-5 gap-4"), archival_card(title="Home Text"):
if markdown_text:
@@ -42,6 +106,7 @@ def _render_homepage_editor(*, render_image_panel, markdown_input, on_upload) ->
on_upload=on_upload,
label="Upload image",
extensions=IMAGE_UPLOAD_EXTENSIONS,
+ multiple=True,
)
render_image_panel()
@@ -61,6 +126,17 @@ def register_page() -> None:
@ui.page("/homepage", title="VibeScribe Home")
def homepage_page() -> None:
render_navigation_header(current_path="/homepage")
+ image_paths = list_homepage_images()
+ active_index = [len(image_paths) - 1 if image_paths else 0]
+
+ @ui.refreshable
+ def render_image_panel() -> None:
+ with archival_card(title="Homepage Images"):
+ _render_homepage_gallery(
+ image_paths=image_paths,
+ active_index=active_index,
+ on_change=render_image_panel.refresh,
+ )
with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"):
with section_header_row():
@@ -70,26 +146,44 @@ def register_page() -> None:
on_click=lambda: ui.navigate.to("/homepage/edit"),
icon="edit",
).classes("ui-btn-primary text-xs")
-
_render_homepage_view(
markdown_text=read_homepage_markdown().strip(),
- image_path=latest_homepage_image(),
+ render_image_panel=render_image_panel,
)
@ui.page("/homepage/edit", title="Edit Homepage")
def homepage_edit_page() -> None:
render_navigation_header(current_path="/homepage")
- preview_image = [latest_homepage_image()]
+ preview_images = [*list_homepage_images()]
+ active_index = [len(preview_images) - 1 if preview_images else 0]
+ rotate_enabled = [False]
markdown_input = [None]
@ui.refreshable
def render_image_panel() -> None:
- dark_room_viewer(str(preview_image[0]) if preview_image[0] else None, count_label="Homepage Image")
+ with archival_card(title="Homepage Images"):
+ _render_homepage_gallery(
+ image_paths=preview_images,
+ active_index=active_index,
+ enable_rotation=True,
+ rotate_enabled=rotate_enabled,
+ on_change=render_image_panel.refresh,
+ )
+
+ def rotate_gallery() -> None:
+ if not rotate_enabled[0]:
+ return
+ _shift_gallery_index(image_paths=preview_images, active_index=active_index, step=1)
+ render_image_panel.refresh()
+
+ ui.timer(interval=600, callback=rotate_gallery)
async def on_upload(event: events.UploadEventArguments) -> None:
payload = await event.file.read()
- preview_image[0] = await store_homepage_image(filename=event.file.name, file_bytes=payload)
+ stored = await store_homepage_image(filename=event.file.name, file_bytes=payload)
+ preview_images.append(stored)
+ active_index[0] = len(preview_images) - 1
ui.notify(f"Uploaded {event.file.name}", type="positive")
render_image_panel.refresh()
diff --git a/src/transcription/ui/pages/jobs_page.py b/src/transcription/ui/pages/jobs_page.py
index 67382f3..001ab95 100644
--- a/src/transcription/ui/pages/jobs_page.py
+++ b/src/transcription/ui/pages/jobs_page.py
@@ -53,33 +53,39 @@ def register_page() -> None: # noqa: PLR0915
"""Register jobs list and detail routes."""
@ui.page("/jobs")
- async def jobs_page(session_factory: SessionFactoryDep) -> None:
+ async def jobs_page(session_factory: SessionFactoryDep, document_id: str | None = None) -> None:
jobs_service = JobService(session_factory=session_factory)
+ parsed_document_id = parse_uuid(document_id)
+ is_document_context = parsed_document_id is not None
render_navigation_header(current_path="/jobs")
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with section_header_row():
- page_header("Transcription Pipeline Jobs")
- with ui.row().classes("items-center gap-2"):
- ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").classes(
- "ui-btn-primary"
- )
- ui.button("Refresh", on_click=lambda: render_table.refresh(), icon="refresh").props("flat")
+ page_header("Jobs for Document" if is_document_context else "Transcription Pipeline Jobs")
+ if not is_document_context:
+ with ui.row().classes("items-center gap-2"):
+ ui.button("Create job", on_click=lambda: ui.navigate.to("/jobs/new"), icon="add").classes(
+ "ui-btn-primary"
+ )
+ ui.button("Refresh", on_click=lambda: render_table.refresh(), icon="refresh").props("flat")
@ui.refreshable
async def render_table() -> None:
- jobs = [
+ jobs = list(await jobs_service.list_jobs())
+ if parsed_document_id is not None:
+ jobs = [job for job in jobs if job.document_id == parsed_document_id]
+ rows = [
JobTableRow(
id=job.id,
status=job.status.value,
- filename=job.filename,
+ document_name=job.document.name if job.document is not None else "Unknown document",
retry_count=job.retry_count,
date_created=job.date_created.isoformat(),
date_updated=job.date_updated.isoformat(),
)
- for job in await jobs_service.list_jobs()
+ for job in jobs
]
- render_jobs_table(jobs)
+ render_jobs_table(rows)
await render_table()
@@ -564,18 +570,23 @@ def _render_job_logistics(job: Job) -> None:
def _render_job_document_links(job: Job) -> None:
with archival_card(title="Document Links"):
- ui.label("Navigate to related archival records:").classes("text-xs ui-text-muted mb-3")
- with ui.column().classes("w-full gap-2"):
+ document_name = job.document.name if job.document is not None else str(job.document_id)
+ source_count = len(job.document.sources) if job.document is not None else len(job.job_sources)
+ with ui.row().classes("w-full items-center justify-between gap-2"):
+ ui.label("Document Name:").classes("text-xs font-semibold ui-text-primary")
ui.button(
- "View Linked Document",
+ document_name,
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}"),
icon="description",
- ).classes("ui-btn-primary text-xs w-full")
+ ).props("flat dense no-caps").classes("ui-link-primary text-xs")
+
+ metadata_row("Sources:", str(source_count))
+ with ui.row().classes("w-full gap-2 mt-2"):
ui.button(
- "View Linked Sources",
- on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"),
+ "View Sources",
+ on_click=lambda: ui.navigate.to(f"/sources?document_id={job.document_id}"),
icon="description",
- ).props("flat text-xs").classes("ui-link-primary w-full")
+ ).props("flat dense text-xs").classes("ui-link-primary")
def _latest_prompt_name(job: Job) -> str | None:
diff --git a/src/transcription/ui/pages/people_page.py b/src/transcription/ui/pages/people_page.py
index 3fdba1f..5a5d3cd 100644
--- a/src/transcription/ui/pages/people_page.py
+++ b/src/transcription/ui/pages/people_page.py
@@ -20,11 +20,13 @@ from transcription.services.people import store_person_portrait
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.confirm_delete import render_delete_actions
+from transcription.ui.components.data_display import metadata_link_row
from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.error_presenter import run_ui_action
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.formatters import compact_date
from transcription.ui.components.formatters import family_search_url
+from transcription.ui.components.formatters import google_maps_search_url
from transcription.ui.components.formatters import parse_iso_date
from transcription.ui.components.guards import parsed_record_id
from transcription.ui.components.guards import render_record_not_found
@@ -32,6 +34,7 @@ from transcription.ui.components.media_urls import resolve_media_url
from transcription.ui.components.primitives import destructive_button
from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row
+from transcription.ui.components.table.common import build_table
from transcription.ui.components.table.people import PersonTableRow
from transcription.ui.components.table.people import render_people_table
from transcription.ui.components.upload_panel import IMAGE_UPLOAD_EXTENSIONS
@@ -467,17 +470,32 @@ def _render_person_biographical_zone(person: Person) -> None:
with archival_card(title="Biographical Record"):
metadata_row("Full Name:", person.full_name)
metadata_row("Display Name:", person.display_name or "Not set")
- metadata_row("Maiden Name:", person.maiden_name or "Not set")
+ if person.maiden_name:
+ metadata_row("Maiden Name:", person.maiden_name)
metadata_row("Birth Date:", compact_date(person.birth_date, person.birth_date_raw))
- metadata_row("Birth Place:", person.birth_place or "Not set")
+ if person.birth_place:
+ metadata_link_row(
+ "Birth Place:",
+ person.birth_place,
+ google_maps_search_url(person.birth_place),
+ )
+ else:
+ metadata_row("Birth Place:", "Not set")
metadata_row("Death Date:", compact_date(person.death_date, person.death_date_raw))
- metadata_row("Death Place:", person.death_place or "Not set")
+ if person.death_place:
+ metadata_link_row(
+ "Death Place:",
+ person.death_place,
+ google_maps_search_url(person.death_place),
+ )
+ else:
+ metadata_row("Death Place:", "Not set")
if person.family_search_id:
- ui.link(
- "Open in FamilySearch",
+ metadata_link_row(
+ "FamilySearch ID:",
+ person.family_search_id,
family_search_url(person.family_search_id),
- new_tab=True,
- ).classes("mt-2 text-xs font-semibold ui-link-primary")
+ )
with archival_card(title="System Logistics"):
ui.label(f"Created: {person.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
@@ -499,21 +517,64 @@ def _render_linked_documents(person: Person) -> None:
render_empty_state("Link this person from a Document workflow.")
return
- with ui.column().classes("w-full gap-2"):
- for link in person.document_people:
- doc = link.document
- if doc is None:
- continue
- role_label = link.role_ref.label if link.role_ref is not None else "Unknown role"
- with ui.row().classes("w-full justify-between items-center ui-row-surface p-2"):
- with ui.column().classes("gap-0"):
- ui.label(doc.name).classes("text-xs font-semibold ui-text-primary")
- ui.label(f"Role: {role_label}").classes("text-[10px] ui-text-muted")
- ui.button(
- "Open",
- on_click=lambda _=None, doc_id=doc.id: ui.navigate.to(f"/documents/{doc_id}"),
- icon="open_in_new",
- ).props("flat dense text-xs").classes("ui-link-primary")
+ rows = sorted(
+ (
+ {
+ "id": str(link.document.id),
+ "document_name": link.document.name,
+ "role": link.role_ref.label if link.role_ref is not None else "Unknown role",
+ "page_count": len(link.document.sources),
+ }
+ for link in person.document_people
+ if link.document is not None
+ ),
+ key=lambda row: str(row["document_name"]).casefold(),
+ )
+
+ if not rows:
+ render_empty_state("No linked documents yet.", italic=True)
+ render_empty_state("Link this person from a Document workflow.")
+ return
+
+ table = build_table(
+ rows=rows,
+ columns=[
+ {
+ "name": "document_name",
+ "label": "Document Name",
+ "field": "document_name",
+ "sortable": True,
+ "classes": "text-left ui-table-cell-wrap",
+ "align": "left",
+ },
+ {
+ "name": "role",
+ "label": "Role",
+ "field": "role",
+ "sortable": True,
+ "classes": "text-left ui-table-cell-wrap",
+ "align": "left",
+ },
+ {
+ "name": "page_count",
+ "label": "Number of Pages",
+ "field": "page_count",
+ "sortable": True,
+ "align": "center",
+ },
+ ],
+ default_sort_by="document_name",
+ on_row_click_id=lambda doc_id: ui.navigate.to(f"/documents/{doc_id}"),
+ show_search=False,
+ )
+ table.add_slot(
+ "body-cell-document_name",
+ r"""
+
+ {{ props.value }}
+
+ """,
+ )
# --- Utilities & Input Binding Helpers ---
diff --git a/src/transcription/ui/pages/settings_page.py b/src/transcription/ui/pages/settings_page.py
index 7190ca3..adc2151 100644
--- a/src/transcription/ui/pages/settings_page.py
+++ b/src/transcription/ui/pages/settings_page.py
@@ -18,6 +18,8 @@ from transcription.ui.components.primitives import destructive_button
from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.table.registry import render_registry_table
+from transcription.ui.homepage_store import read_homepage_markdown
+from transcription.ui.homepage_store import save_homepage_markdown
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep
@@ -282,7 +284,7 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
)
if not summaries_outcome.ok:
return
- summaries = summaries_outcome.value or ()
+ summaries = tuple(summary for summary in (summaries_outcome.value or ()) if summary.name == "transcribe_document.md")
if not summaries:
render_empty_state("No editable Markdown prompts were found.")
@@ -345,9 +347,52 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
if not summary.has_backup:
recovery.props("disable")
- await render_document_types()
- await render_person_roles()
- await render_prompts()
+ @ui.refreshable
+ async def render_home_page_text() -> None:
+ with archival_card("Home Page Text"):
+ ui.label("Edit the homepage Markdown shown on /homepage.").classes("text-xs ui-text-muted mb-3")
+ load_outcome = await run_ui_action(
+ operation="settings.homepage.read",
+ title="Home Page Text unavailable",
+ action=lambda: _read_home_page_text(settings),
+ )
+ if not load_outcome.ok:
+ return
+ editor = (
+ ui.textarea("Homepage markdown", value=load_outcome.value or "")
+ .props("outlined autogrow")
+ .classes("w-full")
+ )
+
+ async def save_home_text() -> None:
+ save_outcome = await run_ui_action(
+ operation="settings.homepage.write",
+ title="Home Page Text save failed",
+ action=lambda: _write_home_page_text(settings, str(editor.value or "")),
+ )
+ if not save_outcome.ok:
+ return
+ ui.notify("Home Page Text saved", type="positive")
+ render_home_page_text.refresh()
+
+ with ui.row().classes("items-center gap-2"):
+ ui.button("Save home text", icon="save", on_click=save_home_text).classes("ui-btn-primary")
+
+ with ui.tabs().classes("w-full") as tabs:
+ document_types_tab = ui.tab("Document Types")
+ person_roles_tab = ui.tab("Person Roles")
+ prompts_tab = ui.tab("Prompts")
+ home_page_text_tab = ui.tab("Home Page Text")
+
+ with ui.tab_panels(tabs, value=document_types_tab).classes("w-full"):
+ with ui.tab_panel(document_types_tab):
+ await render_document_types()
+ with ui.tab_panel(person_roles_tab):
+ await render_person_roles()
+ with ui.tab_panel(prompts_tab):
+ await render_prompts()
+ with ui.tab_panel(home_page_text_tab):
+ await render_home_page_text()
def _selected_table_row(table: Any) -> dict[str, Any] | None:
@@ -371,3 +416,11 @@ async def _write_prompt(prompts: PromptStore, name: str, content: str) -> None:
async def _recover_prompt(prompts: PromptStore, name: str) -> None:
prompts.recover_prompt(name)
+
+
+async def _read_home_page_text(settings: Settings) -> str:
+ return read_homepage_markdown(settings=settings)
+
+
+async def _write_home_page_text(settings: Settings, markdown_text: str) -> None:
+ save_homepage_markdown(markdown_text, settings=settings)
diff --git a/src/transcription/ui/pages/sources_page.py b/src/transcription/ui/pages/sources_page.py
index da778d0..572ee13 100644
--- a/src/transcription/ui/pages/sources_page.py
+++ b/src/transcription/ui/pages/sources_page.py
@@ -25,6 +25,7 @@ from transcription.ui.components.confirm_delete import render_delete_actions
from transcription.ui.components.confirm_delete import render_delete_blocked_notice
from transcription.ui.components.data_display import archival_badge
from transcription.ui.components.data_display import metadata_row
+from transcription.ui.components.document_panzoom import render_document_panzoom
from transcription.ui.components.error_presenter import run_ui_action
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.formatters import parse_uuid
@@ -37,7 +38,6 @@ from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.table.sources import SourceTableRow
from transcription.ui.components.table.sources import render_sources_table
-from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.runtime import resolve_runtime_settings
from transcription.ui.theme import page_header
@@ -268,7 +268,11 @@ def _render_source_viewer_zone(source: Source, *, settings: Settings, request: R
upload_dir=settings.upload_dir,
base_url=str(request.base_url),
)
- dark_room_viewer(media_url, count_label=f"Page {source.page_number}")
+ render_document_panzoom(
+ media_url=media_url,
+ filename=source.filename,
+ count_label=f"Page {source.page_number}",
+ )
def _render_source_navigation(previous_id: UUID | None, next_id: UUID | None) -> None:
diff --git a/src/transcription/ui/resources.py b/src/transcription/ui/resources.py
index d81e771..46ab750 100644
--- a/src/transcription/ui/resources.py
+++ b/src/transcription/ui/resources.py
@@ -19,6 +19,12 @@ def read_svg(relative_path: str) -> str:
return _read_static(relative_path, suffix=".svg")
+@cache
+def read_js(relative_path: str) -> str:
+ """Read and cache a JavaScript resource relative to ``ui/static``."""
+ return _read_static(relative_path, suffix=".js")
+
+
def _read_static(relative_path: str, *, suffix: str) -> str:
resource_path = PurePosixPath(relative_path)
if resource_path.is_absolute() or ".." in resource_path.parts or resource_path.suffix != suffix:
diff --git a/src/transcription/ui/static/theme.css b/src/transcription/ui/static/theme.css
index 99f0b8b..8453a40 100644
--- a/src/transcription/ui/static/theme.css
+++ b/src/transcription/ui/static/theme.css
@@ -460,6 +460,41 @@ input:focus-visible,
min-height: 31.25rem;
}
+.document-panzoom-host {
+ width: 100%;
+ max-width: 100%;
+ aspect-ratio: var(--panzoom-media-aspect, 4 / 3);
+ overflow: hidden;
+ border: 1px solid var(--theme-viewer-border);
+ border-radius: 0.125rem;
+ background: var(--theme-viewer);
+}
+
+.document-panzoom-surface {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+}
+
+.document-panzoom-media {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ transform-origin: center center;
+ user-select: none;
+ -webkit-user-drag: none;
+}
+
+.document-panzoom-iframe {
+ width: 100%;
+ height: 100%;
+ border: 0;
+ transform-origin: center center;
+}
+
@media (max-width: 700px) {
.app-shell {
padding-inline: 0.75rem;
diff --git a/src/transcription/ui/static/vendor/panzoom.min.js b/src/transcription/ui/static/vendor/panzoom.min.js
new file mode 100644
index 0000000..6bf074f
--- /dev/null
+++ b/src/transcription/ui/static/vendor/panzoom.min.js
@@ -0,0 +1,16 @@
+/**
+* Panzoom 4.6.2 for panning and zooming elements using CSS transforms
+* Copyright Timmy Willison and other contributors
+* https://github.com/timmywil/panzoom/blob/main/MIT-License.txt
+*/
+((e,t)=>{"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).Panzoom=t()})(this,function(){"undefined"!=typeof window&&(window.NodeList&&!NodeList.prototype.forEach&&(NodeList.prototype.forEach=Array.prototype.forEach),"function"!=typeof window.CustomEvent)&&(window.CustomEvent=function(e,t){t=t||{bubbles:!1,cancelable:!1,detail:null};var n=document.createEvent("CustomEvent");return n.initCustomEvent(e,t.bubbles,t.cancelable,t.detail),n});let i="undefined"!=typeof document&&!!document.documentMode,a;let l=["webkit","moz","ms"],s={};function z(e){if(s[e])return s[e];var t=a=a||document.createElement("div").style;if(e in t)return s[e]=e;var n=e[0].toUpperCase()+e.slice(1);let o=l.length;for(;o--;){var r=""+l[o]+n;if(r in t)return s[e]=r}}function r(e,t){return parseFloat(t[z(e)])||0}function c(e,t,n=window.getComputedStyle(e)){var o="border"===t?"Width":"";return{left:r(t+"Left"+o,n),right:r(t+"Right"+o,n),top:r(t+"Top"+o,n),bottom:r(t+"Bottom"+o,n)}}function X(e,t,n){e.style[z(t)]=n}function Y(e){let t=e.parentNode;t&&1===t.nodeType||(t=document.documentElement);var n=window.getComputedStyle(e),o=window.getComputedStyle(t),r=e.getBoundingClientRect(),a=t.getBoundingClientRect();return{elem:{style:n,width:r.width,height:r.height,top:r.top,bottom:r.bottom,left:r.left,right:r.right,margin:c(e,"margin",n),border:c(e,"border",n)},parent:{style:o,width:a.width,height:a.height,top:a.top,bottom:a.bottom,left:a.left,right:a.right,padding:c(t,"padding",o),border:c(t,"border",o)}}}let C={down:"mousedown",move:"mousemove",up:"mouseup mouseleave"};function N(e,t,n,o){C[e].split(" ").forEach(e=>{t.addEventListener(e,n,o)})}function T(e,t,n){C[e].split(" ").forEach(e=>{t.removeEventListener(e,n)})}function $(e,t){let n=e.length;for(;n--;)if(e[n].pointerId===t.pointerId)return n;return-1}function L(e,t){let n;if(t.touches){n=0;for(var o of t.touches)o.pointerId=n++,L(e,o)}else-1<(n=$(e,t))&&e.splice(n,1),e.push(t)}function V(e){let t=(e=e.slice(0)).pop();for(var n;n=e.pop();)t={clientX:(n.clientX-t.clientX)/2+t.clientX,clientY:(n.clientY-t.clientY)/2+t.clientY};return t}function G(e){var t;return e.length<2?0:(t=e[0],e=e[1],Math.sqrt(Math.pow(Math.abs(e.clientX-t.clientX),2)+Math.pow(Math.abs(e.clientY-t.clientY),2)))}"undefined"!=typeof window&&("function"==typeof window.PointerEvent?C={down:"pointerdown",move:"pointermove",up:"pointerup pointerleave pointercancel"}:"function"==typeof window.TouchEvent&&(C={down:"touchstart",move:"touchmove",up:"touchend touchcancel"}));let I=/^http:[\w\.\/]+svg$/;
+/**
+ * Panzoom for panning and zooming elements using CSS transforms
+ * https://github.com/timmywil/panzoom
+ *
+ * Copyright Timmy Willison and other contributors
+ * Released under the MIT license
+ * https://github.com/timmywil/panzoom/blob/main/MIT-License.txt
+ *
+ */
+let R={animate:!1,canvas:!1,cursor:"move",disablePan:!1,disableZoom:!1,disableXAxis:!1,disableYAxis:!1,duration:200,easing:"ease-in-out",exclude:[],excludeClass:"panzoom-exclude",handleStartEvent:e=>{e.preventDefault(),e.stopPropagation()},maxScale:4,minScale:.125,overflow:"hidden",panOnlyWhenZoomed:!1,pinchAndPan:!1,relative:!1,setTransform:function(e,{x:t,y:n,scale:o,isSVG:r},a){X(e,"transform",`scale(${o}) translate(${t}px, ${n}px)`),r&&i&&(o=window.getComputedStyle(e).getPropertyValue("transform"),e.setAttribute("transform",o))},startX:0,startY:0,startScale:1,step:.3,touchAction:"none"};function e(d,p){if(!d)throw new Error("Panzoom requires an element as an argument");if(1!==d.nodeType)throw new Error("Panzoom requires an element with a nodeType of 1");if(!(e=>{let t=e;for(;t&&t.parentNode;){if(t.parentNode===document)return 1;t=t.parentNode instanceof ShadowRoot?t.parentNode.host:t.parentNode}})(d))throw new Error("Panzoom should be called on elements that have been attached to the DOM");p=Object.assign(Object.assign({},R),p);e=d;let c=I.test(e.namespaceURI)&&"svg"!==e.nodeName.toLowerCase();var e;let n=d.parentNode;n.style.overflow=p.overflow,n.style.userSelect="none",n.style.touchAction=p.touchAction,(p.canvas?n:d).style.cursor=p.cursor,d.style.userSelect="none",d.style.touchAction=p.touchAction,X(d,"transformOrigin","string"==typeof p.origin?p.origin:c?"0 0":"50% 50%");let u=0,m=0,f=1,a=!1;function r(e,t,n){n.silent||(n=new CustomEvent(e,{detail:t}),d.dispatchEvent(n))}function l(t,n,e){let o={x:u,y:m,scale:f,isSVG:c,originalEvent:e};return requestAnimationFrame(()=>{var e;"boolean"==typeof n.animate&&(n.animate?(e=n,X(d,"transition",`${z("transform")} ${e.duration}ms `+e.easing)):X(d,"transition","none")),n.setTransform(d,o,n),r(t,o,n),r("panzoomchange",o,n)}),o}function s(e,t,n,o){var r,a,i,l,s=Object.assign(Object.assign({},p),o),c={x:u,y:m,opts:s};return(null!=o&&o.force||!(s.disablePan||s.panOnlyWhenZoomed&&f===s.startScale))&&(e=parseFloat(e),t=parseFloat(t),s.disableXAxis||(c.x=(s.relative?u:0)+e),s.disableYAxis||(c.y=(s.relative?m:0)+t),s.contain&&(e=((t=(e=(o=Y(d)).elem.width/f)*n)-e)/2,a=((r=(a=o.elem.height/f)*n)-a)/2,"inside"===s.contain?(i=(-o.elem.margin.left-o.parent.padding.left+e)/n,l=(o.parent.width-t-o.parent.padding.left-o.elem.margin.left-o.parent.border.left-o.parent.border.right+e)/n,c.x=Math.max(Math.min(c.x,l),i),l=(-o.elem.margin.top-o.parent.padding.top+a)/n,i=(o.parent.height-r-o.parent.padding.top-o.elem.margin.top-o.parent.border.top-o.parent.border.bottom+a)/n,c.y=Math.max(Math.min(c.y,i),l)):"outside"===s.contain&&(i=(-(t-o.parent.width)-o.parent.padding.left-o.parent.border.left-o.parent.border.right+e)/n,l=(e-o.parent.padding.left)/n,c.x=Math.max(Math.min(c.x,l),i),t=(-(r-o.parent.height)-o.parent.padding.top-o.parent.border.top-o.parent.border.bottom+a)/n,e=(a-o.parent.padding.top)/n,c.y=Math.max(Math.min(c.y,e),t))),s.roundPixels)&&(c.x=Math.round(c.x),c.y=Math.round(c.y)),c}function h(n,o){var r,a=Object.assign(Object.assign({},p),o),i={scale:f,opts:a};if(null!=o&&o.force||!a.disableZoom){let e=p.minScale,t=p.maxScale;a.contain&&(a=(o=Y(d)).elem.width/f,r=o.elem.height/f,1{i(p.startX,p.startY,{animate:!1,force:!0})});let y,b,w,x,O,E,S=[];function o(e){((t,n)=>{for(let e=t;null!=e;e=e.parentNode)if(o=e,r=n.excludeClass,1===o.nodeType&&-1<` ${o=o,(o.getAttribute("class")||"").trim()} `.indexOf(` ${r} `)||-1({x:u,y:m}),getScale:()=>f,getOptions:()=>{var e,t=p,n={};for(e in t)t.hasOwnProperty(e)&&(n[e]=t[e]);return n},handleDown:o,handleMove:M,handleUp:A,pan:i,reset:function(e){var e=Object.assign(Object.assign(Object.assign({},p),{animate:!0,force:!0}),e),t=(f=h(e.startScale,e).scale,s(e.startX,e.startY,f,e));return u=t.x,m=t.y,l("panzoomreset",e)},resetStyle:function(){n.style.overflow="",n.style.userSelect="",n.style.touchAction="",n.style.cursor="",d.style.cursor="",d.style.userSelect="",d.style.touchAction="",X(d,"transformOrigin","")},setOptions:function(e={}){for(var t in e)e.hasOwnProperty(t)&&(p[t]=e[t]);(e.hasOwnProperty("cursor")||e.hasOwnProperty("canvas"))&&(n.style.cursor=d.style.cursor="",(p.canvas?n:d).style.cursor=p.cursor),e.hasOwnProperty("overflow")&&(n.style.overflow=e.overflow),e.hasOwnProperty("touchAction")&&(n.style.touchAction=e.touchAction,d.style.touchAction=e.touchAction)},setStyle:(e,t)=>X(d,e,t),zoom:g,zoomIn:function(e){return t(!0,e)},zoomOut:function(e){return t(!1,e)},zoomToPoint:v,zoomWithWheel:function(e,t){e.preventDefault();var t=Object.assign(Object.assign(Object.assign({},p),t),{animate:!1}),n=(0===e.deltaY&&e.deltaX?e.deltaX:e.deltaY)<0?1:-1;return v(h(f*Math.exp(n*t.step/3),t).scale,e,t,e)}}}return e.defaultOptions=R,e});
\ No newline at end of file
diff --git a/tests/test_db.py b/tests/test_db.py
index 616aa0c..9a9803b 100644
--- a/tests/test_db.py
+++ b/tests/test_db.py
@@ -18,6 +18,7 @@ from transcription.db import create_all
from transcription.db import dispose_database_runtime
from transcription.db import initialize_database_runtime
from transcription.db import normalize_legacy_status_spellings
+from transcription.db import reconcile_legacy_job_source_columns
from transcription.db import session_scope
from transcription.db.models import Document
from transcription.db.models import DocumentType
@@ -211,6 +212,47 @@ async def test_normalize_legacy_status_spellings_repairs_job_source_status_rows(
await dispose_database_runtime()
+@pytest.mark.asyncio
+async def test_reconcile_legacy_job_source_columns_drops_executed_at(tmp_path):
+ settings = Settings(
+ openrouter_api_key="test-key",
+ database=SqliteSettings(path=str(tmp_path / "legacy-column.db")),
+ environment="test",
+ )
+ runtime = initialize_database_runtime(settings=settings)
+
+ try:
+ await create_all(engine=runtime.engine)
+ async with runtime.engine.begin() as connection:
+ await connection.execute(text("PRAGMA foreign_keys=OFF"))
+ await connection.execute(text('alter table "job_source" rename to "job_source_current"'))
+ await connection.execute(
+ text(
+ 'create table "job_source" ('
+ 'id char(32) not null primary key, '
+ 'job_id char(32) not null, '
+ 'source_id char(32) not null, '
+ 'status varchar(11) not null, '
+ 'executed_at datetime not null, '
+ 'constraint "uq_job_source_job_source" unique ("job_id", "source_id"), '
+ 'foreign key("job_id") references "job" ("id"), '
+ 'foreign key("source_id") references "source" ("id")'
+ ")"
+ )
+ )
+ await connection.execute(text('drop table "job_source_current"'))
+ await connection.execute(text("PRAGMA foreign_keys=ON"))
+
+ dropped = await reconcile_legacy_job_source_columns(engine=runtime.engine)
+ assert dropped == 1
+
+ async with runtime.engine.connect() as connection:
+ columns = await connection.run_sync(lambda c: [col["name"] for col in inspect(c).get_columns("job_source")])
+ assert "executed_at" not in columns
+ finally:
+ await dispose_database_runtime()
+
+
def test_metadata_has_no_unresolvable_table_cycle():
"""create_all must be able to order every table, including on PostgreSQL."""
with warnings.catch_warnings():
diff --git a/tests/ui/test_documents_page.py b/tests/ui/test_documents_page.py
index 2247d0a..73f8a88 100644
--- a/tests/ui/test_documents_page.py
+++ b/tests/ui/test_documents_page.py
@@ -131,7 +131,7 @@ class TestDocumentsPageRendering:
assert "Edit Document" in response.text
@pytest.mark.asyncio
- async def test_document_jobs_page_renders_job_links(self, app_client):
+ async def test_document_jobs_page_redirects_to_filtered_jobs(self, app_client):
_, client = app_client
async with session_scope() as session:
@@ -143,13 +143,14 @@ class TestDocumentsPageRendering:
session.add(job)
await session.commit()
doc_id = str(doc.id)
- job_id = str(job.id)
+ _ = str(job.id)
response = client.get(f"/ui/documents/{doc_id}/jobs")
assert response.status_code == 200
- assert "Jobs for Doc With Job" in response.text
- assert f"Job ID: {job_id}" in response.text
+ assert "Jobs for Document" in response.text
+ assert "Create job" not in response.text
+ assert "Refresh" not in response.text
@pytest.mark.asyncio
async def test_document_edit_page_prefills_existing_values(self, app_client, seed_person_and_document):
diff --git a/tests/ui/test_formatters.py b/tests/ui/test_formatters.py
index c8de431..f0658f4 100644
--- a/tests/ui/test_formatters.py
+++ b/tests/ui/test_formatters.py
@@ -3,6 +3,7 @@ from datetime import date
from transcription.db.models import Person
from transcription.ui.components.formatters import compact_date
from transcription.ui.components.formatters import family_search_url
+from transcription.ui.components.formatters import google_maps_search_url
from transcription.ui.components.formatters import person_selector_label
@@ -32,3 +33,9 @@ def test_family_search_url_uses_fixed_person_details_route():
assert family_search_url("G8T4-MDQ") == (
"https://www.familysearch.org/tree/person/details/G8T4-MDQ"
)
+
+
+def test_google_maps_search_url_encodes_place_query():
+ assert google_maps_search_url("New York, NY") == (
+ "https://www.google.com/maps/search/?api=1&query=New+York%2C+NY"
+ )
diff --git a/tests/ui/test_jobs_page.py b/tests/ui/test_jobs_page.py
index 91fa01d..eb74c78 100644
--- a/tests/ui/test_jobs_page.py
+++ b/tests/ui/test_jobs_page.py
@@ -54,8 +54,38 @@ class TestJobsPageRendering:
response = client.get("/ui/jobs")
assert response.status_code == 200
+ assert "Document Name" in response.text
+ assert "Source Filename" not in response.text
+ assert "Updated" in response.text
+ assert "Created" not in response.text
assert str(job_id) in response.text
- assert "seeded-document-page.png" in response.text
+
+ @pytest.mark.asyncio
+ async def test_jobs_page_filters_for_document_context(self, app_client):
+ _, client = app_client
+
+ async with session_scope() as session:
+ first = Document(name="First Doc")
+ second = Document(name="Second Doc")
+ session.add_all([first, second])
+ await session.flush()
+ first_job = Job(document_id=first.id, status=JobStatus.QUEUED, provider="openai", model="gpt-4o")
+ second_job = Job(document_id=second.id, status=JobStatus.QUEUED, provider="openai", model="gpt-4o")
+ session.add_all([first_job, second_job])
+ await session.commit()
+
+ first_id = str(first.id)
+ first_job_id = str(first_job.id)
+ second_job_id = str(second_job.id)
+
+ response = client.get(f"/ui/jobs?document_id={first_id}")
+
+ assert response.status_code == 200
+ assert "Jobs for Document" in response.text
+ assert "Create job" not in response.text
+ assert "Refresh" not in response.text
+ assert first_job_id in response.text
+ assert second_job_id not in response.text
@pytest.mark.asyncio
async def test_job_create_page_shows_empty_document_warning_when_no_docs(self, app_client):
@@ -95,8 +125,11 @@ class TestJobsPageRendering:
assert "Execution Logistics".upper() in response.text.upper()
assert "openai" in response.text
assert "gpt-4o" in response.text
- assert "View Linked Document" in response.text
- assert "View Linked Sources" in response.text
+ assert "Document Name:" in response.text
+ assert "Sources:" in response.text
+ assert "View Sources" in response.text
+ assert "View Linked Document" not in response.text
+ assert "View Linked Sources" not in response.text
assert "updates automatically while the job is active" in response.text
@pytest.mark.asyncio
diff --git a/tests/ui/test_pages_registration.py b/tests/ui/test_pages_registration.py
index 73ba460..09e0ff9 100644
--- a/tests/ui/test_pages_registration.py
+++ b/tests/ui/test_pages_registration.py
@@ -24,3 +24,8 @@ class TestPageRegistration:
assert sources_response.status_code == 200
assert jobs_response.status_code == 200
assert settings_response.status_code == 200
+ assert "Document Types" in settings_response.text
+ assert "Person Roles" in settings_response.text
+ assert "Prompts" in settings_response.text
+ assert "Home Page Text" in settings_response.text
+ assert "README.md" not in settings_response.text
diff --git a/tests/ui/test_people_page.py b/tests/ui/test_people_page.py
index 8c4996e..d0f07b4 100644
--- a/tests/ui/test_people_page.py
+++ b/tests/ui/test_people_page.py
@@ -12,6 +12,7 @@ from transcription.db.models import Document
from transcription.db.models import DocumentPerson
from transcription.db.models import Person
from transcription.db.models import PersonRole
+from transcription.db.models import Source
@pytest.mark.integration
@@ -55,6 +56,9 @@ class TestPeoplePageRendering:
assert "Birth date (YYYY-MM-DD)" not in response.text
assert "Death date (YYYY-MM-DD)" not in response.text
assert "FamilySearch ID" in response.text
+ assert "Auto-fill from FamilySearch" not in response.text
+ assert "Marriage date (FamilySearch)" not in response.text
+ assert "Spouse (FamilySearch)" not in response.text
assert "Biography" in response.text
assert "Save person" in response.text
@@ -92,11 +96,16 @@ class TestPeoplePageRendering:
assert "1906-12-09" in response.text
assert "Death Date:" in response.text
assert "1992-01-01" in response.text
+ assert "Open Birth Place in Google Maps" not in response.text
+ assert "Open Death Place in Google Maps" not in response.text
+ assert "google.com/maps/search/?api=1&query=New+York" in response.text
+ assert "google.com/maps/search/?api=1&query=Arlington" in response.text
assert "biography" in response.text.lower()
assert "Computer pioneer" in response.text
assert "Created:" in response.text
assert "Updated:" in response.text
- assert "Open in FamilySearch" in response.text
+ assert "Open in FamilySearch" not in response.text
+ assert "FamilySearch ID:" in response.text
assert "familysearch.org/tree/person/details/G8T4-MDQ" in response.text
assert "New Document" in response.text
assert "No linked documents yet." in response.text
@@ -134,6 +143,28 @@ class TestPeoplePageRendering:
document = Document(name="Linked Document")
session.add_all([person, document])
await session.flush()
+ session.add_all(
+ [
+ Source(
+ document_id=document.id,
+ page_number=1,
+ upload_name="linked-page-1.png",
+ filename="linked-page-1.png",
+ file_path="/tmp/linked-page-1.png",
+ file_hash="1" * 64,
+ file_size_bytes=1,
+ ),
+ Source(
+ document_id=document.id,
+ page_number=2,
+ upload_name="linked-page-2.png",
+ filename="linked-page-2.png",
+ file_path="/tmp/linked-page-2.png",
+ file_hash="2" * 64,
+ file_size_bytes=1,
+ ),
+ ]
+ )
session.add(
DocumentPerson(
@@ -148,8 +179,27 @@ class TestPeoplePageRendering:
response = client.get(f"/ui/people/{person_id}")
assert response.status_code == 200
+ assert "Document Name" in response.text
+ assert "Number of Pages" in response.text
assert "Linked Document" in response.text
- assert "Role: Author" in response.text
+ assert "Author" in response.text
+ assert '"page_count":2' in response.text
+ assert "Open" not in response.text
+
+ @pytest.mark.asyncio
+ async def test_person_detail_page_hides_empty_maiden_name(self, app_client):
+ _, client = app_client
+
+ async with session_scope() as session:
+ person = Person(full_name="No Maiden Name")
+ session.add(person)
+ await session.commit()
+ person_id = str(person.id)
+
+ response = client.get(f"/ui/people/{person_id}")
+
+ assert response.status_code == 200
+ assert "Maiden Name:" not in response.text
def test_person_detail_page_handles_invalid_id(self, app_client):
_, client = app_client
diff --git a/tests/ui/test_sources_page.py b/tests/ui/test_sources_page.py
index 094cd89..e569ee9 100644
--- a/tests/ui/test_sources_page.py
+++ b/tests/ui/test_sources_page.py
@@ -1,5 +1,7 @@
"""Tests for the sources page routes and Source model properties."""
+from datetime import UTC
+from datetime import datetime
from pathlib import Path
from uuid import uuid4
@@ -10,6 +12,7 @@ from transcription.db import session_scope
from transcription.db.loading import orm_attribute
from transcription.db.loading import selectinload
from transcription.db.models import Document
+from transcription.db.models import ExecutionAttempt
from transcription.db.models import Job
from transcription.db.models import JobSource
from transcription.db.models import JobSourceStatus
@@ -71,6 +74,51 @@ class TestSourceModelProperties:
assert source.latest_error_detail == "Timeout during OCR parsing"
assert source.latest_job_source is not None
+ @pytest.mark.asyncio
+ async def test_latest_error_detail_ignores_older_failures_when_latest_attempt_succeeds(self):
+ source = Source(
+ document_id=uuid4(),
+ page_number=1,
+ upload_name="page_one.png",
+ filename="stored_page_one.png",
+ file_path="/tmp/stored_page_one.png",
+ file_hash="b" * 64,
+ file_size_bytes=1,
+ )
+ job_source = JobSource(
+ job_id=uuid4(),
+ source_id=source.id,
+ status=JobSourceStatus.TRANSCRIBED,
+ )
+ failed_attempt = ExecutionAttempt(
+ job_source_id=job_source.id,
+ job_id=job_source.job_id,
+ source_id=source.id,
+ attempt_number=1,
+ status=JobSourceStatus.FAILED,
+ provider="openrouter",
+ error_detail="Provider timeout",
+ started_at=datetime.now(UTC),
+ finished_at=datetime.now(UTC),
+ duration_ms=10,
+ )
+ successful_attempt = ExecutionAttempt(
+ job_source_id=job_source.id,
+ job_id=job_source.job_id,
+ source_id=source.id,
+ attempt_number=2,
+ status=JobSourceStatus.TRANSCRIBED,
+ provider="openrouter",
+ error_detail=None,
+ started_at=datetime.now(UTC),
+ finished_at=datetime.now(UTC),
+ duration_ms=10,
+ )
+ job_source.execution_attempts = [failed_attempt, successful_attempt]
+ source.job_sources = [job_source]
+
+ assert source.latest_error_detail is None
+
# --- Integration Tests for Page Rendering ---