UI slog continues

This commit is contained in:
Jim Lancaster
2026-08-02 20:03:02 -05:00
parent 49e2e48df1
commit c098013a68
7 changed files with 156 additions and 31 deletions
+30 -7
View File
@@ -25,6 +25,7 @@ from .documents import UploadJobResult
logger = logging.getLogger(__name__)
SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
SUPPORTED_PORTRAIT_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tif", ".tiff"}
class UploadError(AppError):
@@ -246,13 +247,35 @@ def _best_effort_delete(path: Path) -> None:
def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
"""Persist an uploaded file to the configured upload directory."""
runtime_settings = settings or get_settings()
_validate_upload(filename=filename, file_bytes=file_bytes)
_validate_upload(filename=filename, file_bytes=file_bytes, supported_extensions=SUPPORTED_UPLOAD_EXTENSIONS)
return _store_file_bytes(filename=filename, file_bytes=file_bytes, settings=runtime_settings)
upload_dir = runtime_settings.upload_dir
upload_dir.mkdir(parents=True, exist_ok=True)
def store_person_portrait(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
"""Persist a portrait upload under uploads/portraits/person."""
runtime_settings = settings or get_settings()
_validate_upload(filename=filename, file_bytes=file_bytes, supported_extensions=SUPPORTED_PORTRAIT_EXTENSIONS)
return _store_file_bytes(
filename=filename,
file_bytes=file_bytes,
settings=runtime_settings,
relative_directory=Path("portraits") / "person",
)
def _store_file_bytes(
*,
filename: str,
file_bytes: bytes,
settings: Settings,
relative_directory: Path | None = None,
) -> Path:
upload_dir = settings.upload_dir
target_dir = upload_dir if relative_directory is None else upload_dir / relative_directory
target_dir.mkdir(parents=True, exist_ok=True)
stored_name = _build_stored_filename(filename)
stored_path = upload_dir / stored_name
stored_path = target_dir / stored_name
try:
stored_path.write_bytes(file_bytes)
@@ -267,7 +290,7 @@ def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None =
return stored_path
def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
def _validate_upload(*, filename: str, file_bytes: bytes, supported_extensions: set[str]) -> None:
if not file_bytes:
raise UploadError(
"Upload payload is empty",
@@ -284,11 +307,11 @@ def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
)
suffix = Path(safe_name).suffix.lower()
if suffix not in SUPPORTED_UPLOAD_EXTENSIONS:
if suffix not in supported_extensions:
raise UploadError(
f"Unsupported upload extension: {suffix}",
category=ErrorCategory.USER_INPUT,
suggestion="Upload JPG, JPEG, PNG, TIFF, or PDF files only.",
suggestion="Upload a supported image or document file and retry.",
)
+42 -7
View File
@@ -21,6 +21,8 @@ from transcription.ui.components.error_presenter import show_error
from ...db.session import SessionFactoryDep
CREATE_NEW_PERSON_OPTION = "__create_new_person__"
def register_page() -> None:
"""Register documents list and detail routes."""
@@ -41,8 +43,20 @@ def register_page() -> None:
archive_input = ui.input(label="Archive identifier").props("outlined")
notes_input = ui.textarea(label="Notes").props("outlined autogrow")
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
author_options = {"": "No author"} | {str(person.id): person.full_name for person in people}
author_select = ui.select(author_options, label="Author (Person)", value="").props("outlined")
author_options = (
{"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"}
| {str(person.id): person.full_name for person in people}
)
def on_author_change(event) -> None:
selected = str(event.value or "").strip()
if selected == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
author_select = ui.select(author_options, label="Author (Person)", value="", on_change=on_author_change).props(
"outlined"
)
ui.link("Create new person", "/people/new").classes("text-body2")
return_to = request.query_params.get("return_to")
@@ -78,6 +92,9 @@ def register_page() -> None:
return
selected_author = (author_select.value or "").strip()
if selected_author == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
return
if selected_author:
try:
parsed_person_id = UUID(selected_author)
@@ -216,9 +233,9 @@ def register_page() -> None:
icon="description",
).props("flat")
ui.button(
"Add sources",
"+ Add Source",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="upload_file",
icon="add",
).props('unelevated color="primary"')
ui.label(f"{len(document.sources)} source(s) linked").classes("text-body2")
@@ -229,7 +246,7 @@ def register_page() -> None:
"flat"
)
ui.button(
"Create job",
"+ Add Job",
on_click=lambda: ui.navigate.to(f"/jobs/new?document_id={document.id}"),
icon="add",
).props('unelevated color="primary"')
@@ -313,13 +330,28 @@ def register_page() -> None:
archive_input = ui.input(label="Archive identifier", value=document.archive_identifier or "").props("outlined")
notes_input = ui.textarea(label="Notes", value=document.notes or "").props("outlined autogrow")
people = sorted(await document_service.list_people(), key=lambda item: item.full_name.casefold())
author_options = {"": "No author"} | {str(person.id): person.full_name for person in people}
author_options = (
{"": "No author", CREATE_NEW_PERSON_OPTION: "Create new item"}
| {str(person.id): person.full_name for person in people}
)
existing_author = next(
(item for item in document.document_people if item.role == DocumentPersonRole.AUTHOR),
None,
)
author_value = str(existing_author.person_id) if existing_author is not None else ""
author_select = ui.select(author_options, label="Author (Person)", value=author_value).props("outlined")
def on_author_change(event) -> None:
selected = str(event.value or "").strip()
if selected == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
author_select = ui.select(
author_options,
label="Author (Person)",
value=author_value,
on_change=on_author_change,
).props("outlined")
ui.link("Create new person", "/people/new").classes("text-body2")
async def submit_edit() -> None:
candidate_name = (name_input.value or "").strip()
@@ -360,6 +392,9 @@ def register_page() -> None:
return
selected_author = (author_select.value or "").strip()
if selected_author == CREATE_NEW_PERSON_OPTION:
ui.navigate.to("/people/new")
return
existing_author_links = [
link for link in document.document_people if link.role == DocumentPersonRole.AUTHOR
]
+39 -8
View File
@@ -6,13 +6,18 @@ from datetime import date
from urllib.parse import quote
from uuid import UUID
from fastapi import Request
from nicegui import ui
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.documents import DocumentError
from transcription.services.documents import DocumentService
from transcription.services.documents import PersonDeleteBlockedError
from transcription.services.store import UploadError
from transcription.services.store import store_person_portrait
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.error_presenter import show_error
@@ -30,17 +35,36 @@ def _parse_optional_date(value: str | None, *, label: str) -> date | None:
raise ValueError(f"{label} must use YYYY-MM-DD.") from exc
def _bind_portrait_file_picker(portrait_path_input: ui.input) -> None:
def _bind_portrait_file_picker(portrait_path_input: ui.input, *, settings: Settings) -> None:
async def on_portrait_selected(event) -> None:
portrait_path_input.value = event.file.name
ui.notify("Portrait filename selected. Edit the path if needed.", type="info")
payload = await event.file.read()
try:
stored_path = store_person_portrait(
filename=event.file.name,
file_bytes=payload,
settings=settings,
)
except UploadError as exc:
ui.notify(str(exc), type="negative")
return
except Exception: # noqa: BLE001
ui.notify("Unable to store portrait image.", type="negative")
return
try:
relative_path = stored_path.resolve().relative_to(settings.upload_dir.resolve()).as_posix()
except ValueError:
relative_path = stored_path.name
portrait_path_input.value = relative_path
ui.notify("Portrait uploaded.", type="positive")
ui.upload(
on_upload=on_portrait_selected,
auto_upload=True,
label="Choose portrait file",
).props('accept=".jpg,.jpeg,.png,.gif,.webp,.bmp,.tif,.tiff"')
ui.label("This picker fills the filename from your selected image.").classes("text-body2 vibe-text-muted")
ui.label("Portraits are stored under uploads/portraits/person.").classes("text-body2 vibe-text-muted")
def _resolve_portrait_src(path: str | None) -> str | None:
@@ -59,6 +83,13 @@ def _resolve_portrait_src(path: str | None) -> str | None:
return f"/uploads/{quote(normalized)}"
def _resolve_runtime_settings(request: Request) -> Settings:
app_settings = getattr(request.app.state, "settings", None)
if isinstance(app_settings, Settings):
return app_settings
return get_settings()
def register_page() -> None: # noqa: PLR0915
"""Register people list and CRUD routes."""
@@ -101,7 +132,7 @@ def register_page() -> None: # noqa: PLR0915
).props("flat")
@ui.page("/people/new")
async def person_create_page(session_factory: SessionFactoryDep) -> None:
async def person_create_page(request: Request, session_factory: SessionFactoryDep) -> None:
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
@@ -122,7 +153,7 @@ def register_page() -> None: # noqa: PLR0915
biography_input = ui.textarea(label="Biography").props("outlined autogrow")
portrait_path_input = ui.input(label="Portrait path").props("outlined")
_bind_portrait_file_picker(portrait_path_input)
_bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request))
async def submit_create() -> None:
full_name = (full_name_input.value or "").strip()
@@ -238,7 +269,7 @@ def register_page() -> None: # noqa: PLR0915
).props("flat")
@ui.page("/people/{person_id}/edit")
async def person_edit_page(person_id: str, session_factory: SessionFactoryDep) -> None:
async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
people_service = DocumentService(session_factory=session_factory)
render_navigation_header(current_path="/people")
@@ -284,7 +315,7 @@ def register_page() -> None: # noqa: PLR0915
biography_input = ui.textarea(label="Biography", value=person.biography or "").props("outlined autogrow")
portrait_path_input = ui.input(label="Portrait path", value=person.portrait_path or "").props("outlined")
_bind_portrait_file_picker(portrait_path_input)
_bind_portrait_file_picker(portrait_path_input, settings=_resolve_runtime_settings(request))
async def submit_edit() -> None:
full_name = (full_name_input.value or "").strip()
+14 -7
View File
@@ -87,18 +87,21 @@ def register_page() -> None:
with ui.column().classes("w-full gap-2"):
for source in sources:
detail_path = _source_detail_path(source_id=source.id, document_id=document_id, job_id=job_id)
with ui.card().classes("w-full") as card:
card.on(
"click",
lambda _=None, source_id=source.id, filters=_build_filter_query(document_id=document_id, job_id=job_id): ui.navigate.to(
f"/sources/{source_id}{filters}"
),
lambda _=None, route=detail_path: ui.navigate.to(route),
)
card.classes("cursor-pointer")
with ui.column().classes("gap-1"):
ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-subtitle1 text-weight-medium")
ui.label(f"Stored filename: {source.filename}").classes("text-body2")
ui.label(f"Document id: {source.document_id}").classes("text-body2 vibe-text-muted")
with ui.row().classes("w-full items-center justify-between"):
with ui.column().classes("gap-1"):
ui.label(f"Page {source.page_number}: {source.upload_name}").classes("text-subtitle1 text-weight-medium")
ui.label(f"Stored filename: {source.filename}").classes("text-body2")
ui.label(f"Document id: {source.document_id}").classes("text-body2 vibe-text-muted")
ui.button("Open source detail", on_click=lambda route=detail_path: ui.navigate.to(route), icon="open_in_new").props(
"flat"
)
@ui.page("/sources/{source_id}")
async def source_detail_page(source_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
@@ -194,6 +197,10 @@ def _build_filter_query(*, document_id: UUID | None, job_id: UUID | None) -> str
return f"?{urlencode(params)}" if params else ""
def _source_detail_path(*, source_id: UUID, document_id: UUID | None, job_id: UUID | None) -> str:
return f"/sources/{source_id}{_build_filter_query(document_id=document_id, job_id=job_id)}"
def _back_query(query_params) -> str:
params = {}
for key in ("document_id", "job_id"):