Refactoring: moved some source files into subdirectories

This commit is contained in:
Przemek Wiech
2020-05-03 20:53:59 +02:00
parent f588c62696
commit a61b4848a5
14 changed files with 29 additions and 31 deletions

4
src/util/analytics.ts Normal file
View 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
View 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);
}

View 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
View 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;
}