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'{filename}" + ) + + _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 ---