From ff4882607bd4f7a4525ddd030bab7314b67e7b41 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 22 Jan 2026 04:06:02 +0000 Subject: [PATCH 01/27] build: bump version to 0.1.1 [skip ci] --- client/package.json | 2 +- package-lock.json | 54 ++++++++++++++++++++++++++++----------------- package.json | 2 +- server/package.json | 2 +- shared/package.json | 2 +- 5 files changed, 38 insertions(+), 24 deletions(-) diff --git a/client/package.json b/client/package.json index 8cba875..987781f 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/client", - "version": "1.0.0", + "version": "0.1.1", "type": "module", "scripts": { "dev": "vite --port 6373", diff --git a/package-lock.json b/package-lock.json index 0003cd5..1168d30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "FamilySearchFinder", - "version": "1.0.0", + "name": "sparsetree", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "FamilySearchFinder", - "version": "1.0.0", + "name": "sparsetree", + "version": "0.1.1", "license": "ISC", "workspaces": [ "shared", @@ -51,7 +51,7 @@ }, "client": { "name": "@fsf/client", - "version": "1.0.0", + "version": "0.1.1", "dependencies": { "@fsf/shared": "*", "d3": "^7.9.0", @@ -120,7 +120,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -385,6 +384,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -402,6 +402,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -419,6 +420,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -436,6 +438,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -453,6 +456,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -470,6 +474,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -487,6 +492,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -504,6 +510,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -521,6 +528,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -538,6 +546,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -555,6 +564,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -572,6 +582,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -589,6 +600,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -606,6 +618,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -623,6 +636,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -640,6 +654,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -657,6 +672,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -674,6 +690,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -691,6 +708,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -708,6 +726,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -725,6 +744,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -742,6 +762,7 @@ "os": [ "openharmony" ], + "peer": true, "engines": { "node": ">=18" } @@ -759,6 +780,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -776,6 +798,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -793,6 +816,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -810,6 +834,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -1676,7 +1701,6 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2028,7 +2052,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2348,8 +2371,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/d3": { "version": "7.9.0", @@ -2668,7 +2690,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -3611,7 +3632,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4122,7 +4142,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4362,7 +4381,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4375,7 +4393,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5135,7 +5152,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5371,7 +5387,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -5949,7 +5964,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6082,7 +6096,7 @@ }, "server": { "name": "@fsf/server", - "version": "1.0.0", + "version": "0.1.1", "dependencies": { "@fsf/shared": "*", "cors": "^2.8.5", @@ -6102,7 +6116,7 @@ }, "shared": { "name": "@fsf/shared", - "version": "1.0.0", + "version": "0.1.1", "devDependencies": { "typescript": "^5.7.2" } diff --git a/package.json b/package.json index c916fdf..038a61b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sparsetree", - "version": "0.1.0", + "version": "0.1.1", "private": true, "description": "", "main": "index.js", diff --git a/server/package.json b/server/package.json index 349d2e1..0167312 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/server", - "version": "1.0.0", + "version": "0.1.1", "type": "module", "main": "dist/index.js", "scripts": { diff --git a/shared/package.json b/shared/package.json index a73e796..6c77f97 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/shared", - "version": "1.0.0", + "version": "0.1.1", "type": "module", "main": "types/index.js", "types": "types/index.d.ts", From d5012f5fabc5d48234777ad33536e4174a578258 Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Wed, 21 Jan 2026 20:11:22 -0800 Subject: [PATCH 02/27] Add CDP browser integration for indexer and fix ancestry tree lines - Indexer now extracts FamilySearch token from browser session cookies - Spawns CLI with FS_ACCESS_TOKEN for API-based indexing - Real-time CLI output forwarded via SSE to browser UI - IndexerPage rewritten with two-column layout and output console - Fixed ancestry tree vertical connector lines visibility - Fixed hasMoreAncestors logic to hide expand arrows when loaded - Added auto-centering on expanded nodes - Added tree line CSS variables for better theme support --- .../ancestry-tree/AncestryTreeView.tsx | 132 +++++- .../components/ancestry-tree/PersonCard.tsx | 8 +- client/src/components/indexer/IndexerPage.tsx | 418 +++++++++++++----- client/src/index.css | 19 + server/src/routes/browser.routes.ts | 30 ++ server/src/services/ancestry-tree.service.ts | 14 +- server/src/services/browser.service.ts | 62 +-- server/src/services/indexer.service.ts | 225 ++++++++-- 8 files changed, 719 insertions(+), 189 deletions(-) diff --git a/client/src/components/ancestry-tree/AncestryTreeView.tsx b/client/src/components/ancestry-tree/AncestryTreeView.tsx index 5252c67..0982649 100644 --- a/client/src/components/ancestry-tree/AncestryTreeView.tsx +++ b/client/src/components/ancestry-tree/AncestryTreeView.tsx @@ -15,6 +15,8 @@ export function AncestryTreeView() { const [expandingNodes, setExpandingNodes] = useState>(new Set()); const containerRef = useRef(null); const contentRef = useRef(null); + const zoomRef = useRef | null>(null); + const [pendingCenterId, setPendingCenterId] = useState(null); // Get database info to find root if no personId provided useEffect(() => { @@ -104,6 +106,12 @@ export function AncestryTreeView() { return newData; }); + + // Set the ID to center on after render + const personId = request.fatherId || request.motherId; + if (personId) { + setPendingCenterId(personId); + } }, [dbId, expandingNodes, treeData]); // Setup D3 zoom behavior @@ -121,6 +129,7 @@ export function AncestryTreeView() { }); container.call(zoom); + zoomRef.current = zoom; // Set initial transform to center the tree const containerRect = containerRef.current.getBoundingClientRect(); @@ -134,6 +143,39 @@ export function AncestryTreeView() { }; }, [treeData]); + // Center on expanded node after render + useEffect(() => { + if (!pendingCenterId || !containerRef.current || !contentRef.current || !zoomRef.current) return; + + // Find the element with the person ID + const personElement = contentRef.current.querySelector(`[data-person-id="${pendingCenterId}"]`); + if (!personElement) { + setPendingCenterId(null); + return; + } + + // Get positions + const containerRect = containerRef.current.getBoundingClientRect(); + const elementRect = personElement.getBoundingClientRect(); + const contentRect = contentRef.current.getBoundingClientRect(); + + // Calculate where the element is relative to content origin + const elementX = elementRect.left - contentRect.left + elementRect.width / 2; + const elementY = elementRect.top - contentRect.top + elementRect.height / 2; + + // Calculate transform to center this element in the container + const targetX = containerRect.width / 2 - elementX; + const targetY = containerRect.height / 2 - elementY; + + // Apply the transform with animation + const container = d3.select(containerRef.current); + container.transition() + .duration(500) + .call(zoomRef.current.transform, d3.zoomIdentity.translate(targetX, targetY)); + + setPendingCenterId(null); + }, [pendingCenterId, treeData]); + // Recursive component to render family units const renderFamilyUnit = ( unit: AncestryFamilyUnit, @@ -166,20 +208,41 @@ export function AncestryTreeView() { {/* Render parent units recursively */} {unit.parentUnits && unit.parentUnits.length > 0 && ( -
-
- {unit.parentUnits.map((parentUnit) => ( -
- {/* Horizontal connector line */} -
- {renderFamilyUnit(parentUnit, depth + 1)} -
- ))} + <> + {/* Horizontal connector line */} +
+ +
+ {/* Vertical trunk line - connects all siblings */} + {unit.parentUnits.length > 1 && ( +
+ )} + +
+ {unit.parentUnits.map((parentUnit) => ( +
+ {/* Short horizontal branch line */} +
+ {renderFamilyUnit(parentUnit, depth + 1)} +
+ ))} +
-
+ )}
); @@ -246,7 +309,7 @@ export function AncestryTreeView() { {/* Tree container with zoom/pan */}
@@ -271,15 +334,38 @@ export function AncestryTreeView() { {/* Parent units */} {treeData.parentUnits && treeData.parentUnits.length > 0 && (
- {/* Connector from root to parents */} -
- -
- {treeData.parentUnits.map((unit) => ( -
- {renderFamilyUnit(unit, 1)} -
- ))} + {/* Horizontal connector from root to parents */} +
+ +
+ {/* Vertical trunk line - connects all siblings */} + {treeData.parentUnits.length > 1 && ( +
+ )} + +
+ {treeData.parentUnits.map((unit) => ( +
+ {/* Short horizontal branch line */} +
+ {renderFamilyUnit(unit, 1)} +
+ ))} +
)} diff --git a/client/src/components/ancestry-tree/PersonCard.tsx b/client/src/components/ancestry-tree/PersonCard.tsx index 0c17be4..e1050cd 100644 --- a/client/src/components/ancestry-tree/PersonCard.tsx +++ b/client/src/components/ancestry-tree/PersonCard.tsx @@ -33,12 +33,14 @@ export function PersonCard({ person, dbId, onExpand, isLoading }: PersonCardProp return (
{/* Circular photo or placeholder */} diff --git a/client/src/components/indexer/IndexerPage.tsx b/client/src/components/indexer/IndexerPage.tsx index 25de6fc..7cf5805 100644 --- a/client/src/components/indexer/IndexerPage.tsx +++ b/client/src/components/indexer/IndexerPage.tsx @@ -1,23 +1,51 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import type { IndexerStatus } from '@fsf/shared'; import { api } from '../../services/api'; +interface BrowserStatus { + connected: boolean; + cdpUrl: string; + cdpPort: number; + pageCount: number; + pages: Array<{ url: string; title: string }>; + familySearchLoggedIn: boolean; + browserProcessRunning: boolean; + autoConnect: boolean; +} + export function IndexerPage() { const [status, setStatus] = useState(null); + const [browserStatus, setBrowserStatus] = useState(null); const [rootId, setRootId] = useState(''); const [maxGenerations, setMaxGenerations] = useState(''); const [ignoreIds, setIgnoreIds] = useState(''); const [cacheMode, setCacheMode] = useState<'all' | 'complete' | 'none'>('all'); const [oldest, setOldest] = useState(''); const [loading, setLoading] = useState(false); + const [browserLoading, setBrowserLoading] = useState(false); const [error, setError] = useState(null); + const [outputLines, setOutputLines] = useState([]); + const outputRef = useRef(null); + + // Fetch browser status + const fetchBrowserStatus = useCallback(async () => { + const result = await api.getBrowserStatus().catch(() => null); + if (result) setBrowserStatus(result); + }, []); // Load initial status useEffect(() => { api.getIndexerStatus() .then(setStatus) .catch(err => setError(err.message)); - }, []); + fetchBrowserStatus(); + }, [fetchBrowserStatus]); + + // Poll browser status every 5 seconds + useEffect(() => { + const interval = setInterval(fetchBrowserStatus, 5000); + return () => clearInterval(interval); + }, [fetchBrowserStatus]); // SSE for real-time updates useEffect(() => { @@ -28,6 +56,19 @@ export function IndexerPage() { setStatus(prev => prev ? { ...prev, progress: data.data.progress } : null); }); + eventSource.addEventListener('output', (event) => { + const data = JSON.parse(event.data); + setOutputLines(prev => { + const newLines = [...prev, data.data.line]; + // Keep last 500 lines + return newLines.slice(-500); + }); + }); + + eventSource.addEventListener('started', () => { + setOutputLines([]); // Clear output on new job + }); + eventSource.addEventListener('completed', () => { api.getIndexerStatus().then(setStatus); }); @@ -36,14 +77,65 @@ export function IndexerPage() { api.getIndexerStatus().then(setStatus); }); + eventSource.addEventListener('error', (event) => { + const data = JSON.parse((event as MessageEvent).data || '{}'); + if (data.data?.message) { + setError(data.data.message); + } + api.getIndexerStatus().then(setStatus); + }); + return () => eventSource.close(); }, []); + // Auto-scroll output to bottom + useEffect(() => { + if (outputRef.current) { + outputRef.current.scrollTop = outputRef.current.scrollHeight; + } + }, [outputLines]); + + const handleLaunchBrowser = async () => { + setBrowserLoading(true); + setError(null); + const result = await api.launchBrowser().catch(err => { + setError(err.message); + return null; + }); + if (result && !result.success) { + setError(result.message); + } + await fetchBrowserStatus(); + setBrowserLoading(false); + }; + + const handleConnectBrowser = async () => { + setBrowserLoading(true); + setError(null); + await api.connectBrowser().catch(err => { + setError(err.message); + }); + await fetchBrowserStatus(); + setBrowserLoading(false); + }; + + const handleOpenFamilySearch = async () => { + setBrowserLoading(true); + setError(null); + await api.openFamilySearchLogin().catch(err => { + setError(err.message); + }); + // Wait a moment for user to log in, then refresh status + setTimeout(fetchBrowserStatus, 3000); + setBrowserLoading(false); + }; + const handleStart = async () => { if (!rootId) return; setLoading(true); setError(null); + setOutputLines([]); const result = await api.startIndexing({ rootId, @@ -71,131 +163,245 @@ export function IndexerPage() { }; const isRunning = status?.status === 'running'; + const canStartIndexing = browserStatus?.connected && browserStatus?.familySearchLoggedIn && rootId; return ( -
+

Indexer

- {/* Status */} -
-

Status

-
- - {status?.status || 'Loading...'} -
+
+ {/* Left Column - Controls */} +
+ {/* Browser Status Panel */} +
+

Browser Connection

- {status?.progress && ( -
-
New: {status.progress.new}
-
Cached: {status.progress.cached}
-
Refreshed: {status.progress.refreshed}
-
Generations: {status.progress.generations}
-
- )} -
+
+ {/* Browser Process Status */} +
+ + + Browser: {browserStatus?.browserProcessRunning ? 'Running' : 'Not Running'} + +
- {error && ( -
- {error} -
- )} - - {/* Start Form */} - {!isRunning && ( -
-

Start Indexing

- -
-
- - setRootId(e.target.value.toUpperCase())} - placeholder="e.g., 9H8F-V2S" - className="w-full px-3 py-2 border rounded-md" - /> + {/* CDP Connection Status */} +
+ + + CDP: {browserStatus?.connected ? 'Connected' : 'Disconnected'} + +
+ + {/* FamilySearch Login Status */} +
+ + + FamilySearch: {browserStatus?.familySearchLoggedIn ? 'Logged In' : 'Not Logged In'} + +
-
- - setMaxGenerations(e.target.value)} - placeholder="Leave empty for unlimited" - className="w-full px-3 py-2 border rounded-md" - /> + {/* Action Buttons */} +
+ {!browserStatus?.browserProcessRunning && ( + + )} + + {browserStatus?.browserProcessRunning && !browserStatus?.connected && ( + + )} + + {browserStatus?.connected && !browserStatus?.familySearchLoggedIn && ( + + )} + +
+
-
- - setIgnoreIds(e.target.value.toUpperCase())} - placeholder="e.g., ABC-123, DEF-456" - className="w-full px-3 py-2 border rounded-md" - /> + {/* Indexer Status */} +
+

Status

+
+ + {status?.status || 'Loading...'}
-
- - + {status?.progress && ( +
+
+
New: {status.progress.new}
+
Cached: {status.progress.cached}
+
Gen: {status.progress.generations}
+
+ {status.progress.currentPerson && isRunning && ( +
+ Current: {status.progress.currentPerson} +
+ )} +
+ )} +
+ + {error && ( +
+ {error} +
+ )} + + {/* Start Form */} + {!isRunning && ( +
+

Start Indexing

+ +
+
+ + setRootId(e.target.value.toUpperCase())} + placeholder="e.g., KWCJ-RN4" + className="w-full px-3 py-2 border rounded-md bg-app-bg text-app-text border-app-border focus:border-app-accent focus:outline-none text-sm" + /> +
+ +
+
+ + setMaxGenerations(e.target.value)} + placeholder="Unlimited" + className="w-full px-3 py-2 border rounded-md bg-app-bg text-app-text border-app-border focus:border-app-accent focus:outline-none text-sm" + /> +
-
- - setOldest(e.target.value)} - placeholder="e.g., 1000 or 500BC" - className="w-full px-3 py-2 border rounded-md" - /> +
+ + +
+
+ +
+ + setOldest(e.target.value)} + placeholder="e.g., 1000 or 500BC" + className="w-full px-3 py-2 border rounded-md bg-app-bg text-app-text border-app-border focus:border-app-accent focus:outline-none text-sm" + /> +
+ + + + {!canStartIndexing && rootId && ( +

+ {!browserStatus?.connected + ? 'Connect to browser first' + : 'Log in to FamilySearch first'} +

+ )} +
+ )} + {/* Stop Button */} + {isRunning && ( + )} +
+ + {/* Right Column - Output Console */} +
+
+

Output

+ {outputLines.length} lines +
+
+ {outputLines.length === 0 ? ( + Output will appear here when indexing starts... + ) : ( + outputLines.map((line, i) => ( +
+ {line} +
+ )) + )}
- )} - - {/* Stop Button */} - {isRunning && ( - - )} +
); } diff --git a/client/src/index.css b/client/src/index.css index 01022fd..3e62cb7 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -43,6 +43,10 @@ /* Overlay */ --color-app-overlay: rgba(0, 0, 0, 0.5); + + /* Tree connector lines */ + --color-tree-bg: #f5f5f5; + --color-tree-line: #6b7280; } /* Dark theme */ @@ -86,6 +90,10 @@ /* Overlay */ --color-app-overlay: rgba(0, 0, 0, 0.7); + + /* Tree connector lines */ + --color-tree-bg: #262626; + --color-tree-line: #525252; } body { @@ -174,3 +182,14 @@ input:focus, select:focus, textarea:focus { .border-app-input-border { border-color: var(--color-app-input-border) !important; } + +/* Tree-specific colors */ +.bg-tree-bg { + background-color: var(--color-tree-bg) !important; +} +.bg-tree-line { + background-color: var(--color-tree-line) !important; +} +.border-tree-line { + border-color: var(--color-tree-line) !important; +} diff --git a/server/src/routes/browser.routes.ts b/server/src/routes/browser.routes.ts index 4602621..0ba64b2 100644 --- a/server/src/routes/browser.routes.ts +++ b/server/src/routes/browser.routes.ts @@ -202,4 +202,34 @@ router.get('/photos/:personId/exists', async (req: Request, res: Response) => { res.json({ success: true, data: { exists } }); }); +// Get FamilySearch authentication token from browser session +router.get('/token', async (_req: Request, res: Response) => { + if (!browserService.isConnected()) { + res.status(400).json({ success: false, error: 'Browser not connected' }); + return; + } + + const result = await browserService.getFamilySearchToken().catch(err => { + console.error('[browser] Token extraction error:', err.message); + return { token: null, cookies: [] }; + }); + + if (!result.token) { + res.status(404).json({ + success: false, + error: 'No FamilySearch token found. Make sure you are logged in.', + cookies: result.cookies.map(c => c.name) // Just return cookie names for debugging + }); + return; + } + + res.json({ + success: true, + data: { + token: result.token, + cookieCount: result.cookies.length + } + }); +}); + export const browserRouter = router; diff --git a/server/src/services/ancestry-tree.service.ts b/server/src/services/ancestry-tree.service.ts index 5a7b985..c7ce2dd 100644 --- a/server/src/services/ancestry-tree.service.ts +++ b/server/src/services/ancestry-tree.service.ts @@ -102,6 +102,10 @@ function buildFamilyUnit( ); if (fathersParentUnit) { parentUnits.push(fathersParentUnit); + // Mark father as NOT having more ancestors since we just loaded them + if (unit.father) { + unit.father.hasMoreAncestors = false; + } } } @@ -117,14 +121,20 @@ function buildFamilyUnit( ); if (mothersParentUnit) { parentUnits.push(mothersParentUnit); + // Mark mother as NOT having more ancestors since we just loaded them + if (unit.mother) { + unit.mother.hasMoreAncestors = false; + } } } if (parentUnits.length > 0) { unit.parentUnits = parentUnits; } - } else { - // At max depth, mark cards as having more ancestors if they do + } + + // At max depth OR when no parentUnits were created, check if more ancestors exist + if (!unit.parentUnits || unit.parentUnits.length === 0) { if (unit.father && father?.parents?.some(pid => db[pid])) { unit.father.hasMoreAncestors = true; } diff --git a/server/src/services/browser.service.ts b/server/src/services/browser.service.ts index 8e4f94e..b7dc019 100644 --- a/server/src/services/browser.service.ts +++ b/server/src/services/browser.service.ts @@ -62,15 +62,6 @@ export const browserService = { } connectedBrowser = await chromium.connectOverCDP(url); - - // Log context info for debugging session persistence issues - const contexts = connectedBrowser.contexts(); - console.log(`[Browser] Connected via CDP. Found ${contexts.length} existing context(s)`); - for (const ctx of contexts) { - const pages = ctx.pages(); - console.log(`[Browser] Context has ${pages.length} page(s): ${pages.map(p => p.url()).join(', ')}`); - } - return connectedBrowser; }, @@ -138,29 +129,11 @@ export const browserService = { throw new Error('Browser not connected'); } - // When connected via CDP, we must use existing contexts to preserve cookies/sessions - // browser.newContext() creates an isolated incognito-like context without cookies const contexts = connectedBrowser.contexts(); - if (contexts.length > 0) { - // Use the default context (first one) which has the persistent cookies return contexts[0]; } - // Try to find any existing page and use its context - // This handles cases where contexts() returns empty but pages exist - for (const ctx of connectedBrowser.contexts()) { - const pages = ctx.pages(); - if (pages.length > 0) { - console.log(`[Browser] Using context from existing page: ${pages[0].url()}`); - return ctx; - } - } - - // Fallback: This shouldn't happen with a running Chrome instance - // but if it does, creating a new context will NOT have access to Chrome's cookies - console.warn('[Browser] WARNING: No existing browser contexts found - creating new isolated context'); - console.warn('[Browser] Cookies/sessions will NOT persist. Ensure Chrome has at least one tab open.'); return connectedBrowser.newContext(); }, @@ -255,5 +228,40 @@ export const browserService = { async checkBrowserRunning(): Promise { return checkBrowserProcessRunning(); + }, + + async getFamilySearchToken(): Promise<{ token: string | null; cookies: Array<{ name: string; value: string }> }> { + if (!connectedBrowser?.isConnected()) { + return { token: null, cookies: [] }; + } + + const contexts = connectedBrowser.contexts(); + const allCookies: Array<{ name: string; value: string; domain: string }> = []; + + for (const ctx of contexts) { + const cookies = await ctx.cookies(['https://www.familysearch.org', 'https://ident.familysearch.org']); + allCookies.push(...cookies); + } + + // FamilySearch uses several cookie names for authentication + // The main one is usually 'fssessionid' or we can look for auth tokens + const authCookieNames = ['fssessionid', 'FS_AUTH_TOKEN', 'Authorization']; + + let token: string | null = null; + + for (const cookieName of authCookieNames) { + const cookie = allCookies.find(c => c.name === cookieName); + if (cookie) { + token = cookie.value; + break; + } + } + + // Filter to only return relevant auth cookies + const relevantCookies = allCookies + .filter(c => c.domain.includes('familysearch')) + .map(c => ({ name: c.name, value: c.value })); + + return { token, cookies: relevantCookies }; } }; diff --git a/server/src/services/indexer.service.ts b/server/src/services/indexer.service.ts index 037711d..840bbde 100644 --- a/server/src/services/indexer.service.ts +++ b/server/src/services/indexer.service.ts @@ -1,12 +1,18 @@ -import type { IndexerStatus, IndexOptions } from '@fsf/shared'; +import type { IndexerStatus, IndexOptions, IndexerProgress } from '@fsf/shared'; import { sseManager } from '../utils/sseManager.js'; +import { browserService } from './browser.service.js'; +import { spawn, ChildProcess } from 'child_process'; +import path from 'path'; + +const PROJECT_ROOT = path.resolve(import.meta.dirname, '../../../'); -// Stub implementation - will be expanded in Phase 7 let currentStatus: IndexerStatus = { jobId: null, status: 'idle' }; +let currentProcess: ChildProcess | null = null; + export const indexerService = { getStatus(): IndexerStatus { return currentStatus; @@ -18,18 +24,21 @@ export const indexerService = { } const jobId = `job-${Date.now()}`; + const progress: IndexerProgress = { + new: 0, + cached: 0, + refreshed: 0, + generations: 0, + deepest: '', + currentPerson: undefined + }; + currentStatus = { jobId, status: 'running', rootId: options.rootId, startedAt: new Date().toISOString(), - progress: { - new: 0, - cached: 0, - refreshed: 0, - generations: 0, - deepest: '' - } + progress }; sseManager.broadcast('started', { @@ -38,39 +47,199 @@ export const indexerService = { data: { jobId, rootId: options.rootId, options } }); - // TODO: Implement actual indexing logic in Phase 7 - // For now, just simulate completion after a short delay - setTimeout(() => { + // Run indexing in background + this.runIndexing(options, jobId, progress).catch(err => { + console.error('[indexer] Error during indexing:', err); currentStatus = { ...currentStatus, - status: 'completed' + status: 'error', + error: err.message }; - sseManager.broadcast('completed', { - type: 'completed', + sseManager.broadcast('error', { + type: 'error', timestamp: new Date().toISOString(), - data: { jobId, message: 'Indexing not yet implemented' } + data: { jobId, message: err.message } }); - }, 1000); + }); return currentStatus; }, + async runIndexing(options: IndexOptions, jobId: string, progress: IndexerProgress): Promise { + const { rootId, maxGenerations, ignoreIds = [], cacheMode = 'all', oldest } = options; + + console.log(`[indexer] Starting indexing for ${rootId}`); + console.log(`[indexer] Options: maxGen=${maxGenerations || 'unlimited'}, cacheMode=${cacheMode}, oldest=${oldest || 'none'}, ignoreIds=${ignoreIds.length}`); + + // Get FamilySearch token from browser session + if (!browserService.isConnected()) { + console.log('[indexer] Connecting to browser...'); + await browserService.connect(); + } + + const { token } = await browserService.getFamilySearchToken(); + if (!token) { + throw new Error('No FamilySearch token found. Please log in via the browser.'); + } + + console.log('[indexer] Got FamilySearch token, spawning CLI...'); + + // Build CLI arguments + const args: string[] = ['index.js', rootId]; + + if (maxGenerations !== undefined && maxGenerations !== null) { + args.push(`--max=${maxGenerations}`); + } + + if (cacheMode && cacheMode !== 'all') { + args.push(`--cache=${cacheMode}`); + } + + if (ignoreIds.length > 0) { + args.push(`--ignore=${ignoreIds.join(',')}`); + } + + if (oldest) { + args.push(`--oldest=${oldest}`); + } + + console.log(`[indexer] Running: node ${args.join(' ')}`); + + // Spawn the CLI process with the token + currentProcess = spawn('node', args, { + cwd: PROJECT_ROOT, + env: { + ...process.env, + FS_ACCESS_TOKEN: token + }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + // Parse CLI output to update progress + currentProcess.stdout?.on('data', (data: Buffer) => { + const lines = data.toString().split('\n').filter(l => l.trim()); + + for (const line of lines) { + console.log(`[cli] ${line}`); + + // Parse progress from CLI output + // Format: "icon GGG ID (PARENT+PARENT) LIFESPAN NAME, LOCATION [OCCUPATION]" + // Where GGG is generation number like 000, 001, etc. + // Use \S+ for emoji since emojis are multi-codepoint + const genMatch = line.match(/^\S+\s+(\d{3})\s+/); + const idMatch = line.match(/\d{3}\s+([A-Z0-9]{4}-[A-Z0-9]{2,4})\s/); + + if (genMatch) { + const gen = parseInt(genMatch[1], 10); + if (gen > progress.generations) { + progress.generations = gen; + } + } + + if (idMatch) { + progress.currentPerson = idMatch[1]; + } + + // Parse icon for status counting + if (line.includes('✅')) progress.new++; + else if (line.includes('🔄')) progress.refreshed++; + else if (line.includes('💾')) progress.cached++; + + // Parse deepest ancestor from name + const nameMatch = line.match(/[A-Z0-9]{4}-[A-Z0-9]{2,4}\s+([^-]+)/); + if (nameMatch && genMatch) { + const gen = parseInt(genMatch[1], 10); + if (gen >= progress.generations) { + progress.deepest = nameMatch[1].trim().split(' - ')[0]; + } + } + + // Broadcast CLI output line for real-time display + sseManager.broadcast('output', { + type: 'output', + timestamp: new Date().toISOString(), + data: { jobId, line } + }); + + // Broadcast progress update + sseManager.broadcast('progress', { + type: 'progress', + timestamp: new Date().toISOString(), + data: { jobId, progress: { ...progress } } + }); + } + }); + + currentProcess.stderr?.on('data', (data: Buffer) => { + console.error(`[cli stderr] ${data.toString()}`); + }); + + // Wait for process to complete + await new Promise((resolve, reject) => { + currentProcess!.on('close', (code) => { + console.log(`[indexer] CLI process exited with code ${code}`); + currentProcess = null; + + if (code === 0) { + currentStatus = { + ...currentStatus, + status: 'completed', + progress + }; + sseManager.broadcast('completed', { + type: 'completed', + timestamp: new Date().toISOString(), + data: { + jobId, + progress, + message: `Indexed ${progress.new + progress.cached + progress.refreshed} ancestors over ${progress.generations} generations` + } + }); + resolve(); + } else if (code === null) { + // Process was killed (stopped by user) + currentStatus = { + jobId: null, + status: 'idle', + progress + }; + sseManager.broadcast('stopped', { + type: 'stopped', + timestamp: new Date().toISOString(), + data: { jobId, progress } + }); + resolve(); + } else { + currentStatus = { + ...currentStatus, + status: 'error', + error: `CLI exited with code ${code}` + }; + reject(new Error(`CLI exited with code ${code}`)); + } + }); + + currentProcess!.on('error', (err) => { + console.error('[indexer] Failed to spawn CLI:', err); + currentProcess = null; + reject(err); + }); + }); + + progress.currentPerson = undefined; + }, + async stopIndexing(): Promise { if (currentStatus.status !== 'running') { throw new Error('No indexing job is running'); } - currentStatus.status = 'stopping'; - sseManager.broadcast('stopped', { - type: 'stopped', - timestamp: new Date().toISOString(), - data: { jobId: currentStatus.jobId } - }); + console.log('[indexer] Stop requested'); - // TODO: Implement actual stop logic in Phase 7 - currentStatus = { - jobId: null, - status: 'idle' - }; + if (currentProcess) { + // Send SIGINT to allow graceful shutdown (CLI handles SIGINT to save progress) + currentProcess.kill('SIGINT'); + currentStatus.status = 'stopping'; + } } }; From 4ff17fcc3602c699cdebad3f7bb3c7953c5997c3 Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Wed, 21 Jan 2026 20:22:36 -0800 Subject: [PATCH 03/27] Replace browser status polling with SSE and improve Indexer page layout - Add browserSseManager for real-time browser status updates via SSE - Add /api/browser/events endpoint for SSE subscriptions - Emit status updates on connect, disconnect, launch, and navigation - Update IndexerPage and BrowserSettingsPage to use SSE instead of polling - Reorganize Indexer page: 3-column control layout with full-width output below - Add missing Ignore IDs input field to indexer form --- client/src/components/indexer/IndexerPage.tsx | 423 +++++++++--------- client/src/pages/BrowserSettingsPage.tsx | 12 + server/src/routes/browser.routes.ts | 10 + server/src/services/browser.service.ts | 18 + server/src/utils/browserSseManager.ts | 59 +++ 5 files changed, 317 insertions(+), 205 deletions(-) create mode 100644 server/src/utils/browserSseManager.ts diff --git a/client/src/components/indexer/IndexerPage.tsx b/client/src/components/indexer/IndexerPage.tsx index 7cf5805..f509b68 100644 --- a/client/src/components/indexer/IndexerPage.tsx +++ b/client/src/components/indexer/IndexerPage.tsx @@ -27,7 +27,7 @@ export function IndexerPage() { const [outputLines, setOutputLines] = useState([]); const outputRef = useRef(null); - // Fetch browser status + // Fetch browser status (for manual refresh) const fetchBrowserStatus = useCallback(async () => { const result = await api.getBrowserStatus().catch(() => null); if (result) setBrowserStatus(result); @@ -41,11 +41,17 @@ export function IndexerPage() { fetchBrowserStatus(); }, [fetchBrowserStatus]); - // Poll browser status every 5 seconds + // SSE for real-time browser status updates useEffect(() => { - const interval = setInterval(fetchBrowserStatus, 5000); - return () => clearInterval(interval); - }, [fetchBrowserStatus]); + const eventSource = new EventSource('/api/browser/events'); + + eventSource.addEventListener('status', (event) => { + const { data } = JSON.parse(event.data); + setBrowserStatus(data); + }); + + return () => eventSource.close(); + }, []); // SSE for real-time updates useEffect(() => { @@ -166,242 +172,249 @@ export function IndexerPage() { const canStartIndexing = browserStatus?.connected && browserStatus?.familySearchLoggedIn && rootId; return ( -
+

Indexer

-
- {/* Left Column - Controls */} -
- {/* Browser Status Panel */} -
-

Browser Connection

- -
- {/* Browser Process Status */} -
- - - Browser: {browserStatus?.browserProcessRunning ? 'Running' : 'Not Running'} - -
- - {/* CDP Connection Status */} -
- - - CDP: {browserStatus?.connected ? 'Connected' : 'Disconnected'} - -
+ {/* Top Row - Controls */} +
+ {/* Browser Status Panel */} +
+

Browser Connection

- {/* FamilySearch Login Status */} -
- - - FamilySearch: {browserStatus?.familySearchLoggedIn ? 'Logged In' : 'Not Logged In'} - -
+
+
+ + + Browser: {browserStatus?.browserProcessRunning ? 'Running' : 'Not Running'} +
- {/* Action Buttons */} -
- {!browserStatus?.browserProcessRunning && ( - - )} - - {browserStatus?.browserProcessRunning && !browserStatus?.connected && ( - - )} +
+ + + CDP: {browserStatus?.connected ? 'Connected' : 'Disconnected'} + +
- {browserStatus?.connected && !browserStatus?.familySearchLoggedIn && ( - - )} +
+ + + FamilySearch: {browserStatus?.familySearchLoggedIn ? 'Logged In' : 'Not Logged In'} + +
+
+
+ {!browserStatus?.browserProcessRunning && ( -
-
- - {/* Indexer Status */} -
-

Status

-
- - {status?.status || 'Loading...'} -
- - {status?.progress && ( -
-
-
New: {status.progress.new}
-
Cached: {status.progress.cached}
-
Gen: {status.progress.generations}
-
- {status.progress.currentPerson && isRunning && ( -
- Current: {status.progress.currentPerson} -
- )} -
)} -
- {error && ( -
- {error} - -
- )} + )} - {/* Start Form */} - {!isRunning && ( -
-

Start Indexing

- -
-
- - setRootId(e.target.value.toUpperCase())} - placeholder="e.g., KWCJ-RN4" - className="w-full px-3 py-2 border rounded-md bg-app-bg text-app-text border-app-border focus:border-app-accent focus:outline-none text-sm" - /> -
+ {browserStatus?.connected && !browserStatus?.familySearchLoggedIn && ( + + )} -
-
- - setMaxGenerations(e.target.value)} - placeholder="Unlimited" - className="w-full px-3 py-2 border rounded-md bg-app-bg text-app-text border-app-border focus:border-app-accent focus:outline-none text-sm" - /> -
- -
- - -
-
+ +
+
-
- - setOldest(e.target.value)} - placeholder="e.g., 1000 or 500BC" - className="w-full px-3 py-2 border rounded-md bg-app-bg text-app-text border-app-border focus:border-app-accent focus:outline-none text-sm" - /> -
+ {/* Indexer Status */} +
+

