Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05d2773e25 | |||
| 768c68cbe0 | |||
| 7d6fbce87e | |||
| 12ba0a0fb6 | |||
| 150d69e5ac | |||
| 053ce357ac | |||
| 269cae556f | |||
| 0df44e7e59 | |||
| 7a5c5f2882 | |||
| 20c7fbd8d6 | |||
| b8405ced07 |
@@ -67,6 +67,16 @@ async def guess_gender(
|
||||
return [GenderProposal(**r) for r in rows]
|
||||
|
||||
|
||||
@router.get("/{tree_id}/cleanup/gender/from-spouse", response_model=list[GenderProposal])
|
||||
async def guess_gender_from_spouse(
|
||||
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||
) -> list[GenderProposal]:
|
||||
"""Infer a missing sex from a partner whose sex is set (opposite-sex couple)."""
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
rows = await cleanup_service.guess_gender_by_spouse(session, actor=current, tree=tree)
|
||||
return [GenderProposal(**r) for r in rows]
|
||||
|
||||
|
||||
@router.post("/{tree_id}/cleanup/gender", response_model=CleanupResult)
|
||||
async def apply_gender(
|
||||
tree_id: uuid.UUID, data: GenderApply, session: SessionDep, current: CurrentUser
|
||||
|
||||
@@ -12,8 +12,10 @@ import uuid
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.enums import RelationshipType
|
||||
from app.models.event import Event
|
||||
from app.models.person import Name, Person
|
||||
from app.models.relationship import Relationship
|
||||
from app.models.tree import Tree
|
||||
from app.models.user import User
|
||||
from app.services import gedcom, privacy
|
||||
@@ -182,6 +184,52 @@ async def guess_gender_by_name(
|
||||
return out
|
||||
|
||||
|
||||
async def guess_gender_by_spouse(
|
||||
session: AsyncSession, *, actor: User, tree: Tree
|
||||
) -> list[dict]:
|
||||
"""Infer the sex of a person who has none set from a partner whose sex IS set
|
||||
(couples in a tree are opposite-sex in practice — e.g. a confirmed-male
|
||||
husband implies a female wife). People whose known partners disagree are
|
||||
ambiguous and skipped; the result is a preview to review, not an auto-write."""
|
||||
await _require_editor(session, actor=actor, tree=tree)
|
||||
persons = await _persons(session, tree.id)
|
||||
gender = {p.id: p.gender for p in persons}
|
||||
names = await _primary_name_by_person(session, tree.id)
|
||||
rels = (
|
||||
await session.execute(
|
||||
select(Relationship).where(
|
||||
Relationship.tree_id == tree.id,
|
||||
Relationship.deleted_at.is_(None),
|
||||
Relationship.type == RelationshipType.partnership,
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
opp = {"male": "female", "female": "male"}
|
||||
proposals: dict[uuid.UUID, set[str]] = {}
|
||||
for r in rels:
|
||||
for me_id, other_id in (
|
||||
(r.person_from_id, r.person_to_id),
|
||||
(r.person_to_id, r.person_from_id),
|
||||
):
|
||||
if gender.get(me_id):
|
||||
continue # this person already has a sex
|
||||
other_sex = str(gender.get(other_id) or "")
|
||||
if other_sex in opp:
|
||||
proposals.setdefault(me_id, set()).add(opp[other_sex])
|
||||
out: list[dict] = []
|
||||
for pid, sexes in proposals.items():
|
||||
if len(sexes) != 1:
|
||||
continue # partners of differing known sex → ambiguous
|
||||
nm = names.get(pid)
|
||||
if nm is None:
|
||||
continue
|
||||
out.append(
|
||||
{"person_id": str(pid), "name": _display(nm), "proposed_gender": next(iter(sexes))}
|
||||
)
|
||||
out.sort(key=lambda r: r["name"])
|
||||
return out
|
||||
|
||||
|
||||
async def apply_gender(
|
||||
session: AsyncSession, *, actor: User, tree: Tree, updates: list[dict]
|
||||
) -> int:
|
||||
|
||||
@@ -51,6 +51,44 @@ async def test_deceased_preview_and_apply(client):
|
||||
assert old not in [r["person_id"] for r in prev2]
|
||||
|
||||
|
||||
async def test_gender_from_spouse_preview_and_apply(client):
|
||||
h, tid = await _tree(client, "cl-spouse@example.com")
|
||||
husband = (
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tid}/persons",
|
||||
json={"given": "Otto", "surname": "Frey", "gender": "male"},
|
||||
headers=h,
|
||||
)
|
||||
).json()["id"]
|
||||
wife = await _person(client, h, tid, "Bea", "Frey") # no sex
|
||||
loner = await _person(client, h, tid, "Nyx", "Alone") # no sex, no partner
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tid}/relationships",
|
||||
json={"type": "partnership", "person_from_id": husband, "person_to_id": wife},
|
||||
headers=h,
|
||||
)
|
||||
|
||||
prev = (await client.get(f"/api/v1/trees/{tid}/cleanup/gender/from-spouse", headers=h)).json()
|
||||
by = {r["person_id"]: r["proposed_gender"] for r in prev}
|
||||
assert by.get(wife) == "female" # opposite of the confirmed-male husband
|
||||
assert loner not in by # no known-sex partner → not proposed
|
||||
assert husband not in by # already has a sex
|
||||
|
||||
r = await client.post(
|
||||
f"/api/v1/trees/{tid}/cleanup/gender",
|
||||
json={"updates": [{"person_id": wife, "gender": "female"}]},
|
||||
headers=h,
|
||||
)
|
||||
assert r.status_code == 200 and r.json()["updated"] == 1
|
||||
assert (
|
||||
await client.get(f"/api/v1/trees/{tid}/persons/{wife}", headers=h)
|
||||
).json()["gender"] == "female"
|
||||
|
||||
# Once set, the wife is no longer proposed.
|
||||
prev2 = (await client.get(f"/api/v1/trees/{tid}/cleanup/gender/from-spouse", headers=h)).json()
|
||||
assert wife not in [r["person_id"] for r in prev2]
|
||||
|
||||
|
||||
GED = b"""0 HEAD
|
||||
0 @I1@ INDI
|
||||
1 NAME Josias /Moody/
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { PublicHeader } from "@/components/public-header";
|
||||
|
||||
export default function ExploreLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<PublicHeader />
|
||||
<main className="mx-auto max-w-5xl px-4 py-8">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import type { components } from "@/lib/api/schema";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type Tree = components["schemas"]["PublicTreeRead"];
|
||||
|
||||
// Public directory of trees. The backend returns `public` to everyone and adds
|
||||
// `site_members` trees when the request carries a valid session — so signed-in
|
||||
// users see more here without any client-side branching.
|
||||
export default function ExplorePage() {
|
||||
const [trees, setTrees] = useState<Tree[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const q = search.trim();
|
||||
const t = setTimeout(async () => {
|
||||
const { data } = await api.GET("/api/v1/public/trees", {
|
||||
params: { query: q ? { q } : {} },
|
||||
});
|
||||
setTrees(data ?? []);
|
||||
setReady(true);
|
||||
}, 200);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="font-serif text-3xl font-semibold">Explore public trees</h1>
|
||||
<p className="mt-1 text-[var(--muted)]">
|
||||
Browse family trees shared on this site. Living people are always hidden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
className="w-72"
|
||||
placeholder="Search trees by name…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
|
||||
{!ready ? (
|
||||
<p className="text-[var(--muted)]">Loading…</p>
|
||||
) : trees.length === 0 ? (
|
||||
<p className="text-[var(--muted)]">No public trees{search.trim() ? " match that search" : " yet"}.</p>
|
||||
) : (
|
||||
<ul className="grid gap-3 sm:grid-cols-2">
|
||||
{trees.map((t) => (
|
||||
<li key={t.id}>
|
||||
<Link href={`/p/${t.id}`}>
|
||||
<Card className="h-full transition-colors hover:border-bronze/50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-medium">{t.name}</span>
|
||||
<span className="shrink-0 text-xs uppercase tracking-wide text-[var(--muted)]">
|
||||
{t.visibility === "site_members" ? "Members" : "Public"}
|
||||
</span>
|
||||
</div>
|
||||
{t.description && (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-[var(--muted)]">{t.description}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import type { components } from "@/lib/api/schema";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PublicTreeChart } from "@/components/public-tree-chart";
|
||||
|
||||
type Person = components["schemas"]["PersonRead"];
|
||||
type Event = components["schemas"]["EventRead"];
|
||||
type Relationship = components["schemas"]["RelationshipRead"];
|
||||
type Tree = components["schemas"]["PublicTreeRead"];
|
||||
|
||||
// Public, no-login view of a tree. Everything here is already redacted by the
|
||||
// /api/v1/public surface (living people show as "Living person").
|
||||
export default function PublicTreePage() {
|
||||
const { treeId } = useParams<{ treeId: string }>();
|
||||
const router = useRouter();
|
||||
const [tree, setTree] = useState<Tree | null>(null);
|
||||
const [people, setPeople] = useState<Person[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [rels, setRels] = useState<Relationship[]>([]);
|
||||
const [focusId, setFocusId] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "notfound">("loading");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const t = await api.GET("/api/v1/public/trees/{tree_id}", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (!t.data) {
|
||||
setStatus("notfound");
|
||||
return;
|
||||
}
|
||||
const [p, e, r] = await Promise.all([
|
||||
api.GET("/api/v1/public/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
|
||||
api.GET("/api/v1/public/trees/{tree_id}/events", { params: { path: { tree_id: treeId } } }),
|
||||
api.GET("/api/v1/public/trees/{tree_id}/relationships", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
}),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
const ppl = p.data ?? [];
|
||||
const home = t.data.home_person_id;
|
||||
const homeId = home && ppl.some((x) => x.id === home) ? home : null;
|
||||
setTree(t.data);
|
||||
setPeople(ppl);
|
||||
setEvents(e.data ?? []);
|
||||
setRels(r.data ?? []);
|
||||
setFocusId((cur) => cur ?? homeId ?? ppl[0]?.id ?? null);
|
||||
setStatus("ready");
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [treeId]);
|
||||
|
||||
const years = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
const yr = (e: Event) => (e.date_start ? e.date_start.slice(0, 4) : e.date_value ?? "");
|
||||
for (const p of people) {
|
||||
const b = events.find((e) => e.person_id === p.id && e.event_type === "birth");
|
||||
const d = events.find((e) => e.person_id === p.id && e.event_type === "death");
|
||||
const parts = [b ? yr(b) : "", d ? yr(d) : ""];
|
||||
if (parts[0] || parts[1]) m.set(p.id, `${parts[0]}–${parts[1]}`.replace(/^–$/, ""));
|
||||
}
|
||||
return m;
|
||||
}, [people, events]);
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const sorted = [...people].sort((a, b) =>
|
||||
(a.primary_name ?? "").localeCompare(b.primary_name ?? ""),
|
||||
);
|
||||
return (q ? sorted.filter((p) => (p.primary_name ?? "").toLowerCase().includes(q)) : sorted).slice(
|
||||
0,
|
||||
300,
|
||||
);
|
||||
}, [people, search]);
|
||||
|
||||
if (status === "loading") return <p className="text-[var(--muted)]">Loading…</p>;
|
||||
if (status === "notfound")
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold">Not available</h1>
|
||||
<p className="text-[var(--muted)]">
|
||||
This tree isn’t public, or the link is wrong.{" "}
|
||||
<Link href="/login" className="text-bronze hover:underline">
|
||||
Sign in
|
||||
</Link>{" "}
|
||||
if it’s yours.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="mx-auto w-full max-w-5xl">
|
||||
<h1 className="font-serif text-3xl font-semibold">{tree?.name}</h1>
|
||||
{tree?.description && <p className="mt-1 text-[var(--muted)]">{tree.description}</p>}
|
||||
<p className="mt-1 text-sm text-[var(--muted)]">
|
||||
{people.length} {people.length === 1 ? "person" : "people"} · living people are hidden
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Chart spans the full canvas (the layout removes max-width for /p/<id>). */}
|
||||
{focusId && people.length > 0 && (
|
||||
<PublicTreeChart
|
||||
people={people}
|
||||
rels={rels}
|
||||
events={events}
|
||||
focusId={focusId}
|
||||
onFocus={setFocusId}
|
||||
onOpen={(id) => router.push(`/p/${treeId}/persons/${id}`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mx-auto w-full max-w-5xl space-y-3">
|
||||
<h2 className="font-serif text-base font-semibold">All people</h2>
|
||||
<Input
|
||||
className="w-72"
|
||||
placeholder="Search people…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="mx-auto w-full max-w-5xl overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
{shown.length === 0 ? (
|
||||
<div className="px-4 py-6 text-sm text-[var(--muted)]">No matches.</div>
|
||||
) : (
|
||||
shown.map((p, i) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
href={`/p/${treeId}/persons/${p.id}`}
|
||||
className={`flex items-center justify-between gap-3 px-4 py-2.5 text-sm transition-colors hover:bg-bronze/[0.05] ${
|
||||
i > 0 ? "border-t border-[var(--border)]" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="truncate font-medium">{p.primary_name ?? "Unnamed"}</span>
|
||||
<span className="shrink-0 text-xs text-[var(--muted)]">{years.get(p.id) ?? ""}</span>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import type { components } from "@/lib/api/schema";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
type Person = components["schemas"]["PersonRead"];
|
||||
type Name = components["schemas"]["NameRead"];
|
||||
type Event = components["schemas"]["EventRead"];
|
||||
type Relationship = components["schemas"]["RelationshipRead"];
|
||||
|
||||
// Public, no-login person view. The /api/v1/public surface returns only what an
|
||||
// anonymous viewer may see: deceased people in full, living people redacted.
|
||||
export default function PublicPersonPage() {
|
||||
const { treeId, personId } = useParams<{ treeId: string; personId: string }>();
|
||||
const [person, setPerson] = useState<Person | null>(null);
|
||||
const [names, setNames] = useState<Name[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [people, setPeople] = useState<Person[]>([]);
|
||||
const [rels, setRels] = useState<Relationship[]>([]);
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "notfound">("loading");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const pr = await api.GET("/api/v1/public/trees/{tree_id}/persons/{person_id}", {
|
||||
params: { path: { tree_id: treeId, person_id: personId } },
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (!pr.data) {
|
||||
setStatus("notfound");
|
||||
return;
|
||||
}
|
||||
const [nm, ev, ppl, rl] = await Promise.all([
|
||||
api.GET("/api/v1/public/trees/{tree_id}/persons/{person_id}/names", {
|
||||
params: { path: { tree_id: treeId, person_id: personId } },
|
||||
}),
|
||||
api.GET("/api/v1/public/trees/{tree_id}/persons/{person_id}/events", {
|
||||
params: { path: { tree_id: treeId, person_id: personId } },
|
||||
}),
|
||||
api.GET("/api/v1/public/trees/{tree_id}/persons", { params: { path: { tree_id: treeId } } }),
|
||||
api.GET("/api/v1/public/trees/{tree_id}/relationships", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
}),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setPerson(pr.data);
|
||||
setNames(nm.data ?? []);
|
||||
setEvents(ev.data ?? []);
|
||||
setPeople(ppl.data ?? []);
|
||||
setRels(rl.data ?? []);
|
||||
setStatus("ready");
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [treeId, personId]);
|
||||
|
||||
const nameOf = useMemo(
|
||||
() => (id: string) => people.find((p) => p.id === id)?.primary_name ?? "Unknown",
|
||||
[people],
|
||||
);
|
||||
|
||||
const parents = rels.filter((r) => r.type === "parent_child" && r.person_to_id === personId);
|
||||
const children = rels.filter((r) => r.type === "parent_child" && r.person_from_id === personId);
|
||||
const partners = rels.filter(
|
||||
(r) => r.type === "partnership" && (r.person_from_id === personId || r.person_to_id === personId),
|
||||
);
|
||||
const otherEnd = (r: Relationship) =>
|
||||
r.person_from_id === personId ? r.person_to_id : r.person_from_id;
|
||||
|
||||
if (status === "loading") return <p className="text-[var(--muted)]">Loading…</p>;
|
||||
if (status === "notfound")
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold">Not available</h1>
|
||||
<p className="text-[var(--muted)]">This person isn’t publicly visible.</p>
|
||||
<Link href={`/p/${treeId}`} className="text-sm text-bronze hover:underline">
|
||||
← Back to the tree
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
const relGroup = (label: string, items: Relationship[]) =>
|
||||
items.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-bronze">{label}</h3>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{items.map((r) => (
|
||||
<li key={r.id} className="text-sm">
|
||||
<Link
|
||||
href={`/p/${treeId}/persons/${otherEnd(r)}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{nameOf(otherEnd(r))}
|
||||
{r.qualifier ? <span className="text-[var(--muted)]"> · {r.qualifier}</span> : null}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link href={`/p/${treeId}`} className="text-sm text-[var(--muted)] hover:underline">
|
||||
← Back to the tree
|
||||
</Link>
|
||||
|
||||
<h1 className="font-serif text-3xl font-semibold">{person?.primary_name ?? "Unnamed"}</h1>
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
{events.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="space-y-2 p-5">
|
||||
<h2 className="font-serif text-base font-semibold">Events</h2>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{events.map((e) => (
|
||||
<li key={e.id} className="flex justify-between gap-3">
|
||||
<span className="capitalize">{e.event_type}</span>
|
||||
<span className="text-[var(--muted)]">
|
||||
{e.date_value ?? e.date_start ?? ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{names.length > 1 && (
|
||||
<Card>
|
||||
<CardContent className="space-y-2 p-5">
|
||||
<h2 className="font-serif text-base font-semibold">Names</h2>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{names.map((n) => (
|
||||
<li key={n.id} className="flex justify-between gap-3">
|
||||
<span>{[n.given, n.surname].filter(Boolean).join(" ") || "—"}</span>
|
||||
<span className="text-[var(--muted)]">{n.name_type}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(parents.length > 0 || children.length > 0 || partners.length > 0) && (
|
||||
<Card>
|
||||
<CardContent className="grid gap-4 p-5 sm:grid-cols-3">
|
||||
{relGroup("Parents", parents)}
|
||||
{relGroup("Partners", partners)}
|
||||
{relGroup("Children", children)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { PublicHeader } from "@/components/public-header";
|
||||
|
||||
// Public viewing surface — no auth, no app sidebar. The tree page (/p/<id>)
|
||||
// wants the whole canvas like the member tree view; the person detail page
|
||||
// reads better in a centered column.
|
||||
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const fullWidth = /^\/p\/[^/]+$/.test(pathname);
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<PublicHeader />
|
||||
<main className={fullWidth ? "w-full px-4 py-6 md:px-6" : "mx-auto max-w-5xl px-4 py-8"}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,9 @@ export default function Home() {
|
||||
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-7 w-auto" />
|
||||
</Link>
|
||||
<nav className="flex items-center gap-5 text-sm">
|
||||
<Link href="/explore" className="text-[var(--muted)] hover:text-[var(--foreground)]">
|
||||
Explore
|
||||
</Link>
|
||||
<Link href="/trees" className="text-[var(--muted)] hover:text-[var(--foreground)]">
|
||||
Trees
|
||||
</Link>
|
||||
@@ -66,6 +69,11 @@ export default function Home() {
|
||||
Sign in
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/explore">
|
||||
<Button size="lg" variant="ghost">
|
||||
Explore public trees
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
// Allow crawlers on the public surface; keep the authenticated app out of the
|
||||
// index. (Per-tree noindex for `unlisted`/`site_members` pages needs server
|
||||
// rendering — tracked as a follow-up; those trees aren't linked or listed, so
|
||||
// they aren't discoverable by crawl in the meantime.)
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: ["/", "/p/"],
|
||||
disallow: ["/trees", "/settings", "/import", "/login", "/register"],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -109,6 +109,15 @@ export default function CleanupPage() {
|
||||
setGenSel(new Set((data ?? []).map((g) => g.person_id)));
|
||||
}
|
||||
|
||||
async function guessGenderFromSpouse() {
|
||||
setGenMsg(null);
|
||||
const { data } = await api.GET("/api/v1/trees/{tree_id}/cleanup/gender/from-spouse", {
|
||||
params: { path: { tree_id: treeId } },
|
||||
});
|
||||
setGender(data ?? []);
|
||||
setGenSel(new Set((data ?? []).map((g) => g.person_id)));
|
||||
}
|
||||
|
||||
async function applyGender() {
|
||||
const updates = (gender ?? [])
|
||||
.filter((g) => genSel.has(g.person_id))
|
||||
@@ -246,10 +255,15 @@ export default function CleanupPage() {
|
||||
<Button variant="outline" onClick={guessGender}>
|
||||
Guess from first name
|
||||
</Button>
|
||||
<Button variant="outline" onClick={guessGenderFromSpouse}>
|
||||
Infer from spouse
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--muted)]">
|
||||
“Guess from first name” uses a built-in name dictionary for people with no sex set;
|
||||
ambiguous names (Marion, Frances, …) are left for you to decide.
|
||||
“Guess from first name” uses a built-in name dictionary for people with no sex set.
|
||||
“Infer from spouse” sets the opposite sex for an unset partner of someone whose sex is
|
||||
known (e.g. a confirmed-male husband ⇒ a female wife) — review before applying, since
|
||||
it assumes opposite-sex couples.
|
||||
</p>
|
||||
{genMsg && <p className="text-sm text-bronze">{genMsg}</p>}
|
||||
{gender && (
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { components } from "@/lib/api/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FanChart } from "@/components/fan-chart";
|
||||
import { DepthControl } from "@/components/depth-control";
|
||||
|
||||
type Person = components["schemas"]["PersonRead"];
|
||||
type Relationship = components["schemas"]["RelationshipRead"];
|
||||
@@ -280,67 +281,6 @@ export default function TreePage() {
|
||||
</button>
|
||||
);
|
||||
|
||||
// Slider + number stepper + "All" for one generation direction.
|
||||
const DepthControl = ({
|
||||
label,
|
||||
icon,
|
||||
value,
|
||||
all,
|
||||
onValue,
|
||||
onAll,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
icon: string;
|
||||
value: number;
|
||||
all: boolean;
|
||||
onValue: (v: number) => void;
|
||||
onAll: (b: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<div className={`flex items-center gap-2 ${disabled ? "opacity-40" : ""}`}>
|
||||
<span className="flex w-24 items-center gap-1 text-xs text-[var(--muted)]">
|
||||
<span aria-hidden>{icon}</span> {label}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={12}
|
||||
step={1}
|
||||
value={all ? 12 : value}
|
||||
disabled={disabled || all}
|
||||
onChange={(e) => onValue(Number(e.target.value))}
|
||||
className="w-28 accent-bronze"
|
||||
aria-label={`${label} generations`}
|
||||
/>
|
||||
{all ? (
|
||||
<span className="w-10 text-center text-sm font-medium text-bronze">All</span>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={99}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onValue(Math.max(0, Math.min(99, Number(e.target.value) || 0)))}
|
||||
className="h-7 w-12 rounded-md border border-[var(--border)] bg-[var(--surface)] px-1 text-center text-sm"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onAll(!all)}
|
||||
title={`Show all ${label.toLowerCase()}`}
|
||||
className={`rounded-md px-2 py-1 text-xs transition-colors ${
|
||||
all
|
||||
? "bg-bronze text-paper"
|
||||
: "border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -109,6 +109,17 @@ export default function TreesPage() {
|
||||
<option value="unlisted">Unlisted</option>
|
||||
<option value="public">Public</option>
|
||||
</select>
|
||||
{tree.visibility && tree.visibility !== "private" && (
|
||||
<a
|
||||
href={`/p/${tree.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Open the public, no-login view"
|
||||
className="shrink-0 text-xs text-bronze hover:underline"
|
||||
>
|
||||
Public page ↗
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => remove(tree.id)}
|
||||
className="text-[var(--muted)] hover:text-bronze"
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Archive,
|
||||
ArrowDownUp,
|
||||
BookText,
|
||||
Compass,
|
||||
FolderTree,
|
||||
Image as ImageIcon,
|
||||
LogOut,
|
||||
@@ -92,6 +93,7 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
</Link>
|
||||
|
||||
<Item href="/trees" label="Trees" icon={FolderTree} active={pathname === "/trees"} />
|
||||
<Item href="/explore" label="Explore" icon={Compass} active={pathname === "/explore"} />
|
||||
<Item href="/import" label="Import" icon={ArrowDownUp} active={pathname === "/import"} />
|
||||
|
||||
{treeId && (
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
// Slider + number stepper + "All" toggle for one generation direction
|
||||
// (ancestors or descendants). Shared by the member and public tree views.
|
||||
export function DepthControl({
|
||||
label,
|
||||
icon,
|
||||
value,
|
||||
all,
|
||||
onValue,
|
||||
onAll,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
icon: string;
|
||||
value: number;
|
||||
all: boolean;
|
||||
onValue: (v: number) => void;
|
||||
onAll: (b: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${disabled ? "opacity-40" : ""}`}>
|
||||
<span className="flex w-24 items-center gap-1 text-xs text-[var(--muted)]">
|
||||
<span aria-hidden>{icon}</span> {label}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={12}
|
||||
step={1}
|
||||
value={all ? 12 : value}
|
||||
disabled={disabled || all}
|
||||
onChange={(e) => onValue(Number(e.target.value))}
|
||||
className="w-28 accent-bronze"
|
||||
aria-label={`${label} generations`}
|
||||
/>
|
||||
{all ? (
|
||||
<span className="w-10 text-center text-sm font-medium text-bronze">All</span>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={99}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onValue(Math.max(0, Math.min(99, Number(e.target.value) || 0)))}
|
||||
className="h-7 w-12 rounded-md border border-[var(--border)] bg-[var(--surface)] px-1 text-center text-sm"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onAll(!all)}
|
||||
title={`Show all ${label.toLowerCase()}`}
|
||||
className={`rounded-md px-2 py-1 text-xs transition-colors ${
|
||||
all
|
||||
? "bg-bronze text-paper"
|
||||
: "border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Link from "next/link";
|
||||
|
||||
// Slim header for the public (no-auth) surface: /p/* and /explore.
|
||||
export function PublicHeader() {
|
||||
return (
|
||||
<header className="border-b border-[var(--border)]">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
|
||||
<Link href="/" aria-label="Provenance — home" className="flex items-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/provenance-logo-plain.svg" alt="Provenance" className="h-6 w-auto" />
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4 text-sm">
|
||||
<Link href="/explore" className="text-[var(--muted)] hover:text-[var(--foreground)]">
|
||||
Explore
|
||||
</Link>
|
||||
<Link href="/login" className="text-bronze hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client";
|
||||
|
||||
// Vendored family-chart styles (the package blocks the CSS subpath export).
|
||||
import "../app/trees/[id]/tree/chart.css";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { components } from "@/lib/api/schema";
|
||||
import { DepthControl } from "@/components/depth-control";
|
||||
|
||||
type Person = components["schemas"]["PersonRead"];
|
||||
type Relationship = components["schemas"]["RelationshipRead"];
|
||||
type Event = components["schemas"]["EventRead"];
|
||||
|
||||
function splitName(name: string | null | undefined): [string, string] {
|
||||
const t = (name ?? "").trim().split(/\s+/).filter(Boolean);
|
||||
if (t.length <= 1) return [name ?? "", ""];
|
||||
return [t.slice(0, -1).join(" "), t[t.length - 1]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only family-chart hourglass for the public surface. Same renderer the
|
||||
* member tree view uses (incl. the cycle-sanitisation that keeps a bad graph
|
||||
* from blowing the stack), fed by already-redacted public data. Clicking a card
|
||||
* recenters; `onOpen` links out to the person's public page.
|
||||
*/
|
||||
export function PublicTreeChart({
|
||||
people,
|
||||
rels,
|
||||
events,
|
||||
focusId,
|
||||
onFocus,
|
||||
onOpen,
|
||||
}: {
|
||||
people: Person[];
|
||||
rels: Relationship[];
|
||||
events: Event[];
|
||||
focusId: string | null;
|
||||
onFocus: (id: string) => void;
|
||||
onOpen?: (id: string) => void;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const chartRef = useRef<any>(null);
|
||||
|
||||
// Generations to show around the focus, each settable (or "all"). ALL_DEPTH is
|
||||
// a number bigger than any real lineage; the chart only renders real people.
|
||||
const [ancDepth, setAncDepth] = useState(3);
|
||||
const [progDepth, setProgDepth] = useState(2);
|
||||
const [ancAll, setAncAll] = useState(false);
|
||||
const [progAll, setProgAll] = useState(false);
|
||||
const ALL_DEPTH = 100;
|
||||
const effAnc = ancAll ? ALL_DEPTH : ancDepth;
|
||||
const effProg = progAll ? ALL_DEPTH : progDepth;
|
||||
|
||||
const parentsOf = useCallback(
|
||||
(id: string) =>
|
||||
rels.filter((x) => x.type === "parent_child" && x.person_to_id === id).map((x) => x.person_from_id),
|
||||
[rels],
|
||||
);
|
||||
const partnersOf = useCallback(
|
||||
(id: string) =>
|
||||
rels
|
||||
.filter((x) => x.type === "partnership" && (x.person_from_id === id || x.person_to_id === id))
|
||||
.map((x) => (x.person_from_id === id ? x.person_to_id : x.person_from_id)),
|
||||
[rels],
|
||||
);
|
||||
const years = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const ev of events) {
|
||||
if (ev.person_id && ev.event_type === "birth" && !m.has(ev.person_id)) {
|
||||
const y = ev.date_start ? ev.date_start.slice(0, 4) : ev.date_value ?? "";
|
||||
if (y) m.set(ev.person_id, y);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}, [events]);
|
||||
const byId = useMemo(() => new Map(people.map((p) => [p.id, p])), [people]);
|
||||
const nameOf = useCallback((id: string) => byId.get(id)?.primary_name ?? "person", [byId]);
|
||||
|
||||
// Build the chart when the data changes (not on focus — recenter handles that).
|
||||
useEffect(() => {
|
||||
if (people.length === 0 || !containerRef.current) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const alive = new Set(people.map((pp) => pp.id));
|
||||
const ok = (ids: string[], self: string) =>
|
||||
[...new Set(ids)].filter((id) => alive.has(id) && id !== self);
|
||||
|
||||
const parentsMap = new Map<string, string[]>();
|
||||
const childrenMap = new Map<string, string[]>();
|
||||
const isAncestorOf = (ancestor: string, of: string): boolean => {
|
||||
const stack = [...(parentsMap.get(of) ?? [])];
|
||||
const seen = new Set<string>();
|
||||
while (stack.length) {
|
||||
const n = stack.pop()!;
|
||||
if (n === ancestor) return true;
|
||||
if (seen.has(n)) continue;
|
||||
seen.add(n);
|
||||
for (const p of parentsMap.get(n) ?? []) stack.push(p);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (const pp of people) {
|
||||
const accepted: string[] = [];
|
||||
for (const par of ok(parentsOf(pp.id), pp.id)) {
|
||||
if (isAncestorOf(pp.id, par)) continue;
|
||||
accepted.push(par);
|
||||
parentsMap.set(pp.id, accepted);
|
||||
childrenMap.set(par, [...(childrenMap.get(par) ?? []), pp.id]);
|
||||
}
|
||||
parentsMap.set(pp.id, accepted);
|
||||
}
|
||||
|
||||
const data = people.map((pp) => {
|
||||
const [fn, ln] = splitName(pp.primary_name);
|
||||
return {
|
||||
id: pp.id,
|
||||
data: {
|
||||
"first name": fn || "Unnamed",
|
||||
"last name": ln,
|
||||
birthday: years.get(pp.id) ?? "",
|
||||
gender: pp.gender === "female" ? "F" : "M",
|
||||
},
|
||||
rels: {
|
||||
spouses: ok(partnersOf(pp.id), pp.id),
|
||||
parents: parentsMap.get(pp.id) ?? [],
|
||||
children: childrenMap.get(pp.id) ?? [],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const f3 = await import("family-chart");
|
||||
if (cancelled || !containerRef.current) return;
|
||||
try {
|
||||
containerRef.current.innerHTML = "";
|
||||
const chart = f3.createChart(containerRef.current, data);
|
||||
chart.setCardHtml().setCardDisplay([["first name", "last name"], ["birthday"]]);
|
||||
chart.setOrientationHorizontal();
|
||||
chart.setAncestryDepth?.(effAnc);
|
||||
chart.setProgenyDepth?.(effProg);
|
||||
chart.setAfterUpdate?.(() => {
|
||||
const md = chart.getMainDatum?.();
|
||||
const id = md?.id ?? md?.data?.id;
|
||||
if (id) onFocus(id);
|
||||
});
|
||||
chartRef.current = chart;
|
||||
if (focusId) chart.updateMainId(focusId);
|
||||
chart.updateTree({ initial: true });
|
||||
} catch (err) {
|
||||
console.error("public tree render failed", err);
|
||||
if (containerRef.current) containerRef.current.innerHTML = "";
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [people, rels, events]);
|
||||
|
||||
// Recenter when the focus changes without rebuilding the chart.
|
||||
useEffect(() => {
|
||||
if (focusId && chartRef.current) {
|
||||
chartRef.current.updateMainId?.(focusId);
|
||||
chartRef.current.updateTree?.();
|
||||
}
|
||||
}, [focusId]);
|
||||
|
||||
// Apply depth changes to the live chart without a full rebuild.
|
||||
useEffect(() => {
|
||||
if (!chartRef.current) return;
|
||||
chartRef.current.setAncestryDepth?.(effAnc);
|
||||
chartRef.current.setProgenyDepth?.(effProg);
|
||||
chartRef.current.updateTree?.();
|
||||
}, [effAnc, effProg]);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-lg border border-[var(--border)] bg-[var(--surface)] px-4 py-2.5">
|
||||
<span className="text-sm font-medium">Generations</span>
|
||||
<DepthControl
|
||||
label="Ancestors"
|
||||
icon="↑"
|
||||
value={ancDepth}
|
||||
all={ancAll}
|
||||
onValue={(v) => {
|
||||
setAncAll(false);
|
||||
setAncDepth(v);
|
||||
}}
|
||||
onAll={setAncAll}
|
||||
/>
|
||||
<DepthControl
|
||||
label="Descendants"
|
||||
icon="↓"
|
||||
value={progDepth}
|
||||
all={progAll}
|
||||
onValue={(v) => {
|
||||
setProgAll(false);
|
||||
setProgDepth(v);
|
||||
}}
|
||||
onAll={setProgAll}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="f3 rounded-xl border border-[var(--border)]"
|
||||
style={{ width: "100%", height: "74vh", background: "var(--surface)" }}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm text-[var(--muted)]">
|
||||
Drag to pan · scroll to zoom · click a person to recenter.
|
||||
</p>
|
||||
{focusId && onOpen && (
|
||||
<button onClick={() => onOpen(focusId)} className="text-sm text-bronze hover:underline">
|
||||
Open {nameOf(focusId)} →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+51
@@ -734,6 +734,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/trees/{tree_id}/cleanup/gender/from-spouse": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Guess Gender From Spouse
|
||||
* @description Infer a missing sex from a partner whose sex is set (opposite-sex couple).
|
||||
*/
|
||||
get: operations["guess_gender_from_spouse_api_v1_trees__tree_id__cleanup_gender_from_spouse_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;
|
||||
@@ -3687,6 +3707,37 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
guess_gender_from_spouse_api_v1_trees__tree_id__cleanup_gender_from_spouse_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;
|
||||
|
||||
@@ -2915,6 +2915,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/trees/{tree_id}/cleanup/gender/from-spouse": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"cleanup"
|
||||
],
|
||||
"summary": "Guess Gender From Spouse",
|
||||
"description": "Infer a missing sex from a partner whose sex is set (opposite-sex couple).",
|
||||
"operationId": "guess_gender_from_spouse_api_v1_trees__tree_id__cleanup_gender_from_spouse_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 From Spouse Api V1 Trees Tree Id Cleanup Gender From Spouse Get"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/trees/{tree_id}/cleanup/gender": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
||||
Reference in New Issue
Block a user