Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| cf5518c7ec | |||
| 26df03cfd7 | |||
| ab064bce6e | |||
| 76b7f453c1 | |||
| 438d2db2e7 | |||
| 99913ada94 | |||
| 584b323121 | |||
| 4788ae7723 | |||
| 51f0066e61 | |||
| bfa6c0782a | |||
| 2f21e767f3 | |||
| f6bcf198ee | |||
| b13fafd624 | |||
| 631d050540 | |||
| d48029a407 | |||
| 18dea507d1 | |||
| 99a660485e | |||
| cf6dcf9ce2 | |||
| 22bc536978 | |||
| f2205b93f4 | |||
| b0c7c8570b |
@@ -19,6 +19,7 @@ These are product invariants, not preferences. Do not violate them, and flag any
|
|||||||
5. **Sources are first-class.** Don't model citations as free-text afterthoughts. A `Source` is a reusable entity; a `Citation` links it to a specific fact.
|
5. **Sources are first-class.** Don't model citations as free-text afterthoughts. A `Source` is a reusable entity; a `Citation` links it to a specific fact.
|
||||||
6. **Only legal data sources.** Ship scrapers/connectors only for permissible sources (FamilySearch API, Find A Grave, WikiTree, BLM/GLO, USGS, public-domain newspapers, public county records). Never add connectors for paywalled/terms-prohibited sites (Ancestry, MyHeritage, 23andMe).
|
6. **Only legal data sources.** Ship scrapers/connectors only for permissible sources (FamilySearch API, Find A Grave, WikiTree, BLM/GLO, USGS, public-domain newspapers, public county records). Never add connectors for paywalled/terms-prohibited sites (Ancestry, MyHeritage, 23andMe).
|
||||||
7. **Everything is configurable via environment.** Auth, mail, object storage, database, model providers, scrapers — all twelve-factor. No hard-coded endpoints or keys.
|
7. **Everything is configurable via environment.** Auth, mail, object storage, database, model providers, scrapers — all twelve-factor. No hard-coded endpoints or keys.
|
||||||
|
8. **Full CRUD on every object.** Every stored entity (person, name, event, relationship, source, citation, media, tree, …) must support create, read, **update**, and delete — in the API *and* the UI. Historical research is constant correction and new information, so nothing is write-once. Any new feature or data type ships with all four operations; an entity you can create but not edit is a bug.
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ from fastapi import APIRouter
|
|||||||
from app.api.v1 import (
|
from app.api.v1 import (
|
||||||
auth,
|
auth,
|
||||||
citations,
|
citations,
|
||||||
|
cleanup,
|
||||||
events,
|
events,
|
||||||
|
gedcom,
|
||||||
media,
|
media,
|
||||||
|
names,
|
||||||
persons,
|
persons,
|
||||||
relationships,
|
relationships,
|
||||||
sources,
|
sources,
|
||||||
@@ -19,8 +22,11 @@ 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(cleanup.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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import uuid
|
|||||||
from fastapi import APIRouter, status
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, SessionDep
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
from app.schemas.source import CitationCreate, CitationRead
|
from app.schemas.source import CitationCreate, CitationRead, CitationUpdate
|
||||||
from app.services import citation_service, tree_service
|
from app.services import citation_service, tree_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/trees", tags=["citations"])
|
router = APIRouter(prefix="/trees", tags=["citations"])
|
||||||
@@ -31,6 +31,25 @@ async def list_citations(
|
|||||||
return [CitationRead.model_validate(c) for c in citations]
|
return [CitationRead.model_validate(c) for c in citations]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{tree_id}/citations/{citation_id}", response_model=CitationRead)
|
||||||
|
async def update_citation(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
citation_id: uuid.UUID,
|
||||||
|
data: CitationUpdate,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
) -> CitationRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
citation = await citation_service.update_citation(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
citation_id=citation_id,
|
||||||
|
changes=data.model_dump(exclude_unset=True),
|
||||||
|
)
|
||||||
|
return CitationRead.model_validate(citation)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{tree_id}/citations/{citation_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{tree_id}/citations/{citation_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_citation(
|
async def delete_citation(
|
||||||
tree_id: uuid.UUID, citation_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
tree_id: uuid.UUID, citation_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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.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)
|
||||||
@@ -3,7 +3,7 @@ import uuid
|
|||||||
from fastapi import APIRouter, status
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, SessionDep
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
from app.schemas.event import EventCreate, EventRead
|
from app.schemas.event import EventCreate, EventRead, EventUpdate
|
||||||
from app.services import event_service, tree_service
|
from app.services import event_service, tree_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/trees", tags=["events"])
|
router = APIRouter(prefix="/trees", tags=["events"])
|
||||||
@@ -20,6 +20,15 @@ async def create_event(
|
|||||||
return EventRead.model_validate(event)
|
return EventRead.model_validate(event)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/events", response_model=list[EventRead])
|
||||||
|
async def list_tree_events(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[EventRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
events = await event_service.list_events(session, viewer_id=current.id, tree=tree)
|
||||||
|
return [EventRead.model_validate(e) for e in events]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{tree_id}/persons/{person_id}/events", response_model=list[EventRead])
|
@router.get("/{tree_id}/persons/{person_id}/events", response_model=list[EventRead])
|
||||||
async def list_person_events(
|
async def list_person_events(
|
||||||
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
@@ -31,6 +40,25 @@ async def list_person_events(
|
|||||||
return [EventRead.model_validate(e) for e in events]
|
return [EventRead.model_validate(e) for e in events]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{tree_id}/events/{event_id}", response_model=EventRead)
|
||||||
|
async def update_event(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
event_id: uuid.UUID,
|
||||||
|
data: EventUpdate,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
) -> EventRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
event = await event_service.update_event(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
event_id=event_id,
|
||||||
|
changes=data.model_dump(exclude_unset=True),
|
||||||
|
)
|
||||||
|
return EventRead.model_validate(event)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{tree_id}/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{tree_id}/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_event(
|
async def delete_event(
|
||||||
tree_id: uuid.UUID, event_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
tree_id: uuid.UUID, event_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, Form, Response, UploadFile
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.gedcom import ImportPreview, ImportReport
|
||||||
|
from app.services import gedcom, tree_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["gedcom"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/gedcom/preview", response_model=ImportPreview)
|
||||||
|
async def preview_gedcom(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
) -> ImportPreview:
|
||||||
|
"""Dry run: report counts and incoming people that look like duplicates of
|
||||||
|
existing ones, so the user can choose how to resolve each before importing."""
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
text = (await file.read()).decode("utf-8", errors="replace")
|
||||||
|
report = await gedcom.preview_gedcom(session, actor=current, tree=tree, text=text)
|
||||||
|
return ImportPreview(**report)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/gedcom/import", response_model=ImportReport)
|
||||||
|
async def import_gedcom(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
default_action: str = Form("new"),
|
||||||
|
resolutions: str = Form("{}"),
|
||||||
|
) -> ImportReport:
|
||||||
|
"""Import a GEDCOM. ``default_action`` (new|skip|merge|overwrite) applies to
|
||||||
|
incoming people that match an existing one; ``resolutions`` is a JSON object
|
||||||
|
{xref: {action, target_id}} overriding it per record."""
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
text = (await file.read()).decode("utf-8", errors="replace")
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/gedcom/export")
|
||||||
|
async def export_gedcom(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> Response:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
text = await gedcom.export_gedcom(session, viewer_id=current.id, tree=tree)
|
||||||
|
safe = "".join(c for c in tree.name if c.isalnum() or c in " -_").strip() or "tree"
|
||||||
|
return Response(
|
||||||
|
content=text,
|
||||||
|
media_type="text/plain",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{safe}.ged"'},
|
||||||
|
)
|
||||||
@@ -3,7 +3,7 @@ import uuid
|
|||||||
from fastapi import APIRouter, File, Form, Response, UploadFile, status
|
from fastapi import APIRouter, File, Form, Response, UploadFile, status
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
|
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
|
||||||
from app.schemas.media import MediaRead
|
from app.schemas.media import MediaRead, MediaUpdate
|
||||||
from app.services import media_service, tree_service
|
from app.services import media_service, tree_service
|
||||||
|
|
||||||
|
|
||||||
@@ -81,6 +81,26 @@ async def media_content(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{tree_id}/media/{media_id}", response_model=MediaRead)
|
||||||
|
async def update_media(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
media_id: uuid.UUID,
|
||||||
|
data: MediaUpdate,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
store: ObjectStoreDep,
|
||||||
|
) -> MediaRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
media = await media_service.update_media(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
media_id=media_id,
|
||||||
|
changes=data.model_dump(exclude_unset=True),
|
||||||
|
)
|
||||||
|
return _read(media)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{tree_id}/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{tree_id}/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_media(
|
async def delete_media(
|
||||||
tree_id: uuid.UUID, media_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
tree_id: uuid.UUID, media_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -3,7 +3,7 @@ import uuid
|
|||||||
from fastapi import APIRouter, status
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, SessionDep
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
from app.schemas.person import PersonCreate, PersonRead
|
from app.schemas.person import PersonCreate, PersonRead, PersonUpdate
|
||||||
from app.services import person_service, tree_service
|
from app.services import person_service, tree_service
|
||||||
|
|
||||||
# Persons are nested under their tree (the tenant boundary).
|
# Persons are nested under their tree (the tenant boundary).
|
||||||
@@ -36,13 +36,73 @@ async def create_person(
|
|||||||
|
|
||||||
@router.get("/{tree_id}/persons", response_model=list[PersonRead])
|
@router.get("/{tree_id}/persons", response_model=list[PersonRead])
|
||||||
async def list_persons(
|
async def list_persons(
|
||||||
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
tree_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
deleted: bool = False,
|
||||||
|
q: str | None = None,
|
||||||
) -> list[PersonRead]:
|
) -> list[PersonRead]:
|
||||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree)
|
if q:
|
||||||
|
persons = await person_service.search_persons(
|
||||||
|
session, viewer_id=current.id, tree=tree, query=q
|
||||||
|
)
|
||||||
|
elif deleted:
|
||||||
|
persons = await person_service.list_deleted_persons(
|
||||||
|
session, viewer_id=current.id, tree=tree
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree)
|
||||||
return [PersonRead.model_validate(p) for p in persons]
|
return [PersonRead.model_validate(p) for p in persons]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{tree_id}/persons/{person_id}", response_model=PersonRead)
|
||||||
|
async def update_person(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
data: PersonUpdate,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
) -> PersonRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
person = await person_service.update_person(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
person_id=person_id,
|
||||||
|
changes=data.model_dump(exclude_unset=True),
|
||||||
|
)
|
||||||
|
return PersonRead.model_validate(person)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tree_id}/persons/{person_id}")
|
||||||
|
async def delete_person(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
cascade: bool = False,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Delete a person. ``cascade=true`` also deletes all descendants. Returns
|
||||||
|
the number of persons deleted (1 unless cascading)."""
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
deleted = await person_service.delete_person(
|
||||||
|
session, actor=current, tree=tree, person_id=person_id, cascade=cascade
|
||||||
|
)
|
||||||
|
return {"deleted": deleted}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/persons/{person_id}/restore", response_model=PersonRead)
|
||||||
|
async def restore_person(
|
||||||
|
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> PersonRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
person = await person_service.restore_person(
|
||||||
|
session, actor=current, tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
|
return PersonRead.model_validate(person)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{tree_id}/persons/{person_id}", response_model=PersonRead)
|
@router.get("/{tree_id}/persons/{person_id}", response_model=PersonRead)
|
||||||
async def get_person(
|
async def get_person(
|
||||||
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import uuid
|
|||||||
from fastapi import APIRouter, status
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, SessionDep
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
from app.schemas.relationship import RelationshipCreate, RelationshipRead
|
from app.schemas.relationship import RelationshipCreate, RelationshipRead, RelationshipUpdate
|
||||||
from app.services import relationship_service, tree_service
|
from app.services import relationship_service, tree_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/trees", tags=["relationships"])
|
router = APIRouter(prefix="/trees", tags=["relationships"])
|
||||||
@@ -24,6 +24,15 @@ async def create_relationship(
|
|||||||
return RelationshipRead.model_validate(relationship)
|
return RelationshipRead.model_validate(relationship)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/relationships", response_model=list[RelationshipRead])
|
||||||
|
async def list_relationships(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[RelationshipRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
rels = await relationship_service.list_relationships(session, viewer_id=current.id, tree=tree)
|
||||||
|
return [RelationshipRead.model_validate(r) for r in rels]
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{tree_id}/persons/{person_id}/relationships",
|
"/{tree_id}/persons/{person_id}/relationships",
|
||||||
response_model=list[RelationshipRead],
|
response_model=list[RelationshipRead],
|
||||||
@@ -38,6 +47,25 @@ async def list_person_relationships(
|
|||||||
return [RelationshipRead.model_validate(r) for r in rels]
|
return [RelationshipRead.model_validate(r) for r in rels]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{tree_id}/relationships/{relationship_id}", response_model=RelationshipRead)
|
||||||
|
async def update_relationship(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
relationship_id: uuid.UUID,
|
||||||
|
data: RelationshipUpdate,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
) -> RelationshipRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
rel = await relationship_service.update_relationship(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
relationship_id=relationship_id,
|
||||||
|
changes=data.model_dump(exclude_unset=True),
|
||||||
|
)
|
||||||
|
return RelationshipRead.model_validate(rel)
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/{tree_id}/relationships/{relationship_id}", status_code=status.HTTP_204_NO_CONTENT
|
"/{tree_id}/relationships/{relationship_id}", status_code=status.HTTP_204_NO_CONTENT
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import uuid
|
|||||||
from fastapi import APIRouter, status
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, SessionDep
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
from app.schemas.source import SourceCreate, SourceRead
|
from app.schemas.source import SourceCreate, SourceRead, SourceUpdate
|
||||||
from app.services import source_service, tree_service
|
from app.services import source_service, tree_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/trees", tags=["sources"])
|
router = APIRouter(prefix="/trees", tags=["sources"])
|
||||||
@@ -40,6 +40,25 @@ async def get_source(
|
|||||||
return SourceRead.model_validate(source)
|
return SourceRead.model_validate(source)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{tree_id}/sources/{source_id}", response_model=SourceRead)
|
||||||
|
async def update_source(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
source_id: uuid.UUID,
|
||||||
|
data: SourceUpdate,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
) -> SourceRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
source = await source_service.update_source(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
source_id=source_id,
|
||||||
|
changes=data.model_dump(exclude_unset=True),
|
||||||
|
)
|
||||||
|
return SourceRead.model_validate(source)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{tree_id}/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{tree_id}/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_source(
|
async def delete_source(
|
||||||
tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import uuid
|
|||||||
from fastapi import APIRouter, status
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, SessionDep
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
from app.schemas.tree import TreeCreate, TreeRead
|
from app.schemas.tree import TreeCreate, TreeRead, TreeUpdate
|
||||||
from app.services import tree_service
|
from app.services import tree_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/trees", tags=["trees"])
|
router = APIRouter(prefix="/trees", tags=["trees"])
|
||||||
@@ -22,8 +22,13 @@ async def create_tree(data: TreeCreate, session: SessionDep, current: CurrentUse
|
|||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[TreeRead])
|
@router.get("", response_model=list[TreeRead])
|
||||||
async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeRead]:
|
async def list_my_trees(
|
||||||
trees = await tree_service.list_trees_for_user(session, user=current)
|
session: SessionDep, current: CurrentUser, deleted: bool = False
|
||||||
|
) -> list[TreeRead]:
|
||||||
|
if deleted:
|
||||||
|
trees = await tree_service.list_deleted_trees_for_user(session, user=current)
|
||||||
|
else:
|
||||||
|
trees = await tree_service.list_trees_for_user(session, user=current)
|
||||||
return [TreeRead.model_validate(t) for t in trees]
|
return [TreeRead.model_validate(t) for t in trees]
|
||||||
|
|
||||||
|
|
||||||
@@ -31,3 +36,24 @@ async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeR
|
|||||||
async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
|
async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
|
||||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
return TreeRead.model_validate(tree)
|
return TreeRead.model_validate(tree)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{tree_id}", response_model=TreeRead)
|
||||||
|
async def update_tree(
|
||||||
|
tree_id: uuid.UUID, data: TreeUpdate, session: SessionDep, current: CurrentUser
|
||||||
|
) -> TreeRead:
|
||||||
|
tree = await tree_service.update_tree(
|
||||||
|
session, actor=current, tree_id=tree_id, changes=data.model_dump(exclude_unset=True)
|
||||||
|
)
|
||||||
|
return TreeRead.model_validate(tree)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> None:
|
||||||
|
await tree_service.delete_tree(session, actor=current, tree_id=tree_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/restore", response_model=TreeRead)
|
||||||
|
async def restore_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
|
||||||
|
tree = await tree_service.restore_tree(session, actor=current, tree_id=tree_id)
|
||||||
|
return TreeRead.model_validate(tree)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ aliases) so name changes over time are first-class.
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, text
|
from sqlalchemy import Boolean, ForeignKey, Index, Integer, String, Text, text
|
||||||
from sqlalchemy import Enum as SAEnum
|
from sqlalchemy import Enum as SAEnum
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
@@ -33,6 +33,22 @@ class Person(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
|
|||||||
|
|
||||||
class Name(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
|
class Name(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
|
||||||
__tablename__ = "names"
|
__tablename__ = "names"
|
||||||
|
# Trigram indexes for fuzzy name search (Mueller/Müller/Muller). Requires the
|
||||||
|
# pg_trgm extension (enabled in the accompanying migration).
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"ix_names_given_trgm",
|
||||||
|
"given",
|
||||||
|
postgresql_using="gin",
|
||||||
|
postgresql_ops={"given": "gin_trgm_ops"},
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_names_surname_trgm",
|
||||||
|
"surname",
|
||||||
|
postgresql_using="gin",
|
||||||
|
postgresql_ops={"surname": "gin_trgm_ops"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
person_id: Mapped[uuid.UUID] = mapped_column(
|
person_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
ForeignKey("persons.id", ondelete="CASCADE"), index=True
|
ForeignKey("persons.id", ondelete="CASCADE"), index=True
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -20,6 +20,19 @@ class EventCreate(BaseModel):
|
|||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EventUpdate(BaseModel):
|
||||||
|
# All optional; only fields explicitly sent are changed (PATCH semantics).
|
||||||
|
event_type: str | None = None
|
||||||
|
place_id: uuid.UUID | None = None
|
||||||
|
date_value: str | None = None
|
||||||
|
date_start: date | None = None
|
||||||
|
date_end: date | None = None
|
||||||
|
date_precision: str | None = None
|
||||||
|
calendar: str | None = None
|
||||||
|
detail: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class EventRead(BaseModel):
|
class EventRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ImportReport(BaseModel):
|
||||||
|
counts: dict[str, int]
|
||||||
|
unmapped_tags: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateMatch(BaseModel):
|
||||||
|
# An incoming GEDCOM person that resembles an existing one in the tree.
|
||||||
|
xref: str
|
||||||
|
incoming_name: str
|
||||||
|
incoming_birth_year: str | None = None
|
||||||
|
existing_person_id: uuid.UUID
|
||||||
|
existing_name: str
|
||||||
|
existing_birth_year: str | None = None
|
||||||
|
score: str # "high" | "medium"
|
||||||
|
|
||||||
|
|
||||||
|
class ImportPreview(BaseModel):
|
||||||
|
counts: dict[str, int]
|
||||||
|
potential_duplicates: list[DuplicateMatch]
|
||||||
|
unmapped_tags: list[str]
|
||||||
@@ -4,6 +4,13 @@ from datetime import datetime
|
|||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class MediaUpdate(BaseModel):
|
||||||
|
title: str | None = None
|
||||||
|
person_id: uuid.UUID | None = None
|
||||||
|
event_id: uuid.UUID | None = None
|
||||||
|
source_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class MediaRead(BaseModel):
|
class MediaRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -15,6 +15,16 @@ class PersonCreate(BaseModel):
|
|||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PersonUpdate(BaseModel):
|
||||||
|
# Person fields + the primary name's parts; only sent fields are changed.
|
||||||
|
given: str | None = None
|
||||||
|
surname: str | None = None
|
||||||
|
gender: str | None = None
|
||||||
|
is_living: bool | None = None
|
||||||
|
privacy: PersonPrivacy | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class PersonRead(BaseModel):
|
class PersonRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ class RelationshipCreate(BaseModel):
|
|||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RelationshipUpdate(BaseModel):
|
||||||
|
qualifier: ParentChildQualifier | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class RelationshipRead(BaseModel):
|
class RelationshipRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,23 @@ class SourceRead(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SourceUpdate(BaseModel):
|
||||||
|
title: str | None = None
|
||||||
|
author: str | None = None
|
||||||
|
source_type: str | None = None
|
||||||
|
repository: str | None = None
|
||||||
|
url: str | None = None
|
||||||
|
citation_text: str | None = None
|
||||||
|
publication_info: str | None = None
|
||||||
|
quality_note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CitationUpdate(BaseModel):
|
||||||
|
page: str | None = None
|
||||||
|
detail: str | None = None
|
||||||
|
confidence: CitationConfidence | None = None
|
||||||
|
|
||||||
|
|
||||||
class CitationCreate(BaseModel):
|
class CitationCreate(BaseModel):
|
||||||
source_id: uuid.UUID
|
source_id: uuid.UUID
|
||||||
# Exactly one target fact.
|
# Exactly one target fact.
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ class TreeCreate(BaseModel):
|
|||||||
visibility: TreeVisibility = TreeVisibility.private
|
visibility: TreeVisibility = TreeVisibility.private
|
||||||
|
|
||||||
|
|
||||||
|
class TreeUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
visibility: TreeVisibility | None = None
|
||||||
|
home_person_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class TreeRead(BaseModel):
|
class TreeRead(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@@ -20,4 +27,5 @@ 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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
@@ -178,6 +178,26 @@ async def request_password_reset(session: AsyncSession, mailer: Mailer, *, email
|
|||||||
await mailer.send_password_reset(to=email, link=_link("/auth/reset-password", raw))
|
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(
|
||||||
|
|||||||
@@ -113,6 +113,38 @@ async def list_citations(
|
|||||||
return list((await session.execute(stmt)).scalars().all())
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def update_citation(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID, changes: dict
|
||||||
|
) -> Citation:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
citation = (
|
||||||
|
await session.execute(
|
||||||
|
select(Citation).where(
|
||||||
|
Citation.id == citation_id,
|
||||||
|
Citation.tree_id == tree.id,
|
||||||
|
Citation.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if citation is None:
|
||||||
|
raise NotFound("citation not found")
|
||||||
|
for key in {"page", "detail", "confidence"} & changes.keys():
|
||||||
|
setattr(citation, key, changes[key])
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="update",
|
||||||
|
entity_type="Citation",
|
||||||
|
entity_id=citation.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after=changes,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(citation)
|
||||||
|
return citation
|
||||||
|
|
||||||
|
|
||||||
async def delete_citation(
|
async def delete_citation(
|
||||||
session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID
|
session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
"""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.event import Event
|
||||||
|
from app.models.person import Name, Person
|
||||||
|
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 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)
|
||||||
@@ -91,6 +91,20 @@ async def create_event(
|
|||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
async def list_events(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
||||||
|
) -> list[Event]:
|
||||||
|
"""All events in the tree — lets the family view compute birth/death years."""
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
stmt = (
|
||||||
|
select(Event)
|
||||||
|
.where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
|
||||||
|
.order_by(Event.date_start.nulls_last(), Event.created_at)
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
async def list_events_for_person(
|
async def list_events_for_person(
|
||||||
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
||||||
) -> list[Event]:
|
) -> list[Event]:
|
||||||
@@ -108,6 +122,44 @@ async def list_events_for_person(
|
|||||||
return list((await session.execute(stmt)).scalars().all())
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def update_event(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
event_id: uuid.UUID,
|
||||||
|
changes: dict,
|
||||||
|
) -> Event:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
event = (
|
||||||
|
await session.execute(
|
||||||
|
select(Event).where(
|
||||||
|
Event.id == event_id, Event.tree_id == tree.id, Event.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if event is None:
|
||||||
|
raise NotFound("event not found")
|
||||||
|
if "place_id" in changes and changes["place_id"] is not None:
|
||||||
|
if not await _belongs_to_tree(session, Place, changes["place_id"], tree.id):
|
||||||
|
raise NotFound("place not found in this tree")
|
||||||
|
for key, value in changes.items():
|
||||||
|
setattr(event, key, value)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="update",
|
||||||
|
entity_type="Event",
|
||||||
|
entity_id=event.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after=changes,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
async def delete_event(
|
async def delete_event(
|
||||||
session: AsyncSession, *, actor: User, tree: Tree, event_id: uuid.UUID
|
session: AsyncSession, *, actor: User, tree: Tree, event_id: uuid.UUID
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,801 @@
|
|||||||
|
"""GEDCOM import/export.
|
||||||
|
|
||||||
|
A pragmatic parser + mapper for the common subset of GEDCOM (5.5.1 / 7 share
|
||||||
|
the line grammar): INDI, FAM, SOUR. Import maps records into a tree and returns
|
||||||
|
a mapping report (counts + unmapped tags); export serializes the tree back to
|
||||||
|
GEDCOM. Runs inline for now — large files should move to the worker later.
|
||||||
|
|
||||||
|
Import is duplicate-aware: ``preview_gedcom`` reports incoming people that look
|
||||||
|
like existing ones, and ``import_gedcom`` applies a per-record resolution
|
||||||
|
(new / skip / merge / overwrite). Names carry their GEDCOM type (a married name
|
||||||
|
imports as a typed alternate, not a second primary).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
|
from sqlalchemy import or_, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.enums import ParentChildQualifier, RelationshipType
|
||||||
|
from app.models.event import Event
|
||||||
|
from app.models.person import Name, Person
|
||||||
|
from app.models.place import Place
|
||||||
|
from app.models.relationship import Relationship
|
||||||
|
from app.models.source import Citation, Source
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import privacy
|
||||||
|
from app.services.audit import record_audit
|
||||||
|
from app.services.exceptions import Forbidden
|
||||||
|
|
||||||
|
# GEDCOM event tag -> our event_type (INDI-level).
|
||||||
|
INDI_EVENTS = {
|
||||||
|
"BIRT": "birth", "DEAT": "death", "BAPM": "baptism", "CHR": "christening",
|
||||||
|
"BURI": "burial", "CREM": "cremation", "RESI": "residence", "CENS": "census",
|
||||||
|
"IMMI": "immigration", "EMIG": "emigration", "OCCU": "occupation",
|
||||||
|
"EDUC": "education", "GRAD": "graduation", "RETI": "retirement",
|
||||||
|
"NATU": "naturalization", "BAPL": "baptism", "RELI": "religion",
|
||||||
|
}
|
||||||
|
# INDI attribute tags whose line VALUE is the fact (no date), stored in detail.
|
||||||
|
VALUE_EVENTS = {"RELI", "OCCU", "EDUC"}
|
||||||
|
# INDI sub-tags consumed elsewhere or intentionally ignored (not "unmapped").
|
||||||
|
INDI_SKIP_TAGS = {
|
||||||
|
"NAME", "SEX", "SOUR", "FAMC", "FAMS", "CHAN", "OBJE", "_UID", "_MARNM", "NOTE",
|
||||||
|
}
|
||||||
|
# FAM-level events.
|
||||||
|
FAM_EVENTS = {"MARR": "marriage", "DIV": "divorce", "ENGA": "engagement"}
|
||||||
|
EVENT_TO_GED = {v: k for k, v in {**INDI_EVENTS, **FAM_EVENTS}.items()}
|
||||||
|
|
||||||
|
# GEDCOM NAME TYPE (or _MARNM-derived) -> our Name.name_type vocabulary.
|
||||||
|
NAME_TYPE_MAP = {
|
||||||
|
"birth": "birth", "maiden": "birth", "married": "married",
|
||||||
|
"aka": "alias", "also known as": "alias", "nickname": "nickname",
|
||||||
|
"religious": "religious", "immigrant": "immigration",
|
||||||
|
"immigration": "immigration", "professional": "alias", "other": "alias",
|
||||||
|
}
|
||||||
|
# Our type -> GEDCOM TYPE on export (birth is the default; emit nothing).
|
||||||
|
EXPORT_TYPE_MAP = {
|
||||||
|
"married": "married", "alias": "aka", "nickname": "nickname",
|
||||||
|
"religious": "religious", "immigration": "immigrant",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GedcomNode:
|
||||||
|
__slots__ = ("level", "tag", "value", "xref", "children")
|
||||||
|
|
||||||
|
def __init__(self, level: int, tag: str, value: str = "", xref: str | None = None):
|
||||||
|
self.level = level
|
||||||
|
self.tag = tag
|
||||||
|
self.value = value
|
||||||
|
self.xref = xref
|
||||||
|
self.children: list[GedcomNode] = []
|
||||||
|
|
||||||
|
def first(self, tag: str) -> "GedcomNode | None":
|
||||||
|
return next((c for c in self.children if c.tag == tag), None)
|
||||||
|
|
||||||
|
def all(self, tag: str) -> list["GedcomNode"]:
|
||||||
|
return [c for c in self.children if c.tag == tag]
|
||||||
|
|
||||||
|
def text(self, tag: str, default: str | None = None) -> str | None:
|
||||||
|
n = self.first(tag)
|
||||||
|
return n.value if n is not None else default
|
||||||
|
|
||||||
|
|
||||||
|
def parse_records(text: str) -> list[GedcomNode]:
|
||||||
|
roots: list[GedcomNode] = []
|
||||||
|
stack: list[GedcomNode] = []
|
||||||
|
for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
||||||
|
line = raw.lstrip("").rstrip()
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = line.split(" ", 1)
|
||||||
|
try:
|
||||||
|
level = int(parts[0])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
rest = parts[1] if len(parts) > 1 else ""
|
||||||
|
xref: str | None = None
|
||||||
|
if rest.startswith("@"):
|
||||||
|
end = rest.find("@", 1)
|
||||||
|
if end != -1:
|
||||||
|
xref = rest[: end + 1]
|
||||||
|
rest = rest[end + 1:].strip()
|
||||||
|
tparts = rest.split(" ", 1)
|
||||||
|
tag = tparts[0]
|
||||||
|
value = tparts[1] if len(tparts) > 1 else ""
|
||||||
|
|
||||||
|
while stack and stack[-1].level >= level:
|
||||||
|
stack.pop()
|
||||||
|
parent = stack[-1] if stack else None
|
||||||
|
|
||||||
|
if tag in ("CONC", "CONT") and parent is not None:
|
||||||
|
parent.value += ("" if tag == "CONC" else "\n") + value
|
||||||
|
continue
|
||||||
|
|
||||||
|
node = GedcomNode(level, tag, value, xref)
|
||||||
|
if parent is None:
|
||||||
|
roots.append(node)
|
||||||
|
else:
|
||||||
|
parent.children.append(node)
|
||||||
|
stack.append(node)
|
||||||
|
return roots
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_name(value: str) -> tuple[str | None, str | None]:
|
||||||
|
if "/" in value:
|
||||||
|
given, _, rest = value.partition("/")
|
||||||
|
surname = rest.split("/", 1)[0]
|
||||||
|
return given.strip() or None, surname.strip() or None
|
||||||
|
return value.strip() or None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_marnm(value: str, base_given: str | None) -> tuple[str | None, str | None]:
|
||||||
|
"""A _MARNM value is sometimes a full name ("Jane /Smith/") and sometimes
|
||||||
|
just the married surname ("Smith"). Keep the given name from the base name
|
||||||
|
in the latter case."""
|
||||||
|
v = (value or "").strip()
|
||||||
|
if "/" in v:
|
||||||
|
g, s = _parse_name(v)
|
||||||
|
return (g or base_given), s
|
||||||
|
return base_given, (v or None)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_names(rec: GedcomNode) -> list[dict]:
|
||||||
|
"""All names for an INDI, typed. Multiple NAME records (each with an optional
|
||||||
|
TYPE) plus any _MARNM (married name) subtags become separate Name rows. The
|
||||||
|
first birth/maiden name is primary."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for nm in rec.all("NAME"):
|
||||||
|
g, s = _parse_name(nm.value)
|
||||||
|
t = (nm.text("TYPE") or "").strip().lower()
|
||||||
|
ntype = NAME_TYPE_MAP.get(t, t or "birth")
|
||||||
|
out.append({"type": ntype, "given": g, "surname": s, "display": nm.value or None,
|
||||||
|
"nickname": nm.text("NICK")})
|
||||||
|
for mar in nm.all("_MARNM"):
|
||||||
|
mg, ms = _parse_marnm(mar.value, g)
|
||||||
|
out.append({"type": "married", "given": mg, "surname": ms,
|
||||||
|
"display": mar.value or None, "nickname": None})
|
||||||
|
for mar in rec.all("_MARNM"):
|
||||||
|
base_g = out[0]["given"] if out else None
|
||||||
|
mg, ms = _parse_marnm(mar.value, base_g)
|
||||||
|
out.append({"type": "married", "given": mg, "surname": ms,
|
||||||
|
"display": mar.value or None, "nickname": None})
|
||||||
|
if not out:
|
||||||
|
return out
|
||||||
|
primary_idx = next((i for i, n in enumerate(out) if n["type"] == "birth"), 0)
|
||||||
|
for i, n in enumerate(out):
|
||||||
|
n["is_primary"] = i == primary_idx
|
||||||
|
n["sort"] = i
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(given: str | None, surname: str | None) -> str:
|
||||||
|
return re.sub(r"\s+", " ", f"{given or ''} {surname or ''}".strip().lower())
|
||||||
|
|
||||||
|
|
||||||
|
def _year(date_value: str | None) -> str | None:
|
||||||
|
if not date_value:
|
||||||
|
return None
|
||||||
|
m = re.search(r"\b(\d{3,4})\b", date_value)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _date_start(date_value: str | None) -> date | None:
|
||||||
|
y = _year(date_value)
|
||||||
|
if not y:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date(int(y), 1, 1)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sex(value: str | None) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
v = value.strip().upper()
|
||||||
|
return {"M": "male", "F": "female"}.get(v, value.strip().lower() or None)
|
||||||
|
|
||||||
|
|
||||||
|
def _notes_text(rec: GedcomNode) -> str | None:
|
||||||
|
"""Join an INDI's NOTE lines (which pack confidence / findagrave / fs_pid /
|
||||||
|
free text) into the person's notes field."""
|
||||||
|
vals = [n.value.strip() for n in rec.all("NOTE") if n.value and n.value.strip()]
|
||||||
|
return "\n".join(vals) or None
|
||||||
|
|
||||||
|
|
||||||
|
def _person_summary(rec: GedcomNode) -> dict:
|
||||||
|
"""Display name + birth year for an incoming INDI, for duplicate matching."""
|
||||||
|
names = _extract_names(rec)
|
||||||
|
primary = next((n for n in names if n.get("is_primary")), names[0] if names else None)
|
||||||
|
g = primary["given"] if primary else None
|
||||||
|
s = primary["surname"] if primary else None
|
||||||
|
disp = " ".join(x for x in (g, s) if x)
|
||||||
|
if not disp and primary:
|
||||||
|
disp = primary.get("display") or ""
|
||||||
|
birth = rec.first("BIRT")
|
||||||
|
year = _year(birth.text("DATE")) if birth else None
|
||||||
|
return {"names": names, "norm": _norm(g, s), "name": disp or "(no name)", "year": year}
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_existing_index(session: AsyncSession, tree: Tree) -> list[dict]:
|
||||||
|
"""Existing (non-deleted) people with a display name + birth year, for
|
||||||
|
matching incoming records against."""
|
||||||
|
persons = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
names = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Name).where(Name.tree_id == tree.id, Name.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
name_by_person: dict[uuid.UUID, Name] = {}
|
||||||
|
for n in sorted(names, key=lambda n: (not n.is_primary, n.sort_order)):
|
||||||
|
name_by_person.setdefault(n.person_id, n)
|
||||||
|
births = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Event).where(
|
||||||
|
Event.tree_id == tree.id,
|
||||||
|
Event.deleted_at.is_(None),
|
||||||
|
Event.event_type == "birth",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
year_by_person: dict[uuid.UUID, str] = {}
|
||||||
|
for e in births:
|
||||||
|
if e.person_id and e.person_id not in year_by_person:
|
||||||
|
y = str(e.date_start.year) if e.date_start else _year(e.date_value)
|
||||||
|
if y:
|
||||||
|
year_by_person[e.person_id] = y
|
||||||
|
|
||||||
|
index: list[dict] = []
|
||||||
|
for p in persons:
|
||||||
|
nm = name_by_person.get(p.id)
|
||||||
|
g = nm.given if nm else None
|
||||||
|
s = nm.surname if nm else None
|
||||||
|
disp = " ".join(x for x in (g, s) if x) or (nm.display_name if nm else None)
|
||||||
|
index.append({
|
||||||
|
"id": p.id,
|
||||||
|
"norm": _norm(g, s),
|
||||||
|
"name": disp or "(no name)",
|
||||||
|
"year": year_by_person.get(p.id),
|
||||||
|
})
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _best_match(norm: str, year: str | None, index: list[dict]) -> tuple[dict | None, str | None]:
|
||||||
|
"""Closest existing person by name similarity, rejecting clear birth-year
|
||||||
|
conflicts. Returns (entry, "high"|"medium") or (None, None)."""
|
||||||
|
if not norm:
|
||||||
|
return None, None
|
||||||
|
best: dict | None = None
|
||||||
|
best_r = 0.0
|
||||||
|
for e in index:
|
||||||
|
if not e["norm"]:
|
||||||
|
continue
|
||||||
|
r = SequenceMatcher(None, norm, e["norm"]).ratio()
|
||||||
|
if r < 0.88:
|
||||||
|
continue
|
||||||
|
if year and e["year"] and abs(int(year) - int(e["year"])) > 1:
|
||||||
|
continue # same-ish name but different birth year — not a duplicate
|
||||||
|
if r > best_r:
|
||||||
|
best_r = r
|
||||||
|
best = e
|
||||||
|
if best is None:
|
||||||
|
return None, None
|
||||||
|
year_match = bool(year and best["year"] and abs(int(year) - int(best["year"])) <= 1)
|
||||||
|
both_unknown = not year and not best["year"]
|
||||||
|
score = "high" if best_r >= 0.93 and (year_match or both_unknown) else "medium"
|
||||||
|
return best, score
|
||||||
|
|
||||||
|
|
||||||
|
def _relkey(rtype: RelationshipType, a: uuid.UUID, b: uuid.UUID) -> tuple:
|
||||||
|
if rtype == RelationshipType.parent_child:
|
||||||
|
return ("pc", str(a), str(b))
|
||||||
|
return (rtype.value, *sorted([str(a), str(b)]))
|
||||||
|
|
||||||
|
|
||||||
|
def _count_incoming(roots: list[GedcomNode]) -> tuple[dict, list[str]]:
|
||||||
|
counts: dict[str, int] = defaultdict(int)
|
||||||
|
unmapped: set[str] = set()
|
||||||
|
for rec in roots:
|
||||||
|
if rec.tag == "INDI" and rec.xref:
|
||||||
|
counts["persons"] += 1
|
||||||
|
counts["names"] += len(_extract_names(rec))
|
||||||
|
for child in rec.children:
|
||||||
|
if child.tag in INDI_EVENTS:
|
||||||
|
counts["events"] += 1
|
||||||
|
elif child.tag not in INDI_SKIP_TAGS:
|
||||||
|
unmapped.add(child.tag)
|
||||||
|
elif rec.tag == "FAM":
|
||||||
|
counts["families"] += 1
|
||||||
|
for child in rec.children:
|
||||||
|
if child.tag in FAM_EVENTS:
|
||||||
|
counts["events"] += 1
|
||||||
|
elif rec.tag == "SOUR" and rec.xref:
|
||||||
|
counts["sources"] += 1
|
||||||
|
return dict(counts), sorted(unmapped)
|
||||||
|
|
||||||
|
|
||||||
|
async def preview_gedcom(session: AsyncSession, *, actor: User, tree: Tree, text: str) -> dict:
|
||||||
|
"""Dry run: what would import, and which incoming people look like existing
|
||||||
|
ones. No writes."""
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
roots = parse_records(text)
|
||||||
|
counts, unmapped = _count_incoming(roots)
|
||||||
|
index = await _build_existing_index(session, tree)
|
||||||
|
|
||||||
|
duplicates: list[dict] = []
|
||||||
|
for rec in roots:
|
||||||
|
if rec.tag != "INDI" or not rec.xref:
|
||||||
|
continue
|
||||||
|
summ = _person_summary(rec)
|
||||||
|
entry, score = _best_match(summ["norm"], summ["year"], index)
|
||||||
|
if entry is None:
|
||||||
|
continue
|
||||||
|
duplicates.append({
|
||||||
|
"xref": rec.xref,
|
||||||
|
"incoming_name": summ["name"],
|
||||||
|
"incoming_birth_year": summ["year"],
|
||||||
|
"existing_person_id": entry["id"],
|
||||||
|
"existing_name": entry["name"],
|
||||||
|
"existing_birth_year": entry["year"],
|
||||||
|
"score": score,
|
||||||
|
})
|
||||||
|
return {"counts": counts, "potential_duplicates": duplicates, "unmapped_tags": unmapped}
|
||||||
|
|
||||||
|
|
||||||
|
async def import_gedcom(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
text: str,
|
||||||
|
default_action: str = "new",
|
||||||
|
resolutions: dict | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Import records. ``default_action`` (new|skip|merge|overwrite) applies to
|
||||||
|
incoming people that match an existing one; ``resolutions`` overrides it per
|
||||||
|
GEDCOM xref ({xref: {action, target_id}}). 'skip' links families to the
|
||||||
|
existing person but copies nothing; 'merge' also copies the incoming names
|
||||||
|
(as alternates), events and citations onto them; 'overwrite' deletes the
|
||||||
|
existing person and imports the incoming one fresh."""
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
|
||||||
|
resolutions = resolutions or {}
|
||||||
|
roots = parse_records(text)
|
||||||
|
counts: dict[str, int] = defaultdict(int)
|
||||||
|
unmapped: set[str] = set()
|
||||||
|
place_cache: dict[str, uuid.UUID] = {}
|
||||||
|
source_map: dict[str, uuid.UUID] = {}
|
||||||
|
person_map: dict[str, uuid.UUID] = {}
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
index = await _build_existing_index(session, tree)
|
||||||
|
|
||||||
|
# Pre-load existing relationship keys so a merge doesn't create dup edges.
|
||||||
|
existing_rels = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Relationship).where(
|
||||||
|
Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
rel_keys = {_relkey(r.type, r.person_from_id, r.person_to_id) for r in existing_rels}
|
||||||
|
|
||||||
|
def add_relationship(
|
||||||
|
rtype: RelationshipType, a: uuid.UUID, b: uuid.UUID, **kw
|
||||||
|
) -> Relationship | None:
|
||||||
|
key = _relkey(rtype, a, b)
|
||||||
|
if key in rel_keys:
|
||||||
|
return None
|
||||||
|
rel = Relationship(tree_id=tree.id, type=rtype, person_from_id=a, person_to_id=b, **kw)
|
||||||
|
session.add(rel)
|
||||||
|
rel_keys.add(key)
|
||||||
|
counts["relationships"] += 1
|
||||||
|
return rel
|
||||||
|
|
||||||
|
async def place_id(name: str | None) -> uuid.UUID | None:
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
if name in place_cache:
|
||||||
|
return place_cache[name]
|
||||||
|
p = Place(tree_id=tree.id, name=name)
|
||||||
|
session.add(p)
|
||||||
|
await session.flush()
|
||||||
|
place_cache[name] = p.id
|
||||||
|
counts["places"] += 1
|
||||||
|
return p.id
|
||||||
|
|
||||||
|
# Sources first (so citations can reference them).
|
||||||
|
for rec in roots:
|
||||||
|
if rec.tag == "SOUR" and rec.xref:
|
||||||
|
src = Source(
|
||||||
|
tree_id=tree.id,
|
||||||
|
title=rec.text("TITL") or rec.text("ABBR") or "Untitled source",
|
||||||
|
author=rec.text("AUTH"),
|
||||||
|
publication_info=rec.text("PUBL"),
|
||||||
|
citation_text=rec.text("TEXT"),
|
||||||
|
)
|
||||||
|
session.add(src)
|
||||||
|
await session.flush()
|
||||||
|
source_map[rec.xref] = src.id
|
||||||
|
counts["sources"] += 1
|
||||||
|
|
||||||
|
async def add_citations(holder: GedcomNode, **target) -> None:
|
||||||
|
for s in holder.all("SOUR"):
|
||||||
|
sid = source_map.get(s.value.strip())
|
||||||
|
if sid is None:
|
||||||
|
continue
|
||||||
|
session.add(Citation(tree_id=tree.id, source_id=sid, page=s.text("PAGE"), **target))
|
||||||
|
counts["citations"] += 1
|
||||||
|
|
||||||
|
def add_names(person_id: uuid.UUID, names: list[dict], *, set_primary: bool) -> None:
|
||||||
|
for nd in names:
|
||||||
|
session.add(
|
||||||
|
Name(
|
||||||
|
tree_id=tree.id,
|
||||||
|
person_id=person_id,
|
||||||
|
name_type=nd["type"],
|
||||||
|
given=nd["given"],
|
||||||
|
surname=nd["surname"],
|
||||||
|
nickname=nd.get("nickname"),
|
||||||
|
display_name=nd.get("display"),
|
||||||
|
is_primary=set_primary and nd.get("is_primary", False),
|
||||||
|
sort_order=nd.get("sort", 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
counts["names"] += 1
|
||||||
|
|
||||||
|
async def add_events(rec: GedcomNode, person_id: uuid.UUID) -> None:
|
||||||
|
for child in rec.children:
|
||||||
|
if child.tag in INDI_EVENTS:
|
||||||
|
dv = child.text("DATE")
|
||||||
|
# Attribute-style facts (RELI, OCCU, EDUC) carry their value on
|
||||||
|
# the line itself; store it in detail.
|
||||||
|
detail = child.value.strip() if child.tag in VALUE_EVENTS else None
|
||||||
|
ev = Event(
|
||||||
|
tree_id=tree.id,
|
||||||
|
person_id=person_id,
|
||||||
|
event_type=INDI_EVENTS[child.tag],
|
||||||
|
date_value=dv,
|
||||||
|
date_start=_date_start(dv),
|
||||||
|
place_id=await place_id(child.text("PLAC")),
|
||||||
|
detail=detail or None,
|
||||||
|
notes=child.text("NOTE"),
|
||||||
|
)
|
||||||
|
session.add(ev)
|
||||||
|
await session.flush()
|
||||||
|
counts["events"] += 1
|
||||||
|
await add_citations(child, event_id=ev.id)
|
||||||
|
elif child.tag in INDI_SKIP_TAGS:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
unmapped.add(child.tag)
|
||||||
|
|
||||||
|
async def soft_delete_existing(person_id: uuid.UUID) -> None:
|
||||||
|
p = (
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(Person.id == person_id, Person.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if p is None:
|
||||||
|
return
|
||||||
|
p.deleted_at = now
|
||||||
|
rels = (
|
||||||
|
await session.execute(
|
||||||
|
select(Relationship).where(
|
||||||
|
Relationship.tree_id == tree.id,
|
||||||
|
Relationship.deleted_at.is_(None),
|
||||||
|
or_(
|
||||||
|
Relationship.person_from_id == person_id,
|
||||||
|
Relationship.person_to_id == person_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for r in rels:
|
||||||
|
r.deleted_at = now
|
||||||
|
await session.execute(
|
||||||
|
update(User).where(User.self_person_id == person_id).values(self_person_id=None)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Precompute the best match per incoming xref (for default-policy resolution).
|
||||||
|
matches: dict[str, dict] = {}
|
||||||
|
for rec in roots:
|
||||||
|
if rec.tag == "INDI" and rec.xref:
|
||||||
|
summ = _person_summary(rec)
|
||||||
|
entry, _score = _best_match(summ["norm"], summ["year"], index)
|
||||||
|
if entry is not None:
|
||||||
|
matches[rec.xref] = entry
|
||||||
|
|
||||||
|
def resolve(xref: str) -> tuple[str, uuid.UUID | None]:
|
||||||
|
ov = resolutions.get(xref)
|
||||||
|
if ov:
|
||||||
|
action = ov.get("action", "new")
|
||||||
|
tid = ov.get("target_id")
|
||||||
|
target = uuid.UUID(tid) if tid else (matches[xref]["id"] if xref in matches else None)
|
||||||
|
if action in ("skip", "merge", "overwrite") and target is None:
|
||||||
|
return "new", None
|
||||||
|
return action, target
|
||||||
|
if default_action != "new" and xref in matches:
|
||||||
|
return default_action, matches[xref]["id"]
|
||||||
|
return "new", None
|
||||||
|
|
||||||
|
# Individuals.
|
||||||
|
for rec in roots:
|
||||||
|
if rec.tag != "INDI" or not rec.xref:
|
||||||
|
continue
|
||||||
|
names = _extract_names(rec)
|
||||||
|
action, target = resolve(rec.xref)
|
||||||
|
|
||||||
|
if action == "skip" and target is not None:
|
||||||
|
person_map[rec.xref] = target
|
||||||
|
counts["skipped"] += 1
|
||||||
|
continue
|
||||||
|
if action == "merge" and target is not None:
|
||||||
|
person_map[rec.xref] = target
|
||||||
|
add_names(target, names, set_primary=False)
|
||||||
|
await add_events(rec, target)
|
||||||
|
await add_citations(rec, person_id=target)
|
||||||
|
note = _notes_text(rec)
|
||||||
|
if note:
|
||||||
|
existing = (
|
||||||
|
await session.execute(select(Person).where(Person.id == target))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
existing.notes = "\n".join(filter(None, [existing.notes, note]))
|
||||||
|
counts["merged"] += 1
|
||||||
|
continue
|
||||||
|
if action == "overwrite" and target is not None:
|
||||||
|
await soft_delete_existing(target)
|
||||||
|
counts["overwritten"] += 1
|
||||||
|
|
||||||
|
person = Person(tree_id=tree.id, gender=_sex(rec.text("SEX")), notes=_notes_text(rec))
|
||||||
|
session.add(person)
|
||||||
|
await session.flush()
|
||||||
|
person_map[rec.xref] = person.id
|
||||||
|
counts["persons"] += 1
|
||||||
|
add_names(person.id, names, set_primary=True)
|
||||||
|
await add_citations(rec, person_id=person.id)
|
||||||
|
await add_events(rec, person.id)
|
||||||
|
|
||||||
|
# Families -> partnerships, parent-child edges, marriage events.
|
||||||
|
for rec in roots:
|
||||||
|
if rec.tag != "FAM":
|
||||||
|
continue
|
||||||
|
counts["families"] += 1
|
||||||
|
husb = person_map.get((rec.text("HUSB") or "").strip())
|
||||||
|
wife = person_map.get((rec.text("WIFE") or "").strip())
|
||||||
|
partnership_id: uuid.UUID | None = None
|
||||||
|
if husb and wife and husb != wife:
|
||||||
|
rel = add_relationship(RelationshipType.partnership, husb, wife)
|
||||||
|
if rel is not None:
|
||||||
|
await session.flush()
|
||||||
|
partnership_id = rel.id
|
||||||
|
if partnership_id is None and husb and wife:
|
||||||
|
# Edge already existed — find it so marriage events can attach.
|
||||||
|
existing = next(
|
||||||
|
(
|
||||||
|
r for r in existing_rels
|
||||||
|
if r.type == RelationshipType.partnership
|
||||||
|
and {r.person_from_id, r.person_to_id} == {husb, wife}
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
partnership_id = existing.id if existing else None
|
||||||
|
|
||||||
|
for fe in rec.children:
|
||||||
|
if fe.tag in FAM_EVENTS and partnership_id is not None:
|
||||||
|
dv = fe.text("DATE")
|
||||||
|
ev = Event(
|
||||||
|
tree_id=tree.id,
|
||||||
|
relationship_id=partnership_id,
|
||||||
|
event_type=FAM_EVENTS[fe.tag],
|
||||||
|
date_value=dv,
|
||||||
|
date_start=_date_start(dv),
|
||||||
|
place_id=await place_id(fe.text("PLAC")),
|
||||||
|
)
|
||||||
|
session.add(ev)
|
||||||
|
await session.flush()
|
||||||
|
counts["events"] += 1
|
||||||
|
|
||||||
|
for chil in rec.all("CHIL"):
|
||||||
|
cp = person_map.get(chil.value.strip())
|
||||||
|
if cp is None:
|
||||||
|
continue
|
||||||
|
for parent in (husb, wife):
|
||||||
|
if parent and parent != cp:
|
||||||
|
add_relationship(
|
||||||
|
RelationshipType.parent_child,
|
||||||
|
parent,
|
||||||
|
cp,
|
||||||
|
qualifier=ParentChildQualifier.biological,
|
||||||
|
)
|
||||||
|
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="import",
|
||||||
|
entity_type="Gedcom",
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after=dict(counts),
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return {"counts": dict(counts), "unmapped_tags": sorted(unmapped)}
|
||||||
|
|
||||||
|
|
||||||
|
def _ged_date(value: str | None) -> str | None:
|
||||||
|
return value.strip() if value else None
|
||||||
|
|
||||||
|
|
||||||
|
async def export_gedcom(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> str:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
|
||||||
|
persons = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
names = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Name).where(Name.tree_id == tree.id, Name.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
events = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Event).where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
rels = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Relationship).where(
|
||||||
|
Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
sources = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Source).where(Source.tree_id == tree.id, Source.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
places = {
|
||||||
|
p.id: p
|
||||||
|
for p in (
|
||||||
|
await session.execute(select(Place).where(Place.tree_id == tree.id))
|
||||||
|
).scalars().all()
|
||||||
|
}
|
||||||
|
|
||||||
|
pxref = {p.id: f"@I{i + 1}@" for i, p in enumerate(persons)}
|
||||||
|
gender_by_id = {p.id: p.gender for p in persons}
|
||||||
|
sxref = {s.id: f"@S{i + 1}@" for i, s in enumerate(sources)}
|
||||||
|
names_by_person: dict[uuid.UUID, list[Name]] = defaultdict(list)
|
||||||
|
for n in sorted(names, key=lambda n: (n.sort_order, not n.is_primary)):
|
||||||
|
names_by_person[n.person_id].append(n)
|
||||||
|
events_by_person: dict[uuid.UUID, list[Event]] = defaultdict(list)
|
||||||
|
events_by_rel: dict[uuid.UUID, list[Event]] = defaultdict(list)
|
||||||
|
for e in events:
|
||||||
|
if e.person_id:
|
||||||
|
events_by_person[e.person_id].append(e)
|
||||||
|
elif e.relationship_id:
|
||||||
|
events_by_rel[e.relationship_id].append(e)
|
||||||
|
|
||||||
|
# Build families from parent-child + partnership edges (group by parent set).
|
||||||
|
parents_of: dict[uuid.UUID, set[uuid.UUID]] = defaultdict(set)
|
||||||
|
for r in rels:
|
||||||
|
if r.type == RelationshipType.parent_child:
|
||||||
|
parents_of[r.person_to_id].add(r.person_from_id)
|
||||||
|
fams: dict[frozenset, dict] = {}
|
||||||
|
for child, ps in parents_of.items():
|
||||||
|
key = frozenset(ps)
|
||||||
|
fams.setdefault(key, {"parents": set(ps), "children": [], "rel_id": None})
|
||||||
|
fams[key]["children"].append(child)
|
||||||
|
for r in rels:
|
||||||
|
if r.type == RelationshipType.partnership:
|
||||||
|
key = frozenset({r.person_from_id, r.person_to_id})
|
||||||
|
fam = fams.setdefault(
|
||||||
|
key,
|
||||||
|
{"parents": {r.person_from_id, r.person_to_id}, "children": [], "rel_id": None},
|
||||||
|
)
|
||||||
|
fam["rel_id"] = r.id
|
||||||
|
fam_list = list(fams.values())
|
||||||
|
fxref = {id(f): f"@F{i + 1}@" for i, f in enumerate(fam_list)}
|
||||||
|
# person -> the families they are a spouse in / a child in
|
||||||
|
spouse_fams: dict[uuid.UUID, list[str]] = defaultdict(list)
|
||||||
|
child_fams: dict[uuid.UUID, str] = {}
|
||||||
|
for f in fam_list:
|
||||||
|
x = fxref[id(f)]
|
||||||
|
for pid in f["parents"]:
|
||||||
|
spouse_fams[pid].append(x)
|
||||||
|
for cid in f["children"]:
|
||||||
|
child_fams[cid] = x
|
||||||
|
|
||||||
|
out: list[str] = ["0 HEAD", "1 SOUR Provenance", "1 GEDC", "2 VERS 5.5.1", "1 CHAR UTF-8"]
|
||||||
|
|
||||||
|
for p in persons:
|
||||||
|
out.append(f"0 {pxref[p.id]} INDI")
|
||||||
|
for n in names_by_person.get(p.id, []):
|
||||||
|
display = n.display_name or f"{n.given or ''} /{n.surname or ''}/".strip()
|
||||||
|
out.append(f"1 NAME {display}")
|
||||||
|
ged_type = EXPORT_TYPE_MAP.get(n.name_type)
|
||||||
|
if ged_type:
|
||||||
|
out.append(f"2 TYPE {ged_type}")
|
||||||
|
sex = {"male": "M", "female": "F"}.get(p.gender or "")
|
||||||
|
if sex:
|
||||||
|
out.append(f"1 SEX {sex}")
|
||||||
|
for e in events_by_person.get(p.id, []):
|
||||||
|
tag = EVENT_TO_GED.get(e.event_type)
|
||||||
|
if not tag:
|
||||||
|
continue
|
||||||
|
out.append(f"1 {tag}")
|
||||||
|
if _ged_date(e.date_value):
|
||||||
|
out.append(f"2 DATE {e.date_value}")
|
||||||
|
if e.place_id and e.place_id in places:
|
||||||
|
out.append(f"2 PLAC {places[e.place_id].name}")
|
||||||
|
if p.id in child_fams:
|
||||||
|
out.append(f"1 FAMC {child_fams[p.id]}")
|
||||||
|
for x in spouse_fams.get(p.id, []):
|
||||||
|
out.append(f"1 FAMS {x}")
|
||||||
|
|
||||||
|
for f in fam_list:
|
||||||
|
x = fxref[id(f)]
|
||||||
|
out.append(f"0 {x} FAM")
|
||||||
|
ps = list(f["parents"])
|
||||||
|
# HUSB/WIFE by recorded gender where possible.
|
||||||
|
males = [pid for pid in ps if gender_by_id.get(pid) == "male"]
|
||||||
|
females = [pid for pid in ps if gender_by_id.get(pid) == "female"]
|
||||||
|
husb = males[0] if males else (ps[0] if ps else None)
|
||||||
|
wife = females[0] if females else next((pid for pid in ps if pid != husb), None)
|
||||||
|
if husb:
|
||||||
|
out.append(f"1 HUSB {pxref[husb]}")
|
||||||
|
if wife:
|
||||||
|
out.append(f"1 WIFE {pxref[wife]}")
|
||||||
|
for cid in f["children"]:
|
||||||
|
out.append(f"1 CHIL {pxref[cid]}")
|
||||||
|
if f["rel_id"]:
|
||||||
|
for e in events_by_rel.get(f["rel_id"], []):
|
||||||
|
tag = EVENT_TO_GED.get(e.event_type)
|
||||||
|
if not tag:
|
||||||
|
continue
|
||||||
|
out.append(f"1 {tag}")
|
||||||
|
if _ged_date(e.date_value):
|
||||||
|
out.append(f"2 DATE {e.date_value}")
|
||||||
|
|
||||||
|
for s in sources:
|
||||||
|
out.append(f"0 {sxref[s.id]} SOUR")
|
||||||
|
if s.title:
|
||||||
|
out.append(f"1 TITL {s.title}")
|
||||||
|
if s.author:
|
||||||
|
out.append(f"1 AUTH {s.author}")
|
||||||
|
if s.publication_info:
|
||||||
|
out.append(f"1 PUBL {s.publication_info}")
|
||||||
|
|
||||||
|
out.append("0 TRLR")
|
||||||
|
return "\n".join(out) + "\n"
|
||||||
@@ -97,6 +97,36 @@ async def get_media(
|
|||||||
return media
|
return media
|
||||||
|
|
||||||
|
|
||||||
|
async def update_media(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID, changes: dict
|
||||||
|
) -> Media:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
media = (
|
||||||
|
await session.execute(
|
||||||
|
select(Media).where(
|
||||||
|
Media.id == media_id, Media.tree_id == tree.id, Media.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if media is None:
|
||||||
|
raise NotFound("media not found")
|
||||||
|
for key in {"title", "person_id", "event_id", "source_id"} & changes.keys():
|
||||||
|
setattr(media, key, changes[key])
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="update",
|
||||||
|
entity_type="Media",
|
||||||
|
entity_id=media.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after=changes,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(media)
|
||||||
|
return media
|
||||||
|
|
||||||
|
|
||||||
async def delete_media(
|
async def delete_media(
|
||||||
session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID
|
session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -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,208 @@
|
|||||||
|
"""Name service. A Person carries one or more Name rows — a primary (typically
|
||||||
|
the birth/maiden name) plus typed alternates (married, alias, religious, …).
|
||||||
|
Exactly one name is primary at a time; it drives display everywhere. Writes
|
||||||
|
require editor rights; reads go through the tree's view check.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.person import Name, Person
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import privacy
|
||||||
|
from app.services.audit import record_audit
|
||||||
|
from app.services.exceptions import Forbidden, NotFound
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_person(session: AsyncSession, *, tree: Tree, person_id: uuid.UUID) -> Person:
|
||||||
|
person = (
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(
|
||||||
|
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if person is None:
|
||||||
|
raise NotFound("person not found")
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
async def _clear_primary(
|
||||||
|
session: AsyncSession, *, person_id: uuid.UUID, keep: uuid.UUID | None
|
||||||
|
) -> None:
|
||||||
|
"""Demote every other name so exactly one stays primary."""
|
||||||
|
stmt = (
|
||||||
|
update(Name)
|
||||||
|
.where(Name.person_id == person_id, Name.deleted_at.is_(None), Name.is_primary.is_(True))
|
||||||
|
.values(is_primary=False)
|
||||||
|
)
|
||||||
|
if keep is not None:
|
||||||
|
stmt = stmt.where(Name.id != keep)
|
||||||
|
await session.execute(stmt)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_names(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
||||||
|
) -> list[Name]:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
await _get_person(session, tree=tree, person_id=person_id)
|
||||||
|
stmt = (
|
||||||
|
select(Name)
|
||||||
|
.where(Name.person_id == person_id, Name.deleted_at.is_(None))
|
||||||
|
.order_by(Name.is_primary.desc(), Name.sort_order, Name.created_at)
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def create_name(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
name_type: str = "birth",
|
||||||
|
given: str | None = None,
|
||||||
|
surname: str | None = None,
|
||||||
|
prefix: str | None = None,
|
||||||
|
suffix: str | None = None,
|
||||||
|
nickname: str | None = None,
|
||||||
|
is_primary: bool = False,
|
||||||
|
) -> Name:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
await _get_person(session, tree=tree, person_id=person_id)
|
||||||
|
|
||||||
|
# First name for a person is always primary; otherwise honor the flag.
|
||||||
|
existing = (
|
||||||
|
await session.execute(
|
||||||
|
select(Name.id).where(Name.person_id == person_id, Name.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
primary = is_primary or existing is None
|
||||||
|
if primary:
|
||||||
|
await _clear_primary(session, person_id=person_id, keep=None)
|
||||||
|
|
||||||
|
name = Name(
|
||||||
|
tree_id=tree.id,
|
||||||
|
person_id=person_id,
|
||||||
|
name_type=name_type,
|
||||||
|
given=given,
|
||||||
|
surname=surname,
|
||||||
|
prefix=prefix,
|
||||||
|
suffix=suffix,
|
||||||
|
nickname=nickname,
|
||||||
|
is_primary=primary,
|
||||||
|
)
|
||||||
|
session.add(name)
|
||||||
|
await session.flush()
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="create",
|
||||||
|
entity_type="Name",
|
||||||
|
entity_id=name.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"name_type": name_type, "given": given, "surname": surname},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(name)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
_NAME_FIELDS = {"name_type", "given", "surname", "prefix", "suffix", "nickname"}
|
||||||
|
|
||||||
|
|
||||||
|
async def update_name(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
name_id: uuid.UUID,
|
||||||
|
changes: dict,
|
||||||
|
) -> Name:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
name = (
|
||||||
|
await session.execute(
|
||||||
|
select(Name).where(
|
||||||
|
Name.id == name_id,
|
||||||
|
Name.person_id == person_id,
|
||||||
|
Name.tree_id == tree.id,
|
||||||
|
Name.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if name is None:
|
||||||
|
raise NotFound("name not found")
|
||||||
|
|
||||||
|
for key in _NAME_FIELDS & changes.keys():
|
||||||
|
setattr(name, key, changes[key])
|
||||||
|
if changes.get("is_primary") is True:
|
||||||
|
await _clear_primary(session, person_id=person_id, keep=name.id)
|
||||||
|
name.is_primary = True
|
||||||
|
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="update",
|
||||||
|
entity_type="Name",
|
||||||
|
entity_id=name.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after=changes,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(name)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_name(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
name_id: uuid.UUID,
|
||||||
|
) -> None:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
name = (
|
||||||
|
await session.execute(
|
||||||
|
select(Name).where(
|
||||||
|
Name.id == name_id,
|
||||||
|
Name.person_id == person_id,
|
||||||
|
Name.tree_id == tree.id,
|
||||||
|
Name.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if name is None:
|
||||||
|
raise NotFound("name not found")
|
||||||
|
name.deleted_at = datetime.now(UTC)
|
||||||
|
was_primary = name.is_primary
|
||||||
|
name.is_primary = False
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="Name",
|
||||||
|
entity_id=name.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
# Promote another name to primary so the person never loses their display name.
|
||||||
|
if was_primary:
|
||||||
|
nxt = (
|
||||||
|
await session.execute(
|
||||||
|
select(Name)
|
||||||
|
.where(Name.person_id == person_id, Name.deleted_at.is_(None))
|
||||||
|
.order_by(Name.sort_order, Name.created_at)
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
|
if nxt is not None:
|
||||||
|
nxt.is_primary = True
|
||||||
|
await session.commit()
|
||||||
@@ -4,12 +4,14 @@ person through the privacy engine. Each returned Person gets a transient
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import 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
|
||||||
@@ -24,6 +26,14 @@ def _format_name(name: Name) -> str | None:
|
|||||||
return joined or name.display_name
|
return joined or name.display_name
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(person: Person) -> None:
|
||||||
|
"""Minimise a possibly-living person for a non-member view (transient only —
|
||||||
|
never committed)."""
|
||||||
|
person.primary_name = "Living person"
|
||||||
|
person.gender = None
|
||||||
|
person.is_living = True
|
||||||
|
|
||||||
|
|
||||||
async def _attach_primary_name(session: AsyncSession, person: Person) -> None:
|
async def _attach_primary_name(session: AsyncSession, person: Person) -> None:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Name)
|
select(Name)
|
||||||
@@ -86,6 +96,59 @@ async def create_person(
|
|||||||
return person
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
_PERSON_FIELDS = {"gender", "is_living", "privacy", "notes"}
|
||||||
|
|
||||||
|
|
||||||
|
async def update_person(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID, changes: dict
|
||||||
|
) -> Person:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
person = (
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(
|
||||||
|
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if person is None:
|
||||||
|
raise NotFound("person not found")
|
||||||
|
|
||||||
|
for key in _PERSON_FIELDS & changes.keys():
|
||||||
|
setattr(person, key, changes[key])
|
||||||
|
|
||||||
|
if "given" in changes or "surname" in changes:
|
||||||
|
name = (
|
||||||
|
await session.execute(
|
||||||
|
select(Name)
|
||||||
|
.where(Name.person_id == person.id, Name.deleted_at.is_(None))
|
||||||
|
.order_by(Name.is_primary.desc(), Name.sort_order)
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
|
if name is None:
|
||||||
|
name = Name(tree_id=tree.id, person_id=person.id, name_type="birth", is_primary=True)
|
||||||
|
session.add(name)
|
||||||
|
if "given" in changes:
|
||||||
|
name.given = changes["given"]
|
||||||
|
if "surname" in changes:
|
||||||
|
name.surname = changes["surname"]
|
||||||
|
name.display_name = None # rebuild display from parts
|
||||||
|
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="update",
|
||||||
|
entity_type="Person",
|
||||||
|
entity_id=person.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after=changes,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(person)
|
||||||
|
await _attach_primary_name(session, person)
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
async def get_person(
|
async def get_person(
|
||||||
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
||||||
) -> Person:
|
) -> Person:
|
||||||
@@ -103,15 +166,181 @@ async def get_person(
|
|||||||
if person is None:
|
if person is None:
|
||||||
raise NotFound("person not found")
|
raise NotFound("person not found")
|
||||||
# Run the single person through the privacy engine (redaction lands Phase 2).
|
# Run the single person through the privacy engine (redaction lands Phase 2).
|
||||||
if (
|
vis = await privacy.person_visibility(
|
||||||
await privacy.person_visibility(session, user_id=viewer_id, tree=tree, person=person)
|
session, user_id=viewer_id, tree=tree, person=person
|
||||||
== Visibility.hidden
|
)
|
||||||
):
|
if vis == Visibility.hidden:
|
||||||
raise NotFound("person not found")
|
raise NotFound("person not found")
|
||||||
|
if vis == Visibility.redacted:
|
||||||
|
_redact(person)
|
||||||
|
else:
|
||||||
|
await _attach_primary_name(session, person)
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
async def _children_of(
|
||||||
|
session: AsyncSession, *, tree_id: uuid.UUID, parent_id: uuid.UUID
|
||||||
|
) -> list[uuid.UUID]:
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(Relationship.person_to_id).where(
|
||||||
|
Relationship.tree_id == tree_id,
|
||||||
|
Relationship.deleted_at.is_(None),
|
||||||
|
Relationship.type == RelationshipType.parent_child,
|
||||||
|
Relationship.person_from_id == parent_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
return list(rows)
|
||||||
|
|
||||||
|
|
||||||
|
async def _soft_delete_one(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, person: Person, now: datetime
|
||||||
|
) -> None:
|
||||||
|
"""Soft-delete a single person and the relationships touching them, so no
|
||||||
|
dangling edges are left to break the tree view."""
|
||||||
|
person.deleted_at = now
|
||||||
|
rels = (
|
||||||
|
await session.execute(
|
||||||
|
select(Relationship).where(
|
||||||
|
Relationship.tree_id == tree.id,
|
||||||
|
Relationship.deleted_at.is_(None),
|
||||||
|
or_(
|
||||||
|
Relationship.person_from_id == person.id,
|
||||||
|
Relationship.person_to_id == person.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for rel in rels:
|
||||||
|
rel.deleted_at = now
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="Person",
|
||||||
|
entity_id=person.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"cascaded_relationships": len(rels)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_person(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
cascade: bool = False,
|
||||||
|
) -> int:
|
||||||
|
"""Soft-delete a person. Always removes the relationships that touch them
|
||||||
|
(preventing dangling edges). With ``cascade=True``, recursively deletes
|
||||||
|
their descendants too — handy for pruning a bad GEDCOM import. Returns the
|
||||||
|
number of persons deleted."""
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
person = (
|
||||||
|
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")
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
# Gather the set of persons to delete. For cascade, walk descendants
|
||||||
|
# breadth-first, guarding against cycles.
|
||||||
|
to_delete: list[Person] = [person]
|
||||||
|
if cascade:
|
||||||
|
seen = {person.id}
|
||||||
|
frontier = [person.id]
|
||||||
|
while frontier:
|
||||||
|
nxt: list[uuid.UUID] = []
|
||||||
|
for pid in frontier:
|
||||||
|
for child_id in await _children_of(session, tree_id=tree.id, parent_id=pid):
|
||||||
|
if child_id not in seen:
|
||||||
|
seen.add(child_id)
|
||||||
|
nxt.append(child_id)
|
||||||
|
frontier = nxt
|
||||||
|
extra_ids = [pid for pid in seen if pid != person.id]
|
||||||
|
if extra_ids:
|
||||||
|
extra = (
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(
|
||||||
|
Person.id.in_(extra_ids),
|
||||||
|
Person.tree_id == tree.id,
|
||||||
|
Person.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
to_delete.extend(extra)
|
||||||
|
|
||||||
|
for p in to_delete:
|
||||||
|
await _soft_delete_one(session, actor=actor, tree=tree, person=p, now=now)
|
||||||
|
|
||||||
|
# Soft delete leaves the row in place, so the DB-level "ON DELETE SET NULL"
|
||||||
|
# never fires — clear any links (account self-person, tree home person) to a
|
||||||
|
# deleted person.
|
||||||
|
deleted_ids = [p.id for p in to_delete]
|
||||||
|
await session.execute(
|
||||||
|
update(User).where(User.self_person_id.in_(deleted_ids)).values(self_person_id=None)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
update(Tree).where(Tree.home_person_id.in_(deleted_ids)).values(home_person_id=None)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return len(to_delete)
|
||||||
|
|
||||||
|
|
||||||
|
async def restore_person(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID
|
||||||
|
) -> Person:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
person = (
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(
|
||||||
|
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_not(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if person is None:
|
||||||
|
raise NotFound("deleted person not found")
|
||||||
|
person.deleted_at = None
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="restore",
|
||||||
|
entity_type="Person",
|
||||||
|
entity_id=person.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(person)
|
||||||
await _attach_primary_name(session, person)
|
await _attach_primary_name(session, person)
|
||||||
return person
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
async def list_deleted_persons(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
||||||
|
) -> list[Person]:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
stmt = (
|
||||||
|
select(Person)
|
||||||
|
.where(Person.tree_id == tree.id, Person.deleted_at.is_not(None))
|
||||||
|
.order_by(Person.deleted_at.desc())
|
||||||
|
)
|
||||||
|
persons = list((await session.execute(stmt)).scalars().all())
|
||||||
|
for person in persons:
|
||||||
|
await _attach_primary_name(session, person)
|
||||||
|
return persons
|
||||||
|
|
||||||
|
|
||||||
async def list_persons(
|
async def list_persons(
|
||||||
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
||||||
) -> list[Person]:
|
) -> list[Person]:
|
||||||
@@ -127,13 +356,66 @@ async def list_persons(
|
|||||||
|
|
||||||
visible: list[Person] = []
|
visible: list[Person] = []
|
||||||
for person in persons:
|
for person in persons:
|
||||||
if (
|
vis = await privacy.person_visibility(
|
||||||
await privacy.person_visibility(
|
session, user_id=viewer_id, tree=tree, person=person
|
||||||
session, user_id=viewer_id, tree=tree, person=person
|
)
|
||||||
)
|
if vis == Visibility.hidden:
|
||||||
== Visibility.hidden
|
|
||||||
):
|
|
||||||
continue
|
continue
|
||||||
await _attach_primary_name(session, person)
|
if vis == Visibility.redacted:
|
||||||
|
_redact(person)
|
||||||
|
else:
|
||||||
|
await _attach_primary_name(session, person)
|
||||||
visible.append(person)
|
visible.append(person)
|
||||||
return visible
|
return visible
|
||||||
|
|
||||||
|
|
||||||
|
async def search_persons(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, query: str, limit: int = 50
|
||||||
|
) -> list[Person]:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
q = query.strip()
|
||||||
|
if not q:
|
||||||
|
return []
|
||||||
|
like = f"%{q}%"
|
||||||
|
score = func.greatest(
|
||||||
|
func.similarity(func.coalesce(Name.given, ""), q),
|
||||||
|
func.similarity(func.coalesce(Name.surname, ""), q),
|
||||||
|
)
|
||||||
|
sub = (
|
||||||
|
select(Name.person_id.label("pid"), func.max(score).label("score"))
|
||||||
|
.where(
|
||||||
|
Name.tree_id == tree.id,
|
||||||
|
Name.deleted_at.is_(None),
|
||||||
|
or_(
|
||||||
|
Name.given.op("%")(q),
|
||||||
|
Name.surname.op("%")(q),
|
||||||
|
Name.given.ilike(like),
|
||||||
|
Name.surname.ilike(like),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.group_by(Name.person_id)
|
||||||
|
.order_by(func.max(score).desc())
|
||||||
|
.limit(limit)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
stmt = (
|
||||||
|
select(Person)
|
||||||
|
.join(sub, sub.c.pid == Person.id)
|
||||||
|
.where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
|
||||||
|
.order_by(sub.c.score.desc())
|
||||||
|
)
|
||||||
|
persons = list((await session.execute(stmt)).scalars().all())
|
||||||
|
out: list[Person] = []
|
||||||
|
for person in persons:
|
||||||
|
vis = await privacy.person_visibility(
|
||||||
|
session, user_id=viewer_id, tree=tree, person=person
|
||||||
|
)
|
||||||
|
if vis == Visibility.hidden:
|
||||||
|
continue
|
||||||
|
if vis == Visibility.redacted:
|
||||||
|
_redact(person)
|
||||||
|
else:
|
||||||
|
await _attach_primary_name(session, person)
|
||||||
|
out.append(person)
|
||||||
|
return out
|
||||||
|
|||||||
@@ -8,14 +8,20 @@ tree's visibility, the per-person override, and (Phase 2) living-person status.
|
|||||||
|
|
||||||
import enum
|
import enum
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.enums import MembershipRole, PersonPrivacy, TreeVisibility
|
from app.models.enums import MembershipRole, PersonPrivacy, TreeVisibility
|
||||||
|
from app.models.event import Event
|
||||||
from app.models.person import Person
|
from app.models.person import Person
|
||||||
from app.models.tree import Tree, TreeMembership
|
from app.models.tree import Tree, TreeMembership
|
||||||
|
|
||||||
|
# A person with no death fact whose birth is within this window (or unknown) is
|
||||||
|
# treated as possibly living and redacted from non-members (ARCHITECTURE §6).
|
||||||
|
LIVING_RECENCY_YEARS = 100
|
||||||
|
|
||||||
|
|
||||||
class Visibility(enum.StrEnum):
|
class Visibility(enum.StrEnum):
|
||||||
full = "full"
|
full = "full"
|
||||||
@@ -48,15 +54,56 @@ async def can_edit_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tre
|
|||||||
return role in (MembershipRole.owner, MembershipRole.editor)
|
return role in (MembershipRole.owner, MembershipRole.editor)
|
||||||
|
|
||||||
|
|
||||||
|
async def is_possibly_living(session: AsyncSession, person: Person) -> bool:
|
||||||
|
"""True if the person should be treated as living: explicit flag, or (absent
|
||||||
|
a death fact) a birth within the recency window or an unknown birth."""
|
||||||
|
if person.is_living is True:
|
||||||
|
return True
|
||||||
|
if person.is_living is False:
|
||||||
|
return False
|
||||||
|
death = (
|
||||||
|
await session.execute(
|
||||||
|
select(Event.id)
|
||||||
|
.where(
|
||||||
|
Event.person_id == person.id,
|
||||||
|
Event.event_type == "death",
|
||||||
|
Event.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if death is not None:
|
||||||
|
return False
|
||||||
|
birth = (
|
||||||
|
await session.execute(
|
||||||
|
select(Event.date_start)
|
||||||
|
.where(
|
||||||
|
Event.person_id == person.id,
|
||||||
|
Event.event_type == "birth",
|
||||||
|
Event.date_start.is_not(None),
|
||||||
|
Event.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(Event.date_start)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if birth is None:
|
||||||
|
return True # unknown birth → treat as possibly living
|
||||||
|
return (datetime.now(UTC).year - birth.year) < LIVING_RECENCY_YEARS
|
||||||
|
|
||||||
|
|
||||||
async def person_visibility(
|
async def person_visibility(
|
||||||
session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree, person: Person
|
session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree, person: Person
|
||||||
) -> Visibility:
|
) -> Visibility:
|
||||||
if not await can_view_tree(session, user_id=user_id, tree=tree):
|
if not await can_view_tree(session, user_id=user_id, tree=tree):
|
||||||
return Visibility.hidden
|
return Visibility.hidden
|
||||||
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 Visibility.full
|
return Visibility.full # members see everyone in their tree
|
||||||
# Non-member viewing a public/unlisted tree:
|
# Non-member viewing a public/unlisted tree:
|
||||||
if person.privacy == PersonPrivacy.private:
|
if person.privacy == PersonPrivacy.private:
|
||||||
return Visibility.hidden
|
return Visibility.hidden
|
||||||
# TODO(Phase 2): redact living people for non-members (ARCHITECTURE §6).
|
if person.privacy == PersonPrivacy.public:
|
||||||
|
return Visibility.full # explicit per-person opt-in
|
||||||
|
if await is_possibly_living(session, person):
|
||||||
|
return Visibility.redacted # living people are protected by default
|
||||||
return Visibility.full
|
return Visibility.full
|
||||||
|
|||||||
@@ -73,6 +73,20 @@ async def create_relationship(
|
|||||||
return relationship
|
return relationship
|
||||||
|
|
||||||
|
|
||||||
|
async def list_relationships(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
||||||
|
) -> list[Relationship]:
|
||||||
|
"""All relationships in the tree — powers the family/pedigree view in one call."""
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
stmt = (
|
||||||
|
select(Relationship)
|
||||||
|
.where(Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None))
|
||||||
|
.order_by(Relationship.created_at)
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
async def list_relationships_for_person(
|
async def list_relationships_for_person(
|
||||||
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
||||||
) -> list[Relationship]:
|
) -> list[Relationship]:
|
||||||
@@ -93,6 +107,44 @@ async def list_relationships_for_person(
|
|||||||
return list((await session.execute(stmt)).scalars().all())
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def update_relationship(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, relationship_id: uuid.UUID, changes: dict
|
||||||
|
) -> Relationship:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
relationship = (
|
||||||
|
await session.execute(
|
||||||
|
select(Relationship).where(
|
||||||
|
Relationship.id == relationship_id,
|
||||||
|
Relationship.tree_id == tree.id,
|
||||||
|
Relationship.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if relationship is None:
|
||||||
|
raise NotFound("relationship not found")
|
||||||
|
if (
|
||||||
|
"qualifier" in changes
|
||||||
|
and changes["qualifier"] is not None
|
||||||
|
and relationship.type is not RelationshipType.parent_child
|
||||||
|
):
|
||||||
|
raise Conflict("qualifier only applies to parent_child relationships")
|
||||||
|
for key in {"qualifier", "notes"} & changes.keys():
|
||||||
|
setattr(relationship, key, changes[key])
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="update",
|
||||||
|
entity_type="Relationship",
|
||||||
|
entity_id=relationship.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after=changes,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(relationship)
|
||||||
|
return relationship
|
||||||
|
|
||||||
|
|
||||||
async def delete_relationship(
|
async def delete_relationship(
|
||||||
session: AsyncSession, *, actor: User, tree: Tree, relationship_id: uuid.UUID
|
session: AsyncSession, *, actor: User, tree: Tree, relationship_id: uuid.UUID
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -86,6 +86,42 @@ async def get_source(
|
|||||||
return source
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
_SOURCE_FIELDS = {
|
||||||
|
"title", "author", "source_type", "repository", "url", "citation_text",
|
||||||
|
"publication_info", "quality_note",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def update_source(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID, changes: dict
|
||||||
|
) -> Source:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
source = (
|
||||||
|
await session.execute(
|
||||||
|
select(Source).where(
|
||||||
|
Source.id == source_id, Source.tree_id == tree.id, Source.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if source is None:
|
||||||
|
raise NotFound("source not found")
|
||||||
|
for key in _SOURCE_FIELDS & changes.keys():
|
||||||
|
setattr(source, key, changes[key])
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="update",
|
||||||
|
entity_type="Source",
|
||||||
|
entity_id=source.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after=changes,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(source)
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
async def delete_source(
|
async def delete_source(
|
||||||
session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID
|
session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ authorization basis) and an audit entry. Reads go through the privacy engine.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -59,3 +60,79 @@ async def get_tree(session: AsyncSession, *, viewer_id: uuid.UUID, tree_id: uuid
|
|||||||
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
raise Forbidden("not permitted to view this tree")
|
raise Forbidden("not permitted to view this tree")
|
||||||
return tree
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
async def update_tree(
|
||||||
|
session: AsyncSession, *, actor: User, tree_id: uuid.UUID, changes: dict
|
||||||
|
) -> Tree:
|
||||||
|
tree = await BaseRepository(session, Tree).get(tree_id)
|
||||||
|
if tree is None:
|
||||||
|
raise NotFound("tree not found")
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
for key in {"name", "description", "visibility", "home_person_id"} & changes.keys():
|
||||||
|
setattr(tree, key, changes[key])
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="update",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after=changes,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(tree)
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
async def _owned_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree:
|
||||||
|
"""Load a tree (including soft-deleted) and require the actor be its owner."""
|
||||||
|
tree = await BaseRepository(session, Tree).get(tree_id, include_deleted=True)
|
||||||
|
if tree is None:
|
||||||
|
raise NotFound("tree not found")
|
||||||
|
role = await privacy.get_membership_role(session, actor.id, tree.id)
|
||||||
|
if role is not MembershipRole.owner:
|
||||||
|
raise Forbidden("only the owner can delete or restore a tree")
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> None:
|
||||||
|
tree = await _owned_tree(session, actor=actor, tree_id=tree_id)
|
||||||
|
if tree.deleted_at is None:
|
||||||
|
tree.deleted_at = datetime.now(UTC)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def restore_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree:
|
||||||
|
tree = await _owned_tree(session, actor=actor, tree_id=tree_id)
|
||||||
|
if tree.deleted_at is not None:
|
||||||
|
tree.deleted_at = None
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="restore",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
async def list_deleted_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]:
|
||||||
|
stmt = (
|
||||||
|
select(Tree)
|
||||||
|
.join(TreeMembership, TreeMembership.tree_id == Tree.id)
|
||||||
|
.where(TreeMembership.user_id == user.id, Tree.deleted_at.is_not(None))
|
||||||
|
.order_by(Tree.deleted_at.desc())
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|||||||
@@ -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,33 @@
|
|||||||
|
"""pg_trgm extension + trigram name indexes for fuzzy search
|
||||||
|
|
||||||
|
Revision ID: 9a2b1c7d4e10
|
||||||
|
Revises: 7fc7024ef432
|
||||||
|
Create Date: 2026-06-07
|
||||||
|
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "9a2b1c7d4e10"
|
||||||
|
down_revision: str | None = "7fc7024ef432"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_names_given_trgm "
|
||||||
|
"ON names USING gin (given gin_trgm_ops)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_names_surname_trgm "
|
||||||
|
"ON names USING gin (surname gin_trgm_ops)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_names_surname_trgm")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_names_given_trgm")
|
||||||
|
# Leave the pg_trgm extension in place; other features may rely on it.
|
||||||
@@ -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")
|
||||||
@@ -11,6 +11,7 @@ import os
|
|||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
import app.models # noqa: F401 — register all models on Base.metadata
|
import app.models # noqa: F401 — register all models on Base.metadata
|
||||||
@@ -72,6 +73,7 @@ async def client():
|
|||||||
|
|
||||||
engine = create_async_engine(TEST_DATABASE_URL)
|
engine = create_async_engine(TEST_DATABASE_URL)
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""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]
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
@@ -68,6 +68,25 @@ async def test_public_tree_viewable_but_not_editable_by_non_member(client):
|
|||||||
assert resp.status_code == 403
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_person_update(client):
|
||||||
|
token = await register(client, "edit@example.com")
|
||||||
|
h = auth(token)
|
||||||
|
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
|
||||||
|
pid = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons", json={"given": "Jon", "surname": "Smith"}, headers=h
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
resp = await client.patch(
|
||||||
|
f"/api/v1/trees/{tid}/persons/{pid}",
|
||||||
|
json={"given": "John", "gender": "male"},
|
||||||
|
headers=auth(token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["primary_name"] == "John Smith"
|
||||||
|
assert resp.json()["gender"] == "male"
|
||||||
|
|
||||||
|
|
||||||
async def test_auth_required_without_token(client):
|
async def test_auth_required_without_token(client):
|
||||||
resp = await client.get("/api/v1/trees")
|
resp = await client.get("/api/v1/trees")
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Update (the U in CRUD) for the remaining entities — rule #8."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup(client, email):
|
||||||
|
h = auth(await register(client, email))
|
||||||
|
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
|
||||||
|
return h, tid
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tree_update(client):
|
||||||
|
h, tid = await _setup(client, "u-tree@example.com")
|
||||||
|
r = await client.patch(
|
||||||
|
f"/api/v1/trees/{tid}", json={"name": "Renamed", "visibility": "unlisted"}, headers=h
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["name"] == "Renamed" and r.json()["visibility"] == "unlisted"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_source_update(client):
|
||||||
|
h, tid = await _setup(client, "u-src@example.com")
|
||||||
|
sid = (
|
||||||
|
await client.post(f"/api/v1/trees/{tid}/sources", json={"title": "Old"}, headers=h)
|
||||||
|
).json()["id"]
|
||||||
|
r = await client.patch(
|
||||||
|
f"/api/v1/trees/{tid}/sources/{sid}",
|
||||||
|
json={"title": "New", "repository": "NARA"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["title"] == "New" and r.json()["repository"] == "NARA"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_media_update(client):
|
||||||
|
h, tid = await _setup(client, "u-media@example.com")
|
||||||
|
mid = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/media",
|
||||||
|
files={"file": ("a.txt", b"x", "text/plain")},
|
||||||
|
data={"title": "old"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
r = await client.patch(f"/api/v1/trees/{tid}/media/{mid}", json={"title": "new"}, headers=h)
|
||||||
|
assert r.status_code == 200 and r.json()["title"] == "new"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_relationship_and_citation_update(client):
|
||||||
|
h, tid = await _setup(client, "u-rc@example.com")
|
||||||
|
|
||||||
|
async def mk(path, body):
|
||||||
|
return (await client.post(f"/api/v1/trees/{tid}/{path}", json=body, headers=h)).json()["id"]
|
||||||
|
|
||||||
|
p1 = await mk("persons", {"given": "A"})
|
||||||
|
p2 = await mk("persons", {"given": "B"})
|
||||||
|
rid = await mk(
|
||||||
|
"relationships",
|
||||||
|
{
|
||||||
|
"type": "parent_child",
|
||||||
|
"person_from_id": p1,
|
||||||
|
"person_to_id": p2,
|
||||||
|
"qualifier": "biological",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
r = await client.patch(
|
||||||
|
f"/api/v1/trees/{tid}/relationships/{rid}", json={"qualifier": "adoptive"}, headers=h
|
||||||
|
)
|
||||||
|
assert r.status_code == 200 and r.json()["qualifier"] == "adoptive"
|
||||||
|
|
||||||
|
src = await mk("sources", {"title": "S"})
|
||||||
|
cid = await mk("citations", {"source_id": src, "person_id": p1})
|
||||||
|
r2 = await client.patch(
|
||||||
|
f"/api/v1/trees/{tid}/citations/{cid}",
|
||||||
|
json={"page": "p.7", "confidence": "high"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert r2.json()["page"] == "p.7" and r2.json()["confidence"] == "high"
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""GEDCOM import + export round-trip."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
SAMPLE = b"""0 HEAD
|
||||||
|
1 CHAR UTF-8
|
||||||
|
0 @I1@ INDI
|
||||||
|
1 NAME John /Smith/
|
||||||
|
1 SEX M
|
||||||
|
1 BIRT
|
||||||
|
2 DATE 1850
|
||||||
|
2 PLAC Boston, Massachusetts
|
||||||
|
0 @I2@ INDI
|
||||||
|
1 NAME Mary /Jones/
|
||||||
|
1 SEX F
|
||||||
|
0 @I3@ INDI
|
||||||
|
1 NAME Junior /Smith/
|
||||||
|
1 BIRT
|
||||||
|
2 DATE 1872
|
||||||
|
0 @F1@ FAM
|
||||||
|
1 HUSB @I1@
|
||||||
|
1 WIFE @I2@
|
||||||
|
1 CHIL @I3@
|
||||||
|
1 MARR
|
||||||
|
2 DATE 1870
|
||||||
|
0 TRLR
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def _tree(client, email):
|
||||||
|
h = auth(await register(client, email))
|
||||||
|
tid = (await client.post("/api/v1/trees", json={"name": "Imported"}, headers=h)).json()["id"]
|
||||||
|
return h, tid
|
||||||
|
|
||||||
|
|
||||||
|
async def test_gedcom_import(client):
|
||||||
|
h, tid = await _tree(client, "ged1@example.com")
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/gedcom/import",
|
||||||
|
files={"file": ("sample.ged", SAMPLE, "text/plain")},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
counts = resp.json()["counts"]
|
||||||
|
assert counts["persons"] == 3
|
||||||
|
assert counts["families"] == 1
|
||||||
|
# partnership (1) + parent_child from both parents to the child (2)
|
||||||
|
assert counts["relationships"] == 3
|
||||||
|
assert counts["events"] == 3 # 2 births + 1 marriage
|
||||||
|
|
||||||
|
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
|
||||||
|
assert len(people) == 3
|
||||||
|
rels = (await client.get(f"/api/v1/trees/{tid}/relationships", headers=h)).json()
|
||||||
|
assert len(rels) == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_gedcom_export_and_reimport(client):
|
||||||
|
h, tid = await _tree(client, "ged2@example.com")
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/gedcom/import",
|
||||||
|
files={"file": ("sample.ged", SAMPLE, "text/plain")},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
exported = await client.get(f"/api/v1/trees/{tid}/gedcom/export", headers=h)
|
||||||
|
assert exported.status_code == 200
|
||||||
|
text = exported.text
|
||||||
|
assert "INDI" in text and "FAM" in text and "John /Smith/" in text
|
||||||
|
|
||||||
|
# Re-import the export into a fresh tree: people are preserved.
|
||||||
|
tid2 = (await client.post("/api/v1/trees", json={"name": "Round"}, headers=h)).json()["id"]
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tid2}/gedcom/import",
|
||||||
|
files={"file": ("rt.ged", text.encode(), "text/plain")},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert resp.json()["counts"]["persons"] == 3
|
||||||
|
assert resp.json()["counts"]["relationships"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -48,6 +48,25 @@ async def test_event_create_list_delete(client):
|
|||||||
assert len(listed.json()) == 0
|
assert len(listed.json()) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_event_update(client):
|
||||||
|
h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "evupd@example.com")
|
||||||
|
eid = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/events",
|
||||||
|
json={"event_type": "birth", "person_id": parent, "date_value": "1850"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
resp = await client.patch(
|
||||||
|
f"/api/v1/trees/{tree_id}/events/{eid}",
|
||||||
|
json={"date_value": "ABT 1851", "event_type": "baptism"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
assert resp.json()["date_value"] == "ABT 1851"
|
||||||
|
assert resp.json()["event_type"] == "baptism"
|
||||||
|
|
||||||
|
|
||||||
async def test_event_requires_exactly_one_subject(client):
|
async def test_event_requires_exactly_one_subject(client):
|
||||||
h, tree_id, _, _ = await _setup_tree_with_two_people(client, "ev2@example.com")
|
h, tree_id, _, _ = await _setup_tree_with_two_people(client, "ev2@example.com")
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
|
|||||||
@@ -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,36 @@
|
|||||||
|
"""Living-person protection: living people are redacted from non-members."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def test_living_person_redacted_for_non_members(client):
|
||||||
|
owner = auth(await register(client, "pub-owner@example.com"))
|
||||||
|
tid = (
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/trees", json={"name": "Public", "visibility": "public"}, headers=owner
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons",
|
||||||
|
json={"given": "Old", "surname": "Ancestor", "is_living": False},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons",
|
||||||
|
json={"given": "Young", "surname": "Living", "is_living": True},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
|
||||||
|
other = auth(await register(client, "pub-viewer@example.com"))
|
||||||
|
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=other)).json()
|
||||||
|
names = {p["primary_name"] for p in people}
|
||||||
|
assert "Old Ancestor" in names # deceased is visible
|
||||||
|
assert "Living person" in names # living is redacted
|
||||||
|
assert "Young Living" not in names # the real living name is hidden
|
||||||
|
# The redacted person leaks no gender.
|
||||||
|
living = next(p for p in people if p["primary_name"] == "Living person")
|
||||||
|
assert living["gender"] is None
|
||||||
|
|
||||||
|
# The owner (a member) sees real names.
|
||||||
|
owner_people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=owner)).json()
|
||||||
|
assert "Young Living" in {p["primary_name"] for p in owner_people}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Soft-delete + recovery for trees and people."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tree_delete_and_restore(client):
|
||||||
|
h = auth(await register(client, "rec1@example.com"))
|
||||||
|
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
|
||||||
|
|
||||||
|
# Delete -> gone from active lists, present in the recovery list.
|
||||||
|
assert (await client.delete(f"/api/v1/trees/{tree_id}", headers=h)).status_code == 204
|
||||||
|
assert len((await client.get("/api/v1/trees", headers=h)).json()) == 0
|
||||||
|
# A soft-deleted tree is no longer visible (404 to the would-be viewer).
|
||||||
|
gone = await client.get(f"/api/v1/trees/{tree_id}", headers=h)
|
||||||
|
assert gone.status_code == 404
|
||||||
|
deleted = (await client.get("/api/v1/trees?deleted=true", headers=h)).json()
|
||||||
|
assert len(deleted) == 1 and deleted[0]["id"] == tree_id
|
||||||
|
|
||||||
|
# Restore -> back in active lists.
|
||||||
|
assert (await client.post(f"/api/v1/trees/{tree_id}/restore", headers=h)).status_code == 200
|
||||||
|
assert len((await client.get("/api/v1/trees", headers=h)).json()) == 1
|
||||||
|
assert (await client.get(f"/api/v1/trees/{tree_id}", headers=h)).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_only_owner_can_delete_tree(client):
|
||||||
|
owner = auth(await register(client, "rec-owner@example.com"))
|
||||||
|
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=owner)).json()["id"]
|
||||||
|
other = auth(await register(client, "rec-other@example.com"))
|
||||||
|
blocked = await client.delete(f"/api/v1/trees/{tree_id}", headers=other)
|
||||||
|
assert blocked.status_code in (403, 404)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_person_delete_and_restore(client):
|
||||||
|
h = auth(await register(client, "rec2@example.com"))
|
||||||
|
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
|
||||||
|
person_id = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/persons", json={"given": "Ada"}, headers=h
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
await client.delete(f"/api/v1/trees/{tree_id}/persons/{person_id}", headers=h)
|
||||||
|
).status_code == 200
|
||||||
|
assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 0
|
||||||
|
deleted = (
|
||||||
|
await client.get(f"/api/v1/trees/{tree_id}/persons?deleted=true", headers=h)
|
||||||
|
).json()
|
||||||
|
assert len(deleted) == 1 and deleted[0]["primary_name"] == "Ada"
|
||||||
|
|
||||||
|
assert (
|
||||||
|
await client.post(f"/api/v1/trees/{tree_id}/persons/{person_id}/restore", headers=h)
|
||||||
|
).status_code == 200
|
||||||
|
assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 1
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Fuzzy name search (pg_trgm)."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fuzzy_name_search(client):
|
||||||
|
h = auth(await register(client, "search@example.com"))
|
||||||
|
tid = (await client.post("/api/v1/trees", json={"name": "S"}, headers=h)).json()["id"]
|
||||||
|
for given, surname in [("Hans", "Mueller"), ("John", "Smith"), ("Anna", "Vogel")]:
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons",
|
||||||
|
json={"given": given, "surname": surname},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Trigram fuzziness: "muller" should find "Mueller" (not a substring match).
|
||||||
|
r = await client.get(f"/api/v1/trees/{tid}/persons", params={"q": "muller"}, headers=h)
|
||||||
|
assert r.status_code == 200
|
||||||
|
names = [p["primary_name"] or "" for p in r.json()]
|
||||||
|
assert any("Mueller" in n for n in names)
|
||||||
|
|
||||||
|
# Substring search still works.
|
||||||
|
r2 = await client.get(f"/api/v1/trees/{tid}/persons", params={"q": "smi"}, headers=h)
|
||||||
|
assert any("Smith" in (p["primary_name"] or "") for p in r2.json())
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -54,3 +59,55 @@ h3,
|
|||||||
::selection {
|
::selection {
|
||||||
background: color-mix(in srgb, var(--color-bronze) 22%, transparent);
|
background: color-mix(in srgb, var(--color-bronze) 22%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pedigree bracket connectors (ancestors grow rightward). Each leaf draws its
|
||||||
|
own half of the vertical spine + a horizontal stub, so lines stay correct
|
||||||
|
regardless of box heights: focus → 2 parents, each parent → 2 grandparents. */
|
||||||
|
.ped-person {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ped-self {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ped-branch {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-left: 2.5rem;
|
||||||
|
}
|
||||||
|
.ped-branch::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -2.5rem;
|
||||||
|
top: 50%;
|
||||||
|
width: 2.5rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.ped-leaf {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
.ped-leaf::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
width: 1.5rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.ped-leaf::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.ped-leaf:first-child::after {
|
||||||
|
top: 50%;
|
||||||
|
}
|
||||||
|
.ped-leaf:last-child::after {
|
||||||
|
bottom: 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,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,325 @@
|
|||||||
|
"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"];
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}, [loadNames]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[var(--muted)]">
|
||||||
|
“Guess from first name” uses a built-in name dictionary for people with no sex set;
|
||||||
|
ambiguous names (Marion, Frances, …) are left for you to decide.
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { GedcomImport } from "@/components/gedcom-import";
|
||||||
|
|
||||||
|
export default function TreeGedcomPage() {
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const treeId = params.id;
|
||||||
|
|
||||||
|
async function exportGed() {
|
||||||
|
const resp = await fetch(`/api/v1/trees/${treeId}/gedcom/export`, { credentials: "include" });
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "tree.ged";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-semibold">Import & export GEDCOM</h1>
|
||||||
|
|
||||||
|
<GedcomImport fixedTreeId={treeId} />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Export this tree</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<p className="text-sm text-[var(--muted)]">
|
||||||
|
Download this tree as a GEDCOM file — people, relationships, events, and sources.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" onClick={exportGed}>
|
||||||
|
Download .ged
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,35 +2,66 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
import type { components } from "@/lib/api/schema";
|
import type { components } from "@/lib/api/schema";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
type Person = components["schemas"]["PersonRead"];
|
type Person = components["schemas"]["PersonRead"];
|
||||||
|
type Relationship = components["schemas"]["RelationshipRead"];
|
||||||
|
type Event = components["schemas"]["EventRead"];
|
||||||
|
|
||||||
export default function TreeDetailPage() {
|
function splitName(full: string): { given: string | null; surname: string | null } {
|
||||||
|
const t = full.trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (t.length === 0) return { given: null, surname: null };
|
||||||
|
if (t.length === 1) return { given: t[0], surname: null };
|
||||||
|
return { given: t.slice(0, -1).join(" "), surname: t[t.length - 1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddKind = "parent" | "child" | "partner";
|
||||||
|
|
||||||
|
export default function FamilyViewPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const treeId = params.id;
|
const treeId = params.id;
|
||||||
|
|
||||||
const [persons, setPersons] = useState<Person[]>([]);
|
const [people, setPeople] = useState<Person[]>([]);
|
||||||
const [given, setGiven] = useState("");
|
const [rels, setRels] = useState<Relationship[]>([]);
|
||||||
const [surname, setSurname] = useState("");
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
const [focusId, setFocusId] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [results, setResults] = useState<Person[] | null>(null); // server fuzzy search
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
// Inline add-relative form: which anchor + kind is open, and the typed name.
|
||||||
|
// `key` keeps each empty slot's inline form independent (a person has 2
|
||||||
|
// parents, 4 grandparents — many same-kind/anchor slots can coexist).
|
||||||
|
const [adding, setAdding] = useState<{ key: string; kind: AddKind; anchor: string } | null>(null);
|
||||||
|
const [addName, setAddName] = useState("");
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
const p = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
||||||
params: { path: { tree_id: treeId } },
|
params: { path: { tree_id: treeId } },
|
||||||
});
|
});
|
||||||
if (response.status === 401) {
|
if (p.response.status === 401) {
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPersons(data ?? []);
|
const [r, e, t] = await Promise.all([
|
||||||
|
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
|
||||||
|
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
|
||||||
|
api.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } }),
|
||||||
|
]);
|
||||||
|
const ppl = p.data ?? [];
|
||||||
|
const home = t.data?.home_person_id ?? null;
|
||||||
|
const homeId = home && ppl.some((x) => x.id === home) ? home : null;
|
||||||
|
setPeople(ppl);
|
||||||
|
setRels(r.data ?? []);
|
||||||
|
setEvents(e.data ?? []);
|
||||||
|
setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null);
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}, [router, treeId]);
|
}, [router, treeId]);
|
||||||
|
|
||||||
@@ -38,60 +69,424 @@ export default function TreeDetailPage() {
|
|||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
async function addPerson(e: React.FormEvent) {
|
// Debounced server-side fuzzy search (pg_trgm) across the whole tree.
|
||||||
e.preventDefault();
|
useEffect(() => {
|
||||||
if (!given.trim() && !surname.trim()) return;
|
const q = search.trim();
|
||||||
const { error } = await api.POST("/api/v1/trees/{tree_id}/persons", {
|
if (!q) {
|
||||||
params: { path: { tree_id: treeId } },
|
setResults(null);
|
||||||
body: { given: given || null, surname: surname || null },
|
return;
|
||||||
});
|
|
||||||
if (!error) {
|
|
||||||
setGiven("");
|
|
||||||
setSurname("");
|
|
||||||
load();
|
|
||||||
}
|
}
|
||||||
|
const t = setTimeout(async () => {
|
||||||
|
const { data } = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
||||||
|
params: { path: { tree_id: treeId }, query: { q } },
|
||||||
|
});
|
||||||
|
setResults(data ?? []);
|
||||||
|
}, 250);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [search, treeId]);
|
||||||
|
|
||||||
|
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
|
||||||
|
// Order parents deterministically: father (male) on top, mother below, with a
|
||||||
|
// stable fallback when gender is unknown (so it doesn't depend on which link
|
||||||
|
// happened to be created first).
|
||||||
|
const parentRank = (id: string) => {
|
||||||
|
const g = byId.get(id)?.gender;
|
||||||
|
return g === "male" ? 0 : g === "female" ? 1 : 2;
|
||||||
|
};
|
||||||
|
const parentsOf = (id: string) =>
|
||||||
|
rels
|
||||||
|
.filter((r) => r.type === "parent_child" && r.person_to_id === id)
|
||||||
|
.map((r) => r.person_from_id)
|
||||||
|
.sort((a, b) => parentRank(a) - parentRank(b));
|
||||||
|
const childrenOf = (id: string) =>
|
||||||
|
rels.filter((r) => r.type === "parent_child" && r.person_from_id === id).map((r) => r.person_to_id);
|
||||||
|
const partnersOf = (id: string) =>
|
||||||
|
rels
|
||||||
|
.filter((r) => r.type === "partnership" && (r.person_from_id === id || r.person_to_id === id))
|
||||||
|
.map((r) => (r.person_from_id === id ? r.person_to_id : r.person_from_id));
|
||||||
|
|
||||||
|
const years = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
const yr = (e: Event) => (e.date_start ? e.date_start.slice(0, 4) : e.date_value ?? "");
|
||||||
|
for (const p of people) {
|
||||||
|
const b = events.find((e) => e.person_id === p.id && e.event_type === "birth");
|
||||||
|
const d = events.find((e) => e.person_id === p.id && e.event_type === "death");
|
||||||
|
const parts = [b ? yr(b) : "", d ? yr(d) : ""];
|
||||||
|
if (parts[0] || parts[1]) m.set(p.id, `${parts[0]}–${parts[1]}`.replace(/^–$/, ""));
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [people, events]);
|
||||||
|
|
||||||
|
async function addPerson(name: string): Promise<string | null> {
|
||||||
|
const { given, surname } = splitName(name);
|
||||||
|
const { data } = await api.POST("/api/v1/trees/{tree_id}/persons", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
body: { given, surname },
|
||||||
|
});
|
||||||
|
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) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!firstName.trim()) return;
|
||||||
|
const id = await addPerson(firstName);
|
||||||
|
setFirstName("");
|
||||||
|
if (id) setFocusId(id);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postRel(body: components["schemas"]["RelationshipCreate"]) {
|
||||||
|
await api.POST("/api/v1/trees/{tree_id}/relationships", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the relationship(s) connecting an (existing or new) person to anchor.
|
||||||
|
async function createLink(kind: AddKind, anchor: string, personId: string) {
|
||||||
|
if (kind === "parent") {
|
||||||
|
await postRel({ type: "parent_child", person_from_id: personId, person_to_id: anchor, qualifier: "biological" });
|
||||||
|
} else if (kind === "partner") {
|
||||||
|
await postRel({ type: "partnership", person_from_id: anchor, person_to_id: personId });
|
||||||
|
} else {
|
||||||
|
// child: link to anchor, and to anchor's spouse too (so both parents show)
|
||||||
|
await postRel({ type: "parent_child", person_from_id: anchor, person_to_id: personId, qualifier: "biological" });
|
||||||
|
const partners = partnersOf(anchor);
|
||||||
|
if (partners.length === 1) {
|
||||||
|
await postRel({ type: "parent_child", person_from_id: partners[0], person_to_id: personId, qualifier: "biological" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkExisting(personId: string) {
|
||||||
|
if (!adding) return;
|
||||||
|
await createLink(adding.kind, adding.anchor, personId);
|
||||||
|
setAdding(null);
|
||||||
|
setAddName("");
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAdd(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!adding || !addName.trim()) return;
|
||||||
|
const newId = await addPerson(addName);
|
||||||
|
if (newId) await createLink(adding.kind, adding.anchor, newId);
|
||||||
|
setAdding(null);
|
||||||
|
setAddName("");
|
||||||
|
load();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
|
|
||||||
return (
|
if (people.length === 0) {
|
||||||
<div className="space-y-6">
|
return (
|
||||||
<h1 className="text-2xl font-semibold">People</h1>
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-semibold">Start your tree</h1>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<form onSubmit={createFirst} className="flex flex-wrap gap-2">
|
||||||
|
<Input
|
||||||
|
className="w-64"
|
||||||
|
placeholder="First person's full name"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="submit">Add person</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const focus = focusId ? byId.get(focusId) : undefined;
|
||||||
|
if (!focus) {
|
||||||
|
setFocusId(people[0].id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PersonBox = ({
|
||||||
|
id,
|
||||||
|
muted,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
muted?: boolean;
|
||||||
|
}) => {
|
||||||
|
const p = byId.get(id);
|
||||||
|
if (!p) return null;
|
||||||
|
const isFocus = id === focusId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setFocusId(id)}
|
||||||
|
className={`w-44 rounded-lg border px-3 py-2 text-left transition-colors ${
|
||||||
|
isFocus
|
||||||
|
? "border-bronze bg-bronze/[0.08]"
|
||||||
|
: "border-[var(--border)] bg-[var(--surface)] hover:border-bronze/60"
|
||||||
|
} ${muted ? "opacity-90" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="truncate text-sm font-medium">{p.primary_name ?? "Unnamed"}</div>
|
||||||
|
<div className="text-xs text-[var(--muted)]">{years.get(id) ?? "—"}</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddSlot = ({
|
||||||
|
formKey,
|
||||||
|
kind,
|
||||||
|
anchor,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
formKey: string;
|
||||||
|
kind: AddKind;
|
||||||
|
anchor: string;
|
||||||
|
label: string;
|
||||||
|
}) =>
|
||||||
|
adding?.key === formKey ? (
|
||||||
|
<form onSubmit={submitAdd} className="flex w-56 flex-col gap-1">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
className="h-9"
|
||||||
|
placeholder="Search existing or type a new name"
|
||||||
|
value={addName}
|
||||||
|
onChange={(e) => setAddName(e.target.value)}
|
||||||
|
/>
|
||||||
|
{addName.trim() && (
|
||||||
|
<div className="overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface)] text-sm">
|
||||||
|
{people
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
p.id !== anchor &&
|
||||||
|
(p.primary_name ?? "").toLowerCase().includes(addName.trim().toLowerCase()),
|
||||||
|
)
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => linkExisting(p.id)}
|
||||||
|
className="flex w-full items-center justify-between gap-2 px-2 py-1.5 text-left hover:bg-bronze/[0.07]"
|
||||||
|
>
|
||||||
|
<span className="truncate">{p.primary_name ?? "Unnamed"}</span>
|
||||||
|
<span className="shrink-0 text-xs text-[var(--muted)]">{years.get(p.id) ?? ""}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex w-full items-center gap-1 border-t border-[var(--border)] px-2 py-1.5 text-left text-bronze hover:bg-bronze/[0.07]"
|
||||||
|
>
|
||||||
|
+ Create new “{addName.trim()}”
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={() => setAdding(null)} className="text-xs text-[var(--muted)]">
|
||||||
|
cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setAdding({ key: formKey, kind, anchor });
|
||||||
|
setAddName("");
|
||||||
|
}}
|
||||||
|
className="w-44 rounded-lg border border-dashed border-[var(--border)] px-3 py-2 text-left text-sm text-[var(--muted)] hover:border-bronze hover:text-bronze"
|
||||||
|
>
|
||||||
|
+ {label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Recursive ancestor chart (grows rightward): a node is its box plus a
|
||||||
|
// two-leaf "branch" of its parents, with CSS bracket connectors. Depth 0 =
|
||||||
|
// focus, capped at grandparents (depth 2).
|
||||||
|
const renderNode = (
|
||||||
|
slotPersonId: string | null,
|
||||||
|
childId: string,
|
||||||
|
keyPrefix: string,
|
||||||
|
depth: number,
|
||||||
|
): React.ReactNode => {
|
||||||
|
const box = slotPersonId ? (
|
||||||
|
<PersonBox id={slotPersonId} muted={depth > 0} />
|
||||||
|
) : (
|
||||||
|
<AddSlot formKey={keyPrefix} kind="parent" anchor={childId} label="add parent" />
|
||||||
|
);
|
||||||
|
if (!slotPersonId || depth >= 2) {
|
||||||
|
return <div className="ped-person">{box}</div>;
|
||||||
|
}
|
||||||
|
const ps = parentsOf(slotPersonId);
|
||||||
|
return (
|
||||||
|
<div className="ped-person">
|
||||||
|
<div className="ped-self">{box}</div>
|
||||||
|
<div className="ped-branch">
|
||||||
|
<div className="ped-leaf">
|
||||||
|
{renderNode(ps[0] ?? null, slotPersonId, `${keyPrefix}-a`, depth + 1)}
|
||||||
|
</div>
|
||||||
|
<div className="ped-leaf">
|
||||||
|
{renderNode(ps[1] ?? null, slotPersonId, `${keyPrefix}-b`, depth + 1)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const partners = partnersOf(focus.id);
|
||||||
|
const children = childrenOf(focus.id);
|
||||||
|
|
||||||
|
// "Dangling" people: not linked to anyone. Common after a GEDCOM import or a
|
||||||
|
// mistaken delete — surface them so they're not lost in the directory.
|
||||||
|
const connected = new Set<string>();
|
||||||
|
for (const r of rels) {
|
||||||
|
connected.add(r.person_from_id);
|
||||||
|
connected.add(r.person_to_id);
|
||||||
|
}
|
||||||
|
const unconnected = people
|
||||||
|
.filter((p) => !connected.has(p.id))
|
||||||
|
.sort((a, b) => (a.primary_name ?? "").localeCompare(b.primary_name ?? ""));
|
||||||
|
|
||||||
|
const sorted = [...people].sort((a, b) =>
|
||||||
|
(a.primary_name ?? "").localeCompare(b.primary_name ?? ""),
|
||||||
|
);
|
||||||
|
// Server fuzzy results when searching; otherwise the loaded set.
|
||||||
|
const directory = results ?? sorted;
|
||||||
|
const shown = directory.slice(0, 200); // cap DOM nodes; refine search to narrow
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold">Family view</h1>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button size="sm" onClick={newPersonAndGo}>
|
||||||
|
+ Add person
|
||||||
|
</Button>
|
||||||
|
<Link
|
||||||
|
href={`/trees/${treeId}/persons/${focus.id}`}
|
||||||
|
className="text-sm text-bronze hover:underline"
|
||||||
|
>
|
||||||
|
Open {focus.primary_name ?? "person"} →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pedigree: focus → parents → grandparents, with bracket connectors */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardContent className="overflow-x-auto p-6">
|
||||||
<CardTitle className="text-base">Add a person</CardTitle>
|
<div className="min-w-[44rem]">{renderNode(focus.id, focus.id, "ped", 0)}</div>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={addPerson} className="flex gap-2">
|
|
||||||
<Input placeholder="Given name" value={given} onChange={(e) => setGiven(e.target.value)} />
|
|
||||||
<Input placeholder="Surname" value={surname} onChange={(e) => setSurname(e.target.value)} />
|
|
||||||
<Button type="submit">Add</Button>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div>
|
{/* Family group: partners + children of the focus */}
|
||||||
<h2 className="mb-2 text-lg font-semibold">People</h2>
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
{persons.length === 0 ? (
|
<Card>
|
||||||
<p className="text-[var(--muted)]">No people yet.</p>
|
<CardContent className="space-y-3 p-6">
|
||||||
) : (
|
<h2 className="font-serif text-base font-semibold">Spouses & partners</h2>
|
||||||
<ul className="space-y-2">
|
<div className="flex flex-wrap gap-3">
|
||||||
{persons.map((person) => (
|
{partners.map((id) => (
|
||||||
<li key={person.id}>
|
<PersonBox key={id} id={id} muted />
|
||||||
<Link href={`/trees/${treeId}/persons/${person.id}`}>
|
))}
|
||||||
<Card className="transition-colors hover:border-bronze/50">
|
<AddSlot
|
||||||
<CardContent className="p-4">
|
formKey={`partner-${focus.id}`}
|
||||||
{person.primary_name ?? (
|
kind="partner"
|
||||||
<span className="text-[var(--muted)]">Unnamed</span>
|
anchor={focus.id}
|
||||||
)}
|
label="add spouse"
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
</div>
|
||||||
</Link>
|
</CardContent>
|
||||||
</li>
|
</Card>
|
||||||
))}
|
|
||||||
</ul>
|
<Card>
|
||||||
)}
|
<CardContent className="space-y-3 p-6">
|
||||||
|
<h2 className="font-serif text-base font-semibold">Children</h2>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{children.map((id) => (
|
||||||
|
<PersonBox key={id} id={id} muted />
|
||||||
|
))}
|
||||||
|
<AddSlot
|
||||||
|
formKey={`child-${focus.id}`}
|
||||||
|
kind="child"
|
||||||
|
anchor={focus.id}
|
||||||
|
label="add child"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unconnected people — not linked to anyone in the tree */}
|
||||||
|
{unconnected.length > 0 && (
|
||||||
|
<Card className="border-bronze/40">
|
||||||
|
<CardContent className="space-y-3 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="font-serif text-base font-semibold">
|
||||||
|
Not connected to anyone ({unconnected.length})
|
||||||
|
</h2>
|
||||||
|
<span className="text-xs text-[var(--muted)]">
|
||||||
|
Open one and add a relationship, or delete it.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{unconnected.slice(0, 60).map((p) => (
|
||||||
|
<div key={p.id} className="flex items-center gap-1">
|
||||||
|
<PersonBox id={p.id} muted />
|
||||||
|
<Link
|
||||||
|
href={`/trees/${treeId}/persons/${p.id}`}
|
||||||
|
className="text-xs text-bronze hover:underline"
|
||||||
|
>
|
||||||
|
open
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{unconnected.length > 60 && (
|
||||||
|
<p className="text-xs text-[var(--muted)]">
|
||||||
|
Showing 60 of {unconnected.length}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scrollable, searchable people directory (scales to large trees) */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 className="font-serif text-base font-semibold">People ({people.length})</h2>
|
||||||
|
<Input
|
||||||
|
className="w-64"
|
||||||
|
placeholder="Search by name…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{shown.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-sm text-[var(--muted)]">No matches.</div>
|
||||||
|
) : (
|
||||||
|
shown.map((p, i) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => setFocusId(p.id)}
|
||||||
|
className={`flex w-full items-center justify-between gap-3 px-4 py-2.5 text-left text-sm transition-colors ${
|
||||||
|
i > 0 ? "border-t border-[var(--border)]" : ""
|
||||||
|
} ${p.id === focusId ? "bg-bronze/[0.08]" : "hover:bg-bronze/[0.05]"}`}
|
||||||
|
>
|
||||||
|
<span className="truncate font-medium">{p.primary_name ?? "Unnamed"}</span>
|
||||||
|
<span className="shrink-0 text-xs text-[var(--muted)]">
|
||||||
|
{years.get(p.id) ?? ""}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{directory.length > shown.length && (
|
||||||
|
<div className="border-t border-[var(--border)] bg-[var(--surface)] px-4 py-2 text-xs text-[var(--muted)]">
|
||||||
|
Showing {shown.length} of {directory.length} — refine your search to narrow.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api/client";
|
||||||
|
import type { components } from "@/lib/api/schema";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
|
||||||
|
type Person = components["schemas"]["PersonRead"];
|
||||||
|
|
||||||
|
export default function RecoveryPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const treeId = params.id;
|
||||||
|
|
||||||
|
const [people, setPeople] = useState<Person[]>([]);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
||||||
|
params: { path: { tree_id: treeId }, query: { deleted: true } },
|
||||||
|
});
|
||||||
|
if (response.status === 401) {
|
||||||
|
router.push("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPeople(data ?? []);
|
||||||
|
setReady(true);
|
||||||
|
}, [router, treeId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function restore(id: string) {
|
||||||
|
await api.POST("/api/v1/trees/{tree_id}/persons/{person_id}/restore", {
|
||||||
|
params: { path: { tree_id: treeId, person_id: id } },
|
||||||
|
});
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-semibold">Recently deleted</h1>
|
||||||
|
<p className="text-sm text-[var(--muted)]">
|
||||||
|
Deleted people are recoverable for 30 days, then permanently purged.
|
||||||
|
</p>
|
||||||
|
{people.length === 0 ? (
|
||||||
|
<p className="text-[var(--muted)]">Nothing here.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{people.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<span className="text-[var(--muted)]">{p.primary_name ?? "Unnamed"}</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => restore(p.id)}>
|
||||||
|
Restore
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,885 @@
|
|||||||
|
.f3 {
|
||||||
|
--female-color: rgb(196, 138, 146);
|
||||||
|
--male-color: rgb(120, 159, 172);
|
||||||
|
--genderless-color: lightgray;
|
||||||
|
--background-color: rgb(33, 33, 33);
|
||||||
|
--text-color: #fff;
|
||||||
|
|
||||||
|
font-family: 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.f3 svg.main_svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.f3 svg.main_svg text {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
.f3 rect.card-female, .f3 .card-female .card-body-rect, .f3 .card-female .text-overflow-mask {
|
||||||
|
fill: var(--female-color);
|
||||||
|
}
|
||||||
|
.f3 rect.card-male, .f3 .card-male .card-body-rect, .f3 .card-male .text-overflow-mask {
|
||||||
|
fill: var(--male-color);
|
||||||
|
}
|
||||||
|
.f3 .card-genderless .card-body-rect, .f3 .card-genderless .text-overflow-mask {
|
||||||
|
fill: var(--genderless-color);
|
||||||
|
}
|
||||||
|
.f3 .card_add .card-body-rect {
|
||||||
|
fill: #3b5560;
|
||||||
|
stroke-width: 4px;
|
||||||
|
stroke: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.f3 g.card_add text {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
.f3 .card-main-outline {
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 3px;
|
||||||
|
}
|
||||||
|
.f3 .card_family_tree rect {
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
.f3 .card_family_tree:hover rect {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
.f3 .card_add_relative {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #fff;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
.f3 .card_add_relative circle {
|
||||||
|
fill: rgba(0, 0, 0, 0);
|
||||||
|
}
|
||||||
|
.f3 .card_add_relative:hover {
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.f3 .card_edit.pencil_icon {
|
||||||
|
color: #fff;
|
||||||
|
transition: 0.3s;
|
||||||
|
}
|
||||||
|
.f3 .card_edit.pencil_icon:hover {
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.f3 .card_break_link, .f3 .link_upper, .f3 .link_lower, .f3 .link_particles {
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
transition: 1s;
|
||||||
|
}
|
||||||
|
.f3 .card_break_link {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.f3 .card_break_link.closed .link_upper {
|
||||||
|
transform: translate(-140.5px, 655.6px);
|
||||||
|
}
|
||||||
|
.f3 .card_break_link.closed .link_upper g {
|
||||||
|
transform: rotate(-58deg);
|
||||||
|
}
|
||||||
|
.f3 .card_break_link.closed .link_particles {
|
||||||
|
transform: scale(0);
|
||||||
|
}
|
||||||
|
.f3 .input-field input {
|
||||||
|
height: 2.5rem !important;
|
||||||
|
}
|
||||||
|
.f3 .input-field > label:not(.label-icon).active {
|
||||||
|
-webkit-transform: translateY(-8px) scale(0.8);
|
||||||
|
transform: translateY(-8px) scale(0.8);
|
||||||
|
}
|
||||||
|
.f3.f3-cont {
|
||||||
|
width:100%;
|
||||||
|
height:900px;
|
||||||
|
max-height:70vh;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
.f3 {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* form-info */
|
||||||
|
.f3-form input[type="text"],
|
||||||
|
.f3-form textarea,
|
||||||
|
.f3-form select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 8px 0;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--background-color);
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form input[type="text"]:focus,
|
||||||
|
.f3-form textarea:focus,
|
||||||
|
.f3-form select:focus {
|
||||||
|
box-shadow: 0 0 5px rgba(76, 175, 80, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 10px 0;
|
||||||
|
transition: background-color 0.3s ease-in-out, border-color 0.3s ease-in-out, color 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form button[type="submit"] {
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-cancel-btn {
|
||||||
|
background-color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form .f3-delete-btn {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid #f44336;
|
||||||
|
color: #f44336;
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-delete-btn:hover {
|
||||||
|
background-color: #da190b;
|
||||||
|
border-color: #da190b;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-delete-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
background-color: transparent;
|
||||||
|
color: #f44336;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form .f3-remove-relative-btn {
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
color: currentColor;
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-remove-relative-btn:hover, .f3-remove-relative-btn.active {
|
||||||
|
background-color: var(--text-color);
|
||||||
|
border-color: var(--text-color);
|
||||||
|
color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-radio-group {
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-radio-group label {
|
||||||
|
margin-right: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-radio-group input[type="radio"] {
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-info-field-label, .f3-form-field label {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
display: block;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-info-field-value {
|
||||||
|
font-weight: normal;
|
||||||
|
display: block;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.2);
|
||||||
|
padding-bottom: 1px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
min-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form-buttons {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form-title {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form.non-editable .f3-form-buttons,
|
||||||
|
.f3-form.non-editable .f3-delete-btn,
|
||||||
|
.f3-form.non-editable .f3-remove-relative-btn,
|
||||||
|
.f3-form.non-editable .f3-link-existing-relative {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-close-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
top: 8px;
|
||||||
|
font-size: 30px;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-edit-btn {
|
||||||
|
position: relative;
|
||||||
|
top: -1px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-add-relative-btn {
|
||||||
|
cursor: pointer;
|
||||||
|
width: 27px;
|
||||||
|
height: 27px;
|
||||||
|
margin-right: 5px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* card-html */
|
||||||
|
|
||||||
|
.f3 div.card {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-color);
|
||||||
|
position: relative;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-image-circle {
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 5px;
|
||||||
|
width: 90px;
|
||||||
|
height: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-image-circle div.card-label {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -10px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 50%);
|
||||||
|
max-width: 150%;
|
||||||
|
min-height: 22px;
|
||||||
|
text-align: center;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-image-circle img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-image-circle svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-image-circle img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-rect {
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
width: 120px;
|
||||||
|
min-height: 70px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.f3 div.card-image-rect {
|
||||||
|
width: 200px;
|
||||||
|
min-height: 70px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-image-rect .person-icon {
|
||||||
|
height: 70px;
|
||||||
|
width: 70px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 5px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-image-rect img {
|
||||||
|
height: 70px;
|
||||||
|
width: 70px;
|
||||||
|
object-fit: cover;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 5px;
|
||||||
|
margin-right: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-image-rect svg {
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-image-rect div.card-label {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.mini-tree {
|
||||||
|
text-align: right;
|
||||||
|
position: absolute;
|
||||||
|
top: -15px;
|
||||||
|
right: -2px;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
.f3 div.mini-tree svg {
|
||||||
|
width: 55px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-card-duplicate-tag {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
color: rgb(255, 251, 220);
|
||||||
|
background-color: rgba(255, 251, 220, 0);
|
||||||
|
border-radius: 50%;
|
||||||
|
padding: 2px;
|
||||||
|
transition: color 0.2s ease-in-out, background-color 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-card-duplicate-hover div.card-inner {
|
||||||
|
transform: translate(0, -2px);
|
||||||
|
outline: 4px solid rgb(255, 251, 220);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-card-duplicate-hover .f3-card-duplicate-tag {
|
||||||
|
background-color: rgba(255, 251, 220, .8);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-remove-relative-active .card {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-remove-relative-active .card-inner {
|
||||||
|
transition: border 0.2s ease-in-out, opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
|
||||||
|
opacity: .75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-remove-relative-active .card:hover .card-inner {
|
||||||
|
opacity: .25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-remove-relative-active .card-male.card-depth--1:hover .card-inner {
|
||||||
|
transform: translate(-8px, -8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-remove-relative-active .card.card-female.card-depth--1:hover .card-inner {
|
||||||
|
transform: translate(8px, -8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-remove-relative-active .card.card-female.card-depth-0:hover .card-inner {
|
||||||
|
transform: translate(8px, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-remove-relative-active .card.card-male.card-depth-0:hover .card-inner {
|
||||||
|
transform: translate(-8px, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-remove-relative-active .card.card-depth-1:hover .card-inner {
|
||||||
|
transform: translate(0, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-remove-relative-active .card.card-main .card-inner {
|
||||||
|
transform: translate(0, 0)!important;
|
||||||
|
opacity: 1!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.f3 div.card > div {
|
||||||
|
transition: transform 0.2s ease-in-out;
|
||||||
|
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .card-inner {
|
||||||
|
outline: 0px solid rgba(255, 255, 255, 1);
|
||||||
|
transition: outline 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-female .card-inner, .f3 div.card-female .person-icon svg {
|
||||||
|
background-color: var(--female-color);
|
||||||
|
}
|
||||||
|
.f3 div.card-male .card-inner, .f3 div.card-male .person-icon svg {
|
||||||
|
background-color: var(--male-color);
|
||||||
|
}
|
||||||
|
.f3 div.card-genderless .card-inner, .f3 div.card-genderless .person-icon svg {
|
||||||
|
background-color: var(--genderless-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-new-rel .card-inner, .f3 div.card-new-rel .person-icon svg {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-to-add .card-inner {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-to-add .card-inner .card-label {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-to-add .person-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-new-rel .card-inner {
|
||||||
|
border-width: 1px;
|
||||||
|
border-style: dashed;
|
||||||
|
outline: 0px !important;
|
||||||
|
}
|
||||||
|
.f3 div.card-new-rel.card-female .card-inner, .f3 div.card-to-add.card-female .card-inner {
|
||||||
|
border-color: var(--female-color);
|
||||||
|
color: var(--female-color);
|
||||||
|
}
|
||||||
|
.f3 div.card-new-rel.card-male .card-inner, .f3 div.card-to-add.card-male .card-inner {
|
||||||
|
color: var(--male-color);
|
||||||
|
border-color: var(--male-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-unknown .card-inner {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-unknown .card-inner .card-label {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-unknown .person-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-new-rel .card-inner {
|
||||||
|
border-width: 1px;
|
||||||
|
border-style: dashed;
|
||||||
|
outline: 0px !important;
|
||||||
|
}
|
||||||
|
.f3 div.card-new-rel.card-female .card-inner, .f3 div.card-unknown.card-female .card-inner {
|
||||||
|
border-color: var(--female-color);
|
||||||
|
color: var(--female-color);
|
||||||
|
}
|
||||||
|
.f3 div.card-new-rel.card-male .card-inner, .f3 div.card-unknown.card-male .card-inner {
|
||||||
|
color: var(--male-color);
|
||||||
|
border-color: var(--male-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card:hover > div {
|
||||||
|
transform: translate(0, -2px);
|
||||||
|
}
|
||||||
|
.f3 div.card-main .card-inner, .f3 div.card:hover .card-inner {
|
||||||
|
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-main .card-inner {
|
||||||
|
outline: 4px solid rgba(220, 220, 220, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 div.card-inner.f3-path-to-main {
|
||||||
|
outline: 4px solid rgba(255, 255, 255, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .link {
|
||||||
|
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 {
|
||||||
|
stroke-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.f3-form-cont {
|
||||||
|
position: relative;
|
||||||
|
z-index: 6;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
overflow: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form-cont.opened {
|
||||||
|
width: 350px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-form hr {
|
||||||
|
border-style: solid;
|
||||||
|
border-width: thin 0 0 0;
|
||||||
|
opacity: 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-nav-cont {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-history-controls {
|
||||||
|
padding: 8px 5px 7px 9px;
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-back-button, .f3-forward-button {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-block;
|
||||||
|
background-color: transparent;
|
||||||
|
border: none;
|
||||||
|
margin-right: 10px;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-history-controls svg {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-back-button.disabled, .f3-forward-button.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-modal {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
background-color: rgb(0,0,0);
|
||||||
|
background-color: rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-modal-content {
|
||||||
|
position: relative;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
margin: 15% auto;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #888;
|
||||||
|
border-radius: 5px;
|
||||||
|
width: 500px;
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-modal-close {
|
||||||
|
color: #aaa;
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
top: 7px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-modal-close:hover,
|
||||||
|
.f3-modal-close:focus {
|
||||||
|
color: black;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-popup {
|
||||||
|
position: fixed;
|
||||||
|
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-popup-content {
|
||||||
|
position: relative;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border: 1px solid #888;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-popup-nav {
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-popup-content-inner {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-popup-close {
|
||||||
|
color: #aaa;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 4;
|
||||||
|
right: 6px;
|
||||||
|
top: 1px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-popup-close:hover,
|
||||||
|
.f3-popup-close:focus {
|
||||||
|
color: black;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.f3-btn {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px 10px;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
border-width: 0;
|
||||||
|
outline: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, .6);
|
||||||
|
|
||||||
|
background-color: var(--text-color);
|
||||||
|
color: var(--background-color);
|
||||||
|
|
||||||
|
transition: background-color .3s;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-btn:hover, .f3-btn:focus {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-female-bg {
|
||||||
|
background-color: var(--female-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-male-bg {
|
||||||
|
background-color: var(--male-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-genderless-bg {
|
||||||
|
background-color: var(--genderless-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-female-color {
|
||||||
|
color: var(--female-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-male-color {
|
||||||
|
color: var(--male-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-genderless-color {
|
||||||
|
color: var(--genderless-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-autocomplete-cont {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
z-index: 2;
|
||||||
|
font-size: 14px;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-autocomplete input {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
padding: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.f3-autocomplete input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-autocomplete-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
top: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-color);
|
||||||
|
transition: color 0.3s ease-in-out;
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-autocomplete-items {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-top: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 0;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
transition: max-height 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-autocomplete.active .f3-autocomplete-items {
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-autocomplete-item > div {
|
||||||
|
padding: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
transition: background-color 0.3s ease-in-out, color 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
.f3-autocomplete-item > div:hover, .f3-autocomplete-item.f3-selected > div {
|
||||||
|
background-color: var(--text-color);
|
||||||
|
color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-autocomplete-active {
|
||||||
|
background-color: DodgerBlue !important;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-kinship-info {
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-kinship-info .f3-info-field {
|
||||||
|
color:#b3b01e
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-kinship-info-icon {
|
||||||
|
cursor:pointer;
|
||||||
|
display:inline-block;
|
||||||
|
width:18px;
|
||||||
|
height:18px;
|
||||||
|
color:#04a4f4;
|
||||||
|
position:relative;
|
||||||
|
top:4px;
|
||||||
|
left:2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-kinship-info .f3 {
|
||||||
|
width:100%;
|
||||||
|
height: 100%;
|
||||||
|
position:relative;
|
||||||
|
background-color:rgb(33,33,33);
|
||||||
|
color:#fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-kinship-info .card-kinship-self {
|
||||||
|
min-height: 0px;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--background-color) !important;
|
||||||
|
border: solid 3px;
|
||||||
|
color: #437fae;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-kinship-info .card-kinship-self.f3-real-label {
|
||||||
|
width: 150px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-kinship-info .card-kinship-rel {
|
||||||
|
min-height: 0px;
|
||||||
|
width: 150px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 50px;
|
||||||
|
background-color: #1d3456 !important;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3 .f3-kinship-info .card-kinship-default {
|
||||||
|
min-height: 0px;
|
||||||
|
width: 150px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 50px;
|
||||||
|
background-color: var(--background-color) !important;
|
||||||
|
border: solid 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-kinship-labels-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-kinship-labels-toggle label {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f3-kinship-labels-toggle input[type="checkbox"] {
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 5px;
|
||||||
|
margin-right: 5px;
|
||||||
|
margin-top: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Vendored from family-chart/dist/styles (the package blocks the CSS subpath export).
|
||||||
|
import "./chart.css";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api/client";
|
||||||
|
import type { components } from "@/lib/api/schema";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { FanChart } from "@/components/fan-chart";
|
||||||
|
|
||||||
|
type Person = components["schemas"]["PersonRead"];
|
||||||
|
type Relationship = components["schemas"]["RelationshipRead"];
|
||||||
|
type Event = components["schemas"]["EventRead"];
|
||||||
|
type Mode = "landscape" | "portrait" | "fan";
|
||||||
|
|
||||||
|
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]];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TreePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const treeId = params.id;
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const chartRef = useRef<any>(null);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const [people, setPeople] = useState<Person[]>([]);
|
||||||
|
const [rels, setRels] = useState<Relationship[]>([]);
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [status, setStatus] = useState<"loading" | "empty" | "ready" | "error">("loading");
|
||||||
|
const [focusId, setFocusId] = useState<string | null>(null);
|
||||||
|
const [mode, setMode] = useState<Mode>("landscape");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const p = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
||||||
|
params: { path: { tree_id: treeId } },
|
||||||
|
});
|
||||||
|
if (p.response.status === 401) {
|
||||||
|
router.push("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [r, e, t] = await Promise.all([
|
||||||
|
api.GET("/api/v1/trees/{tree_id}/relationships", { params: { path: { tree_id: treeId } } }),
|
||||||
|
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
|
||||||
|
api.GET("/api/v1/trees/{tree_id}", { params: { path: { tree_id: treeId } } }),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
const ppl = p.data ?? [];
|
||||||
|
const home = t.data?.home_person_id ?? null;
|
||||||
|
const homeId = home && ppl.some((x) => x.id === home) ? home : null;
|
||||||
|
setPeople(ppl);
|
||||||
|
setRels(r.data ?? []);
|
||||||
|
setEvents(e.data ?? []);
|
||||||
|
// Open on the tree's default/home person when set, else the first person.
|
||||||
|
setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null);
|
||||||
|
setStatus(ppl.length ? "ready" : "empty");
|
||||||
|
})().catch(() => !cancelled && setStatus("error"));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [router, treeId]);
|
||||||
|
|
||||||
|
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
|
||||||
|
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 childrenOf = useCallback(
|
||||||
|
(id: string) =>
|
||||||
|
rels.filter((x) => x.type === "parent_child" && x.person_from_id === id).map((x) => x.person_to_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 nameOf = useCallback((id: string) => byId.get(id)?.primary_name ?? "Unknown", [byId]);
|
||||||
|
const yearOf = useCallback((id: string) => years.get(id) ?? "", [years]);
|
||||||
|
|
||||||
|
// family-chart for landscape/portrait. Intentionally not keyed on focusId —
|
||||||
|
// card clicks recenter via updateMainId without rebuilding the chart.
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== "ready" || mode === "fan" || !containerRef.current) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
// Only link to people that still exist — a soft-deleted person leaves
|
||||||
|
// dangling relationship rows, and family-chart breaks on an id with no
|
||||||
|
// matching datum. Filter them out so a deletion never blanks the tree.
|
||||||
|
const alive = new Set(people.map((pp) => pp.id));
|
||||||
|
const keep = (ids: string[]) => ids.filter((id) => alive.has(id));
|
||||||
|
const data = people.map((pp) => {
|
||||||
|
const [fn, ln] = splitName(pp.primary_name);
|
||||||
|
return {
|
||||||
|
id: pp.id,
|
||||||
|
data: {
|
||||||
|
"first name": fn || "Unnamed",
|
||||||
|
"last name": ln,
|
||||||
|
birthday: years.get(pp.id) ?? "",
|
||||||
|
gender: pp.gender === "female" ? "F" : "M",
|
||||||
|
},
|
||||||
|
rels: {
|
||||||
|
spouses: keep(partnersOf(pp.id)),
|
||||||
|
parents: keep(parentsOf(pp.id)),
|
||||||
|
children: keep(childrenOf(pp.id)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const f3 = await import("family-chart");
|
||||||
|
if (cancelled || !containerRef.current) return;
|
||||||
|
containerRef.current.innerHTML = "";
|
||||||
|
const chart = f3.createChart(containerRef.current, data);
|
||||||
|
chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]);
|
||||||
|
if (mode === "portrait") chart.setOrientationVertical();
|
||||||
|
else chart.setOrientationHorizontal();
|
||||||
|
// Show enough generations that a recenter reveals grandparents + children.
|
||||||
|
chart.setAncestryDepth?.(3);
|
||||||
|
chart.setProgenyDepth?.(2);
|
||||||
|
// Default card click recenters the whole hourglass; sync focus for the
|
||||||
|
// "Open profile" link after every (re)build.
|
||||||
|
chart.setAfterUpdate?.(() => {
|
||||||
|
const md = chart.getMainDatum?.();
|
||||||
|
const id = md?.id ?? md?.data?.id;
|
||||||
|
if (id) setFocusId(id);
|
||||||
|
});
|
||||||
|
chartRef.current = chart;
|
||||||
|
if (focusId) chart.updateMainId(focusId);
|
||||||
|
chart.updateTree({ initial: true });
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [status, mode, people, rels, events]);
|
||||||
|
|
||||||
|
// Jump the tree (or fan) to a person and rebuild the hourglass around them.
|
||||||
|
const goTo = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setFocusId(id);
|
||||||
|
setQuery("");
|
||||||
|
if (mode !== "fan" && chartRef.current) {
|
||||||
|
chartRef.current.updateMainId?.(id);
|
||||||
|
chartRef.current.updateTree?.();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const matches = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return [];
|
||||||
|
return people
|
||||||
|
.filter((p) => (p.primary_name ?? "").toLowerCase().includes(q))
|
||||||
|
.slice(0, 8);
|
||||||
|
}, [query, people]);
|
||||||
|
|
||||||
|
const ModeButton = ({ m, label }: { m: Mode; label: string }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => setMode(m)}
|
||||||
|
className={`rounded-md px-3 py-1.5 text-sm transition-colors ${
|
||||||
|
mode === m ? "bg-bronze text-paper" : "text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-semibold">Tree</h1>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Find a person…"
|
||||||
|
className="w-56"
|
||||||
|
/>
|
||||||
|
{matches.length > 0 && (
|
||||||
|
<ul className="absolute z-20 mt-1 w-72 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
|
||||||
|
{matches.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<button
|
||||||
|
onClick={() => goTo(p.id)}
|
||||||
|
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-[var(--muted-bg,rgba(0,0,0,0.04))]"
|
||||||
|
>
|
||||||
|
<span>{p.primary_name ?? "Unnamed"}</span>
|
||||||
|
{yearOf(p.id) && (
|
||||||
|
<span className="text-xs text-[var(--muted)]">{yearOf(p.id)}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center rounded-lg border border-[var(--border)] p-0.5">
|
||||||
|
<ModeButton m="landscape" label="Landscape" />
|
||||||
|
<ModeButton m="portrait" label="Portrait" />
|
||||||
|
<ModeButton m="fan" label="Fan" />
|
||||||
|
</div>
|
||||||
|
{focusId && (
|
||||||
|
<Link
|
||||||
|
href={`/trees/${treeId}/persons/${focusId}`}
|
||||||
|
className="text-sm text-bronze hover:underline"
|
||||||
|
>
|
||||||
|
Open {nameOf(focusId)} →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === "empty" && (
|
||||||
|
<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 === "ready" && mode === "fan" && focusId ? (
|
||||||
|
<div className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-4">
|
||||||
|
<FanChart
|
||||||
|
focusId={focusId}
|
||||||
|
parentsOf={parentsOf}
|
||||||
|
nameOf={nameOf}
|
||||||
|
yearOf={yearOf}
|
||||||
|
onSelect={setFocusId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="f3 rounded-xl border border-[var(--border)]"
|
||||||
|
style={{ width: "100%", height: "74vh", background: "var(--surface)" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-sm text-[var(--muted)]">
|
||||||
|
{mode === "fan"
|
||||||
|
? "Click an ancestor to recenter the fan."
|
||||||
|
: "Drag to pan · scroll to zoom · click a person to recenter."}
|
||||||
|
</p>
|
||||||
|
</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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+56
-22
@@ -7,7 +7,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { api } from "@/lib/api/client";
|
import { api } from "@/lib/api/client";
|
||||||
import type { components } from "@/lib/api/schema";
|
import type { components } from "@/lib/api/schema";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
type Tree = components["schemas"]["TreeRead"];
|
type Tree = components["schemas"]["TreeRead"];
|
||||||
@@ -15,6 +15,7 @@ type Tree = components["schemas"]["TreeRead"];
|
|||||||
export default function TreesPage() {
|
export default function TreesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [trees, setTrees] = useState<Tree[]>([]);
|
const [trees, setTrees] = useState<Tree[]>([]);
|
||||||
|
const [deleted, setDeleted] = useState<Tree[]>([]);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ export default function TreesPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTrees(data ?? []);
|
setTrees(data ?? []);
|
||||||
|
const del = await api.GET("/api/v1/trees", { params: { query: { deleted: true } } });
|
||||||
|
setDeleted(del.data ?? []);
|
||||||
setReady(true);
|
setReady(true);
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
@@ -42,24 +45,26 @@ export default function TreesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
await api.DELETE("/api/v1/trees/{tree_id}", { params: { path: { tree_id: id } } });
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
async function restore(id: string) {
|
||||||
|
await api.POST("/api/v1/trees/{tree_id}/restore", { params: { path: { tree_id: id } } });
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
if (!ready) return <p className="text-[var(--muted)]">Loading…</p>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<h1 className="text-2xl font-semibold">Your trees</h1>
|
<h1 className="text-2xl font-semibold">Your trees</h1>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardContent className="p-5">
|
||||||
<CardTitle className="text-base">New tree</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={createTree} className="flex gap-2">
|
<form onSubmit={createTree} className="flex gap-2">
|
||||||
<Input
|
<Input placeholder="Family name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
placeholder="Family name"
|
<Button type="submit">Create tree</Button>
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Button type="submit">Create</Button>
|
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -67,23 +72,52 @@ export default function TreesPage() {
|
|||||||
{trees.length === 0 ? (
|
{trees.length === 0 ? (
|
||||||
<p className="text-[var(--muted)]">No trees yet — create your first one above.</p>
|
<p className="text-[var(--muted)]">No trees yet — create your first one above.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-2">
|
<ul className="grid gap-3 sm:grid-cols-2">
|
||||||
{trees.map((tree) => (
|
{trees.map((tree) => (
|
||||||
<li key={tree.id}>
|
<li key={tree.id}>
|
||||||
<Link href={`/trees/${tree.id}`}>
|
<Card className="transition-colors hover:border-bronze/50">
|
||||||
<Card className="transition-colors hover:border-bronze/50">
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
<Link href={`/trees/${tree.id}/tree`} className="min-w-0 flex-1">
|
||||||
<span className="font-medium">{tree.name}</span>
|
<div className="truncate font-medium">{tree.name}</div>
|
||||||
<span className="text-xs uppercase tracking-wide text-bronze">
|
<div className="text-xs uppercase tracking-wide text-bronze">
|
||||||
{tree.visibility}
|
{tree.visibility}
|
||||||
</span>
|
</div>
|
||||||
</CardContent>
|
</Link>
|
||||||
</Card>
|
<button
|
||||||
</Link>
|
onClick={() => remove(tree.id)}
|
||||||
|
className="ml-3 text-[var(--muted)] hover:text-bronze"
|
||||||
|
aria-label="Delete tree"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{deleted.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="font-serif text-base font-semibold text-[var(--muted)]">
|
||||||
|
Recently deleted
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{deleted.map((tree) => (
|
||||||
|
<li key={tree.id}>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center justify-between p-4">
|
||||||
|
<span className="text-[var(--muted)]">{tree.name}</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => restore(tree.id)}>
|
||||||
|
Restore
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,34 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BookText, FolderTree, Image as ImageIcon, LogOut, Users } from "lucide-react";
|
import {
|
||||||
|
Archive,
|
||||||
|
ArrowDownUp,
|
||||||
|
BookText,
|
||||||
|
FolderTree,
|
||||||
|
Image as ImageIcon,
|
||||||
|
LogOut,
|
||||||
|
Network,
|
||||||
|
Settings,
|
||||||
|
Sparkles,
|
||||||
|
Users,
|
||||||
|
} 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) {
|
||||||
@@ -25,7 +40,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");
|
||||||
}
|
}
|
||||||
@@ -43,6 +71,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
|
||||||
@@ -63,12 +92,19 @@ 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="/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">
|
||||||
<div className="truncate px-3 pb-1 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
|
<div className="truncate px-3 pb-1 text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
|
||||||
{treeName ?? "Tree"}
|
{treeName ?? "Tree"}
|
||||||
</div>
|
</div>
|
||||||
|
<Item
|
||||||
|
href={`/trees/${treeId}/tree`}
|
||||||
|
label="Tree"
|
||||||
|
icon={Network}
|
||||||
|
active={pathname.startsWith(`/trees/${treeId}/tree`)}
|
||||||
|
/>
|
||||||
<Item
|
<Item
|
||||||
href={`/trees/${treeId}`}
|
href={`/trees/${treeId}`}
|
||||||
label="People"
|
label="People"
|
||||||
@@ -87,16 +123,66 @@ export function AppSidebar() {
|
|||||||
icon={ImageIcon}
|
icon={ImageIcon}
|
||||||
active={pathname.startsWith(`/trees/${treeId}/media`)}
|
active={pathname.startsWith(`/trees/${treeId}/media`)}
|
||||||
/>
|
/>
|
||||||
|
<Item
|
||||||
|
href={`/trees/${treeId}/gedcom`}
|
||||||
|
label="Import / Export"
|
||||||
|
icon={ArrowDownUp}
|
||||||
|
active={pathname.startsWith(`/trees/${treeId}/gedcom`)}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
href={`/trees/${treeId}/cleanup`}
|
||||||
|
label="Cleanup"
|
||||||
|
icon={Sparkles}
|
||||||
|
active={pathname.startsWith(`/trees/${treeId}/cleanup`)}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
href={`/trees/${treeId}/recovery`}
|
||||||
|
label="Recovery"
|
||||||
|
icon={Archive}
|
||||||
|
active={pathname.startsWith(`/trees/${treeId}/recovery`)}
|
||||||
|
/>
|
||||||
</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,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Radial fan chart of a focus person's ancestors (family-chart has no fan).
|
||||||
|
// Each generation is a ring; slot p in generation g descends from slot floor(p/2)
|
||||||
|
// in g-1. Click a wedge to refocus.
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
focusId: string;
|
||||||
|
parentsOf: (id: string) => string[];
|
||||||
|
nameOf: (id: string) => string;
|
||||||
|
yearOf: (id: string) => string;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
generations?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIZE = 720;
|
||||||
|
const CENTER = SIZE / 2;
|
||||||
|
const FOCUS_R = 46;
|
||||||
|
const SPAN = Math.PI * 1.6; // 288° fan
|
||||||
|
|
||||||
|
function polar(r: number, a: number): [number, number] {
|
||||||
|
// a = 0 points up, increasing clockwise.
|
||||||
|
return [CENTER + r * Math.sin(a), CENTER - r * Math.cos(a)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sector(r0: number, r1: number, a0: number, a1: number): string {
|
||||||
|
const [x0, y0] = polar(r1, a0);
|
||||||
|
const [x1, y1] = polar(r1, a1);
|
||||||
|
const [x2, y2] = polar(r0, a1);
|
||||||
|
const [x3, y3] = polar(r0, a0);
|
||||||
|
const large = a1 - a0 > Math.PI ? 1 : 0;
|
||||||
|
return `M${x0} ${y0} A${r1} ${r1} 0 ${large} 1 ${x1} ${y1} L${x2} ${y2} A${r0} ${r0} 0 ${large} 0 ${x3} ${y3} Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clip(s: string, n: number): string {
|
||||||
|
return s.length > n ? s.slice(0, n - 1) + "…" : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FanChart({
|
||||||
|
focusId,
|
||||||
|
parentsOf,
|
||||||
|
nameOf,
|
||||||
|
yearOf,
|
||||||
|
onSelect,
|
||||||
|
generations = 4,
|
||||||
|
}: Props) {
|
||||||
|
const gens: (string | null)[][] = [[focusId]];
|
||||||
|
for (let g = 1; g <= generations; g++) {
|
||||||
|
const row: (string | null)[] = [];
|
||||||
|
for (const slot of gens[g - 1]) {
|
||||||
|
const ps = slot ? parentsOf(slot) : [];
|
||||||
|
row.push(ps[0] ?? null, ps[1] ?? null);
|
||||||
|
}
|
||||||
|
gens.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ringT = (CENTER - 60 - FOCUS_R) / generations;
|
||||||
|
const start = -SPAN / 2;
|
||||||
|
const wedges: React.ReactNode[] = [];
|
||||||
|
|
||||||
|
for (let g = 1; g <= generations; g++) {
|
||||||
|
const row = gens[g];
|
||||||
|
const w = SPAN / row.length;
|
||||||
|
const r0 = FOCUS_R + (g - 1) * ringT;
|
||||||
|
const r1 = FOCUS_R + g * ringT;
|
||||||
|
row.forEach((id, i) => {
|
||||||
|
const a0 = start + i * w;
|
||||||
|
const a1 = start + (i + 1) * w;
|
||||||
|
const mid = (a0 + a1) / 2;
|
||||||
|
const [tx, ty] = polar((r0 + r1) / 2, mid);
|
||||||
|
let deg = (mid * 180) / Math.PI;
|
||||||
|
if (deg > 90 || deg < -90) deg += 180; // keep text upright
|
||||||
|
wedges.push(
|
||||||
|
<g
|
||||||
|
key={`${g}-${i}`}
|
||||||
|
onClick={() => id && onSelect(id)}
|
||||||
|
style={{ cursor: id ? "pointer" : "default" }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d={sector(r0 + 1, r1 - 1, a0 + 0.004, a1 - 0.004)}
|
||||||
|
fill={id ? "var(--surface)" : "transparent"}
|
||||||
|
stroke="var(--line)"
|
||||||
|
/>
|
||||||
|
{id && (
|
||||||
|
<text
|
||||||
|
x={tx}
|
||||||
|
y={ty}
|
||||||
|
transform={`rotate(${deg} ${tx} ${ty})`}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
style={{ fontSize: g >= 3 ? 9 : 11, fill: "var(--foreground)" }}
|
||||||
|
>
|
||||||
|
{clip(nameOf(id), g >= 3 ? 12 : 18)}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [fx, fy] = [CENTER, CENTER];
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<svg viewBox={`0 0 ${SIZE} ${SIZE}`} className="mx-auto block w-full max-w-3xl">
|
||||||
|
{wedges}
|
||||||
|
<circle cx={fx} cy={fy} r={FOCUS_R} fill="var(--color-bronze)" />
|
||||||
|
<text
|
||||||
|
x={fx}
|
||||||
|
y={fy - 4}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
style={{ fontSize: 12, fill: "var(--color-paper)", fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
{clip(nameOf(focusId), 12)}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x={fx}
|
||||||
|
y={fy + 12}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
style={{ fontSize: 10, fill: "var(--color-paper)" }}
|
||||||
|
>
|
||||||
|
{yearOf(focusId)}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,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
+1746
-8
File diff suppressed because it is too large
Load Diff
+2861
-13
File diff suppressed because it is too large
Load Diff
Generated
+429
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"family-chart": "^0.9.0",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
"openapi-fetch": "^0.13.0",
|
"openapi-fetch": "^0.13.0",
|
||||||
@@ -1111,12 +1112,390 @@
|
|||||||
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
|
"integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/commander": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"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",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/d3": {
|
||||||
|
"version": "7.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz",
|
||||||
|
"integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "3",
|
||||||
|
"d3-axis": "3",
|
||||||
|
"d3-brush": "3",
|
||||||
|
"d3-chord": "3",
|
||||||
|
"d3-color": "3",
|
||||||
|
"d3-contour": "4",
|
||||||
|
"d3-delaunay": "6",
|
||||||
|
"d3-dispatch": "3",
|
||||||
|
"d3-drag": "3",
|
||||||
|
"d3-dsv": "3",
|
||||||
|
"d3-ease": "3",
|
||||||
|
"d3-fetch": "3",
|
||||||
|
"d3-force": "3",
|
||||||
|
"d3-format": "3",
|
||||||
|
"d3-geo": "3",
|
||||||
|
"d3-hierarchy": "3",
|
||||||
|
"d3-interpolate": "3",
|
||||||
|
"d3-path": "3",
|
||||||
|
"d3-polygon": "3",
|
||||||
|
"d3-quadtree": "3",
|
||||||
|
"d3-random": "3",
|
||||||
|
"d3-scale": "4",
|
||||||
|
"d3-scale-chromatic": "3",
|
||||||
|
"d3-selection": "3",
|
||||||
|
"d3-shape": "3",
|
||||||
|
"d3-time": "3",
|
||||||
|
"d3-time-format": "4",
|
||||||
|
"d3-timer": "3",
|
||||||
|
"d3-transition": "3",
|
||||||
|
"d3-zoom": "3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-axis": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-brush": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-drag": "2 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-selection": "3",
|
||||||
|
"d3-transition": "3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-chord": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-contour": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "^3.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-delaunay": {
|
||||||
|
"version": "6.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
|
||||||
|
"integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
|
||||||
|
"dependencies": {
|
||||||
|
"delaunator": "5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-dispatch": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-drag": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-selection": "3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-dsv": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "7",
|
||||||
|
"iconv-lite": "0.6",
|
||||||
|
"rw": "1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"csv2json": "bin/dsv2json.js",
|
||||||
|
"csv2tsv": "bin/dsv2dsv.js",
|
||||||
|
"dsv2dsv": "bin/dsv2dsv.js",
|
||||||
|
"dsv2json": "bin/dsv2json.js",
|
||||||
|
"json2csv": "bin/json2dsv.js",
|
||||||
|
"json2dsv": "bin/json2dsv.js",
|
||||||
|
"json2tsv": "bin/json2dsv.js",
|
||||||
|
"tsv2csv": "bin/dsv2dsv.js",
|
||||||
|
"tsv2json": "bin/dsv2json.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-fetch": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dsv": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-force": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-quadtree": "1 - 3",
|
||||||
|
"d3-timer": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-geo": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.5.0 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-hierarchy": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-polygon": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-quadtree": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-random": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale-chromatic": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3",
|
||||||
|
"d3-interpolate": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-selection": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-transition": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3",
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-ease": "1 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-timer": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"d3-selection": "2 - 3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-zoom": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-drag": "2 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-selection": "2 - 3",
|
||||||
|
"d3-transition": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@@ -1134,6 +1513,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/delaunator": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"robust-predicates": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -1156,6 +1543,14 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/family-chart": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/family-chart/-/family-chart-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-+JdLr1Oo+YFnQWUXgdnk4nCMTbe1MXKdpbx3KEBXPeq2oX+2v5ccmrcK39CZ761/zQfgSHFZ2cT/+gbaeeACcA==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3": "^7.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -1181,6 +1576,17 @@
|
|||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iconv-lite": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/index-to-position": {
|
"node_modules/index-to-position": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz",
|
||||||
@@ -1193,6 +1599,14 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"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",
|
||||||
@@ -1734,6 +2148,21 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/robust-predicates": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="
|
||||||
|
},
|
||||||
|
"node_modules/rw": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
|
||||||
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
|
|||||||
+11
-10
@@ -10,23 +10,24 @@
|
|||||||
"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": {
|
||||||
"next": "^15.1.0",
|
|
||||||
"react": "^19.0.0",
|
|
||||||
"react-dom": "^19.0.0",
|
|
||||||
"openapi-fetch": "^0.13.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"family-chart": "^0.9.0",
|
||||||
"lucide-react": "^0.469.0"
|
"lucide-react": "^0.469.0",
|
||||||
|
"next": "^15.1.0",
|
||||||
|
"openapi-fetch": "^0.13.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^2.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"openapi-typescript": "^7.5.0",
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"openapi-typescript": "^7.5.0"
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
declare module "family-chart";
|
||||||
|
declare module "family-chart/dist/styles/family-chart.css";
|
||||||
Reference in New Issue
Block a user