From 6a3ee267330d2ff9d55a73698c801ab90dbdec5a Mon Sep 17 00:00:00 2001 From: zoltan57 <40281233+zoltan57@users.noreply.github.com> Date: Mon, 17 Aug 2026 17:44:39 -0500 Subject: [PATCH] V4.6 Phase 5: UI boundaries and duplication Fixes the three ui.instructions.md violations recorded as [HIGH-07] and extracts the page-level duplication catalogued in review section 4. Boundary violations - jobs_page no longer imports session_scope or manages a transaction. store.create_document_job and store.create_job_for_document accept an optional session_factory and open their own session scope when the caller supplies neither a session nor a factory. - sources_page no longer calls sqlalchemy.inspect. SourceService .read_latest_execution_attempt now returns a LatestExecutionAttempt read model carrying a plain transport_body_deferred flag, so ORM loader state stays inside the service. Rendered output is unchanged. - Deletes ui/components/document_panzoom.py, its export, and its CSS. The component was exported but used by no page. Pan-zoom is planned for a clean reintroduction in V4.7 alongside the other photo/image work. Extracted duplication - ui/components/media_urls.py: pure upload-URL resolution taking upload_dir and base_url, replacing two identical ~60-line copies in sources_page and people_page. - ui/components/guards.py: parse-then-render-terminal-message, replacing 28 hand-written guard labels across five pages. - ui/components/confirm_delete.py: the blocked-dependency notice and the delete/cancel action row, from four delete pages. - ui/components/upload_panel.py: the auto-uploading file picker, from three pages. Source accept lists now derive from services.source_media .SOURCE_EXTENSIONS instead of being hard-coded. - ui/components/table/registry.py: the two hand-rolled label-registry tables on the settings page now go through build_table, which gained selection and rows_per_page options. - ui/components/formatters.py gains parse_uuid and parse_iso_date, replacing five and two private copies. - ui/runtime.py owns resolve_runtime_settings, replacing three copies and removing get_settings from every page module. [LOW-05] - Upload handlers are annotated with events.UploadEventArguments. - The Document and Person form builders return DocumentFormFields and PersonFormFields dataclasses instead of dict[str, Any]. Verification - tests/test_ui_boundaries.py asserts no page imports a session scope, a session factory, get_settings, sqlalchemy, or sqlmodel, and that no component imports request or application state. - 275 passed, 4 skipped. ruff check clean. Findings: HIGH-07, LOW-05 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/transcription/services/sources.py | 24 +- src/transcription/services/store.py | 62 +++-- src/transcription/services/workflows.py | 2 +- src/transcription/ui/components/__init__.py | 2 - .../ui/components/confirm_delete.py | 56 ++++ .../ui/components/document_panzoom.py | 169 ------------- src/transcription/ui/components/formatters.py | 27 ++ src/transcription/ui/components/guards.py | 32 +++ .../ui/components/linked_people.py | 12 +- src/transcription/ui/components/media_urls.py | 74 ++++++ .../ui/components/table/common.py | 20 +- .../ui/components/table/registry.py | 49 ++++ .../ui/components/upload_panel.py | 50 ++++ src/transcription/ui/pages/documents_page.py | 161 ++++++------ src/transcription/ui/pages/home_page.py | 13 +- src/transcription/ui/pages/jobs_page.py | 105 ++++---- src/transcription/ui/pages/people_page.py | 239 +++++++----------- .../ui/pages/print_preview_page.py | 10 +- src/transcription/ui/pages/settings_page.py | 96 +------ src/transcription/ui/pages/sources_page.py | 159 +++--------- src/transcription/ui/runtime.py | 14 + src/transcription/ui/static/theme.css | 40 --- tests/test_ui_boundaries.py | 85 +++++++ tests/test_ui_theme.py | 1 - tests/test_v42_evidence.py | 2 +- 25 files changed, 729 insertions(+), 775 deletions(-) create mode 100644 src/transcription/ui/components/confirm_delete.py delete mode 100644 src/transcription/ui/components/document_panzoom.py create mode 100644 src/transcription/ui/components/guards.py create mode 100644 src/transcription/ui/components/media_urls.py create mode 100644 src/transcription/ui/components/table/registry.py create mode 100644 src/transcription/ui/components/upload_panel.py create mode 100644 src/transcription/ui/runtime.py create mode 100644 tests/test_ui_boundaries.py 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'{source.filename}" - ) - - _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():