From aa62ca490eb2d71275dccb0ebb51ba4192fdf7d1 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Mon, 8 Jun 2026 10:17:01 -0400 Subject: [PATCH] =?UTF-8?q?Tree=20Cleanup=20tool:=20bulk=20fixes=20with=20?= =?UTF-8?q?preview=20=E2=86=92=20approve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new per-tree Cleanup page (and cleanup_service + endpoints), each fix preview-first per the propose-then-approve rule: - Mark deceased by birth year: lists people born ≤ a cutoff (default 1930) not already deceased; apply sets is_living=false for the ones you keep checked. - Set sex from a source GEDCOM: upload the source .ged (it carries SEX); matches by name and proposes sex only where it's missing — far more accurate than guessing from first names. Review, then apply. - Names that look broken: flags date-in-surname / date-in-given / no-surname / packed given names, with inline editable given+surname; fix the checked ones. No migration (uses existing columns). 55 backend tests pass (preview+apply for all three); frontend builds. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/api/v1/__init__.py | 2 + backend/app/api/v1/cleanup.py | 91 ++++ backend/app/schemas/cleanup.py | 50 +++ backend/app/services/cleanup_service.py | 267 +++++++++++ backend/tests/test_cleanup.py | 107 +++++ frontend/app/trees/[id]/cleanup/page.tsx | 307 +++++++++++++ frontend/components/app-sidebar.tsx | 7 + frontend/lib/api/schema.d.ts | 364 +++++++++++++++ frontend/openapi.json | 542 +++++++++++++++++++++++ 9 files changed, 1737 insertions(+) create mode 100644 backend/app/api/v1/cleanup.py create mode 100644 backend/app/schemas/cleanup.py create mode 100644 backend/app/services/cleanup_service.py create mode 100644 backend/tests/test_cleanup.py create mode 100644 frontend/app/trees/[id]/cleanup/page.tsx diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index d11ded5..a6f5bcf 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -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) diff --git a/backend/app/api/v1/cleanup.py b/backend/app/api/v1/cleanup.py new file mode 100644 index 0000000..666649b --- /dev/null +++ b/backend/app/api/v1/cleanup.py @@ -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) diff --git a/backend/app/schemas/cleanup.py b/backend/app/schemas/cleanup.py new file mode 100644 index 0000000..f402b33 --- /dev/null +++ b/backend/app/schemas/cleanup.py @@ -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 diff --git a/backend/app/services/cleanup_service.py b/backend/app/services/cleanup_service.py new file mode 100644 index 0000000..506a4ef --- /dev/null +++ b/backend/app/services/cleanup_service.py @@ -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) diff --git a/backend/tests/test_cleanup.py b/backend/tests/test_cleanup.py new file mode 100644 index 0000000..e452676 --- /dev/null +++ b/backend/tests/test_cleanup.py @@ -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" diff --git a/frontend/app/trees/[id]/cleanup/page.tsx b/frontend/app/trees/[id]/cleanup/page.tsx new file mode 100644 index 0000000..addfdc3 --- /dev/null +++ b/frontend/app/trees/[id]/cleanup/page.tsx @@ -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 = { + 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(null); + const [decSel, setDecSel] = useState>(new Set()); + const [decMsg, setDecMsg] = useState(null); + + // 2) Gender from source GEDCOM + const [gender, setGender] = useState(null); + const [genSel, setGenSel] = useState>(new Set()); + const [genMsg, setGenMsg] = useState(null); + const genFile = useRef(null); + + // 3) Name issues + const [issues, setIssues] = useState(null); + const [edits, setEdits] = useState>({}); + const [nameMsg, setNameMsg] = useState(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) { + 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 = {}; + 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, id: string, setter: (s: Set) => void) => { + const n = new Set(set); + if (n.has(id)) n.delete(id); + else n.add(id); + setter(n); + }; + + return ( +
+
+

Cleanup

+

+ Fix common import messes in bulk. Each tool previews its changes — nothing is saved + until you apply. +

+
+ + {/* 1) Deceased by year */} + + + Mark deceased by birth year + + +
+ + +
+ {decMsg &&

{decMsg}

} + {deceased && ( +
+

+ {deceased.length} people born ≤ {year} (not already marked deceased). +

+
    + {deceased.map((d) => ( +
  • + toggle(decSel, d.person_id, setDecSel)} + /> + {d.name} + b. {d.birth_year} +
  • + ))} +
+ {deceased.length > 0 && ( + + )} +
+ )} +
+
+ + {/* 2) Gender from source */} + + + Set sex from a source GEDCOM + + +

+ Upload your source .ged (it carries each person’s sex). We match by + name and propose sex only for people who don’t have it set. +

+ + + {genMsg &&

{genMsg}

} + {gender && ( +
+

{gender.length} matches with a sex to set.

+
    + {gender.map((g) => ( +
  • + toggle(genSel, g.person_id, setGenSel)} + /> + {g.name} + + {g.proposed_gender} + +
  • + ))} +
+ {gender.length > 0 && ( + + )} +
+ )} +
+
+ + {/* 3) Name issues */} + + + Names that look broken + + + {nameMsg &&

{nameMsg}

} + {issues === null ? ( +

Scanning…

+ ) : issues.length === 0 ? ( +

No obvious name problems found.

+ ) : ( +
+

+ {issues.length} flagged. Edit given/surname, tick the ones to fix, then apply. +

+
    + {issues.map((i) => { + const e = edits[i.name_id] ?? { given: "", surname: "", on: false }; + return ( +
  • + + setEdits((p) => ({ ...p, [i.name_id]: { ...e, on: !e.on } })) + } + /> + + setEdits((p) => ({ ...p, [i.name_id]: { ...e, given: ev.target.value } })) + } + /> + + setEdits((p) => ({ + ...p, + [i.name_id]: { ...e, surname: ev.target.value }, + })) + } + /> + + {ISSUE_LABEL[i.issue] ?? i.issue} + +
  • + ); + })} +
+ +
+ )} +
+
+
+ ); +} diff --git a/frontend/components/app-sidebar.tsx b/frontend/components/app-sidebar.tsx index b799634..aeb662a 100644 --- a/frontend/components/app-sidebar.tsx +++ b/frontend/components/app-sidebar.tsx @@ -9,6 +9,7 @@ import { LogOut, Network, Settings, + Sparkles, Users, } from "lucide-react"; import Link from "next/link"; @@ -128,6 +129,12 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) { icon={ArrowDownUp} active={pathname.startsWith(`/trees/${treeId}/gedcom`)} /> + ; export interface components { @@ -713,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 */ @@ -796,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 */ @@ -905,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 */ @@ -984,6 +1108,11 @@ export interface components { /** Source Id */ source_id?: string | null; }; + /** NameApply */ + NameApply: { + /** Edits */ + edits: components["schemas"]["NameEdit"][]; + }; /** NameCreate */ NameCreate: { /** @@ -1007,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: { /** @@ -3218,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"]; + }; + }; + }; + }; } diff --git a/frontend/openapi.json b/frontend/openapi.json index ba312b2..47c7215 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -2701,6 +2701,322 @@ } } } + }, + "/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": { @@ -2770,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": { @@ -3091,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": { @@ -3526,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": { @@ -3774,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": { @@ -3845,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": { -- 2.52.0