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 <[email protected]>
This commit is contained in:
zoltan57
2026-08-17 17:44:39 -05:00
co-authored by Copilot App
parent 97b3d0fd62
commit 6a3ee26733
25 changed files with 729 additions and 775 deletions
+21 -3
View File
@@ -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,
+42 -20
View File
@@ -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)
+1 -1
View File
@@ -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,
@@ -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",
@@ -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)}"
@@ -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/[email protected]/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'<iframe class="document-panzoom-iframe" '
f'src="{document_url}" title="{source.filename}" '
"data-panzoom-target></iframe>"
)
else:
ui.html(
f'<img class="document-panzoom-media" '
f'src="{document_url}" alt="{source.filename}" '
"data-panzoom-target data-panzoom-media />"
)
_attach_panzoom(host_id)
@lru_cache(maxsize=1)
def _register_panzoom_assets() -> None:
ui.add_head_html(
f'<script src="{PANGOZOOM_CDN_URL}"></script>',
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();
}})();
"""
)
@@ -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:
+32
View File
@@ -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")
@@ -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
@@ -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)
@@ -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%;" '
@@ -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 = """
<q-td :props="props" class="text-center">
<q-checkbox :model-value="props.value" disable dense />
</q-td>
"""
_BUILT_IN_CELL = """
<q-td :props="props" class="text-center">
{{ props.value ? 'Yes' : 'No' }}
</q-td>
"""
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
@@ -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")
)
+72 -89
View File
@@ -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]]:
+9 -4
View File
@@ -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")
+43 -62
View File
@@ -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
+89 -150
View File
@@ -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
@@ -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")
+9 -87
View File
@@ -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",
"""
<q-td :props="props" class="text-center">
<q-checkbox :model-value="props.value" disable dense />
</q-td>
""",
)
table.add_slot(
"body-cell-is_built_in",
"""
<q-td :props="props" class="text-center">
{{ props.value ? 'Yes' : 'No' }}
</q-td>
""",
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",
"""
<q-td :props="props" class="text-center">
<q-checkbox :model-value="props.value" disable dense />
</q-td>
""",
)
table.add_slot(
"body-cell-is_built_in",
"""
<q-td :props="props" class="text-center">
{{ props.value ? 'Yes' : 'No' }}
</q-td>
""",
table = render_registry_table(
rows,
count_field="link_count",
count_label="Links",
)
async def save_role(
+34 -125
View File
@@ -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
+14
View File
@@ -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()
-40
View File
@@ -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;