|
1 | 1 | import fs from 'fs'; |
2 | 2 | import path from 'path'; |
3 | | -import type { Database, DatabaseInfo, Person, PersonWithId } from '@fsf/shared'; |
| 3 | +import type { Database, DatabaseInfo, Person, PersonWithId, OnThisDayEvent } from '@fsf/shared'; |
4 | 4 | import { sqliteService } from '../db/sqlite.service.js'; |
5 | 5 | import { idMappingService } from './id-mapping.service.js'; |
6 | 6 | import { scraperService } from './scraper.service.js'; |
@@ -1346,4 +1346,101 @@ export const databaseService = { |
1346 | 1346 | occupations: occupationRows.map(r => ({ occupation: r.occupation, count: r.count })), |
1347 | 1347 | }; |
1348 | 1348 | }, |
| 1349 | + |
| 1350 | + /** |
| 1351 | + * Get ancestors with birth/death anniversaries on a given month/day. |
| 1352 | + */ |
| 1353 | + getOnThisDay(rootId: string, month: number, day: number): OnThisDayEvent[] { |
| 1354 | + if (!useSqlite) return []; |
| 1355 | + |
| 1356 | + const canonical = idMappingService.resolveId(rootId, 'familysearch') || rootId; |
| 1357 | + const rootInfo = sqliteService.queryOne<{ db_id: string }>( |
| 1358 | + 'SELECT db_id FROM database_info WHERE db_id = @canonical', |
| 1359 | + { canonical } |
| 1360 | + ); |
| 1361 | + if (!rootInfo) return []; |
| 1362 | + |
| 1363 | + const dbId = canonical; |
| 1364 | + |
| 1365 | + // Query all birth/death events with date_original text |
| 1366 | + const rows = sqliteService.queryAll<{ |
| 1367 | + person_id: string; |
| 1368 | + display_name: string; |
| 1369 | + gender: string | null; |
| 1370 | + event_type: string; |
| 1371 | + date_original: string; |
| 1372 | + date_year: number | null; |
| 1373 | + place: string | null; |
| 1374 | + }>( |
| 1375 | + `SELECT ve.person_id, p.display_name, p.gender, |
| 1376 | + ve.event_type, ve.date_original, ve.date_year, ve.place |
| 1377 | + FROM vital_event ve |
| 1378 | + JOIN database_membership dm ON ve.person_id = dm.person_id AND dm.db_id = @dbId |
| 1379 | + JOIN person p ON ve.person_id = p.person_id |
| 1380 | + WHERE ve.event_type IN ('birth', 'death') |
| 1381 | + AND ve.date_original IS NOT NULL`, |
| 1382 | + { dbId } |
| 1383 | + ); |
| 1384 | + |
| 1385 | + // Check which persons have photos |
| 1386 | + const photoPersons = new Set( |
| 1387 | + sqliteService.queryAll<{ person_id: string }>( |
| 1388 | + `SELECT DISTINCT m.person_id |
| 1389 | + FROM media m |
| 1390 | + JOIN database_membership dm ON m.person_id = dm.person_id AND dm.db_id = @dbId`, |
| 1391 | + { dbId } |
| 1392 | + ).map(r => r.person_id) |
| 1393 | + ); |
| 1394 | + |
| 1395 | + // Parse dates and filter for matching month/day |
| 1396 | + const results: OnThisDayEvent[] = []; |
| 1397 | + const seen = new Set<string>(); // Dedupe by personId+eventType |
| 1398 | + |
| 1399 | + for (const row of rows) { |
| 1400 | + const parsed = parseDateMonthDay(row.date_original); |
| 1401 | + if (!parsed || parsed.month !== month || parsed.day !== day) continue; |
| 1402 | + |
| 1403 | + const key = `${row.person_id}:${row.event_type}`; |
| 1404 | + if (seen.has(key)) continue; |
| 1405 | + seen.add(key); |
| 1406 | + |
| 1407 | + results.push({ |
| 1408 | + personId: row.person_id, |
| 1409 | + displayName: row.display_name, |
| 1410 | + gender: (row.gender as OnThisDayEvent['gender']) ?? undefined, |
| 1411 | + eventType: row.event_type as 'birth' | 'death', |
| 1412 | + dateOriginal: row.date_original, |
| 1413 | + year: row.date_year, |
| 1414 | + place: row.place ?? undefined, |
| 1415 | + hasPhoto: photoPersons.has(row.person_id), |
| 1416 | + }); |
| 1417 | + } |
| 1418 | + |
| 1419 | + // Sort: births first, then by year ascending |
| 1420 | + results.sort((a, b) => { |
| 1421 | + if (a.eventType !== b.eventType) return a.eventType === 'birth' ? -1 : 1; |
| 1422 | + return (a.year ?? 0) - (b.year ?? 0); |
| 1423 | + }); |
| 1424 | + |
| 1425 | + return results; |
| 1426 | + }, |
| 1427 | +}; |
| 1428 | + |
| 1429 | +const MONTH_NAMES: Record<string, number> = { |
| 1430 | + january: 1, february: 2, march: 3, april: 4, may: 5, june: 6, |
| 1431 | + july: 7, august: 8, september: 9, october: 10, november: 11, december: 12, |
| 1432 | + jan: 1, feb: 2, mar: 3, apr: 4, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12, |
1349 | 1433 | }; |
| 1434 | + |
| 1435 | +/** Extract month and day from a date string like "12 March 1847" or "12 Mar 1847" */ |
| 1436 | +function parseDateMonthDay(dateOriginal: string): { month: number; day: number } | null { |
| 1437 | + const match = dateOriginal.match(/(\d{1,2})\s+([A-Za-z]+)\s+/); |
| 1438 | + if (!match) return null; |
| 1439 | + |
| 1440 | + const day = parseInt(match[1]); |
| 1441 | + const monthStr = match[2].toLowerCase(); |
| 1442 | + const month = MONTH_NAMES[monthStr]; |
| 1443 | + if (!month || day < 1 || day > 31) return null; |
| 1444 | + |
| 1445 | + return { month, day }; |
| 1446 | +} |
0 commit comments