diff --git a/package.json b/package.json index c22f7e3..75c72b7 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,13 @@ "array.prototype.flatmap": "^1.2.1", "canvas-toBlob": "^1.0.0", "d3": "^5.7.0", + "debounce": "^1.2.0", "detect-browser": "^4.1.0", "file-saver": "^2.0.1", "history": "^4.7.2", + "javascript-natural-sort": "^0.7.1", "jspdf": "^1.5.3", + "lunr": "^2.3.6", "md5": "^2.2.1", "parse-gedcom": "^1.0.5", "query-string": "^5.1.1", @@ -25,13 +28,15 @@ "devDependencies": { "@types/array.prototype.flatmap": "^1.2.0", "@types/d3": "^5.5.0", + "@types/debounce": "^1.2.0", "@types/file-saver": "^2.0.0", "@types/history": "^4.7.2", "@types/jest": "*", "@types/jspdf": "^1.2.2", + "@types/lunr": "^2.3.2", "@types/md5": "^2.1.33", "@types/node": "*", - "@types/query-string": "^6.2.0", + "@types/query-string": "6.2.0", "@types/react": "*", "@types/react-dom": "*", "@types/react-intl": "^2.3.15", diff --git a/src/app.tsx b/src/app.tsx index fc38590..1e8ff43 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -190,6 +190,7 @@ export class App extends React.Component { render={(props: RouteComponentProps) => ( { this.state.selection ) } + onSelection={this.onSelection} onPrint={() => { analyticsEvent('print'); this.chartRef && this.chartRef.print(); diff --git a/src/date_util.ts b/src/date_util.ts new file mode 100644 index 0000000..7c20e4a --- /dev/null +++ b/src/date_util.ts @@ -0,0 +1,92 @@ +import {Date as TopolaDate, 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 date given in GEDCOM format. */ +export function translateDate(gedcomDate: string, intl: InjectedIntl) { + const dateOrRange = getDate(gedcomDate); + if (!dateOrRange) { + return ''; + } + if (dateOrRange.date) { + return formatDate(dateOrRange.date, intl); + } + if (dateOrRange.dateRange) { + return formatDateRage(dateOrRange.dateRange, intl); + } + return ''; +} diff --git a/src/details.tsx b/src/details.tsx index f952d08..5f34f13 100644 --- a/src/details.tsx +++ b/src/details.tsx @@ -1,11 +1,11 @@ import * as React from 'react'; import flatMap from 'array.prototype.flatmap'; import Linkify from 'react-linkify'; -import {Date as TopolaDate, DateRange, getDate} from 'topola'; import {FormattedMessage, InjectedIntl} from 'react-intl'; import {GedcomData} from './gedcom_util'; import {GedcomEntry} from 'parse-gedcom'; import {intlShape} from 'react-intl'; +import {translateDate} from './date_util'; interface Props { gedcom: GedcomData; @@ -35,95 +35,6 @@ function translateTag(tag: string) { ); } -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 ''; -} - -function translateDate(gedcomDate: string, intl: InjectedIntl) { - const dateOrRange = getDate(gedcomDate); - if (!dateOrRange) { - return ''; - } - if (dateOrRange.date) { - return formatDate(dateOrRange.date, intl); - } - if (dateOrRange.dateRange) { - return formatDateRage(dateOrRange.dateRange, intl); - } - return ''; -} - function joinLines(lines: (JSX.Element | string)[]) { return ( <> diff --git a/src/javascript-natural-sort.d.ts b/src/javascript-natural-sort.d.ts new file mode 100644 index 0000000..16ae64b --- /dev/null +++ b/src/javascript-natural-sort.d.ts @@ -0,0 +1,4 @@ +// Data type definitions for the javascript-natural-sort library. +declare module 'javascript-natural-sort' { + export default function naturalSort(a: string, b: string): number; +} diff --git a/src/search_index.ts b/src/search_index.ts new file mode 100644 index 0000000..a225c73 --- /dev/null +++ b/src/search_index.ts @@ -0,0 +1,71 @@ +import naturalSort from 'javascript-natural-sort'; +import lunr from 'lunr'; +import {GedcomData} from './gedcom_util'; +import {GedcomEntry} from 'parse-gedcom'; + +const MAX_RESULTS = 8; + +export interface SearchResult { + id: string; + indi: GedcomEntry; +} + +export interface SearchIndex { + search(input: string): SearchResult[]; +} + +/** Removes accents from letters, e.g. ó->o, ę->e. */ +function normalize(input: string) { + return input + .toLocaleLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/\u0142/g, 'l'); // Special case: ł is not affected by NFD. +} + +/** Comparator to sort by score first, then by id. */ +function compare(a: lunr.Index.Result, b: lunr.Index.Result) { + if (a.score !== b.score) { + return a.score - b.score; + } + return naturalSort(a.ref, b.ref); +} + +class LunrSearchIndex implements SearchIndex { + private index: lunr.Index; + + public constructor(private gedcom: GedcomData) { + this.index = lunr(function() { + this.ref('id'); + this.field('id'); + this.field('name'); + this.field('normalizedName'); + + for (let id in gedcom.indis) { + const name = gedcom.indis[id].tree + .filter((entry) => entry.tag === 'NAME') + .map((entry) => entry.data) + .join(' '); + this.add({id, name, normalizedName: normalize(name)}); + } + }); + } + + public search(input: string) { + const query = input + .split(' ') + .filter((s) => !!s) + .map((s) => `+${s}*`) + .join(' '); + const results = this.index.search(query); + return results + .sort(compare) + .slice(0, MAX_RESULTS) + .map((result) => ({id: result.ref, indi: this.gedcom.indis[result.ref]})); + } +} + +/** Builds a search index from data. */ +export function buildSearchIndex(gedcom: GedcomData): SearchIndex { + return new LunrSearchIndex(gedcom); +} diff --git a/src/search_util.tsx b/src/search_util.tsx new file mode 100644 index 0000000..99596d1 --- /dev/null +++ b/src/search_util.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import {GedcomEntry} from 'parse-gedcom'; +import {InjectedIntl} from 'react-intl'; +import {SearchResult} from './search_index'; +import {translateDate} from './date_util'; + +function getNameLine(result: SearchResult) { + const nameTag = result.indi.tree.find((entry) => entry.tag === 'NAME'); + const name = + nameTag && + nameTag.data + .split('/') + .filter((s) => !!s) + .join(' '); + if (result.id.length > 8) { + return name; + } + return ( + <> + {name} ({result.id}) + + ); +} + +function getDate(indi: GedcomEntry, tag: string, intl: InjectedIntl) { + const eventEntry = indi.tree.find((entry) => entry.tag === tag); + const dateEntry = + eventEntry && eventEntry.tree.find((entry) => entry.tag === 'DATE'); + return (dateEntry && translateDate(dateEntry.data, intl)) || ''; +} + +function getDescriptionLine(indi: GedcomEntry, intl: InjectedIntl) { + const birthDate = getDate(indi, 'BIRT', intl); + const deathDate = getDate(indi, 'DEAT', intl); + if (!deathDate) { + return birthDate; + } + return `${birthDate} – ${deathDate}`; +} + +/** Produces an object that is displayed in the Semantic UI Search results. */ +export function displaySearchResult(result: SearchResult, intl: InjectedIntl) { + return { + id: result.id, + title: getNameLine(result), + description: getDescriptionLine(result.indi, intl), + }; +} diff --git a/src/top_bar.tsx b/src/top_bar.tsx index 27d766a..3a0e3bd 100644 --- a/src/top_bar.tsx +++ b/src/top_bar.tsx @@ -1,8 +1,13 @@ import * as queryString from 'query-string'; import * as React from 'react'; +import debounce from 'debounce'; import md5 from 'md5'; import {analyticsEvent} from './analytics'; -import {FormattedMessage} from 'react-intl'; +import {buildSearchIndex, SearchIndex, SearchResult} from './search_index'; +import {displaySearchResult} from './search_util'; +import {FormattedMessage, intlShape} from 'react-intl'; +import {GedcomData} from './gedcom_util'; +import {IndiInfo} from 'topola'; import {Link} from 'react-router-dom'; import {RouteComponentProps} from 'react-router-dom'; import { @@ -14,16 +19,21 @@ import { Input, Form, Dropdown, + Search, + SearchProps, } from 'semantic-ui-react'; /** Menus and dialogs state. */ interface State { loadUrlDialogOpen: boolean; url?: string; + searchResults: SearchResult[]; } interface Props { showingChart: boolean; + gedcom?: GedcomData; + onSelection: (indiInfo: IndiInfo) => void; onPrint: () => void; onDownloadPdf: () => void; onDownloadPng: () => void; @@ -49,8 +59,13 @@ export class TopBar extends React.Component< RouteComponentProps & Props, State > { - state: State = {loadUrlDialogOpen: false}; + state: State = { + loadUrlDialogOpen: false, + searchResults: [], + }; inputRef?: Input; + searchRef?: {setValue(value: string): void}; + searchIndex?: SearchIndex; /** Handles the "Upload file" button. */ async handleUpload(event: React.SyntheticEvent) { @@ -135,6 +150,44 @@ export class TopBar extends React.Component< ); } + /** On search input change. */ + handleSearch(input: string | undefined) { + if (!input) { + return; + } + const results = this.searchIndex!.search(input).map((result) => + displaySearchResult(result, this.context.intl), + ); + this.setState(Object.assign({}, this.state, {searchResults: results})); + } + + /** On search result selected. */ + handleResultSelect(id: string) { + this.props.onSelection({id, generation: 0}); + this.searchRef!.setValue(''); + } + + initializeSearchIndex() { + if (this.props.gedcom) { + this.searchIndex = buildSearchIndex(this.props.gedcom); + } + } + + componentDidMount() { + this.initializeSearchIndex(); + } + + componentDidUpdate(prevProps: Props) { + if (prevProps.gedcom !== this.props.gedcom) { + this.initializeSearchIndex(); + } + } + + /** Make intl appear in this.context. */ + static contextTypes = { + intl: intlShape, + }; + render() { const loadFromUrlModal = ( + , data: SearchProps) => + this.handleSearch(data.value), + 200, + )} + onResultSelect={(_, data) => this.handleResultSelect(data.result.id)} + results={this.state.searchResults} + noResultsMessage={this.context.intl.formatMessage({ + id: 'menu.search.no_results', + defaultMessage: 'No results found', + })} + placeholder={this.context.intl.formatMessage({ + id: 'menu.search.placeholder', + defaultMessage: 'Search for people', + })} + selectFirstResult={true} + ref={(ref) => + (this.searchRef = (ref as unknown) as { + setValue(value: string): void; + }) + } + /> ) : null; diff --git a/src/translations/pl.json b/src/translations/pl.json index d0cf921..92a9cda 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -7,6 +7,8 @@ "menu.png_file": "Plik PNG", "menu.svg_file": "Plik SVG", "menu.github": "Źródła na GitHub", + "menu.search.placeholder": "Szukaj osoby", + "menu.search.no_results": "Brak wyników", "intro.title": "Topola Genealogy", "intro.description": "Topola Genealogy pozwala przeglądać drzewo genealogiczne w interaktywny sposób.", "intro.instructions": "Kliknij OTWÓRZ URL lub OTWÓRZ PLIK, aby załadować plik GEDCOM. Większość programów genealogicznych posiada funkcję eksportu do pliku GEDCOM.",