feat(plugins): let a plugin answer place searches from its own index

TREK searches its own place index and OpenStreetMap. Neither carries a rating,
so "the best rated hotel around here" has never been answerable, and an operator
with a better index for their region had no way to reach the search box at all.
The searchProvider hook is that way in: a plugin is handed the query, a limit,
the language and the coordinate the search is biased toward, and answers with
places in the same shape the core search returns, plus a rating.

It sits beside the core search rather than inside it. The plugin runtime already
imports PlacesModule, which imports MapsModule, so asking the providers from the
maps service would close a module cycle — the same reason the other fourteen
contribution controllers stand next to what they contribute to. The client asks
both at once and merges, at the single point every caller already goes through,
so all nine search surfaces get plugin hits without knowing the hook exists.

A plugin index may never hold up the list: the client stops waiting at two and a
half seconds and the host gives a provider two, which is also why the budget is
the shortest of any hook here. With no such plugin installed the route answers
without reaching the runtime at all.

Every hit is normalized host-side and shared by the REST route and the MCP tool,
so a cap that holds for the browser holds for the assistant: coordinates are
range-checked, strings capped, the rating clamped to 0..5, a website must be
http or https, and ids are namespaced so they can never collide with an OSM one.

search_places_via_plugins is the MCP half, because what the user can see the
assistant has to be able to see; search_place now points at it.
This commit is contained in:
Maurice
2026-09-10 19:33:55 +02:00
committed by Maurice
parent a00cc889ed
commit a9907e2f10
51 changed files with 971 additions and 12 deletions
+27
View File
@@ -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 })))
+14 -1
View File
@@ -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' },
+61 -2
View File
@@ -1143,6 +1143,50 @@ async function withCachedPlaces<T>(
}
}
/**
* 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<Record<string, unknown>[]> {
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<string, unknown>[]) : []
} 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,
@@ -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') })
@@ -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') })
@@ -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,
@@ -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: [] })),
];
+1
View File
@@ -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 |
+1
View File
@@ -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' },
+2
View File
@@ -6,6 +6,7 @@ export const HOOK_PERMISSION: Readonly<Record<string, string>> = {
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',
+40
View File
@@ -476,6 +476,45 @@ export interface PlaceDetailProvider {
* The host caps results at 12 items per provider. */
getDetails(placeId: number, ctx: PluginContext): Promise<PlaceDetailItem[]>;
}
/**
* 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:<yourId>:<id>`. */
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<SearchResultPlace[]>;
}
/** 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;
+1 -1
View File
@@ -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'),
@@ -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 {}
@@ -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<SearchHit[]> => {
try {
return normalizeSearchHits(id, await this.hooks.searchPlaces(id, request, userId));
} catch {
return []; // a slow / failing provider is skipped, never fatal
}
}),
);
return { places: interleave(results) };
}
}
@@ -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:<id>`, 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 `<a href>`. 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<Record<string, unknown>>) : [];
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]);
}
}
@@ -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<McpTextResult> {
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<SearchHit[]> => {
try {
return normalizeSearchHits(id, await this.hooks.searchPlaces(id, request, ctx.userId));
} catch {
return []; // a slow or failing provider contributes nothing, it does not fail the tool
}
}),
);
return ok({ places: interleave(results) });
}
}
@@ -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<unknown> {
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<unknown> {
return this.runtime.invokeHook(pluginId, 'warningProvider', 'getWarnings', [tripId], userId, 5000);
+1 -1
View File
@@ -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
@@ -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',
@@ -414,6 +414,39 @@ export interface PlaceDetailItem { label: string; value?: string; url?: string;
export interface PlaceDetailProvider {
getDetails(placeId: number, ctx: PluginContext): Promise<PlaceDetailItem[]>;
}
/**
* 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:<yourId>:<id>`. */
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<SearchResultPlace[]>;
}
/** 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;
+3 -1
View File
@@ -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 },
);
@@ -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<void>) {
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<string, unknown>) =>
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']);
});
});
});
@@ -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],
@@ -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<string, unknown> = {}) => ({ id: 'a', name: 'Ristorante', lat: 45.4, lng: 9.2, ...over });
function controller(over: Partial<PluginHooks> = {}) {
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);
});
});
+2
View File
@@ -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': 'يوفّر التوجيه',
+3
View File
@@ -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',
+3
View File
@@ -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',
+2
View File
@@ -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í',
+3
View File
@@ -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',
+2
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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': 'Παρέχει δρομολόγηση',
+3
View File
@@ -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',
+2
View File
@@ -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',
+3
View File
@@ -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',
+2
View File
@@ -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': 'ルーティングを提供',
+2
View File
@@ -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': '라우팅 제공',
+3
View File
@@ -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',
+2
View File
@@ -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',
+3
View File
@@ -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': 'Предоставляет маршрутизацию',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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': 'Пропонує маршрутизацію',
+2
View File
@@ -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',
+2
View File
@@ -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': '提供路線規劃',
+2
View File
@@ -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': '提供路线规划',
+2
View File
@@ -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<Record<string, string>> = {
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',
+43
View File
@@ -94,6 +94,49 @@ module.exports = {
}
```
## Answer place searches from your own index
**Needs:** `hook:search-provider` (+ `http:outbound:<host>` 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:<yourId>:<ref>
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`
+5 -2
View File
@@ -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:<yourId>:<id>` 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:<host>` | 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 |
+1
View File
@@ -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`. |