mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-05-26 15:16:14 +00:00
Improve code quality
Fix lint warnings
This commit is contained in:
11
.eslintrc.js
11
.eslintrc.js
@@ -25,5 +25,14 @@ module.exports = {
|
||||
'plugin:react/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
],
|
||||
rules: {},
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
33
src/app.tsx
33
src/app.tsx
@@ -25,6 +25,7 @@ import {
|
||||
GedcomUrlDataSource,
|
||||
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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(' ');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
1
src/family-chart.d.ts
vendored
1
src/family-chart.d.ts
vendored
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// Data type definitions for the family-chart library.
|
||||
declare module 'family-chart' {
|
||||
export function createStore(args: any): any;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -20,7 +20,7 @@ export function UrlMenu(props: Props) {
|
||||
useEffect(() => {
|
||||
if (dialogOpen) {
|
||||
setUrl('');
|
||||
inputRef.current!.focus();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [dialogOpen]);
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -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
4
tests/global.d.ts
vendored
@@ -1,8 +1,8 @@
|
||||
import {ModelContext} from '../src/webmcp_types';
|
||||
import {ModelContext, ToolDefinition} from '../src/webmcp_types';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__registeredTools?: any[];
|
||||
__registeredTools?: ToolDefinition[];
|
||||
}
|
||||
|
||||
interface Navigator {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user