Files
topola-viewer/src/datasource/wikitree_api.ts
2026-06-15 07:39:22 +02:00

294 lines
7.9 KiB
TypeScript

import {
clientLogin,
getLoggedInUserName,
getPeople,
getRelatives as getRelativesApi,
Person,
} from 'wikitree-js';
import {TopolaError} from '../util/error';
const WIKITREE_APP_ID = 'topola-viewer';
/** Prefix for IDs of private individuals. */
export const PRIVATE_ID_PREFIX = '~Private';
const ANCESTORS_GENERATION_LIMIT = 5;
const DESCENDANT_GENERATION_LIMIT = 5;
function getSessionStorageItem(key: string): string | null {
try {
return sessionStorage.getItem(key);
} catch (e) {
console.warn('Failed to load data from session storage: ' + e);
}
return null;
}
function setSessionStorageItem(key: string, value: string) {
try {
return sessionStorage.setItem(key, value);
} catch (e) {
console.warn('Failed to store data in session storage: ' + e);
}
}
function getCacheItem<T>(key: string): T | null {
const cachedData = getSessionStorageItem(key);
if (cachedData) {
try {
return JSON.parse(cachedData) as T;
} catch (e) {
console.warn(`Failed to parse cached data for key ${key}: ${e}`);
}
}
return null;
}
function setCacheItem<T>(key: string, value: T): void {
setSessionStorageItem(key, JSON.stringify(value));
}
function getApiOptions(handleCors: boolean) {
return Object.assign(
{appId: WIKITREE_APP_ID},
handleCors
? {
apiUrl:
'https://topolaproxy.bieda.it/https://api.wikitree.com/api.php',
}
: {},
);
}
async function getAncestors(
key: string,
handleCors: boolean,
): Promise<Person[]> {
const cacheKey = `wikitree:ancestors:${key}`;
const cachedData = getCacheItem<Person[]>(cacheKey);
if (cachedData) {
return cachedData;
}
const result = await getPeople(
[key],
{ancestors: ANCESTORS_GENERATION_LIMIT},
getApiOptions(handleCors),
);
setCacheItem(cacheKey, result);
return result;
}
async function getRelatives(
keys: string[],
handleCors: boolean,
): Promise<Person[]> {
const result: Person[] = [];
const keysToFetch: string[] = [];
// Deduplicate keys
const uniqueKeys: string[] = [];
keys.forEach((key) => {
if (!uniqueKeys.includes(key)) {
uniqueKeys.push(key);
}
});
uniqueKeys.forEach((key) => {
const cachedData = getCacheItem<Person>(`wikitree:relatives:${key}`);
if (cachedData) {
result.push(cachedData);
} else {
keysToFetch.push(key);
}
});
if (keysToFetch.length === 0) {
return result;
}
const response = await getRelativesApi(
keysToFetch,
{getChildren: true, getSpouses: true},
getApiOptions(handleCors),
);
if (!response) {
const id = keysToFetch[0];
throw new TopolaError(
'WIKITREE_PROFILE_NOT_FOUND',
`WikiTree profile ${id} not found`,
{id},
);
}
response.forEach((person) => {
setCacheItem(`wikitree:relatives:${person.Name}`, person);
});
return result.concat(response);
}
async function logInIfNeeded(
authcode: string | undefined,
handleCors: boolean,
) {
if (!handleCors && !getLoggedInUserName() && authcode) {
const loginResult = await clientLogin(authcode, {appId: WIKITREE_APP_ID});
if (loginResult.result === 'Success') {
sessionStorage.clear();
}
}
}
async function getFirstPerson(key: string, handleCors: boolean) {
const person = (await getRelatives([key], handleCors))[0];
if (!person?.Name) {
const id = key;
throw new TopolaError(
'WIKITREE_PROFILE_NOT_ACCESSIBLE',
`WikiTree profile ${id} is not accessible. Try logging in.`,
{id},
);
}
return person;
}
function getSpouseKeys(person: Person) {
return Object.values(person.Spouses || {}).map((s) => s.Name);
}
async function getAllAncestors(keys: string[], handleCors: boolean) {
// Deduplicate input keys
const uniqueKeys: string[] = [];
keys.forEach((k) => {
if (!uniqueKeys.includes(k)) {
uniqueKeys.push(k);
}
});
const ancestors = await Promise.all(
uniqueKeys.map((key) => getAncestors(key, handleCors)),
);
const ancestorKeys = ancestors
.flat()
.map((person) => person.Name)
.filter((key) => !!key);
// Deduplicate ancestorKeys as well before calling getRelatives
const uniqueAncestorKeys: string[] = [];
ancestorKeys.forEach((k) => {
if (!uniqueAncestorKeys.includes(k)) {
uniqueAncestorKeys.push(k);
}
});
const ancestorDetails = await getRelatives(uniqueAncestorKeys, handleCors);
// Map from person id to father id if the father profile is private.
const privateFathers: Map<number, number> = new Map();
// Map from person id to mother id if the mother profile is private.
const privateMothers: Map<number, number> = new Map();
// Adjust private individual ids so that there are no collisions in the case
// that ancestors were collected for more than one person.
ancestors.forEach((ancestorList, index) => {
const offset = 1000 * index;
// Adjust ids by offset.
ancestorList.forEach((person) => {
if (person.Id < 0) {
person.Id -= offset;
person.Name = `${PRIVATE_ID_PREFIX}${person.Id}`;
}
if (person.Father < 0) {
person.Father -= offset;
privateFathers.set(person.Id, person.Father);
}
if (person.Mother < 0) {
person.Mother -= offset;
privateMothers.set(person.Id, person.Mother);
}
});
});
// Set the Father and Mother fields again because getRelatives doesn't return
// private parents.
ancestorDetails.forEach((person) => {
const privateFather = privateFathers.get(person.Id);
if (privateFather) {
person.Father = privateFather;
}
const privateMother = privateMothers.get(person.Id);
if (privateMother) {
person.Mother = privateMother;
}
});
// Collect private individuals.
const privateAncestors = ancestors.flat().filter((person) => person.Id < 0);
return ancestorDetails.concat(privateAncestors);
}
async function getAllDescendants(key: string, handleCors: boolean) {
const everyone: Person[] = [];
// Fetch descendants recursively.
let toFetch = [key];
let generation = 0;
while (toFetch.length > 0 && generation <= DESCENDANT_GENERATION_LIMIT) {
const people = await getRelatives(toFetch, handleCors);
everyone.push(...people);
const allSpouses = people.flatMap((person) =>
Object.values(person.Spouses || {}),
);
everyone.push(...allSpouses);
// Fetch all children.
const nextToFetch = people.flatMap((person) =>
Object.values(person.Children || {}).map((c) => c.Name),
);
// Deduplicate nextToFetch and filter out already fetched people
const uniqueNextToFetch: string[] = [];
nextToFetch.forEach((name) => {
if (
!uniqueNextToFetch.includes(name) &&
!everyone.some((p) => p.Name === name)
) {
uniqueNextToFetch.push(name);
}
});
toFetch = uniqueNextToFetch;
generation++;
}
return everyone;
}
/**
* Loads data from the WikiTree API for a given person key.
*
* @param key The WikiTree profile ID to load.
* @param authcode Optional authentication code.
* @returns A unique list of WikiTree `Person` records.
*/
export async function loadData(
key: string,
authcode?: string,
): Promise<Person[]> {
// Work around CORS if not in apps.wikitree.com domain.
const handleCors = window.location.hostname !== 'apps.wikitree.com';
await logInIfNeeded(authcode, handleCors);
const firstPerson = await getFirstPerson(key, handleCors);
const spouseKeys = getSpouseKeys(firstPerson);
// Fetch the ancestors of the input person and ancestors of his/her spouses.
const allAncestors = getAllAncestors([key].concat(spouseKeys), handleCors);
// Fetch descendants and their spouses.
const allDescendants = getAllDescendants(key, handleCors);
const everyone: Person[] = [
...(await allAncestors),
...(await allDescendants),
];
// Make sure the list contains unique elements.
return Array.from(
new Map(everyone.map((person) => [person.Id, person])).values(),
);
}