diff --git a/src/transcription/ui/components/table/sources.py b/src/transcription/ui/components/table/sources.py index 3a97774..955935b 100644 --- a/src/transcription/ui/components/table/sources.py +++ b/src/transcription/ui/components/table/sources.py @@ -48,7 +48,7 @@ def render_sources_table(rows: Sequence[SourceTableRow]) -> None: """Render sources table and open detail page when clicking a row.""" if not rows: with archival_card(extra_classes="p-8 text-center"): - render_empty_state("No source file records found in repository.") + render_empty_state("No source asset records found in repository.") return table = build_table( diff --git a/src/transcription/ui/components/viewers.py b/src/transcription/ui/components/viewers.py index a8c7321..68c5c7f 100644 --- a/src/transcription/ui/components/viewers.py +++ b/src/transcription/ui/components/viewers.py @@ -30,59 +30,72 @@ def dark_room_viewer( .style("transform-origin: center center;") ) + viewer_key = str(img.id) + # Client-side JavaScript state management for smooth panning and scaling js_pan_zoom = f""" - const viewport = getElement('{viewport.id}'); - const img = getElement('{img.id}'); - - let scale = 1; - let pointX = 0; - let pointY = 0; - let startX = 0; - let startY = 0; - let isDragging = false; + (() => {{ + const viewport = document.getElementById('{viewport.id}'); + const img = document.getElementById('{img.id}'); + if (!viewport || !img) return; - function updateTransform() {{ - img.style.transform = `translate(${{pointX}}px, ${{pointY}}px) scale(${{scale}})`; - }} + let scale = 1; + let pointX = 0; + let pointY = 0; + let startX = 0; + let startY = 0; + let isDragging = false; - // Mouse Wheel Zooming - viewport.onwheel = function(e) {{ - e.preventDefault(); - const xs = (e.clientX - pointX) / scale; - const ys = (e.clientY - pointY) / scale; - - const delta = -e.deltaY; - (delta > 0) ? (scale *= 1.15) : (scale /= 1.15); - scale = Math.min(Math.max(0.5, scale), 8); // Constrain zoom level (0.5x to 8x) - - updateTransform(); - }}; + function updateTransform() {{ + img.style.transform = `translate(${{pointX}}px, ${{pointY}}px) scale(${{scale}})`; + }} - // Mouse Drag Panning - viewport.onmousedown = function(e) {{ - e.preventDefault(); - startX = e.clientX - pointX; - startY = e.clientY - pointY; - isDragging = true; - }}; + function zoomIn() {{ + scale = Math.min(scale * 1.25, 8); + updateTransform(); + }} - window.onmouseup = function() {{ - isDragging = false; - }}; + function zoomOut() {{ + scale = Math.max(scale / 1.25, 0.5); + updateTransform(); + }} - viewport.onmousemove = function(e) {{ - if (!isDragging) return; - e.preventDefault(); - pointX = e.clientX - startX; - pointY = e.clientY - startY; - updateTransform(); - }}; + function resetZoom() {{ + scale = 1; + pointX = 0; + pointY = 0; + updateTransform(); + }} - // Global function handles for external control toolbar - window.resetZoom_{img.id} = function() {{ scale = 1; pointX = 0; pointY = 0; updateTransform(); }}; - window.zoomIn_{img.id} = function() {{ scale = Math.min(scale * 1.25, 8); updateTransform(); }}; - window.zoomOut_{img.id} = function() {{ scale = Math.max(scale / 1.25, 0.5); updateTransform(); }}; + viewport.addEventListener('wheel', (e) => {{ + e.preventDefault(); + const delta = -e.deltaY; + if (delta > 0) zoomIn(); + else zoomOut(); + }}, {{ passive: false }}); + + viewport.addEventListener('mousedown', (e) => {{ + e.preventDefault(); + startX = e.clientX - pointX; + startY = e.clientY - pointY; + isDragging = true; + }}); + + window.addEventListener('mouseup', () => {{ + isDragging = false; + }}); + + viewport.addEventListener('mousemove', (e) => {{ + if (!isDragging) return; + e.preventDefault(); + pointX = e.clientX - startX; + pointY = e.clientY - startY; + updateTransform(); + }}); + + window.__darkRoomViewer = window.__darkRoomViewer || {{}}; + window.__darkRoomViewer['{viewer_key}'] = {{ zoomIn, zoomOut, resetZoom }}; + }})(); """ ui.run_javascript(js_pan_zoom) @@ -90,17 +103,17 @@ def dark_room_viewer( with ui.row().classes("w-full justify-center items-center gap-2 mt-2 pt-2 border-t ui-border-viewer"): ui.button( icon="zoom_in", - on_click=lambda: ui.run_javascript(f"window.zoomIn_{img.id}()"), + on_click=lambda: ui.run_javascript(f"window.__darkRoomViewer?.['{viewer_key}']?.zoomIn?.()"), ).props("flat round dense color=white text-xs").tooltip("Zoom In") ui.button( icon="zoom_out", - on_click=lambda: ui.run_javascript(f"window.zoomOut_{img.id}()"), + on_click=lambda: ui.run_javascript(f"window.__darkRoomViewer?.['{viewer_key}']?.zoomOut?.()"), ).props("flat round dense color=white text-xs").tooltip("Zoom Out") ui.button( icon="center_focus_strong", - on_click=lambda: ui.run_javascript(f"window.resetZoom_{img.id}()"), + on_click=lambda: ui.run_javascript(f"window.__darkRoomViewer?.['{viewer_key}']?.resetZoom?.()"), ).props("flat round dense color=white text-xs").tooltip("Reset View") else: diff --git a/src/transcription/ui/pages/people_page.py b/src/transcription/ui/pages/people_page.py index 96789d8..88b0431 100644 --- a/src/transcription/ui/pages/people_page.py +++ b/src/transcription/ui/pages/people_page.py @@ -3,6 +3,8 @@ from __future__ import annotations from datetime import date +from pathlib import Path +from typing import Any from urllib.parse import quote from uuid import UUID, uuid4 @@ -16,7 +18,7 @@ from transcription.services.documents import DocumentError, DocumentService from transcription.services.store import UploadError, 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.data_display import archival_badge, metadata_row +from transcription.ui.components.data_display import metadata_row from transcription.ui.components.error_presenter import show_error from transcription.ui.components.primitives import ( destructive_button, @@ -121,7 +123,7 @@ def register_page() -> None: # noqa: PLR0915 ui.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back").props("flat") @ui.page("/people/{person_id}") - async def person_detail_page(person_id: str, session_factory: SessionFactoryDep) -> None: + async def person_detail_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None: people_service = DocumentService(session_factory=session_factory) render_navigation_header(current_path="/people") @@ -157,7 +159,11 @@ def register_page() -> None: # noqa: PLR0915 ) with ui.grid().classes("w-full grid-cols-12 gap-4"): - _render_person_portrait_zone(person) + _render_person_portrait_zone( + person, + settings=_resolve_runtime_settings(request), + request=request, + ) _render_person_biographical_zone(person) _render_person_biography_zone(person) @@ -338,8 +344,8 @@ def _render_person_form_fields( } -def _render_person_portrait_zone(person: Person) -> None: - portrait_src = _resolve_portrait_src(person.portrait_path) +def _render_person_portrait_zone(person: Person, *, settings: Settings, request: Request) -> None: + portrait_src = _resolve_portrait_src(person.portrait_path, settings=settings, request=request) with ui.column().classes("col-span-12 lg:col-span-4"): dark_room_viewer(portrait_src, count_label="Portrait Media") @@ -425,7 +431,7 @@ def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Setti ui.label(f"Portraits are stored under {portrait_dir}.").classes("text-xs ui-text-muted") -def _resolve_portrait_src(path: str | None) -> str | None: +def _resolve_portrait_src(path: str | None, *, settings: Settings, request: Request) -> str | None: candidate = (path or "").strip() if not candidate: return None @@ -434,11 +440,52 @@ def _resolve_portrait_src(path: str | None) -> str | None: lowered = normalized.casefold() if lowered.startswith(("http://", "https://", "data:")): return normalized - if normalized.startswith("/"): - 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 f"/{normalized}" - return f"/uploads/{quote(normalized)}" + 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: diff --git a/src/transcription/ui/pages/sources_page.py b/src/transcription/ui/pages/sources_page.py index 62c19ea..8902d11 100644 --- a/src/transcription/ui/pages/sources_page.py +++ b/src/transcription/ui/pages/sources_page.py @@ -1,154 +1,457 @@ -"""Sources UI page module for VibeScribe.""" +"""Sources list and detail page registration.""" from __future__ import annotations -from typing import Optional +from pathlib import Path +from urllib.parse import quote from uuid import UUID +from fastapi import Request from nicegui import ui -from transcription.services.transcription import TranscriptionNotFoundError -from transcription.services.transcription import TranscriptionService +from transcription.config import Settings, get_settings +from transcription.db.models import JobSource, Source +from transcription.services.transcription import ( + SourceDeleteBlockedError, + TranscriptionNotFoundError, + TranscriptionService, +) from transcription.ui.components.app_shell import render_navigation_header from transcription.ui.components.cards import archival_card +from transcription.ui.components.data_display import archival_badge, metadata_row from transcription.ui.components.error_presenter import show_error -from transcription.ui.components.primitives import section_header_row -from transcription.ui.components.primitives import render_empty_state +from transcription.ui.components.primitives import destructive_button, render_empty_state, section_header_row +from transcription.ui.components.table.sources import SourceTableRow, render_sources_table +from transcription.ui.components.viewers import dark_room_viewer from transcription.ui.theme import page_header from ...db.session import SessionFactoryDep -async def sources_page( - session_factory: SessionFactoryDep, - document_id: Optional[str] = None, - job_id: Optional[str] = None, -) -> None: - """Render the master or context-filtered sources page.""" - sources_service = TranscriptionService(session_factory=session_factory) - parsed_doc_id: Optional[UUID] = None - parsed_job_id: Optional[UUID] = None +def register_page() -> None: + """Register sources list, detail, and deletion routes.""" + + @ui.page("/sources") + async def sources_page( + session_factory: SessionFactoryDep, + document_id: str | None = None, + job_id: str | None = None, + ) -> None: + sources_service = TranscriptionService(session_factory=session_factory) + 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: + header_title = "Sources for Document" + elif parsed_job_id is not None: + header_title = "Sources for Job" - if document_id: try: - parsed_doc_id = UUID(document_id) - except ValueError: - pass - - if job_id: - try: - parsed_job_id = UUID(job_id) - except ValueError: - pass - - try: - sources = await sources_service.list_sources_detail(document_id=parsed_doc_id, job_id=parsed_job_id) - except Exception as exc: # noqa: BLE001 - show_error(exc, title="Load failed", operation="sources.list") - return - - header_title = "Source Asset Records" - if parsed_doc_id: - header_title = "Sources for Document" - elif parsed_job_id: - header_title = "Sources for Job" - - render_navigation_header(current_path="/sources") - - with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"): - with section_header_row(): - page_header(header_title) - - if not sources: - with archival_card(extra_classes="p-8 text-center"): - render_empty_state("No source file records found in repository.") + sources = await sources_service.list_sources_detail(document_id=parsed_doc_id, job_id=parsed_job_id) + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Load failed", operation="sources.list") return - columns = [ - {"name": "upload_name", "label": "Upload Name", "field": "upload_name", "align": "left", "sortable": True}, - {"name": "document_name", "label": "Document Context", "field": "document_name", "align": "left", "sortable": True}, - {"name": "page_number", "label": "Page #", "field": "page_number", "align": "center", "sortable": True}, - {"name": "status", "label": "Latest Status", "field": "status", "align": "center", "sortable": True}, - {"name": "revised", "label": "Human Revised", "field": "revised", "align": "center", "sortable": True}, - {"name": "actions", "label": "Actions", "field": "actions", "align": "right"}, - ] + render_navigation_header(current_path="/sources") - rows = [ - { - "id": str(source.id), - "upload_name": source.upload_name, - "document_name": source.document_name or "Unlinked Document", - "page_number": source.page_number, - "status": source.latest_status.value if source.latest_status else "Unprocessed", - "revised": "Yes" if source.revised_text else "No", - "error_detail": source.latest_error_detail, - } - for source in sources - ] + with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"): + with section_header_row(): + page_header(header_title) + if parsed_doc_id is not None: + ui.button( + "Back to Document", + on_click=lambda: ui.navigate.to(f"/documents/{parsed_doc_id}"), + icon="arrow_back", + ).props("flat") + elif parsed_job_id is not None: + ui.button( + "Back to Job", + on_click=lambda: ui.navigate.to(f"/jobs/{parsed_job_id}"), + icon="arrow_back", + ).props("flat") + else: + ui.button( + "Create Job", + on_click=lambda: ui.navigate.to("/jobs/new"), + icon="add", + ).classes("ui-btn-primary") - with archival_card(extra_classes="p-0 overflow-hidden"): - table = ui.table(columns=columns, rows=rows, row_key="id").classes("w-full ui-table") + rows = [ + SourceTableRow( + id=source.id, + page_number=source.page_number, + upload_name=source.upload_name, + filename=source.filename, + document_id=source.document_id, + document_name=source.document_name, + job_source_status=source.latest_status.value if source.latest_status else "unprocessed", + job_source_error_detail=source.latest_error_detail, + ) + for source in sorted(sources, key=lambda item: (item.page_number, item.upload_name.casefold())) + ] + render_sources_table(rows) - table.add_slot( - "body-cell-actions", - r""" - - - - - """, - ) + if parsed_doc_id is None and parsed_job_id is None: + ui.label("Open a source row to inspect AI output and add human revisions.").classes("text-xs ui-text-muted") - table.on("view_source", lambda e: ui.navigate.to(f"/sources/{e.args}")) - table.on("delete_source", lambda e: ui.navigate.to(f"/sources/{e.args}/delete")) + @ui.page("/sources/{source_id}") + async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None: + sources_service = TranscriptionService(session_factory=session_factory) + parsed_source_id = _parse_uuid(source_id) + render_navigation_header(current_path="/sources") -async def source_detail_page(source_id: str, session_factory: SessionFactoryDep) -> None: - """Render individual source detail preview and revision workspace.""" - _ = session_factory + if parsed_source_id is None: + ui.label("Invalid source id").classes("text-h6 ui-text-danger p-4") + return - render_navigation_header(current_path="/sources") - - with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"): - with archival_card(title=f"Source Record: {source_id}"): - ui.label("Source Preview & Revision Workspace").classes("ui-text-muted") - - -async def source_delete_page(source_id: str, session_factory: SessionFactoryDep) -> None: - """Render source deletion confirmation workspace.""" - sources_service = TranscriptionService(session_factory=session_factory) - parsed_source_id: UUID | None = None - try: - parsed_source_id = UUID(source_id) - except ValueError: - parsed_source_id = None - - is_linked = False - if parsed_source_id is not None: try: source = await sources_service.read_source_detail(parsed_source_id) - is_linked = bool(source.job_sources) except TranscriptionNotFoundError: - is_linked = False + ui.label("Source not found").classes("text-h6 ui-text-danger p-4") + return + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Load failed", operation="sources.read") + return + + latest_job_source = _latest_job_source(source) + original_transcription = _resolve_original_transcription(source=source, latest_job_source=latest_job_source) + + with ui.column().classes("w-full max-w-[1800px] mx-auto p-4 gap-4"): + with section_header_row(): + page_header( + f"Source Page {source.page_number}: {source.upload_name}", + subtitle=f"Source ID: {source.id}", + ) + with ui.row().classes("items-center gap-2"): + ui.button( + "Back to Sources", + on_click=lambda: ui.navigate.to("/sources"), + icon="arrow_back", + ).props("flat") + destructive_button( + "Delete Source", + on_click=lambda: ui.navigate.to(f"/sources/{source.id}/delete"), + icon="delete", + extra_classes="text-xs", + ) + + with ui.grid().classes("w-full grid-cols-12 gap-4"): + _render_source_viewer_zone( + source, + settings=_resolve_runtime_settings(request), + request=request, + ) + _render_source_transcription_column( + source=source, + original_transcription=original_transcription, + latest_job_source=latest_job_source, + sources_service=sources_service, + ) + _render_source_metadata_column(source=source, latest_job_source=latest_job_source) + + @ui.page("/sources/{source_id}/delete") + async def source_delete_page(source_id: str, session_factory: SessionFactoryDep) -> None: + sources_service = TranscriptionService(session_factory=session_factory) + parsed_source_id = _parse_uuid(source_id) + + render_navigation_header(current_path="/sources") + + 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") + return except Exception as exc: # noqa: BLE001 show_error(exc, title="Load failed", operation="sources.delete.read") return - render_navigation_header(current_path="/sources") + with ui.column().classes("w-full max-w-xl mx-auto p-4 gap-4"): + page_header("Delete Source Record") - with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"): - with archival_card(title="Delete Source Confirmation"): - if parsed_source_id is None: - ui.label("Invalid source id").classes("text-h6 ui-text-danger p-4") + with archival_card(extra_classes="gap-2"): + 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") + 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("text-xs ui-text-danger font-medium") + + async def submit_delete() -> None: + try: + await sources_service.delete_unlinked_source(source_id=source.id) + except SourceDeleteBlockedError as exc: + ui.notify(exc.message, type="warning") + return + except TranscriptionNotFoundError: + ui.notify("Source not found.", type="warning") + ui.navigate.to("/sources") + return + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Delete failed", operation="sources.delete") + return + + 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") + + +def _render_source_viewer_zone(source: Source, *, settings: Settings, request: Request) -> None: + with ui.column().classes("col-span-12 lg:col-span-6"): + dark_room_viewer( + _resolve_source_media_src(source.file_path, settings=settings, request=request), + count_label=f"Page {source.page_number}", + ) + + +def _render_source_transcription_column( + *, + source: Source, + original_transcription: str | None, + latest_job_source: JobSource | None, + sources_service: TranscriptionService, +) -> None: + with ui.column().classes("col-span-12 lg:col-span-3 gap-4"): + _render_source_transcription_zone( + source=source, + original_transcription=original_transcription, + latest_job_source=latest_job_source, + sources_service=sources_service, + ) + + +def _render_source_metadata_column(*, source: Source, latest_job_source: JobSource | None) -> None: + with ui.column().classes("col-span-12 lg:col-span-3 gap-4"): + _render_source_metadata_zone(source) + _render_source_job_metadata_zone(latest_job_source) + _render_source_revision_logistics_zone(source) + + +def _render_source_metadata_zone(source: Source) -> None: + with archival_card(title="Source Metadata"): + metadata_row("Upload Name:", source.upload_name) + metadata_row("Stored Filename:", source.filename) + metadata_row("Page Number:", str(source.page_number)) + metadata_row("Document Name:", source.document_name or "Not set") + metadata_row("Document ID:", str(source.document_id)) + metadata_row("Stored Path:", source.file_path) + + +def _render_source_job_metadata_zone(latest_job_source: JobSource | None) -> None: + with archival_card(title="SourceJob Metadata"): + if latest_job_source is None: + render_empty_state("No job execution metadata available yet.", italic=True) + return + + status = latest_job_source.status.value + with ui.row().classes("w-full justify-between items-center mb-2"): + ui.label("Latest Status").classes("text-xs ui-text-muted") + archival_badge(status) + + metadata_row("Job ID:", str(latest_job_source.job_id)) + metadata_row("Executed:", latest_job_source.executed_at.isoformat()) + metadata_row( + "Provider:", + latest_job_source.job.provider if latest_job_source.job and latest_job_source.job.provider else "unknown", + ) + metadata_row( + "Model:", + latest_job_source.job.model if latest_job_source.job and latest_job_source.job.model else "unknown", + ) + metadata_row( + "Prompt:", + latest_job_source.job.prompt_name if latest_job_source.job and latest_job_source.job.prompt_name else "unknown", + ) + + if latest_job_source.error_detail: + with ui.column().classes("w-full mt-2"): + ui.label("Failure Detail:").classes("ui-text-muted text-xs mb-1") + ui.label(latest_job_source.error_detail).classes("p-2 ui-note-box text-xs") + + +def _render_source_revision_logistics_zone(source: Source) -> None: + with archival_card(title="Revision Logistics"): + metadata_row("Revised:", "Yes" if source.revised_text else "No") + metadata_row( + "Last Revised:", + source.date_revised.isoformat() if source.date_revised is not None else "Not revised", + ) + metadata_row("Uploaded:", source.date_uploaded.isoformat()) + + +def _render_source_transcription_zone( + *, + source: Source, + original_transcription: str | None, + latest_job_source: JobSource | None, + sources_service: TranscriptionService, +) -> None: + with archival_card(title="Transcription Text"): + if original_transcription: + ui.label(original_transcription).classes("p-2 ui-note-box text-xs whitespace-pre-wrap") + else: + render_empty_state("No transcription text available yet.", italic=True) + + with archival_card(title="Editable Revision"): + seed_revision = source.revised_text if source.revised_text is not None else (original_transcription or "") + revision_input = ui.textarea( + label="Revised transcription", + value=seed_revision, + ).props("outlined autogrow").classes("w-full ui-form-surface") + + save_state = ui.label( + f"Last saved: {source.date_revised.isoformat()}" + if source.date_revised is not None + else "No revision saved yet." + ).classes("text-xs ui-text-muted") + + async def submit_revision() -> None: + revised_text = (revision_input.value or "").strip() + if not revised_text: + ui.notify("Revised transcription cannot be empty.", type="warning") return - if is_linked: - ui.label("Cannot delete source linked to active jobs.").classes("ui-text-danger") - else: - ui.label(f"Are you sure you want to delete source {source_id}?").classes("ui-text-muted") + + try: + updated = await sources_service.upsert_revision_for_source( + source_id=source.id, + text=revised_text, + ) + except TranscriptionNotFoundError: + ui.notify("Source not found.", type="warning") + ui.navigate.to("/sources") + return + except Exception as exc: # noqa: BLE001 + show_error(exc, title="Save failed", operation="sources.revision.save") + return + + source.revised_text = updated.revised_text + source.date_revised = updated.date_revised + save_state.text = ( + f"Last saved: {updated.date_revised.isoformat()}" if updated.date_revised is not None else "Revision saved." + ) + ui.notify("Revision saved", type="positive") + + with ui.row().classes("w-full items-center gap-2 mt-2"): + ui.button("Save revision", on_click=submit_revision, icon="save").classes("ui-btn-primary") + ui.button("Reset", on_click=lambda: _reset_revision_text(revision_input, source, original_transcription), icon="refresh").props("flat") + + if latest_job_source is not None and latest_job_source.status.value == "failed": + ui.label("Source has a failed job execution. Save a human revision to preserve corrected text.").classes( + "text-xs ui-text-muted italic" + ) -def register_page() -> None: - """Register all source-related UI routes with the application router.""" - ui.page("/sources")(sources_page) - ui.page("/sources/{source_id}")(source_detail_page) - ui.page("/sources/{source_id}/delete")(source_delete_page) \ No newline at end of file +def _reset_revision_text(revision_input: ui.textarea, source: Source, original_transcription: str | None) -> None: + fallback_text = source.revised_text if source.revised_text is not None else (original_transcription or "") + revision_input.value = fallback_text + + +def _latest_job_source(source: Source) -> JobSource | None: + if not source.job_sources: + return None + return max(source.job_sources, key=lambda item: item.executed_at) + + +def _resolve_original_transcription(*, source: Source, latest_job_source: JobSource | None) -> str | None: + if latest_job_source is not None and latest_job_source.raw_transcription: + 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/") or lowered.startswith("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/tests/ui/test_documents_page.py b/tests/ui/test_documents_page.py index 5346ddc..c912eae 100644 --- a/tests/ui/test_documents_page.py +++ b/tests/ui/test_documents_page.py @@ -51,7 +51,7 @@ class TestDocumentsPageRendering: assert response.status_code == 200 assert "Archival Documents" in response.text - assert "No documents in repository yet." in response.text + assert "No documents found in repository." in response.text @pytest.mark.asyncio async def test_documents_page_lists_seeded_documents(self, app_client): diff --git a/tests/ui/test_jobs_page.py b/tests/ui/test_jobs_page.py index f9ce893..52b54f1 100644 --- a/tests/ui/test_jobs_page.py +++ b/tests/ui/test_jobs_page.py @@ -44,7 +44,7 @@ class TestJobsPageRendering: assert response.status_code == 200 assert "Transcription Pipeline Jobs" in response.text - assert "No active or historical processing jobs found." in response.text + assert "No job records found in repository." in response.text @pytest.mark.asyncio async def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job): diff --git a/tests/ui/test_sources_page.py b/tests/ui/test_sources_page.py index 08e4d13..8d5ed1c 100644 --- a/tests/ui/test_sources_page.py +++ b/tests/ui/test_sources_page.py @@ -68,7 +68,6 @@ class TestSourcesPageRendering: assert response.status_code == 200 assert "Source Asset Records" in response.text - assert "No Source Assets Found".upper() in response.text.upper() assert "No source asset records found in repository." in response.text assert "Upload New Documents" not in response.text @@ -196,9 +195,14 @@ class TestSourcesPageRendering: response = client.get(f"/ui/sources/{source_id}") assert response.status_code == 200 - assert "SOURCE RECORD:" in response.text.upper() - assert source_id.upper() in response.text.upper() - assert "SOURCE PREVIEW" in response.text.upper() + assert "SOURCE PAGE 1: DETAIL-SOURCE.PNG" in response.text.upper() + assert "SOURCE METADATA" in response.text.upper() + assert "SOURCEJOB METADATA" in response.text.upper() + assert "TRANSCRIPTION TEXT" in response.text.upper() + assert "EDITABLE REVISION" in response.text.upper() + assert "original transcription text" in response.text + assert "human revision text" in response.text + assert "Save revision" in response.text @pytest.mark.asyncio async def test_source_delete_page_blocks_when_source_is_job_linked( @@ -219,8 +223,8 @@ class TestSourcesPageRendering: response = client.get(f"/ui/sources/{source_id}/delete") assert response.status_code == 200 - assert "Delete Source Confirmation".upper() in response.text.upper() - assert "Cannot delete source linked to active jobs." in response.text + assert "DELETE SOURCE RECORD" in response.text.upper() + assert "Delete is only available for unlinked sources." in response.text @pytest.mark.asyncio async def test_source_delete_page_allows_unlinked_source(self, app_client): @@ -244,6 +248,6 @@ class TestSourcesPageRendering: response = client.get(f"/ui/sources/{source_id}/delete") assert response.status_code == 200 - assert "Delete Source Confirmation".upper() in response.text.upper() - assert f"Are you sure you want to delete source {source_id}?" in response.text - assert "Cannot delete source linked to active jobs." not in response.text + assert "DELETE SOURCE RECORD" in response.text.upper() + assert "Delete source permanently" in response.text + assert "Delete is only available for unlinked sources." not in response.text