11import fs from 'fs' ;
22import path from 'path' ;
3- import type { SparseTreeNode , SparseTreeResult , Database , FavoriteData , PersonAugmentation } from '@fsf/shared' ;
3+ import type { SparseTreeNode , SparseTreeResult , FavoriteData , PersonAugmentation } from '@fsf/shared' ;
44import { databaseService } from './database.service.js' ;
55import { favoritesService } from './favorites.service.js' ;
6+ import { sqliteService } from '../db/sqlite.service.js' ;
7+ import { idMappingService } from './id-mapping.service.js' ;
68
79const DATA_DIR = path . resolve ( import . meta. dirname , '../../../data' ) ;
810const AUGMENT_DIR = path . join ( DATA_DIR , 'augment' ) ;
911const PHOTOS_DIR = path . join ( DATA_DIR , 'photos' ) ;
1012
1113/**
12- * BFS to find shortest path from source to target through parents (ancestors)
13- * This traverses upward from the root person to their ancestors
14+ * Get a path from root DOWN to an ancestor (favorite)
15+ * Root is the "self" person (descendant), favorites are ancestors
16+ * We walk UP from root to find the ancestor, then reverse the path
1417 */
15- function findShortestPath ( db : Database , source : string , target : string ) : string [ ] {
16- const queue = [ source ] ;
17- const visited : Record < string , boolean > = { [ source ] : true } ;
18- const cameFrom : Record < string , string > = { } ;
18+ function getPathToAncestor ( rootId : string , ancestorId : string , maxDepth = 100 ) : string [ ] {
19+ // BFS from root upward to find ancestor
20+ // We need to search broadly since we don't know which parent line leads to the ancestor
21+ const visited = new Set < string > ( [ rootId ] ) ;
22+ const parent : Map < string , string > = new Map ( ) ; // Maps child -> parent used to reach child
23+ const queue : Array < { id : string ; depth : number } > = [ { id : rootId , depth : 0 } ] ;
1924
2025 while ( queue . length > 0 ) {
21- const id = queue . shift ( ) ! ;
22- const person = db [ id ] ;
23- if ( ! person ) continue ;
24-
25- // Traverse parents to find ancestors
26- const parentIds = person . parents || [ ] ;
27- for ( const parentId of parentIds ) {
28- if ( ! parentId || visited [ parentId ] ) continue ;
29- visited [ parentId ] = true ;
30-
31- if ( parentId === target ) {
32- const pathArr = [ parentId ] ;
33- let current = id ;
34- while ( current !== source ) {
35- pathArr . push ( current ) ;
36- current = cameFrom [ current ] ;
37- }
38- pathArr . push ( source ) ;
39- pathArr . reverse ( ) ;
40- return pathArr ;
26+ const current = queue . shift ( ) ! ;
27+ if ( current . id === ancestorId ) {
28+ // Reconstruct path from root to ancestor
29+ const path : string [ ] = [ ancestorId ] ;
30+ let node = ancestorId ;
31+ while ( parent . has ( node ) ) {
32+ node = parent . get ( node ) ! ;
33+ path . push ( node ) ;
4134 }
35+ return path . reverse ( ) ; // Reverse to get root -> ancestor order
36+ }
37+
38+ if ( current . depth >= maxDepth ) continue ;
39+
40+ // Get parents of current person
41+ const parents = sqliteService . queryAll < { parent_id : string } > (
42+ 'SELECT parent_id FROM parent_edge WHERE child_id = @current' ,
43+ { current : current . id }
44+ ) ;
4245
43- cameFrom [ parentId ] = id ;
44- queue . push ( parentId ) ;
46+ for ( const p of parents ) {
47+ if ( ! visited . has ( p . parent_id ) ) {
48+ visited . add ( p . parent_id ) ;
49+ parent . set ( p . parent_id , current . id ) ;
50+ queue . push ( { id : p . parent_id , depth : current . depth + 1 } ) ;
51+ }
4552 }
4653 }
4754
48- return [ ] ;
55+ return [ ] ; // No path found
56+ }
57+
58+ /**
59+ * Batch fetch person data for a list of IDs from SQLite
60+ */
61+ function batchFetchPersons ( personIds : string [ ] ) : Map < string , { name : string ; lifespan : string } > {
62+ if ( personIds . length === 0 ) return new Map ( ) ;
63+
64+ const placeholders = personIds . map ( ( _ , i ) => `@id${ i } ` ) . join ( ',' ) ;
65+ const params : Record < string , string > = { } ;
66+ personIds . forEach ( ( id , i ) => { params [ `id${ i } ` ] = id ; } ) ;
67+
68+ const rows = sqliteService . queryAll < {
69+ person_id : string ;
70+ display_name : string ;
71+ birth_year : number | null ;
72+ death_year : number | null ;
73+ } > (
74+ `SELECT p.person_id, p.display_name,
75+ (SELECT date_year FROM vital_event WHERE person_id = p.person_id AND event_type = 'birth') as birth_year,
76+ (SELECT date_year FROM vital_event WHERE person_id = p.person_id AND event_type = 'death') as death_year
77+ FROM person p
78+ WHERE p.person_id IN (${ placeholders } )` ,
79+ params
80+ ) ;
81+
82+ const result = new Map < string , { name : string ; lifespan : string } > ( ) ;
83+ for ( const row of rows ) {
84+ const birthStr = row . birth_year ? String ( row . birth_year ) : '' ;
85+ const deathStr = row . death_year ? String ( row . death_year ) : '' ;
86+ const lifespan = birthStr || deathStr ? `${ birthStr } -${ deathStr } ` : '' ;
87+ result . set ( row . person_id , {
88+ name : row . display_name ,
89+ lifespan,
90+ } ) ;
91+ }
92+ return result ;
4993}
5094
5195/**
@@ -60,31 +104,26 @@ function getFavoriteData(personId: string): FavoriteData | null {
60104
61105/**
62106 * Get photo URL for a person
63- * Priority: Ancestry > WikiTree > Wikipedia > FamilySearch scraped
64107 */
65108function getPhotoUrl ( personId : string ) : string | undefined {
66- // Check for Ancestry photo (highest priority)
67109 const ancestryJpgPath = path . join ( PHOTOS_DIR , `${ personId } -ancestry.jpg` ) ;
68110 const ancestryPngPath = path . join ( PHOTOS_DIR , `${ personId } -ancestry.png` ) ;
69111 if ( fs . existsSync ( ancestryJpgPath ) || fs . existsSync ( ancestryPngPath ) ) {
70112 return `/api/augment/${ personId } /ancestry-photo` ;
71113 }
72114
73- // Check for WikiTree photo
74115 const wikiTreeJpgPath = path . join ( PHOTOS_DIR , `${ personId } -wikitree.jpg` ) ;
75116 const wikiTreePngPath = path . join ( PHOTOS_DIR , `${ personId } -wikitree.png` ) ;
76117 if ( fs . existsSync ( wikiTreeJpgPath ) || fs . existsSync ( wikiTreePngPath ) ) {
77118 return `/api/augment/${ personId } /wikitree-photo` ;
78119 }
79120
80- // Check for Wikipedia photo
81121 const wikiJpgPath = path . join ( PHOTOS_DIR , `${ personId } -wiki.jpg` ) ;
82122 const wikiPngPath = path . join ( PHOTOS_DIR , `${ personId } -wiki.png` ) ;
83123 if ( fs . existsSync ( wikiJpgPath ) || fs . existsSync ( wikiPngPath ) ) {
84124 return `/api/augment/${ personId } /wiki-photo` ;
85125 }
86126
87- // Check for scraped FamilySearch photo
88127 const jpgPath = path . join ( PHOTOS_DIR , `${ personId } .jpg` ) ;
89128 const pngPath = path . join ( PHOTOS_DIR , `${ personId } .png` ) ;
90129 if ( fs . existsSync ( jpgPath ) || fs . existsSync ( pngPath ) ) {
@@ -97,25 +136,27 @@ function getPhotoUrl(personId: string): string | undefined {
97136export const sparseTreeService = {
98137 /**
99138 * Generate a sparse tree showing only favorites and their paths from root
139+ * Uses reverse traversal (from each favorite to root) for efficiency
100140 */
101141 async getSparseTree ( dbId : string ) : Promise < SparseTreeResult > {
102142 const dbInfo = await databaseService . getDatabaseInfo ( dbId ) ;
103- const db = await databaseService . getDatabase ( dbId ) ;
104143 const rootId = dbInfo . rootId ;
105144
106145 // Get all favorites in this database
107146 const favoritesInDb = await favoritesService . getFavoritesInDatabase ( dbId ) ;
108147
148+ // Resolve rootId to canonical ID for SQLite queries
149+ const canonicalRootId = idMappingService . resolveId ( rootId , 'familysearch' ) || rootId ;
150+
109151 if ( favoritesInDb . length === 0 ) {
110- // Return just root with no children
111- const rootPerson = db [ rootId ] ;
112- const rootFavorite = getFavoriteData ( rootId ) ;
152+ const rootData = batchFetchPersons ( [ canonicalRootId ] ) . get ( canonicalRootId ) ;
153+ const rootFavorite = getFavoriteData ( canonicalRootId ) ;
113154 return {
114155 root : {
115- id : rootId ,
116- name : rootPerson ?. name || rootId ,
117- lifespan : rootPerson ?. lifespan || '' ,
118- photoUrl : getPhotoUrl ( rootId ) ,
156+ id : canonicalRootId ,
157+ name : rootData ?. name || rootId ,
158+ lifespan : rootData ?. lifespan || '' ,
159+ photoUrl : getPhotoUrl ( canonicalRootId ) ,
119160 whyInteresting : rootFavorite ?. whyInteresting ,
120161 tags : rootFavorite ?. tags ,
121162 generationFromRoot : 0 ,
@@ -127,31 +168,39 @@ export const sparseTreeService = {
127168 } ;
128169 }
129170
130- // Find shortest path from root to each favorite
171+ // Get ancestor chain for each favorite (reverse traversal - fast!)
131172 const paths : Map < string , string [ ] > = new Map ( ) ;
173+ const allPersonIds = new Set < string > ( [ canonicalRootId ] ) ;
174+
132175 for ( const fav of favoritesInDb ) {
133- const pathArr = findShortestPath ( db , rootId , fav . personId ) ;
134- if ( pathArr . length > 0 ) {
135- paths . set ( fav . personId , pathArr ) ;
176+ const canonicalFavId = idMappingService . resolveId ( fav . personId , 'familysearch' ) || fav . personId ;
177+ const pathArr = getPathToAncestor ( canonicalRootId , canonicalFavId ) ;
178+ if ( pathArr . length > 0 && pathArr [ 0 ] === canonicalRootId ) {
179+ paths . set ( canonicalFavId , pathArr ) ;
180+ pathArr . forEach ( id => allPersonIds . add ( id ) ) ;
136181 }
137182 }
138183
139- const favoriteIds = new Set ( favoritesInDb . map ( f => f . personId ) ) ;
184+ // Batch fetch all person data we need
185+ const personData = batchFetchPersons ( [ ...allPersonIds ] ) ;
186+
187+ const favoriteIds = new Set ( favoritesInDb . map ( f =>
188+ idMappingService . resolveId ( f . personId , 'familysearch' ) || f . personId
189+ ) ) ;
140190
141- // Build a full tree structure from all paths
191+ // Build tree structure from paths
142192 interface TreeBuildNode {
143193 id : string ;
144194 generation : number ;
145195 children : Map < string , TreeBuildNode > ;
146196 }
147197
148198 const fullTree : TreeBuildNode = {
149- id : rootId ,
199+ id : canonicalRootId ,
150200 generation : 0 ,
151201 children : new Map ( ) ,
152202 } ;
153203
154- // Add all paths to tree
155204 for ( const [ , pathArr ] of paths ) {
156205 let current = fullTree ;
157206 for ( let i = 1 ; i < pathArr . length ; i ++ ) {
@@ -167,11 +216,10 @@ export const sparseTreeService = {
167216 }
168217 }
169218
170- // Find nodes that should be shown: favorites, root, and branch points (non-favorites with 2+ children leading to favorites)
171- const nodesToShow = new Set < string > ( [ rootId , ...favoriteIds ] ) ;
219+ // Find branch points
220+ const nodesToShow = new Set < string > ( [ canonicalRootId , ...favoriteIds ] ) ;
172221
173222 const findBranchPoints = ( node : TreeBuildNode ) : boolean => {
174- // Returns true if this node has any favorite descendants
175223 if ( favoriteIds . has ( node . id ) ) return true ;
176224
177225 let branchesWithFavorites = 0 ;
@@ -181,8 +229,7 @@ export const sparseTreeService = {
181229 }
182230 }
183231
184- // If this non-favorite node has 2+ branches leading to favorites, it's a branch point
185- if ( branchesWithFavorites >= 2 && ! favoriteIds . has ( node . id ) && node . id !== rootId ) {
232+ if ( branchesWithFavorites >= 2 && ! favoriteIds . has ( node . id ) && node . id !== canonicalRootId ) {
186233 nodesToShow . add ( node . id ) ;
187234 }
188235
@@ -191,18 +238,16 @@ export const sparseTreeService = {
191238
192239 findBranchPoints ( fullTree ) ;
193240
194- // Build sparse tree showing only selected nodes
241+ // Build sparse tree
195242 const buildSparseNode = ( node : TreeBuildNode , lastShownGeneration : number ) : SparseTreeNode | null => {
196243 const shouldShow = nodesToShow . has ( node . id ) ;
197- const person = db [ node . id ] ;
244+ const person = personData . get ( node . id ) ;
198245 const favorite = getFavoriteData ( node . id ) ;
199246
200- // Recursively build children
201247 const childResults : SparseTreeNode [ ] = [ ] ;
202248 for ( const [ , child ] of node . children ) {
203249 const childResult = buildSparseNode ( child , shouldShow ? node . generation : lastShownGeneration ) ;
204250 if ( childResult ) {
205- // If child is a "pass-through" (not shown), merge its children up
206251 if ( Array . isArray ( childResult . children ) && ! nodesToShow . has ( childResult . id ) ) {
207252 childResults . push ( ...childResult . children ) ;
208253 } else {
@@ -227,12 +272,10 @@ export const sparseTreeService = {
227272 } ;
228273 }
229274
230- // Not shown - pass children through
231275 if ( childResults . length === 1 ) {
232276 return childResults [ 0 ] ;
233277 }
234278 if ( childResults . length > 1 ) {
235- // Return a placeholder to pass multiple children up
236279 return {
237280 id : node . id ,
238281 name : '' ,
@@ -247,7 +290,6 @@ export const sparseTreeService = {
247290
248291 const sparseRoot = buildSparseNode ( fullTree , - 1 ) ;
249292
250- // Calculate max generation
251293 let maxGeneration = 0 ;
252294 const findMaxGen = ( node : SparseTreeNode ) => {
253295 maxGeneration = Math . max ( maxGeneration , node . generationFromRoot ) ;
@@ -259,11 +301,11 @@ export const sparseTreeService = {
259301
260302 return {
261303 root : sparseRoot || {
262- id : rootId ,
263- name : db [ rootId ] ?. name || rootId ,
264- lifespan : db [ rootId ] ?. lifespan || '' ,
304+ id : canonicalRootId ,
305+ name : personData . get ( canonicalRootId ) ?. name || rootId ,
306+ lifespan : personData . get ( canonicalRootId ) ?. lifespan || '' ,
265307 generationFromRoot : 0 ,
266- isFavorite : favoriteIds . has ( rootId ) ,
308+ isFavorite : favoriteIds . has ( canonicalRootId ) ,
267309 } ,
268310 totalFavorites : favoritesInDb . length ,
269311 maxGeneration,
0 commit comments