Add GEDCOM Import/Export UI (defaults to importing into a new tree)

An Import/Export page (sidebar) that defaults to importing into a NEW tree to avoid duplicating existing people, with an explicit 'append to this tree' option (warned), a mapping-report display (counts + skipped tags), and a one-click .ged export download.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
2026-06-06 22:46:48 -04:00
parent d48029a407
commit 631d050540
4 changed files with 424 additions and 1 deletions
+163
View File
@@ -0,0 +1,163 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useRef, useState } from "react";
import { api } from "@/lib/api/client";
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[] };
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);
async function onFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setBusy(true);
setReport(null);
setImportedTreeId(null);
let tid = treeId;
if (target === "new") {
const { data } = await api.POST("/api/v1/trees", {
body: { name: newName.trim() || "Imported tree" },
});
if (!data) {
setBusy(false);
return;
}
tid = data.id;
setImportedTreeId(tid);
} else {
setImportedTreeId(treeId);
}
const fd = new FormData();
fd.append("file", file);
const resp = await fetch(`/api/v1/trees/${tid}/gedcom/import`, {
method: "POST",
body: fd,
credentials: "include",
});
if (resp.ok) setReport(await resp.json());
setBusy(false);
if (fileRef.current) fileRef.current.value = "";
}
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);
}
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold">Import &amp; 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")}
/>
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")}
/>
Import into <strong>this tree</strong> (appends)
</label>
{target === "this" && (
<p className="rounded-md bg-bronze/[0.08] px-3 py-2 text-sm text-[var(--muted)]">
Importing appends everyone in the file as new records it does not merge with
people already in this tree, so duplicates are possible.
</p>
)}
<input ref={fileRef} type="file" accept=".ged,.gedcom,text/plain" onChange={onFile} className="hidden" />
<Button onClick={() => fileRef.current?.click()} disabled={busy}>
{busy ? "Importing…" : "Choose GEDCOM file"}
</Button>
{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>
);
}
+15 -1
View File
@@ -1,6 +1,14 @@
"use client";
import { Archive, BookText, FolderTree, Image as ImageIcon, LogOut, Users } from "lucide-react";
import {
Archive,
ArrowDownUp,
BookText,
FolderTree,
Image as ImageIcon,
LogOut,
Users,
} from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
@@ -87,6 +95,12 @@ export function AppSidebar() {
icon={ImageIcon}
active={pathname.startsWith(`/trees/${treeId}/media`)}
/>
<Item
href={`/trees/${treeId}/gedcom`}
label="Import / Export"
icon={ArrowDownUp}
active={pathname.startsWith(`/trees/${treeId}/gedcom`)}
/>
<Item
href={`/trees/${treeId}/recovery`}
label="Recovery"
+114
View File
@@ -490,10 +490,49 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/gedcom/import": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Import Gedcom */
post: operations["import_gedcom_api_v1_trees__tree_id__gedcom_import_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/trees/{tree_id}/gedcom/export": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Export Gedcom */
get: operations["export_gedcom_api_v1_trees__tree_id__gedcom_export_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post */
Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post: {
/** File */
file: string;
};
/** Body_upload_media_api_v1_trees__tree_id__media_post */
Body_upload_media_api_v1_trees__tree_id__media_post: {
/** File */
@@ -642,6 +681,15 @@ export interface components {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
/** ImportReport */
ImportReport: {
/** Counts */
counts: {
[key: string]: number;
};
/** Unmapped Tags */
unmapped_tags: string[];
};
/** LoginRequest */
LoginRequest: {
/** Email */
@@ -2130,4 +2178,70 @@ export interface operations {
};
};
};
import_gedcom_api_v1_trees__tree_id__gedcom_import_post: {
parameters: {
query?: never;
header?: never;
path: {
tree_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ImportReport"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
export_gedcom_api_v1_trees__tree_id__gedcom_export_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": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
}
+132
View File
@@ -1679,10 +1679,118 @@
}
}
}
},
"/api/v1/trees/{tree_id}/gedcom/import": {
"post": {
"tags": [
"gedcom"
],
"summary": "Import Gedcom",
"operationId": "import_gedcom_api_v1_trees__tree_id__gedcom_import_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_import_gedcom_api_v1_trees__tree_id__gedcom_import_post"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ImportReport"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/v1/trees/{tree_id}/gedcom/export": {
"get": {
"tags": [
"gedcom"
],
"summary": "Export Gedcom",
"operationId": "export_gedcom_api_v1_trees__tree_id__gedcom_export_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": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post": {
"properties": {
"file": {
"type": "string",
"contentMediaType": "application/octet-stream",
"title": "File"
}
},
"type": "object",
"required": [
"file"
],
"title": "Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post"
},
"Body_upload_media_api_v1_trees__tree_id__media_post": {
"properties": {
"file": {
@@ -2250,6 +2358,30 @@
"type": "object",
"title": "HTTPValidationError"
},
"ImportReport": {
"properties": {
"counts": {
"additionalProperties": {
"type": "integer"
},
"type": "object",
"title": "Counts"
},
"unmapped_tags": {
"items": {
"type": "string"
},
"type": "array",
"title": "Unmapped Tags"
}
},
"type": "object",
"required": [
"counts",
"unmapped_tags"
],
"title": "ImportReport"
},
"LoginRequest": {
"properties": {
"email": {