10 Commits

Author SHA1 Message Date
justin c4e9d69e00 Merge pull request 'Alternate names, self-person link, deletion integrity + dangling people' (#20) from names-deletion-self into main
build-backend / build (push) Has been cancelled
build-frontend / build (push) Has been cancelled
2026-06-07 10:41:02 -04:00
justin 0673896133 Merge pull request 'Tree search + click-rebuild; searchable relationship picker; gender dropdown' (#19) from tree-search-combobox into main
build-frontend / build (push) Has been cancelled
2026-06-07 10:40:59 -04:00
justin 04ccdbf96a Alternate names (maiden/married), self-person link, deletion integrity
Names (the genealogy standard: maiden name primary, married/alias as typed
alternates):
- Name model already supported multiple typed names; expose full CRUD —
  NameCreate/Read/Update schemas, name_service (one-primary invariant,
  promote-on-delete), nested /persons/{id}/names routes.
- Person page gains a Names card: add/edit/delete + "make primary", with a
  curated name_type dropdown (birth/maiden, married, alias, nickname, …).

Self-person ("who am I"):
- users.self_person_id FK (use_alter for the users<->persons<->trees cycle)
  + migration; PATCH /users/me/self-person; "This is me" / "This is you"
  on the person page. Soft-deleting the linked person clears it.

Deletion integrity (fixes the broken tree view):
- delete_person now soft-deletes the relationships touching the person, so no
  dangling edges remain; family-chart also filters links to missing people.
- Optional cascade=true recursively deletes descendants (GEDCOM cleanup);
  the person page asks "only this person" vs "with all descendants".
- DELETE returns {deleted: n}.

Family view surfaces "Not connected to anyone" so dangling people aren't lost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:21:12 -04:00
justin f165ccb941 Tree search + click-rebuild; searchable relationship picker; gender dropdown
- Tree page: add a "Find a person" search box that jumps the chart to a
  match and rebuilds the hourglass (parents/grandparents/partner/children)
  around them. Clicking any card recenters via family-chart's default
  behavior (setAncestryDepth 3 / setProgenyDepth 2), syncing focus through
  setAfterUpdate for the "Open profile" link.
- Person detail: replace the relationship "add" <select> with a
  type-to-filter PersonCombobox so long people lists are searchable.
- Person detail: gender is now a Male/Female dropdown, not free text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:58:45 -04:00
justin e0fb924a1d Merge pull request 'Full-CRUD sweep (API): update for tree/source/citation/relationship/media' (#18) from crud-sweep-backend into main
build-backend / build (push) Successful in 30s
2026-06-07 09:53:18 -04:00
justin cf5518c7ec Full-CRUD sweep: update endpoints for tree, source, citation, relationship, media
Closes the rule #8 gap at the API layer: PATCH endpoints + service updates for Tree (name/description/visibility), Source, Citation (page/detail/confidence), Relationship (qualifier/notes), and Media (title/attachment) — editor-gated and audited. Every core entity now has create/read/update/delete. Edit UIs for these land in the frontend batch. 37 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 09:53:17 -04:00
justin 26df03cfd7 Merge pull request 'Edit people + events; existing-person picker; full-CRUD rule' (#17) from crud-edits into main
build-backend / build (push) Successful in 27s
build-frontend / build (push) Successful in 1m25s
2026-06-07 09:35:56 -04:00
justin ab064bce6e Edit UI for people and life events; existing-person picker in family view
Person detail: an Edit form for name + gender + living status + privacy, and inline edit of each life event (type + structured date). Family view: the add-relative buttons now search existing people (link the real person) or create new — preventing duplicate spouses/parents — and adding a child to someone with one spouse links both parents.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 09:35:55 -04:00
justin 76b7f453c1 Add update (CRUD) for events and people; record the full-CRUD invariant
Events and people are now editable, not write-once: PATCH /events/{id} (type, structured date, place, notes) and PATCH /persons/{id} (vitals, privacy, and the primary name's given/surname). CLAUDE.md gains rule #8: every stored object must support full CRUD in API and UI — historical research is constant correction. Tests cover both updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-07 09:35:55 -04:00
justin 438d2db2e7 Merge pull request 'Tree layout toggles + fan + card->profile + server search' (#16) from phase2-tree-toggles into main
build-frontend / build (push) Successful in 1m26s
2026-06-07 08:01:32 -04:00
42 changed files with 4271 additions and 143 deletions
+1
View File
@@ -19,6 +19,7 @@ These are product invariants, not preferences. Do not violate them, and flag any
5. **Sources are first-class.** Don't model citations as free-text afterthoughts. A `Source` is a reusable entity; a `Citation` links it to a specific fact. 5. **Sources are first-class.** Don't model citations as free-text afterthoughts. A `Source` is a reusable entity; a `Citation` links it to a specific fact.
6. **Only legal data sources.** Ship scrapers/connectors only for permissible sources (FamilySearch API, Find A Grave, WikiTree, BLM/GLO, USGS, public-domain newspapers, public county records). Never add connectors for paywalled/terms-prohibited sites (Ancestry, MyHeritage, 23andMe). 6. **Only legal data sources.** Ship scrapers/connectors only for permissible sources (FamilySearch API, Find A Grave, WikiTree, BLM/GLO, USGS, public-domain newspapers, public county records). Never add connectors for paywalled/terms-prohibited sites (Ancestry, MyHeritage, 23andMe).
7. **Everything is configurable via environment.** Auth, mail, object storage, database, model providers, scrapers — all twelve-factor. No hard-coded endpoints or keys. 7. **Everything is configurable via environment.** Auth, mail, object storage, database, model providers, scrapers — all twelve-factor. No hard-coded endpoints or keys.
8. **Full CRUD on every object.** Every stored entity (person, name, event, relationship, source, citation, media, tree, …) must support create, read, **update**, and delete — in the API *and* the UI. Historical research is constant correction and new information, so nothing is write-once. Any new feature or data type ships with all four operations; an entity you can create but not edit is a bug.
## Tech stack ## Tech stack
+2
View File
@@ -8,6 +8,7 @@ from app.api.v1 import (
events, events,
gedcom, gedcom,
media, media,
names,
persons, persons,
relationships, relationships,
sources, sources,
@@ -20,6 +21,7 @@ api_router.include_router(auth.router)
api_router.include_router(users.router) api_router.include_router(users.router)
api_router.include_router(trees.router) api_router.include_router(trees.router)
api_router.include_router(persons.router) api_router.include_router(persons.router)
api_router.include_router(names.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(sources.router)
+20 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.source import CitationCreate, CitationRead from app.schemas.source import CitationCreate, CitationRead, CitationUpdate
from app.services import citation_service, tree_service from app.services import citation_service, tree_service
router = APIRouter(prefix="/trees", tags=["citations"]) router = APIRouter(prefix="/trees", tags=["citations"])
@@ -31,6 +31,25 @@ async def list_citations(
return [CitationRead.model_validate(c) for c in citations] return [CitationRead.model_validate(c) for c in citations]
@router.patch("/{tree_id}/citations/{citation_id}", response_model=CitationRead)
async def update_citation(
tree_id: uuid.UUID,
citation_id: uuid.UUID,
data: CitationUpdate,
session: SessionDep,
current: CurrentUser,
) -> CitationRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
citation = await citation_service.update_citation(
session,
actor=current,
tree=tree,
citation_id=citation_id,
changes=data.model_dump(exclude_unset=True),
)
return CitationRead.model_validate(citation)
@router.delete("/{tree_id}/citations/{citation_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tree_id}/citations/{citation_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_citation( async def delete_citation(
tree_id: uuid.UUID, citation_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, citation_id: uuid.UUID, session: SessionDep, current: CurrentUser
+20 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.event import EventCreate, EventRead from app.schemas.event import EventCreate, EventRead, EventUpdate
from app.services import event_service, tree_service from app.services import event_service, tree_service
router = APIRouter(prefix="/trees", tags=["events"]) router = APIRouter(prefix="/trees", tags=["events"])
@@ -40,6 +40,25 @@ async def list_person_events(
return [EventRead.model_validate(e) for e in events] return [EventRead.model_validate(e) for e in events]
@router.patch("/{tree_id}/events/{event_id}", response_model=EventRead)
async def update_event(
tree_id: uuid.UUID,
event_id: uuid.UUID,
data: EventUpdate,
session: SessionDep,
current: CurrentUser,
) -> EventRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
event = await event_service.update_event(
session,
actor=current,
tree=tree,
event_id=event_id,
changes=data.model_dump(exclude_unset=True),
)
return EventRead.model_validate(event)
@router.delete("/{tree_id}/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tree_id}/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_event( async def delete_event(
tree_id: uuid.UUID, event_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, event_id: uuid.UUID, session: SessionDep, current: CurrentUser
+21 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, File, Form, Response, UploadFile, status from fastapi import APIRouter, File, Form, Response, UploadFile, status
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
from app.schemas.media import MediaRead from app.schemas.media import MediaRead, MediaUpdate
from app.services import media_service, tree_service from app.services import media_service, tree_service
@@ -81,6 +81,26 @@ async def media_content(
) )
@router.patch("/{tree_id}/media/{media_id}", response_model=MediaRead)
async def update_media(
tree_id: uuid.UUID,
media_id: uuid.UUID,
data: MediaUpdate,
session: SessionDep,
current: CurrentUser,
store: ObjectStoreDep,
) -> MediaRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
media = await media_service.update_media(
session,
actor=current,
tree=tree,
media_id=media_id,
changes=data.model_dump(exclude_unset=True),
)
return _read(media)
@router.delete("/{tree_id}/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tree_id}/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_media( async def delete_media(
tree_id: uuid.UUID, media_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, media_id: uuid.UUID, session: SessionDep, current: CurrentUser
+90
View File
@@ -0,0 +1,90 @@
import uuid
from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep
from app.schemas.name import NameCreate, NameRead, NameUpdate
from app.services import name_service, tree_service
# Names are nested under their person (which is nested under the tree tenant).
router = APIRouter(prefix="/trees", tags=["names"])
@router.get("/{tree_id}/persons/{person_id}/names", response_model=list[NameRead])
async def list_names(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[NameRead]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
names = await name_service.list_names(
session, viewer_id=current.id, tree=tree, person_id=person_id
)
return [NameRead.model_validate(n) for n in names]
@router.post(
"/{tree_id}/persons/{person_id}/names",
response_model=NameRead,
status_code=status.HTTP_201_CREATED,
)
async def create_name(
tree_id: uuid.UUID,
person_id: uuid.UUID,
data: NameCreate,
session: SessionDep,
current: CurrentUser,
) -> NameRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
name = await name_service.create_name(
session,
actor=current,
tree=tree,
person_id=person_id,
name_type=data.name_type,
given=data.given,
surname=data.surname,
prefix=data.prefix,
suffix=data.suffix,
nickname=data.nickname,
is_primary=data.is_primary,
)
return NameRead.model_validate(name)
@router.patch(
"/{tree_id}/persons/{person_id}/names/{name_id}", response_model=NameRead
)
async def update_name(
tree_id: uuid.UUID,
person_id: uuid.UUID,
name_id: uuid.UUID,
data: NameUpdate,
session: SessionDep,
current: CurrentUser,
) -> NameRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
name = await name_service.update_name(
session,
actor=current,
tree=tree,
person_id=person_id,
name_id=name_id,
changes=data.model_dump(exclude_unset=True),
)
return NameRead.model_validate(name)
@router.delete(
"/{tree_id}/persons/{person_id}/names/{name_id}",
status_code=status.HTTP_204_NO_CONTENT,
)
async def delete_name(
tree_id: uuid.UUID,
person_id: uuid.UUID,
name_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
) -> None:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
await name_service.delete_name(
session, actor=current, tree=tree, person_id=person_id, name_id=name_id
)
+34 -6
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.person import PersonCreate, PersonRead from app.schemas.person import PersonCreate, PersonRead, PersonUpdate
from app.services import person_service, tree_service from app.services import person_service, tree_service
# Persons are nested under their tree (the tenant boundary). # Persons are nested under their tree (the tenant boundary).
@@ -56,12 +56,40 @@ async def list_persons(
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) @router.patch("/{tree_id}/persons/{person_id}", response_model=PersonRead)
async def delete_person( async def update_person(
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID,
) -> None: person_id: uuid.UUID,
data: PersonUpdate,
session: SessionDep,
current: CurrentUser,
) -> 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)
await person_service.delete_person(session, actor=current, tree=tree, person_id=person_id) person = await person_service.update_person(
session,
actor=current,
tree=tree,
person_id=person_id,
changes=data.model_dump(exclude_unset=True),
)
return PersonRead.model_validate(person)
@router.delete("/{tree_id}/persons/{person_id}")
async def delete_person(
tree_id: uuid.UUID,
person_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
cascade: bool = False,
) -> dict[str, int]:
"""Delete a person. ``cascade=true`` also deletes all descendants. Returns
the number of persons deleted (1 unless cascading)."""
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
deleted = await person_service.delete_person(
session, actor=current, tree=tree, person_id=person_id, cascade=cascade
)
return {"deleted": deleted}
@router.post("/{tree_id}/persons/{person_id}/restore", response_model=PersonRead) @router.post("/{tree_id}/persons/{person_id}/restore", response_model=PersonRead)
+20 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.relationship import RelationshipCreate, RelationshipRead from app.schemas.relationship import RelationshipCreate, RelationshipRead, RelationshipUpdate
from app.services import relationship_service, tree_service from app.services import relationship_service, tree_service
router = APIRouter(prefix="/trees", tags=["relationships"]) router = APIRouter(prefix="/trees", tags=["relationships"])
@@ -47,6 +47,25 @@ async def list_person_relationships(
return [RelationshipRead.model_validate(r) for r in rels] return [RelationshipRead.model_validate(r) for r in rels]
@router.patch("/{tree_id}/relationships/{relationship_id}", response_model=RelationshipRead)
async def update_relationship(
tree_id: uuid.UUID,
relationship_id: uuid.UUID,
data: RelationshipUpdate,
session: SessionDep,
current: CurrentUser,
) -> RelationshipRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rel = await relationship_service.update_relationship(
session,
actor=current,
tree=tree,
relationship_id=relationship_id,
changes=data.model_dump(exclude_unset=True),
)
return RelationshipRead.model_validate(rel)
@router.delete( @router.delete(
"/{tree_id}/relationships/{relationship_id}", status_code=status.HTTP_204_NO_CONTENT "/{tree_id}/relationships/{relationship_id}", status_code=status.HTTP_204_NO_CONTENT
) )
+20 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.source import SourceCreate, SourceRead from app.schemas.source import SourceCreate, SourceRead, SourceUpdate
from app.services import source_service, tree_service from app.services import source_service, tree_service
router = APIRouter(prefix="/trees", tags=["sources"]) router = APIRouter(prefix="/trees", tags=["sources"])
@@ -40,6 +40,25 @@ async def get_source(
return SourceRead.model_validate(source) return SourceRead.model_validate(source)
@router.patch("/{tree_id}/sources/{source_id}", response_model=SourceRead)
async def update_source(
tree_id: uuid.UUID,
source_id: uuid.UUID,
data: SourceUpdate,
session: SessionDep,
current: CurrentUser,
) -> SourceRead:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
source = await source_service.update_source(
session,
actor=current,
tree=tree,
source_id=source_id,
changes=data.model_dump(exclude_unset=True),
)
return SourceRead.model_validate(source)
@router.delete("/{tree_id}/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tree_id}/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_source( async def delete_source(
tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser
+11 -1
View File
@@ -3,7 +3,7 @@ import uuid
from fastapi import APIRouter, status from fastapi import APIRouter, status
from app.api.deps import CurrentUser, SessionDep from app.api.deps import CurrentUser, SessionDep
from app.schemas.tree import TreeCreate, TreeRead from app.schemas.tree import TreeCreate, TreeRead, TreeUpdate
from app.services import tree_service from app.services import tree_service
router = APIRouter(prefix="/trees", tags=["trees"]) router = APIRouter(prefix="/trees", tags=["trees"])
@@ -38,6 +38,16 @@ async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
return TreeRead.model_validate(tree) return TreeRead.model_validate(tree)
@router.patch("/{tree_id}", response_model=TreeRead)
async def update_tree(
tree_id: uuid.UUID, data: TreeUpdate, session: SessionDep, current: CurrentUser
) -> TreeRead:
tree = await tree_service.update_tree(
session, actor=current, tree_id=tree_id, changes=data.model_dump(exclude_unset=True)
)
return TreeRead.model_validate(tree)
@router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> None: 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) await tree_service.delete_tree(session, actor=current, tree_id=tree_id)
+14 -2
View File
@@ -1,7 +1,8 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.deps import CurrentUser from app.api.deps import CurrentUser, SessionDep
from app.schemas.user import UserRead from app.schemas.user import UserRead, UserSelfPersonUpdate
from app.services import user_service
router = APIRouter(prefix="/users", tags=["users"]) router = APIRouter(prefix="/users", tags=["users"])
@@ -9,3 +10,14 @@ router = APIRouter(prefix="/users", tags=["users"])
@router.get("/me", response_model=UserRead) @router.get("/me", response_model=UserRead)
async def read_me(current: CurrentUser) -> UserRead: async def read_me(current: CurrentUser) -> UserRead:
return UserRead.model_validate(current) return UserRead.model_validate(current)
@router.patch("/me/self-person", response_model=UserRead)
async def set_self_person(
data: UserSelfPersonUpdate, session: SessionDep, current: CurrentUser
) -> UserRead:
"""Link (or unlink) the Person record that represents this account."""
user = await user_service.set_self_person(
session, user=current, person_id=data.self_person_id
)
return UserRead.model_validate(user)
+14 -1
View File
@@ -3,9 +3,10 @@ multiple auth providers later (the provider-link table arrives with the auth
slice). ``hashed_password`` is nullable: external/OIDC users have none. slice). ``hashed_password`` is nullable: external/OIDC users have none.
""" """
import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, String from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base from app.models.base import Base
@@ -19,3 +20,15 @@ class User(Base, UUIDPrimaryKey, Timestamps, SoftDelete):
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
display_name: Mapped[str | None] = mapped_column(String(255)) display_name: Mapped[str | None] = mapped_column(String(255))
hashed_password: Mapped[str | None] = mapped_column(String(255)) hashed_password: Mapped[str | None] = mapped_column(String(255))
# The Person record that *is* this user ("home person"). Cleared if that
# person is deleted, so the link can never dangle.
self_person_id: Mapped[uuid.UUID | None] = mapped_column(
# use_alter + explicit name: users<->persons<->trees form an FK cycle,
# so this constraint must be created/dropped via ALTER, not inline.
ForeignKey(
"persons.id",
ondelete="SET NULL",
name="fk_users_self_person_id",
use_alter=True,
)
)
+13
View File
@@ -20,6 +20,19 @@ class EventCreate(BaseModel):
notes: str | None = None notes: str | None = None
class EventUpdate(BaseModel):
# All optional; only fields explicitly sent are changed (PATCH semantics).
event_type: str | None = None
place_id: uuid.UUID | None = None
date_value: str | None = None
date_start: date | None = None
date_end: date | None = None
date_precision: str | None = None
calendar: str | None = None
detail: str | None = None
notes: str | None = None
class EventRead(BaseModel): class EventRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+7
View File
@@ -4,6 +4,13 @@ from datetime import datetime
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
class MediaUpdate(BaseModel):
title: str | None = None
person_id: uuid.UUID | None = None
event_id: uuid.UUID | None = None
source_id: uuid.UUID | None = None
class MediaRead(BaseModel): class MediaRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+42
View File
@@ -0,0 +1,42 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class NameCreate(BaseModel):
# Open vocabulary: birth/maiden, married, alias, religious, nickname, ...
name_type: str = "birth"
given: str | None = None
surname: str | None = None
prefix: str | None = None
suffix: str | None = None
nickname: str | None = None
is_primary: bool = False
class NameUpdate(BaseModel):
name_type: str | None = None
given: str | None = None
surname: str | None = None
prefix: str | None = None
suffix: str | None = None
nickname: str | None = None
is_primary: bool | None = None
class NameRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
tree_id: uuid.UUID
person_id: uuid.UUID
name_type: str
given: str | None
surname: str | None
prefix: str | None
suffix: str | None
nickname: str | None
is_primary: bool
sort_order: int
created_at: datetime
+10
View File
@@ -15,6 +15,16 @@ class PersonCreate(BaseModel):
notes: str | None = None notes: str | None = None
class PersonUpdate(BaseModel):
# Person fields + the primary name's parts; only sent fields are changed.
given: str | None = None
surname: str | None = None
gender: str | None = None
is_living: bool | None = None
privacy: PersonPrivacy | None = None
notes: str | None = None
class PersonRead(BaseModel): class PersonRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+5
View File
@@ -15,6 +15,11 @@ class RelationshipCreate(BaseModel):
notes: str | None = None notes: str | None = None
class RelationshipUpdate(BaseModel):
qualifier: ParentChildQualifier | None = None
notes: str | None = None
class RelationshipRead(BaseModel): class RelationshipRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+17
View File
@@ -33,6 +33,23 @@ class SourceRead(BaseModel):
created_at: datetime created_at: datetime
class SourceUpdate(BaseModel):
title: str | None = None
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 CitationUpdate(BaseModel):
page: str | None = None
detail: str | None = None
confidence: CitationConfidence | None = None
class CitationCreate(BaseModel): class CitationCreate(BaseModel):
source_id: uuid.UUID source_id: uuid.UUID
# Exactly one target fact. # Exactly one target fact.
+6
View File
@@ -12,6 +12,12 @@ class TreeCreate(BaseModel):
visibility: TreeVisibility = TreeVisibility.private visibility: TreeVisibility = TreeVisibility.private
class TreeUpdate(BaseModel):
name: str | None = None
description: str | None = None
visibility: TreeVisibility | None = None
class TreeRead(BaseModel): class TreeRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
+6
View File
@@ -19,4 +19,10 @@ class UserRead(BaseModel):
email: str email: str
display_name: str | None display_name: str | None
email_verified_at: datetime | None email_verified_at: datetime | None
self_person_id: uuid.UUID | None = None
created_at: datetime created_at: datetime
class UserSelfPersonUpdate(BaseModel):
# null clears the link; otherwise the Person that represents this account.
self_person_id: uuid.UUID | None = None
+32
View File
@@ -113,6 +113,38 @@ async def list_citations(
return list((await session.execute(stmt)).scalars().all()) return list((await session.execute(stmt)).scalars().all())
async def update_citation(
session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID, changes: dict
) -> Citation:
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")
for key in {"page", "detail", "confidence"} & changes.keys():
setattr(citation, key, changes[key])
record_audit(
session,
action="update",
entity_type="Citation",
entity_id=citation.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(citation)
return citation
async def delete_citation( async def delete_citation(
session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID
) -> None: ) -> None:
+38
View File
@@ -122,6 +122,44 @@ async def list_events_for_person(
return list((await session.execute(stmt)).scalars().all()) return list((await session.execute(stmt)).scalars().all())
async def update_event(
session: AsyncSession,
*,
actor: User,
tree: Tree,
event_id: uuid.UUID,
changes: dict,
) -> Event:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
event = (
await session.execute(
select(Event).where(
Event.id == event_id, Event.tree_id == tree.id, Event.deleted_at.is_(None)
)
)
).scalar_one_or_none()
if event is None:
raise NotFound("event not found")
if "place_id" in changes and changes["place_id"] is not None:
if not await _belongs_to_tree(session, Place, changes["place_id"], tree.id):
raise NotFound("place not found in this tree")
for key, value in changes.items():
setattr(event, key, value)
record_audit(
session,
action="update",
entity_type="Event",
entity_id=event.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(event)
return event
async def delete_event( async def delete_event(
session: AsyncSession, *, actor: User, tree: Tree, event_id: uuid.UUID session: AsyncSession, *, actor: User, tree: Tree, event_id: uuid.UUID
) -> None: ) -> None:
+30
View File
@@ -97,6 +97,36 @@ async def get_media(
return media return media
async def update_media(
session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID, changes: dict
) -> Media:
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")
for key in {"title", "person_id", "event_id", "source_id"} & changes.keys():
setattr(media, key, changes[key])
record_audit(
session,
action="update",
entity_type="Media",
entity_id=media.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(media)
return media
async def delete_media( async def delete_media(
session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID
) -> None: ) -> None:
+208
View File
@@ -0,0 +1,208 @@
"""Name service. A Person carries one or more Name rows — a primary (typically
the birth/maiden name) plus typed alternates (married, alias, religious, …).
Exactly one name is primary at a time; it drives display everywhere. Writes
require editor rights; reads go through the tree's view check.
"""
import uuid
from datetime import UTC, datetime
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.person import Name, Person
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 _get_person(session: AsyncSession, *, tree: Tree, person_id: uuid.UUID) -> Person:
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")
return person
async def _clear_primary(
session: AsyncSession, *, person_id: uuid.UUID, keep: uuid.UUID | None
) -> None:
"""Demote every other name so exactly one stays primary."""
stmt = (
update(Name)
.where(Name.person_id == person_id, Name.deleted_at.is_(None), Name.is_primary.is_(True))
.values(is_primary=False)
)
if keep is not None:
stmt = stmt.where(Name.id != keep)
await session.execute(stmt)
async def list_names(
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
) -> list[Name]:
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
await _get_person(session, tree=tree, person_id=person_id)
stmt = (
select(Name)
.where(Name.person_id == person_id, Name.deleted_at.is_(None))
.order_by(Name.is_primary.desc(), Name.sort_order, Name.created_at)
)
return list((await session.execute(stmt)).scalars().all())
async def create_name(
session: AsyncSession,
*,
actor: User,
tree: Tree,
person_id: uuid.UUID,
name_type: str = "birth",
given: str | None = None,
surname: str | None = None,
prefix: str | None = None,
suffix: str | None = None,
nickname: str | None = None,
is_primary: bool = False,
) -> Name:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
await _get_person(session, tree=tree, person_id=person_id)
# First name for a person is always primary; otherwise honor the flag.
existing = (
await session.execute(
select(Name.id).where(Name.person_id == person_id, Name.deleted_at.is_(None))
)
).first()
primary = is_primary or existing is None
if primary:
await _clear_primary(session, person_id=person_id, keep=None)
name = Name(
tree_id=tree.id,
person_id=person_id,
name_type=name_type,
given=given,
surname=surname,
prefix=prefix,
suffix=suffix,
nickname=nickname,
is_primary=primary,
)
session.add(name)
await session.flush()
record_audit(
session,
action="create",
entity_type="Name",
entity_id=name.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"name_type": name_type, "given": given, "surname": surname},
)
await session.commit()
await session.refresh(name)
return name
_NAME_FIELDS = {"name_type", "given", "surname", "prefix", "suffix", "nickname"}
async def update_name(
session: AsyncSession,
*,
actor: User,
tree: Tree,
person_id: uuid.UUID,
name_id: uuid.UUID,
changes: dict,
) -> Name:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
name = (
await session.execute(
select(Name).where(
Name.id == name_id,
Name.person_id == person_id,
Name.tree_id == tree.id,
Name.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if name is None:
raise NotFound("name not found")
for key in _NAME_FIELDS & changes.keys():
setattr(name, key, changes[key])
if changes.get("is_primary") is True:
await _clear_primary(session, person_id=person_id, keep=name.id)
name.is_primary = True
record_audit(
session,
action="update",
entity_type="Name",
entity_id=name.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(name)
return name
async def delete_name(
session: AsyncSession,
*,
actor: User,
tree: Tree,
person_id: uuid.UUID,
name_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")
name = (
await session.execute(
select(Name).where(
Name.id == name_id,
Name.person_id == person_id,
Name.tree_id == tree.id,
Name.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if name is None:
raise NotFound("name not found")
name.deleted_at = datetime.now(UTC)
was_primary = name.is_primary
name.is_primary = False
record_audit(
session,
action="delete",
entity_type="Name",
entity_id=name.id,
tree_id=tree.id,
actor_user_id=actor.id,
)
# Promote another name to primary so the person never loses their display name.
if was_primary:
nxt = (
await session.execute(
select(Name)
.where(Name.person_id == person_id, Name.deleted_at.is_(None))
.order_by(Name.sort_order, Name.created_at)
)
).scalars().first()
if nxt is not None:
nxt.is_primary = True
await session.commit()
+155 -12
View File
@@ -6,11 +6,12 @@ person through the privacy engine. Each returned Person gets a transient
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import PersonPrivacy from app.models.enums import PersonPrivacy, RelationshipType
from app.models.person import Name, Person from app.models.person import Name, Person
from app.models.relationship import Relationship
from app.models.tree import Tree from app.models.tree import Tree
from app.models.user import User from app.models.user import User
from app.services import privacy from app.services import privacy
@@ -95,6 +96,59 @@ async def create_person(
return person return person
_PERSON_FIELDS = {"gender", "is_living", "privacy", "notes"}
async def update_person(
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID, changes: dict
) -> 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_(None)
)
)
).scalar_one_or_none()
if person is None:
raise NotFound("person not found")
for key in _PERSON_FIELDS & changes.keys():
setattr(person, key, changes[key])
if "given" in changes or "surname" in changes:
name = (
await session.execute(
select(Name)
.where(Name.person_id == person.id, Name.deleted_at.is_(None))
.order_by(Name.is_primary.desc(), Name.sort_order)
)
).scalars().first()
if name is None:
name = Name(tree_id=tree.id, person_id=person.id, name_type="birth", is_primary=True)
session.add(name)
if "given" in changes:
name.given = changes["given"]
if "surname" in changes:
name.surname = changes["surname"]
name.display_name = None # rebuild display from parts
record_audit(
session,
action="update",
entity_type="Person",
entity_id=person.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(person)
await _attach_primary_name(session, person)
return person
async def get_person( async def get_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
) -> Person: ) -> Person:
@@ -124,9 +178,65 @@ async def get_person(
return person return person
async def delete_person( async def _children_of(
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID session: AsyncSession, *, tree_id: uuid.UUID, parent_id: uuid.UUID
) -> list[uuid.UUID]:
rows = (
await session.execute(
select(Relationship.person_to_id).where(
Relationship.tree_id == tree_id,
Relationship.deleted_at.is_(None),
Relationship.type == RelationshipType.parent_child,
Relationship.person_from_id == parent_id,
)
)
).scalars().all()
return list(rows)
async def _soft_delete_one(
session: AsyncSession, *, actor: User, tree: Tree, person: Person, now: datetime
) -> None: ) -> None:
"""Soft-delete a single person and the relationships touching them, so no
dangling edges are left to break the tree view."""
person.deleted_at = now
rels = (
await session.execute(
select(Relationship).where(
Relationship.tree_id == tree.id,
Relationship.deleted_at.is_(None),
or_(
Relationship.person_from_id == person.id,
Relationship.person_to_id == person.id,
),
)
)
).scalars().all()
for rel in rels:
rel.deleted_at = now
record_audit(
session,
action="delete",
entity_type="Person",
entity_id=person.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"cascaded_relationships": len(rels)},
)
async def delete_person(
session: AsyncSession,
*,
actor: User,
tree: Tree,
person_id: uuid.UUID,
cascade: bool = False,
) -> int:
"""Soft-delete a person. Always removes the relationships that touch them
(preventing dangling edges). With ``cascade=True``, recursively deletes
their descendants too — handy for pruning a bad GEDCOM import. Returns the
number of persons deleted."""
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree): if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree") raise Forbidden("not an editor of this tree")
person = ( person = (
@@ -138,16 +248,49 @@ async def delete_person(
).scalar_one_or_none() ).scalar_one_or_none()
if person is None: if person is None:
raise NotFound("person not found") raise NotFound("person not found")
person.deleted_at = datetime.now(UTC)
record_audit( now = datetime.now(UTC)
session,
action="delete", # Gather the set of persons to delete. For cascade, walk descendants
entity_type="Person", # breadth-first, guarding against cycles.
entity_id=person.id, to_delete: list[Person] = [person]
tree_id=tree.id, if cascade:
actor_user_id=actor.id, seen = {person.id}
frontier = [person.id]
while frontier:
nxt: list[uuid.UUID] = []
for pid in frontier:
for child_id in await _children_of(session, tree_id=tree.id, parent_id=pid):
if child_id not in seen:
seen.add(child_id)
nxt.append(child_id)
frontier = nxt
extra_ids = [pid for pid in seen if pid != person.id]
if extra_ids:
extra = (
await session.execute(
select(Person).where(
Person.id.in_(extra_ids),
Person.tree_id == tree.id,
Person.deleted_at.is_(None),
) )
)
).scalars().all()
to_delete.extend(extra)
for p in to_delete:
await _soft_delete_one(session, actor=actor, tree=tree, person=p, now=now)
# Soft delete leaves the row in place, so the DB-level "ON DELETE SET NULL"
# never fires — clear any account's self-person link to a deleted person.
await session.execute(
update(User)
.where(User.self_person_id.in_([p.id for p in to_delete]))
.values(self_person_id=None)
)
await session.commit() await session.commit()
return len(to_delete)
async def restore_person( async def restore_person(
@@ -107,6 +107,44 @@ async def list_relationships_for_person(
return list((await session.execute(stmt)).scalars().all()) return list((await session.execute(stmt)).scalars().all())
async def update_relationship(
session: AsyncSession, *, actor: User, tree: Tree, relationship_id: uuid.UUID, changes: dict
) -> Relationship:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
relationship = (
await session.execute(
select(Relationship).where(
Relationship.id == relationship_id,
Relationship.tree_id == tree.id,
Relationship.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if relationship is None:
raise NotFound("relationship not found")
if (
"qualifier" in changes
and changes["qualifier"] is not None
and relationship.type is not RelationshipType.parent_child
):
raise Conflict("qualifier only applies to parent_child relationships")
for key in {"qualifier", "notes"} & changes.keys():
setattr(relationship, key, changes[key])
record_audit(
session,
action="update",
entity_type="Relationship",
entity_id=relationship.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(relationship)
return relationship
async def delete_relationship( async def delete_relationship(
session: AsyncSession, *, actor: User, tree: Tree, relationship_id: uuid.UUID session: AsyncSession, *, actor: User, tree: Tree, relationship_id: uuid.UUID
) -> None: ) -> None:
+36
View File
@@ -86,6 +86,42 @@ async def get_source(
return source return source
_SOURCE_FIELDS = {
"title", "author", "source_type", "repository", "url", "citation_text",
"publication_info", "quality_note",
}
async def update_source(
session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID, changes: dict
) -> Source:
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")
for key in _SOURCE_FIELDS & changes.keys():
setattr(source, key, changes[key])
record_audit(
session,
action="update",
entity_type="Source",
entity_id=source.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(source)
return source
async def delete_source( async def delete_source(
session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID
) -> None: ) -> None:
+24
View File
@@ -62,6 +62,30 @@ async def get_tree(session: AsyncSession, *, viewer_id: uuid.UUID, tree_id: uuid
return tree return tree
async def update_tree(
session: AsyncSession, *, actor: User, tree_id: uuid.UUID, changes: dict
) -> Tree:
tree = await BaseRepository(session, Tree).get(tree_id)
if tree is None:
raise NotFound("tree not found")
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
for key in {"name", "description", "visibility"} & changes.keys():
setattr(tree, key, changes[key])
record_audit(
session,
action="update",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
after=changes,
)
await session.commit()
await session.refresh(tree)
return tree
async def _owned_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> 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.""" """Load a tree (including soft-deleted) and require the actor be its owner."""
tree = await BaseRepository(session, Tree).get(tree_id, include_deleted=True) tree = await BaseRepository(session, Tree).get(tree_id, include_deleted=True)
+40 -1
View File
@@ -8,10 +8,13 @@ import uuid
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.person import Person
from app.models.tree import Tree
from app.models.user import User from app.models.user import User
from app.repositories.base import BaseRepository from app.repositories.base import BaseRepository
from app.services import privacy
from app.services.audit import record_audit from app.services.audit import record_audit
from app.services.exceptions import Conflict from app.services.exceptions import Conflict, Forbidden, NotFound
async def create_user( async def create_user(
@@ -42,3 +45,39 @@ async def create_user(
async def get_user(session: AsyncSession, user_id: uuid.UUID) -> User | None: async def get_user(session: AsyncSession, user_id: uuid.UUID) -> User | None:
return await BaseRepository(session, User).get(user_id) return await BaseRepository(session, User).get(user_id)
async def set_self_person(
session: AsyncSession, *, user: User, person_id: uuid.UUID | None
) -> User:
"""Point a user's account at the Person record that *is* them ("home
person"), or clear it with ``None``. The person must live in a tree the
user can view."""
if person_id is not None:
person = (
await session.execute(
select(Person).where(Person.id == person_id, Person.deleted_at.is_(None))
)
).scalar_one_or_none()
if person is None:
raise NotFound("person not found")
tree = (
await session.execute(select(Tree).where(Tree.id == person.tree_id))
).scalar_one_or_none()
if tree is None or not await privacy.can_view_tree(
session, user_id=user.id, tree=tree
):
raise Forbidden("not permitted to link this person")
user.self_person_id = person_id
record_audit(
session,
action="update",
entity_type="User",
entity_id=user.id,
actor_user_id=user.id,
after={"self_person_id": str(person_id) if person_id else None},
)
await session.commit()
await session.refresh(user)
return user
@@ -0,0 +1,36 @@
"""user.self_person_id ("home person" link)
Revision ID: b3d5f8a1c920
Revises: 9a2b1c7d4e10
Create Date: 2026-06-07
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "b3d5f8a1c920"
down_revision: str | None = "9a2b1c7d4e10"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("self_person_id", sa.Uuid(), nullable=True),
)
op.create_foreign_key(
"fk_users_self_person_id",
"users",
"persons",
["self_person_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
op.drop_constraint("fk_users_self_person_id", "users", type_="foreignkey")
op.drop_column("users", "self_person_id")
+19
View File
@@ -68,6 +68,25 @@ async def test_public_tree_viewable_but_not_editable_by_non_member(client):
assert resp.status_code == 403 assert resp.status_code == 403
async def test_person_update(client):
token = await register(client, "edit@example.com")
h = auth(token)
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
pid = (
await client.post(
f"/api/v1/trees/{tid}/persons", json={"given": "Jon", "surname": "Smith"}, headers=h
)
).json()["id"]
resp = await client.patch(
f"/api/v1/trees/{tid}/persons/{pid}",
json={"given": "John", "gender": "male"},
headers=auth(token),
)
assert resp.status_code == 200, resp.text
assert resp.json()["primary_name"] == "John Smith"
assert resp.json()["gender"] == "male"
async def test_auth_required_without_token(client): async def test_auth_required_without_token(client):
resp = await client.get("/api/v1/trees") resp = await client.get("/api/v1/trees")
assert resp.status_code == 401 assert resp.status_code == 401
+79
View File
@@ -0,0 +1,79 @@
"""Update (the U in CRUD) for the remaining entities — rule #8."""
from tests.conftest import auth, register
async def _setup(client, email):
h = auth(await register(client, email))
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
return h, tid
async def test_tree_update(client):
h, tid = await _setup(client, "u-tree@example.com")
r = await client.patch(
f"/api/v1/trees/{tid}", json={"name": "Renamed", "visibility": "unlisted"}, headers=h
)
assert r.status_code == 200
assert r.json()["name"] == "Renamed" and r.json()["visibility"] == "unlisted"
async def test_source_update(client):
h, tid = await _setup(client, "u-src@example.com")
sid = (
await client.post(f"/api/v1/trees/{tid}/sources", json={"title": "Old"}, headers=h)
).json()["id"]
r = await client.patch(
f"/api/v1/trees/{tid}/sources/{sid}",
json={"title": "New", "repository": "NARA"},
headers=h,
)
assert r.status_code == 200
assert r.json()["title"] == "New" and r.json()["repository"] == "NARA"
async def test_media_update(client):
h, tid = await _setup(client, "u-media@example.com")
mid = (
await client.post(
f"/api/v1/trees/{tid}/media",
files={"file": ("a.txt", b"x", "text/plain")},
data={"title": "old"},
headers=h,
)
).json()["id"]
r = await client.patch(f"/api/v1/trees/{tid}/media/{mid}", json={"title": "new"}, headers=h)
assert r.status_code == 200 and r.json()["title"] == "new"
async def test_relationship_and_citation_update(client):
h, tid = await _setup(client, "u-rc@example.com")
async def mk(path, body):
return (await client.post(f"/api/v1/trees/{tid}/{path}", json=body, headers=h)).json()["id"]
p1 = await mk("persons", {"given": "A"})
p2 = await mk("persons", {"given": "B"})
rid = await mk(
"relationships",
{
"type": "parent_child",
"person_from_id": p1,
"person_to_id": p2,
"qualifier": "biological",
},
)
r = await client.patch(
f"/api/v1/trees/{tid}/relationships/{rid}", json={"qualifier": "adoptive"}, headers=h
)
assert r.status_code == 200 and r.json()["qualifier"] == "adoptive"
src = await mk("sources", {"title": "S"})
cid = await mk("citations", {"source_id": src, "person_id": p1})
r2 = await client.patch(
f"/api/v1/trees/{tid}/citations/{cid}",
json={"page": "p.7", "confidence": "high"},
headers=h,
)
assert r2.status_code == 200
assert r2.json()["page"] == "p.7" and r2.json()["confidence"] == "high"
+83
View File
@@ -0,0 +1,83 @@
"""Deletion integrity (relationship cleanup + cascade) and the self-person link."""
from tests.conftest import auth, register
async def _setup(client, email):
h = auth(await register(client, email))
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
return h, tid
async def _person(client, h, tid, given):
return (
await client.post(f"/api/v1/trees/{tid}/persons", json={"given": given}, headers=h)
).json()["id"]
async def _link_parent(client, h, tid, parent, child):
await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "parent_child", "person_from_id": parent, "person_to_id": child},
headers=h,
)
async def test_delete_removes_relationships(client):
h, tid = await _setup(client, "d-rels@example.com")
gp = await _person(client, h, tid, "Grandpa")
dad = await _person(client, h, tid, "Dad")
await _link_parent(client, h, tid, gp, dad)
r = await client.delete(f"/api/v1/trees/{tid}/persons/{gp}", headers=h)
assert r.status_code == 200 and r.json()["deleted"] == 1
# The dangling edge is gone, so the tree view can't break on it.
rels = (
await client.get(f"/api/v1/trees/{tid}/relationships", headers=h)
).json()
assert rels == []
# Dad survives.
ppl = {p["id"] for p in (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()}
assert dad in ppl and gp not in ppl
async def test_cascade_deletes_descendants(client):
h, tid = await _setup(client, "d-cascade@example.com")
gp = await _person(client, h, tid, "Grandpa")
dad = await _person(client, h, tid, "Dad")
kid = await _person(client, h, tid, "Kid")
await _link_parent(client, h, tid, gp, dad)
await _link_parent(client, h, tid, dad, kid)
r = await client.delete(f"/api/v1/trees/{tid}/persons/{gp}?cascade=true", headers=h)
assert r.status_code == 200 and r.json()["deleted"] == 3
ppl = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
assert ppl == []
async def test_self_person_link(client):
h, tid = await _setup(client, "self@example.com")
me = await _person(client, h, tid, "Me")
r = await client.patch(
"/api/v1/users/me/self-person", json={"self_person_id": me}, headers=h
)
assert r.status_code == 200 and r.json()["self_person_id"] == me
# Reflected on /me.
assert (await client.get("/api/v1/users/me", headers=h)).json()["self_person_id"] == me
# Deleting that person clears the link (SET NULL).
await client.delete(f"/api/v1/trees/{tid}/persons/{me}", headers=h)
assert (await client.get("/api/v1/users/me", headers=h)).json()["self_person_id"] is None
async def test_self_person_clear(client):
h, tid = await _setup(client, "self-clear@example.com")
me = await _person(client, h, tid, "Me")
await client.patch("/api/v1/users/me/self-person", json={"self_person_id": me}, headers=h)
r = await client.patch(
"/api/v1/users/me/self-person", json={"self_person_id": None}, headers=h
)
assert r.status_code == 200 and r.json()["self_person_id"] is None
+19
View File
@@ -48,6 +48,25 @@ async def test_event_create_list_delete(client):
assert len(listed.json()) == 0 assert len(listed.json()) == 0
async def test_event_update(client):
h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "evupd@example.com")
eid = (
await client.post(
f"/api/v1/trees/{tree_id}/events",
json={"event_type": "birth", "person_id": parent, "date_value": "1850"},
headers=h,
)
).json()["id"]
resp = await client.patch(
f"/api/v1/trees/{tree_id}/events/{eid}",
json={"date_value": "ABT 1851", "event_type": "baptism"},
headers=h,
)
assert resp.status_code == 200, resp.text
assert resp.json()["date_value"] == "ABT 1851"
assert resp.json()["event_type"] == "baptism"
async def test_event_requires_exactly_one_subject(client): async def test_event_requires_exactly_one_subject(client):
h, tree_id, _, _ = await _setup_tree_with_two_people(client, "ev2@example.com") h, tree_id, _, _ = await _setup_tree_with_two_people(client, "ev2@example.com")
resp = await client.post( resp = await client.post(
+92
View File
@@ -0,0 +1,92 @@
"""Multiple typed names per person: maiden (primary) + married/alias alternates."""
from tests.conftest import auth, register
async def _setup(client, email):
h = auth(await register(client, email))
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
pid = (
await client.post(
f"/api/v1/trees/{tid}/persons", json={"given": "Mary", "surname": "Smith"}, headers=h
)
).json()["id"]
return h, tid, pid
async def test_create_lists_and_primary(client):
h, tid, pid = await _setup(client, "n-create@example.com")
base = f"/api/v1/trees/{tid}/persons/{pid}/names"
# The person was created with a primary birth name.
names = (await client.get(base, headers=h)).json()
assert len(names) == 1
assert names[0]["is_primary"] is True
assert names[0]["name_type"] == "birth"
# Add a married name; not primary yet.
r = await client.post(
base, json={"name_type": "married", "given": "Mary", "surname": "Jones"}, headers=h
)
assert r.status_code == 201
assert r.json()["is_primary"] is False
names = (await client.get(base, headers=h)).json()
assert len(names) == 2
# Primary first.
assert names[0]["surname"] == "Smith" and names[0]["is_primary"] is True
async def test_set_primary_demotes_others(client):
h, tid, pid = await _setup(client, "n-primary@example.com")
base = f"/api/v1/trees/{tid}/persons/{pid}/names"
married = (
await client.post(
base, json={"name_type": "married", "given": "Mary", "surname": "Jones"}, headers=h
)
).json()
r = await client.patch(f"{base}/{married['id']}", json={"is_primary": True}, headers=h)
assert r.status_code == 200 and r.json()["is_primary"] is True
names = {n["surname"]: n["is_primary"] for n in (await client.get(base, headers=h)).json()}
assert names == {"Jones": True, "Smith": False}
# The person's display name now reflects the new primary.
person = (
await client.get(f"/api/v1/trees/{tid}/persons/{pid}", headers=h)
).json()
assert person["primary_name"] == "Mary Jones"
async def test_update_fields(client):
h, tid, pid = await _setup(client, "n-update@example.com")
base = f"/api/v1/trees/{tid}/persons/{pid}/names"
nid = (
await client.post(base, json={"name_type": "alias", "given": "Polly"}, headers=h)
).json()["id"]
r = await client.patch(
f"{base}/{nid}", json={"surname": "Smith", "nickname": "Poll"}, headers=h
)
assert r.status_code == 200
assert r.json()["surname"] == "Smith" and r.json()["nickname"] == "Poll"
async def test_delete_promotes_new_primary(client):
h, tid, pid = await _setup(client, "n-delete@example.com")
base = f"/api/v1/trees/{tid}/persons/{pid}/names"
alt = (
await client.post(
base, json={"name_type": "married", "given": "Mary", "surname": "Jones"}, headers=h
)
).json()["id"]
# Delete the (primary) birth name; the married name should be promoted.
primary = next(
n for n in (await client.get(base, headers=h)).json() if n["is_primary"]
)
r = await client.delete(f"{base}/{primary['id']}", headers=h)
assert r.status_code == 204
names = (await client.get(base, headers=h)).json()
assert len(names) == 1 and names[0]["id"] == alt and names[0]["is_primary"] is True
+1 -1
View File
@@ -41,7 +41,7 @@ async def test_person_delete_and_restore(client):
assert ( assert (
await client.delete(f"/api/v1/trees/{tree_id}/persons/{person_id}", headers=h) await client.delete(f"/api/v1/trees/{tree_id}/persons/{person_id}", headers=h)
).status_code == 204 ).status_code == 200
assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 0 assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 0
deleted = ( deleted = (
await client.get(f"/api/v1/trees/{tree_id}/persons?deleted=true", headers=h) await client.get(f"/api/v1/trees/{tree_id}/persons?deleted=true", headers=h)
+104 -21
View File
@@ -122,23 +122,42 @@ export default function FamilyViewPage() {
load(); load();
} }
async function submitAdd(e: React.FormEvent) { async function postRel(body: components["schemas"]["RelationshipCreate"]) {
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", { await api.POST("/api/v1/trees/{tree_id}/relationships", {
params: { path: { tree_id: treeId } }, params: { path: { tree_id: treeId } },
body, body,
}); });
} }
// Create the relationship(s) connecting an (existing or new) person to anchor.
async function createLink(kind: AddKind, anchor: string, personId: string) {
if (kind === "parent") {
await postRel({ type: "parent_child", person_from_id: personId, person_to_id: anchor, qualifier: "biological" });
} else if (kind === "partner") {
await postRel({ type: "partnership", person_from_id: anchor, person_to_id: personId });
} else {
// child: link to anchor, and to anchor's spouse too (so both parents show)
await postRel({ type: "parent_child", person_from_id: anchor, person_to_id: personId, qualifier: "biological" });
const partners = partnersOf(anchor);
if (partners.length === 1) {
await postRel({ type: "parent_child", person_from_id: partners[0], person_to_id: personId, qualifier: "biological" });
}
}
}
async function linkExisting(personId: string) {
if (!adding) return;
await createLink(adding.kind, adding.anchor, personId);
setAdding(null);
setAddName("");
load();
}
async function submitAdd(e: React.FormEvent) {
e.preventDefault();
if (!adding || !addName.trim()) return;
const newId = await addPerson(addName);
if (newId) await createLink(adding.kind, adding.anchor, newId);
setAdding(null); setAdding(null);
setAddName(""); setAddName("");
load(); load();
@@ -210,26 +229,45 @@ export default function FamilyViewPage() {
label: string; label: string;
}) => }) =>
adding?.key === formKey ? ( adding?.key === formKey ? (
<form onSubmit={submitAdd} className="flex w-44 flex-col gap-1"> <form onSubmit={submitAdd} className="flex w-56 flex-col gap-1">
<Input <Input
autoFocus autoFocus
className="h-9" className="h-9"
placeholder="Full name" placeholder="Search existing or type a new name"
value={addName} value={addName}
onChange={(e) => setAddName(e.target.value)} onChange={(e) => setAddName(e.target.value)}
/> />
<div className="flex gap-1"> {addName.trim() && (
<Button type="submit" size="sm"> <div className="overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface)] text-sm">
Add {people
</Button> .filter(
(p) =>
p.id !== anchor &&
(p.primary_name ?? "").toLowerCase().includes(addName.trim().toLowerCase()),
)
.slice(0, 6)
.map((p) => (
<button <button
key={p.id}
type="button" type="button"
onClick={() => setAdding(null)} onClick={() => linkExisting(p.id)}
className="text-xs text-[var(--muted)]" className="flex w-full items-center justify-between gap-2 px-2 py-1.5 text-left hover:bg-bronze/[0.07]"
> >
cancel <span className="truncate">{p.primary_name ?? "Unnamed"}</span>
<span className="shrink-0 text-xs text-[var(--muted)]">{years.get(p.id) ?? ""}</span>
</button>
))}
<button
type="submit"
className="flex w-full items-center gap-1 border-t border-[var(--border)] px-2 py-1.5 text-left text-bronze hover:bg-bronze/[0.07]"
>
+ Create new {addName.trim()}
</button> </button>
</div> </div>
)}
<button type="button" onClick={() => setAdding(null)} className="text-xs text-[var(--muted)]">
cancel
</button>
</form> </form>
) : ( ) : (
<button <button
@@ -279,6 +317,17 @@ export default function FamilyViewPage() {
const partners = partnersOf(focus.id); const partners = partnersOf(focus.id);
const children = childrenOf(focus.id); const children = childrenOf(focus.id);
// "Dangling" people: not linked to anyone. Common after a GEDCOM import or a
// mistaken delete — surface them so they're not lost in the directory.
const connected = new Set<string>();
for (const r of rels) {
connected.add(r.person_from_id);
connected.add(r.person_to_id);
}
const unconnected = people
.filter((p) => !connected.has(p.id))
.sort((a, b) => (a.primary_name ?? "").localeCompare(b.primary_name ?? ""));
const sorted = [...people].sort((a, b) => const sorted = [...people].sort((a, b) =>
(a.primary_name ?? "").localeCompare(b.primary_name ?? ""), (a.primary_name ?? "").localeCompare(b.primary_name ?? ""),
); );
@@ -342,6 +391,40 @@ export default function FamilyViewPage() {
</Card> </Card>
</div> </div>
{/* Unconnected people — not linked to anyone in the tree */}
{unconnected.length > 0 && (
<Card className="border-bronze/40">
<CardContent className="space-y-3 p-6">
<div className="flex items-center justify-between">
<h2 className="font-serif text-base font-semibold">
Not connected to anyone ({unconnected.length})
</h2>
<span className="text-xs text-[var(--muted)]">
Open one and add a relationship, or delete it.
</span>
</div>
<div className="flex flex-wrap gap-3">
{unconnected.slice(0, 60).map((p) => (
<div key={p.id} className="flex items-center gap-1">
<PersonBox id={p.id} muted />
<Link
href={`/trees/${treeId}/persons/${p.id}`}
className="text-xs text-bronze hover:underline"
>
open
</Link>
</div>
))}
</div>
{unconnected.length > 60 && (
<p className="text-xs text-[var(--muted)]">
Showing 60 of {unconnected.length}.
</p>
)}
</CardContent>
</Card>
)}
{/* Scrollable, searchable people directory (scales to large trees) */} {/* Scrollable, searchable people directory (scales to large trees) */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
@@ -9,8 +9,11 @@ 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, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { PersonCombobox } from "@/components/person-combobox";
type Person = components["schemas"]["PersonRead"]; type Person = components["schemas"]["PersonRead"];
type Name = components["schemas"]["NameRead"];
type Me = components["schemas"]["UserRead"];
type Event = components["schemas"]["EventRead"]; 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"];
@@ -22,6 +25,21 @@ type CitationCreate = components["schemas"]["CitationCreate"];
const fieldCls = "h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm"; const fieldCls = "h-9 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"];
// Typed name vocabulary. "birth" is the maiden/birth name; "married" etc. are
// alternates. The maiden name stays primary by convention (Ancestry/FamilySearch).
const NAME_TYPES: { value: string; label: string }[] = [
{ value: "birth", label: "Birth / maiden" },
{ value: "married", label: "Married" },
{ value: "alias", label: "Also known as" },
{ value: "nickname", label: "Nickname" },
{ value: "religious", label: "Religious" },
{ value: "immigration", label: "Anglicized" },
];
const nameTypeLabel = (t: string) =>
NAME_TYPES.find((n) => n.value === t)?.label ?? t;
const formatName = (n: Name) =>
[n.given, n.surname].filter(Boolean).join(" ") || "—";
// Curated genealogical event vocabulary (with an escape hatch). // Curated genealogical event vocabulary (with an escape hatch).
const EVENT_TYPES = [ const EVENT_TYPES = [
"birth", "death", "marriage", "divorce", "engagement", "baptism", "burial", "birth", "death", "marriage", "divorce", "engagement", "baptism", "burial",
@@ -33,6 +51,53 @@ const GED_MON = ["", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SE
const DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" }; const DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" };
const pad = (n: number, len: number) => String(n).padStart(len, "0"); const pad = (n: number, len: number) => String(n).padStart(len, "0");
function composeDate(qual: string, day: string, month: string, year: string) {
const y = year.trim();
if (!y || Number.isNaN(Number(y))) {
return { date_value: null as string | null, date_start: null as string | null, date_precision: null as string | null };
}
const m = month ? Number(month) : null;
const d = day.trim() ? Number(day) : null;
const parts: string[] = [];
if (d && m) parts.push(String(d));
if (m) parts.push(GED_MON[m]);
parts.push(y);
const prefix = DATE_QUALS[qual];
return {
date_value: (prefix ? `${prefix} ` : "") + parts.join(" "),
date_start: `${pad(Number(y), 4)}-${pad(m ?? 1, 2)}-${pad(d ?? 1, 2)}`,
date_precision: qual,
};
}
// Parse a stored date_value (e.g. "ABT 12 MAR 1900") back into form fields.
function parseDateValue(v: string | null | undefined) {
let qual = "exact";
let day = "";
let month = "";
let year = "";
if (v) {
let s = v.trim();
const up = s.toUpperCase();
for (const [q, pre] of Object.entries(DATE_QUALS)) {
if (pre && up.startsWith(`${pre} `)) {
qual = q;
s = s.slice(pre.length + 1).trim();
break;
}
}
for (const t of s.toUpperCase().split(/\s+/).filter(Boolean)) {
if (/^\d{3,4}$/.test(t) && !year) year = t;
else if (/^\d{1,2}$/.test(t)) day = String(Number(t));
else {
const mi = GED_MON.indexOf(t);
if (mi > 0) month = String(mi);
}
}
}
return { qual, day, month, year };
}
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 }>();
@@ -41,6 +106,8 @@ export default function PersonDetailPage() {
const [person, setPerson] = useState<Person | null>(null); const [person, setPerson] = useState<Person | null>(null);
const [people, setPeople] = useState<Person[]>([]); const [people, setPeople] = useState<Person[]>([]);
const [names, setNames] = useState<Name[]>([]);
const [me, setMe] = useState<Me | null>(null);
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 [sources, setSources] = useState<Source[]>([]);
@@ -54,10 +121,39 @@ export default function PersonDetailPage() {
const [dateMonth, setDateMonth] = useState(""); const [dateMonth, setDateMonth] = useState("");
const [dateYear, setDateYear] = useState(""); const [dateYear, setDateYear] = useState("");
// Inline edit-event form.
const [editId, setEditId] = useState<string | null>(null);
const [edType, setEdType] = useState("birth");
const [edTypeOther, setEdTypeOther] = useState("");
const [edQual, setEdQual] = useState("exact");
const [edDay, setEdDay] = useState("");
const [edMonth, setEdMonth] = useState("");
const [edYear, setEdYear] = useState("");
// Inline edit-person form (name + vitals).
const [editingPerson, setEditingPerson] = useState(false);
const [pGiven, setPGiven] = useState("");
const [pSurname, setPSurname] = useState("");
const [pGender, setPGender] = useState("");
const [pLiving, setPLiving] = useState("unknown");
const [pPrivacy, setPPrivacy] = useState<"inherit" | "private" | "public">("inherit");
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");
// Add-name form + inline edit.
const [nameType, setNameType] = useState("married");
const [nGiven, setNGiven] = useState("");
const [nSurname, setNSurname] = useState("");
const [editNameId, setEditNameId] = useState<string | null>(null);
const [enType, setEnType] = useState("married");
const [enGiven, setEnGiven] = useState("");
const [enSurname, setEnSurname] = useState("");
// Delete confirmation (with optional cascade to descendants).
const [confirmingDelete, setConfirmingDelete] = useState(false);
// Inline citation form: which fact is being cited ("p" = person, `e:<id>`). // Inline citation form: which fact is being cited ("p" = person, `e:<id>`).
const [citeFor, setCiteFor] = useState<string | null>(null); const [citeFor, setCiteFor] = useState<string | null>(null);
const [citeSource, setCiteSource] = useState(""); const [citeSource, setCiteSource] = useState("");
@@ -72,8 +168,12 @@ export default function PersonDetailPage() {
return; return;
} }
setPerson(p.data ?? null); setPerson(p.data ?? null);
const [all, ev, rl, src, cit] = await Promise.all([ const [all, nm, mine, 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}/names", {
params: { path: { tree_id: treeId, person_id: personId } },
}),
api.GET("/api/v1/users/me"),
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 } },
}), }),
@@ -84,6 +184,8 @@ export default function PersonDetailPage() {
api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }), api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }),
]); ]);
setPeople(all.data ?? []); setPeople(all.data ?? []);
setNames(nm.data ?? []);
setMe(mine.data ?? null);
setEvents(ev.data ?? []); setEvents(ev.data ?? []);
setRels(rl.data ?? []); setRels(rl.data ?? []);
setSources(src.data ?? []); setSources(src.data ?? []);
@@ -112,30 +214,16 @@ export default function PersonDetailPage() {
const eventCites = (id: string) => citations.filter((c) => c.event_id === id); const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
const personCites = citations.filter((c) => c.person_id === personId); 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();
const event_type = evType === "other" ? evTypeOther.trim() : evType; const event_type = evType === "other" ? evTypeOther.trim() : evType;
if (!event_type) return; if (!event_type) return;
const { date_value, date_start, date_precision } = buildDate(); const { date_value, date_start, date_precision } = composeDate(
dateQual,
dateDay,
dateMonth,
dateYear,
);
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, person_id: personId, date_value, date_start, date_precision }, body: { event_type, person_id: personId, date_value, date_start, date_precision },
@@ -156,6 +244,33 @@ export default function PersonDetailPage() {
load(); load();
} }
function startEdit(ev: Event) {
setEditId(ev.id);
const known = EVENT_TYPES.includes(ev.event_type);
setEdType(known ? ev.event_type : "other");
setEdTypeOther(known ? "" : ev.event_type);
const parsed = parseDateValue(ev.date_value);
setEdQual(parsed.qual);
setEdDay(parsed.day);
setEdMonth(parsed.month);
setEdYear(parsed.year);
}
async function saveEdit() {
if (!editId) return;
const event_type = edType === "other" ? edTypeOther.trim() : edType;
if (!event_type) return;
const { date_value, date_start, date_precision } = composeDate(edQual, edDay, edMonth, edYear);
const { error } = await api.PATCH("/api/v1/trees/{tree_id}/events/{event_id}", {
params: { path: { tree_id: treeId, event_id: editId } },
body: { event_type, date_value, date_start, date_precision },
});
if (!error) {
setEditId(null);
load();
}
}
async function addRel(e: React.FormEvent) { async function addRel(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!relOther) return; if (!relOther) return;
@@ -206,16 +321,104 @@ export default function PersonDetailPage() {
load(); load();
} }
async function removePerson() { async function removePerson(cascade: boolean) {
await api.DELETE("/api/v1/trees/{tree_id}/persons/{person_id}", { await api.DELETE("/api/v1/trees/{tree_id}/persons/{person_id}", {
params: { path: { tree_id: treeId, person_id: personId } }, params: { path: { tree_id: treeId, person_id: personId }, query: { cascade } },
}); });
router.push(`/trees/${treeId}`); router.push(`/trees/${treeId}`);
} }
async function addName(e: React.FormEvent) {
e.preventDefault();
if (!nGiven.trim() && !nSurname.trim()) return;
const { error } = await api.POST("/api/v1/trees/{tree_id}/persons/{person_id}/names", {
params: { path: { tree_id: treeId, person_id: personId } },
body: { name_type: nameType, given: nGiven || null, surname: nSurname || null },
});
if (!error) {
setNGiven("");
setNSurname("");
setNameType("married");
load();
}
}
function startEditName(n: Name) {
setEditNameId(n.id);
setEnType(n.name_type);
setEnGiven(n.given ?? "");
setEnSurname(n.surname ?? "");
}
async function saveName() {
if (!editNameId) return;
const { error } = await api.PATCH(
"/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}",
{
params: { path: { tree_id: treeId, person_id: personId, name_id: editNameId } },
body: { name_type: enType, given: enGiven || null, surname: enSurname || null },
},
);
if (!error) {
setEditNameId(null);
load();
}
}
async function makePrimaryName(id: string) {
await api.PATCH("/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}", {
params: { path: { tree_id: treeId, person_id: personId, name_id: id } },
body: { is_primary: true },
});
load();
}
async function removeName(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}", {
params: { path: { tree_id: treeId, person_id: personId, name_id: id } },
});
load();
}
async function setSelf(link: boolean) {
await api.PATCH("/api/v1/users/me/self-person", {
body: { self_person_id: link ? personId : null },
});
load();
}
function startEditPerson(current: Person) {
const t = (current.primary_name ?? "").trim().split(/\s+/).filter(Boolean);
setPGiven(t.length > 1 ? t.slice(0, -1).join(" ") : (t[0] ?? ""));
setPSurname(t.length > 1 ? t[t.length - 1] : "");
setPGender(current.gender ?? "");
setPLiving(current.is_living === true ? "living" : current.is_living === false ? "deceased" : "unknown");
setPPrivacy((current.privacy as "inherit" | "private" | "public") ?? "inherit");
setEditingPerson(true);
}
async function savePerson() {
const { error } = await api.PATCH("/api/v1/trees/{tree_id}/persons/{person_id}", {
params: { path: { tree_id: treeId, person_id: personId } },
body: {
given: pGiven || null,
surname: pSurname || null,
gender: pGender || null,
is_living: pLiving === "living" ? true : pLiving === "deceased" ? false : null,
privacy: pPrivacy,
},
});
if (!error) {
setEditingPerson(false);
load();
}
}
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>;
const isSelf = me?.self_person_id === personId;
// Inline "cite" control: a badge with count, a toggle, and the picker form. // Inline "cite" control: a badge with count, a toggle, and the picker form.
function citeControl(key: string, target: Partial<CitationCreate>, cites: Citation[]) { function citeControl(key: string, target: Partial<CitationCreate>, cites: Citation[]) {
return ( return (
@@ -316,15 +519,236 @@ export default function PersonDetailPage() {
Back to tree Back to tree
</Link> </Link>
{editingPerson ? (
<form
onSubmit={(e) => {
e.preventDefault();
savePerson();
}}
className="space-y-3 rounded-lg border border-[var(--border)] p-4"
>
<div className="flex flex-wrap gap-2">
<Input className="w-40" placeholder="Given name" value={pGiven} onChange={(e) => setPGiven(e.target.value)} />
<Input className="w-40" placeholder="Surname" value={pSurname} onChange={(e) => setPSurname(e.target.value)} />
<select className={fieldCls} value={pGender} onChange={(e) => setPGender(e.target.value)}>
<option value="">Gender: </option>
<option value="male">Male</option>
<option value="female">Female</option>
</select>
<select className={fieldCls} value={pLiving} onChange={(e) => setPLiving(e.target.value)}>
<option value="unknown">Status: unknown</option>
<option value="living">Living</option>
<option value="deceased">Deceased</option>
</select>
<select
className={fieldCls}
value={pPrivacy}
onChange={(e) => setPPrivacy(e.target.value as "inherit" | "private" | "public")}
>
<option value="inherit">Privacy: default</option>
<option value="private">Private</option>
<option value="public">Public</option>
</select>
</div>
<div className="flex gap-2">
<Button type="submit" size="sm">
Save
</Button>
<button type="button" onClick={() => setEditingPerson(false)} className="text-xs text-[var(--muted)]">
cancel
</button>
</div>
</form>
) : (
<div className="flex flex-wrap items-center justify-between gap-2"> <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="flex items-center gap-3 text-3xl font-semibold">
{person.primary_name ?? "Unnamed person"}
{isSelf && (
<span className="rounded-full bg-bronze/15 px-2.5 py-1 text-xs font-medium text-bronze">
This is you
</span>
)}
</h1>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{citeControl("p", { person_id: personId }, personCites)} {citeControl("p", { person_id: personId }, personCites)}
<Button variant="ghost" size="sm" onClick={removePerson}> {isSelf ? (
<Button variant="ghost" size="sm" onClick={() => setSelf(false)}>
Unlink me
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => setSelf(true)}>
This is me
</Button>
)}
<Button variant="outline" size="sm" onClick={() => startEditPerson(person)}>
Edit
</Button>
<Button variant="ghost" size="sm" onClick={() => setConfirmingDelete(true)}>
Delete Delete
</Button> </Button>
</div> </div>
</div> </div>
)}
{confirmingDelete && (
<div className="space-y-3 rounded-lg border border-bronze/40 bg-bronze/[0.05] p-4">
<p className="text-sm">
Delete <strong>{person.primary_name ?? "this person"}</strong>? Their relationships
will be removed too. This can be undone from Recovery.
</p>
<div className="flex flex-wrap gap-2">
<Button variant="ghost" size="sm" onClick={() => removePerson(false)}>
Delete only this person
</Button>
<Button variant="outline" size="sm" onClick={() => removePerson(true)}>
Delete with all descendants
</Button>
<button
type="button"
onClick={() => setConfirmingDelete(false)}
className="text-xs text-[var(--muted)]"
>
cancel
</button>
</div>
</div>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Names</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{names.length === 0 ? (
<p className="text-sm text-[var(--muted)]">No names yet.</p>
) : (
<ul className="space-y-2">
{names.map((n) =>
editNameId === n.id ? (
<li key={n.id}>
<form
onSubmit={(e) => {
e.preventDefault();
saveName();
}}
className="flex flex-wrap items-center gap-2"
>
<select
className={fieldCls}
value={enType}
onChange={(e) => setEnType(e.target.value)}
>
{NAME_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
<Input
className="h-9 w-36"
placeholder="Given"
value={enGiven}
onChange={(e) => setEnGiven(e.target.value)}
/>
<Input
className="h-9 w-36"
placeholder="Surname"
value={enSurname}
onChange={(e) => setEnSurname(e.target.value)}
/>
<Button type="submit" size="sm">
Save
</Button>
<button
type="button"
onClick={() => setEditNameId(null)}
className="text-xs text-[var(--muted)]"
>
cancel
</button>
</form>
</li>
) : (
<li
key={n.id}
className="flex flex-wrap items-center justify-between gap-2 text-sm"
>
<span className="flex items-center gap-2">
<span className="font-medium">{formatName(n)}</span>
<span className="rounded bg-[var(--border)]/50 px-1.5 py-0.5 text-xs text-[var(--muted)]">
{nameTypeLabel(n.name_type)}
</span>
{n.is_primary && (
<span className="rounded bg-bronze/15 px-1.5 py-0.5 text-xs text-bronze">
primary
</span>
)}
</span>
<span className="flex items-center gap-3">
{!n.is_primary && (
<button
onClick={() => makePrimaryName(n.id)}
className="text-xs text-bronze hover:underline"
>
make primary
</button>
)}
<button
onClick={() => startEditName(n)}
className="text-xs text-bronze hover:underline"
>
edit
</button>
<button
onClick={() => removeName(n.id)}
className="text-[var(--muted)] hover:text-bronze"
aria-label="Remove"
>
×
</button>
</span>
</li>
),
)}
</ul>
)}
<form onSubmit={addName} className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Type</span>
<select
className={fieldCls}
value={nameType}
onChange={(e) => setNameType(e.target.value)}
>
{NAME_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Given</span>
<Input
className="h-9 w-36"
placeholder="Given"
value={nGiven}
onChange={(e) => setNGiven(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-[var(--muted)]">Surname</span>
<Input
className="h-9 w-36"
placeholder="Surname"
value={nSurname}
onChange={(e) => setNSurname(e.target.value)}
/>
</label>
<Button type="submit">Add name</Button>
</form>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
@@ -335,7 +759,72 @@ export default function PersonDetailPage() {
<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-2"> <ul className="space-y-2">
{events.map((ev) => ( {events.map((ev) =>
editId === ev.id ? (
<li key={ev.id}>
<form
onSubmit={(e) => {
e.preventDefault();
saveEdit();
}}
className="flex flex-wrap items-end gap-2"
>
<select
className={`${fieldCls} capitalize`}
value={edType}
onChange={(e) => setEdType(e.target.value)}
>
{EVENT_TYPES.map((t) => (
<option key={t} value={t} className="capitalize">
{t}
</option>
))}
</select>
{edType === "other" && (
<Input
className="h-9 w-32"
placeholder="Custom"
value={edTypeOther}
onChange={(e) => setEdTypeOther(e.target.value)}
/>
)}
<select className={fieldCls} value={edQual} onChange={(e) => setEdQual(e.target.value)}>
<option value="exact">on</option>
<option value="about">about</option>
<option value="before">before</option>
<option value="after">after</option>
</select>
<input
className={`${fieldCls} w-14`}
inputMode="numeric"
placeholder="Day"
value={edDay}
onChange={(e) => setEdDay(e.target.value)}
/>
<select className={fieldCls} value={edMonth} onChange={(e) => setEdMonth(e.target.value)}>
<option value=""></option>
{MONTHS.map((m, i) => (i > 0 ? <option key={i} value={i}>{m}</option> : null))}
</select>
<input
className={`${fieldCls} w-20`}
inputMode="numeric"
placeholder="Year"
value={edYear}
onChange={(e) => setEdYear(e.target.value)}
/>
<Button type="submit" size="sm">
Save
</Button>
<button
type="button"
onClick={() => setEditId(null)}
className="text-xs text-[var(--muted)]"
>
cancel
</button>
</form>
</li>
) : (
<li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 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>
@@ -345,6 +834,12 @@ export default function PersonDetailPage() {
</span> </span>
<span className="flex items-center gap-3"> <span className="flex items-center gap-3">
{citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))} {citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))}
<button
onClick={() => startEdit(ev)}
className="text-xs text-bronze hover:underline"
>
edit
</button>
<button <button
onClick={() => removeEvent(ev.id)} onClick={() => removeEvent(ev.id)}
className="text-[var(--muted)] hover:text-bronze" className="text-[var(--muted)] hover:text-bronze"
@@ -354,7 +849,8 @@ export default function PersonDetailPage() {
</button> </button>
</span> </span>
</li> </li>
))} ),
)}
</ul> </ul>
)} )}
<form onSubmit={addEvent} className="flex flex-wrap items-end gap-2"> <form onSubmit={addEvent} className="flex flex-wrap items-end gap-2">
@@ -455,14 +951,12 @@ export default function PersonDetailPage() {
<option value="partner">partner</option> <option value="partner">partner</option>
<option value="sibling">sibling</option> <option value="sibling">sibling</option>
</select> </select>
<select className={fieldCls} value={relOther} onChange={(e) => setRelOther(e.target.value)}> <PersonCombobox
<option value=""> person </option> people={others}
{others.map((p) => ( value={relOther}
<option key={p.id} value={p.id}> onChange={setRelOther}
{p.primary_name ?? "Unnamed"} placeholder="Search for a person…"
</option> />
))}
</select>
{(relKind === "parent" || relKind === "child") && ( {(relKind === "parent" || relKind === "child") && (
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}> <select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
{QUALIFIERS.map((q) => ( {QUALIFIERS.map((q) => (
+74 -12
View File
@@ -10,6 +10,7 @@ import { useCallback, useEffect, useMemo, useRef, 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 { Input } from "@/components/ui/input";
import { FanChart } from "@/components/fan-chart"; import { FanChart } from "@/components/fan-chart";
type Person = components["schemas"]["PersonRead"]; type Person = components["schemas"]["PersonRead"];
@@ -28,6 +29,9 @@ export default function TreePage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const treeId = params.id; const treeId = params.id;
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const chartRef = useRef<any>(null);
const [query, setQuery] = useState("");
const [people, setPeople] = useState<Person[]>([]); const [people, setPeople] = useState<Person[]>([]);
const [rels, setRels] = useState<Relationship[]>([]); const [rels, setRels] = useState<Relationship[]>([]);
@@ -100,6 +104,11 @@ export default function TreePage() {
if (status !== "ready" || mode === "fan" || !containerRef.current) return; if (status !== "ready" || mode === "fan" || !containerRef.current) return;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
// Only link to people that still exist — a soft-deleted person leaves
// dangling relationship rows, and family-chart breaks on an id with no
// matching datum. Filter them out so a deletion never blanks the tree.
const alive = new Set(people.map((pp) => pp.id));
const keep = (ids: string[]) => ids.filter((id) => alive.has(id));
const data = people.map((pp) => { const data = people.map((pp) => {
const [fn, ln] = splitName(pp.primary_name); const [fn, ln] = splitName(pp.primary_name);
return { return {
@@ -110,26 +119,31 @@ export default function TreePage() {
birthday: years.get(pp.id) ?? "", birthday: years.get(pp.id) ?? "",
gender: pp.gender === "female" ? "F" : "M", gender: pp.gender === "female" ? "F" : "M",
}, },
rels: { spouses: partnersOf(pp.id), parents: parentsOf(pp.id), children: childrenOf(pp.id) }, rels: {
spouses: keep(partnersOf(pp.id)),
parents: keep(parentsOf(pp.id)),
children: keep(childrenOf(pp.id)),
},
}; };
}); });
const f3 = await import("family-chart"); const f3 = await import("family-chart");
if (cancelled || !containerRef.current) return; if (cancelled || !containerRef.current) return;
containerRef.current.innerHTML = ""; containerRef.current.innerHTML = "";
const chart = f3.createChart(containerRef.current, data); const chart = f3.createChart(containerRef.current, data);
chart chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]);
.setCardHtml()
.setCardDisplay([["first name", "last name"], ["birthday"]])
.setOnCardClick((_e: unknown, d: { data?: { id?: string } }) => {
const id = d?.data?.id;
if (id) {
setFocusId(id);
chart.updateMainId(id);
chart.updateTree();
}
});
if (mode === "portrait") chart.setOrientationVertical(); if (mode === "portrait") chart.setOrientationVertical();
else chart.setOrientationHorizontal(); else chart.setOrientationHorizontal();
// Show enough generations that a recenter reveals grandparents + children.
chart.setAncestryDepth?.(3);
chart.setProgenyDepth?.(2);
// Default card click recenters the whole hourglass; sync focus for the
// "Open profile" link after every (re)build.
chart.setAfterUpdate?.(() => {
const md = chart.getMainDatum?.();
const id = md?.id ?? md?.data?.id;
if (id) setFocusId(id);
});
chartRef.current = chart;
if (focusId) chart.updateMainId(focusId); if (focusId) chart.updateMainId(focusId);
chart.updateTree({ initial: true }); chart.updateTree({ initial: true });
})(); })();
@@ -139,6 +153,27 @@ export default function TreePage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, mode, people, rels, events]); }, [status, mode, people, rels, events]);
// Jump the tree (or fan) to a person and rebuild the hourglass around them.
const goTo = useCallback(
(id: string) => {
setFocusId(id);
setQuery("");
if (mode !== "fan" && chartRef.current) {
chartRef.current.updateMainId?.(id);
chartRef.current.updateTree?.();
}
},
[mode],
);
const matches = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
return people
.filter((p) => (p.primary_name ?? "").toLowerCase().includes(q))
.slice(0, 8);
}, [query, people]);
const ModeButton = ({ m, label }: { m: Mode; label: string }) => ( const ModeButton = ({ m, label }: { m: Mode; label: string }) => (
<button <button
onClick={() => setMode(m)} onClick={() => setMode(m)}
@@ -153,7 +188,34 @@ export default function TreePage() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold">Tree</h1> <h1 className="text-2xl font-semibold">Tree</h1>
<div className="relative">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Find a person…"
className="w-56"
/>
{matches.length > 0 && (
<ul className="absolute z-20 mt-1 w-72 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
{matches.map((p) => (
<li key={p.id}>
<button
onClick={() => goTo(p.id)}
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-[var(--muted-bg,rgba(0,0,0,0.04))]"
>
<span>{p.primary_name ?? "Unnamed"}</span>
{yearOf(p.id) && (
<span className="text-xs text-[var(--muted)]">{yearOf(p.id)}</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center rounded-lg border border-[var(--border)] p-0.5"> <div className="flex items-center rounded-lg border border-[var(--border)] p-0.5">
<ModeButton m="landscape" label="Landscape" /> <ModeButton m="landscape" label="Landscape" />
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { components } from "@/lib/api/schema";
type Person = components["schemas"]["PersonRead"];
/**
* A type-to-filter person picker. Shows a text input; as you type, a dropdown
* of matching people appears. Selecting one sets `value` (a person id) and
* fills the input with their name. Replaces a plain <select> when the list is
* long enough that scanning it by hand is painful.
*/
export function PersonCombobox({
people,
value,
onChange,
placeholder = "Search for a person…",
className,
}: {
people: Person[];
value: string;
onChange: (id: string) => void;
placeholder?: string;
className?: string;
}) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const nameOf = useMemo(
() => new Map(people.map((p) => [p.id, p.primary_name ?? "Unnamed"])),
[people],
);
// Keep the input text in sync when the selection changes externally
// (e.g. cleared to "" after a successful add).
useEffect(() => {
if (!value) {
setQuery("");
} else if (!open) {
setQuery(nameOf.get(value) ?? "");
}
}, [value, open, nameOf]);
// Close on outside click.
useEffect(() => {
function onDoc(e: MouseEvent) {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, []);
const matches = useMemo(() => {
const q = query.trim().toLowerCase();
const pool = q
? people.filter((p) => (p.primary_name ?? "").toLowerCase().includes(q))
: people;
return pool.slice(0, 10);
}, [query, people]);
const base =
"h-9 w-56 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm placeholder:text-[var(--muted)] focus-visible:border-bronze focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-bronze/40";
return (
<div ref={wrapRef} className="relative">
<input
className={`${base} ${className ?? ""}`}
value={query}
placeholder={placeholder}
onFocus={() => setOpen(true)}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
if (value) onChange(""); // typing invalidates the prior pick
}}
/>
{open && matches.length > 0 && (
<ul className="absolute z-30 mt-1 max-h-64 w-72 overflow-auto rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
{matches.map((p) => (
<li key={p.id}>
<button
type="button"
onClick={() => {
onChange(p.id);
setQuery(p.primary_name ?? "Unnamed");
setOpen(false);
}}
className={`block w-full px-3 py-2 text-left text-sm hover:bg-[var(--muted-bg,rgba(0,0,0,0.04))] ${
p.id === value ? "text-bronze" : ""
}`}
>
{p.primary_name ?? "Unnamed"}
</button>
</li>
))}
</ul>
)}
</div>
);
}
+677 -11
View File
@@ -157,6 +157,26 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/users/me/self-person": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
/**
* Set Self Person
* @description Link (or unlink) the Person record that represents this account.
*/
patch: operations["set_self_person_api_v1_users_me_self_person_patch"];
trace?: never;
};
"/api/v1/trees": { "/api/v1/trees": {
parameters: { parameters: {
query?: never; query?: never;
@@ -190,7 +210,8 @@ export interface paths {
delete: operations["delete_tree_api_v1_trees__tree_id__delete"]; delete: operations["delete_tree_api_v1_trees__tree_id__delete"];
options?: never; options?: never;
head?: never; head?: never;
patch?: never; /** Update Tree */
patch: operations["update_tree_api_v1_trees__tree_id__patch"];
trace?: never; trace?: never;
}; };
"/api/v1/trees/{tree_id}/restore": { "/api/v1/trees/{tree_id}/restore": {
@@ -239,11 +260,16 @@ export interface paths {
get: operations["get_person_api_v1_trees__tree_id__persons__person_id__get"]; get: operations["get_person_api_v1_trees__tree_id__persons__person_id__get"];
put?: never; put?: never;
post?: never; post?: never;
/** Delete Person */ /**
* Delete Person
* @description Delete a person. ``cascade=true`` also deletes all descendants. Returns
* the number of persons deleted (1 unless cascading).
*/
delete: operations["delete_person_api_v1_trees__tree_id__persons__person_id__delete"]; delete: operations["delete_person_api_v1_trees__tree_id__persons__person_id__delete"];
options?: never; options?: never;
head?: never; head?: never;
patch?: never; /** Update Person */
patch: operations["update_person_api_v1_trees__tree_id__persons__person_id__patch"];
trace?: never; trace?: never;
}; };
"/api/v1/trees/{tree_id}/persons/{person_id}/restore": { "/api/v1/trees/{tree_id}/persons/{person_id}/restore": {
@@ -263,6 +289,42 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/trees/{tree_id}/persons/{person_id}/names": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Names */
get: operations["list_names_api_v1_trees__tree_id__persons__person_id__names_get"];
put?: never;
/** Create Name */
post: operations["create_name_api_v1_trees__tree_id__persons__person_id__names_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Delete Name */
delete: operations["delete_name_api_v1_trees__tree_id__persons__person_id__names__name_id__delete"];
options?: never;
head?: never;
/** Update Name */
patch: operations["update_name_api_v1_trees__tree_id__persons__person_id__names__name_id__patch"];
trace?: never;
};
"/api/v1/trees/{tree_id}/events": { "/api/v1/trees/{tree_id}/events": {
parameters: { parameters: {
query?: never; query?: never;
@@ -312,7 +374,8 @@ export interface paths {
delete: operations["delete_event_api_v1_trees__tree_id__events__event_id__delete"]; delete: operations["delete_event_api_v1_trees__tree_id__events__event_id__delete"];
options?: never; options?: never;
head?: never; head?: never;
patch?: never; /** Update Event */
patch: operations["update_event_api_v1_trees__tree_id__events__event_id__patch"];
trace?: never; trace?: never;
}; };
"/api/v1/trees/{tree_id}/relationships": { "/api/v1/trees/{tree_id}/relationships": {
@@ -364,7 +427,8 @@ export interface paths {
delete: operations["delete_relationship_api_v1_trees__tree_id__relationships__relationship_id__delete"]; delete: operations["delete_relationship_api_v1_trees__tree_id__relationships__relationship_id__delete"];
options?: never; options?: never;
head?: never; head?: never;
patch?: never; /** Update Relationship */
patch: operations["update_relationship_api_v1_trees__tree_id__relationships__relationship_id__patch"];
trace?: never; trace?: never;
}; };
"/api/v1/trees/{tree_id}/sources": { "/api/v1/trees/{tree_id}/sources": {
@@ -400,7 +464,8 @@ export interface paths {
delete: operations["delete_source_api_v1_trees__tree_id__sources__source_id__delete"]; delete: operations["delete_source_api_v1_trees__tree_id__sources__source_id__delete"];
options?: never; options?: never;
head?: never; head?: never;
patch?: never; /** Update Source */
patch: operations["update_source_api_v1_trees__tree_id__sources__source_id__patch"];
trace?: never; trace?: never;
}; };
"/api/v1/trees/{tree_id}/citations": { "/api/v1/trees/{tree_id}/citations": {
@@ -435,7 +500,8 @@ export interface paths {
delete: operations["delete_citation_api_v1_trees__tree_id__citations__citation_id__delete"]; delete: operations["delete_citation_api_v1_trees__tree_id__citations__citation_id__delete"];
options?: never; options?: never;
head?: never; head?: never;
patch?: never; /** Update Citation */
patch: operations["update_citation_api_v1_trees__tree_id__citations__citation_id__patch"];
trace?: never; trace?: never;
}; };
"/api/v1/trees/{tree_id}/media": { "/api/v1/trees/{tree_id}/media": {
@@ -487,7 +553,8 @@ export interface paths {
delete: operations["delete_media_api_v1_trees__tree_id__media__media_id__delete"]; delete: operations["delete_media_api_v1_trees__tree_id__media__media_id__delete"];
options?: never; options?: never;
head?: never; head?: never;
patch?: never; /** Update Media */
patch: operations["update_media_api_v1_trees__tree_id__media__media_id__patch"];
trace?: never; trace?: never;
}; };
"/api/v1/trees/{tree_id}/gedcom/import": { "/api/v1/trees/{tree_id}/gedcom/import": {
@@ -608,6 +675,14 @@ export interface components {
*/ */
created_at: string; created_at: string;
}; };
/** CitationUpdate */
CitationUpdate: {
/** Page */
page?: string | null;
/** Detail */
detail?: string | null;
confidence?: components["schemas"]["CitationConfidence"] | null;
};
/** EventCreate */ /** EventCreate */
EventCreate: { EventCreate: {
/** Event Type */ /** Event Type */
@@ -676,6 +751,27 @@ export interface components {
*/ */
created_at: string; created_at: string;
}; };
/** EventUpdate */
EventUpdate: {
/** Event Type */
event_type?: string | null;
/** Place Id */
place_id?: string | null;
/** Date Value */
date_value?: string | null;
/** Date Start */
date_start?: string | null;
/** Date End */
date_end?: string | null;
/** Date Precision */
date_precision?: string | null;
/** Calendar */
calendar?: string | null;
/** Detail */
detail?: string | null;
/** Notes */
notes?: string | null;
};
/** HTTPValidationError */ /** HTTPValidationError */
HTTPValidationError: { HTTPValidationError: {
/** Detail */ /** Detail */
@@ -733,6 +829,96 @@ export interface components {
/** Url */ /** Url */
url?: string | null; url?: string | null;
}; };
/** MediaUpdate */
MediaUpdate: {
/** Title */
title?: string | null;
/** Person Id */
person_id?: string | null;
/** Event Id */
event_id?: string | null;
/** Source Id */
source_id?: string | null;
};
/** NameCreate */
NameCreate: {
/**
* Name Type
* @default birth
*/
name_type?: string;
/** Given */
given?: string | null;
/** Surname */
surname?: string | null;
/** Prefix */
prefix?: string | null;
/** Suffix */
suffix?: string | null;
/** Nickname */
nickname?: string | null;
/**
* Is Primary
* @default false
*/
is_primary?: boolean;
};
/** NameRead */
NameRead: {
/**
* Id
* Format: uuid
*/
id: string;
/**
* Tree Id
* Format: uuid
*/
tree_id: string;
/**
* Person Id
* Format: uuid
*/
person_id: string;
/** Name Type */
name_type: string;
/** Given */
given: string | null;
/** Surname */
surname: string | null;
/** Prefix */
prefix: string | null;
/** Suffix */
suffix: string | null;
/** Nickname */
nickname: string | null;
/** Is Primary */
is_primary: boolean;
/** Sort Order */
sort_order: number;
/**
* Created At
* Format: date-time
*/
created_at: string;
};
/** NameUpdate */
NameUpdate: {
/** Name Type */
name_type?: string | null;
/** Given */
given?: string | null;
/** Surname */
surname?: string | null;
/** Prefix */
prefix?: string | null;
/** Suffix */
suffix?: string | null;
/** Nickname */
nickname?: string | null;
/** Is Primary */
is_primary?: boolean | null;
};
/** /**
* ParentChildQualifier * ParentChildQualifier
* @description Qualifies a parent_child edge so adoption/donor/blended families are * @description Qualifies a parent_child edge so adoption/donor/blended families are
@@ -798,6 +984,20 @@ export interface components {
*/ */
created_at: string; created_at: string;
}; };
/** PersonUpdate */
PersonUpdate: {
/** Given */
given?: string | null;
/** Surname */
surname?: string | null;
/** Gender */
gender?: string | null;
/** Is Living */
is_living?: boolean | null;
privacy?: components["schemas"]["PersonPrivacy"] | null;
/** Notes */
notes?: string | null;
};
/** RegisterRequest */ /** RegisterRequest */
RegisterRequest: { RegisterRequest: {
/** Email */ /** Email */
@@ -861,6 +1061,12 @@ export interface components {
* @enum {string} * @enum {string}
*/ */
RelationshipType: "parent_child" | "partnership" | "sibling"; RelationshipType: "parent_child" | "partnership" | "sibling";
/** RelationshipUpdate */
RelationshipUpdate: {
qualifier?: components["schemas"]["ParentChildQualifier"] | null;
/** Notes */
notes?: string | null;
};
/** SessionRead */ /** SessionRead */
SessionRead: { SessionRead: {
user: components["schemas"]["UserRead"]; user: components["schemas"]["UserRead"];
@@ -925,6 +1131,25 @@ export interface components {
*/ */
created_at: string; created_at: string;
}; };
/** SourceUpdate */
SourceUpdate: {
/** Title */
title?: string | null;
/** Author */
author?: string | null;
/** Source Type */
source_type?: string | null;
/** Repository */
repository?: string | null;
/** Url */
url?: string | null;
/** Citation Text */
citation_text?: string | null;
/** Publication Info */
publication_info?: string | null;
/** Quality Note */
quality_note?: string | null;
};
/** TokenRequest */ /** TokenRequest */
TokenRequest: { TokenRequest: {
/** Token */ /** Token */
@@ -962,6 +1187,14 @@ export interface components {
*/ */
created_at: string; created_at: string;
}; };
/** TreeUpdate */
TreeUpdate: {
/** Name */
name?: string | null;
/** Description */
description?: string | null;
visibility?: components["schemas"]["TreeVisibility"] | null;
};
/** /**
* TreeVisibility * TreeVisibility
* @enum {string} * @enum {string}
@@ -980,12 +1213,19 @@ export interface components {
display_name: string | null; display_name: string | null;
/** Email Verified At */ /** Email Verified At */
email_verified_at: string | null; email_verified_at: string | null;
/** Self Person Id */
self_person_id?: string | null;
/** /**
* Created At * Created At
* Format: date-time * Format: date-time
*/ */
created_at: string; created_at: string;
}; };
/** UserSelfPersonUpdate */
UserSelfPersonUpdate: {
/** Self Person Id */
self_person_id?: string | null;
};
/** ValidationError */ /** ValidationError */
ValidationError: { ValidationError: {
/** Location */ /** Location */
@@ -1253,6 +1493,39 @@ export interface operations {
}; };
}; };
}; };
set_self_person_api_v1_users_me_self_person_patch: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UserSelfPersonUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_my_trees_api_v1_trees_get: { list_my_trees_api_v1_trees_get: {
parameters: { parameters: {
query?: { query?: {
@@ -1377,6 +1650,41 @@ export interface operations {
}; };
}; };
}; };
update_tree_api_v1_trees__tree_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TreeUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TreeRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
restore_tree_api_v1_trees__tree_id__restore_post: { restore_tree_api_v1_trees__tree_id__restore_post: {
parameters: { parameters: {
query?: never; query?: never;
@@ -1511,7 +1819,9 @@ export interface operations {
}; };
delete_person_api_v1_trees__tree_id__persons__person_id__delete: { delete_person_api_v1_trees__tree_id__persons__person_id__delete: {
parameters: { parameters: {
query?: never; query?: {
cascade?: boolean;
};
header?: never; header?: never;
path: { path: {
tree_id: string; tree_id: string;
@@ -1522,11 +1832,51 @@ export interface operations {
requestBody?: never; requestBody?: never;
responses: { responses: {
/** @description Successful Response */ /** @description Successful Response */
204: { 200: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
}; };
content?: never; content: {
"application/json": {
[key: string]: number;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
update_person_api_v1_trees__tree_id__persons__person_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
person_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PersonUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PersonRead"];
};
}; };
/** @description Validation Error */ /** @description Validation Error */
422: { 422: {
@@ -1571,6 +1921,142 @@ export interface operations {
}; };
}; };
}; };
list_names_api_v1_trees__tree_id__persons__person_id__names_get: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
person_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["NameRead"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
create_name_api_v1_trees__tree_id__persons__person_id__names_post: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
person_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["NameCreate"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["NameRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_name_api_v1_trees__tree_id__persons__person_id__names__name_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
person_id: string;
name_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
update_name_api_v1_trees__tree_id__persons__person_id__names__name_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
person_id: string;
name_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["NameUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["NameRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_tree_events_api_v1_trees__tree_id__events_get: { list_tree_events_api_v1_trees__tree_id__events_get: {
parameters: { parameters: {
query?: never; query?: never;
@@ -1699,6 +2185,42 @@ export interface operations {
}; };
}; };
}; };
update_event_api_v1_trees__tree_id__events__event_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
event_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["EventUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["EventRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_relationships_api_v1_trees__tree_id__relationships_get: { list_relationships_api_v1_trees__tree_id__relationships_get: {
parameters: { parameters: {
query?: never; query?: never;
@@ -1827,6 +2349,42 @@ export interface operations {
}; };
}; };
}; };
update_relationship_api_v1_trees__tree_id__relationships__relationship_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
relationship_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RelationshipUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["RelationshipRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_sources_api_v1_trees__tree_id__sources_get: { list_sources_api_v1_trees__tree_id__sources_get: {
parameters: { parameters: {
query?: never; query?: never;
@@ -1955,6 +2513,42 @@ export interface operations {
}; };
}; };
}; };
update_source_api_v1_trees__tree_id__sources__source_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
source_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["SourceUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SourceRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_citations_api_v1_trees__tree_id__citations_get: { list_citations_api_v1_trees__tree_id__citations_get: {
parameters: { parameters: {
query?: never; query?: never;
@@ -2051,6 +2645,42 @@ export interface operations {
}; };
}; };
}; };
update_citation_api_v1_trees__tree_id__citations__citation_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
citation_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CitationUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CitationRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_media_api_v1_trees__tree_id__media_get: { list_media_api_v1_trees__tree_id__media_get: {
parameters: { parameters: {
query?: never; query?: never;
@@ -2179,6 +2809,42 @@ export interface operations {
}; };
}; };
}; };
update_media_api_v1_trees__tree_id__media__media_id__patch: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
media_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["MediaUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MediaRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
import_gedcom_api_v1_trees__tree_id__gedcom_import_post: { import_gedcom_api_v1_trees__tree_id__gedcom_import_post: {
parameters: { parameters: {
query?: never; query?: never;
+1451 -5
View File
File diff suppressed because it is too large Load Diff