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/recommended',
'plugin:react/jsx-runtime', 'plugin:react/jsx-runtime',
], ],
rules: {}, rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
},
}; };

View File

@@ -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:

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. */ /** 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;
} }

View File

@@ -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.',
);
}
} }
}); });
} }

View File

@@ -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) => {

View File

@@ -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(' ');
} }

View File

@@ -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;
} }

View File

@@ -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}`,
]), ]),
); );

View File

@@ -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];
} }

View File

@@ -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);

View File

@@ -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;

View File

@@ -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(

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) || ''; 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}`}
> >

View File

@@ -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({

View File

@@ -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);
} }

View File

@@ -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}
/> />

View File

@@ -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]);

View File

@@ -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() {

View File

@@ -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: {

View File

@@ -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);
} }

View File

@@ -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
} }

View File

@@ -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,
);
} }

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 */
} }

View File

@@ -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'],
}, },
]; };

View File

@@ -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
View File

@@ -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 {

View File

@@ -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.