V4.11 Added tags + lots of little changes to the UI
Quality Gate / gate (push) Failing after 12s

This commit is contained in:
Jim Lancaster
2026-08-22 18:32:52 -05:00
parent 63c21d4a14
commit 0d554c0648
36 changed files with 932 additions and 116 deletions
+2
View File
@@ -29,9 +29,11 @@ from transcription.db.engine import get_database_url
EXPORT_TABLE_ORDER = (
"document_type",
"person_role",
"tag",
"document",
"person",
"document_person",
"document_tag",
"job",
"source",
"job_source",
+52
View File
@@ -119,6 +119,28 @@ class PersonRole(SQLModel, table=True):
)
class Tag(SQLModel, table=True):
"""Registry of labels that can be attached to Documents."""
__tablename__ = "tag"
id: UUID = Field(default_factory=uuid4, primary_key=True)
semantic_key: str | None = Field(default=None, index=True, unique=True)
label: str
normalized_label: str = Field(index=True, unique=True)
is_active: bool = True
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(UTC),
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
)
document_tags: list["DocumentTag"] = Relationship(
back_populates="tag_ref",
sa_relationship_kwargs={"lazy": "raise"},
)
class Document(SQLModel, table=True):
"""An historical document."""
@@ -141,6 +163,10 @@ class Document(SQLModel, table=True):
document_people: list["DocumentPerson"] = Relationship(
back_populates="document", sa_relationship_kwargs={"lazy": "raise"}
)
document_tags: list["DocumentTag"] = Relationship(
back_populates="document",
sa_relationship_kwargs={"lazy": "raise"},
)
document_type_ref: Optional["DocumentType"] = Relationship(
back_populates="documents", sa_relationship_kwargs={"lazy": "raise"}
)
@@ -205,6 +231,32 @@ class DocumentPerson(SQLModel, table=True):
)
class DocumentTag(SQLModel, table=True):
"""Associates Documents with Tags."""
__tablename__ = "document_tag"
id: UUID = Field(default_factory=uuid4, primary_key=True)
document_id: UUID = Field(foreign_key="document.id", index=True)
tag_id: UUID = Field(foreign_key="tag.id", index=True)
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = Field(
default_factory=lambda: datetime.now(UTC),
sa_column_kwargs={"onupdate": lambda: datetime.now(UTC)},
)
__table_args__ = (UniqueConstraint("document_id", "tag_id", name="uq_document_tag"),)
document: Optional["Document"] = Relationship(
back_populates="document_tags",
sa_relationship_kwargs={"lazy": "raise"},
)
tag_ref: Optional["Tag"] = Relationship(
back_populates="document_tags",
sa_relationship_kwargs={"lazy": "raise"},
)
class Job(SQLModel, table=True):
"""A transcription job tied to a single document."""
+164
View File
@@ -20,7 +20,9 @@ from ..db.loading import orm_attribute
from ..db.loading import selectinload
from ..db.models import Document
from ..db.models import DocumentPerson
from ..db.models import DocumentTag
from ..db.models import DocumentType
from ..db.models import Tag
from ..db.registries import AUTHOR_ROLE_SEMANTIC_KEY
from ..errors import AppError
from ..errors import ErrorCategory
@@ -53,6 +55,10 @@ class DocumentTypeError(DocumentError):
"""Raised when Document Type maintenance fails."""
class TagError(DocumentError):
"""Raised when Tag maintenance fails."""
class DocumentTypeRegistry(RegistryService[DocumentType]):
"""Document Type registry maintenance."""
@@ -75,6 +81,28 @@ class DocumentTypeRegistry(RegistryService[DocumentType]):
type DocumentTypeSummary = RegistrySummary
class TagRegistry(RegistryService[Tag]):
"""Tag registry maintenance."""
model = Tag
error = TagError
noun = "Tag"
short_noun = "tag"
referenced_retainer = "historical Documents"
def reference_model(self) -> type[SQLModel]:
return DocumentTag
def reference_id_column(self) -> Any:
return col(DocumentTag.id)
def reference_key_column(self) -> Any:
return col(DocumentTag.tag_id)
type TagSummary = RegistrySummary
@dataclass(frozen=True, slots=True)
class DocumentPrintSource:
id: UUID
@@ -119,6 +147,7 @@ class DocumentService(ServiceBase):
) -> None:
super().__init__(session_factory, settings)
self._document_types = DocumentTypeRegistry(self.session_factory, self.settings)
self._tags = TagRegistry(self.session_factory, self.settings)
async def _validate_document_type(self, *, session: AsyncSession, document: Document) -> None:
"""Validate the UUID-backed Document Type reference."""
@@ -272,6 +301,8 @@ class DocumentService(ServiceBase):
selectinload(Document.document_people).selectinload(orm_attribute(DocumentPerson.person)),
selectinload(Document.document_people).selectinload(orm_attribute(DocumentPerson.role_ref)),
selectinload(Document.document_type_ref),
selectinload(Document.document_tags).selectinload(orm_attribute(DocumentTag.tag_ref)),
selectinload(Document.sources),
)
result = await _session.exec(query)
return result.all()
@@ -287,6 +318,7 @@ class DocumentService(ServiceBase):
selectinload(Document.document_people).selectinload(orm_attribute(DocumentPerson.person)),
selectinload(Document.document_people).selectinload(orm_attribute(DocumentPerson.role_ref)),
selectinload(Document.document_type_ref),
selectinload(Document.document_tags).selectinload(orm_attribute(DocumentTag.tag_ref)),
)
.where(Document.id == document_id)
.execution_options(populate_existing=True)
@@ -362,6 +394,15 @@ class DocumentService(ServiceBase):
"""List configured document types."""
return await self._document_types.list_entries(active_only=active_only, session=session)
async def list_tags(
self,
*,
active_only: bool = True,
session: AsyncSession | None = None,
) -> Sequence[Tag]:
"""List configured tags."""
return await self._tags.list_entries(active_only=active_only, session=session)
async def list_document_type_summaries(
self,
*,
@@ -380,6 +421,24 @@ class DocumentService(ServiceBase):
for document_type, document_count in rows
]
async def list_tag_summaries(
self,
*,
session: AsyncSession | None = None,
) -> Sequence[TagSummary]:
"""List Tags alphabetically with current usage counts."""
rows = await self._tags.list_entries_with_counts(session=session)
return [
RegistrySummary(
id=tag.id,
label=tag.label,
is_active=tag.is_active,
is_built_in=tag.semantic_key is not None,
reference_count=document_count,
)
for tag, document_count in rows
]
async def create_document_type(
self,
*,
@@ -390,6 +449,16 @@ class DocumentService(ServiceBase):
"""Create a UUID-identified Document Type with a unique label."""
return await self._document_types.create_entry(label=label, is_active=is_active, session=session)
async def create_tag(
self,
*,
label: str,
is_active: bool = True,
session: AsyncSession | None = None,
) -> Tag:
"""Create a UUID-identified Tag with a unique label."""
return await self._tags.create_entry(label=label, is_active=is_active, session=session)
async def read_document_type(
self,
document_type_id: UUID,
@@ -399,6 +468,15 @@ class DocumentService(ServiceBase):
"""Read a Document Type by id."""
return await self._document_types.read_entry(document_type_id, session=session)
async def read_tag(
self,
tag_id: UUID,
*,
session: AsyncSession | None = None,
) -> Tag:
"""Read a Tag by id."""
return await self._tags.read_entry(tag_id, session=session)
async def update_document_type(
self,
document_type_id: UUID,
@@ -415,6 +493,22 @@ class DocumentService(ServiceBase):
session=session,
)
async def update_tag(
self,
tag_id: UUID,
*,
label: str,
is_active: bool,
session: AsyncSession | None = None,
) -> Tag:
"""Update a Tag label and active state."""
return await self._tags.update_entry(
tag_id,
label=label,
is_active=is_active,
session=session,
)
async def delete_document_type(
self,
document_type_id: UUID,
@@ -424,6 +518,15 @@ class DocumentService(ServiceBase):
"""Delete an unreferenced Document Type without cascade behavior."""
await self._document_types.delete_entry(document_type_id, session=session)
async def delete_tag(
self,
tag_id: UUID,
*,
session: AsyncSession | None = None,
) -> None:
"""Delete an unreferenced Tag without cascade behavior."""
await self._tags.delete_entry(tag_id, session=session)
async def is_document_type_referenced(
self,
document_type_id: UUID,
@@ -433,6 +536,15 @@ class DocumentService(ServiceBase):
"""Return whether a Document references a Document Type."""
return await self._document_types.is_referenced(document_type_id, session=session)
async def is_tag_referenced(
self,
tag_id: UUID,
*,
session: AsyncSession | None = None,
) -> bool:
"""Return whether a Document references a Tag."""
return await self._tags.is_referenced(tag_id, session=session)
async def set_document_type(
self,
*,
@@ -448,6 +560,58 @@ class DocumentService(ServiceBase):
await self._finalize(session=_session, caller_session=session, refresh=(document,))
return document
async def sync_document_tags_by_labels(
self,
*,
document_id: UUID,
labels: Sequence[str],
session: AsyncSession | None = None,
) -> None:
"""Replace a Document's tag set using label-based assignment."""
normalized_labels = [self._tags.normalize_label(label) for label in labels]
deduplicated_labels = list(dict.fromkeys(normalized_labels))
label_keys = [self._tags.label_key(label) for label in deduplicated_labels]
async with self._session_scope(session) as _session:
existing_document = await _session.get(Document, document_id)
if existing_document is None:
raise DocumentError(
f"Document with id {document_id} not found",
category=ErrorCategory.NOT_FOUND,
suggestion="Refresh and select an existing document.",
)
existing_tags = (
(await _session.exec(select(Tag).where(col(Tag.normalized_label).in_(label_keys))))
.all()
if label_keys
else []
)
tags_by_key = {tag.normalized_label: tag for tag in existing_tags}
selected_tag_ids: set[UUID] = set()
for label in deduplicated_labels:
key = self._tags.label_key(label)
tag = tags_by_key.get(key)
if tag is None:
tag = await self._tags.create_entry(label=label, is_active=True, session=_session)
tags_by_key[key] = tag
selected_tag_ids.add(tag.id)
links = (
await _session.exec(select(DocumentTag).where(DocumentTag.document_id == document_id))
).all()
existing_ids = {link.tag_id for link in links}
for link in links:
if link.tag_id not in selected_tag_ids:
await _session.delete(link)
for tag_id in selected_tag_ids - existing_ids:
_session.add(DocumentTag(document_id=document_id, tag_id=tag_id))
await self._finalize(session=_session, caller_session=session)
def _print_media_type(filename: str) -> str:
"""Resolve a stored Source filename to its MIME type for print rendering."""
+2 -1
View File
@@ -217,7 +217,8 @@ class PeopleService(ServiceBase):
async def list_people(self, *, session: AsyncSession | None = None) -> Sequence[Person]:
async with self._session_scope(session) as _session:
return (await _session.exec(select(Person))).all()
query = select(Person).options(selectinload(Person.document_people))
return (await _session.exec(query)).all()
async def list_person_roles(
self,
+4
View File
@@ -44,6 +44,7 @@ async def create_document_with_people(
*,
document: Document,
links: list[DocumentPersonInput],
tag_labels: list[str],
documents: DocumentService,
people: PeopleService,
) -> Document:
@@ -51,6 +52,7 @@ async def create_document_with_people(
async with transaction_scope(session_factory=documents.session_factory) as session:
created = await documents.create_document(document, session=session)
await people.sync_document_people(document_id=created.id, links=links, session=session)
await documents.sync_document_tags_by_labels(document_id=created.id, labels=tag_labels, session=session)
return created
@@ -58,6 +60,7 @@ async def update_document_with_people(
*,
document: Document,
links: list[DocumentPersonInput],
tag_labels: list[str],
documents: DocumentService,
people: PeopleService,
) -> Document:
@@ -65,6 +68,7 @@ async def update_document_with_people(
async with transaction_scope(session_factory=documents.session_factory) as session:
updated = await documents.update_document(document, session=session)
await people.sync_document_people(document_id=updated.id, links=links, session=session)
await documents.sync_document_tags_by_labels(document_id=updated.id, labels=tag_labels, session=session)
return updated
+2
View File
@@ -13,6 +13,7 @@ from transcription.ui.pages.people_page import register_page as register_people_
from transcription.ui.pages.print_preview_page import register_page as register_print_preview_page
from transcription.ui.pages.settings_page import register_page as register_settings_page
from transcription.ui.pages.sources_page import register_page as register_sources_page
from transcription.ui.pages.tags_page import register_page as register_tags_page
from transcription.ui.resources import read_css
from transcription.ui.theme import apply_archival_theme
@@ -37,6 +38,7 @@ def register_pages(app: FastAPI) -> None:
_register_global_styles(app)
register_home_page()
register_documents_page()
register_tags_page()
register_people_page()
register_print_preview_page()
register_sources_page()
@@ -8,6 +8,7 @@ from transcription.ui.resources import read_svg
NAV_ITEMS: tuple[tuple[str, str, str], ...] = (
("Documents", "/documents", "description"),
("Tags", "/tags", "sell"),
("People", "/people", "group"),
("Sources", "/sources", "folder"),
("Jobs", "/jobs", "work_history"),
@@ -22,6 +23,8 @@ def _is_active_path(*, current_path: str, item_path: str) -> bool:
return current_path == "/documents" or current_path.startswith("/documents/")
if item_path == "/people":
return current_path == "/people" or current_path.startswith("/people/")
if item_path == "/tags":
return current_path == "/tags" or current_path.startswith("/tags/")
if item_path == "/sources":
return current_path == "/sources" or current_path.startswith("/sources/")
if item_path == "/settings":
@@ -23,7 +23,7 @@ class DocumentTableRow:
document_type: str
authors: str
document_date: str
archive_identifier: str
source_count: int
def _serialize_rows(rows: Sequence[DocumentTableRow]) -> list[dict[str, Any]]:
@@ -34,7 +34,7 @@ def _serialize_rows(rows: Sequence[DocumentTableRow]) -> list[dict[str, Any]]:
"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",
"source_count": row.source_count,
}
for row in rows
]
@@ -87,17 +87,17 @@ def render_documents_table(rows: Sequence[DocumentTableRow]) -> None:
"style": "width: 14%;",
},
{
"name": "archive_identifier",
"label": "Archive Ref",
"field": "archive_identifier",
"name": "source_count",
"label": "# Sources",
"field": "source_count",
"sortable": True,
"classes": "font-mono text-xs",
"classes": "font-mono",
"align": "center",
"style": "width: 20%;",
},
],
default_sort_by="name",
search_placeholder="Search documents by title, type, or reference...",
search_placeholder="Search documents by title, type, or author...",
on_row_click_id=lambda doc_id: ui.navigate.to(f"/documents/{doc_id}"),
)
+13 -4
View File
@@ -24,6 +24,7 @@ class JobTableRow:
id: UUID
status: str
document_name: str
source_count: int
retry_count: int
date_created: str
date_updated: str
@@ -45,6 +46,7 @@ def _serialize_rows(rows: Sequence[JobTableRow]) -> list[dict[str, Any]]:
"id": str(row.id),
"status": row.status.lower(),
"document_name": row.document_name,
"source_count": row.source_count,
"retry_count": row.retry_count,
"date_created": _format_timestamp(row.date_created),
"date_updated": _format_timestamp(row.date_updated),
@@ -71,7 +73,7 @@ def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
"field": "id",
"sortable": True,
"classes": "font-mono text-xs",
"style": "width: 30%;",
"style": "width: 26%;",
},
{
"name": "status",
@@ -79,7 +81,7 @@ def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
"field": "status",
"sortable": True,
"classes": "font-mono",
"style": "width: 15%;",
"style": "width: 13%;",
},
{
"name": "document_name",
@@ -88,7 +90,14 @@ def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
"sortable": True,
"classes": "font-serif text-left ui-table-cell-wrap",
"align": "left",
"style": "width: 35%;",
"style": "width: 31%;",
},
{
"name": "source_count",
"label": "# Sources",
"field": "source_count",
"sortable": True,
"style": "width: 8%;",
},
{
"name": "retry_count",
@@ -102,7 +111,7 @@ def render_jobs_table(rows: Sequence[JobTableRow]) -> None:
"label": "Updated",
"field": "updated_sort",
"sortable": True,
"style": "width: 12%;",
"style": "width: 14%;",
},
],
default_sort_by="updated_sort",
+14 -21
View File
@@ -20,10 +20,10 @@ class PersonTableRow:
id: UUID
full_name: str
display_name: str
maiden_name: str
family_search_id: str
birth_date: str
death_date: str
document_count: int
def _serialize_rows(rows: Sequence[PersonTableRow]) -> list[dict[str, Any]]:
@@ -31,10 +31,10 @@ def _serialize_rows(rows: Sequence[PersonTableRow]) -> list[dict[str, Any]]:
{
"id": str(row.id),
"full_name": row.full_name,
"display_name": row.display_name or "Not set",
"maiden_name": row.maiden_name or "N/A",
"family_search_id": row.family_search_id or "Not set",
"birth_date": row.birth_date or "Unknown",
"death_date": row.death_date or "Unknown",
"document_count": row.document_count,
}
for row in rows
]
@@ -57,22 +57,7 @@ def render_people_table(rows: Sequence[PersonTableRow]) -> None:
"sortable": True,
"classes": "font-serif font-semibold text-left ui-table-cell-wrap",
},
{
"name": "display_name",
"label": "Display Name",
"field": "display_name",
"sortable": True,
"classes": "ui-table-cell-wrap",
"align": "center",
},
{
"name": "maiden_name",
"label": "Maiden Name",
"field": "maiden_name",
"sortable": True,
"classes": "ui-table-cell-wrap",
"align": "center",
},
{"name": "family_search_id", "label": "FamilySearch ID", "field": "family_search_id", "sortable": True},
{
"name": "birth_date",
"label": "Birth Date",
@@ -89,9 +74,17 @@ def render_people_table(rows: Sequence[PersonTableRow]) -> None:
"classes": "font-mono text-xs",
"align": "center",
},
{
"name": "document_count",
"label": "# Documents",
"field": "document_count",
"sortable": True,
"align": "center",
"classes": "font-mono",
},
],
default_sort_by="full_name",
search_placeholder="Search people by name, birth date, or death date...",
search_placeholder="Search people by name, FamilySearch ID, or dates...",
on_row_click_id=lambda person_id: ui.navigate.to(f"/people/{person_id}"),
)
+67 -5
View File
@@ -10,6 +10,7 @@ from fastapi import Request
from fastapi.responses import RedirectResponse
from nicegui import ui
from transcription.config import Settings
from transcription.db.models import Document
from transcription.db.registries import AUTHOR_ROLE_SEMANTIC_KEY
from transcription.errors import ErrorCategory
@@ -35,12 +36,14 @@ from transcription.ui.components.guards import parsed_record_id
from transcription.ui.components.guards import render_record_not_found
from transcription.ui.components.linked_people import LinkedPeopleEditor
from transcription.ui.components.linked_people import StagedLinkedPerson
from transcription.ui.components.media_urls import resolve_media_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.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.runtime import resolve_runtime_settings
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep
@@ -58,6 +61,7 @@ class DocumentFormFields:
location: ui.input
archive: ui.input
notes: ui.textarea
tags: ui.select
def register_page() -> None: # noqa: PLR0915
@@ -75,6 +79,7 @@ def register_page() -> None: # noqa: PLR0915
people = sorted(await people_service.list_people(), key=lambda item: item.full_name.casefold())
role_catalog = list(await people_service.list_person_roles(active_only=False))
type_catalog = await document_service.list_document_types()
tag_catalog = await document_service.list_tags(active_only=True)
requested_person_id = parse_uuid(request.query_params.get("person_id"))
staged_links: list[StagedLinkedPerson] = []
if requested_person_id is not None and any(person.id == requested_person_id for person in people):
@@ -101,6 +106,7 @@ def register_page() -> None: # noqa: PLR0915
)
form = _render_document_form_fields(
type_options={str(doc_type.id): doc_type.label for doc_type in type_catalog},
tag_options=[tag.label for tag in tag_catalog],
linked_people=linked_people,
)
@@ -137,6 +143,7 @@ def register_page() -> None: # noqa: PLR0915
action=lambda: create_document_with_people(
document=candidate,
links=linked_people.values(),
tag_labels=_resolve_selected_tag_labels(form.tags.value),
documents=document_service,
people=people_service,
),
@@ -189,16 +196,17 @@ def register_page() -> None: # noqa: PLR0915
document_type=(doc.document_type_ref.label if doc.document_type_ref is not None else ""),
authors=", ".join(_author_names(doc)),
document_date=compact_date(doc.document_date, doc.document_date_raw),
archive_identifier=doc.archive_identifier or "",
source_count=len(doc.sources),
)
for doc in documents
]
render_documents_table(rows)
@ui.page("/documents/{document_id}")
async def document_detail_page(document_id: str, session_factory: SessionFactoryDep) -> None:
async def document_detail_page(request: Request, document_id: str, session_factory: SessionFactoryDep) -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/documents")
settings = resolve_runtime_settings(request)
parsed_doc_id = parsed_record_id(document_id, noun="Document")
if parsed_doc_id is None:
@@ -237,7 +245,7 @@ def register_page() -> None: # noqa: PLR0915
)
with ui.grid().classes("w-full grid-cols-12 gap-4"):
_render_bento_viewer_zone(document)
_render_bento_viewer_zone(document, base_url=str(request.base_url), settings=settings)
_render_bento_metadata_zone(document)
_render_bento_relations_zone(document)
@@ -276,6 +284,7 @@ def register_page() -> None: # noqa: PLR0915
people = sorted(await people_service.list_people(), key=lambda item: item.full_name.casefold())
role_catalog = list(await people_service.list_person_roles(active_only=False))
type_catalog = await document_service.list_document_types(active_only=False)
tag_catalog = await document_service.list_tags(active_only=False)
linked_people = LinkedPeopleEditor(
people=people,
roles=role_catalog,
@@ -284,6 +293,7 @@ def register_page() -> None: # noqa: PLR0915
form = _render_document_form_fields(
document=document,
type_options={str(doc_type.id): doc_type.label for doc_type in type_catalog},
tag_options=[tag.label for tag in tag_catalog],
linked_people=linked_people,
)
@@ -321,6 +331,7 @@ def register_page() -> None: # noqa: PLR0915
action=lambda: update_document_with_people(
document=candidate,
links=linked_people.values(),
tag_labels=_resolve_selected_tag_labels(form.tags.value),
documents=document_service,
people=people_service,
),
@@ -410,6 +421,7 @@ def _render_document_form_fields(
*,
document: Document | None = None,
type_options: dict[str, str],
tag_options: list[str],
linked_people: LinkedPeopleEditor,
) -> DocumentFormFields:
with archival_card(extra_classes="gap-3"):
@@ -471,6 +483,26 @@ def _render_document_form_fields(
.props("outlined autogrow")
.classes("w-full ui-form-surface")
)
selected_tags = (
sorted(
[
link.tag_ref.label
for link in (document.document_tags if document is not None else [])
if link.tag_ref is not None
],
key=str.casefold,
)
if document is not None
else []
)
tags_input = ui.select(
sorted(tag_options, key=str.casefold),
label="Tags",
value=selected_tags,
multiple=True,
with_input=True,
new_value_mode="add-unique",
).props("outlined use-chips").classes("w-full ui-form-surface")
linked_people.render()
@@ -483,13 +515,15 @@ def _render_document_form_fields(
location=location_input,
archive=archive_input,
notes=notes_input,
tags=tags_input,
)
def _render_bento_viewer_zone(document: Document) -> None:
def _render_bento_viewer_zone(document: Document, *, base_url: str, settings: Settings) -> 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")
source_url = resolve_media_url(source_path, upload_dir=settings.upload_dir, base_url=base_url)
dark_room_viewer(source_url, count_label=f"{len(document.sources)} Source(s) Linked")
def _render_bento_metadata_zone(document: Document) -> None:
@@ -502,6 +536,11 @@ def _render_bento_metadata_zone(document: Document) -> None:
"Document Type:",
document.document_type_ref.label if document.document_type_ref is not None else "Not set",
)
tags = sorted(
[link.tag_ref.label for link in document.document_tags if link.tag_ref is not None],
key=str.casefold,
)
metadata_row("Tags:", ", ".join(tags) if tags else "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")
@@ -587,3 +626,26 @@ def _author_names(document: Document) -> list[str]:
),
key=str.casefold,
)
def _resolve_selected_tag_labels(value: object) -> list[str]:
def flatten(item: object) -> list[str]:
if item is None:
return []
if isinstance(item, str):
return [item]
if isinstance(item, dict):
if "value" in item:
return flatten(item.get("value"))
if "label" in item:
return flatten(item.get("label"))
return []
if isinstance(item, (list, tuple, set)):
values: list[str] = []
for child in item:
values.extend(flatten(child))
return values
return [str(item)]
labels = [candidate.strip() for candidate in flatten(value) if candidate and candidate.strip()]
return list(dict.fromkeys(labels))
+16 -5
View File
@@ -79,6 +79,7 @@ def register_page() -> None: # noqa: PLR0915
id=job.id,
status=job.status.value,
document_name=job.document.name if job.document is not None else "Unknown document",
source_count=len(job.job_sources),
retry_count=job.retry_count,
date_created=job.date_created.isoformat(),
date_updated=job.date_updated.isoformat(),
@@ -146,20 +147,30 @@ def register_page() -> None: # noqa: PLR0915
document_select.value = requested_document_id
with ui.row().classes("w-full gap-3 grid grid-cols-1 md:grid-cols-2"):
provider_options = [settings.provider.value]
model_options = list(settings.provider_models) or ([settings.provider_model] if settings.provider_model else [])
if locked_source is not None:
provider_input = (
ui.input(label="Provider", value=settings.provider.value)
.props("outlined readonly")
ui.select(provider_options, label="Provider", value=settings.provider.value)
.props("outlined disable")
.classes("ui-form-surface")
)
model_input = (
ui.select(list(settings.provider_models), label="Model", value=settings.provider_model)
ui.select(model_options, label="Model", value=settings.provider_model)
.props("outlined")
.classes("ui-form-surface")
)
else:
provider_input = ui.input(label="Provider").props("outlined").classes("ui-form-surface")
model_input = ui.input(label="Model").props("outlined").classes("ui-form-surface")
provider_input = (
ui.select(provider_options, label="Provider", value=settings.provider.value)
.props("outlined")
.classes("ui-form-surface")
)
model_input = (
ui.select(model_options, label="Model", value=settings.provider_model)
.props("outlined use-input")
.classes("ui-form-surface")
)
if locked_source is None:
_render_upload_section(uploaded_files)
+2 -2
View File
@@ -98,10 +98,10 @@ def register_page() -> None: # noqa: PLR0915
PersonTableRow(
id=person.id,
full_name=person.full_name,
display_name=person.display_name or "",
maiden_name=person.maiden_name or "",
family_search_id=person.family_search_id or "",
birth_date=compact_date(person.birth_date, person.birth_date_raw),
death_date=compact_date(person.death_date, person.death_date_raw),
document_count=len(person.document_people),
)
for person in people
]
+117
View File
@@ -271,6 +271,120 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
ui.button("Edit", icon="edit", on_click=lambda: open_role_editor(creating=False)).props("flat")
destructive_button("Delete", icon="delete", on_click=delete_selected_role)
@ui.refreshable
async def render_tags() -> None: # noqa: PLR0915
with archival_card("Tags"):
ui.label("Tags are listed alphabetically. Select one row to edit or delete it.").classes(
"text-xs ui-text-muted mb-3"
)
tags_outcome = await run_ui_action(
operation="settings.tags.list",
title="Tags unavailable",
action=documents.list_tag_summaries,
)
if not tags_outcome.ok:
return
tags = tags_outcome.value or ()
if not tags:
render_empty_state("No Tags are configured.", extra_classes="mt-3")
rows = [
{
"id": str(item.id),
"label": item.label,
"reference_count": item.reference_count,
"is_active": item.is_active,
"is_built_in": item.is_built_in,
}
for item in tags
]
table = render_registry_table(
rows,
count_field="reference_count",
count_label="Documents",
)
async def save_tag(
*,
item_id: UUID | None,
label: str,
is_active: bool,
) -> bool:
async def _save_tag() -> None:
if item_id is None:
await documents.create_tag(label=label, is_active=is_active)
return
await documents.update_tag(
item_id,
label=label,
is_active=is_active,
)
save_outcome = await run_ui_action(
operation="settings.tags.save",
title="Tag save failed",
action=_save_tag,
)
if not save_outcome.ok:
return False
ui.notify("Tag saved", type="positive")
render_tags.refresh()
return True
def open_tag_editor(*, creating: bool) -> None:
selected = _selected_table_row(table)
if not creating and selected is None:
ui.notify("Select one Tag to edit.", type="warning")
return
if creating:
item_id = None
current_label = ""
current_active = True
else:
assert selected is not None
item_id = UUID(str(selected["id"]))
current_label = str(selected["label"])
current_active = bool(selected["is_active"])
with ui.dialog() as dialog, ui.card().classes("w-full max-w-lg ui-card-surface"):
ui.label("Add Tag" if creating else "Edit Tag").classes("text-lg font-semibold")
label_input = ui.input("Label", value=current_label).props("outlined").classes("w-full")
active_input = ui.checkbox("Active", value=current_active)
async def submit() -> None:
saved = await save_tag(
item_id=item_id,
label=str(label_input.value or ""),
is_active=bool(active_input.value),
)
if saved:
dialog.close()
with ui.row().classes("w-full justify-end gap-2"):
ui.button("Cancel", on_click=dialog.close).props("flat")
ui.button("Save", icon="save", on_click=submit).classes("ui-btn-primary")
dialog.open()
async def delete_selected_tag() -> None:
selected = _selected_table_row(table)
if selected is None:
ui.notify("Select one Tag to delete.", type="warning")
return
delete_outcome = await run_ui_action(
operation="settings.tags.delete",
title="Tag deletion failed",
action=lambda: documents.delete_tag(UUID(str(selected["id"]))),
)
if not delete_outcome.ok:
return
ui.notify("Tag deleted", type="positive")
render_tags.refresh()
with ui.row().classes("w-full items-center gap-2 mt-3"):
ui.button("Add", icon="add", on_click=lambda: open_tag_editor(creating=True)).classes(
"ui-btn-primary"
)
ui.button("Edit", icon="edit", on_click=lambda: open_tag_editor(creating=False)).props("flat")
destructive_button("Delete", icon="delete", on_click=delete_selected_tag)
@ui.refreshable
async def render_prompts() -> None:
with archival_card("Prompts"):
@@ -381,6 +495,7 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
with ui.tabs().classes("w-full") as tabs:
document_types_tab = ui.tab("Document Types")
person_roles_tab = ui.tab("Person Roles")
tags_tab = ui.tab("Tags")
prompts_tab = ui.tab("Prompts")
home_page_text_tab = ui.tab("Home Page Text")
@@ -389,6 +504,8 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915
await render_document_types()
with ui.tab_panel(person_roles_tab):
await render_person_roles()
with ui.tab_panel(tags_tab):
await render_tags()
with ui.tab_panel(prompts_tab):
await render_prompts()
with ui.tab_panel(home_page_text_tab):
+21 -25
View File
@@ -176,24 +176,26 @@ def register_page() -> None: # noqa: PLR0915
extra_classes="text-xs",
)
with ui.grid().classes("w-full grid-cols-12 gap-4"):
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,
with ui.column().classes("col-span-12 lg:col-span-8 gap-4"):
with ui.grid().classes("w-full grid-cols-1 lg:grid-cols-2 gap-4"):
with ui.column().classes("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,
latest_job_source=latest_job_source,
sources_service=sources_service,
)
_render_machine_candidates(
source=source,
attempts=attempts,
evidence_service=evidence_service,
)
_render_source_transcription_column(
source=source,
original_transcription=original_transcription,
latest_job_source=latest_job_source,
sources_service=sources_service,
)
_render_machine_candidates(
source=source,
attempts=attempts,
evidence_service=evidence_service,
)
_render_source_metadata_column(
source=source,
latest_job_source=latest_job_source,
@@ -301,7 +303,7 @@ def _render_source_transcription_column(
latest_job_source: JobSource | None,
sources_service: SourceService,
) -> None:
with ui.column().classes("col-span-12 lg:col-span-4 gap-4"):
with ui.column().classes("gap-4"):
_render_source_transcription_zone(
source=source,
original_transcription=original_transcription,
@@ -474,12 +476,6 @@ def _render_source_transcription_zone(
latest_job_source: JobSource | None,
sources_service: SourceService,
) -> None:
with archival_card(title="Transcription Text"):
if original_transcription:
ui.label(original_transcription).classes("p-2 ui-note-box text-xs whitespace-pre-wrap")
else:
render_empty_state("No transcription text available yet.", italic=True)
with archival_card(title="Editable Revision"):
seed_revision = source.revised_text if source.revised_text is not None else (original_transcription or "")
revision_input = (
@@ -565,7 +561,7 @@ def _render_machine_candidates(
None,
)
with ui.column().classes("col-span-12 lg:col-span-8 gap-2"): # noqa: PLR1702, SIM117
with ui.column().classes("w-full gap-2"): # noqa: PLR1702, SIM117
with archival_card(title="Candidate Machine Transcriptions"):
if preferred_attempt is not None:
_render_attempt_warnings(preferred_attempt, label="Preferred machine transcription warnings")
+92
View File
@@ -0,0 +1,92 @@
"""Tags browse and filter page registration."""
from __future__ import annotations
from nicegui import ui
from transcription.services.documents import DocumentService
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.cards import archival_card
from transcription.ui.components.error_presenter import run_ui_action
from transcription.ui.components.primitives import render_empty_state
from transcription.ui.components.primitives import section_header_row
from transcription.ui.theme import page_header
from ...db.session import SessionFactoryDep
def register_page() -> None:
"""Register the tags browse/filter route."""
@ui.page("/tags")
async def tags_page(session_factory: SessionFactoryDep) -> None:
document_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/tags")
tags_outcome = await run_ui_action(
operation="tags.list",
title="Tags unavailable",
action=document_service.list_tag_summaries,
)
if not tags_outcome.ok:
return
tag_summaries = tags_outcome.value or ()
tag_labels = [item.label for item in tag_summaries]
documents_outcome = await run_ui_action(
operation="documents.list",
title="Documents unavailable",
action=document_service.list_documents,
)
if not documents_outcome.ok:
return
documents = documents_outcome.value or ()
with ui.column().classes("w-full max-w-7xl mx-auto p-4 gap-4"):
with section_header_row():
page_header("Tags", subtitle="Browse documents by tag.")
if not tag_summaries:
with archival_card(extra_classes="p-8 text-center"):
render_empty_state("No tags are configured yet.")
return
selected_tag = (
ui.select(tag_labels, label="Filter by tag")
.props("outlined clearable use-input")
.classes("w-full md:w-96 ui-form-surface")
)
@ui.refreshable
def render_groups() -> None:
selected = str(selected_tag.value or "").strip()
with ui.column().classes("w-full gap-3"):
rendered_any = False
for summary in tag_summaries:
if selected and summary.label != selected:
continue
tagged_documents = [
document
for document in documents
if any(
link.tag_ref is not None and link.tag_ref.id == summary.id
for link in document.document_tags
)
]
if not tagged_documents:
continue
rendered_any = True
with archival_card(title=f"{summary.label} ({len(tagged_documents)})"):
for document in sorted(tagged_documents, key=lambda item: item.name.casefold()):
ui.button(
document.name,
on_click=lambda _=None, doc_id=document.id: ui.navigate.to(f"/documents/{doc_id}"),
icon="description",
).props("flat dense no-caps").classes("self-start ui-link-primary text-xs")
if not rendered_any:
with archival_card(extra_classes="p-6"):
render_empty_state("No documents match this tag filter.", italic=True)
selected_tag.on_value_change(lambda _event: render_groups.refresh())
render_groups()