V4.10
Quality Gate / gate (push) Failing after 11s

This commit is contained in:
Jim Lancaster
2026-08-22 10:19:30 -05:00
parent bf2f3ac09c
commit cf49c3c127
35 changed files with 1029 additions and 161 deletions
+2
View File
@@ -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)
+2
View File
@@ -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",
]
+4 -4
View File
@@ -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:
+41
View File
@@ -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()
+2 -1
View File
@@ -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)
+3 -1
View File
@@ -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)
@@ -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")
@@ -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'<iframe class="document-panzoom-iframe" src="{media_url}" title="{filename}" '
"data-panzoom-target></iframe>"
)
else:
ui.html(
f'<img class="document-panzoom-media" src="{media_url}" alt="{filename}" '
"data-panzoom-target data-panzoom-media />"
)
_attach_panzoom(host_id=host_id)
@lru_cache(maxsize=1)
def _register_panzoom_assets() -> None:
ui.add_head_html(f"<script>{read_js('vendor/panzoom.min.js')}</script>", 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();
}})();
"""
)
@@ -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)}"
+14 -14
View File
@@ -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}"),
)
+4 -48
View File
@@ -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:
+102 -8
View File
@@ -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()
+29 -18
View File
@@ -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:
+83 -22
View File
@@ -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"""
<q-td :props="props">
<span class="ui-link-primary font-semibold">{{ props.value }}</span>
</q-td>
""",
)
# --- Utilities & Input Binding Helpers ---
+57 -4
View File
@@ -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)
+6 -2
View File
@@ -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:
+6
View File
@@ -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:
+35
View File
@@ -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;
File diff suppressed because one or more lines are too long