diff --git a/docs/Proposed Revisions to V4.md b/docs/Proposed Revisions to V4.md new file mode 100644 index 0000000..3a4f276 --- /dev/null +++ b/docs/Proposed Revisions to V4.md @@ -0,0 +1,63 @@ +# Proposed Revisions + +This document contains a list of proposed fixes, adjustments, and additional features. Consider whether each change provides a real improvement, is practical to implement, and whether or not it introduces unnecessary complexity and make recommendations. + +Also, consider whether these changes should be implemented in more than one revision. For example, some might be considered Version 4.1 revisions, some might be Version 5, and some might be saved for a future date after I've had time to use the app for a while. + +## UI +Most of the proposed changes to the UI are cosmetic. The issue numbers refer to issues I have accumulated in the Gitea repository for my own reference. + +### Archival Documents page (issue #13) +* Limit the Document Title column width and wrap long titles. All columns in the table should fit on the screen. +* Left-align Document Title and Type columns +* Add Author, Document Date columns to the table + +### Document Detail page (issue #15) +* In Create/Edit mode, When linking people by role it is impossible to distinguish between two people with the exact same name (e.g., there are two Albert Edward Higgins, Albert Edward Higgins (father & son). Possible solutions: + * Consider using the Display Name instead, and adding a contraint that the Display Name be unique + * Consider concatenating birth year onto the Full Name (e.g., 'Albert Edward Higgins (1885)') +* In View mode, The person entries in the "Related People" box should be hyper-linked to their People Detail pages +* Move "View All Sources" box from under image to under Pipeline Jobs. Consider combining both Pipeline Jobs and Sources together in one box. +* Hide unused fields when not in Edit mode. Example: When editing the Document Detail page there are boxes for Exact Date and Approx. Date. Only one field is ever used (never both). Is it possible to hide the unused field when not in Edit mode? + +### Archival Entities: People page (issue #19) +* Left align Display Name, Maiden Name columns +* Birth Date column should contain Birth Date if known, else Approx Birth Date if known, else "unknown". +* Add Death Date column with rule similar to Birth Date + +### Person Detail page (issue #17) +* Add "New Document" button to People page to accommodate an additional workflow: ```Add new person --> Add document --> Add job```. The current workflow is this: ```Add new document --> Link person, add new if one doesn't exist --> Add job``` The user should be able to start from either Document or Person, whichever seems more natural to them. +* Hide unused fields when not in Edit mode. Example: When editing the People Detail page there are boxes for birth date and approx. birth date. Only one field is ever used (never both). Hide the unused fields on the Person Detail page when not in Edit mode. + +### Source Asset Records page (issue #12) +* A Source with a long Document Name or Upload Title forces columns (e.g., Stored Filename, Status, and Error Detail) off the right edge of the bounding box. Limit the width of the columns and wrap their contents. All columns in the table should fit on the screen. +* Remove Stored Filename from the table +* Left align Document Name, Upload Title, and Error Detail + +### Source Detail page (issue #10) +* If a Source is one page of a multi-page document, add left/right navigation arrows on either side of the displayed image or page (which ever is easier to implement) screen to allow the user to page through the Sources without having to exit select another source then go back into the source detail screen. + +### Transcription Pipeline Jobs page +* No changes at this time + +### Job Detail Page (issue #7) +* Is it possible to add an automatic screen refresh while waiting for a job to finish? This could happen on a timed basis, like every 3-5 seconds. + +### Home page +* Is the current location of the home page in the ./data/homepage folder the correct location to store the image & markdown file? Does it belong somewhere else in the repository like the context prompt which is in ./prompts + +--- + +## New Features +* Consider creating a "Settings" page. My idea is that this page would provide one place for the user to customize the app. This screen may lead to subscreens. For example, the View/Edit prompt button might call up a page where the markdown is displayed in an editable box, and include a brief description of what the prompt is, how it's used, and the document format (markdown). + * Maintain Document Types and People Roles + * View/edit .env? + * View/edit theme? + * View/edit prompt? +* Consider adding field to Person Detail page (and person table) for links to ancestry.com and familysearch.org (and maybe others in the future). Explore using familysearch.org api to automatically populate Person details like Birth date, Birth Place, Death Date, Death Place, etc. (The goal here is not reinvent the wheel and leverage existing data). +* Consider adding the ability to renumber/reorder pages. (What happens when they get imported out of order?) +* Consider adding link to location/place fields to Google Maps. (Perhaps this is better left to the genealogy services...) + + + + diff --git a/docs/api_qa.md b/docs/api_qa.md deleted file mode 100644 index 6b8f73e..0000000 --- a/docs/api_qa.md +++ /dev/null @@ -1,219 +0,0 @@ -# API Questions & Answers - -## Question -``` -what is v4_documents.py? -``` - -## Response -v4_documents.py is the new additive V4 API router I added in Phase 4. - -What it does: -- Defines V4 endpoints under /api/v4 for registry-driven document/relationship workflows. -- Exposes read endpoints for catalogs: - - GET /api/v4/document-types - - GET /api/v4/person-roles -- Exposes write endpoints for code-or-id updates: - - PUT /api/v4/documents/{document_id}/type - - POST /api/v4/documents/{document_id}/people - - PATCH /api/v4/document-people/{document_person_id} - - DELETE /api/v4/document-people/{document_person_id} -- Uses DocumentService methods as the backend (no direct DB access in the route layer). -- Returns typed request/response models for backward-compatible, additive API evolution. - -It is registered in app.py, so it is live when the app starts. - -If you want, I can walk endpoint-by-endpoint with examples of request/response payloads. - ---- - -## Question -``` -Please do -``` - -## Response -Absolutely. Here is a quick endpoint-by-endpoint walkthrough of v4_documents.py, with practical request/response examples. - -**What This Router Is** -- Prefix: /api/v4 -- Purpose: additive V4 contract for: -1. Registry catalogs (document types, person roles) -2. Role-aware document-person linking -3. Code-or-id document type assignment -- It delegates to DocumentService and does not access DB sessions directly in route handlers. - -**Dependency Wiring** -- Route dependency: get_document_service in v4_documents.py -- Behavior: -1. Uses app.state.services.documents when running in the app lifecycle -2. Falls back to a direct DocumentService() instance if needed (useful in isolated tests) - -**1) List Document Types** -- Endpoint: GET /api/v4/document-types -- Query param: active_only=true by default -- Backing service call: list_document_types(active_only=...) -- Response shape: -[ - { - "id": "uuid", - "code": "letter", - "label": "Letter", - "is_active": true, - "sort_order": 0 - } -] - -**2) List Person Roles** -- Endpoint: GET /api/v4/person-roles -- Query param: active_only=true by default -- Backing service call: list_person_roles(active_only=...) -- Response shape: -[ - { - "id": "uuid", - "code": "author", - "label": "Author", - "is_active": true - } -] - -**3) Set Document Type (By Id Or Code)** -- Endpoint: PUT /api/v4/documents/{document_id}/type -- Body options: -1. document_type_id -2. document_type_code -- Rule: provide one selector, not both -- Example request: -{ - "document_type_code": "record" -} -- Example response: -{ - "document_id": "uuid", - "document_type_id": "uuid", - "document_type_code": "record" -} - -**4) List Document-Person Links For A Document** -- Endpoint: GET /api/v4/documents/{document_id}/people -- Backing service call: list_document_people(document_id=...) -- Example response: -{ - "document_id": "uuid", - "links": [ - { - "id": "uuid", - "document_id": "uuid", - "person_id": "uuid", - "role_id": "uuid", - "role_code": "author", - "person_name": "Zenna Cochran" - } - ] -} - -**5) Add A Document-Person Link** -- Endpoint: POST /api/v4/documents/{document_id}/people -- Body fields: -1. person_id required -2. role_id optional -3. role_code optional -- If duplicate document/person/role link is attempted, returns conflict envelope via shared error handlers. -- Example request: -{ - "person_id": "uuid", - "role_code": "mentioned" -} -- Example success response: -{ - "id": "uuid", - "document_id": "uuid", - "person_id": "uuid", - "role_id": "uuid", - "role_code": "mentioned", - "person_name": "Ada Lovelace" -} - -**6) Update Link Role** -- Endpoint: PATCH /api/v4/document-people/{document_person_id} -- Body options: -1. role_id -2. role_code -- Rule: provide one selector, not both -- Example request: -{ - "role_code": "recipient" -} -- Example response: -{ - "id": "uuid", - "document_id": "uuid", - "person_id": "uuid", - "role_id": "uuid", - "role_code": "recipient", - "person_name": "Ada Lovelace" -} - -**7) Delete Link** -- Endpoint: DELETE /api/v4/document-people/{document_person_id} -- Response: HTTP 204 No Content - -**Error Behavior** -- Uses shared registration in errors.py -- So AppError categories map to consistent envelope + status codes: -1. validation_error -> 400 -2. not_found_error -> 404 -3. conflict_error -> 409 -4. internal_unexpected_error -> 500 - -**Where It Gets Mounted** -- Included in app factory at app.py -- That makes all /api/v4 endpoints available during normal app startup. - -If you want, next I can generate a compact curl-style smoke checklist for all 7 endpoints so you can test them in under 2 minutes. - ---- - -## Question -``` -Should there be one of these for people, jobs, and sources too? -``` - -## Response - -Short answer: yes, but not all at once. - -Given your V4 scope, the strongest split is: - -1. Keep v4_documents.py for document-centric workflows -This file already owns: -- document type catalog/use -- document-person role links -- document-level relationship mutations - -2. Add a V4 people router next -Recommended new file: -- src/transcription/api/v4_people.py - -Why: -- You likely need person-centric queries and updates that are not naturally document-scoped -- It keeps API boundaries clearer as UI grows - -3. Do not rush a separate jobs/sources V4 router unless contracts are diverging -For now, jobs and sources are still tightly tied to the existing transcription pipeline behavior. A new v4_jobs or v4_sources router is useful only when you need genuinely new contract shape, such as: -- job lifecycle contract changes -- source revision/audit contract changes -- new pagination/filtering semantics incompatible with current endpoints - -Practical rule of thumb: -1. New router if the domain has new V4 semantics and independent lifecycle -2. Keep existing router if only additive fields are needed - -Suggested target structure: -1. v4_documents.py -2. src/transcription/api/v4_people.py -3. optional later: src/transcription/api/v4_jobs.py -4. optional later: src/transcription/api/v4_sources.py - -If you want, I can scaffold v4_people next with additive endpoints that mirror your current service capabilities and keep error envelope behavior consistent. \ No newline at end of file diff --git a/docs/ver4.1/implementation_plan_v4_1.md b/docs/ver4.1/implementation_plan_v4_1.md new file mode 100644 index 0000000..b3ca6aa --- /dev/null +++ b/docs/ver4.1/implementation_plan_v4_1.md @@ -0,0 +1,146 @@ +# Implementation Plan (Version 4.1) + +## Goal + +Deliver the V4.1 usability revision as a small, behavior-safe increment over the V4 baseline. + +## Implementation Principles + +- Keep presentation formatting in UI components and route orchestration in pages. +- Keep persistence and cross-record queries behind service boundaries. +- Reuse shared table and date-label helpers instead of duplicating fallback logic. +- Make the FamilySearch schema change additive and nullable. +- Add focused tests for changed behavior before broad regression verification. + +## Current Project Impact + +| Area | Expected impact | +| --- | --- | +| Persistence | Add nullable `Person.family_search_id`; provide the repository's supported schema-upgrade path for existing databases. | +| People service | Normalize and validate FamilySearch IDs at the domain/service boundary if model validation does not fully cover writes. | +| Documents UI | Add table data, improve relationship labels/links, compact date display, and combine processing navigation. | +| People UI | Add table date fields, Person-first Document creation, compact date display, and FamilySearch controls. | +| Sources service/UI | Query adjacent document Sources and add bounded navigation; revise list columns and wrapping. | +| Jobs UI | Refresh the active detail read model on a timer until terminal status. | +| Shared UI | Add reusable constrained/wrapped table presentation and compact date formatting where appropriate. | +| Tests | Update model/service and UI coverage for all affected workflows. | + +## Implementation Phases + +### 1. Add Shared Presentation Rules + +- Review `ui/components/table/common.py` and packaged theme CSS for the narrowest reusable table-width solution. +- Add reusable styles or column slots for constrained, wrapping, left-aligned text. +- Add a shared formatter for exact/approximate/unknown dates if it can be reused without coupling components to persistence. +- Preserve sorting and search behavior for rendered display values. + +### 2. Update Archival List Tables + +- Extend the Document table read model with author names and the compact document date. +- Build author display from eagerly loaded document-person links using the `author` role. +- Apply title/type alignment and constrained title wrapping. +- Extend the Person table read model with compact birth and death date values. +- Apply Display Name and Maiden Name alignment. +- Remove Stored Filename from the Source table read model only if no other list behavior consumes it; always remove its rendered column. +- Constrain and left-align the requested Source columns. +- Add or update UI component tests for serialized rows, columns, and fallback formatting. + +### 3. Improve Document Relationship Workflows + +- Introduce one person-label formatter that combines preferred Display Name, Full Name context, and known birth year without implying uniqueness. +- Use Person UUIDs as selector values. +- Apply the formatter to every relationship role selector. +- Change Related People rows into actions that navigate to `/people/{person_id}`. +- Replace separate exact/approximate rows in view mode with one conditional Document Date row. +- Combine Pipeline Jobs and Sources into one related-processing card beneath Related People. +- Preserve existing job/source counts and navigation actions. + +### 4. Add the Person-First Document Workflow + +- Add a New Document action on Person Detail. +- Pass the Person UUID through a narrowly defined query parameter to `/documents/new`. +- Validate the requested UUID against the loaded people list. +- Preselect that person in the intended default relationship role. Use `author` unless a different role is explicitly encoded later. +- Ignore invalid or unavailable preselection values with the application's normal visible error/notification behavior. +- Confirm ordinary `/documents/new` behavior remains unchanged. + +### 5. Add FamilySearch Person References + +- Add nullable, unique `family_search_id` to the `Person` model and schema. +- Implement a non-destructive upgrade for existing SQLite and PostgreSQL databases using the repository's established schema-management approach. +- Normalize values by trimming and uppercasing. +- Validate the `XXXX-XXX` alphanumeric identifier shape and return a clear validation error for malformed input. +- Report duplicate identifiers as a deterministic conflict rather than a generic persistence failure. +- Add the field to Person create/edit forms and preserve it during updates. +- Add a URL builder that safely inserts only a validated identifier into the fixed FamilySearch details URL. +- Render a FamilySearch action on Person Detail only when an identifier is present. +- Add persistence, normalization, validation, form, and link-generation tests. + +### 6. Add Source Page Navigation + +- Add a Sources service query that returns previous/current/next context for a Source within its Document. +- Define ordering by `page_number`, with a stable secondary key such as Source UUID for defensive determinism. +- Keep navigation bounded to the current `document_id`. +- Render previous and next actions adjacent to the source viewer or detail header. +- Disable or omit unavailable boundary actions. +- Test first, middle, last, single-page, and cross-document cases. + +### 7. Add Job Detail Auto-Refresh + +- Make Job Detail content refreshable without rebuilding unrelated global navigation. +- Start a NiceGUI timer only for queued or processing jobs. +- On each tick, re-read the Job through `JobService` and refresh the detail content. +- Use a 4-second default interval. +- Stop or deactivate the timer when status becomes completed, partial success, failed, or cancelled, according to the model's actual terminal states. +- Prevent overlapping refresh callbacks. +- Retain existing error presentation if a refresh read fails. +- Add UI tests for timer creation, refresh, and terminal-state stopping. + +### 8. Simplify View-Mode Date Rows + +- On Document Detail, show exact date, else approximate date, else one not-set value. +- On Person Detail, apply the same independent rule to birth and death. +- Do not hide either input in create/edit mode. +- Test each exact, approximate, and absent state. + +### 9. Verification and Documentation Alignment + +- Run the focused model/service/UI tests covering changed surfaces. +- Run the existing regression suite appropriate to persistence and UI changes. +- Confirm SQLite and PostgreSQL model compatibility at the schema-definition level. +- Update V4.1 documentation if implementation reveals a necessary boundary change; do not silently expand scope. + +## Recommended Delivery Order + +1. Shared formatters and table presentation. +2. Additive Person schema change and FamilySearch validation. +3. Document and Person list/detail changes. +4. Person-first Document workflow. +5. Source navigation. +6. Job polling. +7. Focused and regression verification. + +## Done When + +- Every V4.1 acceptance criterion is demonstrated or covered by a focused test. +- Existing Person rows remain valid after the nullable schema addition. +- Duplicate FamilySearch references cannot be assigned to multiple local Person records. +- FamilySearch links are generated only from normalized, validated IDs. +- Auto-refresh performs no polling after a terminal job state. +- Adjacent Source navigation never crosses Document boundaries. +- The existing V4 workflows remain operational. + +## Out of Scope + +- Page reordering. +- Settings management. +- External genealogy API integration. +- Raw `.env` editing. +- Theme editing. + +## Related Local References + +- [V4.1 Scope Boundary](scope_boundary_v4_1.md) +- [V4 Implementation Plan](../ver4/implementation_plan_v4.md) +- [V4 Requirements](../ver4/requirements_v4.md) +- [V4 Error Handling Policy](../ver4/error_handling_v4.md) diff --git a/docs/ver4.1/scope_boundary_v4_1.md b/docs/ver4.1/scope_boundary_v4_1.md new file mode 100644 index 0000000..a6efb36 --- /dev/null +++ b/docs/ver4.1/scope_boundary_v4_1.md @@ -0,0 +1,142 @@ +# V4.1 Scope Boundary + +This document defines the scope of the first incremental revision to Version 4. V4 remains the product and architecture baseline; V4.1 adds focused usability improvements and one additive Person field. + +## Purpose + +- Improve common archival record workflows without redesigning the application. +- Resolve table overflow, ambiguous person selection, and unnecessary navigation. +- Add a manually maintained FamilySearch person reference without introducing external API integration. + +## In Scope + +### 1. Archival Documents List + +- Keep every table column within the available page width. +- Limit and wrap long Document Title values. +- Left-align Document Title and Type. +- Add Author and Document Date columns. +- Display all people linked through the `author` role in the Author column. +- Display exact document date when present, otherwise approximate date when present, otherwise `Unknown`. + +### 2. Document Detail and Editing + +- Use an unambiguous label in person selectors. The label should prefer Display Name, retain Full Name for context, and include the birth year when known. +- Do not require Display Name to be unique. +- Link each Related People entry to its Person Detail page. +- In view mode, display only the populated exact or approximate document date row. Display a single unknown/not-set state when neither exists. +- Keep both exact and approximate inputs available in create/edit mode. +- Move source navigation out from beneath the media viewer. +- Present Pipeline Jobs and Sources together in one related-processing card with counts and actions. + +### 3. People List + +- Left-align Display Name and Maiden Name. +- Display exact birth date when present, otherwise approximate birth date when present, otherwise `Unknown`. +- Add a Death Date column with the same fallback rule. + +### 4. Person Detail and Editing + +- Add a New Document action that opens Document creation with the current person preselected. +- Preserve the existing Document-first workflow. +- In view mode, display only the populated exact or approximate row for each of birth and death date. Display a single unknown/not-set state when neither value exists. +- Keep both exact and approximate inputs available in create/edit mode. + +### 5. FamilySearch Reference + +- Add a nullable, unique `family_search_id` field to `Person`. +- Allow the field to be entered and changed in Person create/edit flows. +- Trim whitespace, normalize the identifier to uppercase, and validate it against the supported + `XXXX-XXX` alphanumeric shape before persistence. +- When an identifier exists, show a FamilySearch action on Person Detail linking to: + `https://www.familysearch.org/tree/person/details/{family_search_id}` +- Construct the URL in application code; do not persist the full URL. + +### 6. Source List and Detail + +- Keep every Source Asset Records table column within the available page width. +- Limit and wrap long Document Name, Upload Title, and Error Detail values. +- Left-align Document Name, Upload Title, and Error Detail. +- Remove Stored Filename only from the Source Asset Records table. Continue storing it and showing it on Source Detail. +- On Source Detail, add previous and next navigation for Sources belonging to the same Document, ordered by `page_number`. +- Disable or omit the previous/next action at the first/last page. + +### 7. Job Detail + +- Automatically refresh Job Detail while the job is in a non-terminal state. +- Use a modest interval in the 3-5 second range. +- Stop polling when the job reaches a terminal state or the page is no longer active. +- Preserve manual navigation and existing job actions. + +### 8. Homepage Storage Decision + +- Continue treating homepage markdown and images as mutable application data, not prompt artifacts or packaged source assets. +- Keep homepage content separate from `prompts`. +- Defer relocation to a configurable application-data root unless the existing location prevents normal installed or deployed operation. + +## Out of Scope + +- Source page renumbering or reordering. +- A Settings page. +- Editing `.env` or secrets through the UI. +- Runtime theme editing. +- FamilySearch authentication, API calls, search, import, synchronization, or conflict resolution. +- Ancestry references or other genealogy providers. +- Google Maps links from place fields. +- Enforcing unique Display Name values. +- Changes to transcription execution or provider behavior. +- Changes to the V4 API solely to expose the V4.1 presentation enhancements. + +## Locked Design Decisions + +### A. Person Selector Identity + +- Selection values remain internal Person UUIDs. +- Display labels provide disambiguating context but are not identity keys. +- Duplicate Full Name and Display Name values remain valid. + +### B. Date Presentation + +- Exact dates take precedence over approximate/raw dates for compact list and view presentation. +- Create/edit forms retain both fields so either representation can be maintained. +- V4.1 does not introduce a new mutual-exclusion database constraint. + +### C. FamilySearch Storage + +- Store only the FamilySearch person identifier. +- Treat a FamilySearch person identifier as unique across local Person records. +- Use one dedicated nullable Person field while FamilySearch is the only supported external genealogy reference. +- Reconsider a generic external-reference model only when a second provider or multiple references per person are required. + +### D. Stored Filename + +- Stored Filename remains part of the Source model and Source Detail diagnostics. +- Only the list-table column is removed. + +## Data and Compatibility Policy + +- The `family_search_id` addition must be nullable and non-destructive for existing Person rows. +- Existing records, routes, relationships, jobs, Sources, prompt provenance, and uploaded media remain valid. +- UI changes must preserve both Document-first and Person-first workflows. +- V4.1 must remain portable across SQLite and PostgreSQL. + +## Acceptance Criteria + +1. Document, Person, and Source tables fit their page containers at supported desktop widths without losing requested columns. +2. Long table text wraps or is constrained without forcing important columns outside the table container. +3. Duplicate-named people can be distinguished in every document relationship selector. +4. Related People entries navigate to the correct Person Detail page. +5. Compact date displays consistently use exact, then approximate, then unknown fallback behavior. +6. Starting from Person Detail can create a Document with that person preselected without breaking normal Document creation. +7. A valid FamilySearch ID is persisted and produces the correct Person Detail hyperlink; absent IDs produce no action. +8. Source previous/next actions remain within the same Document and follow `page_number`. +9. Active Job Detail pages update without manual refresh and stop polling after terminal status. +10. Stored Filename is absent from the Source list table but remains available on Source Detail. +11. Focused automated tests pass and unaffected V4 behavior remains intact. + +## Related Local References + +- [V4.1 Implementation Plan](implementation_plan_v4_1.md) +- [V4 Scope Boundary](../ver4/scope_boundary_v4.md) +- [V4 Architecture](../ver4/architecture_v4.md) +- [V4 Schema](../ver4/schema_v4.md) diff --git a/docs/ver4.2/implementation_plan_v4_2.md b/docs/ver4.2/implementation_plan_v4_2.md new file mode 100644 index 0000000..26250e8 --- /dev/null +++ b/docs/ver4.2/implementation_plan_v4_2.md @@ -0,0 +1,137 @@ +# Draft Implementation Plan (Version 4.2) + +## Goal + +Prepare a safe implementation path for Source page reordering and constrained application settings. This plan remains provisional until the V4.2 scope-freeze decisions are resolved. + +## Planning Constraints + +- V4 and V4.1 remain the behavioral baseline. +- Reordering must be atomic and service-owned. +- Settings must use explicit domain operations rather than direct database, environment-file, or arbitrary filesystem access from UI pages. +- Prompt changes must preserve historical Job provenance and use a defined safe-write policy. + +## Expected Project Impact + +| Area | Expected impact | +| --- | --- | +| Sources service | Add validated, transactional set-based page reordering. | +| Documents/Sources UI | Add a reorder entry point and interaction for one Document. | +| Documents service | Expand controlled Document Type maintenance operations. | +| People service | Expand controlled Person Role maintenance operations. | +| Prompt adapter/service | Add constrained listing, reading, validation, and safe writing of prompt artifacts. | +| UI composition/navigation | Register Settings routes and navigation without moving persistence into UI code. | +| Tests | Add transaction, conflict, registry lifecycle, prompt safety, and UI workflow coverage. | + +## Proposed Implementation Phases + +### 1. Resolve Scope-Freeze Decisions + +- Select and document the reorder interaction. +- Define whether active Jobs block reorder. +- Define prompt atomic-write, backup, and recovery policy. +- Decide whether prompt creation/deletion is excluded. +- Finalize registry ordering requirements. +- Remove the Draft designation only after these decisions are reflected in scope and acceptance criteria. + +### 2. Define Service Contracts + +- Define a Source reorder command containing `document_id`, the complete ordered Source ID list, and a concurrency token or equivalent stale-write guard if supported by the current model. +- Define Document Type maintenance commands for create, relabel, sort, activate, and deactivate. +- Define Person Role maintenance commands for create, relabel, activate, and deactivate. +- Define a Prompt Store interface for constrained list/read/write behavior. +- Map validation, conflict, not-found, dependency, and filesystem failures to existing `AppError` categories. + +### 3. Implement Transactional Source Reordering + +- Load all Sources for the target Document in the same transaction. +- Reject missing, extra, duplicate, or foreign Source IDs. +- Reject stale writes using the selected concurrency policy. +- Apply a collision-safe renumbering strategy suitable for both SQLite and PostgreSQL. +- Finish with contiguous `page_number` values beginning at 1. +- Roll back the entire operation on any failure. +- Add service tests for valid reorder, no-op, reverse order, invalid membership, duplicates, stale submissions, rollback, and backend-compatible SQL behavior. + +### 4. Implement the Reorder UI + +- Add a Reorder Pages action from a Document-scoped Source view or Document Detail. +- Render Source labels/previews sufficient to identify each page. +- Capture the complete intended order. +- Require explicit Save and provide Cancel without mutation. +- Surface validation and conflict errors through the shared error presenter. +- Return to a Document-scoped ordered view after success. +- Verify keyboard-accessible controls for any drag-and-drop interaction. + +### 5. Expand Registry Maintenance Services + +- Reuse existing Document and People service ownership. +- Add explicit write methods rather than passing UI-mutated ORM objects directly where practical. +- Normalize and validate new stable codes. +- Reject duplicate codes deterministically. +- Block deletion or omit deletion entirely; use activation state for lifecycle management. +- Preserve inactive entries for historical reads. +- Add service tests for create, relabel, activation, deactivation, duplicates, immutable codes, and referenced records. + +### 6. Add Constrained Prompt Storage + +- Place filesystem access behind a dedicated Prompt Store/service boundary. +- Resolve all filenames directly beneath the configured prompt root and reject traversal. +- Permit only the agreed markdown extension and reject empty content. +- Implement the approved safe-write strategy, including flush/replace behavior and backup/recovery if selected. +- Preserve file encoding and provide explicit failures for read-only or unavailable storage. +- Do not modify any Job row when prompt defaults change. +- Add unit tests for valid reads/writes, traversal, invalid names, empty content, filesystem failures, and unchanged Job provenance. + +### 7. Build the Settings UI + +- Register a Settings landing page and navigation entry. +- Add separate pages or panels for Document Types, Person Roles, and Prompts. +- Keep pages responsible for orchestration and notifications only. +- Use service callbacks for all mutations. +- Explain stable codes, inactive historical entries, and future-only prompt effects in the UI. +- Do not render raw environment values or secrets. + +### 8. Verification and Rollout + +- Run focused service tests before UI integration tests. +- Verify reorder behavior against Documents with one and many Sources. +- Verify ordered transcription rendering and V4.1 previous/next navigation after reorder. +- Verify inactive registry behavior in both historical display and create/edit selectors. +- Verify prompt changes are picked up by newly created Jobs while historical Jobs retain frozen content/hash. +- Run the relevant regression suite. + +## Migration and Compatibility Notes + +- No new table is expected solely for reordering; `Source.page_number` remains authoritative. +- A uniqueness constraint on `(document_id, page_number)` should be evaluated before scope freeze. If added, migration and collision-safe update behavior must be designed for both supported databases. +- Existing registry records remain valid. +- Prompt editing changes mutable application files, not database provenance already captured on Jobs. +- V4.2 must not require users to recreate existing Sources, Documents, People, roles, or types. + +## Proposed Delivery Order + +1. Freeze the remaining decisions. +2. Implement and verify Source reorder service semantics. +3. Build the reorder UI. +4. Implement registry maintenance service operations. +5. Implement the Prompt Store and safety policy. +6. Build Settings pages. +7. Run integration and regression verification. + +## Draft Done Criteria + +- All V4.2 acceptance criteria are testable and satisfied. +- Reordering is atomic, conflict-aware, contiguous, and cross-database compatible. +- Settings mutations cross explicit service or adapter boundaries. +- Registry codes cannot be accidentally changed. +- Prompt writes cannot escape the configured directory or rewrite historical provenance. +- No secret or raw environment editor exists. +- V4.1 workflows remain intact. + +## Related Local References + +- [Draft V4.2 Scope Boundary](scope_boundary_v4_2.md) +- [V4.1 Implementation Plan](../ver4.1/implementation_plan_v4_1.md) +- [V4 Implementation Plan](../ver4/implementation_plan_v4.md) +- [V4 Error Handling Policy](../ver4/error_handling_v4.md) + diff --git a/docs/ver4.2/scope_boundary_v4_2.md b/docs/ver4.2/scope_boundary_v4_2.md new file mode 100644 index 0000000..ca52469 --- /dev/null +++ b/docs/ver4.2/scope_boundary_v4_2.md @@ -0,0 +1,130 @@ +# Draft V4.2 Scope Boundary + +This document defines the proposed boundary for the second incremental revision to Version 4. It is intentionally a draft until V4.1 has been used and the remaining workflows have been validated. + +## Purpose + +- Allow correction of Source page order after import. +- Provide a constrained Settings area for safe maintenance of selected application-managed configuration. +- Avoid exposing secrets, restart-sensitive settings, or unrestricted filesystem editing through the UI. + +## Proposed In Scope + +### 1. Source Page Reordering + +- Allow Sources within one Document to be reordered after import. +- Present the current order using page number and a recognizable source label or preview. +- Persist the complete intended order atomically. +- Renumber the affected Document's Sources to a contiguous sequence beginning at 1. +- Keep all Sources attached to their existing Document. +- Ensure transcription rendering and previous/next navigation use the updated order. +- Detect stale or invalid reorder submissions and fail without partial mutation. + +### 2. Settings Navigation + +- Add a Settings entry to application navigation. +- Provide separate, clearly described settings areas rather than a raw configuration editor. +- Restrict V4.2 settings to application-managed values that can be validated and safely changed at runtime. + +### 3. Document Type Maintenance + +- List active and inactive Document Types. +- Add new types with a stable unique code and user-facing label. +- Edit mutable labels and sort order. +- Activate or deactivate types without invalidating historical Documents. +- Do not allow changing a stable code after creation. +- Do not delete types that are referenced by Documents. + +### 4. Person Role Maintenance + +- List active and inactive Person Roles. +- Add new roles with a stable unique code and user-facing label. +- Edit mutable labels. +- Activate or deactivate roles without invalidating historical links. +- Do not allow changing a stable code after creation. +- Do not delete roles that are referenced by document-person links. + +### 5. Prompt Maintenance + +- List prompt markdown files from the configured prompt directory. +- View a prompt with a concise explanation of its purpose and use. +- Edit an existing prompt as plain markdown text. +- Validate the filename boundary and reject empty prompt content. +- Save changes explicitly and report filesystem failures. +- Preserve submission-time prompt text and hash already frozen on existing Jobs. +- Define a safe-write and recovery approach before this feature is considered final scope. + +## Proposed Out of Scope + +- Viewing or editing raw `.env` files. +- Displaying or changing provider API keys and other secrets. +- Editing host, port, database connection, upload paths, or other restart-sensitive runtime settings. +- Arbitrary file browsing or arbitrary prompt paths. +- Runtime theme/CSS editing. +- Installing themes or plugins. +- Source movement between Documents as part of reordering. +- Automatic ordering based on filenames, OCR, or image content. +- FamilySearch API synchronization. +- A generic external-reference registry. +- Ancestry references and Google Maps links. + +## Proposed Design Decisions + +### A. Reordering Is Set-Based + +- The client submits the full ordered list of Source IDs for one Document. +- The service validates membership, completeness, duplicates, and authorization/context before writing. +- All page-number updates occur in one transaction. + +### B. Registry Codes Are Immutable + +- Document Type and Person Role codes are stable identifiers. +- Labels and active state remain mutable. +- Historical references remain valid when a registry entry is inactive. + +### C. No Raw Environment Editor + +- `.env` may contain secrets and values that are not safely reloadable. +- V4.2 exposes only purpose-built forms backed by explicit validation and service methods. + +### D. Prompt Editing Is Constrained + +- Prompt maintenance is limited to direct children of the configured prompt directory. +- Existing Job provenance is never rewritten when a prompt file changes. +- The UI must distinguish editing the default for future submissions from inspecting historical Job prompts. + +## Decisions Required Before Scope Freeze + +1. Choose the reorder interaction: move-up/down controls, drag-and-drop, or both. +2. Decide whether reordering is allowed while the Document has a queued or processing Job. +3. Define prompt backup, atomic-write, and recovery behavior. +4. Decide whether prompt creation and deletion are needed or whether V4.2 edits existing prompts only. +5. Confirm whether registry sort-order maintenance is needed for Person Roles as well as Document Types. +6. Confirm that settings changes remain local to the current installation and do not require an API surface. + +## Draft Acceptance Criteria + +1. Reordering a Document's Sources produces contiguous page numbers and updates every ordered view consistently. +2. Invalid, incomplete, duplicate, cross-Document, or stale reorder requests make no changes. +3. Document Type and Person Role maintenance preserves stable codes and historical references. +4. Inactive registry entries remain visible on historical records but are excluded from default create selectors. +5. Prompt edits are restricted to valid markdown files in the configured prompt directory. +6. A prompt edit affects future Jobs only and leaves stored Job provenance unchanged. +7. No Settings page exposes secrets or unrestricted filesystem access. +8. Focused service and UI tests pass without regressing V4.1 workflows. + +## Scope Freeze Gate + +V4.2 implementation should not begin until: + +- V4.1 has been used sufficiently to validate priorities. +- The six open decisions above are resolved. +- The prompt-write safety policy is documented. +- The implementation plan is revised from draft to committed delivery plan. + +## Related Local References + +- [Draft V4.2 Implementation Plan](implementation_plan_v4_2.md) +- [V4.1 Scope Boundary](../ver4.1/scope_boundary_v4_1.md) +- [V4 Architecture](../ver4/architecture_v4.md) +- [V4 Schema](../ver4/schema_v4.md) diff --git a/src/transcription/db/__init__.py b/src/transcription/db/__init__.py index 961fbd7..dbf6bdd 100644 --- a/src/transcription/db/__init__.py +++ b/src/transcription/db/__init__.py @@ -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", ] diff --git a/src/transcription/db/models.py b/src/transcription/db/models.py index b05519c..c3068d8 100644 --- a/src/transcription/db/models.py +++ b/src/transcription/db/models.py @@ -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), diff --git a/src/transcription/db/operations.py b/src/transcription/db/operations.py index 157a8e9..1c889bd 100644 --- a/src/transcription/db/operations.py +++ b/src/transcription/db/operations.py @@ -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() diff --git a/src/transcription/services/documents.py b/src/transcription/services/documents.py index 91261b6..bd28f88 100644 --- a/src/transcription/services/documents.py +++ b/src/transcription/services/documents.py @@ -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: diff --git a/src/transcription/services/people.py b/src/transcription/services/people.py index e5f59cb..76ddd19 100644 --- a/src/transcription/services/people.py +++ b/src/transcription/services/people.py @@ -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: diff --git a/src/transcription/services/sources.py b/src/transcription/services/sources.py index a88375d..94f8df1 100644 --- a/src/transcription/services/sources.py +++ b/src/transcription/services/sources.py @@ -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: diff --git a/src/transcription/ui/components/formatters.py b/src/transcription/ui/components/formatters.py new file mode 100644 index 0000000..47e8cd4 --- /dev/null +++ b/src/transcription/ui/components/formatters.py @@ -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}" diff --git a/src/transcription/ui/components/table/documents.py b/src/transcription/ui/components/table/documents.py index cab04e0..1599a74 100644 --- a/src/transcription/ui/components/table/documents.py +++ b/src/transcription/ui/components/table/documents.py @@ -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", diff --git a/src/transcription/ui/components/table/people.py b/src/transcription/ui/components/table/people.py index aefbfe8..1c8f2c9 100644 --- a/src/transcription/ui/components/table/people.py +++ b/src/transcription/ui/components/table/people.py @@ -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: """, - ) \ No newline at end of file + ) diff --git a/src/transcription/ui/components/table/sources.py b/src/transcription/ui/components/table/sources.py index 7488309..2701f1a 100644 --- a/src/transcription/ui/components/table/sources.py +++ b/src/transcription/ui/components/table/sources.py @@ -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", diff --git a/src/transcription/ui/pages/documents_page.py b/src/transcription/ui/pages/documents_page.py index b850eaf..d3d6edd 100644 --- a/src/transcription/ui/pages/documents_page.py +++ b/src/transcription/ui/pages/documents_page.py @@ -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 diff --git a/src/transcription/ui/pages/jobs_page.py b/src/transcription/ui/pages/jobs_page.py index c8c4148..c12a962 100644 --- a/src/transcription/ui/pages/jobs_page.py +++ b/src/transcription/ui/pages/jobs_page.py @@ -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 diff --git a/src/transcription/ui/pages/people_page.py b/src/transcription/ui/pages/people_page.py index d12f53c..8def5b0 100644 --- a/src/transcription/ui/pages/people_page.py +++ b/src/transcription/ui/pages/people_page.py @@ -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 --- diff --git a/src/transcription/ui/pages/sources_page.py b/src/transcription/ui/pages/sources_page.py index d84312d..49efa70 100644 --- a/src/transcription/ui/pages/sources_page.py +++ b/src/transcription/ui/pages/sources_page.py @@ -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) diff --git a/src/transcription/ui/static/theme.css b/src/transcription/ui/static/theme.css index a92a7c0..f0b45df 100644 --- a/src/transcription/ui/static/theme.css +++ b/src/transcription/ui/static/theme.css @@ -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; diff --git a/tests/services/test_v2_crud.py b/tests/services/test_v2_crud.py index 8b774f2..a1d056a 100644 --- a/tests/services/test_v2_crud.py +++ b/tests/services/test_v2_crud.py @@ -10,9 +10,11 @@ from transcription.db.models import JobSource from transcription.db.models import JobSourceStatus from transcription.db.models import Person from transcription.db.models import Source +from transcription.errors import ErrorCategory from transcription.services.documents import DocumentDeleteBlockedError from transcription.services.documents import DocumentService from transcription.services.jobs import JobService +from transcription.services.people import PeopleError from transcription.services.people import PeopleService from transcription.services.sources import SourceDeleteBlockedError from transcription.services.sources import SourceService @@ -53,6 +55,28 @@ async def test_people_service_handles_person_and_document_person_crud(default_se assert len(await people_service.list_document_people(document_id=document.id)) == 0 +@pytest.mark.asyncio +async def test_people_service_normalizes_and_rejects_duplicate_family_search_ids(default_session_factory): + people_service = PeopleService(session_factory=default_session_factory) + + created = await people_service.create_person( + Person(full_name="Hig Higgins", family_search_id=" g8t4-mdq ") + ) + assert created.family_search_id == "G8T4-MDQ" + + with pytest.raises(PeopleError) as duplicate: + await people_service.create_person( + Person(full_name="Duplicate Hig", family_search_id="G8T4-MDQ") + ) + assert duplicate.value.category == ErrorCategory.CONFLICT + + with pytest.raises(PeopleError) as malformed: + await people_service.create_person( + Person(full_name="Malformed", family_search_id="not-an-id") + ) + assert malformed.value.category == ErrorCategory.VALIDATION + + @pytest.mark.asyncio async def test_transcription_service_manages_source_crud(default_session_factory): documents = DocumentService(session_factory=default_session_factory) @@ -88,6 +112,42 @@ async def test_transcription_service_manages_source_crud(default_session_factory assert len(await transcriptions.list_sources(document_id=document.id)) == 0 +@pytest.mark.asyncio +async def test_source_navigation_is_bounded_to_ordered_document(default_session_factory): + documents = DocumentService(session_factory=default_session_factory) + sources = SourceService(session_factory=default_session_factory) + document = await documents.create_document(Document(id=uuid4(), name="ordered")) + other = await documents.create_document(Document(id=uuid4(), name="other")) + + first, second, third, _foreign = [ + await sources.create_source( + Source( + document_id=document_id, + page_number=page_number, + upload_name=f"page-{page_number}.jpg", + filename=f"page-{page_number}.jpg", + file_path=f"uploads/page-{page_number}.jpg", + file_hash=str(page_number) * 64, + file_size_bytes=1, + ) + ) + for document_id, page_number in [ + (document.id, 1), + (document.id, 2), + (document.id, 3), + (other.id, 2), + ] + ] + + first_navigation = await sources.read_source_navigation(first.id) + middle_navigation = await sources.read_source_navigation(second.id) + last_navigation = await sources.read_source_navigation(third.id) + + assert (first_navigation.previous_id, first_navigation.next_id) == (None, second.id) + assert (middle_navigation.previous_id, middle_navigation.next_id) == (first.id, third.id) + assert (last_navigation.previous_id, last_navigation.next_id) == (second.id, None) + + @pytest.mark.asyncio async def test_transcription_service_job_source_crud_uses_caller_session(default_session_factory): transcriptions = SourceService(session_factory=default_session_factory) diff --git a/tests/test_db.py b/tests/test_db.py index e56496e..d330b38 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -2,6 +2,7 @@ import pytest from sqlalchemy import inspect +from sqlalchemy import text from sqlmodel import select from sqlmodel.ext.asyncio.session import AsyncSession @@ -79,6 +80,38 @@ async def test_create_all_seeds_default_registry_rows(tmp_path): await dispose_database_runtime() +@pytest.mark.asyncio +async def test_create_all_upgrades_existing_person_table_for_family_search(tmp_path): + settings = Settings( + openrouter_api_key="test-key", + database=SqliteSettings(path=str(tmp_path / "upgrade.db")), + environment="test", + ) + runtime = initialize_database_runtime(settings=settings) + + try: + async with runtime.engine.begin() as connection: + await connection.execute( + text("CREATE TABLE person (id CHAR(32) PRIMARY KEY NOT NULL, full_name VARCHAR NOT NULL)") + ) + + await create_all(engine=runtime.engine) + async with runtime.engine.connect() as connection: + columns, indexes = await connection.run_sync( + lambda sync_connection: ( + {column["name"] for column in inspect(sync_connection).get_columns("person")}, + inspect(sync_connection).get_indexes("person"), + ) + ) + + assert "family_search_id" in columns + assert any( + index["column_names"] == ["family_search_id"] and index["unique"] for index in indexes + ) + finally: + await dispose_database_runtime() + + def test_bootstrap_policy_production_defaults_false(): settings = Settings(openrouter_api_key="test-key", environment="production") assert settings.should_bootstrap_schema is False diff --git a/tests/test_models.py b/tests/test_models.py index d8d6c96..b20314d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,16 +1,14 @@ """Tests for the V2 SQLModel persistence layer and relationships.""" -from datetime import UTC -from datetime import datetime from uuid import UUID import pytest from sqlalchemy.exc import IntegrityError from transcription.db.models import Document -from transcription.db.models import DocumentType from transcription.db.models import DocumentPerson from transcription.db.models import DocumentPersonRole +from transcription.db.models import DocumentType from transcription.db.models import Job from transcription.db.models import JobSource from transcription.db.models import JobSourceStatus @@ -181,6 +179,14 @@ class TestSourceModel: class TestPersonAndDocumentPersonModel: + def test_family_search_id_is_unique_when_present(self, session): + session.add(Person(full_name="First Person", family_search_id="G8T4-MDQ")) + session.commit() + + session.add(Person(full_name="Second Person", family_search_id="G8T4-MDQ")) + with pytest.raises(IntegrityError): + session.commit() + def test_document_person_role_is_unique_per_document_person(self, session): document = _persist_document(session) person = _persist_person(session) diff --git a/tests/ui/test_documents_page.py b/tests/ui/test_documents_page.py index 7358aaf..08a176f 100644 --- a/tests/ui/test_documents_page.py +++ b/tests/ui/test_documents_page.py @@ -1,12 +1,17 @@ """Tests for the documents page routes and action handlers.""" +from datetime import date + import pytest import pytest_asyncio -from sqlmodel import select from transcription.db import session_scope -from transcription.db.models import Document, DocumentPerson, DocumentPersonRole, Job, Person, Source - +from transcription.db.models import Document +from transcription.db.models import DocumentPerson +from transcription.db.models import DocumentPersonRole +from transcription.db.models import Job +from transcription.db.models import Person +from transcription.db.models import Source # --- Helper Fixtures --- @@ -68,6 +73,8 @@ class TestDocumentsPageRendering: assert "1924 Postcard" in response.text assert "postcard" in response.text assert "PC-001" in response.text + assert "Document Date" in response.text + assert "Author" in response.text def test_document_create_page_renders_form(self, app_client): _, client = app_client @@ -80,6 +87,24 @@ class TestDocumentsPageRendering: assert "Linked People by Role" in response.text assert "Document type" in response.text + @pytest.mark.asyncio + async def test_document_create_page_preselects_person_with_disambiguating_label(self, app_client): + _, client = app_client + async with session_scope() as session: + person = Person( + full_name="Albert Edward Higgins", + display_name="Hig", + birth_date=date(1885, 1, 2), + ) + session.add(person) + await session.commit() + person_id = str(person.id) + + response = client.get(f"/ui/documents/new?person_id={person_id}") + + assert response.status_code == 200 + assert "Hig - Albert Edward Higgins (1885)" in response.text + @pytest.mark.asyncio async def test_document_detail_page_renders_bento_grid_and_metadata( self, app_client, seed_person_and_document @@ -93,6 +118,8 @@ class TestDocumentsPageRendering: assert "Letter from Hig" in response.text assert "ZC-1924-001" in response.text assert "Zenna Cochran" in response.text + assert f"/ui/people/{seed_person_and_document[1]}" in response.text + assert "PIPELINE JOBS" in response.text.upper() assert "Edit Document" in response.text @pytest.mark.asyncio @@ -176,4 +203,4 @@ class TestDocumentsPageRendering: assert response.status_code == 200 assert "Delete Document" in response.text assert "Delete document permanently" in response.text - assert "Delete is blocked" not in response.text \ No newline at end of file + assert "Delete is blocked" not in response.text diff --git a/tests/ui/test_formatters.py b/tests/ui/test_formatters.py new file mode 100644 index 0000000..c8de431 --- /dev/null +++ b/tests/ui/test_formatters.py @@ -0,0 +1,34 @@ +from datetime import date + +from transcription.db.models import Person +from transcription.ui.components.formatters import compact_date +from transcription.ui.components.formatters import family_search_url +from transcription.ui.components.formatters import person_selector_label + + +def test_compact_date_prefers_exact_then_approximate_then_unknown(): + assert compact_date(date(1924, 3, 2), "about 1924") == "1924-03-02" + assert compact_date(None, "about 1924") == "about 1924" + assert compact_date(None, " ") == "Unknown" + + +def test_person_selector_label_disambiguates_without_changing_identity(): + person = Person( + full_name="Albert Edward Higgins", + display_name="Hig", + birth_date=date(1885, 1, 2), + ) + assert person_selector_label(person) == "Hig - Albert Edward Higgins (1885)" + + approximate = Person( + full_name="Albert Edward Higgins", + display_name="Hig", + birth_date_raw="about 1912", + ) + assert person_selector_label(approximate) == "Hig - Albert Edward Higgins (1912)" + + +def test_family_search_url_uses_fixed_person_details_route(): + assert family_search_url("G8T4-MDQ") == ( + "https://www.familysearch.org/tree/person/details/G8T4-MDQ" + ) diff --git a/tests/ui/test_jobs_page.py b/tests/ui/test_jobs_page.py index 497dbf3..be71ea7 100644 --- a/tests/ui/test_jobs_page.py +++ b/tests/ui/test_jobs_page.py @@ -2,11 +2,11 @@ import pytest import pytest_asyncio -from sqlmodel import select from transcription.db import session_scope -from transcription.db.models import Document, Job, JobSourceStatus, JobStatus - +from transcription.db.models import Document +from transcription.db.models import Job +from transcription.db.models import JobStatus # --- Helper Fixtures --- @@ -99,6 +99,7 @@ class TestJobsPageRendering: assert "gpt-4o" in response.text assert "View Linked Document" in response.text assert "View Linked Sources" in response.text + assert "updates automatically while the job is active" in response.text @pytest.mark.asyncio async def test_job_cancel_page_renders_confirmation( @@ -164,4 +165,4 @@ class TestJobsPageRendering: assert response.status_code == 200 assert "Delete Processing Job" in response.text assert "Delete job permanently" in response.text - assert "Delete is blocked" not in response.text \ No newline at end of file + assert "Delete is blocked" not in response.text diff --git a/tests/ui/test_people_page.py b/tests/ui/test_people_page.py index 18fe25d..1527a2d 100644 --- a/tests/ui/test_people_page.py +++ b/tests/ui/test_people_page.py @@ -6,7 +6,10 @@ from uuid import uuid4 import pytest from transcription.db import session_scope -from transcription.db.models import Document, DocumentPerson, DocumentPersonRole, Person +from transcription.db.models import Document +from transcription.db.models import DocumentPerson +from transcription.db.models import DocumentPersonRole +from transcription.db.models import Person @pytest.mark.integration @@ -47,6 +50,7 @@ class TestPeoplePageRendering: assert "Full name is required." in response.text assert "Birth date (YYYY-MM-DD)" in response.text assert "Death date (YYYY-MM-DD)" in response.text + assert "FamilySearch ID" in response.text assert "Biography" in response.text assert "Save person" in response.text @@ -67,6 +71,7 @@ class TestPeoplePageRendering: death_place="Arlington", biography="Computer pioneer", portrait_path="/images/grace.jpg", + family_search_id="G8T4-MDQ", ) session.add(person) await session.commit() @@ -87,6 +92,9 @@ class TestPeoplePageRendering: assert "Computer pioneer" in response.text assert "Created:" in response.text assert "Updated:" in response.text + assert "Open in FamilySearch" in response.text + assert "familysearch.org/tree/person/details/G8T4-MDQ" in response.text + assert "New Document" in response.text assert "No linked documents yet." in response.text @pytest.mark.asyncio @@ -182,4 +190,4 @@ class TestPeoplePageRendering: assert response.status_code == 200 assert "Delete Person Record" in response.text assert "This action permanently deletes the person record." in response.text - assert "Delete person permanently" in response.text \ No newline at end of file + assert "Delete person permanently" in response.text diff --git a/tests/ui/test_sources_page.py b/tests/ui/test_sources_page.py index b59bc1c..0918dc6 100644 --- a/tests/ui/test_sources_page.py +++ b/tests/ui/test_sources_page.py @@ -4,11 +4,13 @@ from pathlib import Path import pytest from sqlmodel import select -from sqlmodel.ext.asyncio.session import AsyncSession from transcription.db import session_scope -from transcription.db.models import Document, Job, JobSourceStatus, JobStatus, Source - +from transcription.db.models import Document +from transcription.db.models import Job +from transcription.db.models import JobSourceStatus +from transcription.db.models import JobStatus +from transcription.db.models import Source # --- Unit Tests for Model @property Definitions --- @@ -99,6 +101,7 @@ class TestSourcesPageRendering: assert response.status_code == 200 assert "page_one.png" in response.text assert "Source Document" in response.text + assert "Stored Filename" not in response.text @pytest.mark.asyncio async def test_sources_page_filters_to_document_context(self, app_client): @@ -211,6 +214,8 @@ class TestSourcesPageRendering: assert "original transcription text" in response.text assert "human revision text" in response.text assert "Save revision" in response.text + assert "Previous Page" in response.text + assert "Next Page" in response.text @pytest.mark.asyncio async def test_source_delete_page_blocks_when_source_is_job_linked(