mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(plugins): search plugins can answer the place search as you type
A searchProvider plugin can now implement an optional suggest() beside search(). Its places then show up in the place search's typed-ahead list, after the core suggestions and marked with the plugin's name, on desktop and phone. An index that cannot take a request per keystroke leaves it out and keeps answering the full search only. The child reports which functions each hook object carries at load, so GET /api/plugin-search/suggest only asks the plugins that implement suggest: from the second character, three rows at most across all of them, 800 ms to answer. The feed marks plugins holding hook:search-provider and the client only asks the route while one is installed. A plugin row carries its whole place, so picking it skips the details lookup, which knows no plugin id. The phone's search results now name their source as the desktop ones do, and the desktop dropdown scrolls instead of running past the dialog. Addresses discussion #2221.
This commit is contained in:
@@ -1107,7 +1107,7 @@ export const memoriesApi = {
|
||||
* They filter on the axios cancel code and on the DOMException name, so an
|
||||
* abort from the cache path has to look like one or it surfaces as a toast.
|
||||
*/
|
||||
function abortedError(): Error & { code: string } {
|
||||
export function abortedError(): Error & { code: string } {
|
||||
const err = new Error('canceled') as Error & { code: string }
|
||||
err.name = 'CanceledError'
|
||||
err.code = 'ERR_CANCELED'
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { pluginSuggestResultSchema, type PluginSuggestResult } from '@trek/shared'
|
||||
import { apiClient, parseInDev } from './client'
|
||||
|
||||
/**
|
||||
* GET /api/plugin-search/suggest (#2221): what the plugins that answer as you type
|
||||
* found for the query so far. The place search reaches it through
|
||||
* repo/pluginSearchRepo.ts, which adds the offline guard and the strict check.
|
||||
*/
|
||||
export const pluginSearchApi = {
|
||||
suggest: (
|
||||
query: string,
|
||||
lang: string,
|
||||
near: { lat: number; lng: number } | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<PluginSuggestResult> =>
|
||||
apiClient
|
||||
.get('/plugin-search/suggest', { params: { q: query, lang, lat: near?.lat, lng: near?.lng }, signal })
|
||||
.then(r => parseInDev(pluginSuggestResultSchema, r.data, 'pluginSearch.suggest')),
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-067, plus FE-PLANNER-PLACEFORM-068 to -092
|
||||
// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-067, plus FE-PLANNER-PLACEFORM-068 to -095
|
||||
import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
@@ -7,6 +7,7 @@ import { useAuthStore } from '../../store/authStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import { usePermissionsStore } from '../../store/permissionsStore';
|
||||
import { usePluginStore } from '../../store/pluginStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip, buildPlace, buildCategory, buildAssignment } from '../../../tests/helpers/factories';
|
||||
import PlaceFormModal from './PlaceFormModal';
|
||||
@@ -2202,3 +2203,81 @@ describe('PlaceFormModal as a road trip service stop', () => {
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlaceFormModal plugin search (#2221)', () => {
|
||||
const ATP = { id: 'all-the-places', name: 'All the Places', type: 'integration' as const, icon: null };
|
||||
const hit = {
|
||||
osm_id: 'plugin:all-the-places:ichiran-ueno', name: 'Ichiran Ueno', address: 'Ueno 6-11-12, Taito',
|
||||
lat: 35.7101, lng: 139.7745, rating: 4.3, website: 'https://ichiran.com/shop/ueno', phone: '+81 3 5818 3531',
|
||||
category: 'restaurant', description: null, source: 'plugin:all-the-places', pluginId: 'all-the-places',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
usePluginStore.setState({ plugins: [] });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-093: a search plugin that answers as you type lists its places under the core ones, marked with its name', async () => {
|
||||
usePluginStore.setState({ plugins: [{ ...ATP, searchProvider: true }] });
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({ suggestions: [{ placeId: 'gers:1', mainText: 'Ichiran Shibuya', secondaryText: 'Jinnan', source: 'trek-places' }], source: 'trek-places' })),
|
||||
http.get('/api/plugin-search/suggest', () => HttpResponse.json({ places: [hit] })),
|
||||
);
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'Ichiran');
|
||||
|
||||
const pluginRow = (await screen.findByText('Ichiran Ueno')).closest('button')!;
|
||||
const coreRow = screen.getByText('Ichiran Shibuya').closest('button')!;
|
||||
expect(within(pluginRow).getByText('All the Places')).toBeInTheDocument();
|
||||
expect(within(coreRow).getByText('TREK')).toBeInTheDocument();
|
||||
// Appended, never ahead of the ranked core list.
|
||||
expect(coreRow.compareDocumentPosition(pluginRow) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-094: picking a plugin row takes its place as it is, with no details lookup and no search', async () => {
|
||||
usePluginStore.setState({ plugins: [{ ...ATP, searchProvider: true }] });
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const detour = vi.fn();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () => HttpResponse.json({ suggestions: [], source: 'trek-places' })),
|
||||
http.get('/api/plugin-search/suggest', () => HttpResponse.json({ places: [hit] })),
|
||||
http.get('/api/maps/details/:placeId', () => { detour(); return HttpResponse.json({ place: null }); }),
|
||||
http.post('/api/maps/search', () => { detour(); return HttpResponse.json({ places: [], source: 'trek-places' }); }),
|
||||
);
|
||||
render(<PlaceFormModal {...defaultProps} onSave={onSave} />);
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'Ichiran');
|
||||
await user.click(await screen.findByText('Ichiran Ueno'));
|
||||
|
||||
expect(await screen.findByDisplayValue('35.7101')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('139.7745')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('https://ichiran.com/shop/ueno')).toBeInTheDocument();
|
||||
expect(detour).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'Ichiran Ueno', address: 'Ueno 6-11-12, Taito', osm_id: 'plugin:all-the-places:ichiran-ueno',
|
||||
}));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-095: without a search plugin nothing is asked per keystroke, and a searched plugin row still names its plugin', async () => {
|
||||
usePluginStore.setState({ plugins: [ATP] });
|
||||
const user = userEvent.setup();
|
||||
const perKeystroke = vi.fn();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () => HttpResponse.json({ suggestions: [], source: 'trek-places' })),
|
||||
http.get('/api/plugin-search/suggest', () => { perKeystroke(); return HttpResponse.json({ places: [] }); }),
|
||||
http.post('/api/maps/search', () =>
|
||||
HttpResponse.json({ places: [{ name: 'Ichiran Shibuya', address: 'Jinnan', lat: 35.66, lng: 139.7, source: 'trek-places' }], source: 'trek-places' })),
|
||||
http.get('/api/plugin-search', () => HttpResponse.json({ places: [hit] })),
|
||||
);
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
await user.type(screen.getByPlaceholderText('Search places...'), 'Ichiran{Enter}');
|
||||
|
||||
const pluginRow = (await screen.findByText('Ichiran Ueno')).closest('button')!;
|
||||
expect(within(pluginRow).getByText('All the Places')).toBeInTheDocument();
|
||||
expect(perKeystroke).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,8 +18,9 @@ import { useTranslation } from '../../i18n'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { DEFAULT_FORM, isMapUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import { offersGoogleRetry, selectGoogleHoldsSlot, sourceLabelFor } from '../../utils/placeSource'
|
||||
import { corePickRank, offersGoogleRetry, selectGoogleHoldsSlot } from '../../utils/placeSource'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
import { usePlaceSuggestions } from '../../hooks/usePlaceSuggestions'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
import type { Place, Category, Assignment, BudgetItem } from '../../types'
|
||||
@@ -78,7 +79,8 @@ interface PlaceFormModalProps {
|
||||
*
|
||||
* `source`, `lat` and `lng` are optional because not every index fills them:
|
||||
* Google answers with neither, and the mark falls back to the name the whole
|
||||
* list carries.
|
||||
* list carries. `place` is only on a plugin's row, which brings its whole place
|
||||
* along because no details lookup knows a plugin id (#2221).
|
||||
*/
|
||||
type Suggestion = {
|
||||
placeId: string
|
||||
@@ -87,6 +89,7 @@ type Suggestion = {
|
||||
source?: string
|
||||
lat?: number
|
||||
lng?: number
|
||||
place?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** The mark itself. Quiet on purpose: it answers a question, it does not advertise. */
|
||||
@@ -363,6 +366,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
// thousand identically named places is meant. Autocomplete wants a box, the
|
||||
// search wants a point; useLocationBias derives both from the same places.
|
||||
const { box: locationBias, point: locationBiasPoint } = useLocationBias()
|
||||
const { autocomplete, sourceLabel } = usePlaceSuggestions()
|
||||
|
||||
/**
|
||||
* What a stop on a drive might be a second copy of, said while the form is being filled.
|
||||
@@ -388,7 +392,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
const controller = new AbortController()
|
||||
acAbortRef.current = controller
|
||||
try {
|
||||
const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal, placesSessionRef.current.current())
|
||||
const result = await autocomplete(query, language, locationBias, controller.signal, placesSessionRef.current.current())
|
||||
acMetaRef.current = { query, source: result.source || 'unknown' }
|
||||
setAcSuggestions(result.suggestions || [])
|
||||
setAcSource(result.source || '')
|
||||
@@ -399,7 +403,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
console.error('Autocomplete failed:', err)
|
||||
setAcSuggestions([])
|
||||
}
|
||||
}, [language, locationBias])
|
||||
}, [autocomplete, language, locationBias])
|
||||
|
||||
// Debounce effect — only watches mapsSearch
|
||||
useEffect(() => {
|
||||
@@ -407,6 +411,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
|
||||
const trimmed = mapsSearch.trim()
|
||||
if (trimmed.length < 2 || isMapUrl(trimmed)) {
|
||||
// A list still on its way belongs to a query that is gone.
|
||||
acAbortRef.current?.abort()
|
||||
setAcSuggestions([])
|
||||
setAcHighlight(-1)
|
||||
placesSessionRef.current.end()
|
||||
@@ -518,8 +524,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
|
||||
const handleSelectSuggestion = async (suggestion: Suggestion) => {
|
||||
// Read before the list is cleared: this is the rank the user saw.
|
||||
const acRank = acSuggestions.findIndex(s => s.placeId === suggestion.placeId)
|
||||
const acCount = acSuggestions.length
|
||||
const acPick = corePickRank(acSuggestions, suggestion)
|
||||
acAbortRef.current?.abort()
|
||||
setAcSuggestions([])
|
||||
setAcHighlight(-1)
|
||||
const previousSearch = mapsSearch
|
||||
@@ -534,16 +540,18 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
// miss and fall back to the reliable text-search path the search button
|
||||
// uses (its results already carry coordinates), so dropdown items stay
|
||||
// clickable instead of dead-ending on "Place search failed". (#1192)
|
||||
let place: Record<string, unknown> | null = null
|
||||
try {
|
||||
// Spends the session the suggestions opened, so Google bills the search
|
||||
// once rather than per keystroke.
|
||||
const result = await mapsApi.details(suggestion.placeId, language, placesSessionRef.current.peek())
|
||||
if (result.place && result.place.lat != null && result.place.lng != null) {
|
||||
place = result.place
|
||||
let place: Record<string, unknown> | null = suggestion.place ?? null
|
||||
if (!place) {
|
||||
try {
|
||||
// Spends the session the suggestions opened, so Google bills the search
|
||||
// once rather than per keystroke.
|
||||
const result = await mapsApi.details(suggestion.placeId, language, placesSessionRef.current.peek())
|
||||
if (result.place && result.place.lat != null && result.place.lng != null) {
|
||||
place = result.place
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch place details:', err)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch place details:', err)
|
||||
}
|
||||
// Closed while the details were on their way: the pick belongs to an
|
||||
// opening that is over, and the fallback search below is not worth a
|
||||
@@ -571,7 +579,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
place = search.places?.[0] ?? null
|
||||
}
|
||||
if (place) {
|
||||
handleSelectMapsResult(place, acRank >= 0 ? { mode: 'autocomplete', rank: acRank, count: acCount } : undefined)
|
||||
handleSelectMapsResult(place, acPick && { mode: 'autocomplete', ...acPick })
|
||||
} else {
|
||||
setMapsSearch(previousSearch)
|
||||
toast.error(t('places.mapsSearchError'))
|
||||
@@ -817,6 +825,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
locationBias,
|
||||
searchInputRef,
|
||||
fetchSuggestions,
|
||||
sourceLabel,
|
||||
handleChange,
|
||||
handleMapsSearch,
|
||||
handleSelectMapsResult,
|
||||
@@ -895,6 +904,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
locationBias,
|
||||
searchInputRef,
|
||||
fetchSuggestions,
|
||||
sourceLabel,
|
||||
handleChange,
|
||||
handleMapsSearch,
|
||||
handleSelectMapsResult,
|
||||
@@ -1002,12 +1012,15 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Autocomplete dropdown */}
|
||||
{/* Autocomplete dropdown. Capped and scrolling, because plugin rows can
|
||||
follow the core ones (#2221) and the list must stay inside the dialog;
|
||||
the row the arrow keys land on is scrolled into view. */}
|
||||
{acSuggestions.length > 0 && (
|
||||
<div className="absolute left-0 right-0 z-20 mt-1 bg-surface-card rounded-lg border border-edge shadow-dropdown overflow-hidden">
|
||||
<div className="absolute left-0 right-0 z-20 mt-1 max-h-96 overflow-y-auto bg-surface-card rounded-lg border border-edge shadow-dropdown">
|
||||
{acSuggestions.map((s, idx) => (
|
||||
<button
|
||||
key={s.placeId}
|
||||
ref={idx === acHighlight ? (el) => el?.scrollIntoView?.({ block: 'nearest' }) : undefined}
|
||||
type="button"
|
||||
onMouseDown={() => handleSelectSuggestion(s)}
|
||||
onMouseEnter={() => setAcHighlight(idx)}
|
||||
@@ -1022,7 +1035,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
<div className="text-xs text-content-muted truncate">{s.secondaryText}</div>
|
||||
)}
|
||||
</div>
|
||||
<SourceBadge label={sourceLabelFor(s, acSource, t)} />
|
||||
<SourceBadge label={sourceLabel(s, acSource)} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -1045,7 +1058,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
<div className="font-medium text-sm truncate">{result.name}</div>
|
||||
<div className="text-xs text-content-muted truncate">{result.address}</div>
|
||||
</div>
|
||||
<SourceBadge label={sourceLabelFor(result, searchSource, t)} />
|
||||
<SourceBadge label={sourceLabel(result, searchSource)} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* FE-PLACESUGG-001 to FE-PLACESUGG-006: the typed-ahead list both place search
|
||||
* shells read from (#2221).
|
||||
*
|
||||
* The desktop form and the phone sheet both call this hook, so what is pinned here
|
||||
* holds for both: plugins are only asked while a search plugin is installed, their
|
||||
* rows come after the core ones, a plugin that finds nothing leaves the core answer
|
||||
* untouched, a superseded keystroke never lands, and a plugin row is labelled with
|
||||
* the plugin's name.
|
||||
*/
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TranslationProvider } from '../i18n/TranslationContext'
|
||||
import { usePluginStore, type ActivePlugin } from '../store/pluginStore'
|
||||
import { usePlaceSuggestions } from './usePlaceSuggestions'
|
||||
|
||||
const autocomplete = vi.fn()
|
||||
vi.mock('../api/client', () => ({
|
||||
mapsApi: { autocomplete: (...args: unknown[]) => autocomplete(...args) },
|
||||
abortedError: () => Object.assign(new Error('canceled'), { name: 'CanceledError', code: 'ERR_CANCELED' }),
|
||||
}))
|
||||
const suggest = vi.fn()
|
||||
vi.mock('../repo/pluginSearchRepo', () => ({ pluginSearchRepo: { suggest: (...args: unknown[]) => suggest(...args) } }))
|
||||
|
||||
const BOX = { low: { lat: 35.6, lng: 139.6 }, high: { lat: 35.7, lng: 139.8 } }
|
||||
const core = {
|
||||
suggestions: [{ placeId: 'gers:1', mainText: 'Ichiran', secondaryText: 'Shibuya', source: 'trek-places' }],
|
||||
source: 'trek-places',
|
||||
}
|
||||
const pluginRow = {
|
||||
placeId: 'plugin:all-the-places:ichiran-ueno', mainText: 'Ichiran Ueno', secondaryText: 'Ueno 6-11',
|
||||
source: 'plugin:all-the-places', lat: 35.71, lng: 139.77, place: { osm_id: 'plugin:all-the-places:ichiran-ueno' },
|
||||
}
|
||||
const plugin = (over: Partial<ActivePlugin>): ActivePlugin => ({ id: 'p', name: 'P', type: 'integration', icon: null, ...over })
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children)
|
||||
|
||||
beforeEach(() => {
|
||||
autocomplete.mockResolvedValue(core)
|
||||
suggest.mockResolvedValue([pluginRow])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
usePluginStore.setState({ plugins: [] })
|
||||
})
|
||||
|
||||
describe('usePlaceSuggestions', () => {
|
||||
it('FE-PLACESUGG-001: without a search plugin installed, the plugin route is never asked', async () => {
|
||||
usePluginStore.setState({ plugins: [plugin({ id: 'remote-api', name: 'Remote API' })] })
|
||||
const { result } = renderHook(() => usePlaceSuggestions(), { wrapper })
|
||||
const signal = new AbortController().signal
|
||||
expect(await result.current.autocomplete('ichi', 'ja', BOX, signal, 'session-1')).toBe(core)
|
||||
expect(autocomplete).toHaveBeenCalledWith('ichi', 'ja', BOX, signal, 'session-1')
|
||||
expect(suggest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLACESUGG-002: with one, its rows follow the core rows and it is asked at the centre of the bias', async () => {
|
||||
usePluginStore.setState({ plugins: [plugin({ id: 'all-the-places', name: 'All the Places', searchProvider: true })] })
|
||||
const { result } = renderHook(() => usePlaceSuggestions(), { wrapper })
|
||||
const signal = new AbortController().signal
|
||||
const answer = await result.current.autocomplete('ichi', 'ja', BOX, signal)
|
||||
expect(answer).toEqual({ ...core, suggestions: [...core.suggestions, pluginRow] })
|
||||
expect(suggest).toHaveBeenCalledWith('ichi', 'ja', expect.objectContaining({ lat: expect.closeTo(35.65, 6), lng: expect.closeTo(139.7, 6) }), signal)
|
||||
})
|
||||
|
||||
it('FE-PLACESUGG-003: a plugin that finds nothing leaves the core answer as it was', async () => {
|
||||
usePluginStore.setState({ plugins: [plugin({ id: 'all-the-places', searchProvider: true })] })
|
||||
suggest.mockResolvedValue([])
|
||||
const { result } = renderHook(() => usePlaceSuggestions(), { wrapper })
|
||||
expect(await result.current.autocomplete('ichi', 'ja', undefined, new AbortController().signal)).toBe(core)
|
||||
expect(suggest).toHaveBeenCalledWith('ichi', 'ja', undefined, expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('FE-PLACESUGG-004: a failing core call still fails the list, whatever the plugins found', async () => {
|
||||
usePluginStore.setState({ plugins: [plugin({ id: 'all-the-places', searchProvider: true })] })
|
||||
autocomplete.mockRejectedValue(new Error('Network Error'))
|
||||
const { result } = renderHook(() => usePlaceSuggestions(), { wrapper })
|
||||
await expect(result.current.autocomplete('ichi', 'ja', BOX, new AbortController().signal)).rejects.toThrow('Network Error')
|
||||
})
|
||||
|
||||
it('FE-PLACESUGG-006: an answer to a keystroke that was superseded rejects as cancelled, even when both halves arrived', async () => {
|
||||
usePluginStore.setState({ plugins: [plugin({ id: 'all-the-places', searchProvider: true })] })
|
||||
const superseded = new AbortController()
|
||||
suggest.mockImplementation(async () => { superseded.abort(); return [] })
|
||||
const { result } = renderHook(() => usePlaceSuggestions(), { wrapper })
|
||||
await expect(result.current.autocomplete('ichi', 'ja', BOX, superseded.signal)).rejects.toMatchObject({ name: 'CanceledError' })
|
||||
})
|
||||
|
||||
it('FE-PLACESUGG-005: a plugin row is marked with the name the plugin was installed under', () => {
|
||||
usePluginStore.setState({ plugins: [plugin({ id: 'all-the-places', name: 'All the Places', searchProvider: true })] })
|
||||
const { result } = renderHook(() => usePlaceSuggestions(), { wrapper })
|
||||
expect(result.current.sourceLabel(pluginRow, 'trek-places')).toBe('All the Places')
|
||||
expect(result.current.sourceLabel(core.suggestions[0], 'trek-places')).toBe('TREK')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useCallback } from 'react'
|
||||
import { abortedError, mapsApi } from '../api/client'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { pluginSearchRepo } from '../repo/pluginSearchRepo'
|
||||
import { usePluginStore } from '../store/pluginStore'
|
||||
import { sourceLabelFor } from '../utils/placeSource'
|
||||
import { pointFromBox, type LocationBiasBox } from './useLocationBias'
|
||||
|
||||
/**
|
||||
* The typed-ahead half of the place search, for the desktop form and the phone sheet
|
||||
* alike: the core suggestions with the rows of the plugins that answer as you type
|
||||
* appended (#2221), and the mark each row carries.
|
||||
*
|
||||
* Appended rather than interleaved, as the full search does it: the core list is
|
||||
* ranked against the query and has earned its order. The plugin route is only asked
|
||||
* while a search plugin is installed, so an instance without one sends exactly the
|
||||
* requests it always did, and the route itself only reaches the plugins whose build
|
||||
* implements `suggest`, the ones that can take a request per keystroke.
|
||||
*/
|
||||
export function usePlaceSuggestions() {
|
||||
const { t } = useTranslation()
|
||||
const plugins = usePluginStore(s => s.plugins)
|
||||
const asksPlugins = plugins.some(p => p.searchProvider)
|
||||
|
||||
const autocomplete = useCallback(
|
||||
async (input: string, lang: string, box: LocationBiasBox | undefined, signal: AbortSignal, sessionToken?: string) => {
|
||||
// Side by side, and the plugin half never rejects, so only the core call can
|
||||
// send the list down its error path.
|
||||
const [core, fromPlugins] = await Promise.all([
|
||||
mapsApi.autocomplete(input, lang, box, signal, sessionToken),
|
||||
asksPlugins ? pluginSearchRepo.suggest(input, lang, pointFromBox(box), signal) : [],
|
||||
])
|
||||
// Both shells order their lists by aborting the previous keystroke and dropping
|
||||
// its rejection. The core half can have answered before the abort while the
|
||||
// plugin half was still out, so a superseded answer rejects here as well rather
|
||||
// than overwriting the newer list.
|
||||
if (signal.aborted) throw abortedError()
|
||||
if (fromPlugins.length === 0) return core
|
||||
return { ...core, suggestions: [...(core.suggestions || []), ...fromPlugins] }
|
||||
},
|
||||
[asksPlugins],
|
||||
)
|
||||
|
||||
const sourceLabel = useCallback(
|
||||
(row: unknown, listSource: string) => sourceLabelFor(row, listSource, t, id => plugins.find(p => p.id === id)?.name),
|
||||
[plugins, t],
|
||||
)
|
||||
|
||||
return { autocomplete, sourceLabel }
|
||||
}
|
||||
@@ -2,12 +2,13 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Loader2, RotateCcw, Search } from 'lucide-react'
|
||||
import { mapsApi } from '../../../../api/client'
|
||||
import { useAuthStore } from '../../../../store/authStore'
|
||||
import { offersGoogleRetry, selectGoogleHoldsSlot, sourceLabelFor } from '../../../../utils/placeSource'
|
||||
import { corePickRank, offersGoogleRetry, selectGoogleHoldsSlot } from '../../../../utils/placeSource'
|
||||
import { recordPlacePick } from '../../../../api/placeShadow'
|
||||
import { PlacesSession } from '../../../../utils/placesSession'
|
||||
import { isMapUrl } from '../../../../components/Planner/PlaceFormModal.helpers'
|
||||
import { getApiErrorMessage } from '../../../../utils/apiError'
|
||||
import { pointFromBox } from '../../../../hooks/useLocationBias'
|
||||
import { usePlaceSuggestions } from '../../../../hooks/usePlaceSuggestions'
|
||||
import { FIELD_CLS } from './PlSheetChrome'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
|
||||
@@ -40,6 +41,8 @@ interface Suggestion {
|
||||
source?: string
|
||||
lat?: number
|
||||
lng?: number
|
||||
/** Only on a plugin's row: its whole place, since no details lookup knows a plugin id. */
|
||||
place?: MapsPlace
|
||||
}
|
||||
|
||||
type MapsPlace = Record<string, unknown>
|
||||
@@ -107,6 +110,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
// What answered the last full search, for the line that offers Google instead.
|
||||
const [searchSource, setSearchSource] = useState('')
|
||||
const googleAnswers = useAuthStore(selectGoogleHoldsSlot)
|
||||
const { autocomplete, sourceLabel } = usePlaceSuggestions()
|
||||
|
||||
const setResolving = useCallback(
|
||||
(v: boolean) => {
|
||||
@@ -122,7 +126,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
try {
|
||||
const result = await mapsApi.autocomplete(input, language, locationBias, controller.signal, placesSessionRef.current.current())
|
||||
const result = await autocomplete(input, language, locationBias, controller.signal, placesSessionRef.current.current())
|
||||
acMetaRef.current = { query: input, source: result.source || 'unknown' }
|
||||
setAcSource(result.source || '')
|
||||
setSuggestions(result.suggestions || [])
|
||||
@@ -132,7 +136,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
setSuggestions([])
|
||||
}
|
||||
},
|
||||
[language, locationBias],
|
||||
[autocomplete, language, locationBias],
|
||||
)
|
||||
|
||||
// Debounced autocomplete — URLs and coordinate pastes go to the search button.
|
||||
@@ -140,6 +144,8 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
const trimmed = query.trim()
|
||||
if (trimmed.length < 2 || isMapUrl(trimmed) || COORD_RE.test(trimmed)) {
|
||||
// A list still on its way belongs to a query that is gone.
|
||||
abortRef.current?.abort()
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
@@ -228,8 +234,8 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
|
||||
const handleSelectSuggestion = async (suggestion: Suggestion) => {
|
||||
// Read before the list is cleared: this is the rank the user saw.
|
||||
const acRank = suggestions.findIndex(s => s.placeId === suggestion.placeId)
|
||||
const acCount = suggestions.length
|
||||
const acPick = corePickRank(suggestions, suggestion)
|
||||
abortRef.current?.abort()
|
||||
setSuggestions([])
|
||||
const previousQuery = query
|
||||
setQuery('')
|
||||
@@ -238,13 +244,15 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
try {
|
||||
// Details are a fragile second hop (kill-switch, Overpass load) — fall
|
||||
// back to the text-search path so suggestions never dead-end. (#1192)
|
||||
let place: MapsPlace | null = null
|
||||
try {
|
||||
// Spends the session the suggestions opened.
|
||||
const result = await mapsApi.details(suggestion.placeId, language, placesSessionRef.current.peek())
|
||||
if (result.place && result.place.lat != null && result.place.lng != null) place = result.place
|
||||
} catch {
|
||||
// fall through to text search
|
||||
let place: MapsPlace | null = suggestion.place ?? null
|
||||
if (!place) {
|
||||
try {
|
||||
// Spends the session the suggestions opened.
|
||||
const result = await mapsApi.details(suggestion.placeId, language, placesSessionRef.current.peek())
|
||||
if (result.place && result.place.lat != null && result.place.lng != null) place = result.place
|
||||
} catch {
|
||||
// fall through to text search
|
||||
}
|
||||
}
|
||||
if (!place && suggestion.source === 'openstreetmap' && suggestion.lat != null && suggestion.lng != null) {
|
||||
// The layer's second line is the local name, not an address, so joining
|
||||
@@ -266,7 +274,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
place = (search.places?.[0] as MapsPlace | undefined) ?? null
|
||||
}
|
||||
if (place) {
|
||||
applyPlace(place, acRank >= 0 ? { mode: 'autocomplete', rank: acRank, count: acCount } : undefined)
|
||||
applyPlace(place, acPick && { mode: 'autocomplete', ...acPick })
|
||||
} else {
|
||||
setQuery(previousQuery)
|
||||
toast.error(t('places.mapsSearchError'))
|
||||
@@ -325,7 +333,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
<div className="truncate font-geist text-[0.65625rem] text-m-muted">{s.secondaryText}</div>
|
||||
)}
|
||||
</div>
|
||||
<SourceMark label={sourceLabelFor(s, acSource, t)} />
|
||||
<SourceMark label={sourceLabel(s, acSource)} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -341,8 +349,13 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi
|
||||
onClick={() => applyPlace(result, { mode: 'search', rank: idx, count: results.length })}
|
||||
className="block w-full border-t border-[color:var(--m-rowbr)] px-[13px] py-[10px] text-left first:border-t-0"
|
||||
>
|
||||
<div className="truncate text-[0.8125rem] font-semibold text-m-ink">{String(result.name ?? '')}</div>
|
||||
<div className="truncate font-geist text-[0.65625rem] text-m-muted">{String(result.address ?? '')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[0.8125rem] font-semibold text-m-ink">{String(result.name ?? '')}</div>
|
||||
<div className="truncate font-geist text-[0.65625rem] text-m-muted">{String(result.address ?? '')}</div>
|
||||
</div>
|
||||
<SourceMark label={sourceLabel(result, searchSource)} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// FE-REPO-PLUGINSEARCH-001 to FE-REPO-PLUGINSEARCH-006
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { delay, http, HttpResponse } from 'msw'
|
||||
import type { PluginSearchHit } from '@trek/shared'
|
||||
import { server } from '../../tests/helpers/msw/server'
|
||||
import { PLUGIN_SUGGEST_DEADLINE_MS, pluginSearchRepo } from './pluginSearchRepo'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
|
||||
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) }))
|
||||
|
||||
const hit: PluginSearchHit = {
|
||||
osm_id: 'plugin:all-the-places:ichiran-shibuya', name: 'Ichiran Shibuya', address: 'Jinnan 1-22-7, Shibuya',
|
||||
lat: 35.6617, lng: 139.6993, rating: 4.4, website: 'https://ichiran.com', phone: null,
|
||||
category: 'restaurant', description: null, source: 'plugin:all-the-places', pluginId: 'all-the-places',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('pluginSearchRepo.suggest', () => {
|
||||
it('FE-REPO-PLUGINSEARCH-001: asks the typed-ahead route with the query, language and bias, and hands back rows that carry their place', async () => {
|
||||
let url: URL | null = null
|
||||
server.use(http.get('/api/plugin-search/suggest', ({ request }) => {
|
||||
url = new URL(request.url)
|
||||
return HttpResponse.json({ places: [hit] })
|
||||
}))
|
||||
const rows = await pluginSearchRepo.suggest('ichi', 'ja', { lat: 35.66, lng: 139.7 }, new AbortController().signal)
|
||||
expect(Object.fromEntries(url!.searchParams)).toEqual({ q: 'ichi', lang: 'ja', lat: '35.66', lng: '139.7' })
|
||||
expect(rows).toEqual([{
|
||||
placeId: 'plugin:all-the-places:ichiran-shibuya',
|
||||
mainText: 'Ichiran Shibuya',
|
||||
secondaryText: 'Jinnan 1-22-7, Shibuya',
|
||||
source: 'plugin:all-the-places',
|
||||
lat: 35.6617,
|
||||
lng: 139.6993,
|
||||
place: hit,
|
||||
}])
|
||||
})
|
||||
|
||||
it('FE-REPO-PLUGINSEARCH-002: sends no bias when the trip has nowhere to point to', async () => {
|
||||
let url: URL | null = null
|
||||
server.use(http.get('/api/plugin-search/suggest', ({ request }) => {
|
||||
url = new URL(request.url)
|
||||
return HttpResponse.json({ places: [] })
|
||||
}))
|
||||
await pluginSearchRepo.suggest('ichi', 'en', undefined, new AbortController().signal)
|
||||
expect(url!.searchParams.has('lat')).toBe(false)
|
||||
expect(url!.searchParams.has('lng')).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-REPO-PLUGINSEARCH-003: offline it answers empty without making the request', async () => {
|
||||
vi.mocked(isEffectivelyOffline).mockReturnValue(true)
|
||||
const asked = vi.fn()
|
||||
server.use(http.get('/api/plugin-search/suggest', () => { asked(); return HttpResponse.json({ places: [hit] }) }))
|
||||
expect(await pluginSearchRepo.suggest('ichi', 'en', undefined, new AbortController().signal)).toEqual([])
|
||||
expect(asked).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-REPO-PLUGINSEARCH-004: an answer off the contract is dropped whole, not drawn', async () => {
|
||||
server.use(http.get('/api/plugin-search/suggest', () =>
|
||||
HttpResponse.json({ places: [{ ...hit, osm_id: 'node:1' }] })))
|
||||
expect(await pluginSearchRepo.suggest('ichi', 'en', undefined, new AbortController().signal)).toEqual([])
|
||||
server.use(http.get('/api/plugin-search/suggest', () =>
|
||||
HttpResponse.json({ places: [hit, hit, hit, hit] })))
|
||||
expect(await pluginSearchRepo.suggest('ichi', 'en', undefined, new AbortController().signal)).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-REPO-PLUGINSEARCH-005: a failing route or an aborted keystroke is an empty list, never a rejection', async () => {
|
||||
server.use(http.get('/api/plugin-search/suggest', () => HttpResponse.json({ error: 'boom' }, { status: 500 })))
|
||||
expect(await pluginSearchRepo.suggest('ichi', 'en', undefined, new AbortController().signal)).toEqual([])
|
||||
|
||||
server.use(http.get('/api/plugin-search/suggest', () => HttpResponse.json({ places: [hit] })))
|
||||
const superseded = new AbortController()
|
||||
superseded.abort()
|
||||
expect(await pluginSearchRepo.suggest('ichi', 'en', undefined, superseded.signal)).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-REPO-PLUGINSEARCH-006: a plugin that misses the deadline costs its rows, not the list', async () => {
|
||||
server.use(http.get('/api/plugin-search/suggest', async () => {
|
||||
await delay(PLUGIN_SUGGEST_DEADLINE_MS + 500)
|
||||
return HttpResponse.json({ places: [hit] })
|
||||
}))
|
||||
const started = Date.now()
|
||||
expect(await pluginSearchRepo.suggest('ichi', 'en', undefined, new AbortController().signal)).toEqual([])
|
||||
expect(Date.now() - started).toBeLessThan(PLUGIN_SUGGEST_DEADLINE_MS + 400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { pluginSuggestResultSchema, type PluginSearchHit } from '@trek/shared'
|
||||
import { pluginSearchApi } from '../api/pluginSearch'
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
|
||||
/**
|
||||
* A plugin's row in the typed-ahead list. It carries the whole place, because a
|
||||
* plugin id means nothing to the details lookup a core row goes through on a pick:
|
||||
* the row already knows everything its plugin said about the place.
|
||||
*/
|
||||
export interface PluginPlaceSuggestion {
|
||||
placeId: string
|
||||
mainText: string
|
||||
secondaryText: string
|
||||
source: string
|
||||
lat: number
|
||||
lng: number
|
||||
place: PluginSearchHit
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the typed-ahead list waits for the plugin rows, in milliseconds.
|
||||
*
|
||||
* The host gives a provider 800 ms and this adds the round trip. It is a deadline on
|
||||
* the wait, kept here rather than as the request's timeout: a request that fails
|
||||
* without an answer goes through the api client's connectivity probe before it
|
||||
* rejects, and the core suggestions must not wait on that too.
|
||||
*/
|
||||
export const PLUGIN_SUGGEST_DEADLINE_MS = 1000
|
||||
|
||||
function withinDeadline<T>(work: Promise<T>, fallback: T): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const late = new Promise<T>(resolve => { timer = setTimeout(() => resolve(fallback), PLUGIN_SUGGEST_DEADLINE_MS) })
|
||||
return Promise.race([work, late]).finally(() => clearTimeout(timer))
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin rows for the place search's typed-ahead list (#2221).
|
||||
*
|
||||
* Online only and never kept, like every plugin answer. Anything that goes wrong is
|
||||
* an empty list: offline, past the deadline, the next keystroke aborting this one, or
|
||||
* an answer the shared contract refuses. These rows sit under the core suggestions,
|
||||
* and the core list must not fail because an optional index did.
|
||||
*/
|
||||
export const pluginSearchRepo = {
|
||||
async suggest(
|
||||
query: string,
|
||||
lang: string,
|
||||
near: { lat: number; lng: number } | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<PluginPlaceSuggestion[]> {
|
||||
if (isEffectivelyOffline()) return []
|
||||
try {
|
||||
const answer = await withinDeadline(pluginSearchApi.suggest(query, lang, near, signal), null)
|
||||
if (!answer) return []
|
||||
const { places } = pluginSuggestResultSchema.parse(answer)
|
||||
return places.map(hit => ({
|
||||
placeId: hit.osm_id,
|
||||
mainText: hit.name,
|
||||
secondaryText: hit.address,
|
||||
source: hit.source,
|
||||
lat: hit.lat,
|
||||
lng: hit.lng,
|
||||
place: hit,
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -68,6 +68,10 @@ export interface ActivePlugin {
|
||||
* server only sends these when the hook permission is granted). Re-validated on
|
||||
* arrival by readPoiCategories, so everything here is safe to draw. */
|
||||
poiCategories?: PluginPoiCategory[]
|
||||
/** The plugin holds hook:search-provider (#2221), so the place search also asks the
|
||||
* plugin route while it is typed; that route only reaches the plugins whose build
|
||||
* implements `suggest`. */
|
||||
searchProvider?: true
|
||||
/** The plugin holds the geolocation:read grant — its frames may ask the host
|
||||
* for the browser position over the bridge. */
|
||||
geolocation?: true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { googleHoldsSlot, offersGoogleRetry, placesGoogleOnlyHint, selectGoogleHoldsSlot, sourceLabelFor } from './placeSource'
|
||||
import { corePickRank, googleHoldsSlot, offersGoogleRetry, placesGoogleOnlyHint, selectGoogleHoldsSlot, sourceLabelFor } from './placeSource'
|
||||
|
||||
describe('offersGoogleRetry', () => {
|
||||
it('FE-PLACESOURCE-001: offers Google for a list the index or OpenStreetMap answered, only where Google answers', () => {
|
||||
@@ -55,4 +55,30 @@ describe('sourceLabelFor', () => {
|
||||
expect(sourceLabelFor({}, 'google', t)).toBe('Google')
|
||||
expect(sourceLabelFor({}, 'amap', t)).toBe('places.source.amap')
|
||||
})
|
||||
|
||||
it('FE-PLACESOURCE-007: a plugin row names its plugin, by the installed name when there is one', () => {
|
||||
const t = (k: string) => k
|
||||
const names: Record<string, string> = { 'all-the-places': 'All the Places' }
|
||||
const pluginName = (id: string) => names[id]
|
||||
expect(sourceLabelFor({ source: 'plugin:all-the-places' }, 'trek-places', t, pluginName)).toBe('All the Places')
|
||||
// Uninstalled since the list was drawn, or no lookup at hand: the id still says where it came from.
|
||||
expect(sourceLabelFor({ source: 'plugin:gone' }, 'trek-places', t, pluginName)).toBe('gone')
|
||||
expect(sourceLabelFor({ source: 'plugin:all-the-places' }, 'trek-places', t)).toBe('all-the-places')
|
||||
// A merged list's own source names no single row, so an unmarked core row stays unlabelled.
|
||||
expect(sourceLabelFor({}, 'trek-places+plugin:all-the-places', t, pluginName)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('corePickRank', () => {
|
||||
it('FE-PLACESOURCE-008: ranks a core row among the core rows only, and gives a plugin row no rank at all', () => {
|
||||
const rows = [
|
||||
{ placeId: 'gers:1' },
|
||||
{ placeId: 'node:2' },
|
||||
{ placeId: 'plugin:atp:3', place: { osm_id: 'plugin:atp:3' } },
|
||||
{ placeId: 'plugin:atp:4', place: { osm_id: 'plugin:atp:4' } },
|
||||
]
|
||||
expect(corePickRank(rows, rows[1])).toEqual({ rank: 1, count: 2 })
|
||||
expect(corePickRank(rows, rows[2])).toBeUndefined()
|
||||
expect(corePickRank(rows, { placeId: 'gone' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -78,6 +78,28 @@ export function placesGoogleOnlyHint(hasMapsKey: boolean, placesProvider: string
|
||||
return 'admin.placesGoogleOnly.subtitle'
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a picked typed-ahead row stood among the core rows, for the place search log,
|
||||
* or undefined for a row that log has no business ranking.
|
||||
*
|
||||
* A plugin's row (#2221) carries its place and was never ranked by the core index, so
|
||||
* counting it would credit the index with a hit it did not make. Plugin rows follow
|
||||
* the core ones, so a core row's rank is the same with or without them, and the count
|
||||
* leaves them out.
|
||||
*/
|
||||
export function corePickRank(
|
||||
rows: readonly { placeId: string; place?: unknown }[],
|
||||
picked: { placeId: string; place?: unknown },
|
||||
): { rank: number; count: number } | undefined {
|
||||
if (picked.place) return undefined
|
||||
const core = rows.filter(row => !row.place)
|
||||
const rank = core.findIndex(row => row.placeId === picked.placeId)
|
||||
return rank >= 0 ? { rank, count: core.length } : undefined
|
||||
}
|
||||
|
||||
/** How a plugin index marks its rows: `plugin:<pluginId>` (#2221). */
|
||||
const PLUGIN_SOURCE = 'plugin:'
|
||||
|
||||
/**
|
||||
* The label for one row.
|
||||
*
|
||||
@@ -85,12 +107,24 @@ export function placesGoogleOnlyHint(hasMapsKey: boolean, placesProvider: string
|
||||
* is what makes an interleaved list readable. Everything else falls back to what
|
||||
* answered the call: Google never marks its places, and a merged list marks only
|
||||
* the index side, so an unmarked row in one is OpenStreetMap by elimination.
|
||||
*
|
||||
* A plugin's row is named after the plugin, the name the admin installed it under,
|
||||
* which `pluginName` looks up; the plugin id stands in when it cannot.
|
||||
*/
|
||||
export function sourceLabelFor(place: unknown, listSource: string, t: TranslationFn): string | null {
|
||||
export function sourceLabelFor(
|
||||
place: unknown,
|
||||
listSource: string,
|
||||
t: TranslationFn,
|
||||
pluginName?: (pluginId: string) => string | undefined,
|
||||
): string | null {
|
||||
const own = (place as { source?: unknown } | null)?.source
|
||||
const source = typeof own === 'string' && own
|
||||
? own
|
||||
: listSource.includes('openstreetmap') ? 'openstreetmap' : listSource
|
||||
if (source.startsWith(PLUGIN_SOURCE)) {
|
||||
const pluginId = source.slice(PLUGIN_SOURCE.length)
|
||||
return pluginName?.(pluginId) || pluginId || null
|
||||
}
|
||||
if (SOURCE_KEYS[source]) return t(SOURCE_KEYS[source])
|
||||
return SOURCE_LABELS[source] ?? null
|
||||
}
|
||||
|
||||
@@ -13,4 +13,5 @@ import { http, HttpResponse } from 'msw';
|
||||
*/
|
||||
export const pluginSearchHandlers = [
|
||||
http.get('/api/plugin-search', () => HttpResponse.json({ places: [] })),
|
||||
http.get('/api/plugin-search/suggest', () => HttpResponse.json({ places: [] })),
|
||||
];
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { HttpResponse, delay, http } from 'msw'
|
||||
import PlPlaceSearch from '../../../../src/mobile/screens/trip/sheets/PlPlaceSearch'
|
||||
import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'
|
||||
import { useAuthStore } from '../../../../src/store/authStore'
|
||||
import { usePluginStore } from '../../../../src/store/pluginStore'
|
||||
import { buildPlanner } from '../../../helpers/mobileTrip'
|
||||
import { server } from '../../../helpers/msw/server'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../helpers/render'
|
||||
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'
|
||||
import { resetAllStores, seedStore } from '../../../helpers/store'
|
||||
|
||||
// FE-MOB-PLSRCH-001 to FE-MOB-PLSRCH-022b
|
||||
// FE-MOB-PLSRCH-001 to FE-MOB-PLSRCH-025
|
||||
// planner.t echoes the key, so labels/toasts are asserted as their keys.
|
||||
|
||||
const LOUVRE = {
|
||||
@@ -500,3 +501,73 @@ describe('PlPlaceSearch', () => {
|
||||
expect(searchBodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('PlPlaceSearch plugin search (#2221)', () => {
|
||||
const ATP = { id: 'all-the-places', name: 'All the Places', type: 'integration' as const, icon: null }
|
||||
const HIT = {
|
||||
osm_id: 'plugin:all-the-places:ichiran-ueno', name: 'Ichiran Ueno', address: 'Ueno 6-11-12, Taito',
|
||||
lat: 35.7101, lng: 139.7745, rating: 4.3, website: 'https://ichiran.com/shop/ueno', phone: null,
|
||||
category: 'restaurant', description: null, source: 'plugin:all-the-places', pluginId: 'all-the-places',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
autocompleteBodies = []
|
||||
searchBodies = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
usePluginStore.setState({ plugins: [] })
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-023: a plugin that answers as you type adds its rows under the core ones, marked with its name', async () => {
|
||||
usePluginStore.setState({ plugins: [{ ...ATP, searchProvider: true }] })
|
||||
server.use(recordAutocomplete(), http.get('/api/plugin-search/suggest', () => HttpResponse.json({ places: [HIT] })))
|
||||
const { input } = setup()
|
||||
fireEvent.change(input, { target: { value: 'Ichi' } })
|
||||
const pluginRow = (await screen.findByText('Ichiran Ueno')).closest('button')!
|
||||
expect(within(pluginRow).getByText('All the Places')).toBeInTheDocument()
|
||||
const coreRow = screen.getByText('Louvre').closest('button')!
|
||||
expect(coreRow.compareDocumentPosition(pluginRow) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-024: picking a plugin row hands over its place without a details lookup', async () => {
|
||||
usePluginStore.setState({ plugins: [{ ...ATP, searchProvider: true }] })
|
||||
const details = vi.fn()
|
||||
server.use(
|
||||
recordAutocomplete([]),
|
||||
http.get('/api/plugin-search/suggest', () => HttpResponse.json({ places: [HIT] })),
|
||||
http.get('/api/maps/details/:placeId', () => { details(); return HttpResponse.json({ place: null }) }),
|
||||
recordSearch([]),
|
||||
)
|
||||
const { input, onPick } = setup()
|
||||
fireEvent.change(input, { target: { value: 'Ichi' } })
|
||||
fireEvent.click(await screen.findByText('Ichiran Ueno'))
|
||||
await waitFor(() => expect(onPick).toHaveBeenCalledTimes(2))
|
||||
expect(onPick).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
name: 'Ichiran Ueno', address: 'Ueno 6-11-12, Taito', lat: '35.7101', lng: '139.7745',
|
||||
osm_id: 'plugin:all-the-places:ichiran-ueno', website: 'https://ichiran.com/shop/ueno',
|
||||
}))
|
||||
expect(details).not.toHaveBeenCalled()
|
||||
expect(searchBodies).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-MOB-PLSRCH-025: a searched list marks every row with its source, as the desktop form does', async () => {
|
||||
usePluginStore.setState({ plugins: [ATP] })
|
||||
const perKeystroke = vi.fn()
|
||||
server.use(
|
||||
recordAutocomplete([]),
|
||||
http.get('/api/plugin-search/suggest', () => { perKeystroke(); return HttpResponse.json({ places: [] }) }),
|
||||
http.post('/api/maps/search', () =>
|
||||
HttpResponse.json({ places: [{ name: 'Ichiran Shibuya', address: 'Jinnan', lat: 35.66, lng: 139.7, source: 'trek-places' }], source: 'trek-places' })),
|
||||
http.get('/api/plugin-search', () => HttpResponse.json({ places: [HIT] })),
|
||||
)
|
||||
const { input } = setup()
|
||||
fireEvent.change(input, { target: { value: 'Ichiran' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
const pluginRow = (await screen.findByText('Ichiran Ueno')).closest('button')!
|
||||
expect(within(pluginRow).getByText('All the Places')).toBeInTheDocument()
|
||||
expect(within(screen.getByText('Ichiran Shibuya').closest('button')!).getByText('TREK')).toBeInTheDocument()
|
||||
expect(perKeystroke).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -225,7 +225,7 @@ skipped, never fatal):
|
||||
| `mapMarkerProvider` | ≤200 markers per provider |
|
||||
| `warningProvider` | ≤20 warnings per provider, each message ≤300 chars |
|
||||
| `placeDetailProvider` | ≤12 items per provider |
|
||||
| `searchProvider` | ≤20 places per provider, 2 s to answer |
|
||||
| `searchProvider` | `search`: ≤20 places per provider, 2 s to answer. `suggest` (optional): ≤3 places in the dropdown across all providers, 800 ms to answer |
|
||||
| `poiCategoryProvider` | ≤4 declared categories per plugin; ≤60 places per answer (inside `bounds` only), ≤6 detail rows per place, 8 s to answer |
|
||||
| `photoProvider` | ≤60 photos per page |
|
||||
| `calendarSource` | ≤500 events per source per request |
|
||||
@@ -413,6 +413,10 @@ The SDK tooling in this repo is MIT. Your plugin is your own code under your own
|
||||
|
||||
The `searchProvider.search` request can include `category` and `bounds` (south, west, north, east). Search within that rectangle for the category; `query` remains a readable category query and `near` its centre, so existing providers continue to work. These optional fields are absent on older hosts and ordinary name searches. The host validates and filters coordinates, namespaces IDs, and applies the exact route corridor after combining sources. The existing permission and two-second hook timeout still apply.
|
||||
|
||||
### Suggestions while the user types
|
||||
|
||||
`searchProvider.suggest` is optional. Implement it only when your index can take a request per keystroke, typically one you keep in your own database; your rows then appear in the place search's dropdown while the person types, after TREK's own suggestions and labelled with your plugin's name. It gets the same `SearchRequest` as `search`, from the second typed character on, with `limit` 3 and 800 ms to answer, and the dropdown keeps at most three plugin rows across all providers. A picked row is taken as it is, without a details lookup, so fill in address, website and phone right away. The host learns whether your hook has `suggest` when the plugin loads (a class method counts), and never calls it on a plugin without one.
|
||||
|
||||
### POI categories on the trip map
|
||||
|
||||
A plugin can add up to four chips of its own to the trip map's **Explore places** pill
|
||||
|
||||
@@ -517,6 +517,16 @@ export interface SearchProvider {
|
||||
* Called for an explicit search, not for every keystroke: an external index has rate
|
||||
* limits, and a request per typed letter would spend them on words nobody finished. */
|
||||
search(request: SearchRequest, ctx: PluginContext): Promise<SearchResultPlace[]>;
|
||||
/** Optional: places for the query while it is still being typed, shown under the
|
||||
* core suggestions in the place search's dropdown. Implement it only when your index
|
||||
* can take a request per keystroke, such as one you keep locally; leave it out and
|
||||
* your places appear once the search is run, as before.
|
||||
*
|
||||
* Called from the second typed character on, with `limit` 3, and given 800 ms. The
|
||||
* host keeps at most 3 rows across every provider, so return your best few, and
|
||||
* `near` is where the person is planning. Picking a row takes it as it is, so fill in
|
||||
* what you know (address, website, phone) here rather than later. */
|
||||
suggest?(request: SearchRequest, ctx: PluginContext): Promise<SearchResultPlace[]>;
|
||||
}
|
||||
/**
|
||||
* What the host asks a POI category provider for: the places of ONE of your declared
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { PLUGIN_SEARCH_SUGGEST_MAX } from '@trek/shared';
|
||||
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
|
||||
import { pluginsEnabled } from '../kill-switch';
|
||||
import { PluginHooks } from '../plugin-hooks.service';
|
||||
import {
|
||||
collectHits,
|
||||
interleave,
|
||||
limitFrom,
|
||||
MAX_QUERY,
|
||||
nearFrom,
|
||||
normalizeSearchHits,
|
||||
searchRequestFrom,
|
||||
type SearchHit,
|
||||
} from './plugin-search.helpers';
|
||||
|
||||
@@ -47,29 +47,42 @@ export class PluginSearchController {
|
||||
): Promise<{ places: SearchHit[] }> {
|
||||
if (!pluginsEnabled()) return { places: [] };
|
||||
const userId = req.user?.id;
|
||||
const query = String(q ?? '').trim().slice(0, MAX_QUERY);
|
||||
// An empty query is not an error here: the client fires this beside every search,
|
||||
// and a blank box simply has nothing to ask a provider about.
|
||||
if (!query || userId == null) return { places: [] };
|
||||
const request = searchRequestFrom(q, lat, lng, lang, limitFrom(limit));
|
||||
if (!request || userId == null) return { places: [] };
|
||||
|
||||
const ids = this.hooks.providersOf('searchProvider');
|
||||
if (ids.length === 0) return { places: [] };
|
||||
return { places: interleave(await collectHits(ids, (id) => this.hooks.searchPlaces(id, request, userId))) };
|
||||
}
|
||||
|
||||
const request = {
|
||||
query,
|
||||
limit: limitFrom(limit),
|
||||
lang: lang ? String(lang).slice(0, 20) : undefined,
|
||||
near: nearFrom(lat, lng),
|
||||
};
|
||||
const results = await Promise.all(
|
||||
ids.map(async (id): Promise<SearchHit[]> => {
|
||||
try {
|
||||
return normalizeSearchHits(id, await this.hooks.searchPlaces(id, request, userId));
|
||||
} catch {
|
||||
return []; // a slow / failing provider is skipped, never fatal
|
||||
}
|
||||
}),
|
||||
);
|
||||
return { places: interleave(results) };
|
||||
/**
|
||||
* GET /api/plugin-search/suggest: the same indexes, asked while the query is typed.
|
||||
*
|
||||
* Only a provider that implements `suggest` is asked. One that implements `search`
|
||||
* alone has said its index cannot take a request per keystroke, which is the
|
||||
* normal case for anything behind a rate-limited API. At most three rows across
|
||||
* all providers, because they sit under the core suggestions in a list that has to
|
||||
* stay short enough to read while typing.
|
||||
*/
|
||||
@Get('suggest')
|
||||
async suggest(
|
||||
@Query('q') q: string | undefined,
|
||||
@Query('lat') lat: string | undefined,
|
||||
@Query('lng') lng: string | undefined,
|
||||
@Query('lang') lang: string | undefined,
|
||||
@Req() req: Request & { user?: { id: number } },
|
||||
): Promise<{ places: SearchHit[] }> {
|
||||
if (!pluginsEnabled()) return { places: [] };
|
||||
const userId = req.user?.id;
|
||||
// From the second character on, as the core autocomplete asks.
|
||||
const request = searchRequestFrom(q, lat, lng, lang, PLUGIN_SEARCH_SUGGEST_MAX, 2);
|
||||
if (!request || userId == null) return { places: [] };
|
||||
|
||||
const ids = this.hooks.providersOf('searchProvider', 'suggest');
|
||||
if (ids.length === 0) return { places: [] };
|
||||
const found = await collectHits(ids, (id) => this.hooks.suggestPlaces(id, request, userId));
|
||||
return { places: interleave(found).slice(0, PLUGIN_SEARCH_SUGGEST_MAX) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { normalizePlaceWebsite } from '@trek/shared';
|
||||
import type { HookSearchRequest } from '../plugin-hooks.service';
|
||||
import { stripEmoji } from '../text-sanitize';
|
||||
|
||||
/**
|
||||
@@ -111,6 +112,23 @@ export function normalizeSearchHits(pluginId: string, raw: unknown): SearchHit[]
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every provider's normalized hits for one question, in provider order. A provider
|
||||
* that throws or runs out of time contributes nothing rather than failing the rest:
|
||||
* a list that breaks when an optional index is unwell is worse than one without it.
|
||||
*/
|
||||
export function collectHits(ids: string[], ask: (pluginId: string) => Promise<unknown>): Promise<SearchHit[][]> {
|
||||
return Promise.all(
|
||||
ids.map(async (id) => {
|
||||
try {
|
||||
return normalizeSearchHits(id, await ask(id));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** The bias, when the caller supplied a real coordinate rather than two strings. */
|
||||
export function nearFrom(latRaw: unknown, lngRaw: unknown): { lat: number; lng: number } | undefined {
|
||||
const lat = Number(latRaw);
|
||||
@@ -120,6 +138,24 @@ export function nearFrom(latRaw: unknown, lngRaw: unknown): { lat: number; lng:
|
||||
return { lat, lng };
|
||||
}
|
||||
|
||||
/**
|
||||
* The question a provider is handed, read off the query string, or null when there is
|
||||
* nothing to ask. `minLength` is 2 for the typed-ahead list: one letter is not worth a
|
||||
* round trip into every provider.
|
||||
*/
|
||||
export function searchRequestFrom(
|
||||
q: unknown,
|
||||
lat: unknown,
|
||||
lng: unknown,
|
||||
lang: unknown,
|
||||
limit: number,
|
||||
minLength = 1,
|
||||
): HookSearchRequest | null {
|
||||
const query = String(q ?? '').trim().slice(0, MAX_QUERY);
|
||||
if (query.length < minLength) return null;
|
||||
return { query, limit, lang: lang ? String(lang).slice(0, 20) : undefined, near: nearFrom(lat, lng) };
|
||||
}
|
||||
|
||||
export function limitFrom(raw: unknown): number {
|
||||
const asked = Number(raw);
|
||||
return Number.isFinite(asked) && asked > 0 ? Math.min(MAX_LIMIT, Math.trunc(asked)) : DEFAULT_LIMIT;
|
||||
|
||||
@@ -3,12 +3,11 @@ import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext, ty
|
||||
import { pluginsEnabled } from '../kill-switch';
|
||||
import { PluginHooks } from '../plugin-hooks.service';
|
||||
import {
|
||||
collectHits,
|
||||
interleave,
|
||||
limitFrom,
|
||||
MAX_QUERY,
|
||||
nearFrom,
|
||||
normalizeSearchHits,
|
||||
type SearchHit,
|
||||
} from './plugin-search.helpers';
|
||||
|
||||
/**
|
||||
@@ -63,15 +62,7 @@ export class PluginSearchMcp {
|
||||
lang,
|
||||
near: nearFrom(near?.lat, near?.lng),
|
||||
};
|
||||
const results = await Promise.all(
|
||||
ids.map(async (id): Promise<SearchHit[]> => {
|
||||
try {
|
||||
return normalizeSearchHits(id, await this.hooks.searchPlaces(id, request, ctx.userId));
|
||||
} catch {
|
||||
return []; // a slow or failing provider contributes nothing, it does not fail the tool
|
||||
}
|
||||
}),
|
||||
);
|
||||
return ok({ places: interleave(results) });
|
||||
// A slow or failing provider contributes nothing, it does not fail the tool.
|
||||
return ok({ places: interleave(await collectHits(ids, (id) => this.hooks.searchPlaces(id, request, ctx.userId))) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,9 +84,9 @@ export interface HookRouteRequest {
|
||||
export class PluginHooks {
|
||||
constructor(private readonly runtime: PluginRuntimeService) {}
|
||||
|
||||
/** The plugins that declared `hook`, in manifest order. */
|
||||
providersOf(hook: string): string[] {
|
||||
return this.runtime.providersOf(hook);
|
||||
/** The plugins that declared `hook`, in manifest order; with `fn`, only those whose hook also has that function. */
|
||||
providersOf(hook: string, fn?: string): string[] {
|
||||
return this.runtime.providersOf(hook, fn);
|
||||
}
|
||||
|
||||
@PluginHook('photoProvider', { permission: 'hook:photo-provider', fn: 'search', timeoutMs: 5000 })
|
||||
@@ -117,7 +117,7 @@ export class PluginHooks {
|
||||
}
|
||||
|
||||
/**
|
||||
* The shortest leash of any hook here, because a person is waiting on a list.
|
||||
* A short leash, because a person is waiting on a list.
|
||||
*
|
||||
* The core search and this one run side by side, so the wait is the slower of the
|
||||
* two rather than their sum, and the client stops waiting at two and a half seconds
|
||||
@@ -130,6 +130,20 @@ export class PluginHooks {
|
||||
return this.runtime.invokeHook(pluginId, 'searchProvider', 'search', [request], userId, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same question asked while it is still being typed (#2221), and the shortest
|
||||
* leash of any hook here.
|
||||
*
|
||||
* Only for a provider that implements `suggest`, which says its index can take a
|
||||
* request per keystroke. The typed-ahead list waits for it beside the core
|
||||
* suggestions, which arrive in a few hundred milliseconds, and the next keystroke
|
||||
* replaces the question anyway, so an answer later than this is one nobody reads.
|
||||
*/
|
||||
@PluginHook('searchProvider', { permission: 'hook:search-provider', fn: 'suggest', timeoutMs: 800 })
|
||||
suggestPlaces(pluginId: string, request: HookSearchRequest, userId: number): Promise<unknown> {
|
||||
return this.runtime.invokeHook(pluginId, 'searchProvider', 'suggest', [request], userId, 800);
|
||||
}
|
||||
|
||||
/**
|
||||
* A chip in the explore pill, answered by the plugin that declared it.
|
||||
*
|
||||
|
||||
@@ -982,9 +982,9 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
invoke(id: string, method: string, params: Record<string, unknown>, actingUserId?: number): Promise<unknown> {
|
||||
return this.supervisor.invoke(id, method, params, { actingUserId });
|
||||
}
|
||||
/** Ids of active plugins implementing a provider hook (e.g. 'placeDetailProvider'). */
|
||||
providersOf(hook: string): string[] {
|
||||
return this.supervisor.providersOf(hook);
|
||||
/** Ids of active plugins implementing a provider hook (e.g. 'placeDetailProvider'), or one optional function of it. */
|
||||
providersOf(hook: string, fn?: string): string[] {
|
||||
return this.supervisor.providersOf(hook, fn);
|
||||
}
|
||||
/**
|
||||
* Ask ONE plugin's provider hook for data (host→plugin). A tighter default
|
||||
|
||||
@@ -26,6 +26,13 @@ interface ActivePlugin {
|
||||
routeProfiles?: Array<{ id: string; label: string; icon?: string }>;
|
||||
/** Categories the explore pill offers (poiCategoryProvider hook, granted only, #1781). */
|
||||
poiCategories?: PluginPoiCategory[];
|
||||
/**
|
||||
* The plugin holds hook:search-provider, so the place search asks the plugin routes
|
||||
* while it is typed too (#2221). Read off the grant rather than the running child, so
|
||||
* a feed loaded while plugins are still starting says the same; the suggest route
|
||||
* itself only asks the providers whose build implements `suggest`.
|
||||
*/
|
||||
searchProvider?: true;
|
||||
/** The plugin holds the geolocation:read grant — its frames may request the
|
||||
* browser position over the host bridge (the browser prompt still applies). */
|
||||
geolocation?: true;
|
||||
@@ -53,6 +60,7 @@ export class PluginsFeedController {
|
||||
...(settingsUiOf(capabilities) ? { settingsUi: true as const } : {}),
|
||||
...(routeProfiles ? { routeProfiles } : {}),
|
||||
...(poiCategories.length ? { poiCategories } : {}),
|
||||
...(hasGrant(granted_permissions, 'hook:search-provider') ? { searchProvider: true as const } : {}),
|
||||
...(hasGrant(granted_permissions, 'geolocation:read') ? { geolocation: true as const } : {}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -79,6 +79,30 @@ function installSdkInjection(requirePlugin: NodeJS.Require): void {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The function names one hook object carries, inherited ones included: a hook written
|
||||
* as a class instance keeps its methods on the prototype, and the dispatch below finds
|
||||
* them there, so this has to as well.
|
||||
*
|
||||
* Read off the property descriptors, so no getter of the plugin's runs here, and any
|
||||
* failure (a revoked Proxy, say) reports no functions rather than failing the load: a
|
||||
* plugin that loaded before this report existed must still load.
|
||||
*/
|
||||
function functionsOf(impl: unknown): string[] {
|
||||
if (!impl || typeof impl !== 'object') return [];
|
||||
const names = new Set<string>();
|
||||
try {
|
||||
for (let o: object | null = impl; o && o !== Object.prototype; o = Object.getPrototypeOf(o) as object | null) {
|
||||
for (const key of Object.getOwnPropertyNames(o)) {
|
||||
if (key !== 'constructor' && typeof Object.getOwnPropertyDescriptor(o, key)?.value === 'function') names.add(key);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return [...names];
|
||||
}
|
||||
|
||||
// True once the plugin has loaded successfully. A subsequent async throw is then a
|
||||
// runtime crash (supervisor restarts with backoff), not a load failure — so we don't
|
||||
// send 'load-error' after activation, which the supervisor treats as a terminal disable.
|
||||
@@ -104,7 +128,11 @@ async function boot(config: Record<string, unknown>): Promise<void> {
|
||||
// the manifest.
|
||||
const routes = (def.routes ?? []).map((r, i) => ({ i, method: r.method, path: r.path, auth: r.auth !== false }));
|
||||
const jobs = (def.jobs ?? []).map((j) => ({ id: j.id, schedule: j.schedule }));
|
||||
const hooks = Object.keys((def.hooks ?? {}) as Record<string, unknown>);
|
||||
const hookImpls = (def.hooks ?? {}) as Record<string, unknown>;
|
||||
const hooks = Object.keys(hookImpls);
|
||||
// Which functions each hook answers to, so the host can leave a plugin out of an
|
||||
// optional one (searchProvider.suggest) instead of calling it into an error.
|
||||
const hookFns = Object.fromEntries(hooks.map((h) => [h, functionsOf(hookImpls[h])]));
|
||||
const events = (def.events ?? []).map((e) => e.on);
|
||||
// Inter-plugin surface: the callable exports this plugin implements, and the
|
||||
// other-plugin events it subscribes to (so the host can route fan-out).
|
||||
@@ -114,7 +142,7 @@ async function boot(config: Record<string, unknown>): Promise<void> {
|
||||
// synchronous: the host never asks the child what tools it has, it
|
||||
// intersects this with the signed manifest.
|
||||
const mcpTools = (def.hooks?.mcpToolProvider?.tools ?? []).filter((t) => typeof t === 'string');
|
||||
send({ k: 'evt', topic: 'loaded', data: { routes, jobs, hooks, events, exports: exportNames, subscriptions, mcpTools } });
|
||||
send({ k: 'evt', topic: 'loaded', data: { routes, jobs, hooks, hookFns, events, exports: exportNames, subscriptions, mcpTools } });
|
||||
activated = true; // past load: a later async throw is a runtime CRASH, not a load failure
|
||||
// An immediate first heartbeat confirms liveness without waiting a full interval.
|
||||
send({ k: 'evt', topic: 'heartbeat', data: { rss: process.memoryUsage().rss } });
|
||||
|
||||
@@ -449,6 +449,8 @@ export interface SearchRequest {
|
||||
}
|
||||
export interface SearchProvider {
|
||||
search(request: SearchRequest, ctx: PluginContext): Promise<SearchResultPlace[]>;
|
||||
/** Optional: the same question while it is typed (#2221). The host only calls it when the child reported it at load. */
|
||||
suggest?(request: SearchRequest, ctx: PluginContext): Promise<SearchResultPlace[]>;
|
||||
}
|
||||
/**
|
||||
* A POI category provider (#1781). Only the hook's name and call are spelled out here:
|
||||
|
||||
@@ -55,6 +55,7 @@ interface Supervised {
|
||||
jobs: ScheduledJob[]; // declared background jobs (id + cron schedule)
|
||||
jobTasks?: ReturnType<typeof scheduleJobs>; // live cron jobs (only when jobs:run granted)
|
||||
hooks: string[]; // provider hooks the plugin implements (e.g. 'placeDetailProvider')
|
||||
hookFns: Record<string, string[]>; // the functions each of those hooks answers to (e.g. searchProvider: ['search', 'suggest'])
|
||||
events: string[]; // core events the plugin subscribes to (names or '*')
|
||||
exports: string[]; // functions the plugin exposes to dependents (ctx.plugins.call)
|
||||
mcpTools: string[]; // MCP tool names the plugin reported implementing at load
|
||||
@@ -94,6 +95,21 @@ const DEFAULTS: Required<SupervisorTuning> = {
|
||||
maxRssBytes: readEnv().plugins.maxRssMb * 1024 * 1024,
|
||||
};
|
||||
|
||||
/**
|
||||
* The `hookFns` of a `loaded` report. The child is untrusted, so only hooks the host
|
||||
* knows survive (which also keeps a `__proto__` key off the record), each with its
|
||||
* string entries.
|
||||
*/
|
||||
function readHookFns(raw: unknown): Record<string, string[]> {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||
const out: Record<string, string[]> = {};
|
||||
for (const [hook, fns] of Object.entries(raw)) {
|
||||
if (Object.hasOwn(HOOK_PERMISSION, hook) && Array.isArray(fns)) {
|
||||
out[hook] = fns.filter((fn): fn is string => typeof fn === 'string');
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class PluginSupervisor {
|
||||
private running = new Map<string, Supervised>();
|
||||
@@ -143,6 +159,7 @@ export class PluginSupervisor {
|
||||
routes: [],
|
||||
jobs: [],
|
||||
hooks: [],
|
||||
hookFns: {},
|
||||
events: [],
|
||||
exports: [],
|
||||
mcpTools: [],
|
||||
@@ -240,15 +257,20 @@ export class PluginSupervisor {
|
||||
* Ids of ACTIVE plugins that may act as a given provider hook — they implement it
|
||||
* (reported at load) AND hold the matching hook:* grant the admin consented to.
|
||||
* An unknown hook, or one with no permission mapping, resolves to nobody.
|
||||
*
|
||||
* `fn` narrows that to the plugins whose hook also carries one optional function,
|
||||
* such as `searchProvider.suggest`, which most search providers leave out.
|
||||
*/
|
||||
providersOf(hook: string): string[] {
|
||||
providersOf(hook: string, fn?: string): string[] {
|
||||
// Cast: `hook` is a plain string off the child's report, and HOOK_PERMISSION is
|
||||
// now a literal object, so indexing it needs the same widening envelope.ts uses.
|
||||
const perm = (HOOK_PERMISSION as Readonly<Record<string, string | undefined>>)[hook];
|
||||
if (!perm) return [];
|
||||
const out: string[] = [];
|
||||
for (const [id, sup] of this.running) {
|
||||
if (sup.status === 'active' && sup.hooks.includes(hook) && sup.granted.has(perm)) out.push(id);
|
||||
if (sup.status !== 'active' || !sup.hooks.includes(hook) || !sup.granted.has(perm)) continue;
|
||||
if (fn && !sup.hookFns[hook]?.includes(fn)) continue;
|
||||
out.push(id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -590,7 +612,7 @@ export class PluginSupervisor {
|
||||
if (this.running.get(sup.id) !== sup || sup.status !== 'starting') break;
|
||||
sup.lastBeat = Date.now();
|
||||
const d = msg.data as {
|
||||
routes?: PluginRouteInfo[]; jobs?: ScheduledJob[]; hooks?: string[]; events?: string[];
|
||||
routes?: PluginRouteInfo[]; jobs?: ScheduledJob[]; hooks?: string[]; hookFns?: unknown; events?: string[];
|
||||
exports?: string[]; subscriptions?: Array<{ plugin: string; event: string }>;
|
||||
mcpTools?: string[];
|
||||
};
|
||||
@@ -599,6 +621,7 @@ export class PluginSupervisor {
|
||||
? d.jobs.filter((j): j is ScheduledJob => !!j && typeof j.id === 'string' && typeof j.schedule === 'string')
|
||||
: [];
|
||||
sup.hooks = d.hooks ?? [];
|
||||
sup.hookFns = readHookFns(d.hookFns);
|
||||
sup.events = d.events ?? [];
|
||||
sup.exports = Array.isArray(d.exports) ? d.exports.filter((e): e is string => typeof e === 'string') : [];
|
||||
sup.mcpTools = Array.isArray(d.mcpTools) ? d.mcpTools.filter((t): t is string => typeof t === 'string') : [];
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* GET /api/plugin-search and GET /api/plugin-search/suggest e2e (#2221): the real
|
||||
* guard chain and the real contribution module against a temp SQLite db, with only
|
||||
* the plugins played by spies.
|
||||
*
|
||||
* The unit tests pin the parsing and the normalizer. What only a booted container
|
||||
* shows is that both routes sit behind JwtAuthGuard, that the static `suggest`
|
||||
* segment is its own route rather than a query on the search, and that the typed-ahead
|
||||
* route asks exactly the providers whose hook carries `suggest`.
|
||||
*/
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
import { PluginContributionsModule } from '../../src/nest/plugins/contributions/plugin-contributions.module';
|
||||
import { PluginHooks } from '../../src/nest/plugins/plugin-hooks.service';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
import { pluginSuggestResultSchema } from '@trek/shared';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import request from 'supertest';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
|
||||
|
||||
const { db, pluginsEnabled } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec('PRAGMA foreign_keys = ON');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0,
|
||||
avatar TEXT);`);
|
||||
tmp.exec(`CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT NOT NULL, type TEXT, icon TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'inactive', sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
capabilities TEXT, permissions TEXT DEFAULT '[]', granted_permissions TEXT DEFAULT '[]');`);
|
||||
tmp.exec('CREATE TABLE addons (id TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 0, name TEXT, description TEXT, category TEXT, sort_order INTEGER DEFAULT 0);');
|
||||
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
|
||||
// Off while the app boots, so the runtime does not go looking for plugins on disk;
|
||||
// the tests switch it on.
|
||||
return { db: tmp, pluginsEnabled: { value: false } };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({
|
||||
db,
|
||||
canAccessTrip: () => undefined,
|
||||
isOwner: () => false,
|
||||
getPlaceWithTags: () => null,
|
||||
closeDb: () => {},
|
||||
reinitialize: () => {},
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled: () => pluginsEnabled.value }));
|
||||
|
||||
const place = (id: string, name: string) => ({ id, name, lat: 35.66, lng: 139.7, address: 'Shibuya', rating: 4.4 });
|
||||
|
||||
describe('Plugin search e2e (real guard chain + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
let providersOf: MockInstance;
|
||||
let searchPlaces: MockInstance;
|
||||
let suggestPlaces: MockInstance;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [DatabaseModule, RealtimeModule, PluginContributionsModule],
|
||||
}).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalPipes(new ZodValidationPipe());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
const hooks = app.get(PluginHooks, { strict: false });
|
||||
providersOf = vi.spyOn(hooks, 'providersOf');
|
||||
searchPlaces = vi.spyOn(hooks, 'searchPlaces');
|
||||
suggestPlaces = vi.spyOn(hooks, 'suggestPlaces');
|
||||
server = app.getHttpServer();
|
||||
pluginsEnabled.value = true;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Two installed indexes; only the local one takes a request per keystroke.
|
||||
providersOf.mockReset().mockImplementation((_hook: string, fn?: string) =>
|
||||
fn === 'suggest' ? ['local-index'] : ['local-index', 'remote-api'],
|
||||
);
|
||||
searchPlaces.mockReset().mockImplementation(async (pluginId: string) => [place('s1', `${pluginId} search`)]);
|
||||
suggestPlaces.mockReset().mockResolvedValue([
|
||||
place('a', 'Ichiran Shibuya'), place('b', 'Ichiran Harajuku'), place('c', 'Ichiran Ueno'), place('d', 'Ichiran Asakusa'),
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
const cookie = () => sessionCookie(1);
|
||||
|
||||
it('refuses a caller without a session on both routes before any plugin is asked', async () => {
|
||||
await request(server).get('/api/plugin-search').query({ q: 'ichiran' }).expect(401);
|
||||
await request(server).get('/api/plugin-search/suggest').query({ q: 'ichiran' }).expect(401);
|
||||
expect(searchPlaces).not.toHaveBeenCalled();
|
||||
expect(suggestPlaces).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('suggests from the providers that implement suggest only, three rows at most, in the shared row', async () => {
|
||||
const res = await request(server)
|
||||
.get('/api/plugin-search/suggest')
|
||||
.set('Cookie', cookie())
|
||||
.query({ q: 'ichi', lat: '35.66', lng: '139.7', lang: 'ja' })
|
||||
.expect(200);
|
||||
expect(pluginSuggestResultSchema.parse(res.body).places.map((p) => p.osm_id)).toEqual([
|
||||
'plugin:local-index:a', 'plugin:local-index:b', 'plugin:local-index:c',
|
||||
]);
|
||||
expect(providersOf).toHaveBeenCalledWith('searchProvider', 'suggest');
|
||||
expect(suggestPlaces).toHaveBeenCalledTimes(1);
|
||||
expect(suggestPlaces).toHaveBeenCalledWith('local-index', { query: 'ichi', limit: 3, lang: 'ja', near: { lat: 35.66, lng: 139.7 } }, 1);
|
||||
expect(searchPlaces).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks nobody for a single letter', async () => {
|
||||
const res = await request(server).get('/api/plugin-search/suggest').set('Cookie', cookie()).query({ q: 'i' }).expect(200);
|
||||
expect(res.body).toEqual({ places: [] });
|
||||
expect(suggestPlaces).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the explicit search on every provider, the typed-ahead route beside it', async () => {
|
||||
const res = await request(server).get('/api/plugin-search').set('Cookie', cookie()).query({ q: 'ichiran' }).expect(200);
|
||||
expect(res.body.places.map((p: { name: string }) => p.name)).toEqual(['local-index search', 'remote-api search']);
|
||||
expect(suggestPlaces).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers an empty list while the plugin runtime is switched off', async () => {
|
||||
pluginsEnabled.value = false;
|
||||
try {
|
||||
const res = await request(server).get('/api/plugin-search/suggest').set('Cookie', cookie()).query({ q: 'ichiran' }).expect(200);
|
||||
expect(res.body).toEqual({ places: [] });
|
||||
expect(suggestPlaces).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
pluginsEnabled.value = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -172,6 +172,37 @@ describe('PluginSupervisor — isolated runtime', () => {
|
||||
expect(res).toEqual({ name: 'echo', args: { v: 1 } });
|
||||
});
|
||||
|
||||
it('reports the functions each hook carries at load, class methods included (#2221)', async () => {
|
||||
// Only the forked child knows whether searchProvider has the optional suggest, and
|
||||
// plugin-host-entry.ts is excluded from coverage, so this is the proof that the
|
||||
// report reaches the host for a literal and for a class instance alike.
|
||||
const events: Array<{ topic: string; data: unknown }> = [];
|
||||
sup = makeSupervisor(events);
|
||||
writePlugin(
|
||||
'typeahead',
|
||||
`class LocalIndex {
|
||||
async search() { return []; }
|
||||
async suggest(request) { return [{ id: 'x', name: request.query, lat: 1, lng: 2 }]; }
|
||||
// Reading the hook's functions must not run a getter, let alone fail the load on one.
|
||||
get broken() { throw new Error('getter ran at load'); }
|
||||
}
|
||||
module.exports = { hooks: { searchProvider: new LocalIndex() } };`,
|
||||
);
|
||||
writePlugin('search-only', `module.exports = { hooks: { searchProvider: { async search() { return []; } } } };`);
|
||||
await sup.activate('typeahead', new Set(['hook:search-provider']), {});
|
||||
await sup.activate('search-only', new Set(['hook:search-provider']), {});
|
||||
|
||||
expect(sup.providersOf('searchProvider').sort()).toEqual(['search-only', 'typeahead']);
|
||||
expect(sup.providersOf('searchProvider', 'suggest')).toEqual(['typeahead']);
|
||||
const res = await sup.invoke(
|
||||
'typeahead',
|
||||
'invoke.hook',
|
||||
{ hook: 'searchProvider', fn: 'suggest', args: [{ query: 'ic', limit: 3 }] },
|
||||
{ actingUserId: 5 },
|
||||
);
|
||||
expect(res).toEqual([{ id: 'x', name: 'ic', lat: 1, lng: 2 }]);
|
||||
});
|
||||
|
||||
it('stops reporting MCP tools once the plugin is no longer active', async () => {
|
||||
const events: Array<{ topic: string; data: unknown }> = [];
|
||||
sup = makeSupervisor(events);
|
||||
|
||||
@@ -33,6 +33,7 @@ const CONTRACTS: Array<[hook: string, fn: string, timeoutMs: number]> = [
|
||||
['calendarSource', 'getEvents', 5000],
|
||||
['placeDetailProvider', 'getDetails', 5000],
|
||||
['searchProvider', 'search', 2000],
|
||||
['searchProvider', 'suggest', 800],
|
||||
['poiCategoryProvider', 'getPois', 8000],
|
||||
['warningProvider', 'getWarnings', 5000],
|
||||
['tableContributor', 'getContributions', 5000],
|
||||
@@ -82,7 +83,7 @@ describe('PluginHooks contracts', () => {
|
||||
it('PLUGHOOK-005 providersOf is passed straight through to the runtime', () => {
|
||||
const { hooks: h, providersOf } = hooks();
|
||||
expect(h.providersOf('photoProvider')).toEqual(['p1', 'p2']);
|
||||
expect(providersOf).toHaveBeenCalledWith('photoProvider');
|
||||
expect(providersOf).toHaveBeenCalledWith('photoProvider', undefined);
|
||||
});
|
||||
|
||||
it('PLUGHOOK-006 the read hooks forward their arguments and the acting user', async () => {
|
||||
@@ -160,6 +161,18 @@ describe('PluginHooks contracts', () => {
|
||||
expect(invokeHook).toHaveBeenCalledWith('p', 'poiCategoryProvider', 'getPois', [request], 7, 8000);
|
||||
});
|
||||
|
||||
it('PLUGHOOK-012: a typed-ahead suggestion binds the requesting user and takes the shortest budget (#2221)', async () => {
|
||||
// Asked per keystroke, beside core suggestions that arrive in a few hundred
|
||||
// milliseconds; the next keystroke replaces the question, so a later answer is dead.
|
||||
const { hooks: h, invokeHook, providersOf } = hooks();
|
||||
const request = { query: 'ichi', limit: 3, lang: 'ja', near: { lat: 35.66, lng: 139.7 } };
|
||||
await h.suggestPlaces('p', request, 7);
|
||||
expect(invokeHook).toHaveBeenCalledWith('p', 'searchProvider', 'suggest', [request], 7, 800);
|
||||
// Only the providers whose hook carries the optional function are asked.
|
||||
h.providersOf('searchProvider', 'suggest');
|
||||
expect(providersOf).toHaveBeenLastCalledWith('searchProvider', 'suggest');
|
||||
});
|
||||
|
||||
it('PLUGHOOK-009 the class is listed in its module providers', () => {
|
||||
// A decorated class no module registers is invisible to discovery, which would
|
||||
// now fail boot on "no host-side consumer" rather than fail quietly. Cheapest
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
const { pluginsEnabled } = vi.hoisted(() => ({ pluginsEnabled: vi.fn(() => true) }));
|
||||
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
|
||||
|
||||
import { pluginSearchHitSchema, pluginSuggestResultSchema } from '@trek/shared';
|
||||
import { PluginSearchController } from '../../../src/nest/plugins/contributions/plugin-search.controller';
|
||||
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
|
||||
|
||||
@@ -15,6 +16,7 @@ function controller(over: Partial<PluginHooks> = {}) {
|
||||
const hooks = {
|
||||
providersOf: vi.fn(() => ['p1']),
|
||||
searchPlaces: vi.fn(async () => [hit()]),
|
||||
suggestPlaces: vi.fn(async () => [hit()]),
|
||||
...over,
|
||||
} as unknown as PluginHooks;
|
||||
return { c: new PluginSearchController(hooks), hooks };
|
||||
@@ -198,4 +200,75 @@ describe('PluginSearchController', () => {
|
||||
const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5));
|
||||
expect(places).toHaveLength(20);
|
||||
});
|
||||
|
||||
it('answers in the row the shared contract describes, so the client can check it strictly', async () => {
|
||||
// The server's SearchHit and the shared schema are two spellings of one row; this
|
||||
// is what fails when one of them moves without the other.
|
||||
const { c } = controller({
|
||||
searchPlaces: vi.fn(async () => [
|
||||
hit({ address: 'Via Roma 1', rating: 4.5, website: 'ok.example', phone: '+39 02', category: 'restaurant', description: 'Good' }),
|
||||
hit({ id: 'bare', name: 'Bare' }),
|
||||
]) as unknown as PluginHooks['searchPlaces'],
|
||||
});
|
||||
const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5));
|
||||
for (const place of places) expect(pluginSearchHitSchema.safeParse(place).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PluginSearchController.suggest (#2221)', () => {
|
||||
beforeEach(() => { pluginsEnabled.mockReturnValue(true); });
|
||||
|
||||
it('asks only the providers that implement suggest, never their search', async () => {
|
||||
const { c, hooks } = controller();
|
||||
await c.suggest('ich', '35.66', '139.7', 'ja', req(5));
|
||||
expect(hooks.providersOf).toHaveBeenCalledWith('searchProvider', 'suggest');
|
||||
expect(hooks.suggestPlaces).toHaveBeenCalledWith(
|
||||
'p1',
|
||||
{ query: 'ich', limit: 3, lang: 'ja', near: { lat: 35.66, lng: 139.7 } },
|
||||
5,
|
||||
);
|
||||
expect(hooks.searchPlaces).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks nothing for one letter, a blank box, a caller without a session or a runtime that is off', async () => {
|
||||
const { c, hooks } = controller();
|
||||
expect(await c.suggest('i', undefined, undefined, undefined, req(5))).toEqual({ places: [] });
|
||||
expect(await c.suggest(' ', undefined, undefined, undefined, req(5))).toEqual({ places: [] });
|
||||
expect(await c.suggest(undefined, undefined, undefined, undefined, req(5))).toEqual({ places: [] });
|
||||
expect(await c.suggest('ichiran', undefined, undefined, undefined, req(undefined))).toEqual({ places: [] });
|
||||
pluginsEnabled.mockReturnValue(false);
|
||||
expect(await c.suggest('ichiran', undefined, undefined, undefined, req(5))).toEqual({ places: [] });
|
||||
expect(hooks.providersOf).not.toHaveBeenCalled();
|
||||
expect(hooks.suggestPlaces).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers without reaching any plugin when none implements suggest', async () => {
|
||||
const { c, hooks } = controller({ providersOf: vi.fn(() => []) });
|
||||
expect(await c.suggest('ichiran', undefined, undefined, undefined, req(5))).toEqual({ places: [] });
|
||||
expect(hooks.suggestPlaces).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps three rows across all providers, interleaved, whatever each one sends', async () => {
|
||||
const { c } = controller({
|
||||
providersOf: vi.fn(() => ['a', 'b']),
|
||||
suggestPlaces: vi.fn(async (id: string) =>
|
||||
Array.from({ length: 10 }, (_v, i) => hit({ id: `${id}${i}`, name: `${id.toUpperCase()}${i}` })),
|
||||
) as unknown as PluginHooks['suggestPlaces'],
|
||||
});
|
||||
const result = await c.suggest('ichiran', undefined, undefined, undefined, req(5));
|
||||
expect(result.places.map(p => p.name)).toEqual(['A0', 'B0', 'A1']);
|
||||
expect(pluginSuggestResultSchema.safeParse(result).success).toBe(true);
|
||||
});
|
||||
|
||||
it('drops a provider that times out and normalizes what the others send', async () => {
|
||||
const { c } = controller({
|
||||
providersOf: vi.fn(() => ['slow', 'ok']),
|
||||
suggestPlaces: vi.fn(async (id: string) => {
|
||||
if (id === 'slow') throw new Error('plugin invoke timed out');
|
||||
return [hit({ id: 'r1', website: 'javascript:alert(1)' }), { name: 'no coordinates' }];
|
||||
}) as unknown as PluginHooks['suggestPlaces'],
|
||||
});
|
||||
const { places } = await c.suggest('ichiran', undefined, undefined, undefined, req(5));
|
||||
expect(places).toEqual([expect.objectContaining({ osm_id: 'plugin:ok:r1', source: 'plugin:ok', website: null })]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,18 @@ describe('PluginsFeedController', () => {
|
||||
const c = new PluginsFeedController(new DatabaseService(dbConn));
|
||||
beforeEach(() => { pluginsEnabled.mockReturnValue(true); rows.value = []; });
|
||||
|
||||
it('marks a plugin as a search provider only alongside the recorded hook:search-provider grant (#2221)', () => {
|
||||
rows.value = [
|
||||
row({ id: 'index', granted_permissions: JSON.stringify(['hook:search-provider']) }),
|
||||
row({ id: 'asked-only', permissions: JSON.stringify(['hook:search-provider']) }),
|
||||
row({ id: 'poi-only', granted_permissions: JSON.stringify(['hook:poi-category-provider']) }),
|
||||
];
|
||||
const { plugins } = c.list();
|
||||
expect(plugins.find(p => p.id === 'index')?.searchProvider).toBe(true);
|
||||
expect(plugins.find(p => p.id === 'asked-only')).not.toHaveProperty('searchProvider');
|
||||
expect(plugins.find(p => p.id === 'poi-only')).not.toHaveProperty('searchProvider');
|
||||
});
|
||||
|
||||
it('returns an empty feed when the runtime is disabled', () => {
|
||||
pluginsEnabled.mockReturnValue(false);
|
||||
rows.value = [row()];
|
||||
|
||||
@@ -19,9 +19,9 @@ function makeSupervisor(): PluginSupervisor {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new PluginSupervisor((() => ({})) as any, {}, {});
|
||||
}
|
||||
function put(s: PluginSupervisor, id: string, status: string, hooks: string[], granted: string[]): void {
|
||||
function put(s: PluginSupervisor, id: string, status: string, hooks: string[], granted: string[], hookFns: Record<string, string[]> = {}): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(s as any).running.set(id, { id, status, hooks, granted: new Set(granted) });
|
||||
(s as any).running.set(id, { id, status, hooks, hookFns, granted: new Set(granted) });
|
||||
}
|
||||
|
||||
describe('providersOf enforces the hook:* grant', () => {
|
||||
@@ -107,6 +107,51 @@ describe('providersOf enforces the hook:* grant', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('providersOf narrowed to one optional function (#2221)', () => {
|
||||
it('keeps only the providers whose hook reported that function at load, with every other gate still on', () => {
|
||||
const s = makeSupervisor();
|
||||
const both = { searchProvider: ['search', 'suggest'] };
|
||||
put(s, 'typeahead', 'active', ['searchProvider'], ['hook:search-provider'], both);
|
||||
put(s, 'search-only', 'active', ['searchProvider'], ['hook:search-provider'], { searchProvider: ['search'] });
|
||||
put(s, 'ungranted', 'active', ['searchProvider'], ['hook:poi-category-provider'], both);
|
||||
put(s, 'restarting', 'starting', ['searchProvider'], ['hook:search-provider'], both);
|
||||
// A function of another hook of the same plugin does not count for this one.
|
||||
put(s, 'crossed', 'active', ['searchProvider', 'photoProvider'], ['hook:search-provider'], { searchProvider: ['search'], photoProvider: ['suggest'] });
|
||||
expect(s.providersOf('searchProvider', 'suggest')).toEqual(['typeahead']);
|
||||
expect(s.providersOf('searchProvider')).toEqual(['typeahead', 'search-only', 'crossed']);
|
||||
});
|
||||
|
||||
it('reads the functions off the loaded report the way it reads anything from a child: warily', async () => {
|
||||
const s = makeSupervisor();
|
||||
const starting = () => ({
|
||||
id: 'p', status: 'starting', granted: new Set(['hook:search-provider']), hooks: [], hookFns: {}, jobs: [],
|
||||
events: [], exports: [], mcpTools: [], subscriptions: [], pending: new Map(), invocations: new Map(), crashes: [],
|
||||
});
|
||||
const sup = starting();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(s as any).running.set('p', sup);
|
||||
// Parsed from JSON, as it crosses the IPC channel, so `__proto__` is an own key here.
|
||||
const hookFns = JSON.parse('{"searchProvider":["search","suggest",7],"__proto__":["suggest"],"mysteryProvider":["suggest"],"photoProvider":"suggest"}');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (s as any).onMessage(sup, { k: 'evt', topic: 'loaded', data: { hooks: ['searchProvider'], hookFns } });
|
||||
expect(sup.status).toBe('active');
|
||||
expect(sup.hookFns).toEqual({ searchProvider: ['search', 'suggest'] });
|
||||
expect(Object.getPrototypeOf(sup.hookFns)).toBe(Object.prototype);
|
||||
expect(s.providersOf('searchProvider', 'suggest')).toEqual(['p']);
|
||||
|
||||
// A report with no usable functions at all still activates; it just offers none.
|
||||
const bare = starting();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(s as any).running.set('p', bare);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (s as any).onMessage(bare, { k: 'evt', topic: 'loaded', data: { hooks: ['searchProvider'], hookFns: ['suggest'] } });
|
||||
expect(bare.status).toBe('active');
|
||||
expect(bare.hookFns).toEqual({});
|
||||
expect(s.providersOf('searchProvider', 'suggest')).toEqual([]);
|
||||
expect(s.providersOf('searchProvider')).toEqual(['p']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime.invokeHook defense-in-depth', () => {
|
||||
it('refuses a plugin id that is not a granted provider of the hook, even if passed directly', async () => {
|
||||
const rt = createPluginRuntime(new DatabaseService(db));
|
||||
|
||||
@@ -89,6 +89,8 @@ export * from './plugins/plugins.schema';
|
||||
// Plugin POI categories (#1781): the declared category, the icon allow-list and GET /api/plugin-pois.
|
||||
export * from './plugins/plugin-poi-facts';
|
||||
export * from './plugins/plugin-poi.schema';
|
||||
// Plugin search providers (#2221): the row GET /api/plugin-search and its typed-ahead twin answer with.
|
||||
export * from './plugins/plugin-search.schema';
|
||||
export * from './roadtrip/preferences.schema';
|
||||
export * from './roadtrip/hazards.schema';
|
||||
export * from './roadtrip/planning.schema';
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { PLUGIN_SEARCH_SUGGEST_MAX, pluginSearchHitSchema, pluginSuggestResultSchema } from './plugin-search.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Plugin search rows (#2221). The client checks every typed-ahead answer against
|
||||
* this before a row reaches the list, so the spec pins what gets through: only a
|
||||
* namespaced plugin row, on the earth, with a rating on the five-point scale.
|
||||
*/
|
||||
const hit = {
|
||||
osm_id: 'plugin:all-the-places:ichiran-ueno',
|
||||
name: 'Ichiran Ueno',
|
||||
address: 'Ueno 6-11-12, Taito',
|
||||
lat: 35.7101,
|
||||
lng: 139.7745,
|
||||
rating: 4.3,
|
||||
website: 'https://ichiran.com/shop/ueno',
|
||||
phone: null,
|
||||
category: 'restaurant',
|
||||
description: null,
|
||||
source: 'plugin:all-the-places',
|
||||
pluginId: 'all-the-places',
|
||||
};
|
||||
|
||||
describe('pluginSearchHitSchema', () => {
|
||||
it('accepts a row as the host normalizes it, unrated or rated', () => {
|
||||
expect(pluginSearchHitSchema.safeParse(hit).success).toBe(true);
|
||||
expect(pluginSearchHitSchema.safeParse({ ...hit, rating: null, website: null }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a row that is not namespaced as a plugin one', () => {
|
||||
expect(pluginSearchHitSchema.safeParse({ ...hit, osm_id: 'node:123' }).success).toBe(false);
|
||||
expect(pluginSearchHitSchema.safeParse({ ...hit, source: 'trek-places' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a row with no name, off the globe, or rated past five', () => {
|
||||
expect(pluginSearchHitSchema.safeParse({ ...hit, name: '' }).success).toBe(false);
|
||||
expect(pluginSearchHitSchema.safeParse({ ...hit, lat: 91 }).success).toBe(false);
|
||||
expect(pluginSearchHitSchema.safeParse({ ...hit, lng: -181 }).success).toBe(false);
|
||||
expect(pluginSearchHitSchema.safeParse({ ...hit, rating: 7 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pluginSuggestResultSchema', () => {
|
||||
it(`keeps a typed-ahead answer to ${PLUGIN_SEARCH_SUGGEST_MAX} rows`, () => {
|
||||
const rows = (n: number) => Array.from({ length: n }, () => hit);
|
||||
expect(pluginSuggestResultSchema.safeParse({ places: rows(PLUGIN_SEARCH_SUGGEST_MAX) }).success).toBe(true);
|
||||
expect(pluginSuggestResultSchema.safeParse({ places: rows(PLUGIN_SEARCH_SUGGEST_MAX + 1) }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Places a `searchProvider` plugin found (#2221). GET /api/plugin-search answers an
|
||||
* explicit search, GET /api/plugin-search/suggest the query while it is being typed,
|
||||
* and both send this row: the record the core search returns, plus the rating only a
|
||||
* plugin index can carry and the plugin that answered.
|
||||
*
|
||||
* The host has already capped, range-checked and namespaced every field
|
||||
* (plugin-search.helpers.ts). The client checks the answer against this again before
|
||||
* a row reaches the list, because a plugin is somebody else's code.
|
||||
*/
|
||||
|
||||
/** Most rows one typed-ahead answer carries, all providers together. */
|
||||
export const PLUGIN_SEARCH_SUGGEST_MAX = 3;
|
||||
|
||||
export const pluginSearchHitSchema = z.object({
|
||||
/** `plugin:<pluginId>:<id>`, so it can never collide with an OSM or index id. */
|
||||
osm_id: z.string().startsWith('plugin:'),
|
||||
name: z.string().min(1),
|
||||
address: z.string(),
|
||||
lat: z.number().min(-90).max(90),
|
||||
lng: z.number().min(-180).max(180),
|
||||
/** Zero to five, or null. Open data has no rating at all. */
|
||||
rating: z.number().min(0).max(5).nullable(),
|
||||
website: z.string().nullable(),
|
||||
phone: z.string().nullable(),
|
||||
category: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
/** `plugin:<pluginId>`, which the list shows as the plugin's name. */
|
||||
source: z.string().startsWith('plugin:'),
|
||||
pluginId: z.string(),
|
||||
});
|
||||
export type PluginSearchHit = z.infer<typeof pluginSearchHitSchema>;
|
||||
|
||||
export const pluginSuggestResultSchema = z.object({
|
||||
places: z.array(pluginSearchHitSchema).max(PLUGIN_SEARCH_SUGGEST_MAX),
|
||||
});
|
||||
export type PluginSuggestResult = z.infer<typeof pluginSuggestResultSchema>;
|
||||
@@ -21,6 +21,7 @@ After 2 or more characters, and a 300 ms pause, suggestions appear in a dropdown
|
||||
- Use **↑ / ↓** to move through them, **Enter** to pick one, **Esc** to close the list.
|
||||
- Suggestions come from the TREK API, from its index and from an OpenStreetMap layer it keeps. A suggestion from that layer shows the name that matched what you typed, with the name used on the spot underneath when the two differ.
|
||||
- Only when the TREK API has nothing does TREK ask the keyed provider (Google or Amap), or, without one, OpenStreetMap's own search service.
|
||||
- An installed [search plugin](Plugin-Cookbook#suggestions-while-the-user-types) whose index can keep up with typing adds up to three of its places under the suggestions, marked with the plugin's name. Picking one fills in what the plugin knows about the place, with no second lookup.
|
||||
|
||||
### The full search
|
||||
|
||||
@@ -28,11 +29,11 @@ Press **Enter** without picking a suggestion, or click the search button, to run
|
||||
|
||||
A Google or Amap key does not change this. The keyed provider is asked only when the TREK API and OpenStreetMap both come back empty; see [Google and Amap](#google-and-amap).
|
||||
|
||||
Installed [search plugins](Plugin-Cookbook#answer-place-searches-from-your-own-index) add their results below the core list, on the full search only.
|
||||
Installed [search plugins](Plugin-Cookbook#answer-place-searches-from-your-own-index) add their results below the core list. Only the ones built for it also answer while you type; the others appear here, once the search is run.
|
||||
|
||||
### Where each result came from
|
||||
|
||||
On the desktop every row, in the suggestions and in the search results, carries a small mark naming its source: **TREK**, **OpenStreetMap**, **Google** or **Amap** (高德地图, or 高德地圖 in traditional Chinese). On the phone the suggestions carry it. A list that mixes the TREK API and OpenStreetMap stays readable that way. Results from the offline cache carry no mark; see [Searching offline](#searching-offline).
|
||||
Every row, in the suggestions and in the search results, on the desktop and on the phone, carries a small mark naming its source: **TREK**, **OpenStreetMap**, **Google** or **Amap** (高德地图, or 高德地圖 in traditional Chinese), and for a row from a search plugin the name the plugin was installed under. A list that mixes several sources stays readable that way. Results from the offline cache carry no mark; see [Searching offline](#searching-offline).
|
||||
|
||||
### The open day steers the search
|
||||
|
||||
|
||||
@@ -148,6 +148,47 @@ outside `bounds` are dropped, and so is a hit whose own `category` names a diffe
|
||||
of those seven kinds. Both fields are absent on ordinary searches, so read them as
|
||||
optional.
|
||||
|
||||
### Suggestions while the user types
|
||||
|
||||
Implement `suggest` beside `search`, and your places also show up in the dropdown under
|
||||
the search box while the person is still typing: after TREK's own suggestions, marked
|
||||
with your plugin's name. It gets the same request as `search`, from the second typed
|
||||
character on, with `limit` 3 and 800 ms to answer. The dropdown keeps at most three
|
||||
plugin rows, all search plugins together, so send your best few.
|
||||
|
||||
Leave it out when every call costs you something, such as a rate-limited API. `search`
|
||||
alone works as before, and your places then appear once the search is run. `suggest` is
|
||||
for an index you hold yourself, for example an [All the Places](https://alltheplaces.xyz/)
|
||||
extract in your plugin's own database, where a prefix match per keystroke is cheap. `near`
|
||||
tells you where the person is planning, so an index kept per country only has to load the
|
||||
country they are looking at.
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
hooks: {
|
||||
searchProvider: {
|
||||
async search(request, ctx) {
|
||||
return lookUp(request, ctx)
|
||||
},
|
||||
async suggest({ query, limit }, ctx) {
|
||||
const rows = await ctx.db.query(
|
||||
'SELECT ref, name, lat, lng, address, website FROM places WHERE name LIKE ? LIMIT ?',
|
||||
`${query}%`,
|
||||
limit,
|
||||
)
|
||||
return rows.map(r => ({ id: r.ref, name: r.name, lat: r.lat, lng: r.lng, address: r.address, website: r.website }))
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
(`ctx.db` needs `db:own`.) Picking one of your rows takes it as it is: TREK does not look
|
||||
the place up again, because no details service knows your ids. So send the address,
|
||||
website and phone with the row rather than later. TREK finds out whether your hook has
|
||||
`suggest` when the plugin starts, from the hook object itself, so a class instance with a
|
||||
`suggest` method counts as well as an object literal.
|
||||
|
||||
## Add your own place categories to the map
|
||||
|
||||
**Needs:** `hook:poi-category-provider` and `capabilities.poiCategories` (+
|
||||
|
||||
@@ -809,7 +809,7 @@ module.exports = definePlugin({
|
||||
| Hook | Permission | Status |
|
||||
|---|---|---|
|
||||
| `placeDetailProvider.getDetails(placeId, ctx)` → `{ label, value?, url? }[]` | `hook:place-detail-provider` | **live** — shown in the place-detail panel; also `GET /api/place-details/:placeId` |
|
||||
| `searchProvider.search(request, ctx)` → `SearchResultPlace[]` | `hook:search-provider` | **live** — answers place searches from an index TREK does not ship, drawn into the app's own search list beside the core results (#2221). `request` is `{query, limit, lang?, near?, category?, bounds?}`; each place is `{id?, name, lat, lng, address?, rating?, website?, phone?, category?, description?}`. `rating` is the field open data cannot answer — OpenStreetMap carries none — so it is what makes "the best rated one around here" answerable at all; it is clamped to 0..5. Coordinates are range-checked, strings capped, `website` must be http/https, and ids are namespaced to `plugin:<yourId>:<id>` so they can never collide with an OSM one. Called for an explicit search, **not** per keystroke. The Road trip search along the route calls it too, once per kind the person picked, with `category` (`fuel`, `charging`, `rest_area`, `campsite`, `restaurant`, `sights` or `hotel`) and `bounds` (`{south, west, north, east}`, with `near` at its centre); `query` is then a fixed English phrase for that kind such as `fuel station` or `EV charging station`. The host drops every hit outside `bounds`, and a hit whose own `category` names a different one of those seven kinds; the rest count as the requested kind. Both fields are absent on ordinary searches. Also `GET /api/plugin-search`, and to a connected assistant as the `search_places_via_plugins` MCP tool; the corridor search is `POST /api/roadtrip/search-area` and the `search_roadtrip_corridor` MCP tool |
|
||||
| `searchProvider.search(request, ctx)` → `SearchResultPlace[]` | `hook:search-provider` | **live** — answers place searches from an index TREK does not ship, drawn into the app's own search list beside the core results (#2221). `request` is `{query, limit, lang?, near?, category?, bounds?}`; each place is `{id?, name, lat, lng, address?, rating?, website?, phone?, category?, description?}`. `rating` is the field open data cannot answer — OpenStreetMap carries none — so it is what makes "the best rated one around here" answerable at all; it is clamped to 0..5. Coordinates are range-checked, strings capped, `website` must be http/https, and ids are namespaced to `plugin:<yourId>:<id>` so they can never collide with an OSM one. `search` is called for an explicit search, **not** per keystroke. The optional `searchProvider.suggest(request, ctx)` takes the same request while the query is typed (from the second character, `limit` 3, 800 ms) and adds at most three rows across all providers to the place search's dropdown, labelled with the plugin's name; implement it only for an index that can take a request per keystroke, and send every field you have, because a picked row is taken without a details lookup. The host learns at load whether the hook has `suggest`. The Road trip search along the route calls `search` too, once per kind the person picked, with `category` (`fuel`, `charging`, `rest_area`, `campsite`, `restaurant`, `sights` or `hotel`) and `bounds` (`{south, west, north, east}`, with `near` at its centre); `query` is then a fixed English phrase for that kind such as `fuel station` or `EV charging station`. The host drops every hit outside `bounds`, and a hit whose own `category` names a different one of those seven kinds; the rest count as the requested kind. Both fields are absent on ordinary searches. Also `GET /api/plugin-search` (and `GET /api/plugin-search/suggest` for `suggest`), and to a connected assistant as the `search_places_via_plugins` MCP tool; the corridor search is `POST /api/roadtrip/search-area` and the `search_roadtrip_corridor` MCP tool |
|
||||
| `poiCategoryProvider.getPois(request, ctx)` → `PoiCategoryPlace[]` | `hook:poi-category-provider` | **live**: answers the chips your plugin adds to the trip map's **Explore places** pill (#1781): trailheads, EV chargers, step-free places, toilets and drinking water, campsites, community places. Declare up to four in [`capabilities.poiCategories`](#manifest-reference-trek-pluginjson). `request` is `{category, bounds, lang?, limit}`: `category` is one of your own declared ids (the host never sends another), `bounds` is `{south, west, north, east}`, the viewport narrowed to a centred window of at most 0.5 degrees a side (the same window the OpenStreetMap categories use) and folded onto -180..180, `limit` is 60. Each place is `{id?, name, lat, lng, address?, website?, phone?, rating?, details?}` where `details` is up to 6 `{label, value}` rows only your index knows (a trail length, a step-free entrance), label ≤40 and value ≤120 chars, flattened to one line and emoji-stripped. The rows show as plain text in the marker's hover card on the desktop; `rating` (0..5) is not drawn on the map and reaches a connected assistant only. A click on a marker opens the place form with the name, address, website, phone and coordinates filled in, and the saved place keeps `plugin:<yourId>:<id>` as its `osm_id`, which TREK never mistakes for a Google place id. The host keeps places inside `bounds` only, at most 60 (a longer answer is reported as `truncated`), namespaces ids to `plugin:<yourId>:<id>`, checks `website` like the search provider does, and draws every place with **your declared** icon and colour, whatever the answer says. **Targeted, not a fan-out**: TREK asks exactly the plugin whose chip the user picked, as that user (so `ctx.settings.get()` is theirs), with an 8 s timeout; a timeout, a thrown error or an answer that is not an array turns into a `502` for that chip only, and the core categories never wait on it. Called when the user picks the chip or asks to search the area again, not on every pan. Nothing is cached. Also `GET /api/plugin-pois?pluginId=&category=&south=&west=&north=&east=&lang=`, and to a connected assistant as the `list_plugin_poi_categories` and `search_plugin_pois` MCP tools |
|
||||
| `warningProvider.getWarnings(tripId, ctx)` → `{ level, message, dayId?, placeId? }[]` | `hook:trip-warning-provider` | **live** — validation warnings shown as a non-blocking banner in the trip planner; also `GET /api/trip-warnings/:tripId`, and to a connected assistant as the `get_trip_warnings` MCP tool (≤20 warnings per provider, message ≤300 chars) — that path needs only the trips read scope, not `plugins:use` |
|
||||
| `tableContributor.getContributions(view, tripId, ctx)` → `TableContribution[]` | `hook:table-contributor` | **live** — host-rendered **columns/actions** keyed by `entityId` in the reservations, transports, places, day, costs, packing, files and todos views. A `column` is `{kind:'column', entityId, id, label, value?, url?, icon?, tone?}` (url is http/https/mailto only); an `action` is `{kind:'action', entityId, id, label, icon?, target}` where `target` opens your sandboxed frame (`{kind:'frame', sub}`) or calls a route (`{kind:'route', method, sub}`). All fields are bounded + normalized host-side; also `GET /api/view-contributions/:view/:tripId` |
|
||||
|
||||
@@ -58,7 +58,7 @@ ungranted capability is physically unreachable**, not just disallowed. See
|
||||
| `hook:photo-provider` | Register as a photo provider in Memories | Implement the `PhotoProvider` interface. |
|
||||
| `hook:calendar-source` | Register as a calendar source | Implement the `CalendarSource` interface. |
|
||||
| `hook:place-detail-provider` | Contribute extra details (reviews, ratings, links) to a place via the `hooks.placeDetailProvider` provider hook | Implement `PlaceDetailProvider` in `hooks` on the plugin definition (not on `ctx`) — shown in the place-detail panel; also exposed at `GET /api/place-details/:placeId`. |
|
||||
| `hook:search-provider` | Answer place searches from an index TREK does not ship, beside its own results | Implement `SearchProvider` in `hooks` on the plugin definition — hits are drawn into the app’s search list; also exposed at `GET /api/plugin-search` and as the `search_places_via_plugins` MCP tool. Only these results can carry a `rating`: open data has none. The Road trip search along the route calls the same hook once per kind, with `category` and `bounds` added to the request and a fixed category phrase as `query`; hits outside `bounds`, or of a different road trip kind, are dropped. |
|
||||
| `hook:search-provider` | Answer place searches from an index TREK does not ship, beside its own results | Implement `SearchProvider` in `hooks` on the plugin definition — hits are drawn into the app’s search list; also exposed at `GET /api/plugin-search` and as the `search_places_via_plugins` MCP tool. A plugin that also implements the optional `suggest` is asked while the user types, so it receives what is typed into the place search from the second character on, not only the finished query, and adds up to three rows to the dropdown (`GET /api/plugin-search/suggest`). Only these results can carry a `rating`: open data has none. The Road trip search along the route calls the same hook once per kind, with `category` and `bounds` added to the request and a fixed category phrase as `query`; hits outside `bounds`, or of a different road trip kind, are dropped. |
|
||||
| `hook:poi-category-provider` | Add its own place categories to the trip map's **Explore places** pill and answer them for the map area the user is viewing | Declare up to 4 chips in `capabilities.poiCategories` (each `{id, label, labels?, icon, color}`: the icon from a fixed list of lucide names, the colour `#rrggbb` only) and implement `PoiCategoryProvider` in `hooks`. When a user picks one of the chips, TREK calls `getPois({category, bounds, lang?, limit})` on exactly that plugin, **as that user**, with the viewport narrowed to at most 0.5 degrees a side, so granting this means the plugin learns which map area each user who uses its chips is looking at. Its own permission for that reason, rather than a reuse of `hook:search-provider`: that hook is asked about a search somebody ran, this one about the map area somebody is looking at. The host keeps at most 60 places inside `bounds`, caps every string and the up-to-6 `details` rows, allows only http/https websites, namespaces ids as `plugin:<id>:<ref>`, and styles every marker with the declared icon and colour rather than anything the answer carries. 8 s timeout; a slow or failing plugin costs only its own chip. Nothing is cached, because the answer may depend on the user's own settings. Without the grant a declaration still installs but its chips never appear. Exposed at `GET /api/plugin-pois` and as the `list_plugin_poi_categories` and `search_plugin_pois` MCP tools. |
|
||||
| `hook:trip-warning-provider` | Raise validation warnings on a trip via the `hooks.warningProvider` provider hook | Implement `WarningProvider` in `hooks` on the plugin definition (not on `ctx`) — shown as a non-blocking banner in the planner; also exposed at `GET /api/trip-warnings/:tripId`, and to a connected assistant as the `get_trip_warnings` MCP tool (≤20 warnings per provider, message ≤300 chars) — that path needs only the trips read scope, not `plugins:use`. |
|
||||
| `hook:map-marker-provider` | Overlay bounded **markers** on the trip map via the `hooks.mapMarkerProvider` provider hook | Implement `MapMarkerProvider` in `hooks` — returns `{id, lat, lng, label?, popupText?, url?, icon?, tone?}[]` (#587 "show bookings on map"). **Declarative only** — plugin JS never runs on the map canvas. The host range-checks coordinates (−90..90 / −180..180), String-coerces + length-caps text, allowlists the popup url (http/https/mailto), and caps the marker count (≤200) per plugin; a failing provider is skipped. Exposed at `GET /api/map-markers/:tripId`. |
|
||||
|
||||
Reference in New Issue
Block a user