import Cookies from 'js-cookie'; import { fetch } from 'cross-fetch'; import FormData from 'form-data'; import { BioFormat, ClientLoginResponse, GetAncestorsRequest, GetDescendantsRequest, GetPeopleRequest, GetPersonRequest, GetRelativesRequest, Person, PersonField, WikiTreeRequest, } from './wikitree_types'; /** Default API URL if not explicitly specified. */ const WIKITREE_API_URL = 'https://api.wikitree.com/api.php'; /** Default appId sent if not explicitly specified. */ const WIKITREE_JS_APPID = 'wikitree-js'; /** * Cookie where the logged in user name is stored. This cookie is shared * between apps hosted on apps.wikitree.com. It is not used to authenticate * requests but only to allow displaying what user is logged in. * Authentication cookies are stored when the user logs in via * https://api.wikitree.com/api.php */ const USER_NAME_COOKIE = 'wikidb_wtb_UserName'; /** Wraps an error returned from the WikiTree API. */ export class WikiTreeError extends Error { constructor(message: string) { super(message); this.name = 'WikiTreeError'; } } export interface WikiTreeAuthentication { cookies: string; } /** Additional API options common to all requests. */ export interface ApiOptions { /** Authentication credentials as returned by the login() function. */ auth?: WikiTreeAuthentication; /** Alternative API URL. */ apiUrl?: string; /** Application id used for statistical purposes. */ appId?: string; } /** Sends a request to the WikiTree API. Returns the raw response. */ export async function fetchWikiTree( request: WikiTreeRequest, options?: ApiOptions ) { const requestData = new FormData(); requestData.append('format', 'json'); requestData.append('appId', options?.appId ?? WIKITREE_JS_APPID); for (const key in request) { if (request[key]) { requestData.append(key, request[key]); } } const apiUrl = options?.apiUrl || WIKITREE_API_URL; const postRequest: RequestInit = { method: 'POST', redirect: 'manual', // required for NodeJS login body: requestData as any, credentials: isWikiTreeUrl(apiUrl) ? 'include' : undefined, }; if (options?.auth) { postRequest.headers = { Cookie: options.auth.cookies }; } return await fetch(apiUrl, postRequest); } /** Sends a request to the WikiTree API. Returns the parsed response JSON. */ export async function wikiTreeGet( request: WikiTreeRequest, options?: ApiOptions ) { const response = await fetchWikiTree(request, options); const result = await response.json(); if (result[0]?.status) { throw new WikiTreeError(result[0].status); } return result; } /** Optional arguments for the GetPerson API call. */ export interface GetPersonArgs { bioFormat?: BioFormat; fields?: Array | '*'; resolveRedirect?: boolean; } /** * Retrieves a single person record from WikiTree. * * See also: https://github.com/wikitree/wikitree-api/blob/main/getPerson.md */ export async function getPerson( key: string, args?: GetPersonArgs, options?: ApiOptions ): Promise { const request: GetPersonRequest = { action: 'getPerson', key, bioFormat: args?.bioFormat, fields: args?.fields instanceof Array ? args.fields.join(',') : args?.fields, resolveRedirect: args?.resolveRedirect ? '1' : undefined, }; const response = await wikiTreeGet(request, options); return response[0].person as Person; } /** Optional arguments for the GetAncestors API call. */ interface GetAncestorsArgs { depth?: number; bioFormat?: BioFormat; fields?: Array | '*'; resolveRedirect?: boolean; } /** * Retrieves ancestors from WikiTree for the given person ID. * * See also: https://github.com/wikitree/wikitree-api/blob/main/getAncestors.md */ export async function getAncestors( key: string, args?: GetAncestorsArgs, options?: ApiOptions ): Promise { const request: GetAncestorsRequest = { action: 'getAncestors', key, depth: args?.depth, bioFormat: args?.bioFormat, fields: args?.fields instanceof Array ? args.fields.join(',') : args?.fields, resolveRedirect: args?.resolveRedirect ? '1' : undefined, }; const response = await wikiTreeGet(request, options); return response[0].ancestors as Person[]; } /** Optional arguments for the GetRelatives API call. */ export interface GetRelativesArgs { getParents?: boolean; getChildren?: boolean; getSpouses?: boolean; getSiblings?: boolean; bioFormat?: BioFormat; fields?: Array | '*'; } /** Optional arguments for the GetDescendants API call. */ export interface GetDescendantsArgs { depth?: number; bioFormat?: BioFormat; fields?: Array | '*'; resolveRedirect?: boolean; } /** * Retrieves descendants from WikiTree for the given person ID. * * See also: https://github.com/wikitree/wikitree-api/blob/main/getDescendants.md */ export async function getDescendants( key: string, args?: GetDescendantsArgs, options?: ApiOptions ): Promise { const request: GetDescendantsRequest = { action: 'getDescendants', key, depth: args?.depth, bioFormat: args?.bioFormat, fields: args?.fields instanceof Array ? args.fields.join(',') : args?.fields, resolveRedirect: args?.resolveRedirect ? '1' : undefined, }; const response = await wikiTreeGet(request, options); return response[0].descendants as Person[]; } /** * Retrieves relatives from WikiTree for the given array of person IDs. * If a key does not exist or is inaccessible, it is omitted in the result. * * See also: https://github.com/wikitree/wikitree-api/blob/main/getRelatives.md */ export async function getRelatives( keys: string[], args?: GetRelativesArgs, options?: ApiOptions ): Promise { if (args?.bioFormat && !args?.fields?.includes('Bio')) { console.warn( 'Setting bioFormat has no effect if the "Bio" field is not requested' + ' explicitly' ); } const request: GetRelativesRequest = { action: 'getRelatives', keys: keys.join(','), getParents: args?.getParents ? 'true' : undefined, getChildren: args?.getChildren ? 'true' : undefined, getSpouses: args?.getSpouses ? 'true' : undefined, getSiblings: args?.getSiblings ? 'true' : undefined, bioFormat: args?.bioFormat, fields: args?.fields instanceof Array ? args.fields.join(',') : args?.fields, }; const response = await wikiTreeGet(request, options); if (response[0].items === null) { return []; } return response[0].items.map( (item: { person: Person }) => item.person ) as Person[]; } /** Optional arguments for the GetPeople API call. */ export interface GetPeopleArgs { siblings?: boolean; ancestors?: number; descendants?: number; nuclear?: number; minGeneration?: number; limit?: number; start?: number; bioFormat?: BioFormat; fields?: Array | '*'; } /** * Retrieves relatives from WikiTree for the given array of person IDs. * * See also: https://github.com/wikitree/wikitree-api/blob/main/getPeople.md */ export async function getPeople( keys: string[], args?: GetPeopleArgs, options?: ApiOptions ): Promise { if (args?.bioFormat && !args?.fields?.includes('Bio')) { console.warn( 'Setting bioFormat has no effect if the "Bio" field is not requested' + ' explicitly' ); } const request: GetPeopleRequest = { action: 'getPeople', keys: keys.join(','), siblings: args?.siblings ? '1' : undefined, ancestors: args?.ancestors, descendants: args?.descendants, nuclear: args?.nuclear, minGeneration: args?.minGeneration, limit: args?.limit, start: args?.start, bioFormat: args?.bioFormat, fields: args?.fields instanceof Array ? args.fields.join(',') : args?.fields, }; const response = await wikiTreeGet(request, options); console.log(JSON.stringify(response, null, 2)); if (response[0].people === null) { return []; } return Object.values(response[0].people) as Person[]; } /** * Returns the logged in user name or undefined if not logged in. * * In the browser, call this function without arguments. * This is not an authoritative answer. The result of this function relies on * the cookies set on the apps.wikitree.com domain under which this application * is hosted. The authoritative source of login information is in cookies set on * the api.wikitree.com domain. * * In Node.js, call this function with the auth parameter. This is an * authoritative answer because the login flow is under control of the * wikitree-js library. */ export function getLoggedInUserName( auth?: WikiTreeAuthentication ): string | undefined { if (!auth) { // Return user name stored in browser cookies. return Cookies.get(USER_NAME_COOKIE); } // Extract user name from cookies in WikiTreeAuthentication. const regex = new RegExp(`${USER_NAME_COOKIE}=(.*?);`); const match = auth.cookies.match(regex); return match ? match[1] : undefined; } // === Browser-specific code === /** * Navigates to WikiTree login screen at https://api.wikitree.com/api.php with * the specified return URL. */ export function navigateToLoginPage(returnUrl: string) { if (!isWikiTreeUrl(returnUrl)) { console.warn( 'Return URLs outside of the wikitree.com domain will not work with the' + " WikiTree login flow because of WikiTree API's CORS settings." ); } const form = document.createElement('form'); form.setAttribute('action', WIKITREE_API_URL); form.setAttribute('method', 'POST'); form.setAttribute('hidden', 'true'); const actionInput = document.createElement('input'); actionInput.setAttribute('name', 'action'); actionInput.setAttribute('type', 'hidden'); actionInput.setAttribute('value', 'clientLogin'); const returnUrlInput = document.createElement('input'); returnUrlInput.setAttribute('name', 'returnURL'); returnUrlInput.setAttribute('type', 'hidden'); returnUrlInput.setAttribute('value', returnUrl); form.appendChild(actionInput); form.appendChild(returnUrlInput); document.body.appendChild(form); form.submit(); } export async function clientLogin( authcode: string, options?: ApiOptions ): Promise { const response = await wikiTreeGet( { action: 'clientLogin', authcode, }, options ); const result = response.clientLogin; if (result.result === 'Success') { Cookies.set(USER_NAME_COOKIE, result.username); } return result; } // === Node.js-specific code === /** * Logs in to WikiTree returning authentication credentials. * This function will not work in the browser because it handles cookies directly. * Throws an exception if login fails. */ export async function login( email: string, password: string, options?: ApiOptions ): Promise { const authcode = await getAuthcode(email, password, options); return { cookies: await getAuthCookies(authcode, options) }; } async function getAuthcode( email: string, password: string, options?: ApiOptions ): Promise { const response = await fetchWikiTree( { action: 'clientLogin', doLogin: 1, returnURL: 'https://x/', wpEmail: email, wpPassword: password, } as any, options ); if (response.status !== 302) { throw new WikiTreeError('Invalid login credentials'); } return response.headers.get('location')!.replace('https://x/?authcode=', ''); } async function getAuthCookies( authcode: string, options?: ApiOptions ): Promise { const response = await fetchWikiTree( { action: 'clientLogin', authcode, }, options ); const result = await response.json(); if (result.clientLogin?.result !== 'Success') { throw new WikiTreeError('Could not authorize authcode'); } return response.headers.get('set-cookie'); } function isWikiTreeUrl(url: string) { return url.match(/^https:\/\/[^/]*wikitree.com\/.*/); }