mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-07-18 01:31:47 +00:00
622 lines
17 KiB
TypeScript
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;
|
|
}
|