Improve code quality

Fix lint warnings
This commit is contained in:
Przemek Więch
2026-05-10 21:56:51 +02:00
parent ea82bc4c98
commit 8202c9cd05
29 changed files with 315 additions and 221 deletions

View File

@@ -25,5 +25,14 @@ module.exports = {
'plugin:react/recommended',
'plugin:react/jsx-runtime',
],
rules: {},
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
};

View File

@@ -25,6 +25,7 @@ import {
GedcomUrlDataSource,
getSelection,
UploadedDataSource,
UploadLocationState,
UploadSourceSpec,
UrlSourceSpec,
} from './datasource/load_data';
@@ -142,7 +143,7 @@ interface Arguments {
function getParamFromSearch(
name: string,
search: queryString.ParsedQuery<string>,
search: queryString.ParsedQuery,
) {
const value = search[name];
return typeof value === 'string' ? value : undefined;
@@ -152,7 +153,7 @@ function getParamFromSearch(
* Retrieve arguments passed into the application through the URL and uploaded
* data.
*/
function getArguments(location: H.Location<any>): Arguments {
function getArguments(location: H.Location<UploadLocationState>): Arguments {
const search = queryString.parse(location.search);
const getParam = (name: string) => getParamFromSearch(name, search);
@@ -273,7 +274,7 @@ export function App() {
if (
!selection ||
selection.id !== newSelection.id ||
selection!.generation !== newSelection.generation
selection.generation !== newSelection.generation
) {
setSelection(newSelection);
setDetailIndi(newSelection.id);
@@ -413,8 +414,8 @@ export function App() {
updateChartWithConfig(args.config, data);
setShowSidePanel(args.showSidePanel);
setState(AppState.SHOWING_CHART);
} catch (error: any) {
setErrorMessage(getI18nMessage(error, intl));
} catch (error: unknown) {
setErrorMessage(getI18nMessage(error as Error, intl));
}
} else if (
state === AppState.SHOWING_CHART ||
@@ -428,9 +429,11 @@ export function App() {
setState(
loadMoreFromWikitree ? AppState.LOADING_MORE : AppState.SHOWING_CHART,
);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
updateDisplay(getSelection(data!.chartData, args.selection));
if (loadMoreFromWikitree) {
try {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const data = await loadWikiTree(args.selection!.id, intl);
const newSelection = getSelection(data.chartData, args.selection);
setData(data);
@@ -475,7 +478,7 @@ export function App() {
});
}, [mcpBridge, location]);
function updateUrl(args: queryString.ParsedQuery<any>) {
function updateUrl(args: queryString.ParsedQuery<string>) {
const search = queryString.parse(location.search);
for (const key in args) {
search[key] = args[key];
@@ -496,7 +499,7 @@ export function App() {
analyticsEvent('selection_changed');
updateUrl({
indi: selection.id,
gen: selection.generation,
gen: String(selection.generation),
});
}
/**
@@ -559,10 +562,13 @@ export function App() {
}
function renderChart(selection: IndiInfo) {
if (!data) {
return null;
}
if (chartType === ChartType.Donatso) {
return (
<DonatsoChart
data={data!.chartData}
data={data.chartData}
selection={selection}
onSelection={onSelection}
/>
@@ -570,7 +576,7 @@ export function App() {
}
return (
<Chart
data={data!.chartData}
data={data.chartData}
selection={selection}
chartType={chartType}
onSelection={onSelection}
@@ -587,7 +593,10 @@ export function App() {
switch (state) {
case AppState.SHOWING_CHART:
case AppState.LOADING_MORE: {
const updatedSelection = getSelection(data!.chartData, selection);
if (!data) {
return null;
}
const updatedSelection = getSelection(data.chartData, selection);
return (
<div id="content">
<ErrorPopup
@@ -600,7 +609,7 @@ export function App() {
) : null}
<SidebarPushable>
<SidePanel
data={data!}
data={data}
selectedIndiId={detailIndi || updatedSelection.id}
config={config}
expanded={showSidePanel}
@@ -618,7 +627,7 @@ export function App() {
}
case AppState.ERROR:
return <ErrorMessage message={error!} />;
return <ErrorMessage message={error || 'Unknown error'} />;
case AppState.INITIAL:
case AppState.LOADING:

View File

@@ -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. */
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(() => {
(async () => {
const seenVersion = localStorage.getItem(LAST_SEEN_VERSION_KEY);
const currentVersion = import.meta.env.VITE_GIT_TIME!;
if (!seenVersion || seenVersion === currentVersion) {
const currentVersion = import.meta.env.VITE_GIT_TIME;
if (!seenVersion || !currentVersion || seenVersion === currentVersion) {
return;
}

View File

@@ -1,6 +1,6 @@
import {max, min} from 'd3-array';
import {interpolateNumber} from 'd3-interpolate';
import {select, Selection} from 'd3-selection';
import {BaseType, select, Selection} from 'd3-selection';
import 'd3-transition';
import {
D3ZoomEvent,
@@ -69,7 +69,7 @@ function scrolled() {
function loadAsDataUrl(blob: Blob): Promise<string> {
const reader = new FileReader();
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);
});
}
@@ -103,7 +103,7 @@ async function inlineImages(svg: Element): Promise<void> {
function loadImage(blob: Blob): Promise<HTMLImageElement> {
const image = new Image();
image.src = URL.createObjectURL(blob);
return new Promise<HTMLImageElement>((resolve, reject) => {
return new Promise<HTMLImageElement>((resolve, _reject) => {
image.addEventListener('load', () => resolve(image));
});
}
@@ -115,6 +115,7 @@ function drawImageOnCanvas(image: HTMLImageElement) {
canvas.width = image.width * 2;
canvas.height = image.height * 2;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const ctx = canvas.getContext('2d')!;
const oldFill = ctx.fillStyle;
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. */
function getStrippedSvg() {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const svg = document.getElementById('chartSvg')!.cloneNode(true) as Element;
svg.removeAttribute('transform');
@@ -149,12 +151,14 @@ function getStrippedSvg() {
'height',
String(Number(svg.getAttribute('height')) / scale),
);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
svg.querySelector('#chart')!.removeAttribute('transform');
return svg;
}
function getSvgDimensions() {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const svg = document.getElementById('chartSvg')!;
return {
width: Number(svg.getAttribute('width')),
@@ -191,14 +195,14 @@ export function printChart() {
printWindow.style.top = '-1000px';
printWindow.style.left = '-1000px';
printWindow.onload = () => {
printWindow.contentDocument!.open();
printWindow.contentDocument!.write(getSvgContents());
printWindow.contentDocument!.close();
printWindow.contentDocument?.open();
printWindow.contentDocument?.write(getSvgContents());
printWindow.contentDocument?.close();
// Doesn't work on Firefox without the setTimeout.
setTimeout(() => {
printWindow.contentWindow!.focus();
printWindow.contentWindow!.print();
printWindow.parentNode!.removeChild(printWindow);
printWindow.contentWindow?.focus();
printWindow.contentWindow?.print();
printWindow.parentNode?.removeChild(printWindow);
}, 500);
};
document.body.appendChild(printWindow);
@@ -299,6 +303,7 @@ function calculateScaleExtent(
): [number, number] {
const [availWidth, availHeight] = getScrollbarAwareSize(parent);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const zoomOutFactor = min([
1,
scale,
@@ -306,6 +311,7 @@ function calculateScaleExtent(
availHeight / chartInfo.size[1],
])!;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return [max([0.1, zoomOutFactor])!, 2];
}
@@ -328,14 +334,19 @@ class ChartWrapper {
/** Rendering is required after the current animation finishes. */
private rerenderRequired = false;
/** The d3 zoom behavior object. */
private zoomBehavior?: ZoomBehavior<Element, any>;
private zoomBehavior?: ZoomBehavior<Element, unknown>;
/** Props that will be used for rerendering. */
private rerenderProps?: ChartProps;
private rerenderResetPosition?: boolean;
zoom(factor: number) {
const parent = select('#svgContainer') as Selection<Element, any, any, any>;
this.zoomBehavior!.scaleBy(parent, factor);
const parent = select('#svgContainer') as Selection<
Element,
unknown,
BaseType,
unknown
>;
this.zoomBehavior?.scaleBy(parent, factor);
}
/**
@@ -364,7 +375,7 @@ class ChartWrapper {
return;
}
if (args.initialRender) {
if (args.initialRender || !this.chart) {
(select('#chart').node() as HTMLElement).innerHTML = '';
this.chart = createChart({
json: props.data,
@@ -382,15 +393,15 @@ class ChartWrapper {
props.onSelection(info);
}
},
colors: chartColors.get(props.colors!),
colors: (props.colors && chartColors.get(props.colors)) || undefined,
animate: true,
updateSvgSize: false,
locale: intl.locale,
});
} 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,
baseGeneration: props.selection.generation,
});
@@ -461,10 +472,16 @@ class ChartWrapper {
this.rerenderRequired = false;
// Use `this.rerenderProps` instead of the props in scope because
// the props may have been updated in the meantime.
this.renderChart(this.rerenderProps!, intl, {
initialRender: false,
resetPosition: !!this.rerenderResetPosition,
});
if (this.rerenderProps) {
this.renderChart(this.rerenderProps, intl, {
initialRender: false,
resetPosition: !!this.rerenderResetPosition,
});
} else {
console.error(
'Rerender required after animation, but rerenderProps was not set.',
);
}
}
});
}

View File

@@ -33,9 +33,9 @@ export interface EmbeddedSourceSpec {
/** GEDCOM file received from outside of the iframe. */
export class EmbeddedDataSource implements DataSource<EmbeddedSourceSpec> {
isNewData(
newSource: SourceSelection<EmbeddedSourceSpec>,
oldSource: SourceSelection<EmbeddedSourceSpec>,
data?: TopolaData,
_newSource: SourceSelection<EmbeddedSourceSpec>,
_oldSource: SourceSelection<EmbeddedSourceSpec>,
_data?: TopolaData,
): boolean {
// Never reload data.
return false;
@@ -44,7 +44,7 @@ export class EmbeddedDataSource implements DataSource<EmbeddedSourceSpec> {
private async onMessage(
message: EmbeddedMessage,
resolve: (value: TopolaData) => void,
reject: (reason: any) => void,
reject: (reason: unknown) => void,
) {
if (message.message === EmbeddedMessageType.PARENT_READY) {
// 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(
source: SourceSelection<EmbeddedSourceSpec>,
_source: SourceSelection<EmbeddedSourceSpec>,
): Promise<TopolaData> {
// Notify the parent window that we are ready.
return new Promise<TopolaData>((resolve, reject) => {

View File

@@ -27,7 +27,8 @@ const MONTHS = new Map<number, 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)
.join(' ');
}

View File

@@ -151,13 +151,18 @@ export interface UploadSourceSpec {
images?: Map<string, string>;
}
export interface UploadLocationState {
data: string;
images: Map<string, string>;
}
/** Files opened from the local computer. */
export class UploadedDataSource implements DataSource<UploadSourceSpec> {
// isNewData(args: Arguments, state: State): boolean {
isNewData(
newSource: SourceSelection<UploadSourceSpec>,
oldSource: SourceSelection<UploadSourceSpec>,
data?: TopolaData,
_data?: TopolaData,
): boolean {
return newSource.spec.hash !== oldSource.spec.hash;
}
@@ -196,7 +201,7 @@ export class GedcomUrlDataSource implements DataSource<UrlSourceSpec> {
isNewData(
newSource: SourceSelection<UrlSourceSpec>,
oldSource: SourceSelection<UrlSourceSpec>,
data?: TopolaData,
_data?: TopolaData,
): boolean {
return newSource.spec.url !== oldSource.spec.url;
}

View File

@@ -42,6 +42,7 @@ export async function loadWikiTree(
.filter((person) => person.PhotoData?.path)
.map((person) => [
person.Name,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
`https://www.wikitree.com${person.PhotoData!.path}`,
]),
);

View File

@@ -1,5 +1,12 @@
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 {Person} from 'wikitree-js';
import {PRIVATE_ID_PREFIX} from './wikitree_api';
@@ -274,7 +281,7 @@ function parseDate(date: string, dataStatus?: string): DateOrRange | undefined {
if (!matchedDate) {
return {date: {text: date}};
}
const parsedDate: any = {};
const parsedDate: Date = {};
if (matchedDate[1] !== '0000') {
parsedDate.year = ~~matchedDate[1];
}

View File

@@ -19,7 +19,7 @@ function convertData(data: JsonGedcomData, intl: IntlShape) {
const famMap = new Map<string, JsonFam>();
data.fams.forEach((fam) => famMap.set(fam.id, fam));
return data.indis.map((indi) => {
const famc = famMap.get(indi.famc!);
const famc = (indi.famc && famMap.get(indi.famc)) || undefined;
const fams = (indi.fams || [])
.map((fam) => famMap.get(fam))
.filter((fam): fam is JsonFam => fam !== undefined);

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// Data type definitions for the family-chart library.
declare module 'family-chart' {
export function createStore(args: any): any;

View File

@@ -28,8 +28,9 @@ const language = navigator.language && navigator.language.split(/[-_]/)[0];
const browser = detect();
const container = document.getElementById('root');
const root = createRoot(container!);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const container = document.getElementById('root')!;
const root = createRoot(container);
if (browser && browser.name === 'ie') {
root.render(

View File

@@ -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) || '';
}
@@ -126,7 +126,7 @@ function Contents() {
/>
<p className="ui right aligned version">
version: {formatBuildDate(import.meta.env.VITE_GIT_TIME!)} (
version: {formatBuildDate(import.meta.env.VITE_GIT_TIME)} (
<a
href={`https://github.com/PeWu/topola-viewer/commit/${import.meta.env.VITE_GIT_SHA}`}
>

View File

@@ -53,11 +53,11 @@ export function SearchBar(props: Props) {
/** On search input change. */
function handleSearch(input: string | undefined) {
if (!input) {
if (!input || !searchIndex.current) {
return;
}
const results = searchIndex
.current!.search(input)
const results = searchIndex.current
.search(input)
.map((result) => displaySearchResult(result));
setSearchResults(results);
}
@@ -71,9 +71,9 @@ export function SearchBar(props: Props) {
}
/** On search string changed. */
function onChange(value: string) {
function onChange(value: string | undefined) {
debouncedHandleSearch.current(value);
setSearchString(value);
setSearchString(value || '');
}
// Initialize the search index.
@@ -83,7 +83,7 @@ export function SearchBar(props: Props) {
return (
<Search
onSearchChange={(_, data) => onChange(data.value!)}
onSearchChange={(_, data) => onChange(data.value)}
onResultSelect={(_, data) => handleResultSelect(data.result.id)}
results={searchResults}
noResultsMessage={intl.formatMessage({

View File

@@ -57,7 +57,7 @@ function getHusbandLastName(
}
class LunrSearchIndex implements SearchIndex {
private index: lunr.Index | undefined;
private index!: lunr.Index;
private indiMap: Map<string, JsonIndi>;
private famMap: Map<string, JsonFam>;
@@ -107,7 +107,6 @@ class LunrSearchIndex implements SearchIndex {
lunrInstance: any,
languages: string[],
): void {
let wordCharacters = '';
const pipelineFunctions: PipelineFunction[] = [];
const searchPipelineFunctions: PipelineFunction[] = [];
languages.forEach((language) => {
@@ -115,12 +114,10 @@ class LunrSearchIndex implements SearchIndex {
// @ts-ignore
const lunrLanguage = lunr[language];
if (language === 'en') {
wordCharacters += '\\w';
pipelineFunctions.unshift(lunr.stopWordFilter);
pipelineFunctions.push(lunr.stemmer);
searchPipelineFunctions.push(lunr.stemmer);
} else {
wordCharacters += lunrLanguage.wordCharacters;
if (lunrLanguage.stopWordFilter) {
pipelineFunctions.unshift(lunrLanguage.stopWordFilter);
}

View File

@@ -51,7 +51,7 @@ export function TopBar(props: Props) {
}
function chartMenus(screenSize: ScreenSize) {
if (!props.showingChart) {
if (!props.showingChart || !props.data) {
return null;
}
const chartTypeItems = (
@@ -147,7 +147,7 @@ export function TopBar(props: Props) {
<Dropdown.Menu>{chartTypeItems}</Dropdown.Menu>
</Dropdown>
<SearchBar
data={props.data!}
data={props.data}
onSelection={props.eventHandlers.onSelection}
{...props}
/>
@@ -312,9 +312,9 @@ export function TopBar(props: Props) {
<div className="topbar--title">
{props.standalone ? <Link to="/">{title()}</Link> : title()}
</div>
{props.showingChart && (
{props.showingChart && props.data && (
<SearchBar
data={props.data!}
data={props.data}
onSelection={props.eventHandlers.onSelection}
{...props}
/>

View File

@@ -20,7 +20,7 @@ export function UrlMenu(props: Props) {
useEffect(() => {
if (dialogOpen) {
setUrl('');
inputRef.current!.focus();
inputRef.current?.focus();
}
}, [dialogOpen]);

View File

@@ -23,7 +23,7 @@ export function WikiTreeMenu(props: Props) {
useEffect(() => {
if (dialogOpen) {
setWikiTreeId('');
inputRef.current!.focus();
inputRef.current?.focus();
}
}, [dialogOpen]);
@@ -50,7 +50,7 @@ export function WikiTreeMenu(props: Props) {
function enterId(event: React.MouseEvent, id: string) {
event.preventDefault(); // Do not follow link in href.
setWikiTreeId(id);
inputRef.current!.focus();
inputRef.current?.focus();
}
function wikiTreeIdModal() {

View File

@@ -54,7 +54,7 @@ const SEX_ARG = new Map<string, Sex>([
const SEX_ARG_INVERSE = new Map<Sex, string>();
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 value = args[name];
return typeof value === 'string' ? value : undefined;
@@ -67,12 +67,21 @@ export function argsToConfig(args: ParsedQuery<any>): Config {
};
}
export function configToArgs(config: Config): ParsedQuery<any> {
return {
c: COLOR_ARG_INVERSE.get(config.color),
i: ID_ARG_INVERSE.get(config.id),
s: SEX_ARG_INVERSE.get(config.sex),
};
export function configToArgs(config: Config): ParsedQuery {
const result: ParsedQuery = {};
const color = COLOR_ARG_INVERSE.get(config.color);
if (color){
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: {

View File

@@ -1,4 +1,5 @@
/** 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);
}

View File

@@ -1,4 +1,4 @@
/** No-op function for analytics. */
export function analyticsEvent(action: string, data?: any) {
export function analyticsEvent(_action: string, _data?: unknown) {
// no-op
}

View File

@@ -180,9 +180,5 @@ export function isDateRangeClosed(range: DateRange | undefined): boolean {
}
export function toDateObject(date: TopolaDate): Date {
return new Date(
date.year !== undefined ? date.year! : 0,
date.month !== undefined ? date.month! - 1 : 0,
date.day !== undefined ? date.day! : 1,
);
return new Date(date.year ?? 0, (date.month ?? 1) - 1, date.day ?? 1);
}

View File

@@ -1,5 +1,14 @@
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()', () => {
it('sorts children', () => {
@@ -62,7 +71,7 @@ describe('normalizeGedcom()', () => {
],
};
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',
'F2',
'F1',
@@ -140,16 +149,8 @@ describe('getName()', () => {
});
});
import {
findRelationshipPath,
getAncestors,
getDescendants,
idToFamMap,
idToIndiMap,
} from './gedcom_util';
describe('Relationship algorithms', () => {
const sampleData = {
const sampleData: JsonGedcomData = {
indis: [
{id: 'I1', fams: ['F1']},
{id: 'I2', fams: ['F1'], famc: 'F2'},
@@ -160,7 +161,7 @@ describe('Relationship algorithms', () => {
{id: 'F1', husb: 'I1', wife: 'I2', children: ['I3']},
{id: 'F2', children: ['I2', 'I4']},
],
} as any;
};
const indiMap = idToIndiMap(sampleData);
const famMap = idToFamMap(sampleData);

View File

@@ -13,7 +13,7 @@ import {TopolaError} from './error';
export interface GedcomData {
/** The HEAD entry. */
head: GedcomEntry;
head?: GedcomEntry;
/** INDI entries mapped by id. */
indis: {[key: string]: GedcomEntry};
/** FAM entries mapped by id. */
@@ -62,7 +62,7 @@ export function idToFamMap(data: JsonGedcomData): Map<string, JsonFam> {
}
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 fams: {[key: string]: GedcomEntry} = {};
const other: {[key: string]: GedcomEntry} = {};
@@ -223,12 +223,13 @@ function filterImage(indi: JsonIndi, images: Map<string, string>): JsonIndi {
const newImages: JsonImage[] = [];
indi.images.forEach((image) => {
const filePath = image.url.replaceAll('\\', '/');
const fileName = filePath.match(/[^/]*$/)![0];
// If the image file has been loaded into memory, use it.
if (images.has(filePath)) {
newImages.push({url: images.get(filePath)!, title: image.title});
} else if (images.has(fileName)) {
newImages.push({url: images.get(fileName)!, title: image.title});
const fileName = filePath.split('/').pop() || '';
const fileUrl = images.get(filePath);
const nameUrl = images.get(fileName);
if (fileUrl) {
newImages.push({url: fileUrl, title: image.title});
} else if (nameUrl) {
newImages.push({url: nameUrl, title: image.title});
} else if (image.url.startsWith('http') && isImageFile(image.url)) {
newImages.push(image);
}
@@ -279,7 +280,7 @@ export function convertGedcom(
}
/** 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 =
head && head.tree && head.tree.find((entry) => entry.tag === 'SOUR');
const name =
@@ -443,6 +444,7 @@ export function findRelationshipPath(
visited.set(indiId1, null);
while (queue.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const current = queue.shift()!;
if (current === indiId2) {
const path: string[] = [];
@@ -479,6 +481,7 @@ export function getAncestors(
visited.add(indiId);
while (queue.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const {id, gen} = queue.shift()!;
if (id !== indiId && !id.startsWith('private_')) {
result.push(id);
@@ -518,6 +521,7 @@ export function getDescendants(
visited.add(indiId);
while (queue.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const {id, gen} = queue.shift()!;
if (id !== indiId && !id.startsWith('private_')) {
result.push(id);

View File

@@ -8,9 +8,16 @@ import {
idToIndiMap,
TopolaData,
} from './util/gedcom_util';
import {WEBMCP_TOOLS} from './webmcp_definitions';
import './webmcp_types';
import {
FIND_RELATIONSHIP_PATH,
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.
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. */
public registerTools(): void {
if (this.toolsRegistered || !navigator.modelContext) {
@@ -331,31 +382,8 @@ export class WebMcpBridge {
const modelContext = navigator.modelContext;
const implementations = {
get_selected_person: () => this.handleGetSelectedPerson(),
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.getTools().forEach((tool) => {
modelContext.registerTool(tool);
});
this.toolsRegistered = true;
}
@@ -368,9 +396,9 @@ export class WebMcpBridge {
const modelContext = navigator.modelContext;
const unregister = modelContext.unregisterTool;
if (typeof unregister === 'function') {
WEBMCP_TOOLS.forEach((toolDef) => {
this.getTools().forEach((tool) => {
try {
unregister(toolDef.name);
unregister(tool.name);
} catch (e) {
/* ignore */
}

View File

@@ -1,97 +1,101 @@
import {ToolDefinition} from './webmcp_types';
export const WEBMCP_TOOLS: ToolDefinition[] = [
{
name: 'get_selected_person',
description:
'Returns the full details (name, events, immediate relatives) of the individual currently selected in the browser viewport.',
inputSchema: {type: 'object', properties: {}},
},
{
name: 'search_indi',
description:
'Searches the genealogy index for individuals by name. Returns up to 10 results starting with the ones that match the best.',
inputSchema: {
type: 'object',
properties: {
query: {type: 'string', description: 'The name to search for.'},
},
required: ['query'],
export const GET_SELECTED_PERSON: ToolDefinition = {
name: 'get_selected_person',
description:
'Returns the full details (name, events, immediate relatives) of the individual currently selected in the browser viewport.',
inputSchema: {type: 'object', properties: {}},
};
export const SEARCH_INDI: ToolDefinition = {
name: 'search_indi',
description:
'Searches the genealogy index for individuals by name. Returns up to 10 results starting with the ones that match the best.',
inputSchema: {
type: 'object',
properties: {
query: {type: 'string', description: 'The name to search for.'},
},
required: ['query'],
},
{
name: 'inspect_indi',
description:
'Fetches detailed information for a specific individual by ID, including their immediate relatives and life events.',
inputSchema: {
type: 'object',
properties: {
id: {type: 'string', description: 'The ID of the individual.'},
},
required: ['id'],
};
export const INSPECT_INDI: ToolDefinition = {
name: 'inspect_indi',
description:
'Fetches detailed information for a specific individual by ID, including their immediate relatives and life events.',
inputSchema: {
type: 'object',
properties: {
id: {type: 'string', description: 'The ID of the individual.'},
},
required: ['id'],
},
{
name: 'focus_indi',
description:
'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.',
inputSchema: {
type: 'object',
properties: {
id: {type: 'string', description: 'The ID to focus.'},
},
required: ['id'],
};
export const FOCUS_INDI: ToolDefinition = {
name: 'focus_indi',
description:
'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.',
inputSchema: {
type: 'object',
properties: {
id: {type: 'string', description: 'The ID to focus.'},
},
required: ['id'],
},
{
name: 'find_relationship_path',
description:
'Finds the shortest path connecting two individuals (e.g., through parents or marriages). Returns an ordered list of connecting individuals.',
inputSchema: {
type: 'object',
properties: {
source: {type: 'string', description: 'Start individual ID'},
target: {type: 'string', description: 'End individual ID'},
},
required: ['source', 'target'],
};
export const FIND_RELATIONSHIP_PATH: ToolDefinition = {
name: 'find_relationship_path',
description:
'Finds the shortest path connecting two individuals (e.g., through parents or marriages). Returns an ordered list of connecting individuals.',
inputSchema: {
type: 'object',
properties: {
source: {type: 'string', description: 'Start individual ID'},
target: {type: 'string', description: 'End individual ID'},
},
required: ['source', 'target'],
},
{
name: 'get_ancestors',
description:
'Returns ancestors of a specific individual up to a maximum depth of 5 generations.',
inputSchema: {
type: 'object',
properties: {
id: {type: 'string', description: 'Target individual ID'},
generations: {
type: 'number',
description: 'Depth bound limit (1-5). Defaults to 3.',
minimum: 1,
maximum: 5,
default: 3,
},
};
export const GET_ANCESTORS: ToolDefinition = {
name: 'get_ancestors',
description:
'Returns ancestors of a specific individual up to a maximum depth of 5 generations.',
inputSchema: {
type: 'object',
properties: {
id: {type: 'string', description: 'Target individual ID'},
generations: {
type: 'number',
description: 'Depth bound limit (1-5). Defaults to 3.',
minimum: 1,
maximum: 5,
default: 3,
},
required: ['id'],
},
required: ['id'],
},
{
name: 'get_descendants',
description:
'Returns descendants of a specific individual up to a maximum depth of 5 generations.',
inputSchema: {
type: 'object',
properties: {
id: {type: 'string', description: 'Target individual ID'},
generations: {
type: 'number',
description: 'Depth bound limit (1-5). Defaults to 3.',
minimum: 1,
maximum: 5,
default: 3,
},
};
export const GET_DESCENDANTS: ToolDefinition = {
name: 'get_descendants',
description:
'Returns descendants of a specific individual up to a maximum depth of 5 generations.',
inputSchema: {
type: 'object',
properties: {
id: {type: 'string', description: 'Target individual ID'},
generations: {
type: 'number',
description: 'Depth bound limit (1-5). Defaults to 3.',
minimum: 1,
maximum: 5,
default: 3,
},
required: ['id'],
},
required: ['id'],
},
];
};

View File

@@ -2,7 +2,7 @@ import {expect, test} from '@playwright/test';
import {setupGedcomRoute} from './helpers';
test.describe('Core SVG Canvas Layouts @visual', () => {
test.beforeEach(async ({page, context}) => {
test.beforeEach(async ({context}) => {
await setupGedcomRoute(context);
});

4
tests/global.d.ts vendored
View File

@@ -1,8 +1,8 @@
import {ModelContext} from '../src/webmcp_types';
import {ModelContext, ToolDefinition} from '../src/webmcp_types';
declare global {
interface Window {
__registeredTools?: any[];
__registeredTools?: ToolDefinition[];
}
interface Navigator {

View File

@@ -1,4 +1,5 @@
import {expect, test} from '@playwright/test';
import {ToolDefinition} from '../src/webmcp_types';
import {setupGedcomRoute} from './helpers';
const EXPECTED_TOOL_NAMES = [
@@ -17,10 +18,10 @@ test.describe('WebMCP Integration', () => {
// Add init script to expose modelContext mock BEFORE application boots.
await page.addInitScript(() => {
const registeredTools: any[] = [];
const registeredTools: ToolDefinition[] = [];
window.__registeredTools = registeredTools;
window.navigator.modelContext = {
registerTool: (tool: any) => {
registerTool: (tool: ToolDefinition) => {
registeredTools.push(tool);
},
unregisterTool: (name: string) => {
@@ -42,7 +43,7 @@ test.describe('WebMCP Integration', () => {
const toolNames = await page.evaluate(() =>
window.__registeredTools
? window.__registeredTools.map((t: any) => t.name)
? window.__registeredTools.map((t) => t.name)
: [],
);
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.
await page.evaluate(async () => {
const focusTool = window.__registeredTools
? window.__registeredTools.find((t: any) => t.name === 'focus_indi')
? window.__registeredTools.find((t) => t.name === 'focus_indi')
: null;
if (!focusTool) throw new Error('focus_indi tool not found');
await focusTool.execute({id: 'I21'}); // Shifts view focus to Chike.