generated from john/python-template
Fixed Source Detail page
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"""
|
||||
<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>
|
||||
""",
|
||||
)
|
||||
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)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user