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..c845d6d --- /dev/null +++ b/.changelog/v0.2.x.md @@ -0,0 +1,99 @@ +# 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 + +### 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 +- 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) +- **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 +- 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 + +- 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 + +```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/.gitignore b/.gitignore index e37ab2a..f9d3a77 100644 --- a/.gitignore +++ b/.gitignore @@ -22,10 +22,12 @@ node_modules/ server/dist/ client/dist/ shared/types/ +*.tsbuildinfo # IDE .vscode/ .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/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) diff --git a/client/package.json b/client/package.json index 8cba875..d3923e9 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/client", - "version": "1.0.0", + "version": "0.2.10", "type": "module", "scripts": { "dev": "vite --port 6373", diff --git a/client/src/components/ancestry-tree/AncestryTreeView.tsx b/client/src/components/ancestry-tree/AncestryTreeView.tsx index 5252c67..025a3ae 100644 --- a/client/src/components/ancestry-tree/AncestryTreeView.tsx +++ b/client/src/components/ancestry-tree/AncestryTreeView.tsx @@ -1,13 +1,40 @@ +/** + * 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; + unitPositions: number[]; +} export function AncestryTreeView() { const { dbId, personId } = useParams<{ dbId: string; personId?: string }>(); + const [searchParams, setSearchParams] = useSearchParams(); const [treeData, setTreeData] = useState(null); const [rootId, setRootId] = useState(personId || null); const [loading, setLoading] = useState(true); @@ -15,6 +42,16 @@ 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); + const parentUnitsContainerRef = useRef(null); + const [rootLinePositions, setRootLinePositions] = useState({ 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(() => { @@ -32,15 +69,46 @@ 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 (classic view) + useEffect(() => { + if (viewMode !== 'classic' || !parentUnitsContainerRef.current || !treeData?.parentUnits) return; + + const calculatePositions = () => { + const container = parentUnitsContainerRef.current; + if (!container) return; + + const totalHeight = container.offsetHeight; + const positions: number[] = []; - // Handle expanding a node + 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 }); + }; + + const timeoutId = setTimeout(calculatePositions, 100); + window.addEventListener('resize', calculatePositions); + + return () => { + clearTimeout(timeoutId); + window.removeEventListener('resize', calculatePositions); + }; + }, [treeData, viewMode]); + + // Handle expanding a node (classic view) const handleExpand = useCallback(async (request: ExpandAncestryRequest, nodeId: string) => { if (!dbId || expandingNodes.has(nodeId)) return; @@ -59,132 +127,137 @@ 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 || 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 = []; - } - - // Add the expanded unit - unit.parentUnits.push(expandedData); - - // Update hasMoreAncestors flags - if (unit.father?.id === request.fatherId && unit.father) { - unit.father.hasMoreAncestors = false; - } - if (unit.mother?.id === request.motherId && unit.mother) { - unit.mother.hasMoreAncestors = false; - } + if (unit.father?.id === request.fatherId) { + if (!unit.fatherParentUnits) unit.fatherParentUnits = []; + unit.fatherParentUnits.push(expandedData); + if (unit.father) unit.father.hasMoreAncestors = false; + return true; + } + if (unit.mother?.id === request.motherId) { + if (!unit.motherParentUnits) unit.motherParentUnits = []; + unit.motherParentUnits.push(expandedData); + 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; }; updateUnit(newData.parentUnits); - return newData; }); + + const personIdToCenter = request.fatherId || request.motherId; + 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); 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'); }); 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 - - container.call(zoom.transform, d3.zoomIdentity.translate(initialX, initialY)); + const initialX = 80; + 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 (classic view) + useEffect(() => { + if (!pendingCenterId || !containerRef.current || !contentRef.current || !zoomRef.current) return; + + const personElement = contentRef.current.querySelector(`[data-person-id="${pendingCenterId}"]`); + if (!personElement) { + setPendingCenterId(null); + return; + } + + const containerRect = containerRef.current.getBoundingClientRect(); + const elementRect = personElement.getBoundingClientRect(); + const contentRect = contentRef.current.getBoundingClientRect(); + + const elementX = elementRect.left - contentRect.left + elementRect.width / 2; + const elementY = elementRect.top - contentRect.top + elementRect.height / 2; + + const targetX = containerRect.width / 2 - elementX; + const targetY = containerRect.height / 2 - elementY; + + const containerSelection = d3.select(containerRef.current); + containerSelection.transition() + .duration(500) + .call(zoomRef.current.transform, d3.zoomIdentity.translate(targetX, targetY)); + + setPendingCenterId(null); + }, [pendingCenterId, treeData]); + + // Render functions for classic view + 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, - 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; 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 && ( -
-
- {unit.parentUnits.map((parentUnit) => ( -
- {/* Horizontal connector 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} + />
); }; + // Loading state if (loading) { return (
@@ -196,15 +269,13 @@ export function AncestryTreeView() { ); } + // Error state if (error) { return (

Error: {error}

- + Back to Dashboard
@@ -212,91 +283,163 @@ export function AncestryTreeView() { ); } + // No data state if (!treeData) { return (
-
-

No tree data available

-
+

No tree data available

); } + const hasParents = treeData.parentUnits && treeData.parentUnits.length > 0; + const { totalHeight, unitPositions } = rootLinePositions; + return (
- {/* Header */} -
-

Ancestry Tree

+ {/* Header with view switcher */} +
+
+

Ancestry Tree

+ {treeData.rootPerson.name} +
+ + {/* View mode switcher */} +
+ {VIEW_MODES.map(mode => ( + + ))} +
+
- + Search - + Find Path
- {/* Tree container with zoom/pan */} -
-
- {/* Tree visualization */} -
- {/* Root person */} -
- - {treeData.rootSpouse && ( -
- -
- )} -
+ {/* View content */} +
+ {viewMode === 'focus' && ( + + )} - {/* Parent units */} - {treeData.parentUnits && treeData.parentUnits.length > 0 && ( -
- {/* Connector from root to parents */} -
+ {viewMode === 'pedigree' && ( + + )} -
- {treeData.parentUnits.map((unit) => ( -
- {renderFamilyUnit(unit, 1)} + {viewMode === 'columns' && ( + { + 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' && ( +
+ {/* Classic tree container with zoom/pan */} +
+
+
+ {/* Root person section */} +
+ + {treeData.rootSpouse && ( +
+ +
+ )} +
+ + {/* Parent units with SVG connector */} + {hasParents && ( +
+ + + + {unitPositions.length > 1 && ( + + )} + + {unitPositions.length > 0 && ( + <> + {totalHeight / 2 < unitPositions[0] && ( + + )} + {unitPositions.length > 1 && totalHeight / 2 > unitPositions[unitPositions.length - 1] && ( + + )} + {unitPositions.length === 1 && ( + + )} + + )} + + {unitPositions.map((y, i) => ( + + ))} + + +
+ {treeData.parentUnits!.map((unit) => ( +
+ {renderFamilyUnit(unit, 1)} +
+ ))} +
- ))} + )}
- )} -
-
-
+
- {/* Info footer */} -
- Scroll to zoom • Drag to pan - - Generations loaded: {treeData.maxGenerationLoaded} - - - Male - Female - + {/* Classic view footer */} +
+ Scroll to zoom | Drag to pan + | + Generations loaded: {treeData.maxGenerationLoaded} + | + + Male + Female + +
+
+ )}
); 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/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/ancestry-tree/index.ts b/client/src/components/ancestry-tree/index.ts index 63731c0..f9ab872 100644 --- a/client/src/components/ancestry-tree/index.ts +++ b/client/src/components/ancestry-tree/index.ts @@ -2,3 +2,8 @@ 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..bba81b7 --- /dev/null +++ b/client/src/components/ancestry-tree/views/FocusNavigatorView.tsx @@ -0,0 +1,318 @@ +/** + * 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. + * Shows detailed information since we only display 3 cards at a time. + */ +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 { + const map = new Map(); + + 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; +} + +// Parent card component - medium detail level +function ParentCard({ + person, + label, + onNavigate +}: { + person: AncestryPersonCard | undefined; + label: string; + onNavigate: (person: AncestryPersonCard) => void; +}) { + const isMale = person?.gender === 'male'; + + if (!person) { + return ( +
+
+
+ ? +
+
+
{label}
+
Unknown
+
+
+
+ ); + } + + return ( + + ); +} + +// Focused person card component - full detail level +function FocusedPersonCard({ + person, + dbId +}: { + person: AncestryPersonCard; + dbId: string; +}) { + const isMale = person.gender === 'male'; + + return ( +
+
+
+ {person.photoUrl ? ( + + ) : ( + {isMale ? '\u{1F468}' : '\u{1F469}'} + )} +
+
+

