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:
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Vendored
+114
@@ -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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user