Status

+
+ + {status?.status || 'Loading...'} +
- - - {!canStartIndexing && rootId && ( -

- {!browserStatus?.connected - ? 'Connect to browser first' - : 'Log in to FamilySearch first'} -

- )} + {status?.progress && ( +
+
+
New: {status.progress.new}
+
Cached: {status.progress.cached}
+
Gen: {status.progress.generations}
+ {status.progress.currentPerson && isRunning && ( +
+ Current: {status.progress.currentPerson} +
+ )}
)} - {/* Stop Button */} {isRunning && ( )}
- {/* Right Column - Output Console */} -
-
-

Output

- {outputLines.length} lines -
-
- {outputLines.length === 0 ? ( - Output will appear here when indexing starts... - ) : ( - outputLines.map((line, i) => ( -
- {line} -
- )) + {/* Start Form */} +
+

Start Indexing

+ +
+
+ + setRootId(e.target.value.toUpperCase())} + placeholder="e.g., KWCJ-RN4" + className="w-full px-3 py-2 border rounded-md bg-app-bg text-app-text border-app-border focus:border-app-accent focus:outline-none text-sm" + /> +
+ +
+
+ + setMaxGenerations(e.target.value)} + placeholder="∞" + className="w-full px-3 py-2 border rounded-md bg-app-bg text-app-text border-app-border focus:border-app-accent focus:outline-none text-sm" + /> +
+ +
+ + +
+
+ +
+
+ + setOldest(e.target.value)} + placeholder="e.g., 500BC" + className="w-full px-3 py-2 border rounded-md bg-app-bg text-app-text border-app-border focus:border-app-accent focus:outline-none text-sm" + /> +
+ +
+ + setIgnoreIds(e.target.value.toUpperCase())} + placeholder="ID1,ID2" + className="w-full px-3 py-2 border rounded-md bg-app-bg text-app-text border-app-border focus:border-app-accent focus:outline-none text-sm" + /> +
+
+ + + + {!canStartIndexing && rootId && !isRunning && ( +

+ {!browserStatus?.connected + ? 'Connect to browser first' + : 'Log in to FamilySearch first'} +

)}
+ + {/* Error Display */} + {error && ( +
+ {error} + +
+ )} + + {/* Full Width Output Console */} +
+
+

Output

