generated from john/python-template
V4.1 Mostly UI adjustments by GC
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user