5824e70895
Duplicate detection (the "merge / skip / overwrite" the user asked for):
- New POST /gedcom/preview dry-runs the file and flags incoming people that
resemble existing ones (name similarity via difflib + birth-year guard;
high/medium score). No writes.
- /gedcom/import takes default_action (new|skip|merge|overwrite) + per-xref
resolutions {xref: {action, target_id}}:
new create as a new person (current behavior)
skip link families to the existing person, copy nothing
merge attach the incoming names (as alternates), events, citations,
and notes onto the existing person
overwrite soft-delete the existing person, import the incoming one fresh
Relationship creation is deduped so a merge can't double an edge.
Richer record mapping (covers the user's repo's GEDCOM):
- Multiple NAME records honor their TYPE; _MARNM (and NICK) import as typed
alternate names — maiden stays primary, married becomes a "married" Name.
- RELI -> a "religion" event with the value in detail; OCCU/EDUC values too.
- NOTE -> person notes (and event notes); NOTE/RELI are no longer "unmapped".
- Export round-trips name TYPE.
Verified against the user's 2185-person export: 0 unmapped tags. 48 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useParams } from "next/navigation";
|
|
import { useRef, useState } from "react";
|
|
|
|
import { api } from "@/lib/api/client";
|
|
import type { components } from "@/lib/api/schema";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
type Report = { counts: Record<string, number>; unmapped_tags: string[] };
|
|
type Preview = components["schemas"]["ImportPreview"];
|
|
type Dup = components["schemas"]["DuplicateMatch"];
|
|
type Action = "new" | "skip" | "merge" | "overwrite";
|
|
|
|
const ACTIONS: { value: Action; label: string }[] = [
|
|
{ value: "new", label: "Import as new" },
|
|
{ value: "merge", label: "Merge into existing" },
|
|
{ value: "skip", label: "Skip (use existing)" },
|
|
{ value: "overwrite", label: "Overwrite existing" },
|
|
];
|
|
|
|
const fieldCls = "h-9 rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm";
|
|
|
|
export default function GedcomPage() {
|
|
const params = useParams<{ id: string }>();
|
|
const treeId = params.id;
|
|
|
|
const [target, setTarget] = useState<"new" | "this">("new");
|
|
const [newName, setNewName] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [report, setReport] = useState<Report | null>(null);
|
|
const [importedTreeId, setImportedTreeId] = useState<string | null>(null);
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Two-step dedupe flow (only when importing into an existing tree).
|
|
const [file, setFile] = useState<File | null>(null);
|
|
const [preview, setPreview] = useState<Preview | null>(null);
|
|
const [resolutions, setResolutions] = useState<Record<string, Action>>({});
|
|
|
|
function resetAll() {
|
|
setReport(null);
|
|
setImportedTreeId(null);
|
|
setPreview(null);
|
|
setFile(null);
|
|
setResolutions({});
|
|
}
|
|
|
|
async function postImport(
|
|
tid: string,
|
|
f: File,
|
|
opts?: { resolutions?: string; defaultAction?: Action },
|
|
) {
|
|
const fd = new FormData();
|
|
fd.append("file", f);
|
|
if (opts?.defaultAction) fd.append("default_action", opts.defaultAction);
|
|
if (opts?.resolutions) fd.append("resolutions", opts.resolutions);
|
|
const resp = await fetch(`/api/v1/trees/${tid}/gedcom/import`, {
|
|
method: "POST",
|
|
body: fd,
|
|
credentials: "include",
|
|
});
|
|
if (resp.ok) {
|
|
setReport(await resp.json());
|
|
setImportedTreeId(tid);
|
|
}
|
|
}
|
|
|
|
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const f = e.target.files?.[0];
|
|
if (fileRef.current) fileRef.current.value = "";
|
|
if (!f) return;
|
|
setBusy(true);
|
|
resetAll();
|
|
|
|
if (target === "new") {
|
|
// Fresh tree — nothing to dedupe against, import directly.
|
|
const { data } = await api.POST("/api/v1/trees", {
|
|
body: { name: newName.trim() || "Imported tree" },
|
|
});
|
|
if (data) await postImport(data.id, f);
|
|
setBusy(false);
|
|
return;
|
|
}
|
|
|
|
// Existing tree — preview for duplicates first.
|
|
setFile(f);
|
|
const fd = new FormData();
|
|
fd.append("file", f);
|
|
const resp = await fetch(`/api/v1/trees/${treeId}/gedcom/preview`, {
|
|
method: "POST",
|
|
body: fd,
|
|
credentials: "include",
|
|
});
|
|
if (resp.ok) {
|
|
const pv: Preview = await resp.json();
|
|
setPreview(pv);
|
|
// Default: high-confidence matches merge, lower ones come in as new.
|
|
const init: Record<string, Action> = {};
|
|
for (const d of pv.potential_duplicates) init[d.xref] = d.score === "high" ? "merge" : "new";
|
|
setResolutions(init);
|
|
}
|
|
setBusy(false);
|
|
}
|
|
|
|
async function runImport() {
|
|
if (!file) return;
|
|
setBusy(true);
|
|
const map: Record<string, { action: Action; target_id: string }> = {};
|
|
for (const d of preview?.potential_duplicates ?? []) {
|
|
const action = resolutions[d.xref] ?? "new";
|
|
if (action !== "new") map[d.xref] = { action, target_id: d.existing_person_id };
|
|
}
|
|
await postImport(treeId, file, { resolutions: JSON.stringify(map) });
|
|
setPreview(null);
|
|
setFile(null);
|
|
setBusy(false);
|
|
}
|
|
|
|
async function exportGed() {
|
|
const resp = await fetch(`/api/v1/trees/${treeId}/gedcom/export`, { credentials: "include" });
|
|
if (!resp.ok) return;
|
|
const blob = await resp.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = "tree.ged";
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
const dups = preview?.potential_duplicates ?? [];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<h1 className="text-2xl font-semibold">Import & export GEDCOM</h1>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Import a GEDCOM file</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input
|
|
type="radio"
|
|
name="target"
|
|
checked={target === "new"}
|
|
onChange={() => {
|
|
setTarget("new");
|
|
resetAll();
|
|
}}
|
|
/>
|
|
Import into a <strong>new tree</strong> (recommended)
|
|
</label>
|
|
{target === "new" && (
|
|
<Input
|
|
className="max-w-xs"
|
|
placeholder="New tree name"
|
|
value={newName}
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
/>
|
|
)}
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input
|
|
type="radio"
|
|
name="target"
|
|
checked={target === "this"}
|
|
onChange={() => {
|
|
setTarget("this");
|
|
resetAll();
|
|
}}
|
|
/>
|
|
Import into <strong>this tree</strong> (checks for duplicates)
|
|
</label>
|
|
{target === "this" && !preview && (
|
|
<p className="rounded-md bg-bronze/[0.08] px-3 py-2 text-sm text-[var(--muted)]">
|
|
We'll scan the file and flag anyone who looks like a person already in this
|
|
tree, so you can merge, skip, or overwrite before anything is saved.
|
|
</p>
|
|
)}
|
|
|
|
<input
|
|
ref={fileRef}
|
|
type="file"
|
|
accept=".ged,.gedcom,text/plain"
|
|
onChange={onFile}
|
|
className="hidden"
|
|
/>
|
|
{!preview && (
|
|
<Button onClick={() => fileRef.current?.click()} disabled={busy}>
|
|
{busy ? "Working…" : "Choose GEDCOM file"}
|
|
</Button>
|
|
)}
|
|
|
|
{/* Duplicate-resolution step */}
|
|
{preview && (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-[var(--muted)]">
|
|
{Object.entries(preview.counts).map(([k, v]) => (
|
|
<span key={k}>
|
|
<span className="font-medium text-[var(--foreground)]">{v}</span> {k}
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
{dups.length === 0 ? (
|
|
<p className="rounded-md bg-bronze/[0.08] px-3 py-2 text-sm">
|
|
No likely duplicates found — everyone will be imported as new.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold">
|
|
{dups.length} possible duplicate{dups.length === 1 ? "" : "s"}
|
|
</h3>
|
|
<label className="flex items-center gap-2 text-xs text-[var(--muted)]">
|
|
Set all to
|
|
<select
|
|
className={fieldCls}
|
|
onChange={(e) => {
|
|
const a = e.target.value as Action;
|
|
const all: Record<string, Action> = {};
|
|
for (const d of dups) all[d.xref] = a;
|
|
setResolutions(all);
|
|
}}
|
|
defaultValue=""
|
|
>
|
|
<option value="" disabled>
|
|
choose…
|
|
</option>
|
|
{ACTIONS.map((a) => (
|
|
<option key={a.value} value={a.value}>
|
|
{a.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<ul className="divide-y divide-[var(--border)] rounded-lg border border-[var(--border)]">
|
|
{dups.map((d: Dup) => (
|
|
<li
|
|
key={d.xref}
|
|
className="flex flex-wrap items-center justify-between gap-3 px-3 py-2 text-sm"
|
|
>
|
|
<div className="min-w-0">
|
|
<span className="font-medium">{d.incoming_name}</span>
|
|
{d.incoming_birth_year && (
|
|
<span className="text-[var(--muted)]"> b. {d.incoming_birth_year}</span>
|
|
)}
|
|
<span className="text-[var(--muted)]"> ↔ </span>
|
|
<span>{d.existing_name}</span>
|
|
{d.existing_birth_year && (
|
|
<span className="text-[var(--muted)]"> b. {d.existing_birth_year}</span>
|
|
)}
|
|
<span
|
|
className={`ml-2 rounded px-1.5 py-0.5 text-xs ${
|
|
d.score === "high"
|
|
? "bg-bronze/15 text-bronze"
|
|
: "bg-[var(--border)]/50 text-[var(--muted)]"
|
|
}`}
|
|
>
|
|
{d.score}
|
|
</span>
|
|
</div>
|
|
<select
|
|
className={fieldCls}
|
|
value={resolutions[d.xref] ?? "new"}
|
|
onChange={(e) =>
|
|
setResolutions((r) => ({ ...r, [d.xref]: e.target.value as Action }))
|
|
}
|
|
>
|
|
{ACTIONS.map((a) => (
|
|
<option key={a.value} value={a.value}>
|
|
{a.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2">
|
|
<Button onClick={runImport} disabled={busy}>
|
|
{busy ? "Importing…" : "Run import"}
|
|
</Button>
|
|
<Button variant="ghost" onClick={resetAll} disabled={busy}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{report && (
|
|
<div className="space-y-3 rounded-lg border border-[var(--border)] p-4">
|
|
<div className="font-medium">Import complete</div>
|
|
<div className="flex flex-wrap gap-x-6 gap-y-1 text-sm text-[var(--muted)]">
|
|
{Object.entries(report.counts).map(([k, v]) => (
|
|
<span key={k}>
|
|
<span className="font-medium text-[var(--foreground)]">{v}</span> {k}
|
|
</span>
|
|
))}
|
|
</div>
|
|
{report.unmapped_tags.length > 0 && (
|
|
<div className="text-xs text-[var(--muted)]">
|
|
Unmapped tags (skipped): {report.unmapped_tags.join(", ")}
|
|
</div>
|
|
)}
|
|
{importedTreeId && (
|
|
<Link
|
|
href={`/trees/${importedTreeId}`}
|
|
className="inline-block text-sm text-bronze hover:underline"
|
|
>
|
|
Open the imported tree →
|
|
</Link>
|
|
)}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Export this tree</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<p className="text-sm text-[var(--muted)]">
|
|
Download this tree as a GEDCOM file — people, relationships, events, and sources.
|
|
</p>
|
|
<Button variant="outline" onClick={exportGed}>
|
|
Download .ged
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|