Compare commits
11 Commits
943f459b91
...
aa62ca490e
| Author | SHA1 | Date | |
|---|---|---|---|
| aa62ca490e | |||
| 97f7a9e0ff | |||
| cd4ccb4ac8 | |||
| 6696015970 | |||
| e8839b15a0 | |||
| 548e883d82 | |||
| 37ac49767e | |||
| 9b04bcefba | |||
| e9b2436ce0 | |||
| 8903e480cf | |||
| d27cc5dddc |
@@ -5,6 +5,7 @@ from fastapi import APIRouter
|
||||
from app.api.v1 import (
|
||||
auth,
|
||||
citations,
|
||||
cleanup,
|
||||
events,
|
||||
gedcom,
|
||||
media,
|
||||
@@ -28,3 +29,4 @@ api_router.include_router(sources.router)
|
||||
api_router.include_router(citations.router)
|
||||
api_router.include_router(media.router)
|
||||
api_router.include_router(gedcom.router)
|
||||
api_router.include_router(cleanup.router)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
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.post("/{tree_id}/cleanup/gender", response_model=CleanupResult)
|
||||
async def apply_gender(
|
||||
tree_id: uuid.UUID, data: GenderApply, session: SessionDep, current: CurrentUser
|
||||
) -> CleanupResult:
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
n = await cleanup_service.apply_gender(
|
||||
session,
|
||||
actor=current,
|
||||
tree=tree,
|
||||
updates=[u.model_dump() for u in data.updates],
|
||||
)
|
||||
return CleanupResult(updated=n)
|
||||
|
||||
|
||||
@router.get("/{tree_id}/cleanup/names", response_model=list[NameIssue])
|
||||
async def preview_names(
|
||||
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||
) -> list[NameIssue]:
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
rows = await cleanup_service.preview_names(session, actor=current, tree=tree)
|
||||
return [NameIssue(**r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/{tree_id}/cleanup/names", response_model=CleanupResult)
|
||||
async def apply_names(
|
||||
tree_id: uuid.UUID, data: NameApply, session: SessionDep, current: CurrentUser
|
||||
) -> CleanupResult:
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
n = await cleanup_service.apply_names(
|
||||
session, actor=current, tree=tree, edits=[e.model_dump() for e in data.edits]
|
||||
)
|
||||
return CleanupResult(updated=n)
|
||||
@@ -1,8 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, File, Form, Response, UploadFile
|
||||
|
||||
from app.api.deps import CurrentUser, SessionDep
|
||||
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
|
||||
from app.schemas.user import UserRead, UserSelfPersonUpdate
|
||||
from app.services import user_service
|
||||
from app.services import account_service, user_service
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
@@ -21,3 +21,37 @@ async def 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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,267 @@
|
||||
"""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
|
||||
|
||||
|
||||
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 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)
|
||||
@@ -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,107 @@
|
||||
"""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_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"
|
||||
@@ -11,23 +11,28 @@
|
||||
--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 {
|
||||
--background: #f7f3ec;
|
||||
--foreground: #1a1a17;
|
||||
--muted: #6b6862;
|
||||
--surface: #fffdf9;
|
||||
--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) {
|
||||
:root {
|
||||
.dark {
|
||||
--background: #161410;
|
||||
--foreground: #f2eee6;
|
||||
--muted: #9a968e;
|
||||
--surface: #211d17;
|
||||
--border: #353029;
|
||||
}
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -78,7 +83,7 @@ h3,
|
||||
left: -2.5rem;
|
||||
top: 50%;
|
||||
width: 2.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.ped-leaf {
|
||||
position: relative;
|
||||
@@ -90,7 +95,7 @@ h3,
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.ped-leaf::after {
|
||||
content: "";
|
||||
@@ -98,7 +103,7 @@ h3,
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
.ped-leaf:first-child::after {
|
||||
top: 50%;
|
||||
|
||||
@@ -18,9 +18,16 @@ export const metadata: Metadata = {
|
||||
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 }) {
|
||||
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>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -8,6 +9,7 @@ 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("");
|
||||
@@ -16,10 +18,72 @@ export default function SettingsPage() {
|
||||
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);
|
||||
@@ -109,10 +173,55 @@ export default function SettingsPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Your data</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-[var(--muted)]">
|
||||
Full-account export, restore, and account deletion are coming next.
|
||||
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,307 @@
|
||||
"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 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"
|
||||
/>
|
||||
<Button variant="outline" onClick={() => genFile.current?.click()}>
|
||||
Choose source GEDCOM
|
||||
</Button>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,9 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
type Media = components["schemas"]["MediaRead"];
|
||||
type Person = components["schemas"]["PersonRead"];
|
||||
|
||||
const fieldCls = "h-8 w-full rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-xs";
|
||||
|
||||
function humanSize(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
@@ -22,6 +25,7 @@ export default function MediaPage() {
|
||||
const treeId = params.id;
|
||||
|
||||
const [items, setItems] = useState<Media[]>([]);
|
||||
const [people, setPeople] = useState<Person[]>([]);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
@@ -34,10 +38,22 @@ export default function MediaPage() {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
const ppl = await api.GET("/api/v1/trees/{tree_id}/persons", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
});
|
||||
setItems(data ?? []);
|
||||
setPeople(ppl.data ?? []);
|
||||
setReady(true);
|
||||
}, [router, treeId]);
|
||||
|
||||
async function linkPerson(mediaId: string, personId: string) {
|
||||
await api.PATCH("/api/v1/trees/{tree_id}/media/{media_id}", {
|
||||
params: { path: { tree_id: treeId, media_id: mediaId } },
|
||||
body: { person_id: personId || null },
|
||||
});
|
||||
load();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
@@ -124,6 +140,19 @@ export default function MediaPage() {
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
className={`${fieldCls} mt-2`}
|
||||
value={m.person_id ?? ""}
|
||||
onChange={(e) => linkPerson(m.id, e.target.value)}
|
||||
title="Link to a person"
|
||||
>
|
||||
<option value="">— link to person —</option>
|
||||
{people.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.primary_name ?? "Unnamed"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -126,6 +126,12 @@ export default function FamilyViewPage() {
|
||||
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;
|
||||
@@ -352,6 +358,10 @@ export default function FamilyViewPage() {
|
||||
<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"
|
||||
@@ -359,6 +369,7 @@ export default function FamilyViewPage() {
|
||||
Open {focus.primary_name ?? "person"} →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pedigree: focus → parents → grandparents, with bracket connectors */}
|
||||
<Card>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import type { components } from "@/lib/api/schema";
|
||||
@@ -116,6 +116,9 @@ export default function PersonDetailPage() {
|
||||
const [rels, setRels] = useState<Relationship[]>([]);
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [citations, setCitations] = useState<Citation[]>([]);
|
||||
const [media, setMedia] = useState<components["schemas"]["MediaRead"][]>([]);
|
||||
const [uploadingMedia, setUploadingMedia] = useState(false);
|
||||
const mediaFileRef = useRef<HTMLInputElement>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
const [evType, setEvType] = useState("birth");
|
||||
@@ -174,7 +177,7 @@ export default function PersonDetailPage() {
|
||||
return;
|
||||
}
|
||||
setPerson(p.data ?? null);
|
||||
const [all, nm, mine, tr, ev, rl, src, cit, evAll] = await Promise.all([
|
||||
const [all, nm, mine, tr, ev, rl, src, cit, evAll, med] = await Promise.all([
|
||||
api.GET("/api/v1/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
|
||||
api.GET("/api/v1/trees/{tree_id}/persons/{person_id}/names", {
|
||||
params: { path: { tree_id: treeId, person_id: personId } },
|
||||
@@ -190,6 +193,7 @@ export default function PersonDetailPage() {
|
||||
api.GET("/api/v1/trees/{tree_id}/sources", { params: { path: { tree_id: treeId } } }),
|
||||
api.GET("/api/v1/trees/{tree_id}/citations", { params: { path: { tree_id: treeId } } }),
|
||||
api.GET("/api/v1/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
|
||||
api.GET("/api/v1/trees/{tree_id}/media", { params: { path: { tree_id: treeId } } }),
|
||||
]);
|
||||
setPeople(all.data ?? []);
|
||||
setNames(nm.data ?? []);
|
||||
@@ -197,6 +201,7 @@ export default function PersonDetailPage() {
|
||||
setTree(tr.data ?? null);
|
||||
setEvents(ev.data ?? []);
|
||||
setAllEvents(evAll.data ?? []);
|
||||
setMedia(med.data ?? []);
|
||||
setRels(rl.data ?? []);
|
||||
setSources(src.data ?? []);
|
||||
setCitations(cit.data ?? []);
|
||||
@@ -337,28 +342,47 @@ export default function PersonDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function addRel(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!relOther) return;
|
||||
async function linkRelative(otherId: string): Promise<boolean> {
|
||||
let body: RelCreate;
|
||||
if (relKind === "parent") {
|
||||
body = { type: "parent_child", person_from_id: relOther, person_to_id: personId, qualifier: relQual };
|
||||
body = { type: "parent_child", person_from_id: otherId, person_to_id: personId, qualifier: relQual };
|
||||
} else if (relKind === "child") {
|
||||
body = { type: "parent_child", person_from_id: personId, person_to_id: relOther, qualifier: relQual };
|
||||
body = { type: "parent_child", person_from_id: personId, person_to_id: otherId, qualifier: relQual };
|
||||
} else if (relKind === "partner") {
|
||||
body = { type: "partnership", person_from_id: personId, person_to_id: relOther };
|
||||
body = { type: "partnership", person_from_id: personId, person_to_id: otherId };
|
||||
} else {
|
||||
body = { type: "sibling", person_from_id: personId, person_to_id: relOther };
|
||||
body = { type: "sibling", person_from_id: personId, person_to_id: otherId };
|
||||
}
|
||||
const { error } = await api.POST("/api/v1/trees/{tree_id}/relationships", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
body,
|
||||
});
|
||||
if (!error) {
|
||||
return !error;
|
||||
}
|
||||
|
||||
async function addRel(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!relOther) return;
|
||||
if (await linkRelative(relOther)) {
|
||||
setRelOther("");
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
// Create a brand-new person, link them in the chosen role, then jump to their
|
||||
// page so the user can fill in details immediately.
|
||||
async function createRelativeAndGo(name: string) {
|
||||
const toks = name.trim().split(/\s+/).filter(Boolean);
|
||||
const given = toks.length > 1 ? toks.slice(0, -1).join(" ") : toks[0] ?? name.trim();
|
||||
const surname = toks.length > 1 ? toks[toks.length - 1] : null;
|
||||
const { data } = await api.POST("/api/v1/trees/{tree_id}/persons", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
body: { given, surname },
|
||||
});
|
||||
if (!data) return;
|
||||
await linkRelative(data.id);
|
||||
router.push(`/trees/${treeId}/persons/${data.id}`);
|
||||
}
|
||||
async function removeRel(id: string) {
|
||||
await api.DELETE("/api/v1/trees/{tree_id}/relationships/{relationship_id}", {
|
||||
params: { path: { tree_id: treeId, relationship_id: id } },
|
||||
@@ -461,6 +485,31 @@ export default function PersonDetailPage() {
|
||||
load();
|
||||
}
|
||||
|
||||
async function uploadMediaForPerson(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (mediaFileRef.current) mediaFileRef.current.value = "";
|
||||
if (!file) return;
|
||||
setUploadingMedia(true);
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("person_id", personId); // link on upload
|
||||
await fetch(`/api/v1/trees/${treeId}/media`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
credentials: "include",
|
||||
});
|
||||
setUploadingMedia(false);
|
||||
load();
|
||||
}
|
||||
|
||||
async function linkMedia(mediaId: string, link: boolean) {
|
||||
await api.PATCH("/api/v1/trees/{tree_id}/media/{media_id}", {
|
||||
params: { path: { tree_id: treeId, media_id: mediaId } },
|
||||
body: { person_id: link ? personId : null },
|
||||
});
|
||||
load();
|
||||
}
|
||||
|
||||
function startEditPerson(current: Person) {
|
||||
const t = (current.primary_name ?? "").trim().split(/\s+/).filter(Boolean);
|
||||
setPGiven(t.length > 1 ? t.slice(0, -1).join(" ") : (t[0] ?? ""));
|
||||
@@ -637,7 +686,19 @@ export default function PersonDetailPage() {
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="flex flex-wrap items-center gap-3 text-3xl font-semibold">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{person.primary_name ?? "Unnamed person"}
|
||||
{person.gender === "male" && (
|
||||
<span title="Male" aria-label="Male" style={{ color: "rgb(120, 159, 172)" }}>
|
||||
♂
|
||||
</span>
|
||||
)}
|
||||
{person.gender === "female" && (
|
||||
<span title="Female" aria-label="Female" style={{ color: "rgb(196, 138, 146)" }}>
|
||||
♀
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{isSelf && (
|
||||
<span className="rounded-full bg-bronze/15 px-2.5 py-1 text-xs font-medium text-bronze">
|
||||
This is you
|
||||
@@ -1066,7 +1127,8 @@ export default function PersonDetailPage() {
|
||||
people={others}
|
||||
value={relOther}
|
||||
onChange={setRelOther}
|
||||
placeholder="Search for a person…"
|
||||
onCreate={createRelativeAndGo}
|
||||
placeholder="Search, or type a new name…"
|
||||
/>
|
||||
{(relKind === "parent" || relKind === "child") && (
|
||||
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
|
||||
@@ -1082,6 +1144,87 @@ export default function PersonDetailPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Media</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{(() => {
|
||||
const personMedia = media.filter((m) => m.person_id === personId);
|
||||
const unlinked = media.filter((m) => !m.person_id);
|
||||
return (
|
||||
<>
|
||||
{personMedia.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted)]">No media linked to this person yet.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
{personMedia.map((m) => (
|
||||
<div key={m.id} className="overflow-hidden rounded-lg border border-[var(--border)]">
|
||||
<a href={m.url ?? "#"} target="_blank" rel="noreferrer" className="block">
|
||||
{m.content_type.startsWith("image/") ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={m.url ?? ""}
|
||||
alt={m.title ?? m.original_filename}
|
||||
className="aspect-square w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="grid aspect-square w-full place-items-center bg-bronze/[0.06] font-serif text-2xl text-bronze">
|
||||
{(m.original_filename.split(".").pop() ?? "file").toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
<div className="flex items-center justify-between gap-2 p-2">
|
||||
<span className="truncate text-xs" title={m.original_filename}>
|
||||
{m.title ?? m.original_filename}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => linkMedia(m.id, false)}
|
||||
className="shrink-0 text-xs text-[var(--muted)] hover:text-bronze"
|
||||
>
|
||||
unlink
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
ref={mediaFileRef}
|
||||
type="file"
|
||||
onChange={uploadMediaForPerson}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => mediaFileRef.current?.click()}
|
||||
disabled={uploadingMedia}
|
||||
>
|
||||
{uploadingMedia ? "Uploading…" : "Upload & link"}
|
||||
</Button>
|
||||
{unlinked.length > 0 && (
|
||||
<select
|
||||
className={fieldCls}
|
||||
defaultValue=""
|
||||
onChange={(e) => e.target.value && linkMedia(e.target.value, true)}
|
||||
>
|
||||
<option value="">Link existing media…</option>
|
||||
{unlinked.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.title ?? m.original_filename}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -530,6 +530,11 @@
|
||||
|
||||
.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 {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
LogOut,
|
||||
Network,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -17,6 +18,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
|
||||
export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const pathname = usePathname();
|
||||
@@ -127,6 +129,12 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
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"
|
||||
@@ -136,7 +144,11 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={menuRef} className="relative mt-auto">
|
||||
<div className="mt-auto">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div ref={menuRef} className="relative">
|
||||
{menuOpen && (
|
||||
<div className="absolute bottom-full left-0 mb-2 w-full overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--surface)] shadow-lg">
|
||||
<Link
|
||||
|
||||
@@ -79,7 +79,7 @@ export function FanChart({
|
||||
<path
|
||||
d={sector(r0 + 1, r1 - 1, a0 + 0.004, a1 - 0.004)}
|
||||
fill={id ? "var(--surface)" : "transparent"}
|
||||
stroke="var(--border)"
|
||||
stroke="var(--line)"
|
||||
/>
|
||||
{id && (
|
||||
<text
|
||||
|
||||
@@ -16,12 +16,15 @@ 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;
|
||||
}) {
|
||||
@@ -77,7 +80,7 @@ export function PersonCombobox({
|
||||
if (value) onChange(""); // typing invalidates the prior pick
|
||||
}}
|
||||
/>
|
||||
{open && matches.length > 0 && (
|
||||
{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}>
|
||||
@@ -96,6 +99,21 @@ export function PersonCombobox({
|
||||
</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
+506
-1
@@ -168,7 +168,12 @@ export interface paths {
|
||||
get: operations["read_me_api_v1_users_me_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
/**
|
||||
* Delete Account
|
||||
* @description Delete the account: the user, their owned trees, and their sessions.
|
||||
* Requires retyping the account email as a guard.
|
||||
*/
|
||||
delete: operations["delete_account_api_v1_users_me_delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
@@ -194,6 +199,46 @@ export interface paths {
|
||||
patch: operations["set_self_person_api_v1_users_me_self_person_patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/users/me/export": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Export Account
|
||||
* @description Download a full backup (JSON + media) of every tree the user owns.
|
||||
*/
|
||||
get: operations["export_account_api_v1_users_me_export_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/users/me/import": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Import Account
|
||||
* @description Restore a previously-exported backup into new trees (non-destructive).
|
||||
*/
|
||||
post: operations["import_account_api_v1_users_me_import_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/trees": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -634,10 +679,90 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/trees/{tree_id}/cleanup/deceased": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Preview Deceased */
|
||||
get: operations["preview_deceased_api_v1_trees__tree_id__cleanup_deceased_get"];
|
||||
put?: never;
|
||||
/** Apply Deceased */
|
||||
post: operations["apply_deceased_api_v1_trees__tree_id__cleanup_deceased_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/trees/{tree_id}/cleanup/gender/preview": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Preview Gender */
|
||||
post: operations["preview_gender_api_v1_trees__tree_id__cleanup_gender_preview_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/trees/{tree_id}/cleanup/gender": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Apply Gender */
|
||||
post: operations["apply_gender_api_v1_trees__tree_id__cleanup_gender_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/trees/{tree_id}/cleanup/names": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Preview Names */
|
||||
get: operations["preview_names_api_v1_trees__tree_id__cleanup_names_get"];
|
||||
put?: never;
|
||||
/** Apply Names */
|
||||
post: operations["apply_names_api_v1_trees__tree_id__cleanup_names_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
/** Body_delete_account_api_v1_users_me_delete */
|
||||
Body_delete_account_api_v1_users_me_delete: {
|
||||
/** Confirm Email */
|
||||
confirm_email: string;
|
||||
};
|
||||
/** Body_import_account_api_v1_users_me_import_post */
|
||||
Body_import_account_api_v1_users_me_import_post: {
|
||||
/** File */
|
||||
file: string;
|
||||
};
|
||||
/** Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post */
|
||||
Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post: {
|
||||
/** File */
|
||||
@@ -658,6 +783,11 @@ export interface components {
|
||||
/** File */
|
||||
file: string;
|
||||
};
|
||||
/** Body_preview_gender_api_v1_trees__tree_id__cleanup_gender_preview_post */
|
||||
Body_preview_gender_api_v1_trees__tree_id__cleanup_gender_preview_post: {
|
||||
/** File */
|
||||
file: string;
|
||||
};
|
||||
/** Body_upload_media_api_v1_trees__tree_id__media_post */
|
||||
Body_upload_media_api_v1_trees__tree_id__media_post: {
|
||||
/** File */
|
||||
@@ -741,6 +871,28 @@ export interface components {
|
||||
detail?: string | null;
|
||||
confidence?: components["schemas"]["CitationConfidence"] | null;
|
||||
};
|
||||
/** CleanupResult */
|
||||
CleanupResult: {
|
||||
/** Updated */
|
||||
updated: number;
|
||||
};
|
||||
/** DeceasedApply */
|
||||
DeceasedApply: {
|
||||
/** Person Ids */
|
||||
person_ids: string[];
|
||||
};
|
||||
/** DeceasedCandidate */
|
||||
DeceasedCandidate: {
|
||||
/**
|
||||
* Person Id
|
||||
* Format: uuid
|
||||
*/
|
||||
person_id: string;
|
||||
/** Name */
|
||||
name: string;
|
||||
/** Birth Year */
|
||||
birth_year: number;
|
||||
};
|
||||
/** DuplicateMatch */
|
||||
DuplicateMatch: {
|
||||
/** Xref */
|
||||
@@ -850,6 +1002,33 @@ export interface components {
|
||||
/** Notes */
|
||||
notes?: string | null;
|
||||
};
|
||||
/** GenderApply */
|
||||
GenderApply: {
|
||||
/** Updates */
|
||||
updates: components["schemas"]["GenderUpdate"][];
|
||||
};
|
||||
/** GenderProposal */
|
||||
GenderProposal: {
|
||||
/**
|
||||
* Person Id
|
||||
* Format: uuid
|
||||
*/
|
||||
person_id: string;
|
||||
/** Name */
|
||||
name: string;
|
||||
/** Proposed Gender */
|
||||
proposed_gender: string;
|
||||
};
|
||||
/** GenderUpdate */
|
||||
GenderUpdate: {
|
||||
/**
|
||||
* Person Id
|
||||
* Format: uuid
|
||||
*/
|
||||
person_id: string;
|
||||
/** Gender */
|
||||
gender: string;
|
||||
};
|
||||
/** HTTPValidationError */
|
||||
HTTPValidationError: {
|
||||
/** Detail */
|
||||
@@ -929,6 +1108,11 @@ export interface components {
|
||||
/** Source Id */
|
||||
source_id?: string | null;
|
||||
};
|
||||
/** NameApply */
|
||||
NameApply: {
|
||||
/** Edits */
|
||||
edits: components["schemas"]["NameEdit"][];
|
||||
};
|
||||
/** NameCreate */
|
||||
NameCreate: {
|
||||
/**
|
||||
@@ -952,6 +1136,37 @@ export interface components {
|
||||
*/
|
||||
is_primary?: boolean;
|
||||
};
|
||||
/** NameEdit */
|
||||
NameEdit: {
|
||||
/**
|
||||
* Name Id
|
||||
* Format: uuid
|
||||
*/
|
||||
name_id: string;
|
||||
/** Given */
|
||||
given?: string | null;
|
||||
/** Surname */
|
||||
surname?: string | null;
|
||||
};
|
||||
/** NameIssue */
|
||||
NameIssue: {
|
||||
/**
|
||||
* Name Id
|
||||
* Format: uuid
|
||||
*/
|
||||
name_id: string;
|
||||
/**
|
||||
* Person Id
|
||||
* Format: uuid
|
||||
*/
|
||||
person_id: string;
|
||||
/** Given */
|
||||
given?: string | null;
|
||||
/** Surname */
|
||||
surname?: string | null;
|
||||
/** Issue */
|
||||
issue: string;
|
||||
};
|
||||
/** NameRead */
|
||||
NameRead: {
|
||||
/**
|
||||
@@ -1624,6 +1839,37 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
delete_account_api_v1_users_me_delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/x-www-form-urlencoded": components["schemas"]["Body_delete_account_api_v1_users_me_delete"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
set_self_person_api_v1_users_me_self_person_patch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1657,6 +1903,61 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
export_account_api_v1_users_me_export_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
import_account_api_v1_users_me_import_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": components["schemas"]["Body_import_account_api_v1_users_me_import_post"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_my_trees_api_v1_trees_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -3077,4 +3378,208 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
preview_deceased_api_v1_trees__tree_id__cleanup_deceased_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
born_on_or_before?: number;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
tree_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DeceasedCandidate"][];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
apply_deceased_api_v1_trees__tree_id__cleanup_deceased_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
tree_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["DeceasedApply"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["CleanupResult"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
preview_gender_api_v1_trees__tree_id__cleanup_gender_preview_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
tree_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": components["schemas"]["Body_preview_gender_api_v1_trees__tree_id__cleanup_gender_preview_post"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["GenderProposal"][];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
apply_gender_api_v1_trees__tree_id__cleanup_gender_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
tree_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["GenderApply"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["CleanupResult"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
preview_names_api_v1_trees__tree_id__cleanup_names_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
tree_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["NameIssue"][];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
apply_names_api_v1_trees__tree_id__cleanup_names_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
tree_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["NameApply"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["CleanupResult"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -312,6 +312,39 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"users"
|
||||
],
|
||||
"summary": "Delete Account",
|
||||
"description": "Delete the account: the user, their owned trees, and their sessions.\nRequires retyping the account email as a guard.",
|
||||
"operationId": "delete_account_api_v1_users_me_delete",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/x-www-form-urlencoded": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Body_delete_account_api_v1_users_me_delete"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/users/me/self-person": {
|
||||
@@ -356,6 +389,70 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/users/me/export": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"users"
|
||||
],
|
||||
"summary": "Export Account",
|
||||
"description": "Download a full backup (JSON + media) of every tree the user owns.",
|
||||
"operationId": "export_account_api_v1_users_me_export_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/users/me/import": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"users"
|
||||
],
|
||||
"summary": "Import Account",
|
||||
"description": "Restore a previously-exported backup into new trees (non-destructive).",
|
||||
"operationId": "import_account_api_v1_users_me_import_post",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Body_import_account_api_v1_users_me_import_post"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"additionalProperties": true,
|
||||
"type": "object",
|
||||
"title": "Response Import Account Api V1 Users Me Import Post"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/trees": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -2604,10 +2701,353 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/trees/{tree_id}/cleanup/deceased": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"cleanup"
|
||||
],
|
||||
"summary": "Preview Deceased",
|
||||
"operationId": "preview_deceased_api_v1_trees__tree_id__cleanup_deceased_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tree_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Tree Id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "born_on_or_before",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 1930,
|
||||
"title": "Born On Or Before"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DeceasedCandidate"
|
||||
},
|
||||
"title": "Response Preview Deceased Api V1 Trees Tree Id Cleanup Deceased Get"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"cleanup"
|
||||
],
|
||||
"summary": "Apply Deceased",
|
||||
"operationId": "apply_deceased_api_v1_trees__tree_id__cleanup_deceased_post",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tree_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Tree Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DeceasedApply"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CleanupResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/trees/{tree_id}/cleanup/gender/preview": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"cleanup"
|
||||
],
|
||||
"summary": "Preview Gender",
|
||||
"operationId": "preview_gender_api_v1_trees__tree_id__cleanup_gender_preview_post",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tree_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Tree Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Body_preview_gender_api_v1_trees__tree_id__cleanup_gender_preview_post"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/GenderProposal"
|
||||
},
|
||||
"title": "Response Preview Gender Api V1 Trees Tree Id Cleanup Gender Preview Post"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/trees/{tree_id}/cleanup/gender": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"cleanup"
|
||||
],
|
||||
"summary": "Apply Gender",
|
||||
"operationId": "apply_gender_api_v1_trees__tree_id__cleanup_gender_post",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tree_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Tree Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GenderApply"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CleanupResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/trees/{tree_id}/cleanup/names": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"cleanup"
|
||||
],
|
||||
"summary": "Preview Names",
|
||||
"operationId": "preview_names_api_v1_trees__tree_id__cleanup_names_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tree_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Tree Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NameIssue"
|
||||
},
|
||||
"title": "Response Preview Names Api V1 Trees Tree Id Cleanup Names Get"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": [
|
||||
"cleanup"
|
||||
],
|
||||
"summary": "Apply Names",
|
||||
"operationId": "apply_names_api_v1_trees__tree_id__cleanup_names_post",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tree_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Tree Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/NameApply"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CleanupResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Body_delete_account_api_v1_users_me_delete": {
|
||||
"properties": {
|
||||
"confirm_email": {
|
||||
"type": "string",
|
||||
"title": "Confirm Email"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"confirm_email"
|
||||
],
|
||||
"title": "Body_delete_account_api_v1_users_me_delete"
|
||||
},
|
||||
"Body_import_account_api_v1_users_me_import_post": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string",
|
||||
"contentMediaType": "application/octet-stream",
|
||||
"title": "File"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"title": "Body_import_account_api_v1_users_me_import_post"
|
||||
},
|
||||
"Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post": {
|
||||
"properties": {
|
||||
"file": {
|
||||
@@ -2646,6 +3086,20 @@
|
||||
],
|
||||
"title": "Body_preview_gedcom_api_v1_trees__tree_id__gedcom_preview_post"
|
||||
},
|
||||
"Body_preview_gender_api_v1_trees__tree_id__cleanup_gender_preview_post": {
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string",
|
||||
"contentMediaType": "application/octet-stream",
|
||||
"title": "File"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"file"
|
||||
],
|
||||
"title": "Body_preview_gender_api_v1_trees__tree_id__cleanup_gender_preview_post"
|
||||
},
|
||||
"Body_upload_media_api_v1_trees__tree_id__media_post": {
|
||||
"properties": {
|
||||
"file": {
|
||||
@@ -2967,6 +3421,60 @@
|
||||
"type": "object",
|
||||
"title": "CitationUpdate"
|
||||
},
|
||||
"CleanupResult": {
|
||||
"properties": {
|
||||
"updated": {
|
||||
"type": "integer",
|
||||
"title": "Updated"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"updated"
|
||||
],
|
||||
"title": "CleanupResult"
|
||||
},
|
||||
"DeceasedApply": {
|
||||
"properties": {
|
||||
"person_ids": {
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Person Ids"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"person_ids"
|
||||
],
|
||||
"title": "DeceasedApply"
|
||||
},
|
||||
"DeceasedCandidate": {
|
||||
"properties": {
|
||||
"person_id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Person Id"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Name"
|
||||
},
|
||||
"birth_year": {
|
||||
"type": "integer",
|
||||
"title": "Birth Year"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"person_id",
|
||||
"name",
|
||||
"birth_year"
|
||||
],
|
||||
"title": "DeceasedCandidate"
|
||||
},
|
||||
"DuplicateMatch": {
|
||||
"properties": {
|
||||
"xref": {
|
||||
@@ -3402,6 +3910,65 @@
|
||||
"type": "object",
|
||||
"title": "EventUpdate"
|
||||
},
|
||||
"GenderApply": {
|
||||
"properties": {
|
||||
"updates": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/GenderUpdate"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Updates"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"updates"
|
||||
],
|
||||
"title": "GenderApply"
|
||||
},
|
||||
"GenderProposal": {
|
||||
"properties": {
|
||||
"person_id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Person Id"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Name"
|
||||
},
|
||||
"proposed_gender": {
|
||||
"type": "string",
|
||||
"title": "Proposed Gender"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"person_id",
|
||||
"name",
|
||||
"proposed_gender"
|
||||
],
|
||||
"title": "GenderProposal"
|
||||
},
|
||||
"GenderUpdate": {
|
||||
"properties": {
|
||||
"person_id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Person Id"
|
||||
},
|
||||
"gender": {
|
||||
"type": "string",
|
||||
"title": "Gender"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"person_id",
|
||||
"gender"
|
||||
],
|
||||
"title": "GenderUpdate"
|
||||
},
|
||||
"HTTPValidationError": {
|
||||
"properties": {
|
||||
"detail": {
|
||||
@@ -3650,6 +4217,22 @@
|
||||
"type": "object",
|
||||
"title": "MediaUpdate"
|
||||
},
|
||||
"NameApply": {
|
||||
"properties": {
|
||||
"edits": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NameEdit"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Edits"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"edits"
|
||||
],
|
||||
"title": "NameApply"
|
||||
},
|
||||
"NameCreate": {
|
||||
"properties": {
|
||||
"name_type": {
|
||||
@@ -3721,6 +4304,89 @@
|
||||
"type": "object",
|
||||
"title": "NameCreate"
|
||||
},
|
||||
"NameEdit": {
|
||||
"properties": {
|
||||
"name_id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Name Id"
|
||||
},
|
||||
"given": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Given"
|
||||
},
|
||||
"surname": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Surname"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name_id"
|
||||
],
|
||||
"title": "NameEdit"
|
||||
},
|
||||
"NameIssue": {
|
||||
"properties": {
|
||||
"name_id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Name Id"
|
||||
},
|
||||
"person_id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Person Id"
|
||||
},
|
||||
"given": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Given"
|
||||
},
|
||||
"surname": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Surname"
|
||||
},
|
||||
"issue": {
|
||||
"type": "string",
|
||||
"title": "Issue"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name_id",
|
||||
"person_id",
|
||||
"issue"
|
||||
],
|
||||
"title": "NameIssue"
|
||||
},
|
||||
"NameRead": {
|
||||
"properties": {
|
||||
"id": {
|
||||
|
||||
Reference in New Issue
Block a user