generated from john/python-template
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -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}"),
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user