UI updates continue. Focus on Sources

This commit is contained in:
Jim Lancaster
2026-08-02 18:41:09 -05:00
parent 0ab7ad50f2
commit 49e2e48df1
10 changed files with 398 additions and 39 deletions
+1 -2
View File
@@ -15,13 +15,12 @@ logger = logging.getLogger(__name__)
async def create_all(*, engine: AsyncEngine | None = None) -> None:
"""Create all tables on the selected engine from scratch."""
"""Create any missing tables on the selected engine."""
# Import models so SQLModel metadata is fully registered before bootstrap.
from transcription.db import models as _models # noqa: F401
active_engine = engine or resolve_engine()
async with active_engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.drop_all)
await connection.run_sync(SQLModel.metadata.create_all)
logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url)
@@ -83,6 +83,27 @@ class TranscriptionService(ServiceBase):
)
return source
async def read_source_detail(self, source_id: UUID, *, session: AsyncSession | None = None) -> Source:
"""Read a source page record with job-source context for UI detail rendering."""
async with self._session_scope(session) as _session:
query = (
select(Source)
.options(
selectinload(Source.job_sources).selectinload(JobSource.job), # pyright: ignore[reportArgumentType]
)
.where(Source.id == source_id)
.execution_options(populate_existing=True)
)
source = (await _session.exec(query)).first()
if source is None:
raise TranscriptionNotFoundError(
f"Source with id {source_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Verify the source id and retry.",
)
return source
async def update_source(self, source: Source, *, session: AsyncSession | None = None) -> Source:
"""Update an existing source page record."""
async with self._session_scope(session) as _session:
+2
View File
@@ -6,6 +6,7 @@ from nicegui import ui
from transcription.ui.pages.documents_page import register_page as register_documents_page
from transcription.ui.pages.jobs_page import register_page as register_jobs_page
from transcription.ui.pages.people_page import register_page as register_people_page
from transcription.ui.pages.sources_page import register_page as register_sources_page
from transcription.ui.pages.upload_page import register_page as register_upload_page
from transcription.ui.resources import read_css
@@ -27,5 +28,6 @@ def register_pages(app: FastAPI) -> None:
register_upload_page()
register_documents_page()
register_people_page()
register_sources_page()
register_jobs_page()
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
@@ -9,6 +9,7 @@ from transcription.ui.resources import read_css
NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
("Documents", "/documents", "description"),
("People", "/people", "group"),
("Sources", "/sources", "folder"),
("Jobs", "/jobs", "work_history"),
)
@@ -20,6 +21,8 @@ def _is_active_path(*, current_path: str, item_path: str) -> bool:
return current_path == "/documents" or current_path.startswith("/documents/")
if item_path == "/people":
return current_path == "/people" or current_path.startswith("/people/")
if item_path == "/sources":
return current_path == "/sources" or current_path.startswith("/sources/")
return current_path == item_path
+5 -35
View File
@@ -6,6 +6,7 @@ from datetime import date
from uuid import UUID
from fastapi import Request
from fastapi.responses import RedirectResponse
from nicegui import ui
from transcription.db.models import Document
@@ -211,7 +212,7 @@ def register_page() -> None:
with ui.row().classes("w-full items-center gap-2"):
ui.button(
"Sources",
on_click=lambda: ui.navigate.to(f"/documents/{document.id}/sources"),
on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"),
icon="description",
).props("flat")
ui.button(
@@ -274,40 +275,9 @@ def register_page() -> None:
)
@ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
try:
parsed_document_id = UUID(document_id)
except ValueError:
ui.label("Invalid document id").classes("text-h6 text-negative")
return
try:
document = await document_service.read_document_detail(document_id=parsed_document_id)
except DocumentError:
ui.label("Document not found").classes("text-h6 text-negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="documents.sources")
return
ui.label(f"Sources for {document.name}").classes("text-h5 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
ui.button("Back to Document", on_click=lambda: ui.navigate.to(f"/documents/{document.id}"), icon="arrow_back")
ui.button("Add sources", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="upload_file").props(
'unelevated color="primary"'
)
if not document.sources:
ui.label("No sources added yet.").classes("text-body2 vibe-text-muted")
return
for source in sorted(document.sources, key=lambda item: item.page_number):
with ui.card().classes("w-full"):
ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-body2")
ui.label(f"Stored filename: {source.filename}").classes("text-body2")
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse:
_ = session_factory
return RedirectResponse(url=f"/ui/sources?document_id={document_id}")
@ui.page("/documents/{document_id}/edit")
async def document_edit_page(document_id: str, session_factory: SessionFactoryDep) -> None:
+1 -1
View File
@@ -218,7 +218,7 @@ def register_page() -> None: # noqa: PLR0915
).props("flat")
ui.button(
"Sources",
on_click=lambda: ui.navigate.to(f"/documents/{job.document_id}/sources"),
on_click=lambda: ui.navigate.to(f"/sources?job_id={job.id}"),
icon="description",
).props("flat")
ui.button(
+222
View File
@@ -0,0 +1,222 @@
"""Sources list and detail page registration."""
from __future__ import annotations
from uuid import UUID
from urllib.parse import urlencode
from fastapi import Request
from fastapi.responses import RedirectResponse
from nicegui import ui
from transcription.db.models import JobSource
from transcription.db.models import Source
from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService
from transcription.services.jobs import JobService
from transcription.services.transcription import TranscriptionService
from transcription.services.transcription import TranscriptionNotFoundError
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.document_panzoom import render_document_panzoom
from transcription.ui.components.error_presenter import show_error
from ...db.session import SessionFactoryDep
def register_page() -> None:
"""Register source list and detail routes."""
@ui.page("/sources")
async def sources_page(request: Request, session_factory: SessionFactoryDep) -> None:
sources_service = TranscriptionService(session_factory=session_factory)
jobs_service = JobService(session_factory=session_factory)
documents_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/sources")
document_id_text = request.query_params.get("document_id")
job_id_text = request.query_params.get("job_id")
document_id = _parse_uuid(document_id_text)
job_id = _parse_uuid(job_id_text)
document_name = None
job_label = None
back_path = None
sources: list[Source] = []
try:
if document_id is not None:
document = await documents_service.read_document_detail(document_id=document_id)
document_name = document.name
back_path = f"/documents/{document.id}"
sources = list(sorted(document.sources, key=lambda item: (item.page_number, item.upload_name.casefold())))
elif job_id is not None:
job = await jobs_service.read_job(job_id=job_id)
job_label = str(job.id)
back_path = f"/jobs/{job.id}"
job_sources = await sources_service.list_job_sources(job_id=job.id)
sources = [job_source.source for job_source in job_sources if job_source.source is not None]
sources.sort(key=lambda item: (item.page_number, item.upload_name.casefold()))
else:
sources = list(sorted(await sources_service.list_sources(), key=lambda item: (item.document_id, item.page_number)))
except DocumentError:
ui.label("Document not found").classes("text-h6 text-negative")
return
except ValueError:
ui.label("Job not found").classes("text-h6 text-negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="sources.list")
return
if document_name is not None:
ui.label(f"Sources for {document_name}").classes("text-h5 text-weight-medium")
elif job_label is not None:
ui.label(f"Sources for Job {job_label}").classes("text-h5 text-weight-medium")
else:
ui.label("Sources").classes("text-h5 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
if back_path is not None:
back_label = "Back to Document" if document_id is not None else "Back to Job"
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back")
if not sources:
ui.label("No sources added yet.").classes("text-body2 vibe-text-muted")
return
with ui.column().classes("w-full gap-2"):
for source in sources:
with ui.card().classes("w-full") as card:
card.on(
"click",
lambda _=None, source_id=source.id, filters=_build_filter_query(document_id=document_id, job_id=job_id): ui.navigate.to(
f"/sources/{source_id}{filters}"
),
)
card.classes("cursor-pointer")
with ui.column().classes("gap-1"):
ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-subtitle1 text-weight-medium")
ui.label(f"Stored filename: {source.filename}").classes("text-body2")
ui.label(f"Document id: {source.document_id}").classes("text-body2 vibe-text-muted")
@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)
render_navigation_header(current_path="/sources")
try:
parsed_source_id = UUID(source_id)
except ValueError:
ui.label("Invalid source id").classes("text-h6 text-negative")
return
try:
source = await sources_service.read_source_detail(source_id=parsed_source_id)
except TranscriptionNotFoundError:
ui.label("Source not found").classes("text-h6 text-negative")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="sources.read")
return
back_path = _back_path_from_query(request.query_params)
ui.label(f"Source {source.upload_name}").classes("text-h5 text-weight-medium")
with ui.row().classes("w-full items-center gap-2"):
if back_path is not None:
back_label = "Back to Document" if "document_id" in request.query_params else "Back to Job" if "job_id" in request.query_params else "Back to Sources"
ui.button(back_label, on_click=lambda route=back_path: ui.navigate.to(route), icon="arrow_back")
else:
ui.button("Back to Sources", on_click=lambda: ui.navigate.to("/sources"), icon="arrow_back")
with ui.column().classes("w-full gap-3"):
render_document_panzoom(source=source)
with ui.column().classes("gap-1"):
ui.label(f"Page number: {source.page_number}").classes("text-body2")
ui.label(f"Upload name: {source.upload_name}").classes("text-body2")
ui.label(f"Stored filename: {source.filename}").classes("text-body2")
ui.label(f"Document id: {source.document_id}").classes("text-body2")
ui.label(f"Uploaded: {source.date_uploaded.isoformat()}").classes("text-body2")
ui.label(f"Revised: {source.date_revised.isoformat() if source.date_revised else 'not set'}").classes("text-body2")
ui.separator()
ui.label("Transcription text").classes("text-subtitle1 text-weight-medium")
ui.textarea(value=_source_transcription_text(source) or "").props("outlined autogrow readonly").classes("w-full")
ui.label("Revision text").classes("text-subtitle1 text-weight-medium")
revision_input = ui.textarea(label="Revision text", value=source.revised_text or "").props("outlined autogrow")
revision_input.classes("w-full")
async def save_revision() -> None:
candidate = (revision_input.value or "").strip()
if not candidate:
ui.notify("Revision text is required.", type="warning")
return
try:
await sources_service.upsert_revision_for_source(source_id=source.id, text=candidate)
except Exception as exc: # noqa: BLE001
show_error(exc, title="Save failed", operation="sources.save_revision")
return
ui.notify("Revision saved", type="positive")
ui.navigate.to(request.url.path + _back_query(request.query_params))
with ui.row().classes("w-full items-center gap-2"):
ui.button("Save revision", on_click=save_revision, icon="save").props('unelevated color="primary"')
@ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str) -> RedirectResponse:
return RedirectResponse(url=f"/ui/sources?document_id={document_id}")
@ui.page("/jobs/{job_id}/sources")
async def job_sources_page(job_id: str) -> RedirectResponse:
return RedirectResponse(url=f"/ui/sources?job_id={job_id}")
def _parse_uuid(value: str | None) -> UUID | None:
if not value:
return None
try:
return UUID(value)
except ValueError:
return None
def _build_filter_query(*, document_id: UUID | None, job_id: UUID | None) -> str:
params: dict[str, str] = {}
if document_id is not None:
params["document_id"] = str(document_id)
if job_id is not None:
params["job_id"] = str(job_id)
return f"?{urlencode(params)}" if params else ""
def _back_query(query_params) -> str:
params = {}
for key in ("document_id", "job_id"):
if query_params.get(key):
params[key] = query_params.get(key)
return f"?{urlencode(params)}" if params else ""
def _back_path_from_query(query_params) -> str | None:
document_id = query_params.get("document_id")
if document_id:
return f"/documents/{document_id}"
job_id = query_params.get("job_id")
if job_id:
return f"/jobs/{job_id}"
return None
def _source_transcription_text(source: Source) -> str | None:
for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True):
if job_source.raw_transcription:
return job_source.raw_transcription
for job_source in sorted(source.job_sources, key=lambda item: item.executed_at, reverse=True):
if job_source.error_detail:
return job_source.error_detail
return None