Files
topola-viewer/src/util/gedcom_util.ts

622 lines
17 KiB
TypeScript

import {GedcomEntry, parse as parseGedcom} from 'parse-gedcom';
import {
DateOrRange,
gedcomEntriesToJson,
getDate,
JsonFam,
JsonGedcomData,
JsonImage,
JsonIndi,
} from 'topola';
import {compareDates} from './date_util';
import {TopolaError} from './error';
export interface GedcomData {
/** The HEAD entry. */
head?: GedcomEntry;
/** INDI entries mapped by id. */
indis: {[key: string]: GedcomEntry};
/** FAM entries mapped by id. */
fams: {[key: string]: GedcomEntry};
/** Other entries mapped by id, e.g. NOTE, SOUR. */
other: {[key: string]: GedcomEntry};
}
export interface TopolaData {
chartData: JsonGedcomData;
gedcom: GedcomData;
images?: Map<string, string>;
}
export interface Source {
title?: string;
author?: string;
page?: string;
date?: DateOrRange;
publicationInfo?: string;
}
/**
* Returns the identifier extracted from a pointer string.
* E.g. '@I123@' -> 'I123'
*/
export function pointerToId(pointer: string): string {
return pointer.substring(1, pointer.length - 1);
}
/** Returns a map from individual ID to individual data object. */
export function idToIndiMap(data: JsonGedcomData): Map<string, JsonIndi> {
const map = new Map<string, JsonIndi>();
data.indis.forEach((indi) => {
map.set(indi.id, indi);
});
return map;
}
/** Returns a map from family ID to family data object. */
export function idToFamMap(data: JsonGedcomData): Map<string, JsonFam> {
const map = new Map<string, JsonFam>();
data.fams.forEach((fam) => {
map.set(fam.id, fam);
});
return map;
}
function prepareGedcom(entries: GedcomEntry[]): GedcomData {
const head = entries.find((entry) => entry.tag === 'HEAD');
const indis: {[key: string]: GedcomEntry} = {};
const fams: {[key: string]: GedcomEntry} = {};
const other: {[key: string]: GedcomEntry} = {};
entries.forEach((entry) => {
if (entry.tag === 'INDI') {
indis[pointerToId(entry.pointer)] = entry;
} else if (entry.tag === 'FAM') {
fams[pointerToId(entry.pointer)] = entry;
} else if (entry.pointer) {
other[pointerToId(entry.pointer)] = entry;
}
});
return {head, indis, fams, other};
}
function strcmp(a: string, b: string) {
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
return 0;
}
/** Birth date comparator for individuals. */
function birthDatesComparator(gedcom: JsonGedcomData) {
const indiMap = idToIndiMap(gedcom);
return (indiId1: string, indiId2: string) => {
const indi1: JsonIndi | undefined = indiMap.get(indiId1);
const indi2: JsonIndi | undefined = indiMap.get(indiId2);
return (
compareDates(indi1 && indi1.birth, indi2 && indi2.birth) ||
strcmp(indiId1, indiId2)
);
};
}
/** Marriage date comparator for families. */
function marriageDatesComparator(gedcom: JsonGedcomData) {
const famMap = idToFamMap(gedcom);
return (famId1: string, famId2: string) => {
const fam1: JsonFam | undefined = famMap.get(famId1);
const fam2: JsonFam | undefined = famMap.get(famId2);
return (
compareDates(fam1 && fam1.marriage, fam2 && fam2.marriage) ||
strcmp(famId1, famId2)
);
};
}
/**
* Sorts children by birth date in the given family.
* Does not modify the input objects.
*/
function sortFamilyChildren(
fam: JsonFam,
comparator: (id1: string, id2: string) => number,
): JsonFam {
if (!fam.children) {
return fam;
}
const newChildren = fam.children.sort(comparator);
return Object.assign({}, fam, {children: newChildren});
}
/**
* Sorts children by birth date.
* Does not modify the input object.
*/
function sortChildren(gedcom: JsonGedcomData): JsonGedcomData {
const comparator = birthDatesComparator(gedcom);
const newFams = gedcom.fams.map((fam) => sortFamilyChildren(fam, comparator));
return Object.assign({}, gedcom, {fams: newFams});
}
/**
* Sorts spouses by marriage date.
* Does not modify the input objects.
*/
function sortIndiSpouses(
indi: JsonIndi,
comparator: (id1: string, id2: string) => number,
): JsonFam {
if (!indi.fams) {
return indi;
}
const newFams = indi.fams.sort(comparator);
return Object.assign({}, indi, {fams: newFams});
}
function sortSpouses(gedcom: JsonGedcomData): JsonGedcomData {
const comparator = marriageDatesComparator(gedcom);
const newIndis = gedcom.indis.map((indi) =>
sortIndiSpouses(indi, comparator),
);
return Object.assign({}, gedcom, {indis: newIndis});
}
/**
* If the entry is a reference to a top-level entry, the referenced entry is
* returned. Otherwise, returns the given entry unmodified.
*/
export function dereference(
entry: GedcomEntry,
gedcom: GedcomData,
getterFunction: (gedcom: GedcomData) => {[key: string]: GedcomEntry},
) {
if (entry.data) {
const dereferenced = getterFunction(gedcom)[pointerToId(entry.data)];
if (dereferenced) {
return dereferenced;
}
}
return entry;
}
/**
* Returns the data for the given GEDCOM entry as an array of lines. Supports
* continuations with CONT and CONC.
*/
export function getData(entry: GedcomEntry) {
const result = [entry.data];
entry.tree.forEach((subentry) => {
if (subentry.tag === 'CONC' && subentry.data) {
const last = result.length - 1;
result[last] += subentry.data;
} else if (subentry.tag === 'CONT' && subentry.data) {
result.push(subentry.data);
}
});
return result;
}
/** Sorts children and spouses. */
export function normalizeGedcom(gedcom: JsonGedcomData): JsonGedcomData {
return sortSpouses(sortChildren(gedcom));
}
const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
/**
* Returns true if the given file reference points to an image: a name/URL with
* a known image extension, or an extensionless but inherently browser-renderable
* `blob:` object URL or `data:image/...` URI. The latter two carry no path
* extension but are already accepted by `isBrowserLoadable`, so without this
* `filterImage` would drop them. A `blob:` URL's type is not derivable from the
* string, so it is accepted unconditionally; `data:` is required to be an image.
*/
export function isImageFile(fileName: string): boolean {
// Anchored tests read only the scheme, so an inlined data: payload (which can
// be multi-megabyte) is never scanned or copied.
if (/^blob:/i.test(fileName)) {
return true;
}
if (/^data:/i.test(fileName)) {
return /^data:image\//i.test(fileName);
}
const cleanName = fileName.split(/[?#]/)[0].toLowerCase();
return IMAGE_EXTENSIONS.some((ext) => cleanName.endsWith(ext));
}
/**
* Removes images that are not HTTP links or do not have known image extensions.
* Does not modify the input object.
*/
export function isBrowserLoadable(url: string): boolean {
return /^(https?:|blob:|data:|\/\/)/i.test(url);
}
export function resolveFileUrl(
url: string,
images?: Map<string, string>,
): string {
if (isBrowserLoadable(url)) {
return url;
}
const normalizedUrl = url.replace(/\\/g, '/');
if (images instanceof Map) {
const lowercasePath = normalizedUrl.toLowerCase();
const mappedUrl = images.get(lowercasePath);
if (mappedUrl) {
return mappedUrl;
}
}
return normalizedUrl;
}
function filterImage(indi: JsonIndi, images: Map<string, string>): JsonIndi {
if (!indi.images || indi.images.length === 0) {
return indi;
}
const newImages: JsonImage[] = [];
indi.images.forEach((image) => {
const resolvedUrl = resolveFileUrl(image.url, images);
const normalizedUrl = image.url.replace(/\\/g, '/');
if (
resolvedUrl !== normalizedUrl ||
(isBrowserLoadable(resolvedUrl) && isImageFile(resolvedUrl))
) {
newImages.push({url: resolvedUrl, title: image.title});
}
});
return Object.assign({}, indi, {images: newImages});
}
/**
* Removes images that are not HTTP links.
* Does not modify the input object.
*/
function filterImages(
gedcom: JsonGedcomData,
images: Map<string, string>,
): JsonGedcomData {
const newIndis = gedcom.indis.map((indi) => filterImage(indi, images));
return Object.assign({}, gedcom, {indis: newIndis});
}
/**
* Converts GEDCOM file into JSON data performing additional transformations:
* - sort children by birth date
* - remove images that are not HTTP links and aren't mapped in `images`.
*
* @param images Map from file name to image URL. This is used to pass in
* uploaded images.
*/
/**
* Yields to the browser event loop, allowing incremental GC and UI updates.
* Calls onProgress if provided; does not touch the DOM directly.
*/
function yieldToEventLoop(
onProgress?: (status: string) => void,
status?: string,
): Promise<void> {
if (status) onProgress?.(status);
return new Promise((resolve) => setTimeout(resolve, 0));
}
export async function convertGedcom(
gedcom: string,
images: Map<string, string>,
onProgress?: (status: string) => void,
): Promise<TopolaData> {
await yieldToEventLoop(onProgress, 'Step 1/4: parsing GEDCOM…');
const entries = parseGedcom(gedcom);
await yieldToEventLoop(onProgress, 'Step 2/4: building family graph…');
const json = gedcomEntriesToJson(entries);
if (
!json ||
!json.indis ||
!json.indis.length ||
!json.fams ||
!json.fams.length
) {
throw new TopolaError('GEDCOM_READ_FAILED', 'Failed to read GEDCOM file');
}
await yieldToEventLoop(onProgress, 'Step 3/4: sorting & normalizing…');
const chartData = filterImages(normalizeGedcom(json), images);
await yieldToEventLoop(onProgress, 'Step 4/4: indexing records…');
const gedcomData = prepareGedcom(entries);
return {
chartData,
gedcom: gedcomData,
images,
};
}
/** Returns the name of the software used to generate the GEDCOM file, if available. */
export function getSoftware(head?: GedcomEntry): string | null {
const sour =
head && head.tree && head.tree.find((entry) => entry.tag === 'SOUR');
const name =
sour && sour.tree && sour.tree.find((entry) => entry.tag === 'NAME');
return (name && name.data) || null;
}
/** Returns the name of an individual, preferring birth name over married name. */
export function getName(person: GedcomEntry): string | undefined {
const names = person.tree.filter((subEntry) => subEntry.tag === 'NAME');
const notMarriedName = names.find(
(subEntry) =>
subEntry.tree.filter(
(nameEntry) => nameEntry.tag === 'TYPE' && nameEntry.data === 'married',
).length === 0,
);
const name = notMarriedName || names[0];
return name?.data.replace(/\//g, '');
}
/** Returns the file name for a media entry, combining title and extension. */
export function getFileName(fileEntry: GedcomEntry): string | undefined {
const fileTitle = fileEntry?.tree.find((entry) => entry.tag === 'TITL')?.data;
const fileExtension = fileEntry?.tree.find(
(entry) => entry.tag === 'FORM',
)?.data;
if (fileTitle && fileExtension) {
return fileTitle + '.' + fileExtension;
}
if (fileEntry && fileEntry.data) {
const path = fileEntry.data.replace(/\\/g, '/');
const cleanPath = path.split(/[?#]/)[0];
return cleanPath.split('/').pop();
}
return undefined;
}
function findFileEntry(
objectEntry: GedcomEntry,
predicate: (entry: GedcomEntry) => boolean,
): GedcomEntry | undefined {
return objectEntry.tree.find(
(entry) => entry.tag === 'FILE' && entry.data && predicate(entry),
);
}
/** Returns the first non-image file entry for a media object. */
export function getNonImageFileEntry(
objectEntry: GedcomEntry,
): GedcomEntry | undefined {
return findFileEntry(objectEntry, (entry) => !isImageFile(entry.data));
}
/** Returns the first image file entry for a media object. */
export function getImageFileEntry(
objectEntry: GedcomEntry,
): GedcomEntry | undefined {
return findFileEntry(objectEntry, (entry) => isImageFile(entry.data));
}
/** Resolves the DATE sub-entry for the given GEDCOM entry. */
export function resolveDate(entry: GedcomEntry) {
return entry.tree.find((subEntry) => subEntry.tag === 'DATE');
}
/** Resolves the TYPE sub-entry data for the given GEDCOM entry. */
export function resolveType(entry: GedcomEntry) {
return entry.tree.find((subEntry) => subEntry.tag === 'TYPE')?.data;
}
/** Converts a GEDCOM source reference entry to a structured Source object. */
export function mapToSource(
sourceEntryReference: GedcomEntry,
gedcom: GedcomData,
) {
const sourceEntry = dereference(
sourceEntryReference,
gedcom,
(gedcom) => gedcom.other,
);
const title = sourceEntry.tree.find((subEntry) => 'TITL' === subEntry.tag);
const abbr = sourceEntry.tree.find((subEntry) => 'ABBR' === subEntry.tag);
const author = sourceEntry.tree.find((subEntry) => 'AUTH' === subEntry.tag);
const publicationInfo = sourceEntry.tree.find(
(subEntry) => 'PUBL' === subEntry.tag,
);
const page = sourceEntryReference.tree.find(
(subEntry) => 'PAGE' === subEntry.tag,
);
const sourceData = sourceEntryReference.tree.find(
(subEntry) => 'DATA' === subEntry.tag,
);
const date = sourceData ? resolveDate(sourceData) : undefined;
return {
title: title?.data || abbr?.data,
author: author?.data,
page: page?.data,
date: date ? getDate(date.data) : undefined,
publicationInfo: publicationInfo?.data,
};
}
/** Finds the shortest relationship path between two individuals in the family tree. */
export function findRelationshipPath(
indiId1: string,
indiId2: string,
indiMap: Map<string, JsonIndi>,
famMap: Map<string, JsonFam>,
): string[] {
const getNeighbors = (id: string): string[] => {
const indi = indiMap.get(id);
if (!indi) {
return [];
}
const neighbors: string[] = [];
if (indi.famc) {
const fam = famMap.get(indi.famc);
if (fam) {
if (fam.wife) {
neighbors.push(fam.wife);
}
if (fam.husb) {
neighbors.push(fam.husb);
}
if (fam.children) {
fam.children.forEach((child) => {
if (child !== id) {
neighbors.push(child);
}
});
}
}
}
if (indi.fams) {
indi.fams.forEach((famId) => {
const fam = famMap.get(famId);
if (fam) {
if (fam.wife && fam.wife !== id) {
neighbors.push(fam.wife);
}
if (fam.husb && fam.husb !== id) {
neighbors.push(fam.husb);
}
if (fam.children) {
fam.children.forEach((child) => neighbors.push(child));
}
}
});
}
return neighbors.filter((nId) => !nId.startsWith('private_'));
};
const queue: string[] = [indiId1];
const visited = new Map<string, string | null>();
visited.set(indiId1, null);
while (queue.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const current = queue.shift()!;
if (current === indiId2) {
const path: string[] = [];
let curr: string | null = current;
while (curr !== null) {
path.push(curr);
curr = visited.get(curr) || null;
}
return path.reverse();
}
const neighbors = getNeighbors(current);
for (const neighbor of neighbors) {
if (!visited.has(neighbor)) {
visited.set(neighbor, current);
queue.push(neighbor);
}
}
}
return [];
}
/** Returns the ancestors of an individual up to a specified number of generations. */
export function getAncestors(
indiId: string,
generations: number,
indiMap: Map<string, JsonIndi>,
famMap: Map<string, JsonFam>,
): string[] {
const result: string[] = [];
const queue: {id: string; gen: number}[] = [{id: indiId, gen: 0}];
const visited = new Set<string>();
visited.add(indiId);
while (queue.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const {id, gen} = queue.shift()!;
if (id !== indiId && !id.startsWith('private_')) {
result.push(id);
}
if (gen < generations) {
const indi = indiMap.get(id);
if (indi && indi.famc) {
const fam = famMap.get(indi.famc);
if (fam) {
if (fam.wife && !visited.has(fam.wife)) {
visited.add(fam.wife);
queue.push({id: fam.wife, gen: gen + 1});
}
if (fam.husb && !visited.has(fam.husb)) {
visited.add(fam.husb);
queue.push({id: fam.husb, gen: gen + 1});
}
}
}
}
}
return result;
}
/** Returns the descendants of an individual up to a specified number of generations. */
export function getDescendants(
indiId: string,
generations: number,
indiMap: Map<string, JsonIndi>,
famMap: Map<string, JsonFam>,
): string[] {
const result: string[] = [];
const queue: {id: string; gen: number}[] = [{id: indiId, gen: 0}];
const visited = new Set<string>();
visited.add(indiId);
while (queue.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const {id, gen} = queue.shift()!;
if (id !== indiId && !id.startsWith('private_')) {
result.push(id);
}
if (gen < generations) {
const indi = indiMap.get(id);
if (indi && indi.fams) {
indi.fams.forEach((famId) => {
const fam = famMap.get(famId);
if (fam && fam.children) {
fam.children.forEach((child) => {
if (!visited.has(child)) {
visited.add(child);
queue.push({id: child, gen: gen + 1});
}
});
}
});
}
}
}
return result;
}