mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-07-28 06:31:49 +00:00
Refactoring: moved some source files into subdirectories
This commit is contained in:
4
src/util/analytics.ts
Normal file
4
src/util/analytics.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Sends an event to Google Analytics. */
|
||||
export function analyticsEvent(action: string, data?: any) {
|
||||
(window as any).gtag('event', action, data);
|
||||
}
|
||||
99
src/util/date_util.ts
Normal file
99
src/util/date_util.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import {Date as TopolaDate, DateOrRange, DateRange, getDate} from 'topola';
|
||||
import {InjectedIntl} from 'react-intl';
|
||||
|
||||
const DATE_QUALIFIERS = new Map([
|
||||
['abt', 'about'],
|
||||
['cal', 'calculated'],
|
||||
['est', 'estimated'],
|
||||
]);
|
||||
|
||||
function formatDate(date: TopolaDate, intl: InjectedIntl) {
|
||||
const hasDay = date.day !== undefined;
|
||||
const hasMonth = date.month !== undefined;
|
||||
const hasYear = date.year !== undefined;
|
||||
if (!hasDay && !hasMonth && !hasYear) {
|
||||
return date.text || '';
|
||||
}
|
||||
const dateObject = new Date(
|
||||
hasYear ? date.year! : 0,
|
||||
hasMonth ? date.month! - 1 : 0,
|
||||
hasDay ? date.day! : 1,
|
||||
);
|
||||
|
||||
const qualifier = date.qualifier && date.qualifier.toLowerCase();
|
||||
const translatedQualifier =
|
||||
qualifier &&
|
||||
intl.formatMessage({
|
||||
id: `date.${qualifier}`,
|
||||
defaultMessage: DATE_QUALIFIERS.get(qualifier) || qualifier,
|
||||
});
|
||||
|
||||
const formatOptions = {
|
||||
day: hasDay ? 'numeric' : undefined,
|
||||
month: hasMonth ? 'long' : undefined,
|
||||
year: hasYear ? 'numeric' : undefined,
|
||||
};
|
||||
const translatedDate = new Intl.DateTimeFormat(
|
||||
intl.locale,
|
||||
formatOptions,
|
||||
).format(dateObject);
|
||||
|
||||
return [translatedQualifier, translatedDate].join(' ');
|
||||
}
|
||||
|
||||
function formatDateRage(dateRange: DateRange, intl: InjectedIntl) {
|
||||
const fromDate = dateRange.from;
|
||||
const toDate = dateRange.to;
|
||||
const translatedFromDate = fromDate && formatDate(fromDate, intl);
|
||||
const translatedToDate = toDate && formatDate(toDate, intl);
|
||||
if (translatedFromDate && translatedToDate) {
|
||||
return intl.formatMessage(
|
||||
{
|
||||
id: 'date.between',
|
||||
defaultMessage: 'between {from} and {to}',
|
||||
},
|
||||
{from: translatedFromDate, to: translatedToDate},
|
||||
);
|
||||
}
|
||||
if (translatedFromDate) {
|
||||
return intl.formatMessage(
|
||||
{
|
||||
id: 'date.after',
|
||||
defaultMessage: 'after {from}',
|
||||
},
|
||||
{from: translatedFromDate},
|
||||
);
|
||||
}
|
||||
if (translatedToDate) {
|
||||
return intl.formatMessage(
|
||||
{
|
||||
id: 'date.before',
|
||||
defaultMessage: 'before {to}',
|
||||
},
|
||||
{to: translatedToDate},
|
||||
);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Formats a DateOrRange object. */
|
||||
export function formatDateOrRange(
|
||||
dateOrRange: DateOrRange | undefined,
|
||||
intl: InjectedIntl,
|
||||
): string {
|
||||
if (!dateOrRange) {
|
||||
return '';
|
||||
}
|
||||
if (dateOrRange.date) {
|
||||
return formatDate(dateOrRange.date, intl);
|
||||
}
|
||||
if (dateOrRange.dateRange) {
|
||||
return formatDateRage(dateOrRange.dateRange, intl);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Formats a date given in GEDCOM format. */
|
||||
export function translateDate(gedcomDate: string, intl: InjectedIntl): string {
|
||||
return formatDateOrRange(getDate(gedcomDate), intl);
|
||||
}
|
||||
70
src/util/gedcom_util.spec.ts
Normal file
70
src/util/gedcom_util.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import {normalizeGedcom} from './gedcom_util';
|
||||
|
||||
describe('normalizeGedcom()', () => {
|
||||
it('sorts children', () => {
|
||||
const data = {
|
||||
indis: [
|
||||
{
|
||||
id: 'I3',
|
||||
birth: {date: {year: 1901}},
|
||||
famc: 'F1',
|
||||
},
|
||||
{
|
||||
id: 'I2',
|
||||
birth: {date: {year: 1902, month: 7}},
|
||||
famc: 'F1',
|
||||
},
|
||||
{
|
||||
id: 'I1',
|
||||
birth: {date: {year: 1902, month: 8}},
|
||||
famc: 'F1',
|
||||
},
|
||||
],
|
||||
fams: [
|
||||
{
|
||||
id: 'F1',
|
||||
children: ['I1', 'I2', 'I3'],
|
||||
},
|
||||
],
|
||||
};
|
||||
const normalized = normalizeGedcom(data);
|
||||
expect(normalized.fams[0].children).toEqual(['I3', 'I2', 'I1']);
|
||||
});
|
||||
|
||||
it('sorts spouses', () => {
|
||||
const data = {
|
||||
indis: [
|
||||
{id: 'I1', fams: ['F1']},
|
||||
{id: 'I2', fams: ['F2']},
|
||||
{id: 'I3', fams: ['F3']},
|
||||
{id: 'I4', fams: ['F1', 'F2', 'F3']},
|
||||
],
|
||||
fams: [
|
||||
{
|
||||
id: 'F3',
|
||||
marriage: {date: {year: 1901}},
|
||||
husband: 'I4',
|
||||
wife: 'I3',
|
||||
},
|
||||
{
|
||||
id: 'F2',
|
||||
marriage: {date: {year: 1902, month: 7}},
|
||||
husband: 'I4',
|
||||
wife: 'I2',
|
||||
},
|
||||
{
|
||||
id: 'F1',
|
||||
marriage: {date: {year: 1902, month: 8}},
|
||||
husband: 'I4',
|
||||
wife: 'I1',
|
||||
},
|
||||
],
|
||||
};
|
||||
const normalized = normalizeGedcom(data);
|
||||
expect(normalized.indis.find((i) => i.id === 'I4')!.fams).toEqual([
|
||||
'F3',
|
||||
'F2',
|
||||
'F1',
|
||||
]);
|
||||
});
|
||||
});
|
||||
263
src/util/gedcom_util.ts
Normal file
263
src/util/gedcom_util.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import {GedcomEntry, parse as parseGedcom} from 'parse-gedcom';
|
||||
import {
|
||||
JsonFam,
|
||||
JsonGedcomData,
|
||||
JsonIndi,
|
||||
gedcomEntriesToJson,
|
||||
JsonImage,
|
||||
JsonEvent,
|
||||
} from 'topola';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Compares dates of the given events. */
|
||||
function compareDates(
|
||||
event1: JsonEvent | undefined,
|
||||
event2: JsonEvent | undefined,
|
||||
): number {
|
||||
const date1 =
|
||||
event1 && (event1.date || (event1.dateRange && event1.dateRange.from));
|
||||
const date2 =
|
||||
event2 && (event2.date || (event2.dateRange && event2.dateRange.from));
|
||||
if (!date1 || !date1.year || !date2 || !date2.year) {
|
||||
return 0;
|
||||
}
|
||||
if (date1.year !== date2.year) {
|
||||
return date1.year - date2.year;
|
||||
}
|
||||
if (!date1.month || !date2.month) {
|
||||
return 0;
|
||||
}
|
||||
if (date1.month !== date2.month) {
|
||||
return date1.month - date2.month;
|
||||
}
|
||||
if (date1.day && date2.day && date1.day !== date2.day) {
|
||||
return date1.month - date2.month;
|
||||
}
|
||||
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});
|
||||
}
|
||||
|
||||
/** Sorts children and spouses. */
|
||||
export function normalizeGedcom(gedcom: JsonGedcomData): JsonGedcomData {
|
||||
return sortSpouses(sortChildren(gedcom));
|
||||
}
|
||||
|
||||
const IMAGE_EXTENSIONS = ['.jpg', '.png', '.gif'];
|
||||
|
||||
/** Returns true if the given file name has a known image extension. */
|
||||
function isImageFile(fileName: string): boolean {
|
||||
const lowerName = fileName.toLowerCase();
|
||||
return IMAGE_EXTENSIONS.some((ext) => lowerName.endsWith(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes images that are not HTTP links or do not have known image extensions.
|
||||
* Does not modify the input object.
|
||||
*/
|
||||
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 fileName = image.url.match(/[^/\\]*$/)![0];
|
||||
// If the image file has been loaded into memory, use it.
|
||||
if (images.has(fileName)) {
|
||||
newImages.push({url: images.get(fileName)!, title: image.title});
|
||||
} else if (image.url.startsWith('http') && isImageFile(image.url)) {
|
||||
newImages.push(image);
|
||||
}
|
||||
});
|
||||
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.
|
||||
*/
|
||||
export function convertGedcom(
|
||||
gedcom: string,
|
||||
images: Map<string, string>,
|
||||
): TopolaData {
|
||||
const entries = parseGedcom(gedcom);
|
||||
const json = gedcomEntriesToJson(entries);
|
||||
if (
|
||||
!json ||
|
||||
!json.indis ||
|
||||
!json.indis.length ||
|
||||
!json.fams ||
|
||||
!json.fams.length
|
||||
) {
|
||||
throw new Error('Failed to read GEDCOM file');
|
||||
}
|
||||
|
||||
return {
|
||||
chartData: filterImages(normalizeGedcom(json), images),
|
||||
gedcom: prepareGedcom(entries),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user