V4.1 Mostly UI adjustments by GC

This commit is contained in:
Jim Lancaster
2026-08-12 13:11:50 -05:00
parent 1e8d8572d4
commit 89cf69f8a2
29 changed files with 1261 additions and 404 deletions
+2
View File
@@ -1,4 +1,5 @@
from .operations import create_all
from .operations import upgrade_schema
from .runtime import dispose_database_runtime
from .runtime import initialize_database_runtime
from .session import session_scope
@@ -10,4 +11,5 @@ __all__ = [
"initialize_database_runtime",
"session_scope",
"transaction_scope",
"upgrade_schema",
]
+1
View File
@@ -129,6 +129,7 @@ class Person(SQLModel, table=True):
death_place: str | None = None
biography: str | None = None
portrait_path: str | None = None
family_search_id: str | None = Field(default=None, unique=True)
metadata_: dict[str, JsonValue] | None = Field(
default=None,
sa_column=Column("metadata", JSONBCompat(), nullable=True),
+41 -2
View File
@@ -2,16 +2,19 @@ from __future__ import annotations
import logging
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy import inspect
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel import SQLModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from .engine import resolve_engine
from .models import DocumentType
from .models import Job
from .models import JobStatus
from .models import DocumentType
from .models import PersonRole
logger = logging.getLogger(__name__)
@@ -41,10 +44,46 @@ async def create_all(*, engine: AsyncEngine | None = None) -> None:
active_engine = engine or resolve_engine()
async with active_engine.begin() as connection:
await connection.run_sync(SQLModel.metadata.create_all)
await _upgrade_person_family_search_id(connection)
await seed_registry_defaults(engine=active_engine)
logger.debug("Database schema bootstrap complete for database_url=%s", active_engine.url)
async def upgrade_schema(*, engine: AsyncEngine | None = None) -> None:
"""Apply non-destructive additive upgrades to an existing schema."""
active_engine = engine or resolve_engine()
async with active_engine.begin() as connection:
await _upgrade_person_family_search_id(connection)
async def _upgrade_person_family_search_id(connection: AsyncConnection) -> None:
"""Add the nullable V4.1 FamilySearch field to an existing database."""
def inspect_person(sync_connection) -> tuple[bool, bool]:
database = inspect(sync_connection)
if "person" not in database.get_table_names():
return False, False
columns = {column["name"] for column in database.get_columns("person")}
indexes = database.get_indexes("person")
constraints = database.get_unique_constraints("person")
has_unique_id = any(
entry.get("column_names") == ["family_search_id"] for entry in [*indexes, *constraints]
)
return "family_search_id" in columns, has_unique_id
has_column, has_unique_id = await connection.run_sync(inspect_person)
if not has_column and not await connection.run_sync(
lambda sync_connection: "person" in inspect(sync_connection).get_table_names()
):
return
if not has_column:
await connection.execute(text("ALTER TABLE person ADD COLUMN family_search_id VARCHAR"))
if not has_unique_id:
await connection.execute(
text("CREATE UNIQUE INDEX IF NOT EXISTS ix_person_family_search_id ON person (family_search_id)")
)
async def seed_registry_defaults(*, engine: AsyncEngine | None = None) -> None:
"""Seed default registry rows for role and document type taxonomies."""
active_engine = engine or resolve_engine()
+7 -2
View File
@@ -228,9 +228,14 @@ class DocumentService(ServiceBase):
return result.all()
async def list_documents(self, *, session: AsyncSession | None = None) -> Sequence[Document]:
"""List all documents in the database."""
"""List documents with relations needed by the archival table."""
async with self._session_scope(session) as _session:
result = await _session.exec(select(Document))
query = select(Document).options(
selectinload(Document.document_people).selectinload(DocumentPerson.person), # pyright: ignore[reportArgumentType]
selectinload(Document.document_people).selectinload(DocumentPerson.role_ref), # pyright: ignore[reportArgumentType]
selectinload(Document.document_type_ref), # pyright: ignore[reportArgumentType]
)
result = await _session.exec(query)
return result.all()
async def read_document_detail(self, document_id: UUID, *, session: AsyncSession | None = None) -> Document:
+34 -2
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import logging
import re
from collections.abc import Sequence
from datetime import UTC
from datetime import datetime
@@ -29,6 +30,7 @@ from .base import ServiceBase
logger = logging.getLogger(__name__)
PORTRAIT_EXTENSIONS = frozenset({".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"})
FAMILY_SEARCH_ID_PATTERN = re.compile(r"^[A-Z0-9]{4}-[A-Z0-9]{3}$")
class PeopleError(AppError):
@@ -39,13 +41,31 @@ class PersonMediaError(PeopleError):
"""Raised when Person portrait media cannot be validated or persisted."""
def normalize_family_search_id(value: str | None) -> str | None:
"""Normalize and validate a FamilySearch tree person identifier."""
normalized = (value or "").strip().upper()
if not normalized:
return None
if not FAMILY_SEARCH_ID_PATTERN.fullmatch(normalized):
raise PeopleError(
"FamilySearch ID must use the format XXXX-XXX",
category=ErrorCategory.VALIDATION,
suggestion="Enter the seven-character FamilySearch person ID, including its hyphen.",
)
return normalized
class PeopleService(ServiceBase):
"""Manage People, relationship roles, and document-person links."""
async def create_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
async with self._session_scope(session) as _session:
person.family_search_id = normalize_family_search_id(person.family_search_id)
_session.add(person)
await self._finalize(session=_session, caller_session=session, refresh=(person,))
try:
await self._finalize(session=_session, caller_session=session, refresh=(person,))
except IntegrityError as exc:
raise self._family_search_conflict(person.family_search_id) from exc
return person
async def read_person(self, person_id: UUID, *, session: AsyncSession | None = None) -> Person:
@@ -57,9 +77,13 @@ class PeopleService(ServiceBase):
async def update_person(self, person: Person, *, session: AsyncSession | None = None) -> Person:
async with self._session_scope(session) as _session:
person.family_search_id = normalize_family_search_id(person.family_search_id)
person.updated_at = datetime.now(UTC)
merged = await _session.merge(person)
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
try:
await self._finalize(session=_session, caller_session=session, refresh=(merged,))
except IntegrityError as exc:
raise self._family_search_conflict(person.family_search_id) from exc
return merged
async def delete_person(self, person: Person, *, session: AsyncSession | None = None) -> None:
@@ -295,6 +319,14 @@ class PeopleService(ServiceBase):
if await session.get(Document, document_id) is None:
raise self._not_found(f"Document with id {document_id} not found")
@staticmethod
def _family_search_conflict(family_search_id: str | None) -> PeopleError:
return PeopleError(
f"FamilySearch ID {family_search_id} is already assigned to another person",
category=ErrorCategory.CONFLICT,
suggestion="Open the existing person record or enter a different FamilySearch ID.",
)
@staticmethod
def _legacy_role(role_code: str) -> DocumentPersonRole:
try:
+37
View File
@@ -6,6 +6,7 @@ import hashlib
import logging
from collections.abc import Sequence
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import UTC
from datetime import datetime
from pathlib import Path
@@ -84,6 +85,14 @@ class SourceDeleteBlockedError(TranscriptionError):
"""Raised when source deletion is blocked by dependency policy."""
@dataclass(frozen=True, slots=True)
class SourceNavigation:
"""Adjacent Source identifiers within one ordered Document."""
previous_id: UUID | None
next_id: UUID | None
class SourceService(ServiceBase):
"""Manage source records, media payloads, revisions, and page execution output."""
@@ -137,6 +146,34 @@ class SourceService(ServiceBase):
)
return source
async def read_source_navigation(
self,
source_id: UUID,
*,
session: AsyncSession | None = None,
) -> SourceNavigation:
"""Return adjacent Sources ordered within the current Document."""
async with self._session_scope(session) as _session:
source = await _session.get(Source, source_id)
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.",
)
query = (
select(Source.id)
.where(Source.document_id == source.document_id)
.order_by(Source.page_number, Source.id) # pyright: ignore[reportArgumentType]
)
source_ids = list((await _session.exec(query)).all())
current_index = source_ids.index(source_id)
return SourceNavigation(
previous_id=source_ids[current_index - 1] if current_index > 0 else None,
next_id=source_ids[current_index + 1] if current_index + 1 < len(source_ids) else None,
)
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:
@@ -0,0 +1,35 @@
"""Presentation-only formatting shared by archival UI surfaces."""
import re
from datetime import date
from transcription.db.models import Person
YEAR_PATTERN = re.compile(r"\b[12]\d{3}\b")
def compact_date(exact: date | None, approximate: str | None) -> str:
"""Prefer an exact date, then an approximate value, then an unknown marker."""
if exact is not None:
return exact.isoformat()
return (approximate or "").strip() or "Unknown"
def person_selector_label(person: Person) -> str:
"""Build a readable selector label without treating names as identity."""
preferred = (person.display_name or "").strip()
full_name = person.full_name.strip()
label = preferred if not preferred or preferred == full_name else f"{preferred} - {full_name}"
if not label:
label = full_name
if person.birth_date is not None:
return f"{label} ({person.birth_date.year})"
approximate_year = YEAR_PATTERN.search(person.birth_date_raw or "")
if approximate_year is not None:
return f"{label} ({approximate_year.group(0)})"
return label
def family_search_url(family_search_id: str) -> str:
"""Build the fixed FamilySearch details URL for a validated identifier."""
return f"https://www.familysearch.org/tree/person/details/{family_search_id}"
@@ -21,8 +21,9 @@ class DocumentTableRow:
id: UUID
name: str
document_type: str
authors: str
document_date: str
archive_identifier: str
created_at: str
def _serialize_rows(rows: Sequence[DocumentTableRow]) -> list[dict[str, Any]]:
@@ -31,8 +32,9 @@ def _serialize_rows(rows: Sequence[DocumentTableRow]) -> list[dict[str, Any]]:
"id": str(row.id),
"name": row.name,
"document_type": row.document_type or "Unspecified",
"authors": row.authors or "Not set",
"document_date": row.document_date,
"archive_identifier": row.archive_identifier or "N/A",
"created_at": row.created_at,
}
for row in rows
]
@@ -53,13 +55,32 @@ def render_documents_table(rows: Sequence[DocumentTableRow]) -> None:
"label": "Document Title",
"field": "name",
"sortable": True,
"classes": "font-serif font-semibold",
"classes": "font-serif font-semibold text-left ui-table-cell-wrap",
"style": "width: 30%;",
},
{
"name": "document_type",
"label": "Type",
"field": "document_type",
"sortable": True,
"classes": "text-left ui-table-cell-wrap",
"style": "width: 14%;",
},
{
"name": "authors",
"label": "Author",
"field": "authors",
"sortable": True,
"classes": "text-left ui-table-cell-wrap",
"style": "width: 22%;",
},
{
"name": "document_date",
"label": "Document Date",
"field": "document_date",
"sortable": True,
"classes": "font-mono text-xs",
"style": "width: 14%;",
},
{
"name": "archive_identifier",
@@ -67,12 +88,7 @@ def render_documents_table(rows: Sequence[DocumentTableRow]) -> None:
"field": "archive_identifier",
"sortable": True,
"classes": "font-mono text-xs",
},
{
"name": "created_at",
"label": "Created",
"field": "created_at",
"sortable": True,
"style": "width: 20%;",
},
],
default_sort_by="name",
@@ -23,6 +23,7 @@ class PersonTableRow:
display_name: str
maiden_name: str
birth_date: str
death_date: str
def _serialize_rows(rows: Sequence[PersonTableRow]) -> list[dict[str, Any]]:
@@ -33,6 +34,7 @@ def _serialize_rows(rows: Sequence[PersonTableRow]) -> list[dict[str, Any]]:
"display_name": row.display_name or "Not set",
"maiden_name": row.maiden_name or "N/A",
"birth_date": row.birth_date or "Unknown",
"death_date": row.death_date or "Unknown",
}
for row in rows
]
@@ -53,19 +55,21 @@ def render_people_table(rows: Sequence[PersonTableRow]) -> None:
"label": "Full Name",
"field": "full_name",
"sortable": True,
"classes": "font-serif font-semibold",
"classes": "font-serif font-semibold text-left ui-table-cell-wrap",
},
{
"name": "display_name",
"label": "Display Name",
"field": "display_name",
"sortable": True,
"classes": "text-left ui-table-cell-wrap",
},
{
"name": "maiden_name",
"label": "Maiden Name",
"field": "maiden_name",
"sortable": True,
"classes": "text-left ui-table-cell-wrap",
},
{
"name": "birth_date",
@@ -74,9 +78,16 @@ def render_people_table(rows: Sequence[PersonTableRow]) -> None:
"sortable": True,
"classes": "font-mono text-xs",
},
{
"name": "death_date",
"label": "Death Date",
"field": "death_date",
"sortable": True,
"classes": "font-mono text-xs",
},
],
default_sort_by="full_name",
search_placeholder="Search people by name or birth date...",
search_placeholder="Search people by name, birth date, or death date...",
on_row_click_id=lambda person_id: ui.navigate.to(f"/people/{person_id}"),
)
@@ -91,4 +102,4 @@ def render_people_table(rows: Sequence[PersonTableRow]) -> None:
</div>
</q-td>
""",
)
)
@@ -21,7 +21,6 @@ class SourceTableRow:
id: UUID
page_number: int
upload_name: str
filename: str
document_id: UUID
document_name: str | None = None
job_source_status: str | None = None
@@ -34,7 +33,6 @@ def _serialize_rows(rows: Sequence[SourceTableRow]) -> list[dict[str, Any]]:
"id": str(row.id),
"page_number": row.page_number,
"upload_name": row.upload_name,
"filename": row.filename,
"document_id": str(row.document_id),
"document_name": row.document_name or "-",
"job_source_status": row.job_source_status or "-",
@@ -59,27 +57,23 @@ def render_sources_table(rows: Sequence[SourceTableRow]) -> None:
"label": "Document Name",
"field": "document_name",
"sortable": True,
"classes": "font-serif",
"classes": "font-serif text-left ui-table-cell-wrap",
"style": "width: 27%;",
},
{
"name": "page_number",
"label": "Page Number",
"field": "page_number",
"sortable": True,
"style": "width: 10%;",
},
{
"name": "upload_name",
"label": "Upload Title",
"field": "upload_name",
"sortable": True,
"classes": "font-serif",
},
{
"name": "filename",
"label": "Stored Filename",
"field": "filename",
"sortable": True,
"classes": "font-mono",
"classes": "font-serif text-left ui-table-cell-wrap",
"style": "width: 25%;",
},
{
"name": "job_source_status",
@@ -87,13 +81,15 @@ def render_sources_table(rows: Sequence[SourceTableRow]) -> None:
"field": "job_source_status",
"sortable": True,
"classes": "font-mono",
"style": "width: 14%;",
},
{
"name": "job_source_error_detail",
"label": "Error Detail",
"field": "job_source_error_detail",
"sortable": False,
"classes": "font-mono text-xs truncate max-w-xs ui-text-muted",
"classes": "font-mono text-xs text-left ui-text-muted ui-table-cell-wrap",
"style": "width: 24%;",
},
],
default_sort_by="page_number",
+82 -62
View File
@@ -12,29 +12,29 @@ from nicegui import ui
from transcription.db.models import Document
from transcription.errors import ErrorCategory
from transcription.services.documents import (
DocumentDeleteBlockedError,
DocumentError,
DocumentService,
)
from transcription.services.documents import DocumentDeleteBlockedError
from transcription.services.documents import DocumentError
from transcription.services.documents import DocumentService
from transcription.services.people import PeopleService
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 archival_badge
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,
render_empty_state,
section_header_row,
)
from transcription.ui.components.table.documents import DocumentTableRow, render_documents_table
from transcription.ui.components.formatters import compact_date
from transcription.ui.components.formatters import person_selector_label
from transcription.ui.components.primitives import destructive_button
from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.table.documents import DocumentTableRow
from transcription.ui.components.table.documents import render_documents_table
from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep
def register_page() -> None:
def register_page() -> None: # noqa: PLR0915
"""Register documents list and detail routes."""
@ui.page("/documents/new")
@@ -49,14 +49,20 @@ def register_page() -> None:
people = sorted(await people_service.list_people(), key=lambda item: item.full_name.casefold())
role_catalog = await people_service.list_person_roles()
type_catalog = await document_service.list_document_types()
requested_person_id = _parse_uuid(request.query_params.get("person_id"))
selected_people_by_role: dict[str, list[UUID]] = {}
if requested_person_id is not None and any(person.id == requested_person_id for person in people):
selected_people_by_role["author"] = [requested_person_id]
elif request.query_params.get("person_id"):
ui.notify("The requested person could not be preselected.", type="warning")
form = _render_document_form_fields(
people=people,
role_codes=[role.code for role in role_catalog],
role_labels={role.code: role.label for role in role_catalog},
type_options={doc_type.code: doc_type.label for doc_type in type_catalog},
selected_people_by_role=selected_people_by_role,
)
requested_doc_id = request.query_params.get("document_id")
return_to = request.query_params.get("return_to")
async def submit_create() -> None:
@@ -145,8 +151,9 @@ def register_page() -> None:
document_type=(
doc.document_type_ref.label if doc.document_type_ref is not None else (doc.document_type or "")
),
authors=", ".join(_group_people_labels_by_role(doc).get("author", [])),
document_date=compact_date(doc.document_date, doc.document_date_raw),
archive_identifier=doc.archive_identifier or "",
created_at=doc.created_at.strftime("%b %d, %Y"),
)
for doc in documents
]
@@ -238,16 +245,17 @@ def register_page() -> None:
return
for job in sorted(document.jobs, key=lambda item: item.date_created, reverse=True):
with archival_card(extra_classes="p-3"):
with ui.row().classes("w-full items-center justify-between"):
with ui.row().classes("items-center gap-2"):
archival_badge(job.status.value)
ui.label(f"Job ID: {job.id}").classes("text-xs font-mono ui-text-primary")
ui.button(
"Open Job",
on_click=lambda _=None, jid=job.id: ui.navigate.to(f"/jobs/{jid}"),
icon="open_in_new",
).props("flat dense").classes("text-xs ui-link-primary")
with archival_card(extra_classes="p-3"), ui.row().classes(
"w-full items-center justify-between"
):
with ui.row().classes("items-center gap-2"):
archival_badge(job.status.value)
ui.label(f"Job ID: {job.id}").classes("text-xs font-mono ui-text-primary")
ui.button(
"Open Job",
on_click=lambda _=None, jid=job.id: ui.navigate.to(f"/jobs/{jid}"),
icon="open_in_new",
).props("flat dense").classes("text-xs ui-link-primary")
@ui.page("/documents/{document_id}/sources")
async def document_sources_page(document_id: str, session_factory: SessionFactoryDep) -> RedirectResponse:
@@ -514,7 +522,7 @@ def _render_document_form_fields(
ui.button("Create new person", on_click=lambda: ui.navigate.to("/people/new"), icon="person_add").props(
"flat dense"
).classes("self-start")
people_options = {str(p.id): p.full_name for p in people}
people_options = {str(p.id): person_selector_label(p) for p in people}
existing = selected_people_by_role or {}
role_people_inputs: dict[str, Any] = {}
for role_code in role_codes:
@@ -549,15 +557,6 @@ def _render_bento_viewer_zone(document: Document) -> None:
with ui.column().classes("col-span-12 lg:col-span-4"):
source_path = document.sources[0].file_path if document.sources else None
dark_room_viewer(source_path, count_label=f"{len(document.sources)} Source(s) Linked")
with ui.row().classes("w-full justify-between items-center mt-2"):
ui.button(
"View All Sources",
on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"),
icon="description",
).props("flat dense text-xs").classes("ui-link-primary")
ui.button(
"+ Add Source", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add"
).classes("ui-btn-primary text-xs")
def _render_bento_metadata_zone(document: Document) -> None:
@@ -567,8 +566,7 @@ def _render_bento_metadata_zone(document: Document) -> None:
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Archival Metadata"):
metadata_row("Author(s):", ", ".join(author_names) if author_names else "Not set")
metadata_row("Exact Date:", document.document_date.isoformat() if document.document_date else "Not set")
metadata_row("Approx. Date:", document.document_date_raw or "Not set")
metadata_row("Document Date:", compact_date(document.document_date, document.document_date_raw))
metadata_row("Location Created:", document.location_created or "Not set")
metadata_row("Archive Identifier:", document.archive_identifier or "Not set")
@@ -583,29 +581,43 @@ def _render_bento_metadata_zone(document: Document) -> None:
def _render_bento_relations_zone(document: Document) -> None:
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with archival_card(title="Related People"):
if not document.document_people:
render_empty_state("No linked people yet.", italic=True)
else:
grouped = _group_people_labels_by_role(document)
with ui.column().classes("w-full gap-2"):
for role_code in sorted(grouped.keys()):
with ui.column().classes("w-full ui-row-surface p-2 gap-1"):
archival_badge(role_code)
for person_label in grouped[role_code]:
ui.label(person_label).classes("text-xs font-semibold ui-text-primary")
_render_related_people_card(document)
_render_document_processing_card(document)
with archival_card(title="Pipeline Jobs"):
with ui.row().classes("w-full justify-between items-center mb-2"):
ui.label(f"{len(document.jobs)} Active Jobs").classes("text-xs ui-link-primary font-bold")
with ui.row().classes("w-full gap-2 mt-2"):
ui.button(
"View Jobs", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"), icon="work_history"
).props("flat dense text-xs").classes("ui-link-primary")
ui.button(
"+ Add Job", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add"
).classes("ui-btn-primary text-xs")
def _render_related_people_card(document: Document) -> None:
with archival_card(title="Related People"):
if not document.document_people:
render_empty_state("No linked people yet.", italic=True)
return
grouped = _group_people_by_role(document)
with ui.column().classes("w-full gap-2"):
for role_code in sorted(grouped.keys()):
with ui.column().classes("w-full ui-row-surface p-2 gap-1"):
archival_badge(role_code)
for person in grouped[role_code]:
ui.link(person.full_name, f"/ui/people/{person.id}").classes(
"text-xs font-semibold ui-link-primary"
)
def _render_document_processing_card(document: Document) -> None:
with archival_card(title="Sources & Pipeline Jobs"):
metadata_row("Sources:", str(len(document.sources)))
metadata_row("Jobs:", str(len(document.jobs)))
with ui.row().classes("w-full gap-2 mt-2 flex-wrap"):
ui.button(
"View Sources",
on_click=lambda: ui.navigate.to(f"/sources?document_id={document.id}"),
icon="description",
).props("flat dense text-xs").classes("ui-link-primary")
ui.button(
"View Jobs", on_click=lambda: ui.navigate.to(f"/documents/{document.id}/jobs"), icon="work_history"
).props("flat dense text-xs").classes("ui-link-primary")
ui.button(
"+ Add Job", on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"), icon="add"
).classes("ui-btn-primary text-xs")
def _parse_uuid(value: str | None) -> UUID | None:
@@ -660,10 +672,7 @@ def _collect_role_link_candidates(
desired: set[tuple[str, UUID]] = set()
for role_code in role_codes:
selected = role_people_inputs[role_code].value or []
if isinstance(selected, str):
selected_ids = [selected]
else:
selected_ids = list(selected)
selected_ids = [selected] if isinstance(selected, str) else list(selected)
for selected_id in selected_ids:
parsed = _parse_uuid(selected_id)
@@ -681,3 +690,14 @@ def _group_people_labels_by_role(document: Document) -> dict[str, list[str]]:
person_label = link.person.full_name if link.person is not None else "Unknown person"
grouped.setdefault(role_code, []).append(person_label)
return grouped
def _group_people_by_role(document: Document) -> dict[str, list[Any]]:
grouped: dict[str, list[Any]] = {}
for link in document.document_people:
role_code = _resolve_link_role_code(link)
if role_code is not None and link.person is not None:
grouped.setdefault(role_code, []).append(link.person)
for people in grouped.values():
people.sort(key=lambda person: person.full_name.casefold())
return grouped
+49 -20
View File
@@ -3,31 +3,32 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import Request
from nicegui import ui
from transcription.db.models import Job, JobSourceStatus, JobStatus
from transcription.db.models import Job
from transcription.db.models import JobSourceStatus
from transcription.db.models import JobStatus
from transcription.db.session import session_scope
from transcription.services.documents import DocumentService
from transcription.services.jobs import (
JobCancelBlockedError,
JobDeleteBlockedError,
JobResubmitBlockedError,
JobService,
)
from transcription.services.jobs import JobCancelBlockedError
from transcription.services.jobs import JobDeleteBlockedError
from transcription.services.jobs import JobResubmitBlockedError
from transcription.services.jobs import JobService
from transcription.services.store import create_job_for_document
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 archival_badge
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,
render_empty_state,
section_header_row,
)
from transcription.ui.components.table.jobs import JobTableRow, render_jobs_table
from transcription.ui.components.primitives import destructive_button
from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.table.jobs import JobTableRow
from transcription.ui.components.table.jobs import render_jobs_table
from transcription.ui.theme import page_header
from transcription.worker import resolve_worker_notifier
@@ -141,7 +142,7 @@ def register_page() -> None: # noqa: PLR0915
ui.button("Back to Jobs", on_click=lambda: ui.navigate.to("/jobs"), icon="arrow_back").props("flat")
@ui.page("/jobs/{job_id}")
async def job_detail_page(job_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
async def job_detail_page(job_id: str, session_factory: SessionFactoryDep) -> None:
jobs_service = JobService(session_factory=session_factory)
render_navigation_header(current_path="/jobs")
@@ -157,11 +158,38 @@ def register_page() -> None: # noqa: PLR0915
return
with ui.column().classes("w-full max-w-4xl mx-auto p-4 gap-4"):
_render_job_detail_header(job)
current_job = [job]
timer_holder: list[Any] = [None]
with ui.grid().classes("w-full grid-cols-1 md:grid-cols-2 gap-4"):
_render_job_logistics(job)
_render_job_document_links(job)
@ui.refreshable
def render_detail() -> None:
active_job = current_job[0]
_render_job_detail_header(active_job)
with ui.grid().classes("w-full grid-cols-1 md:grid-cols-2 gap-4"):
_render_job_logistics(active_job)
_render_job_document_links(active_job)
render_detail()
if job.status in {JobStatus.QUEUED, JobStatus.PROCESSING}:
ui.label("This page updates automatically while the job is active.").classes(
"text-xs ui-text-muted"
)
async def refresh_job() -> None:
try:
current_job[0] = await jobs_service.read_job(job_id=parsed_job_id)
except Exception as exc: # noqa: BLE001
if timer_holder[0] is not None:
timer_holder[0].active = False
show_error(exc, title="Auto-refresh failed", operation="jobs.detail.refresh")
return
render_detail.refresh()
if current_job[0].status not in {JobStatus.QUEUED, JobStatus.PROCESSING}:
timer_holder[0].active = False
timer_holder[0] = ui.timer(4.0, refresh_job)
@ui.page("/jobs/{job_id}/cancel")
async def job_cancel_page(job_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
@@ -355,7 +383,8 @@ def _render_no_documents_card() -> None:
def _render_upload_section(uploaded_files: list[tuple[str, bytes]]) -> None:
with archival_card(title="Source Files"):
ui.label(
"Files are processed alphabetically by original filename. Use leading numbers such as 001, 002, 003 to control order."
"Files are processed alphabetically by original filename. "
"Use leading numbers such as 001, 002, 003 to control order."
).classes("text-xs ui-text-muted mb-2")
@ui.refreshable
+66 -34
View File
@@ -6,26 +6,31 @@ from datetime import date
from pathlib import Path
from typing import Any
from urllib.parse import quote
from uuid import UUID, uuid4
from uuid import UUID
from uuid import uuid4
from fastapi import Request
from nicegui import ui
from transcription.config import Settings, get_settings
from transcription.config import Settings
from transcription.config import get_settings
from transcription.db.models import Person
from transcription.errors import ErrorCategory
from transcription.services.people import PeopleError, PeopleService
from transcription.services.people import PersonMediaError, store_person_portrait
from transcription.services.people import PeopleError
from transcription.services.people import PeopleService
from transcription.services.people import PersonMediaError
from transcription.services.people import store_person_portrait
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.primitives import (
destructive_button,
render_empty_state,
section_header_row,
)
from transcription.ui.components.table.people import PersonTableRow, render_people_table
from transcription.ui.components.formatters import compact_date
from transcription.ui.components.formatters import family_search_url
from transcription.ui.components.primitives import destructive_button
from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.table.people import PersonTableRow
from transcription.ui.components.table.people import render_people_table
from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.theme import page_header
@@ -65,7 +70,8 @@ def register_page() -> None: # noqa: PLR0915
full_name=person.full_name,
display_name=person.display_name or "",
maiden_name=person.maiden_name or "",
birth_date=person.birth_date.isoformat() if person.birth_date else "",
birth_date=compact_date(person.birth_date, person.birth_date_raw),
death_date=compact_date(person.death_date, person.death_date_raw),
)
for person in people
]
@@ -107,6 +113,7 @@ def register_page() -> None: # noqa: PLR0915
death_place=(form["death_place"].value or "").strip() or None,
biography=(form["biography"].value or "").strip() or None,
portrait_path=(form["portrait_path"].value or "").strip() or None,
family_search_id=(form["family_search_id"].value or "").strip() or None,
)
try:
@@ -146,6 +153,11 @@ def register_page() -> None: # noqa: PLR0915
page_header(person.full_name, subtitle=f"Person ID: {person.id}")
with ui.row().classes("items-center gap-2"):
ui.button(
"New Document",
on_click=lambda: ui.navigate.to(f"/documents/new?person_id={person.id}"),
icon="note_add",
).props("flat").classes("text-xs ui-link-primary")
ui.button(
"Edit Person",
on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"),
@@ -217,6 +229,7 @@ def register_page() -> None: # noqa: PLR0915
death_place=(form["death_place"].value or "").strip() or None,
biography=(form["biography"].value or "").strip() or None,
portrait_path=(form["portrait_path"].value or "").strip() or None,
family_search_id=(form["family_search_id"].value or "").strip() or None,
metadata_=person.metadata_,
created_at=person.created_at,
updated_at=person.updated_at,
@@ -380,6 +393,15 @@ def _render_person_form_fields(
.props("outlined")
.classes("w-full ui-form-surface")
)
family_search_id_input = (
ui.input(
label="FamilySearch ID",
value=person.family_search_id if person and person.family_search_id else "",
placeholder="XXXX-XXX",
)
.props("outlined")
.classes("w-full ui-form-surface")
)
_bind_portrait_file_picker(
portrait_path_input,
@@ -399,6 +421,7 @@ def _render_person_form_fields(
"death_place": death_place_input,
"biography": biography_input,
"portrait_path": portrait_path_input,
"family_search_id": family_search_id_input,
}
@@ -414,12 +437,16 @@ def _render_person_biographical_zone(person: Person) -> None:
metadata_row("Full Name:", person.full_name)
metadata_row("Display Name:", person.display_name or "Not set")
metadata_row("Maiden Name:", person.maiden_name or "Not set")
metadata_row("Birth Date:", person.birth_date.isoformat() if person.birth_date else "Not set")
metadata_row("Approx. Birth Date:", person.birth_date_raw or "Not set")
metadata_row("Birth Date:", compact_date(person.birth_date, person.birth_date_raw))
metadata_row("Birth Place:", person.birth_place or "Not set")
metadata_row("Death Date:", person.death_date.isoformat() if person.death_date else "Not set")
metadata_row("Approx. Death Date:", person.death_date_raw or "Not set")
metadata_row("Death Date:", compact_date(person.death_date, person.death_date_raw))
metadata_row("Death Place:", person.death_place or "Not set")
if person.family_search_id:
ui.link(
"Open in FamilySearch",
family_search_url(person.family_search_id),
new_tab=True,
).classes("mt-2 text-xs font-semibold ui-link-primary")
with archival_card(title="System Logistics"):
ui.label(f"Created: {person.created_at.isoformat()}").classes("text-[11px] ui-text-muted")
@@ -432,25 +459,30 @@ def _render_person_biography_zone(person: Person) -> None:
ui.label(person.biography or "No biography recorded.").classes("p-2 ui-note-box text-xs w-full")
with archival_card(title="Linked Documents"):
if not person.document_people:
render_empty_state("No linked documents yet.", italic=True)
render_empty_state("Link this person from a Document workflow.")
else:
with ui.column().classes("w-full gap-2"):
for link in person.document_people:
doc = link.document
if doc is None:
continue
role_code = link.role_ref.code if link.role_ref is not None else link.role.value
with ui.row().classes("w-full justify-between items-center ui-row-surface p-2"):
with ui.column().classes("gap-0"):
ui.label(doc.name).classes("text-xs font-semibold ui-text-primary")
ui.label(f"Role: {role_code}").classes("text-[10px] ui-text-muted")
ui.button(
"Open",
on_click=lambda _=None, doc_id=doc.id: ui.navigate.to(f"/documents/{doc_id}"),
icon="open_in_new",
).props("flat dense text-xs").classes("ui-link-primary")
_render_linked_documents(person)
def _render_linked_documents(person: Person) -> None:
if not person.document_people:
render_empty_state("No linked documents yet.", italic=True)
render_empty_state("Link this person from a Document workflow.")
return
with ui.column().classes("w-full gap-2"):
for link in person.document_people:
doc = link.document
if doc is None:
continue
role_code = link.role_ref.code if link.role_ref is not None else link.role.value
with ui.row().classes("w-full justify-between items-center ui-row-surface p-2"):
with ui.column().classes("gap-0"):
ui.label(doc.name).classes("text-xs font-semibold ui-text-primary")
ui.label(f"Role: {role_code}").classes("text-[10px] ui-text-muted")
ui.button(
"Open",
on_click=lambda _=None, doc_id=doc.id: ui.navigate.to(f"/documents/{doc_id}"),
icon="open_in_new",
).props("flat dense text-xs").classes("ui-link-primary")
# --- Utilities & Input Binding Helpers ---
+47 -23
View File
@@ -9,26 +9,30 @@ from uuid import UUID
from fastapi import Request
from nicegui import ui
from transcription.config import Settings, get_settings
from transcription.db.models import JobSource, Source
from transcription.services.sources import (
SourceDeleteBlockedError,
SourceService,
TranscriptionNotFoundError,
)
from transcription.config import Settings
from transcription.config import get_settings
from transcription.db.models import JobSource
from transcription.db.models import Source
from transcription.services.sources import SourceDeleteBlockedError
from transcription.services.sources import SourceService
from transcription.services.sources import TranscriptionNotFoundError
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.data_display import archival_badge, metadata_row
from transcription.ui.components.data_display import archival_badge
from transcription.ui.components.data_display import metadata_row
from transcription.ui.components.error_presenter import show_error
from transcription.ui.components.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.primitives import destructive_button
from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row
from transcription.ui.components.table.sources import SourceTableRow
from transcription.ui.components.table.sources import render_sources_table
from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep
def register_page() -> None:
def register_page() -> None: # noqa: PLR0915
"""Register sources list, detail, and deletion routes."""
@ui.page("/sources")
@@ -82,7 +86,6 @@ def register_page() -> None:
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",
@@ -110,6 +113,7 @@ def register_page() -> None:
try:
source = await sources_service.read_source_detail(parsed_source_id)
navigation = await sources_service.read_source_navigation(parsed_source_id)
except TranscriptionNotFoundError:
ui.label("Source not found").classes("text-h6 ui-text-danger p-4")
return
@@ -140,11 +144,13 @@ def register_page() -> None:
)
with ui.grid().classes("w-full grid-cols-12 gap-4"):
_render_source_viewer_zone(
source,
settings=_resolve_runtime_settings(request),
request=request,
)
with ui.column().classes("col-span-12 lg:col-span-4 gap-2"):
_render_source_navigation(navigation.previous_id, navigation.next_id)
_render_source_viewer_zone(
source,
settings=_resolve_runtime_settings(request),
request=request,
)
_render_source_transcription_column(
source=source,
original_transcription=original_transcription,
@@ -230,11 +236,29 @@ def register_page() -> None:
def _render_source_viewer_zone(source: Source, *, settings: Settings, request: Request) -> None:
with ui.column().classes("col-span-12 lg:col-span-4"):
dark_room_viewer(
_resolve_source_media_src(source.file_path, settings=settings, request=request),
count_label=f"Page {source.page_number}",
)
dark_room_viewer(
_resolve_source_media_src(source.file_path, settings=settings, request=request),
count_label=f"Page {source.page_number}",
)
def _render_source_navigation(previous_id: UUID | None, next_id: UUID | None) -> None:
with ui.row().classes("w-full justify-between items-center"):
previous = ui.button(
"Previous Page",
on_click=lambda: ui.navigate.to(f"/sources/{previous_id}"),
icon="chevron_left",
).props("flat dense")
if previous_id is None:
previous.props("disable")
following = ui.button(
"Next Page",
on_click=lambda: ui.navigate.to(f"/sources/{next_id}"),
icon="chevron_right",
).props("flat dense icon-right")
if next_id is None:
following.props("disable")
def _render_source_transcription_column(
@@ -453,7 +477,7 @@ def _resolve_source_media_src(path: str | None, *, settings: Settings, request:
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/"):
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)
+5
View File
@@ -269,6 +269,11 @@ input:focus-visible,
color: var(--theme-text);
}
.ui-table-cell-wrap {
overflow-wrap: anywhere;
white-space: normal;
}
.ui-table .q-table tbody tr:hover {
background-color: var(--theme-surface);
cursor: pointer;