diff --git a/src/transcription/services/sources.py b/src/transcription/services/sources.py
index 041e833..a9fe2aa 100644
--- a/src/transcription/services/sources.py
+++ b/src/transcription/services/sources.py
@@ -24,6 +24,7 @@ from pydantic import JsonValue
from pydantic import TypeAdapter
from pydantic import ValidationError
from sqlalchemy import func
+from sqlalchemy import inspect as sqlalchemy_inspect
from sqlalchemy import tuple_
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.orm import defer
@@ -124,6 +125,14 @@ class ProviderInput:
transformation: str | None = None
+@dataclass(frozen=True, slots=True)
+class LatestExecutionAttempt:
+ """One execution attempt plus the loader facts a caller needs to render it."""
+
+ attempt: ExecutionAttempt
+ transport_body_deferred: bool
+
+
class SourceService(ServiceBase):
"""Manage source records, media payloads, revisions, and page execution output."""
@@ -207,8 +216,13 @@ class SourceService(ServiceBase):
*,
job_source_id: UUID,
session: AsyncSession | None = None,
- ) -> ExecutionAttempt | None:
- """Read only the latest immutable attempt for one compatibility projection."""
+ ) -> LatestExecutionAttempt | None:
+ """Read only the latest immutable attempt for one compatibility projection.
+
+ The transport body is deferred because it can be arbitrarily large; the
+ returned read model reports that as a plain flag so callers never have to
+ inspect ORM loader state.
+ """
async with self._session_scope(session) as _session:
query = (
select(ExecutionAttempt)
@@ -220,7 +234,11 @@ class SourceService(ServiceBase):
)
.limit(1)
)
- return (await _session.exec(query)).first()
+ attempt = (await _session.exec(query)).first()
+ if attempt is None:
+ return None
+ deferred = "transport_body" in sqlalchemy_inspect(attempt).unloaded
+ return LatestExecutionAttempt(attempt=attempt, transport_body_deferred=deferred)
async def read_source_navigation(
self,
diff --git a/src/transcription/services/store.py b/src/transcription/services/store.py
index d488de6..dacd499 100644
--- a/src/transcription/services/store.py
+++ b/src/transcription/services/store.py
@@ -21,6 +21,8 @@ from ..db.models import Job
from ..db.models import JobSource
from ..db.models import JobSourceStatus
from ..db.models import Source
+from ..db.session import SessionFactory
+from ..db.session import session_scope
from .media_storage import build_stored_filename
from .media_storage import write_media_bytes
from .sources import TranscriptionError
@@ -68,10 +70,15 @@ async def create_document_job(
*,
filename: str,
file_bytes: bytes,
- session: AsyncSession,
+ session: AsyncSession | None = None,
+ session_factory: SessionFactory | None = None,
settings: Settings | None = None,
) -> DocumentJobResult:
- """Create a Document, its first Source, and a queued Job."""
+ """Create a Document, its first Source, and a queued Job.
+
+ Owns its own session when the caller does not supply one, so UI callers
+ never have to import a session scope.
+ """
runtime_settings = settings or get_settings()
prompt_execution = build_prompt_execution(settings=runtime_settings)
document_id = uuid4()
@@ -85,16 +92,21 @@ async def create_document_job(
)
file_hash, file_size_bytes = _compute_file_metadata(file_bytes)
try:
- document, job = await _create_document_job_records(
+ async with session_scope(
+ session_factory=session_factory,
session=session,
- document_id=document_id,
- source_id=source_id,
- original_filename=filename,
- stored_path=stored_path,
- file_hash=file_hash,
- file_size_bytes=file_size_bytes,
- prompt_execution=prompt_execution,
- )
+ settings=runtime_settings,
+ ) as _session:
+ document, job = await _create_document_job_records(
+ session=_session,
+ document_id=document_id,
+ source_id=source_id,
+ original_filename=filename,
+ stored_path=stored_path,
+ file_hash=file_hash,
+ file_size_bytes=file_size_bytes,
+ prompt_execution=prompt_execution,
+ )
except Exception as exc:
_best_effort_delete(stored_path)
raise SourceStorageError(
@@ -117,12 +129,17 @@ async def create_job_for_document(
*,
document_id: UUID,
source_files: Sequence[tuple[str, bytes]],
- session: AsyncSession,
+ session: AsyncSession | None = None,
+ session_factory: SessionFactory | None = None,
provider: str | None = None,
model: str | None = None,
settings: Settings | None = None,
) -> JobCreateResult:
- """Create a queued Job for an existing Document with one or more Sources."""
+ """Create a queued Job for an existing Document with one or more Sources.
+
+ Owns its own session when the caller does not supply one, so UI callers
+ never have to import a session scope.
+ """
if not source_files:
raise SourceStorageError(
"At least one Source file is required to create a Job",
@@ -154,14 +171,19 @@ async def create_job_for_document(
)
try:
- job, source_ids = await _create_job_for_document_records(
+ async with session_scope(
+ session_factory=session_factory,
session=session,
- document_id=document_id,
- stored_sources=stored_sources,
- provider=provider,
- model=model,
- prompt_execution=prompt_execution,
- )
+ settings=runtime_settings,
+ ) as _session:
+ job, source_ids = await _create_job_for_document_records(
+ session=_session,
+ document_id=document_id,
+ stored_sources=stored_sources,
+ provider=provider,
+ model=model,
+ prompt_execution=prompt_execution,
+ )
except Exception as exc:
for source in stored_sources:
_best_effort_delete(source.stored_path)
diff --git a/src/transcription/services/workflows.py b/src/transcription/services/workflows.py
index f69702c..cd1db9b 100644
--- a/src/transcription/services/workflows.py
+++ b/src/transcription/services/workflows.py
@@ -560,7 +560,7 @@ async def _write_page_outcome(
warnings = analyze_transcription_quality(result.text)
await services.sources.create_json_artifact(
source_id=source.id,
- execution_attempt_id=attempt.id,
+ execution_attempt_id=attempt.attempt.id,
artifact_type="transcription_quality_warnings",
schema_name=QUALITY_ANALYSIS_SCHEMA,
schema_version=QUALITY_ANALYSIS_VERSION,
diff --git a/src/transcription/ui/components/__init__.py b/src/transcription/ui/components/__init__.py
index 20b4e62..c2a0f65 100644
--- a/src/transcription/ui/components/__init__.py
+++ b/src/transcription/ui/components/__init__.py
@@ -3,7 +3,6 @@
from transcription.ui.components.app_shell import NAV_ITEMS
from transcription.ui.components.app_shell import render_app_shell
from transcription.ui.components.app_shell import render_navigation_header
-from transcription.ui.components.document_panzoom import render_document_panzoom
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
@@ -12,7 +11,6 @@ __all__ = [
"NAV_ITEMS",
"destructive_button",
"render_app_shell",
- "render_document_panzoom",
"render_empty_state",
"render_navigation_header",
"section_header_row",
diff --git a/src/transcription/ui/components/confirm_delete.py b/src/transcription/ui/components/confirm_delete.py
new file mode 100644
index 0000000..725f2f0
--- /dev/null
+++ b/src/transcription/ui/components/confirm_delete.py
@@ -0,0 +1,56 @@
+"""Shared rendering for the destructive-confirmation pages."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable
+from collections.abc import Callable
+from collections.abc import Sequence
+
+from nicegui import ui
+
+from transcription.ui.components.primitives import destructive_button
+
+
+def render_delete_blocked_notice(
+ *,
+ reason: str,
+ guidance: str,
+ detail: str | None = None,
+ back_label: str,
+ back_target: str,
+ secondary_label: str = "Go to Jobs",
+ secondary_target: str = "/jobs",
+ secondary_icon: str = "work_history",
+) -> None:
+ """Render the blocked-dependency notice plus its two navigation actions."""
+ ui.label(reason).classes("text-xs ui-text-danger font-bold mt-2")
+ if detail is not None:
+ ui.label(detail).classes("text-xs ui-text-muted")
+ ui.label(guidance).classes("text-xs ui-text-muted italic")
+ with ui.row().classes("w-full items-center gap-2 mt-4"):
+ ui.button(back_label, on_click=lambda: ui.navigate.to(back_target), icon="arrow_back").classes(
+ "ui-btn-primary text-xs"
+ )
+ ui.button(
+ secondary_label,
+ on_click=lambda: ui.navigate.to(secondary_target),
+ icon=secondary_icon,
+ ).props("flat text-xs")
+
+
+def render_delete_actions(
+ *,
+ confirm_label: str,
+ on_confirm: Callable[[], Awaitable[None]],
+ cancel_target: str,
+) -> None:
+ """Render the permanent-delete button beside a cancel action."""
+ with ui.row().classes("w-full items-center gap-2 mt-2"):
+ destructive_button(confirm_label, on_click=on_confirm, icon="delete_forever", variant="solid")
+ ui.button("Cancel", on_click=lambda: ui.navigate.to(cancel_target), icon="arrow_back").props("flat")
+
+
+def dependency_summary(categories: Sequence[tuple[str, bool]]) -> str:
+ """Describe which related record categories are blocking a delete."""
+ present = [name for name, is_present in categories if is_present]
+ return f"Dependencies present: {', '.join(present)}"
diff --git a/src/transcription/ui/components/document_panzoom.py b/src/transcription/ui/components/document_panzoom.py
deleted file mode 100644
index ed0e019..0000000
--- a/src/transcription/ui/components/document_panzoom.py
+++ /dev/null
@@ -1,169 +0,0 @@
-"""Panzoom-backed document preview component."""
-
-from __future__ import annotations
-
-from functools import lru_cache
-from pathlib import Path
-from urllib.parse import quote
-from uuid import uuid4
-
-from nicegui import ui
-
-from transcription.config import get_settings
-from transcription.db.models import Source
-
-PANGOZOOM_CDN_URL = "https://unpkg.com/@panzoom/panzoom@4.6.2/dist/panzoom.min.js"
-UPLOADS_URL_PREFIX = "/uploads"
-
-
-def render_document_panzoom(*, source: Source) -> None:
- """Render a source preview with pan and zoom interactions."""
- _register_panzoom_assets()
-
- host_id = f"document-panzoom-{uuid4().hex}"
- document_url = _document_url(source)
- document_kind = _document_kind(source)
-
- with ui.card().classes("w-full q-pa-md ui-card-surface"):
- with ui.row().classes("w-full items-center justify-between no-wrap"):
- ui.label("Document preview").classes("text-subtitle1 text-weight-medium")
- ui.label(source.filename).classes("text-caption ui-text-muted ellipsis document-panzoom-filename")
-
- with ui.element("div").classes("w-full document-panzoom-host q-mt-md") as host:
- host.props(f"id={host_id}")
- with ui.element("div").classes("document-panzoom-surface"):
- if document_kind == "pdf":
- ui.html(
- f'"
- )
- else:
- ui.html(
- f'
"
- )
-
- _attach_panzoom(host_id)
-
-
-@lru_cache(maxsize=1)
-def _register_panzoom_assets() -> None:
- ui.add_head_html(
- f'',
- shared=True,
- )
-
-
-def _document_url(source: Source) -> str:
- file_path = Path(source.file_path)
- upload_dir = get_settings().upload_dir
-
- relative_path: Path
- try:
- relative_path = file_path.resolve().relative_to(upload_dir.resolve())
- except ValueError:
- parts = file_path.parts
- if "uploads" in parts:
- uploads_index = parts.index("uploads")
- relative_path = Path(*parts[uploads_index + 1 :])
- else:
- relative_path = Path(file_path.name)
-
- encoded_relative_path = "/".join(quote(part) for part in relative_path.parts)
- return f"{UPLOADS_URL_PREFIX}/{encoded_relative_path}"
-
-
-def _document_kind(source: Source) -> str:
- suffix = Path(source.file_path).suffix.lower()
- if suffix == ".pdf":
- return "pdf"
- return "image"
-
-
-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 computeFitScale = () => {{
- const hostRect = host.getBoundingClientRect();
- if (hostRect.width <= 0 || hostRect.height <= 0) return null;
- return 1;
- }};
-
- const buildInstance = () => {{
- cleanup();
-
- const fitScale = computeFitScale();
- if (fitScale === null) return false;
-
- const minScale = Math.min(fitScale, 0.01);
- const instance = Panzoom(target, {{
- startX: 0,
- startY: 0,
- startScale: fitScale,
- minScale: minScale,
- maxScale: 256,
- step: 0.2,
- roundPixels: false,
- panOnlyWhenZoomed: true,
- overflow: 'hidden',
- }});
-
- const wheelHandler = (event) => instance.zoomWithWheel(event);
- host.addEventListener('wheel', wheelHandler, {{ passive: false }});
-
- requestAnimationFrame(() => {{
- instance.reset({{ animate: false }});
- }});
-
- const resizeObserver = new ResizeObserver(() => {{
- const nextFitScale = computeFitScale();
- if (nextFitScale === null) return;
- instance.setOptions({{
- startScale: nextFitScale,
- minScale: Math.min(nextFitScale, 0.01),
- }});
- instance.reset({{ animate: false }});
- }});
- resizeObserver.observe(host);
-
- window.__transcriptionPanzoom[{host_id!r}] = {{
- instance,
- wheelHandler,
- resizeObserver,
- }};
- return true;
- }};
-
- const initWhenReady = (retries = 15) => {{
- if (buildInstance()) return;
- if (retries <= 0) return;
- requestAnimationFrame(() => initWhenReady(retries - 1));
- }};
-
- if (media && media.tagName === 'IMG' && !media.complete) {{
- media.addEventListener('load', () => initWhenReady(), {{ once: true }});
- return;
- }}
-
- initWhenReady();
- }})();
- """
- )
diff --git a/src/transcription/ui/components/formatters.py b/src/transcription/ui/components/formatters.py
index 47e8cd4..0e409ad 100644
--- a/src/transcription/ui/components/formatters.py
+++ b/src/transcription/ui/components/formatters.py
@@ -2,12 +2,39 @@
import re
from datetime import date
+from uuid import UUID
from transcription.db.models import Person
YEAR_PATTERN = re.compile(r"\b[12]\d{3}\b")
+def parse_uuid(value: object | None) -> UUID | None:
+ """Parse a user-supplied identifier, treating anything unusable as absent."""
+ if value is None:
+ return None
+ if isinstance(value, UUID):
+ return value
+ candidate = str(value).strip()
+ if not candidate:
+ return None
+ try:
+ return UUID(candidate)
+ except ValueError:
+ return None
+
+
+def parse_iso_date(value: str | None) -> date | None:
+ """Parse an ISO date from a form field, treating anything unusable as absent."""
+ candidate = (value or "").strip()
+ if not candidate:
+ return None
+ try:
+ return date.fromisoformat(candidate)
+ except ValueError:
+ return None
+
+
def compact_date(exact: date | None, approximate: str | None) -> str:
"""Prefer an exact date, then an approximate value, then an unknown marker."""
if exact is not None:
diff --git a/src/transcription/ui/components/guards.py b/src/transcription/ui/components/guards.py
new file mode 100644
index 0000000..6fa19c7
--- /dev/null
+++ b/src/transcription/ui/components/guards.py
@@ -0,0 +1,32 @@
+"""Page-entry guards that render a terminal message instead of a record view."""
+
+from __future__ import annotations
+
+from uuid import UUID
+
+from nicegui import ui
+
+from transcription.ui.components.formatters import parse_uuid
+
+_GUARD_CLASSES = "text-h6 ui-text-danger p-4"
+
+
+def render_guard_message(message: str) -> None:
+ """Render the single terminal message shown when a record cannot be displayed."""
+ ui.label(message).classes(_GUARD_CLASSES)
+
+
+def parsed_record_id(value: str | None, *, noun: str) -> UUID | None:
+ """Parse a route identifier, rendering the invalid-id message when it is unusable.
+
+ `noun` is the capitalized record name, for example "Document".
+ """
+ parsed = parse_uuid(value)
+ if parsed is None:
+ render_guard_message(f"Invalid {noun.lower()} id")
+ return parsed
+
+
+def render_record_not_found(noun: str) -> None:
+ """Render the not-found message for a record that failed to load."""
+ render_guard_message(f"{noun} not found")
diff --git a/src/transcription/ui/components/linked_people.py b/src/transcription/ui/components/linked_people.py
index c19591b..22367f1 100644
--- a/src/transcription/ui/components/linked_people.py
+++ b/src/transcription/ui/components/linked_people.py
@@ -12,6 +12,7 @@ from transcription.db.models import DocumentPerson
from transcription.db.models import Person
from transcription.db.models import PersonRole
from transcription.services.people import DocumentPersonInput
+from transcription.ui.components.formatters import parse_uuid
from transcription.ui.components.formatters import person_selector_label
from transcription.ui.components.primitives import destructive_button
@@ -106,8 +107,8 @@ class LinkedPeopleEditor:
role_input.value = str(current.role_id)
def save() -> None:
- person_id = self._parse_uuid(person_input.value)
- role_id = self._parse_uuid(role_input.value)
+ person_id = parse_uuid(person_input.value)
+ role_id = parse_uuid(role_input.value)
if person_id is None or role_id is None:
ui.notify("Select both a Person and Person Role.", type="warning")
return
@@ -171,10 +172,3 @@ class LinkedPeopleEditor:
@staticmethod
def _role_option_label(role: PersonRole) -> str:
return role.label if role.is_active else f"{role.label} (inactive)"
-
- @staticmethod
- def _parse_uuid(value: Any) -> UUID | None:
- try:
- return UUID(str(value))
- except (TypeError, ValueError):
- return None
diff --git a/src/transcription/ui/components/media_urls.py b/src/transcription/ui/components/media_urls.py
new file mode 100644
index 0000000..81942a3
--- /dev/null
+++ b/src/transcription/ui/components/media_urls.py
@@ -0,0 +1,74 @@
+"""Pure resolution of stored media paths into browser-reachable upload URLs."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from urllib.parse import quote
+
+_ABSOLUTE_SCHEMES = ("http://", "https://", "data:")
+_UPLOAD_ROUTE_PREFIX = "/uploads/"
+
+
+def absolute_upload_url(path: str, *, base_url: str) -> str:
+ """Join an application-relative upload path onto the request base URL."""
+ base = base_url.rstrip("/")
+ normalized_path = path if path.startswith("/") else f"/{path}"
+ return f"{base}{normalized_path}"
+
+
+def resolve_media_url(path: str | None, *, upload_dir: Path, base_url: str) -> str | None:
+ """Map a stored media path onto a served upload URL.
+
+ Stored paths have accumulated several shapes over the life of the schema:
+ absolute filesystem paths, paths relative to the working directory, paths
+ relative to the upload root, and paths that already carry an upload route.
+ All of them must still resolve, so each shape is tried in turn.
+ """
+ candidate = (path or "").strip()
+ if not candidate:
+ return None
+
+ normalized = candidate.replace("\\", "/")
+ lowered = normalized.casefold()
+ if lowered.startswith(_ABSOLUTE_SCHEMES):
+ return normalized
+ if normalized.startswith(_UPLOAD_ROUTE_PREFIX):
+ return absolute_upload_url(normalized, base_url=base_url)
+
+ resolved_upload_dir = upload_dir.resolve()
+ path_obj = Path(candidate)
+
+ if path_obj.is_absolute():
+ absolute_candidates = [path_obj.resolve()]
+ else:
+ absolute_candidates = [
+ (Path.cwd() / path_obj).resolve(),
+ (resolved_upload_dir / path_obj).resolve(),
+ ]
+
+ for absolute_candidate in absolute_candidates:
+ try:
+ relative = absolute_candidate.relative_to(resolved_upload_dir).as_posix()
+ except ValueError:
+ continue
+ return absolute_upload_url(f"/uploads/{quote(relative)}", base_url=base_url)
+
+ upload_name = resolved_upload_dir.name.casefold()
+ normalized_parts = Path(normalized).parts
+ lowered_parts = [part.casefold() for part in normalized_parts]
+ if upload_name in lowered_parts:
+ index = lowered_parts.index(upload_name)
+ relative = Path(*normalized_parts[index + 1 :]).as_posix()
+ if relative:
+ return absolute_upload_url(f"/uploads/{quote(relative)}", base_url=base_url)
+
+ if lowered.startswith("uploads/"):
+ return absolute_upload_url(f"/{normalized}", base_url=base_url)
+ if lowered.startswith("data/"):
+ relative = normalized.split("/", 1)[1] if "/" in normalized else ""
+ if relative:
+ return absolute_upload_url(f"/uploads/{quote(relative)}", base_url=base_url)
+ if lowered.startswith(("documents/", "persons/")):
+ return absolute_upload_url(f"/uploads/{quote(normalized)}", base_url=base_url)
+
+ return absolute_upload_url(f"/uploads/{quote(path_obj.name)}", base_url=base_url)
diff --git a/src/transcription/ui/components/table/common.py b/src/transcription/ui/components/table/common.py
index 9b66923..a0cb47c 100644
--- a/src/transcription/ui/components/table/common.py
+++ b/src/transcription/ui/components/table/common.py
@@ -48,9 +48,11 @@ def build_table(
show_search: bool = True,
search_placeholder: str = "Search records...",
on_row_click_id: Callable[[str], None] | None = None,
+ selection: str | None = None,
+ rows_per_page: int = 25,
) -> Any:
"""Build a styled Quasar table widget with optional client-side filtering and row-click handlers."""
- pagination: dict[str, Any] = {"rowsPerPage": 25}
+ pagination: dict[str, Any] = {"rowsPerPage": rows_per_page}
if default_sort_by is not None:
pagination["sortBy"] = default_sort_by
pagination["descending"] = default_descending
@@ -65,13 +67,17 @@ def build_table(
.classes("w-64 text-xs ui-form-surface")
)
+ table_kwargs: dict[str, Any] = {
+ "rows": rows,
+ "columns": columns,
+ "row_key": "id",
+ "pagination": pagination,
+ }
+ if selection is not None:
+ table_kwargs["selection"] = selection
+
table = (
- ui.table(
- rows=rows,
- columns=columns,
- row_key="id",
- pagination=pagination,
- )
+ ui.table(**table_kwargs)
.classes(f"w-full ui-table {classes}".strip())
.props(
'flat square binary-state-sort table-style="table-layout: fixed; width: 100%;" '
diff --git a/src/transcription/ui/components/table/registry.py b/src/transcription/ui/components/table/registry.py
new file mode 100644
index 0000000..1062f26
--- /dev/null
+++ b/src/transcription/ui/components/table/registry.py
@@ -0,0 +1,49 @@
+"""Table rendering for the label registries edited on the settings page."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from transcription.ui.components.table.common import build_table
+
+_ACTIVE_CELL = """
+
+
+
+ """
+_BUILT_IN_CELL = """
+
+ {{ props.value ? 'Yes' : 'No' }}
+
+ """
+
+
+def render_registry_table(
+ rows: list[dict[str, Any]],
+ *,
+ count_field: str,
+ count_label: str,
+) -> Any:
+ """Render one label registry with its usage count and read-only status flags."""
+ table = build_table(
+ rows,
+ [
+ {"name": "label", "label": "Label", "field": "label", "align": "left", "sortable": True},
+ {
+ "name": count_field,
+ "label": count_label,
+ "field": count_field,
+ "align": "right",
+ "sortable": True,
+ },
+ {"name": "is_active", "label": "Active", "field": "is_active", "align": "center"},
+ {"name": "is_built_in", "label": "Built-in", "field": "is_built_in", "align": "center"},
+ ],
+ default_sort_by="label",
+ show_search=False,
+ selection="single",
+ rows_per_page=0,
+ )
+ table.add_slot("body-cell-is_active", _ACTIVE_CELL)
+ table.add_slot("body-cell-is_built_in", _BUILT_IN_CELL)
+ return table
diff --git a/src/transcription/ui/components/upload_panel.py b/src/transcription/ui/components/upload_panel.py
new file mode 100644
index 0000000..d27d02e
--- /dev/null
+++ b/src/transcription/ui/components/upload_panel.py
@@ -0,0 +1,50 @@
+"""Shared file-picker wiring for the upload surfaces."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable
+from collections.abc import Callable
+from collections.abc import Iterable
+from typing import Any
+
+from nicegui import ui
+
+from transcription.services.source_media import SOURCE_EXTENSIONS
+
+SOURCE_UPLOAD_EXTENSIONS: tuple[str, ...] = tuple(sorted(SOURCE_EXTENSIONS))
+IMAGE_UPLOAD_EXTENSIONS: tuple[str, ...] = (
+ ".bmp",
+ ".gif",
+ ".jpeg",
+ ".jpg",
+ ".png",
+ ".tif",
+ ".tiff",
+ ".webp",
+)
+
+
+def accept_attribute(extensions: Iterable[str]) -> str:
+ """Build the HTML ``accept`` attribute value for a set of extensions."""
+ return f'accept="{",".join(extensions)}"'
+
+
+def render_upload_picker(
+ *,
+ on_upload: Callable[[Any], Awaitable[None]],
+ label: str,
+ extensions: Iterable[str],
+ directory: bool = False,
+ multiple: bool = False,
+) -> ui.upload:
+ """Render the standard auto-uploading file picker used across pages."""
+ props = [accept_attribute(extensions)]
+ if directory:
+ props.append("webkitdirectory directory")
+ if multiple:
+ props.append("multiple")
+ return (
+ ui.upload(on_upload=on_upload, auto_upload=True, label=label)
+ .props(" ".join(props))
+ .classes("w-full")
+ )
diff --git a/src/transcription/ui/pages/documents_page.py b/src/transcription/ui/pages/documents_page.py
index c9dbe54..35c3b8b 100644
--- a/src/transcription/ui/pages/documents_page.py
+++ b/src/transcription/ui/pages/documents_page.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from datetime import date
+from dataclasses import dataclass
from typing import Any
from uuid import UUID
@@ -21,10 +21,17 @@ from transcription.services.workflows import create_document_with_people
from transcription.services.workflows import update_document_with_people
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 dependency_summary
+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.error_presenter import show_error
from transcription.ui.components.formatters import compact_date
+from transcription.ui.components.formatters import parse_iso_date
+from transcription.ui.components.formatters import parse_uuid
+from transcription.ui.components.guards import parsed_record_id
+from transcription.ui.components.guards import render_record_not_found
from transcription.ui.components.linked_people import LinkedPeopleEditor
from transcription.ui.components.linked_people import StagedLinkedPerson
from transcription.ui.components.primitives import destructive_button
@@ -38,6 +45,20 @@ from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep
+@dataclass(frozen=True, slots=True)
+class DocumentFormFields:
+ """Bound input widgets for the Document create and edit forms."""
+
+ name: ui.input
+ document_type: ui.select
+ type_options: dict[str, str]
+ document_date: ui.input
+ document_date_raw: ui.input
+ location: ui.input
+ archive: ui.input
+ notes: ui.textarea
+
+
def register_page() -> None: # noqa: PLR0915
"""Register documents list and detail routes."""
@@ -53,7 +74,7 @@ def register_page() -> None: # noqa: PLR0915
people = sorted(await people_service.list_people(), key=lambda item: item.full_name.casefold())
role_catalog = list(await people_service.list_person_roles(active_only=False))
type_catalog = await document_service.list_document_types()
- requested_person_id = _parse_uuid(request.query_params.get("person_id"))
+ requested_person_id = parse_uuid(request.query_params.get("person_id"))
staged_links: list[StagedLinkedPerson] = []
if requested_person_id is not None and any(person.id == requested_person_id for person in people):
try:
@@ -82,8 +103,8 @@ def register_page() -> None: # noqa: PLR0915
return_to = request.query_params.get("return_to")
async def submit_create() -> None:
- candidate_name = (form["name"].value or "").strip()
- candidate_type_id = _resolve_selected_document_type_id(form["type"].value, form["type_options"])
+ candidate_name = (form.name.value or "").strip()
+ candidate_type_id = _resolve_selected_document_type_id(form.document_type.value, form.type_options)
if not candidate_name:
ui.notify("Document name is required.", type="warning")
return
@@ -91,8 +112,8 @@ def register_page() -> None: # noqa: PLR0915
ui.notify("Document type is required.", type="warning")
return
- parsed_date = _parse_iso_date(form["date"].value)
- if form["date"].value and parsed_date is None:
+ parsed_date = parse_iso_date(form.document_date.value)
+ if form.document_date.value and parsed_date is None:
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
return
@@ -100,10 +121,10 @@ def register_page() -> None: # noqa: PLR0915
name=candidate_name,
document_type_id=candidate_type_id,
document_date=parsed_date,
- document_date_raw=(form["date_raw"].value or "").strip() or None,
- location_created=(form["location"].value or "").strip() or None,
- notes=(form["notes"].value or "").strip() or None,
- archive_identifier=(form["archive"].value or "").strip() or None,
+ document_date_raw=(form.document_date_raw.value or "").strip() or None,
+ location_created=(form.location.value or "").strip() or None,
+ notes=(form.notes.value or "").strip() or None,
+ archive_identifier=(form.archive.value or "").strip() or None,
)
try:
@@ -169,15 +190,14 @@ def register_page() -> None: # noqa: PLR0915
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
- parsed_doc_id = _parse_uuid(document_id)
+ parsed_doc_id = parsed_record_id(document_id, noun="Document")
if parsed_doc_id is None:
- ui.label("Invalid document id").classes("text-h6 ui-text-danger p-4")
return
try:
document = await document_service.read_document_detail(document_id=parsed_doc_id)
except DocumentError:
- ui.label("Document not found").classes("text-h6 ui-text-danger p-4")
+ render_record_not_found("Document")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.read")
@@ -216,15 +236,14 @@ def register_page() -> None: # noqa: PLR0915
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
- parsed_doc_id = _parse_uuid(document_id)
+ parsed_doc_id = parsed_record_id(document_id, noun="Document")
if parsed_doc_id is None:
- ui.label("Invalid document id").classes("text-h6 ui-text-danger p-4")
return
try:
document = await document_service.read_document_detail(document_id=parsed_doc_id)
except DocumentError:
- ui.label("Document not found").classes("text-h6 ui-text-danger p-4")
+ render_record_not_found("Document")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.jobs")
@@ -272,15 +291,14 @@ def register_page() -> None: # noqa: PLR0915
people_service = PeopleService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
- parsed_doc_id = _parse_uuid(document_id)
+ parsed_doc_id = parsed_record_id(document_id, noun="Document")
if parsed_doc_id is None:
- ui.label("Invalid document id").classes("text-h6 ui-text-danger p-4")
return
try:
document = await document_service.read_document_detail(document_id=parsed_doc_id)
except DocumentError:
- ui.label("Document not found").classes("text-h6 ui-text-danger p-4")
+ render_record_not_found("Document")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.edit.read")
@@ -304,8 +322,8 @@ def register_page() -> None: # noqa: PLR0915
)
async def submit_edit() -> None:
- candidate_name = (form["name"].value or "").strip()
- candidate_type_id = _resolve_selected_document_type_id(form["type"].value, form["type_options"])
+ candidate_name = (form.name.value or "").strip()
+ candidate_type_id = _resolve_selected_document_type_id(form.document_type.value, form.type_options)
if not candidate_name:
ui.notify("Document name is required.", type="warning")
return
@@ -313,8 +331,8 @@ def register_page() -> None: # noqa: PLR0915
ui.notify("Document type is required.", type="warning")
return
- parsed_date = _parse_iso_date(form["date"].value)
- if form["date"].value and parsed_date is None:
+ parsed_date = parse_iso_date(form.document_date.value)
+ if form.document_date.value and parsed_date is None:
ui.notify("Exact date must use YYYY-MM-DD.", type="warning")
return
@@ -323,10 +341,10 @@ def register_page() -> None: # noqa: PLR0915
name=candidate_name,
document_type_id=candidate_type_id,
document_date=parsed_date,
- document_date_raw=(form["date_raw"].value or "").strip() or None,
- location_created=(form["location"].value or "").strip() or None,
- notes=(form["notes"].value or "").strip() or None,
- archive_identifier=(form["archive"].value or "").strip() or None,
+ document_date_raw=(form.document_date_raw.value or "").strip() or None,
+ location_created=(form.location.value or "").strip() or None,
+ notes=(form.notes.value or "").strip() or None,
+ archive_identifier=(form.archive.value or "").strip() or None,
created_at=document.created_at,
updated_at=document.updated_at,
)
@@ -356,15 +374,14 @@ def register_page() -> None: # noqa: PLR0915
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
- parsed_doc_id = _parse_uuid(document_id)
+ parsed_doc_id = parsed_record_id(document_id, noun="Document")
if parsed_doc_id is None:
- ui.label("Invalid document id").classes("text-h6 ui-text-danger p-4")
return
try:
document = await document_service.read_document_detail(document_id=parsed_doc_id)
except DocumentError:
- ui.label("Document not found").classes("text-h6 ui-text-danger p-4")
+ render_record_not_found("Document")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.delete.read")
@@ -377,28 +394,15 @@ def register_page() -> None: # noqa: PLR0915
ui.label(f"Document: {document.name}").classes("text-sm font-semibold ui-text-primary")
if document.sources or document.jobs:
- ui.label("Delete is blocked because related records exist.").classes(
- "text-xs ui-text-danger font-bold mt-2"
+ render_delete_blocked_notice(
+ reason="Delete is blocked because related records exist.",
+ detail=dependency_summary(
+ [("Sources", bool(document.sources)), ("Jobs", bool(document.jobs))]
+ ),
+ guidance="Remove related records first, then retry deletion.",
+ back_label="Back to Document",
+ back_target=f"/documents/{document.id}",
)
- deps = [
- cat
- for cat, present in [("Sources", bool(document.sources)), ("Jobs", bool(document.jobs))]
- if present
- ]
- ui.label(f"Dependencies present: {', '.join(deps)}").classes("text-xs ui-text-muted")
- ui.label("Remove related records first, then retry deletion.").classes(
- "text-xs ui-text-muted italic"
- )
-
- with ui.row().classes("w-full items-center gap-2 mt-4"):
- ui.button(
- "Back to Document",
- on_click=lambda: ui.navigate.to(f"/documents/{document.id}"),
- icon="arrow_back",
- ).classes("ui-btn-primary text-xs")
- ui.button("Go to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
- "flat text-xs"
- )
return
ui.label("This action permanently deletes the document.").classes("text-xs ui-text-danger font-medium")
@@ -424,13 +428,11 @@ def register_page() -> None: # noqa: PLR0915
ui.notify("Document deleted", type="positive")
ui.navigate.to("/documents")
- with ui.row().classes("w-full items-center gap-2 mt-2"):
- destructive_button(
- "Delete document permanently", on_click=submit_delete, icon="delete_forever", variant="solid"
- )
- ui.button(
- "Cancel", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back"
- ).props("flat")
+ render_delete_actions(
+ confirm_label="Delete document permanently",
+ on_confirm=submit_delete,
+ cancel_target=f"/documents/{document.id}",
+ )
# --- Helper Sub-Components ---
@@ -441,7 +443,7 @@ def _render_document_form_fields(
document: Document | None = None,
type_options: dict[str, str],
linked_people: LinkedPeopleEditor,
-) -> dict[str, Any]:
+) -> DocumentFormFields:
with archival_card(extra_classes="gap-3"):
name_input = (
ui.input(label="Document name", value=document.name if document else "")
@@ -504,16 +506,16 @@ def _render_document_form_fields(
linked_people.render()
- return {
- "name": name_input,
- "type": type_input,
- "type_options": type_display_to_id,
- "date": date_input,
- "date_raw": date_raw_input,
- "location": location_input,
- "archive": archive_input,
- "notes": notes_input,
- }
+ return DocumentFormFields(
+ name=name_input,
+ document_type=type_input,
+ type_options=type_display_to_id,
+ document_date=date_input,
+ document_date_raw=date_raw_input,
+ location=location_input,
+ archive=archive_input,
+ notes=notes_input,
+ )
def _render_bento_viewer_zone(document: Document) -> None:
@@ -588,31 +590,12 @@ def _render_document_processing_card(document: Document) -> None:
).classes("ui-btn-primary text-xs")
-def _parse_uuid(value: str | None) -> UUID | None:
- if not value:
- return None
- try:
- return UUID(value)
- except ValueError:
- return None
-
-
-def _parse_iso_date(value: str | None) -> date | None:
- candidate = (value or "").strip()
- if not candidate:
- return None
- try:
- return date.fromisoformat(candidate)
- except ValueError:
- return None
-
-
def _resolve_selected_document_type_id(selected_value: Any, type_options: dict[str, str]) -> UUID | None:
candidate = str(selected_value).strip() if selected_value is not None else ""
if not candidate:
return None
selected_id = type_options.get(candidate)
- return _parse_uuid(selected_id)
+ return parse_uuid(selected_id)
def _group_people_by_role(document: Document) -> dict[str, list[Any]]:
diff --git a/src/transcription/ui/pages/home_page.py b/src/transcription/ui/pages/home_page.py
index 9696dc6..5deb678 100644
--- a/src/transcription/ui/pages/home_page.py
+++ b/src/transcription/ui/pages/home_page.py
@@ -2,12 +2,15 @@
from __future__ import annotations
+from nicegui import events
from nicegui import ui
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.primitives import render_empty_state
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 read_homepage_markdown
@@ -35,9 +38,11 @@ def _render_homepage_editor(*, render_image_panel, markdown_input, on_upload) ->
with ui.grid().classes("w-full grid-cols-12 gap-4"):
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Homepage Image"):
- ui.upload(on_upload=on_upload, auto_upload=True, label="Upload image").props(
- 'accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"'
- ).classes("w-full")
+ render_upload_picker(
+ on_upload=on_upload,
+ label="Upload image",
+ extensions=IMAGE_UPLOAD_EXTENSIONS,
+ )
render_image_panel()
with ui.column().classes("col-span-12 lg:col-span-5 gap-4"), archival_card(title="Home Text"):
@@ -82,7 +87,7 @@ def register_page() -> None:
def render_image_panel() -> None:
dark_room_viewer(str(preview_image[0]) if preview_image[0] else None, count_label="Homepage Image")
- async def on_upload(event) -> None:
+ 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)
ui.notify(f"Uploaded {event.file.name}", type="positive")
diff --git a/src/transcription/ui/pages/jobs_page.py b/src/transcription/ui/pages/jobs_page.py
index 50b33f2..8fe7347 100644
--- a/src/transcription/ui/pages/jobs_page.py
+++ b/src/transcription/ui/pages/jobs_page.py
@@ -7,14 +7,12 @@ from typing import Any
from uuid import UUID
from fastapi import Request
+from nicegui import events
from nicegui import ui
-from transcription.config import Settings
-from transcription.config import get_settings
from transcription.db.models import Job
from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus
-from transcription.db.session import session_scope
from transcription.services import ServiceBundle
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobCancelBlockedError
@@ -26,14 +24,22 @@ from transcription.services.store import create_job_for_document
from transcription.services.workflows import create_source_retranscription_job
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.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.error_presenter import show_error
+from transcription.ui.components.formatters import parse_uuid
+from transcription.ui.components.guards import parsed_record_id
+from transcription.ui.components.guards import render_record_not_found
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.jobs import JobTableRow
from transcription.ui.components.table.jobs import render_jobs_table
+from transcription.ui.components.upload_panel import SOURCE_UPLOAD_EXTENSIONS
+from transcription.ui.components.upload_panel import render_upload_picker
+from transcription.ui.runtime import resolve_runtime_settings
from transcription.ui.theme import page_header
from transcription.worker import resolve_worker_notifier
@@ -83,7 +89,7 @@ def register_page() -> None: # noqa: PLR0915
) -> None:
documents_service = DocumentService(session_factory=session_factory)
sources_service = SourceService(session_factory=session_factory)
- settings = _resolve_runtime_settings(request)
+ settings = resolve_runtime_settings(request)
render_navigation_header(current_path="/jobs")
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
@@ -91,7 +97,7 @@ def register_page() -> None: # noqa: PLR0915
"Create Processing Job", subtitle="Queue source files for AI transcription and entity processing."
)
- requested_source_id = _parse_uuid(request.query_params.get("source_id"))
+ requested_source_id = parse_uuid(request.query_params.get("source_id"))
try:
locked_source = (
await sources_service.read_source_detail(requested_source_id)
@@ -184,14 +190,13 @@ def register_page() -> None: # noqa: PLR0915
return
try:
- async with session_scope(session_factory=session_factory) as session:
- result = await create_job_for_document(
- document_id=document_id,
- source_files=uploaded_files,
- provider=(provider_input.value or None),
- model=(model_input.value or None),
- session=session,
- )
+ result = await create_job_for_document(
+ document_id=document_id,
+ source_files=uploaded_files,
+ provider=(provider_input.value or None),
+ model=(model_input.value or None),
+ session_factory=session_factory,
+ )
except Exception as exc: # noqa: BLE001
show_error(exc, title="Create job failed", operation="jobs.create")
return
@@ -211,15 +216,14 @@ def register_page() -> None: # noqa: PLR0915
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
- parsed_job_id = _parse_uuid(job_id)
+ parsed_job_id = parsed_record_id(job_id, noun="Job")
if parsed_job_id is None:
- ui.label("Invalid job id").classes("text-h6 ui-text-danger p-4")
return
try:
job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError:
- ui.label("Job not found").classes("text-h6 ui-text-danger p-4")
+ render_record_not_found("Job")
return
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
@@ -264,15 +268,14 @@ def register_page() -> None: # noqa: PLR0915
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
- parsed_job_id = _parse_uuid(job_id)
+ parsed_job_id = parsed_record_id(job_id, noun="Job")
if parsed_job_id is None:
- ui.label("Invalid job id").classes("text-h6 ui-text-danger p-4")
return
try:
job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError:
- ui.label("Job not found").classes("text-h6 ui-text-danger p-4")
+ render_record_not_found("Job")
return
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
@@ -314,15 +317,14 @@ def register_page() -> None: # noqa: PLR0915
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
- parsed_job_id = _parse_uuid(job_id)
+ parsed_job_id = parsed_record_id(job_id, noun="Job")
if parsed_job_id is None:
- ui.label("Invalid job id").classes("text-h6 ui-text-danger p-4")
return
try:
job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError:
- ui.label("Job not found").classes("text-h6 ui-text-danger p-4")
+ render_record_not_found("Job")
return
failed_count = sum(1 for js in job.job_sources if js.status == JobSourceStatus.FAILED)
@@ -367,15 +369,14 @@ def register_page() -> None: # noqa: PLR0915
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
- parsed_job_id = _parse_uuid(job_id)
+ parsed_job_id = parsed_record_id(job_id, noun="Job")
if parsed_job_id is None:
- ui.label("Invalid job id").classes("text-h6 ui-text-danger p-4")
return
try:
job = await jobs_service.read_job(job_id=parsed_job_id)
except ValueError:
- ui.label("Job not found").classes("text-h6 ui-text-danger p-4")
+ render_record_not_found("Job")
return
with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"):
@@ -385,19 +386,13 @@ def register_page() -> None: # noqa: PLR0915
ui.label(f"Job ID: {job.id}").classes("text-sm font-semibold font-mono ui-text-primary")
if job.status == JobStatus.PROCESSING:
- ui.label("Delete is blocked while the job is processing.").classes(
- "text-xs ui-text-danger font-bold mt-2"
+ render_delete_blocked_notice(
+ reason="Delete is blocked while the job is processing.",
+ guidance="Wait for processing to complete, then retry delete.",
+ back_label="Back to Job",
+ back_target=f"/jobs/{job.id}",
+ secondary_label="Back to Jobs",
)
- ui.label("Wait for processing to complete, then retry delete.").classes(
- "text-xs ui-text-muted italic"
- )
- with ui.row().classes("w-full items-center gap-2 mt-4"):
- ui.button(
- "Back to Job", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back"
- ).classes("ui-btn-primary text-xs")
- ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="work_history").props(
- "flat text-xs"
- )
return
ui.label("This action permanently deletes the job and its immutable execution evidence.").classes(
@@ -428,11 +423,11 @@ def register_page() -> None: # noqa: PLR0915
ui.notify("Job deleted", type="positive")
ui.navigate.to("/jobs")
- with ui.row().classes("w-full items-center gap-2 mt-2"):
- destructive_button(
- "Delete job and evidence", on_click=submit_delete, icon="delete_forever", variant="solid"
- )
- ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/jobs/{job.id}"), icon="arrow_back").props("flat")
+ render_delete_actions(
+ confirm_label="Delete job and evidence",
+ on_confirm=submit_delete,
+ cancel_target=f"/jobs/{job.id}",
+ )
# --- Helper Sub-Components ---
@@ -495,17 +490,19 @@ def _render_upload_section(uploaded_files: list[tuple[str, bytes]]) -> None:
"text-xs ui-text-danger"
)
- async def on_upload(event) -> None:
+ async def on_upload(event: events.UploadEventArguments) -> None:
payload = await event.file.read()
uploaded_files.append((event.file.name, payload))
ui.notify(f"Added {event.file.name}", type="positive")
render_upload_list.refresh()
- ui.upload(
+ render_upload_picker(
on_upload=on_upload,
- auto_upload=True,
label="Select source files or a folder",
- ).props('accept=".jpg,.jpeg,.png,.tif,.tiff,.pdf" webkitdirectory directory multiple').classes("w-full")
+ extensions=SOURCE_UPLOAD_EXTENSIONS,
+ directory=True,
+ multiple=True,
+ )
render_upload_list()
@@ -562,21 +559,5 @@ def _render_job_document_links(job: Job) -> None:
).props("flat text-xs").classes("ui-link-primary w-full")
-def _parse_uuid(value: str | None) -> UUID | None:
- if not value:
- return None
- try:
- return UUID(value)
- except ValueError:
- return None
-
-
-def _resolve_runtime_settings(request: Request) -> Settings:
- app_settings = getattr(request.app.state, "settings", None)
- if isinstance(app_settings, Settings):
- return app_settings
- return get_settings()
-
-
def _latest_prompt_name(job: Job) -> str | None:
return job.prompt_name
diff --git a/src/transcription/ui/pages/people_page.py b/src/transcription/ui/pages/people_page.py
index 802ec32..7501e45 100644
--- a/src/transcription/ui/pages/people_page.py
+++ b/src/transcription/ui/pages/people_page.py
@@ -2,18 +2,15 @@
from __future__ import annotations
-from datetime import date
-from pathlib import Path
-from typing import Any
-from urllib.parse import quote
+from dataclasses import dataclass
from uuid import UUID
from uuid import uuid4
from fastapi import Request
+from nicegui import events
from nicegui import ui
from transcription.config import Settings
-from transcription.config import get_settings
from transcription.db.models import Person
from transcription.errors import ErrorCategory
from transcription.services.people import PeopleError
@@ -22,21 +19,47 @@ from transcription.services.people import PersonMediaError
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_row
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 parse_iso_date
+from transcription.ui.components.guards import parsed_record_id
+from transcription.ui.components.guards import render_record_not_found
+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.people import PersonTableRow
from transcription.ui.components.table.people import render_people_table
+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.runtime import resolve_runtime_settings
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep
+@dataclass(frozen=True, slots=True)
+class PersonFormFields:
+ """Bound input widgets for the Person create and edit forms."""
+
+ full_name: ui.input
+ display_name: ui.input
+ maiden_name: ui.input
+ birth_date: ui.input
+ birth_date_raw: ui.input
+ birth_place: ui.input
+ death_date: ui.input
+ death_date_raw: ui.input
+ death_place: ui.input
+ biography: ui.textarea
+ portrait_path: ui.input
+ family_search_id: ui.input
+
+
def register_page() -> None: # noqa: PLR0915
"""Register people list and CRUD routes."""
@@ -92,28 +115,28 @@ def register_page() -> None: # noqa: PLR0915
)
async def submit_create() -> None:
- full_name = (form["full_name"].value or "").strip()
+ full_name = (form.full_name.value or "").strip()
if not full_name:
ui.notify("Full name is required.", type="warning")
return
- birth_date = _parse_iso_date(form["birth_date"].value)
- death_date = _parse_iso_date(form["death_date"].value)
+ birth_date = parse_iso_date(form.birth_date.value)
+ death_date = parse_iso_date(form.death_date.value)
candidate = Person(
id=draft_person_id,
full_name=full_name,
- display_name=(form["display_name"].value or "").strip() or None,
- maiden_name=(form["maiden_name"].value or "").strip() or None,
+ display_name=(form.display_name.value or "").strip() or None,
+ maiden_name=(form.maiden_name.value or "").strip() or None,
birth_date=birth_date,
- birth_date_raw=(form["birth_date_raw"].value or "").strip() or None,
- birth_place=(form["birth_place"].value or "").strip() or None,
+ birth_date_raw=(form.birth_date_raw.value or "").strip() or None,
+ birth_place=(form.birth_place.value or "").strip() or None,
death_date=death_date,
- death_date_raw=(form["death_date_raw"].value or "").strip() or None,
- death_place=(form["death_place"].value or "").strip() or None,
- biography=(form["biography"].value or "").strip() or None,
- portrait_path=(form["portrait_path"].value or "").strip() or None,
- family_search_id=(form["family_search_id"].value or "").strip() or None,
+ death_date_raw=(form.death_date_raw.value or "").strip() or None,
+ death_place=(form.death_place.value or "").strip() or None,
+ biography=(form.biography.value or "").strip() or None,
+ portrait_path=(form.portrait_path.value or "").strip() or None,
+ family_search_id=(form.family_search_id.value or "").strip() or None,
)
try:
@@ -134,15 +157,14 @@ def register_page() -> None: # noqa: PLR0915
people_service = PeopleService(session_factory=session_factory)
render_navigation_header(current_path="/people")
- parsed_person_id = _parse_uuid(person_id)
+ parsed_person_id = parsed_record_id(person_id, noun="Person")
if parsed_person_id is None:
- ui.label("Invalid person id").classes("text-h6 ui-text-danger p-4")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except PeopleError:
- ui.label("Person not found").classes("text-h6 ui-text-danger p-4")
+ render_record_not_found("Person")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.read")
@@ -173,7 +195,7 @@ def register_page() -> None: # noqa: PLR0915
with ui.grid().classes("w-full grid-cols-12 gap-4"):
_render_person_portrait_zone(
person,
- settings=_resolve_runtime_settings(request),
+ settings=resolve_runtime_settings(request),
request=request,
)
_render_person_biographical_zone(person)
@@ -184,15 +206,14 @@ def register_page() -> None: # noqa: PLR0915
people_service = PeopleService(session_factory=session_factory)
render_navigation_header(current_path="/people")
- parsed_person_id = _parse_uuid(person_id)
+ parsed_person_id = parsed_record_id(person_id, noun="Person")
if parsed_person_id is None:
- ui.label("Invalid person id").classes("text-h6 ui-text-danger p-4")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except PeopleError:
- ui.label("Person not found").classes("text-h6 ui-text-danger p-4")
+ render_record_not_found("Person")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.edit.read")
@@ -208,28 +229,28 @@ def register_page() -> None: # noqa: PLR0915
)
async def submit_edit() -> None:
- full_name = (form["full_name"].value or "").strip()
+ full_name = (form.full_name.value or "").strip()
if not full_name:
ui.notify("Full name is required.", type="warning")
return
- birth_date = _parse_iso_date(form["birth_date"].value)
- death_date = _parse_iso_date(form["death_date"].value)
+ birth_date = parse_iso_date(form.birth_date.value)
+ death_date = parse_iso_date(form.death_date.value)
candidate = Person(
id=person.id,
full_name=full_name,
- display_name=(form["display_name"].value or "").strip() or None,
- maiden_name=(form["maiden_name"].value or "").strip() or None,
+ display_name=(form.display_name.value or "").strip() or None,
+ maiden_name=(form.maiden_name.value or "").strip() or None,
birth_date=birth_date,
- birth_date_raw=(form["birth_date_raw"].value or "").strip() or None,
- birth_place=(form["birth_place"].value or "").strip() or None,
+ birth_date_raw=(form.birth_date_raw.value or "").strip() or None,
+ birth_place=(form.birth_place.value or "").strip() or None,
death_date=death_date,
- death_date_raw=(form["death_date_raw"].value or "").strip() or None,
- death_place=(form["death_place"].value or "").strip() or None,
- biography=(form["biography"].value or "").strip() or None,
- portrait_path=(form["portrait_path"].value or "").strip() or None,
- family_search_id=(form["family_search_id"].value or "").strip() or None,
+ death_date_raw=(form.death_date_raw.value or "").strip() or None,
+ death_place=(form.death_place.value or "").strip() or None,
+ biography=(form.biography.value or "").strip() or None,
+ portrait_path=(form.portrait_path.value or "").strip() or None,
+ family_search_id=(form.family_search_id.value or "").strip() or None,
metadata_=person.metadata_,
created_at=person.created_at,
updated_at=person.updated_at,
@@ -255,15 +276,14 @@ def register_page() -> None: # noqa: PLR0915
people_service = PeopleService(session_factory=session_factory)
render_navigation_header(current_path="/people")
- parsed_person_id = _parse_uuid(person_id)
+ parsed_person_id = parsed_record_id(person_id, noun="Person")
if parsed_person_id is None:
- ui.label("Invalid person id").classes("text-h6 ui-text-danger p-4")
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except PeopleError:
- ui.label("Person not found").classes("text-h6 ui-text-danger p-4")
+ render_record_not_found("Person")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.delete.read")
@@ -301,13 +321,11 @@ def register_page() -> None: # noqa: PLR0915
ui.notify("Person deleted", type="positive")
ui.navigate.to("/people")
- with ui.row().classes("w-full items-center gap-2 mt-2"):
- destructive_button(
- "Delete person permanently", on_click=submit_delete, icon="delete_forever", variant="solid"
- )
- ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props(
- "flat"
- )
+ render_delete_actions(
+ confirm_label="Delete person permanently",
+ on_confirm=submit_delete,
+ cancel_target=f"/people/{person.id}",
+ )
# --- Helper Sub-Components & Form Builders ---
@@ -318,7 +336,7 @@ def _render_person_form_fields(
request: Request,
person: Person | None = None,
person_id: UUID,
-) -> dict[str, Any]:
+) -> PersonFormFields:
with archival_card(extra_classes="gap-3"):
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-3"):
full_name_input = (
@@ -405,28 +423,32 @@ def _render_person_form_fields(
_bind_portrait_file_picker(
portrait_path_input,
- settings=_resolve_runtime_settings(request),
+ settings=resolve_runtime_settings(request),
person_id=person_id,
)
- return {
- "full_name": full_name_input,
- "display_name": display_name_input,
- "maiden_name": maiden_name_input,
- "birth_date": birth_date_input,
- "birth_date_raw": birth_date_raw_input,
- "birth_place": birth_place_input,
- "death_date": death_date_input,
- "death_date_raw": death_date_raw_input,
- "death_place": death_place_input,
- "biography": biography_input,
- "portrait_path": portrait_path_input,
- "family_search_id": family_search_id_input,
- }
+ return PersonFormFields(
+ full_name=full_name_input,
+ display_name=display_name_input,
+ maiden_name=maiden_name_input,
+ birth_date=birth_date_input,
+ birth_date_raw=birth_date_raw_input,
+ birth_place=birth_place_input,
+ death_date=death_date_input,
+ death_date_raw=death_date_raw_input,
+ death_place=death_place_input,
+ biography=biography_input,
+ portrait_path=portrait_path_input,
+ family_search_id=family_search_id_input,
+ )
def _render_person_portrait_zone(person: Person, *, settings: Settings, request: Request) -> None:
- portrait_src = _resolve_portrait_src(person.portrait_path, settings=settings, request=request)
+ portrait_src = resolve_media_url(
+ person.portrait_path,
+ upload_dir=settings.upload_dir,
+ base_url=str(request.base_url),
+ )
with ui.column().classes("col-span-12 lg:col-span-4"):
dark_room_viewer(portrait_src, count_label="Portrait Media")
@@ -489,7 +511,7 @@ def _render_linked_documents(person: Person) -> None:
def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Settings, person_id: UUID) -> None:
- async def on_portrait_selected(event) -> None:
+ async def on_portrait_selected(event: events.UploadEventArguments) -> None:
payload = await event.file.read()
try:
stored_path = await store_person_portrait(
@@ -513,93 +535,10 @@ def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Setti
portrait_path_input.value = relative_path
ui.notify("Portrait uploaded.", type="positive")
- ui.upload(
+ render_upload_picker(
on_upload=on_portrait_selected,
- auto_upload=True,
label="Choose portrait file",
- ).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"').classes("w-full")
+ extensions=IMAGE_UPLOAD_EXTENSIONS,
+ )
portrait_dir = settings.upload_dir / "persons" / str(person_id)
ui.label(f"Portraits are stored under {portrait_dir}.").classes("text-xs ui-text-muted")
-
-
-def _resolve_portrait_src(path: str | None, *, settings: Settings, request: Request) -> str | None:
- candidate = (path or "").strip()
- if not candidate:
- return None
-
- normalized = candidate.replace("\\", "/")
- lowered = normalized.casefold()
- if lowered.startswith(("http://", "https://", "data:")):
- return normalized
- if normalized.startswith("/uploads/"):
- return _to_absolute_upload_url(normalized, request=request)
-
- upload_dir = settings.upload_dir.resolve()
- path_obj = Path(candidate)
-
- if path_obj.is_absolute():
- absolute_candidates = [path_obj.resolve()]
- else:
- absolute_candidates = [
- (Path.cwd() / path_obj).resolve(),
- (upload_dir / path_obj).resolve(),
- ]
-
- for absolute_candidate in absolute_candidates:
- try:
- relative = absolute_candidate.relative_to(upload_dir).as_posix()
- return _to_absolute_upload_url(f"/uploads/{quote(relative)}", request=request)
- except ValueError:
- continue
-
- upload_name = upload_dir.name.casefold()
- normalized_parts = Path(normalized).parts
- lowered_parts = [part.casefold() for part in normalized_parts]
- if upload_name in lowered_parts:
- idx = lowered_parts.index(upload_name)
- relative = Path(*normalized_parts[idx + 1 :]).as_posix()
- if relative:
- return _to_absolute_upload_url(f"/uploads/{quote(relative)}", request=request)
-
- if lowered.startswith("uploads/"):
- return _to_absolute_upload_url(f"/{normalized}", request=request)
- if lowered.startswith("data/"):
- relative = normalized.split("/", 1)[1] if "/" in normalized else ""
- if relative:
- return _to_absolute_upload_url(f"/uploads/{quote(relative)}", request=request)
- if lowered.startswith(("documents/", "persons/")):
- return _to_absolute_upload_url(f"/uploads/{quote(normalized)}", request=request)
-
- return _to_absolute_upload_url(f"/uploads/{quote(path_obj.name)}", request=request)
-
-
-def _to_absolute_upload_url(path: str, *, request: Request) -> str:
- base = str(request.base_url).rstrip("/")
- normalized_path = path if path.startswith("/") else f"/{path}"
- return f"{base}{normalized_path}"
-
-
-def _resolve_runtime_settings(request: Request) -> Settings:
- app_settings = getattr(request.app.state, "settings", None)
- if isinstance(app_settings, Settings):
- return app_settings
- return get_settings()
-
-
-def _parse_uuid(value: str | None) -> UUID | None:
- if not value:
- return None
- try:
- return UUID(value)
- except ValueError:
- return None
-
-
-def _parse_iso_date(value: str | None) -> date | None:
- candidate = (value or "").strip()
- if not candidate:
- return None
- try:
- return date.fromisoformat(candidate)
- except ValueError:
- return None
diff --git a/src/transcription/ui/pages/print_preview_page.py b/src/transcription/ui/pages/print_preview_page.py
index 3c9808e..1de5fbc 100644
--- a/src/transcription/ui/pages/print_preview_page.py
+++ b/src/transcription/ui/pages/print_preview_page.py
@@ -14,6 +14,8 @@ from transcription.services.documents import DocumentPrintSource
from transcription.services.documents import DocumentService
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.formatters import compact_date
+from transcription.ui.components.guards import parsed_record_id
+from transcription.ui.components.guards import render_record_not_found
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep
@@ -26,17 +28,15 @@ def register_page() -> None:
@ui.page("/documents/{document_id}/print")
async def document_print_preview_page(document_id: str, session_factory: SessionFactoryDep) -> None:
- try:
- parsed_id = UUID(document_id)
- except ValueError:
- ui.label("Invalid document id").classes("text-h6 ui-text-danger p-4")
+ parsed_id = parsed_record_id(document_id, noun="Document")
+ if parsed_id is None:
return
service = DocumentService(session_factory=session_factory)
try:
projection = await service.read_document_print_projection(parsed_id)
except DocumentError:
- ui.label("Document not found").classes("text-h6 ui-text-danger p-4")
+ render_record_not_found("Document")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Print preview unavailable", operation="documents.print.read")
diff --git a/src/transcription/ui/pages/settings_page.py b/src/transcription/ui/pages/settings_page.py
index de517ff..3992bc5 100644
--- a/src/transcription/ui/pages/settings_page.py
+++ b/src/transcription/ui/pages/settings_page.py
@@ -17,6 +17,7 @@ from transcription.ui.components.error_presenter import show_error
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.theme import page_header
from ...db.session import SessionFactoryDep
@@ -62,55 +63,10 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
}
for item in document_types
]
- table = ui.table(
- columns=[
- {
- "name": "label",
- "label": "Label",
- "field": "label",
- "align": "left",
- "sortable": True,
- },
- {
- "name": "document_count",
- "label": "Documents",
- "field": "document_count",
- "align": "right",
- "sortable": True,
- },
- {
- "name": "is_active",
- "label": "Active",
- "field": "is_active",
- "align": "center",
- },
- {
- "name": "is_built_in",
- "label": "Built-in",
- "field": "is_built_in",
- "align": "center",
- },
- ],
- rows=rows,
- row_key="id",
- selection="single",
- pagination={"rowsPerPage": 0, "sortBy": "label"},
- ).classes("w-full ui-table")
- table.add_slot(
- "body-cell-is_active",
- """
-
-
-
- """,
- )
- table.add_slot(
- "body-cell-is_built_in",
- """
-
- {{ props.value ? 'Yes' : 'No' }}
-
- """,
+ table = render_registry_table(
+ rows,
+ count_field="document_count",
+ count_label="Documents",
)
async def save_type(
@@ -214,44 +170,10 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
}
for role in roles
]
- table = ui.table(
- columns=[
- {"name": "label", "label": "Label", "field": "label", "align": "left", "sortable": True},
- {
- "name": "link_count",
- "label": "Links",
- "field": "link_count",
- "align": "right",
- "sortable": True,
- },
- {"name": "is_active", "label": "Active", "field": "is_active", "align": "center"},
- {
- "name": "is_built_in",
- "label": "Built-in",
- "field": "is_built_in",
- "align": "center",
- },
- ],
- rows=rows,
- row_key="id",
- selection="single",
- pagination={"rowsPerPage": 0, "sortBy": "label"},
- ).classes("w-full ui-table")
- table.add_slot(
- "body-cell-is_active",
- """
-
-
-
- """,
- )
- table.add_slot(
- "body-cell-is_built_in",
- """
-
- {{ props.value ? 'Yes' : 'No' }}
-
- """,
+ table = render_registry_table(
+ rows,
+ count_field="link_count",
+ count_label="Links",
)
async def save_role(
diff --git a/src/transcription/ui/pages/sources_page.py b/src/transcription/ui/pages/sources_page.py
index 611ab58..35498f1 100644
--- a/src/transcription/ui/pages/sources_page.py
+++ b/src/transcription/ui/pages/sources_page.py
@@ -4,34 +4,38 @@ from __future__ import annotations
import base64
import json
-from pathlib import Path
-from urllib.parse import quote
from uuid import UUID
from fastapi import Request
from nicegui import ui
-from sqlalchemy import inspect as sqlalchemy_inspect
from transcription.config import Settings
-from transcription.config import get_settings
from transcription.db.models import ExecutionAttempt
from transcription.db.models import JobSource
from transcription.db.models import ProcessingArtifact
from transcription.db.models import Source
+from transcription.services.sources import LatestExecutionAttempt
from transcription.services.sources import SourceDeleteBlockedError
from transcription.services.sources import SourceService
from transcription.services.sources import TranscriptionNotFoundError
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.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.error_presenter import show_error
+from transcription.ui.components.formatters import parse_uuid
+from transcription.ui.components.guards import parsed_record_id
+from transcription.ui.components.guards import render_record_not_found
+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.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
from ...db.session import SessionFactoryDep
@@ -47,8 +51,8 @@ def register_page() -> None: # noqa: PLR0915
job_id: str | None = None,
) -> None:
sources_service = SourceService(session_factory=session_factory)
- parsed_doc_id = _parse_uuid(document_id)
- parsed_job_id = _parse_uuid(job_id)
+ parsed_doc_id = parse_uuid(document_id)
+ parsed_job_id = parse_uuid(job_id)
header_title = "Source Asset Records"
if parsed_doc_id is not None:
@@ -108,12 +112,10 @@ def register_page() -> None: # noqa: PLR0915
@ui.page("/sources/{source_id}")
async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
sources_service = SourceService(session_factory=session_factory)
- parsed_source_id = _parse_uuid(source_id)
-
render_navigation_header(current_path="/sources")
+ parsed_source_id = parsed_record_id(source_id, noun="Source")
if parsed_source_id is None:
- ui.label("Invalid source id").classes("text-h6 ui-text-danger p-4")
return
try:
@@ -130,7 +132,7 @@ def register_page() -> None: # noqa: PLR0915
)
attempts = list(await sources_service.list_execution_attempts(source_id=parsed_source_id))
except TranscriptionNotFoundError:
- ui.label("Source not found").classes("text-h6 ui-text-danger p-4")
+ render_record_not_found("Source")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="sources.read")
@@ -180,7 +182,7 @@ def register_page() -> None: # noqa: PLR0915
_render_source_navigation(navigation.previous_id, navigation.next_id)
_render_source_viewer_zone(
source,
- settings=_resolve_runtime_settings(request),
+ settings=resolve_runtime_settings(request),
request=request,
)
_render_source_transcription_column(
@@ -204,18 +206,16 @@ def register_page() -> None: # noqa: PLR0915
@ui.page("/sources/{source_id}/delete")
async def source_delete_page(source_id: str, session_factory: SessionFactoryDep) -> None:
sources_service = SourceService(session_factory=session_factory)
- parsed_source_id = _parse_uuid(source_id)
-
render_navigation_header(current_path="/sources")
+ parsed_source_id = parsed_record_id(source_id, noun="Source")
if parsed_source_id is None:
- ui.label("Invalid source id").classes("text-h6 ui-text-danger p-4")
return
try:
source = await sources_service.read_source_detail(parsed_source_id)
except TranscriptionNotFoundError:
- ui.label("Source not found").classes("text-h6 ui-text-danger p-4")
+ render_record_not_found("Source")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="sources.delete.read")
@@ -228,23 +228,12 @@ def register_page() -> None: # noqa: PLR0915
ui.label(f"Source: {source.upload_name}").classes("text-sm font-semibold ui-text-primary")
if source.job_sources:
- ui.label("Delete is only available for unlinked sources.").classes(
- "text-xs ui-text-danger font-bold mt-2"
+ render_delete_blocked_notice(
+ reason="Delete is only available for unlinked sources.",
+ guidance="Open the related job record and remove job links first.",
+ back_label="Back to Source",
+ back_target=f"/sources/{source.id}",
)
- ui.label("Open the related job record and remove job links first.").classes(
- "text-xs ui-text-muted italic"
- )
- with ui.row().classes("w-full items-center gap-2 mt-4"):
- ui.button(
- "Back to Source",
- on_click=lambda: ui.navigate.to(f"/sources/{source.id}"),
- icon="arrow_back",
- ).classes("ui-btn-primary text-xs")
- ui.button(
- "Go to Jobs",
- on_click=lambda: ui.navigate.to("/jobs"),
- icon="work_history",
- ).props("flat text-xs")
return
ui.label("This action permanently deletes the source record.").classes(
@@ -268,18 +257,16 @@ def register_page() -> None: # noqa: PLR0915
ui.notify("Source deleted", type="positive")
ui.navigate.to("/sources")
- with ui.row().classes("w-full items-center gap-2 mt-2"):
- destructive_button(
- "Delete source permanently", on_click=submit_delete, icon="delete_forever", variant="solid"
- )
- ui.button("Cancel", on_click=lambda: ui.navigate.to(f"/sources/{source.id}"), icon="arrow_back").props(
- "flat"
- )
+ render_delete_actions(
+ confirm_label="Delete source permanently",
+ on_confirm=submit_delete,
+ cancel_target=f"/sources/{source.id}",
+ )
def _render_source_viewer_zone(source: Source, *, settings: Settings, request: Request) -> None:
dark_room_viewer(
- _resolve_source_media_src(source.file_path, settings=settings, request=request),
+ resolve_media_url(source.file_path, upload_dir=settings.upload_dir, base_url=str(request.base_url)),
count_label=f"Page {source.page_number}",
)
@@ -323,7 +310,7 @@ def _render_source_metadata_column(
*,
source: Source,
latest_job_source: JobSource | None,
- latest_attempt: ExecutionAttempt | None,
+ latest_attempt: LatestExecutionAttempt | None,
source_artifacts: list[ProcessingArtifact],
) -> None:
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
@@ -349,7 +336,7 @@ def _render_source_metadata_zone(source: Source) -> None:
def _render_source_job_metadata_zone(
latest_job_source: JobSource | None,
*,
- latest_attempt: ExecutionAttempt | None,
+ latest_attempt: LatestExecutionAttempt | None,
source_artifacts: list[ProcessingArtifact],
) -> None:
with archival_card(title="SourceJob Metadata"):
@@ -394,7 +381,7 @@ def _render_source_job_metadata_zone(
def _render_provider_evidence(
job_source: JobSource,
*,
- latest_attempt: ExecutionAttempt | None,
+ latest_attempt: LatestExecutionAttempt | None,
source_artifacts: list[ProcessingArtifact],
) -> None:
ui.label("Provider Evidence").classes("text-xs font-semibold ui-text-primary mt-3")
@@ -408,11 +395,11 @@ def _render_provider_evidence(
_render_json_evidence("Derived Artifacts", _artifact_display(source_artifacts))
return
- attempt = latest_attempt
+ attempt = latest_attempt.attempt
metadata_row("Attempt:", str(attempt.attempt_number))
metadata_row("Duration:", f"{attempt.duration_ms} ms")
_render_json_evidence("Request Manifest", attempt.request_manifest)
- _render_json_evidence("Transport Response", _transport_display(attempt))
+ _render_json_evidence("Transport Response", _transport_display(latest_attempt))
_render_json_evidence("OpenRouter SDK Response Snapshot", attempt.sdk_response_snapshot)
_render_json_evidence("Normalized Metadata", attempt.normalized_metadata)
_render_json_evidence("Software Context", attempt.software_context)
@@ -434,10 +421,10 @@ def _artifact_display(artifacts: list[ProcessingArtifact]) -> list[dict[str, obj
return payload or None
-def _transport_display(attempt: ExecutionAttempt) -> dict[str, object]:
+def _transport_display(latest_attempt: LatestExecutionAttempt) -> dict[str, object]:
+ attempt = latest_attempt.attempt
body: object | None = None
- body_is_deferred = "transport_body" in sqlalchemy_inspect(attempt).unloaded
- if body_is_deferred:
+ if latest_attempt.transport_body_deferred:
body = "Omitted from Source Detail; use Export Evidence to retrieve the exact bytes."
elif attempt.transport_body is not None:
try:
@@ -706,81 +693,3 @@ def _resolve_original_transcription(*, source: Source, latest_job_source: JobSou
if source.raw_transcription is None and latest_job_source is not None:
return latest_job_source.raw_transcription
return source.raw_transcription
-
-
-def _resolve_source_media_src(path: str | None, *, settings: Settings, request: Request) -> str | None:
- candidate = (path or "").strip()
- if not candidate:
- return None
-
- normalized = candidate.replace("\\", "/")
- lowered = normalized.casefold()
-
- if lowered.startswith(("http://", "https://", "data:")):
- return normalized
- if normalized.startswith("/uploads/"):
- return _to_absolute_upload_url(normalized, request=request)
-
- upload_dir = settings.upload_dir.resolve()
- path_obj = Path(candidate)
-
- # Case 1: absolute filesystem path
- if path_obj.is_absolute():
- absolute_candidates = [path_obj.resolve()]
- else:
- # Case 2: relative path that may already include data root name (e.g. data/documents/...)
- absolute_candidates = [
- (Path.cwd() / path_obj).resolve(),
- (upload_dir / path_obj).resolve(),
- ]
-
- for absolute_candidate in absolute_candidates:
- try:
- relative = absolute_candidate.relative_to(upload_dir).as_posix()
- return _to_absolute_upload_url(f"/uploads/{quote(relative)}", request=request)
- except ValueError:
- continue
-
- # Fallback: if path contains upload-dir folder name, strip through that segment.
- upload_name = upload_dir.name.casefold()
- normalized_parts = Path(normalized).parts
- lowered_parts = [part.casefold() for part in normalized_parts]
- if upload_name in lowered_parts:
- idx = lowered_parts.index(upload_name)
- relative = Path(*normalized_parts[idx + 1 :]).as_posix()
- if relative:
- return _to_absolute_upload_url(f"/uploads/{quote(relative)}", request=request)
-
- # Final fallback: treat as already relative to upload root.
- if lowered.startswith("uploads/"):
- return _to_absolute_upload_url(f"/{normalized}", request=request)
- if lowered.startswith("data/"):
- relative = normalized.split("/", 1)[1] if "/" in normalized else ""
- if relative:
- return _to_absolute_upload_url(f"/uploads/{quote(relative)}", request=request)
- if lowered.startswith(("documents/", "persons/")):
- return _to_absolute_upload_url(f"/uploads/{quote(normalized)}", request=request)
-
- return _to_absolute_upload_url(f"/uploads/{quote(path_obj.name)}", request=request)
-
-
-def _to_absolute_upload_url(path: str, *, request: Request) -> str:
- base = str(request.base_url).rstrip("/")
- normalized_path = path if path.startswith("/") else f"/{path}"
- return f"{base}{normalized_path}"
-
-
-def _resolve_runtime_settings(request: Request) -> Settings:
- app_settings = getattr(request.app.state, "settings", None)
- if isinstance(app_settings, Settings):
- return app_settings
- return get_settings()
-
-
-def _parse_uuid(value: str | None) -> UUID | None:
- if not value:
- return None
- try:
- return UUID(value)
- except ValueError:
- return None
diff --git a/src/transcription/ui/runtime.py b/src/transcription/ui/runtime.py
new file mode 100644
index 0000000..bad3232
--- /dev/null
+++ b/src/transcription/ui/runtime.py
@@ -0,0 +1,14 @@
+"""Request-scoped runtime resolution shared by page modules."""
+
+from fastapi import Request
+
+from transcription.config import Settings
+from transcription.config import get_settings
+
+
+def resolve_runtime_settings(request: Request) -> Settings:
+ """Prefer settings installed on application state, falling back to the default."""
+ app_settings = getattr(request.app.state, "settings", None)
+ if isinstance(app_settings, Settings):
+ return app_settings
+ return get_settings()
diff --git a/src/transcription/ui/static/theme.css b/src/transcription/ui/static/theme.css
index 5e352a0..72e3e79 100644
--- a/src/transcription/ui/static/theme.css
+++ b/src/transcription/ui/static/theme.css
@@ -461,46 +461,6 @@ input:focus-visible,
min-height: 31.25rem;
}
-.document-panzoom-filename {
- max-width: 60%;
- text-align: right;
-}
-
-.document-panzoom-host {
- height: min(70vh, 52rem);
- overflow: hidden;
- touch-action: none;
- 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: flex-start;
- justify-content: flex-start;
-}
-
-.document-panzoom-media {
- width: auto;
- height: auto;
- display: block;
- max-width: 100%;
- max-height: 100%;
- user-select: none;
- -webkit-user-drag: none;
-}
-
-.document-panzoom-iframe {
- width: 100%;
- height: 100%;
- border: 0;
- pointer-events: none;
- background: var(--theme-surface-raised);
-}
-
@media (max-width: 700px) {
.app-shell {
padding-inline: 0.75rem;
diff --git a/tests/test_ui_boundaries.py b/tests/test_ui_boundaries.py
new file mode 100644
index 0000000..f1c2c56
--- /dev/null
+++ b/tests/test_ui_boundaries.py
@@ -0,0 +1,85 @@
+"""Structural rules for the NiceGUI page layer.
+
+`.github/instructions/ui.instructions.md:23,31` forbids pages from owning
+persistence or query-building concerns, and forbids components from resolving
+request or application state. HIGH-07 recorded three violations of those rules;
+these tests keep them from coming back.
+"""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+UI_DIR = Path(__file__).resolve().parents[1] / "src" / "transcription" / "ui"
+PAGES_DIR = UI_DIR / "pages"
+COMPONENTS_DIR = UI_DIR / "components"
+
+# Names a page must not pull in: they hand the page a session, a transaction, ORM
+# loader introspection, or process-global configuration.
+FORBIDDEN_PAGE_IMPORTS = frozenset(
+ {
+ "session_scope",
+ "transaction_scope",
+ "get_session_factory",
+ "resolve_session_factory",
+ "get_settings",
+ "get_engine",
+ "upgrade_schema",
+ "create_all",
+ }
+)
+
+FORBIDDEN_PAGE_MODULES = frozenset({"sqlalchemy", "sqlmodel"})
+
+
+def _page_paths() -> list[Path]:
+ return sorted(path for path in PAGES_DIR.glob("*.py") if path.stem != "__init__")
+
+
+def _component_paths() -> list[Path]:
+ return sorted(COMPONENTS_DIR.rglob("*.py"))
+
+
+def _imports(tree: ast.Module) -> tuple[set[str], set[str]]:
+ """Return (imported names, imported root modules)."""
+ names: set[str] = set()
+ modules: set[str] = set()
+ for node in ast.walk(tree):
+ if isinstance(node, ast.ImportFrom):
+ if node.module and node.level == 0:
+ modules.add(node.module.split(".")[0])
+ names.update(alias.name for alias in node.names)
+ elif isinstance(node, ast.Import):
+ for alias in node.names:
+ modules.add(alias.name.split(".")[0])
+ names.add(alias.name)
+ return names, modules
+
+
+def test_page_modules_are_discovered():
+ """Guard the guard: the rules below are meaningless if nothing is scanned."""
+ discovered = {path.stem for path in _page_paths()}
+ assert discovered >= {"documents_page", "jobs_page", "people_page", "sources_page"}
+
+
+def test_no_page_imports_persistence_or_process_globals():
+ """HIGH-07: pages orchestrate services; they do not own sessions or settings."""
+ violations: dict[str, list[str]] = {}
+ for path in _page_paths():
+ names, modules = _imports(ast.parse(path.read_text(encoding="utf-8")))
+ found = sorted((names & FORBIDDEN_PAGE_IMPORTS) | (modules & FORBIDDEN_PAGE_MODULES))
+ if found:
+ violations[path.stem] = found
+ assert violations == {}
+
+
+def test_no_component_resolves_request_or_application_state():
+ """`ui.instructions.md:31`: components render, they do not resolve app state."""
+ violations: dict[str, list[str]] = {}
+ for path in _component_paths():
+ names, modules = _imports(ast.parse(path.read_text(encoding="utf-8")))
+ found = sorted((names & FORBIDDEN_PAGE_IMPORTS) | (modules & (FORBIDDEN_PAGE_MODULES | {"fastapi"})))
+ if found:
+ violations[str(path.relative_to(COMPONENTS_DIR))] = found
+ assert violations == {}
diff --git a/tests/test_ui_theme.py b/tests/test_ui_theme.py
index fd369cf..135658c 100644
--- a/tests/test_ui_theme.py
+++ b/tests/test_ui_theme.py
@@ -77,6 +77,5 @@ def test_theme_defines_shared_semantic_surfaces():
"ui-chip-primary",
"ui-badge-secondary",
"ui-status",
- "document-panzoom-host",
):
assert f".{class_name}" in theme_css
diff --git a/tests/test_v42_evidence.py b/tests/test_v42_evidence.py
index e594156..1233447 100644
--- a/tests/test_v42_evidence.py
+++ b/tests/test_v42_evidence.py
@@ -275,7 +275,7 @@ async def test_attempts_are_append_only_and_exported_with_integrity(default_sess
assert detail.processing_artifacts == []
latest_attempt = await sources.read_latest_execution_attempt(job_source_id=latest_job_source.id)
assert latest_attempt is not None
- assert latest_attempt.attempt_number == 2
+ assert latest_attempt.attempt.attempt_number == 2
def test_benchmark_scoring_preserves_literal_differences():