Fixed Source Detail page

This commit is contained in:
Jim Lancaster
2026-08-08 08:21:35 -05:00
parent 21a7e83563
commit 4b5e7ac23e
7 changed files with 554 additions and 187 deletions
@@ -48,7 +48,7 @@ def render_sources_table(rows: Sequence[SourceTableRow]) -> None:
"""Render sources table and open detail page when clicking a row.""" """Render sources table and open detail page when clicking a row."""
if not rows: if not rows:
with archival_card(extra_classes="p-8 text-center"): 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 return
table = build_table( table = build_table(
+40 -27
View File
@@ -30,10 +30,14 @@ def dark_room_viewer(
.style("transform-origin: center center;") .style("transform-origin: center center;")
) )
viewer_key = str(img.id)
# Client-side JavaScript state management for smooth panning and scaling # Client-side JavaScript state management for smooth panning and scaling
js_pan_zoom = f""" js_pan_zoom = f"""
const viewport = getElement('{viewport.id}'); (() => {{
const img = getElement('{img.id}'); const viewport = document.getElementById('{viewport.id}');
const img = document.getElementById('{img.id}');
if (!viewport || !img) return;
let scale = 1; let scale = 1;
let pointX = 0; let pointX = 0;
@@ -46,43 +50,52 @@ def dark_room_viewer(
img.style.transform = `translate(${{pointX}}px, ${{pointY}}px) scale(${{scale}})`; img.style.transform = `translate(${{pointX}}px, ${{pointY}}px) scale(${{scale}})`;
}} }}
// Mouse Wheel Zooming function zoomIn() {{
viewport.onwheel = function(e) {{ scale = Math.min(scale * 1.25, 8);
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(); updateTransform();
}}; }}
// Mouse Drag Panning function zoomOut() {{
viewport.onmousedown = function(e) {{ scale = Math.max(scale / 1.25, 0.5);
updateTransform();
}}
function resetZoom() {{
scale = 1;
pointX = 0;
pointY = 0;
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(); e.preventDefault();
startX = e.clientX - pointX; startX = e.clientX - pointX;
startY = e.clientY - pointY; startY = e.clientY - pointY;
isDragging = true; isDragging = true;
}}; }});
window.onmouseup = function() {{ window.addEventListener('mouseup', () => {{
isDragging = false; isDragging = false;
}}; }});
viewport.onmousemove = function(e) {{ viewport.addEventListener('mousemove', (e) => {{
if (!isDragging) return; if (!isDragging) return;
e.preventDefault(); e.preventDefault();
pointX = e.clientX - startX; pointX = e.clientX - startX;
pointY = e.clientY - startY; pointY = e.clientY - startY;
updateTransform(); updateTransform();
}}; }});
// Global function handles for external control toolbar window.__darkRoomViewer = window.__darkRoomViewer || {{}};
window.resetZoom_{img.id} = function() {{ scale = 1; pointX = 0; pointY = 0; updateTransform(); }}; window.__darkRoomViewer['{viewer_key}'] = {{ zoomIn, zoomOut, resetZoom }};
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(); }};
""" """
ui.run_javascript(js_pan_zoom) 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"): with ui.row().classes("w-full justify-center items-center gap-2 mt-2 pt-2 border-t ui-border-viewer"):
ui.button( ui.button(
icon="zoom_in", 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") ).props("flat round dense color=white text-xs").tooltip("Zoom In")
ui.button( ui.button(
icon="zoom_out", 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") ).props("flat round dense color=white text-xs").tooltip("Zoom Out")
ui.button( ui.button(
icon="center_focus_strong", 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") ).props("flat round dense color=white text-xs").tooltip("Reset View")
else: else:
+57 -10
View File
@@ -3,6 +3,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import date from datetime import date
from pathlib import Path
from typing import Any
from urllib.parse import quote from urllib.parse import quote
from uuid import UUID, uuid4 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.services.store import UploadError, store_person_portrait
from transcription.ui.components.app_shell import render_navigation_header from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card 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.error_presenter import show_error
from transcription.ui.components.primitives import ( from transcription.ui.components.primitives import (
destructive_button, 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.button("Cancel", on_click=lambda: ui.navigate.to("/people"), icon="arrow_back").props("flat")
@ui.page("/people/{person_id}") @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) people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people") 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"): 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_biographical_zone(person)
_render_person_biography_zone(person) _render_person_biography_zone(person)
@@ -338,8 +344,8 @@ def _render_person_form_fields(
} }
def _render_person_portrait_zone(person: Person) -> None: def _render_person_portrait_zone(person: Person, *, settings: Settings, request: Request) -> None:
portrait_src = _resolve_portrait_src(person.portrait_path) portrait_src = _resolve_portrait_src(person.portrait_path, settings=settings, request=request)
with ui.column().classes("col-span-12 lg:col-span-4"): with ui.column().classes("col-span-12 lg:col-span-4"):
dark_room_viewer(portrait_src, count_label="Portrait Media") 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") 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() candidate = (path or "").strip()
if not candidate: if not candidate:
return None return None
@@ -434,11 +440,52 @@ def _resolve_portrait_src(path: str | None) -> str | None:
lowered = normalized.casefold() lowered = normalized.casefold()
if lowered.startswith(("http://", "https://", "data:")): if lowered.startswith(("http://", "https://", "data:")):
return normalized return normalized
if normalized.startswith("/"): if normalized.startswith("/uploads/"):
return normalized 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/"): if lowered.startswith("uploads/"):
return f"/{normalized}" return _to_absolute_upload_url(f"/{normalized}", request=request)
return f"/uploads/{quote(normalized)}" 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: def _resolve_runtime_settings(request: Request) -> Settings:
+405 -102
View File
@@ -1,45 +1,51 @@
"""Sources UI page module for VibeScribe.""" """Sources list and detail page registration."""
from __future__ import annotations from __future__ import annotations
from typing import Optional from pathlib import Path
from urllib.parse import quote
from uuid import UUID from uuid import UUID
from fastapi import Request
from nicegui import ui from nicegui import ui
from transcription.services.transcription import TranscriptionNotFoundError from transcription.config import Settings, get_settings
from transcription.services.transcription import TranscriptionService 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.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card 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.error_presenter import show_error
from transcription.ui.components.primitives import section_header_row from transcription.ui.components.primitives import destructive_button, render_empty_state, section_header_row
from transcription.ui.components.primitives import render_empty_state 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 transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep from ...db.session import SessionFactoryDep
def register_page() -> None:
"""Register sources list, detail, and deletion routes."""
@ui.page("/sources")
async def sources_page( async def sources_page(
session_factory: SessionFactoryDep, session_factory: SessionFactoryDep,
document_id: Optional[str] = None, document_id: str | None = None,
job_id: Optional[str] = None, job_id: str | None = None,
) -> None: ) -> None:
"""Render the master or context-filtered sources page."""
sources_service = TranscriptionService(session_factory=session_factory) sources_service = TranscriptionService(session_factory=session_factory)
parsed_doc_id: Optional[UUID] = None parsed_doc_id = _parse_uuid(document_id)
parsed_job_id: Optional[UUID] = None parsed_job_id = _parse_uuid(job_id)
if document_id: header_title = "Source Asset Records"
try: if parsed_doc_id is not None:
parsed_doc_id = UUID(document_id) header_title = "Sources for Document"
except ValueError: elif parsed_job_id is not None:
pass header_title = "Sources for Job"
if job_id:
try:
parsed_job_id = UUID(job_id)
except ValueError:
pass
try: try:
sources = await sources_service.list_sources_detail(document_id=parsed_doc_id, job_id=parsed_job_id) sources = await sources_service.list_sources_detail(document_id=parsed_doc_id, job_id=parsed_job_id)
@@ -47,108 +53,405 @@ async def sources_page(
show_error(exc, title="Load failed", operation="sources.list") show_error(exc, title="Load failed", operation="sources.list")
return 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") render_navigation_header(current_path="/sources")
with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"): with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with section_header_row(): with section_header_row():
page_header(header_title) page_header(header_title)
if parsed_doc_id is not None:
if not sources: ui.button(
with archival_card(extra_classes="p-8 text-center"): "Back to Document",
render_empty_state("No source file records found in repository.") on_click=lambda: ui.navigate.to(f"/documents/{parsed_doc_id}"),
return icon="arrow_back",
).props("flat")
columns = [ elif parsed_job_id is not None:
{"name": "upload_name", "label": "Upload Name", "field": "upload_name", "align": "left", "sortable": True}, ui.button(
{"name": "document_name", "label": "Document Context", "field": "document_name", "align": "left", "sortable": True}, "Back to Job",
{"name": "page_number", "label": "Page #", "field": "page_number", "align": "center", "sortable": True}, on_click=lambda: ui.navigate.to(f"/jobs/{parsed_job_id}"),
{"name": "status", "label": "Latest Status", "field": "status", "align": "center", "sortable": True}, icon="arrow_back",
{"name": "revised", "label": "Human Revised", "field": "revised", "align": "center", "sortable": True}, ).props("flat")
{"name": "actions", "label": "Actions", "field": "actions", "align": "right"}, else:
] ui.button(
"Create Job",
on_click=lambda: ui.navigate.to("/jobs/new"),
icon="add",
).classes("ui-btn-primary")
rows = [ rows = [
{ SourceTableRow(
"id": str(source.id), id=source.id,
"upload_name": source.upload_name, page_number=source.page_number,
"document_name": source.document_name or "Unlinked Document", upload_name=source.upload_name,
"page_number": source.page_number, filename=source.filename,
"status": source.latest_status.value if source.latest_status else "Unprocessed", document_id=source.document_id,
"revised": "Yes" if source.revised_text else "No", document_name=source.document_name,
"error_detail": source.latest_error_detail, job_source_status=source.latest_status.value if source.latest_status else "unprocessed",
} job_source_error_detail=source.latest_error_detail,
for source in sources
]
with archival_card(extra_classes="p-0 overflow-hidden"):
table = ui.table(columns=columns, rows=rows, row_key="id").classes("w-full ui-table")
table.add_slot(
"body-cell-actions",
r"""
<q-td :props="props">
<q-btn flat round dense icon="visibility" color="primary" @click="() => $parent.$emit('view_source', props.row.id)" />
<q-btn flat round dense icon="delete" color="negative" @click="() => $parent.$emit('delete_source', props.row.id)" />
</q-td>
""",
) )
for source in sorted(sources, key=lambda item: (item.page_number, item.upload_name.casefold()))
]
render_sources_table(rows)
table.on("view_source", lambda e: ui.navigate.to(f"/sources/{e.args}")) if parsed_doc_id is None and parsed_job_id is None:
table.on("delete_source", lambda e: ui.navigate.to(f"/sources/{e.args}/delete")) ui.label("Open a source row to inspect AI output and add human revisions.").classes("text-xs ui-text-muted")
@ui.page("/sources/{source_id}")
async def source_detail_page(source_id: str, session_factory: SessionFactoryDep) -> None: async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
"""Render individual source detail preview and revision workspace.""" sources_service = TranscriptionService(session_factory=session_factory)
_ = session_factory parsed_source_id = _parse_uuid(source_id)
render_navigation_header(current_path="/sources") render_navigation_header(current_path="/sources")
with ui.column().classes("w-full max-w-7xl mx-auto p-6 gap-6"): if parsed_source_id is None:
with archival_card(title=f"Source Record: {source_id}"): ui.label("Invalid source id").classes("text-h6 ui-text-danger p-4")
ui.label("Source Preview & Revision Workspace").classes("ui-text-muted") return
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: try:
source = await sources_service.read_source_detail(parsed_source_id) source = await sources_service.read_source_detail(parsed_source_id)
is_linked = bool(source.job_sources)
except TranscriptionNotFoundError: 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 except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="sources.delete.read") show_error(exc, title="Load failed", operation="sources.delete.read")
return 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(extra_classes="gap-2"):
with archival_card(title="Delete Source Confirmation"): ui.label(f"Source: {source.upload_name}").classes("text-sm font-semibold ui-text-primary")
if parsed_source_id is None:
ui.label("Invalid source id").classes("text-h6 ui-text-danger p-4") 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 return
if is_linked:
ui.label("Cannot delete source linked to active jobs.").classes("ui-text-danger") 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: else:
ui.label(f"Are you sure you want to delete source {source_id}?").classes("ui-text-muted") 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
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: def _reset_revision_text(revision_input: ui.textarea, source: Source, original_transcription: str | None) -> None:
"""Register all source-related UI routes with the application router.""" fallback_text = source.revised_text if source.revised_text is not None else (original_transcription or "")
ui.page("/sources")(sources_page) revision_input.value = fallback_text
ui.page("/sources/{source_id}")(source_detail_page)
ui.page("/sources/{source_id}/delete")(source_delete_page)
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
+1 -1
View File
@@ -51,7 +51,7 @@ class TestDocumentsPageRendering:
assert response.status_code == 200 assert response.status_code == 200
assert "Archival Documents" in response.text 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 @pytest.mark.asyncio
async def test_documents_page_lists_seeded_documents(self, app_client): async def test_documents_page_lists_seeded_documents(self, app_client):
+1 -1
View File
@@ -44,7 +44,7 @@ class TestJobsPageRendering:
assert response.status_code == 200 assert response.status_code == 200
assert "Transcription Pipeline Jobs" in response.text 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 @pytest.mark.asyncio
async def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job): async def test_jobs_page_lists_seeded_jobs(self, app_client, seed_job):
+13 -9
View File
@@ -68,7 +68,6 @@ class TestSourcesPageRendering:
assert response.status_code == 200 assert response.status_code == 200
assert "Source Asset Records" in response.text 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 "No source asset records found in repository." in response.text
assert "Upload New Documents" not 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}") response = client.get(f"/ui/sources/{source_id}")
assert response.status_code == 200 assert response.status_code == 200
assert "SOURCE RECORD:" in response.text.upper() assert "SOURCE PAGE 1: DETAIL-SOURCE.PNG" in response.text.upper()
assert source_id.upper() in response.text.upper() assert "SOURCE METADATA" in response.text.upper()
assert "SOURCE PREVIEW" 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 @pytest.mark.asyncio
async def test_source_delete_page_blocks_when_source_is_job_linked( 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") response = client.get(f"/ui/sources/{source_id}/delete")
assert response.status_code == 200 assert response.status_code == 200
assert "Delete Source Confirmation".upper() in response.text.upper() assert "DELETE SOURCE RECORD" in response.text.upper()
assert "Cannot delete source linked to active jobs." in response.text assert "Delete is only available for unlinked sources." in response.text
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_source_delete_page_allows_unlinked_source(self, app_client): 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") response = client.get(f"/ui/sources/{source_id}/delete")
assert response.status_code == 200 assert response.status_code == 200
assert "Delete Source Confirmation".upper() in response.text.upper() assert "DELETE SOURCE RECORD" in response.text.upper()
assert f"Are you sure you want to delete source {source_id}?" in response.text assert "Delete source permanently" in response.text
assert "Cannot delete source linked to active jobs." not in response.text assert "Delete is only available for unlinked sources." not in response.text