11 Commits

Author SHA1 Message Date
justin fae1162ff8 Prevent duplicate relationships; harden tree render against bad graphs
Root cause of the blank Jung tree: a child double-linked to the same parent
(and, generally, any cycle) made family-chart recurse forever.

Backend (the real fix):
- create_relationship now rejects an equivalent existing edge → 409.
  parent_child is directional (parent→child); partnership/sibling match the
  pair in either order. So you can't link the same two people the same way
  twice. (GEDCOM import already deduped; manual creates didn't.)

Frontend (defense in depth so data can never blank the view):
- Tree view sanitizes the graph before rendering: dedupes parents/spouses,
  drops self-links, and greedily breaks ancestor cycles (a person can't be
  their own ancestor); children are derived from the kept edges. The render is
  wrapped in try/catch and shows a note instead of a blank canvas, telling you
  which conflicting links were skipped.
- Person page surfaces the 409 ("They're already linked that way.").

59 backend tests pass (incl. dup-rejection + reverse-parent-child allowed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:35:11 -04:00
justin a53858f920 Cleanup: list people with no sex set + inline set
Adds a "People with no sex set" section to the Cleanup page — lists everyone
whose gender is still null with inline ♂ Male / ♀ Female buttons (and a link to
their page). Refreshes after the source-match and first-name guess passes, so
it's the manual mop-up for whatever those leave behind.

Frontend only (reuses person list + PATCH) — no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:43:08 -04:00
justin 6ec852a23a Cleanup: best-guess sex from first name (offline dictionary)
A "Guess from first name" option in the Cleanup gender section: a bundled,
curated given-name -> sex dictionary (weighted English + German for the first
real tree) proposes sex for people who don't have it set. Deterministic, offline,
no model. Genuinely ambiguous names (Marion, Frances, Jordan, …) are excluded
from both sets so they're left for a human. Reuses the existing preview/apply
gender flow, so every guess is reviewed before saving.

No migration. 56 backend tests pass; frontend builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:30:35 -04:00
justin 7405ec762f Merge pull request 'Tree Cleanup tool: bulk deceased / gender-from-source / name fixes (preview-first)' (#31) from tree-cleanup into main
build-backend / build (push) Successful in 27s
build-frontend / build (push) Successful in 1m28s
2026-06-08 10:17:02 -04:00
justin aa62ca490e Tree Cleanup tool: bulk fixes with preview → approve
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) <noreply@anthropic.com>
2026-06-08 10:17:01 -04:00
justin 97f7a9e0ff Merge pull request 'Show a sex symbol after the name on the person page' (#30) from person-sex-symbol into main
build-frontend / build (push) Successful in 1m27s
2026-06-08 09:16:40 -04:00
justin cd4ccb4ac8 Show a sex symbol after the name on the person page
A blue ♂ (male) or pink ♀ (female) symbol now follows the person's name in the
detail header, using the same gender tints as the tree cards. Nothing shows when
sex is unknown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 09:16:38 -04:00
justin 6696015970 Merge pull request 'Full light/dark theme toggle; brand-aware connector lines' (#29) from light-dark-theme into main
build-frontend / build (push) Successful in 1m26s
2026-06-07 11:49:01 -04:00
justin e8839b15a0 Full light/dark theme toggle; brand-aware connector lines
- Theme is now class-based (.dark on <html>) with a System/Light/Dark toggle in
  the sidebar, persisted to localStorage and applied pre-paint by an inline
  script (no flash). Replaces the prefers-color-scheme-only behavior, so a phone
  on a light OS theme can still choose dark and vice versa.
- New brand-derived --line token (Ink at 55%): a dark line on the light paper,
  light on dark. The family-chart tree connectors had the library's default
  white stroke and were invisible in light mode — now they use --line, as do
  the pedigree brackets and the fan-chart sectors.
- Light/dark tokens use the exact brand palette (Ink/Muted flip; Bronze/Paper
  constant).

Frontend only — no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:48:59 -04:00
justin 548e883d82 Merge pull request 'Discoverable Add Person + inline create-new when linking relatives' (#28) from create-person-ux into main
build-frontend / build (push) Successful in 1m27s
2026-06-07 11:30:16 -04:00
justin 37ac49767e Make creating a person obvious; inline "create new" when linking relatives
- Family view gets a prominent "+ Add person" button that creates a person and
  opens their page to fill in details (previously you could only add a person
  via the empty-state form or by linking from another person).
- The person page's relationship picker (PersonCombobox) now offers
  "+ Create '<typed name>'" when the person doesn't exist yet: it creates them,
  links them in the chosen role (parent/child/partner/sibling), and jumps to
  their new page to edit — no more create-then-go-back-and-link.

Frontend only — no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:30:14 -04:00
21 changed files with 2399 additions and 58 deletions
+2
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter
from app.api.v1 import ( from app.api.v1 import (
auth, auth,
citations, citations,
cleanup,
events, events,
gedcom, gedcom,
media, media,
@@ -28,3 +29,4 @@ api_router.include_router(sources.router)
api_router.include_router(citations.router) api_router.include_router(citations.router)
api_router.include_router(media.router) api_router.include_router(media.router)
api_router.include_router(gedcom.router) api_router.include_router(gedcom.router)
api_router.include_router(cleanup.router)
+101
View File
@@ -0,0 +1,101 @@
import uuid
from fastapi import APIRouter, File, UploadFile
from app.api.deps import CurrentUser, SessionDep
from app.schemas.cleanup import (
CleanupResult,
DeceasedApply,
DeceasedCandidate,
GenderApply,
GenderProposal,
NameApply,
NameIssue,
)
from app.services import cleanup_service, tree_service
router = APIRouter(prefix="/trees", tags=["cleanup"])
@router.get("/{tree_id}/cleanup/deceased", response_model=list[DeceasedCandidate])
async def preview_deceased(
tree_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
born_on_or_before: int = 1930,
) -> list[DeceasedCandidate]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rows = await cleanup_service.preview_deceased(
session, actor=current, tree=tree, year=born_on_or_before
)
return [DeceasedCandidate(**r) for r in rows]
@router.post("/{tree_id}/cleanup/deceased", response_model=CleanupResult)
async def apply_deceased(
tree_id: uuid.UUID, data: DeceasedApply, session: SessionDep, current: CurrentUser
) -> CleanupResult:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
n = await cleanup_service.apply_deceased(
session, actor=current, tree=tree, person_ids=data.person_ids
)
return CleanupResult(updated=n)
@router.post("/{tree_id}/cleanup/gender/preview", response_model=list[GenderProposal])
async def preview_gender(
tree_id: uuid.UUID,
session: SessionDep,
current: CurrentUser,
file: UploadFile = File(...),
) -> list[GenderProposal]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
text = (await file.read()).decode("utf-8", errors="replace")
rows = await cleanup_service.preview_gender(
session, actor=current, tree=tree, gedcom_text=text
)
return [GenderProposal(**r) for r in rows]
@router.get("/{tree_id}/cleanup/gender/guess", response_model=list[GenderProposal])
async def guess_gender(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[GenderProposal]:
"""Best-guess sex from first names (bundled dictionary) for people missing it."""
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rows = await cleanup_service.guess_gender_by_name(session, actor=current, tree=tree)
return [GenderProposal(**r) for r in rows]
@router.post("/{tree_id}/cleanup/gender", response_model=CleanupResult)
async def apply_gender(
tree_id: uuid.UUID, data: GenderApply, session: SessionDep, current: CurrentUser
) -> CleanupResult:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
n = await cleanup_service.apply_gender(
session,
actor=current,
tree=tree,
updates=[u.model_dump() for u in data.updates],
)
return CleanupResult(updated=n)
@router.get("/{tree_id}/cleanup/names", response_model=list[NameIssue])
async def preview_names(
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
) -> list[NameIssue]:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
rows = await cleanup_service.preview_names(session, actor=current, tree=tree)
return [NameIssue(**r) for r in rows]
@router.post("/{tree_id}/cleanup/names", response_model=CleanupResult)
async def apply_names(
tree_id: uuid.UUID, data: NameApply, session: SessionDep, current: CurrentUser
) -> CleanupResult:
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
n = await cleanup_service.apply_names(
session, actor=current, tree=tree, edits=[e.model_dump() for e in data.edits]
)
return CleanupResult(updated=n)
+50
View File
@@ -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
+289
View File
@@ -0,0 +1,289 @@
"""Bulk tree cleanup — preview/apply pairs for common import messes.
Per the project's #1 rule (the assistant proposes, humans approve), each fix has
a *preview* that returns the proposed changes and an *apply* that commits only
the ids/edits the user confirmed. Nothing here mutates without an explicit apply
call carrying the user's selections.
"""
import re
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.event import Event
from app.models.person import Name, Person
from app.models.tree import Tree
from app.models.user import User
from app.services import gedcom, privacy
from app.services.audit import record_audit
from app.services.exceptions import Forbidden, NotFound
from app.services.name_gender_data import guess_sex
async def _require_editor(session: AsyncSession, *, actor: User, tree: Tree) -> None:
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
raise Forbidden("not an editor of this tree")
async def _persons(session: AsyncSession, tree_id: uuid.UUID) -> list[Person]:
return list(
(
await session.execute(
select(Person).where(Person.tree_id == tree_id, Person.deleted_at.is_(None))
)
).scalars().all()
)
async def _primary_name_by_person(
session: AsyncSession, tree_id: uuid.UUID
) -> dict[uuid.UUID, Name]:
names = (
await session.execute(
select(Name)
.where(Name.tree_id == tree_id, Name.deleted_at.is_(None))
.order_by(Name.is_primary.desc(), Name.sort_order)
)
).scalars().all()
out: dict[uuid.UUID, Name] = {}
for n in names:
out.setdefault(n.person_id, n)
return out
async def _birth_year_by_person(session: AsyncSession, tree_id: uuid.UUID) -> dict[uuid.UUID, int]:
evs = (
await session.execute(
select(Event).where(
Event.tree_id == tree_id,
Event.deleted_at.is_(None),
Event.event_type == "birth",
)
)
).scalars().all()
out: dict[uuid.UUID, int] = {}
for e in evs:
if not e.person_id or e.person_id in out:
continue
y = e.date_start.year if e.date_start else None
if y is None:
ys = gedcom._year(e.date_value)
y = int(ys) if ys else None
if y is not None:
out[e.person_id] = y
return out
def _display(n: Name | None) -> str:
if n is None:
return "Unnamed"
return " ".join(x for x in (n.given, n.surname) if x) or (n.display_name or "Unnamed")
# ---- 1. Mark deceased by birth year -------------------------------------------------
async def preview_deceased(
session: AsyncSession, *, actor: User, tree: Tree, year: int
) -> list[dict]:
await _require_editor(session, actor=actor, tree=tree)
names = await _primary_name_by_person(session, tree.id)
years = await _birth_year_by_person(session, tree.id)
out: list[dict] = []
for p in await _persons(session, tree.id):
if p.is_living is False: # already deceased
continue
by = years.get(p.id)
if by is not None and by <= year:
out.append(
{"person_id": str(p.id), "name": _display(names.get(p.id)), "birth_year": by}
)
out.sort(key=lambda r: r["birth_year"])
return out
async def apply_deceased(
session: AsyncSession, *, actor: User, tree: Tree, person_ids: list[uuid.UUID]
) -> int:
await _require_editor(session, actor=actor, tree=tree)
persons = (
await session.execute(
select(Person).where(
Person.tree_id == tree.id,
Person.deleted_at.is_(None),
Person.id.in_(person_ids),
)
)
).scalars().all()
for p in persons:
p.is_living = False
record_audit(
session,
action="cleanup_deceased",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"count": len(persons)},
)
await session.commit()
return len(persons)
# ---- 2. Re-derive gender from a source GEDCOM (matches by name) ----------------------
async def preview_gender(
session: AsyncSession, *, actor: User, tree: Tree, gedcom_text: str
) -> list[dict]:
await _require_editor(session, actor=actor, tree=tree)
name2sex: dict[str, str] = {}
for rec in gedcom.parse_records(gedcom_text):
if rec.tag != "INDI":
continue
summ = gedcom._person_summary(rec)
sex = gedcom._sex(rec.text("SEX"))
if sex and summ["norm"]:
name2sex.setdefault(summ["norm"], sex)
names = await _primary_name_by_person(session, tree.id)
out: list[dict] = []
for p in await _persons(session, tree.id):
if p.gender: # only fill in what's missing
continue
nm = names.get(p.id)
if nm is None:
continue
proposed = name2sex.get(gedcom._norm(nm.given, nm.surname))
if proposed:
out.append({"person_id": str(p.id), "name": _display(nm), "proposed_gender": proposed})
out.sort(key=lambda r: r["name"])
return out
async def guess_gender_by_name(
session: AsyncSession, *, actor: User, tree: Tree
) -> list[dict]:
"""Best-guess sex from the first given name for people who don't have it set,
using the bundled name dictionary. Ambiguous/unknown names are skipped."""
await _require_editor(session, actor=actor, tree=tree)
names = await _primary_name_by_person(session, tree.id)
out: list[dict] = []
for p in await _persons(session, tree.id):
if p.gender:
continue
nm = names.get(p.id)
if nm is None:
continue
proposed = guess_sex(nm.given)
if proposed:
out.append({"person_id": str(p.id), "name": _display(nm), "proposed_gender": proposed})
out.sort(key=lambda r: r["name"])
return out
async def apply_gender(
session: AsyncSession, *, actor: User, tree: Tree, updates: list[dict]
) -> int:
"""updates: [{person_id, gender}]."""
await _require_editor(session, actor=actor, tree=tree)
wanted = {uuid.UUID(str(u["person_id"])): u["gender"] for u in updates if u.get("gender")}
persons = (
await session.execute(
select(Person).where(
Person.tree_id == tree.id,
Person.deleted_at.is_(None),
Person.id.in_(wanted.keys()),
)
)
).scalars().all()
for p in persons:
p.gender = wanted[p.id]
record_audit(
session,
action="cleanup_gender",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"count": len(persons)},
)
await session.commit()
return len(persons)
# ---- 3. Flag malformed names for review --------------------------------------------
_YEAR_RE = re.compile(r"\b\d{3,4}\b")
def _name_issue(n: Name) -> str | None:
given = (n.given or "").strip()
surname = (n.surname or "").strip()
if _YEAR_RE.search(surname) or re.search(r"\d", surname):
return "date_in_surname"
if re.search(r"\d", given):
return "date_in_given"
# A given name with many tokens often means a maiden+married name was packed
# in (e.g. "Mary Smith Jones") — surface it for a human to split.
if surname == "" and len(given.split()) >= 2:
return "no_surname"
if len(given.split()) >= 3:
return "packed_given"
return None
async def preview_names(session: AsyncSession, *, actor: User, tree: Tree) -> list[dict]:
await _require_editor(session, actor=actor, tree=tree)
names = (
await session.execute(
select(Name).where(Name.tree_id == tree.id, Name.deleted_at.is_(None))
)
).scalars().all()
out: list[dict] = []
for n in names:
issue = _name_issue(n)
if issue:
out.append({
"name_id": str(n.id),
"person_id": str(n.person_id),
"given": n.given,
"surname": n.surname,
"issue": issue,
})
return out
async def apply_names(
session: AsyncSession, *, actor: User, tree: Tree, edits: list[dict]
) -> int:
"""edits: [{name_id, given, surname}] — the user's corrected values."""
await _require_editor(session, actor=actor, tree=tree)
by_id = {uuid.UUID(str(e["name_id"])): e for e in edits}
rows = (
await session.execute(
select(Name).where(
Name.tree_id == tree.id,
Name.deleted_at.is_(None),
Name.id.in_(by_id.keys()),
)
)
).scalars().all()
if len(rows) != len(by_id):
raise NotFound("one or more names not found in this tree")
for n in rows:
e = by_id[n.id]
n.given = (e.get("given") or "").strip() or None
n.surname = (e.get("surname") or "").strip() or None
n.display_name = None # rebuild from parts
record_audit(
session,
action="cleanup_names",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=actor.id,
after={"count": len(rows)},
)
await session.commit()
return len(rows)
+69
View File
@@ -0,0 +1,69 @@
"""A curated given-name -> sex lookup for best-guessing a person's sex from
their first name. Weighted toward English + German names (this codebase's first
real tree is a German-American family). Deterministic and offline — no model
needed; the Cleanup tool previews every guess before anything is applied.
Genuinely ambiguous names (Marion, Frances/Francis, Jordan, Jamie, Robin, Leslie,
Dana, …) are intentionally left out of BOTH sets so they aren't guessed — better
a human decides those than a coin flip.
"""
MALE_NAMES: set[str] = {
# English / common US
"james", "john", "robert", "michael", "william", "david", "richard", "joseph",
"thomas", "charles", "christopher", "daniel", "matthew", "anthony", "donald",
"mark", "paul", "steven", "andrew", "kenneth", "george", "joshua", "kevin",
"brian", "edward", "ronald", "timothy", "jason", "jeffrey", "gary", "ryan",
"nicholas", "eric", "stephen", "jacob", "larry", "frank", "jonathan", "scott",
"raymond", "gregory", "samuel", "benjamin", "patrick", "jack", "dennis", "jerry",
"alexander", "tyler", "henry", "douglas", "peter", "adam", "harold", "albert",
"arthur", "carl", "ralph", "roy", "eugene", "louis", "philip", "bobby", "walter",
"willie", "wayne", "fred", "howard", "ernest", "earl", "clarence", "leon",
"leonard", "lewis", "floyd", "leroy", "elmer", "homer", "orrin", "josias",
"emerson", "dale", "bernard", "vernon", "virgil", "wilbur", "russell",
"harvey", "herbert", "melvin", "lloyd", "marvin", "norman", "stanley",
# German
"hans", "karl", "wilhelm", "friedrich", "heinrich", "otto", "hermann", "gustav",
"ludwig", "ernst", "fritz", "johann", "conrad", "konrad", "reinhold", "rudolf",
"rudolph", "gerhard", "helmut", "horst", "klaus", "kurt", "dieter", "günther",
"gunther", "manfred", "siegfried", "hilgard", "christian", "august", "wolfgang",
"jürgen", "jurgen", "matthias", "lothar", "bruno", "gottlieb", "reinhard",
}
FEMALE_NAMES: set[str] = {
# English / common US
"mary", "patricia", "jennifer", "linda", "elizabeth", "barbara", "susan",
"jessica", "sarah", "karen", "nancy", "lisa", "betty", "margaret", "sandra",
"ashley", "kimberly", "emily", "donna", "michelle", "carol", "amanda", "dorothy",
"melissa", "deborah", "stephanie", "rebecca", "sharon", "laura", "cynthia",
"kathleen", "amy", "angela", "shirley", "anna", "ruth", "brenda", "pamela",
"nicole", "katherine", "virginia", "catherine", "helen", "debra", "rachel",
"carolyn", "janet", "maria", "heather", "diane", "julie", "joyce", "victoria",
"kelly", "christina", "joan", "evelyn", "judith", "megan", "alice", "frances",
"marie", "florence", "flora", "zella", "thelma", "ellen", "althea", "della",
"beatrice", "pauline", "hedwig", "florentine", "wilhelmina", "augusta", "bertha",
"gladys", "mildred", "lucille", "edith", "esther", "irene", "hazel", "doris",
"rose", "rita", "norma", "june", "lois", "marjorie",
# German
"greta", "ilse", "ursula", "gertrud", "gertrude", "frieda", "frida", "else",
"hilda", "hilde", "hildegard", "ingrid", "helga", "renate", "monika", "sieglinde",
"brigitte", "gisela", "elke", "anneliese", "waltraud", "edeltraud", "johanna",
"katharina", "margarethe", "wilhelmine", "emilie", "auguste",
}
def guess_sex(given: str | None) -> str | None:
"""Best-guess "male"/"female" from the first token of a given name, or None
if unknown/ambiguous."""
if not given:
return None
first = given.strip().split()[0].lower() if given.strip() else ""
# Strip trailing punctuation/initials like "wm." -> "wm".
first = first.strip(".,'\"")
if not first:
return None
if first in MALE_NAMES:
return "male"
if first in FEMALE_NAMES:
return "female"
return None
+33 -1
View File
@@ -4,7 +4,7 @@ Writes require editor rights; reads go through the privacy engine."""
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from sqlalchemy import or_, select from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import ParentChildQualifier, RelationshipType from app.models.enums import ParentChildQualifier, RelationshipType
@@ -49,6 +49,38 @@ async def create_relationship(
if not await _person_in_tree(session, pid, tree.id): if not await _person_in_tree(session, pid, tree.id):
raise NotFound("person not found in this tree") raise NotFound("person not found in this tree")
# Reject an equivalent existing edge so the same two people can't be linked
# the same way twice. parent_child is directional (parent -> child);
# partnership/sibling are symmetric, so match the pair in either order.
if type is RelationshipType.parent_child:
pair = and_(
Relationship.person_from_id == person_from_id,
Relationship.person_to_id == person_to_id,
)
else:
pair = or_(
and_(
Relationship.person_from_id == person_from_id,
Relationship.person_to_id == person_to_id,
),
and_(
Relationship.person_from_id == person_to_id,
Relationship.person_to_id == person_from_id,
),
)
existing = (
await session.execute(
select(Relationship.id).where(
Relationship.tree_id == tree.id,
Relationship.type == type,
Relationship.deleted_at.is_(None),
pair,
)
)
).scalar_one_or_none()
if existing is not None:
raise Conflict("these two people are already linked that way")
relationship = Relationship( relationship = Relationship(
tree_id=tree.id, tree_id=tree.id,
type=type, type=type,
+129
View File
@@ -0,0 +1,129 @@
"""Tree cleanup: preview/apply for deceased-by-year, gender-from-source, names."""
from tests.conftest import auth, register
async def _tree(client, email):
h = auth(await register(client, email))
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
return h, tid
async def _person(client, h, tid, given, surname=None):
return (
await client.post(
f"/api/v1/trees/{tid}/persons", json={"given": given, "surname": surname}, headers=h
)
).json()["id"]
async def _birth(client, h, tid, pid, year):
await client.post(
f"/api/v1/trees/{tid}/events",
json={"event_type": "birth", "person_id": pid, "date_value": str(year)},
headers=h,
)
async def test_deceased_preview_and_apply(client):
h, tid = await _tree(client, "cl-dec@example.com")
old = await _person(client, h, tid, "Josias", "Moody")
young = await _person(client, h, tid, "Kid", "Moody")
await _birth(client, h, tid, old, 1900)
await _birth(client, h, tid, young, 1990)
prev = (
await client.get(f"/api/v1/trees/{tid}/cleanup/deceased?born_on_or_before=1930", headers=h)
).json()
assert [r["person_id"] for r in prev] == [old]
r = await client.post(
f"/api/v1/trees/{tid}/cleanup/deceased", json={"person_ids": [old]}, headers=h
)
assert r.status_code == 200 and r.json()["updated"] == 1
assert (
await client.get(f"/api/v1/trees/{tid}/persons/{old}", headers=h)
).json()["is_living"] is False
# Re-preview no longer lists the now-deceased person.
prev2 = (
await client.get(f"/api/v1/trees/{tid}/cleanup/deceased?born_on_or_before=1930", headers=h)
).json()
assert old not in [r["person_id"] for r in prev2]
GED = b"""0 HEAD
0 @I1@ INDI
1 NAME Josias /Moody/
1 SEX M
0 @I2@ INDI
1 NAME Flora /Paul/
1 SEX F
0 TRLR
"""
async def test_gender_from_source(client):
h, tid = await _tree(client, "cl-gen@example.com")
await _person(client, h, tid, "Josias", "Moody")
await _person(client, h, tid, "Flora", "Paul")
await _person(client, h, tid, "Nobody", "Else") # not in source
prev = await client.post(
f"/api/v1/trees/{tid}/cleanup/gender/preview",
files={"file": ("src.ged", GED, "text/plain")},
headers=h,
)
props = prev.json()
by_name = {p["name"]: p["proposed_gender"] for p in props}
assert by_name == {"Josias Moody": "male", "Flora Paul": "female"}
updates = [{"person_id": p["person_id"], "gender": p["proposed_gender"]} for p in props]
r = await client.post(
f"/api/v1/trees/{tid}/cleanup/gender", json={"updates": updates}, headers=h
)
assert r.status_code == 200 and r.json()["updated"] == 2
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
genders = {p["primary_name"]: p["gender"] for p in people}
assert genders["Josias Moody"] == "male" and genders["Flora Paul"] == "female"
async def test_guess_gender_from_first_name(client):
h, tid = await _tree(client, "cl-guess@example.com")
await _person(client, h, tid, "William", "Paul") # male
await _person(client, h, tid, "Flora", "Reier") # female
await _person(client, h, tid, "Marion", "Doe") # ambiguous -> skipped
# Already-gendered person is left alone even if guessable.
gendered = await _person(client, h, tid, "James", "Known")
await client.patch(
f"/api/v1/trees/{tid}/persons/{gendered}", json={"gender": "male"}, headers=h
)
prev = (await client.get(f"/api/v1/trees/{tid}/cleanup/gender/guess", headers=h)).json()
by = {p["name"]: p["proposed_gender"] for p in prev}
assert by == {"William Paul": "male", "Flora Reier": "female"}
updates = [{"person_id": p["person_id"], "gender": p["proposed_gender"]} for p in prev]
r = await client.post(
f"/api/v1/trees/{tid}/cleanup/gender", json={"updates": updates}, headers=h
)
assert r.status_code == 200 and r.json()["updated"] == 2
async def test_name_issues_preview_and_fix(client):
h, tid = await _tree(client, "cl-name@example.com")
# surname got a date; real surname landed in the given name.
bad = await _person(client, h, tid, "Henry Paul", "1859")
await _person(client, h, tid, "Normal", "Person") # should not be flagged
issues = (await client.get(f"/api/v1/trees/{tid}/cleanup/names", headers=h)).json()
assert len(issues) == 1 and issues[0]["issue"] == "date_in_surname"
name_id = issues[0]["name_id"]
r = await client.post(
f"/api/v1/trees/{tid}/cleanup/names",
json={"edits": [{"name_id": name_id, "given": "Henry", "surname": "Paul"}]},
headers=h,
)
assert r.status_code == 200 and r.json()["updated"] == 1
person = (await client.get(f"/api/v1/trees/{tid}/persons/{bad}", headers=h)).json()
assert person["primary_name"] == "Henry Paul"
+65
View File
@@ -0,0 +1,65 @@
"""Duplicate relationships are rejected (no double-linking)."""
from tests.conftest import auth, register
async def _setup(client, email):
h = auth(await register(client, email))
tid = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
async def person(given):
return (
await client.post(f"/api/v1/trees/{tid}/persons", json={"given": given}, headers=h)
).json()["id"]
return h, tid, person
async def test_duplicate_parent_child_rejected(client):
h, tid, person = await _setup(client, "dup-pc@example.com")
karl = await person("Karl")
kid = await person("Kid")
body = {"type": "parent_child", "person_from_id": karl, "person_to_id": kid}
first = await client.post(f"/api/v1/trees/{tid}/relationships", json=body, headers=h)
assert first.status_code == 201
dup = await client.post(f"/api/v1/trees/{tid}/relationships", json=body, headers=h)
assert dup.status_code == 409
async def test_duplicate_partnership_either_direction_rejected(client):
h, tid, person = await _setup(client, "dup-sp@example.com")
a = await person("A")
b = await person("B")
first = await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "partnership", "person_from_id": a, "person_to_id": b},
headers=h,
)
assert first.status_code == 201
# Same couple, reversed order — still a duplicate.
dup = await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "partnership", "person_from_id": b, "person_to_id": a},
headers=h,
)
assert dup.status_code == 409
async def test_reverse_parent_child_is_allowed(client):
"""A->B as parent_child shouldn't block B->A (different meaning)."""
h, tid, person = await _setup(client, "dup-rev@example.com")
a = await person("A")
b = await person("B")
r1 = await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "parent_child", "person_from_id": a, "person_to_id": b},
headers=h,
)
r2 = await client.post(
f"/api/v1/trees/{tid}/relationships",
json={"type": "parent_child", "person_from_id": b, "person_to_id": a},
headers=h,
)
assert r1.status_code == 201 and r2.status_code == 201
+17 -12
View File
@@ -11,23 +11,28 @@
--font-serif: var(--font-fraunces), Georgia, "Times New Roman", ui-serif, serif; --font-serif: var(--font-fraunces), Georgia, "Times New Roman", ui-serif, serif;
} }
/* Adaptive tokens — ink/paper flip for light/dark; bronze + paper are constant. */ /* Adaptive tokens — ink/paper flip for light/dark; bronze + paper are constant.
Theme is class-based (.dark on <html>) so it can be toggled manually; an inline
script in the root layout sets it pre-paint from the saved choice or the OS. */
:root { :root {
--background: #f7f3ec; --background: #f7f3ec;
--foreground: #1a1a17; --foreground: #1a1a17;
--muted: #6b6862; --muted: #6b6862;
--surface: #fffdf9; --surface: #fffdf9;
--border: #e6ddcc; --border: #e6ddcc;
/* Connector "lines between people" (pedigree + tree chart). Derived from Ink
(the brand mark color): a dark line on light, light on dark. */
--line: color-mix(in srgb, var(--foreground) 55%, transparent);
color-scheme: light;
} }
@media (prefers-color-scheme: dark) { .dark {
:root { --background: #161410;
--background: #161410; --foreground: #f2eee6;
--foreground: #f2eee6; --muted: #9a968e;
--muted: #9a968e; --surface: #211d17;
--surface: #211d17; --border: #353029;
--border: #353029; color-scheme: dark;
}
} }
body { body {
@@ -78,7 +83,7 @@ h3,
left: -2.5rem; left: -2.5rem;
top: 50%; top: 50%;
width: 2.5rem; width: 2.5rem;
border-top: 1px solid var(--border); border-top: 1px solid var(--line);
} }
.ped-leaf { .ped-leaf {
position: relative; position: relative;
@@ -90,7 +95,7 @@ h3,
left: 0; left: 0;
top: 50%; top: 50%;
width: 1.5rem; width: 1.5rem;
border-top: 1px solid var(--border); border-top: 1px solid var(--line);
} }
.ped-leaf::after { .ped-leaf::after {
content: ""; content: "";
@@ -98,7 +103,7 @@ h3,
left: 0; left: 0;
top: 0; top: 0;
bottom: 0; bottom: 0;
border-left: 1px solid var(--border); border-left: 1px solid var(--line);
} }
.ped-leaf:first-child::after { .ped-leaf:first-child::after {
top: 50%; top: 50%;
+8 -1
View File
@@ -18,9 +18,16 @@ export const metadata: Metadata = {
icons: { icon: "/favicon.svg" }, icons: { icon: "/favicon.svg" },
}; };
// Sets the theme class before first paint to avoid a flash; reads the saved
// choice ("light"/"dark"/"system") or falls back to the OS preference.
const themeScript = `(function(){try{var t=localStorage.getItem("theme");var d=t==="dark"||((!t||t==="system")&&window.matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.classList.toggle("dark",d);}catch(e){}})();`;
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en" className={`${serif.variable} ${sans.variable}`}> <html lang="en" className={`${serif.variable} ${sans.variable}`} suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body className="min-h-screen antialiased">{children}</body> <body className="min-h-screen antialiased">{children}</body>
</html> </html>
); );
+393
View File
@@ -0,0 +1,393 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "next/navigation";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
type Deceased = components["schemas"]["DeceasedCandidate"];
type GenderProp = components["schemas"]["GenderProposal"];
type NameIssue = components["schemas"]["NameIssue"];
type Person = components["schemas"]["PersonRead"];
const ISSUE_LABEL: Record<string, string> = {
date_in_surname: "date in surname",
date_in_given: "date in given name",
no_surname: "no surname",
packed_given: "long given name",
};
export default function CleanupPage() {
const params = useParams<{ id: string }>();
const treeId = params.id;
// 1) Deceased by birth year
const [year, setYear] = useState(1930);
const [deceased, setDeceased] = useState<Deceased[] | null>(null);
const [decSel, setDecSel] = useState<Set<string>>(new Set());
const [decMsg, setDecMsg] = useState<string | null>(null);
// 2) Gender from source GEDCOM
const [gender, setGender] = useState<GenderProp[] | null>(null);
const [genSel, setGenSel] = useState<Set<string>>(new Set());
const [genMsg, setGenMsg] = useState<string | null>(null);
const genFile = useRef<HTMLInputElement>(null);
// People still missing a sex (manual mop-up)
const [unset, setUnset] = useState<Person[] | null>(null);
// 3) Name issues
const [issues, setIssues] = useState<NameIssue[] | null>(null);
const [edits, setEdits] = useState<Record<string, { given: string; surname: string; on: boolean }>>({});
const [nameMsg, setNameMsg] = useState<string | null>(null);
async function previewDeceased() {
setDecMsg(null);
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/deceased", {
params: { path: { tree_id: treeId }, query: { born_on_or_before: year } },
});
setDeceased(data ?? []);
setDecSel(new Set((data ?? []).map((d) => d.person_id)));
}
async function applyDeceased() {
const ids = [...decSel];
const { data } = await api.POST("/api/v1/trees/{tree_id}/cleanup/deceased", {
params: { path: { tree_id: treeId } },
body: { person_ids: ids },
});
setDecMsg(`Marked ${data?.updated ?? 0} people deceased.`);
setDeceased(null);
}
async function previewGender(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (genFile.current) genFile.current.value = "";
if (!file) return;
setGenMsg(null);
const fd = new FormData();
fd.append("file", file);
const resp = await fetch(`/api/v1/trees/${treeId}/cleanup/gender/preview`, {
method: "POST",
body: fd,
credentials: "include",
});
if (resp.ok) {
const data: GenderProp[] = await resp.json();
setGender(data);
setGenSel(new Set(data.map((g) => g.person_id)));
}
}
const loadUnset = useCallback(async () => {
const { data } = await api.GET("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } },
});
setUnset(
(data ?? [])
.filter((p) => !p.gender)
.sort((a, b) => (a.primary_name ?? "").localeCompare(b.primary_name ?? "")),
);
}, [treeId]);
async function setSex(personId: string, gender: "male" | "female") {
await api.PATCH("/api/v1/trees/{tree_id}/persons/{person_id}", {
params: { path: { tree_id: treeId, person_id: personId } },
body: { gender },
});
setUnset((prev) => (prev ? prev.filter((p) => p.id !== personId) : prev));
}
async function guessGender() {
setGenMsg(null);
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/gender/guess", {
params: { path: { tree_id: treeId } },
});
setGender(data ?? []);
setGenSel(new Set((data ?? []).map((g) => g.person_id)));
}
async function applyGender() {
const updates = (gender ?? [])
.filter((g) => genSel.has(g.person_id))
.map((g) => ({ person_id: g.person_id, gender: g.proposed_gender }));
const { data } = await api.POST("/api/v1/trees/{tree_id}/cleanup/gender", {
params: { path: { tree_id: treeId } },
body: { updates },
});
setGenMsg(`Set gender on ${data?.updated ?? 0} people.`);
setGender(null);
loadUnset();
}
const loadNames = useCallback(async () => {
setNameMsg(null);
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/names", {
params: { path: { tree_id: treeId } },
});
setIssues(data ?? []);
const init: Record<string, { given: string; surname: string; on: boolean }> = {};
for (const i of data ?? []) {
init[i.name_id] = { given: i.given ?? "", surname: i.surname ?? "", on: false };
}
setEdits(init);
}, [treeId]);
useEffect(() => {
loadNames();
loadUnset();
}, [loadNames, loadUnset]);
async function applyNames() {
const chosen = (issues ?? []).filter((i) => edits[i.name_id]?.on);
const body = {
edits: chosen.map((i) => ({
name_id: i.name_id,
given: edits[i.name_id].given,
surname: edits[i.name_id].surname,
})),
};
if (!body.edits.length) return;
const { data } = await api.POST("/api/v1/trees/{tree_id}/cleanup/names", {
params: { path: { tree_id: treeId } },
body,
});
setNameMsg(`Fixed ${data?.updated ?? 0} names.`);
loadNames();
}
const toggle = (set: Set<string>, id: string, setter: (s: Set<string>) => void) => {
const n = new Set(set);
if (n.has(id)) n.delete(id);
else n.add(id);
setter(n);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Cleanup</h1>
<p className="mt-1 text-sm text-[var(--muted)]">
Fix common import messes in bulk. Each tool previews its changes nothing is saved
until you apply.
</p>
</div>
{/* 1) Deceased by year */}
<Card>
<CardHeader>
<CardTitle className="text-base">Mark deceased by birth year</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap items-end gap-2">
<label className="flex flex-col gap-1 text-sm">
<span className="text-xs text-[var(--muted)]">Born on or before</span>
<Input
type="number"
className="w-28"
value={year}
onChange={(e) => setYear(Number(e.target.value))}
/>
</label>
<Button variant="outline" onClick={previewDeceased}>
Preview
</Button>
</div>
{decMsg && <p className="text-sm text-bronze">{decMsg}</p>}
{deceased && (
<div className="space-y-2">
<p className="text-sm text-[var(--muted)]">
{deceased.length} people born {year} (not already marked deceased).
</p>
<ul className="max-h-64 divide-y divide-[var(--border)] overflow-auto rounded-lg border border-[var(--border)]">
{deceased.map((d) => (
<li key={d.person_id} className="flex items-center gap-3 px-3 py-1.5 text-sm">
<input
type="checkbox"
checked={decSel.has(d.person_id)}
onChange={() => toggle(decSel, d.person_id, setDecSel)}
/>
<span className="flex-1">{d.name}</span>
<span className="text-xs text-[var(--muted)]">b. {d.birth_year}</span>
</li>
))}
</ul>
{deceased.length > 0 && (
<Button onClick={applyDeceased}>Mark {decSel.size} deceased</Button>
)}
</div>
)}
</CardContent>
</Card>
{/* 2) Gender from source */}
<Card>
<CardHeader>
<CardTitle className="text-base">Set sex from a source GEDCOM</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-[var(--muted)]">
Upload your source <code>.ged</code> (it carries each persons sex). We match by
name and propose sex only for people who dont have it set.
</p>
<input
ref={genFile}
type="file"
accept=".ged,.gedcom,text/plain"
onChange={previewGender}
className="hidden"
/>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => genFile.current?.click()}>
Choose source GEDCOM
</Button>
<Button variant="outline" onClick={guessGender}>
Guess from first name
</Button>
</div>
<p className="text-xs text-[var(--muted)]">
Guess from first name uses a built-in name dictionary for people with no sex set;
ambiguous names (Marion, Frances, ) are left for you to decide.
</p>
{genMsg && <p className="text-sm text-bronze">{genMsg}</p>}
{gender && (
<div className="space-y-2">
<p className="text-sm text-[var(--muted)]">{gender.length} matches with a sex to set.</p>
<ul className="max-h-64 divide-y divide-[var(--border)] overflow-auto rounded-lg border border-[var(--border)]">
{gender.map((g) => (
<li key={g.person_id} className="flex items-center gap-3 px-3 py-1.5 text-sm">
<input
type="checkbox"
checked={genSel.has(g.person_id)}
onChange={() => toggle(genSel, g.person_id, setGenSel)}
/>
<span className="flex-1">{g.name}</span>
<span
className="text-xs"
style={{
color:
g.proposed_gender === "male"
? "rgb(120,159,172)"
: "rgb(196,138,146)",
}}
>
{g.proposed_gender}
</span>
</li>
))}
</ul>
{gender.length > 0 && (
<Button onClick={applyGender}>Set sex on {genSel.size} people</Button>
)}
</div>
)}
</CardContent>
</Card>
{/* People still missing a sex */}
<Card>
<CardHeader>
<CardTitle className="text-base">
People with no sex set{unset ? ` (${unset.length})` : ""}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{unset === null ? (
<p className="text-sm text-[var(--muted)]">Loading</p>
) : unset.length === 0 ? (
<p className="text-sm text-[var(--muted)]">Everyone has a sex set. 🎉</p>
) : (
<ul className="max-h-80 divide-y divide-[var(--border)] overflow-auto rounded-lg border border-[var(--border)]">
{unset.map((p) => (
<li key={p.id} className="flex items-center gap-3 px-3 py-1.5 text-sm">
<a
href={`/trees/${treeId}/persons/${p.id}`}
className="flex-1 truncate hover:underline"
>
{p.primary_name ?? "Unnamed"}
</a>
<button
onClick={() => setSex(p.id, "male")}
className="rounded px-2 py-0.5 text-xs"
style={{ color: "rgb(120,159,172)", border: "1px solid rgb(120,159,172)" }}
>
Male
</button>
<button
onClick={() => setSex(p.id, "female")}
className="rounded px-2 py-0.5 text-xs"
style={{ color: "rgb(196,138,146)", border: "1px solid rgb(196,138,146)" }}
>
Female
</button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
{/* 3) Name issues */}
<Card>
<CardHeader>
<CardTitle className="text-base">Names that look broken</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{nameMsg && <p className="text-sm text-bronze">{nameMsg}</p>}
{issues === null ? (
<p className="text-sm text-[var(--muted)]">Scanning</p>
) : issues.length === 0 ? (
<p className="text-sm text-[var(--muted)]">No obvious name problems found.</p>
) : (
<div className="space-y-2">
<p className="text-sm text-[var(--muted)]">
{issues.length} flagged. Edit given/surname, tick the ones to fix, then apply.
</p>
<ul className="space-y-2">
{issues.map((i) => {
const e = edits[i.name_id] ?? { given: "", surname: "", on: false };
return (
<li key={i.name_id} className="flex flex-wrap items-center gap-2 text-sm">
<input
type="checkbox"
checked={e.on}
onChange={() =>
setEdits((p) => ({ ...p, [i.name_id]: { ...e, on: !e.on } }))
}
/>
<Input
className="h-9 w-40"
placeholder="Given"
value={e.given}
onChange={(ev) =>
setEdits((p) => ({ ...p, [i.name_id]: { ...e, given: ev.target.value } }))
}
/>
<Input
className="h-9 w-40"
placeholder="Surname"
value={e.surname}
onChange={(ev) =>
setEdits((p) => ({
...p,
[i.name_id]: { ...e, surname: ev.target.value },
}))
}
/>
<span className="rounded bg-[var(--border)]/50 px-1.5 py-0.5 text-xs text-[var(--muted)]">
{ISSUE_LABEL[i.issue] ?? i.issue}
</span>
</li>
);
})}
</ul>
<Button onClick={applyNames}>Fix selected</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}
+17 -6
View File
@@ -126,6 +126,12 @@ export default function FamilyViewPage() {
return data?.id ?? null; return data?.id ?? null;
} }
// Create a new (blank) person and open their page to fill in details.
async function newPersonAndGo() {
const id = await addPerson("");
if (id) router.push(`/trees/${treeId}/persons/${id}`);
}
async function createFirst(e: React.FormEvent) { async function createFirst(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!firstName.trim()) return; if (!firstName.trim()) return;
@@ -352,12 +358,17 @@ export default function FamilyViewPage() {
<div className="space-y-8"> <div className="space-y-8">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold">Family view</h1> <h1 className="text-2xl font-semibold">Family view</h1>
<Link <div className="flex items-center gap-3">
href={`/trees/${treeId}/persons/${focus.id}`} <Button size="sm" onClick={newPersonAndGo}>
className="text-sm text-bronze hover:underline" + Add person
> </Button>
Open {focus.primary_name ?? "person"} <Link
</Link> href={`/trees/${treeId}/persons/${focus.id}`}
className="text-sm text-bronze hover:underline"
>
Open {focus.primary_name ?? "person"}
</Link>
</div>
</div> </div>
{/* Pedigree: focus → parents → grandparents, with bracket connectors */} {/* Pedigree: focus → parents → grandparents, with bracket connectors */}
@@ -150,6 +150,7 @@ export default function PersonDetailPage() {
const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent"); const [relKind, setRelKind] = useState<"parent" | "child" | "partner" | "sibling">("parent");
const [relOther, setRelOther] = useState(""); const [relOther, setRelOther] = useState("");
const [relQual, setRelQual] = useState<Qualifier>("biological"); const [relQual, setRelQual] = useState<Qualifier>("biological");
const [relErr, setRelErr] = useState<string | null>(null);
// Add-name form + inline edit. // Add-name form + inline edit.
const [nameType, setNameType] = useState("married"); const [nameType, setNameType] = useState("married");
@@ -342,28 +343,50 @@ export default function PersonDetailPage() {
} }
} }
async function addRel(e: React.FormEvent) { async function linkRelative(otherId: string): Promise<boolean> {
e.preventDefault();
if (!relOther) return;
let body: RelCreate; let body: RelCreate;
if (relKind === "parent") { if (relKind === "parent") {
body = { type: "parent_child", person_from_id: relOther, person_to_id: personId, qualifier: relQual }; body = { type: "parent_child", person_from_id: otherId, person_to_id: personId, qualifier: relQual };
} else if (relKind === "child") { } else if (relKind === "child") {
body = { type: "parent_child", person_from_id: personId, person_to_id: relOther, qualifier: relQual }; body = { type: "parent_child", person_from_id: personId, person_to_id: otherId, qualifier: relQual };
} else if (relKind === "partner") { } else if (relKind === "partner") {
body = { type: "partnership", person_from_id: personId, person_to_id: relOther }; body = { type: "partnership", person_from_id: personId, person_to_id: otherId };
} else { } else {
body = { type: "sibling", person_from_id: personId, person_to_id: relOther }; body = { type: "sibling", person_from_id: personId, person_to_id: otherId };
} }
const { error } = await api.POST("/api/v1/trees/{tree_id}/relationships", { const { error } = await api.POST("/api/v1/trees/{tree_id}/relationships", {
params: { path: { tree_id: treeId } }, params: { path: { tree_id: treeId } },
body, body,
}); });
if (!error) { return !error;
}
async function addRel(e: React.FormEvent) {
e.preventDefault();
if (!relOther) return;
setRelErr(null);
if (await linkRelative(relOther)) {
setRelOther(""); setRelOther("");
load(); load();
} else {
setRelErr("They're already linked that way.");
} }
} }
// Create a brand-new person, link them in the chosen role, then jump to their
// page so the user can fill in details immediately.
async function createRelativeAndGo(name: string) {
const toks = name.trim().split(/\s+/).filter(Boolean);
const given = toks.length > 1 ? toks.slice(0, -1).join(" ") : toks[0] ?? name.trim();
const surname = toks.length > 1 ? toks[toks.length - 1] : null;
const { data } = await api.POST("/api/v1/trees/{tree_id}/persons", {
params: { path: { tree_id: treeId } },
body: { given, surname },
});
if (!data) return;
await linkRelative(data.id);
router.push(`/trees/${treeId}/persons/${data.id}`);
}
async function removeRel(id: string) { async function removeRel(id: string) {
await api.DELETE("/api/v1/trees/{tree_id}/relationships/{relationship_id}", { await api.DELETE("/api/v1/trees/{tree_id}/relationships/{relationship_id}", {
params: { path: { tree_id: treeId, relationship_id: id } }, params: { path: { tree_id: treeId, relationship_id: id } },
@@ -667,7 +690,19 @@ export default function PersonDetailPage() {
) : ( ) : (
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<h1 className="flex flex-wrap items-center gap-3 text-3xl font-semibold"> <h1 className="flex flex-wrap items-center gap-3 text-3xl font-semibold">
{person.primary_name ?? "Unnamed person"} <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 && ( {isSelf && (
<span className="rounded-full bg-bronze/15 px-2.5 py-1 text-xs font-medium text-bronze"> <span className="rounded-full bg-bronze/15 px-2.5 py-1 text-xs font-medium text-bronze">
This is you This is you
@@ -1096,7 +1131,8 @@ export default function PersonDetailPage() {
people={others} people={others}
value={relOther} value={relOther}
onChange={setRelOther} onChange={setRelOther}
placeholder="Search for a person…" onCreate={createRelativeAndGo}
placeholder="Search, or type a new name…"
/> />
{(relKind === "parent" || relKind === "child") && ( {(relKind === "parent" || relKind === "child") && (
<select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}> <select className={fieldCls} value={relQual} onChange={(e) => setRelQual(e.target.value as Qualifier)}>
@@ -1110,6 +1146,7 @@ export default function PersonDetailPage() {
<Button type="submit">Link</Button> <Button type="submit">Link</Button>
</form> </form>
)} )}
{relErr && <p className="text-sm text-red-600">{relErr}</p>}
</CardContent> </CardContent>
</Card> </Card>
+5
View File
@@ -530,6 +530,11 @@
.f3 .link { .f3 .link {
transition: stroke-width 0.2s ease-in-out; transition: stroke-width 0.2s ease-in-out;
/* Brand-aware connector lines: dark on the light paper, light on dark. The
library's default white stroke is invisible on the light theme. */
fill: none;
stroke: var(--line);
stroke-width: 1.5px;
} }
.f3 .link.f3-path-to-main { .f3 .link.f3-path-to-main {
+82 -25
View File
@@ -39,6 +39,7 @@ export default function TreePage() {
const [status, setStatus] = useState<"loading" | "empty" | "ready" | "error">("loading"); const [status, setStatus] = useState<"loading" | "empty" | "ready" | "error">("loading");
const [focusId, setFocusId] = useState<string | null>(null); const [focusId, setFocusId] = useState<string | null>(null);
const [mode, setMode] = useState<Mode>("landscape"); const [mode, setMode] = useState<Mode>("landscape");
const [renderNote, setRenderNote] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -108,11 +109,46 @@ export default function TreePage() {
if (status !== "ready" || mode === "fan" || !containerRef.current) return; if (status !== "ready" || mode === "fan" || !containerRef.current) return;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
// Only link to people that still exist — a soft-deleted person leaves // Sanitize the graph before handing it to family-chart, which recurses
// dangling relationship rows, and family-chart breaks on an id with no // through parents and will blow the stack (blank tree) on a cycle — e.g. a
// matching datum. Filter them out so a deletion never blanks the tree. // person edited into being their own ancestor.
const alive = new Set(people.map((pp) => pp.id)); const alive = new Set(people.map((pp) => pp.id));
const keep = (ids: string[]) => ids.filter((id) => alive.has(id)); const ok = (ids: string[], self: string) =>
[...new Set(ids)].filter((id) => alive.has(id) && id !== self);
// Build an acyclic set of parent edges: skip any edge that would make a
// person their own ancestor. Children are derived from the kept edges so
// parent/child stays consistent.
const parentsMap = new Map<string, string[]>();
const childrenMap = new Map<string, string[]>();
let dropped = 0;
const isAncestorOf = (ancestor: string, of: string): boolean => {
const stack = [...(parentsMap.get(of) ?? [])];
const seen = new Set<string>();
while (stack.length) {
const n = stack.pop()!;
if (n === ancestor) return true;
if (seen.has(n)) continue;
seen.add(n);
for (const p of parentsMap.get(n) ?? []) stack.push(p);
}
return false;
};
for (const pp of people) {
const accepted: string[] = [];
for (const par of ok(parentsOf(pp.id), pp.id)) {
// Edge "pp has parent par" loops if pp is already an ancestor of par.
if (isAncestorOf(pp.id, par)) {
dropped++;
continue;
}
accepted.push(par);
parentsMap.set(pp.id, accepted);
childrenMap.set(par, [...(childrenMap.get(par) ?? []), pp.id]);
}
parentsMap.set(pp.id, accepted);
}
const data = people.map((pp) => { const data = people.map((pp) => {
const [fn, ln] = splitName(pp.primary_name); const [fn, ln] = splitName(pp.primary_name);
return { return {
@@ -124,32 +160,47 @@ export default function TreePage() {
gender: pp.gender === "female" ? "F" : "M", gender: pp.gender === "female" ? "F" : "M",
}, },
rels: { rels: {
spouses: keep(partnersOf(pp.id)), spouses: ok(partnersOf(pp.id), pp.id),
parents: keep(parentsOf(pp.id)), parents: parentsMap.get(pp.id) ?? [],
children: keep(childrenOf(pp.id)), children: childrenMap.get(pp.id) ?? [],
}, },
}; };
}); });
const f3 = await import("family-chart"); const f3 = await import("family-chart");
if (cancelled || !containerRef.current) return; if (cancelled || !containerRef.current) return;
containerRef.current.innerHTML = ""; try {
const chart = f3.createChart(containerRef.current, data); containerRef.current.innerHTML = "";
chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]); const chart = f3.createChart(containerRef.current, data);
if (mode === "portrait") chart.setOrientationVertical(); chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]);
else chart.setOrientationHorizontal(); if (mode === "portrait") chart.setOrientationVertical();
// Show enough generations that a recenter reveals grandparents + children. else chart.setOrientationHorizontal();
chart.setAncestryDepth?.(3); // Show enough generations that a recenter reveals grandparents + children.
chart.setProgenyDepth?.(2); chart.setAncestryDepth?.(3);
// Default card click recenters the whole hourglass; sync focus for the chart.setProgenyDepth?.(2);
// "Open profile" link after every (re)build. // Default card click recenters the whole hourglass; sync focus for the
chart.setAfterUpdate?.(() => { // "Open profile" link after every (re)build.
const md = chart.getMainDatum?.(); chart.setAfterUpdate?.(() => {
const id = md?.id ?? md?.data?.id; const md = chart.getMainDatum?.();
if (id) setFocusId(id); const id = md?.id ?? md?.data?.id;
}); if (id) setFocusId(id);
chartRef.current = chart; });
if (focusId) chart.updateMainId(focusId); chartRef.current = chart;
chart.updateTree({ initial: true }); if (focusId) chart.updateMainId(focusId);
chart.updateTree({ initial: true });
setRenderNote(
dropped > 0
? `Skipped ${dropped} conflicting parent link${dropped === 1 ? "" : "s"} (a person can't be their own ancestor). Open the people involved to fix the relationship.`
: null,
);
} catch (err) {
// Never leave a blank canvas — show a message and let them fix via the
// Family view / person pages.
console.error("tree render failed", err);
if (containerRef.current) containerRef.current.innerHTML = "";
setRenderNote(
"The tree couldn't be drawn — a relationship may be conflicting. Use the Family view to open the affected people and check their parents/children.",
);
}
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
@@ -242,6 +293,12 @@ export default function TreePage() {
)} )}
{status === "error" && <p className="text-[var(--muted)]">Could not render the tree.</p>} {status === "error" && <p className="text-[var(--muted)]">Could not render the tree.</p>}
{renderNote && mode !== "fan" && (
<p className="rounded-md border border-bronze/40 bg-bronze/[0.06] px-3 py-2 text-sm text-bronze">
{renderNote}
</p>
)}
{status === "ready" && mode === "fan" && focusId ? ( {status === "ready" && mode === "fan" && focusId ? (
<div className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-4"> <div className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-4">
<FanChart <FanChart
+13 -1
View File
@@ -9,6 +9,7 @@ import {
LogOut, LogOut,
Network, Network,
Settings, Settings,
Sparkles,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@@ -17,6 +18,7 @@ import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api/client"; import { api } from "@/lib/api/client";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ThemeToggle } from "@/components/theme-toggle";
export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) { export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname(); const pathname = usePathname();
@@ -127,6 +129,12 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
icon={ArrowDownUp} icon={ArrowDownUp}
active={pathname.startsWith(`/trees/${treeId}/gedcom`)} active={pathname.startsWith(`/trees/${treeId}/gedcom`)}
/> />
<Item
href={`/trees/${treeId}/cleanup`}
label="Cleanup"
icon={Sparkles}
active={pathname.startsWith(`/trees/${treeId}/cleanup`)}
/>
<Item <Item
href={`/trees/${treeId}/recovery`} href={`/trees/${treeId}/recovery`}
label="Recovery" label="Recovery"
@@ -136,7 +144,11 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
</div> </div>
)} )}
<div ref={menuRef} className="relative mt-auto"> <div className="mt-auto">
<ThemeToggle />
</div>
<div ref={menuRef} className="relative">
{menuOpen && ( {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"> <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 <Link
+1 -1
View File
@@ -79,7 +79,7 @@ export function FanChart({
<path <path
d={sector(r0 + 1, r1 - 1, a0 + 0.004, a1 - 0.004)} d={sector(r0 + 1, r1 - 1, a0 + 0.004, a1 - 0.004)}
fill={id ? "var(--surface)" : "transparent"} fill={id ? "var(--surface)" : "transparent"}
stroke="var(--border)" stroke="var(--line)"
/> />
{id && ( {id && (
<text <text
+19 -1
View File
@@ -16,12 +16,15 @@ export function PersonCombobox({
people, people,
value, value,
onChange, onChange,
onCreate,
placeholder = "Search for a person…", placeholder = "Search for a person…",
className, className,
}: { }: {
people: Person[]; people: Person[];
value: string; value: string;
onChange: (id: string) => void; onChange: (id: string) => void;
/** When set, the dropdown offers a "Create '<typed name>'" action. */
onCreate?: (name: string) => void;
placeholder?: string; placeholder?: string;
className?: string; className?: string;
}) { }) {
@@ -77,7 +80,7 @@ export function PersonCombobox({
if (value) onChange(""); // typing invalidates the prior pick 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"> <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) => ( {matches.map((p) => (
<li key={p.id}> <li key={p.id}>
@@ -96,6 +99,21 @@ export function PersonCombobox({
</button> </button>
</li> </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> </ul>
)} )}
</div> </div>
+54
View File
@@ -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>
);
}
+415
View File
@@ -679,6 +679,96 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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/guess": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Guess Gender
* @description Best-guess sex from first names (bundled dictionary) for people missing it.
*/
get: operations["guess_gender_api_v1_trees__tree_id__cleanup_gender_guess_get"];
put?: never;
post?: never;
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 type webhooks = Record<string, never>;
export interface components { export interface components {
@@ -713,6 +803,11 @@ export interface components {
/** File */ /** File */
file: string; 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 */
Body_upload_media_api_v1_trees__tree_id__media_post: { Body_upload_media_api_v1_trees__tree_id__media_post: {
/** File */ /** File */
@@ -796,6 +891,28 @@ export interface components {
detail?: string | null; detail?: string | null;
confidence?: components["schemas"]["CitationConfidence"] | 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 */
DuplicateMatch: { DuplicateMatch: {
/** Xref */ /** Xref */
@@ -905,6 +1022,33 @@ export interface components {
/** Notes */ /** Notes */
notes?: string | null; 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 */
HTTPValidationError: { HTTPValidationError: {
/** Detail */ /** Detail */
@@ -984,6 +1128,11 @@ export interface components {
/** Source Id */ /** Source Id */
source_id?: string | null; source_id?: string | null;
}; };
/** NameApply */
NameApply: {
/** Edits */
edits: components["schemas"]["NameEdit"][];
};
/** NameCreate */ /** NameCreate */
NameCreate: { NameCreate: {
/** /**
@@ -1007,6 +1156,37 @@ export interface components {
*/ */
is_primary?: boolean; 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 */
NameRead: { NameRead: {
/** /**
@@ -3218,4 +3398,239 @@ 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"];
};
};
};
};
guess_gender_api_v1_trees__tree_id__cleanup_gender_guess_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"]["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"];
};
};
};
};
} }
+590
View File
@@ -2701,6 +2701,370 @@
} }
} }
} }
},
"/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/guess": {
"get": {
"tags": [
"cleanup"
],
"summary": "Guess Gender",
"description": "Best-guess sex from first names (bundled dictionary) for people missing it.",
"operationId": "guess_gender_api_v1_trees__tree_id__cleanup_gender_guess_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/GenderProposal"
},
"title": "Response Guess Gender Api V1 Trees Tree Id Cleanup Gender Guess Get"
}
}
}
},
"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": { "components": {
@@ -2770,6 +3134,20 @@
], ],
"title": "Body_preview_gedcom_api_v1_trees__tree_id__gedcom_preview_post" "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": { "Body_upload_media_api_v1_trees__tree_id__media_post": {
"properties": { "properties": {
"file": { "file": {
@@ -3091,6 +3469,60 @@
"type": "object", "type": "object",
"title": "CitationUpdate" "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": { "DuplicateMatch": {
"properties": { "properties": {
"xref": { "xref": {
@@ -3526,6 +3958,65 @@
"type": "object", "type": "object",
"title": "EventUpdate" "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": { "HTTPValidationError": {
"properties": { "properties": {
"detail": { "detail": {
@@ -3774,6 +4265,22 @@
"type": "object", "type": "object",
"title": "MediaUpdate" "title": "MediaUpdate"
}, },
"NameApply": {
"properties": {
"edits": {
"items": {
"$ref": "#/components/schemas/NameEdit"
},
"type": "array",
"title": "Edits"
}
},
"type": "object",
"required": [
"edits"
],
"title": "NameApply"
},
"NameCreate": { "NameCreate": {
"properties": { "properties": {
"name_type": { "name_type": {
@@ -3845,6 +4352,89 @@
"type": "object", "type": "object",
"title": "NameCreate" "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": { "NameRead": {
"properties": { "properties": {
"id": { "id": {