V5.0 fixes and revisions
Quality Gate / gate (push) Failing after 11s

This commit is contained in:
Jim Lancaster
2026-08-23 10:32:20 -05:00
parent 86b8e83ff4
commit 0f30d902b9
10 changed files with 492 additions and 102 deletions
+7
View File
@@ -21,3 +21,10 @@ data/*
# Local destructive-test backups # Local destructive-test backups
.test-backups/ .test-backups/
# Temporary migration files
.migration-bundle/*
.migration-bundle-v5test/*
.migration-bundle-v5test2/*
data.old/*
+10 -2
View File
@@ -11,6 +11,7 @@ People manages reusable historical-person records. A Person may appear in many D
| `/people` | Searchable People list. | | `/people` | Searchable People list. |
| `/people/new` | Create a Person. | | `/people/new` | Create a Person. |
| `/people/{person_id}` | View one Person and linked Documents. | | `/people/{person_id}` | View one Person and linked Documents. |
| `/people/{person_id}/photos` | Manage Person photos. |
| `/people/{person_id}/edit` | Edit the Person. | | `/people/{person_id}/edit` | Edit the Person. |
| `/people/{person_id}/delete` | Confirm permanent deletion. | | `/people/{person_id}/delete` | Confirm permanent deletion. |
@@ -53,8 +54,8 @@ Rules:
- The header provides **New Document**, **Edit Person**, and **Delete**. - The header provides **New Document**, **Edit Person**, and **Delete**.
- **New Document** opens Document creation with this Person requested for author preselection. - **New Document** opens Document creation with this Person requested for author preselection.
- Person Detail includes a photo gallery card with multi-file upload, per-photo description edits, set-primary, and delete. - Person Detail shows a single-photo viewer with **Previous/Next** navigation; the page-level **Edit Photo(s)** header action opens photo management.
- Primary photo is shown first and labeled as the primary portrait. - Photo management (upload, description edit, set-primary, delete) is intentionally moved to `/people/{person_id}/photos`.
- Biographical Record shows names, compact birth/death dates, and places. - Biographical Record shows names, compact birth/death dates, and places.
- Birth and death place values are clickable links to Google Maps when present. - Birth and death place values are clickable links to Google Maps when present.
- FamilySearch ID is shown as a metadata value and is clickable to the FamilySearch person details route when present. - FamilySearch ID is shown as a metadata value and is clickable to the FamilySearch person details route when present.
@@ -72,6 +73,13 @@ Rules:
- Success returns to the People list. - Success returns to the People list.
- Missing or already-deleted records return to a safe list state. - Missing or already-deleted records return to a safe list state.
## Photo Gallery Behavior (`/people/{person_id}/photos`)
- Upload is triggered from a header-level **Upload Photo(s)** control beside **Back to Person**.
- The gallery renders all photos in a responsive grid (3-4 tiles wide on larger screens).
- Description text is shown as an overlay at the bottom of each image for quick context.
- The editor provides **Save Description**, **Set Primary** (when applicable), and **Delete Photo** actions.
## Acceptance Checklist ## Acceptance Checklist
- List fields, alignment, date fallback, search, sorting, and navigation match this contract. - List fields, alignment, date fallback, search, sorting, and navigation match this contract.
+78 -16
View File
@@ -18,6 +18,7 @@ from sqlalchemy import create_engine
from sqlalchemy import inspect as sqlalchemy_inspect from sqlalchemy import inspect as sqlalchemy_inspect
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy.engine import make_url
from sqlmodel import SQLModel from sqlmodel import SQLModel
from transcription.config import Settings from transcription.config import Settings
@@ -124,6 +125,14 @@ def import_bundle(*, target_db_url: str, target_upload_dir: Path, bundle_dir: Pa
uploads_bundle_dir = bundle_dir / "uploads" uploads_bundle_dir = bundle_dir / "uploads"
payload = json.loads(export_json.read_text(encoding="utf-8")) payload = json.loads(export_json.read_text(encoding="utf-8"))
if target_upload_dir.exists():
shutil.rmtree(target_upload_dir)
target_upload_dir.mkdir(parents=True, exist_ok=True)
if uploads_bundle_dir.exists():
shutil.copytree(uploads_bundle_dir, target_upload_dir, dirs_exist_ok=True)
_reset_sqlite_target_file(target_db_url)
_ensure_sqlite_target_parent_exists(target_db_url)
engine = create_engine(target_db_url) engine = create_engine(target_db_url)
try: try:
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
@@ -141,11 +150,27 @@ def import_bundle(*, target_db_url: str, target_upload_dir: Path, bundle_dir: Pa
finally: finally:
engine.dispose() engine.dispose()
if target_upload_dir.exists():
shutil.rmtree(target_upload_dir) def _ensure_sqlite_target_parent_exists(target_db_url: str) -> None:
target_upload_dir.mkdir(parents=True, exist_ok=True) parsed = make_url(target_db_url)
if uploads_bundle_dir.exists(): if not parsed.drivername.startswith("sqlite"):
shutil.copytree(uploads_bundle_dir, target_upload_dir, dirs_exist_ok=True) return
database = parsed.database
if not database or database == ":memory:":
return
Path(database).parent.mkdir(parents=True, exist_ok=True)
def _reset_sqlite_target_file(target_db_url: str) -> None:
parsed = make_url(target_db_url)
if not parsed.drivername.startswith("sqlite"):
return
database = parsed.database
if not database or database == ":memory:":
return
target = Path(database)
if target.exists():
target.unlink()
def migrate_via_bundle(paths: MigrationPaths) -> None: def migrate_via_bundle(paths: MigrationPaths) -> None:
@@ -280,8 +305,37 @@ def _prepare_photo_payload_and_uploads(
photos_dir = uploads_bundle_dir / "photos" photos_dir = uploads_bundle_dir / "photos"
photos_dir.mkdir(parents=True, exist_ok=True) photos_dir.mkdir(parents=True, exist_ok=True)
if source_has_photo_table: # Keep only photo rows whose referenced media exists inside the uploads tree.
return # This prevents stale/injected rows from blocking legacy backfill.
retained_rows: list[dict[str, Any]] = []
for row in photo_rows:
path_value = row.get("path")
if not isinstance(path_value, str) or not path_value.strip():
continue
canonical_path = _canonical_media_relative_path(
path_value,
source_upload_dir=uploads_bundle_dir,
preferred_prefix="photos/",
)
candidate = uploads_bundle_dir / canonical_path
if not candidate.exists():
continue
row["path"] = canonical_path
retained_rows.append(row)
photo_rows[:] = retained_rows
existing_homepage_rows = [row for row in photo_rows if row.get("person_id") is None]
existing_person_ids = {
str(row["person_id"])
for row in photo_rows
if row.get("person_id") is not None
}
existing_primary_person_ids = {
str(row["person_id"])
for row in photo_rows
if row.get("person_id") is not None and bool(row.get("is_primary"))
}
has_homepage_primary = any(bool(row.get("is_primary")) for row in existing_homepage_rows)
now_iso = datetime.now().isoformat() now_iso = datetime.now().isoformat()
for row in legacy_portrait_rows: for row in legacy_portrait_rows:
@@ -297,26 +351,32 @@ def _prepare_photo_payload_and_uploads(
preferred_prefix="persons/", preferred_prefix="persons/",
) )
source_file = uploads_bundle_dir / canonical source_file = uploads_bundle_dir / canonical
if not source_file.exists():
continue
person_key = str(person_id)
if person_key in existing_person_ids:
continue
suffix = Path(canonical).suffix.lower() or ".jpg" suffix = Path(canonical).suffix.lower() or ".jpg"
photo_id = str(uuid4()) photo_id = str(uuid4())
relative_path = f"photos/{photo_id}{suffix}" relative_path = f"photos/{photo_id}{suffix}"
if source_file.exists(): target_file = uploads_bundle_dir / relative_path
target_file = uploads_bundle_dir / relative_path target_file.parent.mkdir(parents=True, exist_ok=True)
target_file.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source_file, target_file)
shutil.copy2(source_file, target_file) is_primary = person_key not in existing_primary_person_ids
else:
relative_path = canonical
photo_rows.append( photo_rows.append(
{ {
"id": photo_id, "id": photo_id,
"person_id": str(person_id), "person_id": person_key,
"path": relative_path, "path": relative_path,
"description": None, "description": None,
"is_primary": True, "is_primary": is_primary,
"created_at": now_iso, "created_at": now_iso,
"updated_at": now_iso, "updated_at": now_iso,
} }
) )
existing_person_ids.add(person_key)
if is_primary:
existing_primary_person_ids.add(person_key)
legacy_homepage_dir = uploads_bundle_dir / "homepage" legacy_homepage_dir = uploads_bundle_dir / "homepage"
if not legacy_homepage_dir.exists(): if not legacy_homepage_dir.exists():
@@ -330,6 +390,8 @@ def _prepare_photo_payload_and_uploads(
], ],
key=lambda path: (path.stat().st_mtime, path.name), key=lambda path: (path.stat().st_mtime, path.name),
) )
if existing_homepage_rows:
return
for index, image_path in enumerate(homepage_images): for index, image_path in enumerate(homepage_images):
photo_id = str(uuid4()) photo_id = str(uuid4())
relative_path = f"photos/{photo_id}{image_path.suffix.lower()}" relative_path = f"photos/{photo_id}{image_path.suffix.lower()}"
@@ -342,7 +404,7 @@ def _prepare_photo_payload_and_uploads(
"person_id": None, "person_id": None,
"path": relative_path, "path": relative_path,
"description": None, "description": None,
"is_primary": index == 0, "is_primary": (not has_homepage_primary) and index == 0,
"created_at": now_iso, "created_at": now_iso,
"updated_at": now_iso, "updated_at": now_iso,
} }
+1 -1
View File
@@ -35,7 +35,6 @@ def _register_global_styles(app: FastAPI) -> None:
def register_pages(app: FastAPI) -> None: def register_pages(app: FastAPI) -> None:
"""Register all NiceGUI pages and mount them onto the FastAPI app.""" """Register all NiceGUI pages and mount them onto the FastAPI app."""
_register_global_styles(app)
register_home_page() register_home_page()
register_documents_page() register_documents_page()
register_tags_page() register_tags_page()
@@ -45,3 +44,4 @@ def register_pages(app: FastAPI) -> None:
register_jobs_page() register_jobs_page()
register_settings_page(settings=getattr(app.state, "settings", None) or get_settings()) register_settings_page(settings=getattr(app.state, "settings", None) or get_settings())
ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False) ui.run_with(app, mount_path="/ui", show_welcome_message=False, dark=False)
_register_global_styles(app)
+196 -69
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from uuid import UUID from uuid import UUID
from uuid import uuid4 from uuid import uuid4
@@ -37,7 +38,6 @@ from transcription.ui.components.table.common import build_table
from transcription.ui.components.table.people import PersonTableRow from transcription.ui.components.table.people import PersonTableRow
from transcription.ui.components.table.people import render_people_table from transcription.ui.components.table.people import render_people_table
from transcription.ui.components.upload_panel import IMAGE_UPLOAD_EXTENSIONS from transcription.ui.components.upload_panel import IMAGE_UPLOAD_EXTENSIONS
from transcription.ui.components.upload_panel import render_upload_picker
from transcription.ui.components.viewers import dark_room_viewer from transcription.ui.components.viewers import dark_room_viewer
from transcription.ui.runtime import resolve_runtime_settings from transcription.ui.runtime import resolve_runtime_settings
from transcription.ui.theme import page_header from transcription.ui.theme import page_header
@@ -191,6 +191,11 @@ def register_page() -> None: # noqa: PLR0915
on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"), on_click=lambda: ui.navigate.to(f"/people/{person.id}/edit"),
icon="edit", icon="edit",
).classes("ui-btn-primary text-xs") ).classes("ui-btn-primary text-xs")
ui.button(
"Edit Photo(s)",
on_click=lambda: ui.navigate.to(f"/people/{person.id}/photos"),
icon="photo_library",
).props("flat").classes("text-xs ui-link-primary")
destructive_button( destructive_button(
"Delete", "Delete",
on_click=lambda: ui.navigate.to(f"/people/{person.id}/delete"), on_click=lambda: ui.navigate.to(f"/people/{person.id}/delete"),
@@ -208,6 +213,140 @@ def register_page() -> None: # noqa: PLR0915
_render_person_biographical_zone(person) _render_person_biographical_zone(person)
_render_person_biography_zone(person) _render_person_biography_zone(person)
@ui.page("/people/{person_id}/photos")
async def person_photos_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
people_service = PeopleService(session_factory=session_factory)
photos_service = PhotosService(session_factory=session_factory)
settings = resolve_runtime_settings(request)
render_navigation_header(current_path="/people")
parsed_person_id = parsed_record_id(person_id, noun="Person")
if parsed_person_id is None:
return
try:
person = await people_service.read_person_detail(parsed_person_id)
except PeopleError:
render_record_not_found("Person")
return
except Exception as exc: # noqa: BLE001
show_error(exc, title="Load failed", operation="people.photos.read")
return
photos = await photos_service.list_photos(person_id=person.id)
async def on_photo_selected(event) -> None:
payload = await event.file.read()
try:
await photos_service.create_photo(
person_id=person.id,
filename=event.file.name,
file_bytes=payload,
)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
return
ui.notify("Photo uploaded.", type="positive")
ui.navigate.to(f"/people/{person.id}/photos")
@ui.refreshable
def render_gallery() -> None:
with archival_card(title="Photo Gallery", extra_classes="gap-3"):
if not photos:
render_empty_state("No portrait photo uploaded yet.")
return
with ui.grid().classes("w-full grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"):
for photo in photos:
with archival_card(extra_classes="gap-2"):
photo_url = resolve_media_url(
photo.path,
upload_dir=settings.upload_dir,
base_url=str(request.base_url),
)
with ui.element("div").classes("relative w-full"):
ui.image(photo_url).classes("w-full rounded-md")
description_text = photo.description or "No description"
ui.label(description_text).classes(
"absolute inset-x-0 bottom-0 text-center text-white text-xs font-semibold px-2 py-1 bg-black/60 rounded-b-md"
)
if photo.is_primary:
ui.label("Primary").classes(
"absolute top-2 right-2 text-[11px] text-white font-semibold px-2 py-1 bg-primary/80 rounded"
)
description_input = (
ui.input(
label="Description",
value=photo.description or "",
)
.props("outlined dense")
.classes("w-full")
)
async def save_description(
*,
photo_id: UUID = photo.id,
input_control: ui.input = description_input,
) -> None:
try:
await photos_service.update_description(
photo_id=photo_id,
description=(input_control.value or "").strip() or None,
)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
return
ui.notify("Description saved.", type="positive")
ui.navigate.to(f"/people/{person.id}/photos")
async def set_primary(*, photo_id: UUID = photo.id) -> None:
try:
await photos_service.set_primary(photo_id=photo_id)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
return
ui.notify("Primary photo updated.", type="positive")
ui.navigate.to(f"/people/{person.id}/photos")
async def delete_photo(*, photo_id: UUID = photo.id) -> None:
try:
await photos_service.delete_photo(photo_id=photo_id)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
return
ui.notify("Photo deleted.", type="positive")
ui.navigate.to(f"/people/{person.id}/photos")
with ui.row().classes("w-full items-center gap-1"):
ui.button("Save Description", on_click=save_description, icon="save").props("flat dense")
if not photo.is_primary:
ui.button("Set Primary", on_click=set_primary, icon="star").props("flat dense")
ui.button("Delete Photo", on_click=delete_photo, icon="delete").props("flat dense color=negative")
with ui.column().classes("w-full max-w-[1400px] mx-auto p-4 gap-4"):
with section_header_row():
page_header("Edit Photos", subtitle=f"{person.full_name} ({person.id})")
with ui.row().classes("items-center gap-2"):
ui.button("Back to Person", on_click=lambda: ui.navigate.to(f"/people/{person.id}"), icon="arrow_back").props(
"flat"
)
ui.upload(
label="",
on_upload=on_photo_selected,
auto_upload=True,
).props(
f'multiple accept="{",".join(sorted(IMAGE_UPLOAD_EXTENSIONS))}"'
).classes("hidden person-photo-upload")
ui.button(
"Upload Photo(s)",
on_click=lambda: ui.run_javascript(
"document.querySelector('.person-photo-upload input[type=file]')?.click()"
),
icon="upload",
).props("flat")
render_gallery()
@ui.page("/people/{person_id}/edit") @ui.page("/people/{person_id}/edit")
async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None: async def person_edit_page(person_id: str, request: Request, session_factory: SessionFactoryDep) -> None:
people_service = PeopleService(session_factory=session_factory) people_service = PeopleService(session_factory=session_factory)
@@ -443,84 +582,72 @@ async def _render_person_photo_zone(
request: Request, request: Request,
) -> None: ) -> None:
photos = await photos_service.list_photos(person_id=person.id) photos = await photos_service.list_photos(person_id=person.id)
active_index = [0]
with ui.column().classes("col-span-12 lg:col-span-4"): with ui.column().classes("col-span-12 lg:col-span-4"):
with archival_card(title="Photos", extra_classes="gap-3"): with archival_card(title="Photos", extra_classes="gap-3"):
async def on_photo_selected(event) -> None: _render_photo_viewer_with_navigation(
payload = await event.file.read() photos=photos,
try: active_index=active_index,
await photos_service.create_photo( settings=settings,
person_id=person.id, request=request,
filename=event.file.name, empty_message="No portrait photo uploaded yet.",
file_bytes=payload,
)
except PhotoError as exc:
ui.notify(str(exc), type="negative")
return
ui.notify("Photo uploaded.", type="positive")
ui.navigate.to(f"/people/{person.id}")
render_upload_picker(
on_upload=on_photo_selected,
label="Upload photo(s)",
extensions=IMAGE_UPLOAD_EXTENSIONS,
multiple=True,
) )
if not photos: def _shift_gallery_index(*, photos: list, active_index: list[int], step: int) -> None:
render_empty_state("No portrait photo uploaded yet.") if len(photos) < 2:
return active_index[0] = 0
return
active_index[0] = (active_index[0] + step) % len(photos)
for photo in photos:
dark_room_viewer(
resolve_media_url(
photo.path,
upload_dir=settings.upload_dir,
base_url=str(request.base_url),
),
count_label="Primary Portrait" if photo.is_primary else "Portrait Media",
)
description_input = (
ui.input(
label="Description",
value=photo.description or "",
)
.props("outlined dense")
.classes("w-full")
)
async def save_description(*, photo_id: UUID = photo.id, input_control: ui.input = description_input) -> None: def _render_photo_viewer_with_navigation(
try: *,
await photos_service.update_description( photos: list,
photo_id=photo_id, active_index: list[int],
description=(input_control.value or "").strip() or None, settings: Settings,
) request: Request,
except PhotoError as exc: empty_message: str,
ui.notify(str(exc), type="negative") on_change: Callable[[], None] | None = None,
return ) -> None:
ui.navigate.to(f"/people/{person.id}") if not photos:
render_empty_state(empty_message)
return
async def set_primary(*, photo_id: UUID = photo.id) -> None: if active_index[0] >= len(photos):
try: active_index[0] = len(photos) - 1
await photos_service.set_primary(photo_id=photo_id) if active_index[0] < 0:
except PhotoError as exc: active_index[0] = 0
ui.notify(str(exc), type="negative")
return
ui.navigate.to(f"/people/{person.id}")
async def delete_photo(*, photo_id: UUID = photo.id) -> None: current_photo = photos[active_index[0]]
try: photo_url = resolve_media_url(
await photos_service.delete_photo(photo_id=photo_id) current_photo.path,
except PhotoError as exc: upload_dir=settings.upload_dir,
ui.notify(str(exc), type="negative") base_url=str(request.base_url),
return )
ui.navigate.to(f"/people/{person.id}") dark_room_viewer(
photo_url,
count_label="Primary Portrait" if current_photo.is_primary else "Portrait Media",
)
with ui.row().classes("w-full items-center gap-2 mb-4"): description_text = current_photo.description or "No description"
ui.button("Save description", on_click=save_description, icon="save").props("flat") with ui.element("div").classes("relative w-full -mt-16 z-10 pointer-events-none"):
if not photo.is_primary: ui.label(description_text).classes(
ui.button("Set primary", on_click=set_primary, icon="star").props("flat") "mx-auto w-fit max-w-[90%] text-center text-white text-xs font-semibold px-2 py-1 rounded bg-black/60"
ui.button("Delete photo", on_click=delete_photo, icon="delete").props("flat color=negative") )
def move(step: int) -> None:
_shift_gallery_index(photos=photos, active_index=active_index, step=step)
if on_change is not None:
on_change()
with ui.row().classes("w-full items-center justify-between mt-2"):
previous = ui.button("Previous", on_click=lambda: move(-1), icon="chevron_left").props("flat dense")
following = ui.button("Next", on_click=lambda: move(1), icon="chevron_right").props("flat dense icon-right")
if len(photos) < 2:
previous.props("disable")
following.props("disable")
ui.label(f"{active_index[0] + 1} of {len(photos)}").classes("text-xs ui-text-muted")
def _render_person_biographical_zone(person: Person) -> None: def _render_person_biographical_zone(person: Person) -> None:
@@ -3,7 +3,8 @@ provider: openrouter
model: openai/gpt-5.3-codex model: openai/gpt-5.3-codex
--- ---
[document body typewritten] [document body typewritten]
BY WAY OF INTRODUCTION:-
BY WAY OF INTRODUCTION:~
These few paragraphs of introduction may help you read BOOK 2 which covers a wider range than did BOOK 1 (Pioneer Days). These few paragraphs of introduction may help you read BOOK 2 which covers a wider range than did BOOK 1 (Pioneer Days).
@@ -13,5 +14,6 @@ We promised in BOOK 1 that in BOOK 2 we would give the story of the trip of John
Some who get this book will consider the group picture the best thing in the book. It took a lot of preliminary photographing to reduce some pictures, enlarge others and bring out the tin types. We wish that instead of 44 faces we could have given 88. Do not blame Ethel Cochran-Shinn for the selection. She furnished enough pictures but we had to take only part of them. We think there are great possibilities in reproducing old pictures. We wish we had a Pickard group. Some Pickard descendant may wish to make a collection. Some who get this book will consider the group picture the best thing in the book. It took a lot of preliminary photographing to reduce some pictures, enlarge others and bring out the tin types. We wish that instead of 44 faces we could have given 88. Do not blame Ethel Cochran-Shinn for the selection. She furnished enough pictures but we had to take only part of them. We think there are great possibilities in reproducing old pictures. We wish we had a Pickard group. Some Pickard descendant may wish to make a collection.
We are much impressed with the future possibilities of getting a complete genealogy of the Pickard family. Mr. Cochran has a fine chapter on the Pickards but to date we have not had the pleasure of finding all of the family dates. We had intended to give more family data in this book but it takes time to get the correct dates. Often times it requires trips to cemeteries to get dates on the tombstones. Winter is no time to collect dates on tombstones. We are much impressed with the future possibilities of getting a complete geneology [sic] of the Pickard family. Mr. Cochran has a fine chapter on the Pickards but to date we have not had the pleasure of finding all of the family dates. We had intended to give more family data in this book but it takes time to get the correct dates. Often times it requires trips to cemeteries to get dates on the tombstones. Winter is no time to collect dates on tombstones.
-2-
~2~
@@ -3,7 +3,7 @@ provider: openrouter
model: openai/gpt-5.3-codex model: openai/gpt-5.3-codex
--- ---
[document body typeset] [document body typeset]
Family Only Family Only
Home | Sibling's Stories | 1st Cousins | Ancestor's Stories | Reunion History | Next Reunion | JECMEF Home | Sibling's Stories | 1st Cousins | Ancestor's Stories | Reunion History | Next Reunion | JECMEF
OMIE WRITES HOME OMIE WRITES HOME
@@ -16,13 +16,11 @@ My Dear Ethel et al.
I don't know when I did write or when you did but I am going to write now however and never the less. But I wish I could talk (I can yet but I mean to tell you all) instead and see ole Unc Pete and Polly sit up and listen and that little black rascal of yours would fairly sparkle with listening. Can't I see him listening now to all the yarns we told last summer? I don't know when I did write or when you did but I am going to write now however and never the less. But I wish I could talk (I can yet but I mean to tell you all) instead and see ole Unc Pete and Polly sit up and listen and that little black rascal of yours would fairly sparkle with listening. Can't I see him listening now to all the yarns we told last summer?
[photograph of children standing in snow]
You see, we-Miss Saville and I, took a trip north on the Buford and it was very interesting. We went north thru the Bering Strait into the Arctic and as far as the Ice Pack. There the captain of our craft and some other mighty hunters went out first in kayaks and later in row boats and shot seven walrus. When they also took a movie man and camera, so you will likely see all this in the movies before I get to tell you. They came back on board and the ship went up along the icebergs and the walrus were 'heisted' on board by the use of the crane which loads tons of freight and the beasts were so huge that they made the pulleys just creak. They were over 12 feet long, as big around as three cows, had no feet but toenails on their flippers or flappers, no head but their body just suddenly ended with a hole for a mouth and big bristles all around it similar to a currycomb in coarseness; no ears but huge tusks of ivory. They are the most repulsive looking animals imaginable and tho I have always read about them I never expect such disagreeable looking creatures. They had a rough brown hairy skin and some of them looked warty. They must have weighed two ton at least. Ere we got them back to Nome to the natives they were getting extremely odiferous—in fact, you could scarcely stay on the ship with any degree of comfort unless you had per chance lost your sense of smell. You see, we-Miss Saville and I, took a trip north on the Buford and it was very interesting. We went north thru the Bering Strait into the Arctic and as far as the Ice Pack. There the captain of our craft and some other mighty hunters went out first in kayaks and later in row boats and shot seven walrus. When they also took a movie man and camera, so you will likely see all this in the movies before I get to tell you. They came back on board and the ship went up along the icebergs and the walrus were 'heisted' on board by the use of the crane which loads tons of freight and the beasts were so huge that they made the pulleys just creak. They were over 12 feet long, as big around as three cows, had no feet but toenails on their flippers or flappers, no head but their body just suddenly ended with a hole for a mouth and big bristles all around it similar to a currycomb in coarseness; no ears but huge tusks of ivory. They are the most repulsive looking animals imaginable and tho I have always read about them I never expect such disagreeable looking creatures. They had a rough brown hairy skin and some of them looked warty. They must have weighed two ton at least. Ere we got them back to Nome to the natives they were getting extremely odiferous—in fact, you could scarcely stay on the ship with any degree of comfort unless you had per chance lost your sense of smell.
Then we went north to a few minutes beyond the 70th degree of latitude and thot for awhile we would go to Wrangell Island where some men from Steffonsons ship were supposed to be stranded but we didn't get there and instead stopped at a small native village at Cape Serdz in Siberia. These Eskimo were very primitive. One white squaw man lived there and had for 23 years. He was a Swede--who else could. Their houses were circular and built up with dirt 2 or 3 feet and then skins were stretched over it and weighted down with rocks. Inside, the room was partitioned off at the sides with skins for sleeping quarters. In the main part they had the fire on the ground and the fish drying on lines and the skins hanging around and the dogs and babies and children. They wore skin clothes entirely. The women's were made like bloomers and were heavily padded for warmth. They wore high mukluks and really looked very comfortable. The babies were in fur skins with the fur inside and they looked like little Teddy bears with faces. I guess they had never seen white women, not so many at one time anyway. We went ashore in 2 life boats and a launch pulling them. On the launch was a six piece band playing and the rear of the last life boat was the movie man. 'Twas very thrilling. Then we went north to a few minutes beyond the 70th degree of latitude and thot for awhile we would go to Wrangell Island where some men from Steffonsons ship were supposed to be stranded but we didn't get there and instead stopped at a small native village at Cape Serdz in Siberia. These Eskimo were very primitive. One white squaw man lived there and had for 23 years. He was a Swede--who else could. Their houses were circular and built up with dirt 2 or 3 feet and then skins were stretched over it and weighted down with rocks. Inside, the room was partitioned off at the sides with skins for sleeping quarters. In the main part they had the fire on the ground and the fish drying on lines and the skins hanging around and the dogs and babies and children. They wore skin clothes entirely. The women's were made like bloomers and were heavily padded for warmth. They wore high mukluks and really looked very comfortable. The babies were in fur skins with the fur inside and they looked like little Teddy bears with faces. I guess they had never seen white women, not so many at one time anyway. We went ashore in 2 life boats and a launch pulling them. On the launch was a six piece band playing and the rear of the last life boat was the movie man. 'Twas very thrilling.
The other place we stopped was at Whalen, a trading post in Siberia. There these 'towerists' went wild. They rushed helter-skelter, hither and thither, here and there, trying to find something to [buy?]. Prices raised right before your eyes. One would [buy?] something for $1.00 and the next might have to pay $2.00, $4.00 or $10.00. That made no difference. They had to have it. One man I was sort of taking care of, tho he had his son along for the purpose, bought 2 ivory tusks, 1 pup, 2 moccasins, 3 or 4 billikens, 6 or 8 ivory and silver rings, one fishing line, hooks, floats, etc. and two bird slings. The slings have rocks at the end and the little natives throw them at the flocks of geese and ducks which fly close over the village and the slings entangle their wings and legs, sometimes more than one, and they can't fly. They come down and the natives capture them. There was more junk brot aboard than baggage, I do believe. And they say that at the first stop it was worse than here. The red flag was flying over Whalen and the Russian soldiers were there—a few, one or two or three, I forget the number. The other place we stopped was at Whalen, a trading post in Siberia. There these 'towerists' went wild. They rushed helter-skelter, hither and thither, here and there, trying to find something to buy. Prices raised right before your eyes. One would but [sic] something for $1.00 and the next might have to pay $2.00, $4.00 or $10.00. That made no difference. They had to have it. One man I was sort of taking care of, tho he had his son along for the purpose, bought 2 ivory tusks, 1 pup, 2 moccasins, 3 or 4 billikens, 6 or 8 ivory and silver rings, one fishing line, hooks, floats, etc. and two bird slings. The slings have rocks at the end and the little natives throw them at the flocks of geese and ducks which fly close over the village and the slings entangle their wings and legs, sometimes more than one, and they can't fly. They come down and the natives capture them. There was more junk brot aboard than baggage, I do believe. And they say that at the first stop it was worse than here. The red flag was flying over Whalen and the Russian soldiers were there—a few, one or two or three, I forget the number.
We got home yesterday morning at 5 a.m. but missed the first lighter in so had to stay out until 2:30. The girls had prepared a big meal for us and invited up the Hartfords and then let us talk. Miss Saville talked quite a bit. Any how if you folks don't like this I don't care, it is all I had to write about and I know Buster'ud listen anyway and I'd soak ole Peter's head if he didn't and Polly would in my lap and I don't know much about the youngest one of yours so likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf. We got home yesterday morning at 5 a.m. but missed the first lighter in so had to stay out until 2:30. The girls had prepared a big meal for us and invited up the Hartfords and then let us talk. Miss Saville talked quite a bit. Any how if you folks don't like this I don't care, it is all I had to write about and I know Buster'ud listen anyway and I'd soak ole Peter's head if he didn't and Polly would in my lap and I don't know much about the youngest one of yours so likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf.
@@ -16,17 +16,18 @@ I was at home a
few nights ago & saw a few nights ago & saw a
letter from your folks, so letter from your folks, so
I decided to write you I decided to write you
a few lines myself as a few lines [in?] regards of
I am contemplating a I am contemplat[ing?] a
trip out west next summer trip out west next summer
& want lots of places to go & want lots of [places?] to go
where I am [there?]. where I am from.
Am getting Am getting
up in years & [remembering?] up in years & [remembering?].
so your one the object of So you see the object of
my trip, is to get a wife my trip, is to get a wife
If there is any old maids If there is any old maids
or widows out there I or widows out there I
want you to hire them want you to hire them
at [our?] [land?] [my?] at [them?] at [pur?] [find?] me at them
as soon as I get there.] as soon as I get there.]
+148
View File
@@ -10,8 +10,10 @@ from sqlalchemy import text
from sqlalchemy import select from sqlalchemy import select
from sqlmodel import SQLModel from sqlmodel import SQLModel
from transcription.db.migration import MigrationPaths
from transcription.db.migration import export_bundle from transcription.db.migration import export_bundle
from transcription.db.migration import import_bundle from transcription.db.migration import import_bundle
from transcription.db.migration import migrate_via_bundle
from transcription.db.migration import sqlite_url_from_path from transcription.db.migration import sqlite_url_from_path
# Register table metadata. # Register table metadata.
@@ -182,3 +184,149 @@ def test_export_import_migration_backfills_legacy_portraits_and_homepage_images(
assert (target_upload_dir / str(person_photo[1])).read_bytes() == b"portrait" assert (target_upload_dir / str(person_photo[1])).read_bytes() == b"portrait"
assert (target_upload_dir / str(homepage_photo[1])).read_bytes() == b"homepage" assert (target_upload_dir / str(homepage_photo[1])).read_bytes() == b"homepage"
assert (target_upload_dir / "homepage.md").read_text(encoding="utf-8") == "# Legacy Home" assert (target_upload_dir / "homepage.md").read_text(encoding="utf-8") == "# Legacy Home"
def test_import_bundle_creates_missing_target_db_parent_directory(tmp_path):
source_db_path = tmp_path / "source.db"
source_upload_dir = tmp_path / "source_uploads"
bundle_dir = tmp_path / "bundle"
nested_target_db = tmp_path / "missing-parent" / "nested" / "target.db"
target_upload_dir = tmp_path / "target_uploads"
source_db_url = sqlite_url_from_path(source_db_path)
target_db_url = sqlite_url_from_path(nested_target_db)
engine = create_engine(source_db_url)
try:
SQLModel.metadata.create_all(engine)
finally:
engine.dispose()
source_upload_dir.mkdir(parents=True, exist_ok=True)
export_bundle(source_db_url=source_db_url, source_upload_dir=source_upload_dir, bundle_dir=bundle_dir)
import_bundle(target_db_url=target_db_url, target_upload_dir=target_upload_dir, bundle_dir=bundle_dir)
assert nested_target_db.exists()
def test_migration_backfills_legacy_media_when_photo_table_contains_stale_rows(tmp_path):
source_db_path = tmp_path / "source-stale-photo.db"
target_db_path = tmp_path / "target-stale-photo.db"
source_upload_dir = tmp_path / "source_uploads"
target_upload_dir = tmp_path / "target_uploads"
bundle_dir = tmp_path / "bundle-stale-photo"
source_db_url = sqlite_url_from_path(source_db_path)
target_db_url = sqlite_url_from_path(target_db_path)
person_id = "11" * 16
portrait_file = source_upload_dir / "persons" / "legacy" / "portrait.png"
portrait_file.parent.mkdir(parents=True, exist_ok=True)
portrait_file.write_bytes(b"portrait")
homepage_file = source_upload_dir / "homepage" / "banner.jpg"
homepage_file.parent.mkdir(parents=True, exist_ok=True)
homepage_file.write_bytes(b"homepage")
engine = create_engine(source_db_url)
try:
with engine.begin() as connection:
connection.execute(
text(
'create table "person" ('
"id char(32) primary key, "
"full_name varchar not null, "
"portrait_path varchar, "
"created_at datetime not null, "
"updated_at datetime not null"
")"
)
)
connection.execute(
text(
'create table "photo" ('
"id char(32) primary key, "
"person_id char(32), "
"path varchar not null, "
"description varchar, "
"is_primary boolean not null, "
"created_at datetime not null, "
"updated_at datetime not null"
")"
)
)
connection.execute(
text(
'insert into "person" (id, full_name, portrait_path, created_at, updated_at) '
'values (:id, :full_name, :portrait_path, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)'
),
{"id": person_id, "full_name": "Legacy Portrait", "portrait_path": "persons/legacy/portrait.png"},
)
# Stale row whose file does not exist.
connection.execute(
text(
'insert into "photo" (id, person_id, path, description, is_primary, created_at, updated_at) '
"values (:id, NULL, :path, NULL, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
),
{"id": "22" * 16, "path": "photos/missing.png"},
)
finally:
engine.dispose()
migrate_via_bundle(
MigrationPaths(
source_db_url=source_db_url,
target_db_url=target_db_url,
source_upload_dir=source_upload_dir,
target_upload_dir=target_upload_dir,
bundle_dir=bundle_dir,
)
)
target_engine = create_engine(target_db_url)
try:
with target_engine.connect() as connection:
photos = connection.execute(text('select person_id, path from "photo" order by person_id is null, path')).all()
# stale row must not survive; legacy portrait + homepage should be backfilled
assert len(photos) == 2
assert any(row[0] is not None for row in photos)
assert any(row[0] is None for row in photos)
assert all(str(row[1]).startswith("photos/") for row in photos)
finally:
target_engine.dispose()
def test_import_bundle_does_not_leave_upload_copy_db_as_final_database(tmp_path):
source_db_path = tmp_path / "source.db"
source_upload_dir = tmp_path / "source_uploads"
target_upload_dir = tmp_path / "target_uploads"
target_db_path = target_upload_dir / "transcription.db"
bundle_dir = tmp_path / "bundle-overwrite-guard"
source_db_url = sqlite_url_from_path(source_db_path)
target_db_url = sqlite_url_from_path(target_db_path)
engine = create_engine(source_db_url)
try:
SQLModel.metadata.create_all(engine)
with engine.begin() as connection:
connection.execute(
SQLModel.metadata.tables["document"].insert(),
[{"id": uuid4(), "name": "Expected migrated row"}],
)
finally:
engine.dispose()
source_upload_dir.mkdir(parents=True, exist_ok=True)
# Simulate real-world UPLOAD_DIR where a pre-existing DB file is present.
(source_upload_dir / "transcription.db").write_bytes(b"not-a-real-sqlite-db")
export_bundle(source_db_url=source_db_url, source_upload_dir=source_upload_dir, bundle_dir=bundle_dir)
import_bundle(target_db_url=target_db_url, target_upload_dir=target_upload_dir, bundle_dir=bundle_dir)
target_engine = create_engine(target_db_url)
try:
with target_engine.connect() as connection:
count = connection.execute(text('select count(*) from "document"')).scalar_one()
assert count == 1
finally:
target_engine.dispose()
+37
View File
@@ -158,6 +158,43 @@ class TestPeoplePageRendering:
assert response.status_code == 200 assert response.status_code == 200
assert "No source media available for inspection." not in response.text assert "No source media available for inspection." not in response.text
assert "Edit Photo(s)" in response.text
assert response.text.count("Edit Photo(s)") == 1
assert "Upload photo(s)" not in response.text
@pytest.mark.asyncio
async def test_person_photos_page_renders_photo_management_controls(self, app_client):
app, client = app_client
upload_dirs = {app.state.settings.upload_dir, get_settings().upload_dir}
for upload_dir in upload_dirs:
photo_file = upload_dir / "photos" / "seeded.png"
photo_file.parent.mkdir(parents=True, exist_ok=True)
photo_file.write_bytes(b"portrait")
async with session_scope() as session:
person = Person(full_name="Gallery Person")
session.add(person)
await session.flush()
session.add(
Photo(
person_id=person.id,
path="photos/seeded.png",
is_primary=True,
description="Seeded description",
)
)
await session.commit()
person_id = str(person.id)
response = client.get(f"/ui/people/{person_id}/photos")
assert response.status_code == 200
assert "Edit Photos" in response.text
assert "Upload Photo(s)" in response.text
assert "Back to Person" in response.text
assert "Save Description" in response.text
assert "Delete Photo" in response.text
assert "Set Primary" not in response.text
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_person_detail_page_renders_linked_documents(self, app_client): async def test_person_detail_page_renders_linked_documents(self, app_client):