WikiTree: Load ancestors from private profiles

This commit is contained in:
Przemek Wiech
2020-04-12 01:37:03 +02:00
parent b808c490b5
commit 895f125216
3 changed files with 141 additions and 45 deletions

View File

@@ -4,14 +4,14 @@ import * as React from 'react';
import {analyticsEvent} from './analytics'; import {analyticsEvent} from './analytics';
import {Chart, ChartType} from './chart'; import {Chart, ChartType} from './chart';
import {Details} from './details'; import {Details} from './details';
import {FormattedMessage} from 'react-intl'; import {FormattedMessage, InjectedIntl} from 'react-intl';
import {getSelection, loadFromUrl, loadGedcom} from './load_data'; import {getSelection, loadFromUrl, loadGedcom} from './load_data';
import {getSoftware, TopolaData} from './gedcom_util'; import {getSoftware, TopolaData} from './gedcom_util';
import {IndiInfo} from 'topola'; import {IndiInfo} from 'topola';
import {intlShape} from 'react-intl'; import {intlShape} from 'react-intl';
import {Intro} from './intro'; import {Intro} from './intro';
import {Loader, Message, Portal, Responsive} from 'semantic-ui-react'; import {Loader, Message, Portal, Responsive} from 'semantic-ui-react';
import {loadWikiTree} from './wikitree'; import {loadWikiTree, PRIVATE_ID_PREFIX} from './wikitree';
import {Redirect, Route, RouteComponentProps, Switch} from 'react-router-dom'; import {Redirect, Route, RouteComponentProps, Switch} from 'react-router-dom';
import {TopBar} from './top_bar'; import {TopBar} from './top_bar';
@@ -132,6 +132,8 @@ class GedcomUrlDataSource implements DataSource {
/** Loading data from the WikiTree API. */ /** Loading data from the WikiTree API. */
class WikiTreeDataSource implements DataSource { class WikiTreeDataSource implements DataSource {
constructor(private intl: InjectedIntl) {}
isNewData(args: Arguments, state: State): boolean { isNewData(args: Arguments, state: State): boolean {
if (state.selection && state.selection.id === args.indi) { if (state.selection && state.selection.id === args.indi) {
// Selection unchanged -> don't reload. // Selection unchanged -> don't reload.
@@ -149,7 +151,7 @@ class WikiTreeDataSource implements DataSource {
async loadData(args: Arguments): Promise<TopolaData> { async loadData(args: Arguments): Promise<TopolaData> {
try { try {
const data = await loadWikiTree(args.indi!, args.authcode); const data = await loadWikiTree(args.indi!, this.intl, args.authcode);
analyticsEvent('wikitree_loaded'); analyticsEvent('wikitree_loaded');
return data; return data;
} catch (error) { } catch (error) {
@@ -166,13 +168,6 @@ enum DataSourceEnum {
WIKITREE, WIKITREE,
} }
/** Mapping from data source identifier to data source handler functions. */
const DATA_SOURCES = new Map([
[DataSourceEnum.UPLOADED, new UploadedDataSource()],
[DataSourceEnum.GEDCOM_URL, new GedcomUrlDataSource()],
[DataSourceEnum.WIKITREE, new WikiTreeDataSource()],
]);
/** Arguments passed to the application, primarily through URL parameters. */ /** Arguments passed to the application, primarily through URL parameters. */
interface Arguments { interface Arguments {
showSidePanel: boolean; showSidePanel: boolean;
@@ -294,6 +289,13 @@ export class App extends React.Component<RouteComponentProps, {}> {
intl: intlShape, intl: intlShape,
}; };
/** Mapping from data source identifier to data source handler functions. */
private readonly dataSources = new Map([
[DataSourceEnum.UPLOADED, new UploadedDataSource()],
[DataSourceEnum.GEDCOM_URL, new GedcomUrlDataSource()],
[DataSourceEnum.WIKITREE, new WikiTreeDataSource(this.context.intl)],
]);
/** Sets the state with a new individual selection and chart type. */ /** Sets the state with a new individual selection and chart type. */
private updateDisplay( private updateDisplay(
selection: IndiInfo, selection: IndiInfo,
@@ -380,7 +382,7 @@ export class App extends React.Component<RouteComponentProps, {}> {
return; return;
} }
const dataSource = DATA_SOURCES.get(args.source!); const dataSource = this.dataSources.get(args.source!);
if (!dataSource) { if (!dataSource) {
this.props.history.replace({pathname: '/'}); this.props.history.replace({pathname: '/'});
@@ -440,22 +442,42 @@ export class App extends React.Component<RouteComponentProps, {}> {
loadingMore: loadMoreFromWikitree || undefined, loadingMore: loadMoreFromWikitree || undefined,
}); });
if (loadMoreFromWikitree) { if (loadMoreFromWikitree) {
const data = await loadWikiTree(args.indi!); try {
this.setState( const data = await loadWikiTree(args.indi!, this.context.intl);
Object.assign({}, this.state, { const selection = getSelection(
data, data.chartData,
hash: args.hash, args.indi,
selection: getSelection(data.chartData, args.indi, args.generation), args.generation,
error: undefined, );
loading: false, this.setState(
url: args.url, Object.assign({}, this.state, {
showSidePanel: args.showSidePanel, data,
standalone: args.standalone, hash: args.hash,
chartType: args.chartType, selection,
source: args.source, error: undefined,
loadingMore: false, loading: false,
}), url: args.url,
); showSidePanel: args.showSidePanel,
standalone: args.standalone,
chartType: args.chartType,
source: args.source,
loadingMore: false,
}),
);
} catch (error) {
this.showErrorPopup(
this.context.intl.formatMessage(
{
id: 'error.failed_wikitree_load_more',
defaultMessage: 'Failed to load data from WikiTree. {error}',
},
{error},
),
{
loadingMore: false,
},
);
}
} }
} }
} }
@@ -465,6 +487,10 @@ export class App extends React.Component<RouteComponentProps, {}> {
* Updates the browser URL. * Updates the browser URL.
*/ */
private onSelection = (selection: IndiInfo) => { private onSelection = (selection: IndiInfo) => {
// Don't allow selecting WikiTree private profiles.
if (selection.id.startsWith(PRIVATE_ID_PREFIX)) {
return;
}
analyticsEvent('selection_changed'); analyticsEvent('selection_changed');
if (this.state.embedded) { if (this.state.embedded) {
// In embedded mode the URL doesn't change. // In embedded mode the URL doesn't change.
@@ -484,12 +510,17 @@ export class App extends React.Component<RouteComponentProps, {}> {
this.chartRef && this.chartRef.print(); this.chartRef && this.chartRef.print();
}; };
private showErrorPopup(message: string) { private showErrorPopup(message: string, otherStateChanges?: Partial<State>) {
this.setState( this.setState(
Object.assign({}, this.state, { Object.assign(
showErrorPopup: true, {},
error: message, this.state,
}), {
showErrorPopup: true,
error: message,
},
otherStateChanges,
),
); );
} }

View File

@@ -60,5 +60,7 @@
"error.error": "Błąd", "error.error": "Błąd",
"error.failed_pdf": "Nie udało się utworzyć pliku PDF. Spróbuj jeszcze raz z mniejszym diagramem lub pobierz plik SVG.", "error.failed_pdf": "Nie udało się utworzyć pliku PDF. Spróbuj jeszcze raz z mniejszym diagramem lub pobierz plik SVG.",
"error.failed_png": "Nie udało się utworzyć pliku PNG. Spróbuj jeszcze raz z mniejszym diagramem lub pobierz plik SVG.", "error.failed_png": "Nie udało się utworzyć pliku PNG. Spróbuj jeszcze raz z mniejszym diagramem lub pobierz plik SVG.",
"error.failed_to_load_file": "Błąd wczytywania pliku" "error.failed_to_load_file": "Błąd wczytywania pliku",
"error.failed_wikitree_load_more": "Błąd podczas pobierania danych z WikiTree. {error}",
"wikitree.private": "Prywatne"
} }

View File

@@ -2,6 +2,10 @@ import Cookies from 'js-cookie';
import {Date, JsonFam, JsonIndi, DateOrRange} from 'topola'; import {Date, JsonFam, JsonIndi, DateOrRange} from 'topola';
import {GedcomData, TopolaData, normalizeGedcom} from './gedcom_util'; import {GedcomData, TopolaData, normalizeGedcom} from './gedcom_util';
import {GedcomEntry} from 'parse-gedcom'; import {GedcomEntry} from 'parse-gedcom';
import {InjectedIntl} from 'react-intl';
/** Prefix for IDs of private individuals. */
export const PRIVATE_ID_PREFIX = '~Private';
/** /**
* Cookie where the logged in user name is stored. This cookie is shared * Cookie where the logged in user name is stored. This cookie is shared
@@ -109,7 +113,10 @@ async function wikiTreeGet(request: WikiTreeRequest, handleCors: boolean) {
* Retrieves ancestors from WikiTree for the given person ID. * Retrieves ancestors from WikiTree for the given person ID.
* Uses sessionStorage for caching responses. * Uses sessionStorage for caching responses.
*/ */
async function getAncestors(key: string, handleCors: boolean) { async function getAncestors(
key: string,
handleCors: boolean,
): Promise<Person[]> {
const cacheKey = `wikitree:ancestors:${key}`; const cacheKey = `wikitree:ancestors:${key}`;
const cachedData = getSessionStorageItem(cacheKey); const cachedData = getSessionStorageItem(cacheKey);
if (cachedData) { if (cachedData) {
@@ -132,7 +139,10 @@ async function getAncestors(key: string, handleCors: boolean) {
* Retrieves relatives from WikiTree for the given array of person IDs. * Retrieves relatives from WikiTree for the given array of person IDs.
* Uses sessionStorage for caching responses. * Uses sessionStorage for caching responses.
*/ */
async function getRelatives(keys: string[], handleCors: boolean) { async function getRelatives(
keys: string[],
handleCors: boolean,
): Promise<Person[]> {
const result: Person[] = []; const result: Person[] = [];
const keysToFetch: string[] = []; const keysToFetch: string[] = [];
keys.forEach((key) => { keys.forEach((key) => {
@@ -201,6 +211,7 @@ export function getLoggedInUserName(): string | undefined {
*/ */
export async function loadWikiTree( export async function loadWikiTree(
key: string, key: string,
intl: InjectedIntl,
authcode?: string, authcode?: string,
): Promise<TopolaData> { ): Promise<TopolaData> {
// Work around CORS if not in apps.wikitree.com domain. // Work around CORS if not in apps.wikitree.com domain.
@@ -235,8 +246,51 @@ export async function loadWikiTree(
.map((person) => person.Name) .map((person) => person.Name)
.filter((key) => !!key); .filter((key) => !!key);
const ancestorDetails = await getRelatives(ancestorKeys, handleCors); const ancestorDetails = await getRelatives(ancestorKeys, handleCors);
// Map from person id to father id if the father profile is private.
const privateFathers: Map<number, number> = new Map();
// Map from person id to mother id if the mother profile is private.
const privateMothers: Map<number, number> = new Map();
// Andujst private individual ids so that there are no collisions in the case
// that ancestors were collected for more than one person.
ancestors.forEach((ancestorList, index) => {
const offset = 1000 * index;
// Adjust ids by offset.
ancestorList.forEach((person) => {
if (person.Id < 0) {
person.Id -= offset;
person.Name = `${PRIVATE_ID_PREFIX}${person.Id}`;
}
if (person.Father < 0) {
person.Father -= offset;
privateFathers.set(person.Id, person.Father);
}
if (person.Mother < 0) {
person.Mother -= offset;
privateMothers.set(person.Id, person.Mother);
}
});
});
// Set the Father and Mother fields again because getRelatives doesn't return
// private parents.
ancestorDetails.forEach((person) => {
const privateFather = privateFathers.get(person.Id);
if (privateFather) {
person.Father = privateFather;
}
const privateMother = privateMothers.get(person.Id);
if (privateMother) {
person.Mother = privateMother;
}
});
everyone.push(...ancestorDetails); everyone.push(...ancestorDetails);
// Collect private individuals.
const privateAncestors = ancestors.flat().filter((person) => person.Id < 0);
everyone.push(...privateAncestors);
// Limit the number of generations of descendants because there may be tens of // Limit the number of generations of descendants because there may be tens of
// generations for some profiles. // generations for some profiles.
const descendantGenerationLimit = 5; const descendantGenerationLimit = 5;
@@ -291,7 +345,7 @@ export async function loadWikiTree(
return; return;
} }
converted.add(person.Id); converted.add(person.Id);
const indi = convertPerson(person); const indi = convertPerson(person, intl);
if (person.Spouses) { if (person.Spouses) {
Object.values(person.Spouses).forEach((spouse) => { Object.values(person.Spouses).forEach((spouse) => {
const famId = getFamilyId(person.Id, spouse.Id); const famId = getFamilyId(person.Id, spouse.Id);
@@ -350,11 +404,18 @@ function getFamilyId(spouse1: number, spouse2: number) {
return `${spouse2}_${spouse1}`; return `${spouse2}_${spouse1}`;
} }
function convertPerson(person: Person): JsonIndi { function convertPerson(person: Person, intl: InjectedIntl): JsonIndi {
const indi: JsonIndi = { const indi: JsonIndi = {
id: person.Name, id: person.Name,
}; };
if (person.FirstName !== 'Unknown') { if (person.Name.startsWith(PRIVATE_ID_PREFIX)) {
indi.hideId = true;
indi.firstName = intl.formatMessage({
id: 'wikitree.private',
defaultMessage: 'Private',
});
}
if (person.FirstName && person.FirstName !== 'Unknown') {
indi.firstName = person.FirstName; indi.firstName = person.FirstName;
} }
if (person.LastNameAtBirth !== 'Unknown') { if (person.LastNameAtBirth !== 'Unknown') {
@@ -450,15 +511,17 @@ function buildGedcom(indis: JsonIndi[]): GedcomData {
data: `${indi.firstName || ''} /${indi.lastName || ''}/`, data: `${indi.firstName || ''} /${indi.lastName || ''}/`,
tree: [], tree: [],
}, },
{
level: 1,
pointer: '',
tag: 'WWW',
data: `https://www.wikitree.com/wiki/${escapedId}`,
tree: [],
},
], ],
}; };
if (!indi.id.startsWith('~')) {
gedcomIndis[indi.id].tree.push({
level: 1,
pointer: '',
tag: 'WWW',
data: `https://www.wikitree.com/wiki/${escapedId}`,
tree: [],
});
}
}); });
return { return {