{person.name}

+
{person.lifespan}
+ + {/* Detail rows */} +
+ {person.birthPlace && ( +
+ Born: + {person.birthPlace} +
+ )} + {person.deathPlace && ( +
+ Died: + {person.deathPlace} +
+ )} + {person.occupation && ( +
+ Work: + {person.occupation} +
+ )} +
+ +
+ + View full profile → + +
+
+
+
+ ); +} + +export function FocusNavigatorView({ data, dbId }: FocusNavigatorViewProps) { + const [focusedId, setFocusedId] = useState(data.rootPerson.id); + const [breadcrumb, setBreadcrumb] = useState([data.rootPerson]); + const [personMap, setPersonMap] = useState>(new Map()); + + useEffect(() => { + setPersonMap(buildPersonMap(data)); + }, [data]); + + const focused = personMap.get(focusedId); + if (!focused) return
Loading...
; + + 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); + } + }; + + // 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 ( +
+ {/* Breadcrumb navigation */} +
+
+ {breadcrumb.map((person, i) => ( + + {i > 0 && } + + + ))} +
+ {generationLevel > 0 && ( +
+ Viewing: {generationLabel} +
+ )} +
+ + {/* Main content area */} +
+ {/* Parents and connector as unified structure */} +
+ {/* Parents row */} +
+ + +
+ + {/* Connecting bracket - forms └─┬─┘ shape spanning parent cards */} + {/* Container matches parent row: 2x w-56 (224px each) + gap-6 (24px) = ~472px */} +
+ {/* Left vertical from father card center */} +
+ {/* Right vertical from mother card center */} +
+ {/* Horizontal line connecting parents */} +
+ {/* Center vertical going down to child */} +
+
+
+ + {/* Focused person card */} +
+ +
+ + {/* Back navigation button */} + {breadcrumb.length > 1 && ( + + )} +
+ + {/* Footer legend */} +
+ Click a parent card to navigate up the ancestry tree +
+
+ ); +} + +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/client/src/components/ancestry-tree/views/GenerationalColumnsView.tsx b/client/src/components/ancestry-tree/views/GenerationalColumnsView.tsx new file mode 100644 index 0000000..4b9e258 --- /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; +} + +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 ( + +
+ {person.photoUrl ? ( + + ) : ( + {isMale ? '\u{1F468}' : '\u{1F469}'} + )} +
+
+
{person.name}
+
{person.lifespan}
+
+ + ); + } + + return ( + +
+ {person.photoUrl ? ( + + ) : ( + {isMale ? '\u{1F468}' : '\u{1F469}'} + )} +
+
+
{person.name}
+
{person.lifespan}
+
+ + ); +} + +// 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([]); + 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 ( +
+ {/* Controls */} +
+
+ Showing {generations.length} generations ({generations.reduce((sum, g) => sum + getKnownCount(g), 0)} ancestors) + {hasMoreToLoad() && • More available} +
+
+ Visible: +
+ + {maxGen} + +
+ of {data.maxGenerationLoaded} loaded +
+
+ + {/* Columns */} +
+
+ {generations.map((gen) => { + const label = getGenerationLabel(gen.level); + const knownPeople = gen.people.filter(p => p !== null) as GenerationPerson[]; + + return ( +
+ {/* Generation header - solid background */} +
+
+ {label.main} +
+ {label.sub && ( +
+ {label.sub} +
+ )} +
+ {knownPeople.length} of {Math.pow(2, gen.level)} known +
+
+ + {/* People in this generation - only show known people */} +
+
+ {knownPeople.map((item, idx) => ( + 2} + /> + ))} + {knownPeople.length === 0 && ( +
+ No known ancestors +
+ )} +
+
+
+ ); + })} + + {/* Load More column */} + {hasMoreToLoad() && onLoadMore && ( +
+
+
+ More... +
+
+ Load deeper ancestry +
+
+
+ +
+
+ )} +
+
+ + {/* Legend */} +
+ + Male + + + Female + + Scroll horizontally to see more generations +
+
+ ); +} 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..dd5b96a --- /dev/null +++ b/client/src/components/ancestry-tree/views/PedigreeChartView.tsx @@ -0,0 +1,211 @@ +/** + * 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 }; + + const safeParentUnits = Array.isArray(parentUnits) ? parentUnits : undefined; + if (safeParentUnits && safeParentUnits.length > 0) { + const unit = safeParentUnits[0]; + if (unit.father) { + const fatherParentUnits = Array.isArray(unit.fatherParentUnits) ? unit.fatherParentUnits : undefined; + node.father = buildNode(unit.father, fatherParentUnits); + } + if (unit.mother) { + const motherParentUnits = Array.isArray(unit.motherParentUnits) ? unit.motherParentUnits : undefined; + node.mother = buildNode(unit.mother, motherParentUnits); + } + } + + return node; + }; + + const rootParentUnits = Array.isArray(data.parentUnits) ? data.parentUnits : undefined; + return buildNode(data.rootPerson, rootParentUnits); +} + +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 ( + +
+ {person.photoUrl ? ( + + ) : ( + {isMale ? '\u{1F468}' : '\u{1F469}'} + )} +
+
{person.name}
+
{person.lifespan}
+ + ); +} + +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 ( +
+ ? +
{label}
+
+ ); +} + +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 ( +
+ {/* Parents (above) */} + {level < maxLevel - 1 && ( +
+ + +
+ )} + + {/* Connecting lines - bracket opens upward └──┘ */} + {level < maxLevel - 1 && node && (node.father || node.mother) && ( +
+ {/* Vertical lines down from each parent (top half) */} + {node.father && ( +
+ )} + {node.mother && ( +
+ )} + {/* Horizontal line connecting parents (middle) */} +
+ {/* Vertical line down to child (bottom half) */} +
+
+ )} + + {/* This person */} + {node ? ( + + ) : ( + + )} +
+ ); +} + +export function PedigreeChartView({ data, dbId }: PedigreeChartViewProps) { + const [tree, setTree] = useState(null); + const [generations, setGenerations] = useState(4); + + useEffect(() => { + setTree(buildAncestorTree(data)); + }, [data]); + + if (!tree) return
Loading...
; + + return ( +
+ {/* Controls */} +
+
+ Showing {generations} generations +
+
+ + {generations} + +
+
+ + {/* Chart area */} +
+
+ +
+
+ + {/* Legend */} +
+ + Male + + + Female + + Click any person to view details +
+
+ ); +} 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/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/indexer/IndexerPage.tsx b/client/src/components/indexer/IndexerPage.tsx index 25de6fc..f509b68 100644 --- a/client/src/components/indexer/IndexerPage.tsx +++ b/client/src/components/indexer/IndexerPage.tsx @@ -1,22 +1,56 @@ -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 (for manual refresh) + 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]); + + // 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(); }, []); // SSE for real-time updates @@ -28,6 +62,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 +83,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,43 +169,130 @@ export function IndexerPage() { }; const isRunning = status?.status === 'running'; + const canStartIndexing = browserStatus?.connected && browserStatus?.familySearchLoggedIn && rootId; return ( -
+

