From 631d05054096f181e8c7a0ad1bdee3be57e90494 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Sat, 6 Jun 2026 22:46:48 -0400 Subject: [PATCH] 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) Signed-off-by: Justin Paul --- frontend/app/trees/[id]/gedcom/page.tsx | 163 ++++++++++++++++++++++++ frontend/components/app-sidebar.tsx | 16 ++- frontend/lib/api/schema.d.ts | 114 +++++++++++++++++ frontend/openapi.json | 132 +++++++++++++++++++ 4 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 frontend/app/trees/[id]/gedcom/page.tsx diff --git a/frontend/app/trees/[id]/gedcom/page.tsx b/frontend/app/trees/[id]/gedcom/page.tsx new file mode 100644 index 0000000..bf363c3 --- /dev/null +++ b/frontend/app/trees/[id]/gedcom/page.tsx @@ -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; 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(null); + const [importedTreeId, setImportedTreeId] = useState(null); + const fileRef = useRef(null); + + async function onFile(e: React.ChangeEvent) { + 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 ( +
+

Import & export GEDCOM

+ + + + Import a GEDCOM file + + + + {target === "new" && ( + setNewName(e.target.value)} + /> + )} + + {target === "this" && ( +

+ Importing appends everyone in the file as new records — it does not merge with + people already in this tree, so duplicates are possible. +

+ )} + + + + + {report && ( +
+
Import complete
+
+ {Object.entries(report.counts).map(([k, v]) => ( + + {v} {k} + + ))} +
+ {report.unmapped_tags.length > 0 && ( +
+ Unmapped tags (skipped): {report.unmapped_tags.join(", ")} +
+ )} + {importedTreeId && ( + + Open the imported tree → + + )} +
+ )} +
+
+ + + + Export this tree + + +

+ Download this tree as a GEDCOM file — people, relationships, events, and sources. +

+ +
+
+
+ ); +} diff --git a/frontend/components/app-sidebar.tsx b/frontend/components/app-sidebar.tsx index a9c42f7..8decfe3 100644 --- a/frontend/components/app-sidebar.tsx +++ b/frontend/components/app-sidebar.tsx @@ -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`)} /> + ; 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"]; + }; + }; + }; + }; } diff --git a/frontend/openapi.json b/frontend/openapi.json index 439fce7..800df5a 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -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": {