From 8202c9cd05802047f72c1f1954d90978a96d17dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemek=20Wi=C4=99ch?= Date: Sun, 10 May 2026 21:56:51 +0200 Subject: [PATCH] Improve code quality Fix lint warnings --- .eslintrc.js | 11 +- src/app.tsx | 33 +++-- src/changelog.tsx | 8 +- src/chart.tsx | 57 ++++++--- src/datasource/embedded.ts | 10 +- src/datasource/gedcom_generator.ts | 3 +- src/datasource/load_data.ts | 9 +- src/datasource/wikitree.ts | 1 + src/datasource/wikitree_transformer.ts | 11 +- src/donatso-chart.tsx | 2 +- src/family-chart.d.ts | 1 + src/index.tsx | 5 +- src/intro.tsx | 4 +- src/menu/search.tsx | 12 +- src/menu/search_index.ts | 5 +- src/menu/top_bar.tsx | 8 +- src/menu/url_menu.tsx | 2 +- src/menu/wikitree_menu.tsx | 4 +- src/sidepanel/config/config.tsx | 23 ++-- src/util/analytics.ts | 3 +- src/util/analytics_noop.ts | 2 +- src/util/date_util.ts | 6 +- src/util/gedcom_util.spec.ts | 25 ++-- src/util/gedcom_util.ts | 22 ++-- src/webmcp.ts | 88 ++++++++----- src/webmcp_definitions.ts | 166 +++++++++++++------------ tests/charts_visual.spec.ts | 2 +- tests/global.d.ts | 4 +- tests/webmcp.spec.ts | 9 +- 29 files changed, 315 insertions(+), 221 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index a985e0e..b5a8a66 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -25,5 +25,14 @@ module.exports = { 'plugin:react/recommended', 'plugin:react/jsx-runtime', ], - rules: {}, + rules: { + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], + }, }; diff --git a/src/app.tsx b/src/app.tsx index 26606c9..796e43c 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -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, + 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): Arguments { +function getArguments(location: H.Location): 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) { + function updateUrl(args: queryString.ParsedQuery) { 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 ( @@ -570,7 +576,7 @@ export function App() { } return ( ; + return ; case AppState.INITIAL: case AppState.LOADING: diff --git a/src/changelog.tsx b/src/changelog.tsx index b4365c1..3e1f2a7 100644 --- a/src/changelog.tsx +++ b/src/changelog.tsx @@ -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; } diff --git a/src/chart.tsx b/src/chart.tsx index d03b554..459d01f 100644 --- a/src/chart.tsx +++ b/src/chart.tsx @@ -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 { const reader = new FileReader(); reader.readAsDataURL(blob); - return new Promise((resolve, reject) => { + return new Promise((resolve, _reject) => { reader.onload = (e) => resolve((e.target as FileReader).result as string); }); } @@ -103,7 +103,7 @@ async function inlineImages(svg: Element): Promise { function loadImage(blob: Blob): Promise { const image = new Image(); image.src = URL.createObjectURL(blob); - return new Promise((resolve, reject) => { + return new Promise((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; + private zoomBehavior?: ZoomBehavior; /** Props that will be used for rerendering. */ private rerenderProps?: ChartProps; private rerenderResetPosition?: boolean; zoom(factor: number) { - const parent = select('#svgContainer') as Selection; - 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.', + ); + } } }); } diff --git a/src/datasource/embedded.ts b/src/datasource/embedded.ts index f886d60..37eca69 100644 --- a/src/datasource/embedded.ts +++ b/src/datasource/embedded.ts @@ -33,9 +33,9 @@ export interface EmbeddedSourceSpec { /** GEDCOM file received from outside of the iframe. */ export class EmbeddedDataSource implements DataSource { isNewData( - newSource: SourceSelection, - oldSource: SourceSelection, - data?: TopolaData, + _newSource: SourceSelection, + _oldSource: SourceSelection, + _data?: TopolaData, ): boolean { // Never reload data. return false; @@ -44,7 +44,7 @@ export class EmbeddedDataSource implements DataSource { 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 { } async loadData( - source: SourceSelection, + _source: SourceSelection, ): Promise { // Notify the parent window that we are ready. return new Promise((resolve, reject) => { diff --git a/src/datasource/gedcom_generator.ts b/src/datasource/gedcom_generator.ts index 2a693a8..a2adb90 100644 --- a/src/datasource/gedcom_generator.ts +++ b/src/datasource/gedcom_generator.ts @@ -27,7 +27,8 @@ const MONTHS = new Map([ ]); 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(' '); } diff --git a/src/datasource/load_data.ts b/src/datasource/load_data.ts index 06322d7..f692558 100644 --- a/src/datasource/load_data.ts +++ b/src/datasource/load_data.ts @@ -151,13 +151,18 @@ export interface UploadSourceSpec { images?: Map; } +export interface UploadLocationState { + data: string; + images: Map; +} + /** Files opened from the local computer. */ export class UploadedDataSource implements DataSource { // isNewData(args: Arguments, state: State): boolean { isNewData( newSource: SourceSelection, oldSource: SourceSelection, - data?: TopolaData, + _data?: TopolaData, ): boolean { return newSource.spec.hash !== oldSource.spec.hash; } @@ -196,7 +201,7 @@ export class GedcomUrlDataSource implements DataSource { isNewData( newSource: SourceSelection, oldSource: SourceSelection, - data?: TopolaData, + _data?: TopolaData, ): boolean { return newSource.spec.url !== oldSource.spec.url; } diff --git a/src/datasource/wikitree.ts b/src/datasource/wikitree.ts index 788ef79..809f9f2 100644 --- a/src/datasource/wikitree.ts +++ b/src/datasource/wikitree.ts @@ -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}`, ]), ); diff --git a/src/datasource/wikitree_transformer.ts b/src/datasource/wikitree_transformer.ts index 727e3e3..21550a5 100644 --- a/src/datasource/wikitree_transformer.ts +++ b/src/datasource/wikitree_transformer.ts @@ -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]; } diff --git a/src/donatso-chart.tsx b/src/donatso-chart.tsx index 7e7bdf6..ac015bb 100644 --- a/src/donatso-chart.tsx +++ b/src/donatso-chart.tsx @@ -19,7 +19,7 @@ function convertData(data: JsonGedcomData, intl: IntlShape) { const famMap = new Map(); 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); diff --git a/src/family-chart.d.ts b/src/family-chart.d.ts index 7ea5572..89f1ee9 100644 --- a/src/family-chart.d.ts +++ b/src/family-chart.d.ts @@ -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; diff --git a/src/index.tsx b/src/index.tsx index 606772d..1afb569 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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( diff --git a/src/intro.tsx b/src/intro.tsx index b4e97d8..545a4e0 100644 --- a/src/intro.tsx +++ b/src/intro.tsx @@ -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() { />

- version: {formatBuildDate(import.meta.env.VITE_GIT_TIME!)} ( + version: {formatBuildDate(import.meta.env.VITE_GIT_TIME)} ( diff --git a/src/menu/search.tsx b/src/menu/search.tsx index 0ce34c8..8f7299a 100644 --- a/src/menu/search.tsx +++ b/src/menu/search.tsx @@ -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 ( onChange(data.value!)} + onSearchChange={(_, data) => onChange(data.value)} onResultSelect={(_, data) => handleResultSelect(data.result.id)} results={searchResults} noResultsMessage={intl.formatMessage({ diff --git a/src/menu/search_index.ts b/src/menu/search_index.ts index 7d49186..51031c3 100644 --- a/src/menu/search_index.ts +++ b/src/menu/search_index.ts @@ -57,7 +57,7 @@ function getHusbandLastName( } class LunrSearchIndex implements SearchIndex { - private index: lunr.Index | undefined; + private index!: lunr.Index; private indiMap: Map; private famMap: Map; @@ -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); } diff --git a/src/menu/top_bar.tsx b/src/menu/top_bar.tsx index e228a8e..fcc8ddd 100644 --- a/src/menu/top_bar.tsx +++ b/src/menu/top_bar.tsx @@ -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) { {chartTypeItems} @@ -312,9 +312,9 @@ export function TopBar(props: Props) {

- {props.showingChart && ( + {props.showingChart && props.data && ( diff --git a/src/menu/url_menu.tsx b/src/menu/url_menu.tsx index 2cb567d..386cbf4 100644 --- a/src/menu/url_menu.tsx +++ b/src/menu/url_menu.tsx @@ -20,7 +20,7 @@ export function UrlMenu(props: Props) { useEffect(() => { if (dialogOpen) { setUrl(''); - inputRef.current!.focus(); + inputRef.current?.focus(); } }, [dialogOpen]); diff --git a/src/menu/wikitree_menu.tsx b/src/menu/wikitree_menu.tsx index e830b04..a3fa706 100644 --- a/src/menu/wikitree_menu.tsx +++ b/src/menu/wikitree_menu.tsx @@ -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() { diff --git a/src/sidepanel/config/config.tsx b/src/sidepanel/config/config.tsx index 3be84ea..75c031d 100644 --- a/src/sidepanel/config/config.tsx +++ b/src/sidepanel/config/config.tsx @@ -54,7 +54,7 @@ const SEX_ARG = new Map([ const SEX_ARG_INVERSE = new Map(); SEX_ARG.forEach((v, k) => SEX_ARG_INVERSE.set(v, k)); -export function argsToConfig(args: ParsedQuery): Config { +export function argsToConfig(args: ParsedQuery): Config { const getParam = (name: string) => { const value = args[name]; return typeof value === 'string' ? value : undefined; @@ -67,12 +67,21 @@ export function argsToConfig(args: ParsedQuery): Config { }; } -export function configToArgs(config: Config): ParsedQuery { - 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: { diff --git a/src/util/analytics.ts b/src/util/analytics.ts index 337f9d2..69da7f8 100644 --- a/src/util/analytics.ts +++ b/src/util/analytics.ts @@ -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); } diff --git a/src/util/analytics_noop.ts b/src/util/analytics_noop.ts index 9f2bed5..b334c3f 100644 --- a/src/util/analytics_noop.ts +++ b/src/util/analytics_noop.ts @@ -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 } diff --git a/src/util/date_util.ts b/src/util/date_util.ts index 0500916..54a2710 100644 --- a/src/util/date_util.ts +++ b/src/util/date_util.ts @@ -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); } diff --git a/src/util/gedcom_util.spec.ts b/src/util/gedcom_util.spec.ts index fe1e0ce..cb27d99 100644 --- a/src/util/gedcom_util.spec.ts +++ b/src/util/gedcom_util.spec.ts @@ -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); diff --git a/src/util/gedcom_util.ts b/src/util/gedcom_util.ts index 2911f05..461069c 100644 --- a/src/util/gedcom_util.ts +++ b/src/util/gedcom_util.ts @@ -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 { } 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): 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); diff --git a/src/webmcp.ts b/src/webmcp.ts index 77a0891..6731ecf 100644 --- a/src/webmcp.ts +++ b/src/webmcp.ts @@ -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) => + this.handleSearchIndi(params as {query: string}), + }, + { + ...INSPECT_INDI, + execute: (params: Record) => + this.handleInspectIndi(params as {id: string}), + }, + { + ...FOCUS_INDI, + execute: (params: Record) => + this.handleFocusIndi(params as {id: string}), + }, + { + ...FIND_RELATIONSHIP_PATH, + execute: (params: Record) => + this.handleFindRelationshipPath( + params as {source: string; target: string}, + ), + }, + { + ...GET_ANCESTORS, + execute: (params: Record) => + this.handleGetAncestors(params as {id: string; generations: number}), + }, + { + ...GET_DESCENDANTS, + execute: (params: Record) => + 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 Promise> - )[toolDef.name]; - if (execute) { - modelContext.registerTool({ - ...toolDef, - execute: execute as ( - params: Record, - ) => Promise, - }); - } + 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 */ } diff --git a/src/webmcp_definitions.ts b/src/webmcp_definitions.ts index 7736351..0b807d0 100644 --- a/src/webmcp_definitions.ts +++ b/src/webmcp_definitions.ts @@ -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'], }, -]; +}; diff --git a/tests/charts_visual.spec.ts b/tests/charts_visual.spec.ts index a02bf53..4142428 100644 --- a/tests/charts_visual.spec.ts +++ b/tests/charts_visual.spec.ts @@ -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); }); diff --git a/tests/global.d.ts b/tests/global.d.ts index 4c10c61..ccc490a 100644 --- a/tests/global.d.ts +++ b/tests/global.d.ts @@ -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 { diff --git a/tests/webmcp.spec.ts b/tests/webmcp.spec.ts index 4b49bf0..6647ae8 100644 --- a/tests/webmcp.spec.ts +++ b/tests/webmcp.spec.ts @@ -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.