Indexer

- {/* Status */} -
-

Status

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

Browser Connection

+ +
+
+ + + Browser: {browserStatus?.browserProcessRunning ? 'Running' : 'Not Running'} + +
+ +
+ + + CDP: {browserStatus?.connected ? 'Connected' : 'Disconnected'} + +
+ +
+ + + FamilySearch: {browserStatus?.familySearchLoggedIn ? 'Logged In' : 'Not Logged In'} + +
+
+ +
+ {!browserStatus?.browserProcessRunning && ( + + )} + + {browserStatus?.browserProcessRunning && !browserStatus?.connected && ( + + )} + + {browserStatus?.connected && !browserStatus?.familySearchLoggedIn && ( + + )} + + +
- {status?.progress && ( -
-
New: {status.progress.new}
-
Cached: {status.progress.cached}
-
Refreshed: {status.progress.refreshed}
-
Generations: {status.progress.generations}
+ {/* Indexer Status */} +
+

Status

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

Start Indexing

-
+
-
- - setMaxGenerations(e.target.value)} - placeholder="Leave empty for unlimited" - className="w-full px-3 py-2 border rounded-md" - /> -
+
+
+ + 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" + /> +
-
- - setIgnoreIds(e.target.value.toUpperCase())} - placeholder="e.g., ABC-123, DEF-456" - className="w-full px-3 py-2 border rounded-md" - /> +
+ + +
-
- - -
+
+
+ + 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" + /> +
-
- - setOldest(e.target.value)} - placeholder="e.g., 1000 or 500BC" - className="w-full px-3 py-2 border rounded-md" - /> +
+ + 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} + +
)} - {/* Stop Button */} - {isRunning && ( - - )} + {outputLines.length === 0 ? ( + Output will appear here when indexing starts... + ) : ( + outputLines.map((line, i) => ( +
+ {line} +
+ )) + )} +
+
); } 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 01022fd..b5eab1b 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,27 @@ 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; +} + +/* 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/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/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 = { 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() {
-

Genealogy Providers

+
+

Genealogy Providers

+

Configure API access to genealogy data sources

+
- - - Add Provider -
{providers.length === 0 ? (
-

No genealogy providers configured.

- - - Add Your First Provider - +

Loading providers...

) : (
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/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 diff --git a/index.js b/index.js index cb43140..63afb63 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,69 @@ 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 + 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; + + // 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); }); }); 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/package-lock.json b/package-lock.json index 0003cd5..2d37956 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "FamilySearchFinder", - "version": "1.0.0", + "name": "sparsetree", + "version": "0.2.10", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "FamilySearchFinder", - "version": "1.0.0", + "name": "sparsetree", + "version": "0.2.10", "license": "ISC", "workspaces": [ "shared", @@ -51,7 +51,7 @@ }, "client": { "name": "@fsf/client", - "version": "1.0.0", + "version": "0.2.10", "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.2.10", "dependencies": { "@fsf/shared": "*", "cors": "^2.8.5", @@ -6102,7 +6116,7 @@ }, "shared": { "name": "@fsf/shared", - "version": "1.0.0", + "version": "0.2.10", "devDependencies": { "typescript": "^5.7.2" } diff --git a/package.json b/package.json index c916fdf..7c0725a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sparsetree", - "version": "0.1.0", + "version": "0.2.10", "private": true, "description": "", "main": "index.js", 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/package.json b/server/package.json index 349d2e1..9b2b909 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/server", - "version": "1.0.0", + "version": "0.2.10", "type": "module", "main": "dist/index.js", "scripts": { 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/routes/browser.routes.ts b/server/src/routes/browser.routes.ts index 4602621..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 => { @@ -202,4 +212,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..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] }; } @@ -88,9 +92,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,11 +103,15 @@ 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; + } } } - // Mother's parents + // Mother's parents - stored separately if (mother && mother.parents && mother.parents.length > 0) { const [mothersFather, mothersMother] = mother.parents; const mothersParentUnit = buildFamilyUnit( @@ -116,21 +122,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; - } - } else { - // At max depth, mark cards as having more ancestors if they do - 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; - } + // At max depth OR when no parentUnits were created, check if more ancestors exist + 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; @@ -188,8 +194,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..74c9de3 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,591 @@ 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...`); + 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] Auto-login triggered: Using saved credentials for ${username}`); + + 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(); + } else { + console.log(`[augment] WikiTree: Could not extract vital info from page (VITALS pattern not found)`); + } + + // 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; + } + } else { + console.log(`[augment] WikiTree: Could not extract bio from page (profile-text pattern not found)`); + } + + // 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]; + } + } + + 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('//')) { + 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/browser.service.ts b/server/src/services/browser.service.ts index 8e4f94e..d734261 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<boolean> { return response?.ok ?? false; } +// Broadcast status to all SSE clients +async function broadcastStatusUpdate(): Promise<void> { + if (!browserSseManager.hasClients()) return; + const status = await browserService.getStatus().catch(() => null); + if (status) { + browserSseManager.broadcastStatus(status); + } +} + export const browserService = { async connect(cdpUrl?: string): Promise<Browser> { const url = cdpUrl || getCdpUrlInternal(); @@ -62,15 +72,7 @@ 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(', ')}`); - } - + broadcastStatusUpdate(); return connectedBrowser; }, @@ -78,6 +80,7 @@ export const browserService = { if (connectedBrowser) { await connectedBrowser.close(); connectedBrowser = null; + broadcastStatusUpdate(); } }, @@ -138,29 +141,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(); }, @@ -202,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; }, @@ -247,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...' @@ -255,5 +246,49 @@ export const browserService = { async checkBrowserRunning(): Promise<boolean> { 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 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; + + for (const cookieName of authCookieNames) { + const cookie = allCookies.find(c => c.name === cookieName); + if (cookie) { + token = cookie.value; + 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')) + .map(c => ({ name: c.name, value: c.value })); + + return { token, cookies: relevantCookies }; } }; 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/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 { diff --git a/server/src/services/indexer.service.ts b/server/src/services/indexer.service.ts index 037711d..70893e1 100644 --- a/server/src/services/indexer.service.ts +++ b/server/src/services/indexer.service.ts @@ -1,12 +1,19 @@ -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'; +import fs from 'fs'; + +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 +25,21 @@ export const indexerService = { } const jobId = `job-${Date.now()}`; + const progress: IndexerProgress = { + new: 0, + cached: 0, + refreshed: 0, + generations: 0, + deepest: '', + currentPerson: undefined as string | 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 +48,205 @@ 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<void> { + 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(' ')}`); + + // 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, + 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<void>((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<void> { 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'; + } } }; 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/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; + } +}; diff --git a/shared/package.json b/shared/package.json index a73e796..b9344d5 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@fsf/shared", - "version": "1.0.0", + "version": "0.2.10", "type": "module", "main": "types/index.js", "types": "types/index.d.ts", diff --git a/shared/src/index.ts b/shared/src/index.ts index 4a6c84e..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; @@ -230,6 +233,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 @@ -297,6 +301,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 @@ -450,6 +455,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 @@ -458,7 +467,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