18 Commits

Author SHA1 Message Date
justin 631d050540 Add GEDCOM Import/Export UI (defaults to importing into a new tree)
An Import/Export page (sidebar) that defaults to importing into a NEW tree to avoid duplicating existing people, with an explicit 'append to this tree' option (warned), a mapping-report display (counts + skipped tags), and a one-click .ged export download.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:46:48 -04:00
justin d48029a407 Add GEDCOM import/export
A pragmatic GEDCOM parser + mapper: import reads INDI/FAM/SOUR and creates people, names, life events, partnership + qualified parent-child relationships, marriage events, places (deduped), sources, and citations from SOUR refs — returning a mapping report (counts + unmapped tags). Export serializes the tree back to GEDCOM (families derived from the edge model). Import is additive (no merge) and runs inline for now. Round-trip test passes; 29 tests total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:46:48 -04:00
justin 18dea507d1 Merge pull request 'Pedigree connector lines + 4 grandparents' (#11) from pedigree-connectors into main
build-frontend / build (push) Successful in 1m22s
2026-06-06 22:32:12 -04:00
justin 99a660485e Pedigree: connector lines + correct 4-grandparent structure
Rebuilds the family view's pedigree as a recursive bracket chart with CSS connector lines — focus links to its two parents (2 lines), and each parent links to its two parents (4 lines to grandparents). Fixes the prior ambiguity where grandparent slots weren't tied to a specific parent: now every parent shows its own two parent slots, so a person clearly has up to four grandparents grouped by lineage. Height-robust connectors (each leaf draws its own spine half + stub).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:32:10 -04:00
justin cf6dcf9ce2 Merge pull request 'Family view + soft-delete/recovery' (#10) from phase1-familyview into main
build-backend / build (push) Successful in 30s
build-frontend / build (push) Successful in 1m18s
2026-06-06 22:19:02 -04:00
justin 22bc536978 Rebuild People as a family view (pedigree + family group); add recovery UI
The People page is no longer a flat list: it's a focus-person family view with a pedigree of ancestors (parents + grandparents), a spouse/partner panel, and a children panel — with inline 'add parent/child/spouse' (creates the person + the relationship), click-to-refocus, birth–death years, and a searchable people index. Modeled on how real genealogy tools center on a person and let you walk the graph.

Adds delete/restore UI: a Delete on the person page, per-tree delete + a 'Recently deleted' restore section on the trees list, and a Recovery page (sidebar) for deleted people.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:19:01 -04:00
justin f2205b93f4 Add soft-delete + recovery and tree-wide graph endpoints
Tree and person soft-delete + restore (owner-only for trees, editor for people) with recovery listings (?deleted=true); the worker already purges past the 30-day window. Adds tree-wide GET /relationships and /events so the family/pedigree view loads the whole graph in a few calls. 27 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 22:19:01 -04:00
justin b0c7c8570b Merge pull request 'App-shell UI overhaul + media stream endpoint' (#9) from ui-shell into main
build-backend / build (push) Successful in 26s
build-frontend / build (push) Successful in 1m20s
2026-06-06 21:56:26 -04:00
justin fe9a95c60d Rebuild the UI as an app shell: left sidebar, media gallery, structured events
Replaces the centered single-column of full-width cards with a proper application layout: a persistent left sidebar (Trees, and per-tree People/Sources/Media, with the tree name and sign-out) and a constrained content column. Marketing landing and auth pages are split out (own header/footer; centered auth with the logo).

Adds a Media gallery (upload + image thumbnails / file tiles, served via the backend content endpoint). Events are no longer free-text: a curated event-type list (+ custom) and a structured date (qualifier + day/month/year) that composes a proper genealogical date. Regenerated the OpenAPI client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 21:56:05 -04:00
justin bd8ee9b647 Stream media through the backend (browser-reachable, privacy-checked)
Presigned URLs point at the internal minio:9000 host a browser can't reach. Add ObjectStore.get_object and a GET /media/{id}/content endpoint that resolves visibility and streams the bytes; MediaRead.url now points there. Keeps the object store private and downloads behind the privacy engine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 21:56:04 -04:00
justin 660130f007 Merge pull request 'Phase 1: media (object storage) + background worker' (#8) from phase1-media into main
build-backend / build (push) Successful in 30s
2026-06-06 21:46:35 -04:00
justin 34d30e3134 Add media (object storage) and the background worker (Phase 1)
Media model + migration; an ObjectStore interface with an S3/MinIO (boto3) implementation behind the service layer. Upload (multipart) stores bytes in object storage + a metadata row (checksum, size, content-type, optional attach to person/event/source); list returns presigned URLs; delete is soft. Editor-gated, privacy-filtered, audited. 24 tests pass (object store faked).

Introduces the worker container (same image, 'python -m app.worker'): its first job is the scheduled 30-day soft-delete purge across tables + media object cleanup. Compose gains worker + S3 env on backend/worker; dev override builds the worker too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 21:46:09 -04:00
justin 049545fcc8 Merge pull request 'Frontend redesign: real type, hero, depth' (#7) from design-overhaul into main
build-frontend / build (push) Successful in 1m18s
2026-06-06 21:34:48 -04:00
justin 3a14fcc4ca Redesign the frontend: real type, hero landing, depth
Lifts the UI from wireframe to a finished heritage look: Fraunces (display serif) + Inter (sans) via next/font; a proper hero landing with a feature triad and the Origin mark; a warm bronze-tinted background gradient for depth; a sticky branded header and refined footer. Polished button (sizes + bronze focus ring + shadow), card (rounded-xl, soft layered shadow), and input (bronze focus) primitives that carry across every page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 21:34:47 -04:00
justin fc4cb0273e Merge pull request 'Phase 1: sources-first spine (sources + citations)' (#6) from phase1-sources into main
build-backend / build (push) Successful in 24s
build-frontend / build (push) Successful in 1m18s
2026-06-06 13:17:34 -04:00
justin 83f83ab641 Add source manager and inline citing with 'sourced' badges
New /trees/[id]/sources page (list + create sources). Person-detail page now loads tree sources + citations and shows a '✓ N sourced' badge with an inline cite picker (source + page) on each event and on the person. Tree view links to Sources. Regenerated the OpenAPI client.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 13:17:33 -04:00
justin 064bb6ea65 Add sources and citations API (Phase 1: sources-first spine)
Source CRUD (reusable, tree-scoped) and Citation create/list/soft-delete linking one source to exactly one fact (person/event/name/relationship). Editor-gated writes, privacy-filtered reads, audit throughout; tenant + existence validation on source and target. list_citations returns all tree citations so the UI can render 'sourced' indicators in one round-trip. 22 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 13:17:33 -04:00
justin fbb9d0195c Merge pull request 'Phase 1: events + relationships + person detail' (#5) from phase1-graph into main
build-backend / build (push) Successful in 27s
build-frontend / build (push) Successful in 1m16s
2026-06-06 12:11:11 -04:00
57 changed files with 6168 additions and 239 deletions
+9
View File
@@ -10,6 +10,8 @@ from app.core.db import get_session
from app.integrations.mailer.base import Mailer from app.integrations.mailer.base import Mailer
from app.integrations.mailer.console import ConsoleMailer from app.integrations.mailer.console import ConsoleMailer
from app.integrations.mailer.smtp import SMTPMailer from app.integrations.mailer.smtp import SMTPMailer
from app.integrations.objectstore.base import ObjectStore
from app.integrations.objectstore.s3 import S3ObjectStore
from app.models.user import User from app.models.user import User
from app.services import auth_service from app.services import auth_service
@@ -46,3 +48,10 @@ def get_mailer() -> Mailer:
MailerDep = Annotated[Mailer, Depends(get_mailer)] MailerDep = Annotated[Mailer, Depends(get_mailer)]
def get_objectstore() -> ObjectStore:
return S3ObjectStore(get_settings())
ObjectStoreDep = Annotated[ObjectStore, Depends(get_objectstore)]
+16 -1
View File
@@ -2,7 +2,18 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import auth, events, persons, relationships, trees, users from app.api.v1 import (
auth,
citations,
events,
gedcom,
media,
persons,
relationships,
sources,
trees,
users,
)
api_router = APIRouter(prefix="/api/v1") api_router = APIRouter(prefix="/api/v1")
api_router.include_router(auth.router) api_router.include_router(auth.router)
@@ -11,3 +22,7 @@ api_router.include_router(trees.router)
api_router.include_router(persons.router) api_router.include_router(persons.router)
api_router.include_router(events.router) api_router.include_router(events.router)
api_router.include_router(relationships.router) api_router.include_router(relationships.router)
api_router.include_router(sources.router)
api_router.include_router(citations.router)
api_router.include_router(media.router)
api_router.include_router(gedcom.router)
+41
View File
@@ -0,0 +1,41 @@
import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.source import CitationCreate, CitationRead
from app.services import citation_service, tree_service
router = APIRouter(prefix="/trees", tags=["citations"])
@router.post(
"/{tree_id}/citations", response_model=CitationRead, status_code=status.HTTP_201_CREATED
)
async def create_citation(
tree_id: uuid.UUID, data: CitationCreate, session: SessionDep, current: CurrentUser
) -> CitationRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
citation = await citation_service.create_citation(
session, actor=current, tree=tree, **data.model_dump()
)
return CitationRead.model_validate(citation)
@router.get("/{tree_id}/citations", response_model=list[CitationRead])
async def list_citations(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[CitationRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
citations = await citation_service.list_citations(session, viewer_id=current.id, tree=tree)
return [CitationRead.model_validate(c) for c in citations]
@router.delete("/{tree_id}/citations/{citation_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_citation(
tree_id: uuid.UUID, citation_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> None:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
await citation_service.delete_citation(
session, actor=current, tree=tree, citation_id=citation_id
)
+9
View File
@@ -20,6 +20,15 @@ async def create_event(
return EventRead.model_validate(event) return EventRead.model_validate(event)
@router.get("/{tree_id}/events", response_model=list[EventRead])
async def list_tree_events(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[EventRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
events = await event_service.list_events(session, viewer_id=current.id, tree=tree)
return [EventRead.model_validate(e) for e in events]
@router.get("/{tree_id}/persons/{person_id}/events", response_model=list[EventRead]) @router.get("/{tree_id}/persons/{person_id}/events", response_model=list[EventRead])
async def list_person_events( async def list_person_events(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
+37
View File
@@ -0,0 +1,37 @@
import uuid
from fastapi import APIRouter, File, Response, UploadFile
from app.api.deps import CurrentUser, SessionDep
from app.schemas.gedcom import ImportReport
from app.services import gedcom, tree_service
router = APIRouter(prefix="/trees", tags=["gedcom"])
@router.post("/{tree_id}/gedcom/import", response_model=ImportReport)
async def import_gedcom(
tree_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
file: UploadFile = File(...),
) -> ImportReport:
# NOTE: additive — records are created as new; existing people are not merged.
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
text = (await file.read()).decode("utf-8", errors="replace")
report = await gedcom.import_gedcom(session, actor=current, tree=tree, text=text)
return ImportReport(**report)
@router.get("/{tree_id}/gedcom/export")
async def export_gedcom(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> Response:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
text = await gedcom.export_gedcom(session, viewer_id=current.id, tree=tree)
safe = "".join(c for c in tree.name if c.isalnum() or c in " -_").strip() or "tree"
return Response(
content=text,
media_type="text/plain",
headers={"Content-Disposition": f'attachment; filename="{safe}.ged"'},
)
+89
View File
@@ -0,0 +1,89 @@
import uuid
from fastapi import APIRouter, File, Form, Response, UploadFile, status
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
from app.schemas.media import MediaRead
from app.services import media_service, tree_service
def _content_url(media) -> str:
return f"/api/v1/trees/{media.tree_id}/media/{media.id}/content"
def _read(media) -> MediaRead:
out = MediaRead.model_validate(media)
# Stream through the backend (privacy-checked, browser-reachable) rather
# than expose the internal object store directly.
out.url = _content_url(media)
return out
router = APIRouter(prefix="/trees", tags=["media"])
@router.post("/{tree_id}/media", response_model=MediaRead, status_code=status.HTTP_201_CREATED)
async def upload_media(
tree_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
store: ObjectStoreDep,
file: UploadFile = File(...),
title: str | None = Form(None),
person_id: uuid.UUID | None = Form(None),
event_id: uuid.UUID | None = Form(None),
source_id: uuid.UUID | None = Form(None),
) -> MediaRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
data = await file.read()
media = await media_service.upload_media(
session,
store,
actor=current,
tree=tree,
data=data,
filename=file.filename or "upload",
content_type=file.content_type or "application/octet-stream",
title=title,
person_id=person_id,
event_id=event_id,
source_id=source_id,
)
return _read(media)
@router.get("/{tree_id}/media", response_model=list[MediaRead])
async def list_media(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[MediaRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
items = await media_service.list_media(session, viewer_id=current.id, tree=tree)
return [_read(m) for m in items]
@router.get("/{tree_id}/media/{media_id}/content")
async def media_content(
tree_id: uuid.UUID,
media_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
store: ObjectStoreDep,
) -> Response:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
media = await media_service.get_media(
session, viewer_id=current.id, tree=tree, media_id=media_id
)
data = await store.get_object(key=media.storage_key)
return Response(
content=data,
media_type=media.content_type,
headers={"Content-Disposition": f'inline; filename="{media.original_filename}"'},
)
@router.delete("/{tree_id}/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_media(
tree_id: uuid.UUID, media_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> None:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
await media_service.delete_media(session, actor=current, tree=tree, media_id=media_id)
+25 -1
View File
@@ -36,13 +36,37 @@ async def create_person(
@router.get("/{tree_id}/persons", response_model=list[PersonRead]) @router.get("/{tree_id}/persons", response_model=list[PersonRead])
async def list_persons( async def list_persons(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, session: SessionDep, current: CurrentUser, deleted: bool = False
) -> list[PersonRead]: ) -> list[PersonRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
if deleted:
persons = await person_service.list_deleted_persons(
session, viewer_id=current.id, tree=tree
)
else:
persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree) persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree)
return [PersonRead.model_validate(p) for p in persons] return [PersonRead.model_validate(p) for p in persons]
@router.delete("/{tree_id}/persons/{person_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_person(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> None:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
await person_service.delete_person(session, actor=current, tree=tree, person_id=person_id)
@router.post("/{tree_id}/persons/{person_id}/restore", response_model=PersonRead)
async def restore_person(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> PersonRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
person = await person_service.restore_person(
session, actor=current, tree=tree, person_id=person_id
)
return PersonRead.model_validate(person)
@router.get("/{tree_id}/persons/{person_id}", response_model=PersonRead) @router.get("/{tree_id}/persons/{person_id}", response_model=PersonRead)
async def get_person( async def get_person(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
+9
View File
@@ -24,6 +24,15 @@ async def create_relationship(
return RelationshipRead.model_validate(relationship) return RelationshipRead.model_validate(relationship)
@router.get("/{tree_id}/relationships", response_model=list[RelationshipRead])
async def list_relationships(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[RelationshipRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rels = await relationship_service.list_relationships(session, viewer_id=current.id, tree=tree)
return [RelationshipRead.model_validate(r) for r in rels]
@router.get( @router.get(
"/{tree_id}/persons/{person_id}/relationships", "/{tree_id}/persons/{person_id}/relationships",
response_model=list[RelationshipRead], response_model=list[RelationshipRead],
+48
View File
@@ -0,0 +1,48 @@
import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.source import SourceCreate, SourceRead
from app.services import source_service, tree_service
router = APIRouter(prefix="/trees", tags=["sources"])
@router.post("/{tree_id}/sources", response_model=SourceRead, status_code=status.HTTP_201_CREATED)
async def create_source(
tree_id: uuid.UUID, data: SourceCreate, session: SessionDep, current: CurrentUser
) -> SourceRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
source = await source_service.create_source(
session, actor=current, tree=tree, **data.model_dump()
)
return SourceRead.model_validate(source)
@router.get("/{tree_id}/sources", response_model=list[SourceRead])
async def list_sources(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[SourceRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
sources = await source_service.list_sources(session, viewer_id=current.id, tree=tree)
return [SourceRead.model_validate(s) for s in sources]
@router.get("/{tree_id}/sources/{source_id}", response_model=SourceRead)
async def get_source(
tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> SourceRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
source = await source_service.get_source(
session, viewer_id=current.id, tree=tree, source_id=source_id
)
return SourceRead.model_validate(source)
@router.delete("/{tree_id}/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_source(
tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> None:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
await source_service.delete_source(session, actor=current, tree=tree, source_id=source_id)
+17 -1
View File
@@ -22,7 +22,12 @@ async def create_tree(data: TreeCreate, session: SessionDep, current: CurrentUse
@router.get("", response_model=list[TreeRead]) @router.get("", response_model=list[TreeRead])
async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeRead]: async def list_my_trees(
session: SessionDep, current: CurrentUser, deleted: bool = False
) -> list[TreeRead]:
if deleted:
trees = await tree_service.list_deleted_trees_for_user(session, user=current)
else:
trees = await tree_service.list_trees_for_user(session, user=current) trees = await tree_service.list_trees_for_user(session, user=current)
return [TreeRead.model_validate(t) for t in trees] return [TreeRead.model_validate(t) for t in trees]
@@ -31,3 +36,14 @@ async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeR
async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead: async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id) tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
return TreeRead.model_validate(tree) return TreeRead.model_validate(tree)
@router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> None:
await tree_service.delete_tree(session, actor=current, tree_id=tree_id)
@router.post("/{tree_id}/restore", response_model=TreeRead)
async def restore_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
tree = await tree_service.restore_tree(session, actor=current, tree_id=tree_id)
return TreeRead.model_validate(tree)
+12
View File
@@ -35,6 +35,18 @@ class Settings(BaseSettings):
# Base URL used to build links in outbound email. # Base URL used to build links in outbound email.
app_base_url: str = "http://localhost" app_base_url: str = "http://localhost"
# --- Object storage (S3-compatible / MinIO) ---
s3_endpoint_url: str = "http://minio:9000"
s3_bucket: str = "provenance"
s3_access_key: str = "provenance"
s3_secret_key: str = "change-me-too"
s3_region: str = "us-east-1"
s3_presign_ttl: int = 3600 # seconds
# --- Worker ---
purge_interval_seconds: int = 3600 # how often to run the soft-delete purge
purge_after_days: int = 30 # soft-deleted rows older than this are purged
# --- Email (SMTP) --- # --- Email (SMTP) ---
mailer: str = Field(default="console", description="console | smtp") mailer: str = Field(default="console", description="console | smtp")
smtp_host: str | None = None smtp_host: str | None = None
@@ -0,0 +1,25 @@
"""ObjectStore interface — pluggable binary storage behind the service layer.
Implementations are S3-compatible (MinIO for self-host, any S3 otherwise).
Methods are async wrappers so the service layer stays non-blocking even though
the underlying SDK (boto3) is synchronous.
"""
from abc import ABC, abstractmethod
class ObjectStore(ABC):
@abstractmethod
async def ensure_bucket(self) -> None: ...
@abstractmethod
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None: ...
@abstractmethod
async def get_object(self, *, key: str) -> bytes: ...
@abstractmethod
async def presigned_get_url(self, *, key: str) -> str: ...
@abstractmethod
async def delete_object(self, *, key: str) -> None: ...
@@ -0,0 +1,63 @@
"""S3-compatible ObjectStore (boto3), suitable for MinIO or any S3 provider.
boto3 is synchronous; each call is dispatched to a thread so request handlers
and the worker stay async."""
import asyncio
import boto3
from botocore.client import Config
from botocore.exceptions import ClientError
from app.core.config import Settings
from app.integrations.objectstore.base import ObjectStore
class S3ObjectStore(ObjectStore):
def __init__(self, settings: Settings) -> None:
self.bucket = settings.s3_bucket
self.presign_ttl = settings.s3_presign_ttl
self._client = boto3.client(
"s3",
endpoint_url=settings.s3_endpoint_url,
aws_access_key_id=settings.s3_access_key,
aws_secret_access_key=settings.s3_secret_key,
region_name=settings.s3_region,
config=Config(signature_version="s3v4"),
)
def _ensure_bucket_sync(self) -> None:
try:
self._client.head_bucket(Bucket=self.bucket)
except ClientError:
self._client.create_bucket(Bucket=self.bucket)
async def ensure_bucket(self) -> None:
await asyncio.to_thread(self._ensure_bucket_sync)
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None:
await asyncio.to_thread(
self._client.put_object,
Bucket=self.bucket,
Key=key,
Body=data,
ContentType=content_type,
)
async def get_object(self, *, key: str) -> bytes:
def _get() -> bytes:
obj = self._client.get_object(Bucket=self.bucket, Key=key)
return obj["Body"].read()
return await asyncio.to_thread(_get)
async def presigned_get_url(self, *, key: str) -> str:
return await asyncio.to_thread(
self._client.generate_presigned_url,
"get_object",
Params={"Bucket": self.bucket, "Key": key},
ExpiresIn=self.presign_ttl,
)
async def delete_object(self, *, key: str) -> None:
await asyncio.to_thread(self._client.delete_object, Bucket=self.bucket, Key=key)
+2
View File
@@ -5,6 +5,7 @@ from app.models.audit import AuditEntry
from app.models.auth import Session, UserToken from app.models.auth import Session, UserToken
from app.models.base import Base from app.models.base import Base
from app.models.event import Event from app.models.event import Event
from app.models.media import Media
from app.models.person import Name, Person from app.models.person import Name, Person
from app.models.place import Place, PlaceName from app.models.place import Place, PlaceName
from app.models.relationship import Relationship from app.models.relationship import Relationship
@@ -28,4 +29,5 @@ __all__ = [
"AuditEntry", "AuditEntry",
"Session", "Session",
"UserToken", "UserToken",
"Media",
] ]
+36
View File
@@ -0,0 +1,36 @@
"""Media — a binary asset (image, scan, PDF, audio) in object storage. The row
holds metadata + checksum + the storage key; the bytes live in the ObjectStore.
Optionally attached to a single fact (person, event, or source) for now."""
import uuid
from sqlalchemy import BigInteger, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey
class Media(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
__tablename__ = "media"
uploader_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), index=True
)
storage_key: Mapped[str] = mapped_column(String(512), unique=True)
original_filename: Mapped[str] = mapped_column(String(512))
content_type: Mapped[str] = mapped_column(String(128))
byte_size: Mapped[int] = mapped_column(BigInteger)
checksum_sha256: Mapped[str] = mapped_column(String(64), index=True)
title: Mapped[str | None] = mapped_column(String(512))
# Optional single attachment target.
person_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("persons.id", ondelete="SET NULL"), index=True
)
event_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("events.id", ondelete="SET NULL"), index=True
)
source_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("sources.id", ondelete="SET NULL"), index=True
)
+6
View File
@@ -0,0 +1,6 @@
from pydantic import BaseModel
class ImportReport(BaseModel):
counts: dict[str, int]
unmapped_tags: list[str]
+22
View File
@@ -0,0 +1,22 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class MediaRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tree_id: uuid.UUID
original_filename: str
content_type: str
byte_size: int
checksum_sha256: str
title: str | None
person_id: uuid.UUID | None
event_id: uuid.UUID | None
source_id: uuid.UUID | None
created_at: datetime
# Presigned download URL, filled in by the router from the ObjectStore.
url: str | None = None
+61
View File
@@ -0,0 +1,61 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from app.models.enums import CitationConfidence
class SourceCreate(BaseModel):
title: str
author: str | None = None
source_type: str | None = None
repository: str | None = None
url: str | None = None
citation_text: str | None = None
publication_info: str | None = None
quality_note: str | None = None
class SourceRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tree_id: uuid.UUID
title: str
author: str | None
source_type: str | None
repository: str | None
url: str | None
citation_text: str | None
publication_info: str | None
quality_note: str | None
created_at: datetime
class CitationCreate(BaseModel):
source_id: uuid.UUID
# Exactly one target fact.
person_id: uuid.UUID | None = None
event_id: uuid.UUID | None = None
name_id: uuid.UUID | None = None
relationship_id: uuid.UUID | None = None
page: str | None = None
detail: str | None = None
confidence: CitationConfidence | None = None
class CitationRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tree_id: uuid.UUID
source_id: uuid.UUID
person_id: uuid.UUID | None
event_id: uuid.UUID | None
name_id: uuid.UUID | None
relationship_id: uuid.UUID | None
page: str | None
detail: str | None
confidence: CitationConfidence | None
created_at: datetime
+141
View File
@@ -0,0 +1,141 @@
"""Citation service. A citation links one Source to exactly one fact (person,
event, name, or relationship) within a tree — the provenance spine."""
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import CitationConfidence
from app.models.event import Event
from app.models.person import Name, Person
from app.models.relationship import Relationship
from app.models.source import Citation, Source
from app.models.tree import Tree
from app.models.user import User
from app.services import privacy
from app.services.audit import record_audit
from app.services.exceptions import Conflict, Forbidden, NotFound
# Citation target column -> model, for tenant/existence validation.
_TARGET_MODELS = {
"person_id": Person,
"event_id": Event,
"name_id": Name,
"relationship_id": Relationship,
}
async def _in_tree(session: AsyncSession, model: type, id_: uuid.UUID, tree_id: uuid.UUID) -> bool:
row = (
await session.execute(
select(model.id).where(
model.id == id_, model.tree_id == tree_id, model.deleted_at.is_(None)
)
)
).scalar_one_or_none()
return row is not None
async def create_citation(
session: AsyncSession,
*,
actor: User,
tree: Tree,
source_id: uuid.UUID,
person_id: uuid.UUID | None = None,
event_id: uuid.UUID | None = None,
name_id: uuid.UUID | None = None,
relationship_id: uuid.UUID | None = None,
page: str | None = None,
detail: str | None = None,
confidence: CitationConfidence | None = None,
) -> Citation:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
targets = {
"person_id": person_id,
"event_id": event_id,
"name_id": name_id,
"relationship_id": relationship_id,
}
set_targets = {k: v for k, v in targets.items() if v is not None}
if len(set_targets) != 1:
raise Conflict("a citation must reference exactly one fact")
if not await _in_tree(session, Source, source_id, tree.id):
raise NotFound("source not found in this tree")
(target_col, target_id), = set_targets.items()
if not await _in_tree(session, _TARGET_MODELS[target_col], target_id, tree.id):
raise NotFound("cited fact not found in this tree")
citation = Citation(
tree_id=tree.id,
source_id=source_id,
person_id=person_id,
event_id=event_id,
name_id=name_id,
relationship_id=relationship_id,
page=page,
detail=detail,
confidence=confidence,
)
session.add(citation)
await session.flush()
record_audit(
session,
action="create",
entity_type="Citation",
entity_id=citation.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"source_id": str(source_id), target_col: str(target_id)},
)
await session.commit()
await session.refresh(citation)
return citation
async def list_citations(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Citation]:
"""All citations in the tree — the UI maps them to facts to show 'sourced'
indicators in a single round-trip."""
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Citation)
.where(Citation.tree_id == tree.id, Citation.deleted_at.is_(None))
.order_by(Citation.created_at)
)
return list((await session.execute(stmt)).scalars().all())
async def delete_citation(
session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID
) -> None:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
citation = (
await session.execute(
select(Citation).where(
Citation.id == citation_id,
Citation.tree_id == tree.id,
Citation.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if citation is None:
raise NotFound("citation not found")
citation.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Citation",
entity_id=citation.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
+14
View File
@@ -91,6 +91,20 @@ async def create_event(
return event return event
async def list_events(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Event]:
"""All events in the tree — lets the family view compute birth/death years."""
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Event)
.where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
.order_by(Event.date_start.nulls_last(), Event.created_at)
)
return list((await session.execute(stmt)).scalars().all())
async def list_events_for_person( async def list_events_for_person(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
) -> list[Event]: ) -> list[Event]:
+451
View File
@@ -0,0 +1,451 @@
"""GEDCOM import/export.
A pragmatic parser + mapper for the common subset of GEDCOM (5.5.1 / 7 share
the line grammar): INDI, FAM, SOUR. Import maps records into a tree and returns
a mapping report (counts + unmapped tags); export serializes the tree back to
GEDCOM. Runs inline for now — large files should move to the worker later.
"""
import re
import uuid
from collections import defaultdict
from datetime import date
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import ParentChildQualifier, RelationshipType
from app.models.event import Event
from app.models.person import Name, Person
from app.models.place import Place
from app.models.relationship import Relationship
from app.models.source import Citation, Source
from app.models.tree import Tree
from app.models.user import User
from app.services import privacy
from app.services.audit import record_audit
from app.services.exceptions import Forbidden
# GEDCOM event tag -> our event_type (INDI-level).
INDI_EVENTS = {
"BIRT": "birth", "DEAT": "death", "BAPM": "baptism", "CHR": "christening",
"BURI": "burial", "CREM": "cremation", "RESI": "residence", "CENS": "census",
"IMMI": "immigration", "EMIG": "emigration", "OCCU": "occupation",
"EDUC": "education", "GRAD": "graduation", "RETI": "retirement",
"NATU": "naturalization", "BAPL": "baptism",
}
# FAM-level events.
FAM_EVENTS = {"MARR": "marriage", "DIV": "divorce", "ENGA": "engagement"}
EVENT_TO_GED = {v: k for k, v in {**INDI_EVENTS, **FAM_EVENTS}.items()}
class GedcomNode:
__slots__ = ("level", "tag", "value", "xref", "children")
def __init__(self, level: int, tag: str, value: str = "", xref: str | None = None):
self.level = level
self.tag = tag
self.value = value
self.xref = xref
self.children: list[GedcomNode] = []
def first(self, tag: str) -> "GedcomNode | None":
return next((c for c in self.children if c.tag == tag), None)
def all(self, tag: str) -> list["GedcomNode"]:
return [c for c in self.children if c.tag == tag]
def text(self, tag: str, default: str | None = None) -> str | None:
n = self.first(tag)
return n.value if n is not None else default
def parse_records(text: str) -> list[GedcomNode]:
roots: list[GedcomNode] = []
stack: list[GedcomNode] = []
for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
line = raw.lstrip("").rstrip()
if not line.strip():
continue
parts = line.split(" ", 1)
try:
level = int(parts[0])
except ValueError:
continue
rest = parts[1] if len(parts) > 1 else ""
xref: str | None = None
if rest.startswith("@"):
end = rest.find("@", 1)
if end != -1:
xref = rest[: end + 1]
rest = rest[end + 1:].strip()
tparts = rest.split(" ", 1)
tag = tparts[0]
value = tparts[1] if len(tparts) > 1 else ""
while stack and stack[-1].level >= level:
stack.pop()
parent = stack[-1] if stack else None
if tag in ("CONC", "CONT") and parent is not None:
parent.value += ("" if tag == "CONC" else "\n") + value
continue
node = GedcomNode(level, tag, value, xref)
if parent is None:
roots.append(node)
else:
parent.children.append(node)
stack.append(node)
return roots
def _parse_name(value: str) -> tuple[str | None, str | None]:
if "/" in value:
given, _, rest = value.partition("/")
surname = rest.split("/", 1)[0]
return given.strip() or None, surname.strip() or None
return value.strip() or None, None
def _year(date_value: str | None) -> str | None:
if not date_value:
return None
m = re.search(r"\b(\d{3,4})\b", date_value)
return m.group(1) if m else None
def _date_start(date_value: str | None) -> date | None:
y = _year(date_value)
if not y:
return None
try:
return date(int(y), 1, 1)
except ValueError:
return None
def _sex(value: str | None) -> str | None:
if not value:
return None
v = value.strip().upper()
return {"M": "male", "F": "female"}.get(v, value.strip().lower() or None)
async def import_gedcom(
session: AsyncSession, *, actor: User, tree: Tree, text: str
) -> dict:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
roots = parse_records(text)
counts = defaultdict(int)
unmapped: set[str] = set()
place_cache: dict[str, uuid.UUID] = {}
source_map: dict[str, uuid.UUID] = {}
person_map: dict[str, uuid.UUID] = {}
async def place_id(name: str | None) -> uuid.UUID | None:
if not name:
return None
if name in place_cache:
return place_cache[name]
p = Place(tree_id=tree.id, name=name)
session.add(p)
await session.flush()
place_cache[name] = p.id
counts["places"] += 1
return p.id
# Sources first (so citations can reference them).
for rec in roots:
if rec.tag == "SOUR" and rec.xref:
src = Source(
tree_id=tree.id,
title=rec.text("TITL") or rec.text("ABBR") or "Untitled source",
author=rec.text("AUTH"),
publication_info=rec.text("PUBL"),
citation_text=rec.text("TEXT"),
)
session.add(src)
await session.flush()
source_map[rec.xref] = src.id
counts["sources"] += 1
async def add_citations(holder: GedcomNode, **target) -> None:
for s in holder.all("SOUR"):
sid = source_map.get(s.value.strip())
if sid is None:
continue
session.add(
Citation(tree_id=tree.id, source_id=sid, page=s.text("PAGE"), **target)
)
counts["citations"] += 1
# Individuals.
for rec in roots:
if rec.tag != "INDI" or not rec.xref:
continue
person = Person(tree_id=tree.id, gender=_sex(rec.text("SEX")))
session.add(person)
await session.flush()
person_map[rec.xref] = person.id
counts["persons"] += 1
for i, nm in enumerate(rec.all("NAME")):
given, surname = _parse_name(nm.value)
session.add(
Name(
tree_id=tree.id,
person_id=person.id,
name_type="birth",
given=given,
surname=surname,
display_name=nm.value or None,
is_primary=(i == 0),
sort_order=i,
)
)
counts["names"] += 1
await add_citations(rec, person_id=person.id)
for child in rec.children:
if child.tag in INDI_EVENTS:
dv = child.text("DATE")
ev = Event(
tree_id=tree.id,
person_id=person.id,
event_type=INDI_EVENTS[child.tag],
date_value=dv,
date_start=_date_start(dv),
place_id=await place_id(child.text("PLAC")),
)
session.add(ev)
await session.flush()
counts["events"] += 1
await add_citations(child, event_id=ev.id)
elif child.tag in ("NAME", "SEX", "SOUR", "FAMC", "FAMS", "CHAN", "OBJE", "_UID"):
continue
else:
unmapped.add(child.tag)
# Families -> partnerships, parent-child edges, marriage events.
for rec in roots:
if rec.tag != "FAM":
continue
counts["families"] += 1
husb = person_map.get((rec.text("HUSB") or "").strip())
wife = person_map.get((rec.text("WIFE") or "").strip())
partnership_id: uuid.UUID | None = None
if husb and wife:
rel = Relationship(
tree_id=tree.id,
type=RelationshipType.partnership,
person_from_id=husb,
person_to_id=wife,
)
session.add(rel)
await session.flush()
partnership_id = rel.id
counts["relationships"] += 1
for fe in rec.children:
if fe.tag in FAM_EVENTS and partnership_id is not None:
dv = fe.text("DATE")
ev = Event(
tree_id=tree.id,
relationship_id=partnership_id,
event_type=FAM_EVENTS[fe.tag],
date_value=dv,
date_start=_date_start(dv),
place_id=await place_id(fe.text("PLAC")),
)
session.add(ev)
await session.flush()
counts["events"] += 1
for chil in rec.all("CHIL"):
cp = person_map.get(chil.value.strip())
if cp is None:
continue
for parent in (husb, wife):
if parent and parent != cp:
session.add(
Relationship(
tree_id=tree.id,
type=RelationshipType.parent_child,
person_from_id=parent,
person_to_id=cp,
qualifier=ParentChildQualifier.biological,
)
)
counts["relationships"] += 1
record_audit(
session,
action="import",
entity_type="Gedcom",
tree_id=tree.id,
actor_user_id=actor.id,
after=dict(counts),
)
await session.commit()
return {"counts": dict(counts), "unmapped_tags": sorted(unmapped)}
def _ged_date(value: str | None) -> str | None:
return value.strip() if value else None
async def export_gedcom(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> str:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
persons = list(
(
await session.execute(
select(Person).where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
)
).scalars().all()
)
names = list(
(
await session.execute(
select(Name).where(Name.tree_id == tree.id, Name.deleted_at.is_(None))
)
).scalars().all()
)
events = list(
(
await session.execute(
select(Event).where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
)
).scalars().all()
)
rels = list(
(
await session.execute(
select(Relationship).where(
Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)
)
)
).scalars().all()
)
sources = list(
(
await session.execute(
select(Source).where(Source.tree_id == tree.id, Source.deleted_at.is_(None))
)
).scalars().all()
)
places = {
p.id: p
for p in (
await session.execute(select(Place).where(Place.tree_id == tree.id))
).scalars().all()
}
pxref = {p.id: f"@I{i + 1}@" for i, p in enumerate(persons)}
gender_by_id = {p.id: p.gender for p in persons}
sxref = {s.id: f"@S{i + 1}@" for i, s in enumerate(sources)}
names_by_person: dict[uuid.UUID, list[Name]] = defaultdict(list)
for n in sorted(names, key=lambda n: (n.sort_order, not n.is_primary)):
names_by_person[n.person_id].append(n)
events_by_person: dict[uuid.UUID, list[Event]] = defaultdict(list)
events_by_rel: dict[uuid.UUID, list[Event]] = defaultdict(list)
for e in events:
if e.person_id:
events_by_person[e.person_id].append(e)
elif e.relationship_id:
events_by_rel[e.relationship_id].append(e)
# Build families from parent-child + partnership edges (group by parent set).
parents_of: dict[uuid.UUID, set[uuid.UUID]] = defaultdict(set)
for r in rels:
if r.type == RelationshipType.parent_child:
parents_of[r.person_to_id].add(r.person_from_id)
fams: dict[frozenset, dict] = {}
for child, ps in parents_of.items():
key = frozenset(ps)
fams.setdefault(key, {"parents": set(ps), "children": [], "rel_id": None})
fams[key]["children"].append(child)
for r in rels:
if r.type == RelationshipType.partnership:
key = frozenset({r.person_from_id, r.person_to_id})
fam = fams.setdefault(
key,
{"parents": {r.person_from_id, r.person_to_id}, "children": [], "rel_id": None},
)
fam["rel_id"] = r.id
fam_list = list(fams.values())
fxref = {id(f): f"@F{i + 1}@" for i, f in enumerate(fam_list)}
# person -> the families they are a spouse in / a child in
spouse_fams: dict[uuid.UUID, list[str]] = defaultdict(list)
child_fams: dict[uuid.UUID, str] = {}
for f in fam_list:
x = fxref[id(f)]
for pid in f["parents"]:
spouse_fams[pid].append(x)
for cid in f["children"]:
child_fams[cid] = x
out: list[str] = ["0 HEAD", "1 SOUR Provenance", "1 GEDC", "2 VERS 5.5.1", "1 CHAR UTF-8"]
for p in persons:
out.append(f"0 {pxref[p.id]} INDI")
for n in names_by_person.get(p.id, []):
display = n.display_name or f"{n.given or ''} /{n.surname or ''}/".strip()
out.append(f"1 NAME {display}")
sex = {"male": "M", "female": "F"}.get(p.gender or "")
if sex:
out.append(f"1 SEX {sex}")
for e in events_by_person.get(p.id, []):
tag = EVENT_TO_GED.get(e.event_type)
if not tag:
continue
out.append(f"1 {tag}")
if _ged_date(e.date_value):
out.append(f"2 DATE {e.date_value}")
if e.place_id and e.place_id in places:
out.append(f"2 PLAC {places[e.place_id].name}")
if p.id in child_fams:
out.append(f"1 FAMC {child_fams[p.id]}")
for x in spouse_fams.get(p.id, []):
out.append(f"1 FAMS {x}")
for f in fam_list:
x = fxref[id(f)]
out.append(f"0 {x} FAM")
ps = list(f["parents"])
# HUSB/WIFE by recorded gender where possible.
males = [pid for pid in ps if gender_by_id.get(pid) == "male"]
females = [pid for pid in ps if gender_by_id.get(pid) == "female"]
husb = males[0] if males else (ps[0] if ps else None)
wife = females[0] if females else next((pid for pid in ps if pid != husb), None)
if husb:
out.append(f"1 HUSB {pxref[husb]}")
if wife:
out.append(f"1 WIFE {pxref[wife]}")
for cid in f["children"]:
out.append(f"1 CHIL {pxref[cid]}")
if f["rel_id"]:
for e in events_by_rel.get(f["rel_id"], []):
tag = EVENT_TO_GED.get(e.event_type)
if not tag:
continue
out.append(f"1 {tag}")
if _ged_date(e.date_value):
out.append(f"2 DATE {e.date_value}")
for s in sources:
out.append(f"0 {sxref[s.id]} SOUR")
if s.title:
out.append(f"1 TITL {s.title}")
if s.author:
out.append(f"1 AUTH {s.author}")
if s.publication_info:
out.append(f"1 PUBL {s.publication_info}")
out.append("0 TRLR")
return "\n".join(out) + "\n"
+124
View File
@@ -0,0 +1,124 @@
"""Media service. Bytes go to the ObjectStore; a metadata row goes to the DB.
Writes require editor rights; reads go through the privacy engine."""
import hashlib
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.integrations.objectstore.base import ObjectStore
from app.models.media import Media
from app.models.tree import Tree
from app.models.user import User
from app.services import privacy
from app.services.audit import record_audit
from app.services.exceptions import Forbidden, NotFound
async def upload_media(
session: AsyncSession,
store: ObjectStore,
*,
actor: User,
tree: Tree,
data: bytes,
filename: str,
content_type: str,
title: str | None = None,
person_id: uuid.UUID | None = None,
event_id: uuid.UUID | None = None,
source_id: uuid.UUID | None = None,
) -> Media:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
media_id = uuid.uuid4()
key = f"{tree.id}/{media_id}/{filename}"
await store.ensure_bucket()
await store.put_object(key=key, data=data, content_type=content_type)
media = Media(
id=media_id,
tree_id=tree.id,
uploader_id=actor.id,
storage_key=key,
original_filename=filename,
content_type=content_type,
byte_size=len(data),
checksum_sha256=hashlib.sha256(data).hexdigest(),
title=title,
person_id=person_id,
event_id=event_id,
source_id=source_id,
)
session.add(media)
await session.flush()
record_audit(
session,
action="create",
entity_type="Media",
entity_id=media.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"filename": filename, "bytes": len(data)},
)
await session.commit()
await session.refresh(media)
return media
async def list_media(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[Media]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Media)
.where(Media.tree_id == tree.id, Media.deleted_at.is_(None))
.order_by(Media.created_at.desc())
)
return list((await session.execute(stmt)).scalars().all())
async def get_media(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, media_id: uuid.UUID
) -> Media:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
media = (
await session.execute(
select(Media).where(
Media.id == media_id, Media.tree_id == tree.id, Media.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if media is None:
raise NotFound("media not found")
return media
async def delete_media(
session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID
) -> None:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
media = (
await session.execute(
select(Media).where(
Media.id == media_id, Media.tree_id == tree.id, Media.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if media is None:
raise NotFound("media not found")
# Soft delete the row; the object is removed by the worker's purge job.
media.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Media",
entity_id=media.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
+72
View File
@@ -4,6 +4,7 @@ person through the privacy engine. Each returned Person gets a transient
""" """
import uuid import uuid
from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -112,6 +113,77 @@ async def get_person(
return person return person
async def delete_person(
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID
) -> None:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
person = (
await session.execute(
select(Person).where(
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if person is None:
raise NotFound("person not found")
person.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Person",
entity_id=person.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
async def restore_person(
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID
) -> Person:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
person = (
await session.execute(
select(Person).where(
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_not(None)
)
)
).scalar_one_or_none()
if person is None:
raise NotFound("deleted person not found")
person.deleted_at = None
record_audit(
session,
action="restore",
entity_type="Person",
entity_id=person.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
await session.refresh(person)
await _attach_primary_name(session, person)
return person
async def list_deleted_persons(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Person]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Person)
.where(Person.tree_id == tree.id, Person.deleted_at.is_not(None))
.order_by(Person.deleted_at.desc())
)
persons = list((await session.execute(stmt)).scalars().all())
for person in persons:
await _attach_primary_name(session, person)
return persons
async def list_persons( async def list_persons(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Person]: ) -> list[Person]:
@@ -73,6 +73,20 @@ async def create_relationship(
return relationship return relationship
async def list_relationships(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
) -> list[Relationship]:
"""All relationships in the tree — powers the family/pedigree view in one call."""
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Relationship)
.where(Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None))
.order_by(Relationship.created_at)
)
return list((await session.execute(stmt)).scalars().all())
async def list_relationships_for_person( async def list_relationships_for_person(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
) -> list[Relationship]: ) -> list[Relationship]:
+112
View File
@@ -0,0 +1,112 @@
"""Source service. Sources are reusable, tree-scoped records of an origin.
Writes require editor rights; reads go through the privacy engine."""
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.source import Source
from app.models.tree import Tree
from app.models.user import User
from app.services import privacy
from app.services.audit import record_audit
from app.services.exceptions import Forbidden, NotFound
async def create_source(
session: AsyncSession,
*,
actor: User,
tree: Tree,
title: str,
author: str | None = None,
source_type: str | None = None,
repository: str | None = None,
url: str | None = None,
citation_text: str | None = None,
publication_info: str | None = None,
quality_note: str | None = None,
) -> Source:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
source = Source(
tree_id=tree.id,
title=title,
author=author,
source_type=source_type,
repository=repository,
url=url,
citation_text=citation_text,
publication_info=publication_info,
quality_note=quality_note,
)
session.add(source)
await session.flush()
record_audit(
session,
action="create",
entity_type="Source",
entity_id=source.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"title": title},
)
await session.commit()
await session.refresh(source)
return source
async def list_sources(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[Source]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
stmt = (
select(Source)
.where(Source.tree_id == tree.id, Source.deleted_at.is_(None))
.order_by(Source.title)
)
return list((await session.execute(stmt)).scalars().all())
async def get_source(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, source_id: uuid.UUID
) -> Source:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
source = (
await session.execute(
select(Source).where(
Source.id == source_id, Source.tree_id == tree.id, Source.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if source is None:
raise NotFound("source not found")
return source
async def delete_source(
session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID
) -> None:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
source = (
await session.execute(
select(Source).where(
Source.id == source_id, Source.tree_id == tree.id, Source.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if source is None:
raise NotFound("source not found")
source.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Source",
entity_id=source.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
+53
View File
@@ -3,6 +3,7 @@ authorization basis) and an audit entry. Reads go through the privacy engine.
""" """
import uuid import uuid
from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -59,3 +60,55 @@ async def get_tree(session: AsyncSession, *, viewer_id: uuid.UUID, tree_id: uuid
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree): if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree") raise Forbidden("not permitted to view this tree")
return tree return tree
async def _owned_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree:
"""Load a tree (including soft-deleted) and require the actor be its owner."""
tree = await BaseRepository(session, Tree).get(tree_id, include_deleted=True)
if tree is None:
raise NotFound("tree not found")
role = await privacy.get_membership_role(session, actor.id, tree.id)
if role is not MembershipRole.owner:
raise Forbidden("only the owner can delete or restore a tree")
return tree
async def delete_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> None:
tree = await _owned_tree(session, actor=actor, tree_id=tree_id)
if tree.deleted_at is None:
tree.deleted_at = datetime.now(UTC)
record_audit(
session,
action="delete",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
async def restore_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree:
tree = await _owned_tree(session, actor=actor, tree_id=tree_id)
if tree.deleted_at is not None:
tree.deleted_at = None
record_audit(
session,
action="restore",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
await session.commit()
return tree
async def list_deleted_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]:
stmt = (
select(Tree)
.join(TreeMembership, TreeMembership.tree_id == Tree.id)
.where(TreeMembership.user_id == user.id, Tree.deleted_at.is_not(None))
.order_by(Tree.deleted_at.desc())
)
return list((await session.execute(stmt)).scalars().all())
+103
View File
@@ -0,0 +1,103 @@
"""Background worker. Same image as the backend, run in worker mode
(`python -m app.worker`). First job: the scheduled soft-delete purge — hard-
delete rows whose ``deleted_at`` is older than the recovery window, and remove
their objects from storage. More jobs (media processing, scraping, hints) and a
proper queue arrive in later phases.
"""
import asyncio
import logging
import sys
from datetime import UTC, datetime, timedelta
from sqlalchemy import delete, select
from app.core.config import get_settings
from app.core.db import get_sessionmaker
from app.integrations.objectstore.s3 import S3ObjectStore
from app.models import (
Citation,
Event,
Media,
Name,
Person,
Place,
PlaceName,
Relationship,
Source,
Tree,
User,
)
logger = logging.getLogger("provenance.worker")
# Child -> parent so foreign keys are satisfied as rows are removed.
_PURGE_ORDER = [Citation, Name, Event, Relationship, PlaceName, Place, Source, Person, Tree, User]
async def _purge_media(sessionmaker, store, cutoff: datetime) -> None:
async with sessionmaker() as session:
rows = (
await session.execute(
select(Media).where(Media.deleted_at.is_not(None), Media.deleted_at < cutoff)
)
).scalars().all()
for media in rows:
try:
await store.delete_object(key=media.storage_key)
except Exception as exc: # noqa: BLE001
logger.warning("object delete failed for %s: %s", media.storage_key, exc)
await session.delete(media)
await session.commit()
if rows:
logger.info("purged %d media", len(rows))
async def _purge_table(sessionmaker, model, cutoff: datetime) -> None:
async with sessionmaker() as session:
try:
res = await session.execute(
delete(model).where(model.deleted_at.is_not(None), model.deleted_at < cutoff)
)
await session.commit()
if res.rowcount:
logger.info("purged %d %s", res.rowcount, model.__tablename__)
except Exception as exc: # noqa: BLE001
await session.rollback()
logger.warning("purge %s failed: %s", model.__tablename__, exc)
async def purge_once(sessionmaker, store) -> None:
settings = get_settings()
cutoff = datetime.now(UTC) - timedelta(days=settings.purge_after_days)
await _purge_media(sessionmaker, store, cutoff)
for model in _PURGE_ORDER:
await _purge_table(sessionmaker, model, cutoff)
async def main() -> None:
logging.basicConfig(
level=logging.INFO, format="%(levelname)s [%(name)s] %(message)s", stream=sys.stdout
)
settings = get_settings()
store = S3ObjectStore(settings)
try:
await store.ensure_bucket()
except Exception as exc: # noqa: BLE001
logger.warning("ensure_bucket failed: %s", exc)
sessionmaker = get_sessionmaker()
logger.info(
"worker started; purge every %ds (recovery window %dd)",
settings.purge_interval_seconds,
settings.purge_after_days,
)
while True:
try:
await purge_once(sessionmaker, store)
except Exception as exc: # noqa: BLE001
logger.warning("purge cycle error: %s", exc)
await asyncio.sleep(settings.purge_interval_seconds)
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,65 @@
"""media
Revision ID: 7fc7024ef432
Revises: 1f6e54f6406a
Create Date: 2026-06-06 21:44:03.204170
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7fc7024ef432'
down_revision: str | None = '1f6e54f6406a'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('media',
sa.Column('uploader_id', sa.Uuid(), nullable=True),
sa.Column('storage_key', sa.String(length=512), nullable=False),
sa.Column('original_filename', sa.String(length=512), nullable=False),
sa.Column('content_type', sa.String(length=128), nullable=False),
sa.Column('byte_size', sa.BigInteger(), nullable=False),
sa.Column('checksum_sha256', sa.String(length=64), nullable=False),
sa.Column('title', sa.String(length=512), nullable=True),
sa.Column('person_id', sa.Uuid(), nullable=True),
sa.Column('event_id', sa.Uuid(), nullable=True),
sa.Column('source_id', sa.Uuid(), nullable=True),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('tree_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['event_id'], ['events.id'], name=op.f('fk_media_event_id_events'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['person_id'], ['persons.id'], name=op.f('fk_media_person_id_persons'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['source_id'], ['sources.id'], name=op.f('fk_media_source_id_sources'), ondelete='SET NULL'),
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_media_tree_id_trees'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['uploader_id'], ['users.id'], name=op.f('fk_media_uploader_id_users'), ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_media')),
sa.UniqueConstraint('storage_key', name=op.f('uq_media_storage_key'))
)
op.create_index(op.f('ix_media_checksum_sha256'), 'media', ['checksum_sha256'], unique=False)
op.create_index(op.f('ix_media_event_id'), 'media', ['event_id'], unique=False)
op.create_index(op.f('ix_media_person_id'), 'media', ['person_id'], unique=False)
op.create_index(op.f('ix_media_source_id'), 'media', ['source_id'], unique=False)
op.create_index(op.f('ix_media_tree_id'), 'media', ['tree_id'], unique=False)
op.create_index(op.f('ix_media_uploader_id'), 'media', ['uploader_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_media_uploader_id'), table_name='media')
op.drop_index(op.f('ix_media_tree_id'), table_name='media')
op.drop_index(op.f('ix_media_source_id'), table_name='media')
op.drop_index(op.f('ix_media_person_id'), table_name='media')
op.drop_index(op.f('ix_media_event_id'), table_name='media')
op.drop_index(op.f('ix_media_checksum_sha256'), table_name='media')
op.drop_table('media')
# ### end Alembic commands ###
+6
View File
@@ -12,6 +12,8 @@ dependencies = [
"asyncpg>=0.30", "asyncpg>=0.30",
"alembic>=1.14", "alembic>=1.14",
"argon2-cffi>=23.1", "argon2-cffi>=23.1",
"boto3>=1.35",
"python-multipart>=0.0.12",
] ]
[dependency-groups] [dependency-groups]
@@ -36,6 +38,10 @@ extend-exclude = ["migrations/versions"]
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"] select = ["E", "F", "I", "UP", "B"]
[tool.ruff.lint.flake8-bugbear]
# FastAPI uses these as call-expressions in argument defaults by design.
extend-immutable-calls = ["fastapi.File", "fastapi.Form", "fastapi.Depends", "fastapi.Query", "fastapi.Header"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
asyncio_mode = "auto" asyncio_mode = "auto"
pythonpath = ["."] pythonpath = ["."]
+25 -1
View File
@@ -14,9 +14,10 @@ from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
import app.models # noqa: F401 — register all models on Base.metadata import app.models # noqa: F401 — register all models on Base.metadata
from app.api.deps import get_mailer from app.api.deps import get_mailer, get_objectstore
from app.core.db import get_session from app.core.db import get_session
from app.integrations.mailer.base import Mailer from app.integrations.mailer.base import Mailer
from app.integrations.objectstore.base import ObjectStore
from app.main import app from app.main import app
from app.models import Base from app.models import Base
@@ -35,7 +36,28 @@ class CapturingMailer(Mailer):
self.resets.append((to, link)) self.resets.append((to, link))
class FakeObjectStore(ObjectStore):
def __init__(self) -> None:
self.objects: dict[str, tuple[bytes, str]] = {}
async def ensure_bucket(self) -> None:
pass
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None:
self.objects[key] = (data, content_type)
async def get_object(self, *, key: str) -> bytes:
return self.objects[key][0]
async def presigned_get_url(self, *, key: str) -> str:
return f"https://objects.test/{key}"
async def delete_object(self, *, key: str) -> None:
self.objects.pop(key, None)
_mailer = CapturingMailer() _mailer = CapturingMailer()
_store = FakeObjectStore()
@pytest.fixture @pytest.fixture
@@ -61,8 +83,10 @@ async def client():
_mailer.verifications.clear() _mailer.verifications.clear()
_mailer.resets.clear() _mailer.resets.clear()
_store.objects.clear()
app.dependency_overrides[get_session] = _override_session app.dependency_overrides[get_session] = _override_session
app.dependency_overrides[get_mailer] = lambda: _mailer app.dependency_overrides[get_mailer] = lambda: _mailer
app.dependency_overrides[get_objectstore] = lambda: _store
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client: async with AsyncClient(transport=transport, base_url="http://test") as http_client:
+77
View File
@@ -0,0 +1,77 @@
"""GEDCOM import + export round-trip."""
from tests.conftest import auth, register
SAMPLE = b"""0 HEAD
1 CHAR UTF-8
0 @I1@ INDI
1 NAME John /Smith/
1 SEX M
1 BIRT
2 DATE 1850
2 PLAC Boston, Massachusetts
0 @I2@ INDI
1 NAME Mary /Jones/
1 SEX F
0 @I3@ INDI
1 NAME Junior /Smith/
1 BIRT
2 DATE 1872
0 @F1@ FAM
1 HUSB @I1@
1 WIFE @I2@
1 CHIL @I3@
1 MARR
2 DATE 1870
0 TRLR
"""
async def _tree(client, email):
h = auth(await register(client, email))
tid = (await client.post("/api/v1/trees", json={"name": "Imported"}, headers=h)).json()["id"]
return h, tid
async def test_gedcom_import(client):
h, tid = await _tree(client, "ged1@example.com")
resp = await client.post(
f"/api/v1/trees/{tid}/gedcom/import",
files={"file": ("sample.ged", SAMPLE, "text/plain")},
headers=h,
)
assert resp.status_code == 200, resp.text
counts = resp.json()["counts"]
assert counts["persons"] == 3
assert counts["families"] == 1
# partnership (1) + parent_child from both parents to the child (2)
assert counts["relationships"] == 3
assert counts["events"] == 3 # 2 births + 1 marriage
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
assert len(people) == 3
rels = (await client.get(f"/api/v1/trees/{tid}/relationships", headers=h)).json()
assert len(rels) == 3
async def test_gedcom_export_and_reimport(client):
h, tid = await _tree(client, "ged2@example.com")
await client.post(
f"/api/v1/trees/{tid}/gedcom/import",
files={"file": ("sample.ged", SAMPLE, "text/plain")},
headers=h,
)
exported = await client.get(f"/api/v1/trees/{tid}/gedcom/export", headers=h)
assert exported.status_code == 200
text = exported.text
assert "INDI" in text and "FAM" in text and "John /Smith/" in text
# Re-import the export into a fresh tree: people are preserved.
tid2 = (await client.post("/api/v1/trees", json={"name": "Round"}, headers=h)).json()["id"]
resp = await client.post(
f"/api/v1/trees/{tid2}/gedcom/import",
files={"file": ("rt.ged", text.encode(), "text/plain")},
headers=h,
)
assert resp.json()["counts"]["persons"] == 3
assert resp.json()["counts"]["relationships"] == 3
+50
View File
@@ -0,0 +1,50 @@
"""Media upload/list/delete through the API (object store faked in conftest)."""
from tests.conftest import auth, register
async def _tree(client, email):
h = auth(await register(client, email))
tree_id = (await client.post("/api/v1/trees", json={"name": "M"}, headers=h)).json()["id"]
return h, tree_id
async def test_media_upload_list_delete(client):
h, tree_id = await _tree(client, "media1@example.com")
resp = await client.post(
f"/api/v1/trees/{tree_id}/media",
files={"file": ("scan.txt", b"hello world", "text/plain")},
data={"title": "A scan"},
headers=h,
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["original_filename"] == "scan.txt"
assert body["byte_size"] == 11
assert body["url"] == f"/api/v1/trees/{tree_id}/media/{body['id']}/content"
media_id = body["id"]
listed = await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)
assert listed.status_code == 200
assert len(listed.json()) == 1
# The content endpoint streams the bytes back.
content = await client.get(f"/api/v1/trees/{tree_id}/media/{media_id}/content", headers=h)
assert content.status_code == 200
assert content.content == b"hello world"
resp = await client.delete(f"/api/v1/trees/{tree_id}/media/{media_id}", headers=h)
assert resp.status_code == 204
assert len((await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)).json()) == 0
async def test_non_member_cannot_upload(client):
h, tree_id = await _tree(client, "media2@example.com")
other = auth(await register(client, "media-intruder@example.com"))
resp = await client.post(
f"/api/v1/trees/{tree_id}/media",
files={"file": ("x.txt", b"x", "text/plain")},
headers=other,
)
assert resp.status_code == 403
+54
View File
@@ -0,0 +1,54 @@
"""Soft-delete + recovery for trees and people."""
from tests.conftest import auth, register
async def test_tree_delete_and_restore(client):
h = auth(await register(client, "rec1@example.com"))
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
# Delete -> gone from active lists, present in the recovery list.
assert (await client.delete(f"/api/v1/trees/{tree_id}", headers=h)).status_code == 204
assert len((await client.get("/api/v1/trees", headers=h)).json()) == 0
# A soft-deleted tree is no longer visible (404 to the would-be viewer).
gone = await client.get(f"/api/v1/trees/{tree_id}", headers=h)
assert gone.status_code == 404
deleted = (await client.get("/api/v1/trees?deleted=true", headers=h)).json()
assert len(deleted) == 1 and deleted[0]["id"] == tree_id
# Restore -> back in active lists.
assert (await client.post(f"/api/v1/trees/{tree_id}/restore", headers=h)).status_code == 200
assert len((await client.get("/api/v1/trees", headers=h)).json()) == 1
assert (await client.get(f"/api/v1/trees/{tree_id}", headers=h)).status_code == 200
async def test_only_owner_can_delete_tree(client):
owner = auth(await register(client, "rec-owner@example.com"))
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=owner)).json()["id"]
other = auth(await register(client, "rec-other@example.com"))
blocked = await client.delete(f"/api/v1/trees/{tree_id}", headers=other)
assert blocked.status_code in (403, 404)
async def test_person_delete_and_restore(client):
h = auth(await register(client, "rec2@example.com"))
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
person_id = (
await client.post(
f"/api/v1/trees/{tree_id}/persons", json={"given": "Ada"}, headers=h
)
).json()["id"]
assert (
await client.delete(f"/api/v1/trees/{tree_id}/persons/{person_id}", headers=h)
).status_code == 204
assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 0
deleted = (
await client.get(f"/api/v1/trees/{tree_id}/persons?deleted=true", headers=h)
).json()
assert len(deleted) == 1 and deleted[0]["primary_name"] == "Ada"
assert (
await client.post(f"/api/v1/trees/{tree_id}/persons/{person_id}/restore", headers=h)
).status_code == 200
assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 1
+96
View File
@@ -0,0 +1,96 @@
"""Sources and citations (the provenance spine)."""
import uuid
from tests.conftest import auth, register
async def _setup(client, email):
h = auth(await register(client, email))
tree_id = (await client.post("/api/v1/trees", json={"name": "S"}, headers=h)).json()["id"]
person = (
await client.post(
f"/api/v1/trees/{tree_id}/persons", json={"given": "Cy"}, headers=h
)
).json()["id"]
event = (
await client.post(
f"/api/v1/trees/{tree_id}/events",
json={"event_type": "birth", "person_id": person},
headers=h,
)
).json()["id"]
return h, tree_id, person, event
async def test_source_and_citation_flow(client):
h, tree_id, person, event = await _setup(client, "src1@example.com")
src = await client.post(
f"/api/v1/trees/{tree_id}/sources",
json={"title": "1880 Census", "repository": "NARA"},
headers=h,
)
assert src.status_code == 201, src.text
source_id = src.json()["id"]
assert len((await client.get(f"/api/v1/trees/{tree_id}/sources", headers=h)).json()) == 1
# Cite the source on the birth event.
cite = await client.post(
f"/api/v1/trees/{tree_id}/citations",
json={"source_id": source_id, "event_id": event, "page": "p. 4"},
headers=h,
)
assert cite.status_code == 201, cite.text
citation_id = cite.json()["id"]
citations = (await client.get(f"/api/v1/trees/{tree_id}/citations", headers=h)).json()
assert len(citations) == 1
assert citations[0]["event_id"] == event
assert citations[0]["source_id"] == source_id
resp = await client.delete(f"/api/v1/trees/{tree_id}/citations/{citation_id}", headers=h)
assert resp.status_code == 204
assert len((await client.get(f"/api/v1/trees/{tree_id}/citations", headers=h)).json()) == 0
async def test_citation_needs_exactly_one_target(client):
h, tree_id, person, event = await _setup(client, "src2@example.com")
source_id = (
await client.post(
f"/api/v1/trees/{tree_id}/sources", json={"title": "X"}, headers=h
)
).json()["id"]
# No target.
r = await client.post(
f"/api/v1/trees/{tree_id}/citations", json={"source_id": source_id}, headers=h
)
assert r.status_code == 409
# Two targets.
r = await client.post(
f"/api/v1/trees/{tree_id}/citations",
json={"source_id": source_id, "person_id": person, "event_id": event},
headers=h,
)
assert r.status_code == 409
async def test_citation_unknown_source_404(client):
h, tree_id, person, _ = await _setup(client, "src3@example.com")
r = await client.post(
f"/api/v1/trees/{tree_id}/citations",
json={"source_id": str(uuid.uuid4()), "person_id": person},
headers=h,
)
assert r.status_code == 404
async def test_non_member_cannot_create_source(client):
h, tree_id, _, _ = await _setup(client, "src4@example.com")
other = auth(await register(client, "src-intruder@example.com"))
r = await client.post(
f"/api/v1/trees/{tree_id}/sources", json={"title": "nope"}, headers=other
)
assert r.status_code == 403
+92
View File
@@ -125,6 +125,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
] ]
[[package]]
name = "boto3"
version = "1.43.24"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/8f/94dfa39ec618ecb2fe5b5b79428c95100e3ae3c1aa5083c283dd3cfb5ecd/boto3-1.43.24.tar.gz", hash = "sha256:ba5afa266bf7265e0c1a454fcfd48bffe5939cb16ed223bebc669c3dc8ee0bc8", size = 113154, upload-time = "2026-06-05T19:30:01.635Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/b7/e66c9b37b96153aa371fe48d24194151293f6577dd3eaa1fc146c281456d/boto3-1.43.24-py3-none-any.whl", hash = "sha256:b18ef745274ef548a9660d733d985d4a971b16bd8a6af88165ea9d0e40913b86", size = 140536, upload-time = "2026-06-05T19:29:58.968Z" },
]
[[package]]
name = "botocore"
version = "1.43.24"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/67/55d0611b341482bc9649d16df765f849a1862184ac3709356decf632279f/botocore-1.43.24.tar.gz", hash = "sha256:0c02f2b40e99419d496ece0ea2dcdedb5c45998c16fd1674276c7dbb30767a16", size = 15471690, upload-time = "2026-06-05T19:29:33.731Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/b7/360b5afe74c4d7cff871ea6e8f335e2e11de2945c9deb1eea6438f49faa2/botocore-1.43.24-py3-none-any.whl", hash = "sha256:42903b4bfafd8f15a735ed940473f28e4ba21b2ea67a9b9aaa11dfa7fcb19fd5", size = 15155182, upload-time = "2026-06-05T19:29:29.457Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.5.20" version = "2026.5.20"
@@ -357,6 +385,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
] ]
[[package]]
name = "jmespath"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]] [[package]]
name = "mako" name = "mako"
version = "1.3.12" version = "1.3.12"
@@ -447,9 +484,11 @@ dependencies = [
{ name = "alembic" }, { name = "alembic" },
{ name = "argon2-cffi" }, { name = "argon2-cffi" },
{ name = "asyncpg" }, { name = "asyncpg" },
{ name = "boto3" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "python-multipart" },
{ name = "sqlalchemy", extra = ["asyncio"] }, { name = "sqlalchemy", extra = ["asyncio"] },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
] ]
@@ -467,9 +506,11 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.14" }, { name = "alembic", specifier = ">=1.14" },
{ name = "argon2-cffi", specifier = ">=23.1" }, { name = "argon2-cffi", specifier = ">=23.1" },
{ name = "asyncpg", specifier = ">=0.30" }, { name = "asyncpg", specifier = ">=0.30" },
{ name = "boto3", specifier = ">=1.35" },
{ name = "fastapi", specifier = ">=0.115" }, { name = "fastapi", specifier = ">=0.115" },
{ name = "pydantic", specifier = ">=2.9" }, { name = "pydantic", specifier = ">=2.9" },
{ name = "pydantic-settings", specifier = ">=2.5" }, { name = "pydantic-settings", specifier = ">=2.5" },
{ name = "python-multipart", specifier = ">=0.0.12" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34" },
] ]
@@ -613,6 +654,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
] ]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.2" version = "1.2.2"
@@ -622,6 +675,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
] ]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" version = "6.0.3"
@@ -683,6 +745,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" },
] ]
[[package]]
name = "s3transfer"
version = "0.18.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e0/1f/12417f7f493fc45e1f9fd5d4a9b6c125cf8d2cf3f8ddbdfab3e76406e9d6/s3transfer-0.18.0.tar.gz", hash = "sha256:3760b8b7ec1315da54048b2d626276732bee4300d054d492d4e1d43e20d4ecbd", size = 160560, upload-time = "2026-05-28T19:39:09.124Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/58/a58fc997655386daa2e25784e30c288aa3e3819e401f77029ee4899fb55a/s3transfer-0.18.0-py3-none-any.whl", hash = "sha256:239c13b09e65ad0346e1be7348b8a202dcad44ac7ea7c6eb858fc881dce739b6", size = 88572, upload-time = "2026-05-28T19:39:07.999Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.50" version = "2.0.50"
@@ -755,6 +838,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
] ]
[[package]]
name = "urllib3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.49.0" version = "0.49.0"
+5
View File
@@ -12,6 +12,11 @@ services:
context: ../backend context: ../backend
dockerfile: Dockerfile dockerfile: Dockerfile
worker:
build:
context: ../backend
dockerfile: Dockerfile
frontend: frontend:
build: build:
context: ../frontend context: ../frontend
+29
View File
@@ -47,9 +47,16 @@ services:
environment: environment:
APP_ENV: ${APP_ENV:-development} APP_ENV: ${APP_ENV:-development}
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://provenance:provenance@postgres:5432/provenance} DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://provenance:provenance@postgres:5432/provenance}
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://minio:9000}
S3_BUCKET: ${S3_BUCKET:-provenance}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-provenance}
S3_SECRET_KEY: ${S3_SECRET_KEY:-change-me-too}
S3_REGION: ${S3_REGION:-us-east-1}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
minio:
condition: service_healthy
healthcheck: healthcheck:
test: test:
- CMD-SHELL - CMD-SHELL
@@ -62,6 +69,28 @@ services:
start_period: 20s start_period: 20s
restart: unless-stopped restart: unless-stopped
# Background worker — same image as the backend, run in worker mode.
# First job: the scheduled soft-delete purge (and media object cleanup).
worker:
image: git.jpaul.io/justin/provenance-backend:${IMAGE_TAG:-test-main}
command: ["uv", "run", "--no-dev", "python", "-m", "app.worker"]
labels:
com.centurylinklabs.watchtower.enable: "true"
environment:
APP_ENV: ${APP_ENV:-development}
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://provenance:provenance@postgres:5432/provenance}
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://minio:9000}
S3_BUCKET: ${S3_BUCKET:-provenance}
S3_ACCESS_KEY: ${S3_ACCESS_KEY:-provenance}
S3_SECRET_KEY: ${S3_SECRET_KEY:-change-me-too}
S3_REGION: ${S3_REGION:-us-east-1}
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
restart: unless-stopped
frontend: frontend:
image: git.jpaul.io/justin/provenance-frontend:${IMAGE_TAG:-test-main} image: git.jpaul.io/justin/provenance-frontend:${IMAGE_TAG:-test-main}
labels: labels:
+78 -14
View File
@@ -1,44 +1,108 @@
@import "tailwindcss"; @import "tailwindcss";
/* Brand palette (docs/brand): warm ink + bronze + paper. */ /* Brand palette + type (docs/brand): warm ink + bronze + paper, serif display. */
@theme { @theme {
--color-bronze: #a06a42; --color-bronze: #a06a42;
--color-bronze-deep: #8a5836; --color-bronze-deep: #8a5836;
--color-paper: #f7f3ec; --color-paper: #f7f3ec;
--color-ink: #1a1a17; --color-ink: #1a1a17;
--font-serif: Georgia, "Times New Roman", "Liberation Serif", ui-serif, serif; --font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-serif: var(--font-fraunces), Georgia, "Times New Roman", ui-serif, serif;
} }
/* Adaptive tokens (ink/paper flip for light/dark; bronze + paper are constant). */ /* Adaptive tokens ink/paper flip for light/dark; bronze + paper are constant. */
:root { :root {
--background: #f7f3ec; /* paper */ --background: #f7f3ec;
--foreground: #1a1a17; /* ink */ --foreground: #1a1a17;
--muted: #6b6862; --muted: #6b6862;
--surface: #fbf8f2; --surface: #fffdf9;
--border: #e4dccb; --border: #e6ddcc;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--background: #1a1a17; /* warm near-black */ --background: #161410;
--foreground: #f2eee6; /* warm off-white */ --foreground: #f2eee6;
--muted: #9a968e; --muted: #9a968e;
--surface: #232019; --surface: #211d17;
--border: #3a352c; --border: #353029;
} }
} }
body { body {
background: var(--background); /* A faint bronze warmth pooled at the top gives the flat paper some depth. */
background:
radial-gradient(
1100px 520px at 50% -8%,
color-mix(in srgb, var(--color-bronze) 9%, var(--background)),
var(--background) 60%
);
background-attachment: fixed;
color: var(--foreground); color: var(--foreground);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-family: var(--font-sans);
} }
/* Headings use the heritage serif register. */
h1, h1,
h2, h2,
h3, h3,
.font-serif { .font-serif {
font-family: var(--font-serif); font-family: var(--font-serif);
letter-spacing: -0.015em;
}
::selection {
background: color-mix(in srgb, var(--color-bronze) 22%, transparent);
}
/* Pedigree bracket connectors (ancestors grow rightward). Each leaf draws its
own half of the vertical spine + a horizontal stub, so lines stay correct
regardless of box heights: focus → 2 parents, each parent → 2 grandparents. */
.ped-person {
display: flex;
align-items: center;
}
.ped-self {
flex-shrink: 0;
}
.ped-branch {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-left: 2.5rem;
}
.ped-branch::before {
content: "";
position: absolute;
left: -2.5rem;
top: 50%;
width: 2.5rem;
border-top: 1px solid var(--border);
}
.ped-leaf {
position: relative;
padding-left: 1.5rem;
}
.ped-leaf::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 1.5rem;
border-top: 1px solid var(--border);
}
.ped-leaf::after {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-left: 1px solid var(--border);
}
.ped-leaf:first-child::after {
top: 50%;
}
.ped-leaf:last-child::after {
bottom: 50%;
} }
+14 -28
View File
@@ -1,41 +1,27 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import { Fraunces, Inter } from "next/font/google";
import "./globals.css"; import "./globals.css";
const serif = Fraunces({
subsets: ["latin"],
variable: "--font-fraunces",
display: "swap",
axes: ["opsz"],
});
const sans = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Provenance", title: "Provenance — where it came from matters",
description: "Where it came from matters — family and land, every fact sourced.", description:
"Trace your family and your land in one place — every fact linked to the record it came from. Self-hosted, sourced, and yours to keep.",
icons: { icon: "/favicon.svg" }, icons: { icon: "/favicon.svg" },
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en" className={`${serif.variable} ${sans.variable}`}>
<body className="flex min-h-screen flex-col"> <body className="min-h-screen antialiased">{children}</body>
<header className="border-b border-[var(--border)]">
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
<Link href="/" className="flex items-center" aria-label="Provenance — home">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
</Link>
<nav className="flex gap-5 text-sm">
<Link href="/trees" className="text-[var(--muted)] transition-colors hover:text-bronze">
Trees
</Link>
<Link href="/login" className="text-[var(--muted)] transition-colors hover:text-bronze">
Sign in
</Link>
</nav>
</div>
</header>
<main className="mx-auto w-full max-w-3xl flex-1 px-4 py-10">{children}</main>
<footer className="border-t border-[var(--border)]">
<div className="mx-auto max-w-3xl px-4 py-6 text-sm italic text-[var(--muted)]">
where it came from matters
</div>
</footer>
</body>
</html> </html>
); );
} }
+9 -1
View File
@@ -31,7 +31,13 @@ export default function LoginPage() {
} }
return ( return (
<Card className="mx-auto max-w-md"> <div className="grid min-h-screen place-items-center px-4 py-10">
<div className="w-full max-w-md space-y-6">
<Link href="/" className="flex justify-center" aria-label="Provenance — home">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-8 w-auto" />
</Link>
<Card>
<CardHeader> <CardHeader>
<CardTitle>Sign in</CardTitle> <CardTitle>Sign in</CardTitle>
</CardHeader> </CardHeader>
@@ -70,5 +76,7 @@ export default function LoginPage() {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
</div>
</div>
); );
} }
+90 -12
View File
@@ -1,27 +1,105 @@
import { BadgeCheck, MapPin, ShieldCheck, Users } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
const features = [
{
icon: Users,
title: "Family and land, together",
body: "People, relationships, and life events alongside property and chain-of-title — one documented story of where you come from.",
},
{
icon: BadgeCheck,
title: "Sourced or it didn't happen",
body: "Every fact can carry a citation back to the record it came from. Sources are first-class, reusable, and visible.",
},
{
icon: ShieldCheck,
title: "Yours to keep",
body: "Self-hosted and source-available. Living people protected by default. Open formats — export anytime, run it anywhere.",
},
];
export default function Home() { export default function Home() {
return ( return (
<div className="space-y-8 py-4"> <div className="flex min-h-screen flex-col">
<div className="space-y-4"> <header className="border-b border-[var(--border)]">
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl"> <div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
Where it came from matters <Link href="/" aria-label="Provenance — home">
</h1> {/* eslint-disable-next-line @next/next/no-img-element */}
<p className="max-w-prose text-lg text-[var(--muted)]"> <img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
Trace where you come from your family <span className="text-bronze">and</span> your </Link>
land with every fact linked to a source, on infrastructure you control. <nav className="flex items-center gap-5 text-sm">
</p> <Link href="/trees" className="text-[var(--muted)] hover:text-[var(--foreground)]">
Trees
</Link>
<Link
href="/login"
className="rounded-full border border-[var(--border)] px-4 py-1.5 font-medium hover:border-bronze hover:text-bronze"
>
Sign in
</Link>
</nav>
</div> </div>
<div className="flex flex-wrap gap-3"> </header>
<main className="mx-auto w-full max-w-5xl flex-1 px-6">
<section className="grid items-center gap-10 py-16 sm:grid-cols-[1.3fr_1fr] sm:py-24">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-bronze">
Family · Land · Provenance
</p>
<h1 className="mt-4 text-5xl font-semibold leading-[1.04] tracking-tight sm:text-6xl">
Where it came from <span className="italic text-bronze">matters</span>.
</h1>
<p className="mt-6 max-w-xl text-lg leading-relaxed text-[var(--muted)]">
Trace your family and your land in one place every name, every parcel, every claim
linked to the record it came from. Self-hosted, sourced, and yours to keep.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Link href="/register"> <Link href="/register">
<Button>Create an account</Button> <Button size="lg">Create your account</Button>
</Link> </Link>
<Link href="/login"> <Link href="/login">
<Button variant="outline">Sign in</Button> <Button size="lg" variant="outline">
Sign in
</Button>
</Link> </Link>
</div> </div>
</div> </div>
<div className="hidden justify-self-end sm:block">
<div className="relative grid h-64 w-64 place-items-center rounded-full border border-[var(--border)] bg-[var(--surface)] shadow-[0_24px_60px_-24px_rgba(160,106,66,0.35)]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-mark.svg" alt="" className="h-36 w-36" />
<MapPin className="absolute -right-2 top-10 h-7 w-7 text-bronze" />
</div>
</div>
</section>
<section className="grid gap-5 pb-20 sm:grid-cols-3">
{features.map((f) => (
<div
key={f.title}
className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-6 shadow-[0_1px_2px_rgba(26,26,23,0.04)]"
>
<div className="grid h-10 w-10 place-items-center rounded-lg bg-bronze/12 text-bronze">
<f.icon className="h-5 w-5" />
</div>
<h2 className="mt-4 text-lg font-semibold">{f.title}</h2>
<p className="mt-2 text-sm leading-relaxed text-[var(--muted)]">{f.body}</p>
</div>
))}
</section>
</main>
<footer className="border-t border-[var(--border)]">
<div className="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-2 px-6 py-6 text-sm text-[var(--muted)]">
<span className="font-serif text-base italic">where it came from matters</span>
<span>Self-hosted · source-available · your data, your infrastructure</span>
</div>
</footer>
</div>
); );
} }
+9 -1
View File
@@ -34,7 +34,13 @@ export default function RegisterPage() {
} }
return ( return (
<Card className="mx-auto max-w-md"> <div className="grid min-h-screen place-items-center px-4 py-10">
<div className="w-full max-w-md space-y-6">
<Link href="/" className="flex justify-center" aria-label="Provenance — home">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-8 w-auto" />
</Link>
<Card>
<CardHeader> <CardHeader>
<CardTitle>Create your account</CardTitle> <CardTitle>Create your account</CardTitle>
</CardHeader> </CardHeader>
@@ -78,5 +84,7 @@ export default function RegisterPage() {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
</div>
</div>
); );
} }
+163
View File
@@ -0,0 +1,163 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useRef, useState } from "react";
import { api } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
type Report = { counts: Record<string, number>; unmapped_tags: string[] };
export default function GedcomPage() {
const params = useParams<{ id: string }>();
const treeId = params.id;
const [target, setTarget] = useState<"new" | "this">("new");
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
const [report, setReport] = useState<Report | null>(null);
const [importedTreeId, setImportedTreeId] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setBusy(true);
setReport(null);
setImportedTreeId(null);
let tid = treeId;
if (target === "new") {
const { data } = await api.POST("/api/v1/trees", {
body: { name: newName.trim() || "Imported tree" },
});
if (!data) {
setBusy(false);
return;
}
tid = data.id;
setImportedTreeId(tid);
} else {
setImportedTreeId(treeId);
}
const fd = new FormData();
fd.append("file", file);
const resp = await fetch(`/api/v1/trees/${tid}/gedcom/import`, {
method: "POST",
body: fd,
credentials: "include",
});
if (resp.ok) setReport(await resp.json());
setBusy(false);
if (fileRef.current) fileRef.current.value = "";
}
async function exportGed() {
const resp = await fetch(`/api/v1/trees/${treeId}/gedcom/export`, {
credentials: "include",
});
if (!resp.ok) return;
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "tree.ged";
a.click();
URL.revokeObjectURL(url);
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">Import &amp; export GEDCOM</h1>
<Card>
<CardHeader>
<CardTitle className="text-base">Import a GEDCOM file</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="target"
checked={target === "new"}
onChange={() => setTarget("new")}
/>
Import into a <strong>new tree</strong> (recommended)
</label>
{target === "new" && (
<Input
className="max-w-xs"
placeholder="New tree name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
)}
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="target"
checked={target === "this"}
onChange={() => setTarget("this")}
/>
Import into <strong>this tree</strong> (appends)
</label>
{target === "this" && (
<p className="rounded-md bg-bronze/[0.08] px-3 py-2 text-sm text-[var(--muted)]">
Importing appends everyone in the file as new records it does not merge with
people already in this tree, so duplicates are possible.
</p>
)}
<input ref={fileRef} type="file" accept=".ged,.gedcom,text/plain" onChange={onFile} className="hidden" />
<Button onClick={() => fileRef.current?.click()} disabled={busy}>
{busy ? "Importing…" : "Choose GEDCOM file"}
</Button>
{report && (
<div className="space-y-3 rounded-lg border border-[var(--border)] p-4">
<div className="font-medium">Import complete</div>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-[var(--muted)]">
{Object.entries(report.counts).map(([k, v]) => (
<span key={k}>
<span className="font-medium text-[var(--foreground)]">{v}</span> {k}
</span>
))}
</div>
{report.unmapped_tags.length > 0 && (
<div className="text-xs text-[var(--muted)]">
Unmapped tags (skipped): {report.unmapped_tags.join(", ")}
</div>
)}
{importedTreeId && (
<Link
href={`/trees/${importedTreeId}`}
className="inline-block text-sm text-bronze hover:underline"
>
Open the imported tree
</Link>
)}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Export this tree</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-[var(--muted)]">
Download this tree as a GEDCOM file people, relationships, events, and sources.
</p>
<Button variant="outline" onClick={exportGed}>
Download .ged
</Button>
</CardContent>
</Card>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
type Media = components["schemas"]["MediaRead"];
function humanSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
export default function MediaPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const treeId = params.id;
const [items, setItems] = useState<Media[]>([]);
const [ready, setReady] = useState(false);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const load = useCallback(async () => {
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/media", {
params: { path: { tree_id: treeId } },
});
if (response.status === 401) {
router.push("/login");
return;
}
setItems(data ?? []);
setReady(true);
}, [router, treeId]);
useEffect(() => {
load();
}, [load]);
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const fd = new FormData();
fd.append("file", file);
// Plain fetch for multipart (same origin → cookie auth via Caddy).
await fetch(`/api/v1/trees/${treeId}/media`, {
method: "POST",
body: fd,
credentials: "include",
});
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
load();
}
async function remove(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/media/{media_id}", {
params: { path: { tree_id: treeId, media_id: id } },
});
load();
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold">Media</h1>
<div>
<input
ref={fileRef}
type="file"
onChange={onFile}
className="hidden"
id="media-upload"
/>
<Button onClick={() => fileRef.current?.click()} disabled={uploading}>
{uploading ? "Uploading…" : "Upload file"}
</Button>
</div>
</div>
{items.length === 0 ? (
<p className="text-[var(--muted)]">
No media yet upload scans, photos, or documents and attach them to facts.
</p>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{items.map((m) => (
<Card key={m.id} className="overflow-hidden">
<a href={m.url ?? "#"} target="_blank" rel="noreferrer" className="block">
{m.content_type.startsWith("image/") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={m.url ?? ""}
alt={m.title ?? m.original_filename}
className="aspect-square w-full object-cover"
/>
) : (
<div className="grid aspect-square w-full place-items-center bg-bronze/[0.06] text-3xl font-serif text-bronze">
{(m.original_filename.split(".").pop() ?? "file").toUpperCase()}
</div>
)}
</a>
<CardContent className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium" title={m.original_filename}>
{m.title ?? m.original_filename}
</div>
<div className="text-xs text-[var(--muted)]">{humanSize(m.byte_size)}</div>
</div>
<button
onClick={() => remove(m.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Remove"
>
×
</button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
+303 -46
View File
@@ -2,35 +2,62 @@
import Link from "next/link"; import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api/client"; import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema"; import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
type Person = components["schemas"]["PersonRead"]; type Person = components["schemas"]["PersonRead"];
type Relationship = components["schemas"]["RelationshipRead"];
type Event = components["schemas"]["EventRead"];
export default function TreeDetailPage() { function splitName(full: string): { given: string | null; surname: string | null } {
const t = full.trim().split(/\s+/).filter(Boolean);
if (t.length === 0) return { given: null, surname: null };
if (t.length === 1) return { given: t[0], surname: null };
return { given: t.slice(0, -1).join(" "), surname: t[t.length - 1] };
}
type AddKind = "parent" | "child" | "partner";
export default function FamilyViewPage() {
const router = useRouter(); const router = useRouter();
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const treeId = params.id; const treeId = params.id;
const [persons, setPersons] = useState<Person[]>([]); const [people, setPeople] = useState<Person[]>([]);
const [given, setGiven] = useState(""); const [rels, setRels] = useState<Relationship[]>([]);
const [surname, setSurname] = useState(""); const [events, setEvents] = useState<Event[]>([]);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [focusId, setFocusId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [firstName, setFirstName] = useState("");
// Inline add-relative form: which anchor + kind is open, and the typed name.
// `key` keeps each empty slot's inline form independent (a person has 2
// parents, 4 grandparents — many same-kind/anchor slots can coexist).
const [adding, setAdding] = useState<{ key: string; kind: AddKind; anchor: string } | null>(null);
const [addName, setAddName] = useState("");
const load = useCallback(async () => { const load = useCallback(async () => {
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/persons", { const p = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } }, params: { path: { tree_id: treeId } },
}); });
if (response.status === 401) { if (p.response.status === 401) {
router.push("/login"); router.push("/login");
return; return;
} }
setPersons(data ?? []); const [r, e] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
]);
const ppl = p.data ?? [];
setPeople(ppl);
setRels(r.data ?? []);
setEvents(e.data ?? []);
setFocusId((cur) => cur ?? ppl[0]?.id ?? null);
setReady(true); setReady(true);
}, [router, treeId]); }, [router, treeId]);
@@ -38,62 +65,292 @@ export default function TreeDetailPage() {
load(); load();
}, [load]); }, [load]);
async function addPerson(e: React.FormEvent) { const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
e.preventDefault(); const parentsOf = (id: string) =>
if (!given.trim() && !surname.trim()) return; rels.filter((r) => r.type === "parent_child" && r.person_to_id === id).map((r) => r.person_from_id);
const { error } = await api.POST("/api/v1/trees/{tree_id}/persons", { const childrenOf = (id: string) =>
rels.filter((r) => r.type === "parent_child" && r.person_from_id === id).map((r) => r.person_to_id);
const partnersOf = (id: string) =>
rels
.filter((r) => r.type === "partnership" && (r.person_from_id === id || r.person_to_id === id))
.map((r) => (r.person_from_id === id ? r.person_to_id : r.person_from_id));
const years = useMemo(() => {
const m = new Map<string, string>();
const yr = (e: Event) => (e.date_start ? e.date_start.slice(0, 4) : e.date_value ?? "");
for (const p of people) {
const b = events.find((e) => e.person_id === p.id && e.event_type === "birth");
const d = events.find((e) => e.person_id === p.id && e.event_type === "death");
const parts = [b ? yr(b) : "", d ? yr(d) : ""];
if (parts[0] || parts[1]) m.set(p.id, `${parts[0]}${parts[1]}`.replace(/^$/, ""));
}
return m;
}, [people, events]);
async function addPerson(name: string): Promise<string | null> {
const { given, surname } = splitName(name);
const { data } = await api.POST("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } }, params: { path: { tree_id: treeId } },
body: { given: given || null, surname: surname || null }, body: { given, surname },
}); });
if (!error) { return data?.id ?? null;
setGiven(""); }
setSurname("");
async function createFirst(e: React.FormEvent) {
e.preventDefault();
if (!firstName.trim()) return;
const id = await addPerson(firstName);
setFirstName("");
if (id) setFocusId(id);
load(); load();
} }
async function submitAdd(e: React.FormEvent) {
e.preventDefault();
if (!adding || !addName.trim()) return;
const newId = await addPerson(addName);
if (newId) {
const { kind, anchor } = adding;
const body =
kind === "parent"
? { type: "parent_child" as const, person_from_id: newId, person_to_id: anchor, qualifier: "biological" as const }
: kind === "child"
? { type: "parent_child" as const, person_from_id: anchor, person_to_id: newId, qualifier: "biological" as const }
: { type: "partnership" as const, person_from_id: anchor, person_to_id: newId };
await api.POST("/api/v1/trees/{tree_id}/relationships", {
params: { path: { tree_id: treeId } },
body,
});
}
setAdding(null);
setAddName("");
load();
} }
if (!ready) return <p className="text-[var(--muted)]">Loading</p>; if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
if (people.length === 0) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Link href="/trees" className="text-sm text-[var(--muted)] hover:underline"> <h1 className="text-2xl font-semibold">Start your tree</h1>
All trees
</Link>
<Card> <Card>
<CardHeader> <CardContent className="p-6">
<CardTitle className="text-base">Add a person</CardTitle> <form onSubmit={createFirst} className="flex flex-wrap gap-2">
</CardHeader> <Input
<CardContent> className="w-64"
<form onSubmit={addPerson} className="flex gap-2"> placeholder="First person's full name"
<Input placeholder="Given name" value={given} onChange={(e) => setGiven(e.target.value)} /> value={firstName}
<Input placeholder="Surname" value={surname} onChange={(e) => setSurname(e.target.value)} /> onChange={(e) => setFirstName(e.target.value)}
<Button type="submit">Add</Button> />
<Button type="submit">Add person</Button>
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
</div>
);
}
<div> const focus = focusId ? byId.get(focusId) : undefined;
<h2 className="mb-2 text-lg font-semibold">People</h2> if (!focus) {
{persons.length === 0 ? ( setFocusId(people[0].id);
<p className="text-[var(--muted)]">No people yet.</p> return null;
}
const PersonBox = ({
id,
muted,
}: {
id: string;
muted?: boolean;
}) => {
const p = byId.get(id);
if (!p) return null;
const isFocus = id === focusId;
return (
<button
onClick={() => setFocusId(id)}
className={`w-44 rounded-lg border px-3 py-2 text-left transition-colors ${
isFocus
? "border-bronze bg-bronze/[0.08]"
: "border-[var(--border)] bg-[var(--surface)] hover:border-bronze/60"
} ${muted ? "opacity-90" : ""}`}
>
<div className="truncate text-sm font-medium">{p.primary_name ?? "Unnamed"}</div>
<div className="text-xs text-[var(--muted)]">{years.get(id) ?? "—"}</div>
</button>
);
};
const AddSlot = ({
formKey,
kind,
anchor,
label,
}: {
formKey: string;
kind: AddKind;
anchor: string;
label: string;
}) =>
adding?.key === formKey ? (
<form onSubmit={submitAdd} className="flex w-44 flex-col gap-1">
<Input
autoFocus
className="h-9"
placeholder="Full name"
value={addName}
onChange={(e) => setAddName(e.target.value)}
/>
<div className="flex gap-1">
<Button type="submit" size="sm">
Add
</Button>
<button
type="button"
onClick={() => setAdding(null)}
className="text-xs text-[var(--muted)]"
>
cancel
</button>
</div>
</form>
) : ( ) : (
<ul className="space-y-2"> <button
{persons.map((person) => ( onClick={() => {
<li key={person.id}> setAdding({ key: formKey, kind, anchor });
<Link href={`/trees/${treeId}/persons/${person.id}`}> setAddName("");
<Card className="transition-colors hover:border-bronze/50"> }}
<CardContent className="p-4"> className="w-44 rounded-lg border border-dashed border-[var(--border)] px-3 py-2 text-left text-sm text-[var(--muted)] hover:border-bronze hover:text-bronze"
{person.primary_name ?? ( >
<span className="text-[var(--muted)]">Unnamed</span> + {label}
)} </button>
);
// Recursive ancestor chart (grows rightward): a node is its box plus a
// two-leaf "branch" of its parents, with CSS bracket connectors. Depth 0 =
// focus, capped at grandparents (depth 2).
const renderNode = (
slotPersonId: string | null,
childId: string,
keyPrefix: string,
depth: number,
): React.ReactNode => {
const box = slotPersonId ? (
<PersonBox id={slotPersonId} muted={depth > 0} />
) : (
<AddSlot formKey={keyPrefix} kind="parent" anchor={childId} label="add parent" />
);
if (!slotPersonId || depth >= 2) {
return <div className="ped-person">{box}</div>;
}
const ps = parentsOf(slotPersonId);
return (
<div className="ped-person">
<div className="ped-self">{box}</div>
<div className="ped-branch">
<div className="ped-leaf">
{renderNode(ps[0] ?? null, slotPersonId, `${keyPrefix}-a`, depth + 1)}
</div>
<div className="ped-leaf">
{renderNode(ps[1] ?? null, slotPersonId, `${keyPrefix}-b`, depth + 1)}
</div>
</div>
</div>
);
};
const partners = partnersOf(focus.id);
const children = childrenOf(focus.id);
const sorted = [...people].sort((a, b) =>
(a.primary_name ?? "").localeCompare(b.primary_name ?? ""),
);
const matches = search
? sorted.filter((p) => (p.primary_name ?? "").toLowerCase().includes(search.toLowerCase()))
: sorted;
return (
<div className="space-y-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold">Family view</h1>
<Link
href={`/trees/${treeId}/persons/${focus.id}`}
className="text-sm text-bronze hover:underline"
>
Open {focus.primary_name ?? "person"}
</Link>
</div>
{/* Pedigree: focus → parents → grandparents, with bracket connectors */}
<Card>
<CardContent className="overflow-x-auto p-6">
<div className="min-w-[44rem]">{renderNode(focus.id, focus.id, "ped", 0)}</div>
</CardContent> </CardContent>
</Card> </Card>
</Link>
</li> {/* Family group: partners + children of the focus */}
<div className="grid gap-5 sm:grid-cols-2">
<Card>
<CardContent className="space-y-3 p-6">
<h2 className="font-serif text-base font-semibold">Spouses &amp; partners</h2>
<div className="flex flex-wrap gap-3">
{partners.map((id) => (
<PersonBox key={id} id={id} muted />
))} ))}
</ul> <AddSlot
)} formKey={`partner-${focus.id}`}
kind="partner"
anchor={focus.id}
label="add spouse"
/>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="space-y-3 p-6">
<h2 className="font-serif text-base font-semibold">Children</h2>
<div className="flex flex-wrap gap-3">
{children.map((id) => (
<PersonBox key={id} id={id} muted />
))}
<AddSlot
formKey={`child-${focus.id}`}
kind="child"
anchor={focus.id}
label="add child"
/>
</div>
</CardContent>
</Card>
</div>
{/* Searchable index of everyone in the tree */}
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h2 className="font-serif text-base font-semibold">All people ({people.length})</h2>
<Input
className="w-56"
placeholder="Search…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="flex flex-wrap gap-2">
{matches.map((p) => (
<button
key={p.id}
onClick={() => setFocusId(p.id)}
className={`rounded-full border px-3 py-1 text-sm transition-colors ${
p.id === focusId
? "border-bronze bg-bronze/[0.08] text-bronze"
: "border-[var(--border)] hover:border-bronze/60"
}`}
>
{p.primary_name ?? "Unnamed"}
</button>
))}
</div>
</div> </div>
</div> </div>
); );
@@ -15,12 +15,24 @@ type Event = components["schemas"]["EventRead"];
type Relationship = components["schemas"]["RelationshipRead"]; type Relationship = components["schemas"]["RelationshipRead"];
type Qualifier = components["schemas"]["ParentChildQualifier"]; type Qualifier = components["schemas"]["ParentChildQualifier"];
type RelCreate = components["schemas"]["RelationshipCreate"]; type RelCreate = components["schemas"]["RelationshipCreate"];
type Source = components["schemas"]["SourceRead"];
type Citation = components["schemas"]["CitationRead"];
type CitationCreate = components["schemas"]["CitationCreate"];
const fieldCls = const fieldCls = "h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm";
"h-10 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm";
const QUALIFIERS: Qualifier[] = ["biological", "adoptive", "step", "foster", "donor", "guardian"]; const QUALIFIERS: Qualifier[] = ["biological", "adoptive", "step", "foster", "donor", "guardian"];
// Curated genealogical event vocabulary (with an escape hatch).
const EVENT_TYPES = [
"birth", "death", "marriage", "divorce", "engagement", "baptism", "burial",
"residence", "census", "immigration", "emigration", "occupation", "education",
"military service", "naturalization", "other",
];
const MONTHS = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const GED_MON = ["", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
const DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" };
const pad = (n: number, len: number) => String(n).padStart(len, "0");
export default function PersonDetailPage() { export default function PersonDetailPage() {
const router = useRouter(); const router = useRouter();
const params = useParams<{ id: string; personId: string }>(); const params = useParams<{ id: string; personId: string }>();
@@ -31,15 +43,26 @@ export default function PersonDetailPage() {
const [people, setPeople] = useState<Person[]>([]); const [people, setPeople] = useState<Person[]>([]);
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<Event[]>([]);
const [rels, setRels] = useState<Relationship[]>([]); const [rels, setRels] = useState<Relationship[]>([]);
const [sources, setSources] = useState<Source[]>([]);
const [citations, setCitations] = useState<Citation[]>([]);
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [evType, setEvType] = useState("birth"); const [evType, setEvType] = useState("birth");
const [evDate, setEvDate] = useState(""); const [evTypeOther, setEvTypeOther] = useState("");
const [dateQual, setDateQual] = useState("exact");
const [dateDay, setDateDay] = useState("");
const [dateMonth, setDateMonth] = useState("");
const [dateYear, setDateYear] = useState("");
const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent"); const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent");
const [relOther, setRelOther] = useState(""); const [relOther, setRelOther] = useState("");
const [relQual, setRelQual] = useState<Qualifier>("biological"); const [relQual, setRelQual] = useState<Qualifier>("biological");
// Inline citation form: which fact is being cited ("p" = person, `e:<id>`).
const [citeFor, setCiteFor] = useState<string | null>(null);
const [citeSource, setCiteSource] = useState("");
const [citePage, setCitePage] = useState("");
const load = useCallback(async () => { const load = useCallback(async () => {
const p = await api.GET("/api/v1/trees/{tree_id}/persons/{person_id}", { const p = await api.GET("/api/v1/trees/{tree_id}/persons/{person_id}", {
params: { path: { tree_id: treeId, person_id: personId } }, params: { path: { tree_id: treeId, person_id: personId } },
@@ -49,7 +72,7 @@ export default function PersonDetailPage() {
return; return;
} }
setPerson(p.data ?? null); setPerson(p.data ?? null);
const [all, ev, rl] = await Promise.all([ const [all, ev, rl, src, cit] = await Promise.all([
api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }), api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/events", { api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/events", {
params: { path: { tree_id: treeId, person_id: personId } }, params: { path: { tree_id: treeId, person_id: personId } },
@@ -57,10 +80,14 @@ export default function PersonDetailPage() {
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/relationships", { api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/relationships", {
params: { path: { tree_id: treeId, person_id: personId } }, params: { path: { tree_id: treeId, person_id: personId } },
}), }),
api.GET("/api/v1/trees/{tree_id}/sources", { params: { path: { tree_id: treeId } } }),
api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }),
]); ]);
setPeople(all.data ?? []); setPeople(all.data ?? []);
setEvents(ev.data ?? []); setEvents(ev.data ?? []);
setRels(rl.data ?? []); setRels(rl.data ?? []);
setSources(src.data ?? []);
setCitations(cit.data ?? []);
setReady(true); setReady(true);
}, [router, treeId, personId]); }, [router, treeId, personId]);
@@ -72,26 +99,56 @@ export default function PersonDetailPage() {
const m = new Map(people.map((p) => [p.id, p.primary_name ?? "Unnamed"])); const m = new Map(people.map((p) => [p.id, p.primary_name ?? "Unnamed"]));
return (id: string) => m.get(id) ?? "Unknown"; return (id: string) => m.get(id) ?? "Unknown";
}, [people]); }, [people]);
const sourceName = useMemo(() => {
const m = new Map(sources.map((s) => [s.id, s.title]));
return (id: string) => m.get(id) ?? "source";
}, [sources]);
const others = people.filter((p) => p.id !== personId); const others = people.filter((p) => p.id !== personId);
const parents = rels.filter((r) => r.type === "parent_child" && r.person_to_id === personId); const parents = rels.filter((r) => r.type === "parent_child" && r.person_to_id === personId);
const children = rels.filter((r) => r.type === "parent_child" && r.person_from_id === personId); const children = rels.filter((r) => r.type === "parent_child" && r.person_from_id === personId);
const partners = rels.filter((r) => r.type === "partnership"); const partners = rels.filter((r) => r.type === "partnership");
const siblings = rels.filter((r) => r.type === "sibling"); const siblings = rels.filter((r) => r.type === "sibling");
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
const personCites = citations.filter((c) => c.person_id === personId);
function buildDate() {
const year = dateYear.trim();
if (!year || Number.isNaN(Number(year))) {
return { date_value: null, date_start: null, date_precision: null };
}
const m = dateMonth ? Number(dateMonth) : null;
const d = dateDay.trim() ? Number(dateDay) : null;
const parts: string[] = [];
if (d && m) parts.push(String(d));
if (m) parts.push(GED_MON[m]);
parts.push(year);
const prefix = DATE_QUALS[dateQual];
return {
date_value: (prefix ? `${prefix} ` : "") + parts.join(" "),
date_start: `${pad(Number(year), 4)}-${pad(m ?? 1, 2)}-${pad(d ?? 1, 2)}`,
date_precision: dateQual,
};
}
async function addEvent(e: React.FormEvent) { async function addEvent(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!evType.trim()) return; const event_type = evType === "other" ? evTypeOther.trim() : evType;
if (!event_type) return;
const { date_value, date_start, date_precision } = buildDate();
const { error } = await api.POST("/api/v1/trees/{tree_id}/events", { const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
params: { path: { tree_id: treeId } }, params: { path: { tree_id: treeId } },
body: { event_type: evType, person_id: personId, date_value: evDate || null }, body: { event_type, person_id: personId, date_value, date_start, date_precision },
}); });
if (!error) { if (!error) {
setEvDate(""); setDateDay("");
setDateMonth("");
setDateYear("");
setDateQual("exact");
setEvTypeOther("");
load(); load();
} }
} }
async function removeEvent(id: string) { async function removeEvent(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/events/{event_id}", { await api.DELETE("/api/v1/trees/{tree_id}/events/{event_id}", {
params: { path: { tree_id: treeId, event_id: id } }, params: { path: { tree_id: treeId, event_id: id } },
@@ -121,7 +178,6 @@ export default function PersonDetailPage() {
load(); load();
} }
} }
async function removeRel(id: string) { async function removeRel(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/relationships/{relationship_id}", { await api.DELETE("/api/v1/trees/{tree_id}/relationships/{relationship_id}", {
params: { path: { tree_id: treeId, relationship_id: id } }, params: { path: { tree_id: treeId, relationship_id: id } },
@@ -129,9 +185,107 @@ export default function PersonDetailPage() {
load(); load();
} }
async function addCitation(target: Partial<CitationCreate>) {
if (!citeSource) return;
const body: CitationCreate = { source_id: citeSource, page: citePage || null, ...target };
const { error } = await api.POST("/api/v1/trees/{tree_id}/citations", {
params: { path: { tree_id: treeId } },
body,
});
if (!error) {
setCiteFor(null);
setCiteSource("");
setCitePage("");
load();
}
}
async function removeCitation(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/citations/{citation_id}", {
params: { path: { tree_id: treeId, citation_id: id } },
});
load();
}
async function removePerson() {
await api.DELETE("/api/v1/trees/{tree_id}/persons/{person_id}", {
params: { path: { tree_id: treeId, person_id: personId } },
});
router.push(`/trees/${treeId}`);
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>; if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
if (!person) return <p className="text-[var(--muted)]">Not found.</p>; if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
// Inline "cite" control: a badge with count, a toggle, and the picker form.
function citeControl(key: string, target: Partial<CitationCreate>, cites: Citation[]) {
return (
<span className="inline-flex items-center gap-2">
{cites.length > 0 && (
<span
className="rounded bg-bronze/15 px-1.5 py-0.5 text-xs text-bronze"
title={cites.map((c) => sourceName(c.source_id)).join(", ")}
>
{cites.length} sourced
</span>
)}
{citeFor === key ? (
<form
onSubmit={(e) => {
e.preventDefault();
addCitation(target);
}}
className="inline-flex items-center gap-1"
>
<select
className={fieldCls}
value={citeSource}
onChange={(e) => setCiteSource(e.target.value)}
>
<option value=""> source </option>
{sources.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))}
</select>
<input
className={`${fieldCls} w-24`}
placeholder="page"
value={citePage}
onChange={(e) => setCitePage(e.target.value)}
/>
<Button type="submit" size="sm">
cite
</Button>
<button
type="button"
onClick={() => setCiteFor(null)}
className="text-xs text-[var(--muted)]"
>
cancel
</button>
</form>
) : sources.length === 0 ? (
<Link href={`/trees/${treeId}/sources`} className="text-xs text-[var(--muted)] hover:underline">
+ add a source first
</Link>
) : (
<button
type="button"
onClick={() => {
setCiteFor(key);
setCiteSource("");
setCitePage("");
}}
className="text-xs text-bronze hover:underline"
>
+ cite
</button>
)}
</span>
);
}
const relGroup = (label: string, items: Relationship[], otherId: (r: Relationship) => string) => const relGroup = (label: string, items: Relationship[], otherId: (r: Relationship) => string) =>
items.length > 0 && ( items.length > 0 && (
<div> <div>
@@ -162,7 +316,15 @@ export default function PersonDetailPage() {
Back to tree Back to tree
</Link> </Link>
<div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1> <h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1>
<div className="flex items-center gap-3">
{citeControl("p", { person_id: personId }, personCites)}
<Button variant="ghost" size="sm" onClick={removePerson}>
Delete
</Button>
</div>
</div>
<Card> <Card>
<CardHeader> <CardHeader>
@@ -172,15 +334,17 @@ export default function PersonDetailPage() {
{events.length === 0 ? ( {events.length === 0 ? (
<p className="text-sm text-[var(--muted)]">No events yet.</p> <p className="text-sm text-[var(--muted)]">No events yet.</p>
) : ( ) : (
<ul className="space-y-1"> <ul className="space-y-2">
{events.map((ev) => ( {events.map((ev) => (
<li key={ev.id} className="flex items-center justify-between text-sm"> <li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
<span> <span>
<span className="font-medium capitalize">{ev.event_type}</span> <span className="font-medium capitalize">{ev.event_type}</span>
{ev.date_value ? ( {ev.date_value ? (
<span className="text-[var(--muted)]"> {ev.date_value}</span> <span className="text-[var(--muted)]"> {ev.date_value}</span>
) : null} ) : null}
</span> </span>
<span className="flex items-center gap-3">
{citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))}
<button <button
onClick={() => removeEvent(ev.id)} onClick={() => removeEvent(ev.id)}
className="text-[var(--muted)] hover:text-bronze" className="text-[var(--muted)] hover:text-bronze"
@@ -188,23 +352,73 @@ export default function PersonDetailPage() {
> >
× ×
</button> </button>
</span>
</li> </li>
))} ))}
</ul> </ul>
)} )}
<form onSubmit={addEvent} className="flex flex-wrap gap-2"> <form onSubmit={addEvent} className="flex flex-wrap items-end gap-2">
<Input <label className="flex flex-col gap-1">
className="w-36" <span className="text-xs text-[var(--muted)]">Event</span>
placeholder="Event type" <select
className={`${fieldCls} capitalize`}
value={evType} value={evType}
onChange={(e) => setEvType(e.target.value)} onChange={(e) => setEvType(e.target.value)}
/> >
{EVENT_TYPES.map((t) => (
<option key={t} value={t} className="capitalize">
{t}
</option>
))}
</select>
</label>
{evType === "other" && (
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Type</span>
<Input <Input
className="w-40" className="h-9 w-36"
placeholder="Date (e.g. ABT 1850)" placeholder="Custom"
value={evDate} value={evTypeOther}
onChange={(e) => setEvDate(e.target.value)} onChange={(e) => setEvTypeOther(e.target.value)}
/> />
</label>
)}
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">When</span>
<select className={fieldCls} value={dateQual} onChange={(e) => setDateQual(e.target.value)}>
<option value="exact">on</option>
<option value="about">about</option>
<option value="before">before</option>
<option value="after">after</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Day</span>
<input
className={`${fieldCls} w-14`}
inputMode="numeric"
placeholder="—"
value={dateDay}
onChange={(e) => setDateDay(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Month</span>
<select className={fieldCls} value={dateMonth} onChange={(e) => setDateMonth(e.target.value)}>
<option value=""></option>
{MONTHS.map((m, i) => (i > 0 ? <option key={i} value={i}>{m}</option> : null))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Year</span>
<input
className={`${fieldCls} w-20`}
inputMode="numeric"
placeholder="YYYY"
value={dateYear}
onChange={(e) => setDateYear(e.target.value)}
/>
</label>
<Button type="submit">Add event</Button> <Button type="submit">Add event</Button>
</form> </form>
</CardContent> </CardContent>
@@ -235,21 +449,13 @@ export default function PersonDetailPage() {
) : ( ) : (
<form onSubmit={addRel} className="flex flex-wrap items-center gap-2"> <form onSubmit={addRel} className="flex flex-wrap items-center gap-2">
<span className="text-sm text-[var(--muted)]">Add</span> <span className="text-sm text-[var(--muted)]">Add</span>
<select <select className={fieldCls} value={relKind} onChange={(e) => setRelKind(e.target.value as typeof relKind)}>
className={fieldCls}
value={relKind}
onChange={(e) => setRelKind(e.target.value as typeof relKind)}
>
<option value="parent">parent</option> <option value="parent">parent</option>
<option value="child">child</option> <option value="child">child</option>
<option value="partner">partner</option> <option value="partner">partner</option>
<option value="sibling">sibling</option> <option value="sibling">sibling</option>
</select> </select>
<select <select className={fieldCls} value={relOther} onChange={(e) => setRelOther(e.target.value)}>
className={fieldCls}
value={relOther}
onChange={(e) => setRelOther(e.target.value)}
>
<option value=""> person </option> <option value=""> person </option>
{others.map((p) => ( {others.map((p) => (
<option key={p.id} value={p.id}> <option key={p.id} value={p.id}>
@@ -258,11 +464,7 @@ export default function PersonDetailPage() {
))} ))}
</select> </select>
{(relKind === "parent" || relKind === "child") && ( {(relKind === "parent" || relKind === "child") && (
<select <select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
className={fieldCls}
value={relQual}
onChange={(e) => setRelQual(e.target.value as Qualifier)}
>
{QUALIFIERS.map((q) => ( {QUALIFIERS.map((q) => (
<option key={q} value={q}> <option key={q} value={q}>
{q} {q}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
type Person = components["schemas"]["PersonRead"];
export default function RecoveryPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const treeId = params.id;
const [people, setPeople] = useState<Person[]>([]);
const [ready, setReady] = useState(false);
const load = useCallback(async () => {
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId }, query: { deleted: true } },
});
if (response.status === 401) {
router.push("/login");
return;
}
setPeople(data ?? []);
setReady(true);
}, [router, treeId]);
useEffect(() => {
load();
}, [load]);
async function restore(id: string) {
await api.POST("/api/v1/trees/{tree_id}/persons/{person_id}/restore", {
params: { path: { tree_id: treeId, person_id: id } },
});
load();
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">Recently deleted</h1>
<p className="text-sm text-[var(--muted)]">
Deleted people are recoverable for 30 days, then permanently purged.
</p>
{people.length === 0 ? (
<p className="text-[var(--muted)]">Nothing here.</p>
) : (
<ul className="space-y-2">
{people.map((p) => (
<li key={p.id}>
<Card>
<CardContent className="flex items-center justify-between p-4">
<span className="text-[var(--muted)]">{p.primary_name ?? "Unnamed"}</span>
<Button variant="outline" size="sm" onClick={() => restore(p.id)}>
Restore
</Button>
</CardContent>
</Card>
</li>
))}
</ul>
)}
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
type Source = components["schemas"]["SourceRead"];
export default function SourcesPage() {
const router = useRouter();
const params = useParams<{ id: string }>();
const treeId = params.id;
const [sources, setSources] = useState<Source[]>([]);
const [ready, setReady] = useState(false);
const [title, setTitle] = useState("");
const [repository, setRepository] = useState("");
const [url, setUrl] = useState("");
const load = useCallback(async () => {
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/sources", {
params: { path: { tree_id: treeId } },
});
if (response.status === 401) {
router.push("/login");
return;
}
setSources(data ?? []);
setReady(true);
}, [router, treeId]);
useEffect(() => {
load();
}, [load]);
async function add(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) return;
const { error } = await api.POST("/api/v1/trees/{tree_id}/sources", {
params: { path: { tree_id: treeId } },
body: { title, repository: repository || null, url: url || null },
});
if (!error) {
setTitle("");
setRepository("");
setUrl("");
load();
}
}
async function remove(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/sources/{source_id}", {
params: { path: { tree_id: treeId, source_id: id } },
});
load();
}
if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
return (
<div className="space-y-6">
<Link href={`/trees/${treeId}`} className="text-sm text-[var(--muted)] hover:underline">
Back to tree
</Link>
<h1 className="text-2xl font-bold">Sources</h1>
<Card>
<CardHeader>
<CardTitle className="text-base">New source</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={add} className="flex flex-wrap gap-2">
<Input
className="w-56"
placeholder="Title (e.g. 1880 US Census)"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Input
className="w-40"
placeholder="Repository"
value={repository}
onChange={(e) => setRepository(e.target.value)}
/>
<Input
className="w-48"
placeholder="URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<Button type="submit">Add source</Button>
</form>
</CardContent>
</Card>
{sources.length === 0 ? (
<p className="text-[var(--muted)]">No sources yet add one above, then cite it on facts.</p>
) : (
<ul className="space-y-2">
{sources.map((s) => (
<li key={s.id}>
<Card>
<CardContent className="flex items-start justify-between gap-3 p-4">
<div>
<div className="font-medium">{s.title}</div>
<div className="text-sm text-[var(--muted)]">
{[s.repository, s.url].filter(Boolean).join(" · ")}
</div>
</div>
<button
onClick={() => remove(s.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Remove"
>
×
</button>
</CardContent>
</Card>
</li>
))}
</ul>
)}
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import Link from "next/link";
import { AppSidebar } from "@/components/app-sidebar";
export default function TreesLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen">
<aside className="sticky top-0 hidden h-screen w-64 shrink-0 border-r border-[var(--border)] bg-[var(--surface)] md:flex md:flex-col">
<AppSidebar />
</aside>
<div className="flex min-w-0 flex-1 flex-col">
{/* Compact bar for small screens (full sidebar is md+). */}
<div className="flex items-center justify-between border-b border-[var(--border)] bg-[var(--surface)] px-4 py-3 md:hidden">
<Link href="/" aria-label="Provenance — home">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-6 w-auto" />
</Link>
<Link href="/trees" className="text-sm text-bronze">
Trees
</Link>
</div>
<div className="mx-auto w-full max-w-4xl px-6 py-10 md:px-10">{children}</div>
</div>
</div>
);
}
+51 -27
View File
@@ -7,7 +7,7 @@ import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api/client"; import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema"; import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
type Tree = components["schemas"]["TreeRead"]; type Tree = components["schemas"]["TreeRead"];
@@ -15,6 +15,7 @@ type Tree = components["schemas"]["TreeRead"];
export default function TreesPage() { export default function TreesPage() {
const router = useRouter(); const router = useRouter();
const [trees, setTrees] = useState<Tree[]>([]); const [trees, setTrees] = useState<Tree[]>([]);
const [deleted, setDeleted] = useState<Tree[]>([]);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
@@ -25,6 +26,8 @@ export default function TreesPage() {
return; return;
} }
setTrees(data ?? []); setTrees(data ?? []);
const del = await api.GET("/api/v1/trees", { params: { query: { deleted: true } } });
setDeleted(del.data ?? []);
setReady(true); setReady(true);
}, [router]); }, [router]);
@@ -42,34 +45,26 @@ export default function TreesPage() {
} }
} }
async function logout() { async function remove(id: string) {
await api.POST("/api/v1/auth/logout"); await api.DELETE("/api/v1/trees/{tree_id}", { params: { path: { tree_id: id } } });
router.push("/login"); load();
}
async function restore(id: string) {
await api.POST("/api/v1/trees/{tree_id}/restore", { params: { path: { tree_id: id } } });
load();
} }
if (!ready) return <p className="text-[var(--muted)]">Loading</p>; if (!ready) return <p className="text-[var(--muted)]">Loading</p>;
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<div className="flex items-center justify-between"> <h1 className="text-2xl font-semibold">Your trees</h1>
<h1 className="text-2xl font-bold">Your trees</h1>
<Button variant="ghost" size="sm" onClick={logout}>
Sign out
</Button>
</div>
<Card> <Card>
<CardHeader> <CardContent className="p-5">
<CardTitle className="text-base">New tree</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={createTree} className="flex gap-2"> <form onSubmit={createTree} className="flex gap-2">
<Input <Input placeholder="Family name" value={name} onChange={(e) => setName(e.target.value)} />
placeholder="Family name" <Button type="submit">Create tree</Button>
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Button type="submit">Create</Button>
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
@@ -77,23 +72,52 @@ export default function TreesPage() {
{trees.length === 0 ? ( {trees.length === 0 ? (
<p className="text-[var(--muted)]">No trees yet create your first one above.</p> <p className="text-[var(--muted)]">No trees yet create your first one above.</p>
) : ( ) : (
<ul className="space-y-2"> <ul className="grid gap-3 sm:grid-cols-2">
{trees.map((tree) => ( {trees.map((tree) => (
<li key={tree.id}> <li key={tree.id}>
<Link href={`/trees/${tree.id}`}>
<Card className="transition-colors hover:border-bronze/50"> <Card className="transition-colors hover:border-bronze/50">
<CardContent className="flex items-center justify-between p-4"> <CardContent className="flex items-center justify-between p-4">
<span className="font-medium">{tree.name}</span> <Link href={`/trees/${tree.id}`} className="min-w-0 flex-1">
<span className="text-xs uppercase tracking-wide text-bronze"> <div className="truncate font-medium">{tree.name}</div>
<div className="text-xs uppercase tracking-wide text-bronze">
{tree.visibility} {tree.visibility}
</span> </div>
</Link>
<button
onClick={() => remove(tree.id)}
className="ml-3 text-[var(--muted)] hover:text-bronze"
aria-label="Delete tree"
>
×
</button>
</CardContent> </CardContent>
</Card> </Card>
</Link>
</li> </li>
))} ))}
</ul> </ul>
)} )}
{deleted.length > 0 && (
<div className="space-y-3">
<h2 className="font-serif text-base font-semibold text-[var(--muted)]">
Recently deleted
</h2>
<ul className="space-y-2">
{deleted.map((tree) => (
<li key={tree.id}>
<Card>
<CardContent className="flex items-center justify-between p-4">
<span className="text-[var(--muted)]">{tree.name}</span>
<Button variant="outline" size="sm" onClick={() => restore(tree.id)}>
Restore
</Button>
</CardContent>
</Card>
</li>
))}
</ul>
</div>
)}
</div> </div>
); );
} }
+122
View File
@@ -0,0 +1,122 @@
"use client";
import {
Archive,
ArrowDownUp,
BookText,
FolderTree,
Image as ImageIcon,
LogOut,
Users,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { api } from "@/lib/api/client";
import { cn } from "@/lib/utils";
export function AppSidebar() {
const pathname = usePathname();
const router = useRouter();
const segs = pathname.split("/").filter(Boolean); // ["trees", "<id>", ...]
const treeId = segs[0] === "trees" && segs[1] ? segs[1] : null;
const [treeName, setTreeName] = useState<string | null>(null);
useEffect(() => {
if (!treeId) {
setTreeName(null);
return;
}
api
.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } })
.then((r) => setTreeName(r.data?.name ?? null));
}, [treeId]);
async function logout() {
await api.POST("/api/v1/auth/logout");
router.push("/login");
}
const Item = ({
href,
label,
icon: Icon,
active,
}: {
href: string;
label: string;
icon: typeof Users;
active: boolean;
}) => (
<Link
href={href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
active
? "bg-bronze/12 font-medium text-bronze"
: "text-[var(--muted)] hover:bg-bronze/[0.07] hover:text-[var(--foreground)]",
)}
>
<Icon className="h-4 w-4 shrink-0" />
{label}
</Link>
);
return (
<nav className="flex h-full flex-col gap-1 p-4">
<Link href="/" className="mb-5 flex items-center px-2" aria-label="Provenance — home">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
</Link>
<Item href="/trees" label="Trees" icon={FolderTree} active={pathname === "/trees"} />
{treeId && (
<div className="mt-5 flex flex-col gap-1">
<div className="truncate px-3 pb-1 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
{treeName ?? "Tree"}
</div>
<Item
href={`/trees/${treeId}`}
label="People"
icon={Users}
active={pathname === `/trees/${treeId}` || pathname.startsWith(`/trees/${treeId}/persons`)}
/>
<Item
href={`/trees/${treeId}/sources`}
label="Sources"
icon={BookText}
active={pathname.startsWith(`/trees/${treeId}/sources`)}
/>
<Item
href={`/trees/${treeId}/media`}
label="Media"
icon={ImageIcon}
active={pathname.startsWith(`/trees/${treeId}/media`)}
/>
<Item
href={`/trees/${treeId}/gedcom`}
label="Import / Export"
icon={ArrowDownUp}
active={pathname.startsWith(`/trees/${treeId}/gedcom`)}
/>
<Item
href={`/trees/${treeId}/recovery`}
label="Recovery"
icon={Archive}
active={pathname.startsWith(`/trees/${treeId}/recovery`)}
/>
</div>
)}
<button
onClick={logout}
className="mt-auto flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-[var(--muted)] transition-colors hover:bg-bronze/[0.07] hover:text-bronze"
>
<LogOut className="h-4 w-4 shrink-0" />
Sign out
</button>
</nav>
);
}
+6 -6
View File
@@ -4,19 +4,19 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-50", "inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background)] disabled:pointer-events-none disabled:opacity-50",
{ {
variants: { variants: {
variant: { variant: {
// Bronze is the brand accent; paper reads cleanly on it. default: "bg-bronze text-paper shadow-sm hover:bg-bronze-deep hover:shadow",
default: "bg-bronze text-paper hover:bg-bronze-deep",
outline: outline:
"border border-bronze text-bronze bg-transparent hover:bg-bronze hover:text-paper", "border border-[var(--border)] bg-[var(--surface)] hover:border-bronze hover:text-bronze",
ghost: "text-[var(--foreground)] hover:bg-bronze/10", ghost: "text-[var(--foreground)] hover:bg-bronze/10",
}, },
size: { size: {
default: "h-10 px-4 py-2", default: "h-10 px-4 text-sm",
sm: "h-9 px-3", sm: "h-9 px-3 text-sm",
lg: "h-12 px-6 text-base",
}, },
}, },
defaultVariants: { variant: "default", size: "default" }, defaultVariants: { variant: "default", size: "default" },
+1 -1
View File
@@ -6,7 +6,7 @@ export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElemen
return ( return (
<div <div
className={cn( className={cn(
"rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-sm", "rounded-xl border border-[var(--border)] bg-[var(--surface)] shadow-[0_1px_2px_rgba(26,26,23,0.04),0_8px_24px_-12px_rgba(26,26,23,0.10)]",
className, className,
)} )}
{...props} {...props}
+1 -1
View File
@@ -7,7 +7,7 @@ export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttribute
<input <input
ref={ref} ref={ref}
className={cn( className={cn(
"flex h-10 w-full rounded-md border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-sm placeholder:text-[var(--muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze disabled:opacity-50", "flex h-10 w-full rounded-lg border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-sm placeholder:text-[var(--muted)] transition-colors focus-visible:border-bronze focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze/40 disabled:opacity-50",
className, className,
)} )}
{...props} {...props}
+992 -4
View File
File diff suppressed because it is too large Load Diff
+1583 -25
View File
File diff suppressed because it is too large Load Diff