From f3d98338cb6c9e639c9c04a06f97cd9bc8f1f5d5 Mon Sep 17 00:00:00 2001 From: czifumasa Date: Tue, 26 Oct 2021 12:49:18 +0200 Subject: [PATCH] Improvements for events in info panel (#72) * Improved visuals of events panel, handle family events * Create separate package for details panel * Improved handling of multiple event notes --- .gitignore | 1 + src/app.tsx | 2 +- src/details.tsx | 235 --------------------------------- src/details/details.tsx | 148 +++++++++++++++++++++ src/details/events.tsx | 235 +++++++++++++++++++++++++++++++++ src/details/multiline-text.tsx | 31 +++++ src/details/translated-tag.tsx | 51 +++++++ src/translations/pl.json | 2 + src/util/date_util.ts | 31 +++++ src/util/gedcom_util.ts | 68 +++++----- src/util/media.ts | 2 +- 11 files changed, 539 insertions(+), 267 deletions(-) delete mode 100644 src/details.tsx create mode 100644 src/details/details.tsx create mode 100644 src/details/events.tsx create mode 100644 src/details/multiline-text.tsx create mode 100644 src/details/translated-tag.tsx diff --git a/.gitignore b/.gitignore index bb28cfd..3cadc76 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ cypress/plugins/index.js cypress/support/commands.js cypress/support/index.js cypress/videos +.idea diff --git a/src/app.tsx b/src/app.tsx index d866c5c..d784049 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -11,7 +11,7 @@ import { DEFALUT_CONFIG, } from './config'; import {DataSourceEnum, SourceSelection} from './datasource/data_source'; -import {Details} from './details'; +import {Details} from './details/details'; import {EmbeddedDataSource, EmbeddedSourceSpec} from './datasource/embedded'; import {FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl'; import {getI18nMessage} from './util/error_i18n'; diff --git a/src/details.tsx b/src/details.tsx deleted file mode 100644 index d6f5ecb..0000000 --- a/src/details.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import * as React from 'react'; -import flatMap from 'array.prototype.flatmap'; -import Linkify from 'react-linkify'; -import { - FormattedMessage, - injectIntl, - IntlShape, - WrappedComponentProps, -} from 'react-intl'; -import {GedcomData, pointerToId} from './util/gedcom_util'; -import {GedcomEntry} from 'parse-gedcom'; -import {translateDate} from './util/date_util'; - -interface Props { - gedcom: GedcomData; - indi: string; -} - -const EVENT_TAGS = ['BIRT', 'BAPM', 'CHR', 'EVEN', 'CENS', 'DEAT', 'BURI']; -const EXCLUDED_TAGS = ['NAME', 'SEX', 'FAMC', 'FAMS', 'NOTE', 'SOUR']; -const TAG_DESCRIPTIONS = new Map([ - ['ADOP', 'Adoption'], - ['BAPM', 'Baptism'], - ['BIRT', 'Birth'], - ['BURI', 'Burial'], - ['CENS', 'Census'], - ['CHR', 'Christening'], - ['CREM', 'Cremation'], - ['DEAT', 'Death'], - ['EDUC', 'Education'], - ['EMAIL', 'E-mail'], - ['EMIG', 'Emigration'], - ['EVEN', 'Event'], - ['FACT', 'Fact'], - ['IMMI', 'Immigration'], - ['MARR', 'Marriage'], - ['MILT', 'Military services'], - ['NATU', 'Naturalization'], - ['OCCU', 'Occupation'], - ['TITL', 'Title'], - ['WWW', 'WWW'], -]); - -function translateTag(tag: string) { - const normalizedTag = tag.replace(/_/g, ''); - return ( - - ); -} - -function joinLines(lines: (JSX.Element | string)[]) { - return ( - <> - {lines.map((line, index) => ( -
- {line} -
-
- ))} - - ); -} - -/** - * Returns the data for the given GEDCOM entry as an array of lines. Supports - * continuations with CONT and CONC. - */ -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; -} - -function eventDetails(entry: GedcomEntry, intl: IntlShape) { - const lines = []; - if (entry.data && entry.data.length > 1) { - lines.push({entry.data}); - } - const date = entry.tree.find((subentry) => subentry.tag === 'DATE'); - if (date && date.data) { - lines.push(translateDate(date.data, intl)); - } - const place = entry.tree.find((subentry) => subentry.tag === 'PLAC'); - if (place && place.data) { - lines.push(...getData(place)); - } - entry.tree - .filter((subentry) => subentry.tag === 'NOTE') - .forEach((note) => - getData(note).forEach((line) => lines.push({line})), - ); - if (!lines.length) { - return null; - } - return ( - <> -
{translateTag(entry.tag)}
- {joinLines(lines)} - - ); -} - -function dataDetails(entry: GedcomEntry) { - const lines = []; - if (entry.data) { - lines.push(...getData(entry)); - } - entry.tree - .filter((subentry) => subentry.tag === 'NOTE') - .forEach((note) => - getData(note).forEach((line) => lines.push({line})), - ); - if (!lines.length) { - return null; - } - return ( - <> -
{translateTag(entry.tag)}
- {joinLines(lines)} - - ); -} - -function noteDetails(entry: GedcomEntry) { - return joinLines( - getData(entry).map((line, index) => {line}), - ); -} - -function nameDetails(entry: GedcomEntry) { - return ( -

- {entry.data - .split('/') - .filter((name) => !!name) - .map((name, index) => ( -
- {name} -
-
- ))} -

- ); -} - -function getDetails( - entries: GedcomEntry[], - tags: string[], - detailsFunction: (entry: GedcomEntry) => JSX.Element | null, -): JSX.Element[] { - return flatMap(tags, (tag) => - entries - .filter((entry) => entry.tag === tag) - .map((entry) => detailsFunction(entry)), - ) - .filter((element) => element !== null) - .map((element, index) => ( -
- {element} -
- )); -} - -/** - * Returns true if there is displayable information in this entry. - * Returns false if there is no data in this entry or this is only a reference - * to another entry. - */ -function hasData(entry: GedcomEntry) { - return entry.tree.length > 0 || (entry.data && !entry.data.startsWith('@')); -} - -function getOtherDetails(entries: GedcomEntry[]) { - return entries - .filter( - (entry) => - !EXCLUDED_TAGS.includes(entry.tag) && !EVENT_TAGS.includes(entry.tag), - ) - .filter(hasData) - .map((entry) => dataDetails(entry)) - .filter((element) => element !== null) - .map((element, index) => ( -
- {element} -
- )); -} - -/** - * If the entry is a reference to a top-level entry, the referenced entry is - * returned. Otherwise, returns the given entry unmodified. - */ -function dereference(entry: GedcomEntry, gedcom: GedcomData) { - if (entry.data) { - const dereferenced = gedcom.other[pointerToId(entry.data)]; - if (dereferenced) { - return dereferenced; - } - } - return entry; -} - -class DetailsComponent extends React.Component< - Props & WrappedComponentProps, - {} -> { - render() { - const entries = this.props.gedcom.indis[this.props.indi].tree; - const entriesWithData = entries - .map((entry) => dereference(entry, this.props.gedcom)) - .filter(hasData); - - return ( -
- {getDetails(entries, ['NAME'], nameDetails)} - {getDetails(entries, EVENT_TAGS, (entry) => - eventDetails(entry, this.props.intl), - )} - {getOtherDetails(entriesWithData)} - {getDetails(entriesWithData, ['NOTE'], noteDetails)} -
- ); - } -} -export const Details = injectIntl(DetailsComponent); diff --git a/src/details/details.tsx b/src/details/details.tsx new file mode 100644 index 0000000..4c0f5b5 --- /dev/null +++ b/src/details/details.tsx @@ -0,0 +1,148 @@ +import * as React from 'react'; +import flatMap from 'array.prototype.flatmap'; +import {injectIntl, WrappedComponentProps} from 'react-intl'; +import {dereference, GedcomData, getData} from '../util/gedcom_util'; +import {GedcomEntry} from 'parse-gedcom'; +import {TranslatedTag} from './translated-tag'; +import {Events} from './events'; +import {MultilineText} from './multiline-text'; + +interface Props { + gedcom: GedcomData; + indi: string; +} + +const EXCLUDED_TAGS = [ + 'BIRT', + 'BAPM', + 'CHR', + 'EVEN', + 'CENS', + 'DEAT', + 'BURI', + 'NAME', + 'SEX', + 'FAMC', + 'FAMS', + 'NOTE', + 'SOUR', +]; + +function dataDetails(entry: GedcomEntry) { + const lines = []; + if (entry.data) { + lines.push(...getData(entry)); + } + entry.tree + .filter((subentry) => subentry.tag === 'NOTE') + .forEach((note) => + getData(note).forEach((line) => lines.push({line})), + ); + if (!lines.length) { + return null; + } + return ( + <> +
+ +
+ + + + + ); +} + +function noteDetails(entry: GedcomEntry) { + return ( + ( + {line} + ))} + /> + ); +} + +function nameDetails(entry: GedcomEntry) { + return ( +

+ {entry.data + .split('/') + .filter((name) => !!name) + .map((name, index) => ( +
+ {name} +
+
+ ))} +

+ ); +} + +function getDetails( + entries: GedcomEntry[], + tags: string[], + detailsFunction: (entry: GedcomEntry) => JSX.Element | null, +): JSX.Element[] { + return flatMap(tags, (tag) => + entries + .filter((entry) => entry.tag === tag) + .map((entry) => detailsFunction(entry)), + ) + .filter((element) => element !== null) + .map((element, index) => ( +
+ {element} +
+ )); +} + +/** + * Returns true if there is displayable information in this entry. + * Returns false if there is no data in this entry or this is only a reference + * to another entry. + */ +function hasData(entry: GedcomEntry) { + return entry.tree.length > 0 || (entry.data && !entry.data.startsWith('@')); +} + +function getOtherDetails(entries: GedcomEntry[]) { + return entries + .filter((entry) => !EXCLUDED_TAGS.includes(entry.tag)) + .filter(hasData) + .map((entry) => dataDetails(entry)) + .filter((element) => element !== null) + .map((element, index) => ( +
+ {element} +
+ )); +} + +class DetailsComponent extends React.Component< + Props & WrappedComponentProps, + {} +> { + render() { + const entries = this.props.gedcom.indis[this.props.indi].tree; + const entriesWithData = entries + .map((entry) => + dereference(entry, this.props.gedcom, (gedcom) => gedcom.other), + ) + .filter(hasData); + + return ( +
+ {getDetails(entries, ['NAME'], nameDetails)} + + {getOtherDetails(entriesWithData)} + {getDetails(entriesWithData, ['NOTE'], noteDetails)} +
+ ); + } +} +export const Details = injectIntl(DetailsComponent); diff --git a/src/details/events.tsx b/src/details/events.tsx new file mode 100644 index 0000000..87031e2 --- /dev/null +++ b/src/details/events.tsx @@ -0,0 +1,235 @@ +import * as React from 'react'; +import {injectIntl, IntlShape, WrappedComponentProps} from 'react-intl'; +import {dereference, GedcomData, getData} from '../util/gedcom_util'; +import {GedcomEntry} from 'parse-gedcom'; +import {compareDates, translateDate} from '../util/date_util'; +import {DateOrRange, getDate} from 'topola'; +import {TranslatedTag} from './translated-tag'; +import {MultilineText} from './multiline-text'; +import flatMap from 'array.prototype.flatmap'; + +interface Props { + gedcom: GedcomData; + indi: string; + entries: GedcomEntry[]; +} + +interface Event { + type: string; + date: DateOrRange | undefined; + header: JSX.Element; + subHeader: JSX.Element | null; + place: JSX.Element | null; + notes: JSX.Element | null; +} + +const EVENT_TAGS = [ + 'BIRT', + 'BAPM', + 'CHR', + 'FAMS', + 'EVEN', + 'CENS', + 'DEAT', + 'BURI', +]; + +const FAMILY_EVENT_TAGS = ['MARR', 'DIV']; + +function eventHeader(tag: string, date: GedcomEntry | null, intl: IntlShape) { + return ( +
+ + + + {date && date.data ? ( + + {translateDate(date.data, intl)} + + ) : null} +
+ ); +} + +function eventFamilyDetails( + entry: GedcomEntry, + indi: string, + familyEntry: GedcomEntry, + gedcom: GedcomData, +) { + const spouseReference = familyEntry.tree + .filter((familySubEntry) => ['WIFE', 'HUSB'].includes(familySubEntry.tag)) + .find((familySubEntry) => !familySubEntry.data.includes(indi)); + + if (spouseReference) { + const spouseName = dereference( + spouseReference, + gedcom, + (gedcom) => gedcom.indis, + ) + .tree.filter((subEntry) => subEntry.tag === 'NAME') + .find( + (subEntry) => + subEntry.tree.filter( + (nameEntry) => + nameEntry.tag === 'TYPE' && nameEntry.data === 'married', + ).length === 0, + ); + if (spouseName) { + return
{spouseName.data.replaceAll('/', '')}
; + } + } + return null; +} + +function eventPlace(entry: GedcomEntry) { + const place = entry.tree.find((subEntry) => subEntry.tag === 'PLAC'); + if (place && place.data) { + return
{getData(place)}
; + } + return null; +} + +function eventNotes(entry: GedcomEntry, gedcom: GedcomData) { + const notes = entry.tree + .filter((subentry) => ['NOTE', 'TYPE'].includes(subentry.tag)) + .map((note) => dereference(note, gedcom, (gedcom) => gedcom.other)) + .map((note) => noteDetails(note)); + + if (notes && notes.length) { + return ( +
+ {notes.map((note, index) => ( +
{note}
+ ))} +
+ ); + } + return null; +} + +function noteDetails(entry: GedcomEntry) { + return ( + ( + {line} + ))} + /> + ); +} + +function eventDetails(event: Event) { + return ( +
+ {event.header} + {event.subHeader} + {event.place} + {event.notes} +
+ ); +} + +function getEventDetails( + entries: GedcomEntry[], + gedcom: GedcomData, + indi: string, + intl: IntlShape, +): JSX.Element | null { + const events = flatMap(EVENT_TAGS, (tag) => + entries + .filter((entry) => entry.tag === tag) + .map((eventEntry) => toEvent(eventEntry, gedcom, indi, intl)) + .flatMap((events) => events) + .sort((event1, event2) => compareDates(event1.date, event2.date)) + .map((event) => eventDetails(event)), + ); + if (events && events.length) { + return ( +
+ {events.map((eventElement, index) => ( +
+ {eventElement} +
+ ))} +
+ ); + } + return null; +} + +function toEvent( + entry: GedcomEntry, + gedcom: GedcomData, + indi: string, + intl: IntlShape, +): Event[] { + if (entry.tag === 'FAMS') { + return toFamilyEvents(entry, gedcom, indi, intl); + } + return toIndiEvent(entry, gedcom, indi, intl); +} + +function toIndiEvent( + entry: GedcomEntry, + gedcom: GedcomData, + indi: string, + intl: IntlShape, +): Event[] { + const date = resolveDate(entry) || null; + return [ + { + date: date ? getDate(date.data) : undefined, + type: entry.tag, + header: eventHeader(entry.tag, date, intl), + subHeader: null, + place: eventPlace(entry), + notes: eventNotes(entry, gedcom), + }, + ]; +} + +function resolveDate(entry: GedcomEntry) { + return entry.tree.find((subEntry) => subEntry.tag === 'DATE'); +} + +function toFamilyEvents( + entry: GedcomEntry, + gedcom: GedcomData, + indi: string, + intl: IntlShape, +): Event[] { + const family = dereference(entry, gedcom, (gedcom) => gedcom.fams); + return flatMap(FAMILY_EVENT_TAGS, (tag) => + family.tree.filter((entry) => entry.tag === tag), + ).map((familyMarriageEvent) => { + const date = resolveDate(familyMarriageEvent) || null; + return { + date: date ? getDate(date.data) : undefined, + type: familyMarriageEvent.tag, + header: eventHeader(familyMarriageEvent.tag, date, intl), + subHeader: eventFamilyDetails(familyMarriageEvent, indi, family, gedcom), + place: eventPlace(familyMarriageEvent), + notes: eventNotes(familyMarriageEvent, gedcom), + }; + }); +} + +class EventsComponent extends React.Component< + Props & WrappedComponentProps, + {} +> { + render() { + return ( + <> + {getEventDetails( + this.props.entries, + this.props.gedcom, + this.props.indi, + this.props.intl, + )} + + ); + } +} + +export const Events = injectIntl(EventsComponent); diff --git a/src/details/multiline-text.tsx b/src/details/multiline-text.tsx new file mode 100644 index 0000000..2a5a738 --- /dev/null +++ b/src/details/multiline-text.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import {injectIntl, WrappedComponentProps} from 'react-intl'; +import Linkify from 'react-linkify'; + +interface Props { + lines: (JSX.Element | string)[]; +} + +function joinLines(lines: (JSX.Element | string)[]) { + return ( + <> + {lines.map((line, index) => ( +
+ {line} +
+
+ ))} + + ); +} + +class MultilineTextComponent extends React.Component< + Props & WrappedComponentProps, + {} +> { + render() { + return joinLines(this.props.lines); + } +} + +export const MultilineText = injectIntl(MultilineTextComponent); diff --git a/src/details/translated-tag.tsx b/src/details/translated-tag.tsx new file mode 100644 index 0000000..5050502 --- /dev/null +++ b/src/details/translated-tag.tsx @@ -0,0 +1,51 @@ +import {FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl'; +import * as React from 'react'; + +interface Props { + tag: string; +} + +const TAG_DESCRIPTIONS = new Map([ + ['ADOP', 'Adoption'], + ['BAPM', 'Baptism'], + ['BIRT', 'Birth'], + ['BURI', 'Burial'], + ['CENS', 'Census'], + ['CHR', 'Christening'], + ['CREM', 'Cremation'], + ['DEAT', 'Death'], + ['EDUC', 'Education'], + ['EMAIL', 'E-mail'], + ['EMIG', 'Emigration'], + ['EVEN', 'Event'], + ['FACT', 'Fact'], + ['IMMI', 'Immigration'], + ['MARR', 'Marriage'], + ['DIV', 'Divorce'], + ['MILT', 'Military services'], + ['NATU', 'Naturalization'], + ['OCCU', 'Occupation'], + ['TITL', 'Title'], + ['WWW', 'WWW'], +]); + +function translateTag(tag: string) { + const normalizedTag = tag.replace(/_/g, ''); + return ( + + ); +} + +class TranslatedTagComponent extends React.Component< + Props & WrappedComponentProps, + {} +> { + render() { + return translateTag(this.props.tag); + } +} + +export const TranslatedTag = injectIntl(TranslatedTagComponent); diff --git a/src/translations/pl.json b/src/translations/pl.json index 07cfe0e..f67a989 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -54,6 +54,8 @@ "gedcom.TITL": "Tytuł", "gedcom.WWW": "Strona WWW", "gedcom._UPD": "Ostatnia aktualizacja", + "gedcom.MARR": "Małżeństwo", + "gedcom.DIV": "Rozwód", "date.abt": "około", "date.cal": "wyliczone", "date.est": "oszacowane", diff --git a/src/util/date_util.ts b/src/util/date_util.ts index fed7a5d..031eee9 100644 --- a/src/util/date_util.ts +++ b/src/util/date_util.ts @@ -97,3 +97,34 @@ export function formatDateOrRange( export function translateDate(gedcomDate: string, intl: IntlShape): string { return formatDateOrRange(getDate(gedcomDate), intl); } + +/** Compares a dates given in GEDCOM format. */ +export function compareDates( + firstDateOrRange: DateOrRange | undefined, + secondDateOrRange: DateOrRange | undefined, +): number { + const date1 = + firstDateOrRange && + (firstDateOrRange.date || + (firstDateOrRange.dateRange && firstDateOrRange.dateRange.from)); + const date2 = + secondDateOrRange && + (secondDateOrRange.date || + (secondDateOrRange.dateRange && secondDateOrRange.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; +} diff --git a/src/util/gedcom_util.ts b/src/util/gedcom_util.ts index 0381466..887687a 100644 --- a/src/util/gedcom_util.ts +++ b/src/util/gedcom_util.ts @@ -1,13 +1,13 @@ import {GedcomEntry, parse as parseGedcom} from 'parse-gedcom'; import {TopolaError} from './error'; import { + gedcomEntriesToJson, JsonFam, JsonGedcomData, - JsonIndi, - gedcomEntriesToJson, JsonImage, - JsonEvent, + JsonIndi, } from 'topola'; +import {compareDates} from './date_util'; export interface GedcomData { /** The HEAD entry. */ @@ -76,33 +76,6 @@ function strcmp(a: string, b: string) { 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); @@ -179,6 +152,41 @@ function sortSpouses(gedcom: JsonGedcomData): JsonGedcomData { 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)); diff --git a/src/util/media.ts b/src/util/media.ts index 014090e..d5be693 100644 --- a/src/util/media.ts +++ b/src/util/media.ts @@ -1,6 +1,6 @@ import {createMedia} from '@artsy/fresnel'; -/** Defines the breakpoints at which to show different UI variants. */ +/** Defines the breakpoints at which to show different UI variants.*/ const AppMedia = createMedia({ breakpoints: { small: 320,