+ {outputLines.length} lines +
+
+ {outputLines.length === 0 ? ( + Output will appear here when indexing starts... + ) : ( + outputLines.map((line, i) => ( +
+ {line} +
+ )) + )} +
+
); } diff --git a/client/src/pages/BrowserSettingsPage.tsx b/client/src/pages/BrowserSettingsPage.tsx index 9d2d87e..2381947 100644 --- a/client/src/pages/BrowserSettingsPage.tsx +++ b/client/src/pages/BrowserSettingsPage.tsx @@ -91,6 +91,18 @@ export function BrowserSettingsPage() { loadStatus(); }, [loadStatus]); + // SSE for real-time browser status updates + useEffect(() => { + const eventSource = new EventSource('/api/browser/events'); + + eventSource.addEventListener('status', (event) => { + const { data } = JSON.parse(event.data); + setBrowserStatus(data); + }); + + return () => eventSource.close(); + }, []); + const handleConnect = async () => { setConnecting(true); const result = await api.connectBrowser().catch(err => { diff --git a/server/src/routes/browser.routes.ts b/server/src/routes/browser.routes.ts index 0ba64b2..8cea1f3 100644 --- a/server/src/routes/browser.routes.ts +++ b/server/src/routes/browser.routes.ts @@ -3,11 +3,21 @@ import fs from 'fs'; import path from 'path'; import { browserService } from '../services/browser.service'; import { scraperService, ScrapeProgress } from '../services/scraper.service'; +import { browserSseManager } from '../utils/browserSseManager'; const router = Router(); const PHOTOS_DIR = path.resolve(import.meta.dirname, '../../../data/photos'); +// SSE endpoint for real-time browser status updates +router.get('/events', (req: Request, res: Response) => { + const clientId = browserSseManager.addClient(res); + + req.on('close', () => { + browserSseManager.removeClient(clientId); + }); +}); + // Get browser status router.get('/status', async (_req: Request, res: Response) => { const status = await browserService.getStatus().catch(err => { diff --git a/server/src/services/browser.service.ts b/server/src/services/browser.service.ts index b7dc019..68e4587 100644 --- a/server/src/services/browser.service.ts +++ b/server/src/services/browser.service.ts @@ -2,6 +2,7 @@ import { chromium, Browser, Page, BrowserContext } from 'playwright'; import fs from 'fs'; import path from 'path'; import { spawn } from 'child_process'; +import { browserSseManager } from '../utils/browserSseManager'; const DATA_DIR = path.resolve(import.meta.dirname, '../../../data'); const BROWSER_CONFIG_FILE = path.join(DATA_DIR, 'browser-config.json'); @@ -54,6 +55,15 @@ async function checkBrowserProcessRunning(): Promise { return response?.ok ?? false; } +// Broadcast status to all SSE clients +async function broadcastStatusUpdate(): Promise { + if (!browserSseManager.hasClients()) return; + const status = await browserService.getStatus().catch(() => null); + if (status) { + browserSseManager.broadcastStatus(status); + } +} + export const browserService = { async connect(cdpUrl?: string): Promise { const url = cdpUrl || getCdpUrlInternal(); @@ -62,6 +72,7 @@ export const browserService = { } connectedBrowser = await chromium.connectOverCDP(url); + broadcastStatusUpdate(); return connectedBrowser; }, @@ -69,6 +80,7 @@ export const browserService = { if (connectedBrowser) { await connectedBrowser.close(); connectedBrowser = null; + broadcastStatusUpdate(); } }, @@ -175,6 +187,11 @@ export const browserService = { await page.goto(url, { waitUntil: 'domcontentloaded' }); } + // Navigation may change FamilySearch login status + if (url.includes('familysearch.org')) { + broadcastStatusUpdate(); + } + return page; }, @@ -220,6 +237,7 @@ export const browserService = { await new Promise(resolve => setTimeout(resolve, 2000)); const nowRunning = await checkBrowserProcessRunning(); + broadcastStatusUpdate(); return { success: nowRunning, message: nowRunning ? 'Browser launched successfully' : 'Browser may still be starting...' diff --git a/server/src/utils/browserSseManager.ts b/server/src/utils/browserSseManager.ts new file mode 100644 index 0000000..a18a3a2 --- /dev/null +++ b/server/src/utils/browserSseManager.ts @@ -0,0 +1,59 @@ +import { Response } from 'express'; +import crypto from 'crypto'; +import type { BrowserStatus } from '../services/browser.service'; + +interface SSEClient { + id: string; + response: Response; +} + +const clients: SSEClient[] = []; +let lastStatus: BrowserStatus | null = null; + +export const browserSseManager = { + addClient(response: Response): string { + const id = crypto.randomUUID(); + response.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + }); + response.write(`data: ${JSON.stringify({ type: 'connected', clientId: id })}\n\n`); + clients.push({ id, response }); + + // Send current status immediately if available + if (lastStatus) { + const message = `event: status\ndata: ${JSON.stringify({ data: lastStatus })}\n\n`; + response.write(message); + } + + return id; + }, + + removeClient(id: string) { + const index = clients.findIndex(c => c.id === id); + if (index !== -1) clients.splice(index, 1); + }, + + broadcast(event: string, data: object) { + if (clients.length === 0) return; + + const message = `event: ${event}\ndata: ${JSON.stringify({ data })}\n\n`; + clients.forEach(({ response }) => { + response.write(message); + }); + }, + + broadcastStatus(status: BrowserStatus) { + lastStatus = status; + this.broadcast('status', status); + }, + + getClientCount(): number { + return clients.length; + }, + + hasClients(): boolean { + return clients.length > 0; + } +}; From 9f91003b98635328c4b0d2834259375ee2e6261e Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Wed, 21 Jan 2026 21:52:01 -0800 Subject: [PATCH 04/27] Add Ancestry and WikiTree platform linking with photo fetching - Add Ancestry.com linking with browser-based scraping and auto-login - Add WikiTree linking with HTTP-based profile/photo extraction - Add manual "Use Photo" button for all platforms (deferred download) - Add FamilySearch photo support in unified platforms UI - Update photo priority: Ancestry > WikiTree > Wikipedia > FamilySearch - Auto-launch/connect browser when linking Ancestry profiles - Extract highest resolution photos using srcset parsing - Consolidate platform links into single unified UI section --- .gitignore | 3 +- CHANGELOG.md | 43 ++ .../ancestry-tree/AncestryTreeView.tsx | 310 +++++---- .../ancestry-tree/FamilyUnitCard.tsx | 232 ++++++- client/src/components/ancestry-tree/index.ts | 2 +- .../components/favorites/SparseTreePage.tsx | 255 +++++--- client/src/components/person/PersonDetail.tsx | 443 +++++++++++-- client/src/index.css | 13 + client/src/services/api.ts | 30 + server/src/routes/augmentation.routes.ts | 123 ++++ server/src/services/ancestry-tree.service.ts | 34 +- server/src/services/augmentation.service.ts | 614 +++++++++++++++++- server/src/services/sparse-tree.service.ts | 117 ++-- shared/src/index.ts | 5 +- 14 files changed, 1814 insertions(+), 410 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.gitignore b/.gitignore index e37ab2a..df59020 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,5 @@ shared/types/ .idea/ # Screenshots (may contain personal family data) -images/ \ No newline at end of file +images/ +.playwright-mcp \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5ef4c17 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,43 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.2.0] - 2026-01-21 + +### Added +- **Ancestry.com Linking**: Link persons to their Ancestry.com profiles with automatic photo extraction + - Browser-based scraping with auto-login support using saved credentials + - Srcset parsing to extract highest resolution photos (5x = maxside=1800) + - Auto-launches/connects browser when needed +- **WikiTree Linking**: Link persons to their WikiTree profiles with photo extraction + - HTTP-based scraping (no auth required for public profiles) + - Extracts profile photo and description +- **Manual Photo Selection**: "Use Photo" button for each linked platform + - Separated linking from photo fetching for user control + - Support for FamilySearch, Wikipedia, Ancestry, and WikiTree photos + - Photos stored locally with platform-specific naming (`{personId}-ancestry.jpg`, etc.) +- **Unified Platforms UI**: Consolidated all platform links into single "Platforms" section in PersonDetail + - FamilySearch, Wikipedia, Ancestry, WikiTree all shown together + - Link and "Use Photo" buttons for each platform + +### Changed +- **Photo Priority**: Updated sparse tree view to use photos in order: + 1. Ancestry (highest priority) + 2. WikiTree + 3. Wikipedia + 4. FamilySearch scraped (lowest priority) +- **PlatformReference**: Added `photoUrl` field to store discovered photo URLs before downloading + +### Fixed +- Browser auto-connects when linking Ancestry profiles (no more "Browser not connected" errors) +- Ancestry photo now appears in sparse tree view + +## [0.1.1] - Previous + +- Browser status polling replaced with SSE +- CDP browser integration for indexer +- Ancestry tree line improvements + +## [0.1.0] - Initial Release + +- Initial version with FamilySearch indexing, Wikipedia linking, favorites, and sparse tree visualization diff --git a/client/src/components/ancestry-tree/AncestryTreeView.tsx b/client/src/components/ancestry-tree/AncestryTreeView.tsx index 0982649..5f9399d 100644 --- a/client/src/components/ancestry-tree/AncestryTreeView.tsx +++ b/client/src/components/ancestry-tree/AncestryTreeView.tsx @@ -6,6 +6,11 @@ import { api } from '../../services/api'; import { PersonCard } from './PersonCard'; import { FamilyUnitCard } from './FamilyUnitCard'; +interface RootLinePositions { + totalHeight: number; + unitPositions: number[]; +} + export function AncestryTreeView() { const { dbId, personId } = useParams<{ dbId: string; personId?: string }>(); const [treeData, setTreeData] = useState(null); @@ -18,6 +23,10 @@ export function AncestryTreeView() { const zoomRef = useRef | null>(null); const [pendingCenterId, setPendingCenterId] = useState(null); + // Refs for root-level SVG line calculations + const parentUnitsContainerRef = useRef(null); + const [rootLinePositions, setRootLinePositions] = useState({ totalHeight: 400, unitPositions: [] }); + // Get database info to find root if no personId provided useEffect(() => { if (!personId && dbId) { @@ -42,6 +51,40 @@ export function AncestryTreeView() { .finally(() => setLoading(false)); }, [dbId, rootId]); + // Calculate line positions for root-level parent units + useEffect(() => { + if (!parentUnitsContainerRef.current || !treeData?.parentUnits) return; + + const calculatePositions = () => { + const container = parentUnitsContainerRef.current; + if (!container) return; + + const totalHeight = container.offsetHeight; + const positions: number[] = []; + + // Get each parent unit element + const unitElements = container.querySelectorAll('[data-parent-unit]'); + unitElements.forEach((el) => { + const htmlEl = el as HTMLElement; + const centerY = htmlEl.offsetTop + htmlEl.offsetHeight / 2; + positions.push(centerY); + }); + + setRootLinePositions({ totalHeight, unitPositions: positions }); + }; + + // Calculate after render + const timeoutId = setTimeout(calculatePositions, 100); + + // Recalculate on window resize + window.addEventListener('resize', calculatePositions); + + return () => { + clearTimeout(timeoutId); + window.removeEventListener('resize', calculatePositions); + }; + }, [treeData]); + // Handle expanding a node const handleExpand = useCallback(async (request: ExpandAncestryRequest, nodeId: string) => { if (!dbId || expandingNodes.has(nodeId)) return; @@ -74,29 +117,37 @@ export function AncestryTreeView() { for (const unit of units) { // Check if this unit contains the person being expanded - if (unit.father?.id === request.fatherId || unit.mother?.id === request.motherId) { - // The expanded data contains the parents of the person we expanded from - // We need to add these as parentUnits - if (!unit.parentUnits) { - unit.parentUnits = []; + if (unit.father?.id === request.fatherId) { + // Add to father's parent units + if (!unit.fatherParentUnits) { + unit.fatherParentUnits = []; } + unit.fatherParentUnits.push(expandedData); - // Add the expanded unit - unit.parentUnits.push(expandedData); - - // Update hasMoreAncestors flags - if (unit.father?.id === request.fatherId && unit.father) { + // Update hasMoreAncestors flag + if (unit.father) { unit.father.hasMoreAncestors = false; } - if (unit.mother?.id === request.motherId && unit.mother) { - unit.mother.hasMoreAncestors = false; + return true; + } + + if (unit.mother?.id === request.motherId) { + // Add to mother's parent units + if (!unit.motherParentUnits) { + unit.motherParentUnits = []; } + unit.motherParentUnits.push(expandedData); + // Update hasMoreAncestors flag + if (unit.mother) { + unit.mother.hasMoreAncestors = false; + } return true; } // Recursively check child units - if (updateUnit(unit.parentUnits)) return true; + if (updateUnit(unit.fatherParentUnits)) return true; + if (updateUnit(unit.motherParentUnits)) return true; } return false; @@ -108,9 +159,9 @@ export function AncestryTreeView() { }); // Set the ID to center on after render - const personId = request.fatherId || request.motherId; - if (personId) { - setPendingCenterId(personId); + const personIdToCenter = request.fatherId || request.motherId; + if (personIdToCenter) { + setPendingCenterId(personIdToCenter); } }, [dbId, expandingNodes, treeData]); @@ -122,7 +173,7 @@ export function AncestryTreeView() { const content = d3.select(contentRef.current); const zoom = d3.zoom() - .scaleExtent([0.2, 2]) + .scaleExtent([0.15, 2]) .on('zoom', (event) => { content.style('transform', `translate(${event.transform.x}px, ${event.transform.y}px) scale(${event.transform.k})`); content.style('transform-origin', '0 0'); @@ -131,12 +182,11 @@ export function AncestryTreeView() { container.call(zoom); zoomRef.current = zoom; - // Set initial transform to center the tree - const containerRect = containerRef.current.getBoundingClientRect(); - const initialX = 100; - const initialY = containerRect.height / 2 - 90; // Approximate half of family unit height + // Set initial transform to position root at left and vertically centered + const initialX = 80; + const initialY = -100; // Start higher to center the tree vertically - container.call(zoom.transform, d3.zoomIdentity.translate(initialX, initialY)); + container.call(zoom.transform, d3.zoomIdentity.translate(initialX, initialY).scale(0.45)); return () => { container.on('.zoom', null); @@ -168,14 +218,23 @@ export function AncestryTreeView() { const targetY = containerRect.height / 2 - elementY; // Apply the transform with animation - const container = d3.select(containerRef.current); - container.transition() + const containerSelection = d3.select(containerRef.current); + containerSelection.transition() .duration(500) .call(zoomRef.current.transform, d3.zoomIdentity.translate(targetX, targetY)); setPendingCenterId(null); }, [pendingCenterId, treeData]); + // Render a list of parent units + const renderParentUnits = (units: AncestryFamilyUnit[], depth: number): JSX.Element => { + return ( +
+ {units.map((unit) => renderFamilyUnit(unit, depth))} +
+ ); + }; + // Recursive component to render family units const renderFamilyUnit = ( unit: AncestryFamilyUnit, @@ -187,63 +246,24 @@ export function AncestryTreeView() { return (
-
- handleExpand({ fatherId: unit.father!.id }, `expand_${unit.father!.id}`) - : undefined - } - onExpandMother={ - unit.mother?.hasMoreAncestors - ? () => handleExpand({ motherId: unit.mother!.id }, `expand_${unit.mother!.id}`) - : undefined - } - loadingFather={isExpandingFather} - loadingMother={isExpandingMother} - /> -
- - {/* Render parent units recursively */} - {unit.parentUnits && unit.parentUnits.length > 0 && ( - <> - {/* Horizontal connector line */} -
- -
- {/* Vertical trunk line - connects all siblings */} - {unit.parentUnits.length > 1 && ( -
- )} - -
- {unit.parentUnits.map((parentUnit) => ( -
- {/* Short horizontal branch line */} -
- {renderFamilyUnit(parentUnit, depth + 1)} -
- ))} -
-
- - )} + handleExpand({ fatherId: unit.father!.id }, `expand_${unit.father!.id}`) + : undefined + } + onExpandMother={ + unit.mother?.hasMoreAncestors + ? () => handleExpand({ motherId: unit.mother!.id }, `expand_${unit.mother!.id}`) + : undefined + } + loadingFather={isExpandingFather} + loadingMother={isExpandingMother} + renderParentUnits={renderParentUnits} + depth={depth} + />
); }; @@ -285,6 +305,9 @@ export function AncestryTreeView() { ); } + const hasParents = treeData.parentUnits && treeData.parentUnits.length > 0; + const { totalHeight, unitPositions } = rootLinePositions; + return (
{/* Header */} @@ -313,10 +336,10 @@ export function AncestryTreeView() { style={{ minHeight: '600px' }} >
- {/* Tree visualization */} + {/* Tree visualization - horizontal layout */}
- {/* Root person */} -
+ {/* Root person section */} +
- {/* Parent units */} - {treeData.parentUnits && treeData.parentUnits.length > 0 && ( -
- {/* Horizontal connector from root to parents */} -
- -
- {/* Vertical trunk line - connects all siblings */} - {treeData.parentUnits.length > 1 && ( -
+ {/* SVG connector lines */} + + {/* Horizontal line from root (at vertical center) */} + + + {/* Vertical trunk line - from first unit to last unit */} + {unitPositions.length > 1 && ( + )} -
- {treeData.parentUnits.map((unit) => ( -
- {/* Short horizontal branch line */} -
0 && ( + <> + {/* If center is above the trunk top */} + {totalHeight / 2 < unitPositions[0] && ( + + )} + {/* If center is below the trunk bottom */} + {unitPositions.length > 1 && totalHeight / 2 > unitPositions[unitPositions.length - 1] && ( + + )} + {/* Single unit case */} + {unitPositions.length === 1 && ( + - {renderFamilyUnit(unit, 1)} -
- ))} -
+ )} + + )} + + {/* Horizontal branches to each parent unit */} + {unitPositions.map((y, i) => ( + + ))} + + + {/* Parent units container */} +
+ {treeData.parentUnits!.map((unit) => ( +
+ {renderFamilyUnit(unit, 1)} +
+ ))}
)} diff --git a/client/src/components/ancestry-tree/FamilyUnitCard.tsx b/client/src/components/ancestry-tree/FamilyUnitCard.tsx index 5a054df..778b80a 100644 --- a/client/src/components/ancestry-tree/FamilyUnitCard.tsx +++ b/client/src/components/ancestry-tree/FamilyUnitCard.tsx @@ -1,5 +1,6 @@ import type { AncestryFamilyUnit } from '@fsf/shared'; import { PersonCard } from './PersonCard'; +import { useRef, useEffect, useState } from 'react'; interface FamilyUnitCardProps { unit: AncestryFamilyUnit; @@ -8,6 +9,14 @@ interface FamilyUnitCardProps { onExpandMother?: () => void; loadingFather?: boolean; loadingMother?: boolean; + renderParentUnits: (units: AncestryFamilyUnit[], depth: number) => JSX.Element; + depth: number; +} + +interface LinePositions { + totalHeight: number; + fatherY: number; + motherY: number; } export function FamilyUnitCard({ @@ -16,34 +25,209 @@ export function FamilyUnitCard({ onExpandFather, onExpandMother, loadingFather, - loadingMother + loadingMother, + renderParentUnits, + depth }: FamilyUnitCardProps) { + const hasFatherParents = unit.fatherParentUnits && unit.fatherParentUnits.length > 0; + const hasMotherParents = unit.motherParentUnits && unit.motherParentUnits.length > 0; + const hasAnyParents = hasFatherParents || hasMotherParents; + + const parentSectionsRef = useRef(null); + const fatherSectionRef = useRef(null); + const motherSectionRef = useRef(null); + const [linePositions, setLinePositions] = useState({ totalHeight: 200, fatherY: 50, motherY: 150 }); + + // Calculate line positions after render and on resize + useEffect(() => { + if (!hasAnyParents) return; + + const calculatePositions = () => { + const parentSections = parentSectionsRef.current; + const fatherSection = fatherSectionRef.current; + const motherSection = motherSectionRef.current; + + if (!parentSections) return; + + const totalHeight = parentSections.offsetHeight; + let fatherY = totalHeight / 4; // Default + let motherY = (totalHeight * 3) / 4; // Default + + if (fatherSection) { + fatherY = fatherSection.offsetTop + fatherSection.offsetHeight / 2; + } + + if (motherSection) { + motherY = motherSection.offsetTop + motherSection.offsetHeight / 2; + } + + // If only one parent has ancestors, center the line + if (hasFatherParents && !hasMotherParents) { + motherY = fatherY; + } else if (!hasFatherParents && hasMotherParents) { + fatherY = motherY; + } + + setLinePositions({ totalHeight, fatherY, motherY }); + }; + + // Calculate after render + const timeoutId = setTimeout(calculatePositions, 50); + + // Recalculate on window resize + window.addEventListener('resize', calculatePositions); + + return () => { + clearTimeout(timeoutId); + window.removeEventListener('resize', calculatePositions); + }; + }, [hasAnyParents, hasFatherParents, hasMotherParents, unit]); + return ( -
- {/* Father card (top) */} - {unit.father && ( - - )} +
+ {/* Family unit box containing father and mother */} +
+ {/* Father card */} + {unit.father && ( + + )} - {/* Mother card (bottom) */} - {unit.mother && ( - - )} + {/* Mother card */} + {unit.mother && ( + + )} + + {/* Handle case where only one parent exists */} + {!unit.father && !unit.mother && ( +
+ Unknown parents +
+ )} +
+ + {/* Parent ancestry with connector lines */} + {hasAnyParents && ( +
+ {/* SVG connector lines */} + + {/* Horizontal line from family box (at vertical center of this SVG's parent row) */} + + + {/* Vertical trunk line - from first branch to last branch */} + {hasFatherParents && hasMotherParents && ( + + )} + + {/* Connect center to trunk if needed */} + {hasFatherParents && hasMotherParents && ( + <> + {/* If center is above the trunk top */} + {linePositions.totalHeight / 2 < linePositions.fatherY && ( + + )} + {/* If center is below the trunk bottom */} + {linePositions.totalHeight / 2 > linePositions.motherY && ( + + )} + + )} + + {/* Single parent case - connect center directly */} + {(hasFatherParents !== hasMotherParents) && ( + + )} + + {/* Horizontal branch to father's parents */} + {hasFatherParents && ( + + )} + + {/* Horizontal branch to mother's parents */} + {hasMotherParents && ( + + )} + + + {/* Parent sections container */} +
+ {/* Father's ancestry section */} + {hasFatherParents && ( +
+ {renderParentUnits(unit.fatherParentUnits!, depth + 1)} +
+ )} - {/* Handle case where only one parent exists */} - {!unit.father && !unit.mother && ( -
- Unknown parents + {/* Mother's ancestry section */} + {hasMotherParents && ( +
+ {renderParentUnits(unit.motherParentUnits!, depth + 1)} +
+ )} +
)}
diff --git a/client/src/components/ancestry-tree/index.ts b/client/src/components/ancestry-tree/index.ts index 63731c0..d2561fb 100644 --- a/client/src/components/ancestry-tree/index.ts +++ b/client/src/components/ancestry-tree/index.ts @@ -1,4 +1,4 @@ -export { AncestryTreeView } from './AncestryTreeView'; +export { AncestryTreeViewNew as AncestryTreeView } from './AncestryTreeViewNew'; export { PersonCard } from './PersonCard'; export { FamilyUnitCard } from './FamilyUnitCard'; export { ConnectionLine, FamilyConnection } from './ConnectionLine'; diff --git a/client/src/components/favorites/SparseTreePage.tsx b/client/src/components/favorites/SparseTreePage.tsx index ffa9459..8fb2e7c 100644 --- a/client/src/components/favorites/SparseTreePage.tsx +++ b/client/src/components/favorites/SparseTreePage.tsx @@ -55,24 +55,26 @@ export function SparseTreePage() { const cardColor = computedStyle.getPropertyValue('--color-app-card').trim() || '#1a1a1a'; const borderColor = computedStyle.getPropertyValue('--color-app-border').trim() || '#2a2a2a'; const bgSecondaryColor = computedStyle.getPropertyValue('--color-app-bg-secondary').trim() || '#171717'; - // Favorite highlight colors (consistent across themes) - const favoriteStrokeColor = '#eab308'; // yellow-500 - const favoriteAccentBgColor = theme === 'dark' ? '#1e293b' : '#fef3c7'; // slate-800 or amber-100 // Create main group for zoom/pan const g = svg.append('g') - .attr('transform', `translate(${width / 2},${margin.top})`); + .attr('transform', `translate(${width / 2},${height - margin.bottom})`); // Create hierarchy from tree data const root = d3.hierarchy(treeData.root); - // Use tree layout with vertical orientation (root at top) + // Use tree layout with vertical orientation - generous spacing for variable card heights const treeLayout = d3.tree() - .nodeSize([180, 120]) - .separation((a, b) => a.parent === b.parent ? 1 : 1.5); + .nodeSize([180, 220]) + .separation((a, b) => a.parent === b.parent ? 1.2 : 1.8); treeLayout(root); + // Flip Y coordinates so ancestors are on top (negate y values) + root.each(d => { + d.y = -(d.y ?? 0); + }); + // Draw links with generation count labels const links = g.selectAll('.link') .data(root.links()) @@ -80,12 +82,13 @@ export function SparseTreePage() { .append('g') .attr('class', 'link-group'); - // Draw curved links + // Draw curved links with better styling links.append('path') .attr('class', 'link') .attr('fill', 'none') .attr('stroke', borderColor) .attr('stroke-width', 2) + .attr('stroke-dasharray', d => d.target.data.generationsSkipped ? '6,4' : 'none') .attr('d', d3.linkVertical, d3.HierarchyPointNode>() .x(d => d.x) .y(d => d.y) as unknown as string); @@ -97,22 +100,26 @@ export function SparseTreePage() { const midX = ((d.source.x ?? 0) + (d.target.x ?? 0)) / 2; const midY = ((d.source.y ?? 0) + (d.target.y ?? 0)) / 2; + const labelWidth = targetData.generationsSkipped > 99 ? 70 : 55; + d3.select(this) .append('rect') - .attr('x', midX - 30) - .attr('y', midY - 10) - .attr('width', 60) - .attr('height', 20) - .attr('rx', 10) + .attr('x', midX - labelWidth / 2) + .attr('y', midY - 12) + .attr('width', labelWidth) + .attr('height', 24) + .attr('rx', 12) .attr('fill', cardColor) - .attr('stroke', borderColor); + .attr('stroke', borderColor) + .attr('stroke-width', 1); d3.select(this) .append('text') .attr('x', midX) .attr('y', midY + 4) .attr('text-anchor', 'middle') - .attr('font-size', '10px') + .attr('font-size', '11px') + .attr('font-weight', '500') .attr('fill', mutedColor) .text(`${targetData.generationsSkipped} gen`); } @@ -130,125 +137,160 @@ export function SparseTreePage() { setSelectedNode(d.data); }); - // Node card background - nodes.append('rect') - .attr('x', -70) - .attr('y', -35) - .attr('width', 140) - .attr('height', 70) - .attr('rx', 8) - .attr('fill', d => d.data.isFavorite ? favoriteAccentBgColor : cardColor) - .attr('stroke', d => d.data.isFavorite ? favoriteStrokeColor : borderColor) - .attr('stroke-width', d => d.data.isFavorite ? 2 : 1); - - // Star icon for favorites - nodes.filter(d => d.data.isFavorite) - .append('text') - .attr('x', -60) - .attr('y', -20) - .attr('font-size', '14px') - .attr('fill', '#eab308') - .text('★'); - - // Photo placeholder or actual photo + // Node card dimensions - vertical layout with photo on top, text below + const cardWidth = 160; + const photoSize = 60; + + // Calculate card height based on name length (for poster printing - no truncation) + const getCardHeight = (name: string, hasTags: boolean) => { + const charsPerLine = 20; + const lineHeight = 14; + const nameLines = Math.ceil(name.length / charsPerLine); + const baseHeight = 114; // photo area + padding + lifespan + const nameHeight = nameLines * lineHeight; + const tagHeight = hasTags ? 20 : 0; + return baseHeight + nameHeight + tagHeight; + }; + + // Draw card backgrounds with dynamic heights + nodes.each(function(d) { + const node = d3.select(this); + const cardHeight = getCardHeight(d.data.name, (d.data.tags?.length || 0) > 0); + + node.append('rect') + .attr('x', -cardWidth / 2) + .attr('y', -cardHeight / 2) + .attr('width', cardWidth) + .attr('height', cardHeight) + .attr('rx', 10) + .attr('fill', cardColor) + .attr('stroke', borderColor) + .attr('stroke-width', 1); + }); + + // Photo centered at top of card nodes.append('clipPath') .attr('id', d => `clip-${d.data.id}`) .append('circle') - .attr('cx', -40) - .attr('cy', 0) - .attr('r', 20); + .attr('cx', 0) + .attr('cy', d => { + const cardHeight = getCardHeight(d.data.name, (d.data.tags?.length || 0) > 0); + return -cardHeight / 2 + 12 + photoSize / 2; + }) + .attr('r', photoSize / 2); nodes.each(function(d) { const node = d3.select(this); + const cardHeight = getCardHeight(d.data.name, (d.data.tags?.length || 0) > 0); + const photoY = -cardHeight / 2 + 12 + photoSize / 2; + if (d.data.photoUrl) { node.append('image') - .attr('x', -60) - .attr('y', -20) - .attr('width', 40) - .attr('height', 40) + .attr('x', -photoSize / 2) + .attr('y', photoY - photoSize / 2) + .attr('width', photoSize) + .attr('height', photoSize) .attr('clip-path', `url(#clip-${d.data.id})`) .attr('href', d.data.photoUrl) .attr('preserveAspectRatio', 'xMidYMid slice'); } else { node.append('circle') - .attr('cx', -40) - .attr('cy', 0) - .attr('r', 20) + .attr('cx', 0) + .attr('cy', photoY) + .attr('r', photoSize / 2) .attr('fill', bgSecondaryColor) .attr('stroke', borderColor); node.append('text') - .attr('x', -40) - .attr('y', 5) + .attr('x', 0) + .attr('y', photoY + 6) .attr('text-anchor', 'middle') - .attr('font-size', '16px') + .attr('font-size', '24px') .attr('fill', subtleColor) .text('👤'); } }); - // Name label - nodes.append('text') - .attr('x', 0) - .attr('y', -10) - .attr('text-anchor', 'middle') - .attr('font-size', '11px') - .attr('font-weight', 'bold') - .attr('fill', textColor) - .each(function(d) { - const text = d3.select(this); - const name = d.data.name; - // Truncate long names - if (name.length > 18) { - text.text(name.substring(0, 16) + '...'); - text.append('title').text(name); - } else { - text.text(name); - } - }); + // Name label with full text wrapping (no truncation for poster printing) + nodes.each(function(d) { + const node = d3.select(this); + const name = d.data.name; + const cardHeight = getCardHeight(name, (d.data.tags?.length || 0) > 0); + const nameStartY = -cardHeight / 2 + photoSize + 24; + + // Use foreignObject for HTML text wrapping - full text, no truncation + const fo = node.append('foreignObject') + .attr('x', -cardWidth / 2 + 8) + .attr('y', nameStartY) + .attr('width', cardWidth - 16) + .attr('height', 200); // Large enough for any name + + fo.append('xhtml:div') + .style('font-size', '11px') + .style('font-weight', '600') + .style('color', textColor) + .style('line-height', '1.3') + .style('text-align', 'center') + .style('word-break', 'break-word') + .text(name); + }); // Lifespan label - nodes.append('text') - .attr('x', 0) - .attr('y', 6) - .attr('text-anchor', 'middle') - .attr('font-size', '9px') - .attr('fill', mutedColor) - .text(d => d.data.lifespan); - - // Generation badge - nodes.append('text') - .attr('x', 0) - .attr('y', 22) - .attr('text-anchor', 'middle') - .attr('font-size', '9px') - .attr('fill', subtleColor) - .text(d => `Gen ${d.data.generationFromRoot}`); - - // Tags badges (first 2) + nodes.each(function(d) { + const node = d3.select(this); + const name = d.data.name; + const hasTags = (d.data.tags?.length || 0) > 0; + const cardHeight = getCardHeight(name, hasTags); + const charsPerLine = 20; + const nameLines = Math.ceil(name.length / charsPerLine); + const lifespanY = -cardHeight / 2 + photoSize + 42 + nameLines * 14; + + node.append('text') + .attr('x', 0) + .attr('y', lifespanY) + .attr('text-anchor', 'middle') + .attr('font-size', '10px') + .attr('fill', mutedColor) + .text(d.data.lifespan); + }); + + // Tags badges (show all tags, full text) nodes.each(function(d) { if (!d.data.tags || d.data.tags.length === 0) return; const node = d3.select(this); - const tagsToShow = d.data.tags.slice(0, 2); - let xOffset = -tagsToShow.length * 25; + const name = d.data.name; + const cardHeight = getCardHeight(name, true); + const charsPerLine = 20; + const nameLines = Math.ceil(name.length / charsPerLine); + const tagsY = -cardHeight / 2 + photoSize + 58 + nameLines * 14; + + // Calculate total width for centering + const tagWidths = d.data.tags.map((tag: string) => tag.length * 5.5 + 12); + const totalWidth = tagWidths.reduce((a: number, b: number) => a + b, 0) + (d.data.tags.length - 1) * 4; + let xOffset = -totalWidth / 2; + + d.data.tags.forEach((tag: string, i: number) => { + const tagPixelWidth = tagWidths[i]; - tagsToShow.forEach((tag, i) => { node.append('rect') - .attr('x', xOffset + i * 50 - 2) - .attr('y', 28) - .attr('width', 48) + .attr('x', xOffset) + .attr('y', tagsY) + .attr('width', tagPixelWidth) .attr('height', 14) .attr('rx', 7) .attr('fill', '#3b82f6') - .attr('opacity', 0.2); + .attr('opacity', 0.15); node.append('text') - .attr('x', xOffset + i * 50 + 22) - .attr('y', 38) + .attr('x', xOffset + tagPixelWidth / 2) + .attr('y', tagsY + 10) .attr('text-anchor', 'middle') .attr('font-size', '8px') + .attr('font-weight', '500') .attr('fill', '#60a5fa') - .text(tag.length > 8 ? tag.substring(0, 6) + '..' : tag); + .text(tag); + + xOffset += tagPixelWidth + 4; }); }); @@ -295,11 +337,24 @@ export function SparseTreePage() { const handleResetZoom = () => { if (svgRef.current && zoomRef.current) { const svg = d3.select(svgRef.current); + const g = svg.select('g'); + const bounds = (g.node() as SVGGElement)?.getBBox(); const width = svgRef.current.clientWidth; - svg.transition().call( - zoomRef.current.transform, - d3.zoomIdentity.translate(width / 2, 60) - ); + const height = svgRef.current.clientHeight; + + if (bounds) { + const dx = bounds.width; + const dy = bounds.height; + const x = bounds.x + dx / 2; + const y = bounds.y + dy / 2; + const scale = Math.min(0.8, 0.9 / Math.max(dx / width, dy / height)); + const translate = [width / 2 - scale * x, height / 2 - scale * y]; + + svg.transition().call( + zoomRef.current.transform, + d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale) + ); + } } }; diff --git a/client/src/components/person/PersonDetail.tsx b/client/src/components/person/PersonDetail.tsx index a20398c..66a3719 100644 --- a/client/src/components/person/PersonDetail.tsx +++ b/client/src/components/person/PersonDetail.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { useParams, Link } from 'react-router-dom'; -import { MapPin, Briefcase, Users, ExternalLink, GitBranch, Loader2, Camera, User, Link2, BookOpen, Calendar, Heart, Database, Unlink } from 'lucide-react'; +import { MapPin, Briefcase, Users, ExternalLink, GitBranch, Loader2, Camera, User, Link2, BookOpen, Calendar, Heart, Database, Unlink, Download } from 'lucide-react'; import toast from 'react-hot-toast'; import type { PersonWithId, PathResult, DatabaseInfo, PersonAugmentation, GenealogyProviderRegistry, ProviderPersonMapping } from '@fsf/shared'; import { api, LegacyScrapedPersonData } from '../../services/api'; @@ -53,17 +53,6 @@ function getOrdinal(n: number): string { return n + (s[(v - 20) % 10] || s[v] || s[0]); } -// Platform display config -const platformConfig: Record = { - familysearch: { label: 'FamilySearch', color: 'bg-app-success-subtle text-app-success' }, - wikipedia: { label: 'Wikipedia', color: 'bg-app-accent-subtle text-app-accent' }, - findagrave: { label: 'Find A Grave', color: 'bg-gray-600/10 dark:bg-gray-600/20 text-gray-600 dark:text-gray-400' }, - heritage: { label: 'Heritage', color: 'bg-app-warning-subtle text-app-warning' }, - ancestry: { label: 'Ancestry', color: 'bg-emerald-600/10 dark:bg-emerald-600/20 text-emerald-600 dark:text-emerald-400' }, - geni: { label: 'Geni', color: 'bg-cyan-600/10 dark:bg-cyan-600/20 text-cyan-600 dark:text-cyan-400' }, - wikitree: { label: 'WikiTree', color: 'bg-purple-600/10 dark:bg-purple-600/20 text-purple-600 dark:text-purple-400' }, -}; - export function PersonDetail() { const { dbId, personId } = useParams<{ dbId: string; personId: string }>(); const [person, setPerson] = useState(null); @@ -75,12 +64,20 @@ export function PersonDetail() { const [augmentation, setAugmentation] = useState(null); const [hasPhoto, setHasPhoto] = useState(false); const [hasWikiPhoto, setHasWikiPhoto] = useState(false); + const [hasAncestryPhoto, setHasAncestryPhoto] = useState(false); const [loading, setLoading] = useState(true); const [lineageLoading, setLineageLoading] = useState(false); const [scrapeLoading, setScrapeLoading] = useState(false); const [wikiLoading, setWikiLoading] = useState(false); const [wikiUrl, setWikiUrl] = useState(''); const [showWikiInput, setShowWikiInput] = useState(false); + const [ancestryLoading, setAncestryLoading] = useState(false); + const [ancestryUrl, setAncestryUrl] = useState(''); + const [showAncestryInput, setShowAncestryInput] = useState(false); + const [hasWikiTreePhoto, setHasWikiTreePhoto] = useState(false); + const [wikiTreeLoading, setWikiTreeLoading] = useState(false); + const [wikiTreeUrl, setWikiTreeUrl] = useState(''); + const [showWikiTreeInput, setShowWikiTreeInput] = useState(false); const [error, setError] = useState(null); // Provider linking state @@ -92,6 +89,7 @@ export function PersonDetail() { const [providerExternalId, setProviderExternalId] = useState(''); const [providerLinkLoading, setProviderLinkLoading] = useState(false); const [unlinkingProviderId, setUnlinkingProviderId] = useState(null); + const [fetchingPhotoFrom, setFetchingPhotoFrom] = useState(null); useEffect(() => { if (!dbId || !personId) return; @@ -102,10 +100,16 @@ export function PersonDetail() { setAugmentation(null); setHasPhoto(false); setHasWikiPhoto(false); + setHasAncestryPhoto(false); setParentData({}); setSpouseData({}); setWikiUrl(''); setShowWikiInput(false); + setAncestryUrl(''); + setShowAncestryInput(false); + setHasWikiTreePhoto(false); + setWikiTreeUrl(''); + setShowWikiTreeInput(false); setProviderMappings([]); setShowProviderLinkInput(false); setSelectedProviderId(''); @@ -122,9 +126,11 @@ export function PersonDetail() { api.hasPhoto(personId).catch(() => ({ exists: false })), api.getAugmentation(personId).catch(() => null), api.hasWikiPhoto(personId).catch(() => ({ exists: false })), + api.hasAncestryPhoto(personId).catch(() => ({ exists: false })), + api.hasWikiTreePhoto(personId).catch(() => ({ exists: false })), api.getPersonProviderLinks(personId).catch(() => []) ]) - .then(async ([personData, dbData, scraped, photoCheck, augment, wikiPhotoCheck, providerLinks]) => { + .then(async ([personData, dbData, scraped, photoCheck, augment, wikiPhotoCheck, ancestryPhotoCheck, wikiTreePhotoCheck, providerLinks]) => { setPerson(personData); setDatabase(dbData); setProviderMappings(providerLinks || []); @@ -132,6 +138,8 @@ export function PersonDetail() { setHasPhoto(photoCheck?.exists ?? false); setAugmentation(augment); setHasWikiPhoto(wikiPhotoCheck?.exists ?? false); + setHasAncestryPhoto(ancestryPhotoCheck?.exists ?? false); + setHasWikiTreePhoto(wikiTreePhotoCheck?.exists ?? false); // Fetch parent data for names if (personData.parents.length > 0) { @@ -227,6 +235,80 @@ export function PersonDetail() { setWikiLoading(false); }; + const handleLinkAncestry = async () => { + if (!personId || !ancestryUrl.trim()) return; + + setAncestryLoading(true); + + const data = await api.linkAncestry(personId, ancestryUrl.trim()) + .catch(err => { + toast.error(err.message); + return null; + }); + + if (data) { + setAugmentation(data); + const ancestryPhotoExists = await api.hasAncestryPhoto(personId).catch(() => ({ exists: false })); + setHasAncestryPhoto(ancestryPhotoExists?.exists ?? false); + setShowAncestryInput(false); + setAncestryUrl(''); + toast.success('Ancestry linked successfully'); + } + + setAncestryLoading(false); + }; + + const handleLinkWikiTree = async () => { + if (!personId || !wikiTreeUrl.trim()) return; + + setWikiTreeLoading(true); + + const data = await api.linkWikiTree(personId, wikiTreeUrl.trim()) + .catch(err => { + toast.error(err.message); + return null; + }); + + if (data) { + setAugmentation(data); + const wikiTreePhotoExists = await api.hasWikiTreePhoto(personId).catch(() => ({ exists: false })); + setHasWikiTreePhoto(wikiTreePhotoExists?.exists ?? false); + setShowWikiTreeInput(false); + setWikiTreeUrl(''); + toast.success('WikiTree linked successfully'); + } + + setWikiTreeLoading(false); + }; + + const handleFetchPhotoFromPlatform = async (platform: string) => { + if (!personId) return; + + setFetchingPhotoFrom(platform); + + const data = await api.fetchPhotoFromPlatform(personId, platform) + .catch(err => { + toast.error(err.message); + return null; + }); + + if (data) { + setAugmentation(data); + // Refresh photo existence checks + const [wikiExists, ancestryExists, wikiTreeExists] = await Promise.all([ + api.hasWikiPhoto(personId).catch(() => ({ exists: false })), + api.hasAncestryPhoto(personId).catch(() => ({ exists: false })), + api.hasWikiTreePhoto(personId).catch(() => ({ exists: false })) + ]); + setHasWikiPhoto(wikiExists?.exists ?? false); + setHasAncestryPhoto(ancestryExists?.exists ?? false); + setHasWikiTreePhoto(wikiTreeExists?.exists ?? false); + toast.success(`Photo fetched from ${platform}`); + } + + setFetchingPhotoFrom(null); + }; + const handleLinkProvider = async () => { if (!personId || !selectedProviderId || !providerUrl.trim()) return; @@ -292,12 +374,16 @@ export function PersonDetail() { const isRoot = database?.rootId === personId; const generations = lineage ? lineage.path.length - 1 : 0; const relationship = isRoot ? 'Root Person (You)' : lineage ? getRelationshipLabel(generations) : null; - // Prefer Wiki photo over FamilySearch scraped photo - const photoUrl = hasWikiPhoto - ? api.getWikiPhotoUrl(personId!) - : hasPhoto - ? api.getPhotoUrl(personId!) - : null; + // Photo priority: Ancestry > WikiTree > Wiki > FamilySearch scraped + const photoUrl = hasAncestryPhoto + ? api.getAncestryPhotoUrl(personId!) + : hasWikiTreePhoto + ? api.getWikiTreePhotoUrl(personId!) + : hasWikiPhoto + ? api.getWikiPhotoUrl(personId!) + : hasPhoto + ? api.getPhotoUrl(personId!) + : null; // Get primary description from augmentation const wikiDescription = augmentation?.descriptions?.find(d => d.source === 'wikipedia')?.text; @@ -537,63 +623,198 @@ export function PersonDetail() {
)} - {/* Platform badges */} - {augmentation?.platforms && augmentation.platforms.length > 0 && ( -
- {augmentation.platforms.map((platform, idx) => { - const config = platformConfig[platform.platform] || { label: platform.platform, color: 'bg-app-text-subtle/20 text-app-text-muted' }; - return ( - +

Platforms

+
+ {/* FamilySearch - always present */} +
+ + + FamilySearch + + {hasPhoto && ( +
- )} + {fetchingPhotoFrom === 'familysearch' ? ( + <> + + Fetching... + + ) : ( + <> + + Use Photo + + )} + + )} +
- {/* External links and Wikipedia link */} -
- - - View on FamilySearch - + {/* Wikipedia */} +
+ {wikiPlatform ? ( + <> + + + Wikipedia + + + + ) : ( + <> + + + Wikipedia + + {!showWikiInput ? ( + + ) : ( + Enter URL below + )} + + )} +
- {/* Link Wikipedia button */} - {!showWikiInput && !wikiPlatform && ( - - )} + {/* Ancestry */} +
+ {augmentation?.platforms?.find(p => p.platform === 'ancestry') ? ( + <> + p.platform === 'ancestry')!.url} + target="_blank" + rel="noopener noreferrer" + className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm bg-emerald-600/10 text-emerald-600 dark:text-emerald-400 hover:opacity-80 transition-opacity" + > + + Ancestry + + + + ) : ( + <> + + + Ancestry + + {!showAncestryInput ? ( + + ) : ( + Enter URL below + )} + + )} +
- {/* Wikipedia URL already linked */} - {wikiPlatform && ( - - - Wikipedia Linked - - )} + {/* WikiTree */} +
+ {augmentation?.platforms?.find(p => p.platform === 'wikitree') ? ( + <> + p.platform === 'wikitree')!.url} + target="_blank" + rel="noopener noreferrer" + className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm bg-purple-600/10 text-purple-600 dark:text-purple-400 hover:opacity-80 transition-opacity" + > + + WikiTree + + + + ) : ( + <> + + + WikiTree + + {!showWikiTreeInput ? ( + + ) : ( + Enter URL below + )} + + )} +
+
{/* Wikipedia URL input */} @@ -635,6 +856,84 @@ export function PersonDetail() {
)} + {/* Ancestry URL input */} + {showAncestryInput && ( +
+

Link Ancestry Profile

+
+ setAncestryUrl(e.target.value)} + placeholder="https://www.ancestry.com/family-tree/person/tree/.../person/.../facts" + className="flex-1 px-3 py-2 bg-app-bg border border-app-border rounded text-app-text placeholder-app-placeholder focus:border-app-accent focus:outline-none" + /> + + +
+

+ Paste an Ancestry.com person URL. Requires browser to be connected and logged into Ancestry. +

+
+ )} + + {/* WikiTree URL input */} + {showWikiTreeInput && ( +
+

Link WikiTree Profile

+
+ setWikiTreeUrl(e.target.value)} + placeholder="https://www.wikitree.com/wiki/Surname-12345" + className="flex-1 px-3 py-2 bg-app-bg border border-app-border rounded text-app-text placeholder-app-placeholder focus:border-app-accent focus:outline-none" + /> + + +
+

+ Paste a WikiTree URL to link this person to their WikiTree profile. +

+
+ )} + {/* Provider Mappings Section */} {providers && Object.keys(providers.providers).length > 0 && (
diff --git a/client/src/index.css b/client/src/index.css index 3e62cb7..b5eab1b 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -193,3 +193,16 @@ input:focus, select:focus, textarea:focus { .border-tree-line { border-color: var(--color-tree-line) !important; } + +/* Tree connector lines */ +.tree-line-h { + height: 2px; + background-color: var(--color-tree-line); + flex-shrink: 0; +} + +.tree-line-v { + width: 2px; + background-color: var(--color-tree-line); + flex-shrink: 0; +} diff --git a/client/src/services/api.ts b/client/src/services/api.ts index fcff68e..e8149b6 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -163,6 +163,36 @@ export const api = { getWikiPhotoUrl: (personId: string) => `${BASE_URL}/augment/${personId}/wiki-photo`, + // Ancestry linking + linkAncestry: (personId: string, url: string) => + fetchJson(`/augment/${personId}/ancestry`, { + method: 'POST', + body: JSON.stringify({ url }) + }), + + hasAncestryPhoto: (personId: string) => + fetchJson<{ exists: boolean }>(`/augment/${personId}/ancestry-photo/exists`), + + getAncestryPhotoUrl: (personId: string) => `${BASE_URL}/augment/${personId}/ancestry-photo`, + + // WikiTree linking + linkWikiTree: (personId: string, url: string) => + fetchJson(`/augment/${personId}/wikitree`, { + method: 'POST', + body: JSON.stringify({ url }) + }), + + hasWikiTreePhoto: (personId: string) => + fetchJson<{ exists: boolean }>(`/augment/${personId}/wikitree-photo/exists`), + + getWikiTreePhotoUrl: (personId: string) => `${BASE_URL}/augment/${personId}/wikitree-photo`, + + // Fetch photo from linked platform + fetchPhotoFromPlatform: (personId: string, platform: string) => + fetchJson(`/augment/${personId}/fetch-photo/${platform}`, { + method: 'POST' + }), + // Genealogy Providers listGenealogyProviders: () => fetchJson('/genealogy-providers'), diff --git a/server/src/routes/augmentation.routes.ts b/server/src/routes/augmentation.routes.ts index 48a64bc..c8fad93 100644 --- a/server/src/routes/augmentation.routes.ts +++ b/server/src/routes/augmentation.routes.ts @@ -95,6 +95,129 @@ router.get('/:personId/wiki-photo/exists', async (req: Request, res: Response) = res.json({ success: true, data: { exists } }); }); +// Link an Ancestry profile to a person +router.post('/:personId/ancestry', async (req: Request, res: Response) => { + const { personId } = req.params; + const { url } = req.body; + + if (!url) { + res.status(400).json({ success: false, error: 'Ancestry URL required' }); + return; + } + + if (!url.includes('ancestry.com')) { + res.status(400).json({ success: false, error: 'Must be an Ancestry.com URL' }); + return; + } + + const data = await augmentationService.linkAncestry(personId, url).catch(err => { + console.error(`[augment] Error linking Ancestry:`, err.message); + res.status(500).json({ success: false, error: err.message }); + return null; + }); + + if (data) { + res.json({ success: true, data }); + } +}); + +// Serve Ancestry photo +router.get('/:personId/ancestry-photo', async (req: Request, res: Response) => { + const { personId } = req.params; + const photoPath = augmentationService.getAncestryPhotoPath(personId); + + if (!photoPath || !fs.existsSync(photoPath)) { + res.status(404).json({ success: false, error: 'Ancestry photo not found' }); + return; + } + + const ext = path.extname(photoPath).toLowerCase(); + const contentType = ext === '.png' ? 'image/png' : 'image/jpeg'; + + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'public, max-age=86400'); + fs.createReadStream(photoPath).pipe(res); +}); + +// Check if ancestry photo exists +router.get('/:personId/ancestry-photo/exists', async (req: Request, res: Response) => { + const { personId } = req.params; + const exists = augmentationService.hasAncestryPhoto(personId); + res.json({ success: true, data: { exists } }); +}); + +// Link a WikiTree profile to a person +router.post('/:personId/wikitree', async (req: Request, res: Response) => { + const { personId } = req.params; + const { url } = req.body; + + if (!url) { + res.status(400).json({ success: false, error: 'WikiTree URL required' }); + return; + } + + if (!url.includes('wikitree.com')) { + res.status(400).json({ success: false, error: 'Must be a WikiTree URL' }); + return; + } + + const data = await augmentationService.linkWikiTree(personId, url).catch(err => { + console.error(`[augment] Error linking WikiTree:`, err.message); + res.status(500).json({ success: false, error: err.message }); + return null; + }); + + if (data) { + res.json({ success: true, data }); + } +}); + +// Serve WikiTree photo +router.get('/:personId/wikitree-photo', async (req: Request, res: Response) => { + const { personId } = req.params; + const photoPath = augmentationService.getWikiTreePhotoPath(personId); + + if (!photoPath || !fs.existsSync(photoPath)) { + res.status(404).json({ success: false, error: 'WikiTree photo not found' }); + return; + } + + const ext = path.extname(photoPath).toLowerCase(); + const contentType = ext === '.png' ? 'image/png' : 'image/jpeg'; + + res.setHeader('Content-Type', contentType); + res.setHeader('Cache-Control', 'public, max-age=86400'); + fs.createReadStream(photoPath).pipe(res); +}); + +// Check if wikitree photo exists +router.get('/:personId/wikitree-photo/exists', async (req: Request, res: Response) => { + const { personId } = req.params; + const exists = augmentationService.hasWikiTreePhoto(personId); + res.json({ success: true, data: { exists } }); +}); + +// Fetch and download photo from a linked platform +router.post('/:personId/fetch-photo/:platform', async (req: Request, res: Response) => { + const { personId, platform } = req.params; + + const validPlatforms = ['wikipedia', 'ancestry', 'wikitree', 'familysearch', 'findagrave', 'geni']; + if (!validPlatforms.includes(platform)) { + res.status(400).json({ success: false, error: `Invalid platform: ${platform}` }); + return; + } + + const data = await augmentationService.fetchPhotoFromPlatform(personId, platform as any).catch(err => { + console.error(`[augment] Error fetching photo from ${platform}:`, err.message); + res.status(500).json({ success: false, error: err.message }); + return null; + }); + + if (data) { + res.json({ success: true, data }); + } +}); + // Get all provider mappings for a person router.get('/:personId/provider-links', (req: Request, res: Response) => { const { personId } = req.params; diff --git a/server/src/services/ancestry-tree.service.ts b/server/src/services/ancestry-tree.service.ts index c7ce2dd..eb0247b 100644 --- a/server/src/services/ancestry-tree.service.ts +++ b/server/src/services/ancestry-tree.service.ts @@ -88,9 +88,7 @@ function buildFamilyUnit( // If we haven't reached max depth, recursively build parent units if (generation < maxDepth) { - const parentUnits: AncestryFamilyUnit[] = []; - - // Father's parents + // Father's parents - stored separately if (father && father.parents && father.parents.length > 0) { const [fathersFather, fathersMother] = father.parents; const fathersParentUnit = buildFamilyUnit( @@ -101,7 +99,7 @@ function buildFamilyUnit( maxDepth ); if (fathersParentUnit) { - parentUnits.push(fathersParentUnit); + unit.fatherParentUnits = [fathersParentUnit]; // Mark father as NOT having more ancestors since we just loaded them if (unit.father) { unit.father.hasMoreAncestors = false; @@ -109,7 +107,7 @@ function buildFamilyUnit( } } - // Mother's parents + // Mother's parents - stored separately if (mother && mother.parents && mother.parents.length > 0) { const [mothersFather, mothersMother] = mother.parents; const mothersParentUnit = buildFamilyUnit( @@ -120,27 +118,21 @@ function buildFamilyUnit( maxDepth ); if (mothersParentUnit) { - parentUnits.push(mothersParentUnit); + unit.motherParentUnits = [mothersParentUnit]; // Mark mother as NOT having more ancestors since we just loaded them if (unit.mother) { unit.mother.hasMoreAncestors = false; } } } - - if (parentUnits.length > 0) { - unit.parentUnits = parentUnits; - } } // At max depth OR when no parentUnits were created, check if more ancestors exist - if (!unit.parentUnits || unit.parentUnits.length === 0) { - if (unit.father && father?.parents?.some(pid => db[pid])) { - unit.father.hasMoreAncestors = true; - } - if (unit.mother && mother?.parents?.some(pid => db[pid])) { - unit.mother.hasMoreAncestors = true; - } + if (!unit.fatherParentUnits && unit.father && father?.parents?.some(pid => db[pid])) { + unit.father.hasMoreAncestors = true; + } + if (!unit.motherParentUnits && unit.mother && mother?.parents?.some(pid => db[pid])) { + unit.mother.hasMoreAncestors = true; } return unit; @@ -198,8 +190,12 @@ export const ancestryTreeService = { if (!units || units.length === 0) return currentGen - 1; let maxGen = currentGen; for (const unit of units) { - if (unit.parentUnits) { - const childMax = calculateMaxGen(unit.parentUnits, currentGen + 1); + if (unit.fatherParentUnits) { + const childMax = calculateMaxGen(unit.fatherParentUnits, currentGen + 1); + if (childMax > maxGen) maxGen = childMax; + } + if (unit.motherParentUnits) { + const childMax = calculateMaxGen(unit.motherParentUnits, currentGen + 1); if (childMax > maxGen) maxGen = childMax; } } diff --git a/server/src/services/augmentation.service.ts b/server/src/services/augmentation.service.ts index 333baea..0786483 100644 --- a/server/src/services/augmentation.service.ts +++ b/server/src/services/augmentation.service.ts @@ -3,6 +3,9 @@ import path from 'path'; import https from 'https'; import http from 'http'; import type { PersonAugmentation, PlatformReference, PersonPhoto, PersonDescription, PlatformType, ProviderPersonMapping } from '@fsf/shared'; +import { browserService } from './browser.service.js'; +import { credentialsService } from './credentials.service.js'; +import { getScraper } from './scrapers/index.js'; const DATA_DIR = path.resolve(import.meta.dirname, '../../../data'); const AUGMENT_DIR = path.join(DATA_DIR, 'augment'); @@ -395,38 +398,11 @@ export const augmentationService = { }); } - // Add or update Wikipedia photo + // Store photo URL reference (but don't download - user can fetch manually) if (wikiData.photoUrl) { - const existingPhoto = existing.photos.find(p => p.source === 'wikipedia'); - const isPrimary = existing.photos.length === 0; // Primary if first photo - - if (existingPhoto) { - existingPhoto.url = wikiData.photoUrl; - if (isPrimary) existingPhoto.isPrimary = true; - } else { - existing.photos.push({ - url: wikiData.photoUrl, - source: 'wikipedia', - isPrimary, - }); - } - - // Download Wikipedia photo - const ext = wikiData.photoUrl.includes('.png') ? 'png' : 'jpg'; - const photoPath = path.join(PHOTOS_DIR, `${personId}-wiki.${ext}`); - - await downloadImage(wikiData.photoUrl, photoPath).catch(err => { - console.error(`[augment] Failed to download wiki photo: ${err.message}`); - }); - - if (fs.existsSync(photoPath)) { - console.log(`[augment] Downloaded wiki photo to ${photoPath}`); - // Update local path - const photo = existing.photos.find(p => p.source === 'wikipedia'); - if (photo) { - photo.localPath = photoPath; - photo.downloadedAt = new Date().toISOString(); - } + const existingPlatformRef = existing.platforms.find(p => p.platform === 'wikipedia'); + if (existingPlatformRef) { + existingPlatformRef.photoUrl = wikiData.photoUrl; } } @@ -478,6 +454,582 @@ export const augmentationService = { return this.getWikiPhotoPath(personId) !== null; }, + getAncestryPhotoPath(personId: string): string | null { + const jpgPath = path.join(PHOTOS_DIR, `${personId}-ancestry.jpg`); + const pngPath = path.join(PHOTOS_DIR, `${personId}-ancestry.png`); + if (fs.existsSync(jpgPath)) return jpgPath; + if (fs.existsSync(pngPath)) return pngPath; + return null; + }, + + hasAncestryPhoto(personId: string): boolean { + return this.getAncestryPhotoPath(personId) !== null; + }, + + /** + * Parse Ancestry URL to extract treeId and personId + * Format: https://www.ancestry.com/family-tree/person/tree/{treeId}/person/{personId}/facts + */ + parseAncestryUrl(url: string): { treeId: string; ancestryPersonId: string } | null { + const match = url.match(/\/tree\/(\d+)\/person\/(\d+)/); + if (!match) return null; + return { treeId: match[1], ancestryPersonId: match[2] }; + }, + + /** + * Scrape just the photo URL from an Ancestry page using the browser + */ + async scrapeAncestryPhoto(ancestryUrl: string): Promise { + // Auto-connect to browser if not connected + if (!browserService.isConnected()) { + console.log(`[augment] Browser not connected, attempting to connect...`); + const isRunning = await browserService.checkBrowserRunning(); + + if (!isRunning) { + console.log(`[augment] Browser not running, launching...`); + const launchResult = await browserService.launchBrowser(); + if (!launchResult.success) { + throw new Error(`Failed to launch browser: ${launchResult.message}`); + } + await new Promise(resolve => setTimeout(resolve, 3000)); + } + + await browserService.connect().catch(err => { + throw new Error(`Failed to connect to browser: ${err.message}`); + }); + } + + const page = await browserService.createPage(ancestryUrl); + await page.waitForTimeout(3000); + + // Check if redirected to login + let currentUrl = page.url(); + if (currentUrl.includes('/signin') || currentUrl.includes('/login')) { + const credentials = credentialsService.getCredentials('ancestry'); + if (credentials?.password) { + const username = credentials.email || credentials.username || ''; + const scraper = getScraper('ancestry'); + const loginSuccess = await scraper.performLogin(page, username, credentials.password).catch(() => false); + + if (loginSuccess) { + await page.goto(ancestryUrl, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(3000); + currentUrl = page.url(); + if (currentUrl.includes('/signin') || currentUrl.includes('/login')) { + await page.close(); + throw new Error('Login requires additional verification. Please log in manually.'); + } + } else { + await page.close(); + throw new Error('Auto-login failed. Please check credentials or log in manually.'); + } + } else { + await page.close(); + throw new Error('Not authenticated to Ancestry. Please save credentials or log in manually.'); + } + } + + // Extract photo URL + let photoUrl: string | undefined; + + const profilePhotoData = await page.$eval( + '#profileImage img, [data-testid="usercardimg-element"] img', + (el) => { + const srcset = el.getAttribute('srcset'); + const src = el.getAttribute('src'); + return { srcset, src }; + } + ).catch(() => null); + + if (profilePhotoData?.srcset) { + const srcsetParts = profilePhotoData.srcset.split(',').map(s => s.trim()); + for (const multiplier of ['5x', '4x', '3x', '2x', '1.75x', '1.5x', '1.25x', '1x']) { + const match = srcsetParts.find(part => part.endsWith(multiplier)); + if (match) { + photoUrl = match.replace(new RegExp(`\\s+${multiplier}$`), '').trim(); + break; + } + } + } + + if (!photoUrl && profilePhotoData?.src) { + photoUrl = profilePhotoData.src; + } + + await page.close(); + + if (photoUrl) { + if (photoUrl.startsWith('//')) photoUrl = 'https:' + photoUrl; + else if (photoUrl.startsWith('/')) photoUrl = 'https://www.ancestry.com' + photoUrl; + } + + return photoUrl; + }, + + async linkAncestry(personId: string, ancestryUrl: string): Promise { + console.log(`[augment] Linking Ancestry for ${personId}: ${ancestryUrl}`); + + // Parse the Ancestry URL + const parsed = this.parseAncestryUrl(ancestryUrl); + if (!parsed) { + throw new Error('Invalid Ancestry URL format. Expected: https://www.ancestry.com/family-tree/person/tree/{treeId}/person/{personId}/facts'); + } + + // Auto-connect to browser if not connected + if (!browserService.isConnected()) { + console.log(`[augment] Browser not connected, attempting to connect...`); + const isRunning = await browserService.checkBrowserRunning(); + + if (!isRunning) { + console.log(`[augment] Browser not running, launching...`); + const launchResult = await browserService.launchBrowser(); + if (!launchResult.success) { + throw new Error(`Failed to launch browser: ${launchResult.message}`); + } + // Wait a bit for browser to fully start + await new Promise(resolve => setTimeout(resolve, 3000)); + } + + // Now connect to the browser + await browserService.connect().catch(err => { + throw new Error(`Failed to connect to browser: ${err.message}`); + }); + console.log(`[augment] Browser connected successfully`); + } + + // Get or create page + const page = await browserService.createPage(ancestryUrl); + await page.waitForTimeout(3000); // Wait for page to load + + // Check if redirected to login + let currentUrl = page.url(); + if (currentUrl.includes('/signin') || currentUrl.includes('/login')) { + console.log(`[augment] Redirected to login page, attempting auto-login...`); + + // Check for saved credentials + const credentials = credentialsService.getCredentials('ancestry'); + if (credentials?.password) { + const username = credentials.email || credentials.username || ''; + console.log(`[augment] Found saved credentials for ${username}, performing login...`); + + const scraper = getScraper('ancestry'); + const loginSuccess = await scraper.performLogin(page, username, credentials.password) + .catch(err => { + console.error(`[augment] Auto-login failed:`, err.message); + return false; + }); + + if (loginSuccess) { + console.log(`[augment] Auto-login successful, navigating to person page...`); + // Navigate back to the person page after login + await page.goto(ancestryUrl, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(3000); + currentUrl = page.url(); + + // Check if still on login page (might need 2FA or other verification) + if (currentUrl.includes('/signin') || currentUrl.includes('/login')) { + await page.close(); + throw new Error('Login requires additional verification. Please log in manually in the browser.'); + } + } else { + await page.close(); + throw new Error('Auto-login failed. Please check your saved credentials or log in manually.'); + } + } else { + await page.close(); + throw new Error('Not authenticated to Ancestry. Please save your Ancestry credentials in Settings > Providers, or log in manually in the browser.'); + } + } + + // Extract photo URL from page + let photoUrl: string | null = null; + + // Primary selector: Ancestry profile image with srcset (get highest resolution) + const profilePhotoData = await page.$eval( + '#profileImage img, [data-testid="usercardimg-element"] img', + (el) => { + const srcset = el.getAttribute('srcset'); + const src = el.getAttribute('src'); + return { srcset, src }; + } + ).catch(() => null); + + if (profilePhotoData) { + // Parse srcset to get highest resolution (look for 5x, 4x, 3x, etc.) + if (profilePhotoData.srcset) { + const srcsetParts = profilePhotoData.srcset.split(',').map(s => s.trim()); + // Find highest resolution (5x > 4x > 3x > 2x > 1x) + for (const multiplier of ['5x', '4x', '3x', '2x', '1.75x', '1.5x', '1.25x', '1x']) { + const match = srcsetParts.find(part => part.endsWith(multiplier)); + if (match) { + photoUrl = match.replace(new RegExp(`\\s+${multiplier}$`), '').trim(); + console.log(`[augment] Found Ancestry photo from srcset (${multiplier}): ${photoUrl}`); + break; + } + } + } + // Fall back to src if srcset parsing failed + if (!photoUrl && profilePhotoData.src) { + photoUrl = profilePhotoData.src; + console.log(`[augment] Found Ancestry photo from src: ${photoUrl}`); + } + } + + // Fallback selectors if primary didn't work + if (!photoUrl) { + const fallbackSelectors = [ + '.personPhoto img', + '.person-photo img', + '[data-test="person-photo"] img', + '.profilePhoto img', + '.profile-photo img', + '.userCardImg img' + ]; + + for (const selector of fallbackSelectors) { + const photoSrc = await page.$eval(selector, el => el.getAttribute('src')).catch(() => null); + if (photoSrc && !photoSrc.includes('default') && !photoSrc.includes('silhouette') && !photoSrc.includes('placeholder')) { + photoUrl = photoSrc; + console.log(`[augment] Found Ancestry photo via fallback: ${photoUrl}`); + break; + } + } + } + + // Normalize URL + if (photoUrl) { + if (photoUrl.startsWith('//')) { + photoUrl = 'https:' + photoUrl; + } else if (photoUrl.startsWith('/')) { + photoUrl = 'https://www.ancestry.com' + photoUrl; + } + } + + await page.close(); + + // Get existing augmentation or create new + const existing = this.getAugmentation(personId) || { + id: personId, + platforms: [], + photos: [], + descriptions: [], + updatedAt: new Date().toISOString(), + }; + + // Add or update Ancestry platform reference + const existingPlatform = existing.platforms.find(p => p.platform === 'ancestry'); + if (existingPlatform) { + existingPlatform.url = ancestryUrl; + existingPlatform.externalId = parsed.ancestryPersonId; + existingPlatform.linkedAt = new Date().toISOString(); + if (photoUrl) existingPlatform.photoUrl = photoUrl; + } else { + existing.platforms.push({ + platform: 'ancestry', + url: ancestryUrl, + externalId: parsed.ancestryPersonId, + linkedAt: new Date().toISOString(), + photoUrl: photoUrl || undefined, + }); + } + + existing.updatedAt = new Date().toISOString(); + this.saveAugmentation(existing); + return existing; + }, + + getWikiTreePhotoPath(personId: string): string | null { + const jpgPath = path.join(PHOTOS_DIR, `${personId}-wikitree.jpg`); + const pngPath = path.join(PHOTOS_DIR, `${personId}-wikitree.png`); + if (fs.existsSync(jpgPath)) return jpgPath; + if (fs.existsSync(pngPath)) return pngPath; + return null; + }, + + hasWikiTreePhoto(personId: string): boolean { + return this.getWikiTreePhotoPath(personId) !== null; + }, + + /** + * Parse WikiTree URL to extract the WikiTree ID + * Format: https://www.wikitree.com/wiki/Surname-12345 + */ + parseWikiTreeUrl(url: string): string | null { + const match = url.match(/wikitree\.com\/wiki\/([A-Za-z]+-\d+)/); + return match ? match[1] : null; + }, + + async scrapeWikiTree(url: string): Promise<{ title: string; description: string; photoUrl?: string; wikiTreeId: string }> { + // Fetch WikiTree page HTML + const html = await new Promise((resolve, reject) => { + const options = { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; FamilySearchFinder/1.0)', + 'Accept': 'text/html' + } + }; + + const doFetch = (targetUrl: string) => { + https.get(targetUrl, options, (response) => { + if (response.statusCode === 301 || response.statusCode === 302) { + const redirectUrl = response.headers.location; + if (redirectUrl) { + doFetch(redirectUrl.startsWith('http') ? redirectUrl : `https:${redirectUrl}`); + return; + } + } + let data = ''; + response.on('data', chunk => data += chunk); + response.on('end', () => resolve(data)); + }).on('error', reject); + }; + + doFetch(url); + }); + + console.log(`[augment] Fetched ${html.length} bytes from WikiTree`); + + // Extract WikiTree ID from URL + const wikiTreeId = this.parseWikiTreeUrl(url) || ''; + + // Extract title/name from page + const titleMatch = html.match(/([^<]+)<\/title>/i); + const title = titleMatch + ? titleMatch[1].replace(/ \| WikiTree FREE Family Tree$/, '').trim() + : 'Unknown'; + + // Extract birth/death info for description + let description = ''; + const vitalMatch = html.match(/<span class="VITALS"[^>]*>([^<]+)</i); + if (vitalMatch) { + description = vitalMatch[1].trim(); + } + + // Extract profile text/bio + const bioMatch = html.match(/<div class="profile-text"[^>]*>([\s\S]*?)<\/div>/i); + if (bioMatch) { + const bioText = bioMatch[1] + .replace(/<[^>]+>/g, '') // Remove HTML tags + .replace(/\s+/g, ' ') + .trim() + .slice(0, 500); + if (bioText.length > description.length) { + description = bioText; + } + } + + // Extract photo URL + let photoUrl: string | undefined; + + // Try profile image + const imgMatch = html.match(/<img[^>]*class="[^"]*photo[^"]*"[^>]*src="([^"]+)"[^>]*>/i); + if (imgMatch) { + photoUrl = imgMatch[1]; + } + + // Try another pattern for WikiTree photos + if (!photoUrl) { + const altImgMatch = html.match(/<img[^>]*src="([^"]*wikitree\.com[^"]*(?:\.jpg|\.jpeg|\.png)[^"]*)"[^>]*>/i); + if (altImgMatch) { + photoUrl = altImgMatch[1]; + } + } + + // Try GEDCOM photo + if (!photoUrl) { + const gedcomImgMatch = html.match(/src="(https:\/\/www\.wikitree\.com\/photo\.php[^"]+)"/i); + if (gedcomImgMatch) { + photoUrl = gedcomImgMatch[1]; + } + } + + // Normalize photo URL + if (photoUrl) { + if (photoUrl.startsWith('//')) { + photoUrl = 'https:' + photoUrl; + } else if (photoUrl.startsWith('/')) { + photoUrl = 'https://www.wikitree.com' + photoUrl; + } + // Filter out default/placeholder images + if (photoUrl.includes('default') || photoUrl.includes('silhouette')) { + photoUrl = undefined; + } + console.log(`[augment] WikiTree Photo URL: ${photoUrl}`); + } + + return { title, description, photoUrl, wikiTreeId }; + }, + + async linkWikiTree(personId: string, wikiTreeUrl: string): Promise<PersonAugmentation> { + console.log(`[augment] Linking WikiTree for ${personId}: ${wikiTreeUrl}`); + + // Parse the WikiTree URL + const wikiTreeId = this.parseWikiTreeUrl(wikiTreeUrl); + if (!wikiTreeId) { + throw new Error('Invalid WikiTree URL format. Expected: https://www.wikitree.com/wiki/Surname-12345'); + } + + // Scrape WikiTree data + const wikiTreeData = await this.scrapeWikiTree(wikiTreeUrl); + console.log(`[augment] Scraped WikiTree: ${wikiTreeData.title}`); + + // Get existing augmentation or create new + const existing = this.getAugmentation(personId) || { + id: personId, + platforms: [], + photos: [], + descriptions: [], + updatedAt: new Date().toISOString(), + }; + + // Add or update WikiTree platform reference + const existingPlatform = existing.platforms.find(p => p.platform === 'wikitree'); + if (existingPlatform) { + existingPlatform.url = wikiTreeUrl; + existingPlatform.externalId = wikiTreeId; + existingPlatform.linkedAt = new Date().toISOString(); + if (wikiTreeData.photoUrl) existingPlatform.photoUrl = wikiTreeData.photoUrl; + } else { + existing.platforms.push({ + platform: 'wikitree', + url: wikiTreeUrl, + externalId: wikiTreeId, + linkedAt: new Date().toISOString(), + photoUrl: wikiTreeData.photoUrl, + }); + } + + // Add or update WikiTree description + if (wikiTreeData.description) { + const existingDesc = existing.descriptions.find(d => d.source === 'wikitree'); + if (existingDesc) { + existingDesc.text = wikiTreeData.description; + } else { + existing.descriptions.push({ + text: wikiTreeData.description, + source: 'wikitree', + language: 'en', + }); + } + } + + existing.updatedAt = new Date().toISOString(); + this.saveAugmentation(existing); + return existing; + }, + + /** + * Fetch and download photo from a linked platform, making it the primary photo + */ + async fetchPhotoFromPlatform(personId: string, platform: PlatformType): Promise<PersonAugmentation> { + console.log(`[augment] Fetching photo from ${platform} for ${personId}`); + + const existing = this.getAugmentation(personId); + if (!existing) { + throw new Error('No augmentation data found for this person'); + } + + const platformRef = existing.platforms.find(p => p.platform === platform); + if (!platformRef) { + throw new Error(`Platform ${platform} is not linked to this person`); + } + + let photoUrl = platformRef.photoUrl; + + // Special case for FamilySearch - use the already-scraped photo + if (platform === 'familysearch') { + const fsJpgPath = path.join(PHOTOS_DIR, `${personId}.jpg`); + const fsPngPath = path.join(PHOTOS_DIR, `${personId}.png`); + const fsPhotoPath = fs.existsSync(fsJpgPath) ? fsJpgPath : fs.existsSync(fsPngPath) ? fsPngPath : null; + + if (!fsPhotoPath) { + throw new Error('No FamilySearch photo available for this person'); + } + + // FamilySearch photo already exists, just set it as primary + existing.photos.forEach(p => p.isPrimary = false); + + const existingPhoto = existing.photos.find(p => p.source === 'familysearch'); + if (existingPhoto) { + existingPhoto.localPath = fsPhotoPath; + existingPhoto.isPrimary = true; + } else { + existing.photos.push({ + url: `/api/browser/photos/${personId}`, + source: 'familysearch', + localPath: fsPhotoPath, + isPrimary: true, + }); + } + + existing.updatedAt = new Date().toISOString(); + this.saveAugmentation(existing); + return existing; + } + + // If no stored photoUrl, try to re-scrape it + if (!photoUrl) { + if (platform === 'wikipedia') { + const wikiData = await this.scrapeWikipedia(platformRef.url); + photoUrl = wikiData.photoUrl; + } else if (platform === 'wikitree') { + const wikiTreeData = await this.scrapeWikiTree(platformRef.url); + photoUrl = wikiTreeData.photoUrl; + } else if (platform === 'ancestry') { + // For Ancestry, we need to use the browser to re-scrape + console.log(`[augment] Re-scraping Ancestry photo for ${personId}`); + photoUrl = await this.scrapeAncestryPhoto(platformRef.url); + } + + // Update the stored photoUrl + if (photoUrl) { + platformRef.photoUrl = photoUrl; + } + } + + if (!photoUrl) { + throw new Error(`No photo available from ${platform}`); + } + + // Determine file extension and path + const ext = photoUrl.toLowerCase().includes('.png') ? 'png' : 'jpg'; + const photoPath = path.join(PHOTOS_DIR, `${personId}-${platform}.${ext}`); + + // Download the photo + console.log(`[augment] Downloading photo from ${photoUrl}`); + await downloadImage(photoUrl, photoPath); + + if (!fs.existsSync(photoPath)) { + throw new Error(`Failed to download photo from ${platform}`); + } + + console.log(`[augment] Downloaded ${platform} photo to ${photoPath}`); + + // Update or create photo entry and set as primary + // First, unset any existing primary + existing.photos.forEach(p => p.isPrimary = false); + + const existingPhoto = existing.photos.find(p => p.source === platform); + if (existingPhoto) { + existingPhoto.url = photoUrl; + existingPhoto.localPath = photoPath; + existingPhoto.downloadedAt = new Date().toISOString(); + existingPhoto.isPrimary = true; + } else { + existing.photos.push({ + url: photoUrl, + source: platform, + localPath: photoPath, + downloadedAt: new Date().toISOString(), + isPrimary: true, + }); + } + + existing.updatedAt = new Date().toISOString(); + this.saveAugmentation(existing); + return existing; + }, + /** * Check if a platform is linked for a person */ diff --git a/server/src/services/sparse-tree.service.ts b/server/src/services/sparse-tree.service.ts index 1b2ca7e..f491be9 100644 --- a/server/src/services/sparse-tree.service.ts +++ b/server/src/services/sparse-tree.service.ts @@ -60,16 +60,28 @@ function getFavoriteData(personId: string): FavoriteData | null { /** * Get photo URL for a person + * Priority: Ancestry > WikiTree > Wikipedia > FamilySearch scraped */ function getPhotoUrl(personId: string): string | undefined { - // Check for augmentation file with wiki photo - const filePath = path.join(AUGMENT_DIR, `${personId}.json`); - if (fs.existsSync(filePath)) { - const data: PersonAugmentation = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - const wikiPhoto = data.photos?.find(p => p.source === 'wikipedia'); - if (wikiPhoto?.localPath && fs.existsSync(wikiPhoto.localPath)) { - return `/api/augment/${personId}/wiki-photo`; - } + // Check for Ancestry photo (highest priority) + const ancestryJpgPath = path.join(PHOTOS_DIR, `${personId}-ancestry.jpg`); + const ancestryPngPath = path.join(PHOTOS_DIR, `${personId}-ancestry.png`); + if (fs.existsSync(ancestryJpgPath) || fs.existsSync(ancestryPngPath)) { + return `/api/augment/${personId}/ancestry-photo`; + } + + // Check for WikiTree photo + const wikiTreeJpgPath = path.join(PHOTOS_DIR, `${personId}-wikitree.jpg`); + const wikiTreePngPath = path.join(PHOTOS_DIR, `${personId}-wikitree.png`); + if (fs.existsSync(wikiTreeJpgPath) || fs.existsSync(wikiTreePngPath)) { + return `/api/augment/${personId}/wikitree-photo`; + } + + // Check for Wikipedia photo + const wikiJpgPath = path.join(PHOTOS_DIR, `${personId}-wiki.jpg`); + const wikiPngPath = path.join(PHOTOS_DIR, `${personId}-wiki.png`); + if (fs.existsSync(wikiJpgPath) || fs.existsSync(wikiPngPath)) { + return `/api/augment/${personId}/wiki-photo`; } // Check for scraped FamilySearch photo @@ -124,19 +136,16 @@ export const sparseTreeService = { } } - // Build a tree structure from all paths - // First, collect all nodes we need to show (favorites only) const favoriteIds = new Set(favoritesInDb.map(f => f.personId)); - // Build tree recursively - only show favorites, track generation skips + // Build a full tree structure from all paths interface TreeBuildNode { id: string; generation: number; children: Map<string, TreeBuildNode>; } - // Create intermediate tree structure with all path nodes - const rootNode: TreeBuildNode = { + const fullTree: TreeBuildNode = { id: rootId, generation: 0, children: new Map(), @@ -144,7 +153,7 @@ export const sparseTreeService = { // Add all paths to tree for (const [, pathArr] of paths) { - let current = rootNode; + let current = fullTree; for (let i = 1; i < pathArr.length; i++) { const nodeId = pathArr[i]; if (!current.children.has(nodeId)) { @@ -158,30 +167,52 @@ export const sparseTreeService = { } } - // Convert to SparseTreeNode, only keeping favorites (but showing generation skips) - const convertToSparseNode = ( - node: TreeBuildNode, - lastVisibleGeneration: number - ): SparseTreeNode | null => { + // Find nodes that should be shown: favorites, root, and branch points (non-favorites with 2+ children leading to favorites) + const nodesToShow = new Set<string>([rootId, ...favoriteIds]); + + const findBranchPoints = (node: TreeBuildNode): boolean => { + // Returns true if this node has any favorite descendants + if (favoriteIds.has(node.id)) return true; + + let branchesWithFavorites = 0; + for (const [, child] of node.children) { + if (findBranchPoints(child)) { + branchesWithFavorites++; + } + } + + // If this non-favorite node has 2+ branches leading to favorites, it's a branch point + if (branchesWithFavorites >= 2 && !favoriteIds.has(node.id) && node.id !== rootId) { + nodesToShow.add(node.id); + } + + return branchesWithFavorites > 0; + }; + + findBranchPoints(fullTree); + + // Build sparse tree showing only selected nodes + const buildSparseNode = (node: TreeBuildNode, lastShownGeneration: number): SparseTreeNode | null => { + const shouldShow = nodesToShow.has(node.id); const person = db[node.id]; const favorite = getFavoriteData(node.id); - const isFavorite = favoriteIds.has(node.id) || node.id === rootId; - - // Collect all children that lead to favorites - const childNodes: SparseTreeNode[] = []; + // Recursively build children + const childResults: SparseTreeNode[] = []; for (const [, child] of node.children) { - const childResult = convertToSparseNode(child, isFavorite ? node.generation : lastVisibleGeneration); + const childResult = buildSparseNode(child, shouldShow ? node.generation : lastShownGeneration); if (childResult) { - childNodes.push(childResult); + // If child is a "pass-through" (not shown), merge its children up + if (Array.isArray(childResult.children) && !nodesToShow.has(childResult.id)) { + childResults.push(...childResult.children); + } else { + childResults.push(childResult); + } } } - // Only include this node if it's a favorite or root, or if it has multiple children (branch point) - // For now, only show favorites - if (isFavorite || childNodes.length > 1) { - const generationsSkipped = node.generation - lastVisibleGeneration - 1; - + if (shouldShow) { + const generationsSkipped = node.generation - lastShownGeneration - 1; return { id: node.id, name: person?.name || node.id, @@ -192,35 +223,29 @@ export const sparseTreeService = { generationFromRoot: node.generation, generationsSkipped: generationsSkipped > 0 ? generationsSkipped : undefined, isFavorite: favoriteIds.has(node.id), - children: childNodes.length > 0 ? childNodes : undefined, + children: childResults.length > 0 ? childResults : undefined, }; } - // If this is not a visible node, pass through children - if (childNodes.length === 1) { - return childNodes[0]; + // Not shown - pass children through + if (childResults.length === 1) { + return childResults[0]; } - - if (childNodes.length > 1) { - // Branch point - we need to show it + if (childResults.length > 1) { + // Return a placeholder to pass multiple children up return { id: node.id, - name: person?.name || node.id, - lifespan: person?.lifespan || '', - photoUrl: getPhotoUrl(node.id), + name: '', + lifespan: '', generationFromRoot: node.generation, - generationsSkipped: node.generation - lastVisibleGeneration - 1 > 0 - ? node.generation - lastVisibleGeneration - 1 - : undefined, isFavorite: false, - children: childNodes, + children: childResults, }; } - return null; }; - const sparseRoot = convertToSparseNode(rootNode, -1); + const sparseRoot = buildSparseNode(fullTree, -1); // Calculate max generation let maxGeneration = 0; diff --git a/shared/src/index.ts b/shared/src/index.ts index 4a6c84e..46f69d6 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -230,6 +230,7 @@ export interface PlatformReference { externalId?: string; // Platform-specific ID linkedAt: string; // When we linked it verified?: boolean; // Manual verification flag + photoUrl?: string; // Photo URL discovered from this platform (not yet downloaded) } // Photo from any source @@ -458,7 +459,9 @@ export interface AncestryFamilyUnit { father?: AncestryPersonCard; mother?: AncestryPersonCard; generation: number; - parentUnits?: AncestryFamilyUnit[]; + // Separate parent units for each parent's ancestry line + fatherParentUnits?: AncestryFamilyUnit[]; + motherParentUnits?: AncestryFamilyUnit[]; } // Full ancestry tree result From 18f5582bbb1ff11bf7042436977d9dbadea042fe Mon Sep 17 00:00:00 2001 From: Adam Eivy <atomantic@gmail.com> Date: Wed, 21 Jan 2026 21:55:20 -0800 Subject: [PATCH 05/27] Add tsbuildinfo to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index df59020..f9d3a77 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ node_modules/ server/dist/ client/dist/ shared/types/ +*.tsbuildinfo # IDE .vscode/ From 05387c3a5626057b78030165358dfbc46867fea5 Mon Sep 17 00:00:00 2001 From: Adam Eivy <atomantic@gmail.com> Date: Wed, 21 Jan 2026 21:56:10 -0800 Subject: [PATCH 06/27] Remove tsbuildinfo from tracking (now in gitignore) --- client/tsconfig.tsbuildinfo | 1 - 1 file changed, 1 deletion(-) delete mode 100644 client/tsconfig.tsbuildinfo diff --git a/client/tsconfig.tsbuildinfo b/client/tsconfig.tsbuildinfo deleted file mode 100644 index 08aa217..0000000 --- a/client/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/components/dashboard.tsx","./src/components/ancestry-tree/ancestrytreeview.tsx","./src/components/ancestry-tree/connectionline.tsx","./src/components/ancestry-tree/familyunitcard.tsx","./src/components/ancestry-tree/personcard.tsx","./src/components/ancestry-tree/index.ts","./src/components/favorites/favoritebutton.tsx","./src/components/favorites/favoritespage.tsx","./src/components/favorites/sparsetreepage.tsx","./src/components/favorites/whyinterestingmodal.tsx","./src/components/indexer/indexerpage.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/path/pathfinder.tsx","./src/components/person/persondetail.tsx","./src/components/providers/credentialsmodal.tsx","./src/components/search/searchpage.tsx","./src/components/tree/treeview.tsx","./src/context/sidebarcontext.tsx","./src/context/themecontext.tsx","./src/pages/aiproviders.tsx","./src/pages/browsersettingspage.tsx","./src/pages/gedcompage.tsx","./src/pages/genealogyprovideredit.tsx","./src/pages/genealogyproviders.tsx","./src/pages/providerspage.tsx","./src/services/api.ts","./src/types/portos-ai-toolkit.d.ts"],"version":"5.9.3"} \ No newline at end of file From 16cc4d7978719f20fba45ea816d4131a50fac4e7 Mon Sep 17 00:00:00 2001 From: Adam Eivy <atomantic@gmail.com> Date: Wed, 21 Jan 2026 22:11:52 -0800 Subject: [PATCH 07/27] Add multiple tree view modes with lazy loading for unlimited generations - Add four visualization modes: Focus Navigator, Pedigree Chart, Generational Columns, and Classic SVG tree - Implement lazy loading to fetch additional generations on demand - Simplify generation labels (1st/2nd/3rd Great-Grandparents) - Hide unknown ancestor placeholders in columns view - Fix transparent header causing text collision when scrolling - Persist view mode in URL query params (?view=columns) --- CHANGELOG.md | 19 + .../ancestry-tree/AncestryTreeView.tsx | 397 +++++++++--------- client/src/components/ancestry-tree/index.ts | 7 +- .../views/FocusNavigatorView.tsx | 233 ++++++++++ .../views/GenerationalColumnsView.tsx | 302 +++++++++++++ .../ancestry-tree/views/PedigreeChartView.tsx | 207 +++++++++ .../components/ancestry-tree/views/index.ts | 11 + package.json | 2 +- 8 files changed, 966 insertions(+), 212 deletions(-) create mode 100644 client/src/components/ancestry-tree/views/FocusNavigatorView.tsx create mode 100644 client/src/components/ancestry-tree/views/GenerationalColumnsView.tsx create mode 100644 client/src/components/ancestry-tree/views/PedigreeChartView.tsx create mode 100644 client/src/components/ancestry-tree/views/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ef4c17..365595f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to this project will be documented in this file. +## [0.2.1] - 2026-01-21 + +### Added +- **Multiple Tree View Modes**: Redesigned ancestry tree with four visualization options + - Focus Navigator: Navigate one person at a time with breadcrumb trail + - Pedigree Chart: Classic vertical tree with expandable generations + - Generational Columns: Horizontal columns organized by generation + - Classic: Original SVG-based tree with zoom/pan +- **Lazy Loading for Deep Ancestry**: Load unlimited generations on demand + - Initial load of 10 generations for columns view + - "Load 5 more" button appears when more ancestors are available + - Successfully tested loading 21 generations with 17,000+ ancestors +- **View Mode URL Persistence**: Tree view mode saved in URL query params (`?view=columns`) + +### Changed +- **Simplified Generation Labels**: Gen 3+ shows "1st/2nd/3rd Great-Grandparents" instead of verbose labels +- **Columns View Optimization**: Only displays known ancestors (hides unknown placeholders) +- **Header Styling**: Fixed transparent header in columns view to prevent text collision when scrolling + ## [0.2.0] - 2026-01-21 ### Added diff --git a/client/src/components/ancestry-tree/AncestryTreeView.tsx b/client/src/components/ancestry-tree/AncestryTreeView.tsx index 5f9399d..025a3ae 100644 --- a/client/src/components/ancestry-tree/AncestryTreeView.tsx +++ b/client/src/components/ancestry-tree/AncestryTreeView.tsx @@ -1,10 +1,31 @@ +/** + * Ancestry Tree View + * + * Main tree view component with multiple visualization modes: + * - Focus: Navigate one person at a time with breadcrumb trail + * - Pedigree: Classic vertical tree chart + * - Columns: Horizontal generational columns + * - Classic: Original SVG-based horizontal tree with zoom/pan + */ import { useEffect, useRef, useState, useCallback } from 'react'; -import { useParams, Link } from 'react-router-dom'; +import { useParams, Link, useSearchParams } from 'react-router-dom'; import * as d3 from 'd3'; import type { AncestryTreeResult, AncestryFamilyUnit, ExpandAncestryRequest } from '@fsf/shared'; import { api } from '../../services/api'; import { PersonCard } from './PersonCard'; import { FamilyUnitCard } from './FamilyUnitCard'; +import { FocusNavigatorView } from './views/FocusNavigatorView'; +import { PedigreeChartView } from './views/PedigreeChartView'; +import { GenerationalColumnsView } from './views/GenerationalColumnsView'; + +type ViewMode = 'focus' | 'pedigree' | 'columns' | 'classic'; + +const VIEW_MODES: { id: ViewMode; label: string; icon: string; description: string }[] = [ + { id: 'focus', label: 'Focus', icon: '\u{1F3AF}', description: 'Navigate one person at a time' }, + { id: 'pedigree', label: 'Pedigree', icon: '\u{1F333}', description: 'Classic family tree chart' }, + { id: 'columns', label: 'Columns', icon: '\u{1F4CA}', description: 'Generations in columns' }, + { id: 'classic', label: 'Classic', icon: '\u{1F4D0}', description: 'Original SVG tree view' }, +]; interface RootLinePositions { totalHeight: number; @@ -13,6 +34,7 @@ interface RootLinePositions { export function AncestryTreeView() { const { dbId, personId } = useParams<{ dbId: string; personId?: string }>(); + const [searchParams, setSearchParams] = useSearchParams(); const [treeData, setTreeData] = useState<AncestryTreeResult | null>(null); const [rootId, setRootId] = useState<string | null>(personId || null); const [loading, setLoading] = useState(true); @@ -22,11 +44,15 @@ export function AncestryTreeView() { const contentRef = useRef<HTMLDivElement>(null); const zoomRef = useRef<d3.ZoomBehavior<HTMLDivElement, unknown> | null>(null); const [pendingCenterId, setPendingCenterId] = useState<string | null>(null); - - // Refs for root-level SVG line calculations const parentUnitsContainerRef = useRef<HTMLDivElement>(null); const [rootLinePositions, setRootLinePositions] = useState<RootLinePositions>({ totalHeight: 400, unitPositions: [] }); + const viewMode = (searchParams.get('view') as ViewMode) || 'focus'; + + const setViewMode = (mode: ViewMode) => { + setSearchParams({ view: mode }); + }; + // Get database info to find root if no personId provided useEffect(() => { if (!personId && dbId) { @@ -43,17 +69,18 @@ export function AncestryTreeView() { setLoading(true); setError(null); - api.getAncestryTree(dbId, rootId, 4) - .then(data => { - setTreeData(data); - }) + // Load more generations - columns view benefits from more data + const generations = viewMode === 'columns' ? 10 : viewMode === 'classic' ? 4 : 8; + + api.getAncestryTree(dbId, rootId, generations) + .then(data => setTreeData(data)) .catch(err => setError(err.message)) .finally(() => setLoading(false)); - }, [dbId, rootId]); + }, [dbId, rootId, viewMode]); - // Calculate line positions for root-level parent units + // Calculate line positions for root-level parent units (classic view) useEffect(() => { - if (!parentUnitsContainerRef.current || !treeData?.parentUnits) return; + if (viewMode !== 'classic' || !parentUnitsContainerRef.current || !treeData?.parentUnits) return; const calculatePositions = () => { const container = parentUnitsContainerRef.current; @@ -62,7 +89,6 @@ export function AncestryTreeView() { const totalHeight = container.offsetHeight; const positions: number[] = []; - // Get each parent unit element const unitElements = container.querySelectorAll('[data-parent-unit]'); unitElements.forEach((el) => { const htmlEl = el as HTMLElement; @@ -73,19 +99,16 @@ export function AncestryTreeView() { setRootLinePositions({ totalHeight, unitPositions: positions }); }; - // Calculate after render const timeoutId = setTimeout(calculatePositions, 100); - - // Recalculate on window resize window.addEventListener('resize', calculatePositions); return () => { clearTimeout(timeoutId); window.removeEventListener('resize', calculatePositions); }; - }, [treeData]); + }, [treeData, viewMode]); - // Handle expanding a node + // Handle expanding a node (classic view) const handleExpand = useCallback(async (request: ExpandAncestryRequest, nodeId: string) => { if (!dbId || expandingNodes.has(nodeId)) return; @@ -104,48 +127,29 @@ export function AncestryTreeView() { if (!expandedData || !treeData) return; - // Update tree data with expanded node setTreeData(prevData => { if (!prevData) return prevData; - // Deep clone the tree data const newData = JSON.parse(JSON.stringify(prevData)) as AncestryTreeResult; - // Find and update the family unit that needs expansion const updateUnit = (units: AncestryFamilyUnit[] | undefined): boolean => { if (!units) return false; for (const unit of units) { - // Check if this unit contains the person being expanded if (unit.father?.id === request.fatherId) { - // Add to father's parent units - if (!unit.fatherParentUnits) { - unit.fatherParentUnits = []; - } + if (!unit.fatherParentUnits) unit.fatherParentUnits = []; unit.fatherParentUnits.push(expandedData); - - // Update hasMoreAncestors flag - if (unit.father) { - unit.father.hasMoreAncestors = false; - } + if (unit.father) unit.father.hasMoreAncestors = false; return true; } if (unit.mother?.id === request.motherId) { - // Add to mother's parent units - if (!unit.motherParentUnits) { - unit.motherParentUnits = []; - } + if (!unit.motherParentUnits) unit.motherParentUnits = []; unit.motherParentUnits.push(expandedData); - - // Update hasMoreAncestors flag - if (unit.mother) { - unit.mother.hasMoreAncestors = false; - } + if (unit.mother) unit.mother.hasMoreAncestors = false; return true; } - // Recursively check child units if (updateUnit(unit.fatherParentUnits)) return true; if (updateUnit(unit.motherParentUnits)) return true; } @@ -154,20 +158,16 @@ export function AncestryTreeView() { }; updateUnit(newData.parentUnits); - return newData; }); - // Set the ID to center on after render const personIdToCenter = request.fatherId || request.motherId; - if (personIdToCenter) { - setPendingCenterId(personIdToCenter); - } + if (personIdToCenter) setPendingCenterId(personIdToCenter); }, [dbId, expandingNodes, treeData]); - // Setup D3 zoom behavior + // Setup D3 zoom behavior (classic view) useEffect(() => { - if (!containerRef.current || !contentRef.current) return; + if (viewMode !== 'classic' || !containerRef.current || !contentRef.current) return; const container = d3.select(containerRef.current); const content = d3.select(contentRef.current); @@ -182,42 +182,35 @@ export function AncestryTreeView() { container.call(zoom); zoomRef.current = zoom; - // Set initial transform to position root at left and vertically centered const initialX = 80; - const initialY = -100; // Start higher to center the tree vertically - + const initialY = -100; container.call(zoom.transform, d3.zoomIdentity.translate(initialX, initialY).scale(0.45)); return () => { container.on('.zoom', null); }; - }, [treeData]); + }, [treeData, viewMode]); - // Center on expanded node after render + // Center on expanded node after render (classic view) useEffect(() => { if (!pendingCenterId || !containerRef.current || !contentRef.current || !zoomRef.current) return; - // Find the element with the person ID const personElement = contentRef.current.querySelector(`[data-person-id="${pendingCenterId}"]`); if (!personElement) { setPendingCenterId(null); return; } - // Get positions const containerRect = containerRef.current.getBoundingClientRect(); const elementRect = personElement.getBoundingClientRect(); const contentRect = contentRef.current.getBoundingClientRect(); - // Calculate where the element is relative to content origin const elementX = elementRect.left - contentRect.left + elementRect.width / 2; const elementY = elementRect.top - contentRect.top + elementRect.height / 2; - // Calculate transform to center this element in the container const targetX = containerRect.width / 2 - elementX; const targetY = containerRect.height / 2 - elementY; - // Apply the transform with animation const containerSelection = d3.select(containerRef.current); containerSelection.transition() .duration(500) @@ -226,7 +219,7 @@ export function AncestryTreeView() { setPendingCenterId(null); }, [pendingCenterId, treeData]); - // Render a list of parent units + // Render functions for classic view const renderParentUnits = (units: AncestryFamilyUnit[], depth: number): JSX.Element => { return ( <div className="flex flex-col gap-4"> @@ -235,11 +228,7 @@ export function AncestryTreeView() { ); }; - // Recursive component to render family units - const renderFamilyUnit = ( - unit: AncestryFamilyUnit, - depth: number - ): JSX.Element => { + const renderFamilyUnit = (unit: AncestryFamilyUnit, depth: number): JSX.Element => { const nodeId = unit.id; const isExpandingFather = unit.father?.id ? expandingNodes.has(`expand_${unit.father.id}`) : false; const isExpandingMother = unit.mother?.id ? expandingNodes.has(`expand_${unit.mother.id}`) : false; @@ -268,6 +257,7 @@ export function AncestryTreeView() { ); }; + // Loading state if (loading) { return ( <div className="flex items-center justify-center h-full"> @@ -279,15 +269,13 @@ export function AncestryTreeView() { ); } + // Error state if (error) { return ( <div className="flex items-center justify-center h-full"> <div className="text-center"> <p className="text-app-error mb-4">Error: {error}</p> - <Link - to="/" - className="px-4 py-2 bg-app-border text-app-text-secondary rounded hover:bg-app-hover" - > + <Link to="/" className="px-4 py-2 bg-app-border text-app-text-secondary rounded hover:bg-app-hover"> Back to Dashboard </Link> </div> @@ -295,12 +283,11 @@ export function AncestryTreeView() { ); } + // No data state if (!treeData) { return ( <div className="flex items-center justify-center h-full"> - <div className="text-center"> - <p className="text-app-text-muted">No tree data available</p> - </div> + <p className="text-app-text-muted">No tree data available</p> </div> ); } @@ -310,159 +297,149 @@ export function AncestryTreeView() { return ( <div className="h-full flex flex-col"> - {/* Header */} - <div className="flex items-center justify-between mb-4 px-4 pt-4"> - <h1 className="text-2xl font-bold text-app-text">Ancestry Tree</h1> + {/* Header with view switcher */} + <div className="flex items-center justify-between px-4 py-3 border-b border-app-border bg-app-card"> + <div className="flex items-center gap-4"> + <h1 className="text-xl font-bold text-app-text">Ancestry Tree</h1> + <span className="text-sm text-app-text-muted">{treeData.rootPerson.name}</span> + </div> + + {/* View mode switcher */} + <div className="flex items-center gap-1 bg-app-bg rounded-lg p-1"> + {VIEW_MODES.map(mode => ( + <button + key={mode.id} + onClick={() => setViewMode(mode.id)} + className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors ${ + viewMode === mode.id + ? 'bg-app-card text-app-text shadow-sm' + : 'text-app-text-muted hover:text-app-text hover:bg-app-card/50' + }`} + title={mode.description} + > + <span>{mode.icon}</span> + <span className="hidden sm:inline">{mode.label}</span> + </button> + ))} + </div> + <div className="flex gap-2"> - <Link - to={`/search/${dbId}`} - className="px-3 py-1 bg-app-border text-app-text-secondary rounded hover:bg-app-hover text-sm" - > + <Link to={`/search/${dbId}`} className="px-3 py-1 bg-app-border text-app-text-secondary rounded hover:bg-app-hover text-sm"> Search </Link> - <Link - to={`/path/${dbId}`} - className="px-3 py-1 bg-app-border text-app-text-secondary rounded hover:bg-app-hover text-sm" - > + <Link to={`/path/${dbId}`} className="px-3 py-1 bg-app-border text-app-text-secondary rounded hover:bg-app-hover text-sm"> Find Path </Link> </div> </div> - {/* Tree container with zoom/pan */} - <div - ref={containerRef} - className="flex-1 bg-tree-bg rounded-lg border border-app-border overflow-hidden cursor-grab active:cursor-grabbing" - style={{ minHeight: '600px' }} - > - <div ref={contentRef} className="p-8"> - {/* Tree visualization - horizontal layout */} - <div className="flex items-center"> - {/* Root person section */} - <div className="flex flex-col items-start flex-shrink-0"> - <PersonCard - person={treeData.rootPerson} - dbId={dbId!} - /> - {treeData.rootSpouse && ( - <div className="mt-2"> - <PersonCard - person={treeData.rootSpouse} - dbId={dbId!} - /> - </div> - )} - </div> - - {/* Parent units with SVG connector */} - {hasParents && ( - <div className="flex items-stretch"> - {/* SVG connector lines */} - <svg - width="48" - height={totalHeight || 400} - className="flex-shrink-0" - style={{ minHeight: `${totalHeight || 400}px` }} - > - {/* Horizontal line from root (at vertical center) */} - <line - x1="0" - y1={totalHeight / 2} - x2="24" - y2={totalHeight / 2} - stroke="var(--color-tree-line)" - strokeWidth="2" - /> - - {/* Vertical trunk line - from first unit to last unit */} - {unitPositions.length > 1 && ( - <line - x1="24" - y1={unitPositions[0]} - x2="24" - y2={unitPositions[unitPositions.length - 1]} - stroke="var(--color-tree-line)" - strokeWidth="2" - /> - )} - - {/* Connect center to trunk */} - {unitPositions.length > 0 && ( - <> - {/* If center is above the trunk top */} - {totalHeight / 2 < unitPositions[0] && ( - <line - x1="24" - y1={totalHeight / 2} - x2="24" - y2={unitPositions[0]} - stroke="var(--color-tree-line)" - strokeWidth="2" - /> - )} - {/* If center is below the trunk bottom */} - {unitPositions.length > 1 && totalHeight / 2 > unitPositions[unitPositions.length - 1] && ( - <line - x1="24" - y1={unitPositions[unitPositions.length - 1]} - x2="24" - y2={totalHeight / 2} - stroke="var(--color-tree-line)" - strokeWidth="2" - /> - )} - {/* Single unit case */} - {unitPositions.length === 1 && ( - <line - x1="24" - y1={Math.min(totalHeight / 2, unitPositions[0])} - x2="24" - y2={Math.max(totalHeight / 2, unitPositions[0])} - stroke="var(--color-tree-line)" - strokeWidth="2" - /> - )} - </> - )} - - {/* Horizontal branches to each parent unit */} - {unitPositions.map((y, i) => ( - <line - key={i} - x1="24" - y1={y} - x2="48" - y2={y} - stroke="var(--color-tree-line)" - strokeWidth="2" - /> - ))} - </svg> - - {/* Parent units container */} - <div ref={parentUnitsContainerRef} className="flex flex-col gap-4"> - {treeData.parentUnits!.map((unit) => ( - <div key={unit.id} data-parent-unit className="flex items-center"> - {renderFamilyUnit(unit, 1)} + {/* View content */} + <div className="flex-1 overflow-hidden"> + {viewMode === 'focus' && ( + <FocusNavigatorView data={treeData} dbId={dbId!} /> + )} + + {viewMode === 'pedigree' && ( + <PedigreeChartView data={treeData} dbId={dbId!} /> + )} + + {viewMode === 'columns' && ( + <GenerationalColumnsView + data={treeData} + dbId={dbId!} + onLoadMore={async (newDepth: number) => { + if (!dbId || !rootId) return; + setLoading(true); + const data = await api.getAncestryTree(dbId, rootId, newDepth).catch(err => { + console.error('Failed to load more generations:', err); + return null; + }); + if (data) setTreeData(data); + setLoading(false); + }} + /> + )} + + {viewMode === 'classic' && ( + <div className="h-full flex flex-col"> + {/* Classic tree container with zoom/pan */} + <div + ref={containerRef} + className="flex-1 bg-tree-bg rounded-lg border border-app-border overflow-hidden cursor-grab active:cursor-grabbing m-4" + style={{ minHeight: '600px' }} + > + <div ref={contentRef} className="p-8"> + <div className="flex items-center"> + {/* Root person section */} + <div className="flex flex-col items-start flex-shrink-0"> + <PersonCard person={treeData.rootPerson} dbId={dbId!} /> + {treeData.rootSpouse && ( + <div className="mt-2"> + <PersonCard person={treeData.rootSpouse} dbId={dbId!} /> + </div> + )} + </div> + + {/* Parent units with SVG connector */} + {hasParents && ( + <div className="flex items-stretch"> + <svg + width="48" + height={totalHeight || 400} + className="flex-shrink-0" + style={{ minHeight: `${totalHeight || 400}px` }} + > + <line x1="0" y1={totalHeight / 2} x2="24" y2={totalHeight / 2} stroke="var(--color-tree-line)" strokeWidth="2" /> + + {unitPositions.length > 1 && ( + <line x1="24" y1={unitPositions[0]} x2="24" y2={unitPositions[unitPositions.length - 1]} stroke="var(--color-tree-line)" strokeWidth="2" /> + )} + + {unitPositions.length > 0 && ( + <> + {totalHeight / 2 < unitPositions[0] && ( + <line x1="24" y1={totalHeight / 2} x2="24" y2={unitPositions[0]} stroke="var(--color-tree-line)" strokeWidth="2" /> + )} + {unitPositions.length > 1 && totalHeight / 2 > unitPositions[unitPositions.length - 1] && ( + <line x1="24" y1={unitPositions[unitPositions.length - 1]} x2="24" y2={totalHeight / 2} stroke="var(--color-tree-line)" strokeWidth="2" /> + )} + {unitPositions.length === 1 && ( + <line x1="24" y1={Math.min(totalHeight / 2, unitPositions[0])} x2="24" y2={Math.max(totalHeight / 2, unitPositions[0])} stroke="var(--color-tree-line)" strokeWidth="2" /> + )} + </> + )} + + {unitPositions.map((y, i) => ( + <line key={i} x1="24" y1={y} x2="48" y2={y} stroke="var(--color-tree-line)" strokeWidth="2" /> + ))} + </svg> + + <div ref={parentUnitsContainerRef} className="flex flex-col gap-4"> + {treeData.parentUnits!.map((unit) => ( + <div key={unit.id} data-parent-unit className="flex items-center"> + {renderFamilyUnit(unit, 1)} + </div> + ))} + </div> </div> - ))} + )} </div> </div> - )} - </div> - </div> - </div> + </div> - {/* Info footer */} - <div className="px-4 py-2 text-xs text-app-text-subtle flex items-center gap-4"> - <span>Scroll to zoom • Drag to pan</span> - <span>•</span> - <span>Generations loaded: {treeData.maxGenerationLoaded}</span> - <span>•</span> - <span className="flex items-center gap-2"> - <span className="w-3 h-3 border-l-2 border-app-male" /> Male - <span className="w-3 h-3 border-l-2 border-app-female ml-2" /> Female - </span> + {/* Classic view footer */} + <div className="px-4 py-2 text-xs text-app-text-subtle flex items-center gap-4"> + <span>Scroll to zoom | Drag to pan</span> + <span>|</span> + <span>Generations loaded: {treeData.maxGenerationLoaded}</span> + <span>|</span> + <span className="flex items-center gap-2"> + <span className="w-3 h-3 border-l-2 border-app-male" /> Male + <span className="w-3 h-3 border-l-2 border-app-female ml-2" /> Female + </span> + </div> + </div> + )} </div> </div> ); diff --git a/client/src/components/ancestry-tree/index.ts b/client/src/components/ancestry-tree/index.ts index d2561fb..f9ab872 100644 --- a/client/src/components/ancestry-tree/index.ts +++ b/client/src/components/ancestry-tree/index.ts @@ -1,4 +1,9 @@ -export { AncestryTreeViewNew as AncestryTreeView } from './AncestryTreeViewNew'; +export { AncestryTreeView } from './AncestryTreeView'; export { PersonCard } from './PersonCard'; export { FamilyUnitCard } from './FamilyUnitCard'; export { ConnectionLine, FamilyConnection } from './ConnectionLine'; + +// View implementations +export { FocusNavigatorView } from './views/FocusNavigatorView'; +export { PedigreeChartView } from './views/PedigreeChartView'; +export { GenerationalColumnsView } from './views/GenerationalColumnsView'; diff --git a/client/src/components/ancestry-tree/views/FocusNavigatorView.tsx b/client/src/components/ancestry-tree/views/FocusNavigatorView.tsx new file mode 100644 index 0000000..dbbec27 --- /dev/null +++ b/client/src/components/ancestry-tree/views/FocusNavigatorView.tsx @@ -0,0 +1,233 @@ +/** + * Focus Navigator Tree View + * + * A navigation-focused tree view that shows one person at a time with their + * immediate parents above. Click a parent to navigate up the ancestry tree. + * Includes a breadcrumb trail for easy navigation back down. + */ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import type { AncestryTreeResult, AncestryPersonCard, AncestryFamilyUnit } from '@fsf/shared'; + +interface FocusNavigatorViewProps { + data: AncestryTreeResult; + dbId: string; +} + +interface PersonWithAncestors { + person: AncestryPersonCard; + parents?: AncestryFamilyUnit; +} + +// Build a lookup map from the tree data for quick person access +function buildPersonMap(data: AncestryTreeResult): Map<string, PersonWithAncestors> { + const map = new Map<string, PersonWithAncestors>(); + + map.set(data.rootPerson.id, { person: data.rootPerson }); + + const processUnits = (units: AncestryFamilyUnit[] | undefined, childId?: string) => { + if (!units) return; + + for (const unit of units) { + if (childId && map.has(childId)) { + const existing = map.get(childId)!; + existing.parents = unit; + } + + if (unit.father) { + if (!map.has(unit.father.id)) { + map.set(unit.father.id, { person: unit.father }); + } + processUnits(unit.fatherParentUnits, unit.father.id); + } + + if (unit.mother) { + if (!map.has(unit.mother.id)) { + map.set(unit.mother.id, { person: unit.mother }); + } + processUnits(unit.motherParentUnits, unit.mother.id); + } + } + }; + + if (data.parentUnits) { + for (const unit of data.parentUnits) { + const rootEntry = map.get(data.rootPerson.id)!; + rootEntry.parents = unit; + + if (unit.father) { + map.set(unit.father.id, { person: unit.father }); + processUnits(unit.fatherParentUnits, unit.father.id); + } + if (unit.mother) { + map.set(unit.mother.id, { person: unit.mother }); + processUnits(unit.motherParentUnits, unit.mother.id); + } + } + } + + return map; +} + +export function FocusNavigatorView({ data, dbId }: FocusNavigatorViewProps) { + const [focusedId, setFocusedId] = useState(data.rootPerson.id); + const [breadcrumb, setBreadcrumb] = useState<AncestryPersonCard[]>([data.rootPerson]); + const [personMap, setPersonMap] = useState<Map<string, PersonWithAncestors>>(new Map()); + + useEffect(() => { + setPersonMap(buildPersonMap(data)); + }, [data]); + + const focused = personMap.get(focusedId); + if (!focused) return <div className="p-4 text-app-text-muted">Loading...</div>; + + const father = focused.parents?.father; + const mother = focused.parents?.mother; + + const navigateTo = (person: AncestryPersonCard) => { + setFocusedId(person.id); + const existingIndex = breadcrumb.findIndex(p => p.id === person.id); + if (existingIndex >= 0) { + setBreadcrumb(breadcrumb.slice(0, existingIndex + 1)); + } else { + setBreadcrumb([...breadcrumb, person]); + } + }; + + const goBack = () => { + if (breadcrumb.length > 1) { + const newBreadcrumb = breadcrumb.slice(0, -1); + setBreadcrumb(newBreadcrumb); + setFocusedId(newBreadcrumb[newBreadcrumb.length - 1].id); + } + }; + + const isMale = (person: AncestryPersonCard) => person.gender === 'male'; + + return ( + <div className="h-full flex flex-col bg-app-bg"> + {/* Breadcrumb navigation */} + <div className="px-4 py-2 bg-app-card border-b border-app-border flex items-center gap-2 text-sm overflow-x-auto"> + {breadcrumb.map((person, i) => ( + <span key={person.id} className="flex items-center gap-2 whitespace-nowrap"> + {i > 0 && <span className="text-app-text-muted">→</span>} + <button + onClick={() => navigateTo(person)} + className={`hover:text-app-link ${person.id === focusedId ? 'text-app-text font-medium' : 'text-app-text-muted'}`} + > + {person.name.split(' ')[0]} + </button> + </span> + ))} + </div> + + {/* Main content area */} + <div className="flex-1 flex flex-col items-center justify-center p-8 gap-8"> + {/* Parents row */} + <div className="flex gap-8 items-end"> + {/* Father */} + <div className="flex flex-col items-center"> + {father ? ( + <button + onClick={() => navigateTo(father)} + className="group flex flex-col items-center" + > + <div className={`w-24 h-24 rounded-full border-4 ${isMale(father) ? 'border-app-male bg-app-male/10' : 'border-app-female bg-app-female/10'} flex items-center justify-center group-hover:scale-105 transition-transform overflow-hidden`}> + {father.photoUrl ? ( + <img src={father.photoUrl} alt="" className="w-full h-full object-cover" /> + ) : ( + <span className="text-3xl text-app-text-muted">{isMale(father) ? '\u{1F468}' : '\u{1F469}'}</span> + )} + </div> + <div className="mt-2 text-center"> + <div className="font-medium text-app-text group-hover:text-app-link">{father.name}</div> + <div className="text-xs text-app-text-muted">{father.lifespan}</div> + </div> + </button> + ) : ( + <div className="flex flex-col items-center opacity-50"> + <div className="w-24 h-24 rounded-full border-4 border-dashed border-app-border flex items-center justify-center"> + <span className="text-2xl text-app-text-muted">?</span> + </div> + <div className="mt-2 text-sm text-app-text-muted">Father Unknown</div> + </div> + )} + <div className="h-8 w-0.5 bg-app-border mt-2"></div> + </div> + + {/* Mother */} + <div className="flex flex-col items-center"> + {mother ? ( + <button + onClick={() => navigateTo(mother)} + className="group flex flex-col items-center" + > + <div className={`w-24 h-24 rounded-full border-4 ${isMale(mother) ? 'border-app-male bg-app-male/10' : 'border-app-female bg-app-female/10'} flex items-center justify-center group-hover:scale-105 transition-transform overflow-hidden`}> + {mother.photoUrl ? ( + <img src={mother.photoUrl} alt="" className="w-full h-full object-cover" /> + ) : ( + <span className="text-3xl text-app-text-muted">{isMale(mother) ? '\u{1F468}' : '\u{1F469}'}</span> + )} + </div> + <div className="mt-2 text-center"> + <div className="font-medium text-app-text group-hover:text-app-link">{mother.name}</div> + <div className="text-xs text-app-text-muted">{mother.lifespan}</div> + </div> + </button> + ) : ( + <div className="flex flex-col items-center opacity-50"> + <div className="w-24 h-24 rounded-full border-4 border-dashed border-app-border flex items-center justify-center"> + <span className="text-2xl text-app-text-muted">?</span> + </div> + <div className="mt-2 text-sm text-app-text-muted">Mother Unknown</div> + </div> + )} + <div className="h-8 w-0.5 bg-app-border mt-2"></div> + </div> + </div> + + {/* Connecting line to focused person */} + <div className="flex items-center gap-0"> + <div className="w-16 h-0.5 bg-app-border"></div> + <div className="w-3 h-3 rounded-full bg-app-border"></div> + <div className="w-16 h-0.5 bg-app-border"></div> + </div> + + {/* Focused person card */} + <div className={`relative p-6 rounded-2xl border-4 ${isMale(focused.person) ? 'border-app-male bg-app-male/5' : 'border-app-female bg-app-female/5'} shadow-lg max-w-md`}> + <div className="flex items-start gap-4"> + <div className={`w-20 h-20 rounded-full border-4 ${isMale(focused.person) ? 'border-app-male' : 'border-app-female'} flex items-center justify-center flex-shrink-0 overflow-hidden`}> + {focused.person.photoUrl ? ( + <img src={focused.person.photoUrl} alt="" className="w-full h-full object-cover" /> + ) : ( + <span className="text-3xl text-app-text-muted">{isMale(focused.person) ? '\u{1F468}' : '\u{1F469}'}</span> + )} + </div> + <div className="flex-1 min-w-0"> + <h2 className="text-xl font-bold text-app-text">{focused.person.name}</h2> + <div className="text-sm text-app-text-muted mt-1">{focused.person.lifespan}</div> + <div className="mt-2"> + <Link + to={`/person/${dbId}/${focused.person.id}`} + className="text-xs text-app-link hover:underline" + > + View full profile → + </Link> + </div> + </div> + </div> + </div> + + {/* Back navigation button */} + {breadcrumb.length > 1 && ( + <button + onClick={goBack} + className="px-4 py-2 rounded-lg bg-app-border text-app-text-secondary hover:bg-app-hover transition-colors" + > + ← Back to {breadcrumb[breadcrumb.length - 2].name.split(' ')[0]} + </button> + )} + </div> + </div> + ); +} diff --git a/client/src/components/ancestry-tree/views/GenerationalColumnsView.tsx b/client/src/components/ancestry-tree/views/GenerationalColumnsView.tsx new file mode 100644 index 0000000..97abe2d --- /dev/null +++ b/client/src/components/ancestry-tree/views/GenerationalColumnsView.tsx @@ -0,0 +1,302 @@ +/** + * Generational Columns Tree View + * + * Displays ancestors in vertical columns organized by generation. + * Root person on the left, with each generation flowing to the right. + * Scrollable horizontally for deeper ancestry. + */ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import type { AncestryTreeResult, AncestryPersonCard, AncestryFamilyUnit } from '@fsf/shared'; + +interface GenerationalColumnsViewProps { + data: AncestryTreeResult; + dbId: string; + onLoadMore?: (newDepth: number) => Promise<void>; +} + +interface GenerationPerson { + person: AncestryPersonCard; + parentIndex?: number; + slot: 'father' | 'mother'; +} + +interface Generation { + level: number; + people: (GenerationPerson | null)[]; +} + +// Build generations array from tree data +function buildGenerations(data: AncestryTreeResult, maxGen: number): Generation[] { + const generations: Generation[] = []; + + // Gen 0: Root person + generations.push({ + level: 0, + people: [{ person: data.rootPerson, slot: 'father' }] + }); + + // Build subsequent generations + const processLevel = (level: number, units: (AncestryFamilyUnit | undefined)[]) => { + if (level > maxGen) return; + + const people: (GenerationPerson | null)[] = []; + const nextUnits: (AncestryFamilyUnit | undefined)[] = []; + + units.forEach((unit, parentIndex) => { + if (unit?.father) { + people.push({ person: unit.father, parentIndex, slot: 'father' }); + nextUnits.push(unit.fatherParentUnits?.[0]); + } else { + people.push(null); + nextUnits.push(undefined); + } + + if (unit?.mother) { + people.push({ person: unit.mother, parentIndex, slot: 'mother' }); + nextUnits.push(unit.motherParentUnits?.[0]); + } else { + people.push(null); + nextUnits.push(undefined); + } + }); + + if (people.some(p => p !== null)) { + generations.push({ level, people }); + processLevel(level + 1, nextUnits); + } + }; + + // Start with root's parents + if (data.parentUnits && data.parentUnits.length > 0) { + processLevel(1, [data.parentUnits[0]]); + } + + return generations; +} + +interface PersonCardProps { + person: AncestryPersonCard; + dbId: string; + compact?: boolean; +} + +function PersonCard({ person, dbId, compact = false }: PersonCardProps) { + const isMale = person.gender === 'male'; + + if (compact) { + return ( + <Link + to={`/person/${dbId}/${person.id}`} + className={`flex items-center gap-2 p-2 rounded-lg border-l-4 ${isMale ? 'border-l-app-male bg-app-male/10 hover:bg-app-male/15' : 'border-l-app-female bg-app-female/10 hover:bg-app-female/15'} transition-colors min-w-[160px]`} + > + <div className={`w-8 h-8 rounded-full border-2 ${isMale ? 'border-app-male' : 'border-app-female'} flex items-center justify-center flex-shrink-0 overflow-hidden`}> + {person.photoUrl ? ( + <img src={person.photoUrl} alt="" className="w-full h-full object-cover" /> + ) : ( + <span className="text-xs text-app-text-muted">{isMale ? '\u{1F468}' : '\u{1F469}'}</span> + )} + </div> + <div className="min-w-0 flex-1"> + <div className="font-medium text-app-text text-xs truncate">{person.name}</div> + <div className="text-[10px] text-app-text-muted">{person.lifespan}</div> + </div> + </Link> + ); + } + + return ( + <Link + to={`/person/${dbId}/${person.id}`} + className={`flex items-center gap-3 p-3 rounded-xl border-l-4 ${isMale ? 'border-l-app-male bg-app-male/10 hover:bg-app-male/20' : 'border-l-app-female bg-app-female/10 hover:bg-app-female/20'} transition-colors min-w-[200px]`} + > + <div className={`w-12 h-12 rounded-full border-2 ${isMale ? 'border-app-male' : 'border-app-female'} flex items-center justify-center flex-shrink-0 overflow-hidden`}> + {person.photoUrl ? ( + <img src={person.photoUrl} alt="" className="w-full h-full object-cover" /> + ) : ( + <span className="text-lg text-app-text-muted">{isMale ? '\u{1F468}' : '\u{1F469}'}</span> + )} + </div> + <div className="min-w-0 flex-1"> + <div className="font-medium text-app-text text-sm">{person.name}</div> + <div className="text-xs text-app-text-muted">{person.lifespan}</div> + </div> + </Link> + ); +} + +// Get generation label - simplified for deep generations +function getGenerationLabel(level: number): { main: string; sub?: string } { + switch (level) { + case 0: return { main: 'Gen 0', sub: 'Self' }; + case 1: return { main: 'Gen 1', sub: 'Parents' }; + case 2: return { main: 'Gen 2', sub: 'Grandparents' }; + case 3: return { main: 'Gen 3', sub: '1st Great-Grandparents' }; + default: return { main: `Gen ${level}`, sub: `${level - 2}${getOrdinalSuffix(level - 2)} Great-Grandparents` }; + } +} + +function getOrdinalSuffix(n: number): string { + const s = ['th', 'st', 'nd', 'rd']; + const v = n % 100; + return s[(v - 20) % 10] || s[v] || s[0]; +} + +export function GenerationalColumnsView({ data, dbId, onLoadMore }: GenerationalColumnsViewProps) { + const [generations, setGenerations] = useState<Generation[]>([]); + const [maxGen, setMaxGen] = useState(data.maxGenerationLoaded); + const [loadingMore, setLoadingMore] = useState(false); + + useEffect(() => { + setGenerations(buildGenerations(data, maxGen)); + }, [data, maxGen]); + + // Update maxGen when data changes (after loading more) + useEffect(() => { + if (data.maxGenerationLoaded > maxGen) { + setMaxGen(data.maxGenerationLoaded); + } + }, [data.maxGenerationLoaded, maxGen]); + + // Count known ancestors per generation + const getKnownCount = (gen: Generation) => gen.people.filter(p => p !== null).length; + + // Check if any person in the last generation has more ancestors + const hasMoreToLoad = (): boolean => { + if (generations.length === 0) return false; + const lastGen = generations[generations.length - 1]; + return lastGen.people.some(p => p?.person.hasMoreAncestors); + }; + + const handleLoadMore = async () => { + if (!onLoadMore || loadingMore) return; + setLoadingMore(true); + await onLoadMore(data.maxGenerationLoaded + 5); + setLoadingMore(false); + }; + + return ( + <div className="h-full flex flex-col bg-app-bg"> + {/* Controls */} + <div className="px-4 py-3 bg-app-card border-b border-app-border flex items-center justify-between"> + <div className="text-sm text-app-text-muted"> + Showing {generations.length} generations ({generations.reduce((sum, g) => sum + getKnownCount(g), 0)} ancestors) + {hasMoreToLoad() && <span className="text-app-text-subtle ml-2">• More available</span>} + </div> + <div className="flex items-center gap-3"> + <span className="text-xs text-app-text-subtle">Visible:</span> + <div className="flex items-center gap-1"> + <button + onClick={() => setMaxGen(Math.max(2, maxGen - 1))} + disabled={maxGen <= 2} + className="px-2 py-1 text-sm rounded bg-app-border hover:bg-app-hover disabled:opacity-50 disabled:cursor-not-allowed" + > + - + </button> + <span className="text-sm text-app-text w-8 text-center">{maxGen}</span> + <button + onClick={() => setMaxGen(Math.min(maxGen + 1, data.maxGenerationLoaded))} + disabled={maxGen >= data.maxGenerationLoaded} + className="px-2 py-1 text-sm rounded bg-app-border hover:bg-app-hover disabled:opacity-50 disabled:cursor-not-allowed" + > + + + </button> + </div> + <span className="text-xs text-app-text-subtle">of {data.maxGenerationLoaded} loaded</span> + </div> + </div> + + {/* Columns */} + <div className="flex-1 overflow-auto"> + <div className="flex min-h-full"> + {generations.map((gen) => { + const label = getGenerationLabel(gen.level); + const knownPeople = gen.people.filter(p => p !== null) as GenerationPerson[]; + + return ( + <div key={gen.level} className="flex flex-col border-r border-app-border min-w-fit"> + {/* Generation header - solid background */} + <div className="px-4 py-2 bg-app-card border-b border-app-border sticky top-0 z-10"> + <div className="text-xs font-bold text-app-text uppercase tracking-wide"> + {label.main} + </div> + {label.sub && ( + <div className="text-[10px] text-app-text-muted"> + {label.sub} + </div> + )} + <div className="text-[10px] text-app-text-subtle mt-0.5"> + {knownPeople.length} of {Math.pow(2, gen.level)} known + </div> + </div> + + {/* People in this generation - only show known people */} + <div className="flex-1 p-3 flex flex-col"> + <div className="flex flex-col gap-1.5"> + {knownPeople.map((item) => ( + <PersonCard + key={item.person.id} + person={item.person} + dbId={dbId} + compact={gen.level > 2} + /> + ))} + {knownPeople.length === 0 && ( + <div className="text-xs text-app-text-muted p-2 text-center"> + No known ancestors + </div> + )} + </div> + </div> + </div> + ); + })} + + {/* Load More column */} + {hasMoreToLoad() && onLoadMore && ( + <div className="flex flex-col min-w-[140px] border-r-0"> + <div className="px-4 py-2 bg-app-card border-b border-app-border sticky top-0 z-10"> + <div className="text-xs font-bold text-app-text uppercase tracking-wide"> + More... + </div> + <div className="text-[10px] text-app-text-muted"> + Load deeper ancestry + </div> + </div> + <div className="flex-1 p-3 flex items-start justify-center"> + <button + onClick={handleLoadMore} + disabled={loadingMore} + className="flex flex-col items-center gap-2 p-4 rounded-lg bg-app-border hover:bg-app-hover disabled:opacity-50 disabled:cursor-wait transition-colors" + > + {loadingMore ? ( + <> + <div className="w-6 h-6 border-2 border-app-text-muted border-t-transparent rounded-full animate-spin" /> + <span className="text-xs text-app-text-muted">Loading...</span> + </> + ) : ( + <> + <span className="text-2xl">+</span> + <span className="text-xs text-app-text-muted">Load 5 more</span> + </> + )} + </button> + </div> + </div> + )} + </div> + </div> + + {/* Legend */} + <div className="px-4 py-2 border-t border-app-border bg-app-card text-xs text-app-text-muted flex items-center gap-4"> + <span className="flex items-center gap-1"> + <span className="w-3 h-1 bg-app-male"></span> Male + </span> + <span className="flex items-center gap-1"> + <span className="w-3 h-1 bg-app-female"></span> Female + </span> + <span>Scroll horizontally to see more generations</span> + </div> + </div> + ); +} diff --git a/client/src/components/ancestry-tree/views/PedigreeChartView.tsx b/client/src/components/ancestry-tree/views/PedigreeChartView.tsx new file mode 100644 index 0000000..8fb6cff --- /dev/null +++ b/client/src/components/ancestry-tree/views/PedigreeChartView.tsx @@ -0,0 +1,207 @@ +/** + * Pedigree Chart Tree View + * + * Classic vertical pedigree chart with the root person at the bottom + * and ancestors branching upward. Supports 2-6 generations with + * clean CSS-based connecting lines. + */ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import type { AncestryTreeResult, AncestryPersonCard, AncestryFamilyUnit } from '@fsf/shared'; + +interface PedigreeChartViewProps { + data: AncestryTreeResult; + dbId: string; +} + +interface AncestorNode { + person: AncestryPersonCard; + father?: AncestorNode; + mother?: AncestorNode; +} + +// Build a simple tree structure from the ancestry data +function buildAncestorTree(data: AncestryTreeResult): AncestorNode { + const buildNode = (person: AncestryPersonCard, parentUnits?: AncestryFamilyUnit[]): AncestorNode => { + const node: AncestorNode = { person }; + + if (parentUnits && parentUnits.length > 0) { + const unit = parentUnits[0]; + if (unit.father) { + node.father = buildNode(unit.father, unit.fatherParentUnits); + } + if (unit.mother) { + node.mother = buildNode(unit.mother, unit.motherParentUnits); + } + } + + return node; + }; + + return buildNode(data.rootPerson, data.parentUnits); +} + +interface PersonNodeProps { + person: AncestryPersonCard; + dbId: string; + size?: 'sm' | 'md' | 'lg'; +} + +function PersonNode({ person, dbId, size = 'md' }: PersonNodeProps) { + const isMale = person.gender === 'male'; + const sizeClasses = { + sm: 'w-28 p-2', + md: 'w-36 p-3', + lg: 'w-44 p-4' + }; + const avatarSizes = { + sm: 'w-8 h-8 text-sm', + md: 'w-10 h-10 text-lg', + lg: 'w-12 h-12 text-xl' + }; + + return ( + <Link + to={`/person/${dbId}/${person.id}`} + className={`${sizeClasses[size]} rounded-lg border-2 ${isMale ? 'border-app-male bg-app-male/10 hover:bg-app-male/20' : 'border-app-female bg-app-female/10 hover:bg-app-female/20'} transition-colors flex flex-col items-center text-center`} + > + <div className={`${avatarSizes[size]} rounded-full border-2 ${isMale ? 'border-app-male' : 'border-app-female'} flex items-center justify-center mb-1 overflow-hidden`}> + {person.photoUrl ? ( + <img src={person.photoUrl} alt="" className="w-full h-full object-cover" /> + ) : ( + <span className="text-app-text-muted">{isMale ? '\u{1F468}' : '\u{1F469}'}</span> + )} + </div> + <div className="font-medium text-app-text text-xs leading-tight truncate w-full">{person.name}</div> + <div className="text-[10px] text-app-text-muted">{person.lifespan}</div> + </Link> + ); +} + +interface UnknownNodeProps { + label: string; + size?: 'sm' | 'md' | 'lg'; +} + +function UnknownNode({ label, size = 'md' }: UnknownNodeProps) { + const sizeClasses = { + sm: 'w-28 p-2', + md: 'w-36 p-3', + lg: 'w-44 p-4' + }; + + return ( + <div className={`${sizeClasses[size]} rounded-lg border-2 border-dashed border-app-border bg-app-card/50 flex flex-col items-center justify-center text-center opacity-60`}> + <span className="text-xl text-app-text-muted">?</span> + <div className="text-xs text-app-text-muted mt-1">{label}</div> + </div> + ); +} + +interface PedigreeLevelProps { + node?: AncestorNode; + dbId: string; + level?: number; + maxLevel?: number; +} + +// Recursive component to render pedigree levels +function PedigreeLevel({ node, dbId, level = 0, maxLevel = 4 }: PedigreeLevelProps) { + if (level >= maxLevel) return null; + + const size = level === 0 ? 'lg' : level === 1 ? 'md' : 'sm'; + const spacing = level === 0 ? 'gap-12' : level === 1 ? 'gap-8' : 'gap-4'; + + return ( + <div className="flex flex-col items-center"> + {/* Parents (above) */} + {level < maxLevel - 1 && ( + <div className={`flex ${spacing} mb-4`}> + <PedigreeLevel node={node?.father} dbId={dbId} level={level + 1} maxLevel={maxLevel} /> + <PedigreeLevel node={node?.mother} dbId={dbId} level={level + 1} maxLevel={maxLevel} /> + </div> + )} + + {/* Connecting lines */} + {level < maxLevel - 1 && node && (node.father || node.mother) && ( + <div className="relative h-6 w-full flex justify-center mb-2"> + {/* Vertical line down to person */} + <div className="absolute bottom-0 w-0.5 h-3 bg-app-border"></div> + {/* Horizontal line connecting parents */} + <div className="absolute top-0 h-0.5 bg-app-border" style={{ width: level === 0 ? '50%' : level === 1 ? '40%' : '30%' }}></div> + {/* Vertical lines up to each parent */} + {node.father && ( + <div className="absolute top-0 left-1/4 w-0.5 h-3 bg-app-border" style={{ left: level === 0 ? '25%' : level === 1 ? '30%' : '35%' }}></div> + )} + {node.mother && ( + <div className="absolute top-0 right-1/4 w-0.5 h-3 bg-app-border" style={{ right: level === 0 ? '25%' : level === 1 ? '30%' : '35%' }}></div> + )} + </div> + )} + + {/* This person */} + {node ? ( + <PersonNode person={node.person} dbId={dbId} size={size} /> + ) : ( + <UnknownNode label="Unknown" size={size} /> + )} + </div> + ); +} + +export function PedigreeChartView({ data, dbId }: PedigreeChartViewProps) { + const [tree, setTree] = useState<AncestorNode | null>(null); + const [generations, setGenerations] = useState(4); + + useEffect(() => { + setTree(buildAncestorTree(data)); + }, [data]); + + if (!tree) return <div className="p-4 text-app-text-muted">Loading...</div>; + + return ( + <div className="h-full flex flex-col bg-app-bg"> + {/* Controls */} + <div className="px-4 py-3 bg-app-card border-b border-app-border flex items-center justify-between"> + <div className="text-sm text-app-text-muted"> + Showing {generations} generations + </div> + <div className="flex items-center gap-2"> + <button + onClick={() => setGenerations(Math.max(2, generations - 1))} + disabled={generations <= 2} + className="px-2 py-1 text-sm rounded bg-app-border hover:bg-app-hover disabled:opacity-50 disabled:cursor-not-allowed" + > + - + </button> + <span className="text-sm text-app-text w-8 text-center">{generations}</span> + <button + onClick={() => setGenerations(Math.min(6, generations + 1))} + disabled={generations >= 6} + className="px-2 py-1 text-sm rounded bg-app-border hover:bg-app-hover disabled:opacity-50 disabled:cursor-not-allowed" + > + + + </button> + </div> + </div> + + {/* Chart area */} + <div className="flex-1 overflow-auto p-8"> + <div className="min-w-max flex justify-center"> + <PedigreeLevel node={tree} dbId={dbId} maxLevel={generations} /> + </div> + </div> + + {/* Legend */} + <div className="px-4 py-2 border-t border-app-border text-xs text-app-text-muted flex items-center gap-4"> + <span className="flex items-center gap-1"> + <span className="w-3 h-3 rounded border-2 border-app-male"></span> Male + </span> + <span className="flex items-center gap-1"> + <span className="w-3 h-3 rounded border-2 border-app-female"></span> Female + </span> + <span>Click any person to view details</span> + </div> + </div> + ); +} diff --git a/client/src/components/ancestry-tree/views/index.ts b/client/src/components/ancestry-tree/views/index.ts new file mode 100644 index 0000000..97413e6 --- /dev/null +++ b/client/src/components/ancestry-tree/views/index.ts @@ -0,0 +1,11 @@ +/** + * Tree View Implementations + * + * Different visualization modes for ancestry trees: + * - FocusNavigator: Navigate one person at a time with breadcrumb trail + * - PedigreeChart: Classic vertical tree with ancestors branching upward + * - GenerationalColumns: Horizontal columns organized by generation + */ +export { FocusNavigatorView } from './FocusNavigatorView'; +export { PedigreeChartView } from './PedigreeChartView'; +export { GenerationalColumnsView } from './GenerationalColumnsView'; diff --git a/package.json b/package.json index 038a61b..6b6e606 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sparsetree", - "version": "0.1.1", + "version": "0.2.1", "private": true, "description": "", "main": "index.js", From 1a6f9cb70fd560d22cb42a5de5fc3bb5e5338e73 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 06:13:11 +0000 Subject: [PATCH 08/27] build: bump version to 0.2.2 [skip ci] --- client/package.json | 2 +- package-lock.json | 10 +++++----- package.json | 2 +- server/package.json | 2 +- shared/package.json | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/client/package.json b/client/package.json index 987781f..ab2ca6e 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/client", - "version": "0.1.1", + "version": "0.2.2", "type": "module", "scripts": { "dev": "vite --port 6373", diff --git a/package-lock.json b/package-lock.json index 1168d30..7f4267f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sparsetree", - "version": "0.1.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sparsetree", - "version": "0.1.1", + "version": "0.2.2", "license": "ISC", "workspaces": [ "shared", @@ -51,7 +51,7 @@ }, "client": { "name": "@fsf/client", - "version": "0.1.1", + "version": "0.2.2", "dependencies": { "@fsf/shared": "*", "d3": "^7.9.0", @@ -6096,7 +6096,7 @@ }, "server": { "name": "@fsf/server", - "version": "0.1.1", + "version": "0.2.2", "dependencies": { "@fsf/shared": "*", "cors": "^2.8.5", @@ -6116,7 +6116,7 @@ }, "shared": { "name": "@fsf/shared", - "version": "0.1.1", + "version": "0.2.2", "devDependencies": { "typescript": "^5.7.2" } diff --git a/package.json b/package.json index 6b6e606..2cd07f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sparsetree", - "version": "0.2.1", + "version": "0.2.2", "private": true, "description": "", "main": "index.js", diff --git a/server/package.json b/server/package.json index 0167312..3c1a591 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/server", - "version": "0.1.1", + "version": "0.2.2", "type": "module", "main": "dist/index.js", "scripts": { diff --git a/shared/package.json b/shared/package.json index 6c77f97..46dbc8d 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/shared", - "version": "0.1.1", + "version": "0.2.2", "type": "module", "main": "types/index.js", "types": "types/index.d.ts", From 1de108e8f0f9def68b06cd805762b24245ea84f8 Mon Sep 17 00:00:00 2001 From: Adam Eivy <atomantic@gmail.com> Date: Wed, 21 Jan 2026 22:14:15 -0800 Subject: [PATCH 09/27] Migrate changelog to .changelog directory format - Add Git Workflow and Release Changelog Process sections to CLAUDE.md - Create .changelog/v0.2.x.md with current 0.2.x release entries - Create .changelog/README.md with changelog process documentation - Update .changelog/v0.1.x.md to match new format - Remove root CHANGELOG.md (migrated to .changelog/) --- .changelog/README.md | 119 +++++++++++++++++++++++++++++++++++++++++++ .changelog/v0.1.x.md | 28 ++++++++-- .changelog/v0.2.x.md | 80 +++++++++++++++++++++++++++++ CHANGELOG.md | 62 ---------------------- CLAUDE.md | 40 +++++++++++++++ 5 files changed, 263 insertions(+), 66 deletions(-) create mode 100644 .changelog/README.md create mode 100644 .changelog/v0.2.x.md delete mode 100644 CHANGELOG.md diff --git a/.changelog/README.md b/.changelog/README.md new file mode 100644 index 0000000..6eb13e6 --- /dev/null +++ b/.changelog/README.md @@ -0,0 +1,119 @@ +# Release Changelogs + +This directory contains **all** release notes for SparseTree. Unlike traditional projects that maintain a root `CHANGELOG.md` file, we use version-specific files that evolve with development and automatically archive on release. + +**No root CHANGELOG.md needed** - all changelog content lives in this directory. + +## Structure + +Each minor version series has its own markdown file following the naming convention: + +``` +v{major}.{minor}.x.md +``` + +The "x" is a literal character, not a placeholder - it represents the entire minor version series (e.g., all 0.2.x releases share `v0.2.x.md`). + +Examples: +- `v0.1.x.md` - Used for releases 0.1.1, 0.1.2, 0.1.3, etc. +- `v0.2.x.md` - Used for releases 0.2.1, 0.2.2, 0.2.3, etc. +- `v1.0.x.md` - Used for releases 1.0.1, 1.0.2, 1.0.3, etc. + +## Format + +Each changelog file should follow this structure: + +```markdown +# Release v{major}.{minor}.x - {Descriptive Title} + +Released: YYYY-MM-DD + +## Overview + +A brief summary of the release, highlighting the main theme or most important changes. + +## 🎉 New Features + +### Feature Category 1 +- Feature description with technical details +- Another feature in this category + +## 🐛 Bug Fixes + +### Fix Category +- Description of what was fixed +- Impact and technical details + +## 🔧 Improvements + +### Improvement Category +- What was improved +- Why it matters + +## 🗑️ Removed + +### Deprecated Features +- What was removed +- Why it was removed + +## 📦 Installation + +\`\`\`bash +git clone https://github.com/atomantic/SparseTree.git +cd SparseTree +npm run install:all +pm2 start ecosystem.config.cjs +\`\`\` + +## 🔗 Full Changelog + +**Full Diff**: https://github.com/atomantic/SparseTree/compare/v{prev}...v{major}.{minor}.x +``` + +## Workflow + +### During Development + +Update `.changelog/v0.2.x.md` **every time** you add features and fixes: +- Add entries under appropriate emoji sections (🎉 Features, 🐛 Fixes, 🔧 Improvements) +- Keep the version in the file as `v0.2.x` (literal x) +- Don't worry about the final patch number - it will be substituted automatically + +### Before Merging to Main + +Final review before release: +- Ensure all changes are documented +- Add release date (update "YYYY-MM-DD" to actual date) +- Review and polish the content +- Commit the changelog file + +### On Release + +The GitHub Actions workflow automatically: +1. Reads `.changelog/v0.2.x.md` +2. Replaces all instances of `0.2.x` with the actual version (e.g., `0.2.5`) +3. Creates the GitHub release with the substituted changelog +4. Renames `v0.2.x.md` → `v0.2.5.md` (preserves git history) +5. Bumps dev to next minor version + +### After Release + +- Create a new `v0.3.x.md` for the next minor version +- Copy the previous version as a template + +## Best Practices + +### Do: +- Update the changelog file **as you work** (not just before release) +- Use clear, descriptive section headings +- Group related changes together +- Include technical details where helpful +- Explain the "why" not just the "what" +- Use emoji section headers for visual organization + +### Don't: +- Create a root `CHANGELOG.md` file +- Use vague descriptions like "various improvements" +- Include internal implementation details users don't care about +- Leave placeholder or TODO content +- Change the version from `v0.2.x` to specific patch numbers during development diff --git a/.changelog/v0.1.x.md b/.changelog/v0.1.x.md index 79eec70..1d3eed4 100644 --- a/.changelog/v0.1.x.md +++ b/.changelog/v0.1.x.md @@ -1,18 +1,34 @@ -# Release v0.1.x +# Release v0.1.x - Initial Release -## Features +Released: 2026-01-20 +## Overview + +Initial release of SparseTree - a genealogy toolkit for creating local databases of your family tree with sparse tree visualizations. + +## 🎉 New Features + +### Core Functionality - Local web UI enhancement of FamilySearchFinder - Multi-platform genealogy provider support (FamilySearch, Ancestry, WikiTree, 23andMe) - Browser-based scraping with Playwright automation - Provider login credentials with encrypted storage for auto-authentication + +### Visualization - FamilySearch-style ancestry tree visualization - Favorites system with sparse tree visualization -- GEDCOM import/export support - Path finding between ancestors (shortest/longest/random) + +### Data Management +- GEDCOM import/export support - Light/dark theme support -## Installation +### Infrastructure (v0.1.1) +- Browser status polling replaced with SSE +- CDP browser integration for indexer +- Ancestry tree line improvements + +## 📦 Installation ```bash git clone https://github.com/atomantic/SparseTree.git @@ -20,3 +36,7 @@ cd SparseTree npm run install:all pm2 start ecosystem.config.cjs ``` + +## 🔗 Full Changelog + +**Full Diff**: https://github.com/atomantic/SparseTree/releases/tag/v0.1.x diff --git a/.changelog/v0.2.x.md b/.changelog/v0.2.x.md new file mode 100644 index 0000000..3f0e983 --- /dev/null +++ b/.changelog/v0.2.x.md @@ -0,0 +1,80 @@ +# Release v0.2.x - Multi-Platform Linking & Tree Views + +Released: YYYY-MM-DD + +## Overview + +Major enhancements to the ancestry tree visualization with four view modes and lazy loading for deep ancestry. Added multi-platform genealogy linking with Ancestry.com and WikiTree integration. + +## 🎉 New Features + +### Multiple Tree View Modes +- **Focus Navigator**: Navigate one person at a time with breadcrumb trail +- **Pedigree Chart**: Classic vertical tree with expandable generations +- **Generational Columns**: Horizontal columns organized by generation +- **Classic**: Original SVG-based tree with zoom/pan + +### Lazy Loading for Deep Ancestry +- Initial load of 10 generations for columns view +- "Load 5 more" button appears when more ancestors are available +- Successfully tested loading 21 generations with 17,000+ ancestors + +### View Mode URL Persistence +- Tree view mode saved in URL query params (`?view=columns`) + +### Ancestry.com Linking +- Link persons to their Ancestry.com profiles with automatic photo extraction +- Browser-based scraping with auto-login support using saved credentials +- Srcset parsing to extract highest resolution photos (5x = maxside=1800) +- Auto-launches/connects browser when needed + +### WikiTree Linking +- Link persons to their WikiTree profiles with photo extraction +- HTTP-based scraping (no auth required for public profiles) +- Extracts profile photo and description + +### Manual Photo Selection +- "Use Photo" button for each linked platform +- Separated linking from photo fetching for user control +- Support for FamilySearch, Wikipedia, Ancestry, and WikiTree photos +- Photos stored locally with platform-specific naming (`{personId}-ancestry.jpg`, etc.) + +### Unified Platforms UI +- Consolidated all platform links into single "Platforms" section in PersonDetail +- FamilySearch, Wikipedia, Ancestry, WikiTree all shown together +- Link and "Use Photo" buttons for each platform + +## 🔧 Improvements + +### Tree Visualization +- **Simplified Generation Labels**: Gen 3+ shows "1st/2nd/3rd Great-Grandparents" instead of verbose labels +- **Columns View Optimization**: Only displays known ancestors (hides unknown placeholders) +- **Header Styling**: Fixed transparent header in columns view to prevent text collision when scrolling + +### Photo Priority +- Updated sparse tree view to use photos in order: + 1. Ancestry (highest priority) + 2. WikiTree + 3. Wikipedia + 4. FamilySearch scraped (lowest priority) + +### Data Model +- Added `photoUrl` field to PlatformReference to store discovered photo URLs before downloading + +## 🐛 Bug Fixes + +- Browser auto-connects when linking Ancestry profiles (no more "Browser not connected" errors) +- Ancestry photo now appears in sparse tree view + +## 📦 Installation + +```bash +git clone https://github.com/atomantic/SparseTree.git +cd SparseTree +npm run install:all +pm2 start ecosystem.config.cjs +``` + +## 🔗 Full Changelog + +**Full Diff**: https://github.com/atomantic/SparseTree/compare/v0.1.x...v0.2.x diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 365595f..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,62 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -## [0.2.1] - 2026-01-21 - -### Added -- **Multiple Tree View Modes**: Redesigned ancestry tree with four visualization options - - Focus Navigator: Navigate one person at a time with breadcrumb trail - - Pedigree Chart: Classic vertical tree with expandable generations - - Generational Columns: Horizontal columns organized by generation - - Classic: Original SVG-based tree with zoom/pan -- **Lazy Loading for Deep Ancestry**: Load unlimited generations on demand - - Initial load of 10 generations for columns view - - "Load 5 more" button appears when more ancestors are available - - Successfully tested loading 21 generations with 17,000+ ancestors -- **View Mode URL Persistence**: Tree view mode saved in URL query params (`?view=columns`) - -### Changed -- **Simplified Generation Labels**: Gen 3+ shows "1st/2nd/3rd Great-Grandparents" instead of verbose labels -- **Columns View Optimization**: Only displays known ancestors (hides unknown placeholders) -- **Header Styling**: Fixed transparent header in columns view to prevent text collision when scrolling - -## [0.2.0] - 2026-01-21 - -### Added -- **Ancestry.com Linking**: Link persons to their Ancestry.com profiles with automatic photo extraction - - Browser-based scraping with auto-login support using saved credentials - - Srcset parsing to extract highest resolution photos (5x = maxside=1800) - - Auto-launches/connects browser when needed -- **WikiTree Linking**: Link persons to their WikiTree profiles with photo extraction - - HTTP-based scraping (no auth required for public profiles) - - Extracts profile photo and description -- **Manual Photo Selection**: "Use Photo" button for each linked platform - - Separated linking from photo fetching for user control - - Support for FamilySearch, Wikipedia, Ancestry, and WikiTree photos - - Photos stored locally with platform-specific naming (`{personId}-ancestry.jpg`, etc.) -- **Unified Platforms UI**: Consolidated all platform links into single "Platforms" section in PersonDetail - - FamilySearch, Wikipedia, Ancestry, WikiTree all shown together - - Link and "Use Photo" buttons for each platform - -### Changed -- **Photo Priority**: Updated sparse tree view to use photos in order: - 1. Ancestry (highest priority) - 2. WikiTree - 3. Wikipedia - 4. FamilySearch scraped (lowest priority) -- **PlatformReference**: Added `photoUrl` field to store discovered photo URLs before downloading - -### Fixed -- Browser auto-connects when linking Ancestry profiles (no more "Browser not connected" errors) -- Ancestry photo now appears in sparse tree view - -## [0.1.1] - Previous - -- Browser status polling replaced with SSE -- CDP browser integration for indexer -- Ancestry tree line improvements - -## [0.1.0] - Initial Release - -- Initial version with FamilySearch indexing, Wikipedia linking, favorites, and sparse tree visualization diff --git a/CLAUDE.md b/CLAUDE.md index d44e2a0..e450f72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,6 +121,46 @@ Persistent Chrome with CDP on port 9920: ``` Profile data stored in `.browser/data/`. Connect via `ws://localhost:9920`. +## Git Workflow + +- **dev**: Active development (auto-bumps patch on CI pass) +- **main**: Production releases only +- PR `dev → main` creates tagged release and preps next version +- **Use `/gitup` to push** - The dev branch receives auto version bump commits from CI. Always use `git pull --rebase --autostash && git push` (or `/gitup`) instead of plain `git push`. +- Update `.changelog/v{major}.{minor}.x.md` when making changes (see Release Changelog Process below) +- **Commit after each feature or bug fix** - lint, commit, and push automatically to keep work safe + +## Release Changelog Process + +All release notes are maintained in `.changelog/v{major}.{minor}.x.md` files. Each minor version series has a single changelog file that accumulates changes throughout development. **No root CHANGELOG.md** - all changelog content lives in `.changelog/`. + +### During Development + +**Always update `.changelog/v0.2.x.md`** when you make changes: +- Add entries under appropriate emoji sections (🎉 Features, 🐛 Fixes, 🔧 Improvements, 🗑️ Removed) +- Keep the version as `v0.2.x` throughout development (don't change it to 0.2.2, 0.2.3, etc.) +- Group related changes together for clarity +- Explain the "why" not just the "what" + +### Before Releasing to Main + +Final review before merging `dev → main`: +- Ensure all changes are documented in `.changelog/v0.2.x.md` +- Add the release date (update "YYYY-MM-DD" to actual date) +- Polish descriptions for clarity +- Commit the changelog + +### On Release (Automated) + +When merging to `main`, the GitHub Actions workflow automatically: +1. Reads `.changelog/v0.2.x.md` +2. Replaces all instances of `0.2.x` with actual version (e.g., `0.2.5`) +3. Creates the GitHub release with substituted changelog +4. Renames `v0.2.x.md` → `v0.2.5.md` (preserves git history) +5. Bumps dev to next minor version (e.g., 0.3.0) + +See `.changelog/README.md` for detailed format and best practices. + ## Notes - The database has cyclic loop issues (people linked as their own ancestors) - use longest path method to detect these - ES modules (`"type": "module"` in package.json) From 9d509f218d15d438d334dbc209ea4a9f2103639c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 06:15:20 +0000 Subject: [PATCH 10/27] build: bump version to 0.2.3 [skip ci] --- client/package.json | 2 +- package-lock.json | 10 +++++----- package.json | 2 +- server/package.json | 2 +- shared/package.json | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/client/package.json b/client/package.json index ab2ca6e..0205843 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/client", - "version": "0.2.2", + "version": "0.2.3", "type": "module", "scripts": { "dev": "vite --port 6373", diff --git a/package-lock.json b/package-lock.json index 7f4267f..695913f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sparsetree", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sparsetree", - "version": "0.2.2", + "version": "0.2.3", "license": "ISC", "workspaces": [ "shared", @@ -51,7 +51,7 @@ }, "client": { "name": "@fsf/client", - "version": "0.2.2", + "version": "0.2.3", "dependencies": { "@fsf/shared": "*", "d3": "^7.9.0", @@ -6096,7 +6096,7 @@ }, "server": { "name": "@fsf/server", - "version": "0.2.2", + "version": "0.2.3", "dependencies": { "@fsf/shared": "*", "cors": "^2.8.5", @@ -6116,7 +6116,7 @@ }, "shared": { "name": "@fsf/shared", - "version": "0.2.2", + "version": "0.2.3", "devDependencies": { "typescript": "^5.7.2" } diff --git a/package.json b/package.json index 2cd07f8..a9b94b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sparsetree", - "version": "0.2.2", + "version": "0.2.3", "private": true, "description": "", "main": "index.js", diff --git a/server/package.json b/server/package.json index 3c1a591..cdb4aa9 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/server", - "version": "0.2.2", + "version": "0.2.3", "type": "module", "main": "dist/index.js", "scripts": { diff --git a/shared/package.json b/shared/package.json index 46dbc8d..e6336ec 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/shared", - "version": "0.2.2", + "version": "0.2.3", "type": "module", "main": "types/index.js", "types": "types/index.d.ts", From 4be88366f881037fa2cea123c3fddf3ab24962ad Mon Sep 17 00:00:00 2001 From: Adam Eivy <atomantic@gmail.com> Date: Wed, 21 Jan 2026 22:16:37 -0800 Subject: [PATCH 11/27] Fix duplicate React keys and pedigree bracket direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use composite keys (gen-idx-id) in GenerationalColumnsView to handle pedigree collapse where same ancestor appears multiple times - Fix breadcrumb keys in FocusNavigatorView - Flip pedigree bracket direction to open upward (└─┘) showing correct child-to-parent connections --- .../ancestry-tree/views/FocusNavigatorView.tsx | 2 +- .../views/GenerationalColumnsView.tsx | 4 ++-- .../ancestry-tree/views/PedigreeChartView.tsx | 16 ++++++++-------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/client/src/components/ancestry-tree/views/FocusNavigatorView.tsx b/client/src/components/ancestry-tree/views/FocusNavigatorView.tsx index dbbec27..7643d64 100644 --- a/client/src/components/ancestry-tree/views/FocusNavigatorView.tsx +++ b/client/src/components/ancestry-tree/views/FocusNavigatorView.tsx @@ -109,7 +109,7 @@ export function FocusNavigatorView({ data, dbId }: FocusNavigatorViewProps) { {/* Breadcrumb navigation */} <div className="px-4 py-2 bg-app-card border-b border-app-border flex items-center gap-2 text-sm overflow-x-auto"> {breadcrumb.map((person, i) => ( - <span key={person.id} className="flex items-center gap-2 whitespace-nowrap"> + <span key={`${i}-${person.id}`} className="flex items-center gap-2 whitespace-nowrap"> {i > 0 && <span className="text-app-text-muted">→</span>} <button onClick={() => navigateTo(person)} diff --git a/client/src/components/ancestry-tree/views/GenerationalColumnsView.tsx b/client/src/components/ancestry-tree/views/GenerationalColumnsView.tsx index 97abe2d..4b9e258 100644 --- a/client/src/components/ancestry-tree/views/GenerationalColumnsView.tsx +++ b/client/src/components/ancestry-tree/views/GenerationalColumnsView.tsx @@ -233,9 +233,9 @@ export function GenerationalColumnsView({ data, dbId, onLoadMore }: Generational {/* People in this generation - only show known people */} <div className="flex-1 p-3 flex flex-col"> <div className="flex flex-col gap-1.5"> - {knownPeople.map((item) => ( + {knownPeople.map((item, idx) => ( <PersonCard - key={item.person.id} + key={`${gen.level}-${idx}-${item.person.id}`} person={item.person} dbId={dbId} compact={gen.level > 2} diff --git a/client/src/components/ancestry-tree/views/PedigreeChartView.tsx b/client/src/components/ancestry-tree/views/PedigreeChartView.tsx index 8fb6cff..edf06a2 100644 --- a/client/src/components/ancestry-tree/views/PedigreeChartView.tsx +++ b/client/src/components/ancestry-tree/views/PedigreeChartView.tsx @@ -122,19 +122,19 @@ function PedigreeLevel({ node, dbId, level = 0, maxLevel = 4 }: PedigreeLevelPro </div> )} - {/* Connecting lines */} + {/* Connecting lines - bracket opens upward └──┘ */} {level < maxLevel - 1 && node && (node.father || node.mother) && ( <div className="relative h-6 w-full flex justify-center mb-2"> - {/* Vertical line down to person */} - <div className="absolute bottom-0 w-0.5 h-3 bg-app-border"></div> - {/* Horizontal line connecting parents */} - <div className="absolute top-0 h-0.5 bg-app-border" style={{ width: level === 0 ? '50%' : level === 1 ? '40%' : '30%' }}></div> - {/* Vertical lines up to each parent */} + {/* Vertical line up from person */} + <div className="absolute top-0 w-0.5 h-3 bg-app-border"></div> + {/* Horizontal line connecting to parents */} + <div className="absolute bottom-0 h-0.5 bg-app-border" style={{ width: level === 0 ? '50%' : level === 1 ? '40%' : '30%' }}></div> + {/* Vertical lines down from each parent */} {node.father && ( - <div className="absolute top-0 left-1/4 w-0.5 h-3 bg-app-border" style={{ left: level === 0 ? '25%' : level === 1 ? '30%' : '35%' }}></div> + <div className="absolute bottom-0 w-0.5 h-3 bg-app-border" style={{ left: level === 0 ? '25%' : level === 1 ? '30%' : '35%' }}></div> )} {node.mother && ( - <div className="absolute top-0 right-1/4 w-0.5 h-3 bg-app-border" style={{ right: level === 0 ? '25%' : level === 1 ? '30%' : '35%' }}></div> + <div className="absolute bottom-0 w-0.5 h-3 bg-app-border" style={{ right: level === 0 ? '25%' : level === 1 ? '30%' : '35%' }}></div> )} </div> )} From b8f1b007b686a7aa22f7e0ca589bac5645163b4f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 06:17:46 +0000 Subject: [PATCH 12/27] build: bump version to 0.2.4 [skip ci] --- client/package.json | 2 +- package-lock.json | 10 +++++----- package.json | 2 +- server/package.json | 2 +- shared/package.json | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/client/package.json b/client/package.json index 0205843..f04ec5d 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/client", - "version": "0.2.3", + "version": "0.2.4", "type": "module", "scripts": { "dev": "vite --port 6373", diff --git a/package-lock.json b/package-lock.json index 695913f..670bf7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sparsetree", - "version": "0.2.3", + "version": "0.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sparsetree", - "version": "0.2.3", + "version": "0.2.4", "license": "ISC", "workspaces": [ "shared", @@ -51,7 +51,7 @@ }, "client": { "name": "@fsf/client", - "version": "0.2.3", + "version": "0.2.4", "dependencies": { "@fsf/shared": "*", "d3": "^7.9.0", @@ -6096,7 +6096,7 @@ }, "server": { "name": "@fsf/server", - "version": "0.2.3", + "version": "0.2.4", "dependencies": { "@fsf/shared": "*", "cors": "^2.8.5", @@ -6116,7 +6116,7 @@ }, "shared": { "name": "@fsf/shared", - "version": "0.2.3", + "version": "0.2.4", "devDependencies": { "typescript": "^5.7.2" } diff --git a/package.json b/package.json index a9b94b5..2f0094f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sparsetree", - "version": "0.2.3", + "version": "0.2.4", "private": true, "description": "", "main": "index.js", diff --git a/server/package.json b/server/package.json index cdb4aa9..cc32b75 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/server", - "version": "0.2.3", + "version": "0.2.4", "type": "module", "main": "dist/index.js", "scripts": { diff --git a/shared/package.json b/shared/package.json index e6336ec..2907dc6 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/shared", - "version": "0.2.3", + "version": "0.2.4", "type": "module", "main": "types/index.js", "types": "types/index.d.ts", From c0dfd51008c528b0ea5f483bb59f9e9ca00991e2 Mon Sep 17 00:00:00 2001 From: Adam Eivy <atomantic@gmail.com> Date: Wed, 21 Jan 2026 22:20:26 -0800 Subject: [PATCH 13/27] Enhance Focus view with detailed person cards and extended data - Add birthPlace, deathPlace, occupation fields to AncestryPersonCard - Populate extended fields in ancestry-tree.service - Redesign FocusNavigatorView with robust cards: - Parent cards show label, name, lifespan, and birthPlace - Focused card shows full details with all available fields - Shows generation level indicator (Parent, Grandparent, etc.) - "Click to view parents" hint when more ancestors available --- .../views/FocusNavigatorView.tsx | 288 ++++++++++++------ server/src/services/ancestry-tree.service.ts | 6 +- shared/src/index.ts | 4 + 3 files changed, 196 insertions(+), 102 deletions(-) diff --git a/client/src/components/ancestry-tree/views/FocusNavigatorView.tsx b/client/src/components/ancestry-tree/views/FocusNavigatorView.tsx index 7643d64..9951ac5 100644 --- a/client/src/components/ancestry-tree/views/FocusNavigatorView.tsx +++ b/client/src/components/ancestry-tree/views/FocusNavigatorView.tsx @@ -4,6 +4,7 @@ * A navigation-focused tree view that shows one person at a time with their * immediate parents above. Click a parent to navigate up the ancestry tree. * Includes a breadcrumb trail for easy navigation back down. + * Shows detailed information since we only display 3 cards at a time. */ import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; @@ -69,6 +70,135 @@ function buildPersonMap(data: AncestryTreeResult): Map<string, PersonWithAncesto return map; } +// Parent card component - medium detail level +function ParentCard({ + person, + dbId, + label, + onNavigate +}: { + person: AncestryPersonCard | undefined; + dbId: string; + label: string; + onNavigate: (person: AncestryPersonCard) => void; +}) { + const isMale = person?.gender === 'male'; + + if (!person) { + return ( + <div className="flex flex-col items-center"> + <div className="w-48 p-4 rounded-xl border-2 border-dashed border-app-border bg-app-card/30 opacity-60"> + <div className="flex items-center gap-3"> + <div className="w-16 h-16 rounded-full border-2 border-dashed border-app-border flex items-center justify-center flex-shrink-0"> + <span className="text-2xl text-app-text-muted">?</span> + </div> + <div> + <div className="text-sm font-medium text-app-text-muted">{label}</div> + <div className="text-xs text-app-text-subtle">Unknown</div> + </div> + </div> + </div> + <div className="h-8 w-0.5 bg-app-border mt-2"></div> + </div> + ); + } + + return ( + <div className="flex flex-col items-center"> + <button + onClick={() => onNavigate(person)} + className={`group w-56 p-4 rounded-xl border-2 ${isMale ? 'border-app-male bg-app-male/5 hover:bg-app-male/10' : 'border-app-female bg-app-female/5 hover:bg-app-female/10'} transition-all hover:shadow-md`} + > + <div className="flex items-start gap-3"> + <div className={`w-16 h-16 rounded-full border-3 ${isMale ? 'border-app-male' : 'border-app-female'} flex items-center justify-center flex-shrink-0 overflow-hidden group-hover:scale-105 transition-transform`}> + {person.photoUrl ? ( + <img src={person.photoUrl} alt="" className="w-full h-full object-cover" /> + ) : ( + <span className="text-2xl text-app-text-muted">{isMale ? '\u{1F468}' : '\u{1F469}'}</span> + )} + </div> + <div className="flex-1 min-w-0 text-left"> + <div className="text-xs text-app-text-subtle uppercase tracking-wide">{label}</div> + <div className="font-semibold text-app-text group-hover:text-app-link truncate">{person.name}</div> + <div className="text-xs text-app-text-muted">{person.lifespan}</div> + {person.birthPlace && ( + <div className="text-xs text-app-text-subtle mt-1 truncate" title={person.birthPlace}> + Born: {person.birthPlace} + </div> + )} + </div> + </div> + {person.hasMoreAncestors && ( + <div className="mt-2 text-xs text-app-link text-center"> + Click to view parents ↑ + </div> + )} + </button> + <div className="h-8 w-0.5 bg-app-border mt-2"></div> + </div> + ); +} + +// Focused person card component - full detail level +function FocusedPersonCard({ + person, + dbId +}: { + person: AncestryPersonCard; + dbId: string; +}) { + const isMale = person.gender === 'male'; + + return ( + <div className={`relative p-6 rounded-2xl border-4 ${isMale ? 'border-app-male bg-app-male/5' : 'border-app-female bg-app-female/5'} shadow-lg w-full max-w-lg`}> + <div className="flex items-start gap-5"> + <div className={`w-24 h-24 rounded-full border-4 ${isMale ? 'border-app-male' : 'border-app-female'} flex items-center justify-center flex-shrink-0 overflow-hidden`}> + {person.photoUrl ? ( + <img src={person.photoUrl} alt="" className="w-full h-full object-cover" /> + ) : ( + <span className="text-4xl text-app-text-muted">{isMale ? '\u{1F468}' : '\u{1F469}'}</span> + )} + </div> + <div className="flex-1 min-w-0"> + <h2 className="text-2xl font-bold text-app-text">{person.name}</h2> + <div className="text-base text-app-text-muted mt-1">{person.lifespan}</div> + + {/* Detail rows */} + <div className="mt-3 space-y-1.5"> + {person.birthPlace && ( + <div className="flex items-start gap-2 text-sm"> + <span className="text-app-text-subtle w-14 flex-shrink-0">Born:</span> + <span className="text-app-text">{person.birthPlace}</span> + </div> + )} + {person.deathPlace && ( + <div className="flex items-start gap-2 text-sm"> + <span className="text-app-text-subtle w-14 flex-shrink-0">Died:</span> + <span className="text-app-text">{person.deathPlace}</span> + </div> + )} + {person.occupation && ( + <div className="flex items-start gap-2 text-sm"> + <span className="text-app-text-subtle w-14 flex-shrink-0">Work:</span> + <span className="text-app-text">{person.occupation}</span> + </div> + )} + </div> + + <div className="mt-4"> + <Link + to={`/person/${dbId}/${person.id}`} + className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg bg-app-border text-sm text-app-text hover:bg-app-hover transition-colors" + > + View full profile → + </Link> + </div> + </div> + </div> + </div> + ); +} + export function FocusNavigatorView({ data, dbId }: FocusNavigatorViewProps) { const [focusedId, setFocusedId] = useState(data.rootPerson.id); const [breadcrumb, setBreadcrumb] = useState<AncestryPersonCard[]>([data.rootPerson]); @@ -102,121 +232,66 @@ export function FocusNavigatorView({ data, dbId }: FocusNavigatorViewProps) { } }; - const isMale = (person: AncestryPersonCard) => person.gender === 'male'; + // Calculate generation level from breadcrumb + const generationLevel = breadcrumb.length - 1; + const generationLabel = generationLevel === 0 ? 'Self' : + generationLevel === 1 ? 'Parent' : + generationLevel === 2 ? 'Grandparent' : + `${generationLevel - 1}${getOrdinalSuffix(generationLevel - 1)} Great-Grandparent`; return ( <div className="h-full flex flex-col bg-app-bg"> {/* Breadcrumb navigation */} - <div className="px-4 py-2 bg-app-card border-b border-app-border flex items-center gap-2 text-sm overflow-x-auto"> - {breadcrumb.map((person, i) => ( - <span key={`${i}-${person.id}`} className="flex items-center gap-2 whitespace-nowrap"> - {i > 0 && <span className="text-app-text-muted">→</span>} - <button - onClick={() => navigateTo(person)} - className={`hover:text-app-link ${person.id === focusedId ? 'text-app-text font-medium' : 'text-app-text-muted'}`} - > - {person.name.split(' ')[0]} - </button> - </span> - ))} - </div> - - {/* Main content area */} - <div className="flex-1 flex flex-col items-center justify-center p-8 gap-8"> - {/* Parents row */} - <div className="flex gap-8 items-end"> - {/* Father */} - <div className="flex flex-col items-center"> - {father ? ( + <div className="px-4 py-3 bg-app-card border-b border-app-border"> + <div className="flex items-center gap-2 text-sm overflow-x-auto"> + {breadcrumb.map((person, i) => ( + <span key={`${i}-${person.id}`} className="flex items-center gap-2 whitespace-nowrap"> + {i > 0 && <span className="text-app-text-muted">→</span>} <button - onClick={() => navigateTo(father)} - className="group flex flex-col items-center" + onClick={() => navigateTo(person)} + className={`hover:text-app-link ${person.id === focusedId ? 'text-app-text font-medium' : 'text-app-text-muted'}`} > - <div className={`w-24 h-24 rounded-full border-4 ${isMale(father) ? 'border-app-male bg-app-male/10' : 'border-app-female bg-app-female/10'} flex items-center justify-center group-hover:scale-105 transition-transform overflow-hidden`}> - {father.photoUrl ? ( - <img src={father.photoUrl} alt="" className="w-full h-full object-cover" /> - ) : ( - <span className="text-3xl text-app-text-muted">{isMale(father) ? '\u{1F468}' : '\u{1F469}'}</span> - )} - </div> - <div className="mt-2 text-center"> - <div className="font-medium text-app-text group-hover:text-app-link">{father.name}</div> - <div className="text-xs text-app-text-muted">{father.lifespan}</div> - </div> + {person.name.split(' ')[0]} </button> - ) : ( - <div className="flex flex-col items-center opacity-50"> - <div className="w-24 h-24 rounded-full border-4 border-dashed border-app-border flex items-center justify-center"> - <span className="text-2xl text-app-text-muted">?</span> - </div> - <div className="mt-2 text-sm text-app-text-muted">Father Unknown</div> - </div> - )} - <div className="h-8 w-0.5 bg-app-border mt-2"></div> + </span> + ))} + </div> + {generationLevel > 0 && ( + <div className="text-xs text-app-text-subtle mt-1"> + Viewing: {generationLabel} </div> + )} + </div> - {/* Mother */} - <div className="flex flex-col items-center"> - {mother ? ( - <button - onClick={() => navigateTo(mother)} - className="group flex flex-col items-center" - > - <div className={`w-24 h-24 rounded-full border-4 ${isMale(mother) ? 'border-app-male bg-app-male/10' : 'border-app-female bg-app-female/10'} flex items-center justify-center group-hover:scale-105 transition-transform overflow-hidden`}> - {mother.photoUrl ? ( - <img src={mother.photoUrl} alt="" className="w-full h-full object-cover" /> - ) : ( - <span className="text-3xl text-app-text-muted">{isMale(mother) ? '\u{1F468}' : '\u{1F469}'}</span> - )} - </div> - <div className="mt-2 text-center"> - <div className="font-medium text-app-text group-hover:text-app-link">{mother.name}</div> - <div className="text-xs text-app-text-muted">{mother.lifespan}</div> - </div> - </button> - ) : ( - <div className="flex flex-col items-center opacity-50"> - <div className="w-24 h-24 rounded-full border-4 border-dashed border-app-border flex items-center justify-center"> - <span className="text-2xl text-app-text-muted">?</span> - </div> - <div className="mt-2 text-sm text-app-text-muted">Mother Unknown</div> - </div> - )} - <div className="h-8 w-0.5 bg-app-border mt-2"></div> - </div> + {/* Main content area */} + <div className="flex-1 flex flex-col items-center justify-center p-8 gap-6 overflow-auto"> + {/* Parents row */} + <div className="flex gap-6 items-end"> + <ParentCard + person={father} + dbId={dbId} + label="Father" + onNavigate={navigateTo} + /> + <ParentCard + person={mother} + dbId={dbId} + label="Mother" + onNavigate={navigateTo} + /> </div> - {/* Connecting line to focused person */} - <div className="flex items-center gap-0"> - <div className="w-16 h-0.5 bg-app-border"></div> - <div className="w-3 h-3 rounded-full bg-app-border"></div> - <div className="w-16 h-0.5 bg-app-border"></div> + {/* Connecting bracket */} + <div className="flex items-center"> + <div className="w-28 h-0.5 bg-app-border"></div> + <div className="w-4 h-4 rounded-full bg-app-border flex items-center justify-center"> + <div className="w-2 h-2 rounded-full bg-app-card"></div> + </div> + <div className="w-28 h-0.5 bg-app-border"></div> </div> {/* Focused person card */} - <div className={`relative p-6 rounded-2xl border-4 ${isMale(focused.person) ? 'border-app-male bg-app-male/5' : 'border-app-female bg-app-female/5'} shadow-lg max-w-md`}> - <div className="flex items-start gap-4"> - <div className={`w-20 h-20 rounded-full border-4 ${isMale(focused.person) ? 'border-app-male' : 'border-app-female'} flex items-center justify-center flex-shrink-0 overflow-hidden`}> - {focused.person.photoUrl ? ( - <img src={focused.person.photoUrl} alt="" className="w-full h-full object-cover" /> - ) : ( - <span className="text-3xl text-app-text-muted">{isMale(focused.person) ? '\u{1F468}' : '\u{1F469}'}</span> - )} - </div> - <div className="flex-1 min-w-0"> - <h2 className="text-xl font-bold text-app-text">{focused.person.name}</h2> - <div className="text-sm text-app-text-muted mt-1">{focused.person.lifespan}</div> - <div className="mt-2"> - <Link - to={`/person/${dbId}/${focused.person.id}`} - className="text-xs text-app-link hover:underline" - > - View full profile → - </Link> - </div> - </div> - </div> - </div> + <FocusedPersonCard person={focused.person} dbId={dbId} /> {/* Back navigation button */} {breadcrumb.length > 1 && ( @@ -228,6 +303,17 @@ export function FocusNavigatorView({ data, dbId }: FocusNavigatorViewProps) { </button> )} </div> + + {/* Footer legend */} + <div className="px-4 py-2 border-t border-app-border bg-app-card text-xs text-app-text-muted"> + Click a parent card to navigate up the ancestry tree + </div> </div> ); } + +function getOrdinalSuffix(n: number): string { + const s = ['th', 'st', 'nd', 'rd']; + const v = n % 100; + return s[(v - 20) % 10] || s[v] || s[0]; +} diff --git a/server/src/services/ancestry-tree.service.ts b/server/src/services/ancestry-tree.service.ts index eb0247b..35df0ee 100644 --- a/server/src/services/ancestry-tree.service.ts +++ b/server/src/services/ancestry-tree.service.ts @@ -54,7 +54,11 @@ function buildPersonCard( lifespan: person.lifespan, gender: person.gender || 'unknown', photoUrl: resolvePhotoUrl(id), - hasMoreAncestors + hasMoreAncestors, + // Extended fields + birthPlace: person.birth?.place, + deathPlace: person.death?.place, + occupation: person.occupation || person.occupations?.[0] }; } diff --git a/shared/src/index.ts b/shared/src/index.ts index 46f69d6..7f32fb0 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -451,6 +451,10 @@ export interface AncestryPersonCard { gender: 'male' | 'female' | 'unknown'; photoUrl?: string; hasMoreAncestors: boolean; + // Extended fields for detailed views + birthPlace?: string; + deathPlace?: string; + occupation?: string; } // Family unit containing father and mother cards From db9cb8a5d2250d12ddf964e71c33fda4cf89205a Mon Sep 17 00:00:00 2001 From: Adam Eivy <atomantic@gmail.com> Date: Wed, 21 Jan 2026 22:22:05 -0800 Subject: [PATCH 14/27] Fix pedigree view bracket lines - connect vertical line below horizontal --- .../ancestry-tree/views/PedigreeChartView.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/client/src/components/ancestry-tree/views/PedigreeChartView.tsx b/client/src/components/ancestry-tree/views/PedigreeChartView.tsx index edf06a2..06aa773 100644 --- a/client/src/components/ancestry-tree/views/PedigreeChartView.tsx +++ b/client/src/components/ancestry-tree/views/PedigreeChartView.tsx @@ -125,17 +125,17 @@ function PedigreeLevel({ node, dbId, level = 0, maxLevel = 4 }: PedigreeLevelPro {/* Connecting lines - bracket opens upward └──┘ */} {level < maxLevel - 1 && node && (node.father || node.mother) && ( <div className="relative h-6 w-full flex justify-center mb-2"> - {/* Vertical line up from person */} - <div className="absolute top-0 w-0.5 h-3 bg-app-border"></div> - {/* Horizontal line connecting to parents */} - <div className="absolute bottom-0 h-0.5 bg-app-border" style={{ width: level === 0 ? '50%' : level === 1 ? '40%' : '30%' }}></div> - {/* Vertical lines down from each parent */} + {/* Vertical lines down from each parent (top half) */} {node.father && ( - <div className="absolute bottom-0 w-0.5 h-3 bg-app-border" style={{ left: level === 0 ? '25%' : level === 1 ? '30%' : '35%' }}></div> + <div className="absolute top-0 w-0.5 h-3 bg-app-border" style={{ left: level === 0 ? '25%' : level === 1 ? '30%' : '35%' }}></div> )} {node.mother && ( - <div className="absolute bottom-0 w-0.5 h-3 bg-app-border" style={{ right: level === 0 ? '25%' : level === 1 ? '30%' : '35%' }}></div> + <div className="absolute top-0 w-0.5 h-3 bg-app-border" style={{ right: level === 0 ? '25%' : level === 1 ? '30%' : '35%' }}></div> )} + {/* Horizontal line connecting parents (middle) */} + <div className="absolute top-3 h-0.5 bg-app-border" style={{ width: level === 0 ? '50%' : level === 1 ? '40%' : '30%' }}></div> + {/* Vertical line down to child (bottom half) */} + <div className="absolute top-3 w-0.5 h-3 bg-app-border"></div> </div> )} From ad21c962f1d0b911b4134b7d2b237ac5c3cf3089 Mon Sep 17 00:00:00 2001 From: Adam Eivy <atomantic@gmail.com> Date: Wed, 21 Jan 2026 22:27:06 -0800 Subject: [PATCH 15/27] Fix disconnected connector lines in Focus Navigator view - Remove individual vertical lines from parent cards - Create unified connector bracket spanning full parent container width - Position vertical lines aligned with parent card centers - Add proper spacing between connector and focused person card --- .../views/FocusNavigatorView.tsx | 135 +++++++++--------- 1 file changed, 67 insertions(+), 68 deletions(-) diff --git a/client/src/components/ancestry-tree/views/FocusNavigatorView.tsx b/client/src/components/ancestry-tree/views/FocusNavigatorView.tsx index 9951ac5..bba81b7 100644 --- a/client/src/components/ancestry-tree/views/FocusNavigatorView.tsx +++ b/client/src/components/ancestry-tree/views/FocusNavigatorView.tsx @@ -73,12 +73,10 @@ function buildPersonMap(data: AncestryTreeResult): Map<string, PersonWithAncesto // Parent card component - medium detail level function ParentCard({ person, - dbId, label, onNavigate }: { person: AncestryPersonCard | undefined; - dbId: string; label: string; onNavigate: (person: AncestryPersonCard) => void; }) { @@ -86,56 +84,50 @@ function ParentCard({ if (!person) { return ( - <div className="flex flex-col items-center"> - <div className="w-48 p-4 rounded-xl border-2 border-dashed border-app-border bg-app-card/30 opacity-60"> - <div className="flex items-center gap-3"> - <div className="w-16 h-16 rounded-full border-2 border-dashed border-app-border flex items-center justify-center flex-shrink-0"> - <span className="text-2xl text-app-text-muted">?</span> - </div> - <div> - <div className="text-sm font-medium text-app-text-muted">{label}</div> - <div className="text-xs text-app-text-subtle">Unknown</div> - </div> + <div className="w-56 p-4 rounded-xl border-2 border-dashed border-app-border bg-app-card/30 opacity-60"> + <div className="flex items-center gap-3"> + <div className="w-16 h-16 rounded-full border-2 border-dashed border-app-border flex items-center justify-center flex-shrink-0"> + <span className="text-2xl text-app-text-muted">?</span> + </div> + <div> + <div className="text-sm font-medium text-app-text-muted">{label}</div> + <div className="text-xs text-app-text-subtle">Unknown</div> </div> </div> - <div className="h-8 w-0.5 bg-app-border mt-2"></div> </div> ); } return ( - <div className="flex flex-col items-center"> - <button - onClick={() => onNavigate(person)} - className={`group w-56 p-4 rounded-xl border-2 ${isMale ? 'border-app-male bg-app-male/5 hover:bg-app-male/10' : 'border-app-female bg-app-female/5 hover:bg-app-female/10'} transition-all hover:shadow-md`} - > - <div className="flex items-start gap-3"> - <div className={`w-16 h-16 rounded-full border-3 ${isMale ? 'border-app-male' : 'border-app-female'} flex items-center justify-center flex-shrink-0 overflow-hidden group-hover:scale-105 transition-transform`}> - {person.photoUrl ? ( - <img src={person.photoUrl} alt="" className="w-full h-full object-cover" /> - ) : ( - <span className="text-2xl text-app-text-muted">{isMale ? '\u{1F468}' : '\u{1F469}'}</span> - )} - </div> - <div className="flex-1 min-w-0 text-left"> - <div className="text-xs text-app-text-subtle uppercase tracking-wide">{label}</div> - <div className="font-semibold text-app-text group-hover:text-app-link truncate">{person.name}</div> - <div className="text-xs text-app-text-muted">{person.lifespan}</div> - {person.birthPlace && ( - <div className="text-xs text-app-text-subtle mt-1 truncate" title={person.birthPlace}> - Born: {person.birthPlace} - </div> - )} - </div> + <button + onClick={() => onNavigate(person)} + className={`group w-56 p-4 rounded-xl border-2 ${isMale ? 'border-app-male bg-app-male/5 hover:bg-app-male/10' : 'border-app-female bg-app-female/5 hover:bg-app-female/10'} transition-all hover:shadow-md`} + > + <div className="flex items-start gap-3"> + <div className={`w-16 h-16 rounded-full border-3 ${isMale ? 'border-app-male' : 'border-app-female'} flex items-center justify-center flex-shrink-0 overflow-hidden group-hover:scale-105 transition-transform`}> + {person.photoUrl ? ( + <img src={person.photoUrl} alt="" className="w-full h-full object-cover" /> + ) : ( + <span className="text-2xl text-app-text-muted">{isMale ? '\u{1F468}' : '\u{1F469}'}</span> + )} </div> - {person.hasMoreAncestors && ( - <div className="mt-2 text-xs text-app-link text-center"> - Click to view parents ↑ - </div> - )} - </button> - <div className="h-8 w-0.5 bg-app-border mt-2"></div> - </div> + <div className="flex-1 min-w-0 text-left"> + <div className="text-xs text-app-text-subtle uppercase tracking-wide">{label}</div> + <div className="font-semibold text-app-text group-hover:text-app-link truncate">{person.name}</div> + <div className="text-xs text-app-text-muted">{person.lifespan}</div> + {person.birthPlace && ( + <div className="text-xs text-app-text-subtle mt-1 truncate" title={person.birthPlace}> + Born: {person.birthPlace} + </div> + )} + </div> + </div> + {person.hasMoreAncestors && ( + <div className="mt-2 text-xs text-app-link text-center"> + Click to view parents ↑ + </div> + )} + </button> ); } @@ -264,40 +256,47 @@ export function FocusNavigatorView({ data, dbId }: FocusNavigatorViewProps) { </div> {/* Main content area */} - <div className="flex-1 flex flex-col items-center justify-center p-8 gap-6 overflow-auto"> - {/* Parents row */} - <div className="flex gap-6 items-end"> - <ParentCard - person={father} - dbId={dbId} - label="Father" - onNavigate={navigateTo} - /> - <ParentCard - person={mother} - dbId={dbId} - label="Mother" - onNavigate={navigateTo} - /> - </div> + <div className="flex-1 flex flex-col items-center justify-center p-8 overflow-auto"> + {/* Parents and connector as unified structure */} + <div className="flex flex-col items-center"> + {/* Parents row */} + <div className="flex gap-6"> + <ParentCard + person={father} + label="Father" + onNavigate={navigateTo} + /> + <ParentCard + person={mother} + label="Mother" + onNavigate={navigateTo} + /> + </div> - {/* Connecting bracket */} - <div className="flex items-center"> - <div className="w-28 h-0.5 bg-app-border"></div> - <div className="w-4 h-4 rounded-full bg-app-border flex items-center justify-center"> - <div className="w-2 h-2 rounded-full bg-app-card"></div> + {/* Connecting bracket - forms └─┬─┘ shape spanning parent cards */} + {/* Container matches parent row: 2x w-56 (224px each) + gap-6 (24px) = ~472px */} + <div className="relative h-12 mt-0" style={{ width: 'calc(14rem * 2 + 1.5rem)' }}> + {/* Left vertical from father card center */} + <div className="absolute w-0.5 h-4 bg-app-border" style={{ left: 'calc(7rem)', top: 0 }}></div> + {/* Right vertical from mother card center */} + <div className="absolute w-0.5 h-4 bg-app-border" style={{ right: 'calc(7rem)', top: 0 }}></div> + {/* Horizontal line connecting parents */} + <div className="absolute h-0.5 bg-app-border" style={{ left: 'calc(7rem)', right: 'calc(7rem)', top: '1rem' }}></div> + {/* Center vertical going down to child */} + <div className="absolute left-1/2 w-0.5 h-8 bg-app-border -translate-x-1/2" style={{ top: '1rem' }}></div> </div> - <div className="w-28 h-0.5 bg-app-border"></div> </div> {/* Focused person card */} - <FocusedPersonCard person={focused.person} dbId={dbId} /> + <div className="mt-2"> + <FocusedPersonCard person={focused.person} dbId={dbId} /> + </div> {/* Back navigation button */} {breadcrumb.length > 1 && ( <button onClick={goBack} - className="px-4 py-2 rounded-lg bg-app-border text-app-text-secondary hover:bg-app-hover transition-colors" + className="mt-6 px-4 py-2 rounded-lg bg-app-border text-app-text-secondary hover:bg-app-hover transition-colors" > ← Back to {breadcrumb[breadcrumb.length - 2].name.split(' ')[0]} </button> From 54b2f53847100639eabc318501dcafcf660b8ff3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 06:28:57 +0000 Subject: [PATCH 16/27] build: bump version to 0.2.5 [skip ci] --- client/package.json | 2 +- package-lock.json | 10 +++++----- package.json | 2 +- server/package.json | 2 +- shared/package.json | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/client/package.json b/client/package.json index f04ec5d..6948ffa 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/client", - "version": "0.2.4", + "version": "0.2.5", "type": "module", "scripts": { "dev": "vite --port 6373", diff --git a/package-lock.json b/package-lock.json index 670bf7c..ce7aaaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sparsetree", - "version": "0.2.4", + "version": "0.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sparsetree", - "version": "0.2.4", + "version": "0.2.5", "license": "ISC", "workspaces": [ "shared", @@ -51,7 +51,7 @@ }, "client": { "name": "@fsf/client", - "version": "0.2.4", + "version": "0.2.5", "dependencies": { "@fsf/shared": "*", "d3": "^7.9.0", @@ -6096,7 +6096,7 @@ }, "server": { "name": "@fsf/server", - "version": "0.2.4", + "version": "0.2.5", "dependencies": { "@fsf/shared": "*", "cors": "^2.8.5", @@ -6116,7 +6116,7 @@ }, "shared": { "name": "@fsf/shared", - "version": "0.2.4", + "version": "0.2.5", "devDependencies": { "typescript": "^5.7.2" } diff --git a/package.json b/package.json index 2f0094f..1122d4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sparsetree", - "version": "0.2.4", + "version": "0.2.5", "private": true, "description": "", "main": "index.js", diff --git a/server/package.json b/server/package.json index cc32b75..76d3b8e 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/server", - "version": "0.2.4", + "version": "0.2.5", "type": "module", "main": "dist/index.js", "scripts": { diff --git a/shared/package.json b/shared/package.json index 2907dc6..71db05a 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/shared", - "version": "0.2.4", + "version": "0.2.5", "type": "module", "main": "types/index.js", "types": "types/index.d.ts", From 4a5480e105e7ae57a925417c8546fa05f7940f81 Mon Sep 17 00:00:00 2001 From: Adam Eivy <atomantic@gmail.com> Date: Thu, 22 Jan 2026 06:23:08 -0800 Subject: [PATCH 17/27] Fix indexer crash on network timeouts with retry logic - fscget.js now properly categorizes errors (network vs API) - Transient errors (ETIMEDOUT, ECONNRESET, etc.) trigger automatic retry - Exponential backoff: 5s, 10s, 20s between retries (up to 3 attempts) - Failed fetches skip the person and continue indexing instead of crashing - Fixes "Cannot read properties of undefined (reading 'data')" crash --- .changelog/v0.2.x.md | 4 ++ index.js | 95 ++++++++++++++++++++++++++++++-------------- lib/fscget.js | 42 +++++++++++++++++--- 3 files changed, 106 insertions(+), 35 deletions(-) diff --git a/.changelog/v0.2.x.md b/.changelog/v0.2.x.md index 3f0e983..15bc11c 100644 --- a/.changelog/v0.2.x.md +++ b/.changelog/v0.2.x.md @@ -65,6 +65,10 @@ Major enhancements to the ancestry tree visualization with four view modes and l - Browser auto-connects when linking Ancestry profiles (no more "Browser not connected" errors) - Ancestry photo now appears in sparse tree view +- Indexer now handles network timeouts gracefully with retry logic instead of crashing + - ETIMEDOUT, ECONNRESET, and other transient errors automatically retry up to 3 times + - Exponential backoff (5s, 10s, 20s) between retries + - Failed fetches skip the person and continue indexing instead of exiting ## 📦 Installation diff --git a/index.js b/index.js index cb43140..d401c16 100644 --- a/index.js +++ b/index.js @@ -28,6 +28,9 @@ if (logToTSV) { const { minDelay, maxDelay } = config; +const MAX_RETRIES = 3; +const RETRY_BASE_DELAY = 5000; // 5 seconds base delay, doubles each retry + const icons = { cached: "💾", refreshed: "🔄", @@ -82,39 +85,71 @@ const getPerson = async (id, generation) => { } else { activity.new++; } - apidata = await fscget(`/platform/tree/persons/${id}`).catch( - async (response) => { - if ( - response?.data?.errors && - response.data.errors[0].message.includes(`Unable to read Person`) - ) { - // this node was deleted from the API - // go back up through the current cached db and ensure - // we purge this person from disk and reload data for children - console.log(`purging ${id} from cache and reloading children...`); - if (cached) fs.unlinkSync(file); - delete db[id]; - const dbIds = Object.keys(db); - for (let i = 0; i < dbIds.length; i++) { - const child = dbIds[i]; - if (db[child].parents.includes(id)) { - // need to refresh this child - console.log(`refreshing child ${child}...`); - await getPerson(child, generation - 1); - } + + // Fetch with retry logic for transient errors + let lastError; + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + const result = await fscget(`/platform/tree/persons/${id}`).catch( + (err) => ({ _error: err }) + ); + + // Success - got data + if (!result?._error) { + apidata = result; + break; + } + + const error = result._error; + lastError = error; + + // Handle "person deleted" API error - not retryable + if ( + error.errors && + error.errors[0]?.message?.includes(`Unable to read Person`) + ) { + // this node was deleted from the API + // go back up through the current cached db and ensure + // we purge this person from disk and reload data for children + console.log(`purging ${id} from cache and reloading children...`); + if (cached) fs.unlinkSync(file); + delete db[id]; + const dbIds = Object.keys(db); + for (let i = 0; i < dbIds.length; i++) { + const child = dbIds[i]; + if (db[child].parents.includes(id)) { + // need to refresh this child + console.log(`refreshing child ${child}...`); + await getPerson(child, generation - 1); } - return; - } else { - console.error( - "error getting person for", - id, - `you may want to run:\nnode purge ${id}`, - response.data.errors - ); - process.exit(1); } + return; + } + + // Check if error is transient and retryable + if (error.isTransient && attempt < MAX_RETRIES) { + const retryDelay = RETRY_BASE_DELAY * Math.pow(2, attempt); + console.log( + `⚠️ ${error.code || "Network error"} for ${id}, retrying in ${ + retryDelay / 1000 + }s (attempt ${attempt + 1}/${MAX_RETRIES})...` + ); + await sleep(retryDelay); + continue; } - ); + + // Non-transient error or exhausted retries - log and skip this person + const errorMsg = error.isNetworkError + ? `Network error: ${error.code || error.message}` + : `API error: ${error.errors?.[0]?.message || error.statusCode || "Unknown"}`; + + console.error( + `❌ Failed to fetch ${id} after ${attempt + 1} attempts: ${errorMsg}` + ); + console.error(` You may want to run: node purge ${id}`); + + // Continue indexing other people instead of crashing + return; + } if (apidata) { const jsondata = JSON.stringify(apidata, null, 2); if (contents !== jsondata) { diff --git a/lib/fscget.js b/lib/fscget.js index 65a443d..aef907e 100644 --- a/lib/fscget.js +++ b/lib/fscget.js @@ -1,20 +1,52 @@ import { fsc } from "./fs.client.js"; +// Transient network error codes that should trigger retry +const TRANSIENT_ERROR_CODES = [ + "ETIMEDOUT", + "ECONNRESET", + "ECONNREFUSED", + "ENOTFOUND", + "EAI_AGAIN", + "EPIPE", + "EHOSTUNREACH", + "ENETUNREACH", +]; + export const fscget = async (url) => new Promise((resolve, reject) => { fsc.get(url, (error, response) => { - if (error || response.statusCode >= 400) { + // Handle network-level errors (no response received) + if (error) { + const errorCode = error.code || error.errno; + const isTransient = TRANSIENT_ERROR_CODES.includes(errorCode); + return reject({ + isNetworkError: true, + isTransient, + code: errorCode, + message: error.message || String(error), + originalError: error, + }); + } + + // Handle HTTP errors (response received but with error status) + if (response.statusCode >= 400) { const errors = response?.data?.errors; - console.error(errors || error || response); - if (errors && errors[0].label === "Unauthorized") { + console.error(errors || response); + if (errors && errors[0]?.label === "Unauthorized") { console.error( `your FS_ACCESS_TOKEN is invalid, please use a new one.` ); process.exit(1); } - return reject(response); + return reject({ + isNetworkError: false, + isTransient: response.statusCode >= 500, // 5xx errors are often transient + statusCode: response.statusCode, + data: response.data, + errors: errors, + }); } - // if (!response.data) console.log(response); + resolve(response.data); }); }); From 03cbc6fc99ae82ef75904c7a103d4b42acf90d38 Mon Sep 17 00:00:00 2001 From: Adam Eivy <atomantic@gmail.com> Date: Thu, 22 Jan 2026 06:24:07 -0800 Subject: [PATCH 18/27] fix: address PR review feedback - Add array validation for parentUnits in PedigreeChartView to handle malformed data - Add explicit typing for currentPerson field in IndexerProgress - Validate CLI executable exists before spawning indexer process - Add logging for WikiTree HTML parsing when patterns don't match - Add explicit logging when auto-login is triggered for Ancestry - Document FamilySearch token cookie priority and add logging --- .../ancestry-tree/views/PedigreeChartView.tsx | 14 +++++++++----- server/src/services/augmentation.service.ts | 11 ++++++++++- server/src/services/browser.service.ts | 15 +++++++++++++-- server/src/services/indexer.service.ts | 9 ++++++++- 4 files changed, 40 insertions(+), 9 deletions(-) diff --git a/client/src/components/ancestry-tree/views/PedigreeChartView.tsx b/client/src/components/ancestry-tree/views/PedigreeChartView.tsx index 06aa773..dd5b96a 100644 --- a/client/src/components/ancestry-tree/views/PedigreeChartView.tsx +++ b/client/src/components/ancestry-tree/views/PedigreeChartView.tsx @@ -25,20 +25,24 @@ function buildAncestorTree(data: AncestryTreeResult): AncestorNode { const buildNode = (person: AncestryPersonCard, parentUnits?: AncestryFamilyUnit[]): AncestorNode => { const node: AncestorNode = { person }; - if (parentUnits && parentUnits.length > 0) { - const unit = parentUnits[0]; + const safeParentUnits = Array.isArray(parentUnits) ? parentUnits : undefined; + if (safeParentUnits && safeParentUnits.length > 0) { + const unit = safeParentUnits[0]; if (unit.father) { - node.father = buildNode(unit.father, unit.fatherParentUnits); + const fatherParentUnits = Array.isArray(unit.fatherParentUnits) ? unit.fatherParentUnits : undefined; + node.father = buildNode(unit.father, fatherParentUnits); } if (unit.mother) { - node.mother = buildNode(unit.mother, unit.motherParentUnits); + const motherParentUnits = Array.isArray(unit.motherParentUnits) ? unit.motherParentUnits : undefined; + node.mother = buildNode(unit.mother, motherParentUnits); } } return node; }; - return buildNode(data.rootPerson, data.parentUnits); + const rootParentUnits = Array.isArray(data.parentUnits) ? data.parentUnits : undefined; + return buildNode(data.rootPerson, rootParentUnits); } interface PersonNodeProps { diff --git a/server/src/services/augmentation.service.ts b/server/src/services/augmentation.service.ts index 0786483..74c9de3 100644 --- a/server/src/services/augmentation.service.ts +++ b/server/src/services/augmentation.service.ts @@ -605,12 +605,13 @@ export const augmentationService = { let currentUrl = page.url(); if (currentUrl.includes('/signin') || currentUrl.includes('/login')) { console.log(`[augment] Redirected to login page, attempting auto-login...`); + console.log(`[augment] NOTE: Auto-login will use your saved Ancestry credentials automatically.`); // Check for saved credentials const credentials = credentialsService.getCredentials('ancestry'); if (credentials?.password) { const username = credentials.email || credentials.username || ''; - console.log(`[augment] Found saved credentials for ${username}, performing login...`); + console.log(`[augment] Auto-login triggered: Using saved credentials for ${username}`); const scraper = getScraper('ancestry'); const loginSuccess = await scraper.performLogin(page, username, credentials.password) @@ -803,6 +804,8 @@ export const augmentationService = { const vitalMatch = html.match(/<span class="VITALS"[^>]*>([^<]+)</i); if (vitalMatch) { description = vitalMatch[1].trim(); + } else { + console.log(`[augment] WikiTree: Could not extract vital info from page (VITALS pattern not found)`); } // Extract profile text/bio @@ -816,6 +819,8 @@ export const augmentationService = { if (bioText.length > description.length) { description = bioText; } + } else { + console.log(`[augment] WikiTree: Could not extract bio from page (profile-text pattern not found)`); } // Extract photo URL @@ -843,6 +848,10 @@ export const augmentationService = { } } + if (!photoUrl) { + console.log(`[augment] WikiTree: Could not extract photo URL from page (no photo patterns matched)`); + } + // Normalize photo URL if (photoUrl) { if (photoUrl.startsWith('//')) { diff --git a/server/src/services/browser.service.ts b/server/src/services/browser.service.ts index 68e4587..d5716db 100644 --- a/server/src/services/browser.service.ts +++ b/server/src/services/browser.service.ts @@ -261,20 +261,31 @@ export const browserService = { allCookies.push(...cookies); } - // FamilySearch uses several cookie names for authentication - // The main one is usually 'fssessionid' or we can look for auth tokens + // FamilySearch authentication tokens: + // - 'fssessionid': Primary session cookie used by FamilySearch for API authentication + // - 'FS_AUTH_TOKEN': Legacy auth token format (fallback) + // - 'Authorization': Bearer token if stored in cookies (rare) + // Priority: fssessionid > FS_AUTH_TOKEN > Authorization + // We return the first match found to ensure consistent auth. const authCookieNames = ['fssessionid', 'FS_AUTH_TOKEN', 'Authorization']; let token: string | null = null; + let matchedCookieName: string | null = null; for (const cookieName of authCookieNames) { const cookie = allCookies.find(c => c.name === cookieName); if (cookie) { token = cookie.value; + matchedCookieName = cookieName; + console.log(`[browser] Found FamilySearch auth token in cookie: ${cookieName}`); break; } } + if (!token) { + console.log(`[browser] No FamilySearch auth token found. Checked cookies: ${authCookieNames.join(', ')}`); + } + // Filter to only return relevant auth cookies const relevantCookies = allCookies .filter(c => c.domain.includes('familysearch')) diff --git a/server/src/services/indexer.service.ts b/server/src/services/indexer.service.ts index 840bbde..70893e1 100644 --- a/server/src/services/indexer.service.ts +++ b/server/src/services/indexer.service.ts @@ -3,6 +3,7 @@ import { sseManager } from '../utils/sseManager.js'; import { browserService } from './browser.service.js'; import { spawn, ChildProcess } from 'child_process'; import path from 'path'; +import fs from 'fs'; const PROJECT_ROOT = path.resolve(import.meta.dirname, '../../../'); @@ -30,7 +31,7 @@ export const indexerService = { refreshed: 0, generations: 0, deepest: '', - currentPerson: undefined + currentPerson: undefined as string | undefined }; currentStatus = { @@ -105,6 +106,12 @@ export const indexerService = { console.log(`[indexer] Running: node ${args.join(' ')}`); + // Validate CLI executable exists before spawning + const cliPath = path.join(PROJECT_ROOT, 'index.js'); + if (!fs.existsSync(cliPath)) { + throw new Error(`CLI not found at ${cliPath}. Please ensure the project is properly set up.`); + } + // Spawn the CLI process with the token currentProcess = spawn('node', args, { cwd: PROJECT_ROOT, From a3900bebc9cfd9c59846b8149c7bd7b74859a5bc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 14:25:05 +0000 Subject: [PATCH 19/27] build: bump version to 0.2.6 [skip ci] --- client/package.json | 2 +- package-lock.json | 10 +++++----- package.json | 2 +- server/package.json | 2 +- shared/package.json | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/client/package.json b/client/package.json index 6948ffa..266621b 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/client", - "version": "0.2.5", + "version": "0.2.6", "type": "module", "scripts": { "dev": "vite --port 6373", diff --git a/package-lock.json b/package-lock.json index ce7aaaa..5516046 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sparsetree", - "version": "0.2.5", + "version": "0.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sparsetree", - "version": "0.2.5", + "version": "0.2.6", "license": "ISC", "workspaces": [ "shared", @@ -51,7 +51,7 @@ }, "client": { "name": "@fsf/client", - "version": "0.2.5", + "version": "0.2.6", "dependencies": { "@fsf/shared": "*", "d3": "^7.9.0", @@ -6096,7 +6096,7 @@ }, "server": { "name": "@fsf/server", - "version": "0.2.5", + "version": "0.2.6", "dependencies": { "@fsf/shared": "*", "cors": "^2.8.5", @@ -6116,7 +6116,7 @@ }, "shared": { "name": "@fsf/shared", - "version": "0.2.5", + "version": "0.2.6", "devDependencies": { "typescript": "^5.7.2" } diff --git a/package.json b/package.json index 1122d4e..036d34b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sparsetree", - "version": "0.2.5", + "version": "0.2.6", "private": true, "description": "", "main": "index.js", diff --git a/server/package.json b/server/package.json index 76d3b8e..f1b6cfb 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/server", - "version": "0.2.5", + "version": "0.2.6", "type": "module", "main": "dist/index.js", "scripts": { diff --git a/shared/package.json b/shared/package.json index 71db05a..eaca521 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/shared", - "version": "0.2.5", + "version": "0.2.6", "type": "module", "main": "types/index.js", "types": "types/index.d.ts", From 47dfbd05c5debc6b113c1a740f95f68ec7c79ab1 Mon Sep 17 00:00:00 2001 From: Adam Eivy <atomantic@gmail.com> Date: Thu, 22 Jan 2026 08:45:43 -0800 Subject: [PATCH 20/27] feat: auto-seed all 8 genealogy providers on first load - Pre-populate FamilySearch, Ancestry, WikiTree, MyHeritage, Geni, FindMyPast, Find A Grave, and 23andMe providers (all disabled by default) - Users just enable and configure the ones they want instead of manually adding each provider - Remove "Add Provider" button from UI since all providers are pre-seeded - Add 23andMe to platform defaults and colors --- .changelog/v0.2.x.md | 4 ++ client/src/pages/GenealogyProviders.tsx | 24 +++----- .../services/genealogy-provider.service.ts | 56 ++++++++++++++++++- 3 files changed, 65 insertions(+), 19 deletions(-) diff --git a/.changelog/v0.2.x.md b/.changelog/v0.2.x.md index 15bc11c..7500ead 100644 --- a/.changelog/v0.2.x.md +++ b/.changelog/v0.2.x.md @@ -46,6 +46,10 @@ Major enhancements to the ancestry tree visualization with four view modes and l ## 🔧 Improvements +### Genealogy Providers +- All 8 supported genealogy providers (FamilySearch, Ancestry, WikiTree, MyHeritage, Geni, FindMyPast, Find A Grave, 23andMe) are now pre-populated on first launch +- No more manually adding each provider - just enable and configure the ones you want to use + ### Tree Visualization - **Simplified Generation Labels**: Gen 3+ shows "1st/2nd/3rd Great-Grandparents" instead of verbose labels - **Columns View Optimization**: Only displays known ancestors (hides unknown placeholders) diff --git a/client/src/pages/GenealogyProviders.tsx b/client/src/pages/GenealogyProviders.tsx index f4f7239..6d442e8 100644 --- a/client/src/pages/GenealogyProviders.tsx +++ b/client/src/pages/GenealogyProviders.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; -import { Plus, Trash2, Settings, Plug, PlugZap, CheckCircle2, XCircle, AlertCircle, Loader2, Database } from 'lucide-react'; +import { Trash2, Settings, Plug, PlugZap, CheckCircle2, XCircle, AlertCircle, Loader2, Database } from 'lucide-react'; import toast from 'react-hot-toast'; import type { GenealogyProviderConfig, GenealogyProviderRegistry } from '@fsf/shared'; import { api } from '../services/api'; @@ -14,6 +14,7 @@ const platformColors: Record<string, { bg: string; text: string }> = { findmypast: { bg: 'bg-app-accent-subtle', text: 'text-app-accent' }, ancestry: { bg: 'bg-emerald-600/10 dark:bg-emerald-600/20', text: 'text-emerald-600 dark:text-emerald-400' }, findagrave: { bg: 'bg-gray-600/10 dark:bg-gray-600/20', text: 'text-gray-600 dark:text-gray-400' }, + '23andme': { bg: 'bg-pink-600/10 dark:bg-pink-600/20', text: 'text-pink-600 dark:text-pink-400' }, }; interface DeleteConfirmModalProps { @@ -173,28 +174,17 @@ export function GenealogyProvidersPage() { <div className="flex items-center justify-between mb-6"> <div className="flex items-center gap-3"> <Database size={24} className="text-app-accent" /> - <h1 className="text-2xl font-bold text-app-text">Genealogy Providers</h1> + <div> + <h1 className="text-2xl font-bold text-app-text">Genealogy Providers</h1> + <p className="text-sm text-app-text-muted">Configure API access to genealogy data sources</p> + </div> </div> - <Link - to="/providers/genealogy/new" - className="flex items-center gap-2 px-4 py-2 bg-app-accent text-app-text rounded-lg hover:bg-app-accent/80 transition-colors" - > - <Plus size={18} /> - Add Provider - </Link> </div> {providers.length === 0 ? ( <div className="text-center py-12 bg-app-card rounded-lg border border-app-border"> <Database size={48} className="mx-auto text-app-text-subtle mb-4" /> - <p className="text-app-text-muted mb-4">No genealogy providers configured.</p> - <Link - to="/providers/genealogy/new" - className="inline-flex items-center gap-2 px-4 py-2 bg-app-accent text-app-text rounded-lg hover:bg-app-accent/80 transition-colors" - > - <Plus size={18} /> - Add Your First Provider - </Link> + <p className="text-app-text-muted mb-4">Loading providers...</p> </div> ) : ( <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> diff --git a/server/src/services/genealogy-provider.service.ts b/server/src/services/genealogy-provider.service.ts index c90a86c..25badeb 100644 --- a/server/src/services/genealogy-provider.service.ts +++ b/server/src/services/genealogy-provider.service.ts @@ -107,14 +107,66 @@ const platformDefaults: Record<string, Partial<GenealogyProviderConfig>> = { minDelayMs: 2000, maxDelayMs: 5000 } + }, + '23andme': { + name: '23andMe', + platform: '23andme', + authType: 'oauth2', + baseUrl: 'https://api.23andme.com', + timeout: 10000, + rateLimit: { + requestsPerWindow: 30, + windowSeconds: 60, + minDelayMs: 1000, + maxDelayMs: 2000 + } } }; function loadRegistry(): GenealogyProviderRegistry { if (!fs.existsSync(CONFIG_FILE)) { - return { activeProvider: null, providers: {} }; + // Seed with all default providers (disabled) + const seededRegistry = seedDefaultProviders(); + saveRegistry(seededRegistry); + return seededRegistry; } - return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')); + const registry = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')) as GenealogyProviderRegistry; + + // If registry exists but has no providers, seed it + if (Object.keys(registry.providers).length === 0) { + const seededRegistry = seedDefaultProviders(); + saveRegistry(seededRegistry); + return seededRegistry; + } + + return registry; +} + +function seedDefaultProviders(): GenealogyProviderRegistry { + const providers: Record<string, GenealogyProviderConfig> = {}; + + for (const [platform, defaults] of Object.entries(platformDefaults)) { + const id = platform; // Use platform as the ID for seeded providers + providers[id] = { + id, + name: defaults.name || platform, + platform: platform as PlatformType, + enabled: false, // Start disabled - user must enable + authType: defaults.authType || 'none', + credentials: {}, + baseUrl: defaults.baseUrl || '', + timeout: defaults.timeout || 10000, + rateLimit: defaults.rateLimit || { + requestsPerWindow: 60, + windowSeconds: 60, + minDelayMs: 500, + maxDelayMs: 1500 + }, + connectionStatus: 'disconnected' + }; + } + + return { activeProvider: null, providers }; } function saveRegistry(registry: GenealogyProviderRegistry): void { From 496efcf1bb6befd32b459c49173cb06f9ca6456b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:46:48 +0000 Subject: [PATCH 21/27] build: bump version to 0.2.7 [skip ci] --- client/package.json | 2 +- package-lock.json | 10 +++++----- package.json | 2 +- server/package.json | 2 +- shared/package.json | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/client/package.json b/client/package.json index 266621b..5a9dbf1 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/client", - "version": "0.2.6", + "version": "0.2.7", "type": "module", "scripts": { "dev": "vite --port 6373", diff --git a/package-lock.json b/package-lock.json index 5516046..542d055 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sparsetree", - "version": "0.2.6", + "version": "0.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sparsetree", - "version": "0.2.6", + "version": "0.2.7", "license": "ISC", "workspaces": [ "shared", @@ -51,7 +51,7 @@ }, "client": { "name": "@fsf/client", - "version": "0.2.6", + "version": "0.2.7", "dependencies": { "@fsf/shared": "*", "d3": "^7.9.0", @@ -6096,7 +6096,7 @@ }, "server": { "name": "@fsf/server", - "version": "0.2.6", + "version": "0.2.7", "dependencies": { "@fsf/shared": "*", "cors": "^2.8.5", @@ -6116,7 +6116,7 @@ }, "shared": { "name": "@fsf/shared", - "version": "0.2.6", + "version": "0.2.7", "devDependencies": { "typescript": "^5.7.2" } diff --git a/package.json b/package.json index 036d34b..4941b65 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sparsetree", - "version": "0.2.6", + "version": "0.2.7", "private": true, "description": "", "main": "index.js", diff --git a/server/package.json b/server/package.json index f1b6cfb..ab60180 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/server", - "version": "0.2.6", + "version": "0.2.7", "type": "module", "main": "dist/index.js", "scripts": { diff --git a/shared/package.json b/shared/package.json index eaca521..f52bbb4 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/shared", - "version": "0.2.6", + "version": "0.2.7", "type": "module", "main": "types/index.js", "types": "types/index.d.ts", From beefe2fb7afd96a3539c8ad47adf498b1917a5b5 Mon Sep 17 00:00:00 2001 From: Adam Eivy <atomantic@gmail.com> Date: Thu, 22 Jan 2026 08:47:36 -0800 Subject: [PATCH 22/27] feat: add sample database (G849-MHS) for new users - Include medieval Swiss nobility lineage as bundled sample - Server loads databases from both data/ and samples/ directories - Sample databases marked with isSample flag in DatabaseInfo - Protected from deletion (only user databases can be deleted) - User databases override samples with same ID --- .changelog/v0.2.x.md | 6 + samples/db-G849-MHS.json | 108 +++++++++++++++++ server/src/services/database.service.ts | 147 ++++++++++++++---------- shared/src/index.ts | 1 + 4 files changed, 199 insertions(+), 63 deletions(-) create mode 100644 samples/db-G849-MHS.json diff --git a/.changelog/v0.2.x.md b/.changelog/v0.2.x.md index 7500ead..3a9e586 100644 --- a/.changelog/v0.2.x.md +++ b/.changelog/v0.2.x.md @@ -44,6 +44,12 @@ Major enhancements to the ancestry tree visualization with four view modes and l - FamilySearch, Wikipedia, Ancestry, WikiTree all shown together - Link and "Use Photo" buttons for each platform +### Sample Database +- Included sample genealogy database (G849-MHS) for new users to explore the app +- Medieval Swiss nobility lineage (920-968 AD) with 4 persons +- Sample databases load automatically and are marked with `isSample` flag +- Cannot be deleted (protected from accidental removal) + ## 🔧 Improvements ### Genealogy Providers diff --git a/samples/db-G849-MHS.json b/samples/db-G849-MHS.json new file mode 100644 index 0000000..99747ec --- /dev/null +++ b/samples/db-G849-MHS.json @@ -0,0 +1,108 @@ +{ + "G849-MHS": { + "name": "Burkhard I. Graf im Zürichgau", + "gender": "male", + "living": false, + "birth": { + "date": "um 0920", + "dateFormal": "A+0920", + "place": "Zürichgau, Hzgt Schwaben, Ostfrankenreich", + "placeId": "440649" + }, + "death": { + "date": "968", + "dateFormal": "+0968", + "place": "Zürichgau, Hzgt Schwaben, Ostfrankenreich", + "placeId": "440649" + }, + "bio": "Geboren um 915/20, + um 968 (?)\nSchließt 955 Nov. 22. als Reichsvogt von Zürich einen Vertrag mit den Leuten von Uri betreffend den Zehnten von Wildheu (\"Purchardus Turengiensis castri advocatus\"). Zur Errichtung der Urkunde heißt es: \"Nos itaque Cumpoldus et Liutericus cartam solito more levantes er conscribi rogantes eundem advocatum Purchardum cum manu venerabilis domne sue Reginlinde ad Turegum venienzes legitime vestivimus.\"\n\nNach 960 Graf im Zürichgau;\n963 Juni, Zürich: \"sub duce Purchardo et sub comite Purchardo et sub advocato Utono\"\n964 Okt. 5.,12.,19. oder 26., Zürich: \"sub duce Purchardo et sub comite Purchardo\"\n965 Mai, Erstein: Kaiser OTTO I. schenkt dem Kloster Diesentis seinen Eigenhof zu Pfäffikon (kt. Zürich) mit zugehörigen Gütern zu Zell (Kanton Luzern), Entfelden und Mehlseckem, wobei Pfäffikon \"in pago Thuregum, in comitatu Buchardi comitis\" liegt.\n\nUm 964 bis 968 Feb. 19.: Graf Burkhard befiehlt Hörigen, die sich dem Großmünsterdtift Zürich entziehen wollen, demselben untertänig zu sein: \"in legitimo consilio (senioris) Buchardi comitis, dato ab ipso illis fratibus advocato nomine Manigoldo....Isti sunt testes: Burchardus comes, Manigold...\". \n\nName, Ämter und Lebenszeit weisen Graf Burkhard ebenfalls in die Verwandtschaft der Herzogin Reginlind und machen ihn als weiteren Sohn Eberhards II. wie als Bruder des Zürichgaugrafen Gottfried ) wahrscheinlich.\n\n\nGENEALOGISCHES HANDBUCH DER SCHWEIZER GESCHICHTE", + "parents": [ + "G82K-77X" + ], + "children": [], + "lastModified": "2023-10-10T18:39:34.037Z", + "lifespan": "0920-0968", + "location": "Zürichgau, Hzgt Schwaben, Ostfrankenreich" + }, + "G82K-77X": { + "name": "Graf Eberhard Evrard II IM ZÜRICHGAU Nellenbourg", + "alternateNames": [ + "Count Eberhard I of Nellenburg", + "Eberhard Graf von Zürichgau II", + "von Zürichgau" + ], + "gender": "male", + "living": false, + "birth": { + "date": "0886", + "dateFormal": "+0886", + "place": "Zürich, Zürich, Schweiz", + "placeId": "3059835" + }, + "death": { + "date": "0958", + "dateFormal": "+0958", + "place": "Bayern, Deutschland", + "placeId": "2031" + }, + "parents": [ + "G2GR-G9F", + "G2GR-P2W" + ], + "spouses": [ + "G2QW-XKQ", + "GKZZ-M4C", + "GVC6-H9F" + ], + "children": [ + "G849-MHS" + ], + "lastModified": "2023-04-01T19:38:47.329Z", + "lifespan": "0886-0958", + "location": "Zürich, Zürich, Schweiz" + }, + "G2GR-G9F": { + "name": "Eberhard I Graf im Zürichgau", + "gender": "male", + "living": false, + "birth": { + "date": "about 0858", + "dateFormal": "A+0858" + }, + "death": { + "date": "aft June 27 889", + "dateFormal": "+0889-06-27/", + "place": "Italy", + "placeId": "33" + }, + "parents": [], + "children": [ + "G82K-77X" + ], + "lastModified": "2023-10-08T22:02:55.295Z", + "lifespan": "0858-0889", + "location": "Italy" + }, + "G2GR-P2W": { + "name": "Gelisa von Nellenburg", + "gender": "female", + "living": false, + "birth": { + "date": "about 0840", + "dateFormal": "A+0840" + }, + "death": { + "date": "after 911", + "dateFormal": "+0911/", + "place": "Thurgau, Germany (Hingerichtet wegen Hochverrats)", + "placeId": "3564" + }, + "parents": [], + "children": [ + "G82K-77X" + ], + "lastModified": "2023-07-19T05:05:38.196Z", + "lifespan": "0840-0911", + "location": "Thurgau, Germany (Hingerichtet wegen Hochverrats)" + } +} \ No newline at end of file diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index 3985650..7aecd4b 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -4,84 +4,99 @@ import type { Database, DatabaseInfo } from '@fsf/shared'; // Data directory is at root of project, not in server/ const DATA_DIR = path.resolve(import.meta.dirname, '../../../data'); +// Sample databases included in the repo +const SAMPLES_DIR = path.resolve(import.meta.dirname, '../../../samples'); + +// Helper to parse database info from a file +function parseDatabaseInfo(filePath: string, filename: string, isSample = false): DatabaseInfo { + const match = filename.match(/^db-([^.]+)\.json$/); + const id = match ? match[1] : filename; + + const content = fs.readFileSync(filePath, 'utf-8'); + const db: Database = JSON.parse(content); + const personCount = Object.keys(db).length; + + // Extract root ID and max generations from filename + const parts = id.split('-'); + let rootId = id; + let maxGenerations: number | undefined; + + if (parts.length > 2 && /^\d+$/.test(parts[parts.length - 1])) { + const possibleRootId = parts.slice(0, -1).join('-'); + if (db[possibleRootId]) { + rootId = possibleRootId; + maxGenerations = parseInt(parts[parts.length - 1]); + } + } + + const rootName = db[rootId]?.name; + + return { id, filename, personCount, rootId, rootName, maxGenerations, isSample }; +} + +// Find database file path, checking both data and samples directories +function findDatabasePath(id: string): string | null { + const filename = `db-${id}.json`; + const dataPath = path.join(DATA_DIR, filename); + const samplePath = path.join(SAMPLES_DIR, filename); + + if (fs.existsSync(dataPath)) return dataPath; + if (fs.existsSync(samplePath)) return samplePath; + return null; +} export const databaseService = { async listDatabases(): Promise<DatabaseInfo[]> { - const files = fs.readdirSync(DATA_DIR); - const dbFiles = files.filter(f => f.startsWith('db-') && f.endsWith('.json')); - - return dbFiles.map(filename => { - const match = filename.match(/^db-([^.]+)\.json$/); - const id = match ? match[1] : filename; - - // Get database content first to validate rootId - const filePath = path.join(DATA_DIR, filename); - const content = fs.readFileSync(filePath, 'utf-8'); - const db: Database = JSON.parse(content); - const personCount = Object.keys(db).length; - - // Extract root ID and max generations from filename - // FamilySearch IDs are like XXXX-XXX (e.g., L5TF-642) - // Generation suffix would be: db-L5TF-642-50.json - const parts = id.split('-'); - let rootId = id; - let maxGenerations: number | undefined; - - // Only treat last part as generation if: - // 1. It's purely numeric - // 2. The remaining parts form a valid ID in the database - if (parts.length > 2 && /^\d+$/.test(parts[parts.length - 1])) { - const possibleRootId = parts.slice(0, -1).join('-'); - if (db[possibleRootId]) { - rootId = possibleRootId; - maxGenerations = parseInt(parts[parts.length - 1]); - } + const results: DatabaseInfo[] = []; + const seenIds = new Set<string>(); + + // Load from data directory (user databases) + if (fs.existsSync(DATA_DIR)) { + const files = fs.readdirSync(DATA_DIR); + const dbFiles = files.filter(f => f.startsWith('db-') && f.endsWith('.json')); + + for (const filename of dbFiles) { + const filePath = path.join(DATA_DIR, filename); + const info = parseDatabaseInfo(filePath, filename, false); + results.push(info); + seenIds.add(info.id); } + } - const rootName = db[rootId]?.name; + // Load from samples directory (bundled sample databases) + if (fs.existsSync(SAMPLES_DIR)) { + const files = fs.readdirSync(SAMPLES_DIR); + const dbFiles = files.filter(f => f.startsWith('db-') && f.endsWith('.json')); + + for (const filename of dbFiles) { + const filePath = path.join(SAMPLES_DIR, filename); + const info = parseDatabaseInfo(filePath, filename, true); + // Don't add if user has their own copy + if (!seenIds.has(info.id)) { + results.push(info); + } + } + } - return { id, filename, personCount, rootId, rootName, maxGenerations }; - }); + return results; }, async getDatabaseInfo(id: string): Promise<DatabaseInfo> { const filename = `db-${id}.json`; - const filePath = path.join(DATA_DIR, filename); + const filePath = findDatabasePath(id); - if (!fs.existsSync(filePath)) { + if (!filePath) { throw new Error(`Database ${id} not found`); } - const content = fs.readFileSync(filePath, 'utf-8'); - const db: Database = JSON.parse(content); - const personCount = Object.keys(db).length; - - // Extract root ID and max generations from filename - const parts = id.split('-'); - let rootId = id; - let maxGenerations: number | undefined; - - // Only treat last part as generation if: - // 1. It's purely numeric - // 2. The remaining parts form a valid ID in the database - if (parts.length > 2 && /^\d+$/.test(parts[parts.length - 1])) { - const possibleRootId = parts.slice(0, -1).join('-'); - if (db[possibleRootId]) { - rootId = possibleRootId; - maxGenerations = parseInt(parts[parts.length - 1]); - } - } - - const rootName = db[rootId]?.name; - - return { id, filename, personCount, rootId, rootName, maxGenerations }; + const isSample = filePath.includes(SAMPLES_DIR); + return parseDatabaseInfo(filePath, filename, isSample); }, async getDatabase(id: string): Promise<Database> { - const filename = `db-${id}.json`; - const filePath = path.join(DATA_DIR, filename); + const filePath = findDatabasePath(id); - if (!fs.existsSync(filePath)) { + if (!filePath) { throw new Error(`Database ${id} not found`); } @@ -91,12 +106,18 @@ export const databaseService = { async deleteDatabase(id: string): Promise<void> { const filename = `db-${id}.json`; - const filePath = path.join(DATA_DIR, filename); + const dataPath = path.join(DATA_DIR, filename); + const samplePath = path.join(SAMPLES_DIR, filename); + + // Only allow deleting user databases, not samples + if (fs.existsSync(samplePath) && !fs.existsSync(dataPath)) { + throw new Error(`Cannot delete sample database ${id}`); + } - if (!fs.existsSync(filePath)) { + if (!fs.existsSync(dataPath)) { throw new Error(`Database ${id} not found`); } - fs.unlinkSync(filePath); + fs.unlinkSync(dataPath); } }; diff --git a/shared/src/index.ts b/shared/src/index.ts index 7f32fb0..72b0199 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -298,6 +298,7 @@ export interface DatabaseInfo { maxGenerations?: number; sourceProvider?: string; // Provider ID that was used to create this database sourceRootExternalId?: string; // External ID from the source provider + isSample?: boolean; // True if this is a bundled sample database } // Person with ID included From fa7bc4e10b70d60ac8efb2a2860631b60a4e5a65 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:48:32 +0000 Subject: [PATCH 23/27] build: bump version to 0.2.8 [skip ci] --- client/package.json | 2 +- package-lock.json | 10 +++++----- package.json | 2 +- server/package.json | 2 +- shared/package.json | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/client/package.json b/client/package.json index 5a9dbf1..67139d9 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/client", - "version": "0.2.7", + "version": "0.2.8", "type": "module", "scripts": { "dev": "vite --port 6373", diff --git a/package-lock.json b/package-lock.json index 542d055..09e1d87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sparsetree", - "version": "0.2.7", + "version": "0.2.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sparsetree", - "version": "0.2.7", + "version": "0.2.8", "license": "ISC", "workspaces": [ "shared", @@ -51,7 +51,7 @@ }, "client": { "name": "@fsf/client", - "version": "0.2.7", + "version": "0.2.8", "dependencies": { "@fsf/shared": "*", "d3": "^7.9.0", @@ -6096,7 +6096,7 @@ }, "server": { "name": "@fsf/server", - "version": "0.2.7", + "version": "0.2.8", "dependencies": { "@fsf/shared": "*", "cors": "^2.8.5", @@ -6116,7 +6116,7 @@ }, "shared": { "name": "@fsf/shared", - "version": "0.2.7", + "version": "0.2.8", "devDependencies": { "typescript": "^5.7.2" } diff --git a/package.json b/package.json index 4941b65..96310a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sparsetree", - "version": "0.2.7", + "version": "0.2.8", "private": true, "description": "", "main": "index.js", diff --git a/server/package.json b/server/package.json index ab60180..a3a2013 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/server", - "version": "0.2.7", + "version": "0.2.8", "type": "module", "main": "dist/index.js", "scripts": { diff --git a/shared/package.json b/shared/package.json index f52bbb4..df7b000 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/shared", - "version": "0.2.7", + "version": "0.2.8", "type": "module", "main": "types/index.js", "types": "types/index.d.ts", From 0d776b6fed5fa965b9b5e839dc969c42f63ab9ff Mon Sep 17 00:00:00 2001 From: Adam Eivy <atomantic@gmail.com> Date: Thu, 22 Jan 2026 08:51:39 -0800 Subject: [PATCH 24/27] feat: add structured name fields (birthName, marriedNames, aliases) Support for categorized name extraction from FamilySearch GEDCOMX data: - birthName: populated when display name is not a birth name (e.g., married name) - marriedNames: array of names taken after marriage - aliases: array of AlsoKnownAs names (nicknames, alternate spellings) - alternateNames: maintained for backwards compatibility --- .changelog/v0.2.x.md | 5 +++ lib/json2person.js | 91 ++++++++++++++++++++++++++++++++++++++------ shared/src/index.ts | 7 +++- 3 files changed, 90 insertions(+), 13 deletions(-) diff --git a/.changelog/v0.2.x.md b/.changelog/v0.2.x.md index 3a9e586..c845d6d 100644 --- a/.changelog/v0.2.x.md +++ b/.changelog/v0.2.x.md @@ -70,6 +70,11 @@ Major enhancements to the ancestry tree visualization with four view modes and l ### Data Model - Added `photoUrl` field to PlatformReference to store discovered photo URLs before downloading +- Added support for structured name fields: + - `birthName`: Birth/maiden name when display name differs (e.g., married name is preferred) + - `marriedNames`: Array of names taken after marriage + - `aliases`: Array of "also known as" names (nicknames, alternate spellings) + - Maintains backwards-compatible `alternateNames` array ## 🐛 Bug Fixes diff --git a/lib/json2person.js b/lib/json2person.js index 407e81b..b97d0a9 100644 --- a/lib/json2person.js +++ b/lib/json2person.js @@ -13,6 +13,7 @@ const TYPES = { LIFE_SKETCH: "http://familysearch.org/v1/LifeSketch", ALSO_KNOWN_AS: "http://gedcomx.org/AlsoKnownAs", BIRTH_NAME: "http://gedcomx.org/BirthName", + MARRIED_NAME: "http://gedcomx.org/MarriedName", MALE: "http://gedcomx.org/Male", FEMALE: "http://gedcomx.org/Female", }; @@ -24,22 +25,86 @@ const TITLE_TYPES = [ ]; /** - * Extract all alternate names from names array + * Extract names categorized by type + * Returns { birthName, marriedNames, aliases, alternateNames } + * + * birthName: Only set if primary name is NOT a birth name (e.g., they use married name) + * marriedNames: All married names (excluding the primary if it's a married name) + * aliases: All "also known as" names + * alternateNames: All non-primary names (for backwards compat) */ -const extractAlternateNames = (names, primaryName) => { - if (!names || !Array.isArray(names)) return []; +const extractNamesByType = (names, primaryName) => { + const result = { + birthName: undefined, + marriedNames: [], + aliases: [], + alternateNames: [], // All non-preferred names for backwards compat + }; + + if (!names || !Array.isArray(names)) return result; + + // First pass: determine if primary name is a birth name + let primaryIsBirthName = false; + let foundBirthName = null; - const alternates = []; for (const nameObj of names) { const fullText = nameObj?.nameForms?.[0]?.fullText; - if (!fullText || fullText === primaryName) continue; + if (!fullText) continue; - // Include AlsoKnownAs or non-preferred BirthNames - if (nameObj.type === TYPES.ALSO_KNOWN_AS || !nameObj.preferred) { - alternates.push(fullText); + if (fullText === primaryName && nameObj.type === TYPES.BIRTH_NAME) { + primaryIsBirthName = true; + } + // Track first non-primary birth name we find + if (nameObj.type === TYPES.BIRTH_NAME && fullText !== primaryName && !foundBirthName) { + foundBirthName = fullText; } } - return [...new Set(alternates)]; // dedupe + + // Second pass: categorize all names + for (const nameObj of names) { + const fullText = nameObj?.nameForms?.[0]?.fullText; + if (!fullText) continue; + + const isPrimary = fullText === primaryName; + + if (nameObj.type === TYPES.BIRTH_NAME) { + if (!isPrimary) { + // Only set birthName if primary is NOT a birth name + // This means the person uses their married name or alias as display name + if (!primaryIsBirthName && fullText === foundBirthName) { + result.birthName = fullText; + } + result.alternateNames.push(fullText); + } + } else if (nameObj.type === TYPES.MARRIED_NAME) { + if (!isPrimary) { + result.marriedNames.push(fullText); + result.alternateNames.push(fullText); + } + } else if (nameObj.type === TYPES.ALSO_KNOWN_AS) { + result.aliases.push(fullText); + if (!isPrimary) { + result.alternateNames.push(fullText); + } + } else if (!isPrimary) { + // Other non-primary names go to alternateNames + result.alternateNames.push(fullText); + } + } + + // Dedupe all arrays + result.marriedNames = [...new Set(result.marriedNames)]; + result.aliases = [...new Set(result.aliases)]; + result.alternateNames = [...new Set(result.alternateNames)]; + + return result; +}; + +/** + * Extract all alternate names from names array (backwards compat wrapper) + */ +const extractAlternateNames = (names, primaryName) => { + return extractNamesByType(names, primaryName).alternateNames; }; /** @@ -193,8 +258,9 @@ export const json2person = (json) => { // Primary name (from display or first name form) const name = display?.name || selfRef?.names?.[0]?.nameForms?.[0]?.fullText || "unknown"; - // Extract alternate names - const alternateNames = extractAlternateNames(selfRef?.names, name); + // Extract names categorized by type + const nameData = extractNamesByType(selfRef?.names, name); + const { birthName, marriedNames, aliases, alternateNames } = nameData; // Gender const gender = extractGender(selfRef?.gender); @@ -234,6 +300,9 @@ export const json2person = (json) => { return { // Identity name, + birthName, + marriedNames: marriedNames.length > 0 ? marriedNames : undefined, + aliases: aliases.length > 0 ? aliases : undefined, alternateNames: alternateNames.length > 0 ? alternateNames : undefined, gender, living, diff --git a/shared/src/index.ts b/shared/src/index.ts index 72b0199..55d1d3c 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -9,8 +9,11 @@ export interface VitalEvent { // Person data stored in graph database export interface Person { // Identity - name: string; - alternateNames?: string[]; // Aliases, maiden names, etc. + name: string; // Preferred/display name + birthName?: string; // Birth/maiden name (if different from display name) + marriedNames?: string[]; // Names taken after marriage + aliases?: string[]; // Also known as names (nicknames, alternate spellings) + alternateNames?: string[]; // Deprecated: all non-preferred names (kept for backwards compat) gender?: 'male' | 'female' | 'unknown'; living: boolean; From d291c0084b68fef1aed1ac29df1100f7d035b509 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:52:36 +0000 Subject: [PATCH 25/27] build: bump version to 0.2.9 [skip ci] --- client/package.json | 2 +- package-lock.json | 10 +++++----- package.json | 2 +- server/package.json | 2 +- shared/package.json | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/client/package.json b/client/package.json index 67139d9..834a6b1 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/client", - "version": "0.2.8", + "version": "0.2.9", "type": "module", "scripts": { "dev": "vite --port 6373", diff --git a/package-lock.json b/package-lock.json index 09e1d87..659176e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sparsetree", - "version": "0.2.8", + "version": "0.2.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sparsetree", - "version": "0.2.8", + "version": "0.2.9", "license": "ISC", "workspaces": [ "shared", @@ -51,7 +51,7 @@ }, "client": { "name": "@fsf/client", - "version": "0.2.8", + "version": "0.2.9", "dependencies": { "@fsf/shared": "*", "d3": "^7.9.0", @@ -6096,7 +6096,7 @@ }, "server": { "name": "@fsf/server", - "version": "0.2.8", + "version": "0.2.9", "dependencies": { "@fsf/shared": "*", "cors": "^2.8.5", @@ -6116,7 +6116,7 @@ }, "shared": { "name": "@fsf/shared", - "version": "0.2.8", + "version": "0.2.9", "devDependencies": { "typescript": "^5.7.2" } diff --git a/package.json b/package.json index 96310a0..7354fcf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sparsetree", - "version": "0.2.8", + "version": "0.2.9", "private": true, "description": "", "main": "index.js", diff --git a/server/package.json b/server/package.json index a3a2013..55b4840 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/server", - "version": "0.2.8", + "version": "0.2.9", "type": "module", "main": "dist/index.js", "scripts": { diff --git a/shared/package.json b/shared/package.json index df7b000..dce05a4 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/shared", - "version": "0.2.8", + "version": "0.2.9", "type": "module", "main": "types/index.js", "types": "types/index.d.ts", From 9dbb5b0c4c0de4a986a37cb1815fa5953847b849 Mon Sep 17 00:00:00 2001 From: Adam Eivy <atomantic@gmail.com> Date: Thu, 22 Jan 2026 08:54:05 -0800 Subject: [PATCH 26/27] fix: remove unused variables (lastError, matchedCookieName) --- index.js | 2 -- server/src/services/browser.service.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/index.js b/index.js index d401c16..63afb63 100644 --- a/index.js +++ b/index.js @@ -87,7 +87,6 @@ const getPerson = async (id, generation) => { } // Fetch with retry logic for transient errors - let lastError; for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { const result = await fscget(`/platform/tree/persons/${id}`).catch( (err) => ({ _error: err }) @@ -100,7 +99,6 @@ const getPerson = async (id, generation) => { } const error = result._error; - lastError = error; // Handle "person deleted" API error - not retryable if ( diff --git a/server/src/services/browser.service.ts b/server/src/services/browser.service.ts index d5716db..d734261 100644 --- a/server/src/services/browser.service.ts +++ b/server/src/services/browser.service.ts @@ -270,13 +270,11 @@ export const browserService = { const authCookieNames = ['fssessionid', 'FS_AUTH_TOKEN', 'Authorization']; let token: string | null = null; - let matchedCookieName: string | null = null; for (const cookieName of authCookieNames) { const cookie = allCookies.find(c => c.name === cookieName); if (cookie) { token = cookie.value; - matchedCookieName = cookieName; console.log(`[browser] Found FamilySearch auth token in cookie: ${cookieName}`); break; } From 076ff92cea04e3be701b26e642a7edfe17351b6f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <github-actions[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 16:55:03 +0000 Subject: [PATCH 27/27] build: bump version to 0.2.10 [skip ci] --- client/package.json | 2 +- package-lock.json | 10 +++++----- package.json | 2 +- server/package.json | 2 +- shared/package.json | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/client/package.json b/client/package.json index 834a6b1..d3923e9 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/client", - "version": "0.2.9", + "version": "0.2.10", "type": "module", "scripts": { "dev": "vite --port 6373", diff --git a/package-lock.json b/package-lock.json index 659176e..2d37956 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sparsetree", - "version": "0.2.9", + "version": "0.2.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sparsetree", - "version": "0.2.9", + "version": "0.2.10", "license": "ISC", "workspaces": [ "shared", @@ -51,7 +51,7 @@ }, "client": { "name": "@fsf/client", - "version": "0.2.9", + "version": "0.2.10", "dependencies": { "@fsf/shared": "*", "d3": "^7.9.0", @@ -6096,7 +6096,7 @@ }, "server": { "name": "@fsf/server", - "version": "0.2.9", + "version": "0.2.10", "dependencies": { "@fsf/shared": "*", "cors": "^2.8.5", @@ -6116,7 +6116,7 @@ }, "shared": { "name": "@fsf/shared", - "version": "0.2.9", + "version": "0.2.10", "devDependencies": { "typescript": "^5.7.2" } diff --git a/package.json b/package.json index 7354fcf..7c0725a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sparsetree", - "version": "0.2.9", + "version": "0.2.10", "private": true, "description": "", "main": "index.js", diff --git a/server/package.json b/server/package.json index 55b4840..9b2b909 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/server", - "version": "0.2.9", + "version": "0.2.10", "type": "module", "main": "dist/index.js", "scripts": { diff --git a/shared/package.json b/shared/package.json index dce05a4..b9344d5 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/shared", - "version": "0.2.9", + "version": "0.2.10", "type": "module", "main": "types/index.js", "types": "types/index.d.ts",