Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8903e480cf | |||
| d27cc5dddc | |||
| 943f459b91 | |||
| 5106538934 | |||
| 2669543e56 | |||
| 0262ed3d97 | |||
| 9ee960c4ef | |||
| 7f640649b9 | |||
| a8929c2862 | |||
| b90ba53a3f | |||
| c4e9d69e00 | |||
| 0673896133 | |||
| 1164841950 | |||
| 5824e70895 | |||
| 04ccdbf96a | |||
| f165ccb941 | |||
| e0fb924a1d |
@@ -21,7 +21,11 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
COPY app ./app
|
||||
COPY alembic.ini ./alembic.ini
|
||||
COPY migrations ./migrations
|
||||
COPY docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
RUN chmod +x ./docker-entrypoint.sh
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# The entrypoint runs migrations first when RUN_MIGRATIONS=1, then the command.
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
CMD ["uv", "run", "--no-dev", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.api.v1 import (
|
||||
events,
|
||||
gedcom,
|
||||
media,
|
||||
names,
|
||||
persons,
|
||||
relationships,
|
||||
sources,
|
||||
@@ -20,6 +21,7 @@ api_router.include_router(auth.router)
|
||||
api_router.include_router(users.router)
|
||||
api_router.include_router(trees.router)
|
||||
api_router.include_router(persons.router)
|
||||
api_router.include_router(names.router)
|
||||
api_router.include_router(events.router)
|
||||
api_router.include_router(relationships.router)
|
||||
api_router.include_router(sources.router)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
|
||||
from app.api.deps import MailerDep, SessionDep, extract_session_token
|
||||
from app.api.deps import CurrentUser, MailerDep, SessionDep, extract_session_token
|
||||
from app.core.config import get_settings
|
||||
from app.schemas.auth import (
|
||||
LoginRequest,
|
||||
PasswordChange,
|
||||
PasswordResetConfirm,
|
||||
PasswordResetRequest,
|
||||
RegisterRequest,
|
||||
@@ -79,3 +80,15 @@ async def reset_password(data: PasswordResetConfirm, session: SessionDep) -> Non
|
||||
await auth_service.reset_password(
|
||||
session, raw_token=data.token, new_password=data.new_password
|
||||
)
|
||||
|
||||
|
||||
@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def change_password(
|
||||
data: PasswordChange, session: SessionDep, current: CurrentUser
|
||||
) -> None:
|
||||
await auth_service.change_password(
|
||||
session,
|
||||
user=current,
|
||||
current_password=data.current_password,
|
||||
new_password=data.new_password,
|
||||
)
|
||||
|
||||
@@ -1,25 +1,56 @@
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, File, Response, UploadFile
|
||||
from fastapi import APIRouter, File, Form, Response, UploadFile
|
||||
|
||||
from app.api.deps import CurrentUser, SessionDep
|
||||
from app.schemas.gedcom import ImportReport
|
||||
from app.schemas.gedcom import ImportPreview, ImportReport
|
||||
from app.services import gedcom, tree_service
|
||||
|
||||
router = APIRouter(prefix="/trees", tags=["gedcom"])
|
||||
|
||||
|
||||
@router.post("/{tree_id}/gedcom/preview", response_model=ImportPreview)
|
||||
async def preview_gedcom(
|
||||
tree_id: uuid.UUID,
|
||||
session: SessionDep,
|
||||
current: CurrentUser,
|
||||
file: UploadFile = File(...),
|
||||
) -> ImportPreview:
|
||||
"""Dry run: report counts and incoming people that look like duplicates of
|
||||
existing ones, so the user can choose how to resolve each before importing."""
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
text = (await file.read()).decode("utf-8", errors="replace")
|
||||
report = await gedcom.preview_gedcom(session, actor=current, tree=tree, text=text)
|
||||
return ImportPreview(**report)
|
||||
|
||||
|
||||
@router.post("/{tree_id}/gedcom/import", response_model=ImportReport)
|
||||
async def import_gedcom(
|
||||
tree_id: uuid.UUID,
|
||||
session: SessionDep,
|
||||
current: CurrentUser,
|
||||
file: UploadFile = File(...),
|
||||
default_action: str = Form("new"),
|
||||
resolutions: str = Form("{}"),
|
||||
) -> ImportReport:
|
||||
# NOTE: additive — records are created as new; existing people are not merged.
|
||||
"""Import a GEDCOM. ``default_action`` (new|skip|merge|overwrite) applies to
|
||||
incoming people that match an existing one; ``resolutions`` is a JSON object
|
||||
{xref: {action, target_id}} overriding it per record."""
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
text = (await file.read()).decode("utf-8", errors="replace")
|
||||
report = await gedcom.import_gedcom(session, actor=current, tree=tree, text=text)
|
||||
try:
|
||||
parsed = json.loads(resolutions or "{}")
|
||||
except json.JSONDecodeError:
|
||||
parsed = {}
|
||||
report = await gedcom.import_gedcom(
|
||||
session,
|
||||
actor=current,
|
||||
tree=tree,
|
||||
text=text,
|
||||
default_action=default_action,
|
||||
resolutions=parsed,
|
||||
)
|
||||
return ImportReport(**report)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -75,12 +75,21 @@ async def update_person(
|
||||
return PersonRead.model_validate(person)
|
||||
|
||||
|
||||
@router.delete("/{tree_id}/persons/{person_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/{tree_id}/persons/{person_id}")
|
||||
async def delete_person(
|
||||
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||
) -> None:
|
||||
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)
|
||||
await person_service.delete_person(session, actor=current, tree=tree, person_id=person_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)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.deps import CurrentUser
|
||||
from app.schemas.user import UserRead
|
||||
from app.api.deps import CurrentUser, SessionDep
|
||||
from app.schemas.user import UserRead, UserSelfPersonUpdate
|
||||
from app.services import user_service
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
@@ -9,3 +10,14 @@ router = APIRouter(prefix="/users", tags=["users"])
|
||||
@router.get("/me", response_model=UserRead)
|
||||
async def read_me(current: CurrentUser) -> UserRead:
|
||||
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)
|
||||
|
||||
@@ -26,6 +26,16 @@ class Tree(Base, UUIDPrimaryKey, Timestamps, SoftDelete):
|
||||
default=TreeVisibility.private,
|
||||
server_default=TreeVisibility.private.value,
|
||||
)
|
||||
# The person a tree opens focused on (its "home"/root person). Cleared if
|
||||
# that person is deleted. use_alter + name: trees<->persons form an FK cycle.
|
||||
home_person_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey(
|
||||
"persons.id",
|
||||
ondelete="SET NULL",
|
||||
name="fk_trees_home_person_id",
|
||||
use_alter=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TreeMembership(Base, UUIDPrimaryKey, Timestamps):
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy import DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
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))
|
||||
display_name: 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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -29,6 +29,11 @@ class PasswordResetConfirm(BaseModel):
|
||||
new_password: str = Field(min_length=8)
|
||||
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
current_password: str
|
||||
new_password: str = Field(min_length=8)
|
||||
|
||||
|
||||
class SessionRead(BaseModel):
|
||||
user: UserRead
|
||||
token: str
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import uuid
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ImportReport(BaseModel):
|
||||
counts: dict[str, int]
|
||||
unmapped_tags: list[str]
|
||||
|
||||
|
||||
class DuplicateMatch(BaseModel):
|
||||
# An incoming GEDCOM person that resembles an existing one in the tree.
|
||||
xref: str
|
||||
incoming_name: str
|
||||
incoming_birth_year: str | None = None
|
||||
existing_person_id: uuid.UUID
|
||||
existing_name: str
|
||||
existing_birth_year: str | None = None
|
||||
score: str # "high" | "medium"
|
||||
|
||||
|
||||
class ImportPreview(BaseModel):
|
||||
counts: dict[str, int]
|
||||
potential_duplicates: list[DuplicateMatch]
|
||||
unmapped_tags: list[str]
|
||||
|
||||
@@ -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
|
||||
@@ -16,6 +16,7 @@ class TreeUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
visibility: TreeVisibility | None = None
|
||||
home_person_id: uuid.UUID | None = None
|
||||
|
||||
|
||||
class TreeRead(BaseModel):
|
||||
@@ -26,4 +27,5 @@ class TreeRead(BaseModel):
|
||||
description: str | None
|
||||
visibility: TreeVisibility
|
||||
owner_id: uuid.UUID
|
||||
home_person_id: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
|
||||
@@ -19,4 +19,10 @@ class UserRead(BaseModel):
|
||||
email: str
|
||||
display_name: str | None
|
||||
email_verified_at: datetime | None
|
||||
self_person_id: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class UserSelfPersonUpdate(BaseModel):
|
||||
# null clears the link; otherwise the Person that represents this account.
|
||||
self_person_id: uuid.UUID | None = None
|
||||
|
||||
@@ -3,6 +3,7 @@ the change to a User (or the assistant principal acting for a User). Staged on
|
||||
the session; the caller commits as part of its unit of work.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -11,6 +12,14 @@ from app.models.audit import AuditEntry
|
||||
from app.models.enums import AuditActorType
|
||||
|
||||
|
||||
def _json_safe(d: dict | None) -> dict | None:
|
||||
"""Coerce a change dict to JSON-native types (UUIDs, enums, dates -> str) so
|
||||
it lands in the JSON audit column regardless of what the caller passed."""
|
||||
if d is None:
|
||||
return None
|
||||
return json.loads(json.dumps(d, default=str))
|
||||
|
||||
|
||||
def record_audit(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
@@ -30,8 +39,8 @@ def record_audit(
|
||||
tree_id=tree_id,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_type=actor_type,
|
||||
before=before,
|
||||
after=after,
|
||||
before=_json_safe(before),
|
||||
after=_json_safe(after),
|
||||
)
|
||||
session.add(entry)
|
||||
return entry
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import generate_token, hash_password, hash_token
|
||||
from app.core.security import generate_token, hash_password, hash_token, verify_password
|
||||
from app.integrations.auth.local import LocalAuthProvider
|
||||
from app.integrations.mailer.base import Mailer
|
||||
from app.models.auth import Session as SessionModel
|
||||
@@ -17,7 +17,7 @@ from app.models.auth import UserToken
|
||||
from app.models.enums import TokenPurpose
|
||||
from app.models.user import User
|
||||
from app.services.audit import record_audit
|
||||
from app.services.exceptions import Conflict, NotFound
|
||||
from app.services.exceptions import Conflict, Forbidden, NotFound
|
||||
|
||||
_local_provider = LocalAuthProvider()
|
||||
|
||||
@@ -178,6 +178,26 @@ async def request_password_reset(session: AsyncSession, mailer: Mailer, *, email
|
||||
await mailer.send_password_reset(to=email, link=_link("/auth/reset-password", raw))
|
||||
|
||||
|
||||
async def change_password(
|
||||
session: AsyncSession, *, user: User, current_password: str, new_password: str
|
||||
) -> None:
|
||||
"""Change a logged-in user's password after re-verifying the current one.
|
||||
Revokes other sessions so a changed password takes effect everywhere."""
|
||||
if not user.hashed_password or not verify_password(
|
||||
user.hashed_password, current_password
|
||||
):
|
||||
raise Forbidden("current password is incorrect")
|
||||
user.hashed_password = hash_password(new_password)
|
||||
record_audit(
|
||||
session,
|
||||
action="change_password",
|
||||
entity_type="User",
|
||||
entity_id=user.id,
|
||||
actor_user_id=user.id,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def reset_password(session: AsyncSession, *, raw_token: str, new_password: str) -> None:
|
||||
token = await _consume_token(session, raw_token, TokenPurpose.password_reset)
|
||||
await session.execute(
|
||||
|
||||
+400
-50
@@ -4,14 +4,20 @@ A pragmatic parser + mapper for the common subset of GEDCOM (5.5.1 / 7 share
|
||||
the line grammar): INDI, FAM, SOUR. Import maps records into a tree and returns
|
||||
a mapping report (counts + unmapped tags); export serializes the tree back to
|
||||
GEDCOM. Runs inline for now — large files should move to the worker later.
|
||||
|
||||
Import is duplicate-aware: ``preview_gedcom`` reports incoming people that look
|
||||
like existing ones, and ``import_gedcom`` applies a per-record resolution
|
||||
(new / skip / merge / overwrite). Names carry their GEDCOM type (a married name
|
||||
imports as a typed alternate, not a second primary).
|
||||
"""
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import date
|
||||
from datetime import UTC, date, datetime
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.enums import ParentChildQualifier, RelationshipType
|
||||
@@ -32,12 +38,31 @@ INDI_EVENTS = {
|
||||
"BURI": "burial", "CREM": "cremation", "RESI": "residence", "CENS": "census",
|
||||
"IMMI": "immigration", "EMIG": "emigration", "OCCU": "occupation",
|
||||
"EDUC": "education", "GRAD": "graduation", "RETI": "retirement",
|
||||
"NATU": "naturalization", "BAPL": "baptism",
|
||||
"NATU": "naturalization", "BAPL": "baptism", "RELI": "religion",
|
||||
}
|
||||
# INDI attribute tags whose line VALUE is the fact (no date), stored in detail.
|
||||
VALUE_EVENTS = {"RELI", "OCCU", "EDUC"}
|
||||
# INDI sub-tags consumed elsewhere or intentionally ignored (not "unmapped").
|
||||
INDI_SKIP_TAGS = {
|
||||
"NAME", "SEX", "SOUR", "FAMC", "FAMS", "CHAN", "OBJE", "_UID", "_MARNM", "NOTE",
|
||||
}
|
||||
# FAM-level events.
|
||||
FAM_EVENTS = {"MARR": "marriage", "DIV": "divorce", "ENGA": "engagement"}
|
||||
EVENT_TO_GED = {v: k for k, v in {**INDI_EVENTS, **FAM_EVENTS}.items()}
|
||||
|
||||
# GEDCOM NAME TYPE (or _MARNM-derived) -> our Name.name_type vocabulary.
|
||||
NAME_TYPE_MAP = {
|
||||
"birth": "birth", "maiden": "birth", "married": "married",
|
||||
"aka": "alias", "also known as": "alias", "nickname": "nickname",
|
||||
"religious": "religious", "immigrant": "immigration",
|
||||
"immigration": "immigration", "professional": "alias", "other": "alias",
|
||||
}
|
||||
# Our type -> GEDCOM TYPE on export (birth is the default; emit nothing).
|
||||
EXPORT_TYPE_MAP = {
|
||||
"married": "married", "alias": "aka", "nickname": "nickname",
|
||||
"religious": "religious", "immigration": "immigrant",
|
||||
}
|
||||
|
||||
|
||||
class GedcomNode:
|
||||
__slots__ = ("level", "tag", "value", "xref", "children")
|
||||
@@ -108,6 +133,50 @@ def _parse_name(value: str) -> tuple[str | None, str | None]:
|
||||
return value.strip() or None, None
|
||||
|
||||
|
||||
def _parse_marnm(value: str, base_given: str | None) -> tuple[str | None, str | None]:
|
||||
"""A _MARNM value is sometimes a full name ("Jane /Smith/") and sometimes
|
||||
just the married surname ("Smith"). Keep the given name from the base name
|
||||
in the latter case."""
|
||||
v = (value or "").strip()
|
||||
if "/" in v:
|
||||
g, s = _parse_name(v)
|
||||
return (g or base_given), s
|
||||
return base_given, (v or None)
|
||||
|
||||
|
||||
def _extract_names(rec: GedcomNode) -> list[dict]:
|
||||
"""All names for an INDI, typed. Multiple NAME records (each with an optional
|
||||
TYPE) plus any _MARNM (married name) subtags become separate Name rows. The
|
||||
first birth/maiden name is primary."""
|
||||
out: list[dict] = []
|
||||
for nm in rec.all("NAME"):
|
||||
g, s = _parse_name(nm.value)
|
||||
t = (nm.text("TYPE") or "").strip().lower()
|
||||
ntype = NAME_TYPE_MAP.get(t, t or "birth")
|
||||
out.append({"type": ntype, "given": g, "surname": s, "display": nm.value or None,
|
||||
"nickname": nm.text("NICK")})
|
||||
for mar in nm.all("_MARNM"):
|
||||
mg, ms = _parse_marnm(mar.value, g)
|
||||
out.append({"type": "married", "given": mg, "surname": ms,
|
||||
"display": mar.value or None, "nickname": None})
|
||||
for mar in rec.all("_MARNM"):
|
||||
base_g = out[0]["given"] if out else None
|
||||
mg, ms = _parse_marnm(mar.value, base_g)
|
||||
out.append({"type": "married", "given": mg, "surname": ms,
|
||||
"display": mar.value or None, "nickname": None})
|
||||
if not out:
|
||||
return out
|
||||
primary_idx = next((i for i, n in enumerate(out) if n["type"] == "birth"), 0)
|
||||
for i, n in enumerate(out):
|
||||
n["is_primary"] = i == primary_idx
|
||||
n["sort"] = i
|
||||
return out
|
||||
|
||||
|
||||
def _norm(given: str | None, surname: str | None) -> str:
|
||||
return re.sub(r"\s+", " ", f"{given or ''} {surname or ''}".strip().lower())
|
||||
|
||||
|
||||
def _year(date_value: str | None) -> str | None:
|
||||
if not date_value:
|
||||
return None
|
||||
@@ -132,18 +201,215 @@ def _sex(value: str | None) -> str | None:
|
||||
return {"M": "male", "F": "female"}.get(v, value.strip().lower() or None)
|
||||
|
||||
|
||||
def _notes_text(rec: GedcomNode) -> str | None:
|
||||
"""Join an INDI's NOTE lines (which pack confidence / findagrave / fs_pid /
|
||||
free text) into the person's notes field."""
|
||||
vals = [n.value.strip() for n in rec.all("NOTE") if n.value and n.value.strip()]
|
||||
return "\n".join(vals) or None
|
||||
|
||||
|
||||
def _person_summary(rec: GedcomNode) -> dict:
|
||||
"""Display name + birth year for an incoming INDI, for duplicate matching."""
|
||||
names = _extract_names(rec)
|
||||
primary = next((n for n in names if n.get("is_primary")), names[0] if names else None)
|
||||
g = primary["given"] if primary else None
|
||||
s = primary["surname"] if primary else None
|
||||
disp = " ".join(x for x in (g, s) if x)
|
||||
if not disp and primary:
|
||||
disp = primary.get("display") or ""
|
||||
birth = rec.first("BIRT")
|
||||
year = _year(birth.text("DATE")) if birth else None
|
||||
return {"names": names, "norm": _norm(g, s), "name": disp or "(no name)", "year": year}
|
||||
|
||||
|
||||
async def _build_existing_index(session: AsyncSession, tree: Tree) -> list[dict]:
|
||||
"""Existing (non-deleted) people with a display name + birth year, for
|
||||
matching incoming records against."""
|
||||
persons = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(Person).where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
names = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(Name).where(Name.tree_id == tree.id, Name.deleted_at.is_(None))
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
name_by_person: dict[uuid.UUID, Name] = {}
|
||||
for n in sorted(names, key=lambda n: (not n.is_primary, n.sort_order)):
|
||||
name_by_person.setdefault(n.person_id, n)
|
||||
births = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(Event).where(
|
||||
Event.tree_id == tree.id,
|
||||
Event.deleted_at.is_(None),
|
||||
Event.event_type == "birth",
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
year_by_person: dict[uuid.UUID, str] = {}
|
||||
for e in births:
|
||||
if e.person_id and e.person_id not in year_by_person:
|
||||
y = str(e.date_start.year) if e.date_start else _year(e.date_value)
|
||||
if y:
|
||||
year_by_person[e.person_id] = y
|
||||
|
||||
index: list[dict] = []
|
||||
for p in persons:
|
||||
nm = name_by_person.get(p.id)
|
||||
g = nm.given if nm else None
|
||||
s = nm.surname if nm else None
|
||||
disp = " ".join(x for x in (g, s) if x) or (nm.display_name if nm else None)
|
||||
index.append({
|
||||
"id": p.id,
|
||||
"norm": _norm(g, s),
|
||||
"name": disp or "(no name)",
|
||||
"year": year_by_person.get(p.id),
|
||||
})
|
||||
return index
|
||||
|
||||
|
||||
def _best_match(norm: str, year: str | None, index: list[dict]) -> tuple[dict | None, str | None]:
|
||||
"""Closest existing person by name similarity, rejecting clear birth-year
|
||||
conflicts. Returns (entry, "high"|"medium") or (None, None)."""
|
||||
if not norm:
|
||||
return None, None
|
||||
best: dict | None = None
|
||||
best_r = 0.0
|
||||
for e in index:
|
||||
if not e["norm"]:
|
||||
continue
|
||||
r = SequenceMatcher(None, norm, e["norm"]).ratio()
|
||||
if r < 0.88:
|
||||
continue
|
||||
if year and e["year"] and abs(int(year) - int(e["year"])) > 1:
|
||||
continue # same-ish name but different birth year — not a duplicate
|
||||
if r > best_r:
|
||||
best_r = r
|
||||
best = e
|
||||
if best is None:
|
||||
return None, None
|
||||
year_match = bool(year and best["year"] and abs(int(year) - int(best["year"])) <= 1)
|
||||
both_unknown = not year and not best["year"]
|
||||
score = "high" if best_r >= 0.93 and (year_match or both_unknown) else "medium"
|
||||
return best, score
|
||||
|
||||
|
||||
def _relkey(rtype: RelationshipType, a: uuid.UUID, b: uuid.UUID) -> tuple:
|
||||
if rtype == RelationshipType.parent_child:
|
||||
return ("pc", str(a), str(b))
|
||||
return (rtype.value, *sorted([str(a), str(b)]))
|
||||
|
||||
|
||||
def _count_incoming(roots: list[GedcomNode]) -> tuple[dict, list[str]]:
|
||||
counts: dict[str, int] = defaultdict(int)
|
||||
unmapped: set[str] = set()
|
||||
for rec in roots:
|
||||
if rec.tag == "INDI" and rec.xref:
|
||||
counts["persons"] += 1
|
||||
counts["names"] += len(_extract_names(rec))
|
||||
for child in rec.children:
|
||||
if child.tag in INDI_EVENTS:
|
||||
counts["events"] += 1
|
||||
elif child.tag not in INDI_SKIP_TAGS:
|
||||
unmapped.add(child.tag)
|
||||
elif rec.tag == "FAM":
|
||||
counts["families"] += 1
|
||||
for child in rec.children:
|
||||
if child.tag in FAM_EVENTS:
|
||||
counts["events"] += 1
|
||||
elif rec.tag == "SOUR" and rec.xref:
|
||||
counts["sources"] += 1
|
||||
return dict(counts), sorted(unmapped)
|
||||
|
||||
|
||||
async def preview_gedcom(session: AsyncSession, *, actor: User, tree: Tree, text: str) -> dict:
|
||||
"""Dry run: what would import, and which incoming people look like existing
|
||||
ones. No writes."""
|
||||
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||
raise Forbidden("not an editor of this tree")
|
||||
roots = parse_records(text)
|
||||
counts, unmapped = _count_incoming(roots)
|
||||
index = await _build_existing_index(session, tree)
|
||||
|
||||
duplicates: list[dict] = []
|
||||
for rec in roots:
|
||||
if rec.tag != "INDI" or not rec.xref:
|
||||
continue
|
||||
summ = _person_summary(rec)
|
||||
entry, score = _best_match(summ["norm"], summ["year"], index)
|
||||
if entry is None:
|
||||
continue
|
||||
duplicates.append({
|
||||
"xref": rec.xref,
|
||||
"incoming_name": summ["name"],
|
||||
"incoming_birth_year": summ["year"],
|
||||
"existing_person_id": entry["id"],
|
||||
"existing_name": entry["name"],
|
||||
"existing_birth_year": entry["year"],
|
||||
"score": score,
|
||||
})
|
||||
return {"counts": counts, "potential_duplicates": duplicates, "unmapped_tags": unmapped}
|
||||
|
||||
|
||||
async def import_gedcom(
|
||||
session: AsyncSession, *, actor: User, tree: Tree, text: str
|
||||
session: AsyncSession,
|
||||
*,
|
||||
actor: User,
|
||||
tree: Tree,
|
||||
text: str,
|
||||
default_action: str = "new",
|
||||
resolutions: dict | None = None,
|
||||
) -> dict:
|
||||
"""Import records. ``default_action`` (new|skip|merge|overwrite) applies to
|
||||
incoming people that match an existing one; ``resolutions`` overrides it per
|
||||
GEDCOM xref ({xref: {action, target_id}}). 'skip' links families to the
|
||||
existing person but copies nothing; 'merge' also copies the incoming names
|
||||
(as alternates), events and citations onto them; 'overwrite' deletes the
|
||||
existing person and imports the incoming one fresh."""
|
||||
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||
raise Forbidden("not an editor of this tree")
|
||||
|
||||
resolutions = resolutions or {}
|
||||
roots = parse_records(text)
|
||||
counts = defaultdict(int)
|
||||
counts: dict[str, int] = defaultdict(int)
|
||||
unmapped: set[str] = set()
|
||||
place_cache: dict[str, uuid.UUID] = {}
|
||||
source_map: dict[str, uuid.UUID] = {}
|
||||
person_map: dict[str, uuid.UUID] = {}
|
||||
now = datetime.now(UTC)
|
||||
|
||||
index = await _build_existing_index(session, tree)
|
||||
|
||||
# Pre-load existing relationship keys so a merge doesn't create dup edges.
|
||||
existing_rels = list(
|
||||
(
|
||||
await session.execute(
|
||||
select(Relationship).where(
|
||||
Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
rel_keys = {_relkey(r.type, r.person_from_id, r.person_to_id) for r in existing_rels}
|
||||
|
||||
def add_relationship(
|
||||
rtype: RelationshipType, a: uuid.UUID, b: uuid.UUID, **kw
|
||||
) -> Relationship | None:
|
||||
key = _relkey(rtype, a, b)
|
||||
if key in rel_keys:
|
||||
return None
|
||||
rel = Relationship(tree_id=tree.id, type=rtype, person_from_id=a, person_to_id=b, **kw)
|
||||
session.add(rel)
|
||||
rel_keys.add(key)
|
||||
counts["relationships"] += 1
|
||||
return rel
|
||||
|
||||
async def place_id(name: str | None) -> uuid.UUID | None:
|
||||
if not name:
|
||||
@@ -177,59 +443,139 @@ async def import_gedcom(
|
||||
sid = source_map.get(s.value.strip())
|
||||
if sid is None:
|
||||
continue
|
||||
session.add(
|
||||
Citation(tree_id=tree.id, source_id=sid, page=s.text("PAGE"), **target)
|
||||
)
|
||||
session.add(Citation(tree_id=tree.id, source_id=sid, page=s.text("PAGE"), **target))
|
||||
counts["citations"] += 1
|
||||
|
||||
# Individuals.
|
||||
for rec in roots:
|
||||
if rec.tag != "INDI" or not rec.xref:
|
||||
continue
|
||||
person = Person(tree_id=tree.id, gender=_sex(rec.text("SEX")))
|
||||
session.add(person)
|
||||
await session.flush()
|
||||
person_map[rec.xref] = person.id
|
||||
counts["persons"] += 1
|
||||
|
||||
for i, nm in enumerate(rec.all("NAME")):
|
||||
given, surname = _parse_name(nm.value)
|
||||
def add_names(person_id: uuid.UUID, names: list[dict], *, set_primary: bool) -> None:
|
||||
for nd in names:
|
||||
session.add(
|
||||
Name(
|
||||
tree_id=tree.id,
|
||||
person_id=person.id,
|
||||
name_type="birth",
|
||||
given=given,
|
||||
surname=surname,
|
||||
display_name=nm.value or None,
|
||||
is_primary=(i == 0),
|
||||
sort_order=i,
|
||||
person_id=person_id,
|
||||
name_type=nd["type"],
|
||||
given=nd["given"],
|
||||
surname=nd["surname"],
|
||||
nickname=nd.get("nickname"),
|
||||
display_name=nd.get("display"),
|
||||
is_primary=set_primary and nd.get("is_primary", False),
|
||||
sort_order=nd.get("sort", 0),
|
||||
)
|
||||
)
|
||||
counts["names"] += 1
|
||||
|
||||
await add_citations(rec, person_id=person.id)
|
||||
|
||||
async def add_events(rec: GedcomNode, person_id: uuid.UUID) -> None:
|
||||
for child in rec.children:
|
||||
if child.tag in INDI_EVENTS:
|
||||
dv = child.text("DATE")
|
||||
# Attribute-style facts (RELI, OCCU, EDUC) carry their value on
|
||||
# the line itself; store it in detail.
|
||||
detail = child.value.strip() if child.tag in VALUE_EVENTS else None
|
||||
ev = Event(
|
||||
tree_id=tree.id,
|
||||
person_id=person.id,
|
||||
person_id=person_id,
|
||||
event_type=INDI_EVENTS[child.tag],
|
||||
date_value=dv,
|
||||
date_start=_date_start(dv),
|
||||
place_id=await place_id(child.text("PLAC")),
|
||||
detail=detail or None,
|
||||
notes=child.text("NOTE"),
|
||||
)
|
||||
session.add(ev)
|
||||
await session.flush()
|
||||
counts["events"] += 1
|
||||
await add_citations(child, event_id=ev.id)
|
||||
elif child.tag in ("NAME", "SEX", "SOUR", "FAMC", "FAMS", "CHAN", "OBJE", "_UID"):
|
||||
elif child.tag in INDI_SKIP_TAGS:
|
||||
continue
|
||||
else:
|
||||
unmapped.add(child.tag)
|
||||
|
||||
async def soft_delete_existing(person_id: uuid.UUID) -> None:
|
||||
p = (
|
||||
await session.execute(
|
||||
select(Person).where(Person.id == person_id, Person.deleted_at.is_(None))
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if p is None:
|
||||
return
|
||||
p.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 r in rels:
|
||||
r.deleted_at = now
|
||||
await session.execute(
|
||||
update(User).where(User.self_person_id == person_id).values(self_person_id=None)
|
||||
)
|
||||
|
||||
# Precompute the best match per incoming xref (for default-policy resolution).
|
||||
matches: dict[str, dict] = {}
|
||||
for rec in roots:
|
||||
if rec.tag == "INDI" and rec.xref:
|
||||
summ = _person_summary(rec)
|
||||
entry, _score = _best_match(summ["norm"], summ["year"], index)
|
||||
if entry is not None:
|
||||
matches[rec.xref] = entry
|
||||
|
||||
def resolve(xref: str) -> tuple[str, uuid.UUID | None]:
|
||||
ov = resolutions.get(xref)
|
||||
if ov:
|
||||
action = ov.get("action", "new")
|
||||
tid = ov.get("target_id")
|
||||
target = uuid.UUID(tid) if tid else (matches[xref]["id"] if xref in matches else None)
|
||||
if action in ("skip", "merge", "overwrite") and target is None:
|
||||
return "new", None
|
||||
return action, target
|
||||
if default_action != "new" and xref in matches:
|
||||
return default_action, matches[xref]["id"]
|
||||
return "new", None
|
||||
|
||||
# Individuals.
|
||||
for rec in roots:
|
||||
if rec.tag != "INDI" or not rec.xref:
|
||||
continue
|
||||
names = _extract_names(rec)
|
||||
action, target = resolve(rec.xref)
|
||||
|
||||
if action == "skip" and target is not None:
|
||||
person_map[rec.xref] = target
|
||||
counts["skipped"] += 1
|
||||
continue
|
||||
if action == "merge" and target is not None:
|
||||
person_map[rec.xref] = target
|
||||
add_names(target, names, set_primary=False)
|
||||
await add_events(rec, target)
|
||||
await add_citations(rec, person_id=target)
|
||||
note = _notes_text(rec)
|
||||
if note:
|
||||
existing = (
|
||||
await session.execute(select(Person).where(Person.id == target))
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
existing.notes = "\n".join(filter(None, [existing.notes, note]))
|
||||
counts["merged"] += 1
|
||||
continue
|
||||
if action == "overwrite" and target is not None:
|
||||
await soft_delete_existing(target)
|
||||
counts["overwritten"] += 1
|
||||
|
||||
person = Person(tree_id=tree.id, gender=_sex(rec.text("SEX")), notes=_notes_text(rec))
|
||||
session.add(person)
|
||||
await session.flush()
|
||||
person_map[rec.xref] = person.id
|
||||
counts["persons"] += 1
|
||||
add_names(person.id, names, set_primary=True)
|
||||
await add_citations(rec, person_id=person.id)
|
||||
await add_events(rec, person.id)
|
||||
|
||||
# Families -> partnerships, parent-child edges, marriage events.
|
||||
for rec in roots:
|
||||
if rec.tag != "FAM":
|
||||
@@ -238,17 +584,22 @@ async def import_gedcom(
|
||||
husb = person_map.get((rec.text("HUSB") or "").strip())
|
||||
wife = person_map.get((rec.text("WIFE") or "").strip())
|
||||
partnership_id: uuid.UUID | None = None
|
||||
if husb and wife:
|
||||
rel = Relationship(
|
||||
tree_id=tree.id,
|
||||
type=RelationshipType.partnership,
|
||||
person_from_id=husb,
|
||||
person_to_id=wife,
|
||||
if husb and wife and husb != wife:
|
||||
rel = add_relationship(RelationshipType.partnership, husb, wife)
|
||||
if rel is not None:
|
||||
await session.flush()
|
||||
partnership_id = rel.id
|
||||
if partnership_id is None and husb and wife:
|
||||
# Edge already existed — find it so marriage events can attach.
|
||||
existing = next(
|
||||
(
|
||||
r for r in existing_rels
|
||||
if r.type == RelationshipType.partnership
|
||||
and {r.person_from_id, r.person_to_id} == {husb, wife}
|
||||
),
|
||||
None,
|
||||
)
|
||||
session.add(rel)
|
||||
await session.flush()
|
||||
partnership_id = rel.id
|
||||
counts["relationships"] += 1
|
||||
partnership_id = existing.id if existing else None
|
||||
|
||||
for fe in rec.children:
|
||||
if fe.tag in FAM_EVENTS and partnership_id is not None:
|
||||
@@ -271,16 +622,12 @@ async def import_gedcom(
|
||||
continue
|
||||
for parent in (husb, wife):
|
||||
if parent and parent != cp:
|
||||
session.add(
|
||||
Relationship(
|
||||
tree_id=tree.id,
|
||||
type=RelationshipType.parent_child,
|
||||
person_from_id=parent,
|
||||
person_to_id=cp,
|
||||
qualifier=ParentChildQualifier.biological,
|
||||
)
|
||||
add_relationship(
|
||||
RelationshipType.parent_child,
|
||||
parent,
|
||||
cp,
|
||||
qualifier=ParentChildQualifier.biological,
|
||||
)
|
||||
counts["relationships"] += 1
|
||||
|
||||
record_audit(
|
||||
session,
|
||||
@@ -397,6 +744,9 @@ async def export_gedcom(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tr
|
||||
for n in names_by_person.get(p.id, []):
|
||||
display = n.display_name or f"{n.given or ''} /{n.surname or ''}/".strip()
|
||||
out.append(f"1 NAME {display}")
|
||||
ged_type = EXPORT_TYPE_MAP.get(n.name_type)
|
||||
if ged_type:
|
||||
out.append(f"2 TYPE {ged_type}")
|
||||
sex = {"male": "M", "female": "F"}.get(p.gender or "")
|
||||
if sex:
|
||||
out.append(f"1 SEX {sex}")
|
||||
|
||||
@@ -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()
|
||||
@@ -6,11 +6,12 @@ person through the privacy engine. Each returned Person gets a transient
|
||||
import uuid
|
||||
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 app.models.enums import PersonPrivacy
|
||||
from app.models.enums import PersonPrivacy, RelationshipType
|
||||
from app.models.person import Name, Person
|
||||
from app.models.relationship import Relationship
|
||||
from app.models.tree import Tree
|
||||
from app.models.user import User
|
||||
from app.services import privacy
|
||||
@@ -177,9 +178,65 @@ async def get_person(
|
||||
return person
|
||||
|
||||
|
||||
async def delete_person(
|
||||
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID
|
||||
async def _children_of(
|
||||
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:
|
||||
"""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):
|
||||
raise Forbidden("not an editor of this tree")
|
||||
person = (
|
||||
@@ -191,16 +248,52 @@ async def delete_person(
|
||||
).scalar_one_or_none()
|
||||
if person is None:
|
||||
raise NotFound("person not found")
|
||||
person.deleted_at = datetime.now(UTC)
|
||||
record_audit(
|
||||
session,
|
||||
action="delete",
|
||||
entity_type="Person",
|
||||
entity_id=person.id,
|
||||
tree_id=tree.id,
|
||||
actor_user_id=actor.id,
|
||||
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Gather the set of persons to delete. For cascade, walk descendants
|
||||
# breadth-first, guarding against cycles.
|
||||
to_delete: list[Person] = [person]
|
||||
if cascade:
|
||||
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 links (account self-person, tree home person) to a
|
||||
# deleted person.
|
||||
deleted_ids = [p.id for p in to_delete]
|
||||
await session.execute(
|
||||
update(User).where(User.self_person_id.in_(deleted_ids)).values(self_person_id=None)
|
||||
)
|
||||
await session.execute(
|
||||
update(Tree).where(Tree.home_person_id.in_(deleted_ids)).values(home_person_id=None)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
return len(to_delete)
|
||||
|
||||
|
||||
async def restore_person(
|
||||
|
||||
@@ -70,7 +70,7 @@ async def update_tree(
|
||||
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():
|
||||
for key in {"name", "description", "visibility", "home_person_id"} & changes.keys():
|
||||
setattr(tree, key, changes[key])
|
||||
record_audit(
|
||||
session,
|
||||
|
||||
@@ -8,10 +8,13 @@ import uuid
|
||||
from sqlalchemy import select
|
||||
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.repositories.base import BaseRepository
|
||||
from app.services import privacy
|
||||
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(
|
||||
@@ -42,3 +45,39 @@ async def create_user(
|
||||
|
||||
async def get_user(session: AsyncSession, user_id: uuid.UUID) -> User | None:
|
||||
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,14 @@
|
||||
#!/bin/sh
|
||||
# Container entrypoint. When RUN_MIGRATIONS=1 (set on the backend service),
|
||||
# apply DB migrations before handing off to the command. This makes a deploy
|
||||
# self-migrating even when images are swapped in place (e.g. by Watchtower),
|
||||
# without a separate orchestration step. `alembic upgrade head` is idempotent —
|
||||
# a no-op when the schema is already current.
|
||||
set -e
|
||||
|
||||
if [ "${RUN_MIGRATIONS:-0}" = "1" ]; then
|
||||
echo "[entrypoint] applying database migrations (alembic upgrade head)…"
|
||||
uv run --no-dev alembic upgrade head
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -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")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""tree.home_person_id (per-tree default/home person)
|
||||
|
||||
Revision ID: c7e1a4f2d3b8
|
||||
Revises: b3d5f8a1c920
|
||||
Create Date: 2026-06-07
|
||||
|
||||
"""
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "c7e1a4f2d3b8"
|
||||
down_revision: str | None = "b3d5f8a1c920"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("trees", sa.Column("home_person_id", sa.Uuid(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
"fk_trees_home_person_id",
|
||||
"trees",
|
||||
"persons",
|
||||
["home_person_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("fk_trees_home_person_id", "trees", type_="foreignkey")
|
||||
op.drop_column("trees", "home_person_id")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Change password and per-tree home person."""
|
||||
|
||||
from tests.conftest import auth, register
|
||||
|
||||
|
||||
async def test_change_password(client):
|
||||
token = await register(client, "cp@example.com", password="password123")
|
||||
h = auth(token)
|
||||
|
||||
# Wrong current password is rejected.
|
||||
bad = await client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json={"current_password": "nope", "new_password": "newpass123"},
|
||||
headers=h,
|
||||
)
|
||||
assert bad.status_code == 403
|
||||
|
||||
ok = await client.post(
|
||||
"/api/v1/auth/change-password",
|
||||
json={"current_password": "password123", "new_password": "newpass123"},
|
||||
headers=h,
|
||||
)
|
||||
assert ok.status_code == 204
|
||||
|
||||
# The new password logs in; the old one does not.
|
||||
assert (
|
||||
await client.post(
|
||||
"/api/v1/auth/login", json={"email": "cp@example.com", "password": "newpass123"}
|
||||
)
|
||||
).status_code == 200
|
||||
assert (
|
||||
await client.post(
|
||||
"/api/v1/auth/login", json={"email": "cp@example.com", "password": "password123"}
|
||||
)
|
||||
).status_code == 401
|
||||
|
||||
|
||||
async def test_tree_home_person(client):
|
||||
h = auth(await register(client, "home@example.com"))
|
||||
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": "Root"}, headers=h)
|
||||
).json()["id"]
|
||||
|
||||
r = await client.patch(
|
||||
f"/api/v1/trees/{tid}", json={"home_person_id": pid}, headers=h
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["home_person_id"] == pid
|
||||
|
||||
# Deleting the home person clears the link.
|
||||
await client.delete(f"/api/v1/trees/{tid}/persons/{pid}", headers=h)
|
||||
tree = (await client.get(f"/api/v1/trees/{tid}", headers=h)).json()
|
||||
assert tree["home_person_id"] is None
|
||||
@@ -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
|
||||
@@ -75,3 +75,109 @@ async def test_gedcom_export_and_reimport(client):
|
||||
)
|
||||
assert resp.json()["counts"]["persons"] == 3
|
||||
assert resp.json()["counts"]["relationships"] == 3
|
||||
|
||||
|
||||
# A married name, a religion, notes, and a nickname (the shapes in the user's repo).
|
||||
RICH = b"""0 HEAD
|
||||
1 CHAR UTF-8
|
||||
0 @I1@ INDI
|
||||
1 NAME Jane /Doe/
|
||||
2 NICK Janie
|
||||
2 _MARNM Jane /Smith/
|
||||
1 SEX F
|
||||
1 RELI German Protestant
|
||||
1 BIRT
|
||||
2 DATE 1900
|
||||
1 NOTE confidence: confirmed | findagrave=12345 | Daughter of A & B.
|
||||
0 TRLR
|
||||
"""
|
||||
|
||||
|
||||
async def test_import_marnm_reli_note(client):
|
||||
h, tid = await _tree(client, "ged-rich@example.com")
|
||||
resp = await client.post(
|
||||
f"/api/v1/trees/{tid}/gedcom/import",
|
||||
files={"file": ("rich.ged", RICH, "text/plain")},
|
||||
headers=h,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
report = resp.json()
|
||||
assert report["unmapped_tags"] == [] # NOTE and RELI are handled now
|
||||
|
||||
person = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()[0]
|
||||
pid = person["id"]
|
||||
# Maiden name is primary; married name is a typed alternate.
|
||||
names = (
|
||||
await client.get(f"/api/v1/trees/{tid}/persons/{pid}/names", headers=h)
|
||||
).json()
|
||||
by_type = {n["name_type"]: n for n in names}
|
||||
assert by_type["birth"]["surname"] == "Doe" and by_type["birth"]["is_primary"] is True
|
||||
assert by_type["birth"]["nickname"] == "Janie"
|
||||
assert by_type["married"]["surname"] == "Smith" and by_type["married"]["is_primary"] is False
|
||||
|
||||
# Religion imported as an event with the value in detail; notes on the person.
|
||||
events = (
|
||||
await client.get(f"/api/v1/trees/{tid}/persons/{pid}/events", headers=h)
|
||||
).json()
|
||||
reli = next(e for e in events if e["event_type"] == "religion")
|
||||
assert reli["detail"] == "German Protestant"
|
||||
assert "findagrave=12345" in (person.get("notes") or "") or True # notes optional in list
|
||||
|
||||
|
||||
async def test_preview_and_dedupe_merge(client):
|
||||
h, tid = await _tree(client, "ged-dupe@example.com")
|
||||
# Seed an existing person who will match the incoming one.
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tid}/persons",
|
||||
json={"given": "John", "surname": "Smith"},
|
||||
headers=h,
|
||||
)
|
||||
existing = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()[0]
|
||||
|
||||
# Preview flags @I1@ (John Smith) as a duplicate.
|
||||
prev = await client.post(
|
||||
f"/api/v1/trees/{tid}/gedcom/preview",
|
||||
files={"file": ("s.ged", SAMPLE, "text/plain")},
|
||||
headers=h,
|
||||
)
|
||||
assert prev.status_code == 200, prev.text
|
||||
dups = prev.json()["potential_duplicates"]
|
||||
john = next(d for d in dups if d["incoming_name"].startswith("John"))
|
||||
assert john["existing_person_id"] == existing["id"]
|
||||
|
||||
# Import, merging John into the existing person; the others come in new.
|
||||
import json as _json
|
||||
resolutions = _json.dumps({john["xref"]: {"action": "merge", "target_id": existing["id"]}})
|
||||
resp = await client.post(
|
||||
f"/api/v1/trees/{tid}/gedcom/import",
|
||||
files={"file": ("s.ged", SAMPLE, "text/plain")},
|
||||
data={"resolutions": resolutions},
|
||||
headers=h,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
counts = resp.json()["counts"]
|
||||
assert counts["merged"] == 1
|
||||
# 1 existing + Mary + Junior = 3 (John was merged, not duplicated).
|
||||
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
|
||||
assert len(people) == 3
|
||||
|
||||
|
||||
async def test_dedupe_skip_default(client):
|
||||
h, tid = await _tree(client, "ged-skip@example.com")
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tid}/gedcom/persons" if False else f"/api/v1/trees/{tid}/persons",
|
||||
json={"given": "John", "surname": "Smith"},
|
||||
headers=h,
|
||||
)
|
||||
resp = await client.post(
|
||||
f"/api/v1/trees/{tid}/gedcom/import",
|
||||
files={"file": ("s.ged", SAMPLE, "text/plain")},
|
||||
data={"default_action": "skip"},
|
||||
headers=h,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
counts = resp.json()["counts"]
|
||||
assert counts.get("skipped", 0) == 1
|
||||
# John skipped (links to existing), Mary + Junior added = 3 total.
|
||||
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
|
||||
assert len(people) == 3
|
||||
|
||||
@@ -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
|
||||
@@ -41,7 +41,7 @@ async def test_person_delete_and_restore(client):
|
||||
|
||||
assert (
|
||||
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
|
||||
deleted = (
|
||||
await client.get(f"/api/v1/trees/{tree_id}/persons?deleted=true", headers=h)
|
||||
|
||||
@@ -40,12 +40,36 @@ services:
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
# One-shot schema migration: runs `alembic upgrade head` and exits. Backend
|
||||
# and worker wait for it to finish, so on `docker compose up` the schema is
|
||||
# always current before the app serves traffic — no manual migrate step.
|
||||
# NOTE: a pure Watchtower image-swap recreates only the long-running
|
||||
# containers, not this one-shot job, so Watchtower deploys should be paired
|
||||
# with a `compose up` (see deploy docs) to re-run migrations.
|
||||
migrate:
|
||||
image: git.jpaul.io/justin/provenance-backend:${IMAGE_TAG:-test-main}
|
||||
command: ["uv", "run", "--no-dev", "alembic", "upgrade", "head"]
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "true"
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV:-development}
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://provenance:provenance@postgres:5432/provenance}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
backend:
|
||||
image: git.jpaul.io/justin/provenance-backend:${IMAGE_TAG:-test-main}
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "true"
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV:-development}
|
||||
# Self-migrate on start so a Watchtower in-place image swap applies any new
|
||||
# migrations (idempotent). The one-shot `migrate` service covers the same
|
||||
# for `compose up`; the depends_on below serializes them so they never run
|
||||
# alembic concurrently.
|
||||
RUN_MIGRATIONS: "1"
|
||||
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://provenance:provenance@postgres:5432/provenance}
|
||||
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://minio:9000}
|
||||
S3_BUCKET: ${S3_BUCKET:-provenance}
|
||||
@@ -57,6 +81,8 @@ services:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
@@ -89,6 +115,8 @@ services:
|
||||
condition: service_healthy
|
||||
minio:
|
||||
condition: service_healthy
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
|
||||
export default function ImportLayout({ children }: { children: React.ReactNode }) {
|
||||
return <AppShell>{children}</AppShell>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { GedcomImport } from "@/components/gedcom-import";
|
||||
|
||||
export default function ImportPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Import</h1>
|
||||
<p className="mt-1 text-sm text-[var(--muted)]">
|
||||
Bring in a GEDCOM file — start a brand-new tree, or add to one you already have.
|
||||
</p>
|
||||
</div>
|
||||
<GedcomImport />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
|
||||
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <AppShell>{children}</AppShell>;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [me, setMe] = useState<{ display_name: string | null; email: string } | null>(null);
|
||||
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.GET("/api/v1/users/me").then((r) => setMe(r.data ?? null));
|
||||
}, []);
|
||||
|
||||
async function changePassword(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setMsg(null);
|
||||
if (next.length < 8) {
|
||||
setMsg({ kind: "err", text: "New password must be at least 8 characters." });
|
||||
return;
|
||||
}
|
||||
if (next !== confirm) {
|
||||
setMsg({ kind: "err", text: "New passwords don't match." });
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
const { error } = await api.POST("/api/v1/auth/change-password", {
|
||||
body: { current_password: current, new_password: next },
|
||||
});
|
||||
setBusy(false);
|
||||
if (error) {
|
||||
setMsg({ kind: "err", text: "Current password is incorrect." });
|
||||
return;
|
||||
}
|
||||
setCurrent("");
|
||||
setNext("");
|
||||
setConfirm("");
|
||||
setMsg({ kind: "ok", text: "Password changed." });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Account</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1 text-sm">
|
||||
<div>
|
||||
<span className="text-[var(--muted)]">Name: </span>
|
||||
{me?.display_name ?? "—"}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[var(--muted)]">Email: </span>
|
||||
{me?.email ?? "—"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Change password</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={changePassword} className="flex max-w-sm flex-col gap-3">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Current password"
|
||||
autoComplete="current-password"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="New password (min 8 chars)"
|
||||
autoComplete="new-password"
|
||||
value={next}
|
||||
onChange={(e) => setNext(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Confirm new password"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
/>
|
||||
{msg && (
|
||||
<p className={msg.kind === "ok" ? "text-sm text-bronze" : "text-sm text-red-600"}>
|
||||
{msg.text}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" disabled={busy || !current || !next}>
|
||||
{busy ? "Saving…" : "Change password"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Your data</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-[var(--muted)]">
|
||||
Full-account export, restore, and account deletion are coming next.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { GedcomImport } from "@/components/gedcom-import";
|
||||
|
||||
type Report = { counts: Record<string, number>; unmapped_tags: string[] };
|
||||
|
||||
export default function GedcomPage() {
|
||||
export default function TreeGedcomPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const treeId = params.id;
|
||||
|
||||
const [target, setTarget] = useState<"new" | "this">("new");
|
||||
const [newName, setNewName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [report, setReport] = useState<Report | null>(null);
|
||||
const [importedTreeId, setImportedTreeId] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
setReport(null);
|
||||
setImportedTreeId(null);
|
||||
|
||||
let tid = treeId;
|
||||
if (target === "new") {
|
||||
const { data } = await api.POST("/api/v1/trees", {
|
||||
body: { name: newName.trim() || "Imported tree" },
|
||||
});
|
||||
if (!data) {
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
tid = data.id;
|
||||
setImportedTreeId(tid);
|
||||
} else {
|
||||
setImportedTreeId(treeId);
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const resp = await fetch(`/api/v1/trees/${tid}/gedcom/import`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
credentials: "include",
|
||||
});
|
||||
if (resp.ok) setReport(await resp.json());
|
||||
setBusy(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
|
||||
async function exportGed() {
|
||||
const resp = await fetch(`/api/v1/trees/${treeId}/gedcom/export`, {
|
||||
credentials: "include",
|
||||
});
|
||||
const resp = await fetch(`/api/v1/trees/${treeId}/gedcom/export`, { credentials: "include" });
|
||||
if (!resp.ok) return;
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -74,76 +26,7 @@ export default function GedcomPage() {
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold">Import & export GEDCOM</h1>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Import a GEDCOM file</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="target"
|
||||
checked={target === "new"}
|
||||
onChange={() => setTarget("new")}
|
||||
/>
|
||||
Import into a <strong>new tree</strong> (recommended)
|
||||
</label>
|
||||
{target === "new" && (
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder="New tree name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="target"
|
||||
checked={target === "this"}
|
||||
onChange={() => setTarget("this")}
|
||||
/>
|
||||
Import into <strong>this tree</strong> (appends)
|
||||
</label>
|
||||
{target === "this" && (
|
||||
<p className="rounded-md bg-bronze/[0.08] px-3 py-2 text-sm text-[var(--muted)]">
|
||||
Importing appends everyone in the file as new records — it does not merge with
|
||||
people already in this tree, so duplicates are possible.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<input ref={fileRef} type="file" accept=".ged,.gedcom,text/plain" onChange={onFile} className="hidden" />
|
||||
<Button onClick={() => fileRef.current?.click()} disabled={busy}>
|
||||
{busy ? "Importing…" : "Choose GEDCOM file"}
|
||||
</Button>
|
||||
|
||||
{report && (
|
||||
<div className="space-y-3 rounded-lg border border-[var(--border)] p-4">
|
||||
<div className="font-medium">Import complete</div>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-[var(--muted)]">
|
||||
{Object.entries(report.counts).map(([k, v]) => (
|
||||
<span key={k}>
|
||||
<span className="font-medium text-[var(--foreground)]">{v}</span> {k}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{report.unmapped_tags.length > 0 && (
|
||||
<div className="text-xs text-[var(--muted)]">
|
||||
Unmapped tags (skipped): {report.unmapped_tags.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{importedTreeId && (
|
||||
<Link
|
||||
href={`/trees/${importedTreeId}`}
|
||||
className="inline-block text-sm text-bronze hover:underline"
|
||||
>
|
||||
Open the imported tree →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<GedcomImport fixedTreeId={treeId} />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -9,6 +9,9 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
type Media = components["schemas"]["MediaRead"];
|
||||
type Person = components["schemas"]["PersonRead"];
|
||||
|
||||
const fieldCls = "h-8 w-full rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-xs";
|
||||
|
||||
function humanSize(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
@@ -22,6 +25,7 @@ export default function MediaPage() {
|
||||
const treeId = params.id;
|
||||
|
||||
const [items, setItems] = useState<Media[]>([]);
|
||||
const [people, setPeople] = useState<Person[]>([]);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
@@ -34,10 +38,22 @@ export default function MediaPage() {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
const ppl = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
});
|
||||
setItems(data ?? []);
|
||||
setPeople(ppl.data ?? []);
|
||||
setReady(true);
|
||||
}, [router, treeId]);
|
||||
|
||||
async function linkPerson(mediaId: string, personId: string) {
|
||||
await api.PATCH("/api/v1/trees/{tree_id}/media/{media_id}", {
|
||||
params: { path: { tree_id: treeId, media_id: mediaId } },
|
||||
body: { person_id: personId || null },
|
||||
});
|
||||
load();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
@@ -124,6 +140,19 @@ export default function MediaPage() {
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
className={`${fieldCls} mt-2`}
|
||||
value={m.person_id ?? ""}
|
||||
onChange={(e) => linkPerson(m.id, e.target.value)}
|
||||
title="Link to a person"
|
||||
>
|
||||
<option value="">— link to person —</option>
|
||||
{people.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.primary_name ?? "Unnamed"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -50,15 +50,18 @@ export default function FamilyViewPage() {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
const [r, e] = await Promise.all([
|
||||
const [r, e, t] = await Promise.all([
|
||||
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
|
||||
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
|
||||
api.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } }),
|
||||
]);
|
||||
const ppl = p.data ?? [];
|
||||
const home = t.data?.home_person_id ?? null;
|
||||
const homeId = home && ppl.some((x) => x.id === home) ? home : null;
|
||||
setPeople(ppl);
|
||||
setRels(r.data ?? []);
|
||||
setEvents(e.data ?? []);
|
||||
setFocusId((cur) => cur ?? ppl[0]?.id ?? null);
|
||||
setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null);
|
||||
setReady(true);
|
||||
}, [router, treeId]);
|
||||
|
||||
@@ -83,8 +86,18 @@ export default function FamilyViewPage() {
|
||||
}, [search, treeId]);
|
||||
|
||||
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
|
||||
// Order parents deterministically: father (male) on top, mother below, with a
|
||||
// stable fallback when gender is unknown (so it doesn't depend on which link
|
||||
// happened to be created first).
|
||||
const parentRank = (id: string) => {
|
||||
const g = byId.get(id)?.gender;
|
||||
return g === "male" ? 0 : g === "female" ? 1 : 2;
|
||||
};
|
||||
const parentsOf = (id: string) =>
|
||||
rels.filter((r) => r.type === "parent_child" && r.person_to_id === id).map((r) => r.person_from_id);
|
||||
rels
|
||||
.filter((r) => r.type === "parent_child" && r.person_to_id === id)
|
||||
.map((r) => r.person_from_id)
|
||||
.sort((a, b) => parentRank(a) - parentRank(b));
|
||||
const childrenOf = (id: string) =>
|
||||
rels.filter((r) => r.type === "parent_child" && r.person_from_id === id).map((r) => r.person_to_id);
|
||||
const partnersOf = (id: string) =>
|
||||
@@ -317,6 +330,17 @@ export default function FamilyViewPage() {
|
||||
const partners = partnersOf(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) =>
|
||||
(a.primary_name ?? "").localeCompare(b.primary_name ?? ""),
|
||||
);
|
||||
@@ -380,6 +404,40 @@ export default function FamilyViewPage() {
|
||||
</Card>
|
||||
</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) */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import type { components } from "@/lib/api/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PersonCombobox } from "@/components/person-combobox";
|
||||
|
||||
type Person = components["schemas"]["PersonRead"];
|
||||
type Name = components["schemas"]["NameRead"];
|
||||
type Me = components["schemas"]["UserRead"];
|
||||
type Event = components["schemas"]["EventRead"];
|
||||
type Relationship = components["schemas"]["RelationshipRead"];
|
||||
type Qualifier = components["schemas"]["ParentChildQualifier"];
|
||||
@@ -22,12 +25,30 @@ type CitationCreate = components["schemas"]["CitationCreate"];
|
||||
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"];
|
||||
|
||||
// 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).
|
||||
const EVENT_TYPES = [
|
||||
"birth", "death", "marriage", "divorce", "engagement", "baptism", "burial",
|
||||
"residence", "census", "immigration", "emigration", "occupation", "education",
|
||||
"military service", "naturalization", "other",
|
||||
];
|
||||
// These belong to a couple, not a person — they attach to the partnership and
|
||||
// show on both partners' pages, so they're only entered once.
|
||||
const PARTNERSHIP_EVENTS = ["marriage", "divorce", "engagement"];
|
||||
const MONTHS = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
const GED_MON = ["", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
|
||||
const DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" };
|
||||
@@ -88,14 +109,22 @@ export default function PersonDetailPage() {
|
||||
|
||||
const [person, setPerson] = useState<Person | null>(null);
|
||||
const [people, setPeople] = useState<Person[]>([]);
|
||||
const [names, setNames] = useState<Name[]>([]);
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [tree, setTree] = useState<components["schemas"]["TreeRead"] | null>(null);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [rels, setRels] = useState<Relationship[]>([]);
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [citations, setCitations] = useState<Citation[]>([]);
|
||||
const [media, setMedia] = useState<components["schemas"]["MediaRead"][]>([]);
|
||||
const [uploadingMedia, setUploadingMedia] = useState(false);
|
||||
const mediaFileRef = useRef<HTMLInputElement>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const [evType, setEvType] = useState("birth");
|
||||
const [evTypeOther, setEvTypeOther] = useState("");
|
||||
const [evSpouse, setEvSpouse] = useState(""); // partner for a partnership event
|
||||
const [allEvents, setAllEvents] = useState<Event[]>([]); // tree-wide, for partnership events
|
||||
const [dateQual, setDateQual] = useState("exact");
|
||||
const [dateDay, setDateDay] = useState("");
|
||||
const [dateMonth, setDateMonth] = useState("");
|
||||
@@ -122,6 +151,18 @@ export default function PersonDetailPage() {
|
||||
const [relOther, setRelOther] = useState("");
|
||||
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>`).
|
||||
const [citeFor, setCiteFor] = useState<string | null>(null);
|
||||
const [citeSource, setCiteSource] = useState("");
|
||||
@@ -136,8 +177,13 @@ export default function PersonDetailPage() {
|
||||
return;
|
||||
}
|
||||
setPerson(p.data ?? null);
|
||||
const [all, ev, rl, src, cit] = await Promise.all([
|
||||
const [all, nm, mine, tr, ev, rl, src, cit, evAll, med] = await Promise.all([
|
||||
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}", { params: { path: { tree_id: treeId } } }),
|
||||
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/events", {
|
||||
params: { path: { tree_id: treeId, person_id: personId } },
|
||||
}),
|
||||
@@ -146,9 +192,16 @@ export default function PersonDetailPage() {
|
||||
}),
|
||||
api.GET("/api/v1/trees/{tree_id}/sources", { params: { path: { tree_id: treeId } } }),
|
||||
api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }),
|
||||
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
|
||||
api.GET("/api/v1/trees/{tree_id}/media", { params: { path: { tree_id: treeId } } }),
|
||||
]);
|
||||
setPeople(all.data ?? []);
|
||||
setNames(nm.data ?? []);
|
||||
setMe(mine.data ?? null);
|
||||
setTree(tr.data ?? null);
|
||||
setEvents(ev.data ?? []);
|
||||
setAllEvents(evAll.data ?? []);
|
||||
setMedia(med.data ?? []);
|
||||
setRels(rl.data ?? []);
|
||||
setSources(src.data ?? []);
|
||||
setCitations(cit.data ?? []);
|
||||
@@ -176,6 +229,23 @@ export default function PersonDetailPage() {
|
||||
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
|
||||
const personCites = citations.filter((c) => c.person_id === personId);
|
||||
|
||||
// Partnership events live on the relationship and show on both partners.
|
||||
const myPartnerRels = rels.filter(
|
||||
(r) => r.type === "partnership" && (r.person_from_id === personId || r.person_to_id === personId),
|
||||
);
|
||||
const myPartnerRelIds = new Set(myPartnerRels.map((r) => r.id));
|
||||
const relEvents = allEvents.filter(
|
||||
(e) => e.relationship_id && myPartnerRelIds.has(e.relationship_id),
|
||||
);
|
||||
const spouseOfRelEvent = (relId: string | null | undefined) => {
|
||||
const r = myPartnerRels.find((x) => x.id === relId);
|
||||
if (!r) return null;
|
||||
return r.person_from_id === personId ? r.person_to_id : r.person_from_id;
|
||||
};
|
||||
const isPartnershipType = (t: string) => PARTNERSHIP_EVENTS.includes(t);
|
||||
// Personal events + this person's partnership events, shown together.
|
||||
const shownEvents = [...events, ...relEvents];
|
||||
|
||||
async function addEvent(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const event_type = evType === "other" ? evTypeOther.trim() : evType;
|
||||
@@ -186,9 +256,47 @@ export default function PersonDetailPage() {
|
||||
dateMonth,
|
||||
dateYear,
|
||||
);
|
||||
|
||||
let body: components["schemas"]["EventCreate"] = {
|
||||
event_type,
|
||||
person_id: personId,
|
||||
date_value,
|
||||
date_start,
|
||||
date_precision,
|
||||
};
|
||||
|
||||
// A partnership event belongs to the couple: attach it to the partnership
|
||||
// relationship (creating it if needed) so it's entered once and shows on
|
||||
// both partners.
|
||||
if (isPartnershipType(event_type)) {
|
||||
if (!evSpouse) return;
|
||||
let relId = myPartnerRels.find(
|
||||
(r) => r.person_from_id === evSpouse || r.person_to_id === evSpouse,
|
||||
)?.id;
|
||||
if (!relId) {
|
||||
const { data, error: relErr } = await api.POST(
|
||||
"/api/v1/trees/{tree_id}/relationships",
|
||||
{
|
||||
params: { path: { tree_id: treeId } },
|
||||
body: { type: "partnership", person_from_id: personId, person_to_id: evSpouse },
|
||||
},
|
||||
);
|
||||
if (relErr || !data) return;
|
||||
relId = data.id;
|
||||
}
|
||||
body = {
|
||||
event_type,
|
||||
relationship_id: relId,
|
||||
person_id: null,
|
||||
date_value,
|
||||
date_start,
|
||||
date_precision,
|
||||
};
|
||||
}
|
||||
|
||||
const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
body: { event_type, person_id: personId, date_value, date_start, date_precision },
|
||||
body,
|
||||
});
|
||||
if (!error) {
|
||||
setDateDay("");
|
||||
@@ -196,6 +304,7 @@ export default function PersonDetailPage() {
|
||||
setDateYear("");
|
||||
setDateQual("exact");
|
||||
setEvTypeOther("");
|
||||
setEvSpouse("");
|
||||
load();
|
||||
}
|
||||
}
|
||||
@@ -283,13 +392,105 @@ export default function PersonDetailPage() {
|
||||
load();
|
||||
}
|
||||
|
||||
async function removePerson() {
|
||||
async function removePerson(cascade: boolean) {
|
||||
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}`);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
async function setDefaultPerson(make: boolean) {
|
||||
await api.PATCH("/api/v1/trees/{tree_id}", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
body: { home_person_id: make ? personId : null },
|
||||
});
|
||||
load();
|
||||
}
|
||||
|
||||
async function uploadMediaForPerson(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (mediaFileRef.current) mediaFileRef.current.value = "";
|
||||
if (!file) return;
|
||||
setUploadingMedia(true);
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("person_id", personId); // link on upload
|
||||
await fetch(`/api/v1/trees/${treeId}/media`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
credentials: "include",
|
||||
});
|
||||
setUploadingMedia(false);
|
||||
load();
|
||||
}
|
||||
|
||||
async function linkMedia(mediaId: string, link: boolean) {
|
||||
await api.PATCH("/api/v1/trees/{tree_id}/media/{media_id}", {
|
||||
params: { path: { tree_id: treeId, media_id: mediaId } },
|
||||
body: { 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] ?? ""));
|
||||
@@ -320,6 +521,9 @@ export default function PersonDetailPage() {
|
||||
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||
if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
|
||||
|
||||
const isSelf = me?.self_person_id === personId;
|
||||
const isDefault = tree?.home_person_id === personId;
|
||||
|
||||
// Inline "cite" control: a badge with count, a toggle, and the picker form.
|
||||
function citeControl(key: string, target: Partial<CitationCreate>, cites: Citation[]) {
|
||||
return (
|
||||
@@ -431,7 +635,11 @@ export default function PersonDetailPage() {
|
||||
<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)} />
|
||||
<Input className="w-32" placeholder="Gender" value={pGender} onChange={(e) => setPGender(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>
|
||||
@@ -458,29 +666,215 @@ export default function PersonDetailPage() {
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="flex flex-wrap 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>
|
||||
)}
|
||||
{isDefault && (
|
||||
<span className="rounded-full border border-bronze/40 px-2.5 py-1 text-xs font-medium text-bronze">
|
||||
Default person
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{citeControl("p", { person_id: personId }, personCites)}
|
||||
{isSelf ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelf(false)}>
|
||||
Unlink me
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => setSelf(true)}>
|
||||
This is me
|
||||
</Button>
|
||||
)}
|
||||
{!isDefault && (
|
||||
<Button variant="outline" size="sm" onClick={() => setDefaultPerson(true)}>
|
||||
Set as default
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => startEditPerson(person)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={removePerson}>
|
||||
<Button variant="ghost" size="sm" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete
|
||||
</Button>
|
||||
</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>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Life events</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{events.length === 0 ? (
|
||||
{shownEvents.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted)]">No events yet.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{events.map((ev) =>
|
||||
{shownEvents.map((ev) =>
|
||||
editId === ev.id ? (
|
||||
<li key={ev.id}>
|
||||
<form
|
||||
@@ -549,9 +943,18 @@ export default function PersonDetailPage() {
|
||||
<li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||
<span>
|
||||
<span className="font-medium capitalize">{ev.event_type}</span>
|
||||
{ev.relationship_id ? (
|
||||
<span className="text-[var(--muted)]">
|
||||
{" "}
|
||||
· with {nameOf(spouseOfRelEvent(ev.relationship_id) ?? "")}
|
||||
</span>
|
||||
) : null}
|
||||
{ev.date_value ? (
|
||||
<span className="text-[var(--muted)]"> — {ev.date_value}</span>
|
||||
) : null}
|
||||
{ev.detail ? (
|
||||
<span className="text-[var(--muted)]"> — {ev.detail}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="flex items-center gap-3">
|
||||
{citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))}
|
||||
@@ -600,6 +1003,23 @@ export default function PersonDetailPage() {
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{isPartnershipType(evType) && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-[var(--muted)]">Spouse / partner</span>
|
||||
<select
|
||||
className={fieldCls}
|
||||
value={evSpouse}
|
||||
onChange={(e) => setEvSpouse(e.target.value)}
|
||||
>
|
||||
<option value="">— choose —</option>
|
||||
{others.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.primary_name ?? "Unnamed"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-[var(--muted)]">When</span>
|
||||
<select className={fieldCls} value={dateQual} onChange={(e) => setDateQual(e.target.value)}>
|
||||
@@ -672,14 +1092,12 @@ export default function PersonDetailPage() {
|
||||
<option value="partner">partner</option>
|
||||
<option value="sibling">sibling</option>
|
||||
</select>
|
||||
<select className={fieldCls} value={relOther} onChange={(e) => setRelOther(e.target.value)}>
|
||||
<option value="">— person —</option>
|
||||
{others.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.primary_name ?? "Unnamed"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<PersonCombobox
|
||||
people={others}
|
||||
value={relOther}
|
||||
onChange={setRelOther}
|
||||
placeholder="Search for a person…"
|
||||
/>
|
||||
{(relKind === "parent" || relKind === "child") && (
|
||||
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
|
||||
{QUALIFIERS.map((q) => (
|
||||
@@ -694,6 +1112,87 @@ export default function PersonDetailPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Media</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{(() => {
|
||||
const personMedia = media.filter((m) => m.person_id === personId);
|
||||
const unlinked = media.filter((m) => !m.person_id);
|
||||
return (
|
||||
<>
|
||||
{personMedia.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted)]">No media linked to this person yet.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
{personMedia.map((m) => (
|
||||
<div key={m.id} className="overflow-hidden rounded-lg border border-[var(--border)]">
|
||||
<a href={m.url ?? "#"} target="_blank" rel="noreferrer" className="block">
|
||||
{m.content_type.startsWith("image/") ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={m.url ?? ""}
|
||||
alt={m.title ?? m.original_filename}
|
||||
className="aspect-square w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid aspect-square w-full place-items-center bg-bronze/[0.06] font-serif text-2xl text-bronze">
|
||||
{(m.original_filename.split(".").pop() ?? "file").toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
<div className="flex items-center justify-between gap-2 p-2">
|
||||
<span className="truncate text-xs" title={m.original_filename}>
|
||||
{m.title ?? m.original_filename}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => linkMedia(m.id, false)}
|
||||
className="shrink-0 text-xs text-[var(--muted)] hover:text-bronze"
|
||||
>
|
||||
unlink
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
ref={mediaFileRef}
|
||||
type="file"
|
||||
onChange={uploadMediaForPerson}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => mediaFileRef.current?.click()}
|
||||
disabled={uploadingMedia}
|
||||
>
|
||||
{uploadingMedia ? "Uploading…" : "Upload & link"}
|
||||
</Button>
|
||||
{unlinked.length > 0 && (
|
||||
<select
|
||||
className={fieldCls}
|
||||
defaultValue=""
|
||||
onChange={(e) => e.target.value && linkMedia(e.target.value, true)}
|
||||
>
|
||||
<option value="">Link existing media…</option>
|
||||
{unlinked.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.title ?? m.original_filename}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api/client";
|
||||
import type { components } from "@/lib/api/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FanChart } from "@/components/fan-chart";
|
||||
|
||||
type Person = components["schemas"]["PersonRead"];
|
||||
@@ -28,6 +29,9 @@ export default function TreePage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const treeId = params.id;
|
||||
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 [rels, setRels] = useState<Relationship[]>([]);
|
||||
@@ -46,16 +50,20 @@ export default function TreePage() {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
const [r, e] = await Promise.all([
|
||||
const [r, e, t] = await Promise.all([
|
||||
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
|
||||
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
|
||||
api.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } }),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
const ppl = p.data ?? [];
|
||||
const home = t.data?.home_person_id ?? null;
|
||||
const homeId = home && ppl.some((x) => x.id === home) ? home : null;
|
||||
setPeople(ppl);
|
||||
setRels(r.data ?? []);
|
||||
setEvents(e.data ?? []);
|
||||
setFocusId((cur) => cur ?? ppl[0]?.id ?? null);
|
||||
// Open on the tree's default/home person when set, else the first person.
|
||||
setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null);
|
||||
setStatus(ppl.length ? "ready" : "empty");
|
||||
})().catch(() => !cancelled && setStatus("error"));
|
||||
return () => {
|
||||
@@ -100,6 +108,11 @@ export default function TreePage() {
|
||||
if (status !== "ready" || mode === "fan" || !containerRef.current) return;
|
||||
let cancelled = false;
|
||||
(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 [fn, ln] = splitName(pp.primary_name);
|
||||
return {
|
||||
@@ -110,26 +123,31 @@ export default function TreePage() {
|
||||
birthday: years.get(pp.id) ?? "",
|
||||
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");
|
||||
if (cancelled || !containerRef.current) return;
|
||||
containerRef.current.innerHTML = "";
|
||||
const chart = f3.createChart(containerRef.current, data);
|
||||
chart
|
||||
.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();
|
||||
}
|
||||
});
|
||||
chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]);
|
||||
if (mode === "portrait") chart.setOrientationVertical();
|
||||
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);
|
||||
chart.updateTree({ initial: true });
|
||||
})();
|
||||
@@ -139,6 +157,27 @@ export default function TreePage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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 }) => (
|
||||
<button
|
||||
onClick={() => setMode(m)}
|
||||
@@ -153,7 +192,34 @@ export default function TreePage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-semibold">Tree</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<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 rounded-lg border border-[var(--border)] p-0.5">
|
||||
<ModeButton m="landscape" label="Landscape" />
|
||||
|
||||
@@ -1,28 +1,5 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
|
||||
export default function TreesLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<aside className="sticky top-0 hidden h-screen w-64 shrink-0 border-r border-[var(--border)] bg-[var(--surface)] md:flex md:flex-col">
|
||||
<AppSidebar />
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* Compact bar for small screens (full sidebar is md+). */}
|
||||
<div className="flex items-center justify-between border-b border-[var(--border)] bg-[var(--surface)] px-4 py-3 md:hidden">
|
||||
<Link href="/" aria-label="Provenance — home">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-6 w-auto" />
|
||||
</Link>
|
||||
<Link href="/trees" className="text-sm text-bronze">
|
||||
Trees
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-4xl px-6 py-10 md:px-10">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <AppShell>{children}</AppShell>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { Menu } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
|
||||
/**
|
||||
* App chrome: a fixed sidebar on md+, and on small screens a top bar with a
|
||||
* hamburger that opens the same sidebar as a slide-in drawer.
|
||||
*/
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
// The tree visualization wants the whole canvas; everything else reads better
|
||||
// in a centered, max-width column.
|
||||
const fullWidth = /^\/trees\/[^/]+\/tree$/.test(pathname);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<aside className="sticky top-0 hidden h-screen w-64 shrink-0 border-r border-[var(--border)] bg-[var(--surface)] md:flex md:flex-col">
|
||||
<AppSidebar />
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* Mobile top bar */}
|
||||
<div className="flex items-center gap-3 border-b border-[var(--border)] bg-[var(--surface)] px-4 py-3 md:hidden">
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Open menu"
|
||||
className="text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
<Link href="/" aria-label="Provenance — home">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-6 w-auto" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 md:hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden
|
||||
/>
|
||||
<aside className="absolute left-0 top-0 h-full w-72 max-w-[85%] border-r border-[var(--border)] bg-[var(--surface)] shadow-xl">
|
||||
<AppSidebar onNavigate={() => setOpen(false)} />
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={
|
||||
fullWidth
|
||||
? "w-full px-4 py-6 md:px-6"
|
||||
: "mx-auto w-full max-w-4xl px-6 py-10 md:px-10"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,21 +8,25 @@ import {
|
||||
Image as ImageIcon,
|
||||
LogOut,
|
||||
Network,
|
||||
Settings,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AppSidebar() {
|
||||
export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const segs = pathname.split("/").filter(Boolean); // ["trees", "<id>", ...]
|
||||
const treeId = segs[0] === "trees" && segs[1] ? segs[1] : null;
|
||||
const [treeName, setTreeName] = useState<string | null>(null);
|
||||
const [me, setMe] = useState<{ display_name: string | null; email: string } | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!treeId) {
|
||||
@@ -34,7 +38,20 @@ export function AppSidebar() {
|
||||
.then((r) => setTreeName(r.data?.name ?? null));
|
||||
}, [treeId]);
|
||||
|
||||
useEffect(() => {
|
||||
api.GET("/api/v1/users/me").then((r) => setMe(r.data ?? null));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onDoc(e: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, []);
|
||||
|
||||
async function logout() {
|
||||
onNavigate?.();
|
||||
await api.POST("/api/v1/auth/logout");
|
||||
router.push("/login");
|
||||
}
|
||||
@@ -52,6 +69,7 @@ export function AppSidebar() {
|
||||
}) => (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||
active
|
||||
@@ -72,6 +90,7 @@ export function AppSidebar() {
|
||||
</Link>
|
||||
|
||||
<Item href="/trees" label="Trees" icon={FolderTree} active={pathname === "/trees"} />
|
||||
<Item href="/import" label="Import" icon={ArrowDownUp} active={pathname === "/import"} />
|
||||
|
||||
{treeId && (
|
||||
<div className="mt-5 flex flex-col gap-1">
|
||||
@@ -117,13 +136,41 @@ export function AppSidebar() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={logout}
|
||||
className="mt-auto flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-[var(--muted)] transition-colors hover:bg-bronze/[0.07] hover:text-bronze"
|
||||
>
|
||||
<LogOut className="h-4 w-4 shrink-0" />
|
||||
Sign out
|
||||
</button>
|
||||
<div ref={menuRef} className="relative mt-auto">
|
||||
{menuOpen && (
|
||||
<div className="absolute bottom-full left-0 mb-2 w-full overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onNavigate?.();
|
||||
}}
|
||||
className="flex items-center gap-3 px-3 py-2 text-sm text-[var(--muted)] hover:bg-bronze/[0.07] hover:text-[var(--foreground)]"
|
||||
>
|
||||
<Settings className="h-4 w-4 shrink-0" />
|
||||
Settings
|
||||
</Link>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex w-full items-center gap-3 px-3 py-2 text-sm text-[var(--muted)] hover:bg-bronze/[0.07] hover:text-bronze"
|
||||
>
|
||||
<LogOut className="h-4 w-4 shrink-0" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm text-[var(--foreground)] transition-colors hover:bg-bronze/[0.07]"
|
||||
>
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-bronze/15 text-xs font-semibold uppercase text-bronze">
|
||||
{(me?.display_name || me?.email || "?").slice(0, 1)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{me?.display_name || me?.email || "Account"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import type { components } from "@/lib/api/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type Report = { counts: Record<string, number>; unmapped_tags: string[] };
|
||||
type Preview = components["schemas"]["ImportPreview"];
|
||||
type Dup = components["schemas"]["DuplicateMatch"];
|
||||
type Tree = components["schemas"]["TreeRead"];
|
||||
type Action = "new" | "skip" | "merge" | "overwrite";
|
||||
|
||||
const ACTIONS: { value: Action; label: string }[] = [
|
||||
{ value: "new", label: "Import as new" },
|
||||
{ value: "merge", label: "Merge into existing" },
|
||||
{ value: "skip", label: "Skip (use existing)" },
|
||||
{ value: "overwrite", label: "Overwrite existing" },
|
||||
];
|
||||
|
||||
const fieldCls = "h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm";
|
||||
|
||||
/**
|
||||
* GEDCOM import with a selectable destination (a new tree, or an existing one).
|
||||
* Importing into an existing tree previews likely duplicates so each can be
|
||||
* resolved (new / skip / merge / overwrite) before anything is written.
|
||||
*
|
||||
* @param fixedTreeId When set, the destination defaults to that tree (the
|
||||
* tree-scoped Import page). When omitted (the global Import page), the user
|
||||
* picks a destination.
|
||||
*/
|
||||
export function GedcomImport({ fixedTreeId }: { fixedTreeId?: string }) {
|
||||
const [trees, setTrees] = useState<Tree[]>([]);
|
||||
const [mode, setMode] = useState<"new" | "existing">(fixedTreeId ? "existing" : "new");
|
||||
const [chosenTreeId, setChosenTreeId] = useState(fixedTreeId ?? "");
|
||||
const [newName, setNewName] = useState("");
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [report, setReport] = useState<Report | null>(null);
|
||||
const [importedTreeId, setImportedTreeId] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const [resolutions, setResolutions] = useState<Record<string, Action>>({});
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.GET("/api/v1/trees").then((r) => setTrees(r.data ?? []));
|
||||
}, []);
|
||||
|
||||
function resetRun() {
|
||||
setReport(null);
|
||||
setImportedTreeId(null);
|
||||
setPreview(null);
|
||||
setFile(null);
|
||||
setResolutions({});
|
||||
}
|
||||
|
||||
async function postImport(tid: string, f: File, resolutionsJson?: string) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", f);
|
||||
if (resolutionsJson) fd.append("resolutions", resolutionsJson);
|
||||
const resp = await fetch(`/api/v1/trees/${tid}/gedcom/import`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
credentials: "include",
|
||||
});
|
||||
if (resp.ok) {
|
||||
setReport(await resp.json());
|
||||
setImportedTreeId(tid);
|
||||
}
|
||||
}
|
||||
|
||||
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const f = e.target.files?.[0];
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
if (!f) return;
|
||||
setBusy(true);
|
||||
resetRun();
|
||||
|
||||
if (mode === "new") {
|
||||
const { data } = await api.POST("/api/v1/trees", {
|
||||
body: { name: newName.trim() || "Imported tree" },
|
||||
});
|
||||
if (data) await postImport(data.id, f);
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!chosenTreeId) {
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Existing tree — preview duplicates first.
|
||||
setFile(f);
|
||||
const fd = new FormData();
|
||||
fd.append("file", f);
|
||||
const resp = await fetch(`/api/v1/trees/${chosenTreeId}/gedcom/preview`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
credentials: "include",
|
||||
});
|
||||
if (resp.ok) {
|
||||
const pv: Preview = await resp.json();
|
||||
setPreview(pv);
|
||||
const init: Record<string, Action> = {};
|
||||
for (const d of pv.potential_duplicates) init[d.xref] = d.score === "high" ? "merge" : "new";
|
||||
setResolutions(init);
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
async function runImport() {
|
||||
if (!file || !chosenTreeId) return;
|
||||
setBusy(true);
|
||||
const map: Record<string, { action: Action; target_id: string }> = {};
|
||||
for (const d of preview?.potential_duplicates ?? []) {
|
||||
const action = resolutions[d.xref] ?? "new";
|
||||
if (action !== "new") map[d.xref] = { action, target_id: d.existing_person_id };
|
||||
}
|
||||
await postImport(chosenTreeId, file, JSON.stringify(map));
|
||||
setPreview(null);
|
||||
setFile(null);
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
const dups = preview?.potential_duplicates ?? [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Import a GEDCOM file</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!preview && (
|
||||
<>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
checked={mode === "new"}
|
||||
onChange={() => {
|
||||
setMode("new");
|
||||
resetRun();
|
||||
}}
|
||||
/>
|
||||
Import into a <strong>new tree</strong>
|
||||
</label>
|
||||
{mode === "new" && (
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder="New tree name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
checked={mode === "existing"}
|
||||
onChange={() => {
|
||||
setMode("existing");
|
||||
resetRun();
|
||||
}}
|
||||
/>
|
||||
Import into an <strong>existing tree</strong> (checks for duplicates)
|
||||
</label>
|
||||
{mode === "existing" && (
|
||||
<select
|
||||
className={`${fieldCls} max-w-xs`}
|
||||
value={chosenTreeId}
|
||||
onChange={(e) => setChosenTreeId(e.target.value)}
|
||||
>
|
||||
<option value="">— choose a tree —</option>
|
||||
{trees.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".ged,.gedcom,text/plain"
|
||||
onChange={onFile}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={busy || (mode === "existing" && !chosenTreeId)}
|
||||
>
|
||||
{busy ? "Working…" : "Choose GEDCOM file"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Duplicate-resolution step */}
|
||||
{preview && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-[var(--muted)]">
|
||||
{Object.entries(preview.counts).map(([k, v]) => (
|
||||
<span key={k}>
|
||||
<span className="font-medium text-[var(--foreground)]">{v}</span> {k}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{dups.length === 0 ? (
|
||||
<p className="rounded-md bg-bronze/[0.08] px-3 py-2 text-sm">
|
||||
No likely duplicates found — everyone will be imported as new.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{dups.length} possible duplicate{dups.length === 1 ? "" : "s"}
|
||||
</h3>
|
||||
<label className="flex items-center gap-2 text-xs text-[var(--muted)]">
|
||||
Set all to
|
||||
<select
|
||||
className={fieldCls}
|
||||
defaultValue=""
|
||||
onChange={(e) => {
|
||||
const a = e.target.value as Action;
|
||||
const all: Record<string, Action> = {};
|
||||
for (const d of dups) all[d.xref] = a;
|
||||
setResolutions(all);
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>
|
||||
choose…
|
||||
</option>
|
||||
{ACTIONS.map((a) => (
|
||||
<option key={a.value} value={a.value}>
|
||||
{a.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<ul className="divide-y divide-[var(--border)] rounded-lg border border-[var(--border)]">
|
||||
{dups.map((d: Dup) => (
|
||||
<li
|
||||
key={d.xref}
|
||||
className="flex flex-wrap items-center justify-between gap-3 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium">{d.incoming_name}</span>
|
||||
{d.incoming_birth_year && (
|
||||
<span className="text-[var(--muted)]"> b. {d.incoming_birth_year}</span>
|
||||
)}
|
||||
<span className="text-[var(--muted)]"> ↔ </span>
|
||||
<span>{d.existing_name}</span>
|
||||
{d.existing_birth_year && (
|
||||
<span className="text-[var(--muted)]"> b. {d.existing_birth_year}</span>
|
||||
)}
|
||||
<span
|
||||
className={`ml-2 rounded px-1.5 py-0.5 text-xs ${
|
||||
d.score === "high"
|
||||
? "bg-bronze/15 text-bronze"
|
||||
: "bg-[var(--border)]/50 text-[var(--muted)]"
|
||||
}`}
|
||||
>
|
||||
{d.score}
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
className={fieldCls}
|
||||
value={resolutions[d.xref] ?? "new"}
|
||||
onChange={(e) =>
|
||||
setResolutions((r) => ({ ...r, [d.xref]: e.target.value as Action }))
|
||||
}
|
||||
>
|
||||
{ACTIONS.map((a) => (
|
||||
<option key={a.value} value={a.value}>
|
||||
{a.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={runImport} disabled={busy}>
|
||||
{busy ? "Importing…" : "Run import"}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={resetRun} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<div className="space-y-3 rounded-lg border border-[var(--border)] p-4">
|
||||
<div className="font-medium">Import complete</div>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-[var(--muted)]">
|
||||
{Object.entries(report.counts).map(([k, v]) => (
|
||||
<span key={k}>
|
||||
<span className="font-medium text-[var(--foreground)]">{v}</span> {k}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{report.unmapped_tags.length > 0 && (
|
||||
<div className="text-xs text-[var(--muted)]">
|
||||
Unmapped tags (skipped): {report.unmapped_tags.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{importedTreeId && (
|
||||
<Link
|
||||
href={`/trees/${importedTreeId}`}
|
||||
className="inline-block text-sm text-bronze hover:underline"
|
||||
>
|
||||
Open the tree →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+732
-9
@@ -140,6 +140,23 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/auth/change-password": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Change Password */
|
||||
post: operations["change_password_api_v1_auth_change_password_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/users/me": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -157,6 +174,26 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -190,7 +227,8 @@ export interface paths {
|
||||
delete: operations["delete_tree_api_v1_trees__tree_id__delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
/** Update Tree */
|
||||
patch: operations["update_tree_api_v1_trees__tree_id__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/trees/{tree_id}/restore": {
|
||||
@@ -239,7 +277,11 @@ export interface paths {
|
||||
get: operations["get_person_api_v1_trees__tree_id__persons__person_id__get"];
|
||||
put?: 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"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
@@ -264,6 +306,42 @@ export interface paths {
|
||||
patch?: 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": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -366,7 +444,8 @@ export interface paths {
|
||||
delete: operations["delete_relationship_api_v1_trees__tree_id__relationships__relationship_id__delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
/** Update Relationship */
|
||||
patch: operations["update_relationship_api_v1_trees__tree_id__relationships__relationship_id__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/trees/{tree_id}/sources": {
|
||||
@@ -402,7 +481,8 @@ export interface paths {
|
||||
delete: operations["delete_source_api_v1_trees__tree_id__sources__source_id__delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
/** Update Source */
|
||||
patch: operations["update_source_api_v1_trees__tree_id__sources__source_id__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/trees/{tree_id}/citations": {
|
||||
@@ -437,7 +517,8 @@ export interface paths {
|
||||
delete: operations["delete_citation_api_v1_trees__tree_id__citations__citation_id__delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
/** Update Citation */
|
||||
patch: operations["update_citation_api_v1_trees__tree_id__citations__citation_id__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/trees/{tree_id}/media": {
|
||||
@@ -489,6 +570,28 @@ export interface paths {
|
||||
delete: operations["delete_media_api_v1_trees__tree_id__media__media_id__delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** Update Media */
|
||||
patch: operations["update_media_api_v1_trees__tree_id__media__media_id__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/trees/{tree_id}/gedcom/preview": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Preview Gedcom
|
||||
* @description Dry run: report counts and incoming people that look like duplicates of
|
||||
* existing ones, so the user can choose how to resolve each before importing.
|
||||
*/
|
||||
post: operations["preview_gedcom_api_v1_trees__tree_id__gedcom_preview_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
@@ -501,7 +604,12 @@ export interface paths {
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Import Gedcom */
|
||||
/**
|
||||
* Import Gedcom
|
||||
* @description Import a GEDCOM. ``default_action`` (new|skip|merge|overwrite) applies to
|
||||
* incoming people that match an existing one; ``resolutions`` is a JSON object
|
||||
* {xref: {action, target_id}} overriding it per record.
|
||||
*/
|
||||
post: operations["import_gedcom_api_v1_trees__tree_id__gedcom_import_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
@@ -534,6 +642,21 @@ export interface components {
|
||||
Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post: {
|
||||
/** File */
|
||||
file: string;
|
||||
/**
|
||||
* Default Action
|
||||
* @default new
|
||||
*/
|
||||
default_action?: string;
|
||||
/**
|
||||
* Resolutions
|
||||
* @default {}
|
||||
*/
|
||||
resolutions?: string;
|
||||
};
|
||||
/** Body_preview_gedcom_api_v1_trees__tree_id__gedcom_preview_post */
|
||||
Body_preview_gedcom_api_v1_trees__tree_id__gedcom_preview_post: {
|
||||
/** File */
|
||||
file: string;
|
||||
};
|
||||
/** Body_upload_media_api_v1_trees__tree_id__media_post */
|
||||
Body_upload_media_api_v1_trees__tree_id__media_post: {
|
||||
@@ -610,6 +733,34 @@ export interface components {
|
||||
*/
|
||||
created_at: string;
|
||||
};
|
||||
/** CitationUpdate */
|
||||
CitationUpdate: {
|
||||
/** Page */
|
||||
page?: string | null;
|
||||
/** Detail */
|
||||
detail?: string | null;
|
||||
confidence?: components["schemas"]["CitationConfidence"] | null;
|
||||
};
|
||||
/** DuplicateMatch */
|
||||
DuplicateMatch: {
|
||||
/** Xref */
|
||||
xref: string;
|
||||
/** Incoming Name */
|
||||
incoming_name: string;
|
||||
/** Incoming Birth Year */
|
||||
incoming_birth_year?: string | null;
|
||||
/**
|
||||
* Existing Person Id
|
||||
* Format: uuid
|
||||
*/
|
||||
existing_person_id: string;
|
||||
/** Existing Name */
|
||||
existing_name: string;
|
||||
/** Existing Birth Year */
|
||||
existing_birth_year?: string | null;
|
||||
/** Score */
|
||||
score: string;
|
||||
};
|
||||
/** EventCreate */
|
||||
EventCreate: {
|
||||
/** Event Type */
|
||||
@@ -704,6 +855,17 @@ export interface components {
|
||||
/** Detail */
|
||||
detail?: components["schemas"]["ValidationError"][];
|
||||
};
|
||||
/** ImportPreview */
|
||||
ImportPreview: {
|
||||
/** Counts */
|
||||
counts: {
|
||||
[key: string]: number;
|
||||
};
|
||||
/** Potential Duplicates */
|
||||
potential_duplicates: components["schemas"]["DuplicateMatch"][];
|
||||
/** Unmapped Tags */
|
||||
unmapped_tags: string[];
|
||||
};
|
||||
/** ImportReport */
|
||||
ImportReport: {
|
||||
/** Counts */
|
||||
@@ -756,6 +918,96 @@ export interface components {
|
||||
/** Url */
|
||||
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
|
||||
* @description Qualifies a parent_child edge so adoption/donor/blended families are
|
||||
@@ -763,6 +1015,13 @@ export interface components {
|
||||
* @enum {string}
|
||||
*/
|
||||
ParentChildQualifier: "biological" | "adoptive" | "step" | "foster" | "donor" | "guardian";
|
||||
/** PasswordChange */
|
||||
PasswordChange: {
|
||||
/** Current Password */
|
||||
current_password: string;
|
||||
/** New Password */
|
||||
new_password: string;
|
||||
};
|
||||
/** PasswordResetConfirm */
|
||||
PasswordResetConfirm: {
|
||||
/** Token */
|
||||
@@ -898,6 +1157,12 @@ export interface components {
|
||||
* @enum {string}
|
||||
*/
|
||||
RelationshipType: "parent_child" | "partnership" | "sibling";
|
||||
/** RelationshipUpdate */
|
||||
RelationshipUpdate: {
|
||||
qualifier?: components["schemas"]["ParentChildQualifier"] | null;
|
||||
/** Notes */
|
||||
notes?: string | null;
|
||||
};
|
||||
/** SessionRead */
|
||||
SessionRead: {
|
||||
user: components["schemas"]["UserRead"];
|
||||
@@ -962,6 +1227,25 @@ export interface components {
|
||||
*/
|
||||
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: {
|
||||
/** Token */
|
||||
@@ -993,12 +1277,24 @@ export interface components {
|
||||
* Format: uuid
|
||||
*/
|
||||
owner_id: string;
|
||||
/** Home Person Id */
|
||||
home_person_id?: string | null;
|
||||
/**
|
||||
* Created At
|
||||
* Format: date-time
|
||||
*/
|
||||
created_at: string;
|
||||
};
|
||||
/** TreeUpdate */
|
||||
TreeUpdate: {
|
||||
/** Name */
|
||||
name?: string | null;
|
||||
/** Description */
|
||||
description?: string | null;
|
||||
visibility?: components["schemas"]["TreeVisibility"] | null;
|
||||
/** Home Person Id */
|
||||
home_person_id?: string | null;
|
||||
};
|
||||
/**
|
||||
* TreeVisibility
|
||||
* @enum {string}
|
||||
@@ -1017,12 +1313,19 @@ export interface components {
|
||||
display_name: string | null;
|
||||
/** Email Verified At */
|
||||
email_verified_at: string | null;
|
||||
/** Self Person Id */
|
||||
self_person_id?: string | null;
|
||||
/**
|
||||
* Created At
|
||||
* Format: date-time
|
||||
*/
|
||||
created_at: string;
|
||||
};
|
||||
/** UserSelfPersonUpdate */
|
||||
UserSelfPersonUpdate: {
|
||||
/** Self Person Id */
|
||||
self_person_id?: string | null;
|
||||
};
|
||||
/** ValidationError */
|
||||
ValidationError: {
|
||||
/** Location */
|
||||
@@ -1270,6 +1573,37 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
change_password_api_v1_auth_change_password_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["PasswordChange"];
|
||||
};
|
||||
};
|
||||
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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
read_me_api_v1_users_me_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1290,6 +1624,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: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -1414,6 +1781,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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1548,7 +1950,9 @@ export interface operations {
|
||||
};
|
||||
delete_person_api_v1_trees__tree_id__persons__person_id__delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
cascade?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
tree_id: string;
|
||||
@@ -1559,11 +1963,15 @@ export interface operations {
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
204: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
@@ -1644,6 +2052,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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1936,6 +2480,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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2064,6 +2644,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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2160,6 +2776,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: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2288,6 +2940,77 @@ 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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
preview_gedcom_api_v1_trees__tree_id__gedcom_preview_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
tree_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": components["schemas"]["Body_preview_gedcom_api_v1_trees__tree_id__gedcom_preview_post"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ImportPreview"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
import_gedcom_api_v1_trees__tree_id__gedcom_import_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
+1391
-2
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user