Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d540dc3f32 | |||
| 8652425413 | |||
| 3a7728f1dc | |||
| eb0350733b | |||
| 6d3147e86d | |||
| b4434cb5dd | |||
| 39e3eac3df | |||
| 660fe7b37f | |||
| 5485dd2077 | |||
| 05d2773e25 | |||
| 768c68cbe0 | |||
| 7d6fbce87e | |||
| 12ba0a0fb6 | |||
| 150d69e5ac | |||
| 053ce357ac | |||
| 269cae556f | |||
| 0df44e7e59 | |||
| 7a5c5f2882 | |||
| 20c7fbd8d6 | |||
| b8405ced07 | |||
| 91a7ce1dc2 | |||
| 8b91326481 | |||
| 671b560768 | |||
| 6a5ef4d392 | |||
| 3810b65de0 | |||
| 9820a77d25 | |||
| 3ff03b037b | |||
| 84a743f5b9 | |||
| e6dfe39e84 | |||
| 4a3fe983fa | |||
| 251652a935 | |||
| dc1b6aac01 | |||
| f93327f5d3 | |||
| c86771034c | |||
| b51b65de80 | |||
| 93c22b4bcf | |||
| 7255920135 | |||
| 62513ee22e | |||
| ac0b9818dd | |||
| 182a5dab16 | |||
| 77b78410ff | |||
| fe1e0171ff | |||
| 9dbdae975a | |||
| c5a2a7f0d4 | |||
| 8c36785197 | |||
| fae1162ff8 | |||
| 1025f86657 | |||
| a53858f920 | |||
| 941f9827c1 | |||
| 6ec852a23a | |||
| 7405ec762f | |||
| aa62ca490e | |||
| 97f7a9e0ff | |||
| cd4ccb4ac8 | |||
| 6696015970 | |||
| e8839b15a0 | |||
| 548e883d82 | |||
| 37ac49767e | |||
| 9b04bcefba | |||
| e9b2436ce0 | |||
| 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 app ./app
|
||||||
COPY alembic.ini ./alembic.ini
|
COPY alembic.ini ./alembic.ini
|
||||||
COPY migrations ./migrations
|
COPY migrations ./migrations
|
||||||
|
COPY docker-entrypoint.sh ./docker-entrypoint.sh
|
||||||
|
RUN chmod +x ./docker-entrypoint.sh
|
||||||
|
|
||||||
EXPOSE 8000
|
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"]
|
CMD ["uv", "run", "--no-dev", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -40,6 +40,18 @@ async def get_current_user(request: Request, session: SessionDep) -> User:
|
|||||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user_or_none(request: Request, session: SessionDep) -> User | None:
|
||||||
|
"""Optional auth for public read endpoints — never raises. Returns the user
|
||||||
|
when a valid session is present, else None (anonymous viewer)."""
|
||||||
|
raw_token = extract_session_token(request)
|
||||||
|
if raw_token is None:
|
||||||
|
return None
|
||||||
|
return await auth_service.resolve_session_user(session, raw_token=raw_token)
|
||||||
|
|
||||||
|
|
||||||
|
CurrentUserOrNone = Annotated[User | None, Depends(get_current_user_or_none)]
|
||||||
|
|
||||||
|
|
||||||
def get_mailer() -> Mailer:
|
def get_mailer() -> Mailer:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if settings.mailer == "smtp" and settings.smtp_host:
|
if settings.mailer == "smtp" and settings.smtp_host:
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ from fastapi import APIRouter
|
|||||||
from app.api.v1 import (
|
from app.api.v1 import (
|
||||||
auth,
|
auth,
|
||||||
citations,
|
citations,
|
||||||
|
cleanup,
|
||||||
events,
|
events,
|
||||||
gedcom,
|
gedcom,
|
||||||
media,
|
media,
|
||||||
|
members,
|
||||||
|
names,
|
||||||
persons,
|
persons,
|
||||||
|
public,
|
||||||
relationships,
|
relationships,
|
||||||
sources,
|
sources,
|
||||||
trees,
|
trees,
|
||||||
@@ -20,9 +24,13 @@ api_router.include_router(auth.router)
|
|||||||
api_router.include_router(users.router)
|
api_router.include_router(users.router)
|
||||||
api_router.include_router(trees.router)
|
api_router.include_router(trees.router)
|
||||||
api_router.include_router(persons.router)
|
api_router.include_router(persons.router)
|
||||||
|
api_router.include_router(names.router)
|
||||||
api_router.include_router(events.router)
|
api_router.include_router(events.router)
|
||||||
api_router.include_router(relationships.router)
|
api_router.include_router(relationships.router)
|
||||||
api_router.include_router(sources.router)
|
api_router.include_router(sources.router)
|
||||||
api_router.include_router(citations.router)
|
api_router.include_router(citations.router)
|
||||||
api_router.include_router(media.router)
|
api_router.include_router(media.router)
|
||||||
api_router.include_router(gedcom.router)
|
api_router.include_router(gedcom.router)
|
||||||
|
api_router.include_router(cleanup.router)
|
||||||
|
api_router.include_router(public.router)
|
||||||
|
api_router.include_router(members.router)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
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.core.config import get_settings
|
||||||
from app.schemas.auth import (
|
from app.schemas.auth import (
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
|
PasswordChange,
|
||||||
PasswordResetConfirm,
|
PasswordResetConfirm,
|
||||||
PasswordResetRequest,
|
PasswordResetRequest,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
@@ -79,3 +80,15 @@ async def reset_password(data: PasswordResetConfirm, session: SessionDep) -> Non
|
|||||||
await auth_service.reset_password(
|
await auth_service.reset_password(
|
||||||
session, raw_token=data.token, new_password=data.new_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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, UploadFile
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.cleanup import (
|
||||||
|
CleanupResult,
|
||||||
|
DeceasedApply,
|
||||||
|
DeceasedCandidate,
|
||||||
|
GenderApply,
|
||||||
|
GenderProposal,
|
||||||
|
NameApply,
|
||||||
|
NameIssue,
|
||||||
|
)
|
||||||
|
from app.services import cleanup_service, tree_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["cleanup"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/cleanup/deceased", response_model=list[DeceasedCandidate])
|
||||||
|
async def preview_deceased(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
born_on_or_before: int = 1930,
|
||||||
|
) -> list[DeceasedCandidate]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
rows = await cleanup_service.preview_deceased(
|
||||||
|
session, actor=current, tree=tree, year=born_on_or_before
|
||||||
|
)
|
||||||
|
return [DeceasedCandidate(**r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/cleanup/deceased", response_model=CleanupResult)
|
||||||
|
async def apply_deceased(
|
||||||
|
tree_id: uuid.UUID, data: DeceasedApply, session: SessionDep, current: CurrentUser
|
||||||
|
) -> CleanupResult:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
n = await cleanup_service.apply_deceased(
|
||||||
|
session, actor=current, tree=tree, person_ids=data.person_ids
|
||||||
|
)
|
||||||
|
return CleanupResult(updated=n)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/cleanup/gender/preview", response_model=list[GenderProposal])
|
||||||
|
async def preview_gender(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
) -> list[GenderProposal]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
text = (await file.read()).decode("utf-8", errors="replace")
|
||||||
|
rows = await cleanup_service.preview_gender(
|
||||||
|
session, actor=current, tree=tree, gedcom_text=text
|
||||||
|
)
|
||||||
|
return [GenderProposal(**r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/cleanup/gender/guess", response_model=list[GenderProposal])
|
||||||
|
async def guess_gender(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[GenderProposal]:
|
||||||
|
"""Best-guess sex from first names (bundled dictionary) for people missing it."""
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
rows = await cleanup_service.guess_gender_by_name(session, actor=current, tree=tree)
|
||||||
|
return [GenderProposal(**r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/cleanup/gender/from-spouse", response_model=list[GenderProposal])
|
||||||
|
async def guess_gender_from_spouse(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[GenderProposal]:
|
||||||
|
"""Infer a missing sex from a partner whose sex is set (opposite-sex couple)."""
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
rows = await cleanup_service.guess_gender_by_spouse(session, actor=current, tree=tree)
|
||||||
|
return [GenderProposal(**r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/cleanup/gender", response_model=CleanupResult)
|
||||||
|
async def apply_gender(
|
||||||
|
tree_id: uuid.UUID, data: GenderApply, session: SessionDep, current: CurrentUser
|
||||||
|
) -> CleanupResult:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
n = await cleanup_service.apply_gender(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
updates=[u.model_dump() for u in data.updates],
|
||||||
|
)
|
||||||
|
return CleanupResult(updated=n)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/cleanup/names", response_model=list[NameIssue])
|
||||||
|
async def preview_names(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[NameIssue]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
rows = await cleanup_service.preview_names(session, actor=current, tree=tree)
|
||||||
|
return [NameIssue(**r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/cleanup/names", response_model=CleanupResult)
|
||||||
|
async def apply_names(
|
||||||
|
tree_id: uuid.UUID, data: NameApply, session: SessionDep, current: CurrentUser
|
||||||
|
) -> CleanupResult:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
n = await cleanup_service.apply_names(
|
||||||
|
session, actor=current, tree=tree, edits=[e.model_dump() for e in data.edits]
|
||||||
|
)
|
||||||
|
return CleanupResult(updated=n)
|
||||||
@@ -1,25 +1,56 @@
|
|||||||
|
import json
|
||||||
import uuid
|
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.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
|
from app.services import gedcom, tree_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/trees", tags=["gedcom"])
|
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)
|
@router.post("/{tree_id}/gedcom/import", response_model=ImportReport)
|
||||||
async def import_gedcom(
|
async def import_gedcom(
|
||||||
tree_id: uuid.UUID,
|
tree_id: uuid.UUID,
|
||||||
session: SessionDep,
|
session: SessionDep,
|
||||||
current: CurrentUser,
|
current: CurrentUser,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
default_action: str = Form("new"),
|
||||||
|
resolutions: str = Form("{}"),
|
||||||
) -> ImportReport:
|
) -> 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)
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
text = (await file.read()).decode("utf-8", errors="replace")
|
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)
|
return ImportReport(**report)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Tree membership management endpoints (owner-managed; members can list)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.membership import MemberAdd, MemberRoleUpdate, MembershipRead
|
||||||
|
from app.services import membership_service, tree_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["members"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/members", response_model=list[MembershipRead])
|
||||||
|
async def list_members(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[MembershipRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
rows = await membership_service.list_members(session, viewer_id=current.id, tree=tree)
|
||||||
|
return [MembershipRead(**r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{tree_id}/members", response_model=MembershipRead, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
async def add_member(
|
||||||
|
tree_id: uuid.UUID, data: MemberAdd, session: SessionDep, current: CurrentUser
|
||||||
|
) -> MembershipRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
row = await membership_service.add_member(
|
||||||
|
session, actor=current, tree=tree, email=data.email, role=data.role
|
||||||
|
)
|
||||||
|
return MembershipRead(**row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{tree_id}/members/{membership_id}", response_model=MembershipRead)
|
||||||
|
async def update_member(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
membership_id: uuid.UUID,
|
||||||
|
data: MemberRoleUpdate,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
) -> MembershipRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
row = await membership_service.update_member_role(
|
||||||
|
session, actor=current, tree=tree, membership_id=membership_id, role=data.role
|
||||||
|
)
|
||||||
|
return MembershipRead(**row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tree_id}/members/{membership_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def remove_member(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
membership_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
) -> None:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
await membership_service.remove_member(
|
||||||
|
session, actor=current, tree=tree, membership_id=membership_id
|
||||||
|
)
|
||||||
@@ -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)
|
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(
|
async def delete_person(
|
||||||
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
tree_id: uuid.UUID,
|
||||||
) -> None:
|
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)
|
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)
|
@router.post("/{tree_id}/persons/{person_id}/restore", response_model=PersonRead)
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Public, read-only viewing surface.
|
||||||
|
|
||||||
|
Optional auth (anonymous allowed). Every response is built by
|
||||||
|
``public_view_service``, which routes through the privacy engine and redacts
|
||||||
|
possibly-living people. No create/update/delete here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUserOrNone, SessionDep
|
||||||
|
from app.schemas.event import EventRead
|
||||||
|
from app.schemas.name import NameRead
|
||||||
|
from app.schemas.person import PersonRead
|
||||||
|
from app.schemas.relationship import RelationshipRead
|
||||||
|
from app.schemas.tree import PublicTreeRead
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/public", tags=["public"])
|
||||||
|
|
||||||
|
|
||||||
|
def _vid(viewer: CurrentUserOrNone) -> uuid.UUID | None:
|
||||||
|
return viewer.id if viewer else None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trees", response_model=list[PublicTreeRead])
|
||||||
|
async def public_directory(
|
||||||
|
session: SessionDep,
|
||||||
|
viewer: CurrentUserOrNone,
|
||||||
|
q: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[PublicTreeRead]:
|
||||||
|
trees = await public_view_service.list_public_trees(
|
||||||
|
session, viewer_id=_vid(viewer), q=q, limit=limit, offset=offset
|
||||||
|
)
|
||||||
|
return [PublicTreeRead.model_validate(t) for t in trees]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trees/{tree_id}", response_model=PublicTreeRead)
|
||||||
|
async def public_tree(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone
|
||||||
|
) -> PublicTreeRead:
|
||||||
|
tree = await public_view_service.get_public_tree(
|
||||||
|
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||||
|
)
|
||||||
|
return PublicTreeRead.model_validate(tree)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trees/{tree_id}/persons", response_model=list[PersonRead])
|
||||||
|
async def public_persons(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone
|
||||||
|
) -> list[PersonRead]:
|
||||||
|
tree = await public_view_service.get_public_tree(
|
||||||
|
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||||
|
)
|
||||||
|
persons = await public_view_service.list_public_persons(
|
||||||
|
session, viewer_id=_vid(viewer), tree=tree
|
||||||
|
)
|
||||||
|
return [PersonRead.model_validate(p) for p in persons]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trees/{tree_id}/relationships", response_model=list[RelationshipRead])
|
||||||
|
async def public_relationships(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone
|
||||||
|
) -> list[RelationshipRead]:
|
||||||
|
tree = await public_view_service.get_public_tree(
|
||||||
|
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||||
|
)
|
||||||
|
rels = await public_view_service.list_public_relationships(
|
||||||
|
session, viewer_id=_vid(viewer), tree=tree
|
||||||
|
)
|
||||||
|
return [RelationshipRead.model_validate(r) for r in rels]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trees/{tree_id}/events", response_model=list[EventRead])
|
||||||
|
async def public_events(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone
|
||||||
|
) -> list[EventRead]:
|
||||||
|
tree = await public_view_service.get_public_tree(
|
||||||
|
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||||
|
)
|
||||||
|
events = await public_view_service.list_public_events(
|
||||||
|
session, viewer_id=_vid(viewer), tree=tree
|
||||||
|
)
|
||||||
|
return [EventRead.model_validate(e) for e in events]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trees/{tree_id}/persons/{person_id}", response_model=PersonRead)
|
||||||
|
async def public_person(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
viewer: CurrentUserOrNone,
|
||||||
|
) -> PersonRead:
|
||||||
|
tree = await public_view_service.get_public_tree(
|
||||||
|
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||||
|
)
|
||||||
|
person = await public_view_service.get_public_person(
|
||||||
|
session, viewer_id=_vid(viewer), tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
|
return PersonRead.model_validate(person)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trees/{tree_id}/persons/{person_id}/names", response_model=list[NameRead])
|
||||||
|
async def public_person_names(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
viewer: CurrentUserOrNone,
|
||||||
|
) -> list[NameRead]:
|
||||||
|
tree = await public_view_service.get_public_tree(
|
||||||
|
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||||
|
)
|
||||||
|
names = await public_view_service.list_public_person_names(
|
||||||
|
session, viewer_id=_vid(viewer), tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
|
return [NameRead.model_validate(n) for n in names]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trees/{tree_id}/persons/{person_id}/events", response_model=list[EventRead])
|
||||||
|
async def public_person_events(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
viewer: CurrentUserOrNone,
|
||||||
|
) -> list[EventRead]:
|
||||||
|
tree = await public_view_service.get_public_tree(
|
||||||
|
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||||
|
)
|
||||||
|
events = await public_view_service.list_public_person_events(
|
||||||
|
session, viewer_id=_vid(viewer), tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
|
return [EventRead.model_validate(e) for e in events]
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, File, Form, Response, UploadFile
|
||||||
|
|
||||||
from app.api.deps import CurrentUser
|
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
|
||||||
from app.schemas.user import UserRead
|
from app.schemas.user import UserRead, UserSelfPersonUpdate
|
||||||
|
from app.services import account_service, user_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
@@ -9,3 +10,48 @@ router = APIRouter(prefix="/users", tags=["users"])
|
|||||||
@router.get("/me", response_model=UserRead)
|
@router.get("/me", response_model=UserRead)
|
||||||
async def read_me(current: CurrentUser) -> UserRead:
|
async def read_me(current: CurrentUser) -> UserRead:
|
||||||
return UserRead.model_validate(current)
|
return UserRead.model_validate(current)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me/self-person", response_model=UserRead)
|
||||||
|
async def set_self_person(
|
||||||
|
data: UserSelfPersonUpdate, session: SessionDep, current: CurrentUser
|
||||||
|
) -> UserRead:
|
||||||
|
"""Link (or unlink) the Person record that represents this account."""
|
||||||
|
user = await user_service.set_self_person(
|
||||||
|
session, user=current, person_id=data.self_person_id
|
||||||
|
)
|
||||||
|
return UserRead.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/export")
|
||||||
|
async def export_account(
|
||||||
|
session: SessionDep, current: CurrentUser, store: ObjectStoreDep
|
||||||
|
) -> Response:
|
||||||
|
"""Download a full backup (JSON + media) of every tree the user owns."""
|
||||||
|
data = await account_service.export_account(session, store, user=current)
|
||||||
|
return Response(
|
||||||
|
content=data,
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="provenance-export.zip"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/import")
|
||||||
|
async def import_account(
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
store: ObjectStoreDep,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
) -> dict:
|
||||||
|
"""Restore a previously-exported backup into new trees (non-destructive)."""
|
||||||
|
raw = await file.read()
|
||||||
|
return await account_service.import_account(session, store, user=current, raw_zip=raw)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/me", status_code=204)
|
||||||
|
async def delete_account(
|
||||||
|
session: SessionDep, current: CurrentUser, confirm_email: str = Form(...)
|
||||||
|
) -> None:
|
||||||
|
"""Delete the account: the user, their owned trees, and their sessions.
|
||||||
|
Requires retyping the account email as a guard."""
|
||||||
|
await account_service.delete_account(session, user=current, confirm_email=confirm_email)
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ class Settings(BaseSettings):
|
|||||||
purge_after_days: int = 30 # soft-deleted rows older than this are purged
|
purge_after_days: int = 30 # soft-deleted rows older than this are purged
|
||||||
|
|
||||||
# --- Email (SMTP) ---
|
# --- Email (SMTP) ---
|
||||||
|
# When true, a user with no verified email gets no active session (login is
|
||||||
|
# refused and existing sessions stop resolving). Default false so self-hosts
|
||||||
|
# without SMTP — and accounts created before this gate existed — aren't
|
||||||
|
# locked out; operators turn it on once mail works and accounts are verified.
|
||||||
|
require_email_verification: bool = False
|
||||||
mailer: str = Field(default="console", description="console | smtp")
|
mailer: str = Field(default="console", description="console | smtp")
|
||||||
smtp_host: str | None = None
|
smtp_host: str | None = None
|
||||||
smtp_port: int = 587
|
smtp_port: int = 587
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import enum
|
|||||||
|
|
||||||
|
|
||||||
class TreeVisibility(enum.StrEnum):
|
class TreeVisibility(enum.StrEnum):
|
||||||
public = "public"
|
public = "public" # anyone on the web (anonymous), listed + search-indexable
|
||||||
unlisted = "unlisted"
|
site_members = "site_members" # any authenticated user of this instance
|
||||||
private = "private"
|
unlisted = "unlisted" # anyone with the link (anonymous), not listed/indexed
|
||||||
|
private = "private" # members only (default)
|
||||||
|
|
||||||
|
|
||||||
class MembershipRole(enum.StrEnum):
|
class MembershipRole(enum.StrEnum):
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ class Tree(Base, UUIDPrimaryKey, Timestamps, SoftDelete):
|
|||||||
default=TreeVisibility.private,
|
default=TreeVisibility.private,
|
||||||
server_default=TreeVisibility.private.value,
|
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):
|
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.
|
slice). ``hashed_password`` is nullable: external/OIDC users have none.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, String
|
from sqlalchemy import DateTime, ForeignKey, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
@@ -19,3 +20,15 @@ class User(Base, UUIDPrimaryKey, Timestamps, SoftDelete):
|
|||||||
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
display_name: Mapped[str | None] = mapped_column(String(255))
|
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||||
hashed_password: Mapped[str | None] = mapped_column(String(255))
|
hashed_password: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
# The Person record that *is* this user ("home person"). Cleared if that
|
||||||
|
# person is deleted, so the link can never dangle.
|
||||||
|
self_person_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
# use_alter + explicit name: users<->persons<->trees form an FK cycle,
|
||||||
|
# so this constraint must be created/dropped via ALTER, not inline.
|
||||||
|
ForeignKey(
|
||||||
|
"persons.id",
|
||||||
|
ondelete="SET NULL",
|
||||||
|
name="fk_users_self_person_id",
|
||||||
|
use_alter=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ class PasswordResetConfirm(BaseModel):
|
|||||||
new_password: str = Field(min_length=8)
|
new_password: str = Field(min_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChange(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str = Field(min_length=8)
|
||||||
|
|
||||||
|
|
||||||
class SessionRead(BaseModel):
|
class SessionRead(BaseModel):
|
||||||
user: UserRead
|
user: UserRead
|
||||||
token: str
|
token: str
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class DeceasedCandidate(BaseModel):
|
||||||
|
person_id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
birth_year: int
|
||||||
|
|
||||||
|
|
||||||
|
class DeceasedApply(BaseModel):
|
||||||
|
person_ids: list[uuid.UUID]
|
||||||
|
|
||||||
|
|
||||||
|
class GenderProposal(BaseModel):
|
||||||
|
person_id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
proposed_gender: str
|
||||||
|
|
||||||
|
|
||||||
|
class GenderUpdate(BaseModel):
|
||||||
|
person_id: uuid.UUID
|
||||||
|
gender: str
|
||||||
|
|
||||||
|
|
||||||
|
class GenderApply(BaseModel):
|
||||||
|
updates: list[GenderUpdate]
|
||||||
|
|
||||||
|
|
||||||
|
class NameIssue(BaseModel):
|
||||||
|
name_id: uuid.UUID
|
||||||
|
person_id: uuid.UUID
|
||||||
|
given: str | None = None
|
||||||
|
surname: str | None = None
|
||||||
|
issue: str
|
||||||
|
|
||||||
|
|
||||||
|
class NameEdit(BaseModel):
|
||||||
|
name_id: uuid.UUID
|
||||||
|
given: str | None = None
|
||||||
|
surname: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class NameApply(BaseModel):
|
||||||
|
edits: list[NameEdit]
|
||||||
|
|
||||||
|
|
||||||
|
class CleanupResult(BaseModel):
|
||||||
|
updated: int
|
||||||
@@ -1,6 +1,25 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class ImportReport(BaseModel):
|
class ImportReport(BaseModel):
|
||||||
counts: dict[str, int]
|
counts: dict[str, int]
|
||||||
unmapped_tags: list[str]
|
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,26 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.models.enums import MembershipRole
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
user_id: uuid.UUID
|
||||||
|
email: str
|
||||||
|
display_name: str | None
|
||||||
|
role: MembershipRole
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MemberAdd(BaseModel):
|
||||||
|
email: str
|
||||||
|
role: MembershipRole = MembershipRole.viewer
|
||||||
|
|
||||||
|
|
||||||
|
class MemberRoleUpdate(BaseModel):
|
||||||
|
role: MembershipRole
|
||||||
@@ -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
|
name: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
visibility: TreeVisibility | None = None
|
visibility: TreeVisibility | None = None
|
||||||
|
home_person_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class TreeRead(BaseModel):
|
class TreeRead(BaseModel):
|
||||||
@@ -26,4 +27,18 @@ class TreeRead(BaseModel):
|
|||||||
description: str | None
|
description: str | None
|
||||||
visibility: TreeVisibility
|
visibility: TreeVisibility
|
||||||
owner_id: uuid.UUID
|
owner_id: uuid.UUID
|
||||||
|
home_person_id: uuid.UUID | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PublicTreeRead(BaseModel):
|
||||||
|
"""Tree projection for the public surface — deliberately omits owner_id so a
|
||||||
|
public/unlisted tree doesn't reveal which account owns it."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
visibility: TreeVisibility
|
||||||
|
home_person_id: uuid.UUID | None = None
|
||||||
|
|||||||
@@ -19,4 +19,10 @@ class UserRead(BaseModel):
|
|||||||
email: str
|
email: str
|
||||||
display_name: str | None
|
display_name: str | None
|
||||||
email_verified_at: datetime | None
|
email_verified_at: datetime | None
|
||||||
|
self_person_id: uuid.UUID | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UserSelfPersonUpdate(BaseModel):
|
||||||
|
# null clears the link; otherwise the Person that represents this account.
|
||||||
|
self_person_id: uuid.UUID | None = None
|
||||||
|
|||||||
@@ -0,0 +1,352 @@
|
|||||||
|
"""Account-level data portability: export the signed-in user's owned trees as a
|
||||||
|
zip (JSON + media bytes), restore such a zip into a brand-new tree
|
||||||
|
(non-destructive), and delete the account.
|
||||||
|
|
||||||
|
The export format is a zip containing ``account.json`` plus ``media/<id>`` blobs.
|
||||||
|
Restore always creates new trees and remaps ids, so it can't clobber existing
|
||||||
|
data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import zipfile
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.integrations.objectstore.base import ObjectStore
|
||||||
|
from app.models.auth import Session as SessionModel
|
||||||
|
from app.models.enums import MembershipRole
|
||||||
|
from app.models.event import Event
|
||||||
|
from app.models.media import Media
|
||||||
|
from app.models.person import Name, Person
|
||||||
|
from app.models.place import Place
|
||||||
|
from app.models.relationship import Relationship
|
||||||
|
from app.models.source import Citation, Source
|
||||||
|
from app.models.tree import Tree, TreeMembership
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.audit import record_audit
|
||||||
|
from app.services.exceptions import Forbidden, NotFound
|
||||||
|
|
||||||
|
EXPORT_VERSION = 1
|
||||||
|
_DROP = {"created_at", "updated_at", "deleted_at", "tree_id"}
|
||||||
|
# Media columns rebuilt on import (storage is re-keyed, checksum recomputed).
|
||||||
|
_MEDIA_DROP = _DROP | {"uploader_id", "storage_key", "byte_size", "checksum_sha256"}
|
||||||
|
_DATE_FIELDS = {"date_start", "date_end"}
|
||||||
|
|
||||||
|
|
||||||
|
def _row(obj, drop: set[str]) -> dict:
|
||||||
|
out: dict = {}
|
||||||
|
for col in obj.__table__.columns.keys(): # noqa: SIM118
|
||||||
|
if col in drop:
|
||||||
|
continue
|
||||||
|
out[col] = getattr(obj, col)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _entities(session: AsyncSession, model, tree_id: uuid.UUID):
|
||||||
|
stmt = select(model).where(model.tree_id == tree_id, model.deleted_at.is_(None))
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def export_account(session: AsyncSession, store: ObjectStore, *, user: User) -> bytes:
|
||||||
|
"""Build a zip of every tree the user owns: account.json + media blobs."""
|
||||||
|
trees = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Tree).where(Tree.owner_id == user.id, Tree.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
|
||||||
|
payload: dict = {
|
||||||
|
"version": EXPORT_VERSION,
|
||||||
|
"user": {"email": user.email, "display_name": user.display_name},
|
||||||
|
"trees": [],
|
||||||
|
}
|
||||||
|
media_blobs: list[tuple[str, bytes]] = []
|
||||||
|
|
||||||
|
for tree in trees:
|
||||||
|
media_rows = await _entities(session, Media, tree.id)
|
||||||
|
media_out = []
|
||||||
|
for m in media_rows:
|
||||||
|
ref = f"media/{m.id}"
|
||||||
|
rec = _row(m, _MEDIA_DROP)
|
||||||
|
rec["_file"] = ref
|
||||||
|
media_out.append(rec)
|
||||||
|
try:
|
||||||
|
media_blobs.append((ref, await store.get_object(key=m.storage_key)))
|
||||||
|
except Exception: # noqa: BLE001 — a missing blob shouldn't abort the export
|
||||||
|
rec["_file"] = None
|
||||||
|
|
||||||
|
payload["trees"].append({
|
||||||
|
"tree": {
|
||||||
|
"name": tree.name,
|
||||||
|
"description": tree.description,
|
||||||
|
"visibility": tree.visibility,
|
||||||
|
"home_person_id": tree.home_person_id,
|
||||||
|
},
|
||||||
|
"places": [_row(p, _DROP) for p in await _entities(session, Place, tree.id)],
|
||||||
|
"persons": [_row(p, _DROP) for p in await _entities(session, Person, tree.id)],
|
||||||
|
"names": [_row(n, _DROP) for n in await _entities(session, Name, tree.id)],
|
||||||
|
"relationships": [
|
||||||
|
_row(r, _DROP) for r in await _entities(session, Relationship, tree.id)
|
||||||
|
],
|
||||||
|
"events": [_row(e, _DROP) for e in await _entities(session, Event, tree.id)],
|
||||||
|
"sources": [_row(s, _DROP) for s in await _entities(session, Source, tree.id)],
|
||||||
|
"citations": [_row(c, _DROP) for c in await _entities(session, Citation, tree.id)],
|
||||||
|
"media": media_out,
|
||||||
|
})
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr("account.json", json.dumps(payload, default=str, indent=2))
|
||||||
|
for ref, blob in media_blobs:
|
||||||
|
zf.writestr(ref, blob)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _as_uuid(v) -> uuid.UUID | None:
|
||||||
|
return uuid.UUID(v) if v else None
|
||||||
|
|
||||||
|
|
||||||
|
def _as_date(v) -> date | None:
|
||||||
|
return date.fromisoformat(v) if v else None
|
||||||
|
|
||||||
|
|
||||||
|
async def import_account(
|
||||||
|
session: AsyncSession, store: ObjectStore, *, user: User, raw_zip: bytes
|
||||||
|
) -> dict:
|
||||||
|
"""Restore an exported zip into NEW trees owned by the user. Non-destructive:
|
||||||
|
every record gets a fresh id; nothing existing is touched."""
|
||||||
|
try:
|
||||||
|
zf = zipfile.ZipFile(io.BytesIO(raw_zip))
|
||||||
|
payload = json.loads(zf.read("account.json"))
|
||||||
|
except (zipfile.BadZipFile, KeyError, json.JSONDecodeError) as e:
|
||||||
|
raise NotFound("not a valid Provenance export") from e
|
||||||
|
|
||||||
|
counts: dict[str, int] = {"trees": 0, "persons": 0, "events": 0, "media": 0}
|
||||||
|
|
||||||
|
for tdata in payload.get("trees", []):
|
||||||
|
t = tdata.get("tree", {})
|
||||||
|
tree = Tree(
|
||||||
|
owner_id=user.id,
|
||||||
|
name=(t.get("name") or "Imported tree"),
|
||||||
|
description=t.get("description"),
|
||||||
|
visibility=t.get("visibility") or "private",
|
||||||
|
)
|
||||||
|
session.add(tree)
|
||||||
|
await session.flush()
|
||||||
|
session.add(
|
||||||
|
TreeMembership(tree_id=tree.id, user_id=user.id, role=MembershipRole.owner)
|
||||||
|
)
|
||||||
|
counts["trees"] += 1
|
||||||
|
|
||||||
|
# id remaps from the export's ids to the freshly created ones.
|
||||||
|
pmap: dict[str, uuid.UUID] = {}
|
||||||
|
rmap: dict[str, uuid.UUID] = {}
|
||||||
|
smap: dict[str, uuid.UUID] = {}
|
||||||
|
nmap: dict[str, uuid.UUID] = {}
|
||||||
|
emap: dict[str, uuid.UUID] = {}
|
||||||
|
plmap: dict[str, uuid.UUID] = {}
|
||||||
|
|
||||||
|
for pl in tdata.get("places", []):
|
||||||
|
obj = Place(
|
||||||
|
tree_id=tree.id,
|
||||||
|
name=pl.get("name") or "",
|
||||||
|
place_type=pl.get("place_type"),
|
||||||
|
latitude=pl.get("latitude"),
|
||||||
|
longitude=pl.get("longitude"),
|
||||||
|
)
|
||||||
|
session.add(obj)
|
||||||
|
await session.flush()
|
||||||
|
plmap[pl["id"]] = obj.id
|
||||||
|
|
||||||
|
for p in tdata.get("persons", []):
|
||||||
|
obj = Person(
|
||||||
|
tree_id=tree.id,
|
||||||
|
gender=p.get("gender"),
|
||||||
|
is_living=p.get("is_living"),
|
||||||
|
privacy=p.get("privacy") or "inherit",
|
||||||
|
notes=p.get("notes"),
|
||||||
|
)
|
||||||
|
session.add(obj)
|
||||||
|
await session.flush()
|
||||||
|
pmap[p["id"]] = obj.id
|
||||||
|
counts["persons"] += 1
|
||||||
|
|
||||||
|
for n in tdata.get("names", []):
|
||||||
|
pid = pmap.get(n.get("person_id"))
|
||||||
|
if pid is None:
|
||||||
|
continue
|
||||||
|
obj = Name(
|
||||||
|
tree_id=tree.id,
|
||||||
|
person_id=pid,
|
||||||
|
name_type=n.get("name_type") or "birth",
|
||||||
|
given=n.get("given"),
|
||||||
|
surname=n.get("surname"),
|
||||||
|
prefix=n.get("prefix"),
|
||||||
|
suffix=n.get("suffix"),
|
||||||
|
nickname=n.get("nickname"),
|
||||||
|
display_name=n.get("display_name"),
|
||||||
|
is_primary=bool(n.get("is_primary")),
|
||||||
|
sort_order=n.get("sort_order") or 0,
|
||||||
|
)
|
||||||
|
session.add(obj)
|
||||||
|
await session.flush()
|
||||||
|
nmap[n["id"]] = obj.id
|
||||||
|
|
||||||
|
for r in tdata.get("relationships", []):
|
||||||
|
a = pmap.get(r.get("person_from_id"))
|
||||||
|
b = pmap.get(r.get("person_to_id"))
|
||||||
|
if a is None or b is None:
|
||||||
|
continue
|
||||||
|
obj = Relationship(
|
||||||
|
tree_id=tree.id,
|
||||||
|
type=r.get("type"),
|
||||||
|
person_from_id=a,
|
||||||
|
person_to_id=b,
|
||||||
|
qualifier=r.get("qualifier"),
|
||||||
|
notes=r.get("notes"),
|
||||||
|
)
|
||||||
|
session.add(obj)
|
||||||
|
await session.flush()
|
||||||
|
rmap[r["id"]] = obj.id
|
||||||
|
|
||||||
|
for e in tdata.get("events", []):
|
||||||
|
obj = Event(
|
||||||
|
tree_id=tree.id,
|
||||||
|
event_type=e.get("event_type") or "other",
|
||||||
|
person_id=pmap.get(e.get("person_id")),
|
||||||
|
relationship_id=rmap.get(e.get("relationship_id")),
|
||||||
|
place_id=plmap.get(e.get("place_id")),
|
||||||
|
date_value=e.get("date_value"),
|
||||||
|
date_start=_as_date(e.get("date_start")),
|
||||||
|
date_end=_as_date(e.get("date_end")),
|
||||||
|
date_precision=e.get("date_precision"),
|
||||||
|
calendar=e.get("calendar") or "gregorian",
|
||||||
|
detail=e.get("detail"),
|
||||||
|
notes=e.get("notes"),
|
||||||
|
)
|
||||||
|
session.add(obj)
|
||||||
|
await session.flush()
|
||||||
|
emap[e["id"]] = obj.id
|
||||||
|
counts["events"] += 1
|
||||||
|
|
||||||
|
for s in tdata.get("sources", []):
|
||||||
|
obj = Source(
|
||||||
|
tree_id=tree.id,
|
||||||
|
title=s.get("title") or "Untitled source",
|
||||||
|
author=s.get("author"),
|
||||||
|
source_type=s.get("source_type"),
|
||||||
|
repository=s.get("repository"),
|
||||||
|
url=s.get("url"),
|
||||||
|
citation_text=s.get("citation_text"),
|
||||||
|
publication_info=s.get("publication_info"),
|
||||||
|
quality_note=s.get("quality_note"),
|
||||||
|
)
|
||||||
|
session.add(obj)
|
||||||
|
await session.flush()
|
||||||
|
smap[s["id"]] = obj.id
|
||||||
|
|
||||||
|
for c in tdata.get("citations", []):
|
||||||
|
sid = smap.get(c.get("source_id"))
|
||||||
|
if sid is None:
|
||||||
|
continue
|
||||||
|
session.add(
|
||||||
|
Citation(
|
||||||
|
tree_id=tree.id,
|
||||||
|
source_id=sid,
|
||||||
|
person_id=pmap.get(c.get("person_id")),
|
||||||
|
event_id=emap.get(c.get("event_id")),
|
||||||
|
name_id=nmap.get(c.get("name_id")),
|
||||||
|
relationship_id=rmap.get(c.get("relationship_id")),
|
||||||
|
page=c.get("page"),
|
||||||
|
detail=c.get("detail"),
|
||||||
|
confidence=c.get("confidence"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for m in tdata.get("media", []):
|
||||||
|
ref = m.get("_file")
|
||||||
|
if not ref:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
blob = zf.read(ref)
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
media_id = uuid.uuid4()
|
||||||
|
filename = m.get("original_filename") or "upload"
|
||||||
|
key = f"{tree.id}/{media_id}/{filename}"
|
||||||
|
await store.ensure_bucket()
|
||||||
|
await store.put_object(
|
||||||
|
key=key,
|
||||||
|
data=blob,
|
||||||
|
content_type=m.get("content_type") or "application/octet-stream",
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
Media(
|
||||||
|
id=media_id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
uploader_id=user.id,
|
||||||
|
storage_key=key,
|
||||||
|
original_filename=filename,
|
||||||
|
content_type=m.get("content_type") or "application/octet-stream",
|
||||||
|
byte_size=len(blob),
|
||||||
|
checksum_sha256=hashlib.sha256(blob).hexdigest(),
|
||||||
|
title=m.get("title"),
|
||||||
|
person_id=pmap.get(m.get("person_id")),
|
||||||
|
event_id=emap.get(m.get("event_id")),
|
||||||
|
source_id=smap.get(m.get("source_id")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
counts["media"] += 1
|
||||||
|
|
||||||
|
# Remap the home person last, once persons exist.
|
||||||
|
home = t.get("home_person_id")
|
||||||
|
if home and home in pmap:
|
||||||
|
tree.home_person_id = pmap[home]
|
||||||
|
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="import",
|
||||||
|
entity_type="Account",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=user.id,
|
||||||
|
after=counts,
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_account(session: AsyncSession, *, user: User, confirm_email: str) -> None:
|
||||||
|
"""Soft-delete the account: the user, the trees they own, and all their
|
||||||
|
sessions. Requires the user to retype their email as a guard."""
|
||||||
|
if confirm_email.strip().lower() != user.email.lower():
|
||||||
|
raise Forbidden("email confirmation does not match")
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
update(Tree)
|
||||||
|
.where(Tree.owner_id == user.id, Tree.deleted_at.is_(None))
|
||||||
|
.values(deleted_at=now)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
update(SessionModel)
|
||||||
|
.where(SessionModel.user_id == user.id, SessionModel.revoked_at.is_(None))
|
||||||
|
.values(revoked_at=now)
|
||||||
|
)
|
||||||
|
user.deleted_at = now
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="User",
|
||||||
|
entity_id=user.id,
|
||||||
|
actor_user_id=user.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
@@ -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.
|
the session; the caller commits as part of its unit of work.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -11,6 +12,14 @@ from app.models.audit import AuditEntry
|
|||||||
from app.models.enums import AuditActorType
|
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(
|
def record_audit(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
@@ -30,8 +39,8 @@ def record_audit(
|
|||||||
tree_id=tree_id,
|
tree_id=tree_id,
|
||||||
actor_user_id=actor_user_id,
|
actor_user_id=actor_user_id,
|
||||||
actor_type=actor_type,
|
actor_type=actor_type,
|
||||||
before=before,
|
before=_json_safe(before),
|
||||||
after=after,
|
after=_json_safe(after),
|
||||||
)
|
)
|
||||||
session.add(entry)
|
session.add(entry)
|
||||||
return entry
|
return entry
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy import select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.config import get_settings
|
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.auth.local import LocalAuthProvider
|
||||||
from app.integrations.mailer.base import Mailer
|
from app.integrations.mailer.base import Mailer
|
||||||
from app.models.auth import Session as SessionModel
|
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.enums import TokenPurpose
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.audit import record_audit
|
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()
|
_local_provider = LocalAuthProvider()
|
||||||
|
|
||||||
@@ -113,6 +113,8 @@ async def login(
|
|||||||
user = await _local_provider.authenticate(session, identifier=email, secret=password)
|
user = await _local_provider.authenticate(session, identifier=email, secret=password)
|
||||||
if user is None:
|
if user is None:
|
||||||
return None
|
return None
|
||||||
|
if get_settings().require_email_verification and user.email_verified_at is None:
|
||||||
|
raise Forbidden("email not verified — check your inbox for the verification link")
|
||||||
raw_token, record = _issue_session(session, user)
|
raw_token, record = _issue_session(session, user)
|
||||||
record_audit(
|
record_audit(
|
||||||
session, action="login", entity_type="User", entity_id=user.id, actor_user_id=user.id
|
session, action="login", entity_type="User", entity_id=user.id, actor_user_id=user.id
|
||||||
@@ -141,11 +143,16 @@ async def resolve_session_user(session: AsyncSession, *, raw_token: str) -> User
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if record is None or record.revoked_at is not None or record.expires_at <= _now():
|
if record is None or record.revoked_at is not None or record.expires_at <= _now():
|
||||||
return None
|
return None
|
||||||
return (
|
user = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(User).where(User.id == record.user_id, User.deleted_at.is_(None))
|
select(User).where(User.id == record.user_id, User.deleted_at.is_(None))
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
|
# The single read-side enforcement: an unverified user has no active session
|
||||||
|
# when verification is required. Gates every authenticated request at once.
|
||||||
|
if user is not None and get_settings().require_email_verification and user.email_verified_at is None:
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def verify_email(session: AsyncSession, *, raw_token: str) -> None:
|
async def verify_email(session: AsyncSession, *, raw_token: str) -> None:
|
||||||
@@ -178,6 +185,26 @@ async def request_password_reset(session: AsyncSession, mailer: Mailer, *, email
|
|||||||
await mailer.send_password_reset(to=email, link=_link("/auth/reset-password", raw))
|
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:
|
async def reset_password(session: AsyncSession, *, raw_token: str, new_password: str) -> None:
|
||||||
token = await _consume_token(session, raw_token, TokenPurpose.password_reset)
|
token = await _consume_token(session, raw_token, TokenPurpose.password_reset)
|
||||||
await session.execute(
|
await session.execute(
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
"""Bulk tree cleanup — preview/apply pairs for common import messes.
|
||||||
|
|
||||||
|
Per the project's #1 rule (the assistant proposes, humans approve), each fix has
|
||||||
|
a *preview* that returns the proposed changes and an *apply* that commits only
|
||||||
|
the ids/edits the user confirmed. Nothing here mutates without an explicit apply
|
||||||
|
call carrying the user's selections.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.enums import RelationshipType
|
||||||
|
from app.models.event import Event
|
||||||
|
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 gedcom, privacy
|
||||||
|
from app.services.audit import record_audit
|
||||||
|
from app.services.exceptions import Forbidden, NotFound
|
||||||
|
from app.services.name_gender_data import guess_sex
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_editor(session: AsyncSession, *, actor: User, tree: Tree) -> None:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
|
||||||
|
|
||||||
|
async def _persons(session: AsyncSession, tree_id: uuid.UUID) -> list[Person]:
|
||||||
|
return list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(Person.tree_id == tree_id, Person.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _primary_name_by_person(
|
||||||
|
session: AsyncSession, tree_id: uuid.UUID
|
||||||
|
) -> dict[uuid.UUID, Name]:
|
||||||
|
names = (
|
||||||
|
await session.execute(
|
||||||
|
select(Name)
|
||||||
|
.where(Name.tree_id == tree_id, Name.deleted_at.is_(None))
|
||||||
|
.order_by(Name.is_primary.desc(), Name.sort_order)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
out: dict[uuid.UUID, Name] = {}
|
||||||
|
for n in names:
|
||||||
|
out.setdefault(n.person_id, n)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _birth_year_by_person(session: AsyncSession, tree_id: uuid.UUID) -> dict[uuid.UUID, int]:
|
||||||
|
evs = (
|
||||||
|
await session.execute(
|
||||||
|
select(Event).where(
|
||||||
|
Event.tree_id == tree_id,
|
||||||
|
Event.deleted_at.is_(None),
|
||||||
|
Event.event_type == "birth",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
out: dict[uuid.UUID, int] = {}
|
||||||
|
for e in evs:
|
||||||
|
if not e.person_id or e.person_id in out:
|
||||||
|
continue
|
||||||
|
y = e.date_start.year if e.date_start else None
|
||||||
|
if y is None:
|
||||||
|
ys = gedcom._year(e.date_value)
|
||||||
|
y = int(ys) if ys else None
|
||||||
|
if y is not None:
|
||||||
|
out[e.person_id] = y
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _display(n: Name | None) -> str:
|
||||||
|
if n is None:
|
||||||
|
return "Unnamed"
|
||||||
|
return " ".join(x for x in (n.given, n.surname) if x) or (n.display_name or "Unnamed")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 1. Mark deceased by birth year -------------------------------------------------
|
||||||
|
|
||||||
|
async def preview_deceased(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, year: int
|
||||||
|
) -> list[dict]:
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
names = await _primary_name_by_person(session, tree.id)
|
||||||
|
years = await _birth_year_by_person(session, tree.id)
|
||||||
|
out: list[dict] = []
|
||||||
|
for p in await _persons(session, tree.id):
|
||||||
|
if p.is_living is False: # already deceased
|
||||||
|
continue
|
||||||
|
by = years.get(p.id)
|
||||||
|
if by is not None and by <= year:
|
||||||
|
out.append(
|
||||||
|
{"person_id": str(p.id), "name": _display(names.get(p.id)), "birth_year": by}
|
||||||
|
)
|
||||||
|
out.sort(key=lambda r: r["birth_year"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_deceased(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, person_ids: list[uuid.UUID]
|
||||||
|
) -> int:
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
persons = (
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(
|
||||||
|
Person.tree_id == tree.id,
|
||||||
|
Person.deleted_at.is_(None),
|
||||||
|
Person.id.in_(person_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for p in persons:
|
||||||
|
p.is_living = False
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="cleanup_deceased",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"count": len(persons)},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return len(persons)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 2. Re-derive gender from a source GEDCOM (matches by name) ----------------------
|
||||||
|
|
||||||
|
async def preview_gender(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, gedcom_text: str
|
||||||
|
) -> list[dict]:
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
name2sex: dict[str, str] = {}
|
||||||
|
for rec in gedcom.parse_records(gedcom_text):
|
||||||
|
if rec.tag != "INDI":
|
||||||
|
continue
|
||||||
|
summ = gedcom._person_summary(rec)
|
||||||
|
sex = gedcom._sex(rec.text("SEX"))
|
||||||
|
if sex and summ["norm"]:
|
||||||
|
name2sex.setdefault(summ["norm"], sex)
|
||||||
|
|
||||||
|
names = await _primary_name_by_person(session, tree.id)
|
||||||
|
out: list[dict] = []
|
||||||
|
for p in await _persons(session, tree.id):
|
||||||
|
if p.gender: # only fill in what's missing
|
||||||
|
continue
|
||||||
|
nm = names.get(p.id)
|
||||||
|
if nm is None:
|
||||||
|
continue
|
||||||
|
proposed = name2sex.get(gedcom._norm(nm.given, nm.surname))
|
||||||
|
if proposed:
|
||||||
|
out.append({"person_id": str(p.id), "name": _display(nm), "proposed_gender": proposed})
|
||||||
|
out.sort(key=lambda r: r["name"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def guess_gender_by_name(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Best-guess sex from the first given name for people who don't have it set,
|
||||||
|
using the bundled name dictionary. Ambiguous/unknown names are skipped."""
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
names = await _primary_name_by_person(session, tree.id)
|
||||||
|
out: list[dict] = []
|
||||||
|
for p in await _persons(session, tree.id):
|
||||||
|
if p.gender:
|
||||||
|
continue
|
||||||
|
nm = names.get(p.id)
|
||||||
|
if nm is None:
|
||||||
|
continue
|
||||||
|
proposed = guess_sex(nm.given)
|
||||||
|
if proposed:
|
||||||
|
out.append({"person_id": str(p.id), "name": _display(nm), "proposed_gender": proposed})
|
||||||
|
out.sort(key=lambda r: r["name"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def guess_gender_by_spouse(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Infer the sex of a person who has none set from a partner whose sex IS set
|
||||||
|
(couples in a tree are opposite-sex in practice — e.g. a confirmed-male
|
||||||
|
husband implies a female wife). People whose known partners disagree are
|
||||||
|
ambiguous and skipped; the result is a preview to review, not an auto-write."""
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
persons = await _persons(session, tree.id)
|
||||||
|
gender = {p.id: p.gender for p in persons}
|
||||||
|
names = await _primary_name_by_person(session, tree.id)
|
||||||
|
rels = (
|
||||||
|
await session.execute(
|
||||||
|
select(Relationship).where(
|
||||||
|
Relationship.tree_id == tree.id,
|
||||||
|
Relationship.deleted_at.is_(None),
|
||||||
|
Relationship.type == RelationshipType.partnership,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
opp = {"male": "female", "female": "male"}
|
||||||
|
proposals: dict[uuid.UUID, set[str]] = {}
|
||||||
|
for r in rels:
|
||||||
|
for me_id, other_id in (
|
||||||
|
(r.person_from_id, r.person_to_id),
|
||||||
|
(r.person_to_id, r.person_from_id),
|
||||||
|
):
|
||||||
|
if gender.get(me_id):
|
||||||
|
continue # this person already has a sex
|
||||||
|
other_sex = str(gender.get(other_id) or "")
|
||||||
|
if other_sex in opp:
|
||||||
|
proposals.setdefault(me_id, set()).add(opp[other_sex])
|
||||||
|
out: list[dict] = []
|
||||||
|
for pid, sexes in proposals.items():
|
||||||
|
if len(sexes) != 1:
|
||||||
|
continue # partners of differing known sex → ambiguous
|
||||||
|
nm = names.get(pid)
|
||||||
|
if nm is None:
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
{"person_id": str(pid), "name": _display(nm), "proposed_gender": next(iter(sexes))}
|
||||||
|
)
|
||||||
|
out.sort(key=lambda r: r["name"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_gender(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, updates: list[dict]
|
||||||
|
) -> int:
|
||||||
|
"""updates: [{person_id, gender}]."""
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
wanted = {uuid.UUID(str(u["person_id"])): u["gender"] for u in updates if u.get("gender")}
|
||||||
|
persons = (
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(
|
||||||
|
Person.tree_id == tree.id,
|
||||||
|
Person.deleted_at.is_(None),
|
||||||
|
Person.id.in_(wanted.keys()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for p in persons:
|
||||||
|
p.gender = wanted[p.id]
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="cleanup_gender",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"count": len(persons)},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return len(persons)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 3. Flag malformed names for review --------------------------------------------
|
||||||
|
|
||||||
|
_YEAR_RE = re.compile(r"\b\d{3,4}\b")
|
||||||
|
|
||||||
|
|
||||||
|
def _name_issue(n: Name) -> str | None:
|
||||||
|
given = (n.given or "").strip()
|
||||||
|
surname = (n.surname or "").strip()
|
||||||
|
if _YEAR_RE.search(surname) or re.search(r"\d", surname):
|
||||||
|
return "date_in_surname"
|
||||||
|
if re.search(r"\d", given):
|
||||||
|
return "date_in_given"
|
||||||
|
# A given name with many tokens often means a maiden+married name was packed
|
||||||
|
# in (e.g. "Mary Smith Jones") — surface it for a human to split.
|
||||||
|
if surname == "" and len(given.split()) >= 2:
|
||||||
|
return "no_surname"
|
||||||
|
if len(given.split()) >= 3:
|
||||||
|
return "packed_given"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def preview_names(session: AsyncSession, *, actor: User, tree: Tree) -> list[dict]:
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
names = (
|
||||||
|
await session.execute(
|
||||||
|
select(Name).where(Name.tree_id == tree.id, Name.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
out: list[dict] = []
|
||||||
|
for n in names:
|
||||||
|
issue = _name_issue(n)
|
||||||
|
if issue:
|
||||||
|
out.append({
|
||||||
|
"name_id": str(n.id),
|
||||||
|
"person_id": str(n.person_id),
|
||||||
|
"given": n.given,
|
||||||
|
"surname": n.surname,
|
||||||
|
"issue": issue,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_names(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, edits: list[dict]
|
||||||
|
) -> int:
|
||||||
|
"""edits: [{name_id, given, surname}] — the user's corrected values."""
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
by_id = {uuid.UUID(str(e["name_id"])): e for e in edits}
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(Name).where(
|
||||||
|
Name.tree_id == tree.id,
|
||||||
|
Name.deleted_at.is_(None),
|
||||||
|
Name.id.in_(by_id.keys()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
if len(rows) != len(by_id):
|
||||||
|
raise NotFound("one or more names not found in this tree")
|
||||||
|
for n in rows:
|
||||||
|
e = by_id[n.id]
|
||||||
|
n.given = (e.get("given") or "").strip() or None
|
||||||
|
n.surname = (e.get("surname") or "").strip() or None
|
||||||
|
n.display_name = None # rebuild from parts
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="cleanup_names",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"count": len(rows)},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return len(rows)
|
||||||
@@ -97,6 +97,13 @@ async def list_events(
|
|||||||
"""All events in the tree — lets the family view compute birth/death years."""
|
"""All events in the tree — lets the family view compute birth/death years."""
|
||||||
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
raise Forbidden("not permitted to view this tree")
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
# Non-members get the redacted projection (no living-person dates).
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
return await public_view_service.list_public_events(
|
||||||
|
session, viewer_id=viewer_id, tree=tree
|
||||||
|
)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Event)
|
select(Event)
|
||||||
.where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
|
.where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
|
||||||
@@ -110,6 +117,13 @@ async def list_events_for_person(
|
|||||||
) -> list[Event]:
|
) -> list[Event]:
|
||||||
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
raise Forbidden("not permitted to view this tree")
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
# Non-members only see a full-visibility person's events (redacted → none).
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
return await public_view_service.list_public_person_events(
|
||||||
|
session, viewer_id=viewer_id, tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Event)
|
select(Event)
|
||||||
.where(
|
.where(
|
||||||
|
|||||||
+440
-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
|
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
|
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.
|
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 re
|
||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.enums import ParentChildQualifier, RelationshipType
|
from app.models.enums import ParentChildQualifier, RelationshipType
|
||||||
@@ -32,12 +38,31 @@ INDI_EVENTS = {
|
|||||||
"BURI": "burial", "CREM": "cremation", "RESI": "residence", "CENS": "census",
|
"BURI": "burial", "CREM": "cremation", "RESI": "residence", "CENS": "census",
|
||||||
"IMMI": "immigration", "EMIG": "emigration", "OCCU": "occupation",
|
"IMMI": "immigration", "EMIG": "emigration", "OCCU": "occupation",
|
||||||
"EDUC": "education", "GRAD": "graduation", "RETI": "retirement",
|
"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-level events.
|
||||||
FAM_EVENTS = {"MARR": "marriage", "DIV": "divorce", "ENGA": "engagement"}
|
FAM_EVENTS = {"MARR": "marriage", "DIV": "divorce", "ENGA": "engagement"}
|
||||||
EVENT_TO_GED = {v: k for k, v in {**INDI_EVENTS, **FAM_EVENTS}.items()}
|
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:
|
class GedcomNode:
|
||||||
__slots__ = ("level", "tag", "value", "xref", "children")
|
__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
|
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:
|
def _year(date_value: str | None) -> str | None:
|
||||||
if not date_value:
|
if not date_value:
|
||||||
return None
|
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)
|
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(
|
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:
|
) -> 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):
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
raise Forbidden("not an editor of this tree")
|
raise Forbidden("not an editor of this tree")
|
||||||
|
|
||||||
|
resolutions = resolutions or {}
|
||||||
roots = parse_records(text)
|
roots = parse_records(text)
|
||||||
counts = defaultdict(int)
|
counts: dict[str, int] = defaultdict(int)
|
||||||
unmapped: set[str] = set()
|
unmapped: set[str] = set()
|
||||||
place_cache: dict[str, uuid.UUID] = {}
|
place_cache: dict[str, uuid.UUID] = {}
|
||||||
source_map: dict[str, uuid.UUID] = {}
|
source_map: dict[str, uuid.UUID] = {}
|
||||||
person_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:
|
async def place_id(name: str | None) -> uuid.UUID | None:
|
||||||
if not name:
|
if not name:
|
||||||
@@ -177,59 +443,139 @@ async def import_gedcom(
|
|||||||
sid = source_map.get(s.value.strip())
|
sid = source_map.get(s.value.strip())
|
||||||
if sid is None:
|
if sid is None:
|
||||||
continue
|
continue
|
||||||
session.add(
|
session.add(Citation(tree_id=tree.id, source_id=sid, page=s.text("PAGE"), **target))
|
||||||
Citation(tree_id=tree.id, source_id=sid, page=s.text("PAGE"), **target)
|
|
||||||
)
|
|
||||||
counts["citations"] += 1
|
counts["citations"] += 1
|
||||||
|
|
||||||
# Individuals.
|
def add_names(person_id: uuid.UUID, names: list[dict], *, set_primary: bool) -> None:
|
||||||
for rec in roots:
|
for nd in names:
|
||||||
if rec.tag != "INDI" or not rec.xref:
|
|
||||||
continue
|
|
||||||
person = Person(tree_id=tree.id, gender=_sex(rec.text("SEX")))
|
|
||||||
session.add(person)
|
|
||||||
await session.flush()
|
|
||||||
person_map[rec.xref] = person.id
|
|
||||||
counts["persons"] += 1
|
|
||||||
|
|
||||||
for i, nm in enumerate(rec.all("NAME")):
|
|
||||||
given, surname = _parse_name(nm.value)
|
|
||||||
session.add(
|
session.add(
|
||||||
Name(
|
Name(
|
||||||
tree_id=tree.id,
|
tree_id=tree.id,
|
||||||
person_id=person.id,
|
person_id=person_id,
|
||||||
name_type="birth",
|
name_type=nd["type"],
|
||||||
given=given,
|
given=nd["given"],
|
||||||
surname=surname,
|
surname=nd["surname"],
|
||||||
display_name=nm.value or None,
|
nickname=nd.get("nickname"),
|
||||||
is_primary=(i == 0),
|
display_name=nd.get("display"),
|
||||||
sort_order=i,
|
is_primary=set_primary and nd.get("is_primary", False),
|
||||||
|
sort_order=nd.get("sort", 0),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
counts["names"] += 1
|
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:
|
for child in rec.children:
|
||||||
if child.tag in INDI_EVENTS:
|
if child.tag in INDI_EVENTS:
|
||||||
dv = child.text("DATE")
|
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(
|
ev = Event(
|
||||||
tree_id=tree.id,
|
tree_id=tree.id,
|
||||||
person_id=person.id,
|
person_id=person_id,
|
||||||
event_type=INDI_EVENTS[child.tag],
|
event_type=INDI_EVENTS[child.tag],
|
||||||
date_value=dv,
|
date_value=dv,
|
||||||
date_start=_date_start(dv),
|
date_start=_date_start(dv),
|
||||||
place_id=await place_id(child.text("PLAC")),
|
place_id=await place_id(child.text("PLAC")),
|
||||||
|
detail=detail or None,
|
||||||
|
notes=child.text("NOTE"),
|
||||||
)
|
)
|
||||||
session.add(ev)
|
session.add(ev)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
counts["events"] += 1
|
counts["events"] += 1
|
||||||
await add_citations(child, event_id=ev.id)
|
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
|
continue
|
||||||
else:
|
else:
|
||||||
unmapped.add(child.tag)
|
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.
|
# Families -> partnerships, parent-child edges, marriage events.
|
||||||
for rec in roots:
|
for rec in roots:
|
||||||
if rec.tag != "FAM":
|
if rec.tag != "FAM":
|
||||||
@@ -238,17 +584,22 @@ async def import_gedcom(
|
|||||||
husb = person_map.get((rec.text("HUSB") or "").strip())
|
husb = person_map.get((rec.text("HUSB") or "").strip())
|
||||||
wife = person_map.get((rec.text("WIFE") or "").strip())
|
wife = person_map.get((rec.text("WIFE") or "").strip())
|
||||||
partnership_id: uuid.UUID | None = None
|
partnership_id: uuid.UUID | None = None
|
||||||
if husb and wife:
|
if husb and wife and husb != wife:
|
||||||
rel = Relationship(
|
rel = add_relationship(RelationshipType.partnership, husb, wife)
|
||||||
tree_id=tree.id,
|
if rel is not None:
|
||||||
type=RelationshipType.partnership,
|
await session.flush()
|
||||||
person_from_id=husb,
|
partnership_id = rel.id
|
||||||
person_to_id=wife,
|
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)
|
partnership_id = existing.id if existing else None
|
||||||
await session.flush()
|
|
||||||
partnership_id = rel.id
|
|
||||||
counts["relationships"] += 1
|
|
||||||
|
|
||||||
for fe in rec.children:
|
for fe in rec.children:
|
||||||
if fe.tag in FAM_EVENTS and partnership_id is not None:
|
if fe.tag in FAM_EVENTS and partnership_id is not None:
|
||||||
@@ -271,16 +622,12 @@ async def import_gedcom(
|
|||||||
continue
|
continue
|
||||||
for parent in (husb, wife):
|
for parent in (husb, wife):
|
||||||
if parent and parent != cp:
|
if parent and parent != cp:
|
||||||
session.add(
|
add_relationship(
|
||||||
Relationship(
|
RelationshipType.parent_child,
|
||||||
tree_id=tree.id,
|
parent,
|
||||||
type=RelationshipType.parent_child,
|
cp,
|
||||||
person_from_id=parent,
|
qualifier=ParentChildQualifier.biological,
|
||||||
person_to_id=cp,
|
|
||||||
qualifier=ParentChildQualifier.biological,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
counts["relationships"] += 1
|
|
||||||
|
|
||||||
record_audit(
|
record_audit(
|
||||||
session,
|
session,
|
||||||
@@ -345,10 +692,45 @@ async def export_gedcom(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tr
|
|||||||
await session.execute(select(Place).where(Place.tree_id == tree.id))
|
await session.execute(select(Place).where(Place.tree_id == tree.id))
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
}
|
}
|
||||||
|
citations = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Citation).where(
|
||||||
|
Citation.tree_id == tree.id, Citation.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
|
||||||
pxref = {p.id: f"@I{i + 1}@" for i, p in enumerate(persons)}
|
pxref = {p.id: f"@I{i + 1}@" for i, p in enumerate(persons)}
|
||||||
gender_by_id = {p.id: p.gender for p in persons}
|
gender_by_id = {p.id: p.gender for p in persons}
|
||||||
sxref = {s.id: f"@S{i + 1}@" for i, s in enumerate(sources)}
|
sxref = {s.id: f"@S{i + 1}@" for i, s in enumerate(sources)}
|
||||||
|
# Citations grouped by the fact they sit on, so each fact can emit its SOUR
|
||||||
|
# links (dropping these is the round-trip data loss this fixes). Skip any
|
||||||
|
# whose source didn't export.
|
||||||
|
cite_by_person: dict[uuid.UUID, list[Citation]] = defaultdict(list)
|
||||||
|
cite_by_name: dict[uuid.UUID, list[Citation]] = defaultdict(list)
|
||||||
|
cite_by_event: dict[uuid.UUID, list[Citation]] = defaultdict(list)
|
||||||
|
cite_by_rel: dict[uuid.UUID, list[Citation]] = defaultdict(list)
|
||||||
|
for c in citations:
|
||||||
|
if c.source_id not in sxref:
|
||||||
|
continue
|
||||||
|
if c.person_id:
|
||||||
|
cite_by_person[c.person_id].append(c)
|
||||||
|
elif c.event_id:
|
||||||
|
cite_by_event[c.event_id].append(c)
|
||||||
|
elif c.name_id:
|
||||||
|
cite_by_name[c.name_id].append(c)
|
||||||
|
elif c.relationship_id:
|
||||||
|
cite_by_rel[c.relationship_id].append(c)
|
||||||
|
|
||||||
|
def cite_lines(cites: list[Citation], depth: int) -> list[str]:
|
||||||
|
lines: list[str] = []
|
||||||
|
for c in cites:
|
||||||
|
lines.append(f"{depth} SOUR {sxref[c.source_id]}")
|
||||||
|
if c.page:
|
||||||
|
lines.append(f"{depth + 1} PAGE {c.page}")
|
||||||
|
return lines
|
||||||
names_by_person: dict[uuid.UUID, list[Name]] = defaultdict(list)
|
names_by_person: dict[uuid.UUID, list[Name]] = defaultdict(list)
|
||||||
for n in sorted(names, key=lambda n: (n.sort_order, not n.is_primary)):
|
for n in sorted(names, key=lambda n: (n.sort_order, not n.is_primary)):
|
||||||
names_by_person[n.person_id].append(n)
|
names_by_person[n.person_id].append(n)
|
||||||
@@ -397,6 +779,10 @@ async def export_gedcom(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tr
|
|||||||
for n in names_by_person.get(p.id, []):
|
for n in names_by_person.get(p.id, []):
|
||||||
display = n.display_name or f"{n.given or ''} /{n.surname or ''}/".strip()
|
display = n.display_name or f"{n.given or ''} /{n.surname or ''}/".strip()
|
||||||
out.append(f"1 NAME {display}")
|
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}")
|
||||||
|
out += cite_lines(cite_by_name.get(n.id, []), 2)
|
||||||
sex = {"male": "M", "female": "F"}.get(p.gender or "")
|
sex = {"male": "M", "female": "F"}.get(p.gender or "")
|
||||||
if sex:
|
if sex:
|
||||||
out.append(f"1 SEX {sex}")
|
out.append(f"1 SEX {sex}")
|
||||||
@@ -409,6 +795,8 @@ async def export_gedcom(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tr
|
|||||||
out.append(f"2 DATE {e.date_value}")
|
out.append(f"2 DATE {e.date_value}")
|
||||||
if e.place_id and e.place_id in places:
|
if e.place_id and e.place_id in places:
|
||||||
out.append(f"2 PLAC {places[e.place_id].name}")
|
out.append(f"2 PLAC {places[e.place_id].name}")
|
||||||
|
out += cite_lines(cite_by_event.get(e.id, []), 2)
|
||||||
|
out += cite_lines(cite_by_person.get(p.id, []), 1)
|
||||||
if p.id in child_fams:
|
if p.id in child_fams:
|
||||||
out.append(f"1 FAMC {child_fams[p.id]}")
|
out.append(f"1 FAMC {child_fams[p.id]}")
|
||||||
for x in spouse_fams.get(p.id, []):
|
for x in spouse_fams.get(p.id, []):
|
||||||
@@ -437,6 +825,8 @@ async def export_gedcom(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tr
|
|||||||
out.append(f"1 {tag}")
|
out.append(f"1 {tag}")
|
||||||
if _ged_date(e.date_value):
|
if _ged_date(e.date_value):
|
||||||
out.append(f"2 DATE {e.date_value}")
|
out.append(f"2 DATE {e.date_value}")
|
||||||
|
out += cite_lines(cite_by_event.get(e.id, []), 2)
|
||||||
|
out += cite_lines(cite_by_rel.get(f["rel_id"], []), 1)
|
||||||
|
|
||||||
for s in sources:
|
for s in sources:
|
||||||
out.append(f"0 {sxref[s.id]} SOUR")
|
out.append(f"0 {sxref[s.id]} SOUR")
|
||||||
|
|||||||
@@ -72,6 +72,13 @@ async def upload_media(
|
|||||||
async def list_media(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[Media]:
|
async def list_media(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[Media]:
|
||||||
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
raise Forbidden("not permitted to view this tree")
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
# Non-members only see media of a FULL-visibility person (no living-person photos).
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
return await public_view_service.list_public_media(
|
||||||
|
session, viewer_id=viewer_id, tree=tree
|
||||||
|
)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Media)
|
select(Media)
|
||||||
.where(Media.tree_id == tree.id, Media.deleted_at.is_(None))
|
.where(Media.tree_id == tree.id, Media.deleted_at.is_(None))
|
||||||
@@ -94,6 +101,15 @@ async def get_media(
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if media is None:
|
if media is None:
|
||||||
raise NotFound("media not found")
|
raise NotFound("media not found")
|
||||||
|
# Non-members may only see/download media of a FULL-visibility person. 404
|
||||||
|
# (not 403) so the item's existence isn't revealed. This gates media_content.
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
if not await public_view_service.can_view_media(
|
||||||
|
session, viewer_id=viewer_id, tree=tree, media=media
|
||||||
|
):
|
||||||
|
raise NotFound("media not found")
|
||||||
return media
|
return media
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""Tree membership management: list / add / change-role / remove.
|
||||||
|
|
||||||
|
Only an owner may change membership. A tree must always keep at least one owner.
|
||||||
|
The member list (which exposes user emails) is visible only to members — never
|
||||||
|
to a non-member viewing a public/unlisted tree.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.enums import MembershipRole
|
||||||
|
from app.models.tree import Tree, TreeMembership
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import privacy
|
||||||
|
from app.services.audit import record_audit
|
||||||
|
from app.services.exceptions import Conflict, Forbidden, NotFound
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_owner(session: AsyncSession, *, actor_id: uuid.UUID, tree: Tree) -> None:
|
||||||
|
if await privacy.get_membership_role(session, actor_id, tree.id) is not MembershipRole.owner:
|
||||||
|
raise Forbidden("only the owner can manage members")
|
||||||
|
|
||||||
|
|
||||||
|
async def _owner_count(session: AsyncSession, tree_id: uuid.UUID) -> int:
|
||||||
|
return (
|
||||||
|
await session.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(TreeMembership)
|
||||||
|
.where(TreeMembership.tree_id == tree_id, TreeMembership.role == MembershipRole.owner)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
def _row(m: TreeMembership, u: User) -> dict:
|
||||||
|
return {
|
||||||
|
"id": m.id,
|
||||||
|
"user_id": u.id,
|
||||||
|
"email": u.email,
|
||||||
|
"display_name": u.display_name,
|
||||||
|
"role": m.role,
|
||||||
|
"created_at": m.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_members(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[dict]:
|
||||||
|
# Member-only: the list exposes emails, so a non-member (even on a public
|
||||||
|
# tree) must not see it.
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
raise Forbidden("only members can see the member list")
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(TreeMembership, User)
|
||||||
|
.join(User, User.id == TreeMembership.user_id)
|
||||||
|
.where(TreeMembership.tree_id == tree.id)
|
||||||
|
.order_by(TreeMembership.created_at)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return [_row(m, u) for m, u in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def add_member(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, email: str, role: MembershipRole
|
||||||
|
) -> dict:
|
||||||
|
await _require_owner(session, actor_id=actor.id, tree=tree)
|
||||||
|
user = (
|
||||||
|
await session.execute(
|
||||||
|
select(User).where(User.email == email, User.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
raise NotFound("no user with that email on this instance")
|
||||||
|
if await privacy.get_membership_role(session, user.id, tree.id) is not None:
|
||||||
|
raise Conflict("that user is already a member")
|
||||||
|
m = TreeMembership(tree_id=tree.id, user_id=user.id, role=role)
|
||||||
|
session.add(m)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="add_member",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"user_id": str(user.id), "role": role.value},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(m)
|
||||||
|
return _row(m, user)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_membership(
|
||||||
|
session: AsyncSession, tree: Tree, membership_id: uuid.UUID
|
||||||
|
) -> TreeMembership:
|
||||||
|
m = (
|
||||||
|
await session.execute(
|
||||||
|
select(TreeMembership).where(
|
||||||
|
TreeMembership.id == membership_id, TreeMembership.tree_id == tree.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if m is None:
|
||||||
|
raise NotFound("member not found")
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
async def update_member_role(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
membership_id: uuid.UUID,
|
||||||
|
role: MembershipRole,
|
||||||
|
) -> dict:
|
||||||
|
await _require_owner(session, actor_id=actor.id, tree=tree)
|
||||||
|
m = await _get_membership(session, tree, membership_id)
|
||||||
|
if (
|
||||||
|
m.role == MembershipRole.owner
|
||||||
|
and role != MembershipRole.owner
|
||||||
|
and await _owner_count(session, tree.id) <= 1
|
||||||
|
):
|
||||||
|
raise Conflict("a tree must keep at least one owner")
|
||||||
|
m.role = role
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="update_member",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"membership_id": str(m.id), "role": role.value},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(m)
|
||||||
|
u = (await session.execute(select(User).where(User.id == m.user_id))).scalar_one()
|
||||||
|
return _row(m, u)
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_member(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, membership_id: uuid.UUID
|
||||||
|
) -> None:
|
||||||
|
await _require_owner(session, actor_id=actor.id, tree=tree)
|
||||||
|
m = await _get_membership(session, tree, membership_id)
|
||||||
|
if m.role == MembershipRole.owner and await _owner_count(session, tree.id) <= 1:
|
||||||
|
raise Conflict("a tree must keep at least one owner")
|
||||||
|
await session.delete(m)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="remove_member",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"membership_id": str(membership_id)},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""A curated given-name -> sex lookup for best-guessing a person's sex from
|
||||||
|
their first name. Weighted toward English + German names (this codebase's first
|
||||||
|
real tree is a German-American family). Deterministic and offline — no model
|
||||||
|
needed; the Cleanup tool previews every guess before anything is applied.
|
||||||
|
|
||||||
|
Genuinely ambiguous names (Marion, Frances/Francis, Jordan, Jamie, Robin, Leslie,
|
||||||
|
Dana, …) are intentionally left out of BOTH sets so they aren't guessed — better
|
||||||
|
a human decides those than a coin flip.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MALE_NAMES: set[str] = {
|
||||||
|
# English / common US
|
||||||
|
"james", "john", "robert", "michael", "william", "david", "richard", "joseph",
|
||||||
|
"thomas", "charles", "christopher", "daniel", "matthew", "anthony", "donald",
|
||||||
|
"mark", "paul", "steven", "andrew", "kenneth", "george", "joshua", "kevin",
|
||||||
|
"brian", "edward", "ronald", "timothy", "jason", "jeffrey", "gary", "ryan",
|
||||||
|
"nicholas", "eric", "stephen", "jacob", "larry", "frank", "jonathan", "scott",
|
||||||
|
"raymond", "gregory", "samuel", "benjamin", "patrick", "jack", "dennis", "jerry",
|
||||||
|
"alexander", "tyler", "henry", "douglas", "peter", "adam", "harold", "albert",
|
||||||
|
"arthur", "carl", "ralph", "roy", "eugene", "louis", "philip", "bobby", "walter",
|
||||||
|
"willie", "wayne", "fred", "howard", "ernest", "earl", "clarence", "leon",
|
||||||
|
"leonard", "lewis", "floyd", "leroy", "elmer", "homer", "orrin", "josias",
|
||||||
|
"emerson", "dale", "bernard", "vernon", "virgil", "wilbur", "russell",
|
||||||
|
"harvey", "herbert", "melvin", "lloyd", "marvin", "norman", "stanley",
|
||||||
|
# German
|
||||||
|
"hans", "karl", "wilhelm", "friedrich", "heinrich", "otto", "hermann", "gustav",
|
||||||
|
"ludwig", "ernst", "fritz", "johann", "conrad", "konrad", "reinhold", "rudolf",
|
||||||
|
"rudolph", "gerhard", "helmut", "horst", "klaus", "kurt", "dieter", "günther",
|
||||||
|
"gunther", "manfred", "siegfried", "hilgard", "christian", "august", "wolfgang",
|
||||||
|
"jürgen", "jurgen", "matthias", "lothar", "bruno", "gottlieb", "reinhard",
|
||||||
|
}
|
||||||
|
|
||||||
|
FEMALE_NAMES: set[str] = {
|
||||||
|
# English / common US
|
||||||
|
"mary", "patricia", "jennifer", "linda", "elizabeth", "barbara", "susan",
|
||||||
|
"jessica", "sarah", "karen", "nancy", "lisa", "betty", "margaret", "sandra",
|
||||||
|
"ashley", "kimberly", "emily", "donna", "michelle", "carol", "amanda", "dorothy",
|
||||||
|
"melissa", "deborah", "stephanie", "rebecca", "sharon", "laura", "cynthia",
|
||||||
|
"kathleen", "amy", "angela", "shirley", "anna", "ruth", "brenda", "pamela",
|
||||||
|
"nicole", "katherine", "virginia", "catherine", "helen", "debra", "rachel",
|
||||||
|
"carolyn", "janet", "maria", "heather", "diane", "julie", "joyce", "victoria",
|
||||||
|
"kelly", "christina", "joan", "evelyn", "judith", "megan", "alice", "frances",
|
||||||
|
"marie", "florence", "flora", "zella", "thelma", "ellen", "althea", "della",
|
||||||
|
"beatrice", "pauline", "hedwig", "florentine", "wilhelmina", "augusta", "bertha",
|
||||||
|
"gladys", "mildred", "lucille", "edith", "esther", "irene", "hazel", "doris",
|
||||||
|
"rose", "rita", "norma", "june", "lois", "marjorie",
|
||||||
|
# German
|
||||||
|
"greta", "ilse", "ursula", "gertrud", "gertrude", "frieda", "frida", "else",
|
||||||
|
"hilda", "hilde", "hildegard", "ingrid", "helga", "renate", "monika", "sieglinde",
|
||||||
|
"brigitte", "gisela", "elke", "anneliese", "waltraud", "edeltraud", "johanna",
|
||||||
|
"katharina", "margarethe", "wilhelmine", "emilie", "auguste",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def guess_sex(given: str | None) -> str | None:
|
||||||
|
"""Best-guess "male"/"female" from the first token of a given name, or None
|
||||||
|
if unknown/ambiguous."""
|
||||||
|
if not given:
|
||||||
|
return None
|
||||||
|
first = given.strip().split()[0].lower() if given.strip() else ""
|
||||||
|
# Strip trailing punctuation/initials like "wm." -> "wm".
|
||||||
|
first = first.strip(".,'\"")
|
||||||
|
if not first:
|
||||||
|
return None
|
||||||
|
if first in MALE_NAMES:
|
||||||
|
return "male"
|
||||||
|
if first in FEMALE_NAMES:
|
||||||
|
return "female"
|
||||||
|
return None
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""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)
|
||||||
|
# Non-members: a redacted/hidden person's real names must not leak.
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
return await public_view_service.list_public_person_names(
|
||||||
|
session, viewer_id=viewer_id, 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
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, or_, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.enums import PersonPrivacy
|
from app.models.enums import PersonPrivacy, RelationshipType
|
||||||
from app.models.person import Name, Person
|
from app.models.person import Name, Person
|
||||||
|
from app.models.relationship import Relationship
|
||||||
from app.models.tree import Tree
|
from app.models.tree import Tree
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services import privacy
|
from app.services import privacy
|
||||||
@@ -177,9 +178,65 @@ async def get_person(
|
|||||||
return person
|
return person
|
||||||
|
|
||||||
|
|
||||||
async def delete_person(
|
async def _children_of(
|
||||||
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID
|
session: AsyncSession, *, tree_id: uuid.UUID, parent_id: uuid.UUID
|
||||||
|
) -> list[uuid.UUID]:
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(Relationship.person_to_id).where(
|
||||||
|
Relationship.tree_id == tree_id,
|
||||||
|
Relationship.deleted_at.is_(None),
|
||||||
|
Relationship.type == RelationshipType.parent_child,
|
||||||
|
Relationship.person_from_id == parent_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return list(rows)
|
||||||
|
|
||||||
|
|
||||||
|
async def _soft_delete_one(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, person: Person, now: datetime
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Soft-delete a single person and the relationships touching them, so no
|
||||||
|
dangling edges are left to break the tree view."""
|
||||||
|
person.deleted_at = now
|
||||||
|
rels = (
|
||||||
|
await session.execute(
|
||||||
|
select(Relationship).where(
|
||||||
|
Relationship.tree_id == tree.id,
|
||||||
|
Relationship.deleted_at.is_(None),
|
||||||
|
or_(
|
||||||
|
Relationship.person_from_id == person.id,
|
||||||
|
Relationship.person_to_id == person.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for rel in rels:
|
||||||
|
rel.deleted_at = now
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="Person",
|
||||||
|
entity_id=person.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"cascaded_relationships": len(rels)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_person(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
cascade: bool = False,
|
||||||
|
) -> int:
|
||||||
|
"""Soft-delete a person. Always removes the relationships that touch them
|
||||||
|
(preventing dangling edges). With ``cascade=True``, recursively deletes
|
||||||
|
their descendants too — handy for pruning a bad GEDCOM import. Returns the
|
||||||
|
number of persons deleted."""
|
||||||
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
raise Forbidden("not an editor of this tree")
|
raise Forbidden("not an editor of this tree")
|
||||||
person = (
|
person = (
|
||||||
@@ -191,16 +248,52 @@ async def delete_person(
|
|||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if person is None:
|
if person is None:
|
||||||
raise NotFound("person not found")
|
raise NotFound("person not found")
|
||||||
person.deleted_at = datetime.now(UTC)
|
|
||||||
record_audit(
|
now = datetime.now(UTC)
|
||||||
session,
|
|
||||||
action="delete",
|
# Gather the set of persons to delete. For cascade, walk descendants
|
||||||
entity_type="Person",
|
# breadth-first, guarding against cycles.
|
||||||
entity_id=person.id,
|
to_delete: list[Person] = [person]
|
||||||
tree_id=tree.id,
|
if cascade:
|
||||||
actor_user_id=actor.id,
|
seen = {person.id}
|
||||||
|
frontier = [person.id]
|
||||||
|
while frontier:
|
||||||
|
nxt: list[uuid.UUID] = []
|
||||||
|
for pid in frontier:
|
||||||
|
for child_id in await _children_of(session, tree_id=tree.id, parent_id=pid):
|
||||||
|
if child_id not in seen:
|
||||||
|
seen.add(child_id)
|
||||||
|
nxt.append(child_id)
|
||||||
|
frontier = nxt
|
||||||
|
extra_ids = [pid for pid in seen if pid != person.id]
|
||||||
|
if extra_ids:
|
||||||
|
extra = (
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(
|
||||||
|
Person.id.in_(extra_ids),
|
||||||
|
Person.tree_id == tree.id,
|
||||||
|
Person.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
to_delete.extend(extra)
|
||||||
|
|
||||||
|
for p in to_delete:
|
||||||
|
await _soft_delete_one(session, actor=actor, tree=tree, person=p, now=now)
|
||||||
|
|
||||||
|
# Soft delete leaves the row in place, so the DB-level "ON DELETE SET NULL"
|
||||||
|
# never fires — clear any 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()
|
await session.commit()
|
||||||
|
return len(to_delete)
|
||||||
|
|
||||||
|
|
||||||
async def restore_person(
|
async def restore_person(
|
||||||
|
|||||||
@@ -45,8 +45,17 @@ async def can_view_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tre
|
|||||||
if tree.deleted_at is not None:
|
if tree.deleted_at is not None:
|
||||||
return False
|
return False
|
||||||
if await get_membership_role(session, user_id, tree.id) is not None:
|
if await get_membership_role(session, user_id, tree.id) is not None:
|
||||||
|
return True # members always (any role)
|
||||||
|
# Non-members. Branch on the viewer's auth state:
|
||||||
|
# public / unlisted → anyone, including anonymous (unlisted is gated only
|
||||||
|
# by knowing the link, so the API must never *list* it).
|
||||||
|
# site_members → any authenticated account on this instance.
|
||||||
|
# private → no one.
|
||||||
|
if tree.visibility in (TreeVisibility.public, TreeVisibility.unlisted):
|
||||||
return True
|
return True
|
||||||
return tree.visibility in (TreeVisibility.public, TreeVisibility.unlisted)
|
if tree.visibility == TreeVisibility.site_members:
|
||||||
|
return user_id is not None
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def can_edit_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree) -> bool:
|
async def can_edit_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree) -> bool:
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"""Read-only, redaction-safe projections for the public viewing surface.
|
||||||
|
|
||||||
|
INVARIANT (CLAUDE.md #2): everything returned here has passed through
|
||||||
|
``privacy.person_visibility``. A non-member must never receive a possibly-living
|
||||||
|
person's real name, dates, alternate names, or media. The rules:
|
||||||
|
|
||||||
|
- persons : redacted (living → "Living person"); hidden dropped.
|
||||||
|
- relationships : only when BOTH endpoints are non-hidden (a link to a
|
||||||
|
redacted person is fine — the name is already hidden).
|
||||||
|
- events : only for FULL-visibility persons; partnership events only
|
||||||
|
when BOTH partners are full (a marriage date would leak a
|
||||||
|
living partner's timeline otherwise).
|
||||||
|
- names : only for FULL-visibility persons.
|
||||||
|
- media : NOT exposed yet (deferred — see docs/design/tree-visibility.md).
|
||||||
|
|
||||||
|
A tree that isn't viewable raises NotFound (never Forbidden) so the public
|
||||||
|
surface can't be used to probe whether a private tree exists.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.enums import TreeVisibility
|
||||||
|
from app.models.event import Event
|
||||||
|
from app.models.media import Media
|
||||||
|
from app.models.person import Name, Person
|
||||||
|
from app.models.relationship import Relationship
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from app.services import privacy
|
||||||
|
from app.services.exceptions import NotFound
|
||||||
|
from app.services.person_service import _attach_primary_name, _redact
|
||||||
|
from app.services.privacy import Visibility
|
||||||
|
|
||||||
|
|
||||||
|
async def get_public_tree(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree_id: uuid.UUID
|
||||||
|
) -> Tree:
|
||||||
|
tree = (
|
||||||
|
await session.execute(
|
||||||
|
select(Tree).where(Tree.id == tree_id, Tree.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
# 404 (not 403) when not viewable: don't reveal that a private tree exists.
|
||||||
|
if tree is None or not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise NotFound("tree not found")
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
async def _persons(session: AsyncSession, tree: Tree) -> list[Person]:
|
||||||
|
return list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _visibility_map(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, persons: list[Person]
|
||||||
|
) -> dict[uuid.UUID, Visibility]:
|
||||||
|
return {
|
||||||
|
p.id: await privacy.person_visibility(
|
||||||
|
session, user_id=viewer_id, tree=tree, person=p
|
||||||
|
)
|
||||||
|
for p in persons
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_public_persons(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
|
||||||
|
) -> list[Person]:
|
||||||
|
out: list[Person] = []
|
||||||
|
for p in await _persons(session, tree):
|
||||||
|
vis = await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=p)
|
||||||
|
if vis == Visibility.hidden:
|
||||||
|
continue
|
||||||
|
if vis == Visibility.redacted:
|
||||||
|
_redact(p)
|
||||||
|
else:
|
||||||
|
await _attach_primary_name(session, p)
|
||||||
|
out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def get_public_person(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, 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")
|
||||||
|
vis = await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=person)
|
||||||
|
if vis == Visibility.hidden:
|
||||||
|
raise NotFound("person not found")
|
||||||
|
if vis == Visibility.redacted:
|
||||||
|
_redact(person)
|
||||||
|
else:
|
||||||
|
await _attach_primary_name(session, person)
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
async def _person_visibility(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID
|
||||||
|
) -> Visibility | None:
|
||||||
|
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:
|
||||||
|
return None
|
||||||
|
return await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=person)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_public_relationships(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
|
||||||
|
) -> list[Relationship]:
|
||||||
|
persons = await _persons(session, tree)
|
||||||
|
vis = await _visibility_map(session, viewer_id=viewer_id, tree=tree, persons=persons)
|
||||||
|
nonhidden = {pid for pid, v in vis.items() if v != Visibility.hidden}
|
||||||
|
rels = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Relationship).where(
|
||||||
|
Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
r for r in rels if r.person_from_id in nonhidden and r.person_to_id in nonhidden
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_public_events(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
|
||||||
|
) -> list[Event]:
|
||||||
|
persons = await _persons(session, tree)
|
||||||
|
vis = await _visibility_map(session, viewer_id=viewer_id, tree=tree, persons=persons)
|
||||||
|
full = {pid for pid, v in vis.items() if v == Visibility.full}
|
||||||
|
rels = {
|
||||||
|
r.id: r
|
||||||
|
for r in (
|
||||||
|
await session.execute(
|
||||||
|
select(Relationship).where(
|
||||||
|
Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
events = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Event).where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
out: list[Event] = []
|
||||||
|
for e in events:
|
||||||
|
if e.person_id is not None:
|
||||||
|
if e.person_id in full:
|
||||||
|
out.append(e)
|
||||||
|
elif e.relationship_id is not None:
|
||||||
|
r = rels.get(e.relationship_id)
|
||||||
|
if r is not None and r.person_from_id in full and r.person_to_id in full:
|
||||||
|
out.append(e)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def list_public_person_names(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID
|
||||||
|
) -> list[Name]:
|
||||||
|
vis = await _person_visibility(session, viewer_id=viewer_id, tree=tree, person_id=person_id)
|
||||||
|
if vis is None:
|
||||||
|
raise NotFound("person not found")
|
||||||
|
if vis != Visibility.full:
|
||||||
|
return [] # redacted/hidden → no names (the real name must not leak)
|
||||||
|
return list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Name)
|
||||||
|
.where(Name.person_id == person_id, Name.deleted_at.is_(None))
|
||||||
|
.order_by(Name.is_primary.desc(), Name.sort_order, Name.created_at)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_public_person_events(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID
|
||||||
|
) -> list[Event]:
|
||||||
|
vis = await _person_visibility(session, viewer_id=viewer_id, tree=tree, person_id=person_id)
|
||||||
|
if vis is None:
|
||||||
|
raise NotFound("person not found")
|
||||||
|
if vis != Visibility.full:
|
||||||
|
return [] # redacted/hidden → no dates
|
||||||
|
return list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Event)
|
||||||
|
.where(
|
||||||
|
Event.person_id == person_id,
|
||||||
|
Event.tree_id == tree.id,
|
||||||
|
Event.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(Event.date_start.nulls_last(), Event.created_at)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_public_relationships_for_person(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, person_id: uuid.UUID
|
||||||
|
) -> list[Relationship]:
|
||||||
|
persons = await _persons(session, tree)
|
||||||
|
vis = await _visibility_map(session, viewer_id=viewer_id, tree=tree, persons=persons)
|
||||||
|
if vis.get(person_id) in (None, Visibility.hidden):
|
||||||
|
return []
|
||||||
|
nonhidden = {pid for pid, v in vis.items() if v != Visibility.hidden}
|
||||||
|
rels = list(
|
||||||
|
(
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
return [r for r in rels if r.person_from_id in nonhidden and r.person_to_id in nonhidden]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_public_media(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree
|
||||||
|
) -> list[Media]:
|
||||||
|
"""Only media linked to a FULL-visibility person. Media without a person (or
|
||||||
|
linked only to an event/source) is not exposed to non-members — a photo of a
|
||||||
|
living person must never leak."""
|
||||||
|
persons = await _persons(session, tree)
|
||||||
|
vis = await _visibility_map(session, viewer_id=viewer_id, tree=tree, persons=persons)
|
||||||
|
full = {pid for pid, v in vis.items() if v == Visibility.full}
|
||||||
|
media = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Media).where(Media.tree_id == tree.id, Media.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [m for m in media if m.person_id is not None and m.person_id in full]
|
||||||
|
|
||||||
|
|
||||||
|
async def can_view_media(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID | None, tree: Tree, media: Media
|
||||||
|
) -> bool:
|
||||||
|
"""Whether a non-member may see/download a single media item: only when it is
|
||||||
|
linked to a FULL-visibility person."""
|
||||||
|
if media.person_id is None:
|
||||||
|
return False
|
||||||
|
vis = await _person_visibility(
|
||||||
|
session, viewer_id=viewer_id, tree=tree, person_id=media.person_id
|
||||||
|
)
|
||||||
|
return vis == Visibility.full
|
||||||
|
|
||||||
|
|
||||||
|
async def list_public_trees(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
viewer_id: uuid.UUID | None,
|
||||||
|
q: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[Tree]:
|
||||||
|
# Anonymous: only `public`. Authenticated: also `site_members`. Never list
|
||||||
|
# `unlisted` (reachable by link only) or `private`.
|
||||||
|
allowed = [TreeVisibility.public]
|
||||||
|
if viewer_id is not None:
|
||||||
|
allowed.append(TreeVisibility.site_members)
|
||||||
|
stmt = select(Tree).where(
|
||||||
|
Tree.deleted_at.is_(None), Tree.visibility.in_(allowed)
|
||||||
|
)
|
||||||
|
if q and q.strip():
|
||||||
|
stmt = stmt.where(Tree.name.ilike(f"%{q.strip()}%"))
|
||||||
|
stmt = stmt.order_by(Tree.name).limit(min(limit, 100)).offset(max(offset, 0))
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
@@ -4,7 +4,7 @@ Writes require editor rights; reads go through the privacy engine."""
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import or_, select
|
from sqlalchemy import and_, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.enums import ParentChildQualifier, RelationshipType
|
from app.models.enums import ParentChildQualifier, RelationshipType
|
||||||
@@ -49,6 +49,38 @@ async def create_relationship(
|
|||||||
if not await _person_in_tree(session, pid, tree.id):
|
if not await _person_in_tree(session, pid, tree.id):
|
||||||
raise NotFound("person not found in this tree")
|
raise NotFound("person not found in this tree")
|
||||||
|
|
||||||
|
# Reject an equivalent existing edge so the same two people can't be linked
|
||||||
|
# the same way twice. parent_child is directional (parent -> child);
|
||||||
|
# partnership/sibling are symmetric, so match the pair in either order.
|
||||||
|
if type is RelationshipType.parent_child:
|
||||||
|
pair = and_(
|
||||||
|
Relationship.person_from_id == person_from_id,
|
||||||
|
Relationship.person_to_id == person_to_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pair = or_(
|
||||||
|
and_(
|
||||||
|
Relationship.person_from_id == person_from_id,
|
||||||
|
Relationship.person_to_id == person_to_id,
|
||||||
|
),
|
||||||
|
and_(
|
||||||
|
Relationship.person_from_id == person_to_id,
|
||||||
|
Relationship.person_to_id == person_from_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
existing = (
|
||||||
|
await session.execute(
|
||||||
|
select(Relationship.id).where(
|
||||||
|
Relationship.tree_id == tree.id,
|
||||||
|
Relationship.type == type,
|
||||||
|
Relationship.deleted_at.is_(None),
|
||||||
|
pair,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
raise Conflict("these two people are already linked that way")
|
||||||
|
|
||||||
relationship = Relationship(
|
relationship = Relationship(
|
||||||
tree_id=tree.id,
|
tree_id=tree.id,
|
||||||
type=type,
|
type=type,
|
||||||
@@ -79,6 +111,13 @@ async def list_relationships(
|
|||||||
"""All relationships in the tree — powers the family/pedigree view in one call."""
|
"""All relationships in the tree — powers the family/pedigree view in one call."""
|
||||||
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
raise Forbidden("not permitted to view this tree")
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
# Non-members: drop relationships touching a hidden person.
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
return await public_view_service.list_public_relationships(
|
||||||
|
session, viewer_id=viewer_id, tree=tree
|
||||||
|
)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Relationship)
|
select(Relationship)
|
||||||
.where(Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None))
|
.where(Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None))
|
||||||
@@ -92,6 +131,12 @@ async def list_relationships_for_person(
|
|||||||
) -> list[Relationship]:
|
) -> list[Relationship]:
|
||||||
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
raise Forbidden("not permitted to view this tree")
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
return await public_view_service.list_public_relationships_for_person(
|
||||||
|
session, viewer_id=viewer_id, tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Relationship)
|
select(Relationship)
|
||||||
.where(
|
.where(
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async def update_tree(
|
|||||||
raise NotFound("tree not found")
|
raise NotFound("tree not found")
|
||||||
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
raise Forbidden("not an editor of this tree")
|
raise Forbidden("not an editor of this tree")
|
||||||
for key in {"name", "description", "visibility"} & changes.keys():
|
for key in {"name", "description", "visibility", "home_person_id"} & changes.keys():
|
||||||
setattr(tree, key, changes[key])
|
setattr(tree, key, changes[key])
|
||||||
record_audit(
|
record_audit(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ import uuid
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.person import Person
|
||||||
|
from app.models.tree import Tree
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.repositories.base import BaseRepository
|
from app.repositories.base import BaseRepository
|
||||||
|
from app.services import privacy
|
||||||
from app.services.audit import record_audit
|
from app.services.audit import record_audit
|
||||||
from app.services.exceptions import Conflict
|
from app.services.exceptions import Conflict, Forbidden, NotFound
|
||||||
|
|
||||||
|
|
||||||
async def create_user(
|
async def create_user(
|
||||||
@@ -42,3 +45,39 @@ async def create_user(
|
|||||||
|
|
||||||
async def get_user(session: AsyncSession, user_id: uuid.UUID) -> User | None:
|
async def get_user(session: AsyncSession, user_id: uuid.UUID) -> User | None:
|
||||||
return await BaseRepository(session, User).get(user_id)
|
return await BaseRepository(session, User).get(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_self_person(
|
||||||
|
session: AsyncSession, *, user: User, person_id: uuid.UUID | None
|
||||||
|
) -> User:
|
||||||
|
"""Point a user's account at the Person record that *is* them ("home
|
||||||
|
person"), or clear it with ``None``. The person must live in a tree the
|
||||||
|
user can view."""
|
||||||
|
if person_id is not None:
|
||||||
|
person = (
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(Person.id == person_id, Person.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if person is None:
|
||||||
|
raise NotFound("person not found")
|
||||||
|
tree = (
|
||||||
|
await session.execute(select(Tree).where(Tree.id == person.tree_id))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if tree is None or not await privacy.can_view_tree(
|
||||||
|
session, user_id=user.id, tree=tree
|
||||||
|
):
|
||||||
|
raise Forbidden("not permitted to link this person")
|
||||||
|
|
||||||
|
user.self_person_id = person_id
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="update",
|
||||||
|
entity_type="User",
|
||||||
|
entity_id=user.id,
|
||||||
|
actor_user_id=user.id,
|
||||||
|
after={"self_person_id": str(person_id) if person_id else None},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
return user
|
||||||
|
|||||||
@@ -0,0 +1,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,28 @@
|
|||||||
|
"""tree_visibility: add 'site_members' value
|
||||||
|
|
||||||
|
Revision ID: d4a9c1e7b2f3
|
||||||
|
Revises: c7e1a4f2d3b8
|
||||||
|
Create Date: 2026-06-09
|
||||||
|
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "d4a9c1e7b2f3"
|
||||||
|
down_revision: str | None = "c7e1a4f2d3b8"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ALTER TYPE ... ADD VALUE cannot run inside a transaction block on older
|
||||||
|
# Postgres; run it in an autocommit block so it applies regardless of version.
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
op.execute("ALTER TYPE tree_visibility ADD VALUE IF NOT EXISTS 'site_members'")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Postgres cannot drop an enum value without rebuilding the type; treat the
|
||||||
|
# added value as irreversible. (Rows using it would block a rebuild anyway.)
|
||||||
|
pass
|
||||||
@@ -67,16 +67,21 @@ def mailer() -> CapturingMailer:
|
|||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def client():
|
async def engine():
|
||||||
if not TEST_DATABASE_URL:
|
if not TEST_DATABASE_URL:
|
||||||
pytest.skip("TEST_DATABASE_URL not set")
|
pytest.skip("TEST_DATABASE_URL not set")
|
||||||
|
|
||||||
engine = create_async_engine(TEST_DATABASE_URL)
|
eng = create_async_engine(TEST_DATABASE_URL)
|
||||||
async with engine.begin() as conn:
|
async with eng.begin() as conn:
|
||||||
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
|
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
|
||||||
await conn.run_sync(Base.metadata.drop_all)
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield eng
|
||||||
|
await eng.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def client(engine):
|
||||||
sessionmaker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
sessionmaker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||||
|
|
||||||
async def _override_session():
|
async def _override_session():
|
||||||
@@ -95,7 +100,14 @@ async def client():
|
|||||||
yield http_client
|
yield http_client
|
||||||
|
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db_session(engine):
|
||||||
|
"""A raw AsyncSession on the test DB, for unit-testing services directly."""
|
||||||
|
sessionmaker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||||
|
async with sessionmaker() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
def token_from_link(link: str) -> str:
|
def token_from_link(link: str) -> str:
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Account export -> restore round-trip, and account deletion."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed(client, h):
|
||||||
|
tid = (await client.post("/api/v1/trees", json={"name": "Fam"}, headers=h)).json()["id"]
|
||||||
|
p1 = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons", json={"given": "Ada", "surname": "Lovelace"}, headers=h
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
p2 = (
|
||||||
|
await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Kid"}, headers=h)
|
||||||
|
).json()["id"]
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/relationships",
|
||||||
|
json={"type": "parent_child", "person_from_id": p1, "person_to_id": p2},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/events",
|
||||||
|
json={"event_type": "birth", "person_id": p1, "date_value": "1815"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/media",
|
||||||
|
files={"file": ("scan.txt", b"hello", "text/plain")},
|
||||||
|
data={"title": "Scan", "person_id": p1},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
return tid
|
||||||
|
|
||||||
|
|
||||||
|
async def test_export_then_restore_roundtrip(client):
|
||||||
|
h = auth(await register(client, "exp@example.com"))
|
||||||
|
await _seed(client, h)
|
||||||
|
|
||||||
|
export = await client.get("/api/v1/users/me/export", headers=h)
|
||||||
|
assert export.status_code == 200
|
||||||
|
assert export.headers["content-type"] == "application/zip"
|
||||||
|
blob = export.content
|
||||||
|
assert blob[:2] == b"PK" # zip magic
|
||||||
|
|
||||||
|
# Restore into new trees (non-destructive: the original stays).
|
||||||
|
r = await client.post(
|
||||||
|
"/api/v1/users/me/import",
|
||||||
|
files={"file": ("provenance-export.zip", blob, "application/zip")},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
counts = r.json()
|
||||||
|
assert counts["trees"] == 1 and counts["persons"] == 2
|
||||||
|
assert counts["events"] == 1 and counts["media"] == 1
|
||||||
|
|
||||||
|
trees = (await client.get("/api/v1/trees", headers=h)).json()
|
||||||
|
assert len(trees) == 2 # original + restored
|
||||||
|
|
||||||
|
# The restored tree has the people, with a working relationship and media.
|
||||||
|
restored = [t for t in trees if t["name"] == "Fam"][1]["id"]
|
||||||
|
ppl = (await client.get(f"/api/v1/trees/{restored}/persons", headers=h)).json()
|
||||||
|
assert {p["primary_name"] for p in ppl} == {"Ada Lovelace", "Kid"}
|
||||||
|
rels = (await client.get(f"/api/v1/trees/{restored}/relationships", headers=h)).json()
|
||||||
|
assert len(rels) == 1
|
||||||
|
med = (await client.get(f"/api/v1/trees/{restored}/media", headers=h)).json()
|
||||||
|
assert len(med) == 1 and med[0]["title"] == "Scan"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_account_requires_email_then_revokes(client):
|
||||||
|
token = await register(client, "del@example.com")
|
||||||
|
h = auth(token)
|
||||||
|
await _seed(client, h)
|
||||||
|
|
||||||
|
# Wrong email is rejected.
|
||||||
|
bad = await client.request(
|
||||||
|
"DELETE", "/api/v1/users/me", data={"confirm_email": "nope@example.com"}, headers=h
|
||||||
|
)
|
||||||
|
assert bad.status_code == 403
|
||||||
|
|
||||||
|
ok = await client.request(
|
||||||
|
"DELETE", "/api/v1/users/me", data={"confirm_email": "del@example.com"}, headers=h
|
||||||
|
)
|
||||||
|
assert ok.status_code == 204
|
||||||
|
|
||||||
|
# Session is revoked — the token no longer works.
|
||||||
|
assert (await client.get("/api/v1/users/me", headers=h)).status_code == 401
|
||||||
@@ -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
|
||||||
@@ -104,3 +104,44 @@ async def test_logout_revokes_session(client):
|
|||||||
assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 200
|
assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 200
|
||||||
assert (await client.post("/api/v1/auth/logout", headers=auth(token))).status_code == 204
|
assert (await client.post("/api/v1/auth/logout", headers=auth(token))).status_code == 204
|
||||||
assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 401
|
assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unverified_user_works_by_default(client):
|
||||||
|
# Default (require_email_verification off): unverified accounts work as before.
|
||||||
|
token = await register(client, "open@example.com")
|
||||||
|
assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_verification_gate_blocks_until_verified(client, mailer, monkeypatch):
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(get_settings(), "require_email_verification", True)
|
||||||
|
|
||||||
|
reg = await client.post(
|
||||||
|
"/api/v1/auth/register", json={"email": "gate@example.com", "password": "password123"}
|
||||||
|
)
|
||||||
|
assert reg.status_code == 201
|
||||||
|
token = reg.json()["token"]
|
||||||
|
|
||||||
|
# The session issued at registration does not resolve while unverified...
|
||||||
|
assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 401
|
||||||
|
# ...and login is refused with 403 (not 401 — credentials are valid).
|
||||||
|
blocked = await client.post(
|
||||||
|
"/api/v1/auth/login", json={"email": "gate@example.com", "password": "password123"}
|
||||||
|
)
|
||||||
|
assert blocked.status_code == 403
|
||||||
|
|
||||||
|
# Verify via the emailed link.
|
||||||
|
link = mailer.verifications[-1][1]
|
||||||
|
assert (
|
||||||
|
await client.post("/api/v1/auth/verify-email", json={"token": token_from_link(link)})
|
||||||
|
).status_code == 204
|
||||||
|
|
||||||
|
# Now login works and the session resolves.
|
||||||
|
ok = await client.post(
|
||||||
|
"/api/v1/auth/login", json={"email": "gate@example.com", "password": "password123"}
|
||||||
|
)
|
||||||
|
assert ok.status_code == 200
|
||||||
|
assert (
|
||||||
|
await client.get("/api/v1/users/me", headers=auth(ok.json()["token"]))
|
||||||
|
).status_code == 200
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""Authed non-member reads must redact PER-PERSON, not just gate on the tree.
|
||||||
|
|
||||||
|
A logged-in user who is NOT a member of a public tree previously saw living
|
||||||
|
people's dates, real alternate names, and media through the family-view
|
||||||
|
endpoints — only the person *list* was redacted. These tests assert that leak is
|
||||||
|
closed while members still see everything.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
LSURNAME = "Authleaksurname"
|
||||||
|
LALIAS = "Authleakalias"
|
||||||
|
LYEAR = "2003"
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup(client):
|
||||||
|
owner = auth(await register(client, "anm-owner@ex.com"))
|
||||||
|
tid = (
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/trees", json={"name": "Pub", "visibility": "public"}, headers=owner
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
old = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons",
|
||||||
|
json={"given": "Olde", "surname": "Gone", "is_living": False},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
young = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons",
|
||||||
|
json={"given": "Youngauth", "surname": LSURNAME, "is_living": True},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
for pid, year in ((old, "1855"), (young, LYEAR)):
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/events",
|
||||||
|
json={"event_type": "birth", "person_id": pid, "date_value": year},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons/{young}/names",
|
||||||
|
json={"name_type": "alias", "given": LALIAS},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
om = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/media",
|
||||||
|
files={"file": ("o.txt", b"old-photo", "text/plain")},
|
||||||
|
data={"person_id": old},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
ym = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/media",
|
||||||
|
files={"file": ("y.txt", b"young-photo", "text/plain")},
|
||||||
|
data={"person_id": young},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
return owner, tid, old, young, om, ym
|
||||||
|
|
||||||
|
|
||||||
|
async def test_authed_nonmember_does_not_see_living_pii(client):
|
||||||
|
owner, tid, old, young, om, ym = await _setup(client)
|
||||||
|
stranger = auth(await register(client, "anm-stranger@ex.com"))
|
||||||
|
|
||||||
|
# Living person's events dropped; deceased kept.
|
||||||
|
events = (await client.get(f"/api/v1/trees/{tid}/events", headers=stranger)).json()
|
||||||
|
assert any(e["person_id"] == old for e in events)
|
||||||
|
assert not any(e["person_id"] == young for e in events)
|
||||||
|
|
||||||
|
# Per-person living: names + events empty.
|
||||||
|
assert (
|
||||||
|
await client.get(f"/api/v1/trees/{tid}/persons/{young}/names", headers=stranger)
|
||||||
|
).json() == []
|
||||||
|
assert (
|
||||||
|
await client.get(f"/api/v1/trees/{tid}/persons/{young}/events", headers=stranger)
|
||||||
|
).json() == []
|
||||||
|
|
||||||
|
# The living surname/alias/birth-year must not appear in any of these.
|
||||||
|
for path in (
|
||||||
|
f"/api/v1/trees/{tid}/events",
|
||||||
|
f"/api/v1/trees/{tid}/relationships",
|
||||||
|
f"/api/v1/trees/{tid}/persons/{young}/names",
|
||||||
|
f"/api/v1/trees/{tid}/media",
|
||||||
|
):
|
||||||
|
body = (await client.get(path, headers=stranger)).text
|
||||||
|
assert LSURNAME not in body, path
|
||||||
|
assert LALIAS not in body, path
|
||||||
|
assert LYEAR not in body, path
|
||||||
|
|
||||||
|
# Media: living person's media hidden from the list and undownloadable;
|
||||||
|
# deceased person's media is fine.
|
||||||
|
media_ids = {m["id"] for m in (await client.get(f"/api/v1/trees/{tid}/media", headers=stranger)).json()}
|
||||||
|
assert om in media_ids
|
||||||
|
assert ym not in media_ids
|
||||||
|
assert (
|
||||||
|
await client.get(f"/api/v1/trees/{tid}/media/{ym}/content", headers=stranger)
|
||||||
|
).status_code == 404
|
||||||
|
assert (
|
||||||
|
await client.get(f"/api/v1/trees/{tid}/media/{om}/content", headers=stranger)
|
||||||
|
).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_member_still_sees_everything(client):
|
||||||
|
owner, tid, old, young, om, ym = await _setup(client)
|
||||||
|
|
||||||
|
events = (await client.get(f"/api/v1/trees/{tid}/events", headers=owner)).json()
|
||||||
|
assert any(e["person_id"] == young for e in events)
|
||||||
|
assert (
|
||||||
|
await client.get(f"/api/v1/trees/{tid}/persons/{young}/names", headers=owner)
|
||||||
|
).json() != []
|
||||||
|
member_media = {m["id"] for m in (await client.get(f"/api/v1/trees/{tid}/media", headers=owner)).json()}
|
||||||
|
assert ym in member_media
|
||||||
|
assert (
|
||||||
|
await client.get(f"/api/v1/trees/{tid}/media/{ym}/content", headers=owner)
|
||||||
|
).status_code == 200
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""Tree cleanup: preview/apply for deceased-by-year, gender-from-source, names."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def _tree(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, surname=None):
|
||||||
|
return (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons", json={"given": given, "surname": surname}, headers=h
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _birth(client, h, tid, pid, year):
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/events",
|
||||||
|
json={"event_type": "birth", "person_id": pid, "date_value": str(year)},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_deceased_preview_and_apply(client):
|
||||||
|
h, tid = await _tree(client, "cl-dec@example.com")
|
||||||
|
old = await _person(client, h, tid, "Josias", "Moody")
|
||||||
|
young = await _person(client, h, tid, "Kid", "Moody")
|
||||||
|
await _birth(client, h, tid, old, 1900)
|
||||||
|
await _birth(client, h, tid, young, 1990)
|
||||||
|
|
||||||
|
prev = (
|
||||||
|
await client.get(f"/api/v1/trees/{tid}/cleanup/deceased?born_on_or_before=1930", headers=h)
|
||||||
|
).json()
|
||||||
|
assert [r["person_id"] for r in prev] == [old]
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/cleanup/deceased", json={"person_ids": [old]}, headers=h
|
||||||
|
)
|
||||||
|
assert r.status_code == 200 and r.json()["updated"] == 1
|
||||||
|
assert (
|
||||||
|
await client.get(f"/api/v1/trees/{tid}/persons/{old}", headers=h)
|
||||||
|
).json()["is_living"] is False
|
||||||
|
# Re-preview no longer lists the now-deceased person.
|
||||||
|
prev2 = (
|
||||||
|
await client.get(f"/api/v1/trees/{tid}/cleanup/deceased?born_on_or_before=1930", headers=h)
|
||||||
|
).json()
|
||||||
|
assert old not in [r["person_id"] for r in prev2]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_gender_from_spouse_preview_and_apply(client):
|
||||||
|
h, tid = await _tree(client, "cl-spouse@example.com")
|
||||||
|
husband = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons",
|
||||||
|
json={"given": "Otto", "surname": "Frey", "gender": "male"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
wife = await _person(client, h, tid, "Bea", "Frey") # no sex
|
||||||
|
loner = await _person(client, h, tid, "Nyx", "Alone") # no sex, no partner
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/relationships",
|
||||||
|
json={"type": "partnership", "person_from_id": husband, "person_to_id": wife},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
|
||||||
|
prev = (await client.get(f"/api/v1/trees/{tid}/cleanup/gender/from-spouse", headers=h)).json()
|
||||||
|
by = {r["person_id"]: r["proposed_gender"] for r in prev}
|
||||||
|
assert by.get(wife) == "female" # opposite of the confirmed-male husband
|
||||||
|
assert loner not in by # no known-sex partner → not proposed
|
||||||
|
assert husband not in by # already has a sex
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/cleanup/gender",
|
||||||
|
json={"updates": [{"person_id": wife, "gender": "female"}]},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200 and r.json()["updated"] == 1
|
||||||
|
assert (
|
||||||
|
await client.get(f"/api/v1/trees/{tid}/persons/{wife}", headers=h)
|
||||||
|
).json()["gender"] == "female"
|
||||||
|
|
||||||
|
# Once set, the wife is no longer proposed.
|
||||||
|
prev2 = (await client.get(f"/api/v1/trees/{tid}/cleanup/gender/from-spouse", headers=h)).json()
|
||||||
|
assert wife not in [r["person_id"] for r in prev2]
|
||||||
|
|
||||||
|
|
||||||
|
GED = b"""0 HEAD
|
||||||
|
0 @I1@ INDI
|
||||||
|
1 NAME Josias /Moody/
|
||||||
|
1 SEX M
|
||||||
|
0 @I2@ INDI
|
||||||
|
1 NAME Flora /Paul/
|
||||||
|
1 SEX F
|
||||||
|
0 TRLR
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def test_gender_from_source(client):
|
||||||
|
h, tid = await _tree(client, "cl-gen@example.com")
|
||||||
|
await _person(client, h, tid, "Josias", "Moody")
|
||||||
|
await _person(client, h, tid, "Flora", "Paul")
|
||||||
|
await _person(client, h, tid, "Nobody", "Else") # not in source
|
||||||
|
|
||||||
|
prev = await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/cleanup/gender/preview",
|
||||||
|
files={"file": ("src.ged", GED, "text/plain")},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
props = prev.json()
|
||||||
|
by_name = {p["name"]: p["proposed_gender"] for p in props}
|
||||||
|
assert by_name == {"Josias Moody": "male", "Flora Paul": "female"}
|
||||||
|
|
||||||
|
updates = [{"person_id": p["person_id"], "gender": p["proposed_gender"]} for p in props]
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/cleanup/gender", json={"updates": updates}, headers=h
|
||||||
|
)
|
||||||
|
assert r.status_code == 200 and r.json()["updated"] == 2
|
||||||
|
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
|
||||||
|
genders = {p["primary_name"]: p["gender"] for p in people}
|
||||||
|
assert genders["Josias Moody"] == "male" and genders["Flora Paul"] == "female"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_guess_gender_from_first_name(client):
|
||||||
|
h, tid = await _tree(client, "cl-guess@example.com")
|
||||||
|
await _person(client, h, tid, "William", "Paul") # male
|
||||||
|
await _person(client, h, tid, "Flora", "Reier") # female
|
||||||
|
await _person(client, h, tid, "Marion", "Doe") # ambiguous -> skipped
|
||||||
|
# Already-gendered person is left alone even if guessable.
|
||||||
|
gendered = await _person(client, h, tid, "James", "Known")
|
||||||
|
await client.patch(
|
||||||
|
f"/api/v1/trees/{tid}/persons/{gendered}", json={"gender": "male"}, headers=h
|
||||||
|
)
|
||||||
|
|
||||||
|
prev = (await client.get(f"/api/v1/trees/{tid}/cleanup/gender/guess", headers=h)).json()
|
||||||
|
by = {p["name"]: p["proposed_gender"] for p in prev}
|
||||||
|
assert by == {"William Paul": "male", "Flora Reier": "female"}
|
||||||
|
|
||||||
|
updates = [{"person_id": p["person_id"], "gender": p["proposed_gender"]} for p in prev]
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/cleanup/gender", json={"updates": updates}, headers=h
|
||||||
|
)
|
||||||
|
assert r.status_code == 200 and r.json()["updated"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_name_issues_preview_and_fix(client):
|
||||||
|
h, tid = await _tree(client, "cl-name@example.com")
|
||||||
|
# surname got a date; real surname landed in the given name.
|
||||||
|
bad = await _person(client, h, tid, "Henry Paul", "1859")
|
||||||
|
await _person(client, h, tid, "Normal", "Person") # should not be flagged
|
||||||
|
|
||||||
|
issues = (await client.get(f"/api/v1/trees/{tid}/cleanup/names", headers=h)).json()
|
||||||
|
assert len(issues) == 1 and issues[0]["issue"] == "date_in_surname"
|
||||||
|
name_id = issues[0]["name_id"]
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/cleanup/names",
|
||||||
|
json={"edits": [{"name_id": name_id, "given": "Henry", "surname": "Paul"}]},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200 and r.json()["updated"] == 1
|
||||||
|
person = (await client.get(f"/api/v1/trees/{tid}/persons/{bad}", headers=h)).json()
|
||||||
|
assert person["primary_name"] == "Henry Paul"
|
||||||
@@ -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,161 @@ async def test_gedcom_export_and_reimport(client):
|
|||||||
)
|
)
|
||||||
assert resp.json()["counts"]["persons"] == 3
|
assert resp.json()["counts"]["persons"] == 3
|
||||||
assert resp.json()["counts"]["relationships"] == 3
|
assert resp.json()["counts"]["relationships"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_gedcom_export_preserves_citations(client):
|
||||||
|
h, tid = await _tree(client, "ged-cite@example.com")
|
||||||
|
pid = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons", json={"given": "Ada", "surname": "Vance"}, headers=h
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
eid = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/events",
|
||||||
|
json={"event_type": "birth", "person_id": pid, "date_value": "1898"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
sid = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/sources", json={"title": "1900 Census"}, headers=h
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
# A person-level and an event-level citation on the same source.
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/citations",
|
||||||
|
json={"source_id": sid, "person_id": pid, "page": "p.12"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/citations",
|
||||||
|
json={"source_id": sid, "event_id": eid, "page": "line 5"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = (await client.get(f"/api/v1/trees/{tid}/gedcom/export", headers=h)).text
|
||||||
|
# Citation links + pages are emitted (previously dropped).
|
||||||
|
assert "1 SOUR @S1@" in text # person-level
|
||||||
|
assert "2 PAGE p.12" in text
|
||||||
|
assert "2 SOUR @S1@" in text # event-level (under 1 BIRT)
|
||||||
|
assert "3 PAGE line 5" in text
|
||||||
|
|
||||||
|
# Round-trip into a fresh tree: the citations survive.
|
||||||
|
tid2 = (await client.post("/api/v1/trees", json={"name": "RT"}, headers=h)).json()["id"]
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid2}/gedcom/import",
|
||||||
|
files={"file": ("rt.ged", text.encode(), "text/plain")},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
cites = (await client.get(f"/api/v1/trees/{tid2}/citations", headers=h)).json()
|
||||||
|
assert len(cites) >= 2
|
||||||
|
assert any(c["person_id"] for c in cites)
|
||||||
|
assert any(c["event_id"] for c in cites)
|
||||||
|
assert {"p.12", "line 5"} <= {c.get("page") for c in cites}
|
||||||
|
|
||||||
|
|
||||||
|
# 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,74 @@
|
|||||||
|
"""Tree membership management: list, add-by-email, role change, remove, guards."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def test_membership_management(client):
|
||||||
|
owner = auth(await register(client, "mm-owner@ex.com"))
|
||||||
|
ed = auth(await register(client, "mm-editor@ex.com"))
|
||||||
|
tid = (await client.post("/api/v1/trees", json={"name": "Fam"}, headers=owner)).json()["id"]
|
||||||
|
|
||||||
|
# A non-member can't even see the member list of a private tree.
|
||||||
|
assert (await client.get(f"/api/v1/trees/{tid}/members", headers=ed)).status_code == 403
|
||||||
|
|
||||||
|
# Add a non-existent user → 404.
|
||||||
|
assert (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/members",
|
||||||
|
json={"email": "ghost@ex.com", "role": "editor"},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
).status_code == 404
|
||||||
|
|
||||||
|
# Add the editor by email.
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/members",
|
||||||
|
json={"email": "mm-editor@ex.com", "role": "editor"},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
mid = r.json()["id"]
|
||||||
|
assert r.json()["email"] == "mm-editor@ex.com" and r.json()["role"] == "editor"
|
||||||
|
|
||||||
|
# Adding the same user again → 409.
|
||||||
|
assert (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/members",
|
||||||
|
json={"email": "mm-editor@ex.com", "role": "viewer"},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
).status_code == 409
|
||||||
|
|
||||||
|
# The editor can now see the tree's member list (2 members)...
|
||||||
|
ml = (await client.get(f"/api/v1/trees/{tid}/members", headers=ed)).json()
|
||||||
|
assert len(ml) == 2
|
||||||
|
owner_mid = next(m["id"] for m in ml if m["role"] == "owner")
|
||||||
|
# ...but a non-owner can't manage members.
|
||||||
|
assert (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/members",
|
||||||
|
json={"email": "mm-owner@ex.com", "role": "viewer"},
|
||||||
|
headers=ed,
|
||||||
|
)
|
||||||
|
).status_code == 403
|
||||||
|
|
||||||
|
# Owner changes the editor's role.
|
||||||
|
pr = await client.patch(
|
||||||
|
f"/api/v1/trees/{tid}/members/{mid}", json={"role": "viewer"}, headers=owner
|
||||||
|
)
|
||||||
|
assert pr.status_code == 200 and pr.json()["role"] == "viewer"
|
||||||
|
|
||||||
|
# The sole owner can't be demoted or removed.
|
||||||
|
assert (
|
||||||
|
await client.patch(
|
||||||
|
f"/api/v1/trees/{tid}/members/{owner_mid}", json={"role": "editor"}, headers=owner
|
||||||
|
)
|
||||||
|
).status_code == 409
|
||||||
|
assert (
|
||||||
|
await client.delete(f"/api/v1/trees/{tid}/members/{owner_mid}", headers=owner)
|
||||||
|
).status_code == 409
|
||||||
|
|
||||||
|
# Owner removes the editor; the list shrinks and the editor loses access.
|
||||||
|
assert (await client.delete(f"/api/v1/trees/{tid}/members/{mid}", headers=owner)).status_code == 204
|
||||||
|
assert len((await client.get(f"/api/v1/trees/{tid}/members", headers=owner)).json()) == 1
|
||||||
|
assert (await client.get(f"/api/v1/trees/{tid}/members", headers=ed)).status_code == 403
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Tree-visibility access matrix for the privacy engine.
|
||||||
|
|
||||||
|
`can_view_tree` is the gate every read path consults. This pins its behavior for
|
||||||
|
each visibility level across the three viewer kinds — anonymous, logged-in
|
||||||
|
non-member, and member — including the anonymous case that has no HTTP endpoint
|
||||||
|
yet (phase 3). See docs/design/tree-visibility.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import privacy
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def _user_id(db_session, email: str) -> uuid.UUID:
|
||||||
|
return (await db_session.execute(select(User).where(User.email == email))).scalar_one().id
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_tree(client, owner_token: str, visibility: str) -> uuid.UUID:
|
||||||
|
r = await client.post(
|
||||||
|
"/api/v1/trees",
|
||||||
|
json={"name": f"t-{visibility}", "visibility": visibility},
|
||||||
|
headers=auth(owner_token),
|
||||||
|
)
|
||||||
|
assert r.status_code in (200, 201), r.text
|
||||||
|
assert r.json()["visibility"] == visibility
|
||||||
|
return uuid.UUID(r.json()["id"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_tree(db_session, tid: uuid.UUID) -> Tree:
|
||||||
|
return (await db_session.execute(select(Tree).where(Tree.id == tid))).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"visibility,anon,nonmember,member",
|
||||||
|
[
|
||||||
|
("public", True, True, True),
|
||||||
|
("unlisted", True, True, True),
|
||||||
|
("site_members", False, True, True),
|
||||||
|
("private", False, False, True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_can_view_tree_matrix(client, db_session, visibility, anon, nonmember, member):
|
||||||
|
owner_email = f"owner-{visibility}@ex.com"
|
||||||
|
other_email = f"other-{visibility}@ex.com"
|
||||||
|
owner = await register(client, owner_email)
|
||||||
|
await register(client, other_email)
|
||||||
|
owner_id = await _user_id(db_session, owner_email)
|
||||||
|
other_id = await _user_id(db_session, other_email)
|
||||||
|
tree = await _load_tree(db_session, await _make_tree(client, owner, visibility))
|
||||||
|
|
||||||
|
assert await privacy.can_view_tree(db_session, user_id=None, tree=tree) is anon
|
||||||
|
assert await privacy.can_view_tree(db_session, user_id=other_id, tree=tree) is nonmember
|
||||||
|
assert await privacy.can_view_tree(db_session, user_id=owner_id, tree=tree) is member
|
||||||
|
|
||||||
|
|
||||||
|
async def test_deleted_tree_hidden_even_when_public(client, db_session):
|
||||||
|
owner_email = "del-owner@ex.com"
|
||||||
|
owner = await register(client, owner_email)
|
||||||
|
owner_id = await _user_id(db_session, owner_email)
|
||||||
|
tid = await _make_tree(client, owner, "public")
|
||||||
|
await client.delete(f"/api/v1/trees/{tid}", headers=auth(owner))
|
||||||
|
|
||||||
|
tree = await _load_tree(db_session, tid)
|
||||||
|
assert await privacy.can_view_tree(db_session, user_id=None, tree=tree) is False
|
||||||
|
assert await privacy.can_view_tree(db_session, user_id=owner_id, tree=tree) is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_site_members_denies_anonymous_but_allows_any_logged_in(client, db_session):
|
||||||
|
"""The new level: a logged-in non-member sees it; an anonymous viewer does not."""
|
||||||
|
owner_email = "sm-owner@ex.com"
|
||||||
|
stranger_email = "sm-stranger@ex.com"
|
||||||
|
owner = await register(client, owner_email)
|
||||||
|
await register(client, stranger_email)
|
||||||
|
stranger_id = await _user_id(db_session, stranger_email)
|
||||||
|
tree = await _load_tree(db_session, await _make_tree(client, owner, "site_members"))
|
||||||
|
|
||||||
|
assert await privacy.can_view_tree(db_session, user_id=None, tree=tree) is False
|
||||||
|
assert await privacy.can_view_tree(db_session, user_id=stranger_id, tree=tree) is True
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""The public viewing surface (/api/v1/public).
|
||||||
|
|
||||||
|
The central guarantee: an ANONYMOUS viewer of a public tree never receives a
|
||||||
|
possibly-living person's real name, dates, or alternate names — while deceased
|
||||||
|
people are shown in full. Plus the access matrix for each visibility level.
|
||||||
|
See docs/design/tree-visibility.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
# Distinctive strings so we can assert they never leak anywhere anonymously.
|
||||||
|
LIVING_GIVEN = "Younglivingsecret"
|
||||||
|
LIVING_SURNAME = "Hiddensurname"
|
||||||
|
LIVING_ALIAS = "Secretmaidenalias"
|
||||||
|
LIVING_BIRTH_YEAR = "2002"
|
||||||
|
|
||||||
|
|
||||||
|
async def _person(client, tid, headers, given, surname, is_living):
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons",
|
||||||
|
json={"given": given, "surname": surname, "is_living": is_living},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_public_tree(client):
|
||||||
|
owner = auth(await register(client, "pv-owner@ex.com"))
|
||||||
|
tid = (
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/trees", json={"name": "Heritage", "visibility": "public"}, headers=owner
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
|
||||||
|
old = await _person(client, tid, owner, "Olda", "Ancestor", False)
|
||||||
|
young = await _person(client, tid, owner, LIVING_GIVEN, LIVING_SURNAME, True)
|
||||||
|
|
||||||
|
# Birth events for each.
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/events",
|
||||||
|
json={"event_type": "birth", "person_id": old, "date_value": "1850"},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/events",
|
||||||
|
json={"event_type": "birth", "person_id": young, "date_value": LIVING_BIRTH_YEAR},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
# Alternate names for each.
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons/{old}/names",
|
||||||
|
json={"name_type": "alias", "given": "Oldnickname"},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons/{young}/names",
|
||||||
|
json={"name_type": "alias", "given": LIVING_ALIAS},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
# old --parent--> young
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/relationships",
|
||||||
|
json={
|
||||||
|
"type": "parent_child",
|
||||||
|
"person_from_id": old,
|
||||||
|
"person_to_id": young,
|
||||||
|
"qualifier": "biological",
|
||||||
|
},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
return tid, old, young
|
||||||
|
|
||||||
|
|
||||||
|
async def test_anonymous_public_view_never_leaks_living_pii(client):
|
||||||
|
tid, old, young = await _build_public_tree(client)
|
||||||
|
|
||||||
|
# --- persons: deceased full, living redacted ---
|
||||||
|
persons = (await client.get(f"/api/v1/public/trees/{tid}/persons")).json()
|
||||||
|
by_id = {p["id"]: p for p in persons}
|
||||||
|
assert by_id[old]["primary_name"] == "Olda Ancestor"
|
||||||
|
assert by_id[young]["primary_name"] == "Living person"
|
||||||
|
assert by_id[young]["gender"] is None
|
||||||
|
|
||||||
|
# --- the living person's real name/alias/birth year must appear NOWHERE ---
|
||||||
|
for path in (
|
||||||
|
f"/api/v1/public/trees/{tid}/persons",
|
||||||
|
f"/api/v1/public/trees/{tid}/events",
|
||||||
|
f"/api/v1/public/trees/{tid}/relationships",
|
||||||
|
f"/api/v1/public/trees/{tid}/persons/{young}",
|
||||||
|
f"/api/v1/public/trees/{tid}/persons/{young}/names",
|
||||||
|
f"/api/v1/public/trees/{tid}/persons/{young}/events",
|
||||||
|
):
|
||||||
|
body = (await client.get(path)).text
|
||||||
|
assert LIVING_GIVEN not in body, path
|
||||||
|
assert LIVING_SURNAME not in body, path
|
||||||
|
assert LIVING_ALIAS not in body, path
|
||||||
|
assert LIVING_BIRTH_YEAR not in body, path
|
||||||
|
|
||||||
|
# --- events: deceased's date present, living's dropped entirely ---
|
||||||
|
events = (await client.get(f"/api/v1/public/trees/{tid}/events")).json()
|
||||||
|
assert any(e["person_id"] == old for e in events)
|
||||||
|
assert not any(e["person_id"] == young for e in events)
|
||||||
|
|
||||||
|
# --- per-person endpoints for the living person are emptied/redacted ---
|
||||||
|
assert (await client.get(f"/api/v1/public/trees/{tid}/persons/{young}/names")).json() == []
|
||||||
|
assert (await client.get(f"/api/v1/public/trees/{tid}/persons/{young}/events")).json() == []
|
||||||
|
assert (
|
||||||
|
await client.get(f"/api/v1/public/trees/{tid}/persons/{young}")
|
||||||
|
).json()["primary_name"] == "Living person"
|
||||||
|
|
||||||
|
# --- deceased person's names/events ARE exposed ---
|
||||||
|
old_names = (await client.get(f"/api/v1/public/trees/{tid}/persons/{old}/names")).json()
|
||||||
|
assert any(n.get("given") == "Oldnickname" for n in old_names)
|
||||||
|
old_events = (await client.get(f"/api/v1/public/trees/{tid}/persons/{old}/events")).json()
|
||||||
|
assert any(e["date_value"] == "1850" for e in old_events)
|
||||||
|
|
||||||
|
# --- relationship kept (links to the redacted person by id, no PII) ---
|
||||||
|
rels = (await client.get(f"/api/v1/public/trees/{tid}/relationships")).json()
|
||||||
|
assert any(r2["person_from_id"] == old and r2["person_to_id"] == young for r2 in rels)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_private_tree_is_404_anonymously(client):
|
||||||
|
owner = auth(await register(client, "priv-owner@ex.com"))
|
||||||
|
tid = (
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/trees", json={"name": "Secret", "visibility": "private"}, headers=owner
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
assert (await client.get(f"/api/v1/public/trees/{tid}")).status_code == 404
|
||||||
|
assert (await client.get(f"/api/v1/public/trees/{tid}/persons")).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unlisted_viewable_by_link_but_not_in_directory(client):
|
||||||
|
owner = auth(await register(client, "unl-owner@ex.com"))
|
||||||
|
tid = (
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/trees", json={"name": "ByLinkOnly", "visibility": "unlisted"}, headers=owner
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
# Direct link works anonymously...
|
||||||
|
assert (await client.get(f"/api/v1/public/trees/{tid}")).status_code == 200
|
||||||
|
# ...but it is never listed in the directory.
|
||||||
|
directory = (await client.get("/api/v1/public/trees")).json()
|
||||||
|
assert all(t["id"] != tid for t in directory)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_site_members_requires_login(client):
|
||||||
|
owner = auth(await register(client, "sm2-owner@ex.com"))
|
||||||
|
stranger = auth(await register(client, "sm2-stranger@ex.com"))
|
||||||
|
tid = (
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/trees", json={"name": "MembersOnly", "visibility": "site_members"}, headers=owner
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
assert (await client.get(f"/api/v1/public/trees/{tid}")).status_code == 404 # anonymous
|
||||||
|
assert (await client.get(f"/api/v1/public/trees/{tid}", headers=stranger)).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_directory_visibility(client):
|
||||||
|
owner = auth(await register(client, "dir-owner@ex.com"))
|
||||||
|
stranger = auth(await register(client, "dir-stranger@ex.com"))
|
||||||
|
ids = {}
|
||||||
|
for vis in ("public", "site_members", "unlisted", "private"):
|
||||||
|
ids[vis] = (
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/trees", json={"name": f"dir-{vis}", "visibility": vis}, headers=owner
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
|
||||||
|
anon = {t["id"] for t in (await client.get("/api/v1/public/trees")).json()}
|
||||||
|
assert ids["public"] in anon
|
||||||
|
for vis in ("site_members", "unlisted", "private"):
|
||||||
|
assert ids[vis] not in anon
|
||||||
|
|
||||||
|
logged_in = {t["id"] for t in (await client.get("/api/v1/public/trees", headers=stranger)).json()}
|
||||||
|
assert ids["public"] in logged_in
|
||||||
|
assert ids["site_members"] in logged_in
|
||||||
|
assert ids["unlisted"] not in logged_in
|
||||||
|
assert ids["private"] not in logged_in
|
||||||
@@ -41,7 +41,7 @@ async def test_person_delete_and_restore(client):
|
|||||||
|
|
||||||
assert (
|
assert (
|
||||||
await client.delete(f"/api/v1/trees/{tree_id}/persons/{person_id}", headers=h)
|
await client.delete(f"/api/v1/trees/{tree_id}/persons/{person_id}", headers=h)
|
||||||
).status_code == 204
|
).status_code == 200
|
||||||
assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 0
|
assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 0
|
||||||
deleted = (
|
deleted = (
|
||||||
await client.get(f"/api/v1/trees/{tree_id}/persons?deleted=true", headers=h)
|
await client.get(f"/api/v1/trees/{tree_id}/persons?deleted=true", headers=h)
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Duplicate relationships are rejected (no double-linking)."""
|
||||||
|
|
||||||
|
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"]
|
||||||
|
|
||||||
|
async def person(given):
|
||||||
|
return (
|
||||||
|
await client.post(f"/api/v1/trees/{tid}/persons", json={"given": given}, headers=h)
|
||||||
|
).json()["id"]
|
||||||
|
|
||||||
|
return h, tid, person
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate_parent_child_rejected(client):
|
||||||
|
h, tid, person = await _setup(client, "dup-pc@example.com")
|
||||||
|
karl = await person("Karl")
|
||||||
|
kid = await person("Kid")
|
||||||
|
body = {"type": "parent_child", "person_from_id": karl, "person_to_id": kid}
|
||||||
|
|
||||||
|
first = await client.post(f"/api/v1/trees/{tid}/relationships", json=body, headers=h)
|
||||||
|
assert first.status_code == 201
|
||||||
|
dup = await client.post(f"/api/v1/trees/{tid}/relationships", json=body, headers=h)
|
||||||
|
assert dup.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate_partnership_either_direction_rejected(client):
|
||||||
|
h, tid, person = await _setup(client, "dup-sp@example.com")
|
||||||
|
a = await person("A")
|
||||||
|
b = await person("B")
|
||||||
|
|
||||||
|
first = await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/relationships",
|
||||||
|
json={"type": "partnership", "person_from_id": a, "person_to_id": b},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert first.status_code == 201
|
||||||
|
# Same couple, reversed order — still a duplicate.
|
||||||
|
dup = await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/relationships",
|
||||||
|
json={"type": "partnership", "person_from_id": b, "person_to_id": a},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert dup.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reverse_parent_child_is_allowed(client):
|
||||||
|
"""A->B as parent_child shouldn't block B->A (different meaning)."""
|
||||||
|
h, tid, person = await _setup(client, "dup-rev@example.com")
|
||||||
|
a = await person("A")
|
||||||
|
b = await person("B")
|
||||||
|
r1 = await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/relationships",
|
||||||
|
json={"type": "parent_child", "person_from_id": a, "person_to_id": b},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
r2 = await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/relationships",
|
||||||
|
json={"type": "parent_child", "person_from_id": b, "person_to_id": a},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert r1.status_code == 201 and r2.status_code == 201
|
||||||
@@ -46,6 +46,9 @@ COOKIE_SECURE=false
|
|||||||
APP_BASE_URL=http://localhost
|
APP_BASE_URL=http://localhost
|
||||||
# Mailer: 'console' logs links to stdout (dev); 'smtp' uses the SMTP settings below.
|
# Mailer: 'console' logs links to stdout (dev); 'smtp' uses the SMTP settings below.
|
||||||
MAILER=console
|
MAILER=console
|
||||||
|
# Require a verified email before an account has an active session. Leave false
|
||||||
|
# until SMTP works and existing accounts are verified, or you will lock users out.
|
||||||
|
REQUIRE_EMAIL_VERIFICATION=false
|
||||||
|
|
||||||
# --- Email (SMTP) — wired in a later phase ---
|
# --- Email (SMTP) — wired in a later phase ---
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Backup & restore
|
||||||
|
|
||||||
|
`backup.sh` produces a single bundle containing the Postgres database and the
|
||||||
|
MinIO object store. Run it from this `deploy/` directory on the host that runs
|
||||||
|
the stack.
|
||||||
|
|
||||||
|
## Back up
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./backup.sh
|
||||||
|
# → backups/provenance-backup-20260609T140000Z.tar
|
||||||
|
```
|
||||||
|
|
||||||
|
The bundle contains:
|
||||||
|
|
||||||
|
- `db.sql.gz` — `pg_dump --clean --if-exists` of the database, gzipped.
|
||||||
|
- `minio-data.tar.gz` — the MinIO `/data` directory (objects + bucket metadata).
|
||||||
|
- `MANIFEST.txt` — what's inside and when it was made.
|
||||||
|
|
||||||
|
Optional retention: `BACKUP_RETAIN_DAYS=30 ./backup.sh` also deletes bundles
|
||||||
|
older than 30 days. Schedule it from cron for off-box copies, e.g.:
|
||||||
|
|
||||||
|
```cron
|
||||||
|
15 3 * * * cd /path/to/provenance/deploy && BACKUP_RETAIN_DAYS=30 ./backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
(Copy the resulting bundle off the host — a backup on the same disk isn't one.)
|
||||||
|
|
||||||
|
## Restore
|
||||||
|
|
||||||
|
Restoring overwrites live data — stop the app first.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ts=20260609T140000Z # the bundle you're restoring
|
||||||
|
mkdir -p /tmp/restore && tar xf backups/provenance-backup-$ts.tar -C /tmp/restore
|
||||||
|
|
||||||
|
# 1. Database — the dump is --clean, so it drops & recreates objects.
|
||||||
|
docker compose stop backend worker
|
||||||
|
gunzip -c /tmp/restore/db.sql.gz \
|
||||||
|
| docker compose exec -T postgres psql -U "${POSTGRES_USER:-provenance}" -d "${POSTGRES_DB:-provenance}"
|
||||||
|
|
||||||
|
# 2. Objects — replace the MinIO data directory.
|
||||||
|
docker compose stop minio
|
||||||
|
docker compose run --rm --no-deps -T -v provenance_miniodata:/data minio \
|
||||||
|
sh -c 'rm -rf /data/* && tar xzf - -C /data' < /tmp/restore/minio-data.tar.gz
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
rm -rf /tmp/restore
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The MinIO `/data` archive is filesystem-level; restore into the **same** MinIO
|
||||||
|
major version it was taken from.
|
||||||
|
- Verify the volume name (`docker volume ls | grep miniodata`) — compose prefixes
|
||||||
|
it with the project name; adjust the `-v` mount accordingly.
|
||||||
Executable
+57
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# One-command backup of a Provenance deployment: the Postgres database and the
|
||||||
|
# MinIO object store, into a single timestamped bundle under ./backups/.
|
||||||
|
#
|
||||||
|
# ./backup.sh # write backups/provenance-backup-<UTC>.tar
|
||||||
|
# BACKUP_RETAIN_DAYS=30 ./backup.sh # also prune bundles older than 30 days
|
||||||
|
#
|
||||||
|
# Run it from the host where `docker compose` manages the stack (i.e. this
|
||||||
|
# deploy/ directory). Restore steps are in BACKUP.md.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")" # the deploy/ directory (where docker-compose.yml lives)
|
||||||
|
|
||||||
|
# Config comes from the compose .env (same file the stack uses); fall back to
|
||||||
|
# the compose defaults so a vanilla stack still backs up.
|
||||||
|
if [ -f .env ]; then set -a; . ./.env; set +a; fi
|
||||||
|
PGUSER="${POSTGRES_USER:-provenance}"
|
||||||
|
PGDB="${POSTGRES_DB:-provenance}"
|
||||||
|
|
||||||
|
dc() { docker compose "$@"; }
|
||||||
|
|
||||||
|
ts="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
work="backups/.work-$ts"
|
||||||
|
mkdir -p "$work" backups
|
||||||
|
|
||||||
|
cleanup() { rm -rf "$work"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "→ Dumping Postgres database '$PGDB'…"
|
||||||
|
dc exec -T postgres pg_dump -U "$PGUSER" -d "$PGDB" --no-owner --clean --if-exists \
|
||||||
|
| gzip > "$work/db.sql.gz"
|
||||||
|
|
||||||
|
echo "→ Archiving MinIO object store…"
|
||||||
|
# Tar MinIO's data directory straight from the container (objects + bucket
|
||||||
|
# metadata). Restored by extracting back into the miniodata volume.
|
||||||
|
dc exec -T minio tar czf - -C /data . > "$work/minio-data.tar.gz"
|
||||||
|
|
||||||
|
cat > "$work/MANIFEST.txt" <<EOF
|
||||||
|
Provenance backup
|
||||||
|
created: $ts
|
||||||
|
database: $PGDB (pg_dump --clean --if-exists, gzip)
|
||||||
|
objects: MinIO /data (tar.gz)
|
||||||
|
restore: see deploy/BACKUP.md
|
||||||
|
EOF
|
||||||
|
|
||||||
|
bundle="backups/provenance-backup-$ts.tar"
|
||||||
|
# Contents are already gzipped, so the outer archive is a plain tar.
|
||||||
|
tar cf "$bundle" -C "$work" db.sql.gz minio-data.tar.gz MANIFEST.txt
|
||||||
|
|
||||||
|
echo "✓ Backup written: $bundle ($(du -h "$bundle" | cut -f1))"
|
||||||
|
|
||||||
|
if [ -n "${BACKUP_RETAIN_DAYS:-}" ]; then
|
||||||
|
echo "→ Pruning bundles older than ${BACKUP_RETAIN_DAYS} days…"
|
||||||
|
find backups -maxdepth 1 -name 'provenance-backup-*.tar' -type f \
|
||||||
|
-mtime "+${BACKUP_RETAIN_DAYS}" -print -delete
|
||||||
|
fi
|
||||||
@@ -40,12 +40,36 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
restart: unless-stopped
|
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:
|
backend:
|
||||||
image: git.jpaul.io/justin/provenance-backend:${IMAGE_TAG:-test-main}
|
image: git.jpaul.io/justin/provenance-backend:${IMAGE_TAG:-test-main}
|
||||||
labels:
|
labels:
|
||||||
com.centurylinklabs.watchtower.enable: "true"
|
com.centurylinklabs.watchtower.enable: "true"
|
||||||
environment:
|
environment:
|
||||||
APP_ENV: ${APP_ENV:-development}
|
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}
|
DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://provenance:provenance@postgres:5432/provenance}
|
||||||
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://minio:9000}
|
S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://minio:9000}
|
||||||
S3_BUCKET: ${S3_BUCKET:-provenance}
|
S3_BUCKET: ${S3_BUCKET:-provenance}
|
||||||
@@ -57,6 +81,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
- CMD-SHELL
|
- CMD-SHELL
|
||||||
@@ -89,6 +115,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
|||||||
+434
@@ -0,0 +1,434 @@
|
|||||||
|
<!-- Generated by the genealogy-feature-gap-backlog workflow on 2026-06-09. -->
|
||||||
|
<!-- Gap analysis vs commercial (Ancestry/MyHeritage/FamilySearch) and OSS
|
||||||
|
(GRAMPS/Gramps Web/webtrees) genealogy software, verified against the
|
||||||
|
codebase. Statuses reflect the repo at workflow launch (before the
|
||||||
|
tree-visibility phases 1-3 landed; some items are now closed). -->
|
||||||
|
|
||||||
|
# Provenance — Product Backlog
|
||||||
|
|
||||||
|
> Status legend: **Have** (shipped) · **Partial** (substrate exists, surface incomplete) · **Planned** (on roadmap, no code) · **Missing** (no code, off roadmap).
|
||||||
|
> Importance: Critical / High / Medium / Low. Effort: S / M / L / XL.
|
||||||
|
> Phase references map onto the existing 0–9 roadmap. "NN#" = non-negotiable invariant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive summary
|
||||||
|
|
||||||
|
**Where Provenance is strong today.** The foundation is genuinely solid and, in several places, ahead of the OSS field:
|
||||||
|
|
||||||
|
- **Sources-first spine is real.** A reusable `Source` + per-fact `Citation` two-tier model with a `exactly_one_target` CHECK constraint, confidence enum, and full backend CRUD. This is the architectural thing webtrees/Gramps get right and most commercial tools bury. (Caveat: citations are silently dropped on GEDCOM *export* — see below.)
|
||||||
|
- **Privacy architecture is the right shape.** A single `privacy.py` engine, `TenantScoped` mixin on every row, living-person heuristic (`is_possibly_living`, unknown-birth-treated-as-living), and media served **through the backend rather than via raw S3 URLs**. The *shape* is correct; coverage is not yet complete (the media endpoint and several child resources don't yet apply `person_visibility` — see §2.4, §2.10).
|
||||||
|
- **Non-destructive by design.** Soft-delete with timed purge worker, immutable `AuditEntry` (before/after JSONB, `actor_type` ready for the assistant), GEDCOM merge that copies rather than overwrites, full account export/import.
|
||||||
|
- **Modeling maturity.** Typed parent/child qualifiers (biological/adoptive/step/foster/donor/guardian), typed alternate names with one-primary invariant, dual verbatim+normalized dates, duplicate-relationship guards, UUID surrogate keys.
|
||||||
|
- **Standards core.** GEDCOM 5.5.1 import/export is **functional** (with preview/merge-vs-create resolution UI), pg_trgm fuzzy name search, multi-tenant tree hosting with visibility tiers. Round-trip *fidelity* has four tracked gaps (citation links, custom tags, PLAC coords/hierarchy, non-UTF-8 encoding) — see §2.11.
|
||||||
|
|
||||||
|
**Documentation-vs-code gaps to correct now (per "docs travel with code").** Three repo claims are not yet true and should be edited in the same spirit they were written:
|
||||||
|
|
||||||
|
- **ChangeProposal is documented as landed but does not exist.** CLAUDE.md states the core data model (ARCHITECTURE §5) landed / "Phase 0 complete," but `ChangeProposal` — part of §5 and the load-bearing AI invariant — has no model, migration, or schema. Either scope it out of the "landed" claim or build it; don't leave the docs asserting it.
|
||||||
|
- **pgvector is claimed as used; it is not.** Only `pg_trgm` is created. ARCHITECTURE references pgvector for match ranking.
|
||||||
|
- **i18n "from day one" is documented but unmet.** PRD §6 promises externalized strings; every label is a hardcoded literal.
|
||||||
|
|
||||||
|
These three doc edits are themselves trivial quick wins (see §3).
|
||||||
|
|
||||||
|
**The biggest gaps vs commercial (Ancestry / MyHeritage / FamilySearch).** Provenance is not trying to be a record provider, and correctly so — but it is missing several things mainstream users treat as table stakes:
|
||||||
|
|
||||||
|
- **No record hints, no "save to tree," no connector framework.** The entire SourceConnector layer (FamilySearch/Find A Grave/WikiTree) is unbuilt — this gates AI search, hints, and auto-citation.
|
||||||
|
- **No person merge outside GEDCOM import.** Merging duplicate people is fundamental hygiene and is currently impossible in-tree — the single highest-value near-term matching gap.
|
||||||
|
- **No maps at all.** No place autocomplete, no geocoding, no interactive/migration/birthplace maps — a glaring hole for an app whose thesis is *family **and** land*.
|
||||||
|
- **No report/print/PDF output.** Charts render on-screen only; there is no Ahnentafel, family group sheet, narrative report, or any PDF/SVG/HTML export. The whole "Charts, reports & printing" category is on-screen-viewing only.
|
||||||
|
- **DNA absent** (deliberately parked — treat as open question, not a gap).
|
||||||
|
|
||||||
|
**The biggest gaps vs OSS (GRAMPS / Gramps Web / webtrees).** These are where a privacy-first self-host product is expected to compete and currently trails:
|
||||||
|
|
||||||
|
- **Collaboration is plumbed but unreachable.** `TreeMembership` roles are enforced on every read/write, but there is **no API or UI to invite, grant, change, or revoke** a member — the tree is effectively single-user despite multi-user infrastructure. This also breaks the full-CRUD invariant (NN#8) and, because importance and the old Phase-6 schedule disagree, a minimal management slice is pulled forward (§2.9).
|
||||||
|
- **Living-person redaction is non-uniform.** Redaction is applied on person reads but **not** on the event/media/name/relationship/citation/source child-resource endpoints — a real PII leak on public/unlisted trees (NN#3, NN#2).
|
||||||
|
- **`site_members` visibility tier is silently broken** (defined, selectable in UI, never handled in `can_view_tree`).
|
||||||
|
- **No place as a usable first-class entity** (model exists, created by GEDCOM, but no read/edit/delete — a create-only entity, which is a bug per NN#8).
|
||||||
|
- **No research log, to-do/task planner, kinship calculator, data-quality checker, or i18n/string externalization** (the last is a documented day-one commitment that is currently unmet).
|
||||||
|
|
||||||
|
**Security-priority correctness fixes (do these first, regardless of phase).** Three current defects are user-harm or trust issues, not roadmap items:
|
||||||
|
|
||||||
|
1. **Media privacy leak (§2.4)** — `list_media`/`get_media`/`media_content` gate on `can_view_tree` but never `person_visibility`; non-owners can download photos of redacted living people on public/unlisted trees.
|
||||||
|
2. **Child-resource redaction gap (§2.10)** — event/media/name/relationship/citation/source endpoints don't apply living-person redaction.
|
||||||
|
3. **Registration issues a live session before verification (§2.10)** — `register` returns an authenticated session cookie + token (201) and `email_verified_at` is written but never read on any path; there is no env switch to gate self-registration. The *enforcement check* (read-side `email_verified_at`) is small; the approval-mode env switch is the larger piece.
|
||||||
|
|
||||||
|
**Strategic posture.** The differentiators worth pressing — property chain-of-title, the ChangeProposal AI model, the anonymous mutual-consent hint system, and true self-host data ownership — are mostly still ahead on the roadmap. The near-term job is (a) close the **privacy/auth correctness** and **collaboration** gaps that the architecture already implies, (b) ship the **maps + reports + merge** table stakes, and (c) build the **connector/ModelProvider/ChangeProposal** spine that unlocks the entire back half of the roadmap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Backlog by category
|
||||||
|
|
||||||
|
### 2.1 Tree & data model
|
||||||
|
|
||||||
|
Core CRUD, typed relationships, dates, soft-delete, and naming are **have**. Remaining work is about reusable sub-entities, shared/event-centric modeling, and research-grade conveniences.
|
||||||
|
|
||||||
|
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Repository as first-class entity | Promote `Source.repository` string to a reusable `Repository` (name/address/call-numbers) with dedup. | Partial | Med | M | 1–2 | If promoted, full CRUD in API+UI (NN#8) — don't half-build. |
|
||||||
|
| Note as first-class entity (SNOTE) | Promote inline `notes` text fields to reusable shared `Note`/SNOTE records. | Missing | Low | M | 2 | Full CRUD; GEDCOM 7 round-trip parity. |
|
||||||
|
| Shared/event-centric model + witnesses | Remove the `subject_person_xor_relationship` XOR; add participant/role join so one event has many people (FAN/cluster research). | Missing | Med | M | later | Unlocks FAN club + richer sourcing; participants must redact via privacy engine. |
|
||||||
|
| Non-family associations (FAN) | Add associate/neighbor relationship types; best delivered with shared-event participants. | Missing | Low | M | later | — |
|
||||||
|
| Relationship-status enum | Add married/divorced/annulled status on partnership rather than inferring from events. | Partial | High | M | 1–2 | — |
|
||||||
|
| Family/couple unit (GEDCOM FAM) | Persist a true FAM entity (own ID/sources, childless couples) instead of rebuilding on export. | Partial | High | L | 2 | Improves GEDCOM fidelity. |
|
||||||
|
| Kinship / relationship calculator | "How is A related to B" path + cousinship. Graph edges already exist. | Missing | High | M | 1–2 | Self-contained; reads via privacy engine. |
|
||||||
|
| **Read-only audit-log viewer / activity feed** | Surface `AuditEntry` as a per-tree/per-person change feed. Smaller and higher-leverage than value-level undo; partially satisfies NN#8's "read" for AuditEntry and is the substrate for watch/follow + webhooks. | Missing | High | M | 2 | Privacy-filtered projections only — never raw before/after JSON to non-members (NN#2/#3). |
|
||||||
|
| Per-field revision history + restore-prior-value | Value-level history view + undo, built atop the audit feed above. | Partial | High | L | 6 | Audit-log *UI* is the feed item; this is the larger value-level-undo work (NN#8 correction ethos). |
|
||||||
|
| Color-coded tags & custom labels | Tag people for lineages/research-status/grouping. | Missing | Med | M | 2 | Full CRUD; tenant-scoped. |
|
||||||
|
| Person timeline / LifeStory | Sort the merged event list; add place/age enrichment + narrative presentation. | Partial | Med | M | 2 | Sort is trivial (`localeCompare` on `date_start`); narrative is the larger piece. |
|
||||||
|
| Multi-calendar normalization | Store + parse Julian/Hebrew/French Republican (only `calendar` tag stored today, only Gregorian normalized). | Partial | Low | M | 2 | See also Localization §2.17. |
|
||||||
|
| Evidence/persona vs conclusion model | GEDCOM-X persona layer separate from conclusion person. | Missing | Med | XL | later | Large modeling change; strengthens sourcing + hint matching. |
|
||||||
|
| Negative assertions | Boolean "event did not happen" on Event. | Missing | Low | S | 2 | Cheap interop nicety. |
|
||||||
|
| Custom groups / networks | Named manual or rules-based groupings. | Missing | Low | M | later | Lower priority than tags. |
|
||||||
|
| Raw GEDCOM record editor / configurable fact tabs | webtrees-style raw editor + fact-type registry. | Partial | Med | L | later | Open vocabularies give de-facto custom facts today. |
|
||||||
|
| Health/medical, historical-facts index, LDS ordinances | Niche entities. | Missing | Low | M–L | later | LDS BAPL/ENDL/SLGS should map to distinct types if ever pursued; medical is special-category PII. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Sources & citations
|
||||||
|
|
||||||
|
The two-tier model is **have** and production-grade on the backend. The gaps are almost all UI/CRUD-completeness and the connector-dependent "save to tree" flows.
|
||||||
|
|
||||||
|
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Citation confidence selector in UI | Confidence enum is modeled + API-writable but the `citeControl` form never sets it — every UI citation is NULL confidence. | Partial | High | S | 1 | **Quick win.** Full CRUD in UI (NN#8); reinforces evidence-quality thesis. |
|
||||||
|
| Source edit UI + all 8 fields | Source UI is add/list/delete only and create exposes ~3 of 8 fields (no author/source_type/publication_info/quality_note/citation_text). | Partial | High | S | 1 | Update API exists but no edit form — violates NN#8. |
|
||||||
|
| `GET /{tree}/citations/{id}` | Citation API has list but no single-read endpoint. | Partial | Med | S | 1 | API symmetry (NN#8). |
|
||||||
|
| Transcription / abstract / extract fields | Add `transcription_text` + `abstract_text` to Source; don't conflate with `citation_text` (GEDCOM SOUR.TEXT currently dumped into citation_text). | Missing | Med | S | 1–2 | **Quick win.** Central to evidence analysis; full CRUD (NN#8). |
|
||||||
|
| Evidence-Explained guided citation builder | Structured fields → formatted citation (Chicago/MLA/APA) instead of hand-typed `citation_text`. | Missing | High | L | 2 | Signature provenance feature; citation_text should be generated, not typed. |
|
||||||
|
| Citations on OwnershipEvents | Add `ownership_event_id` to Citation + extend CHECK to 5 targets when property lands. | Partial | Critical | S | 3 | **Quick win once Property exists** — single FK + constraint edit (NN#5). |
|
||||||
|
| Record-to-source attachment ("save to tree") | Search a connector record and attach its facts. | Missing | High | XL | 4 | Gated on connector framework; assistant attach must emit ChangeProposal (NN#1); legal sources only (NN#6). |
|
||||||
|
| Source Linker (one record → many persons) | Bulk-attach a record's facts across people. | Missing | Med | L | 4 | Downstream of connectors; reads/writes via service layer. |
|
||||||
|
| Auto-citation on save/match | Generate citation when a hint/record is confirmed. | Missing | Med | L | 4/7 | Blocked on connectors + hints; ChangeProposal if assistant-driven. |
|
||||||
|
| Memories-as-sources (cite a photo directly) | Allow media to be a citation target, not only attachable to a Source. | Partial | Low | M | 2 | Reads stay on privacy-checked media endpoint (NN#2). |
|
||||||
|
| GPS / Proof-Standard reasoning artifact | Container linking sources/citations into a proof narrative reconciling conflicts. | Missing | Med | L | later | Serious-researcher differentiator; full CRUD (NN#8). |
|
||||||
|
| Proprietary record collections | 1921 census, UK sets, etc. | Missing | Low | XL | — | **Out of scope** — conflicts with NN#6 / self-host. Do not pursue. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 Search & matching
|
||||||
|
|
||||||
|
Fuzzy trigram name search is **have**; everything that depends on connectors, embeddings, or multiple populated trees is planned/missing. The standout near-term gap is **in-tree person merge**.
|
||||||
|
|
||||||
|
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Standalone duplicate detection | Lift the GEDCOM `_best_match` logic into a "find duplicates in my tree" scan. | Partial | High | M | 2 | Logic already written; results via privacy engine (NN#2). |
|
||||||
|
| Interactive two-person merge (side-by-side, field-select, undo) | General merge of duplicate persons with citation re-pointing — impossible outside import today. | Partial | High | L | 2 | **Highest-value matching gap.** Preserve + re-point Citations (NN#5); write-once is a bug (NN#8). |
|
||||||
|
| Advanced search (wildcards, boolean, date/place facets, sort) | Search exposes only `?q`. | Partial | High | M | 2 | Keep per-person privacy filter in the search loop (NN#2). |
|
||||||
|
| Phonetic matching (Soundex/Metaphone/DM) | Enable `fuzzystrmatch`; trigram is char-similarity, not phonetic. | Partial | High | M | 2 | Pure utility. |
|
||||||
|
| Semantic / vector search (pgvector) | **Docs claim pgvector is used; it is not** — only pg_trgm extension is created. Add `CREATE EXTENSION vector` + embedding columns (and correct the docs). | Missing | Med | L | 7 | Embedding provider is an open question (PRD §11) — don't pick silently; candidates via privacy engine. |
|
||||||
|
| Tree-to-tree matching (Smart Matches) | Cross-tree candidate generation + ranking. | Planned | High | XL | 7 | Anonymous until mutual consent (NN#4); living-person protection (NN#3). |
|
||||||
|
| Mutual-consent match notification | Anonymous notification, reveal only after both opt in. | Planned | High | L | 7 | **Mandated invariant**, not a toggle (NN#4, NN#3); rides the notification substrate (§2.9). |
|
||||||
|
| Match confirm/reject + "not a match" memory | Persistent rejected-match store (today scoring lives only inside import). | Partial | High | M | 7 | Prevents re-notifying once hints land. |
|
||||||
|
| External search deep-links | Pre-fill FamilySearch/Find A Grave/BLM-GLO search URLs from a person's name/dates/place. | Missing | Med | M | 2–4 | **High value, low risk** before full connectors; legal targets only (NN#6). |
|
||||||
|
| **Automated record hints** | Proactive per-person record suggestions from connectors — a marquee mainstream feature. | Missing | High | XL | 7 | Connector-gated (NN#6); surfaced anonymously where cross-tree (NN#4); attach via ChangeProposal (NN#1). |
|
||||||
|
| Jurisdiction-aware record-search hints | Map place/jurisdiction → relevant collections. Place hierarchy is a ready foundation. | Missing | Med | L | 8 | Suggested collections must be legal (NN#6). |
|
||||||
|
| Cross-language / transliteration matching | Cyrillic/Hebrew/CJK ↔ Latin. | Missing | Med | XL | later | See Localization. |
|
||||||
|
| Record Detective, newspaper matches, collection catalog, GQL query builder, OCR full-text search | Connector/record-layer dependent. | Missing/Planned | Low–Med | L–XL | 4/7/8 | All gated on the connector framework; any query path runs through privacy engine (NN#2). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4 Media & documents
|
||||||
|
|
||||||
|
Universal media attachment is **have**, but with a **confirmed privacy leak** and no asset-processing pipeline.
|
||||||
|
|
||||||
|
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **Media privacy gating on serve paths** | `list_media`/`get_media`/`media_content` gate only on `can_view_tree`, never `person_visibility` — a non-owner can download photos of redacted living people on public/unlisted trees. | Have(leaky) | **Critical** | M | 1 | **Security-priority — fix first. Direct NN#3/NN#2 violation.** Check attached `person_id` visibility and redact/hide. |
|
||||||
|
| EXIF / GPS stripping on upload | Raw bytes stored verbatim; family photos leak GPS/home addresses/timestamps. | Planned | High | M | 1 | **Security-priority**, not cosmetic. Parse EXIF on ingest, strip/quarantine by default, allow override. |
|
||||||
|
| Thumbnail / preview generation | No image pipeline (no Pillow). Async, idempotent worker job. | Planned | High | L | 1 | Derived thumbnail must inherit parent privacy — no bypass path. |
|
||||||
|
| Image reference regions | Mark the rectangle of a census image that supports a Citation. | Missing | Med | M | later | Tenant-scoped, full CRUD; region→Citation preferred over region→Person. |
|
||||||
|
| Photo/face tagging (manual) | Multi-person tagging via single FK today. | Missing | Med | XL(ML)/M(manual) | later | Owner-only, in-deployment; face tags inherit redaction (NN#3); full CRUD. |
|
||||||
|
| Mobile photo scanning + auto-split | Shoebox digitization. | Missing | Med | L | later | Reuse privacy-gated upload + EXIF strip. |
|
||||||
|
| AI photo dating / colorize / restore / animate / narrate | Model-driven media features. | Missing | Low | L–XL | 4+ | Must route through ModelProvider (NN#7), require approval (NN#1), preserve original; animating living faces raises consent issues. |
|
||||||
|
| British Library / paywalled archives, pay-per-view credits | Licensed content + metering. | Missing | Low | XL | — | **Out of scope** — conflicts with NN#6 and the self-host model. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.5 DNA & genetic genealogy
|
||||||
|
|
||||||
|
DNA is an **explicit PRD non-goal / open question** — treat as parked, not a backlog to grind through. Across every DNA row the rule is uniform: **a user uploading their own export is permissible; vendor connectors/scrapers (23andMe / Ancestry / MyHeritage / GEDmatch) are barred (NN#6).** Kits and matches are living-tester PII and route through the privacy engine.
|
||||||
|
|
||||||
|
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| DNA-confirmed relationship flag | Model DNA confirmation as a Source/Citation backing a Relationship (not free text). | Missing | Med | M | parked | Best sources-first fit (NN#5); full CRUD (NN#8). |
|
||||||
|
| Raw DNA upload (own file) | User uploads own export; no vendor scraping. | Missing | Med | L | parked | User's own file is fine; vendor connectors barred (NN#6); special-category PII via privacy engine. |
|
||||||
|
| Kit/Match entities linked to persons | Kit (tester) + Match tied to Person, tenant-scoped/audited. | Missing | Med | M | parked | Kits = living-tester PII (NN#2/#3); full CRUD (NN#8). |
|
||||||
|
| Autosomal match list, segments, chromosome browser, triangulation, ThruLines/AutoTree, ethnicity/admixture, haplogroups, GEDmatch, NPE detection | Full genetic-genealogy suite. | Missing | Low–Med | L–XL | parked | DNA scope is an unresolved open question — **surface the dependency, don't build speculatively.** Own-data only (NN#6); cross-user surfacing obeys NN#4. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.6 Maps, places & gazetteers
|
||||||
|
|
||||||
|
This category is almost entirely **missing** despite being half the product thesis. The Place model has the right bones (parent_id, lat/long, PlaceName with date ranges) but no API/UI and no maps.
|
||||||
|
|
||||||
|
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **Place as usable first-class entity** | Place rows are created by GEDCOM but have **no read/edit/delete** API or UI — a create-only entity. | Partial | High | M | 2–3 | **Violates NN#8** (create-but-not-edit = bug). Make Place citable too (NN#5). |
|
||||||
|
| Place autocomplete + picker in event editor | No `/places` router; the event form has no place input, so users can't attach a place at all. | Missing | High | M | 2 | Table stakes; lookup is low-risk. |
|
||||||
|
| Geocoding (manual coords + forward) | lat/long columns exist; no UI, no geocoder. | Partial | High | M | 3 | Provider via env (NN#7), ToS-compliant (NN#6). |
|
||||||
|
| Pluggable geocoding provider | Nominatim/GeoNames/Bing/Google swappable. | Missing | Med | L | 3 | Provider+keys via env (NN#7); legal providers only (NN#6). |
|
||||||
|
| Bulk/batch geocoding (worker) | Geocode hundreds of GEDCOM-imported places. | Missing | Med | M | 3 | Idempotent, rate-limited worker job; provider via env. |
|
||||||
|
| Place merge/split (dedup) | GEDCOM imports produce near-duplicate place strings. | Missing | High | M | 2–3 | Needs Place update/delete (NN#8); audited merges. |
|
||||||
|
| Place-name cleanup tools | Extend the existing preview→apply cleanup UX to places. | Missing | Med | M | 2 | Preview-first + audited like existing cleanup. |
|
||||||
|
| Standardized-name vs original text | Mirror the verbatim+normalized date pattern for places. | Missing | Med | M | 2–3 | GEDCOM fidelity. |
|
||||||
|
| Alternate/historical place names with date ranges | `PlaceName` model exists with valid_from/to but no CRUD and never populated. | Partial | Med | M | 2–3 | Stored entity with no CRUD surface (NN#8). |
|
||||||
|
| Interactive map of events & places | No map library in frontend. Core to family+land positioning. | Missing | High | L | 3 | Plot via `person_visibility` so non-owners never see living locations (NN#2/#3). |
|
||||||
|
| Migration trail / pedigree-birthplace maps | Per-person life path; ancestor birthplace map. | Missing | Med | L | 3 | Redact living subjects for non-owners (NN#3). |
|
||||||
|
| Bundled world gazetteer | Offline GeoNames-style authority. | Missing | Med | XL | later | GeoNames (CC-BY) verify AGPL-compat; env-configurable. |
|
||||||
|
| Historical boundary overlays, time slider, heatmaps, radius/nearby, tile-provider switch | Advanced geo. | Missing | Low–Med | S–XL | later | PostGIS is an open question (ARCH §14) — **surface dependency**, don't adopt silently; tiles legal (NN#6). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.7 Charts, reports & printing
|
||||||
|
|
||||||
|
On-screen pedigree/descendant/fan/hourglass charts are **have**. The entire **output/print/report** half is missing — this is the linchpin gap of the category.
|
||||||
|
|
||||||
|
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **Multi-format export (PDF / SVG / image / HTML)** | No export/print path, no `@media print`, no `window.print()`. Charts and reports can't leave the screen. | Missing | High | L | 2/6 | **Linchpin.** Generate from privacy-filtered data so living people redacted in shared output (NN#3). |
|
||||||
|
| Ahnentafel report | Numbered-ancestor report; all data exists. | Missing | High | M | 6 | — |
|
||||||
|
| Family group sheet / individual summary | Printable summary; data available, needs print layout. | Missing | High | M | 6 | — |
|
||||||
|
| Narrative descendant/ancestor reports | Multi-standard prose with inline sources. | Missing | High | L | 6 | Cite Sources inline (NN#5); redact living (NN#3). |
|
||||||
|
| Sentence-template narrative engine | Deterministic fact→prose underpinning reports. | Missing | Med | L | 6 | Keep template-based; report text never mutates tree (NN#1). |
|
||||||
|
| Photo boxes in charts | Pass privacy-checked media URLs to `setCardDisplay`; CSS already present. | Missing | High | M | 2 | Stream via privacy-checked /media (NN#2/#3). |
|
||||||
|
| Drag-to-edit / interactive chart canvas | Tree canvas renders but interactive node editing (drag to re-parent, inline edit on the chart) is only partly present. | Partial | Med | M | 2 | Edits go through service layer + audit (NN#1); honor redaction. |
|
||||||
|
| Statistics dashboard | Surname/place/date distributions + tree-health. | Missing | Med | M | 6 | Reads via privacy engine (NN#2). |
|
||||||
|
| Kinship/relationship diagram report | Needs path-finding (see §2.3 calculator) + renderer. | Missing | Med | M | 6 | — |
|
||||||
|
| List reports (sources/places/repos/media) | Printable indexes (current screens are management, not reports). | Missing | Med | M | 6 | — |
|
||||||
|
| Color-by-lineage, fan overlays, lifespan/timeline charts | Sex-coloring exists; lineage/overlay/timeline don't. | Partial/Missing | Med | M | later | Overlays respect privacy engine. |
|
||||||
|
| Book/multi-report compiler, wall-chart tiling, page-setup, customizable charts | Print-shop-grade output. | Missing | Low–Med | L–XL | later | Saved "book" entity = full CRUD (NN#8); honor living-person privacy. |
|
||||||
|
| Bowtie/couple-rooted/circular-sun/3D/network/calendar | Niche chart variants. | Missing/Partial | Low–Med | M–L | later | — |
|
||||||
|
| Print-shop products, XML template engine, blank forms | Commercial/template extras. | Missing | Low | S–XL | later | Weak fit for self-host. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.8 Research workflow & automation
|
||||||
|
|
||||||
|
The preview→approve **bulk cleanup** tool is a genuine **have** and a differentiator. The missing pieces are the serious-researcher workflow entities.
|
||||||
|
|
||||||
|
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Data-quality / consistency checker | Extend cleanup beyond name issues: child-before-parent, death-before-birth, implausible ages, orphans, dups; severity tiers. | Partial | High | L | 2 | New auto-fixes keep preview→apply (NN#1). |
|
||||||
|
| Research log | Searches, repositories visited, negative results, findings — distinct from the system audit log. | Missing | High | M | 6 | Reference reusable Sources (NN#5); tenant-scoped full CRUD (NN#8). |
|
||||||
|
| To-do / research task planner | Tasks on Person/Tree with status/priority/due/assignment. | Missing | High | M | 6 | Full CRUD in API+UI (NN#8). |
|
||||||
|
| Source-driven data entry | Start from a Source document and transcribe facts into the tree. | Missing | High | M | 2 | Natural sources-first differentiator (NN#5). |
|
||||||
|
| Task↔log linkage | FK + joined view once both entities exist. | Missing | Med | S | 6 | Cheap once predecessors land. |
|
||||||
|
| Family chronology / timeline | Sort merged events; family-wide chronology (parents' marriage, siblings' births). | Partial | Med | M | 2 | Sort is trivial; presentation over privacy-filtered data. |
|
||||||
|
| Navigation: active person / history / bookmarks | Large trees rely on browser back only. | Missing | Med | M | 2 | Per-user, tenant-scoped, full CRUD; don't expose redacted persons (NN#2/#3). |
|
||||||
|
| Saved-record shoebox / review queue | Stage candidate records before committing. | Missing | Med | M | 4/7 | Auto-attach via ChangeProposal (NN#1); legal sources (NN#6). |
|
||||||
|
| Guided research suggestions | Proactive "research next" engine (today only flags problems). | Partial | High | L | 4 | Advisory; writes via ChangeProposal (NN#1); cross-tree via privacy engine (NN#2). |
|
||||||
|
| Persona-adaptive onboarding | Family Keeper / Serious Researcher / Property Researcher selector (PRD US-002, documented but unbuilt). | Missing | Low–Med | L | 2 | Pure presentation. |
|
||||||
|
| Dashboard widgets, scratchpad, research-link sidebar, blog/narrative authoring, research wiki, crowd indexing | Conveniences. | Missing | Low | S–XL | later | Widgets/published narratives read via privacy engine (NN#2/#3). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.9 Collaboration & sharing
|
||||||
|
|
||||||
|
Authorization is enforced everywhere, but the **management surface is entirely absent** — the most consequential gap relative to the multi-user product promise. Because the Critical items below previously sat at Phase 6 while their labels said "breaks NN#8," a minimal management slice is pulled forward to Phase 2; the richer invite/email UX stays at Phase 6.
|
||||||
|
|
||||||
|
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **Membership PATCH/DELETE + role change (minimal slice)** | Add/adjust/revoke a collaborator and change `role` — the substrate (mutable `role`) exists; only the endpoints are missing. Resolves the create-only NN#8 break without the full invite flow. | Partial | **Critical** | S–M | 2 | **Pulled forward** — a create-only entity shouldn't wait for Phase 6 (NN#8). Revocation routes through the single privacy point. |
|
||||||
|
| Full invite/grant flow (email + UI) | Email-based invitations, pending-invite state, role-grant UI, resend/expire. Builds on the minimal slice. | Partial | High | L | 6 | Invitation email via configured SMTP (NN#7); membership changes through the one enforcement point. |
|
||||||
|
| **Read-only public tree share** | Visibility model already redacts living persons for anonymous viewers, but every endpoint requires `CurrentUser` — no optional-auth dep, no public route, no public page. | Partial | High | M | 2 | Highest-leverage near-term sharing feature; living-safe by construction via `person_visibility` (NN#2/#3). |
|
||||||
|
| SEO public profile pages (server-rendered) | Intent declared (`public` = search-indexable) but zero implementation; no sitemap/robots/meta. | Partial | Med | L | 2 | NN#2 explicitly names server-rendered public pages — must go through privacy engine, no direct row queries. |
|
||||||
|
| **Notification / event-dispatch substrate** | Shared enabler seeded from `AuditEntry`: subscription + dispatch layer emitting privacy-filtered projections. Underpins watch/follow, mutual-consent match notices, comments, moderation, and in-app messaging. | Missing | High | L | 6 | **Privacy-filtered projections only — never raw before/after JSON** (NN#2/#3). |
|
||||||
|
| Comments / discussion threads | Per-profile discussion (target = person/event/source), threaded. | Missing | High | M | 6 | Comments on living persons redacted for non-members (NN#2/#3); rides the dispatch substrate. |
|
||||||
|
| In-app messaging (contact details hidden) | SMTP exists; no Message/Thread model. | Planned | High | L | 6 | Hide contact details; opens after mutual consent (NN#4); redact living-person content; rides dispatch substrate. |
|
||||||
|
| Watch/follow + change notifications | `AuditEntry` is the natural event source; needs subscription entity + dispatch (substrate above). | Planned | Med | M | 6 | Notification builder reads via privacy engine, not raw rows. |
|
||||||
|
| **Optimistic concurrency / lost-update protection** | No version/etag/`updated_at` precondition checks; concurrent multi-user edits can silently clobber. | Missing | High | M | 6 | Full-CRUD + multi-user without this risks lost updates; concurrent paths still route through privacy engine. |
|
||||||
|
| Pending-changes moderation (human edits) | Queue contributor edits for owner approval — shares infra with the AI ChangeProposal queue. | Missing | Med | L | 6 | **Design together with ChangeProposal** (NN#1). |
|
||||||
|
| Field-by-field profile merge & approval | WikiTree-style merge center + unmerge with per-field provenance. | Missing | Med | XL | later | Conflicting facts each retain Source/Citation (NN#5). |
|
||||||
|
| Ownership transfer | `owner_id` is effectively write-once; needed for self-host longevity. A minimal reassignment endpoint is the NN#8 fix. | Missing | Med | M | 6 | **Violates write-once invariant** (NN#8) — importance/phase tension noted; ship the minimal slice when membership lands. |
|
||||||
|
| Narrative website / HTML export | Static narrated site (reuse public-page renderer). | Missing | Med | L | later | Redact living persons at build time (static bypasses runtime engine) (NN#3). |
|
||||||
|
| Two-way desktop↔online sync | Bidirectional sync with change journals. Audit log could seed a change feed. | Missing | Med | XL | later | No Ancestry TreeShare / paywalled sync (NN#6). |
|
||||||
|
| Curator roles, trusted-list ACLs, field locking, projects/workspaces, forum, honor code, free-space wiki, portal homepage | Community-platform features. | Missing | Low | S–XL | later | New roles/ACLs/locks integrate with the **single** enforcement point, not parallel checks. |
|
||||||
|
| Real-time co-editing | Out of scope; only optimistic concurrency planned. | Planned | Med | XL | later | Concurrent paths must route through privacy engine. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.10 Privacy & access control
|
||||||
|
|
||||||
|
The architecture is correct (single engine, tenant mixin, audit, soft-delete + purge are **have**), but enforcement coverage and configurability have real holes — two of which are security-priority.
|
||||||
|
|
||||||
|
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **Uniform living-person redaction across child resources** | `_redact` runs on person reads but **not** on event/media/name/relationship/citation/source endpoints — non-members fetch a possibly-living person's events/photos/names directly. | Partial | **Critical** | M | 1–2 | **Security-priority. Core NN#3/NN#2 defect.** Apply `person_visibility` on every person-derived fact. |
|
||||||
|
| **Email-verification enforcement gate** | `email_verified_at` is written at `auth_service.py:154` but read on no path; `register` returns an authenticated session cookie + token (201) pre-verification. | Partial | **High** | S | 1–2 | **Security-priority near-quick-win** — add the read-side check (NN#7 trust path). The check is small; the registration-mode switch below is the larger piece. |
|
||||||
|
| Self-registration mode gating (approve / open / closed) | No env switch to choose open vs admin-approval vs closed registration. | Partial | High | M | 2/5 | Twelve-factor registration control (NN#7); pairs with the verification gate above. |
|
||||||
|
| **Fix `site_members` visibility tier** | Defined + selectable in UI but `can_view_tree` only handles public/unlisted — fails closed unintuitively. | Partial | Critical | S | 1 | **Quick win.** Least-surprise; honor the tier the UI offers. |
|
||||||
|
| Make `LIVING_RECENCY_YEARS` configurable | Hardcoded 100 at `privacy.py:23`. | Partial | High | S | 2 | **Quick win.** Twelve-factor (NN#7). |
|
||||||
|
| Privacy-stripped export (redact living) | GEDCOM + account export emit full tree; no "strip living" mode. | Missing | High | M | 2 | Reuse `person_visibility`/`_redact` (NN#3). Owner self-export is safe today; shareable variant is the gap. |
|
||||||
|
| Per-fact / per-field privacy + record flags | tentative/rejected/preferred/private flags on facts. | Missing | Med | L | later | If added, route through the single engine (NN#2). |
|
||||||
|
| Granular rules by record type & viewer relationship | webtrees-style "hide marriages from non-descendants". | Missing | Med | L | later | Single enforcement point. |
|
||||||
|
| OIDC / external IdP login | `AuthProvider` interface ready; only Local implemented. Authentik is the intended real auth. | Planned | High | L | 5 | Additive by design. |
|
||||||
|
| Two-factor auth (TOTP) | Bearer/cookie session auth is solid; no MFA. | Partial | High | L | 5 | — |
|
||||||
|
| DB-level audit immutability | Audit is insert-only by convention; no trigger/constraint. Verified as "adequate for self-host," so importance downgraded to match. | Have(soft) | Med | S | 9 | Adequate for self-host; upgrade to trigger only if true immutability is required. |
|
||||||
|
| Block/hide users, family-group private space, DNA opt-in controls | Depend on messaging/DNA. | Missing | Low–Med | M–XL | 6/parked | DNA parked (NN#6). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.11 Import/export & standards
|
||||||
|
|
||||||
|
GEDCOM 5.5.1 import/export and full data-portability export are **have**, but fidelity gaps directly undercut the provenance thesis — and one is outright data loss.
|
||||||
|
|
||||||
|
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **Citation links dropped on GEDCOM export** | Export never selects the Citation table — fact→source links, page, detail, confidence all dropped on export (they import fine). Re-importing your own export **destroys** the sources-first graph. | Partial | **Critical** | M | 2 | **Silent data loss on the product's signature data + destructive round-trip** (NN#5); breaks PRD US-013. |
|
||||||
|
| GEDCOM 7.0 import/export | Version hardcoded `5.5.1`; no v7 semantics, SCHMA, SUBM, or UID handling. | Partial | High | L | 2 | Stated differentiator (FamilySearch interop). |
|
||||||
|
| Custom/underscore tag preservation | `_MARNM` becomes `TYPE married`, other custom tags dropped — violates ≥99% round-trip goal. | Missing | High | L | 2 | Tension with provenance thesis (faithful record). |
|
||||||
|
| PLAC FORM hierarchy + MAP coordinate round-trip | Import reads only PLAC text; export emits flat PLAC. lat/long + hierarchy lost on round-trip. | Missing | High | M | 2–3 | Round-trip fidelity for the land/maps pillar. |
|
||||||
|
| Encoding detection (ANSEL/UTF-16) | UTF-8 round-trips; non-UTF-8 files silently mangled via `errors='replace'`; CHAR tag ignored. | Partial | High | S | 2 | **Near quick win.** Detect/honor CHAR; reject or transcode rather than corrupt. |
|
||||||
|
| HEAD completeness | HEAD at `gedcom.py:740` emits only `SOUR/GEDC/VERS/CHAR` — missing required `2 FORM LINEAGE-LINKED` (under GEDC) and `1 SUBM`. | Partial | Med | S | 2 | **Quick win.** Pure conformance. |
|
||||||
|
| GEDCOM media (OBJE) round-trip | OBJE in skip-tags; media ignored on import, never emitted on export. | Partial | Med | M | 2 | Any media bundle keeps privacy gating. |
|
||||||
|
| GEDZIP (.gdz) bundle | Bundled-media packaging. | Missing | Med | M | 2 | Natural once v7 + OBJE land. |
|
||||||
|
| Selective / filtered export | Clippings-cart / branch subset. | Missing | Med | M | later | Maintain single-enforcement-point on export (NN#2). |
|
||||||
|
| Import conformance validation | Preview is a mapping report, not structural/cardinality validation; bad lines silently skipped. | Partial | Med | M | 2 | — |
|
||||||
|
| GEDCOM-X, Gramps XML, multi-format import, FHISO/ELF, PRF upload, KML export | Interop extras. | Missing | Low | L | later | PRF needs FamilySearch API (permitted, NN#6). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.12 Mobile & offline
|
||||||
|
|
||||||
|
Responsive web is **partial**; PWA and offline-first are absent. Native apps are an explicit deferral.
|
||||||
|
|
||||||
|
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| PWA (manifest + icons + viewport + service worker) | No manifest, no SW, no `next-pwa`; responsive coverage exists but unaudited on heavy views (tree canvas fixed 74vh). | Partial | High | M | 2 | If SW caches API responses, never retain non-owner PII; cache only what the session is authorized to see (NN#3). |
|
||||||
|
| Responsive parity audit | 17 breakpoint usages; small-screen parity on tree/person views unverified. | Partial | High | M | 1–2 | Feature parity is an ARCH requirement. |
|
||||||
|
| Offline-first editing + reconnect sync | No SW, no local store, no mutation queue. Valuable for archive/courthouse field research. | Missing | High | XL | later | Replayed edits go through service layer + audit (NN#1); cached data respects living-person rule (NN#3). |
|
||||||
|
| Native mobile apps | Explicitly deferred (responsive web only). | Missing | Med | XL | later | If built, reads through one backend privacy engine (NN#2/#3/#4). |
|
||||||
|
| Companion app w/ cross-device sync | Largely redundant with server-backed web. | Missing | Low | XL | later | Sync boundary enforces privacy (NN#2); full CRUD parity (NN#8). |
|
||||||
|
| Relatives Around Me | Nearby-relatives discovery. | Missing | Low | L | later | Explicit opt-in; anonymous until mutual consent (NN#4). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.13 API & extensibility
|
||||||
|
|
||||||
|
Internal REST + OpenAPI + generated TS client are **have**. The externalized developer story and the connector/plugin spine are not built.
|
||||||
|
|
||||||
|
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Public read-only API + scoped tokens (OAuth) | Bearer token is opaque session only; `TokenPurpose` lacks scopes; designed `public.py` never built. | Partial | High | L | 5–6 | Any scoped-token path routes through `person_visibility` + living-person redaction (NN#2/#3). |
|
||||||
|
| SourceConnector framework | Only AuthProvider/ObjectStore/Mailer base classes exist; no connector base/loader/registry. Gates AI, hints, property connectors. | Planned | Med | L | 4 | Read-only, rate-limited; findings via ChangeProposal (NN#1); legal sources only (NN#6). |
|
||||||
|
| Webhooks / change feeds | `AuditEntry` is the natural substrate (shares the notification dispatch layer, §2.9); no feed/webhook layer. | Missing | Med | L | 6 | Emit privacy-filtered, tenant-scoped projections — never raw before/after JSON (NN#2/#3). |
|
||||||
|
| CLI / scripting surface | No `[project.scripts]`, no Typer/Click; worker is a purge loop only. Self-hosters want bulk admin. | Missing | Med | M | 9 | Funnel reads through privacy.py, writes through audit; admin-scoped, no assistant-write path. |
|
||||||
|
| Plugin/addon architecture | Connector framework only; no general UI/report/theme plugin system planned. | Planned | Med | L | later | Sandbox via service layer; no privacy/audit bypass, no writes outside ChangeProposal. |
|
||||||
|
| In-app query tooling (SuperTool) | Power-user expression engine. | Missing | Low | L | later | Execute through privacy engine — no row enumeration bypass (NN#2). |
|
||||||
|
| Certified partner program | Organizational, not software. | Missing | Low | XL | — | Out of scope until a hosted offering exists. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.14 Performance & scale
|
||||||
|
|
||||||
|
Postgres + S3, multi-tenant isolation are **have**. Queue, observability, backups, pagination, and scale validation are the gaps that gate Phases 4/7 — several are current functional limitations, not late-phase validation tasks.
|
||||||
|
|
||||||
|
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Real job queue (Postgres/Redis-backed) | Worker is a fixed-interval purge loop; GEDCOM import and account export run **inline in the request**. | Partial | High | L | 4 (pre-req) | Blocks NN#1 (assistant in worker) and NN#4 (hint matching in worker). Queue backend is an open question (PRD §11). |
|
||||||
|
| **Pagination on list endpoints + server-side tree loading** | List endpoints (`persons.py:37`, events, relationships) take no `limit/offset/skip`; the tree view loads the whole graph client-side. A *current* limitation against the 50k-person target. | Planned | High | M | 1–2 | **Split out from scale validation** — this is a correctness/functional gap now, not a Phase 9 task. |
|
||||||
|
| Scale validation (50k+ trees, P95<2s, load test) | No benchmark or load test exists. | Planned | High | L | 9 | Inline heavy ops risk partial writes — moving to the queue is what makes "failures never corrupt state" true. |
|
||||||
|
| **Operator backup: one-command `pg_dump` + MinIO sync** | Only a documented procedure + per-account ZIP exist; no scripted DB+object dump. For a self-host product this is day-one data-loss exposure. | Partial | Critical | M | 1–2 | **Pulled forward** — Critical importance contradicted the old Phase-9 slot. Restore must re-apply privacy state faithfully (NN#3); safety net for NN#8. |
|
||||||
|
| Scheduled / cloud automated backup + restore tooling | Cron-driven, off-host, verified-restore workflow. | Partial | High | L | 9 | Builds on the one-command slice above. |
|
||||||
|
| ARM64 build matrix | CI builds `linux/amd64` only; many self-hosters run ARM SBCs. | Partial | High | S | 1 | **Quick win.** Add arm64 + QEMU to buildx (NN#7 container-native). |
|
||||||
|
| Structured JSON logs + Prometheus metrics | Plain-text stdlib logging; no `/metrics`. | Partial | Med | M | 9 | Logs/metrics reference UUIDs, never names/PII (NN#3/#4). |
|
||||||
|
| pgvector enablement | Image has pgvector; app never creates the extension or adds embedding columns (docs claim otherwise). | Partial | Med | M | 7 | See §2.3 — embedding provider open question; candidates via privacy engine. |
|
||||||
|
| Database check-and-repair | No orphan/dangling-edge/cycle scanner (recent "harden tree render" commit shows bad graphs occur). | Missing | Med | M | 9 | Tenant-scoped + audited; auto-fix via ChangeProposal (NN#1). |
|
||||||
|
| Pluggable DB backend, billions-scale shared tree, weekly record releases | Different product models. | Missing | Low | XL | — | **Out of scope** — Postgres-only is consistent with the invariants; global shared tree conflicts with NN#2/#3/#4. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.15 Property / land chain-of-title — *headline differentiator*
|
||||||
|
|
||||||
|
The entire "land" half is **planned/missing** but fully specified. This is where Provenance has no real competitor.
|
||||||
|
|
||||||
|
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Property/parcel first-class entity | No model/endpoint/service/migration. Foundation for the whole category. | Planned | High | L | 3 | Full CRUD in API+UI (NN#8); reads added carefully to the **single** privacy engine (NN#2). |
|
||||||
|
| Typed OwnershipEvents | grant/patent, purchase, sale, inheritance, gift, tax sale, foreclosure, eminent domain — with grantor/grantee Persons + Citation. | Planned | High | L | 3 | Each event carries a Citation (NN#5); grantor/grantee living-person links redacted (NN#3). |
|
||||||
|
| Chain-of-title timeline + gap flagging | Ordered OwnershipEvents first-grant→present, breaks flagged. | Planned | High | M | 3 | The genuinely differentiating analytical piece (PRD US-032). |
|
||||||
|
| Bidirectional owner↔person, parcel↔place | "Every property a person held" / "every parcel at a place." | Planned | High | M | 3 | Reverse traversals filtered through privacy engine (NN#2). |
|
||||||
|
| Citations on OwnershipEvents | Add `ownership_event_id` to Citation (5th target). | Partial | Critical | S | 3 | **Quick win once Property lands** — single FK + CHECK edit (NN#5). |
|
||||||
|
| Legal description verbatim storage | metes-and-bounds / PLSS township-range-section as-written. | Planned | Med | L | 3 | Part of the Property model; preserves the record faithfully. |
|
||||||
|
| Parcel/plat boundary geometry | Optional geometry; plain coords first. | Planned | Med | L | 3+ | PostGIS is an open question (ARCH §14) — surface dependency. |
|
||||||
|
| PLSS / metes-and-bounds parsing → geometry | Automated survey parsing. | Planned | Med | XL | later | Hard; gated on PostGIS. |
|
||||||
|
| BLM/GLO federal land-patent connector | Marquee US land source. | Planned | High | L | 8 | Permitted source (NN#6); patents surface as ChangeProposals (NN#1); read-only + rate-limited. |
|
||||||
|
| USGS map + public county-deed connectors | Per-jurisdiction grantor/grantee indexes. | Planned | Med | L | 8 | Each connector verifies a legally open source (NN#6). |
|
||||||
|
| Co-ownership roles / tenure types | joint tenants, TIC, life estate, heirs. | Planned | Low | M | later | Multiple parties likely free with OwnershipEvent; role typing is a refinement. |
|
||||||
|
| Tax/assessment rolls, UK Tithe, Lloyd George Domesday | Valuation + non-US collections. | Missing | Low | M–L | — | US-focused v1; international formats out of scope (model is country-agnostic). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.16 AI assistant — *defining differentiator*
|
||||||
|
|
||||||
|
Entirely **planned** — and note the docs-vs-code gap: ARCHITECTURE §5 lists `ChangeProposal` as part of the "landed" core model, but no model/migration/schema exists. The audit substrate (`actor_type=assistant`, before/after JSONB) is the right foundation; the ChangeProposal model and ModelProvider abstraction are the two critical-path pieces.
|
||||||
|
|
||||||
|
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **ChangeProposal (propose-then-confirm)** | The defining invariant. No `proposal.py`, no migration, no review UI yet — despite docs implying it landed. | Planned | **Critical** | L | 4 | **IS NN#1.** Enforce structurally: assistant tools return proposals; only user action applies one; application flows through the normal service layer (privacy + audit). ChangeProposal itself needs full CRUD (NN#8). Correct the docs to match reality. |
|
||||||
|
| Pluggable LLM + embedding provider | `ModelProvider` over Anthropic/OpenAI/xAI/Ollama; env placeholders exist, no interface code. | Planned | Critical | M | 4 | **Twelve-factor, no hard-coded keys/endpoints** (NN#7); the Ollama/self-hosted path is what makes the privacy-first promise real. |
|
||||||
|
| AI research-assistant chatbot (RAG over tree) | Marquee feature; needs ModelProvider + connector + retrieval through privacy engine. | Planned | High | XL | 4 | NN#1 propose-only, NN#2 privacy retrieval, NN#3 redaction. |
|
||||||
|
| Conversational / connector record search | Search legal sources via the assistant. | Planned | High | L | 4 | Legal sources (NN#6); findings = Source + Citation (NN#5). |
|
||||||
|
| Fact extraction from documents | Extracted facts map cleanly to ChangeProposal review. | Missing | Med | M | 4 | Canonical NN#1 use case; each fact carries a Citation (NN#5). |
|
||||||
|
| OCR/HTR transcription + document translation | Worker job via ModelProvider. | Missing | Med | L | 4+ | Output → Source/Citation (NN#5); via ModelProvider (NN#7); auto-extraction emits ChangeProposal (NN#1). |
|
||||||
|
| Next-step research guidance | Gap analysis → suggested next record. | Planned | Med | M | 4 | Reads via privacy engine; advisory unless it queues fetches. |
|
||||||
|
| AI biography / audio narration | Read-only generation grounded in tree data. | Missing | Low | M–L | later | Must not leak living-person PII (NN#3); via ModelProvider (NN#7); stored biographies = full CRUD (NN#8). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.17 Localization & accessibility
|
||||||
|
|
||||||
|
A documented **day-one commitment** ("UI strings externalized from day one") that is currently **unmet** — every label is a hardcoded literal. Correct the PRD claim or close the gap.
|
||||||
|
|
||||||
|
| Item | Description | Status | Imp | Eff | Phase | Non-negotiable |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **UI string externalization** | No i18n lib, no message catalogs; all copy hardcoded in TSX. Gating prerequisite; cheapest to do now while the surface is small. | Missing | High | L | 1–2 | PRD §6 promises this "from day one" — **docs-vs-code gap; edit the doc now.** |
|
||||||
|
| Multi-language UI (40–60+ langs) | Translation pipeline after externalization (frontend + backend-generated messages). | Missing | High | XL | later | Table stakes across all competitors. |
|
||||||
|
| Accessibility / WCAG 2.2 AA | Some ARIA/focus styling; no CI a11y audit, no skip-links, SVG tree viz not keyboard/screen-reader navigable. | Partial | High | L | 2/9 | Stated PRD §6 target; add axe/pa11y in CI; accessible alternate to the chart. |
|
||||||
|
| Unicode-correct non-Latin names | Stores fine (UTF-8); no NFC normalization on write, no locale-aware collation, no romanized search. | Partial | High | M | 2 | Apply `unicodedata.normalize('NFC')` on input; add COLLATE; supports faithful-record goal. |
|
||||||
|
| Structured/compound surname components | Surname is a single field; no support for Spanish/Portuguese paternal+maternal, Arabic nasab, particles/prefixes. | Missing | Med | M | 2 | New Name sub-fields ship with full CRUD (NN#8); preserves the name as recorded. |
|
||||||
|
| Non-Gregorian calendar dates | `calendar` column is a placeholder; GEDCOM calendar escapes never parsed/populated. | Partial | Med | L | 2 | Preserve original calendar as recorded (sources-first). |
|
||||||
|
| Language tags / romanized variants per name | No language_tag/script/romanized fields; GEDCOM ROMN/LANG unhandled. | Missing | Med | M | 2 | New Name sub-fields ship with full CRUD (NN#8). |
|
||||||
|
| RTL support | `lang="en"` hardcoded, no `dir`, physical CSS properties throughout. | Missing | Med | M | later | Convert to logical CSS properties; cheaper once i18n exists. |
|
||||||
|
| Selectable themes | Light/dark/system works; brand palette intentionally single. | Partial | Med | M | later | Confirm whether additional themes are a deliberate non-goal (brand guide constrains palette). |
|
||||||
|
| Multi-language report/diagram output | Depends on i18n + reports, neither shipped. | Missing | Low | L | later | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Quick wins (high importance / low effort)
|
||||||
|
|
||||||
|
Ordered by leverage. All are S-effort or a thin slice of a larger item, and most close a stated invariant gap.
|
||||||
|
|
||||||
|
1. **Fix `site_members` visibility tier** (Privacy, Critical/S) — defined and selectable in the UI but never handled in `can_view_tree`; fails closed unintuitively.
|
||||||
|
2. **Email-verification enforcement gate** (Privacy/Auth, High/S) — add the read-side `email_verified_at` check so a freshly registered, unverified user doesn't get a live authenticated session. Security-priority; the registration-mode env switch (open/approve/closed) is the larger follow-on, not part of this quick win.
|
||||||
|
3. **Citation confidence selector in the cite form** (Sources, High/S) — confidence is modeled and API-writable but unreachable in the UI; every UI citation is currently NULL. Honors NN#8 and the evidence-quality thesis.
|
||||||
|
4. **Source edit UI + expose all 8 fields** (Sources, High/S) — update API exists but there is no edit form and create exposes ~3 fields; a create-but-not-edit entity violates NN#8.
|
||||||
|
5. **Make `LIVING_RECENCY_YEARS` env-configurable** (Privacy, High/S) — hardcoded 100 at `privacy.py:23`; twelve-factor (NN#7).
|
||||||
|
6. **Add `ownership_event_id` to Citation** (Property/Sources, Critical/S) — single FK + CHECK-constraint edit the moment Property lands; the spine is already built (NN#5).
|
||||||
|
7. **GEDCOM encoding detection** (Standards, High/S) — detect/honor the CHAR tag; reject or transcode ANSEL/UTF-16 rather than silently mangling with `errors='replace'`.
|
||||||
|
8. **GEDCOM HEAD completeness** (Standards, Med/S) — emit the required `2 FORM LINEAGE-LINKED` (under GEDC) and `1 SUBM` at `gedcom.py:740`. Pure conformance.
|
||||||
|
9. **ARM64 CI build matrix** (Perf/Scale, High/S) — add `linux/arm64` + QEMU to buildx for both images; many self-hosters run ARM SBCs.
|
||||||
|
10. **`GET /{tree}/citations/{id}` endpoint** (Sources, Med/S) — API symmetry (NN#8).
|
||||||
|
11. **Transcription/abstract fields on Source** (Sources, Med/S) — add `transcription_text` + `abstract_text`, distinct from `citation_text`; core to evidence analysis.
|
||||||
|
12. **Sort the merged person timeline** (Research workflow, Med/S) — `shownEvents.sort()` on `date_start`; currently appended unsorted.
|
||||||
|
13. **Doc corrections (docs-vs-code)** (Meta, trivial/S) — edit CLAUDE.md / ARCHITECTURE so the pgvector "used" claim, the i18n "from day one" claim, and the ChangeProposal "landed" claim match reality. The repo convention requires docs to travel with code.
|
||||||
|
|
||||||
|
> **Ships-with, not standalone:** *Revocable / adjustable access (membership PATCH/DELETE + role change)* is security-critical and S-effort, but it is the minimal slice of the membership work (§2.9) and ships **with** those endpoints — it is not independently shippable on its own.
|
||||||
|
>
|
||||||
|
> **Higher priority than any quick win, but M-effort (not quick):** the **media privacy leak** (§2.4), the **child-resource redaction gap** (§2.10), and pulling the **one-command operator backup** (§2.14) forward. Treat these as **security-/data-loss-priority Phase 1–2 fixes** regardless of the quick-win list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Strategic differentiators
|
||||||
|
|
||||||
|
Where to invest to make Provenance distinct rather than a webtrees clone. Each leans on a non-negotiable as a *feature*, not a constraint.
|
||||||
|
|
||||||
|
**1. Property chain-of-title (the "land" half).** No surveyed competitor models ownership as a typed, cited event chain tying parties across time, with gap-flagging and bidirectional owner↔person / parcel↔place traversal, fed by **legal** public sources (BLM/GLO patents, USGS, public county deeds). This is the single clearest "no one else does this" capability. Sequence: Property + OwnershipEvent + Citation-target (Phase 3) → chain-of-title view → BLM/GLO connector (Phase 8). The Citation extension is a quick win; the entity is the prerequisite for everything else in the category.
|
||||||
|
|
||||||
|
**2. The ChangeProposal AI model.** "The assistant never writes autonomously" is a *trust* differentiator in a market where users fear AI corrupting their research. Build it structurally — assistant tools return proposals; only an explicit human action applies one; application flows through the normal service layer so it always hits the privacy engine and audit log. The same approval queue moderates untrusted human-contributor edits (Collaboration §2.9), so design them together. The audit substrate is already in place; ChangeProposal + ModelProvider are the critical path — and the docs should stop asserting ChangeProposal has landed until it has.
|
||||||
|
|
||||||
|
**3. Anonymous, mutual-consent cross-tree hints.** The privacy model already redacts living people for anonymous viewers, so a hint system that reveals *nothing identifying* until both sides opt in is achievable by construction — and is a categorically more trustworthy version of MyHeritage Smart Matches / Ancestry hints. Requires the matching engine (pgvector enablement + candidate generation, Phase 7), the notification/event-dispatch substrate (§2.9), and the messaging channel that opens only post-consent.
|
||||||
|
|
||||||
|
**4. True self-hosting + data ownership.** Full account export/import, soft-delete recovery, GEDCOM round-trip, env-driven everything, and (to-build) operator-grade scheduled backup + ARM support make Provenance the genealogy app you actually own. Two correctness items gate the promise: GEDCOM export must stop dropping citations (a Provenance→Provenance round-trip currently destroys the sources graph), and operator backup must move from "documented procedure" to a one-command dump. The Ollama/self-hosted ModelProvider path means even the AI assistant runs without tree data leaving the deployment — a promise no commercial competitor can make.
|
||||||
|
|
||||||
|
**5. Sources-first as a felt experience.** The two-tier model is built; the differentiator is making it *visible and low-friction*: a guided Evidence-Explained citation builder, transcription/abstract fields, source-driven data entry (transcribe a document into the tree), per-fact confidence surfaced in the UI, and — critically — citations that **survive GEDCOM export**. These turn "every fact links to where it came from" from an architecture note into the product's personality.
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Design note: tree visibility & the public viewing surface
|
||||||
|
|
||||||
|
Status: **proposed** (design only — no code yet). Owner: Justin. Created 2026-06-09.
|
||||||
|
|
||||||
|
This is a privacy-critical change (it creates the first anonymous read surface in
|
||||||
|
Provenance). Per CLAUDE.md, design before code. Implementation should land in
|
||||||
|
small, individually-reviewable PRs, with tests on the privacy engine and the
|
||||||
|
public read path before any anonymous endpoint is exposed.
|
||||||
|
|
||||||
|
## 1. The model
|
||||||
|
|
||||||
|
Visibility flattens **two axes** — *who may read* and *how discoverable* — into one
|
||||||
|
ordered enum for the UI:
|
||||||
|
|
||||||
|
| Level | Anonymous (no login) | Any logged-in user | Tree members | In-app directory | Search-indexed |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `public` — anyone on the web | ✅ view¹ | ✅ view¹ | ✅ full | ✅ listed to everyone | ✅ sitemap + indexable |
|
||||||
|
| `site_members` — Public, Site Members | ❌ | ✅ view¹ | ✅ full | ✅ listed to logged-in users | ❌ (`noindex`) |
|
||||||
|
| `unlisted` — anyone with the link | ✅ via direct link¹ | ✅ via link¹ | ✅ full | ❌ never listed | ❌ (`noindex`) |
|
||||||
|
| `private` | ❌ | ❌ | ✅ full | ❌ | ❌ |
|
||||||
|
|
||||||
|
¹ **Every non-member view passes through the privacy engine.** Living people are
|
||||||
|
redacted, and per-person `private` hides / `public` reveals, exactly as
|
||||||
|
`person_visibility()` already does (`backend/app/services/privacy.py:100-110`).
|
||||||
|
This is the single enforcement point — no public code path may issue a raw query.
|
||||||
|
|
||||||
|
Decisions captured (2026-06-09):
|
||||||
|
- **Unlisted** = anyone with the link, no account required. The link must be
|
||||||
|
**unguessable** (the tree UUID is already non-enumerable; do not add a public
|
||||||
|
integer id). Unlisted trees are excluded from the directory and sitemap and
|
||||||
|
served `noindex`.
|
||||||
|
- **Public** discovery for v1 includes **an in-app public browse/search**, not
|
||||||
|
just search-engine indexing.
|
||||||
|
- **Public – Site Members** = *any* registered account on this instance (not an
|
||||||
|
invite list — that is already tree membership / `private`).
|
||||||
|
|
||||||
|
## 2. Data model
|
||||||
|
|
||||||
|
`TreeVisibility` enum (`backend/app/models/enums.py`) gains a value:
|
||||||
|
|
||||||
|
```
|
||||||
|
public # anyone on the web
|
||||||
|
site_members # any authenticated user of this instance <-- NEW
|
||||||
|
unlisted # anyone with the link
|
||||||
|
private # members only (default)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Alembic migration to `ALTER TYPE tree_visibility ADD VALUE 'site_members'`
|
||||||
|
(Postgres enum add-value cannot run inside a transaction with other DDL — use
|
||||||
|
`op.execute` with autocommit, separate migration).
|
||||||
|
- Default stays `private`. Existing rows unchanged.
|
||||||
|
- `TreeRead`/`TreeUpdate`/`TreeCreate` schemas already carry the enum; they pick
|
||||||
|
up the new value automatically. The OpenAPI client regen (`gen:api`) exposes it
|
||||||
|
to the frontend.
|
||||||
|
|
||||||
|
## 3. Privacy engine
|
||||||
|
|
||||||
|
`can_view_tree()` today treats `public` and `unlisted` identically and ignores
|
||||||
|
whether the viewer is anonymous vs authenticated (`privacy.py:44-49`). Replace the
|
||||||
|
final line with explicit branching on viewer auth state:
|
||||||
|
|
||||||
|
```
|
||||||
|
if membership: return True # members always
|
||||||
|
match tree.visibility:
|
||||||
|
public, unlisted: return True # anonymous OK (unlisted gated only by knowing the link)
|
||||||
|
site_members: return user_id is not None # any logged-in account
|
||||||
|
private: return False
|
||||||
|
```
|
||||||
|
|
||||||
|
`person_visibility()` is unchanged — it already redacts living/private people for
|
||||||
|
non-members. Add focused unit tests: anonymous + each visibility; living person
|
||||||
|
redacted on public/unlisted; `site_members` denies anonymous but allows a
|
||||||
|
logged-in non-member; `private` denies both.
|
||||||
|
|
||||||
|
## 4. The anonymous read path (the careful part)
|
||||||
|
|
||||||
|
**Recommendation: a dedicated read-only public API namespace**, not optional-auth
|
||||||
|
on the existing endpoints. Rationale: it is far easier to audit a small,
|
||||||
|
purpose-built surface that *always* funnels through `person_visibility` than to
|
||||||
|
weaken the membership checks on the authenticated endpoints and hope every branch
|
||||||
|
is covered.
|
||||||
|
|
||||||
|
- New router `app/api/v1/public.py`, mounted at `/api/v1/public`, with an
|
||||||
|
**optional-auth** dependency `CurrentUserOrNone` (returns `User | None`; never
|
||||||
|
401s). Contrast with `CurrentUser` (`deps.py:30-36`) which hard-401s.
|
||||||
|
- Endpoints (read-only; no create/update/delete):
|
||||||
|
- `GET /public/trees` — directory: lists `public` to everyone; additionally
|
||||||
|
lists `site_members` when the caller is authenticated. Paginated, search via
|
||||||
|
existing `pg_trgm`. Never lists `unlisted`/`private`.
|
||||||
|
- `GET /public/trees/{id}` — tree metadata if `can_view_tree(user_or_none)`.
|
||||||
|
- `GET /public/trees/{id}/persons`, `/persons/{pid}`, `/relationships`,
|
||||||
|
`/events`, `/media`, … — each filtered through `person_visibility`, returning
|
||||||
|
redacted projections (a `PublicPersonRead` that omits PII for redacted people:
|
||||||
|
no exact dates, no living-person names beyond "Living", etc.).
|
||||||
|
- **A redacted response schema**, distinct from the member `PersonRead`, so the
|
||||||
|
serializer physically cannot emit fields a non-member shouldn't see. Redaction
|
||||||
|
happens in the service, not the route.
|
||||||
|
- **Rate limiting** on the public namespace (per-IP) to blunt scraping/enumeration.
|
||||||
|
- **Audit**: count public reads; do not log PII.
|
||||||
|
|
||||||
|
## 5. Frontend public pages
|
||||||
|
|
||||||
|
- New **server-rendered** routes outside the authed app shell, e.g.
|
||||||
|
`/p/[treeId]` (tree), `/p/[treeId]/[personId]` (person), `/explore` (directory).
|
||||||
|
Server components fetch the `/api/v1/public/*` endpoints; no login redirect.
|
||||||
|
- `robots`: allow + sitemap for `public`; `noindex, nofollow` meta for `unlisted`
|
||||||
|
and `site_members`. Sitemap lists only `public` trees/persons.
|
||||||
|
- The directory `/explore` is anonymous for `public`; shows `site_members` trees
|
||||||
|
only to logged-in users.
|
||||||
|
- Reuse the tree/person view components where possible, fed by the redacted
|
||||||
|
schema.
|
||||||
|
|
||||||
|
## 6. UI control
|
||||||
|
|
||||||
|
Update the visibility dropdown (`frontend/app/trees/page.tsx`, shipped in PR #41)
|
||||||
|
from 3 to 4 options with helper text:
|
||||||
|
|
||||||
|
```
|
||||||
|
Private — only you and people you invite
|
||||||
|
Public – Members — any signed-in user on this site
|
||||||
|
Unlisted — anyone with the link (not listed or indexed)
|
||||||
|
Public — anyone on the web; listed and search-indexable
|
||||||
|
```
|
||||||
|
|
||||||
|
A short confirmation when switching *to* `public` ("This makes <tree> visible to
|
||||||
|
anyone on the web. Living people stay hidden.") is worthwhile given the stakes.
|
||||||
|
|
||||||
|
## 7. Guardrails / invariants
|
||||||
|
|
||||||
|
- One enforcement point: every public response is built from `person_visibility`
|
||||||
|
output. No raw repository reads in the public router.
|
||||||
|
- Living-person protection holds regardless of tree visibility.
|
||||||
|
- Unlisted relies on UUID unguessability; never expose a sequential public id.
|
||||||
|
- `noindex` everything except `public`; sitemap is `public`-only.
|
||||||
|
- Tests gate the merge: privacy-engine matrix + an integration test that hits the
|
||||||
|
public endpoints anonymously and asserts no living-person PII leaks.
|
||||||
|
|
||||||
|
## 8. Suggested phasing (small PRs)
|
||||||
|
|
||||||
|
1. Enum value + migration + regen client (+ dropdown → 4 options). No behavior
|
||||||
|
change yet for non-members.
|
||||||
|
2. Privacy-engine branching + unit tests.
|
||||||
|
3. Public read API namespace (optional-auth, redacted schema, rate limit) + tests.
|
||||||
|
4. Public frontend pages (`/p/...`) + robots/sitemap.
|
||||||
|
5. In-app `/explore` directory + search.
|
||||||
|
|
||||||
|
Steps 2–3 are the privacy-critical core and should be reviewed hardest.
|
||||||
|
|
||||||
|
## 9. Open questions
|
||||||
|
|
||||||
|
- Caching: public pages are cacheable for SEO, but cache keys must not blur the
|
||||||
|
redacted-vs-member rendering. Likely: cache only the anonymous projection at the
|
||||||
|
edge; never cache member responses.
|
||||||
|
- Do `site_members` trees appear in the sitemap for logged-in crawling? (Default:
|
||||||
|
no — `noindex`.)
|
||||||
|
- Per-tree opt-out of the directory even when `public`? (Probably unnecessary;
|
||||||
|
`unlisted` already covers "reachable but not listed.")
|
||||||
@@ -3,4 +3,5 @@
|
|||||||
/out
|
/out
|
||||||
/build
|
/build
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
*.tsbuildinfo
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
FROM node:22-bookworm-slim AS deps
|
FROM node:22-bookworm-slim AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
|
# patches/ must be present before `npm ci` so the postinstall (patch-package)
|
||||||
|
# can apply our vendored family-chart layout fix.
|
||||||
|
COPY patches ./patches
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
FROM node:22-bookworm-slim AS build
|
FROM node:22-bookworm-slim AS build
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { PublicHeader } from "@/components/public-header";
|
||||||
|
|
||||||
|
export default function ExploreLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<PublicHeader />
|
||||||
|
<main className="mx-auto max-w-5xl px-4 py-8">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api/client";
|
||||||
|
import type { components } from "@/lib/api/schema";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
type Tree = components["schemas"]["PublicTreeRead"];
|
||||||
|
|
||||||
|
// Public directory of trees. The backend returns `public` to everyone and adds
|
||||||
|
// `site_members` trees when the request carries a valid session — so signed-in
|
||||||
|
// users see more here without any client-side branching.
|
||||||
|
export default function ExplorePage() {
|
||||||
|
const [trees, setTrees] = useState<Tree[]>([]);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const q = search.trim();
|
||||||
|
const t = setTimeout(async () => {
|
||||||
|
const { data } = await api.GET("/api/v1/public/trees", {
|
||||||
|
params: { query: q ? { q } : {} },
|
||||||
|
});
|
||||||
|
setTrees(data ?? []);
|
||||||
|
setReady(true);
|
||||||
|
}, 200);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-serif text-3xl font-semibold">Explore public trees</h1>
|
||||||
|
<p className="mt-1 text-[var(--muted)]">
|
||||||
|
Browse family trees shared on this site. Living people are always hidden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
className="w-72"
|
||||||
|
placeholder="Search trees by name…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!ready ? (
|
||||||
|
<p className="text-[var(--muted)]">Loading…</p>
|
||||||
|
) : trees.length === 0 ? (
|
||||||
|
<p className="text-[var(--muted)]">No public trees{search.trim() ? " match that search" : " yet"}.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{trees.map((t) => (
|
||||||
|
<li key={t.id}>
|
||||||
|
<Link href={`/p/${t.id}`}>
|
||||||
|
<Card className="h-full transition-colors hover:border-bronze/50">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate font-medium">{t.name}</span>
|
||||||
|
<span className="shrink-0 text-xs uppercase tracking-wide text-[var(--muted)]">
|
||||||
|
{t.visibility === "site_members" ? "Members" : "Public"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{t.description && (
|
||||||
|
<p className="mt-1 line-clamp-2 text-sm text-[var(--muted)]">{t.description}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+17
-12
@@ -11,23 +11,28 @@
|
|||||||
--font-serif: var(--font-fraunces), Georgia, "Times New Roman", ui-serif, serif;
|
--font-serif: var(--font-fraunces), Georgia, "Times New Roman", ui-serif, serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Adaptive tokens — ink/paper flip for light/dark; bronze + paper are constant. */
|
/* Adaptive tokens — ink/paper flip for light/dark; bronze + paper are constant.
|
||||||
|
Theme is class-based (.dark on <html>) so it can be toggled manually; an inline
|
||||||
|
script in the root layout sets it pre-paint from the saved choice or the OS. */
|
||||||
:root {
|
:root {
|
||||||
--background: #f7f3ec;
|
--background: #f7f3ec;
|
||||||
--foreground: #1a1a17;
|
--foreground: #1a1a17;
|
||||||
--muted: #6b6862;
|
--muted: #6b6862;
|
||||||
--surface: #fffdf9;
|
--surface: #fffdf9;
|
||||||
--border: #e6ddcc;
|
--border: #e6ddcc;
|
||||||
|
/* Connector "lines between people" (pedigree + tree chart). Derived from Ink
|
||||||
|
(the brand mark color): a dark line on light, light on dark. */
|
||||||
|
--line: color-mix(in srgb, var(--foreground) 55%, transparent);
|
||||||
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
.dark {
|
||||||
:root {
|
--background: #161410;
|
||||||
--background: #161410;
|
--foreground: #f2eee6;
|
||||||
--foreground: #f2eee6;
|
--muted: #9a968e;
|
||||||
--muted: #9a968e;
|
--surface: #211d17;
|
||||||
--surface: #211d17;
|
--border: #353029;
|
||||||
--border: #353029;
|
color-scheme: dark;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -78,7 +83,7 @@ h3,
|
|||||||
left: -2.5rem;
|
left: -2.5rem;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
width: 2.5rem;
|
width: 2.5rem;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.ped-leaf {
|
.ped-leaf {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -90,7 +95,7 @@ h3,
|
|||||||
left: 0;
|
left: 0;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
width: 1.5rem;
|
width: 1.5rem;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.ped-leaf::after {
|
.ped-leaf::after {
|
||||||
content: "";
|
content: "";
|
||||||
@@ -98,7 +103,7 @@ h3,
|
|||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.ped-leaf:first-child::after {
|
.ped-leaf:first-child::after {
|
||||||
top: 50%;
|
top: 50%;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,9 +18,16 @@ export const metadata: Metadata = {
|
|||||||
icons: { icon: "/favicon.svg" },
|
icons: { icon: "/favicon.svg" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sets the theme class before first paint to avoid a flash; reads the saved
|
||||||
|
// choice ("light"/"dark"/"system") or falls back to the OS preference.
|
||||||
|
const themeScript = `(function(){try{var t=localStorage.getItem("theme");var d=t==="dark"||((!t||t==="system")&&window.matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.classList.toggle("dark",d);}catch(e){}})();`;
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={`${serif.variable} ${sans.variable}`}>
|
<html lang="en" className={`${serif.variable} ${sans.variable}`} suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
||||||
|
</head>
|
||||||
<body className="min-h-screen antialiased">{children}</body>
|
<body className="min-h-screen antialiased">{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api/client";
|
||||||
|
import type { components } from "@/lib/api/schema";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { PublicTreeChart } from "@/components/public-tree-chart";
|
||||||
|
|
||||||
|
type Person = components["schemas"]["PersonRead"];
|
||||||
|
type Event = components["schemas"]["EventRead"];
|
||||||
|
type Relationship = components["schemas"]["RelationshipRead"];
|
||||||
|
type Tree = components["schemas"]["PublicTreeRead"];
|
||||||
|
|
||||||
|
// Public, no-login view of a tree. Everything here is already redacted by the
|
||||||
|
// /api/v1/public surface (living people show as "Living person").
|
||||||
|
export default function PublicTreePage() {
|
||||||
|
const { treeId } = useParams<{ treeId: string }>();
|
||||||
|
const router = useRouter();
|
||||||
|
const [tree, setTree] = useState<Tree | null>(null);
|
||||||
|
const [people, setPeople] = useState<Person[]>([]);
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [rels, setRels] = useState<Relationship[]>([]);
|
||||||
|
const [focusId, setFocusId] = useState<string | null>(null);
|
||||||
|
const [status, setStatus] = useState<"loading" | "ready" | "notfound">("loading");
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const t = await api.GET("/api/v1/public/trees/{tree_id}", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!t.data) {
|
||||||
|
setStatus("notfound");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [p, e, r] = await Promise.all([
|
||||||
|
api.GET("/api/v1/public/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
|
||||||
|
api.GET("/api/v1/public/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
|
||||||
|
api.GET("/api/v1/public/trees/{tree_id}/relationships", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
const ppl = p.data ?? [];
|
||||||
|
const home = t.data.home_person_id;
|
||||||
|
const homeId = home && ppl.some((x) => x.id === home) ? home : null;
|
||||||
|
setTree(t.data);
|
||||||
|
setPeople(ppl);
|
||||||
|
setEvents(e.data ?? []);
|
||||||
|
setRels(r.data ?? []);
|
||||||
|
setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null);
|
||||||
|
setStatus("ready");
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [treeId]);
|
||||||
|
|
||||||
|
const years = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
const yr = (e: Event) => (e.date_start ? e.date_start.slice(0, 4) : e.date_value ?? "");
|
||||||
|
for (const p of people) {
|
||||||
|
const b = events.find((e) => e.person_id === p.id && e.event_type === "birth");
|
||||||
|
const d = events.find((e) => e.person_id === p.id && e.event_type === "death");
|
||||||
|
const parts = [b ? yr(b) : "", d ? yr(d) : ""];
|
||||||
|
if (parts[0] || parts[1]) m.set(p.id, `${parts[0]}–${parts[1]}`.replace(/^–$/, ""));
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [people, events]);
|
||||||
|
|
||||||
|
const shown = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
const sorted = [...people].sort((a, b) =>
|
||||||
|
(a.primary_name ?? "").localeCompare(b.primary_name ?? ""),
|
||||||
|
);
|
||||||
|
return (q ? sorted.filter((p) => (p.primary_name ?? "").toLowerCase().includes(q)) : sorted).slice(
|
||||||
|
0,
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
}, [people, search]);
|
||||||
|
|
||||||
|
if (status === "loading") return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
|
if (status === "notfound")
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-2xl font-semibold">Not available</h1>
|
||||||
|
<p className="text-[var(--muted)]">
|
||||||
|
This tree isn’t public, or the link is wrong.{" "}
|
||||||
|
<Link href="/login" className="text-bronze hover:underline">
|
||||||
|
Sign in
|
||||||
|
</Link>{" "}
|
||||||
|
if it’s yours.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="mx-auto w-full max-w-5xl">
|
||||||
|
<h1 className="font-serif text-3xl font-semibold">{tree?.name}</h1>
|
||||||
|
{tree?.description && <p className="mt-1 text-[var(--muted)]">{tree.description}</p>}
|
||||||
|
<p className="mt-1 text-sm text-[var(--muted)]">
|
||||||
|
{people.length} {people.length === 1 ? "person" : "people"} · living people are hidden
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart spans the full canvas (the layout removes max-width for /p/<id>). */}
|
||||||
|
{focusId && people.length > 0 && (
|
||||||
|
<PublicTreeChart
|
||||||
|
people={people}
|
||||||
|
rels={rels}
|
||||||
|
events={events}
|
||||||
|
focusId={focusId}
|
||||||
|
onFocus={setFocusId}
|
||||||
|
onOpen={(id) => router.push(`/p/${treeId}/persons/${id}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mx-auto w-full max-w-5xl space-y-3">
|
||||||
|
<h2 className="font-serif text-base font-semibold">All people</h2>
|
||||||
|
<Input
|
||||||
|
className="w-72"
|
||||||
|
placeholder="Search people…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mx-auto w-full max-w-5xl overflow-hidden">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{shown.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-sm text-[var(--muted)]">No matches.</div>
|
||||||
|
) : (
|
||||||
|
shown.map((p, i) => (
|
||||||
|
<Link
|
||||||
|
key={p.id}
|
||||||
|
href={`/p/${treeId}/persons/${p.id}`}
|
||||||
|
className={`flex items-center justify-between gap-3 px-4 py-2.5 text-sm transition-colors hover:bg-bronze/[0.05] ${
|
||||||
|
i > 0 ? "border-t border-[var(--border)]" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="truncate font-medium">{p.primary_name ?? "Unnamed"}</span>
|
||||||
|
<span className="shrink-0 text-xs text-[var(--muted)]">{years.get(p.id) ?? ""}</span>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api/client";
|
||||||
|
import type { components } from "@/lib/api/schema";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
type Person = components["schemas"]["PersonRead"];
|
||||||
|
type Name = components["schemas"]["NameRead"];
|
||||||
|
type Event = components["schemas"]["EventRead"];
|
||||||
|
type Relationship = components["schemas"]["RelationshipRead"];
|
||||||
|
|
||||||
|
// Public, no-login person view. The /api/v1/public surface returns only what an
|
||||||
|
// anonymous viewer may see: deceased people in full, living people redacted.
|
||||||
|
export default function PublicPersonPage() {
|
||||||
|
const { treeId, personId } = useParams<{ treeId: string; personId: string }>();
|
||||||
|
const [person, setPerson] = useState<Person | null>(null);
|
||||||
|
const [names, setNames] = useState<Name[]>([]);
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [people, setPeople] = useState<Person[]>([]);
|
||||||
|
const [rels, setRels] = useState<Relationship[]>([]);
|
||||||
|
const [status, setStatus] = useState<"loading" | "ready" | "notfound">("loading");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const pr = await api.GET("/api/v1/public/trees/{tree_id}/persons/{person_id}", {
|
||||||
|
params: { path: { tree_id: treeId, person_id: personId } },
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!pr.data) {
|
||||||
|
setStatus("notfound");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [nm, ev, ppl, rl] = await Promise.all([
|
||||||
|
api.GET("/api/v1/public/trees/{tree_id}/persons/{person_id}/names", {
|
||||||
|
params: { path: { tree_id: treeId, person_id: personId } },
|
||||||
|
}),
|
||||||
|
api.GET("/api/v1/public/trees/{tree_id}/persons/{person_id}/events", {
|
||||||
|
params: { path: { tree_id: treeId, person_id: personId } },
|
||||||
|
}),
|
||||||
|
api.GET("/api/v1/public/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
|
||||||
|
api.GET("/api/v1/public/trees/{tree_id}/relationships", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setPerson(pr.data);
|
||||||
|
setNames(nm.data ?? []);
|
||||||
|
setEvents(ev.data ?? []);
|
||||||
|
setPeople(ppl.data ?? []);
|
||||||
|
setRels(rl.data ?? []);
|
||||||
|
setStatus("ready");
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [treeId, personId]);
|
||||||
|
|
||||||
|
const nameOf = useMemo(
|
||||||
|
() => (id: string) => people.find((p) => p.id === id)?.primary_name ?? "Unknown",
|
||||||
|
[people],
|
||||||
|
);
|
||||||
|
|
||||||
|
const parents = rels.filter((r) => r.type === "parent_child" && r.person_to_id === personId);
|
||||||
|
const children = rels.filter((r) => r.type === "parent_child" && r.person_from_id === personId);
|
||||||
|
const partners = rels.filter(
|
||||||
|
(r) => r.type === "partnership" && (r.person_from_id === personId || r.person_to_id === personId),
|
||||||
|
);
|
||||||
|
const otherEnd = (r: Relationship) =>
|
||||||
|
r.person_from_id === personId ? r.person_to_id : r.person_from_id;
|
||||||
|
|
||||||
|
if (status === "loading") return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
|
if (status === "notfound")
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-2xl font-semibold">Not available</h1>
|
||||||
|
<p className="text-[var(--muted)]">This person isn’t publicly visible.</p>
|
||||||
|
<Link href={`/p/${treeId}`} className="text-sm text-bronze hover:underline">
|
||||||
|
← Back to the tree
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const relGroup = (label: string, items: Relationship[]) =>
|
||||||
|
items.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-bronze">{label}</h3>
|
||||||
|
<ul className="mt-1 space-y-1">
|
||||||
|
{items.map((r) => (
|
||||||
|
<li key={r.id} className="text-sm">
|
||||||
|
<Link
|
||||||
|
href={`/p/${treeId}/persons/${otherEnd(r)}`}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{nameOf(otherEnd(r))}
|
||||||
|
{r.qualifier ? <span className="text-[var(--muted)]"> · {r.qualifier}</span> : null}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link href={`/p/${treeId}`} className="text-sm text-[var(--muted)] hover:underline">
|
||||||
|
← Back to the tree
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="font-serif text-3xl font-semibold">{person?.primary_name ?? "Unnamed"}</h1>
|
||||||
|
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
{events.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-2 p-5">
|
||||||
|
<h2 className="font-serif text-base font-semibold">Events</h2>
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
{events.map((e) => (
|
||||||
|
<li key={e.id} className="flex justify-between gap-3">
|
||||||
|
<span className="capitalize">{e.event_type}</span>
|
||||||
|
<span className="text-[var(--muted)]">
|
||||||
|
{e.date_value ?? e.date_start ?? ""}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{names.length > 1 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="space-y-2 p-5">
|
||||||
|
<h2 className="font-serif text-base font-semibold">Names</h2>
|
||||||
|
<ul className="space-y-1 text-sm">
|
||||||
|
{names.map((n) => (
|
||||||
|
<li key={n.id} className="flex justify-between gap-3">
|
||||||
|
<span>{[n.given, n.surname].filter(Boolean).join(" ") || "—"}</span>
|
||||||
|
<span className="text-[var(--muted)]">{n.name_type}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(parents.length > 0 || children.length > 0 || partners.length > 0) && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="grid gap-4 p-5 sm:grid-cols-3">
|
||||||
|
{relGroup("Parents", parents)}
|
||||||
|
{relGroup("Partners", partners)}
|
||||||
|
{relGroup("Children", children)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import { PublicHeader } from "@/components/public-header";
|
||||||
|
|
||||||
|
// Public viewing surface — no auth, no app sidebar. The tree page (/p/<id>)
|
||||||
|
// wants the whole canvas like the member tree view; the person detail page
|
||||||
|
// reads better in a centered column.
|
||||||
|
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const fullWidth = /^\/p\/[^/]+$/.test(pathname);
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<PublicHeader />
|
||||||
|
<main className={fullWidth ? "w-full px-4 py-6 md:px-6" : "mx-auto max-w-5xl px-4 py-8"}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@ export default function Home() {
|
|||||||
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
|
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-5 text-sm">
|
<nav className="flex items-center gap-5 text-sm">
|
||||||
|
<Link href="/explore" className="text-[var(--muted)] hover:text-[var(--foreground)]">
|
||||||
|
Explore
|
||||||
|
</Link>
|
||||||
<Link href="/trees" className="text-[var(--muted)] hover:text-[var(--foreground)]">
|
<Link href="/trees" className="text-[var(--muted)] hover:text-[var(--foreground)]">
|
||||||
Trees
|
Trees
|
||||||
</Link>
|
</Link>
|
||||||
@@ -66,6 +69,11 @@ export default function Home() {
|
|||||||
Sign in
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/explore">
|
||||||
|
<Button size="lg" variant="ghost">
|
||||||
|
Explore public trees
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
// Allow crawlers on the public surface; keep the authenticated app out of the
|
||||||
|
// index. (Per-tree noindex for `unlisted`/`site_members` pages needs server
|
||||||
|
// rendering — tracked as a follow-up; those trees aren't linked or listed, so
|
||||||
|
// they aren't discoverable by crawl in the meantime.)
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: {
|
||||||
|
userAgent: "*",
|
||||||
|
allow: ["/", "/p/"],
|
||||||
|
disallow: ["/trees", "/settings", "/import", "/login", "/register"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,229 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
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 router = useRouter();
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Data export / restore / delete.
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [restoreMsg, setRestoreMsg] = useState<string | null>(null);
|
||||||
|
const [restoring, setRestoring] = useState(false);
|
||||||
|
const restoreRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState("");
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.GET("/api/v1/users/me").then((r) => setMe(r.data ?? null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function exportData() {
|
||||||
|
setExporting(true);
|
||||||
|
const resp = await fetch("/api/v1/users/me/export", { credentials: "include" });
|
||||||
|
if (resp.ok) {
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "provenance-export.zip";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreData(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (restoreRef.current) restoreRef.current.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
setRestoring(true);
|
||||||
|
setRestoreMsg(null);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const resp = await fetch("/api/v1/users/me/import", {
|
||||||
|
method: "POST",
|
||||||
|
body: fd,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
setRestoring(false);
|
||||||
|
if (resp.ok) {
|
||||||
|
const c = await resp.json();
|
||||||
|
setRestoreMsg(`Restored ${c.trees} tree(s), ${c.persons} people into new trees.`);
|
||||||
|
} else {
|
||||||
|
setRestoreMsg("That doesn't look like a valid Provenance export.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAccount() {
|
||||||
|
if (deleteConfirm !== me?.email) return;
|
||||||
|
setDeleting(true);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("confirm_email", deleteConfirm);
|
||||||
|
const resp = await fetch("/api/v1/users/me", {
|
||||||
|
method: "DELETE",
|
||||||
|
body: fd,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
setDeleting(false);
|
||||||
|
if (resp.ok) {
|
||||||
|
await api.POST("/api/v1/auth/logout");
|
||||||
|
router.push("/register");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-[var(--muted)]">
|
||||||
|
Download a complete backup of every tree you own — people, sources, media, and
|
||||||
|
all — as a zip.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={exportData} disabled={exporting}>
|
||||||
|
{exporting ? "Preparing…" : "Export all my data"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 border-t border-[var(--border)] pt-4">
|
||||||
|
<p className="text-sm text-[var(--muted)]">
|
||||||
|
Restore a backup. It’s imported into <strong>new</strong> trees — nothing existing
|
||||||
|
is touched or overwritten.
|
||||||
|
</p>
|
||||||
|
<input ref={restoreRef} type="file" accept=".zip" onChange={restoreData} className="hidden" />
|
||||||
|
<Button variant="outline" onClick={() => restoreRef.current?.click()} disabled={restoring}>
|
||||||
|
{restoring ? "Restoring…" : "Restore from backup"}
|
||||||
|
</Button>
|
||||||
|
{restoreMsg && <p className="text-sm text-bronze">{restoreMsg}</p>}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-red-300/60">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base text-red-700">Delete account</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<p className="text-sm text-[var(--muted)]">
|
||||||
|
This deletes your account, the trees you own, and signs you out everywhere. Export
|
||||||
|
your data first if you might want it. Type <strong>{me?.email}</strong> to confirm.
|
||||||
|
</p>
|
||||||
|
<div className="flex max-w-sm flex-col gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="your email"
|
||||||
|
value={deleteConfirm}
|
||||||
|
onChange={(e) => setDeleteConfirm(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="bg-red-600 text-white hover:bg-red-700 hover:text-white"
|
||||||
|
onClick={deleteAccount}
|
||||||
|
disabled={deleting || deleteConfirm !== me?.email}
|
||||||
|
>
|
||||||
|
{deleting ? "Deleting…" : "Permanently delete my account"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
|
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 Deceased = components["schemas"]["DeceasedCandidate"];
|
||||||
|
type GenderProp = components["schemas"]["GenderProposal"];
|
||||||
|
type NameIssue = components["schemas"]["NameIssue"];
|
||||||
|
type Person = components["schemas"]["PersonRead"];
|
||||||
|
|
||||||
|
const ISSUE_LABEL: Record<string, string> = {
|
||||||
|
date_in_surname: "date in surname",
|
||||||
|
date_in_given: "date in given name",
|
||||||
|
no_surname: "no surname",
|
||||||
|
packed_given: "long given name",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CleanupPage() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const treeId = params.id;
|
||||||
|
|
||||||
|
// 1) Deceased by birth year
|
||||||
|
const [year, setYear] = useState(1930);
|
||||||
|
const [deceased, setDeceased] = useState<Deceased[] | null>(null);
|
||||||
|
const [decSel, setDecSel] = useState<Set<string>>(new Set());
|
||||||
|
const [decMsg, setDecMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 2) Gender from source GEDCOM
|
||||||
|
const [gender, setGender] = useState<GenderProp[] | null>(null);
|
||||||
|
const [genSel, setGenSel] = useState<Set<string>>(new Set());
|
||||||
|
const [genMsg, setGenMsg] = useState<string | null>(null);
|
||||||
|
const genFile = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// People still missing a sex (manual mop-up)
|
||||||
|
const [unset, setUnset] = useState<Person[] | null>(null);
|
||||||
|
|
||||||
|
// 3) Name issues
|
||||||
|
const [issues, setIssues] = useState<NameIssue[] | null>(null);
|
||||||
|
const [edits, setEdits] = useState<Record<string, { given: string; surname: string; on: boolean }>>({});
|
||||||
|
const [nameMsg, setNameMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function previewDeceased() {
|
||||||
|
setDecMsg(null);
|
||||||
|
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/deceased", {
|
||||||
|
params: { path: { tree_id: treeId }, query: { born_on_or_before: year } },
|
||||||
|
});
|
||||||
|
setDeceased(data ?? []);
|
||||||
|
setDecSel(new Set((data ?? []).map((d) => d.person_id)));
|
||||||
|
}
|
||||||
|
async function applyDeceased() {
|
||||||
|
const ids = [...decSel];
|
||||||
|
const { data } = await api.POST("/api/v1/trees/{tree_id}/cleanup/deceased", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
body: { person_ids: ids },
|
||||||
|
});
|
||||||
|
setDecMsg(`Marked ${data?.updated ?? 0} people deceased.`);
|
||||||
|
setDeceased(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewGender(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (genFile.current) genFile.current.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
setGenMsg(null);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const resp = await fetch(`/api/v1/trees/${treeId}/cleanup/gender/preview`, {
|
||||||
|
method: "POST",
|
||||||
|
body: fd,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
const data: GenderProp[] = await resp.json();
|
||||||
|
setGender(data);
|
||||||
|
setGenSel(new Set(data.map((g) => g.person_id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const loadUnset = useCallback(async () => {
|
||||||
|
const { data } = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
});
|
||||||
|
setUnset(
|
||||||
|
(data ?? [])
|
||||||
|
.filter((p) => !p.gender)
|
||||||
|
.sort((a, b) => (a.primary_name ?? "").localeCompare(b.primary_name ?? "")),
|
||||||
|
);
|
||||||
|
}, [treeId]);
|
||||||
|
|
||||||
|
async function setSex(personId: string, gender: "male" | "female") {
|
||||||
|
await api.PATCH("/api/v1/trees/{tree_id}/persons/{person_id}", {
|
||||||
|
params: { path: { tree_id: treeId, person_id: personId } },
|
||||||
|
body: { gender },
|
||||||
|
});
|
||||||
|
setUnset((prev) => (prev ? prev.filter((p) => p.id !== personId) : prev));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guessGender() {
|
||||||
|
setGenMsg(null);
|
||||||
|
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/gender/guess", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
});
|
||||||
|
setGender(data ?? []);
|
||||||
|
setGenSel(new Set((data ?? []).map((g) => g.person_id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function guessGenderFromSpouse() {
|
||||||
|
setGenMsg(null);
|
||||||
|
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/gender/from-spouse", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
});
|
||||||
|
setGender(data ?? []);
|
||||||
|
setGenSel(new Set((data ?? []).map((g) => g.person_id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyGender() {
|
||||||
|
const updates = (gender ?? [])
|
||||||
|
.filter((g) => genSel.has(g.person_id))
|
||||||
|
.map((g) => ({ person_id: g.person_id, gender: g.proposed_gender }));
|
||||||
|
const { data } = await api.POST("/api/v1/trees/{tree_id}/cleanup/gender", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
body: { updates },
|
||||||
|
});
|
||||||
|
setGenMsg(`Set gender on ${data?.updated ?? 0} people.`);
|
||||||
|
setGender(null);
|
||||||
|
loadUnset();
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadNames = useCallback(async () => {
|
||||||
|
setNameMsg(null);
|
||||||
|
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/names", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
});
|
||||||
|
setIssues(data ?? []);
|
||||||
|
const init: Record<string, { given: string; surname: string; on: boolean }> = {};
|
||||||
|
for (const i of data ?? []) {
|
||||||
|
init[i.name_id] = { given: i.given ?? "", surname: i.surname ?? "", on: false };
|
||||||
|
}
|
||||||
|
setEdits(init);
|
||||||
|
}, [treeId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadNames();
|
||||||
|
loadUnset();
|
||||||
|
}, [loadNames, loadUnset]);
|
||||||
|
|
||||||
|
async function applyNames() {
|
||||||
|
const chosen = (issues ?? []).filter((i) => edits[i.name_id]?.on);
|
||||||
|
const body = {
|
||||||
|
edits: chosen.map((i) => ({
|
||||||
|
name_id: i.name_id,
|
||||||
|
given: edits[i.name_id].given,
|
||||||
|
surname: edits[i.name_id].surname,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
if (!body.edits.length) return;
|
||||||
|
const { data } = await api.POST("/api/v1/trees/{tree_id}/cleanup/names", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
setNameMsg(`Fixed ${data?.updated ?? 0} names.`);
|
||||||
|
loadNames();
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = (set: Set<string>, id: string, setter: (s: Set<string>) => void) => {
|
||||||
|
const n = new Set(set);
|
||||||
|
if (n.has(id)) n.delete(id);
|
||||||
|
else n.add(id);
|
||||||
|
setter(n);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Cleanup</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--muted)]">
|
||||||
|
Fix common import messes in bulk. Each tool previews its changes — nothing is saved
|
||||||
|
until you apply.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 1) Deceased by year */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Mark deceased by birth year</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<label className="flex flex-col gap-1 text-sm">
|
||||||
|
<span className="text-xs text-[var(--muted)]">Born on or before</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="w-28"
|
||||||
|
value={year}
|
||||||
|
onChange={(e) => setYear(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button variant="outline" onClick={previewDeceased}>
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{decMsg && <p className="text-sm text-bronze">{decMsg}</p>}
|
||||||
|
{deceased && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-[var(--muted)]">
|
||||||
|
{deceased.length} people born ≤ {year} (not already marked deceased).
|
||||||
|
</p>
|
||||||
|
<ul className="max-h-64 divide-y divide-[var(--border)] overflow-auto rounded-lg border border-[var(--border)]">
|
||||||
|
{deceased.map((d) => (
|
||||||
|
<li key={d.person_id} className="flex items-center gap-3 px-3 py-1.5 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={decSel.has(d.person_id)}
|
||||||
|
onChange={() => toggle(decSel, d.person_id, setDecSel)}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">{d.name}</span>
|
||||||
|
<span className="text-xs text-[var(--muted)]">b. {d.birth_year}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{deceased.length > 0 && (
|
||||||
|
<Button onClick={applyDeceased}>Mark {decSel.size} deceased</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 2) Gender from source */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Set sex from a source GEDCOM</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<p className="text-sm text-[var(--muted)]">
|
||||||
|
Upload your source <code>.ged</code> (it carries each person’s sex). We match by
|
||||||
|
name and propose sex only for people who don’t have it set.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={genFile}
|
||||||
|
type="file"
|
||||||
|
accept=".ged,.gedcom,text/plain"
|
||||||
|
onChange={previewGender}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" onClick={() => genFile.current?.click()}>
|
||||||
|
Choose source GEDCOM
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={guessGender}>
|
||||||
|
Guess from first name
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={guessGenderFromSpouse}>
|
||||||
|
Infer from spouse
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--muted)]">
|
||||||
|
“Guess from first name” uses a built-in name dictionary for people with no sex set.
|
||||||
|
“Infer from spouse” sets the opposite sex for an unset partner of someone whose sex is
|
||||||
|
known (e.g. a confirmed-male husband ⇒ a female wife) — review before applying, since
|
||||||
|
it assumes opposite-sex couples.
|
||||||
|
</p>
|
||||||
|
{genMsg && <p className="text-sm text-bronze">{genMsg}</p>}
|
||||||
|
{gender && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-[var(--muted)]">{gender.length} matches with a sex to set.</p>
|
||||||
|
<ul className="max-h-64 divide-y divide-[var(--border)] overflow-auto rounded-lg border border-[var(--border)]">
|
||||||
|
{gender.map((g) => (
|
||||||
|
<li key={g.person_id} className="flex items-center gap-3 px-3 py-1.5 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={genSel.has(g.person_id)}
|
||||||
|
onChange={() => toggle(genSel, g.person_id, setGenSel)}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">{g.name}</span>
|
||||||
|
<span
|
||||||
|
className="text-xs"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
g.proposed_gender === "male"
|
||||||
|
? "rgb(120,159,172)"
|
||||||
|
: "rgb(196,138,146)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g.proposed_gender}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{gender.length > 0 && (
|
||||||
|
<Button onClick={applyGender}>Set sex on {genSel.size} people</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* People still missing a sex */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
People with no sex set{unset ? ` (${unset.length})` : ""}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{unset === null ? (
|
||||||
|
<p className="text-sm text-[var(--muted)]">Loading…</p>
|
||||||
|
) : unset.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--muted)]">Everyone has a sex set. 🎉</p>
|
||||||
|
) : (
|
||||||
|
<ul className="max-h-80 divide-y divide-[var(--border)] overflow-auto rounded-lg border border-[var(--border)]">
|
||||||
|
{unset.map((p) => (
|
||||||
|
<li key={p.id} className="flex items-center gap-3 px-3 py-1.5 text-sm">
|
||||||
|
<a
|
||||||
|
href={`/trees/${treeId}/persons/${p.id}`}
|
||||||
|
className="flex-1 truncate hover:underline"
|
||||||
|
>
|
||||||
|
{p.primary_name ?? "Unnamed"}
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => setSex(p.id, "male")}
|
||||||
|
className="rounded px-2 py-0.5 text-xs"
|
||||||
|
style={{ color: "rgb(120,159,172)", border: "1px solid rgb(120,159,172)" }}
|
||||||
|
>
|
||||||
|
♂ Male
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSex(p.id, "female")}
|
||||||
|
className="rounded px-2 py-0.5 text-xs"
|
||||||
|
style={{ color: "rgb(196,138,146)", border: "1px solid rgb(196,138,146)" }}
|
||||||
|
>
|
||||||
|
♀ Female
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 3) Name issues */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Names that look broken</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{nameMsg && <p className="text-sm text-bronze">{nameMsg}</p>}
|
||||||
|
{issues === null ? (
|
||||||
|
<p className="text-sm text-[var(--muted)]">Scanning…</p>
|
||||||
|
) : issues.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--muted)]">No obvious name problems found.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-[var(--muted)]">
|
||||||
|
{issues.length} flagged. Edit given/surname, tick the ones to fix, then apply.
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{issues.map((i) => {
|
||||||
|
const e = edits[i.name_id] ?? { given: "", surname: "", on: false };
|
||||||
|
return (
|
||||||
|
<li key={i.name_id} className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={e.on}
|
||||||
|
onChange={() =>
|
||||||
|
setEdits((p) => ({ ...p, [i.name_id]: { ...e, on: !e.on } }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="h-9 w-40"
|
||||||
|
placeholder="Given"
|
||||||
|
value={e.given}
|
||||||
|
onChange={(ev) =>
|
||||||
|
setEdits((p) => ({ ...p, [i.name_id]: { ...e, given: ev.target.value } }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="h-9 w-40"
|
||||||
|
placeholder="Surname"
|
||||||
|
value={e.surname}
|
||||||
|
onChange={(ev) =>
|
||||||
|
setEdits((p) => ({
|
||||||
|
...p,
|
||||||
|
[i.name_id]: { ...e, surname: ev.target.value },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="rounded bg-[var(--border)]/50 px-1.5 py-0.5 text-xs text-[var(--muted)]">
|
||||||
|
{ISSUE_LABEL[i.issue] ?? i.issue}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<Button onClick={applyNames}>Fix selected</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,65 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useRef, useState } from "react";
|
|
||||||
|
|
||||||
import { api } from "@/lib/api/client";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { GedcomImport } from "@/components/gedcom-import";
|
||||||
|
|
||||||
type Report = { counts: Record<string, number>; unmapped_tags: string[] };
|
export default function TreeGedcomPage() {
|
||||||
|
|
||||||
export default function GedcomPage() {
|
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const treeId = params.id;
|
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() {
|
async function exportGed() {
|
||||||
const resp = await fetch(`/api/v1/trees/${treeId}/gedcom/export`, {
|
const resp = await fetch(`/api/v1/trees/${treeId}/gedcom/export`, { credentials: "include" });
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const blob = await resp.blob();
|
const blob = await resp.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -74,76 +26,7 @@ export default function GedcomPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-semibold">Import & export GEDCOM</h1>
|
<h1 className="text-2xl font-semibold">Import & export GEDCOM</h1>
|
||||||
|
|
||||||
<Card>
|
<GedcomImport fixedTreeId={treeId} />
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-base">Import a GEDCOM file</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<label className="flex items-center gap-2 text-sm">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="target"
|
|
||||||
checked={target === "new"}
|
|
||||||
onChange={() => setTarget("new")}
|
|
||||||
/>
|
|
||||||
Import into a <strong>new tree</strong> (recommended)
|
|
||||||
</label>
|
|
||||||
{target === "new" && (
|
|
||||||
<Input
|
|
||||||
className="max-w-xs"
|
|
||||||
placeholder="New tree name"
|
|
||||||
value={newName}
|
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<label className="flex items-center gap-2 text-sm">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="target"
|
|
||||||
checked={target === "this"}
|
|
||||||
onChange={() => setTarget("this")}
|
|
||||||
/>
|
|
||||||
Import into <strong>this tree</strong> (appends)
|
|
||||||
</label>
|
|
||||||
{target === "this" && (
|
|
||||||
<p className="rounded-md bg-bronze/[0.08] px-3 py-2 text-sm text-[var(--muted)]">
|
|
||||||
Importing appends everyone in the file as new records — it does not merge with
|
|
||||||
people already in this tree, so duplicates are possible.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<input ref={fileRef} type="file" accept=".ged,.gedcom,text/plain" onChange={onFile} className="hidden" />
|
|
||||||
<Button onClick={() => fileRef.current?.click()} disabled={busy}>
|
|
||||||
{busy ? "Importing…" : "Choose GEDCOM file"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{report && (
|
|
||||||
<div className="space-y-3 rounded-lg border border-[var(--border)] p-4">
|
|
||||||
<div className="font-medium">Import complete</div>
|
|
||||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-[var(--muted)]">
|
|
||||||
{Object.entries(report.counts).map(([k, v]) => (
|
|
||||||
<span key={k}>
|
|
||||||
<span className="font-medium text-[var(--foreground)]">{v}</span> {k}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{report.unmapped_tags.length > 0 && (
|
|
||||||
<div className="text-xs text-[var(--muted)]">
|
|
||||||
Unmapped tags (skipped): {report.unmapped_tags.join(", ")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{importedTreeId && (
|
|
||||||
<Link
|
|
||||||
href={`/trees/${importedTreeId}`}
|
|
||||||
className="inline-block text-sm text-bronze hover:underline"
|
|
||||||
>
|
|
||||||
Open the imported tree →
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
type Media = components["schemas"]["MediaRead"];
|
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) {
|
function humanSize(bytes: number) {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
@@ -22,6 +25,7 @@ export default function MediaPage() {
|
|||||||
const treeId = params.id;
|
const treeId = params.id;
|
||||||
|
|
||||||
const [items, setItems] = useState<Media[]>([]);
|
const [items, setItems] = useState<Media[]>([]);
|
||||||
|
const [people, setPeople] = useState<Person[]>([]);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -34,10 +38,22 @@ export default function MediaPage() {
|
|||||||
router.push("/login");
|
router.push("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const ppl = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
});
|
||||||
setItems(data ?? []);
|
setItems(data ?? []);
|
||||||
|
setPeople(ppl.data ?? []);
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}, [router, treeId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
@@ -124,6 +140,19 @@ export default function MediaPage() {
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api/client";
|
||||||
|
import type { components } from "@/lib/api/schema";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
type Member = components["schemas"]["MembershipRead"];
|
||||||
|
type Role = components["schemas"]["MembershipRole"];
|
||||||
|
|
||||||
|
const ROLES: Role[] = ["owner", "editor", "viewer"];
|
||||||
|
|
||||||
|
export default function MembersPage() {
|
||||||
|
const { id: treeId } = useParams<{ id: string }>();
|
||||||
|
const [members, setMembers] = useState<Member[]>([]);
|
||||||
|
const [meId, setMeId] = useState<string | null>(null);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [role, setRole] = useState<Role>("viewer");
|
||||||
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const [me, mem] = await Promise.all([
|
||||||
|
api.GET("/api/v1/users/me"),
|
||||||
|
api.GET("/api/v1/trees/{tree_id}/members", { params: { path: { tree_id: treeId } } }),
|
||||||
|
]);
|
||||||
|
setMeId(me.data?.id ?? null);
|
||||||
|
setMembers(mem.data ?? []);
|
||||||
|
setReady(true);
|
||||||
|
}, [treeId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const myRole = members.find((m) => m.user_id === meId)?.role;
|
||||||
|
const isOwner = myRole === "owner";
|
||||||
|
|
||||||
|
async function addMember(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setMsg(null);
|
||||||
|
const { error, response } = await api.POST("/api/v1/trees/{tree_id}/members", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
body: { email: email.trim(), role },
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
setMsg(
|
||||||
|
response.status === 404
|
||||||
|
? "No account on this site has that email — they need to register first."
|
||||||
|
: response.status === 409
|
||||||
|
? "That person is already a member."
|
||||||
|
: "Couldn't add that member.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEmail("");
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeRole(membershipId: string, newRole: Role) {
|
||||||
|
const { error } = await api.PATCH("/api/v1/trees/{tree_id}/members/{membership_id}", {
|
||||||
|
params: { path: { tree_id: treeId, membership_id: membershipId } },
|
||||||
|
body: { role: newRole },
|
||||||
|
});
|
||||||
|
if (error) setMsg("Couldn't change role (a tree must keep at least one owner).");
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(membershipId: string) {
|
||||||
|
const { error } = await api.DELETE("/api/v1/trees/{tree_id}/members/{membership_id}", {
|
||||||
|
params: { path: { tree_id: treeId, membership_id: membershipId } },
|
||||||
|
});
|
||||||
|
if (error) setMsg("Couldn't remove (a tree must keep at least one owner).");
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Members</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--muted)]">
|
||||||
|
Who can see and edit this tree. {isOwner ? "You're the owner." : `Your role: ${myRole}.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOwner && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-5">
|
||||||
|
<form onSubmit={addMember} className="flex flex-wrap items-end gap-2">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-[var(--muted)]">Add by email (must have an account)</span>
|
||||||
|
<Input
|
||||||
|
className="w-72"
|
||||||
|
type="email"
|
||||||
|
placeholder="person@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm"
|
||||||
|
value={role}
|
||||||
|
onChange={(e) => setRole(e.target.value as Role)}
|
||||||
|
>
|
||||||
|
<option value="viewer">Viewer</option>
|
||||||
|
<option value="editor">Editor</option>
|
||||||
|
<option value="owner">Owner</option>
|
||||||
|
</select>
|
||||||
|
<Button type="submit" disabled={!email.trim()}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{msg && <p className="mt-2 text-sm text-bronze">{msg}</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{members.map((m, i) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className={`flex flex-wrap items-center justify-between gap-3 px-4 py-3 ${
|
||||||
|
i > 0 ? "border-t border-[var(--border)]" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-medium">{m.display_name || m.email}</div>
|
||||||
|
<div className="truncate text-xs text-[var(--muted)]">{m.email}</div>
|
||||||
|
</div>
|
||||||
|
{isOwner ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
className="h-8 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm capitalize"
|
||||||
|
value={m.role}
|
||||||
|
onChange={(e) => changeRole(m.id, e.target.value as Role)}
|
||||||
|
>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{r}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => remove(m.id)}
|
||||||
|
className="text-[var(--muted)] hover:text-bronze"
|
||||||
|
aria-label="Remove member"
|
||||||
|
title="Remove member"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm capitalize text-[var(--muted)]">{m.role}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } 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 { api } from "@/lib/api/client";
|
||||||
import type { components } from "@/lib/api/schema";
|
import type { components } from "@/lib/api/schema";
|
||||||
@@ -26,7 +26,11 @@ type AddKind = "parent" | "child" | "partner";
|
|||||||
export default function FamilyViewPage() {
|
export default function FamilyViewPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const treeId = params.id;
|
const treeId = params.id;
|
||||||
|
// ?focus=… lets another view (or a person page) hand us who to center on.
|
||||||
|
// Read once at mount so the focus→URL sync below doesn't trigger a refetch.
|
||||||
|
const initialFocus = useRef<string | null>(searchParams.get("focus"));
|
||||||
|
|
||||||
const [people, setPeople] = useState<Person[]>([]);
|
const [people, setPeople] = useState<Person[]>([]);
|
||||||
const [rels, setRels] = useState<Relationship[]>([]);
|
const [rels, setRels] = useState<Relationship[]>([]);
|
||||||
@@ -50,15 +54,21 @@ export default function FamilyViewPage() {
|
|||||||
router.push("/login");
|
router.push("/login");
|
||||||
return;
|
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}/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}/events", { params: { path: { tree_id: treeId } } }),
|
||||||
|
api.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } }),
|
||||||
]);
|
]);
|
||||||
const ppl = p.data ?? [];
|
const ppl = p.data ?? [];
|
||||||
|
const home = t.data?.home_person_id ?? null;
|
||||||
|
const homeId = home && ppl.some((x) => x.id === home) ? home : null;
|
||||||
|
const fromUrl = initialFocus.current && ppl.some((x) => x.id === initialFocus.current)
|
||||||
|
? initialFocus.current
|
||||||
|
: null;
|
||||||
setPeople(ppl);
|
setPeople(ppl);
|
||||||
setRels(r.data ?? []);
|
setRels(r.data ?? []);
|
||||||
setEvents(e.data ?? []);
|
setEvents(e.data ?? []);
|
||||||
setFocusId((cur) => cur ?? ppl[0]?.id ?? null);
|
setFocusId((cur) => cur ?? fromUrl ?? homeId ?? ppl[0]?.id ?? null);
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}, [router, treeId]);
|
}, [router, treeId]);
|
||||||
|
|
||||||
@@ -66,6 +76,16 @@ export default function FamilyViewPage() {
|
|||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
// Keep the focused person in the URL (?focus=…) so leaving and returning —
|
||||||
|
// e.g. opening a person then coming back — lands on the same person rather
|
||||||
|
// than resetting to the home person. `replace` keeps history clean.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusId || searchParams.get("focus") === focusId) return;
|
||||||
|
const sp = new URLSearchParams(searchParams.toString());
|
||||||
|
sp.set("focus", focusId);
|
||||||
|
router.replace(`/trees/${treeId}?${sp.toString()}`, { scroll: false });
|
||||||
|
}, [focusId, searchParams, router, treeId]);
|
||||||
|
|
||||||
// Debounced server-side fuzzy search (pg_trgm) across the whole tree.
|
// Debounced server-side fuzzy search (pg_trgm) across the whole tree.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = search.trim();
|
const q = search.trim();
|
||||||
@@ -83,8 +103,18 @@ export default function FamilyViewPage() {
|
|||||||
}, [search, treeId]);
|
}, [search, treeId]);
|
||||||
|
|
||||||
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
|
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) =>
|
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) =>
|
const childrenOf = (id: string) =>
|
||||||
rels.filter((r) => r.type === "parent_child" && r.person_from_id === id).map((r) => r.person_to_id);
|
rels.filter((r) => r.type === "parent_child" && r.person_from_id === id).map((r) => r.person_to_id);
|
||||||
const partnersOf = (id: string) =>
|
const partnersOf = (id: string) =>
|
||||||
@@ -113,6 +143,12 @@ export default function FamilyViewPage() {
|
|||||||
return data?.id ?? null;
|
return data?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a new (blank) person and open their page to fill in details.
|
||||||
|
async function newPersonAndGo() {
|
||||||
|
const id = await addPerson("");
|
||||||
|
if (id) router.push(`/trees/${treeId}/persons/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function createFirst(e: React.FormEvent) {
|
async function createFirst(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!firstName.trim()) return;
|
if (!firstName.trim()) return;
|
||||||
@@ -317,6 +353,17 @@ export default function FamilyViewPage() {
|
|||||||
const partners = partnersOf(focus.id);
|
const partners = partnersOf(focus.id);
|
||||||
const children = childrenOf(focus.id);
|
const children = childrenOf(focus.id);
|
||||||
|
|
||||||
|
// "Dangling" people: not linked to anyone. Common after a GEDCOM import or a
|
||||||
|
// mistaken delete — surface them so they're not lost in the directory.
|
||||||
|
const connected = new Set<string>();
|
||||||
|
for (const r of rels) {
|
||||||
|
connected.add(r.person_from_id);
|
||||||
|
connected.add(r.person_to_id);
|
||||||
|
}
|
||||||
|
const unconnected = people
|
||||||
|
.filter((p) => !connected.has(p.id))
|
||||||
|
.sort((a, b) => (a.primary_name ?? "").localeCompare(b.primary_name ?? ""));
|
||||||
|
|
||||||
const sorted = [...people].sort((a, b) =>
|
const sorted = [...people].sort((a, b) =>
|
||||||
(a.primary_name ?? "").localeCompare(b.primary_name ?? ""),
|
(a.primary_name ?? "").localeCompare(b.primary_name ?? ""),
|
||||||
);
|
);
|
||||||
@@ -328,12 +375,17 @@ export default function FamilyViewPage() {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h1 className="text-2xl font-semibold">Family view</h1>
|
<h1 className="text-2xl font-semibold">Family view</h1>
|
||||||
<Link
|
<div className="flex items-center gap-3">
|
||||||
href={`/trees/${treeId}/persons/${focus.id}`}
|
<Button size="sm" onClick={newPersonAndGo}>
|
||||||
className="text-sm text-bronze hover:underline"
|
+ Add person
|
||||||
>
|
</Button>
|
||||||
Open {focus.primary_name ?? "person"} →
|
<Link
|
||||||
</Link>
|
href={`/trees/${treeId}/persons/${focus.id}?from=people`}
|
||||||
|
className="text-sm text-bronze hover:underline"
|
||||||
|
>
|
||||||
|
Open {focus.primary_name ?? "person"} →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pedigree: focus → parents → grandparents, with bracket connectors */}
|
{/* Pedigree: focus → parents → grandparents, with bracket connectors */}
|
||||||
@@ -380,6 +432,40 @@ export default function FamilyViewPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Unconnected people — not linked to anyone in the tree */}
|
||||||
|
{unconnected.length > 0 && (
|
||||||
|
<Card className="border-bronze/40">
|
||||||
|
<CardContent className="space-y-3 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="font-serif text-base font-semibold">
|
||||||
|
Not connected to anyone ({unconnected.length})
|
||||||
|
</h2>
|
||||||
|
<span className="text-xs text-[var(--muted)]">
|
||||||
|
Open one and add a relationship, or delete it.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{unconnected.slice(0, 60).map((p) => (
|
||||||
|
<div key={p.id} className="flex items-center gap-1">
|
||||||
|
<PersonBox id={p.id} muted />
|
||||||
|
<Link
|
||||||
|
href={`/trees/${treeId}/persons/${p.id}?from=people`}
|
||||||
|
className="text-xs text-bronze hover:underline"
|
||||||
|
>
|
||||||
|
open
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{unconnected.length > 60 && (
|
||||||
|
<p className="text-xs text-[var(--muted)]">
|
||||||
|
Showing 60 of {unconnected.length}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Scrollable, searchable people directory (scales to large trees) */}
|
{/* Scrollable, searchable people directory (scales to large trees) */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } 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 { api } from "@/lib/api/client";
|
||||||
import type { components } from "@/lib/api/schema";
|
import type { components } from "@/lib/api/schema";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { PersonCombobox } from "@/components/person-combobox";
|
||||||
|
|
||||||
type Person = components["schemas"]["PersonRead"];
|
type Person = components["schemas"]["PersonRead"];
|
||||||
|
type Name = components["schemas"]["NameRead"];
|
||||||
|
type Me = components["schemas"]["UserRead"];
|
||||||
type Event = components["schemas"]["EventRead"];
|
type Event = components["schemas"]["EventRead"];
|
||||||
type Relationship = components["schemas"]["RelationshipRead"];
|
type Relationship = components["schemas"]["RelationshipRead"];
|
||||||
type Qualifier = components["schemas"]["ParentChildQualifier"];
|
type Qualifier = components["schemas"]["ParentChildQualifier"];
|
||||||
@@ -22,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 fieldCls = "h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm";
|
||||||
const QUALIFIERS: Qualifier[] = ["biological", "adoptive", "step", "foster", "donor", "guardian"];
|
const QUALIFIERS: Qualifier[] = ["biological", "adoptive", "step", "foster", "donor", "guardian"];
|
||||||
|
|
||||||
|
// Typed name vocabulary. "birth" is the maiden/birth name; "married" etc. are
|
||||||
|
// alternates. The maiden name stays primary by convention (Ancestry/FamilySearch).
|
||||||
|
const NAME_TYPES: { value: string; label: string }[] = [
|
||||||
|
{ value: "birth", label: "Birth / maiden" },
|
||||||
|
{ value: "married", label: "Married" },
|
||||||
|
{ value: "alias", label: "Also known as" },
|
||||||
|
{ value: "nickname", label: "Nickname" },
|
||||||
|
{ value: "religious", label: "Religious" },
|
||||||
|
{ value: "immigration", label: "Anglicized" },
|
||||||
|
];
|
||||||
|
const nameTypeLabel = (t: string) =>
|
||||||
|
NAME_TYPES.find((n) => n.value === t)?.label ?? t;
|
||||||
|
const formatName = (n: Name) =>
|
||||||
|
[n.given, n.surname].filter(Boolean).join(" ") || "—";
|
||||||
|
|
||||||
// Curated genealogical event vocabulary (with an escape hatch).
|
// Curated genealogical event vocabulary (with an escape hatch).
|
||||||
const EVENT_TYPES = [
|
const EVENT_TYPES = [
|
||||||
"birth", "death", "marriage", "divorce", "engagement", "baptism", "burial",
|
"birth", "death", "marriage", "divorce", "engagement", "baptism", "burial",
|
||||||
"residence", "census", "immigration", "emigration", "occupation", "education",
|
"residence", "census", "immigration", "emigration", "occupation", "education",
|
||||||
"military service", "naturalization", "other",
|
"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 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 GED_MON = ["", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
|
||||||
const DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" };
|
const DATE_QUALS: Record<string, string> = { exact: "", about: "ABT", before: "BEF", after: "AFT" };
|
||||||
@@ -83,19 +104,38 @@ function parseDateValue(v: string | null | undefined) {
|
|||||||
export default function PersonDetailPage() {
|
export default function PersonDetailPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams<{ id: string; personId: string }>();
|
const params = useParams<{ id: string; personId: string }>();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const treeId = params.id;
|
const treeId = params.id;
|
||||||
const personId = params.personId;
|
const personId = params.personId;
|
||||||
|
// Where we were opened from, so "back" returns there (centered on this
|
||||||
|
// person) instead of always dumping onto the People view's home person.
|
||||||
|
const from = searchParams.get("from") === "people" ? "people" : "tree";
|
||||||
|
const backHref =
|
||||||
|
from === "people"
|
||||||
|
? `/trees/${treeId}?focus=${personId}`
|
||||||
|
: `/trees/${treeId}/tree?focus=${personId}`;
|
||||||
|
const backLabel = from === "people" ? "← Back to People" : "← Back to Tree";
|
||||||
|
// Carry the origin through person→person links so the chain keeps its anchor.
|
||||||
|
const personHref = (id: string) => `/trees/${treeId}/persons/${id}?from=${from}`;
|
||||||
|
|
||||||
const [person, setPerson] = useState<Person | null>(null);
|
const [person, setPerson] = useState<Person | null>(null);
|
||||||
const [people, setPeople] = useState<Person[]>([]);
|
const [people, setPeople] = useState<Person[]>([]);
|
||||||
|
const [names, setNames] = useState<Name[]>([]);
|
||||||
|
const [me, setMe] = useState<Me | null>(null);
|
||||||
|
const [tree, setTree] = useState<components["schemas"]["TreeRead"] | null>(null);
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [rels, setRels] = useState<Relationship[]>([]);
|
const [rels, setRels] = useState<Relationship[]>([]);
|
||||||
const [sources, setSources] = useState<Source[]>([]);
|
const [sources, setSources] = useState<Source[]>([]);
|
||||||
const [citations, setCitations] = useState<Citation[]>([]);
|
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 [ready, setReady] = useState(false);
|
||||||
|
|
||||||
const [evType, setEvType] = useState("birth");
|
const [evType, setEvType] = useState("birth");
|
||||||
const [evTypeOther, setEvTypeOther] = useState("");
|
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 [dateQual, setDateQual] = useState("exact");
|
||||||
const [dateDay, setDateDay] = useState("");
|
const [dateDay, setDateDay] = useState("");
|
||||||
const [dateMonth, setDateMonth] = useState("");
|
const [dateMonth, setDateMonth] = useState("");
|
||||||
@@ -121,6 +161,19 @@ export default function PersonDetailPage() {
|
|||||||
const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent");
|
const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent");
|
||||||
const [relOther, setRelOther] = useState("");
|
const [relOther, setRelOther] = useState("");
|
||||||
const [relQual, setRelQual] = useState<Qualifier>("biological");
|
const [relQual, setRelQual] = useState<Qualifier>("biological");
|
||||||
|
const [relErr, setRelErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Add-name form + inline edit.
|
||||||
|
const [nameType, setNameType] = useState("married");
|
||||||
|
const [nGiven, setNGiven] = useState("");
|
||||||
|
const [nSurname, setNSurname] = useState("");
|
||||||
|
const [editNameId, setEditNameId] = useState<string | null>(null);
|
||||||
|
const [enType, setEnType] = useState("married");
|
||||||
|
const [enGiven, setEnGiven] = useState("");
|
||||||
|
const [enSurname, setEnSurname] = useState("");
|
||||||
|
|
||||||
|
// Delete confirmation (with optional cascade to descendants).
|
||||||
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||||
|
|
||||||
// Inline citation form: which fact is being cited ("p" = person, `e:<id>`).
|
// Inline citation form: which fact is being cited ("p" = person, `e:<id>`).
|
||||||
const [citeFor, setCiteFor] = useState<string | null>(null);
|
const [citeFor, setCiteFor] = useState<string | null>(null);
|
||||||
@@ -136,8 +189,13 @@ export default function PersonDetailPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPerson(p.data ?? null);
|
setPerson(p.data ?? null);
|
||||||
const [all, ev, rl, src, cit] = await Promise.all([
|
const [all, nm, mine, 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", { 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", {
|
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/events", {
|
||||||
params: { path: { tree_id: treeId, person_id: personId } },
|
params: { path: { tree_id: treeId, person_id: personId } },
|
||||||
}),
|
}),
|
||||||
@@ -146,9 +204,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}/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}/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 ?? []);
|
setPeople(all.data ?? []);
|
||||||
|
setNames(nm.data ?? []);
|
||||||
|
setMe(mine.data ?? null);
|
||||||
|
setTree(tr.data ?? null);
|
||||||
setEvents(ev.data ?? []);
|
setEvents(ev.data ?? []);
|
||||||
|
setAllEvents(evAll.data ?? []);
|
||||||
|
setMedia(med.data ?? []);
|
||||||
setRels(rl.data ?? []);
|
setRels(rl.data ?? []);
|
||||||
setSources(src.data ?? []);
|
setSources(src.data ?? []);
|
||||||
setCitations(cit.data ?? []);
|
setCitations(cit.data ?? []);
|
||||||
@@ -176,6 +241,23 @@ export default function PersonDetailPage() {
|
|||||||
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
|
const eventCites = (id: string) => citations.filter((c) => c.event_id === id);
|
||||||
const personCites = citations.filter((c) => c.person_id === personId);
|
const personCites = citations.filter((c) => c.person_id === personId);
|
||||||
|
|
||||||
|
// 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) {
|
async function addEvent(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const event_type = evType === "other" ? evTypeOther.trim() : evType;
|
const event_type = evType === "other" ? evTypeOther.trim() : evType;
|
||||||
@@ -186,9 +268,47 @@ export default function PersonDetailPage() {
|
|||||||
dateMonth,
|
dateMonth,
|
||||||
dateYear,
|
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", {
|
const { error } = await api.POST("/api/v1/trees/{tree_id}/events", {
|
||||||
params: { path: { tree_id: treeId } },
|
params: { path: { tree_id: treeId } },
|
||||||
body: { event_type, person_id: personId, date_value, date_start, date_precision },
|
body,
|
||||||
});
|
});
|
||||||
if (!error) {
|
if (!error) {
|
||||||
setDateDay("");
|
setDateDay("");
|
||||||
@@ -196,6 +316,7 @@ export default function PersonDetailPage() {
|
|||||||
setDateYear("");
|
setDateYear("");
|
||||||
setDateQual("exact");
|
setDateQual("exact");
|
||||||
setEvTypeOther("");
|
setEvTypeOther("");
|
||||||
|
setEvSpouse("");
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,28 +354,50 @@ export default function PersonDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addRel(e: React.FormEvent) {
|
async function linkRelative(otherId: string): Promise<boolean> {
|
||||||
e.preventDefault();
|
|
||||||
if (!relOther) return;
|
|
||||||
let body: RelCreate;
|
let body: RelCreate;
|
||||||
if (relKind === "parent") {
|
if (relKind === "parent") {
|
||||||
body = { type: "parent_child", person_from_id: relOther, person_to_id: personId, qualifier: relQual };
|
body = { type: "parent_child", person_from_id: otherId, person_to_id: personId, qualifier: relQual };
|
||||||
} else if (relKind === "child") {
|
} else if (relKind === "child") {
|
||||||
body = { type: "parent_child", person_from_id: personId, person_to_id: relOther, qualifier: relQual };
|
body = { type: "parent_child", person_from_id: personId, person_to_id: otherId, qualifier: relQual };
|
||||||
} else if (relKind === "partner") {
|
} else if (relKind === "partner") {
|
||||||
body = { type: "partnership", person_from_id: personId, person_to_id: relOther };
|
body = { type: "partnership", person_from_id: personId, person_to_id: otherId };
|
||||||
} else {
|
} else {
|
||||||
body = { type: "sibling", person_from_id: personId, person_to_id: relOther };
|
body = { type: "sibling", person_from_id: personId, person_to_id: otherId };
|
||||||
}
|
}
|
||||||
const { error } = await api.POST("/api/v1/trees/{tree_id}/relationships", {
|
const { error } = await api.POST("/api/v1/trees/{tree_id}/relationships", {
|
||||||
params: { path: { tree_id: treeId } },
|
params: { path: { tree_id: treeId } },
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
if (!error) {
|
return !error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRel(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!relOther) return;
|
||||||
|
setRelErr(null);
|
||||||
|
if (await linkRelative(relOther)) {
|
||||||
setRelOther("");
|
setRelOther("");
|
||||||
load();
|
load();
|
||||||
|
} else {
|
||||||
|
setRelErr("They're already linked that way.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a brand-new person, link them in the chosen role, then jump to their
|
||||||
|
// page so the user can fill in details immediately.
|
||||||
|
async function createRelativeAndGo(name: string) {
|
||||||
|
const toks = name.trim().split(/\s+/).filter(Boolean);
|
||||||
|
const given = toks.length > 1 ? toks.slice(0, -1).join(" ") : toks[0] ?? name.trim();
|
||||||
|
const surname = toks.length > 1 ? toks[toks.length - 1] : null;
|
||||||
|
const { data } = await api.POST("/api/v1/trees/{tree_id}/persons", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
body: { given, surname },
|
||||||
|
});
|
||||||
|
if (!data) return;
|
||||||
|
await linkRelative(data.id);
|
||||||
|
router.push(personHref(data.id));
|
||||||
|
}
|
||||||
async function removeRel(id: string) {
|
async function removeRel(id: string) {
|
||||||
await api.DELETE("/api/v1/trees/{tree_id}/relationships/{relationship_id}", {
|
await api.DELETE("/api/v1/trees/{tree_id}/relationships/{relationship_id}", {
|
||||||
params: { path: { tree_id: treeId, relationship_id: id } },
|
params: { path: { tree_id: treeId, relationship_id: id } },
|
||||||
@@ -283,13 +426,105 @@ export default function PersonDetailPage() {
|
|||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removePerson() {
|
async function removePerson(cascade: boolean) {
|
||||||
await api.DELETE("/api/v1/trees/{tree_id}/persons/{person_id}", {
|
await api.DELETE("/api/v1/trees/{tree_id}/persons/{person_id}", {
|
||||||
params: { path: { tree_id: treeId, person_id: personId } },
|
params: { path: { tree_id: treeId, person_id: personId }, query: { cascade } },
|
||||||
});
|
});
|
||||||
router.push(`/trees/${treeId}`);
|
router.push(`/trees/${treeId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function addName(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!nGiven.trim() && !nSurname.trim()) return;
|
||||||
|
const { error } = await api.POST("/api/v1/trees/{tree_id}/persons/{person_id}/names", {
|
||||||
|
params: { path: { tree_id: treeId, person_id: personId } },
|
||||||
|
body: { name_type: nameType, given: nGiven || null, surname: nSurname || null },
|
||||||
|
});
|
||||||
|
if (!error) {
|
||||||
|
setNGiven("");
|
||||||
|
setNSurname("");
|
||||||
|
setNameType("married");
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditName(n: Name) {
|
||||||
|
setEditNameId(n.id);
|
||||||
|
setEnType(n.name_type);
|
||||||
|
setEnGiven(n.given ?? "");
|
||||||
|
setEnSurname(n.surname ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveName() {
|
||||||
|
if (!editNameId) return;
|
||||||
|
const { error } = await api.PATCH(
|
||||||
|
"/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}",
|
||||||
|
{
|
||||||
|
params: { path: { tree_id: treeId, person_id: personId, name_id: editNameId } },
|
||||||
|
body: { name_type: enType, given: enGiven || null, surname: enSurname || null },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!error) {
|
||||||
|
setEditNameId(null);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makePrimaryName(id: string) {
|
||||||
|
await api.PATCH("/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}", {
|
||||||
|
params: { path: { tree_id: treeId, person_id: personId, name_id: id } },
|
||||||
|
body: { is_primary: true },
|
||||||
|
});
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeName(id: string) {
|
||||||
|
await api.DELETE("/api/v1/trees/{tree_id}/persons/{person_id}/names/{name_id}", {
|
||||||
|
params: { path: { tree_id: treeId, person_id: personId, name_id: id } },
|
||||||
|
});
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setSelf(link: boolean) {
|
||||||
|
await api.PATCH("/api/v1/users/me/self-person", {
|
||||||
|
body: { self_person_id: link ? personId : null },
|
||||||
|
});
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function startEditPerson(current: Person) {
|
||||||
const t = (current.primary_name ?? "").trim().split(/\s+/).filter(Boolean);
|
const t = (current.primary_name ?? "").trim().split(/\s+/).filter(Boolean);
|
||||||
setPGiven(t.length > 1 ? t.slice(0, -1).join(" ") : (t[0] ?? ""));
|
setPGiven(t.length > 1 ? t.slice(0, -1).join(" ") : (t[0] ?? ""));
|
||||||
@@ -300,6 +535,16 @@ export default function PersonDetailPage() {
|
|||||||
setEditingPerson(true);
|
setEditingPerson(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quick one-click sex setter — no need to open the full edit form. PATCH is
|
||||||
|
// exclude_unset on the backend, so sending only `gender` leaves the rest.
|
||||||
|
async function setGender(value: "male" | "female" | null) {
|
||||||
|
await api.PATCH("/api/v1/trees/{tree_id}/persons/{person_id}", {
|
||||||
|
params: { path: { tree_id: treeId, person_id: personId } },
|
||||||
|
body: { gender: value },
|
||||||
|
});
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
async function savePerson() {
|
async function savePerson() {
|
||||||
const { error } = await api.PATCH("/api/v1/trees/{tree_id}/persons/{person_id}", {
|
const { error } = await api.PATCH("/api/v1/trees/{tree_id}/persons/{person_id}", {
|
||||||
params: { path: { tree_id: treeId, person_id: personId } },
|
params: { path: { tree_id: treeId, person_id: personId } },
|
||||||
@@ -320,6 +565,9 @@ export default function PersonDetailPage() {
|
|||||||
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
|
if (!person) return <p className="text-[var(--muted)]">Not found.</p>;
|
||||||
|
|
||||||
|
const isSelf = me?.self_person_id === personId;
|
||||||
|
const isDefault = tree?.home_person_id === personId;
|
||||||
|
|
||||||
// Inline "cite" control: a badge with count, a toggle, and the picker form.
|
// Inline "cite" control: a badge with count, a toggle, and the picker form.
|
||||||
function citeControl(key: string, target: Partial<CitationCreate>, cites: Citation[]) {
|
function citeControl(key: string, target: Partial<CitationCreate>, cites: Citation[]) {
|
||||||
return (
|
return (
|
||||||
@@ -397,7 +645,7 @@ export default function PersonDetailPage() {
|
|||||||
<ul className="mt-1 space-y-1">
|
<ul className="mt-1 space-y-1">
|
||||||
{items.map((r) => (
|
{items.map((r) => (
|
||||||
<li key={r.id} className="flex items-center justify-between text-sm">
|
<li key={r.id} className="flex items-center justify-between text-sm">
|
||||||
<Link href={`/trees/${treeId}/persons/${otherId(r)}`} className="hover:underline">
|
<Link href={personHref(otherId(r))} className="hover:underline">
|
||||||
{nameOf(otherId(r))}
|
{nameOf(otherId(r))}
|
||||||
{r.qualifier ? <span className="text-[var(--muted)]"> · {r.qualifier}</span> : null}
|
{r.qualifier ? <span className="text-[var(--muted)]"> · {r.qualifier}</span> : null}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -416,9 +664,17 @@ export default function PersonDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Link href={`/trees/${treeId}`} className="text-sm text-[var(--muted)] hover:underline">
|
<div className="flex items-center justify-between gap-3">
|
||||||
← Back to tree
|
<Link href={backHref} className="text-sm text-[var(--muted)] hover:underline">
|
||||||
</Link>
|
{backLabel}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/trees/${treeId}/tree?focus=${personId}`}
|
||||||
|
className="text-sm text-bronze hover:underline"
|
||||||
|
>
|
||||||
|
View in tree →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
{editingPerson ? (
|
{editingPerson ? (
|
||||||
<form
|
<form
|
||||||
@@ -431,7 +687,11 @@ export default function PersonDetailPage() {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<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="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-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)}>
|
<select className={fieldCls} value={pLiving} onChange={(e) => setPLiving(e.target.value)}>
|
||||||
<option value="unknown">Status: unknown</option>
|
<option value="unknown">Status: unknown</option>
|
||||||
<option value="living">Living</option>
|
<option value="living">Living</option>
|
||||||
@@ -458,29 +718,246 @@ export default function PersonDetailPage() {
|
|||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<h1 className="text-3xl font-semibold">{person.primary_name ?? "Unnamed person"}</h1>
|
<h1 className="flex flex-wrap items-center gap-3 text-3xl font-semibold">
|
||||||
<div className="flex items-center gap-3">
|
<span className="inline-flex items-center gap-2">
|
||||||
|
{person.primary_name ?? "Unnamed person"}
|
||||||
|
</span>
|
||||||
|
{/* One-click sex setter — no edit mode needed. Active = current; click it again to clear. */}
|
||||||
|
<span className="inline-flex items-center overflow-hidden rounded-md border border-[var(--border)] text-base font-normal">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setGender(person.gender === "male" ? null : "male")}
|
||||||
|
aria-pressed={person.gender === "male"}
|
||||||
|
title={person.gender === "male" ? "Male — click to clear" : "Set male"}
|
||||||
|
className={`px-3 py-1 leading-none transition-colors ${
|
||||||
|
person.gender === "male"
|
||||||
|
? "bg-[rgb(120,159,172)] text-white"
|
||||||
|
: "text-[var(--muted)] hover:bg-bronze/[0.07]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
♂
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setGender(person.gender === "female" ? null : "female")}
|
||||||
|
aria-pressed={person.gender === "female"}
|
||||||
|
title={person.gender === "female" ? "Female — click to clear" : "Set female"}
|
||||||
|
className={`border-l border-[var(--border)] px-3 py-1 leading-none transition-colors ${
|
||||||
|
person.gender === "female"
|
||||||
|
? "bg-[rgb(196,138,146)] text-white"
|
||||||
|
: "text-[var(--muted)] hover:bg-bronze/[0.07]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
♀
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{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)}
|
{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)}>
|
<Button variant="outline" size="sm" onClick={() => startEditPerson(person)}>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={removePerson}>
|
<Button variant="ghost" size="sm" onClick={() => setConfirmingDelete(true)}>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{confirmingDelete && (
|
||||||
|
<div className="space-y-3 rounded-lg border border-bronze/40 bg-bronze/[0.05] p-4">
|
||||||
|
<p className="text-sm">
|
||||||
|
Delete <strong>{person.primary_name ?? "this person"}</strong>? Their relationships
|
||||||
|
will be removed too. This can be undone from Recovery.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => removePerson(false)}>
|
||||||
|
Delete only this person
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => removePerson(true)}>
|
||||||
|
Delete with all descendants
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmingDelete(false)}
|
||||||
|
className="text-xs text-[var(--muted)]"
|
||||||
|
>
|
||||||
|
cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Names</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{names.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--muted)]">No names yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{names.map((n) =>
|
||||||
|
editNameId === n.id ? (
|
||||||
|
<li key={n.id}>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
saveName();
|
||||||
|
}}
|
||||||
|
className="flex flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
className={fieldCls}
|
||||||
|
value={enType}
|
||||||
|
onChange={(e) => setEnType(e.target.value)}
|
||||||
|
>
|
||||||
|
{NAME_TYPES.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Input
|
||||||
|
className="h-9 w-36"
|
||||||
|
placeholder="Given"
|
||||||
|
value={enGiven}
|
||||||
|
onChange={(e) => setEnGiven(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="h-9 w-36"
|
||||||
|
placeholder="Surname"
|
||||||
|
value={enSurname}
|
||||||
|
onChange={(e) => setEnSurname(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" size="sm">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditNameId(null)}
|
||||||
|
className="text-xs text-[var(--muted)]"
|
||||||
|
>
|
||||||
|
cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
<li
|
||||||
|
key={n.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{formatName(n)}</span>
|
||||||
|
<span className="rounded bg-[var(--border)]/50 px-1.5 py-0.5 text-xs text-[var(--muted)]">
|
||||||
|
{nameTypeLabel(n.name_type)}
|
||||||
|
</span>
|
||||||
|
{n.is_primary && (
|
||||||
|
<span className="rounded bg-bronze/15 px-1.5 py-0.5 text-xs text-bronze">
|
||||||
|
primary
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-3">
|
||||||
|
{!n.is_primary && (
|
||||||
|
<button
|
||||||
|
onClick={() => makePrimaryName(n.id)}
|
||||||
|
className="text-xs text-bronze hover:underline"
|
||||||
|
>
|
||||||
|
make primary
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => startEditName(n)}
|
||||||
|
className="text-xs text-bronze hover:underline"
|
||||||
|
>
|
||||||
|
edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => removeName(n.id)}
|
||||||
|
className="text-[var(--muted)] hover:text-bronze"
|
||||||
|
aria-label="Remove"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<form onSubmit={addName} className="flex flex-wrap items-end gap-2">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-[var(--muted)]">Type</span>
|
||||||
|
<select
|
||||||
|
className={fieldCls}
|
||||||
|
value={nameType}
|
||||||
|
onChange={(e) => setNameType(e.target.value)}
|
||||||
|
>
|
||||||
|
{NAME_TYPES.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-[var(--muted)]">Given</span>
|
||||||
|
<Input
|
||||||
|
className="h-9 w-36"
|
||||||
|
placeholder="Given"
|
||||||
|
value={nGiven}
|
||||||
|
onChange={(e) => setNGiven(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-[var(--muted)]">Surname</span>
|
||||||
|
<Input
|
||||||
|
className="h-9 w-36"
|
||||||
|
placeholder="Surname"
|
||||||
|
value={nSurname}
|
||||||
|
onChange={(e) => setNSurname(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Button type="submit">Add name</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Life events</CardTitle>
|
<CardTitle className="text-base">Life events</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{events.length === 0 ? (
|
{shownEvents.length === 0 ? (
|
||||||
<p className="text-sm text-[var(--muted)]">No events yet.</p>
|
<p className="text-sm text-[var(--muted)]">No events yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{events.map((ev) =>
|
{shownEvents.map((ev) =>
|
||||||
editId === ev.id ? (
|
editId === ev.id ? (
|
||||||
<li key={ev.id}>
|
<li key={ev.id}>
|
||||||
<form
|
<form
|
||||||
@@ -549,9 +1026,18 @@ export default function PersonDetailPage() {
|
|||||||
<li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
<li key={ev.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||||
<span>
|
<span>
|
||||||
<span className="font-medium capitalize">{ev.event_type}</span>
|
<span className="font-medium capitalize">{ev.event_type}</span>
|
||||||
|
{ev.relationship_id ? (
|
||||||
|
<span className="text-[var(--muted)]">
|
||||||
|
{" "}
|
||||||
|
· with {nameOf(spouseOfRelEvent(ev.relationship_id) ?? "")}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{ev.date_value ? (
|
{ev.date_value ? (
|
||||||
<span className="text-[var(--muted)]"> — {ev.date_value}</span>
|
<span className="text-[var(--muted)]"> — {ev.date_value}</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{ev.detail ? (
|
||||||
|
<span className="text-[var(--muted)]"> — {ev.detail}</span>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-3">
|
<span className="flex items-center gap-3">
|
||||||
{citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))}
|
{citeControl(`e:${ev.id}`, { event_id: ev.id }, eventCites(ev.id))}
|
||||||
@@ -600,6 +1086,17 @@ export default function PersonDetailPage() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
{isPartnershipType(evType) && (
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-[var(--muted)]">Spouse / partner</span>
|
||||||
|
<PersonCombobox
|
||||||
|
people={others}
|
||||||
|
value={evSpouse}
|
||||||
|
onChange={setEvSpouse}
|
||||||
|
placeholder="Search for a spouse…"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<label className="flex flex-col gap-1">
|
<label className="flex flex-col gap-1">
|
||||||
<span className="text-xs text-[var(--muted)]">When</span>
|
<span className="text-xs text-[var(--muted)]">When</span>
|
||||||
<select className={fieldCls} value={dateQual} onChange={(e) => setDateQual(e.target.value)}>
|
<select className={fieldCls} value={dateQual} onChange={(e) => setDateQual(e.target.value)}>
|
||||||
@@ -672,14 +1169,13 @@ export default function PersonDetailPage() {
|
|||||||
<option value="partner">partner</option>
|
<option value="partner">partner</option>
|
||||||
<option value="sibling">sibling</option>
|
<option value="sibling">sibling</option>
|
||||||
</select>
|
</select>
|
||||||
<select className={fieldCls} value={relOther} onChange={(e) => setRelOther(e.target.value)}>
|
<PersonCombobox
|
||||||
<option value="">— person —</option>
|
people={others}
|
||||||
{others.map((p) => (
|
value={relOther}
|
||||||
<option key={p.id} value={p.id}>
|
onChange={setRelOther}
|
||||||
{p.primary_name ?? "Unnamed"}
|
onCreate={createRelativeAndGo}
|
||||||
</option>
|
placeholder="Search, or type a new name…"
|
||||||
))}
|
/>
|
||||||
</select>
|
|
||||||
{(relKind === "parent" || relKind === "child") && (
|
{(relKind === "parent" || relKind === "child") && (
|
||||||
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
|
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
|
||||||
{QUALIFIERS.map((q) => (
|
{QUALIFIERS.map((q) => (
|
||||||
@@ -692,6 +1188,88 @@ export default function PersonDetailPage() {
|
|||||||
<Button type="submit">Link</Button>
|
<Button type="submit">Link</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
{relErr && <p className="text-sm text-red-600">{relErr}</p>}
|
||||||
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -530,6 +530,11 @@
|
|||||||
|
|
||||||
.f3 .link {
|
.f3 .link {
|
||||||
transition: stroke-width 0.2s ease-in-out;
|
transition: stroke-width 0.2s ease-in-out;
|
||||||
|
/* Brand-aware connector lines: dark on the light paper, light on dark. The
|
||||||
|
library's default white stroke is invisible on the light theme. */
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--line);
|
||||||
|
stroke-width: 1.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.f3 .link.f3-path-to-main {
|
.f3 .link.f3-path-to-main {
|
||||||
|
|||||||
@@ -4,13 +4,15 @@
|
|||||||
import "./chart.css";
|
import "./chart.css";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
import type { components } from "@/lib/api/schema";
|
import type { components } from "@/lib/api/schema";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { FanChart } from "@/components/fan-chart";
|
import { FanChart } from "@/components/fan-chart";
|
||||||
|
import { DepthControl } from "@/components/depth-control";
|
||||||
|
|
||||||
type Person = components["schemas"]["PersonRead"];
|
type Person = components["schemas"]["PersonRead"];
|
||||||
type Relationship = components["schemas"]["RelationshipRead"];
|
type Relationship = components["schemas"]["RelationshipRead"];
|
||||||
@@ -26,15 +28,35 @@ function splitName(name: string | null | undefined): [string, string] {
|
|||||||
export default function TreePage() {
|
export default function TreePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const treeId = params.id;
|
const treeId = params.id;
|
||||||
|
// The focused person can arrive in the URL (?focus=…) — e.g. coming back from
|
||||||
|
// a person page. Captured once at mount so syncing focus→URL doesn't refetch.
|
||||||
|
const initialFocus = useRef<string | null>(searchParams.get("focus"));
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const chartRef = useRef<any>(null);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
const [people, setPeople] = useState<Person[]>([]);
|
const [people, setPeople] = useState<Person[]>([]);
|
||||||
const [rels, setRels] = useState<Relationship[]>([]);
|
const [rels, setRels] = useState<Relationship[]>([]);
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [status, setStatus] = useState<"loading" | "empty" | "ready" | "error">("loading");
|
const [status, setStatus] = useState<"loading" | "empty" | "ready" | "error">("loading");
|
||||||
const [focusId, setFocusId] = useState<string | null>(null);
|
const [focusId, setFocusId] = useState<string | null>(null);
|
||||||
|
// The tree's default/home person — lets us offer a "recenter on default" jump.
|
||||||
|
const [homeId, setHomeId] = useState<string | null>(null);
|
||||||
const [mode, setMode] = useState<Mode>("landscape");
|
const [mode, setMode] = useState<Mode>("landscape");
|
||||||
|
const [renderNote, setRenderNote] = useState<string | null>(null);
|
||||||
|
// How many generations to show around the focus, each independently settable
|
||||||
|
// (or "all"). ALL_DEPTH is just a number bigger than any real lineage; the
|
||||||
|
// chart only renders people that exist, so a high cap costs nothing.
|
||||||
|
const [ancDepth, setAncDepth] = useState(3); // ancestors (backwards)
|
||||||
|
const [progDepth, setProgDepth] = useState(2); // descendants (forwards)
|
||||||
|
const [ancAll, setAncAll] = useState(false);
|
||||||
|
const [progAll, setProgAll] = useState(false);
|
||||||
|
const ALL_DEPTH = 100;
|
||||||
|
const effAnc = ancAll ? ALL_DEPTH : ancDepth;
|
||||||
|
const effProg = progAll ? ALL_DEPTH : progDepth;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -46,16 +68,25 @@ export default function TreePage() {
|
|||||||
router.push("/login");
|
router.push("/login");
|
||||||
return;
|
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}/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}/events", { params: { path: { tree_id: treeId } } }),
|
||||||
|
api.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } }),
|
||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const ppl = p.data ?? [];
|
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);
|
setPeople(ppl);
|
||||||
setRels(r.data ?? []);
|
setRels(r.data ?? []);
|
||||||
setEvents(e.data ?? []);
|
setEvents(e.data ?? []);
|
||||||
setFocusId((cur) => cur ?? ppl[0]?.id ?? null);
|
setHomeId(homeId);
|
||||||
|
// Honor an explicit ?focus first (came from a person page / a shared
|
||||||
|
// link), then the tree's default/home person, then the first person.
|
||||||
|
const fromUrl = initialFocus.current && ppl.some((x) => x.id === initialFocus.current)
|
||||||
|
? initialFocus.current
|
||||||
|
: null;
|
||||||
|
setFocusId((cur) => cur ?? fromUrl ?? homeId ?? ppl[0]?.id ?? null);
|
||||||
setStatus(ppl.length ? "ready" : "empty");
|
setStatus(ppl.length ? "ready" : "empty");
|
||||||
})().catch(() => !cancelled && setStatus("error"));
|
})().catch(() => !cancelled && setStatus("error"));
|
||||||
return () => {
|
return () => {
|
||||||
@@ -100,6 +131,46 @@ export default function TreePage() {
|
|||||||
if (status !== "ready" || mode === "fan" || !containerRef.current) return;
|
if (status !== "ready" || mode === "fan" || !containerRef.current) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
|
// Sanitize the graph before handing it to family-chart, which recurses
|
||||||
|
// through parents and will blow the stack (blank tree) on a cycle — e.g. a
|
||||||
|
// person edited into being their own ancestor.
|
||||||
|
const alive = new Set(people.map((pp) => pp.id));
|
||||||
|
const ok = (ids: string[], self: string) =>
|
||||||
|
[...new Set(ids)].filter((id) => alive.has(id) && id !== self);
|
||||||
|
|
||||||
|
// Build an acyclic set of parent edges: skip any edge that would make a
|
||||||
|
// person their own ancestor. Children are derived from the kept edges so
|
||||||
|
// parent/child stays consistent.
|
||||||
|
const parentsMap = new Map<string, string[]>();
|
||||||
|
const childrenMap = new Map<string, string[]>();
|
||||||
|
let dropped = 0;
|
||||||
|
const isAncestorOf = (ancestor: string, of: string): boolean => {
|
||||||
|
const stack = [...(parentsMap.get(of) ?? [])];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
while (stack.length) {
|
||||||
|
const n = stack.pop()!;
|
||||||
|
if (n === ancestor) return true;
|
||||||
|
if (seen.has(n)) continue;
|
||||||
|
seen.add(n);
|
||||||
|
for (const p of parentsMap.get(n) ?? []) stack.push(p);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for (const pp of people) {
|
||||||
|
const accepted: string[] = [];
|
||||||
|
for (const par of ok(parentsOf(pp.id), pp.id)) {
|
||||||
|
// Edge "pp has parent par" loops if pp is already an ancestor of par.
|
||||||
|
if (isAncestorOf(pp.id, par)) {
|
||||||
|
dropped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
accepted.push(par);
|
||||||
|
parentsMap.set(pp.id, accepted);
|
||||||
|
childrenMap.set(par, [...(childrenMap.get(par) ?? []), pp.id]);
|
||||||
|
}
|
||||||
|
parentsMap.set(pp.id, accepted);
|
||||||
|
}
|
||||||
|
|
||||||
const data = people.map((pp) => {
|
const data = people.map((pp) => {
|
||||||
const [fn, ln] = splitName(pp.primary_name);
|
const [fn, ln] = splitName(pp.primary_name);
|
||||||
return {
|
return {
|
||||||
@@ -110,28 +181,48 @@ export default function TreePage() {
|
|||||||
birthday: years.get(pp.id) ?? "",
|
birthday: years.get(pp.id) ?? "",
|
||||||
gender: pp.gender === "female" ? "F" : "M",
|
gender: pp.gender === "female" ? "F" : "M",
|
||||||
},
|
},
|
||||||
rels: { spouses: partnersOf(pp.id), parents: parentsOf(pp.id), children: childrenOf(pp.id) },
|
rels: {
|
||||||
|
spouses: ok(partnersOf(pp.id), pp.id),
|
||||||
|
parents: parentsMap.get(pp.id) ?? [],
|
||||||
|
children: childrenMap.get(pp.id) ?? [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const f3 = await import("family-chart");
|
const f3 = await import("family-chart");
|
||||||
if (cancelled || !containerRef.current) return;
|
if (cancelled || !containerRef.current) return;
|
||||||
containerRef.current.innerHTML = "";
|
try {
|
||||||
const chart = f3.createChart(containerRef.current, data);
|
containerRef.current.innerHTML = "";
|
||||||
chart
|
const chart = f3.createChart(containerRef.current, data);
|
||||||
.setCardHtml()
|
chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]);
|
||||||
.setCardDisplay([["first name", "last name"], ["birthday"]])
|
if (mode === "portrait") chart.setOrientationVertical();
|
||||||
.setOnCardClick((_e: unknown, d: { data?: { id?: string } }) => {
|
else chart.setOrientationHorizontal();
|
||||||
const id = d?.data?.id;
|
// Generations to show around the focus (configurable; see depth controls).
|
||||||
if (id) {
|
chart.setAncestryDepth?.(effAnc);
|
||||||
setFocusId(id);
|
chart.setProgenyDepth?.(effProg);
|
||||||
chart.updateMainId(id);
|
// Default card click recenters the whole hourglass; sync focus for the
|
||||||
chart.updateTree();
|
// "Open profile" link after every (re)build.
|
||||||
}
|
chart.setAfterUpdate?.(() => {
|
||||||
|
const md = chart.getMainDatum?.();
|
||||||
|
const id = md?.id ?? md?.data?.id;
|
||||||
|
if (id) setFocusId(id);
|
||||||
});
|
});
|
||||||
if (mode === "portrait") chart.setOrientationVertical();
|
chartRef.current = chart;
|
||||||
else chart.setOrientationHorizontal();
|
if (focusId) chart.updateMainId(focusId);
|
||||||
if (focusId) chart.updateMainId(focusId);
|
chart.updateTree({ initial: true });
|
||||||
chart.updateTree({ initial: true });
|
setRenderNote(
|
||||||
|
dropped > 0
|
||||||
|
? `Skipped ${dropped} conflicting parent link${dropped === 1 ? "" : "s"} (a person can't be their own ancestor). Open the people involved to fix the relationship.`
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Never leave a blank canvas — show a message and let them fix via the
|
||||||
|
// Family view / person pages.
|
||||||
|
console.error("tree render failed", err);
|
||||||
|
if (containerRef.current) containerRef.current.innerHTML = "";
|
||||||
|
setRenderNote(
|
||||||
|
"The tree couldn't be drawn — a relationship may be conflicting. Use the Family view to open the affected people and check their parents/children.",
|
||||||
|
);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -139,6 +230,46 @@ export default function TreePage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [status, mode, people, rels, events]);
|
}, [status, mode, people, rels, events]);
|
||||||
|
|
||||||
|
// Apply depth changes to the already-built chart without a full rebuild
|
||||||
|
// (landscape/portrait only; the fan reads its own `generations` prop).
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "fan" || !chartRef.current) return;
|
||||||
|
chartRef.current.setAncestryDepth?.(effAnc);
|
||||||
|
chartRef.current.setProgenyDepth?.(effProg);
|
||||||
|
chartRef.current.updateTree?.();
|
||||||
|
}, [effAnc, effProg, mode]);
|
||||||
|
|
||||||
|
// 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],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mirror the focused person into the URL (?focus=…) so navigating away and
|
||||||
|
// back — or sharing the link — keeps the tree centered where you left it.
|
||||||
|
// `replace` (not push) so each recenter doesn't pile up in browser history.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusId || searchParams.get("focus") === focusId) return;
|
||||||
|
const sp = new URLSearchParams(searchParams.toString());
|
||||||
|
sp.set("focus", focusId);
|
||||||
|
router.replace(`/trees/${treeId}/tree?${sp.toString()}`, { scroll: false });
|
||||||
|
}, [focusId, searchParams, router, treeId]);
|
||||||
|
|
||||||
|
const matches = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return [];
|
||||||
|
return people
|
||||||
|
.filter((p) => (p.primary_name ?? "").toLowerCase().includes(q))
|
||||||
|
.slice(0, 8);
|
||||||
|
}, [query, people]);
|
||||||
|
|
||||||
const ModeButton = ({ m, label }: { m: Mode; label: string }) => (
|
const ModeButton = ({ m, label }: { m: Mode; label: string }) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => setMode(m)}
|
onClick={() => setMode(m)}
|
||||||
@@ -150,10 +281,48 @@ export default function TreePage() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<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>
|
||||||
|
{homeId && focusId !== homeId && (
|
||||||
|
<button
|
||||||
|
onClick={() => goTo(homeId)}
|
||||||
|
className="text-sm text-bronze hover:underline"
|
||||||
|
title={`Recenter on the tree's default person (${nameOf(homeId)})`}
|
||||||
|
>
|
||||||
|
↩ Back to default person
|
||||||
|
<span className="text-[var(--muted)]"> · {nameOf(homeId)}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center rounded-lg border border-[var(--border)] p-0.5">
|
<div className="flex items-center rounded-lg border border-[var(--border)] p-0.5">
|
||||||
<ModeButton m="landscape" label="Landscape" />
|
<ModeButton m="landscape" label="Landscape" />
|
||||||
@@ -162,7 +331,7 @@ export default function TreePage() {
|
|||||||
</div>
|
</div>
|
||||||
{focusId && (
|
{focusId && (
|
||||||
<Link
|
<Link
|
||||||
href={`/trees/${treeId}/persons/${focusId}`}
|
href={`/trees/${treeId}/persons/${focusId}?from=tree`}
|
||||||
className="text-sm text-bronze hover:underline"
|
className="text-sm text-bronze hover:underline"
|
||||||
>
|
>
|
||||||
Open {nameOf(focusId)} →
|
Open {nameOf(focusId)} →
|
||||||
@@ -171,11 +340,49 @@ export default function TreePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{status === "ready" && (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-lg border border-[var(--border)] bg-[var(--surface)] px-4 py-2.5">
|
||||||
|
<span className="text-sm font-medium">Generations</span>
|
||||||
|
<DepthControl
|
||||||
|
label="Ancestors"
|
||||||
|
icon="↑"
|
||||||
|
value={ancDepth}
|
||||||
|
all={ancAll}
|
||||||
|
onValue={(v) => {
|
||||||
|
setAncAll(false);
|
||||||
|
setAncDepth(v);
|
||||||
|
}}
|
||||||
|
onAll={setAncAll}
|
||||||
|
/>
|
||||||
|
<DepthControl
|
||||||
|
label="Descendants"
|
||||||
|
icon="↓"
|
||||||
|
value={progDepth}
|
||||||
|
all={progAll}
|
||||||
|
onValue={(v) => {
|
||||||
|
setProgAll(false);
|
||||||
|
setProgDepth(v);
|
||||||
|
}}
|
||||||
|
onAll={setProgAll}
|
||||||
|
disabled={mode === "fan"}
|
||||||
|
/>
|
||||||
|
{mode === "fan" && (
|
||||||
|
<span className="text-xs text-[var(--muted)]">Fan shows ancestors only.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{status === "empty" && (
|
{status === "empty" && (
|
||||||
<p className="text-[var(--muted)]">No people yet — add some under People, or import a GEDCOM.</p>
|
<p className="text-[var(--muted)]">No people yet — add some under People, or import a GEDCOM.</p>
|
||||||
)}
|
)}
|
||||||
{status === "error" && <p className="text-[var(--muted)]">Could not render the tree.</p>}
|
{status === "error" && <p className="text-[var(--muted)]">Could not render the tree.</p>}
|
||||||
|
|
||||||
|
{renderNote && mode !== "fan" && (
|
||||||
|
<p className="rounded-md border border-bronze/40 bg-bronze/[0.06] px-3 py-2 text-sm text-bronze">
|
||||||
|
{renderNote}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{status === "ready" && mode === "fan" && focusId ? (
|
{status === "ready" && mode === "fan" && focusId ? (
|
||||||
<div className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-4">
|
<div className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-4">
|
||||||
<FanChart
|
<FanChart
|
||||||
@@ -184,6 +391,7 @@ export default function TreePage() {
|
|||||||
nameOf={nameOf}
|
nameOf={nameOf}
|
||||||
yearOf={yearOf}
|
yearOf={yearOf}
|
||||||
onSelect={setFocusId}
|
onSelect={setFocusId}
|
||||||
|
generations={Math.min(effAnc, 8)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,28 +1,5 @@
|
|||||||
import Link from "next/link";
|
import { AppShell } from "@/components/app-shell";
|
||||||
|
|
||||||
import { AppSidebar } from "@/components/app-sidebar";
|
|
||||||
|
|
||||||
export default function TreesLayout({ children }: { children: React.ReactNode }) {
|
export default function TreesLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return <AppShell>{children}</AppShell>;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,15 @@ export default function TreesPage() {
|
|||||||
await api.POST("/api/v1/trees/{tree_id}/restore", { params: { path: { tree_id: id } } });
|
await api.POST("/api/v1/trees/{tree_id}/restore", { params: { path: { tree_id: id } } });
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
|
// Optimistic visibility change so the dropdown reflects the pick immediately.
|
||||||
|
async function setVisibility(id: string, visibility: NonNullable<Tree["visibility"]>) {
|
||||||
|
setTrees((cur) => cur.map((t) => (t.id === id ? { ...t, visibility } : t)));
|
||||||
|
await api.PATCH("/api/v1/trees/{tree_id}", {
|
||||||
|
params: { path: { tree_id: id } },
|
||||||
|
body: { visibility },
|
||||||
|
});
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
|
|
||||||
@@ -76,16 +85,44 @@ export default function TreesPage() {
|
|||||||
{trees.map((tree) => (
|
{trees.map((tree) => (
|
||||||
<li key={tree.id}>
|
<li key={tree.id}>
|
||||||
<Card className="transition-colors hover:border-bronze/50">
|
<Card className="transition-colors hover:border-bronze/50">
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
<CardContent className="flex items-center justify-between gap-3 p-4">
|
||||||
<Link href={`/trees/${tree.id}/tree`} className="min-w-0 flex-1">
|
<Link href={`/trees/${tree.id}/tree`} className="min-w-0 flex-1">
|
||||||
<div className="truncate font-medium">{tree.name}</div>
|
<div className="truncate font-medium">{tree.name}</div>
|
||||||
<div className="text-xs uppercase tracking-wide text-bronze">
|
|
||||||
{tree.visibility}
|
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
<select
|
||||||
|
value={tree.visibility ?? "private"}
|
||||||
|
onChange={(e) =>
|
||||||
|
setVisibility(tree.id, e.target.value as NonNullable<Tree["visibility"]>)
|
||||||
|
}
|
||||||
|
aria-label="Tree visibility"
|
||||||
|
title={
|
||||||
|
"Who can see this tree (living people stay protected regardless):\n" +
|
||||||
|
"• Private — only you and people you invite\n" +
|
||||||
|
"• Members — any signed-in user on this site\n" +
|
||||||
|
"• Unlisted — anyone with the link (not listed or indexed)\n" +
|
||||||
|
"• Public — anyone on the web; listed and search-indexable"
|
||||||
|
}
|
||||||
|
className="rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 py-1 text-xs uppercase tracking-wide text-bronze focus-visible:border-bronze focus-visible:outline-none"
|
||||||
|
>
|
||||||
|
<option value="private">Private</option>
|
||||||
|
<option value="site_members">Public – Members</option>
|
||||||
|
<option value="unlisted">Unlisted</option>
|
||||||
|
<option value="public">Public</option>
|
||||||
|
</select>
|
||||||
|
{tree.visibility && tree.visibility !== "private" && (
|
||||||
|
<a
|
||||||
|
href={`/p/${tree.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title="Open the public, no-login view"
|
||||||
|
className="shrink-0 text-xs text-bronze hover:underline"
|
||||||
|
>
|
||||||
|
Public page ↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => remove(tree.id)}
|
onClick={() => remove(tree.id)}
|
||||||
className="ml-3 text-[var(--muted)] hover:text-bronze"
|
className="text-[var(--muted)] hover:text-bronze"
|
||||||
aria-label="Delete tree"
|
aria-label="Delete tree"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,25 +4,33 @@ import {
|
|||||||
Archive,
|
Archive,
|
||||||
ArrowDownUp,
|
ArrowDownUp,
|
||||||
BookText,
|
BookText,
|
||||||
|
Compass,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
LogOut,
|
LogOut,
|
||||||
Network,
|
Network,
|
||||||
|
Settings,
|
||||||
|
Sparkles,
|
||||||
|
UserPlus,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
|
||||||
export function AppSidebar() {
|
export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segs = pathname.split("/").filter(Boolean); // ["trees", "<id>", ...]
|
const segs = pathname.split("/").filter(Boolean); // ["trees", "<id>", ...]
|
||||||
const treeId = segs[0] === "trees" && segs[1] ? segs[1] : null;
|
const treeId = segs[0] === "trees" && segs[1] ? segs[1] : null;
|
||||||
const [treeName, setTreeName] = useState<string | null>(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(() => {
|
useEffect(() => {
|
||||||
if (!treeId) {
|
if (!treeId) {
|
||||||
@@ -34,7 +42,20 @@ export function AppSidebar() {
|
|||||||
.then((r) => setTreeName(r.data?.name ?? null));
|
.then((r) => setTreeName(r.data?.name ?? null));
|
||||||
}, [treeId]);
|
}, [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() {
|
async function logout() {
|
||||||
|
onNavigate?.();
|
||||||
await api.POST("/api/v1/auth/logout");
|
await api.POST("/api/v1/auth/logout");
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
}
|
}
|
||||||
@@ -52,6 +73,7 @@ export function AppSidebar() {
|
|||||||
}) => (
|
}) => (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
|
onClick={onNavigate}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
|
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||||
active
|
active
|
||||||
@@ -72,6 +94,8 @@ export function AppSidebar() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Item href="/trees" label="Trees" icon={FolderTree} active={pathname === "/trees"} />
|
<Item href="/trees" label="Trees" icon={FolderTree} active={pathname === "/trees"} />
|
||||||
|
<Item href="/explore" label="Explore" icon={Compass} active={pathname === "/explore"} />
|
||||||
|
<Item href="/import" label="Import" icon={ArrowDownUp} active={pathname === "/import"} />
|
||||||
|
|
||||||
{treeId && (
|
{treeId && (
|
||||||
<div className="mt-5 flex flex-col gap-1">
|
<div className="mt-5 flex flex-col gap-1">
|
||||||
@@ -108,6 +132,18 @@ export function AppSidebar() {
|
|||||||
icon={ArrowDownUp}
|
icon={ArrowDownUp}
|
||||||
active={pathname.startsWith(`/trees/${treeId}/gedcom`)}
|
active={pathname.startsWith(`/trees/${treeId}/gedcom`)}
|
||||||
/>
|
/>
|
||||||
|
<Item
|
||||||
|
href={`/trees/${treeId}/cleanup`}
|
||||||
|
label="Cleanup"
|
||||||
|
icon={Sparkles}
|
||||||
|
active={pathname.startsWith(`/trees/${treeId}/cleanup`)}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
href={`/trees/${treeId}/members`}
|
||||||
|
label="Members"
|
||||||
|
icon={UserPlus}
|
||||||
|
active={pathname.startsWith(`/trees/${treeId}/members`)}
|
||||||
|
/>
|
||||||
<Item
|
<Item
|
||||||
href={`/trees/${treeId}/recovery`}
|
href={`/trees/${treeId}/recovery`}
|
||||||
label="Recovery"
|
label="Recovery"
|
||||||
@@ -117,13 +153,45 @@ export function AppSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<div className="mt-auto">
|
||||||
onClick={logout}
|
<ThemeToggle />
|
||||||
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"
|
</div>
|
||||||
>
|
|
||||||
<LogOut className="h-4 w-4 shrink-0" />
|
<div ref={menuRef} className="relative">
|
||||||
Sign out
|
{menuOpen && (
|
||||||
</button>
|
<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>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Slider + number stepper + "All" toggle for one generation direction
|
||||||
|
// (ancestors or descendants). Shared by the member and public tree views.
|
||||||
|
export function DepthControl({
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
value,
|
||||||
|
all,
|
||||||
|
onValue,
|
||||||
|
onAll,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
value: number;
|
||||||
|
all: boolean;
|
||||||
|
onValue: (v: number) => void;
|
||||||
|
onAll: (b: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2 ${disabled ? "opacity-40" : ""}`}>
|
||||||
|
<span className="flex w-24 items-center gap-1 text-xs text-[var(--muted)]">
|
||||||
|
<span aria-hidden>{icon}</span> {label}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={12}
|
||||||
|
step={1}
|
||||||
|
value={all ? 12 : value}
|
||||||
|
disabled={disabled || all}
|
||||||
|
onChange={(e) => onValue(Number(e.target.value))}
|
||||||
|
className="w-28 accent-bronze"
|
||||||
|
aria-label={`${label} generations`}
|
||||||
|
/>
|
||||||
|
{all ? (
|
||||||
|
<span className="w-10 text-center text-sm font-medium text-bronze">All</span>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={99}
|
||||||
|
value={value}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => onValue(Math.max(0, Math.min(99, Number(e.target.value) || 0)))}
|
||||||
|
className="h-7 w-12 rounded-md border border-[var(--border)] bg-[var(--surface)] px-1 text-center text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onAll(!all)}
|
||||||
|
title={`Show all ${label.toLowerCase()}`}
|
||||||
|
className={`rounded-md px-2 py-1 text-xs transition-colors ${
|
||||||
|
all
|
||||||
|
? "bg-bronze text-paper"
|
||||||
|
: "border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -79,7 +79,7 @@ export function FanChart({
|
|||||||
<path
|
<path
|
||||||
d={sector(r0 + 1, r1 - 1, a0 + 0.004, a1 - 0.004)}
|
d={sector(r0 + 1, r1 - 1, a0 + 0.004, a1 - 0.004)}
|
||||||
fill={id ? "var(--surface)" : "transparent"}
|
fill={id ? "var(--surface)" : "transparent"}
|
||||||
stroke="var(--border)"
|
stroke="var(--line)"
|
||||||
/>
|
/>
|
||||||
{id && (
|
{id && (
|
||||||
<text
|
<text
|
||||||
|
|||||||
@@ -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,121 @@
|
|||||||
|
"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,
|
||||||
|
onCreate,
|
||||||
|
placeholder = "Search for a person…",
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
people: Person[];
|
||||||
|
value: string;
|
||||||
|
onChange: (id: string) => void;
|
||||||
|
/** When set, the dropdown offers a "Create '<typed name>'" action. */
|
||||||
|
onCreate?: (name: 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 || (onCreate && query.trim())) && (
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
{onCreate && query.trim() && (
|
||||||
|
<li className={matches.length > 0 ? "border-t border-[var(--border)]" : ""}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const name = query.trim();
|
||||||
|
setOpen(false);
|
||||||
|
onCreate(name);
|
||||||
|
}}
|
||||||
|
className="block w-full px-3 py-2 text-left text-sm text-bronze hover:bg-[var(--muted-bg,rgba(0,0,0,0.04))]"
|
||||||
|
>
|
||||||
|
+ Create “{query.trim()}”
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
// Slim header for the public (no-auth) surface: /p/* and /explore.
|
||||||
|
export function PublicHeader() {
|
||||||
|
return (
|
||||||
|
<header className="border-b border-[var(--border)]">
|
||||||
|
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
|
||||||
|
<Link href="/" aria-label="Provenance — home" className="flex items-center">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-6 w-auto" />
|
||||||
|
</Link>
|
||||||
|
<nav className="flex items-center gap-4 text-sm">
|
||||||
|
<Link href="/explore" className="text-[var(--muted)] hover:text-[var(--foreground)]">
|
||||||
|
Explore
|
||||||
|
</Link>
|
||||||
|
<Link href="/login" className="text-bronze hover:underline">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Vendored family-chart styles (the package blocks the CSS subpath export).
|
||||||
|
import "../app/trees/[id]/tree/chart.css";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import type { components } from "@/lib/api/schema";
|
||||||
|
import { DepthControl } from "@/components/depth-control";
|
||||||
|
|
||||||
|
type Person = components["schemas"]["PersonRead"];
|
||||||
|
type Relationship = components["schemas"]["RelationshipRead"];
|
||||||
|
type Event = components["schemas"]["EventRead"];
|
||||||
|
|
||||||
|
function splitName(name: string | null | undefined): [string, string] {
|
||||||
|
const t = (name ?? "").trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (t.length <= 1) return [name ?? "", ""];
|
||||||
|
return [t.slice(0, -1).join(" "), t[t.length - 1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only family-chart hourglass for the public surface. Same renderer the
|
||||||
|
* member tree view uses (incl. the cycle-sanitisation that keeps a bad graph
|
||||||
|
* from blowing the stack), fed by already-redacted public data. Clicking a card
|
||||||
|
* recenters; `onOpen` links out to the person's public page.
|
||||||
|
*/
|
||||||
|
export function PublicTreeChart({
|
||||||
|
people,
|
||||||
|
rels,
|
||||||
|
events,
|
||||||
|
focusId,
|
||||||
|
onFocus,
|
||||||
|
onOpen,
|
||||||
|
}: {
|
||||||
|
people: Person[];
|
||||||
|
rels: Relationship[];
|
||||||
|
events: Event[];
|
||||||
|
focusId: string | null;
|
||||||
|
onFocus: (id: string) => void;
|
||||||
|
onOpen?: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const chartRef = useRef<any>(null);
|
||||||
|
|
||||||
|
// Generations to show around the focus, each settable (or "all"). ALL_DEPTH is
|
||||||
|
// a number bigger than any real lineage; the chart only renders real people.
|
||||||
|
const [ancDepth, setAncDepth] = useState(3);
|
||||||
|
const [progDepth, setProgDepth] = useState(2);
|
||||||
|
const [ancAll, setAncAll] = useState(false);
|
||||||
|
const [progAll, setProgAll] = useState(false);
|
||||||
|
const ALL_DEPTH = 100;
|
||||||
|
const effAnc = ancAll ? ALL_DEPTH : ancDepth;
|
||||||
|
const effProg = progAll ? ALL_DEPTH : progDepth;
|
||||||
|
|
||||||
|
const parentsOf = useCallback(
|
||||||
|
(id: string) =>
|
||||||
|
rels.filter((x) => x.type === "parent_child" && x.person_to_id === id).map((x) => x.person_from_id),
|
||||||
|
[rels],
|
||||||
|
);
|
||||||
|
const partnersOf = useCallback(
|
||||||
|
(id: string) =>
|
||||||
|
rels
|
||||||
|
.filter((x) => x.type === "partnership" && (x.person_from_id === id || x.person_to_id === id))
|
||||||
|
.map((x) => (x.person_from_id === id ? x.person_to_id : x.person_from_id)),
|
||||||
|
[rels],
|
||||||
|
);
|
||||||
|
const years = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev.person_id && ev.event_type === "birth" && !m.has(ev.person_id)) {
|
||||||
|
const y = ev.date_start ? ev.date_start.slice(0, 4) : ev.date_value ?? "";
|
||||||
|
if (y) m.set(ev.person_id, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [events]);
|
||||||
|
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
|
||||||
|
const nameOf = useCallback((id: string) => byId.get(id)?.primary_name ?? "person", [byId]);
|
||||||
|
|
||||||
|
// Build the chart when the data changes (not on focus — recenter handles that).
|
||||||
|
useEffect(() => {
|
||||||
|
if (people.length === 0 || !containerRef.current) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const alive = new Set(people.map((pp) => pp.id));
|
||||||
|
const ok = (ids: string[], self: string) =>
|
||||||
|
[...new Set(ids)].filter((id) => alive.has(id) && id !== self);
|
||||||
|
|
||||||
|
const parentsMap = new Map<string, string[]>();
|
||||||
|
const childrenMap = new Map<string, string[]>();
|
||||||
|
const isAncestorOf = (ancestor: string, of: string): boolean => {
|
||||||
|
const stack = [...(parentsMap.get(of) ?? [])];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
while (stack.length) {
|
||||||
|
const n = stack.pop()!;
|
||||||
|
if (n === ancestor) return true;
|
||||||
|
if (seen.has(n)) continue;
|
||||||
|
seen.add(n);
|
||||||
|
for (const p of parentsMap.get(n) ?? []) stack.push(p);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for (const pp of people) {
|
||||||
|
const accepted: string[] = [];
|
||||||
|
for (const par of ok(parentsOf(pp.id), pp.id)) {
|
||||||
|
if (isAncestorOf(pp.id, par)) continue;
|
||||||
|
accepted.push(par);
|
||||||
|
parentsMap.set(pp.id, accepted);
|
||||||
|
childrenMap.set(par, [...(childrenMap.get(par) ?? []), pp.id]);
|
||||||
|
}
|
||||||
|
parentsMap.set(pp.id, accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = people.map((pp) => {
|
||||||
|
const [fn, ln] = splitName(pp.primary_name);
|
||||||
|
return {
|
||||||
|
id: pp.id,
|
||||||
|
data: {
|
||||||
|
"first name": fn || "Unnamed",
|
||||||
|
"last name": ln,
|
||||||
|
birthday: years.get(pp.id) ?? "",
|
||||||
|
gender: pp.gender === "female" ? "F" : "M",
|
||||||
|
},
|
||||||
|
rels: {
|
||||||
|
spouses: ok(partnersOf(pp.id), pp.id),
|
||||||
|
parents: parentsMap.get(pp.id) ?? [],
|
||||||
|
children: childrenMap.get(pp.id) ?? [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const f3 = await import("family-chart");
|
||||||
|
if (cancelled || !containerRef.current) return;
|
||||||
|
try {
|
||||||
|
containerRef.current.innerHTML = "";
|
||||||
|
const chart = f3.createChart(containerRef.current, data);
|
||||||
|
chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]);
|
||||||
|
chart.setOrientationHorizontal();
|
||||||
|
chart.setAncestryDepth?.(effAnc);
|
||||||
|
chart.setProgenyDepth?.(effProg);
|
||||||
|
chart.setAfterUpdate?.(() => {
|
||||||
|
const md = chart.getMainDatum?.();
|
||||||
|
const id = md?.id ?? md?.data?.id;
|
||||||
|
if (id) onFocus(id);
|
||||||
|
});
|
||||||
|
chartRef.current = chart;
|
||||||
|
if (focusId) chart.updateMainId(focusId);
|
||||||
|
chart.updateTree({ initial: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("public tree render failed", err);
|
||||||
|
if (containerRef.current) containerRef.current.innerHTML = "";
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [people, rels, events]);
|
||||||
|
|
||||||
|
// Recenter when the focus changes without rebuilding the chart.
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusId && chartRef.current) {
|
||||||
|
chartRef.current.updateMainId?.(focusId);
|
||||||
|
chartRef.current.updateTree?.();
|
||||||
|
}
|
||||||
|
}, [focusId]);
|
||||||
|
|
||||||
|
// Apply depth changes to the live chart without a full rebuild.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chartRef.current) return;
|
||||||
|
chartRef.current.setAncestryDepth?.(effAnc);
|
||||||
|
chartRef.current.setProgenyDepth?.(effProg);
|
||||||
|
chartRef.current.updateTree?.();
|
||||||
|
}, [effAnc, effProg]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-lg border border-[var(--border)] bg-[var(--surface)] px-4 py-2.5">
|
||||||
|
<span className="text-sm font-medium">Generations</span>
|
||||||
|
<DepthControl
|
||||||
|
label="Ancestors"
|
||||||
|
icon="↑"
|
||||||
|
value={ancDepth}
|
||||||
|
all={ancAll}
|
||||||
|
onValue={(v) => {
|
||||||
|
setAncAll(false);
|
||||||
|
setAncDepth(v);
|
||||||
|
}}
|
||||||
|
onAll={setAncAll}
|
||||||
|
/>
|
||||||
|
<DepthControl
|
||||||
|
label="Descendants"
|
||||||
|
icon="↓"
|
||||||
|
value={progDepth}
|
||||||
|
all={progAll}
|
||||||
|
onValue={(v) => {
|
||||||
|
setProgAll(false);
|
||||||
|
setProgDepth(v);
|
||||||
|
}}
|
||||||
|
onAll={setProgAll}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="f3 rounded-xl border border-[var(--border)]"
|
||||||
|
style={{ width: "100%", height: "74vh", background: "var(--surface)" }}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="text-sm text-[var(--muted)]">
|
||||||
|
Drag to pan · scroll to zoom · click a person to recenter.
|
||||||
|
</p>
|
||||||
|
{focusId && onOpen && (
|
||||||
|
<button onClick={() => onOpen(focusId)} className="text-sm text-bronze hover:underline">
|
||||||
|
Open {nameOf(focusId)} →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Monitor, Moon, Sun } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type Theme = "light" | "dark" | "system";
|
||||||
|
const ORDER: Theme[] = ["system", "light", "dark"];
|
||||||
|
const ICON = { system: Monitor, light: Sun, dark: Moon };
|
||||||
|
const LABEL = { system: "System", light: "Light", dark: "Dark" };
|
||||||
|
|
||||||
|
function apply(theme: Theme) {
|
||||||
|
const dark =
|
||||||
|
theme === "dark" ||
|
||||||
|
(theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
document.documentElement.classList.toggle("dark", dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cycles theme System → Light → Dark, persisting the choice in localStorage. */
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const [theme, setTheme] = useState<Theme>("system");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = (localStorage.getItem("theme") as Theme) || "system";
|
||||||
|
setTheme(saved);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keep "system" in sync if the OS preference changes while selected.
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme !== "system") return;
|
||||||
|
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const onChange = () => apply("system");
|
||||||
|
mq.addEventListener("change", onChange);
|
||||||
|
return () => mq.removeEventListener("change", onChange);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
function cycle() {
|
||||||
|
const next = ORDER[(ORDER.indexOf(theme) + 1) % ORDER.length];
|
||||||
|
localStorage.setItem("theme", next);
|
||||||
|
setTheme(next);
|
||||||
|
apply(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = ICON[theme];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={cycle}
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-[var(--muted)] transition-colors hover:bg-bronze/[0.07] hover:text-[var(--foreground)]"
|
||||||
|
title={`Theme: ${LABEL[theme]} (click to change)`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
Theme: {LABEL[theme]}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+1955
-10
File diff suppressed because it is too large
Load Diff
+2917
-3
File diff suppressed because it is too large
Load Diff
Generated
+666
-1
@@ -24,6 +24,7 @@
|
|||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"openapi-typescript": "^7.5.0",
|
"openapi-typescript": "^7.5.0",
|
||||||
|
"patch-package": "^8.0.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0"
|
||||||
@@ -1018,6 +1019,12 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@yarnpkg/lockfile": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
@@ -1036,6 +1043,21 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/argparse": {
|
"node_modules/argparse": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
@@ -1057,6 +1079,65 @@
|
|||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/braces": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"fill-range": "^7.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bind": {
|
||||||
|
"version": "1.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||||
|
"integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"get-intrinsic": "^1.3.0",
|
||||||
|
"set-function-length": "^1.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bound": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"get-intrinsic": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001797",
|
"version": "1.0.30001797",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz",
|
||||||
@@ -1076,12 +1157,55 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/chalk": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.1.0",
|
||||||
|
"supports-color": "^7.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chalk/node_modules/supports-color": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"has-flag": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/change-case": {
|
"node_modules/change-case": {
|
||||||
"version": "5.4.4",
|
"version": "5.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
|
||||||
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
|
"integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/ci-info": {
|
||||||
|
"version": "3.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
|
||||||
|
"integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/sibiraj-s"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/class-variance-authority": {
|
"node_modules/class-variance-authority": {
|
||||||
"version": "0.7.1",
|
"version": "0.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
||||||
@@ -1106,6 +1230,24 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/colorette": {
|
"node_modules/colorette": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
|
||||||
@@ -1120,6 +1262,20 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cross-spawn": {
|
||||||
|
"version": "7.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"path-key": "^3.1.0",
|
||||||
|
"shebang-command": "^2.0.0",
|
||||||
|
"which": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -1513,6 +1669,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/define-data-property": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"es-define-property": "^1.0.0",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/delaunator": {
|
"node_modules/delaunator": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
|
||||||
@@ -1530,6 +1703,20 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.23.0",
|
"version": "5.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz",
|
||||||
@@ -1543,6 +1730,36 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/family-chart": {
|
"node_modules/family-chart": {
|
||||||
"version": "0.9.0",
|
"version": "0.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/family-chart/-/family-chart-0.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/family-chart/-/family-chart-0.9.0.tgz",
|
||||||
@@ -1557,12 +1774,150 @@
|
|||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/fill-range": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"to-regex-range": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/find-yarn-workspace-root": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"micromatch": "^4.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fs-extra": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.2.0",
|
||||||
|
"jsonfile": "^6.0.1",
|
||||||
|
"universalify": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/graceful-fs": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/has-flag": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-property-descriptors": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"es-define-property": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/https-proxy-agent": {
|
"node_modules/https-proxy-agent": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
@@ -1607,6 +1962,54 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-docker": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"is-docker": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-number": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-wsl": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"is-docker": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/isarray": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/isexe": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||||
@@ -1649,6 +2052,55 @@
|
|||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/json-stable-stringify": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind": "^1.0.8",
|
||||||
|
"call-bound": "^1.0.4",
|
||||||
|
"isarray": "^2.0.5",
|
||||||
|
"jsonify": "^0.0.1",
|
||||||
|
"object-keys": "^1.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jsonfile": {
|
||||||
|
"version": "6.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||||
|
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"universalify": "^2.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"graceful-fs": "^4.1.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jsonify": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/klaw-sync": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"graceful-fs": "^4.1.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -1915,6 +2367,28 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromatch": {
|
||||||
|
"version": "4.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
|
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"braces": "^3.0.3",
|
||||||
|
"picomatch": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "5.1.9",
|
"version": "5.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||||
@@ -1927,6 +2401,15 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -2028,6 +2511,31 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-keys": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/open": {
|
||||||
|
"version": "7.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz",
|
||||||
|
"integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"is-docker": "^2.0.0",
|
||||||
|
"is-wsl": "^2.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/openapi-fetch": {
|
"node_modules/openapi-fetch": {
|
||||||
"version": "0.13.8",
|
"version": "0.13.8",
|
||||||
"resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.13.8.tgz",
|
"resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.13.8.tgz",
|
||||||
@@ -2078,11 +2586,61 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/patch-package": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@yarnpkg/lockfile": "^1.1.0",
|
||||||
|
"chalk": "^4.1.2",
|
||||||
|
"ci-info": "^3.7.0",
|
||||||
|
"cross-spawn": "^7.0.3",
|
||||||
|
"find-yarn-workspace-root": "^2.0.0",
|
||||||
|
"fs-extra": "^10.0.0",
|
||||||
|
"json-stable-stringify": "^1.0.2",
|
||||||
|
"klaw-sync": "^6.0.0",
|
||||||
|
"minimist": "^1.2.6",
|
||||||
|
"open": "^7.4.2",
|
||||||
|
"semver": "^7.5.3",
|
||||||
|
"slash": "^2.0.0",
|
||||||
|
"tmp": "^0.2.4",
|
||||||
|
"yaml": "^2.2.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"patch-package": "index.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14",
|
||||||
|
"npm": ">5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/path-key": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/picomatch": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pluralize": {
|
"node_modules/pluralize": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||||
@@ -2172,7 +2730,7 @@
|
|||||||
"version": "7.8.2",
|
"version": "7.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
|
||||||
"integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
|
"integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
|
||||||
"optional": true,
|
"devOptional": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
@@ -2180,6 +2738,23 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-function-length": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"define-data-property": "^1.1.4",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-intrinsic": "^1.2.4",
|
||||||
|
"gopd": "^1.0.1",
|
||||||
|
"has-property-descriptors": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sharp": {
|
"node_modules/sharp": {
|
||||||
"version": "0.34.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||||
@@ -2224,6 +2799,36 @@
|
|||||||
"@img/sharp-win32-x64": "0.34.5"
|
"@img/sharp-win32-x64": "0.34.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/shebang-command": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"shebang-regex": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shebang-regex": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/slash": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -2294,6 +2899,27 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tmp": {
|
||||||
|
"version": "0.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||||
|
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/to-regex-range": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"is-number": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
@@ -2330,12 +2956,51 @@
|
|||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/universalify": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/uri-js-replace": {
|
"node_modules/uri-js-replace": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz",
|
||||||
"integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
|
"integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/which": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"isexe": "^2.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-which": "bin/node-which"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yaml-ast-parser": {
|
"node_modules/yaml-ast-parser": {
|
||||||
"version": "0.0.43",
|
"version": "0.0.43",
|
||||||
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
|
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
"postinstall": "patch-package",
|
||||||
"gen:api": "openapi-typescript ./openapi.json -o ./lib/api/schema.d.ts --default-non-nullable false"
|
"gen:api": "openapi-typescript ./openapi.json -o ./lib/api/schema.d.ts --default-non-nullable false"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"openapi-typescript": "^7.5.0",
|
"openapi-typescript": "^7.5.0",
|
||||||
|
"patch-package": "^8.0.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user