diff --git a/client/src/api/client.behavior.test.ts b/client/src/api/client.behavior.test.ts index b65ba7dbd..087965f83 100644 --- a/client/src/api/client.behavior.test.ts +++ b/client/src/api/client.behavior.test.ts @@ -387,6 +387,33 @@ describe('client > dev-only contract drift checks', () => { ) }) + it('FE-APIWIRE-037: search-provider hits are appended to the core results and name their index', async () => { + server.use( + http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })), + http.get('/api/plugin-search', () => + HttpResponse.json({ places: [{ name: 'Plugin hit', source: 'plugin:demo' }] })), + ) + + // Appended, not interleaved: the core list keeps the order it earned. + await expect(mapsApi.search('Rome')).resolves.toEqual({ + places: [{ name: 'Core hit' }, { name: 'Plugin hit', source: 'plugin:demo' }], + source: 'openstreetmap+plugin:demo', + }) + }) + + it('FE-APIWIRE-038: a failing search provider leaves the search exactly as it was', async () => { + server.use( + http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })), + http.get('/api/plugin-search', () => HttpResponse.json({ error: 'boom' }, { status: 500 })), + ) + + // The optional index being unwell must never cost the search that worked. + await expect(mapsApi.search('Rome')).resolves.toEqual({ + places: [{ name: 'Core hit' }], + source: 'openstreetmap', + }) + }) + it('FE-APIWIRE-023: a drifting maps response is reported under its own label', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) server.use(http.post('/api/maps/search', () => HttpResponse.json({ nonsense: true }))) diff --git a/client/src/api/client.surface.test.ts b/client/src/api/client.surface.test.ts index be189f2e1..474845ec1 100644 --- a/client/src/api/client.surface.test.ts +++ b/client/src/api/client.surface.test.ts @@ -413,9 +413,22 @@ describe('client > endpoint wiring', () => { expect(rec.url).toBe('/api/integrations/memories/synologyphotos/albums/alb-2/photos?passphrase=p%2Fw%3F') }) + /** + * The one call in this file that is deliberately TWO requests, which is why it is not + * in the list above: a search asks TREK's own indexes and any installed search-provider + * plugin at the same time, and the caller gets one list back (#2221). + */ + it('FE-APISURF-055: mapsApi.search asks the core index and the plugin providers side by side', async () => { + log = [] + await mapsApi.search('Rome') + expect(log.map(r => `${r.method} ${r.url.split('?')[0]}`).sort()).toEqual([ + 'GET /api/plugin-search', + 'POST /api/maps/search', + ]) + }) + it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => { await assertCalls([ - { n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' }, { n: 'maps.autocomplete', r: () => mapsApi.autocomplete('Rom'), e: 'POST /api/maps/autocomplete' }, { n: 'maps.details', r: () => mapsApi.details('place/1'), e: 'GET /api/maps/details/place%2F1' }, { n: 'maps.placePhoto', r: () => mapsApi.placePhoto('place/1'), e: 'GET /api/maps/place-photo/place%2F1' }, diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 07ce7d621..532beeaef 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1143,6 +1143,50 @@ async function withCachedPlaces( } } +/** + * What plugins implementing `searchProvider` found for the same query (#2221). + * + * Its own request beside the core one rather than a branch inside it: the core search + * is TREK's own indexes and must not wait on, or fail with, somebody's plugin. This + * answers with an empty list for every failure there is — no provider installed, one + * that timed out, a 404 on an older server, the network gone — because a search that + * breaks when an optional index is unwell is worse than one without it. + */ +/** + * How long a plugin index may keep the search list waiting, in milliseconds. + * + * The host gives a provider two seconds to answer and this gives the round trip a + * little more. It is a ceiling on the WAIT, not on the provider: with no search + * plugin installed the route answers immediately without reaching any of them, so + * the normal case costs one local round trip and nothing else. + * + * A deadline rather than patience, because the alternative is a search that feels + * broken. A list that arrives without an optional index is a smaller answer; a list + * that arrives four seconds late is no answer at all. + */ +const PLUGIN_SEARCH_DEADLINE_MS = 2500 + +async function pluginSearchPlaces( + query: string, + lang?: string, + near?: { lat: number; lng: number }, +): Promise[]> { + const stop = new AbortController() + const timer = setTimeout(() => stop.abort(), PLUGIN_SEARCH_DEADLINE_MS) + try { + const r = await apiClient.get('/plugin-search', { + params: { q: query, lang: lang || 'en', lat: near?.lat, lng: near?.lng }, + signal: stop.signal, + }) + const places = (r.data as { places?: unknown })?.places + return Array.isArray(places) ? (places as Record[]) : [] + } catch { + return [] + } finally { + clearTimeout(timer) + } +} + export const mapsApi = { /** * `locationBias` is what tells the search which "Hase-dera" is meant, and it @@ -1152,10 +1196,25 @@ export const mapsApi = { * It also decides whether the index answers at all: a common single word * without coordinates is refused upstream as too expensive, and the search * then falls back to Nominatim alone. + * + * Plugin indexes are asked at the same time and their hits are appended, so every + * caller of this one function gets them without knowing they exist. Appended rather + * than interleaved: the core list is ordered by relevance and has earned that order, + * and a plugin's row carries its own `source` for a caller that wants to mark it. */ search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) => - withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), () => - apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search'))), + withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => { + // Side by side, so the wait is the slower of the two rather than their sum. Only + // the core call may reject: that rejection is what hands withCachedPlaces the + // offline path, and a plugin failure must never trigger it. + const [core, extra] = await Promise.all([ + apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')), + pluginSearchPlaces(query, lang, locationBias), + ]) + if (extra.length === 0) return core + const from = [...new Set(extra.map(p => String(p.source ?? 'plugin')))].join('+') + return { places: [...core.places, ...extra], source: `${core.source}+${from}` } + }), autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) => withCachedPlaces( input, diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index 15213f89e..a0abde00f 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -262,6 +262,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') }) if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') }) if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') }) + if (perms.includes('hook:search-provider')) out.push({ icon: Search, label: t('admin.plugins.cap.search') }) if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') }) if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') }) if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') }) diff --git a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx index 4c3d21321..b0db824ba 100644 --- a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx @@ -261,6 +261,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') }) if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') }) if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') }) + if (perms.includes('hook:search-provider')) out.push({ icon: Search, label: t('admin.plugins.cap.search') }) if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') }) if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') }) if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') }) diff --git a/client/tests/helpers/msw/handlers/index.ts b/client/tests/helpers/msw/handlers/index.ts index 42b0395e8..87c80f1a6 100644 --- a/client/tests/helpers/msw/handlers/index.ts +++ b/client/tests/helpers/msw/handlers/index.ts @@ -5,6 +5,7 @@ import { notificationHandlers } from './notifications'; import { vacayHandlers } from './vacay'; import { tripsHandlers } from './trips'; import { placesHandlers } from './places'; +import { pluginSearchHandlers } from './pluginSearch'; import { assignmentsHandlers } from './assignments'; import { packingHandlers } from './packing'; import { todoHandlers } from './todo'; @@ -25,6 +26,7 @@ export const defaultHandlers = [ ...vacayHandlers, ...tripsHandlers, ...placesHandlers, + ...pluginSearchHandlers, ...assignmentsHandlers, ...packingHandlers, ...todoHandlers, diff --git a/client/tests/helpers/msw/handlers/pluginSearch.ts b/client/tests/helpers/msw/handlers/pluginSearch.ts new file mode 100644 index 000000000..6c56d45a8 --- /dev/null +++ b/client/tests/helpers/msw/handlers/pluginSearch.ts @@ -0,0 +1,16 @@ +import { http, HttpResponse } from 'msw'; + +/** + * The search-provider route, answering the way an instance with no such plugin does. + * + * `mapsApi.search` asks this beside every place search (#2221), so without a default + * handler every suite that searches for a place has an unhandled request hanging in it + * until its own deadline — which is a slow test with a warning in it, not a real + * failure, and the kind that later reads as flake. + * + * Empty is the honest default: a plugin providing a search index is the exception, and + * a suite that wants hits says so with its own `server.use`. + */ +export const pluginSearchHandlers = [ + http.get('/api/plugin-search', () => HttpResponse.json({ places: [] })), +]; diff --git a/plugin-sdk/README.md b/plugin-sdk/README.md index 5bfa8d3f7..0a15e485d 100644 --- a/plugin-sdk/README.md +++ b/plugin-sdk/README.md @@ -225,6 +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 | | `photoProvider` | ≤60 photos per page | | `calendarSource` | ≤500 events per source per request | | `tableContributor` | ≤20 columns / ≤10 actions per entity | diff --git a/plugin-sdk/src/cli/ui.ts b/plugin-sdk/src/cli/ui.ts index 61fbcabc6..9f596127b 100644 --- a/plugin-sdk/src/cli/ui.ts +++ b/plugin-sdk/src/cli/ui.ts @@ -212,6 +212,7 @@ export const PERMISSION_FAMILIES: PermissionFamily[] = [ { value: 'hook:photo-provider', hint: 'Supply place photos to TREK' }, { value: 'hook:calendar-source', hint: 'Supply calendar events to TREK' }, { value: 'hook:place-detail-provider', hint: 'Contribute extra details (reviews, ratings, links) to a place' }, + { value: 'hook:search-provider', hint: 'Answer place searches from your own index, beside the core results' }, { value: 'hook:trip-warning-provider', hint: 'Raise validation warnings on a trip (shown in the planner)' }, { value: 'hook:table-contributor', hint: 'Add columns to TREK\'s tables' }, { value: 'hook:map-marker-provider', hint: 'Add your own markers to the map' }, diff --git a/plugin-sdk/src/generated/host-facts.ts b/plugin-sdk/src/generated/host-facts.ts index 6cd9656bd..e48b12ceb 100644 --- a/plugin-sdk/src/generated/host-facts.ts +++ b/plugin-sdk/src/generated/host-facts.ts @@ -6,6 +6,7 @@ export const HOOK_PERMISSION: Readonly> = { photoProvider: 'hook:photo-provider', calendarSource: 'hook:calendar-source', placeDetailProvider: 'hook:place-detail-provider', + searchProvider: 'hook:search-provider', warningProvider: 'hook:trip-warning-provider', tableContributor: 'hook:table-contributor', mapMarkerProvider: 'hook:map-marker-provider', @@ -65,6 +66,7 @@ export const KNOWN_PERMISSIONS: string[] = [ 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', + 'hook:search-provider', 'hook:trip-warning-provider', 'hook:table-contributor', 'hook:map-marker-provider', diff --git a/plugin-sdk/src/index.ts b/plugin-sdk/src/index.ts index 7a45d0021..1b277f8ee 100644 --- a/plugin-sdk/src/index.ts +++ b/plugin-sdk/src/index.ts @@ -476,6 +476,45 @@ export interface PlaceDetailProvider { * The host caps results at 12 items per provider. */ getDetails(placeId: number, ctx: PluginContext): Promise; } +/** + * A place a search provider found, in the shape the host turns into a search row. + * + * `rating` is the one field open data cannot answer, and the reason this hook exists: + * OpenStreetMap carries no ratings at all, so "the best one around here" needs an + * index that has them. Zero to five, the scale every such index uses. + */ +export interface SearchResultPlace { + /** Stable id in your own index. The host namespaces it as `plugin::`. */ + id?: string; + name: string; + lat: number; + lng: number; + address?: string; + rating?: number; + website?: string; + phone?: string; + category?: string; + description?: string; +} +/** What the host asks a search provider to look for. */ +export interface SearchRequest { + query: string; + /** The most rows worth returning. The host caps it at 20 whatever it is asked for. */ + limit: number; + /** The caller's language tag, for indexes that carry localized names. */ + lang?: string; + /** Where the person is looking, when there is somewhere to bias toward. */ + near?: { lat: number; lng: number }; +} +export interface SearchProvider { + /** Places for a query, drawn into TREK's own search list beside the core results. + * Needs `hook:search-provider`. The host caps results at 20 places per provider and + * gives you 2 seconds to answer, because somebody is waiting on the list. + * + * 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; +} /** A validation/warning a plugin raises on a trip; TREK surfaces it in the planner. */ export interface TripWarning { level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number; } export interface WarningProvider { @@ -828,6 +867,7 @@ export interface PluginDefinition { photoProvider?: PhotoProvider; calendarSource?: CalendarSource; placeDetailProvider?: PlaceDetailProvider; + searchProvider?: SearchProvider; warningProvider?: WarningProvider; tableContributor?: TableContributor; mapMarkerProvider?: MapMarkerProvider; diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index 80b5cf666..e747bce78 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -292,7 +292,7 @@ export class PlacesMcp { @Tool({ name: 'search_place', - description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links. Pass locationBias whenever the trip has a destination: a bare name like "Central Station" or "Museum of Modern Art" otherwise resolves wherever the provider guesses, which is regularly the wrong continent.', + description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links. Pass locationBias whenever the trip has a destination: a bare name like "Central Station" or "Museum of Modern Art" otherwise resolves wherever the provider guesses, which is regularly the wrong continent. Searches the TREK index and OpenStreetMap only: if the instance has a plugin providing its own search index, call search_places_via_plugins as well, and note that ratings can only come from there.', inputSchema: { query: z.string().min(1).max(500).describe('Place name or address to search for'), locationBias: mapsSearchRequestSchema.shape.locationBias.describe('Centre the search on a coordinate: { lat, lng, radius? } with radius in metres (default 50000). Only the Google provider honours it; the OpenStreetMap fallback ignores it'), diff --git a/server/src/nest/plugins/contributions/plugin-contributions.module.ts b/server/src/nest/plugins/contributions/plugin-contributions.module.ts index f27406576..c1bfc30b3 100644 --- a/server/src/nest/plugins/contributions/plugin-contributions.module.ts +++ b/server/src/nest/plugins/contributions/plugin-contributions.module.ts @@ -3,8 +3,10 @@ import { PluginsRuntimeModule } from '../plugins-runtime.module'; import { AddonsModule } from '../../addons/addons.module'; import { JourneyDomainModule } from '../../journey/journey-domain.module'; import { PlaceDetailsController } from './place-details.controller'; +import { PluginSearchController } from './plugin-search.controller'; import { TripWarningsController } from './trip-warnings.controller'; import { TripWarningsMcp } from './trip-warnings.mcp'; +import { PluginSearchMcp } from './plugin-search.mcp'; import { PluginMcpToolsService } from './plugin-mcp-tools.service'; import { ViewContributionsController } from './view-contributions.controller'; import { TripCardContributionsController } from './trip-card-contributions.controller'; @@ -21,8 +23,9 @@ import { JournalEntryRowsController } from './journal-entry-rows.controller'; /** * The read-only surface plugins contribute to the app: photos, calendar events, - * place details, trip warnings, table columns, map markers and layers, routes, day - * schedules and tints, PDF sections, atlas layers, journal entry rows, trip cards. + * place details, search results, trip warnings, table columns, map markers and + * layers, routes, day schedules and tints, PDF sections, atlas layers, journal entry + * rows, trip cards. * * Every one of these is the same shape — fan out over `providersOf(hook)`, call the * hook through `PluginHooks`, normalize and cap what comes back, skip a provider that @@ -34,6 +37,7 @@ import { JournalEntryRowsController } from './journal-entry-rows.controller'; imports: [PluginsRuntimeModule, AddonsModule, JourneyDomainModule], controllers: [ PlaceDetailsController, + PluginSearchController, TripWarningsController, ViewContributionsController, TripCardContributionsController, @@ -55,6 +59,6 @@ import { JournalEntryRowsController } from './journal-entry-rows.controller'; // PluginMcpToolsService owns the process-level tool source. It lives here, and // not on PluginRuntimeService beside the other sinks, because it needs // PluginHooks and PluginHooks injects PluginRuntimeService. - providers: [TripWarningsMcp, PluginMcpToolsService], + providers: [TripWarningsMcp, PluginSearchMcp, PluginMcpToolsService], }) export class PluginContributionsModule {} diff --git a/server/src/nest/plugins/contributions/plugin-search.controller.ts b/server/src/nest/plugins/contributions/plugin-search.controller.ts new file mode 100644 index 000000000..b53d6f9aa --- /dev/null +++ b/server/src/nest/plugins/contributions/plugin-search.controller.ts @@ -0,0 +1,75 @@ +import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; +import type { Request } from 'express'; +import { JwtAuthGuard } from '../../auth/jwt-auth.guard'; +import { pluginsEnabled } from '../kill-switch'; +import { PluginHooks } from '../plugin-hooks.service'; +import { + interleave, + limitFrom, + MAX_QUERY, + nearFrom, + normalizeSearchHits, + type SearchHit, +} from './plugin-search.helpers'; + +/** + * GET /api/plugin-search — places found by plugins implementing `searchProvider` (#2221). + * + * A search index TREK does not ship. The core search asks the TREK index and + * OpenStreetMap; this asks whatever else the operator installed, and the client draws + * one list out of both. Additive and fail-safe like the other provider-hook + * controllers: a provider that errors or times out contributes nothing and the search + * is drawn without it. + * + * NOT trip-scoped, unlike its neighbours here. Searching for a place is not a read of + * anybody's trip: the query is the caller's own words and the answer is a public index, + * so the gate is being signed in. Nothing about a trip is passed to the plugin, which + * is also what keeps a search provider from becoming a way to enumerate one. + * + * A route of its own beside `POST /api/maps/search` rather than a branch inside it, + * for the reason `trip-warnings.mcp.ts` documents next door: the plugin runtime already + * imports PlacesModule, which imports MapsModule, so asking the providers from inside + * the maps service would close a module cycle. The client calls both and merges. + */ +@Controller('api/plugin-search') +@UseGuards(JwtAuthGuard) +export class PluginSearchController { + constructor(private readonly hooks: PluginHooks) {} + + @Get() + async search( + @Query('q') q: string | undefined, + @Query('lat') lat: string | undefined, + @Query('lng') lng: string | undefined, + @Query('lang') lang: string | undefined, + @Query('limit') limit: string | undefined, + @Req() req: Request & { user?: { id: number } }, + ): 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 ids = this.hooks.providersOf('searchProvider'); + if (ids.length === 0) return { places: [] }; + + 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 => { + 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) }; + } +} diff --git a/server/src/nest/plugins/contributions/plugin-search.helpers.ts b/server/src/nest/plugins/contributions/plugin-search.helpers.ts new file mode 100644 index 000000000..5d518d7e1 --- /dev/null +++ b/server/src/nest/plugins/contributions/plugin-search.helpers.ts @@ -0,0 +1,135 @@ +import { stripEmoji } from '../text-sanitize'; + +/** + * Turning what a `searchProvider` plugin answers into rows TREK can show (#2221). + * + * Shared by the REST route and the MCP tool rather than written twice: the two are the + * same question asked by a browser and by an assistant, and a cap or a url check that + * held on one path but not the other would be a hole with a second door. + * + * Every hit is normalized into the record shape the core search returns (`toPlaceRecord` + * in maps/trek-places.client.ts), because the callers read `name`, `lat`, `lng` and + * `address` off whatever the list holds and must not need to know which index a row + * came from. + */ +export interface SearchHit { + osm_id: string; + name: string; + address: string; + lat: number; + lng: number; + /** What a plugin index can answer that open data cannot. Null when it does not. */ + rating: number | null; + website: string | null; + phone: string | null; + category: string | null; + description: string | null; + /** `plugin:`, so a row can name the index it came from. */ + source: string; + pluginId: string; +} + +/** Per provider. Bounds one plugin's share of a list a person reads. */ +export const MAX_HITS = 20; +/** The most one provider may be asked for, whatever the caller puts in the query. */ +export const MAX_LIMIT = 20; +export const DEFAULT_LIMIT = 10; +/** Long enough for a full address line, short enough that no row can flood the list. */ +export const MAX_QUERY = 200; + +const cap = (v: unknown, n: number): string => stripEmoji(String(v ?? '')).slice(0, n); + +const capOrNull = (v: unknown, n: number): string | null => { + if (v == null) return null; + const s = cap(v, n); + return s === '' ? null : s; +}; + +/** + * A url that may become an ``. Anything but http/https is dropped — a + * `javascript:` or `data:` url rendered as a link is click-XSS into the search list. + */ +function safeUrl(raw: unknown): string | null { + if (typeof raw !== 'string' || raw === '') return null; + try { + const u = new URL(raw); + return u.protocol === 'http:' || u.protocol === 'https:' ? raw.slice(0, 2048) : null; + } catch { + return null; + } +} + +/** + * A rating a plugin index carries, on the five-point scale every such index uses. + * + * Clamped rather than rejected: a provider answering on a ten-point scale is a bug in + * that provider, and dropping the whole hit over it would lose the place as well. + */ +function safeRating(raw: unknown): number | null { + const n = Number(raw); + if (!Number.isFinite(n)) return null; + return Math.min(5, Math.max(0, Math.round(n * 10) / 10)); +} + +export function normalizeSearchHits(pluginId: string, raw: unknown): SearchHit[] { + const list = Array.isArray(raw) ? (raw as Array>) : []; + const out: SearchHit[] = []; + for (const h of list) { + if (out.length >= MAX_HITS) break; + if (!h || typeof h !== 'object') continue; + const lat = Number(h.lat); + const lng = Number(h.lng); + const name = cap(h.name, 200); + // A hit with no name or no place on the earth cannot be shown or picked. + if (!name || !Number.isFinite(lat) || !Number.isFinite(lng)) continue; + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) continue; + const id = cap(h.id, 100); + out.push({ + // Namespaced so a plugin id can never collide with a real OSM id or a `gers:` + // one from the index. Falls back to the coordinate when the plugin has no stable + // id of its own, which still tells two rows apart. + osm_id: `plugin:${pluginId}:${id || `${lat},${lng}`}`, + name, + address: cap(h.address, 300), + lat, + lng, + rating: safeRating(h.rating), + website: safeUrl(h.website), + phone: capOrNull(h.phone, 60), + category: capOrNull(h.category, 60), + description: capOrNull(h.description, 500), + source: `plugin:${pluginId}`, + pluginId, + }); + } + return out; +} + +/** 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); + const lng = Number(lngRaw); + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return undefined; + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return undefined; + return { 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; +} + +/** + * One list out of several providers, taking a row from each in turn. + * + * Interleaved rather than concatenated: with two indexes installed, a plain + * concatenation buries the second one's best hit under twenty of the first one's. + */ +export function interleave(results: SearchHit[][]): SearchHit[] { + const out: SearchHit[] = []; + for (let i = 0; ; i += 1) { + const row = results.filter(r => r.length > i); + if (row.length === 0) return out; + for (const r of row) out.push(r[i]); + } +} diff --git a/server/src/nest/plugins/contributions/plugin-search.mcp.ts b/server/src/nest/plugins/contributions/plugin-search.mcp.ts new file mode 100644 index 000000000..056e5f27f --- /dev/null +++ b/server/src/nest/plugins/contributions/plugin-search.mcp.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; +import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext, type McpTextResult } from '../../../nest-mcp'; +import { pluginsEnabled } from '../kill-switch'; +import { PluginHooks } from '../plugin-hooks.service'; +import { + interleave, + limitFrom, + MAX_QUERY, + nearFrom, + normalizeSearchHits, + type SearchHit, +} from './plugin-search.helpers'; + +/** + * The MCP half of GET /api/plugin-search (#2221). + * + * The browser merges the plugin hits into its own search list, so a person searching + * for a hotel sees the installed index alongside OpenStreetMap without asking. An + * assistant calling `search_place` saw only TREK's own indexes, which is the same + * asymmetry `get_trip_warnings` exists to close: what the user can see, the assistant + * has to be able to see. + * + * A tool of its own rather than a section of `search_place`, for the reason spelled out + * in trip-warnings.mcp.ts: `search_place` lives in PlacesModule, the plugin runtime + * already imports PlacesModule, and reaching the hooks from there would close a module + * cycle. It is also a different cost — an IPC round trip into every installed provider + * with a four-second budget, against a synchronous call into the maps service. + */ +@McpController() +export class PluginSearchMcp { + constructor(private readonly hooks: PluginHooks) {} + + @Tool({ + name: 'search_places_via_plugins', + description: 'Search for a place in the search indexes installed plugins provide, which are the ones TREK does not ship itself. Use it alongside search_place, never instead of it: search_place is TREK\'s own index and OpenStreetMap, this is whatever else the instance owner installed, and only these results can carry a rating — open data has none, so a question like "the best rated hotel near here" can only be answered from this list. Results have the same shape search_place returns, plus a `rating` and the `pluginId` that found them. Returns an empty list when no plugin provides a search index, which is the normal case.', + inputSchema: { + query: z.string().min(1).max(MAX_QUERY).describe('Place name or address to search for'), + near: z.object({ lat: z.number(), lng: z.number() }) + .optional() + .describe('Centre the search on a coordinate. Pass it whenever the trip has a destination: a bare name like "Central Station" otherwise resolves wherever the provider guesses'), + lang: z.string().max(20).optional().describe('BCP 47 language for the result names, e.g. "de" or "ja"'), + limit: z.number().int().positive().max(20).optional().describe('How many results per provider (default 10, capped at 20)'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'places', mode: 'read' }, + }) + async searchViaPlugins( + { query, near, lang, limit }: { + query: string; near?: { lat: number; lng: number }; lang?: string; limit?: number; + }, + ctx: McpContext, + ): Promise { + if (!pluginsEnabled()) return ok({ places: [] }); + const ids = this.hooks.providersOf('searchProvider'); + if (ids.length === 0) return ok({ places: [] }); + + // Built through the same helpers the REST route uses, so a cap that holds for the + // browser holds for the assistant. `near` arrives typed here and still goes through + // nearFrom, because a schema that accepts any number still accepts latitude 91. + const request = { + query: query.trim().slice(0, MAX_QUERY), + limit: limitFrom(limit), + lang, + near: nearFrom(near?.lat, near?.lng), + }; + const results = await Promise.all( + ids.map(async (id): Promise => { + 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) }); + } +} diff --git a/server/src/nest/plugins/plugin-hooks.service.ts b/server/src/nest/plugins/plugin-hooks.service.ts index 376785421..b6a6a4ff3 100644 --- a/server/src/nest/plugins/plugin-hooks.service.ts +++ b/server/src/nest/plugins/plugin-hooks.service.ts @@ -18,6 +18,21 @@ export interface HookMcpToolCall { args: unknown; } +/** + * What a search provider is asked to look for. + * + * `near` is the same bias the core search uses, and it is what decides which of the + * four places called "Hase-dera" the answer is about. Optional, because a search from + * a trip with no places yet has nothing to bias toward. + */ +export interface HookSearchRequest { + query: string; + limit: number; + /** The caller's language tag, for providers that return localized names. */ + lang?: string; + near?: { lat: number; lng: number }; +} + /** The waypoint request a route provider is asked to solve. */ export interface HookRouteRequest { tripId: number; @@ -29,7 +44,7 @@ export interface HookRouteRequest { /** * Every host-to-plugin hook call, in one place. * - * The 15 hooks the consent screen offers used to be invoked straight from the + * The 16 hooks the consent screen offers used to be invoked straight from the * controllers, with the fn name and the timeout written out at each call site. That * made three things impossible to check: that a granted `hook:*` permission actually * has a consumer (a dead grant on the consent screen looks exactly like a live one), @@ -81,6 +96,20 @@ export class PluginHooks { return this.runtime.invokeHook(pluginId, 'placeDetailProvider', 'getDetails', [placeId], userId, 5000); } + /** + * The shortest leash of any hook here, 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 + * whatever happens. Two seconds is therefore the whole of what a provider can + * usefully spend: past it the list is already drawn and its answer is discarded, so + * a longer budget would only hold an IPC call open for nothing. + */ + @PluginHook('searchProvider', { permission: 'hook:search-provider', fn: 'search', timeoutMs: 2000 }) + searchPlaces(pluginId: string, request: HookSearchRequest, userId: number): Promise { + return this.runtime.invokeHook(pluginId, 'searchProvider', 'search', [request], userId, 2000); + } + @PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings', timeoutMs: 5000 }) tripWarnings(pluginId: string, tripId: number, userId: number): Promise { return this.runtime.invokeHook(pluginId, 'warningProvider', 'getWarnings', [tripId], userId, 5000); diff --git a/server/src/nest/plugins/plugins.module.ts b/server/src/nest/plugins/plugins.module.ts index 8d4c338e1..b01e52882 100644 --- a/server/src/nest/plugins/plugins.module.ts +++ b/server/src/nest/plugins/plugins.module.ts @@ -20,7 +20,7 @@ import { AppConfigModule } from '../app-config/app-config.module'; * * - `PluginsRuntimeModule` — supervisor, capability router, hook contracts. The half * with the domain imports, and the only half AdminModule needs. - * - `PluginContributionsModule` — the 14 read-only hook controllers. + * - `PluginContributionsModule` — the 15 read-only hook controllers. * - `PluginOAuthModule` — the outbound-OAuth leaf. * * What is left here is the CRUD and delivery surface: install/activate/configure, the diff --git a/server/src/nest/plugins/protocol/envelope.ts b/server/src/nest/plugins/protocol/envelope.ts index c4822376a..c32a7a9d8 100644 --- a/server/src/nest/plugins/protocol/envelope.ts +++ b/server/src/nest/plugins/protocol/envelope.ts @@ -358,6 +358,7 @@ export const KNOWN_PERMISSIONS = [ 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', + 'hook:search-provider', 'hook:trip-warning-provider', 'hook:table-contributor', 'hook:map-marker-provider', @@ -430,6 +431,7 @@ export const HOOK_PERMISSION = { photoProvider: 'hook:photo-provider', calendarSource: 'hook:calendar-source', placeDetailProvider: 'hook:place-detail-provider', + searchProvider: 'hook:search-provider', warningProvider: 'hook:trip-warning-provider', tableContributor: 'hook:table-contributor', mapMarkerProvider: 'hook:map-marker-provider', diff --git a/server/src/nest/plugins/runtime/plugin-sdk.ts b/server/src/nest/plugins/runtime/plugin-sdk.ts index a8a324424..42df67ccb 100644 --- a/server/src/nest/plugins/runtime/plugin-sdk.ts +++ b/server/src/nest/plugins/runtime/plugin-sdk.ts @@ -414,6 +414,39 @@ export interface PlaceDetailItem { label: string; value?: string; url?: string; export interface PlaceDetailProvider { getDetails(placeId: number, ctx: PluginContext): Promise; } +/** + * A place a search provider found, in the shape the host turns into a search row. + * + * `rating` is the one field open data cannot answer, and the reason this hook exists: + * OpenStreetMap carries no ratings at all, so "the best one around here" needs an + * index that has them. Zero to five, the scale every such index uses. + */ +export interface SearchResultPlace { + /** Stable id in your own index. The host namespaces it as `plugin::`. */ + id?: string; + name: string; + lat: number; + lng: number; + address?: string; + rating?: number; + website?: string; + phone?: string; + category?: string; + description?: string; +} +/** What the host asks a search provider to look for. */ +export interface SearchRequest { + query: string; + /** The most rows worth returning. The host caps it at 20 whatever it is asked for. */ + limit: number; + /** The caller's language tag, for indexes that carry localized names. */ + lang?: string; + /** Where the person is looking, when there is somewhere to bias toward. */ + near?: { lat: number; lng: number }; +} +export interface SearchProvider { + search(request: SearchRequest, ctx: PluginContext): Promise; +} /** A validation/warning a plugin raises on a trip; TREK surfaces it in the planner. */ export interface TripWarning { level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number; } export interface WarningProvider { @@ -721,6 +754,7 @@ export interface PluginDefinition { photoProvider?: PhotoProvider; calendarSource?: CalendarSource; placeDetailProvider?: PlaceDetailProvider; + searchProvider?: SearchProvider; warningProvider?: WarningProvider; tableContributor?: TableContributor; mapMarkerProvider?: MapMarkerProvider; diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 89ebe5e19..9bac1ba70 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -102,6 +102,7 @@ import { makeStorageFixture } from './storage-fixture'; // and the warnings tool answers empty by default; the trip-warnings suite spies on // PluginHooks.prototype to play the provider fan-out. import { TripWarningsMcp } from '../../src/nest/plugins/contributions/trip-warnings.mcp'; +import { PluginSearchMcp } from '../../src/nest/plugins/contributions/plugin-search.mcp'; import { PluginHooks } from '../../src/nest/plugins/plugin-hooks.service'; import type { PluginRuntimeService } from '../../src/nest/plugins/plugin-runtime.service'; import { AirtrailMcp } from '../../src/nest/integrations/airtrail.mcp'; @@ -214,7 +215,7 @@ export function createMcpTestRegistry(): McpRegistry { new ReservationsMcp(reservationsService, daysService, budgetService, authService, assignmentsService, guards), new DayNotesMcp(new DayNotesService(dbService, permissionsService, realtimeService), authService, guards), new DaysMcp(daysService, authService, guards), - new RoadtripMcp(new RoadtripService(dbService), dbService, guards, authService, addonsService), + new RoadtripMcp(new RoadtripService(dbService, realtimeService), dbService, guards, authService, addonsService), new FilesMcp(new FilesService(dbService, permissionsService, realtimeService, new EphemeralTokenService(), generalStorage), authService, guards), new AccommodationsMcp(accommodationsService, dbService, placesService, authService, guards), new AssignmentsMcp(assignmentsService, daysService, authService, guards), @@ -238,6 +239,7 @@ export function createMcpTestRegistry(): McpRegistry { new SettingsMcp(new SettingsService(dbService), authService), new HelpMcp(), new AddonsMcp(addonsService), new TripWarningsMcp(new PluginHooks({ providersOf: () => [], invokeHook: async () => [] } as unknown as PluginRuntimeService), dbService), + new PluginSearchMcp(new PluginHooks({ providersOf: () => [], invokeHook: async () => [] } as unknown as PluginRuntimeService)), ], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }, ); diff --git a/server/tests/unit/mcp/tools-plugin-search.test.ts b/server/tests/unit/mcp/tools-plugin-search.test.ts new file mode 100644 index 000000000..8b9b3d05e --- /dev/null +++ b/server/tests/unit/mcp/tools-plugin-search.test.ts @@ -0,0 +1,165 @@ +/** + * Unit tests for the search_places_via_plugins MCP tool (src/nest/plugins/ + * contributions/plugin-search.mcp.ts), the MCP counterpart of GET /api/plugin-search. + */ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA foreign_keys = ON'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: number, userId: number) => + db + .prepare( + 'SELECT t.id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)', + ) + .get(userId, tripId, userId), + isOwner: (tripId: number, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +const { broadcastMock, pluginsEnabled } = vi.hoisted(() => ({ + broadcastMock: vi.fn(), + pluginsEnabled: vi.fn(() => true), +})); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); +// The admin kill switch reads live env; drive it from the test instead of the process. +vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled })); + +import { runMigrations } from '../../../src/db/migrations'; +import { createTables } from '../../../src/db/schema'; +import { createUser } from '../../helpers/factories'; +import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; +import { resetTestDb } from '../../helpers/test-db'; +import { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; + +const providersOfMock = vi.spyOn(PluginHooks.prototype, 'providersOf'); +const searchPlacesMock = vi.spyOn(PluginHooks.prototype, 'searchPlaces'); + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + broadcastMock.mockClear(); + pluginsEnabled.mockReturnValue(true); + providersOfMock.mockReset().mockReturnValue([]); + searchPlacesMock.mockReset().mockResolvedValue([]); +}); + +afterAll(() => { + testDb.close(); +}); + +async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: false, scopes: null }); + try { await fn(h); } finally { await h.cleanup(); } +} + +interface PlacesPayload { + places: { osm_id: string; name: string; rating: number | null; pluginId: string; source: string }[]; +} + +const call = async (h: McpHarness, args: Record) => + h.client.callTool({ name: 'search_places_via_plugins', arguments: args }); + +describe('Tool: search_places_via_plugins', () => { + it('returns what the providers found, with the rating open data cannot carry', async () => { + const { user } = createUser(testDb); + providersOfMock.mockReturnValue(['guide']); + searchPlacesMock.mockResolvedValue([ + { id: 'h1', name: 'Hotel Bellevue', lat: 46.5, lng: 6.6, rating: 4.6, address: 'Lakeside 1' }, + ]); + + await withHarness(user.id, async (h) => { + const payload = parseToolResult(await call(h, { query: 'hotel lausanne' })) as PlacesPayload; + expect(payload.places).toHaveLength(1); + expect(payload.places[0]).toMatchObject({ + osm_id: 'plugin:guide:h1', + name: 'Hotel Bellevue', + rating: 4.6, + pluginId: 'guide', + source: 'plugin:guide', + }); + expect(providersOfMock).toHaveBeenCalledWith('searchProvider'); + expect(searchPlacesMock).toHaveBeenCalledWith( + 'guide', + { query: 'hotel lausanne', limit: 10, lang: undefined, near: undefined }, + user.id, + ); + }); + }); + + it('passes the bias and the limit through, and refuses a coordinate off the globe', async () => { + const { user } = createUser(testDb); + providersOfMock.mockReturnValue(['guide']); + + await withHarness(user.id, async (h) => { + await call(h, { query: 'hotel', near: { lat: 46.5, lng: 6.6 }, lang: 'fr', limit: 3 }); + expect(searchPlacesMock).toHaveBeenLastCalledWith( + 'guide', + { query: 'hotel', limit: 3, lang: 'fr', near: { lat: 46.5, lng: 6.6 } }, + user.id, + ); + + // The schema accepts any number, so the range check still has to happen here. + await call(h, { query: 'hotel', near: { lat: 91, lng: 6.6 } }); + expect(searchPlacesMock).toHaveBeenLastCalledWith( + 'guide', + { query: 'hotel', limit: 10, lang: undefined, near: undefined }, + user.id, + ); + }); + }); + + it('answers with an empty list when no plugin provides an index, without asking one', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const payload = parseToolResult(await call(h, { query: 'hotel' })) as PlacesPayload; + expect(payload.places).toEqual([]); + expect(searchPlacesMock).not.toHaveBeenCalled(); + }); + }); + + it('answers with an empty list while the plugin runtime is switched off', async () => { + const { user } = createUser(testDb); + pluginsEnabled.mockReturnValue(false); + providersOfMock.mockReturnValue(['guide']); + + await withHarness(user.id, async (h) => { + const payload = parseToolResult(await call(h, { query: 'hotel' })) as PlacesPayload; + expect(payload.places).toEqual([]); + expect(providersOfMock).not.toHaveBeenCalled(); + }); + }); + + it('skips a provider that fails and still returns the other one', async () => { + const { user } = createUser(testDb); + providersOfMock.mockReturnValue(['broken', 'guide']); + searchPlacesMock.mockImplementation(async (pluginId: string) => { + if (pluginId === 'broken') throw new Error('timed out'); + return [{ id: 'h1', name: 'Hotel Bellevue', lat: 46.5, lng: 6.6 }]; + }); + + await withHarness(user.id, async (h) => { + const payload = parseToolResult(await call(h, { query: 'hotel' })) as PlacesPayload; + expect(payload.places.map(p => p.pluginId)).toEqual(['guide']); + }); + }); +}); diff --git a/server/tests/unit/plugins/plugin-hooks.test.ts b/server/tests/unit/plugins/plugin-hooks.test.ts index ddf53f3af..16cb67058 100644 --- a/server/tests/unit/plugins/plugin-hooks.test.ts +++ b/server/tests/unit/plugins/plugin-hooks.test.ts @@ -32,6 +32,7 @@ const CONTRACTS: Array<[hook: string, fn: string, timeoutMs: number]> = [ ['calendarSource', 'getName', 3000], ['calendarSource', 'getEvents', 5000], ['placeDetailProvider', 'getDetails', 5000], + ['searchProvider', 'search', 2000], ['warningProvider', 'getWarnings', 5000], ['tableContributor', 'getContributions', 5000], ['mapMarkerProvider', 'getMarkers', 5000], diff --git a/server/tests/unit/plugins/plugin-search.controller.test.ts b/server/tests/unit/plugins/plugin-search.controller.test.ts new file mode 100644 index 000000000..01daa0c01 --- /dev/null +++ b/server/tests/unit/plugins/plugin-search.controller.test.ts @@ -0,0 +1,164 @@ +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 { PluginSearchController } from '../../../src/nest/plugins/contributions/plugin-search.controller'; +import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any; + +const hit = (over: Record = {}) => ({ id: 'a', name: 'Ristorante', lat: 45.4, lng: 9.2, ...over }); + +function controller(over: Partial = {}) { + const hooks = { + providersOf: vi.fn(() => ['p1']), + searchPlaces: vi.fn(async () => [hit()]), + ...over, + } as unknown as PluginHooks; + return { c: new PluginSearchController(hooks), hooks }; +} + +describe('PluginSearchController', () => { + beforeEach(() => { pluginsEnabled.mockReturnValue(true); }); + + it('returns [] when the runtime is disabled (no plugin calls)', async () => { + pluginsEnabled.mockReturnValue(false); + const { c, hooks } = controller(); + expect(await c.search('milan', undefined, undefined, undefined, undefined, req(5))).toEqual({ places: [] }); + expect(hooks.providersOf).not.toHaveBeenCalled(); + }); + + it('returns [] without an authenticated user, and for a blank query', async () => { + const { c, hooks } = controller(); + expect(await c.search('milan', undefined, undefined, undefined, undefined, req(undefined))).toEqual({ places: [] }); + expect(await c.search(' ', undefined, undefined, undefined, undefined, req(5))).toEqual({ places: [] }); + expect(await c.search(undefined, undefined, undefined, undefined, undefined, req(5))).toEqual({ places: [] }); + expect(hooks.searchPlaces).not.toHaveBeenCalled(); + }); + + it('asks no provider when none implements the hook', async () => { + const { c, hooks } = controller({ providersOf: vi.fn(() => []) }); + expect(await c.search('milan', undefined, undefined, undefined, undefined, req(5))).toEqual({ places: [] }); + expect(hooks.searchPlaces).not.toHaveBeenCalled(); + }); + + it('passes the query, the bias and a capped limit, and drops a bias that is not a coordinate', async () => { + const { c, hooks } = controller(); + await c.search(' milan ', '45.4', '9.2', 'it', '999', req(5)); + expect(hooks.searchPlaces).toHaveBeenCalledWith( + 'p1', + { query: 'milan', limit: 20, lang: 'it', near: { lat: 45.4, lng: 9.2 } }, + 5, + ); + + await c.search('milan', '91', '9.2', undefined, undefined, req(5)); // latitude out of range + expect(hooks.searchPlaces).toHaveBeenLastCalledWith( + 'p1', + { query: 'milan', limit: 10, lang: undefined, near: undefined }, + 5, + ); + }); + + it('skips a provider that throws and keeps the other one (graceful)', async () => { + const { c } = controller({ + providersOf: vi.fn(() => ['slow', 'ok']), + searchPlaces: vi.fn(async (id: string) => { + if (id === 'slow') throw new Error('timed out'); + return [hit({ name: 'Trattoria' })]; + }) as unknown as PluginHooks['searchPlaces'], + }); + const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); + expect(places).toHaveLength(1); + expect(places[0].name).toBe('Trattoria'); + expect(places[0].pluginId).toBe('ok'); + }); + + it('interleaves two providers so neither one buries the other', async () => { + const { c } = controller({ + providersOf: vi.fn(() => ['a', 'b']), + searchPlaces: vi.fn(async (id: string) => + id === 'a' + ? [hit({ id: 'a1', name: 'A1' }), hit({ id: 'a2', name: 'A2' }), hit({ id: 'a3', name: 'A3' })] + : [hit({ id: 'b1', name: 'B1' })], + ) as unknown as PluginHooks['searchPlaces'], + }); + const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); + expect(places.map(p => p.name)).toEqual(['A1', 'B1', 'A2', 'A3']); + }); + + it('normalizes a hit into the shape the core search returns, and namespaces the id', async () => { + const { c } = controller({ + searchPlaces: vi.fn(async () => [ + hit({ address: 'Via Roma 1', rating: 4.5, website: 'https://ok.example', phone: '+39 02', category: 'restaurant', description: 'Good' }), + ]) as unknown as PluginHooks['searchPlaces'], + }); + const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); + expect(places[0]).toEqual({ + osm_id: 'plugin:p1:a', + name: 'Ristorante', + address: 'Via Roma 1', + lat: 45.4, + lng: 9.2, + rating: 4.5, + website: 'https://ok.example', + phone: '+39 02', + category: 'restaurant', + description: 'Good', + source: 'plugin:p1', + pluginId: 'p1', + }); + }); + + it('falls back to the coordinate when a hit carries no id of its own', async () => { + const { c } = controller({ + searchPlaces: vi.fn(async () => [{ name: 'Nameless index', lat: 45.4, lng: 9.2 }]) as unknown as PluginHooks['searchPlaces'], + }); + const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); + expect(places[0].osm_id).toBe('plugin:p1:45.4,9.2'); + }); + + it('drops a hit with no name or no place on the earth', async () => { + const { c } = controller({ + searchPlaces: vi.fn(async () => [ + { name: '', lat: 45.4, lng: 9.2 }, + { name: 'No coords' }, + { name: 'Off the globe', lat: 91, lng: 9.2 }, + { name: 'Off the globe too', lat: 45.4, lng: 181 }, + 'not an object', + hit({ name: 'Keeper' }), + ]) as unknown as PluginHooks['searchPlaces'], + }); + const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); + expect(places.map(p => p.name)).toEqual(['Keeper']); + }); + + it('strips a javascript: website, clamps the rating and caps every string', async () => { + const { c } = controller({ + searchPlaces: vi.fn(async () => [ + hit({ id: 'x', name: 'n'.repeat(400), address: 'a'.repeat(600), website: 'javascript:alert(1)', rating: 9.7 }), + hit({ id: 'y', name: 'Low', rating: -3, website: 'http://plain.example' }), + hit({ id: 'z', name: 'Unrated', rating: 'not a number' }), + ]) as unknown as PluginHooks['searchPlaces'], + }); + const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); + expect(places[0].name).toHaveLength(200); + expect(places[0].address).toHaveLength(300); + expect(places[0].website).toBeNull(); + expect(places[0].rating).toBe(5); + expect(places[1].rating).toBe(0); + expect(places[1].website).toBe('http://plain.example'); + expect(places[2].rating).toBeNull(); + }); + + it('caps one provider at 20 hits', async () => { + const { c } = controller({ + searchPlaces: vi.fn(async () => + Array.from({ length: 50 }, (_v, i) => hit({ id: `h${i}`, name: `H${i}` })), + ) as unknown as PluginHooks['searchPlaces'], + }); + const { places } = await c.search('milan', undefined, undefined, undefined, undefined, req(5)); + expect(places).toHaveLength(20); + }); +}); diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index da1705e3c..b90baec46 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -378,6 +378,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:photo-provider': 'توفير الصور لميزة الذكريات', 'admin.plugins.perm.hook:calendar-source': 'توفير الأحداث للتقويم', 'admin.plugins.perm.hook:place-detail-provider': 'المساهمة بتفاصيل إضافية (مراجعات، تقييمات، روابط) لمكان ما', + 'admin.plugins.perm.hook:search-provider': 'الإجابة على عمليات البحث عن الأماكن من فهرس خاص به، إلى جانب نتائج TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'إظهار تحذيرات التحقق على الرحلة (تظهر في المخطط)', 'admin.plugins.perm.hook:table-contributor': 'إضافة أعمدة وإجراءات إلى عروض الرحلة (الحجوزات، الأماكن، الأيام)', 'admin.plugins.perm.hook:map-marker-provider': 'إضافة علامات إلى خريطة الرحلة (مثل عرض الحجوزات أو نقاط الاهتمام)', @@ -529,6 +530,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'يوفّر الصور', 'admin.plugins.cap.calendar': 'يوفّر أحداث التقويم', 'admin.plugins.cap.placeDetails': 'يُثري الأماكن', + 'admin.plugins.cap.search': 'يجيب على عمليات البحث', 'admin.plugins.cap.warnings': 'يرصد المشكلات', 'admin.plugins.cap.mapLayers': 'يرسم على الخريطة', 'admin.plugins.cap.routing': 'يوفّر التوجيه', diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 8bd11110c..edfb67689 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -361,6 +361,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Fornecer eventos para o calendário', 'admin.plugins.perm.hook:place-detail-provider': 'Contribuir com detalhes extras (avaliações, notas, links) para um local', + 'admin.plugins.perm.hook:search-provider': + 'Responder buscas de lugares a partir de um índice próprio, ao lado dos resultados do TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Emitir avisos de validação em uma viagem (exibidos no planejador)', 'admin.plugins.perm.hook:table-contributor': 'Adicionar colunas e ações às visualizações da viagem (reservas, locais, dias)', @@ -518,6 +520,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Fornece fotos', 'admin.plugins.cap.calendar': 'Fornece eventos de calendário', 'admin.plugins.cap.placeDetails': 'Enriquece locais', + 'admin.plugins.cap.search': 'Responde buscas', 'admin.plugins.cap.warnings': 'Sinaliza problemas', 'admin.plugins.cap.mapLayers': 'Desenha no mapa', 'admin.plugins.cap.routing': 'Oferece roteamento', diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index f1f6a72bd..85a74f185 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -522,6 +522,8 @@ const admin: TranslationStrings = { 'admin.plugins.requiresTrek': 'Requereix TREK {version}+', 'admin.plugins.reviewedOn': 'Verificat el {date}', 'admin.plugins.perm.hook:map-layer-provider': 'Dibuixar rutes, corredors i zones al mapa del viatge', + 'admin.plugins.perm.hook:search-provider': + "Respondre cerques de llocs des d'un índex propi, al costat dels resultats de TREK", 'admin.plugins.perm.hook:route-provider': 'Oferir perfils de ruta amb què el planificador pot calcular els dies (p. ex. rutes per a cotxe elèctric amb parades de recàrrega)', 'admin.plugins.perm.hook:day-schedule-provider': @@ -654,6 +656,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Proporciona fotos', 'admin.plugins.cap.calendar': 'Proporciona esdeveniments de calendari', 'admin.plugins.cap.placeDetails': 'Enriqueix els llocs', + 'admin.plugins.cap.search': 'Respon cerques', 'admin.plugins.cap.warnings': 'Marca problemes', 'admin.plugins.cap.mapLayers': 'Dibuixa al mapa', 'admin.plugins.cap.routing': 'Calcula rutes', diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index 1b099893d..203e98f95 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -364,6 +364,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:photo-provider': 'Poskytovat fotky do sekce Fotky', 'admin.plugins.perm.hook:calendar-source': 'Poskytovat události do kalendáře', 'admin.plugins.perm.hook:place-detail-provider': 'Přidávat další podrobnosti (recenze, hodnocení, odkazy) k místu', + 'admin.plugins.perm.hook:search-provider': 'Odpovídat na vyhledávání míst z vlastního indexu, vedle výsledků TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Vyvolávat ověřovací upozornění u cesty (zobrazená v plánovači)', 'admin.plugins.perm.hook:table-contributor': 'Přidávat sloupce a akce do zobrazení cesty (rezervace, místa, dny)', 'admin.plugins.perm.hook:map-marker-provider': 'Přidávat značky na mapu cesty (např. zobrazit rezervace nebo POI)', @@ -516,6 +517,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Poskytuje fotky', 'admin.plugins.cap.calendar': 'Poskytuje události kalendáře', 'admin.plugins.cap.placeDetails': 'Obohacuje místa', + 'admin.plugins.cap.search': 'Odpovídá na vyhledávání', 'admin.plugins.cap.warnings': 'Označuje problémy', 'admin.plugins.cap.mapLayers': 'Kreslí na mapě', 'admin.plugins.cap.routing': 'Nabízí trasování', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index 67a2d541c..69e286aee 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -367,6 +367,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Termine für den Kalender bereitstellen', 'admin.plugins.perm.hook:place-detail-provider': 'Zusatzdetails (Bewertungen, Ratings, Links) zu einem Ort beisteuern', + 'admin.plugins.perm.hook:search-provider': + 'Ortssuchen aus einem eigenen Index beantworten, neben TREKs eigenen Ergebnissen', 'admin.plugins.perm.hook:trip-warning-provider': 'Validierungswarnungen zu einer Reise ausgeben (im Planer angezeigt)', 'admin.plugins.perm.hook:table-contributor': @@ -525,6 +527,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Liefert Fotos', 'admin.plugins.cap.calendar': 'Liefert Kalender-Events', 'admin.plugins.cap.placeDetails': 'Reichert Orte an', + 'admin.plugins.cap.search': 'Beantwortet Suchen', 'admin.plugins.cap.warnings': 'Meldet Probleme', 'admin.plugins.cap.mapLayers': 'Zeichnet auf der Karte', 'admin.plugins.cap.routing': 'Bietet Routing an', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index ea91759a7..ba4da1ac4 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -410,6 +410,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:photo-provider': 'Provide photos to Memories', 'admin.plugins.perm.hook:calendar-source': 'Provide events to the calendar', 'admin.plugins.perm.hook:place-detail-provider': 'Contribute extra details (reviews, ratings, links) to a place', + 'admin.plugins.perm.hook:search-provider': "Answer place searches from its own index, beside TREK's own results", 'admin.plugins.perm.hook:trip-warning-provider': 'Raise validation warnings on a trip (shown in the planner)', 'admin.plugins.perm.hook:table-contributor': 'Add columns and actions to trip views (reservations, places, days)', 'admin.plugins.perm.hook:map-marker-provider': 'Add markers to the trip map (e.g. show bookings or POIs)', @@ -563,6 +564,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Provides photos', 'admin.plugins.cap.calendar': 'Provides calendar events', 'admin.plugins.cap.placeDetails': 'Enriches places', + 'admin.plugins.cap.search': 'Answers searches', 'admin.plugins.cap.warnings': 'Flags issues', 'admin.plugins.cap.mapLayers': 'Draws on the map', 'admin.plugins.cap.routing': 'Offers routing', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index 8ab4f1af7..6d3f41745 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -370,6 +370,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Proporcionar eventos al calendario', 'admin.plugins.perm.hook:place-detail-provider': 'Aportar detalles adicionales (reseñas, valoraciones, enlaces) a un lugar', + 'admin.plugins.perm.hook:search-provider': + 'Responder a búsquedas de lugares desde su propio índice, junto a los resultados de TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Generar advertencias de validación en un viaje (mostradas en el planificador)', 'admin.plugins.perm.hook:table-contributor': @@ -527,6 +529,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Proporciona fotos', 'admin.plugins.cap.calendar': 'Proporciona eventos de calendario', 'admin.plugins.cap.placeDetails': 'Enriquece lugares', + 'admin.plugins.cap.search': 'Responde búsquedas', 'admin.plugins.cap.warnings': 'Marca problemas', 'admin.plugins.cap.mapLayers': 'Dibuja en el mapa', 'admin.plugins.cap.routing': 'Ofrece rutas', diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index cfa526abe..03e9247e9 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -370,6 +370,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:photo-provider': 'Fournir des photos à Photos', 'admin.plugins.perm.hook:calendar-source': 'Fournir des événements au calendrier', 'admin.plugins.perm.hook:place-detail-provider': 'Ajouter des détails supplémentaires (avis, notes, liens) à un lieu', + 'admin.plugins.perm.hook:search-provider': + 'Répondre aux recherches de lieux depuis son propre index, à côté des résultats de TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Émettre des avertissements de validation sur un voyage (affichés dans le planificateur)', 'admin.plugins.perm.hook:table-contributor': @@ -530,6 +532,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Fournit des photos', 'admin.plugins.cap.calendar': 'Fournit des événements de calendrier', 'admin.plugins.cap.placeDetails': 'Enrichit les lieux', + 'admin.plugins.cap.search': 'Répond aux recherches', 'admin.plugins.cap.warnings': 'Signale les problèmes', 'admin.plugins.cap.mapLayers': 'Dessine sur la carte', 'admin.plugins.cap.routing': 'Propose des itinéraires', diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index f28c1ae12..4bc3b0b30 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -419,6 +419,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Παροχή συμβάντων στο ημερολόγιο', 'admin.plugins.perm.hook:place-detail-provider': 'Συνεισφορά επιπλέον λεπτομερειών (κριτικές, βαθμολογίες, σύνδεσμοι) σε ένα μέρος', + 'admin.plugins.perm.hook:search-provider': + 'Απάντηση σε αναζητήσεις τοποθεσιών από δικό του ευρετήριο, δίπλα στα αποτελέσματα του TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Εμφάνιση προειδοποιήσεων επικύρωσης σε ένα ταξίδι (εμφανίζονται στον σχεδιαστή)', 'admin.plugins.perm.hook:table-contributor': @@ -578,6 +580,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Παρέχει φωτογραφίες', 'admin.plugins.cap.calendar': 'Παρέχει συμβάντα ημερολογίου', 'admin.plugins.cap.placeDetails': 'Εμπλουτίζει μέρη', + 'admin.plugins.cap.search': 'Απαντά σε αναζητήσεις', 'admin.plugins.cap.warnings': 'Επισημαίνει ζητήματα', 'admin.plugins.cap.mapLayers': 'Σχεδιάζει στον χάρτη', 'admin.plugins.cap.routing': 'Παρέχει δρομολόγηση', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index da6b52e85..7b4cd4243 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -372,6 +372,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Események biztosítása a naptárhoz', 'admin.plugins.perm.hook:place-detail-provider': 'További részletek (értékelések, minősítések, hivatkozások) hozzáadása egy helyhez', + 'admin.plugins.perm.hook:search-provider': + 'Helykeresések megválaszolása saját indexből, a TREK saját találatai mellett', 'admin.plugins.perm.hook:trip-warning-provider': 'Ellenőrzési figyelmeztetések megjelenítése egy utazáson (a tervezőben látható)', 'admin.plugins.perm.hook:table-contributor': @@ -530,6 +532,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Fotókat biztosít', 'admin.plugins.cap.calendar': 'Naptáreseményeket biztosít', 'admin.plugins.cap.placeDetails': 'Helyeket gazdagít', + 'admin.plugins.cap.search': 'Válaszol a keresésekre', 'admin.plugins.cap.warnings': 'Problémákat jelez', 'admin.plugins.cap.mapLayers': 'Rajzol a térképre', 'admin.plugins.cap.routing': 'Útvonaltervezést biztosít', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index 00c554571..292f49e2a 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -416,6 +416,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Menyediakan acara untuk kalender', 'admin.plugins.perm.hook:place-detail-provider': 'Menyumbangkan detail tambahan (ulasan, peringkat, tautan) untuk suatu tempat', + 'admin.plugins.perm.hook:search-provider': 'Menjawab pencarian tempat dari indeks sendiri, di samping hasil TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Munculkan peringatan validasi pada perjalanan (ditampilkan di perencana)', 'admin.plugins.perm.hook:table-contributor': @@ -572,6 +573,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Menyediakan foto', 'admin.plugins.cap.calendar': 'Menyediakan acara kalender', 'admin.plugins.cap.placeDetails': 'Memperkaya tempat', + 'admin.plugins.cap.search': 'Menjawab pencarian', 'admin.plugins.cap.warnings': 'Tandai masalah', 'admin.plugins.cap.mapLayers': 'Menggambar di peta', 'admin.plugins.cap.routing': 'Menyediakan routing', diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index 33393a5ba..1c07fe9ce 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -368,6 +368,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Fornire eventi al calendario', 'admin.plugins.perm.hook:place-detail-provider': 'Fornire dettagli aggiuntivi (recensioni, valutazioni, link) a un luogo', + 'admin.plugins.perm.hook:search-provider': + 'Rispondere alle ricerche di luoghi dal proprio indice, accanto ai risultati di TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Segnala avvisi di validazione su un viaggio (mostrati nel pianificatore)', 'admin.plugins.perm.hook:table-contributor': @@ -525,6 +527,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Fornisce foto', 'admin.plugins.cap.calendar': 'Fornisce eventi calendario', 'admin.plugins.cap.placeDetails': 'Arricchisce luoghi', + 'admin.plugins.cap.search': 'Risponde alle ricerche', 'admin.plugins.cap.warnings': 'Segnala problemi', 'admin.plugins.cap.mapLayers': 'Disegna sulla mappa', 'admin.plugins.cap.routing': 'Offre routing', diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index f34d3cced..4cbcc9bcf 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -387,6 +387,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:photo-provider': '「写真」機能への写真の提供', 'admin.plugins.perm.hook:calendar-source': 'カレンダーへの予定の提供', 'admin.plugins.perm.hook:place-detail-provider': '場所に追加の詳細情報(レビュー、評価、リンク)を提供', + 'admin.plugins.perm.hook:search-provider': '独自のインデックスから場所の検索に回答(TREK 自身の結果と並べて表示)', 'admin.plugins.perm.hook:trip-warning-provider': '旅程に検証警告を表示(プランナー内に表示)', 'admin.plugins.perm.hook:table-contributor': '旅行のビュー(予約、場所、日程)に列とアクションを追加', 'admin.plugins.perm.hook:map-marker-provider': '旅行の地図にマーカーを追加(例: 予約や POI を表示)', @@ -537,6 +538,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': '写真を提供', 'admin.plugins.cap.calendar': 'カレンダー予定を提供', 'admin.plugins.cap.placeDetails': '場所を充実', + 'admin.plugins.cap.search': '検索に回答', 'admin.plugins.cap.warnings': '問題を検出', 'admin.plugins.cap.mapLayers': '地図に描画', 'admin.plugins.cap.routing': 'ルーティングを提供', diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index 82749a5ad..7e9034bdc 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -389,6 +389,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:photo-provider': '사진 기능에 사진 제공', 'admin.plugins.perm.hook:calendar-source': '캘린더에 일정 제공', 'admin.plugins.perm.hook:place-detail-provider': '장소에 추가 정보(리뷰, 평점, 링크)를 제공', + 'admin.plugins.perm.hook:search-provider': '자체 색인에서 장소 검색에 응답 (TREK 자체 결과와 함께 표시)', 'admin.plugins.perm.hook:trip-warning-provider': '여행에 검증 경고를 표시 (플래너에 표시됨)', 'admin.plugins.perm.hook:table-contributor': '여행 보기(예약, 장소, 일자)에 열과 작업 추가', 'admin.plugins.perm.hook:map-marker-provider': '여행 지도에 마커 추가(예: 예약 또는 POI 표시)', @@ -537,6 +538,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': '사진 제공', 'admin.plugins.cap.calendar': '캘린더 일정 제공', 'admin.plugins.cap.placeDetails': '장소 정보 강화', + 'admin.plugins.cap.search': '검색에 응답', 'admin.plugins.cap.warnings': '문제 표시', 'admin.plugins.cap.mapLayers': '지도에 그리기', 'admin.plugins.cap.routing': '라우팅 제공', diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index 249b6db67..c6e23c01e 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -368,6 +368,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Evenementen aanleveren voor de kalender', 'admin.plugins.perm.hook:place-detail-provider': 'Extra details (reviews, beoordelingen, links) aan een plek toevoegen', + 'admin.plugins.perm.hook:search-provider': + 'Zoekopdrachten naar locaties beantwoorden vanuit een eigen index, naast de resultaten van TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Validatiewaarschuwingen tonen bij een reis (zichtbaar in de planner)', 'admin.plugins.perm.hook:table-contributor': @@ -524,6 +526,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': "Levert foto's", 'admin.plugins.cap.calendar': 'Levert kalenderevenementen', 'admin.plugins.cap.placeDetails': 'Verrijkt plekken', + 'admin.plugins.cap.search': 'Beantwoordt zoekopdrachten', 'admin.plugins.cap.warnings': 'Signaleert problemen', 'admin.plugins.cap.mapLayers': 'Tekent op de kaart', 'admin.plugins.cap.routing': 'Biedt routering aan', diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index 48b31707c..12b920089 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -364,6 +364,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Dostarczanie wydarzeń do kalendarza', 'admin.plugins.perm.hook:place-detail-provider': 'Dodawanie dodatkowych szczegółów (recenzji, ocen, linków) do miejsca', + 'admin.plugins.perm.hook:search-provider': 'Odpowiadać na wyszukiwania miejsc z własnego indeksu, obok wyników TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Zgłaszanie ostrzeżeń walidacyjnych dla podróży (widocznych w planerze)', 'admin.plugins.perm.hook:table-contributor': 'Dodawanie kolumn i akcji do widoków podróży (rezerwacje, miejsca, dni)', @@ -522,6 +523,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Dostarcza zdjęcia', 'admin.plugins.cap.calendar': 'Dostarcza wydarzenia kalendarza', 'admin.plugins.cap.placeDetails': 'Wzbogaca miejsca', + 'admin.plugins.cap.search': 'Odpowiada na wyszukiwania', 'admin.plugins.cap.warnings': 'Zgłasza problemy', 'admin.plugins.cap.mapLayers': 'Rysuje na mapie', 'admin.plugins.cap.routing': 'Oferuje trasowanie', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index 22f69a8cd..6e5021a94 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -368,6 +368,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:photo-provider': 'Предоставлять фотографии для «Воспоминаний»', 'admin.plugins.perm.hook:calendar-source': 'Предоставлять события для календаря', 'admin.plugins.perm.hook:place-detail-provider': 'Добавлять дополнительные сведения (отзывы, оценки, ссылки) к месту', + 'admin.plugins.perm.hook:search-provider': + 'Отвечать на поиск мест из собственного индекса, рядом с результатами TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Выдавать предупреждения проверки для поездки (отображаются в планировщике)', 'admin.plugins.perm.hook:table-contributor': @@ -526,6 +528,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Предоставляет фото', 'admin.plugins.cap.calendar': 'Предоставляет события календаря', 'admin.plugins.cap.placeDetails': 'Обогащает места', + 'admin.plugins.cap.search': 'Отвечает на поиск', 'admin.plugins.cap.warnings': 'Отмечает проблемы', 'admin.plugins.cap.mapLayers': 'Рисует на карте', 'admin.plugins.cap.routing': 'Предоставляет маршрутизацию', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index 0d5e67cc8..67358753d 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -427,6 +427,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Tillhandahålla händelser till kalendern', 'admin.plugins.perm.hook:place-detail-provider': 'Bidra med extra detaljer (recensioner, betyg, länkar) till en plats', + 'admin.plugins.perm.hook:search-provider': + 'Besvara platssökningar från ett eget index, vid sidan av TREK:s egna resultat', 'admin.plugins.perm.hook:trip-warning-provider': 'Visa valideringsvarningar på en resa (visas i planeraren)', 'admin.plugins.perm.hook:table-contributor': 'Lägga till kolumner och åtgärder i rese­vyer (bokningar, platser, dagar)', @@ -583,6 +585,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Tillhandahåller foton', 'admin.plugins.cap.calendar': 'Tillhandahåller kalenderhändelser', 'admin.plugins.cap.placeDetails': 'Berikar platser', + 'admin.plugins.cap.search': 'Besvarar sökningar', 'admin.plugins.cap.warnings': 'Flaggar problem', 'admin.plugins.cap.mapLayers': 'Ritar på kartan', 'admin.plugins.cap.routing': 'Erbjuder ruttplanering', diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index f9cdae94d..febaa42ac 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -413,6 +413,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Takvime etkinlik sağlar', 'admin.plugins.perm.hook:place-detail-provider': 'Bir yere ek ayrıntılar (yorumlar, puanlar, bağlantılar) katkıda bulunur', + 'admin.plugins.perm.hook:search-provider': + "Yer aramalarını kendi dizininden yanıtlama, TREK'in kendi sonuçlarının yanında", 'admin.plugins.perm.hook:trip-warning-provider': 'Bir gezide doğrulama uyarıları oluşturur (planlayıcıda gösterilir)', 'admin.plugins.perm.hook:table-contributor': 'Seyahat görünümlerine sütunlar ve eylemler ekler (rezervasyonlar, yerler, günler)', @@ -569,6 +571,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Fotoğraf sağlar', 'admin.plugins.cap.calendar': 'Takvim etkinlikleri sağlar', 'admin.plugins.cap.placeDetails': 'Yerleri zenginleştirir', + 'admin.plugins.cap.search': 'Aramaları yanıtlar', 'admin.plugins.cap.warnings': 'Sorunları işaretler', 'admin.plugins.cap.mapLayers': 'Haritada çizim yapar', 'admin.plugins.cap.routing': 'Rota sunar', diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index 58b325241..803061139 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -366,6 +366,8 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:photo-provider': 'Надавати фотографії для Спогадів', 'admin.plugins.perm.hook:calendar-source': 'Надавати події для календаря', 'admin.plugins.perm.hook:place-detail-provider': 'Додавати додаткові деталі (відгуки, оцінки, посилання) до місця', + 'admin.plugins.perm.hook:search-provider': + 'Відповідати на пошук місць із власного індексу, поряд із результатами TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Створювати попередження перевірки для подорожі (показуються в планувальнику)', 'admin.plugins.perm.hook:table-contributor': 'Додавати стовпці та дії до подань подорожі (бронювання, місця, дні)', @@ -523,6 +525,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Надає фотографії', 'admin.plugins.cap.calendar': 'Надає події календаря', 'admin.plugins.cap.placeDetails': 'Збагачує місця', + 'admin.plugins.cap.search': 'Відповідає на пошук', 'admin.plugins.cap.warnings': 'Позначає проблеми', 'admin.plugins.cap.mapLayers': 'Малює на карті', 'admin.plugins.cap.routing': 'Пропонує маршрутизацію', diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index b9ed6806e..4ad777d66 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -368,6 +368,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:calendar-source': 'Cung cấp sự kiện cho lịch', 'admin.plugins.perm.hook:place-detail-provider': 'Đóng góp thêm thông tin chi tiết (đánh giá, xếp hạng, liên kết) cho một địa điểm', + 'admin.plugins.perm.hook:search-provider': 'Trả lời tìm kiếm địa điểm từ chỉ mục riêng, bên cạnh kết quả của TREK', 'admin.plugins.perm.hook:trip-warning-provider': 'Đưa ra cảnh báo xác thực trên một chuyến đi (hiển thị trong trình lập kế hoạch)', 'admin.plugins.perm.hook:table-contributor': @@ -525,6 +526,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': 'Cung cấp ảnh', 'admin.plugins.cap.calendar': 'Cung cấp sự kiện lịch', 'admin.plugins.cap.placeDetails': 'Làm phong phú địa điểm', + 'admin.plugins.cap.search': 'Trả lời tìm kiếm', 'admin.plugins.cap.warnings': 'Gắn cờ vấn đề', 'admin.plugins.cap.mapLayers': 'Vẽ trên bản đồ', 'admin.plugins.cap.routing': 'Cung cấp định tuyến', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index b32c7a3ac..fde0feb2a 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -376,6 +376,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:photo-provider': '為「回憶」提供照片', 'admin.plugins.perm.hook:calendar-source': '為行事曆提供事件', 'admin.plugins.perm.hook:place-detail-provider': '為地點提供額外資訊(評論、評分、連結)', + 'admin.plugins.perm.hook:search-provider': '從自有索引回答地點搜尋,與 TREK 自身的結果並列顯示', 'admin.plugins.perm.hook:trip-warning-provider': '對行程發出驗證警告(顯示於規劃工具中)', 'admin.plugins.perm.hook:table-contributor': '向行程檢視(預訂、地點、日期)新增欄位與操作', 'admin.plugins.perm.hook:map-marker-provider': '向行程地圖新增標記(例如顯示預訂或興趣點)', @@ -519,6 +520,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': '提供照片', 'admin.plugins.cap.calendar': '提供行事曆事件', 'admin.plugins.cap.placeDetails': '豐富地點', + 'admin.plugins.cap.search': '回答搜尋', 'admin.plugins.cap.warnings': '標記問題', 'admin.plugins.cap.mapLayers': '在地圖上繪製', 'admin.plugins.cap.routing': '提供路線規劃', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index 4c4b51438..a2cea8159 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -335,6 +335,7 @@ const admin: TranslationStrings = { 'admin.plugins.perm.hook:photo-provider': '为回忆提供照片', 'admin.plugins.perm.hook:calendar-source': '为日历提供事件', 'admin.plugins.perm.hook:place-detail-provider': '为地点补充额外信息(评论、评分、链接)', + 'admin.plugins.perm.hook:search-provider': '从自有索引回答地点搜索,与 TREK 自身的结果并列显示', 'admin.plugins.perm.hook:trip-warning-provider': '对行程发出校验警告(显示在规划器中)', 'admin.plugins.perm.hook:table-contributor': '向行程视图(预订、地点、日期)添加列和操作', 'admin.plugins.perm.hook:map-marker-provider': '向旅行地图添加标记(例如显示预订或兴趣点)', @@ -479,6 +480,7 @@ const admin: TranslationStrings = { 'admin.plugins.cap.photos': '提供照片', 'admin.plugins.cap.calendar': '提供日历事件', 'admin.plugins.cap.placeDetails': '丰富地点信息', + 'admin.plugins.cap.search': '回答搜索', 'admin.plugins.cap.warnings': '标记问题', 'admin.plugins.cap.mapLayers': '在地图上绘制', 'admin.plugins.cap.routing': '提供路线规划', diff --git a/shared/src/plugin-permissions.ts b/shared/src/plugin-permissions.ts index 04c1dc5a8..90196d967 100644 --- a/shared/src/plugin-permissions.ts +++ b/shared/src/plugin-permissions.ts @@ -47,6 +47,7 @@ export const PLUGIN_PERMISSIONS: readonly string[] = [ 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', + 'hook:search-provider', 'hook:trip-warning-provider', 'hook:table-contributor', 'hook:map-marker-provider', @@ -74,6 +75,7 @@ export const PLUGIN_HOOK_PERMISSION: Readonly> = { photoProvider: 'hook:photo-provider', calendarSource: 'hook:calendar-source', placeDetailProvider: 'hook:place-detail-provider', + searchProvider: 'hook:search-provider', warningProvider: 'hook:trip-warning-provider', tableContributor: 'hook:table-contributor', mapMarkerProvider: 'hook:map-marker-provider', diff --git a/wiki/Plugin-Cookbook.md b/wiki/Plugin-Cookbook.md index 64d4c317c..af6c7253c 100644 --- a/wiki/Plugin-Cookbook.md +++ b/wiki/Plugin-Cookbook.md @@ -94,6 +94,49 @@ module.exports = { } ``` +## Answer place searches from your own index + +**Needs:** `hook:search-provider` (+ `http:outbound:` and a matching `egress` when +the index is somebody else's server) + +TREK searches its own place index and OpenStreetMap. This hook adds a third one, and +its hits are drawn into the same list the user is already looking at. + +`rating` is the reason to reach for it: open data carries no ratings at all, so a +question like "the best rated hotel around here" cannot be answered from OpenStreetMap. +Whatever your index knows, this is where it reaches the search list. + +```js +module.exports = { + hooks: { + searchProvider: { + async search({ query, limit, lang, near }, ctx) { + const res = await fetch( + `https://your-index.example/search?q=${encodeURIComponent(query)}&n=${limit}`, + ) + const { results } = await res.json() + return results.map(r => ({ + id: r.ref, // namespaced host-side to plugin:: + name: r.title, + lat: r.lat, + lng: r.lon, + address: r.address, + rating: r.stars, // 0..5, clamped host-side + website: r.url, // http/https only + })) + }, + }, + }, +} +``` + +Called for an explicit search, never per keystroke, so an index with rate limits is +safe here. You get two seconds to answer and at most 20 of your places are kept: a +person is waiting on the list, and the app stops waiting at two and a half seconds +whatever happens, so a provider that misses that window is left out of the list rather +than holding it up. `near` is the coordinate the search is biased toward, +and it is what tells the four places called "Hase-dera" apart — use it when you have it. + ## Raise validation warnings on a trip **Needs:** `hook:trip-warning-provider` diff --git a/wiki/Plugin-Development.md b/wiki/Plugin-Development.md index c57cf2c13..d25ae0fab 100644 --- a/wiki/Plugin-Development.md +++ b/wiki/Plugin-Development.md @@ -140,8 +140,9 @@ permissions still requires explicit re-consent). ## The plugin types - **integration** — background logic (jobs, routes) with no UI of its own. Every - provider hook is **live** — from placeDetailProvider and warningProvider through - photoProvider (Memories) and calendarSource — see [Provider hooks](#provider-hooks). + provider hook is **live** — from placeDetailProvider, searchProvider and + warningProvider through photoProvider (Memories) and calendarSource — see + [Provider hooks](#provider-hooks). - **page** — adds a nav entry that opens a full-page sandboxed iframe. - **widget** — adds a card to the dashboard (`sidebar` slot), a hero-bar overlay (`hero` slot), a panel inside the trip planner's **place-detail** view @@ -805,6 +806,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?}`; 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::` so they can never collide with an OSM one. Called for an explicit search, **not** per keystroke. Also `GET /api/plugin-search`, and to a connected assistant as the `search_places_via_plugins` MCP tool | | `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` | | `mapMarkerProvider.getMarkers(tripId, ctx)` → `MapMarkerContribution[]` | `hook:map-marker-provider` | **live** — bounded markers overlaid on the trip map (#587). Each is `{id, lat, lng, label?, popupText?, url?, icon?, tone?}`; coordinates are range-checked (−90..90 / −180..180), text length-capped, url http/https/mailto-only, count capped (≤200/plugin). Declarative only — plugin JS never runs on the map canvas. Also `GET /api/map-markers/:tripId` | @@ -1476,6 +1478,7 @@ install (and by `trek-plugin validate` and registry CI, which check against the | `ws:broadcast:user` | `ctx.ws.broadcastToUser` | | `http:outbound` or `http:outbound:` | outbound HTTP to `egress[]` hosts | | `hook:place-detail-provider` | `hooks.placeDetailProvider` — extra place rows TREK renders (see [Provider hooks](#provider-hooks)) | +| `hook:search-provider` | `hooks.searchProvider` — answers place searches from your own index (see [Provider hooks](#provider-hooks)) | | `hook:trip-warning-provider` | `hooks.warningProvider` — validation warnings in the planner (see [Provider hooks](#provider-hooks)) | | `hook:table-contributor` | `hooks.tableContributor` — host-rendered columns/actions in the reservations, transports, places, day, costs, packing, files and todos views (see [Provider hooks](#provider-hooks)) | | `hook:map-marker-provider` | `hooks.mapMarkerProvider` — bounded markers on the trip map | diff --git a/wiki/Plugin-Permissions.md b/wiki/Plugin-Permissions.md index 25abf404e..a0ca01af0 100644 --- a/wiki/Plugin-Permissions.md +++ b/wiki/Plugin-Permissions.md @@ -58,6 +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. | | `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`. | | `hook:map-layer-provider` | Overlay bounded **vector layers** (polylines, polygons, metric circles) on the trip map via the `hooks.mapLayerProvider` provider hook | Implement `MapLayerProvider` in `hooks` — returns `{id, name?, features}[]` where a feature is `{type, points?/center?+radiusM?, tone?, width?, dash?, opacity?, fill?, label?}`. **Declarative only** — the host draws everything; styling is the tone palette plus clamped numerics (width 1–8, opacity 0.05–1, radius ≤2000 km) and a dash enum, so a layer can't impersonate core UI. Budgets per plugin: ≤4 layers / ≤150 features / ≤8000 vertices / ≤2000 vertices per shape; invalid or oversized shapes are dropped whole (never truncated); a failing provider is skipped. Drawn beneath TREK's own routes. Exposed at `GET /api/map-layers/:tripId`. |