mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-05-26 15:16:14 +00:00
Improve code quality
Fix lint warnings
This commit is contained in:
11
.eslintrc.js
11
.eslintrc.js
@@ -25,5 +25,14 @@ module.exports = {
|
|||||||
'plugin:react/recommended',
|
'plugin:react/recommended',
|
||||||
'plugin:react/jsx-runtime',
|
'plugin:react/jsx-runtime',
|
||||||
],
|
],
|
||||||
rules: {},
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
33
src/app.tsx
33
src/app.tsx
@@ -25,6 +25,7 @@ import {
|
|||||||
GedcomUrlDataSource,
|
GedcomUrlDataSource,
|
||||||
getSelection,
|
getSelection,
|
||||||
UploadedDataSource,
|
UploadedDataSource,
|
||||||
|
UploadLocationState,
|
||||||
UploadSourceSpec,
|
UploadSourceSpec,
|
||||||
UrlSourceSpec,
|
UrlSourceSpec,
|
||||||
} from './datasource/load_data';
|
} from './datasource/load_data';
|
||||||
@@ -142,7 +143,7 @@ interface Arguments {
|
|||||||
|
|
||||||
function getParamFromSearch(
|
function getParamFromSearch(
|
||||||
name: string,
|
name: string,
|
||||||
search: queryString.ParsedQuery<string>,
|
search: queryString.ParsedQuery,
|
||||||
) {
|
) {
|
||||||
const value = search[name];
|
const value = search[name];
|
||||||
return typeof value === 'string' ? value : undefined;
|
return typeof value === 'string' ? value : undefined;
|
||||||
@@ -152,7 +153,7 @@ function getParamFromSearch(
|
|||||||
* Retrieve arguments passed into the application through the URL and uploaded
|
* Retrieve arguments passed into the application through the URL and uploaded
|
||||||
* data.
|
* data.
|
||||||
*/
|
*/
|
||||||
function getArguments(location: H.Location<any>): Arguments {
|
function getArguments(location: H.Location<UploadLocationState>): Arguments {
|
||||||
const search = queryString.parse(location.search);
|
const search = queryString.parse(location.search);
|
||||||
const getParam = (name: string) => getParamFromSearch(name, search);
|
const getParam = (name: string) => getParamFromSearch(name, search);
|
||||||
|
|
||||||
@@ -273,7 +274,7 @@ export function App() {
|
|||||||
if (
|
if (
|
||||||
!selection ||
|
!selection ||
|
||||||
selection.id !== newSelection.id ||
|
selection.id !== newSelection.id ||
|
||||||
selection!.generation !== newSelection.generation
|
selection.generation !== newSelection.generation
|
||||||
) {
|
) {
|
||||||
setSelection(newSelection);
|
setSelection(newSelection);
|
||||||
setDetailIndi(newSelection.id);
|
setDetailIndi(newSelection.id);
|
||||||
@@ -413,8 +414,8 @@ export function App() {
|
|||||||
updateChartWithConfig(args.config, data);
|
updateChartWithConfig(args.config, data);
|
||||||
setShowSidePanel(args.showSidePanel);
|
setShowSidePanel(args.showSidePanel);
|
||||||
setState(AppState.SHOWING_CHART);
|
setState(AppState.SHOWING_CHART);
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
setErrorMessage(getI18nMessage(error, intl));
|
setErrorMessage(getI18nMessage(error as Error, intl));
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
state === AppState.SHOWING_CHART ||
|
state === AppState.SHOWING_CHART ||
|
||||||
@@ -428,9 +429,11 @@ export function App() {
|
|||||||
setState(
|
setState(
|
||||||
loadMoreFromWikitree ? AppState.LOADING_MORE : AppState.SHOWING_CHART,
|
loadMoreFromWikitree ? AppState.LOADING_MORE : AppState.SHOWING_CHART,
|
||||||
);
|
);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
updateDisplay(getSelection(data!.chartData, args.selection));
|
updateDisplay(getSelection(data!.chartData, args.selection));
|
||||||
if (loadMoreFromWikitree) {
|
if (loadMoreFromWikitree) {
|
||||||
try {
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const data = await loadWikiTree(args.selection!.id, intl);
|
const data = await loadWikiTree(args.selection!.id, intl);
|
||||||
const newSelection = getSelection(data.chartData, args.selection);
|
const newSelection = getSelection(data.chartData, args.selection);
|
||||||
setData(data);
|
setData(data);
|
||||||
@@ -475,7 +478,7 @@ export function App() {
|
|||||||
});
|
});
|
||||||
}, [mcpBridge, location]);
|
}, [mcpBridge, location]);
|
||||||
|
|
||||||
function updateUrl(args: queryString.ParsedQuery<any>) {
|
function updateUrl(args: queryString.ParsedQuery<string>) {
|
||||||
const search = queryString.parse(location.search);
|
const search = queryString.parse(location.search);
|
||||||
for (const key in args) {
|
for (const key in args) {
|
||||||
search[key] = args[key];
|
search[key] = args[key];
|
||||||
@@ -496,7 +499,7 @@ export function App() {
|
|||||||
analyticsEvent('selection_changed');
|
analyticsEvent('selection_changed');
|
||||||
updateUrl({
|
updateUrl({
|
||||||
indi: selection.id,
|
indi: selection.id,
|
||||||
gen: selection.generation,
|
gen: String(selection.generation),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@@ -559,10 +562,13 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderChart(selection: IndiInfo) {
|
function renderChart(selection: IndiInfo) {
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (chartType === ChartType.Donatso) {
|
if (chartType === ChartType.Donatso) {
|
||||||
return (
|
return (
|
||||||
<DonatsoChart
|
<DonatsoChart
|
||||||
data={data!.chartData}
|
data={data.chartData}
|
||||||
selection={selection}
|
selection={selection}
|
||||||
onSelection={onSelection}
|
onSelection={onSelection}
|
||||||
/>
|
/>
|
||||||
@@ -570,7 +576,7 @@ export function App() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Chart
|
<Chart
|
||||||
data={data!.chartData}
|
data={data.chartData}
|
||||||
selection={selection}
|
selection={selection}
|
||||||
chartType={chartType}
|
chartType={chartType}
|
||||||
onSelection={onSelection}
|
onSelection={onSelection}
|
||||||
@@ -587,7 +593,10 @@ export function App() {
|
|||||||
switch (state) {
|
switch (state) {
|
||||||
case AppState.SHOWING_CHART:
|
case AppState.SHOWING_CHART:
|
||||||
case AppState.LOADING_MORE: {
|
case AppState.LOADING_MORE: {
|
||||||
const updatedSelection = getSelection(data!.chartData, selection);
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const updatedSelection = getSelection(data.chartData, selection);
|
||||||
return (
|
return (
|
||||||
<div id="content">
|
<div id="content">
|
||||||
<ErrorPopup
|
<ErrorPopup
|
||||||
@@ -600,7 +609,7 @@ export function App() {
|
|||||||
) : null}
|
) : null}
|
||||||
<SidebarPushable>
|
<SidebarPushable>
|
||||||
<SidePanel
|
<SidePanel
|
||||||
data={data!}
|
data={data}
|
||||||
selectedIndiId={detailIndi || updatedSelection.id}
|
selectedIndiId={detailIndi || updatedSelection.id}
|
||||||
config={config}
|
config={config}
|
||||||
expanded={showSidePanel}
|
expanded={showSidePanel}
|
||||||
@@ -618,7 +627,7 @@ export function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case AppState.ERROR:
|
case AppState.ERROR:
|
||||||
return <ErrorMessage message={error!} />;
|
return <ErrorMessage message={error || 'Unknown error'} />;
|
||||||
|
|
||||||
case AppState.INITIAL:
|
case AppState.INITIAL:
|
||||||
case AppState.LOADING:
|
case AppState.LOADING:
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ export async function getChangelog(maxVersions: number, seenVersion?: string) {
|
|||||||
|
|
||||||
/** Stores in local storage the current app version as the last seen version. */
|
/** Stores in local storage the current app version as the last seen version. */
|
||||||
export function updateSeenVersion() {
|
export function updateSeenVersion() {
|
||||||
localStorage.setItem(LAST_SEEN_VERSION_KEY, import.meta.env.VITE_GIT_TIME!);
|
if (import.meta.env.VITE_GIT_TIME) {
|
||||||
|
localStorage.setItem(LAST_SEEN_VERSION_KEY, import.meta.env.VITE_GIT_TIME);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,8 +58,8 @@ export function Changelog() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const seenVersion = localStorage.getItem(LAST_SEEN_VERSION_KEY);
|
const seenVersion = localStorage.getItem(LAST_SEEN_VERSION_KEY);
|
||||||
const currentVersion = import.meta.env.VITE_GIT_TIME!;
|
const currentVersion = import.meta.env.VITE_GIT_TIME;
|
||||||
if (!seenVersion || seenVersion === currentVersion) {
|
if (!seenVersion || !currentVersion || seenVersion === currentVersion) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {max, min} from 'd3-array';
|
import {max, min} from 'd3-array';
|
||||||
import {interpolateNumber} from 'd3-interpolate';
|
import {interpolateNumber} from 'd3-interpolate';
|
||||||
import {select, Selection} from 'd3-selection';
|
import {BaseType, select, Selection} from 'd3-selection';
|
||||||
import 'd3-transition';
|
import 'd3-transition';
|
||||||
import {
|
import {
|
||||||
D3ZoomEvent,
|
D3ZoomEvent,
|
||||||
@@ -69,7 +69,7 @@ function scrolled() {
|
|||||||
function loadAsDataUrl(blob: Blob): Promise<string> {
|
function loadAsDataUrl(blob: Blob): Promise<string> {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.readAsDataURL(blob);
|
reader.readAsDataURL(blob);
|
||||||
return new Promise<string>((resolve, reject) => {
|
return new Promise<string>((resolve, _reject) => {
|
||||||
reader.onload = (e) => resolve((e.target as FileReader).result as string);
|
reader.onload = (e) => resolve((e.target as FileReader).result as string);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -103,7 +103,7 @@ async function inlineImages(svg: Element): Promise<void> {
|
|||||||
function loadImage(blob: Blob): Promise<HTMLImageElement> {
|
function loadImage(blob: Blob): Promise<HTMLImageElement> {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
image.src = URL.createObjectURL(blob);
|
image.src = URL.createObjectURL(blob);
|
||||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
return new Promise<HTMLImageElement>((resolve, _reject) => {
|
||||||
image.addEventListener('load', () => resolve(image));
|
image.addEventListener('load', () => resolve(image));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -115,6 +115,7 @@ function drawImageOnCanvas(image: HTMLImageElement) {
|
|||||||
canvas.width = image.width * 2;
|
canvas.width = image.width * 2;
|
||||||
canvas.height = image.height * 2;
|
canvas.height = image.height * 2;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const ctx = canvas.getContext('2d')!;
|
const ctx = canvas.getContext('2d')!;
|
||||||
const oldFill = ctx.fillStyle;
|
const oldFill = ctx.fillStyle;
|
||||||
ctx.fillStyle = 'white';
|
ctx.fillStyle = 'white';
|
||||||
@@ -139,6 +140,7 @@ function canvasToBlob(canvas: HTMLCanvasElement, type: string) {
|
|||||||
|
|
||||||
/** Return a copy of the SVG chart but without scaling and positioning. */
|
/** Return a copy of the SVG chart but without scaling and positioning. */
|
||||||
function getStrippedSvg() {
|
function getStrippedSvg() {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const svg = document.getElementById('chartSvg')!.cloneNode(true) as Element;
|
const svg = document.getElementById('chartSvg')!.cloneNode(true) as Element;
|
||||||
|
|
||||||
svg.removeAttribute('transform');
|
svg.removeAttribute('transform');
|
||||||
@@ -149,12 +151,14 @@ function getStrippedSvg() {
|
|||||||
'height',
|
'height',
|
||||||
String(Number(svg.getAttribute('height')) / scale),
|
String(Number(svg.getAttribute('height')) / scale),
|
||||||
);
|
);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
svg.querySelector('#chart')!.removeAttribute('transform');
|
svg.querySelector('#chart')!.removeAttribute('transform');
|
||||||
|
|
||||||
return svg;
|
return svg;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSvgDimensions() {
|
function getSvgDimensions() {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const svg = document.getElementById('chartSvg')!;
|
const svg = document.getElementById('chartSvg')!;
|
||||||
return {
|
return {
|
||||||
width: Number(svg.getAttribute('width')),
|
width: Number(svg.getAttribute('width')),
|
||||||
@@ -191,14 +195,14 @@ export function printChart() {
|
|||||||
printWindow.style.top = '-1000px';
|
printWindow.style.top = '-1000px';
|
||||||
printWindow.style.left = '-1000px';
|
printWindow.style.left = '-1000px';
|
||||||
printWindow.onload = () => {
|
printWindow.onload = () => {
|
||||||
printWindow.contentDocument!.open();
|
printWindow.contentDocument?.open();
|
||||||
printWindow.contentDocument!.write(getSvgContents());
|
printWindow.contentDocument?.write(getSvgContents());
|
||||||
printWindow.contentDocument!.close();
|
printWindow.contentDocument?.close();
|
||||||
// Doesn't work on Firefox without the setTimeout.
|
// Doesn't work on Firefox without the setTimeout.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
printWindow.contentWindow!.focus();
|
printWindow.contentWindow?.focus();
|
||||||
printWindow.contentWindow!.print();
|
printWindow.contentWindow?.print();
|
||||||
printWindow.parentNode!.removeChild(printWindow);
|
printWindow.parentNode?.removeChild(printWindow);
|
||||||
}, 500);
|
}, 500);
|
||||||
};
|
};
|
||||||
document.body.appendChild(printWindow);
|
document.body.appendChild(printWindow);
|
||||||
@@ -299,6 +303,7 @@ function calculateScaleExtent(
|
|||||||
): [number, number] {
|
): [number, number] {
|
||||||
const [availWidth, availHeight] = getScrollbarAwareSize(parent);
|
const [availWidth, availHeight] = getScrollbarAwareSize(parent);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const zoomOutFactor = min([
|
const zoomOutFactor = min([
|
||||||
1,
|
1,
|
||||||
scale,
|
scale,
|
||||||
@@ -306,6 +311,7 @@ function calculateScaleExtent(
|
|||||||
availHeight / chartInfo.size[1],
|
availHeight / chartInfo.size[1],
|
||||||
])!;
|
])!;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
return [max([0.1, zoomOutFactor])!, 2];
|
return [max([0.1, zoomOutFactor])!, 2];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,14 +334,19 @@ class ChartWrapper {
|
|||||||
/** Rendering is required after the current animation finishes. */
|
/** Rendering is required after the current animation finishes. */
|
||||||
private rerenderRequired = false;
|
private rerenderRequired = false;
|
||||||
/** The d3 zoom behavior object. */
|
/** The d3 zoom behavior object. */
|
||||||
private zoomBehavior?: ZoomBehavior<Element, any>;
|
private zoomBehavior?: ZoomBehavior<Element, unknown>;
|
||||||
/** Props that will be used for rerendering. */
|
/** Props that will be used for rerendering. */
|
||||||
private rerenderProps?: ChartProps;
|
private rerenderProps?: ChartProps;
|
||||||
private rerenderResetPosition?: boolean;
|
private rerenderResetPosition?: boolean;
|
||||||
|
|
||||||
zoom(factor: number) {
|
zoom(factor: number) {
|
||||||
const parent = select('#svgContainer') as Selection<Element, any, any, any>;
|
const parent = select('#svgContainer') as Selection<
|
||||||
this.zoomBehavior!.scaleBy(parent, factor);
|
Element,
|
||||||
|
unknown,
|
||||||
|
BaseType,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
this.zoomBehavior?.scaleBy(parent, factor);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -364,7 +375,7 @@ class ChartWrapper {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.initialRender) {
|
if (args.initialRender || !this.chart) {
|
||||||
(select('#chart').node() as HTMLElement).innerHTML = '';
|
(select('#chart').node() as HTMLElement).innerHTML = '';
|
||||||
this.chart = createChart({
|
this.chart = createChart({
|
||||||
json: props.data,
|
json: props.data,
|
||||||
@@ -382,15 +393,15 @@ class ChartWrapper {
|
|||||||
props.onSelection(info);
|
props.onSelection(info);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors: chartColors.get(props.colors!),
|
colors: (props.colors && chartColors.get(props.colors)) || undefined,
|
||||||
animate: true,
|
animate: true,
|
||||||
updateSvgSize: false,
|
updateSvgSize: false,
|
||||||
locale: intl.locale,
|
locale: intl.locale,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.chart!.setData(props.data);
|
this.chart.setData(props.data);
|
||||||
}
|
}
|
||||||
const chartInfo = this.chart!.render({
|
const chartInfo = this.chart.render({
|
||||||
startIndi: props.selection.id,
|
startIndi: props.selection.id,
|
||||||
baseGeneration: props.selection.generation,
|
baseGeneration: props.selection.generation,
|
||||||
});
|
});
|
||||||
@@ -461,10 +472,16 @@ class ChartWrapper {
|
|||||||
this.rerenderRequired = false;
|
this.rerenderRequired = false;
|
||||||
// Use `this.rerenderProps` instead of the props in scope because
|
// Use `this.rerenderProps` instead of the props in scope because
|
||||||
// the props may have been updated in the meantime.
|
// the props may have been updated in the meantime.
|
||||||
this.renderChart(this.rerenderProps!, intl, {
|
if (this.rerenderProps) {
|
||||||
initialRender: false,
|
this.renderChart(this.rerenderProps, intl, {
|
||||||
resetPosition: !!this.rerenderResetPosition,
|
initialRender: false,
|
||||||
});
|
resetPosition: !!this.rerenderResetPosition,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
'Rerender required after animation, but rerenderProps was not set.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ export interface EmbeddedSourceSpec {
|
|||||||
/** GEDCOM file received from outside of the iframe. */
|
/** GEDCOM file received from outside of the iframe. */
|
||||||
export class EmbeddedDataSource implements DataSource<EmbeddedSourceSpec> {
|
export class EmbeddedDataSource implements DataSource<EmbeddedSourceSpec> {
|
||||||
isNewData(
|
isNewData(
|
||||||
newSource: SourceSelection<EmbeddedSourceSpec>,
|
_newSource: SourceSelection<EmbeddedSourceSpec>,
|
||||||
oldSource: SourceSelection<EmbeddedSourceSpec>,
|
_oldSource: SourceSelection<EmbeddedSourceSpec>,
|
||||||
data?: TopolaData,
|
_data?: TopolaData,
|
||||||
): boolean {
|
): boolean {
|
||||||
// Never reload data.
|
// Never reload data.
|
||||||
return false;
|
return false;
|
||||||
@@ -44,7 +44,7 @@ export class EmbeddedDataSource implements DataSource<EmbeddedSourceSpec> {
|
|||||||
private async onMessage(
|
private async onMessage(
|
||||||
message: EmbeddedMessage,
|
message: EmbeddedMessage,
|
||||||
resolve: (value: TopolaData) => void,
|
resolve: (value: TopolaData) => void,
|
||||||
reject: (reason: any) => void,
|
reject: (reason: unknown) => void,
|
||||||
) {
|
) {
|
||||||
if (message.message === EmbeddedMessageType.PARENT_READY) {
|
if (message.message === EmbeddedMessageType.PARENT_READY) {
|
||||||
// Parent didn't receive the first 'ready' message, so we need to send it again.
|
// Parent didn't receive the first 'ready' message, so we need to send it again.
|
||||||
@@ -69,7 +69,7 @@ export class EmbeddedDataSource implements DataSource<EmbeddedSourceSpec> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadData(
|
async loadData(
|
||||||
source: SourceSelection<EmbeddedSourceSpec>,
|
_source: SourceSelection<EmbeddedSourceSpec>,
|
||||||
): Promise<TopolaData> {
|
): Promise<TopolaData> {
|
||||||
// Notify the parent window that we are ready.
|
// Notify the parent window that we are ready.
|
||||||
return new Promise<TopolaData>((resolve, reject) => {
|
return new Promise<TopolaData>((resolve, reject) => {
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ const MONTHS = new Map<number, string>([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
function dateToGedcom(date: Date): string {
|
function dateToGedcom(date: Date): string {
|
||||||
return [date.qualifier, date.day, MONTHS.get(date.month!), date.year]
|
const month = (date.month && MONTHS.get(date.month)) || undefined;
|
||||||
|
return [date.qualifier, date.day, month, date.year]
|
||||||
.filter((x) => x !== undefined)
|
.filter((x) => x !== undefined)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,13 +151,18 @@ export interface UploadSourceSpec {
|
|||||||
images?: Map<string, string>;
|
images?: Map<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UploadLocationState {
|
||||||
|
data: string;
|
||||||
|
images: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
/** Files opened from the local computer. */
|
/** Files opened from the local computer. */
|
||||||
export class UploadedDataSource implements DataSource<UploadSourceSpec> {
|
export class UploadedDataSource implements DataSource<UploadSourceSpec> {
|
||||||
// isNewData(args: Arguments, state: State): boolean {
|
// isNewData(args: Arguments, state: State): boolean {
|
||||||
isNewData(
|
isNewData(
|
||||||
newSource: SourceSelection<UploadSourceSpec>,
|
newSource: SourceSelection<UploadSourceSpec>,
|
||||||
oldSource: SourceSelection<UploadSourceSpec>,
|
oldSource: SourceSelection<UploadSourceSpec>,
|
||||||
data?: TopolaData,
|
_data?: TopolaData,
|
||||||
): boolean {
|
): boolean {
|
||||||
return newSource.spec.hash !== oldSource.spec.hash;
|
return newSource.spec.hash !== oldSource.spec.hash;
|
||||||
}
|
}
|
||||||
@@ -196,7 +201,7 @@ export class GedcomUrlDataSource implements DataSource<UrlSourceSpec> {
|
|||||||
isNewData(
|
isNewData(
|
||||||
newSource: SourceSelection<UrlSourceSpec>,
|
newSource: SourceSelection<UrlSourceSpec>,
|
||||||
oldSource: SourceSelection<UrlSourceSpec>,
|
oldSource: SourceSelection<UrlSourceSpec>,
|
||||||
data?: TopolaData,
|
_data?: TopolaData,
|
||||||
): boolean {
|
): boolean {
|
||||||
return newSource.spec.url !== oldSource.spec.url;
|
return newSource.spec.url !== oldSource.spec.url;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export async function loadWikiTree(
|
|||||||
.filter((person) => person.PhotoData?.path)
|
.filter((person) => person.PhotoData?.path)
|
||||||
.map((person) => [
|
.map((person) => [
|
||||||
person.Name,
|
person.Name,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
`https://www.wikitree.com${person.PhotoData!.path}`,
|
`https://www.wikitree.com${person.PhotoData!.path}`,
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import {IntlShape} from 'react-intl';
|
import {IntlShape} from 'react-intl';
|
||||||
import {DateOrRange, JsonEvent, JsonFam, JsonImage, JsonIndi} from 'topola';
|
import {
|
||||||
|
Date,
|
||||||
|
DateOrRange,
|
||||||
|
JsonEvent,
|
||||||
|
JsonFam,
|
||||||
|
JsonImage,
|
||||||
|
JsonIndi,
|
||||||
|
} from 'topola';
|
||||||
import {StringUtils} from 'turbocommons-ts';
|
import {StringUtils} from 'turbocommons-ts';
|
||||||
import {Person} from 'wikitree-js';
|
import {Person} from 'wikitree-js';
|
||||||
import {PRIVATE_ID_PREFIX} from './wikitree_api';
|
import {PRIVATE_ID_PREFIX} from './wikitree_api';
|
||||||
@@ -274,7 +281,7 @@ function parseDate(date: string, dataStatus?: string): DateOrRange | undefined {
|
|||||||
if (!matchedDate) {
|
if (!matchedDate) {
|
||||||
return {date: {text: date}};
|
return {date: {text: date}};
|
||||||
}
|
}
|
||||||
const parsedDate: any = {};
|
const parsedDate: Date = {};
|
||||||
if (matchedDate[1] !== '0000') {
|
if (matchedDate[1] !== '0000') {
|
||||||
parsedDate.year = ~~matchedDate[1];
|
parsedDate.year = ~~matchedDate[1];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ function convertData(data: JsonGedcomData, intl: IntlShape) {
|
|||||||
const famMap = new Map<string, JsonFam>();
|
const famMap = new Map<string, JsonFam>();
|
||||||
data.fams.forEach((fam) => famMap.set(fam.id, fam));
|
data.fams.forEach((fam) => famMap.set(fam.id, fam));
|
||||||
return data.indis.map((indi) => {
|
return data.indis.map((indi) => {
|
||||||
const famc = famMap.get(indi.famc!);
|
const famc = (indi.famc && famMap.get(indi.famc)) || undefined;
|
||||||
const fams = (indi.fams || [])
|
const fams = (indi.fams || [])
|
||||||
.map((fam) => famMap.get(fam))
|
.map((fam) => famMap.get(fam))
|
||||||
.filter((fam): fam is JsonFam => fam !== undefined);
|
.filter((fam): fam is JsonFam => fam !== undefined);
|
||||||
|
|||||||
1
src/family-chart.d.ts
vendored
1
src/family-chart.d.ts
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
// Data type definitions for the family-chart library.
|
// Data type definitions for the family-chart library.
|
||||||
declare module 'family-chart' {
|
declare module 'family-chart' {
|
||||||
export function createStore(args: any): any;
|
export function createStore(args: any): any;
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ const language = navigator.language && navigator.language.split(/[-_]/)[0];
|
|||||||
|
|
||||||
const browser = detect();
|
const browser = detect();
|
||||||
|
|
||||||
const container = document.getElementById('root');
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const root = createRoot(container!);
|
const container = document.getElementById('root')!;
|
||||||
|
const root = createRoot(container);
|
||||||
|
|
||||||
if (browser && browser.name === 'ie') {
|
if (browser && browser.name === 'ie') {
|
||||||
root.render(
|
root.render(
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ function ViewLink(props: {params: {[key: string]: string}; text: string}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBuildDate(dateString: string) {
|
function formatBuildDate(dateString: string | undefined) {
|
||||||
return dateString?.slice(0, 16) || '';
|
return dateString?.slice(0, 16) || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ function Contents() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="ui right aligned version">
|
<p className="ui right aligned version">
|
||||||
version: {formatBuildDate(import.meta.env.VITE_GIT_TIME!)} (
|
version: {formatBuildDate(import.meta.env.VITE_GIT_TIME)} (
|
||||||
<a
|
<a
|
||||||
href={`https://github.com/PeWu/topola-viewer/commit/${import.meta.env.VITE_GIT_SHA}`}
|
href={`https://github.com/PeWu/topola-viewer/commit/${import.meta.env.VITE_GIT_SHA}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -53,11 +53,11 @@ export function SearchBar(props: Props) {
|
|||||||
|
|
||||||
/** On search input change. */
|
/** On search input change. */
|
||||||
function handleSearch(input: string | undefined) {
|
function handleSearch(input: string | undefined) {
|
||||||
if (!input) {
|
if (!input || !searchIndex.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const results = searchIndex
|
const results = searchIndex.current
|
||||||
.current!.search(input)
|
.search(input)
|
||||||
.map((result) => displaySearchResult(result));
|
.map((result) => displaySearchResult(result));
|
||||||
setSearchResults(results);
|
setSearchResults(results);
|
||||||
}
|
}
|
||||||
@@ -71,9 +71,9 @@ export function SearchBar(props: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** On search string changed. */
|
/** On search string changed. */
|
||||||
function onChange(value: string) {
|
function onChange(value: string | undefined) {
|
||||||
debouncedHandleSearch.current(value);
|
debouncedHandleSearch.current(value);
|
||||||
setSearchString(value);
|
setSearchString(value || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the search index.
|
// Initialize the search index.
|
||||||
@@ -83,7 +83,7 @@ export function SearchBar(props: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Search
|
<Search
|
||||||
onSearchChange={(_, data) => onChange(data.value!)}
|
onSearchChange={(_, data) => onChange(data.value)}
|
||||||
onResultSelect={(_, data) => handleResultSelect(data.result.id)}
|
onResultSelect={(_, data) => handleResultSelect(data.result.id)}
|
||||||
results={searchResults}
|
results={searchResults}
|
||||||
noResultsMessage={intl.formatMessage({
|
noResultsMessage={intl.formatMessage({
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function getHusbandLastName(
|
|||||||
}
|
}
|
||||||
|
|
||||||
class LunrSearchIndex implements SearchIndex {
|
class LunrSearchIndex implements SearchIndex {
|
||||||
private index: lunr.Index | undefined;
|
private index!: lunr.Index;
|
||||||
private indiMap: Map<string, JsonIndi>;
|
private indiMap: Map<string, JsonIndi>;
|
||||||
private famMap: Map<string, JsonFam>;
|
private famMap: Map<string, JsonFam>;
|
||||||
|
|
||||||
@@ -107,7 +107,6 @@ class LunrSearchIndex implements SearchIndex {
|
|||||||
lunrInstance: any,
|
lunrInstance: any,
|
||||||
languages: string[],
|
languages: string[],
|
||||||
): void {
|
): void {
|
||||||
let wordCharacters = '';
|
|
||||||
const pipelineFunctions: PipelineFunction[] = [];
|
const pipelineFunctions: PipelineFunction[] = [];
|
||||||
const searchPipelineFunctions: PipelineFunction[] = [];
|
const searchPipelineFunctions: PipelineFunction[] = [];
|
||||||
languages.forEach((language) => {
|
languages.forEach((language) => {
|
||||||
@@ -115,12 +114,10 @@ class LunrSearchIndex implements SearchIndex {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const lunrLanguage = lunr[language];
|
const lunrLanguage = lunr[language];
|
||||||
if (language === 'en') {
|
if (language === 'en') {
|
||||||
wordCharacters += '\\w';
|
|
||||||
pipelineFunctions.unshift(lunr.stopWordFilter);
|
pipelineFunctions.unshift(lunr.stopWordFilter);
|
||||||
pipelineFunctions.push(lunr.stemmer);
|
pipelineFunctions.push(lunr.stemmer);
|
||||||
searchPipelineFunctions.push(lunr.stemmer);
|
searchPipelineFunctions.push(lunr.stemmer);
|
||||||
} else {
|
} else {
|
||||||
wordCharacters += lunrLanguage.wordCharacters;
|
|
||||||
if (lunrLanguage.stopWordFilter) {
|
if (lunrLanguage.stopWordFilter) {
|
||||||
pipelineFunctions.unshift(lunrLanguage.stopWordFilter);
|
pipelineFunctions.unshift(lunrLanguage.stopWordFilter);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function TopBar(props: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function chartMenus(screenSize: ScreenSize) {
|
function chartMenus(screenSize: ScreenSize) {
|
||||||
if (!props.showingChart) {
|
if (!props.showingChart || !props.data) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const chartTypeItems = (
|
const chartTypeItems = (
|
||||||
@@ -147,7 +147,7 @@ export function TopBar(props: Props) {
|
|||||||
<Dropdown.Menu>{chartTypeItems}</Dropdown.Menu>
|
<Dropdown.Menu>{chartTypeItems}</Dropdown.Menu>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<SearchBar
|
<SearchBar
|
||||||
data={props.data!}
|
data={props.data}
|
||||||
onSelection={props.eventHandlers.onSelection}
|
onSelection={props.eventHandlers.onSelection}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -312,9 +312,9 @@ export function TopBar(props: Props) {
|
|||||||
<div className="topbar--title">
|
<div className="topbar--title">
|
||||||
{props.standalone ? <Link to="/">{title()}</Link> : title()}
|
{props.standalone ? <Link to="/">{title()}</Link> : title()}
|
||||||
</div>
|
</div>
|
||||||
{props.showingChart && (
|
{props.showingChart && props.data && (
|
||||||
<SearchBar
|
<SearchBar
|
||||||
data={props.data!}
|
data={props.data}
|
||||||
onSelection={props.eventHandlers.onSelection}
|
onSelection={props.eventHandlers.onSelection}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function UrlMenu(props: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dialogOpen) {
|
if (dialogOpen) {
|
||||||
setUrl('');
|
setUrl('');
|
||||||
inputRef.current!.focus();
|
inputRef.current?.focus();
|
||||||
}
|
}
|
||||||
}, [dialogOpen]);
|
}, [dialogOpen]);
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function WikiTreeMenu(props: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dialogOpen) {
|
if (dialogOpen) {
|
||||||
setWikiTreeId('');
|
setWikiTreeId('');
|
||||||
inputRef.current!.focus();
|
inputRef.current?.focus();
|
||||||
}
|
}
|
||||||
}, [dialogOpen]);
|
}, [dialogOpen]);
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ export function WikiTreeMenu(props: Props) {
|
|||||||
function enterId(event: React.MouseEvent, id: string) {
|
function enterId(event: React.MouseEvent, id: string) {
|
||||||
event.preventDefault(); // Do not follow link in href.
|
event.preventDefault(); // Do not follow link in href.
|
||||||
setWikiTreeId(id);
|
setWikiTreeId(id);
|
||||||
inputRef.current!.focus();
|
inputRef.current?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function wikiTreeIdModal() {
|
function wikiTreeIdModal() {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const SEX_ARG = new Map<string, Sex>([
|
|||||||
const SEX_ARG_INVERSE = new Map<Sex, string>();
|
const SEX_ARG_INVERSE = new Map<Sex, string>();
|
||||||
SEX_ARG.forEach((v, k) => SEX_ARG_INVERSE.set(v, k));
|
SEX_ARG.forEach((v, k) => SEX_ARG_INVERSE.set(v, k));
|
||||||
|
|
||||||
export function argsToConfig(args: ParsedQuery<any>): Config {
|
export function argsToConfig(args: ParsedQuery<unknown>): Config {
|
||||||
const getParam = (name: string) => {
|
const getParam = (name: string) => {
|
||||||
const value = args[name];
|
const value = args[name];
|
||||||
return typeof value === 'string' ? value : undefined;
|
return typeof value === 'string' ? value : undefined;
|
||||||
@@ -67,12 +67,21 @@ export function argsToConfig(args: ParsedQuery<any>): Config {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function configToArgs(config: Config): ParsedQuery<any> {
|
export function configToArgs(config: Config): ParsedQuery {
|
||||||
return {
|
const result: ParsedQuery = {};
|
||||||
c: COLOR_ARG_INVERSE.get(config.color),
|
const color = COLOR_ARG_INVERSE.get(config.color);
|
||||||
i: ID_ARG_INVERSE.get(config.id),
|
if (color){
|
||||||
s: SEX_ARG_INVERSE.get(config.sex),
|
result.c = color;
|
||||||
};
|
}
|
||||||
|
const id = ID_ARG_INVERSE.get(config.id);
|
||||||
|
if (id) {
|
||||||
|
result.i = id;
|
||||||
|
}
|
||||||
|
const sex = SEX_ARG_INVERSE.get(config.sex);
|
||||||
|
if (sex) {
|
||||||
|
result.s = sex;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfigPanel(props: {
|
export function ConfigPanel(props: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/** Sends an event to Google Analytics. */
|
/** Sends an event to Google Analytics. */
|
||||||
export function analyticsEvent(action: string, data?: any) {
|
export function analyticsEvent(action: string, data?: unknown) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(window as any).gtag('event', action, data);
|
(window as any).gtag('event', action, data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** No-op function for analytics. */
|
/** No-op function for analytics. */
|
||||||
export function analyticsEvent(action: string, data?: any) {
|
export function analyticsEvent(_action: string, _data?: unknown) {
|
||||||
// no-op
|
// no-op
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,9 +180,5 @@ export function isDateRangeClosed(range: DateRange | undefined): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toDateObject(date: TopolaDate): Date {
|
export function toDateObject(date: TopolaDate): Date {
|
||||||
return new Date(
|
return new Date(date.year ?? 0, (date.month ?? 1) - 1, date.day ?? 1);
|
||||||
date.year !== undefined ? date.year! : 0,
|
|
||||||
date.month !== undefined ? date.month! - 1 : 0,
|
|
||||||
date.day !== undefined ? date.day! : 1,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import {describe, expect, it} from '@jest/globals';
|
import {describe, expect, it} from '@jest/globals';
|
||||||
import {getName, normalizeGedcom} from './gedcom_util';
|
import {JsonGedcomData} from 'topola';
|
||||||
|
import {
|
||||||
|
findRelationshipPath,
|
||||||
|
getAncestors,
|
||||||
|
getDescendants,
|
||||||
|
getName,
|
||||||
|
idToFamMap,
|
||||||
|
idToIndiMap,
|
||||||
|
normalizeGedcom,
|
||||||
|
} from './gedcom_util';
|
||||||
|
|
||||||
describe('normalizeGedcom()', () => {
|
describe('normalizeGedcom()', () => {
|
||||||
it('sorts children', () => {
|
it('sorts children', () => {
|
||||||
@@ -62,7 +71,7 @@ describe('normalizeGedcom()', () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
const normalized = normalizeGedcom(data);
|
const normalized = normalizeGedcom(data);
|
||||||
expect(normalized.indis.find((i) => i.id === 'I4')!.fams).toEqual([
|
expect(normalized.indis.find((i) => i.id === 'I4')?.fams).toEqual([
|
||||||
'F3',
|
'F3',
|
||||||
'F2',
|
'F2',
|
||||||
'F1',
|
'F1',
|
||||||
@@ -140,16 +149,8 @@ describe('getName()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
import {
|
|
||||||
findRelationshipPath,
|
|
||||||
getAncestors,
|
|
||||||
getDescendants,
|
|
||||||
idToFamMap,
|
|
||||||
idToIndiMap,
|
|
||||||
} from './gedcom_util';
|
|
||||||
|
|
||||||
describe('Relationship algorithms', () => {
|
describe('Relationship algorithms', () => {
|
||||||
const sampleData = {
|
const sampleData: JsonGedcomData = {
|
||||||
indis: [
|
indis: [
|
||||||
{id: 'I1', fams: ['F1']},
|
{id: 'I1', fams: ['F1']},
|
||||||
{id: 'I2', fams: ['F1'], famc: 'F2'},
|
{id: 'I2', fams: ['F1'], famc: 'F2'},
|
||||||
@@ -160,7 +161,7 @@ describe('Relationship algorithms', () => {
|
|||||||
{id: 'F1', husb: 'I1', wife: 'I2', children: ['I3']},
|
{id: 'F1', husb: 'I1', wife: 'I2', children: ['I3']},
|
||||||
{id: 'F2', children: ['I2', 'I4']},
|
{id: 'F2', children: ['I2', 'I4']},
|
||||||
],
|
],
|
||||||
} as any;
|
};
|
||||||
|
|
||||||
const indiMap = idToIndiMap(sampleData);
|
const indiMap = idToIndiMap(sampleData);
|
||||||
const famMap = idToFamMap(sampleData);
|
const famMap = idToFamMap(sampleData);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {TopolaError} from './error';
|
|||||||
|
|
||||||
export interface GedcomData {
|
export interface GedcomData {
|
||||||
/** The HEAD entry. */
|
/** The HEAD entry. */
|
||||||
head: GedcomEntry;
|
head?: GedcomEntry;
|
||||||
/** INDI entries mapped by id. */
|
/** INDI entries mapped by id. */
|
||||||
indis: {[key: string]: GedcomEntry};
|
indis: {[key: string]: GedcomEntry};
|
||||||
/** FAM entries mapped by id. */
|
/** FAM entries mapped by id. */
|
||||||
@@ -62,7 +62,7 @@ export function idToFamMap(data: JsonGedcomData): Map<string, JsonFam> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function prepareGedcom(entries: GedcomEntry[]): GedcomData {
|
function prepareGedcom(entries: GedcomEntry[]): GedcomData {
|
||||||
const head = entries.find((entry) => entry.tag === 'HEAD')!;
|
const head = entries.find((entry) => entry.tag === 'HEAD');
|
||||||
const indis: {[key: string]: GedcomEntry} = {};
|
const indis: {[key: string]: GedcomEntry} = {};
|
||||||
const fams: {[key: string]: GedcomEntry} = {};
|
const fams: {[key: string]: GedcomEntry} = {};
|
||||||
const other: {[key: string]: GedcomEntry} = {};
|
const other: {[key: string]: GedcomEntry} = {};
|
||||||
@@ -223,12 +223,13 @@ function filterImage(indi: JsonIndi, images: Map<string, string>): JsonIndi {
|
|||||||
const newImages: JsonImage[] = [];
|
const newImages: JsonImage[] = [];
|
||||||
indi.images.forEach((image) => {
|
indi.images.forEach((image) => {
|
||||||
const filePath = image.url.replaceAll('\\', '/');
|
const filePath = image.url.replaceAll('\\', '/');
|
||||||
const fileName = filePath.match(/[^/]*$/)![0];
|
const fileName = filePath.split('/').pop() || '';
|
||||||
// If the image file has been loaded into memory, use it.
|
const fileUrl = images.get(filePath);
|
||||||
if (images.has(filePath)) {
|
const nameUrl = images.get(fileName);
|
||||||
newImages.push({url: images.get(filePath)!, title: image.title});
|
if (fileUrl) {
|
||||||
} else if (images.has(fileName)) {
|
newImages.push({url: fileUrl, title: image.title});
|
||||||
newImages.push({url: images.get(fileName)!, title: image.title});
|
} else if (nameUrl) {
|
||||||
|
newImages.push({url: nameUrl, title: image.title});
|
||||||
} else if (image.url.startsWith('http') && isImageFile(image.url)) {
|
} else if (image.url.startsWith('http') && isImageFile(image.url)) {
|
||||||
newImages.push(image);
|
newImages.push(image);
|
||||||
}
|
}
|
||||||
@@ -279,7 +280,7 @@ export function convertGedcom(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the name of the software used to generate the GEDCOM file, if available. */
|
/** Returns the name of the software used to generate the GEDCOM file, if available. */
|
||||||
export function getSoftware(head: GedcomEntry): string | null {
|
export function getSoftware(head?: GedcomEntry): string | null {
|
||||||
const sour =
|
const sour =
|
||||||
head && head.tree && head.tree.find((entry) => entry.tag === 'SOUR');
|
head && head.tree && head.tree.find((entry) => entry.tag === 'SOUR');
|
||||||
const name =
|
const name =
|
||||||
@@ -443,6 +444,7 @@ export function findRelationshipPath(
|
|||||||
visited.set(indiId1, null);
|
visited.set(indiId1, null);
|
||||||
|
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const current = queue.shift()!;
|
const current = queue.shift()!;
|
||||||
if (current === indiId2) {
|
if (current === indiId2) {
|
||||||
const path: string[] = [];
|
const path: string[] = [];
|
||||||
@@ -479,6 +481,7 @@ export function getAncestors(
|
|||||||
visited.add(indiId);
|
visited.add(indiId);
|
||||||
|
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const {id, gen} = queue.shift()!;
|
const {id, gen} = queue.shift()!;
|
||||||
if (id !== indiId && !id.startsWith('private_')) {
|
if (id !== indiId && !id.startsWith('private_')) {
|
||||||
result.push(id);
|
result.push(id);
|
||||||
@@ -518,6 +521,7 @@ export function getDescendants(
|
|||||||
visited.add(indiId);
|
visited.add(indiId);
|
||||||
|
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const {id, gen} = queue.shift()!;
|
const {id, gen} = queue.shift()!;
|
||||||
if (id !== indiId && !id.startsWith('private_')) {
|
if (id !== indiId && !id.startsWith('private_')) {
|
||||||
result.push(id);
|
result.push(id);
|
||||||
|
|||||||
@@ -8,9 +8,16 @@ import {
|
|||||||
idToIndiMap,
|
idToIndiMap,
|
||||||
TopolaData,
|
TopolaData,
|
||||||
} from './util/gedcom_util';
|
} from './util/gedcom_util';
|
||||||
import {WEBMCP_TOOLS} from './webmcp_definitions';
|
import {
|
||||||
|
FIND_RELATIONSHIP_PATH,
|
||||||
import './webmcp_types';
|
FOCUS_INDI,
|
||||||
|
GET_ANCESTORS,
|
||||||
|
GET_DESCENDANTS,
|
||||||
|
GET_SELECTED_PERSON,
|
||||||
|
INSPECT_INDI,
|
||||||
|
SEARCH_INDI,
|
||||||
|
} from './webmcp_definitions';
|
||||||
|
import {WebMcpTool} from './webmcp_types';
|
||||||
|
|
||||||
// Maximum generational lookup depth exposed to the assistant to maintain response latency.
|
// Maximum generational lookup depth exposed to the assistant to maintain response latency.
|
||||||
const MAX_GENERATIONS = 5;
|
const MAX_GENERATIONS = 5;
|
||||||
@@ -323,6 +330,50 @@ export class WebMcpBridge {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns the list of all WebMCP tools with their respective execution handlers. */
|
||||||
|
public getTools(): WebMcpTool[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...GET_SELECTED_PERSON,
|
||||||
|
execute: () => this.handleGetSelectedPerson(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...SEARCH_INDI,
|
||||||
|
execute: (params: Record<string, unknown>) =>
|
||||||
|
this.handleSearchIndi(params as {query: string}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...INSPECT_INDI,
|
||||||
|
execute: (params: Record<string, unknown>) =>
|
||||||
|
this.handleInspectIndi(params as {id: string}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...FOCUS_INDI,
|
||||||
|
execute: (params: Record<string, unknown>) =>
|
||||||
|
this.handleFocusIndi(params as {id: string}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...FIND_RELATIONSHIP_PATH,
|
||||||
|
execute: (params: Record<string, unknown>) =>
|
||||||
|
this.handleFindRelationshipPath(
|
||||||
|
params as {source: string; target: string},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...GET_ANCESTORS,
|
||||||
|
execute: (params: Record<string, unknown>) =>
|
||||||
|
this.handleGetAncestors(params as {id: string; generations: number}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...GET_DESCENDANTS,
|
||||||
|
execute: (params: Record<string, unknown>) =>
|
||||||
|
this.handleGetDescendants(
|
||||||
|
params as {id: string; generations: number},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/** Registers standard tools for the LLM research copilot features. */
|
/** Registers standard tools for the LLM research copilot features. */
|
||||||
public registerTools(): void {
|
public registerTools(): void {
|
||||||
if (this.toolsRegistered || !navigator.modelContext) {
|
if (this.toolsRegistered || !navigator.modelContext) {
|
||||||
@@ -331,31 +382,8 @@ export class WebMcpBridge {
|
|||||||
|
|
||||||
const modelContext = navigator.modelContext;
|
const modelContext = navigator.modelContext;
|
||||||
|
|
||||||
const implementations = {
|
this.getTools().forEach((tool) => {
|
||||||
get_selected_person: () => this.handleGetSelectedPerson(),
|
modelContext.registerTool(tool);
|
||||||
search_indi: (params: {query: string}) => this.handleSearchIndi(params),
|
|
||||||
inspect_indi: (params: {id: string}) => this.handleInspectIndi(params),
|
|
||||||
focus_indi: (params: {id: string}) => this.handleFocusIndi(params),
|
|
||||||
find_relationship_path: (params: {source: string; target: string}) =>
|
|
||||||
this.handleFindRelationshipPath(params),
|
|
||||||
get_ancestors: (params: {id: string; generations: number}) =>
|
|
||||||
this.handleGetAncestors(params),
|
|
||||||
get_descendants: (params: {id: string; generations: number}) =>
|
|
||||||
this.handleGetDescendants(params),
|
|
||||||
};
|
|
||||||
|
|
||||||
WEBMCP_TOOLS.forEach((toolDef) => {
|
|
||||||
const execute = (
|
|
||||||
implementations as Record<string, (p: any) => Promise<unknown>>
|
|
||||||
)[toolDef.name];
|
|
||||||
if (execute) {
|
|
||||||
modelContext.registerTool({
|
|
||||||
...toolDef,
|
|
||||||
execute: execute as (
|
|
||||||
params: Record<string, unknown>,
|
|
||||||
) => Promise<unknown>,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
this.toolsRegistered = true;
|
this.toolsRegistered = true;
|
||||||
}
|
}
|
||||||
@@ -368,9 +396,9 @@ export class WebMcpBridge {
|
|||||||
const modelContext = navigator.modelContext;
|
const modelContext = navigator.modelContext;
|
||||||
const unregister = modelContext.unregisterTool;
|
const unregister = modelContext.unregisterTool;
|
||||||
if (typeof unregister === 'function') {
|
if (typeof unregister === 'function') {
|
||||||
WEBMCP_TOOLS.forEach((toolDef) => {
|
this.getTools().forEach((tool) => {
|
||||||
try {
|
try {
|
||||||
unregister(toolDef.name);
|
unregister(tool.name);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +1,101 @@
|
|||||||
import {ToolDefinition} from './webmcp_types';
|
import {ToolDefinition} from './webmcp_types';
|
||||||
|
|
||||||
export const WEBMCP_TOOLS: ToolDefinition[] = [
|
export const GET_SELECTED_PERSON: ToolDefinition = {
|
||||||
{
|
name: 'get_selected_person',
|
||||||
name: 'get_selected_person',
|
description:
|
||||||
description:
|
'Returns the full details (name, events, immediate relatives) of the individual currently selected in the browser viewport.',
|
||||||
'Returns the full details (name, events, immediate relatives) of the individual currently selected in the browser viewport.',
|
inputSchema: {type: 'object', properties: {}},
|
||||||
inputSchema: {type: 'object', properties: {}},
|
};
|
||||||
},
|
|
||||||
{
|
export const SEARCH_INDI: ToolDefinition = {
|
||||||
name: 'search_indi',
|
name: 'search_indi',
|
||||||
description:
|
description:
|
||||||
'Searches the genealogy index for individuals by name. Returns up to 10 results starting with the ones that match the best.',
|
'Searches the genealogy index for individuals by name. Returns up to 10 results starting with the ones that match the best.',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
query: {type: 'string', description: 'The name to search for.'},
|
query: {type: 'string', description: 'The name to search for.'},
|
||||||
},
|
|
||||||
required: ['query'],
|
|
||||||
},
|
},
|
||||||
|
required: ['query'],
|
||||||
},
|
},
|
||||||
{
|
};
|
||||||
name: 'inspect_indi',
|
|
||||||
description:
|
export const INSPECT_INDI: ToolDefinition = {
|
||||||
'Fetches detailed information for a specific individual by ID, including their immediate relatives and life events.',
|
name: 'inspect_indi',
|
||||||
inputSchema: {
|
description:
|
||||||
type: 'object',
|
'Fetches detailed information for a specific individual by ID, including their immediate relatives and life events.',
|
||||||
properties: {
|
inputSchema: {
|
||||||
id: {type: 'string', description: 'The ID of the individual.'},
|
type: 'object',
|
||||||
},
|
properties: {
|
||||||
required: ['id'],
|
id: {type: 'string', description: 'The ID of the individual.'},
|
||||||
},
|
},
|
||||||
|
required: ['id'],
|
||||||
},
|
},
|
||||||
{
|
};
|
||||||
name: 'focus_indi',
|
|
||||||
description:
|
export const FOCUS_INDI: ToolDefinition = {
|
||||||
'Instructs the Topola viewer camera view to center on and focus a specific person. Restructures the tree view to show ancestors and descendants of the selected person.',
|
name: 'focus_indi',
|
||||||
inputSchema: {
|
description:
|
||||||
type: 'object',
|
'Instructs the Topola viewer camera view to center on and focus a specific person. Restructures the tree view to show ancestors and descendants of the selected person.',
|
||||||
properties: {
|
inputSchema: {
|
||||||
id: {type: 'string', description: 'The ID to focus.'},
|
type: 'object',
|
||||||
},
|
properties: {
|
||||||
required: ['id'],
|
id: {type: 'string', description: 'The ID to focus.'},
|
||||||
},
|
},
|
||||||
|
required: ['id'],
|
||||||
},
|
},
|
||||||
{
|
};
|
||||||
name: 'find_relationship_path',
|
|
||||||
description:
|
export const FIND_RELATIONSHIP_PATH: ToolDefinition = {
|
||||||
'Finds the shortest path connecting two individuals (e.g., through parents or marriages). Returns an ordered list of connecting individuals.',
|
name: 'find_relationship_path',
|
||||||
inputSchema: {
|
description:
|
||||||
type: 'object',
|
'Finds the shortest path connecting two individuals (e.g., through parents or marriages). Returns an ordered list of connecting individuals.',
|
||||||
properties: {
|
inputSchema: {
|
||||||
source: {type: 'string', description: 'Start individual ID'},
|
type: 'object',
|
||||||
target: {type: 'string', description: 'End individual ID'},
|
properties: {
|
||||||
},
|
source: {type: 'string', description: 'Start individual ID'},
|
||||||
required: ['source', 'target'],
|
target: {type: 'string', description: 'End individual ID'},
|
||||||
},
|
},
|
||||||
|
required: ['source', 'target'],
|
||||||
},
|
},
|
||||||
{
|
};
|
||||||
name: 'get_ancestors',
|
|
||||||
description:
|
export const GET_ANCESTORS: ToolDefinition = {
|
||||||
'Returns ancestors of a specific individual up to a maximum depth of 5 generations.',
|
name: 'get_ancestors',
|
||||||
inputSchema: {
|
description:
|
||||||
type: 'object',
|
'Returns ancestors of a specific individual up to a maximum depth of 5 generations.',
|
||||||
properties: {
|
inputSchema: {
|
||||||
id: {type: 'string', description: 'Target individual ID'},
|
type: 'object',
|
||||||
generations: {
|
properties: {
|
||||||
type: 'number',
|
id: {type: 'string', description: 'Target individual ID'},
|
||||||
description: 'Depth bound limit (1-5). Defaults to 3.',
|
generations: {
|
||||||
minimum: 1,
|
type: 'number',
|
||||||
maximum: 5,
|
description: 'Depth bound limit (1-5). Defaults to 3.',
|
||||||
default: 3,
|
minimum: 1,
|
||||||
},
|
maximum: 5,
|
||||||
|
default: 3,
|
||||||
},
|
},
|
||||||
required: ['id'],
|
|
||||||
},
|
},
|
||||||
|
required: ['id'],
|
||||||
},
|
},
|
||||||
{
|
};
|
||||||
name: 'get_descendants',
|
|
||||||
description:
|
export const GET_DESCENDANTS: ToolDefinition = {
|
||||||
'Returns descendants of a specific individual up to a maximum depth of 5 generations.',
|
name: 'get_descendants',
|
||||||
inputSchema: {
|
description:
|
||||||
type: 'object',
|
'Returns descendants of a specific individual up to a maximum depth of 5 generations.',
|
||||||
properties: {
|
inputSchema: {
|
||||||
id: {type: 'string', description: 'Target individual ID'},
|
type: 'object',
|
||||||
generations: {
|
properties: {
|
||||||
type: 'number',
|
id: {type: 'string', description: 'Target individual ID'},
|
||||||
description: 'Depth bound limit (1-5). Defaults to 3.',
|
generations: {
|
||||||
minimum: 1,
|
type: 'number',
|
||||||
maximum: 5,
|
description: 'Depth bound limit (1-5). Defaults to 3.',
|
||||||
default: 3,
|
minimum: 1,
|
||||||
},
|
maximum: 5,
|
||||||
|
default: 3,
|
||||||
},
|
},
|
||||||
required: ['id'],
|
|
||||||
},
|
},
|
||||||
|
required: ['id'],
|
||||||
},
|
},
|
||||||
];
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {expect, test} from '@playwright/test';
|
|||||||
import {setupGedcomRoute} from './helpers';
|
import {setupGedcomRoute} from './helpers';
|
||||||
|
|
||||||
test.describe('Core SVG Canvas Layouts @visual', () => {
|
test.describe('Core SVG Canvas Layouts @visual', () => {
|
||||||
test.beforeEach(async ({page, context}) => {
|
test.beforeEach(async ({context}) => {
|
||||||
await setupGedcomRoute(context);
|
await setupGedcomRoute(context);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
4
tests/global.d.ts
vendored
4
tests/global.d.ts
vendored
@@ -1,8 +1,8 @@
|
|||||||
import {ModelContext} from '../src/webmcp_types';
|
import {ModelContext, ToolDefinition} from '../src/webmcp_types';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__registeredTools?: any[];
|
__registeredTools?: ToolDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Navigator {
|
interface Navigator {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {expect, test} from '@playwright/test';
|
import {expect, test} from '@playwright/test';
|
||||||
|
import {ToolDefinition} from '../src/webmcp_types';
|
||||||
import {setupGedcomRoute} from './helpers';
|
import {setupGedcomRoute} from './helpers';
|
||||||
|
|
||||||
const EXPECTED_TOOL_NAMES = [
|
const EXPECTED_TOOL_NAMES = [
|
||||||
@@ -17,10 +18,10 @@ test.describe('WebMCP Integration', () => {
|
|||||||
|
|
||||||
// Add init script to expose modelContext mock BEFORE application boots.
|
// Add init script to expose modelContext mock BEFORE application boots.
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
const registeredTools: any[] = [];
|
const registeredTools: ToolDefinition[] = [];
|
||||||
window.__registeredTools = registeredTools;
|
window.__registeredTools = registeredTools;
|
||||||
window.navigator.modelContext = {
|
window.navigator.modelContext = {
|
||||||
registerTool: (tool: any) => {
|
registerTool: (tool: ToolDefinition) => {
|
||||||
registeredTools.push(tool);
|
registeredTools.push(tool);
|
||||||
},
|
},
|
||||||
unregisterTool: (name: string) => {
|
unregisterTool: (name: string) => {
|
||||||
@@ -42,7 +43,7 @@ test.describe('WebMCP Integration', () => {
|
|||||||
|
|
||||||
const toolNames = await page.evaluate(() =>
|
const toolNames = await page.evaluate(() =>
|
||||||
window.__registeredTools
|
window.__registeredTools
|
||||||
? window.__registeredTools.map((t: any) => t.name)
|
? window.__registeredTools.map((t) => t.name)
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
expect(toolNames.sort()).toEqual([...EXPECTED_TOOL_NAMES].sort());
|
expect(toolNames.sort()).toEqual([...EXPECTED_TOOL_NAMES].sort());
|
||||||
@@ -61,7 +62,7 @@ test.describe('WebMCP Integration', () => {
|
|||||||
// Execute the non-serializable callback inside the browser environment.
|
// Execute the non-serializable callback inside the browser environment.
|
||||||
await page.evaluate(async () => {
|
await page.evaluate(async () => {
|
||||||
const focusTool = window.__registeredTools
|
const focusTool = window.__registeredTools
|
||||||
? window.__registeredTools.find((t: any) => t.name === 'focus_indi')
|
? window.__registeredTools.find((t) => t.name === 'focus_indi')
|
||||||
: null;
|
: null;
|
||||||
if (!focusTool) throw new Error('focus_indi tool not found');
|
if (!focusTool) throw new Error('focus_indi tool not found');
|
||||||
await focusTool.execute({id: 'I21'}); // Shifts view focus to Chike.
|
await focusTool.execute({id: 'I21'}); // Shifts view focus to Chike.
|
||||||
|
|||||||
Reference in New Issue
Block a user