From ff5fa55fad84198782c54709ef993e60e9a12d05 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 12 Sep 2026 12:24:33 +0200 Subject: [PATCH] add Google Maps route import and plugin corridor search --- client/src/components/Map/poiCategories.ts | 4 +- .../Roadtrip/GoogleRouteImport.test.tsx | 42 +++++++ .../components/Roadtrip/GoogleRouteImport.tsx | 88 +++++++++++++++ .../Roadtrip/RoadtripCorridorPanel.test.tsx | 8 ++ .../Roadtrip/RoadtripCorridorPanel.tsx | 8 +- .../Roadtrip/useCorridorPois.test.ts | 14 ++- .../components/Roadtrip/useCorridorPois.ts | 28 ++++- client/src/components/shared/ContextMenu.tsx | 15 ++- client/src/pages/TripPlannerPage.tsx | 1 + client/src/repo/googleRouteRepo.ts | 17 +++ client/src/repo/roadtripSearchRepo.ts | 11 ++ plugin-sdk/README.md | 4 + plugin-sdk/package-lock.json | 4 +- plugin-sdk/package.json | 2 +- plugin-sdk/src/index.ts | 3 + server/src/nest/maps/maps.service.ts | 8 +- server/src/nest/places/maps-dir.helpers.ts | 4 +- .../src/nest/plugins/plugin-hooks.service.ts | 3 + server/src/nest/plugins/runtime/plugin-sdk.ts | 3 + .../nest/roadtrip/google-route.controller.ts | 31 ++++++ server/src/nest/roadtrip/google-route.mcp.ts | 21 ++++ .../src/nest/roadtrip/google-route.service.ts | 75 +++++++++++++ .../nest/roadtrip/roadtrip-planning.mcp.ts | 15 ++- .../roadtrip/roadtrip-search.controller.ts | 23 ++++ .../nest/roadtrip/roadtrip-search.service.ts | 52 +++++++++ server/src/nest/roadtrip/roadtrip.module.ts | 14 ++- server/tests/e2e/roadtrip.e2e.test.ts | 35 ++++++ server/tests/unit/nest/google-route.test.ts | 103 ++++++++++++++++++ .../tests/unit/nest/roadtrip-planning.test.ts | 6 +- .../tests/unit/nest/roadtrip-search.test.ts | 44 ++++++++ shared/src/i18n/ar/roadtrip.ts | 4 + shared/src/i18n/br/roadtrip.ts | 4 + shared/src/i18n/ca/roadtrip.ts | 4 + shared/src/i18n/cs/roadtrip.ts | 4 + shared/src/i18n/de/roadtrip.ts | 4 + shared/src/i18n/en/roadtrip.ts | 4 + shared/src/i18n/es/roadtrip.ts | 4 + shared/src/i18n/fr/roadtrip.ts | 4 + shared/src/i18n/gr/roadtrip.ts | 4 + shared/src/i18n/hu/roadtrip.ts | 4 + shared/src/i18n/id/roadtrip.ts | 4 + shared/src/i18n/it/roadtrip.ts | 4 + shared/src/i18n/ja/roadtrip.ts | 4 + shared/src/i18n/ko/roadtrip.ts | 4 + shared/src/i18n/nl/roadtrip.ts | 4 + shared/src/i18n/pl/roadtrip.ts | 4 + shared/src/i18n/ru/roadtrip.ts | 4 + shared/src/i18n/sv/roadtrip.ts | 4 + shared/src/i18n/tr/roadtrip.ts | 4 + shared/src/i18n/uk/roadtrip.ts | 4 + shared/src/i18n/vi/roadtrip.ts | 4 + shared/src/i18n/zh-TW/roadtrip.ts | 4 + shared/src/i18n/zh/roadtrip.ts | 4 + shared/src/index.ts | 2 + shared/src/roadtrip/google-import.schema.ts | 27 +++++ shared/src/roadtrip/search.schema.spec.ts | 14 +++ shared/src/roadtrip/search.schema.ts | 52 +++++++++ wiki/MCP-Addon-Tools.md | 4 + wiki/Trip-Planner-Overview.md | 8 ++ 59 files changed, 849 insertions(+), 36 deletions(-) create mode 100644 client/src/components/Roadtrip/GoogleRouteImport.test.tsx create mode 100644 client/src/components/Roadtrip/GoogleRouteImport.tsx create mode 100644 client/src/repo/googleRouteRepo.ts create mode 100644 client/src/repo/roadtripSearchRepo.ts create mode 100644 server/src/nest/roadtrip/google-route.controller.ts create mode 100644 server/src/nest/roadtrip/google-route.mcp.ts create mode 100644 server/src/nest/roadtrip/google-route.service.ts create mode 100644 server/src/nest/roadtrip/roadtrip-search.controller.ts create mode 100644 server/src/nest/roadtrip/roadtrip-search.service.ts create mode 100644 server/tests/unit/nest/google-route.test.ts create mode 100644 server/tests/unit/nest/roadtrip-search.test.ts create mode 100644 shared/src/roadtrip/google-import.schema.ts create mode 100644 shared/src/roadtrip/search.schema.spec.ts create mode 100644 shared/src/roadtrip/search.schema.ts diff --git a/client/src/components/Map/poiCategories.ts b/client/src/components/Map/poiCategories.ts index 0c8feee50..9ecf75df7 100644 --- a/client/src/components/Map/poiCategories.ts +++ b/client/src/components/Map/poiCategories.ts @@ -70,5 +70,7 @@ export interface Poi { capacity: number | null fee: boolean | null } | null - source: 'openstreetmap' + source: string + pluginId?: string + rating?: number | null } diff --git a/client/src/components/Roadtrip/GoogleRouteImport.test.tsx b/client/src/components/Roadtrip/GoogleRouteImport.test.tsx new file mode 100644 index 000000000..3fbbcc4d0 --- /dev/null +++ b/client/src/components/Roadtrip/GoogleRouteImport.test.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { TranslationProvider } from '../../i18n' +import GoogleRouteImport from './GoogleRouteImport' +import { googleRouteRepo } from '../../repo/googleRouteRepo' + +const { loadTrip, state } = vi.hoisted(() => ({ loadTrip: vi.fn(), state: { days: [{ id: 3, day_number: 1, title: 'First day' }] } })) +vi.mock('../../store/tripStore', () => ({ useTripStore: Object.assign((select: (s: typeof state) => unknown) => select(state), { getState: () => ({ loadTrip }) }) })) +vi.mock('../../repo/googleRouteRepo', () => ({ googleRouteRepo: { preview: vi.fn(), append: vi.fn() } })) + +async function openPreview() { + render() + fireEvent.click(screen.getByRole('button', { name: 'Import Google Maps route' })) + fireEvent.click(screen.getByText('Import Google Maps route', { selector: 'span' })) + fireEvent.change(screen.getByRole('textbox', { name: 'Google Maps URL' }), { target: { value: 'https://google.com/maps/dir/A/B' } }) + fireEvent.click(screen.getByRole('button', { name: 'Preview', exact: true })) + await screen.findByText('Munich') +} + +describe('Google route preview', () => { + beforeEach(() => { vi.clearAllMocks(); loadTrip.mockResolvedValue(undefined) }) + + it('previews in order and only saves resolved stops after confirmation', async () => { + vi.mocked(googleRouteRepo.preview).mockResolvedValue({ stops: [{ name: 'Munich', lat: 48, lng: 11 }, { name: 'Unknown', lat: null, lng: null }, { name: 'Rome', lat: 41, lng: 12 }] }) + vi.mocked(googleRouteRepo.append).mockResolvedValue(undefined) + await openPreview() + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual([expect.stringContaining('Munich'), expect.stringContaining('Unknown'), expect.stringContaining('Rome')]) + expect(screen.getByText('Location unresolved; this stop will be skipped.')).toBeInTheDocument() + expect(googleRouteRepo.append).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Import', exact: true })) + await waitFor(() => expect(loadTrip).toHaveBeenCalledWith(1)) + expect(googleRouteRepo.append).toHaveBeenCalledWith(1, { dayId: 3, stops: [{ name: 'Munich', lat: 48, lng: 11 }, { name: 'Rome', lat: 41, lng: 12 }] }, expect.any(String)) + }) + + it('blocks import when fewer than two locations resolve', async () => { + vi.mocked(googleRouteRepo.preview).mockResolvedValue({ stops: [{ name: 'Munich', lat: 48, lng: 11 }, { name: 'Unknown', lat: null, lng: null }] }) + await openPreview() + expect(screen.getByRole('button', { name: 'Import', exact: true })).toBeDisabled() + expect(googleRouteRepo.append).not.toHaveBeenCalled() + }) +}) diff --git a/client/src/components/Roadtrip/GoogleRouteImport.tsx b/client/src/components/Roadtrip/GoogleRouteImport.tsx new file mode 100644 index 000000000..19082888a --- /dev/null +++ b/client/src/components/Roadtrip/GoogleRouteImport.tsx @@ -0,0 +1,88 @@ +import { useEffect, useRef, useState } from 'react' +import { MoreHorizontal, Link2 } from 'lucide-react' +import { useTranslation } from '../../i18n/TranslationContext' +import { useTripStore } from '../../store/tripStore' +import { googleRouteRepo } from '../../repo/googleRouteRepo' +import { generateUUID } from '../../sync/mutationQueue' +import { getApiErrorMessage } from '../../types' +import type { GoogleRoutePreview } from '@trek/shared' +import Modal from '../shared/Modal' +import CustomSelect from '../shared/CustomSelect' +import { ContextMenu, useContextMenu } from '../shared/ContextMenu' +import { Tooltip } from '../shared/Tooltip' + +export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; dayId?: number }) { + const { t } = useTranslation() + const days = useTripStore(s => s.days) + const [open, setOpen] = useState(false) + const [url, setUrl] = useState('') + const [preview, setPreview] = useState(null) + const [target, setTarget] = useState('') + const [busy, setBusy] = useState(false) + const [failure, setFailure] = useState('') + const [saved, setSaved] = useState(false) + const controller = useRef(null) + const requestId = useRef('') + const menu = useContextMenu() + useEffect(() => () => controller.current?.abort(), []) + const inspect = async () => { + controller.current?.abort() + const abort = new AbortController() + controller.current = abort + setBusy(true); setFailure(''); setPreview(null) + try { const parsed = await googleRouteRepo.preview(url, abort.signal); if (!abort.signal.aborted) setPreview(parsed) } + catch (error) { if (!abort.signal.aborted) setFailure(getApiErrorMessage(error, t('common.error'))) } + finally { if (!abort.signal.aborted) setBusy(false) } + } + const save = async () => { + if (!preview || !target) return + setBusy(true); setFailure('') + try { + const stops = preview.stops.filter(s => s.lat != null && s.lng != null).map(s => ({ name: s.name, lat: s.lat!, lng: s.lng! })) + await googleRouteRepo.append(tripId, { dayId: Number(target), stops }, requestId.current) + setSaved(true) + await useTripStore.getState().loadTrip(tripId) + setOpen(false) + } catch (error) { setFailure(getApiErrorMessage(error, t('common.error'))) } + finally { setBusy(false) } + } + const show = () => { + setUrl(''); setPreview(null); setFailure(''); setSaved(false) + setTarget(String(dayId ?? days[0]?.id ?? '')) + requestId.current = generateUUID(); setOpen(true) + } + const resolved = preview?.stops.filter(s => s.lat != null && s.lng != null) ?? [] + return <> + + + + + { if (!busy) setOpen(false) }} title={t('roadtrip.import.title')} size="lg"> +
+

{t('roadtrip.import.note')}

+ {!preview ? setUrl(e.target.value)} aria-label="Google Maps URL" placeholder="https://maps.app.goo.gl/…" + className="w-full rounded-xl border border-edge bg-surface-input p-3 text-body text-content" /> : <> + { setTarget(String(value)); requestId.current = generateUUID() }} + options={days.map(day => ({ value: String(day.id), label: day.title || `${t('roadtrip.import.day')} ${day.day_number}` }))} disabled={busy || saved} /> +
    + {preview.stops.map((stop, index) =>
  1. + {index + 1} + {stop.name}{stop.name !== `${stop.lat}, ${stop.lng}` && {stop.lat == null || stop.lng == null ? t('roadtrip.import.unresolved') : `${stop.lat.toFixed(5)}, ${stop.lng.toFixed(5)}`}} +
  2. )} +
+ } + {failure &&

{saved ? t('common.saved') + ': ' : ''}{failure}

} +
+ + +
+
+
+ +} diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx index 59dcc33b6..a40a5005b 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx @@ -279,6 +279,14 @@ describe('RoadtripCorridorPanel', () => { expect(screen.getByText('Aral')).toBeInTheDocument() }) + it('shows the plugin source and failed providers beside remaining hits', () => { + const c = corridor({}, { failedSources: ['plugin:unavailable'], results: [poi({ osm_id: 'plugin:stations:one', name: 'Plugin Station', pluginId: 'stations' })] }) + wrap() + expect(screen.getByText('Plugin Station')).toBeInTheDocument() + expect(screen.getByText('stations')).toBeInTheDocument() + expect(screen.getByText('Error: plugin:unavailable')).toBeInTheDocument() + }) + it('FE-ROADTRIP-PANEL-015: a total outage reads as a failure, not as an empty road', () => { const c = corridor({}, { error: true, failedAreas: 4 }) wrap() diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx index c5b69f5b6..e8d1fe2e5 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx @@ -1,3 +1,4 @@ +import GoogleRouteImport from './GoogleRouteImport' import React, { useMemo, useState } from 'react' import { Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin, ChevronDown, @@ -17,6 +18,8 @@ import type { CorridorPoi } from './useCorridorPois' import type { RoadtripRoutes } from './useRoadtripRoutes' interface RoadtripCorridorPanelProps { + tripId?: number + canImport?: boolean corridor: RoadtripCorridor routes: RoadtripRoutes /** Opens the place form prefilled from a POI, on a day and at a position in it. */ @@ -103,6 +106,7 @@ function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): Re
{poi.name}
+ {poi.pluginId &&

{poi.pluginId}

} {/* Two facts, two chips. Where it is off the road and how far into the drive it comes are separate answers, and as two phrases sharing a line they read as one run-on sentence about the same thing. A chip each gives them an edge, and the @@ -249,7 +253,7 @@ function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: { * header rather than among the filters — it is the question's subject, not one of its * conditions. */ -export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: RoadtripCorridorPanelProps): React.ReactElement { +export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi, tripId, canImport }: RoadtripCorridorPanelProps): React.ReactElement { const { t } = useTranslation() const distanceUnit = useSettingsStore(s => s.settings.distance_unit) const { search } = corridor @@ -299,6 +303,7 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro : 0 const warnings: [string, string][] = [] + if (search.failedSources?.length) warnings.push(['sources', `${t('common.error')}: ${search.failedSources.join(', ')}`]) if (search.capped) warnings.push(['capped', t('roadtrip.poi.capped')]) if (search.error) warnings.push(['failed', t('roadtrip.poi.failed')]) // Some boxes answered and some did not. Saying so is the difference between "there is @@ -327,6 +332,7 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro /> ) : null} + {tripId && canImport && } {/* What to look for. */} diff --git a/client/src/components/Roadtrip/useCorridorPois.test.ts b/client/src/components/Roadtrip/useCorridorPois.test.ts index 640c48240..62103fce9 100644 --- a/client/src/components/Roadtrip/useCorridorPois.test.ts +++ b/client/src/components/Roadtrip/useCorridorPois.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { LatLng } from './corridor' const { pois } = vi.hoisted(() => ({ pois: vi.fn() })) -vi.mock('../../api/client', () => ({ mapsApi: { pois } })) +vi.mock('../../repo/roadtripSearchRepo', () => ({ roadtripSearchRepo: { search: pois } })) vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en-US' }) })) import { useCorridorPois } from './useCorridorPois' @@ -30,7 +30,7 @@ const hit = (id: string, lat: number, lng: number, category = 'fuel') => ({ source: 'openstreetmap', }) -const answer = (...items: ReturnType[]) => ({ pois: items, source: 'openstreetmap', truncated: false, clamped: false }) +const answer = (...items: ReturnType[]) => ({ pois: items, sources: ['openstreetmap'], failedSources: [], truncated: false, clamped: false }) beforeEach(() => { pois.mockReset() @@ -38,6 +38,14 @@ beforeEach(() => { }) describe('useCorridorPois', () => { + it('keeps plugin results and exposes failed sources', async () => { + pois.mockResolvedValue({ ...answer(hit('plugin:stations:1', 53.5, 9.81)), failedSources: ['plugin:unavailable'] }) + const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10)) + act(() => { result.current.search() }) + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.results[0].osm_id).toBe('plugin:stations:1') + expect(result.current.failedSources).toEqual(['plugin:unavailable']) + }) it('FE-ROADTRIP-CORRIDOR-001: asks each box once for every category together', async () => { const { result } = renderHook(() => useCorridorPois(LINE, ['fuel', 'charging', 'rest_area'], 10)) act(() => { result.current.search() }) @@ -45,7 +53,7 @@ describe('useCorridorPois', () => { expect(pois).toHaveBeenCalled() // The whole point of the batching: the kinds ride along in one query per box. - for (const call of pois.mock.calls) expect(call[0]).toBe('fuel,charging,rest_area') + for (const call of pois.mock.calls) expect(call[0]).toEqual(['fuel', 'charging', 'rest_area']) expect(result.current.progress.total).toBe(pois.mock.calls.length) }) diff --git a/client/src/components/Roadtrip/useCorridorPois.ts b/client/src/components/Roadtrip/useCorridorPois.ts index dee812594..f7b951c74 100644 --- a/client/src/components/Roadtrip/useCorridorPois.ts +++ b/client/src/components/Roadtrip/useCorridorPois.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { mapsApi } from '../../api/client' +import { roadtripSearchRepo } from '../../repo/roadtripSearchRepo' import { useTranslation } from '../../i18n' import { corridorTiles, projectOntoRoute, simplifyLine, type Bbox, type LatLng } from './corridor' import type { Poi } from '../Map/poiCategories' @@ -18,6 +18,7 @@ export interface CorridorSearch { /** True when the route was longer than the request budget and the tail was left out. */ capped: boolean /** Boxes the place search could not answer — a partial result is still shown. */ + failedSources?: string[] failedAreas: number /** * Boxes where the server hit its own per-request ceiling and dropped the rest. @@ -71,6 +72,7 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n const [capped, setCapped] = useState(false) const [failedAreas, setFailedAreas] = useState(0) const [truncatedAreas, setTruncatedAreas] = useState(0) + const [failedSources, setFailedSources] = useState([]) const [error, setError] = useState(false) const abortRef = useRef(null) const runIdRef = useRef(0) @@ -91,6 +93,7 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n setProgress({ done: 0, total: 0 }) setLoading(false) setCapped(false) + setFailedSources([]) setFailedAreas(0) setTruncatedAreas(0) setError(false) @@ -112,6 +115,7 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n const tiles = allTiles.slice(0, MAX_TILES) setCapped(allTiles.length > tiles.length) + setFailedSources([]) setFailedAreas(0) setTruncatedAreas(0) setError(false) @@ -122,11 +126,12 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n // All the wanted kinds in one query per box. Asking per kind was the same answer for // four times the requests, and every extra request is another chance for a shared // mirror to time out — which is what made a search both slow and patchy. - const wanted = categories.join(',') + const jobs = [...tiles] void (async () => { const seen = new Map() + const unavailable = new Set() let failures = 0 let shortened = 0 let done = 0 @@ -139,15 +144,26 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n // cheaply; the small gap afterwards keeps one worker from queueing straight up. const collect = async (tile: Bbox): Promise => { try { - const data = await mapsApi.pois(wanted, tile, locale, controller.signal) + const data = await roadtripSearchRepo.search(categories, tile, locale, controller.signal) + if (controller.signal.aborted || runId !== runIdRef.current) return false + data.failedSources.forEach(source => unavailable.add(source)) + setFailedSources([...unavailable]) // The server answers with at most a few hundred per box and says so. A hit // dropped there is invisible here, so the count has to reach the surface. - if (data.truncated) setTruncatedAreas(++shortened) + if (data.truncated || data.clamped) setTruncatedAreas(++shortened) for (const poi of data.pois) { if (seen.has(poi.osm_id)) continue const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine) if (!hit || hit.offRouteKm > widthKm) continue - seen.set(poi.osm_id, { ...poi, offRouteKm: hit.offRouteKm, alongKm: hit.alongKm }) + seen.set(poi.osm_id, { + ...poi, address: poi.address ?? null, website: poi.website ?? null, phone: poi.phone ?? null, + opening_hours: poi.opening_hours ?? null, cuisine: poi.cuisine ?? null, + charging: poi.charging ? { + capacity: poi.charging.capacity ?? null, fee: poi.charging.fee ?? null, + sockets: poi.charging.sockets.map(s => ({ type: s.type, count: s.count ?? null, kw: s.kw ?? null })), + } : null, + offRouteKm: hit.offRouteKm, alongKm: hit.alongKm, + }) } return true } catch { @@ -193,5 +209,5 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n useEffect(() => () => abortRef.current?.abort(), []) - return { results, progress, loading, capped, failedAreas, truncatedAreas, error, spine, search, clear } + return { results, progress, loading, capped, failedAreas, failedSources, truncatedAreas, error, spine, search, clear } } diff --git a/client/src/components/shared/ContextMenu.tsx b/client/src/components/shared/ContextMenu.tsx index b6c4c9ec6..a1a1d4886 100644 --- a/client/src/components/shared/ContextMenu.tsx +++ b/client/src/components/shared/ContextMenu.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react' +import React, { useState, useEffect, useLayoutEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { LucideIcon } from 'lucide-react' @@ -14,15 +14,17 @@ interface MenuState { x: number y: number items: MenuItem[] + alignEnd?: boolean } export function useContextMenu() { const [menu, setMenu] = useState(null) - const open = (e: React.MouseEvent, items: MenuItem[]) => { + const open = (e: React.MouseEvent, items: MenuItem[], alignEnd = false) => { e.preventDefault() e.stopPropagation() - setMenu({ x: e.clientX, y: e.clientY, items }) + const anchor = e.currentTarget.getBoundingClientRect() + setMenu({ x: alignEnd ? anchor.right : e.clientX, y: alignEnd ? anchor.bottom + 6 : e.clientY, items, alignEnd }) } const close = () => setMenu(null) @@ -49,11 +51,12 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) { } }, [menu, onClose]) - useEffect(() => { + useLayoutEffect(() => { if (!menu || !ref.current) return const el = ref.current const rect = el.getBoundingClientRect() let { x, y } = menu + if (menu.alignEnd) x = Math.max(8, x - rect.width) if (x + rect.width > window.innerWidth - 8) x = window.innerWidth - rect.width - 8 if (y + rect.height > window.innerHeight - 8) y = window.innerHeight - rect.height - 8 if (x !== menu.x || y !== menu.y) { @@ -72,8 +75,10 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) { boxShadow: '0 8px 30px rgba(0,0,0,0.15)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', minWidth: 160, + width: menu.alignEnd ? 'max-content' : undefined, + whiteSpace: menu.alignEnd ? 'nowrap' : undefined, fontFamily: "var(--font-system)", - transformOrigin: 'top left', + transformOrigin: menu.alignEnd ? 'top right' : 'top left', }}> {menu.items.filter(Boolean).map((item, i) => { if (item.divider) return
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 57c840f43..859772eed 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -680,6 +680,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { returns silently without the permission, which reads as a broken button rather than a missing one. */} { const params = new URLSearchParams({ lat, lon: lng, format: 'json', addressdetails: '1', - zoom: '18', + zoom: opts?.locality ? '10' : '18', 'accept-language': toApiLang(lang), }); const response = await nominatimFetch('reverse', params, opts); if (!response.ok) return { name: null, address: null }; const data = (await response.json()) as { name?: string; display_name?: string; address?: Record }; const addr = data.address || {}; - const name = data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null; + const name = opts?.locality + ? addr.city || addr.town || addr.village || addr.municipality || data.name || null + : data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null; return { name, address: data.display_name || null }; } diff --git a/server/src/nest/places/maps-dir.helpers.ts b/server/src/nest/places/maps-dir.helpers.ts index 6e49e7775..fbf243db7 100644 --- a/server/src/nest/places/maps-dir.helpers.ts +++ b/server/src/nest/places/maps-dir.helpers.ts @@ -133,7 +133,7 @@ function fromPath(url: URL): DirWaypoint[] { * Empty when the link is not a route, or holds fewer than two stops — one stop is a * place, and the place search box already takes those. */ -export function parseDirectionsUrl(raw: string): DirWaypoint[] { +export function parseDirectionsUrl(raw: string, limit = MAX_DIR_WAYPOINTS): DirWaypoint[] { let url: URL; try { url = new URL(raw); @@ -142,5 +142,5 @@ export function parseDirectionsUrl(raw: string): DirWaypoint[] { } const fromParams = fromQuery(url); const found = fromParams.length ? fromParams : fromPath(url); - return found.length >= 2 ? found.slice(0, MAX_DIR_WAYPOINTS) : []; + return found.length >= 2 ? found.slice(0, limit) : []; } diff --git a/server/src/nest/plugins/plugin-hooks.service.ts b/server/src/nest/plugins/plugin-hooks.service.ts index b6a6a4ff3..b7d0055f0 100644 --- a/server/src/nest/plugins/plugin-hooks.service.ts +++ b/server/src/nest/plugins/plugin-hooks.service.ts @@ -31,6 +31,9 @@ export interface HookSearchRequest { /** The caller's language tag, for providers that return localized names. */ lang?: string; near?: { lat: number; lng: number }; + /** Category search within a Roadtrip search rectangle. Older hosts omit these fields. */ + category?: string; + bounds?: { south: number; west: number; north: number; east: number }; } /** The waypoint request a route provider is asked to solve. */ diff --git a/server/src/nest/plugins/runtime/plugin-sdk.ts b/server/src/nest/plugins/runtime/plugin-sdk.ts index 42df67ccb..ef49f548a 100644 --- a/server/src/nest/plugins/runtime/plugin-sdk.ts +++ b/server/src/nest/plugins/runtime/plugin-sdk.ts @@ -443,6 +443,9 @@ export interface SearchRequest { lang?: string; /** Where the person is looking, when there is somewhere to bias toward. */ near?: { lat: number; lng: number }; + /** Category search within a Roadtrip search rectangle. Older hosts omit these fields. */ + category?: string; + bounds?: { south: number; west: number; north: number; east: number }; } export interface SearchProvider { search(request: SearchRequest, ctx: PluginContext): Promise; diff --git a/server/src/nest/roadtrip/google-route.controller.ts b/server/src/nest/roadtrip/google-route.controller.ts new file mode 100644 index 000000000..978f51423 --- /dev/null +++ b/server/src/nest/roadtrip/google-route.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, Headers, HttpCode, Param, Post, Req, UseGuards } from '@nestjs/common'; +import { createZodDto } from 'nestjs-zod'; +import type { Request } from 'express'; +import { googleRouteImportSchema, googleRoutePreviewRequestSchema } from '@trek/shared'; +import { ADDON_IDS } from '../../addons'; +import { AddonGuard } from '../addons/addon.guard'; +import { RequireAddon } from '../addons/require-addon.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { TripAccessGuard } from '../permissions/trip-access.guard'; +import { GoogleRouteService } from './google-route.service'; + +class PreviewDto extends createZodDto(googleRoutePreviewRequestSchema) {} +class ImportDto extends createZodDto(googleRouteImportSchema) {} + +@Controller('api') +@UseGuards(AddonGuard, JwtAuthGuard) +@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip') +export class GoogleRouteController { + constructor(private readonly routes: GoogleRouteService) {} + @Post('roadtrip/google-maps-preview') + @HttpCode(200) + preview(@Body() input: PreviewDto) { return this.routes.preview(input.url); } + + @Post('trips/:tripId/roadtrip/google-maps-import') + @HttpCode(200) + @UseGuards(TripAccessGuard) + import(@Param('tripId') tripId: string, @Body() input: ImportDto, + @Req() req: Request & { user: { id: number } }, @Headers('x-socket-id') socketId?: string) { + return this.routes.import(Number(tripId), req.user.id, input, socketId); + } +} diff --git a/server/src/nest/roadtrip/google-route.mcp.ts b/server/src/nest/roadtrip/google-route.mcp.ts new file mode 100644 index 000000000..5198f6dc5 --- /dev/null +++ b/server/src/nest/roadtrip/google-route.mcp.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; +import { googleRouteImportSchema, googleRoutePreviewRequestSchema, type GoogleRouteImport } from '@trek/shared'; +import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp'; +import { ADDON_IDS } from '../../addons'; +import { AddonsService } from '../addons/addons.service'; +import { addonGate } from '../addons/addon-gate'; +import { GoogleRouteService } from './google-route.service'; + +@McpController() +export class GoogleRouteMcp { + constructor(private readonly routes: GoogleRouteService, readonly addons: AddonsService) {} + @Tool({ name: 'preview_google_maps_route', description: 'Read ordered stops from a Google Maps directions link. No changes are saved. Review geocoded positions and unresolved stops before importing. The exact Google road geometry is not imported.', + inputSchema: googleRoutePreviewRequestSchema.shape, annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'trips', mode: 'read' }, when: addonGate(ADDON_IDS.ROADTRIP) }) + async preview({ url }: { url: string }) { return ok(await this.routes.preview(url)); } + + @Tool({ name: 'import_google_maps_route', description: 'Append reviewed Google Maps stops to an existing trip day in supplied order. Creates places and visits atomically. Requires place and day editing permissions. Existing visits remain. TREK calculates the road geometry; no Google route geometry is preserved.', + inputSchema: { tripId: z.number().int().positive(), ...googleRouteImportSchema.shape }, + access: { group: 'trips', mode: 'write' }, when: addonGate(ADDON_IDS.ROADTRIP) }) + import(input: GoogleRouteImport & { tripId: number }, ctx: McpContext) { return ok(this.routes.import(input.tripId, ctx.userId, input)); } +} diff --git a/server/src/nest/roadtrip/google-route.service.ts b/server/src/nest/roadtrip/google-route.service.ts new file mode 100644 index 000000000..291f06253 --- /dev/null +++ b/server/src/nest/roadtrip/google-route.service.ts @@ -0,0 +1,75 @@ +import { HttpException, Injectable } from '@nestjs/common'; +import type { GoogleRouteImport, GoogleRoutePreview } from '@trek/shared'; +import { MapsService, GOOGLE_SHORT_HOSTS, isGoogleMapsHost } from '../maps/maps.service'; +import { isDirectionsUrl, parseDirectionsUrl, MAX_DIR_WAYPOINTS } from '../places/maps-dir.helpers'; +import { safeFetchFollow } from '../../utils/ssrfGuard'; +import { DatabaseService } from '../database/database.service'; +import { PlacesService } from '../places/places.service'; +import { AssignmentsService } from '../assignments/assignments.service'; +import { PermissionsService } from '../permissions/permissions.service'; + +@Injectable() +export class GoogleRouteService { + constructor(private readonly maps: MapsService, private readonly db: DatabaseService, + private readonly places: PlacesService, private readonly assignments: AssignmentsService, + private readonly permissions: PermissionsService) {} + + async preview(raw: string): Promise { + let url = new URL(raw); + if (url.protocol !== 'https:' || url.username || url.password || url.port || + !isGoogleMapsHost(url.hostname) && !GOOGLE_SHORT_HOSTS.includes(url.hostname)) + throw new HttpException({ error: 'Use a Google Maps directions link.' }, 400); + if (GOOGLE_SHORT_HOSTS.includes(url.hostname)) { + const reply = await safeFetchFollow(url.href, { signal: AbortSignal.timeout(10000) }); + url = new URL(reply.url); + await reply.body?.cancel(); + if (!isGoogleMapsHost(url.hostname)) throw new HttpException({ error: 'Use a Google Maps directions link.' }, 400); + } + if (url.protocol !== 'https:' || !isDirectionsUrl(url.href)) + throw new HttpException({ error: 'Use a Google Maps directions link.' }, 400); + const waypoints = parseDirectionsUrl(url.href, MAX_DIR_WAYPOINTS + 1); + if (waypoints.length < 2 || waypoints.length > MAX_DIR_WAYPOINTS) + throw new HttpException({ error: 'The link must contain between 2 and 30 readable stops.' }, 400); + const stops: GoogleRoutePreview['stops'] = []; + for (const waypoint of waypoints) { + let name = (waypoint.name || `${waypoint.lat}, ${waypoint.lng}`).slice(0, 200); + if (waypoint.lat !== null && waypoint.lng !== null) { + if (!waypoint.name) { + try { + const place = await this.maps.reverseGeocode(String(waypoint.lat), String(waypoint.lng), undefined, { lane: 'background', timeoutMs: 5000, locality: true }); + name = (place.name || place.address || name).slice(0, 200); + } catch { /* Keep the supplied position when its name cannot be resolved. */ } + } + stops.push({ name, lat: waypoint.lat, lng: waypoint.lng }); + continue; + } + let position: { lat: number; lng: number } | null = null; + if (!/^(your location|my location|dein standort|mein standort|current location)$/i.test(name)) { + try { position = await this.maps.geocodeQuery(name); } catch { position = null; } + } + if (position && (!Number.isFinite(position.lat) || !Number.isFinite(position.lng) || Math.abs(position.lat) > 90 || Math.abs(position.lng) > 180)) position = null; + stops.push({ name, lat: position?.lat ?? null, lng: position?.lng ?? null }); + } + return { stops }; + } + + import(tripId: number, userId: number, input: GoogleRouteImport, socketId?: string) { + const access = this.db.canAccessTrip(tripId, userId); + if (!access) throw new HttpException({ error: 'Trip not found' }, 404); + const role = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId)?.role ?? 'user'; + if (!['place_edit', 'day_edit'].every(action => this.permissions.checkPermission(action, role, access.user_id, userId, access.user_id !== userId))) + throw new HttpException({ error: 'Permission denied' }, 403); + if (!this.assignments.dayExists(String(input.dayId), String(tripId))) throw new HttpException({ error: 'Day not found' }, 404); + const imported = this.db.transaction(() => input.stops.map(stop => { + const place = this.places.create(String(tripId), { ...stop, transport_mode: 'car', duration_minutes: 0 }); + const assignment = this.assignments.createAssignment(input.dayId, place.id); + return { place, assignment }; + })); + for (const { place, assignment } of imported) { + this.places.broadcast(String(tripId), 'place:created', { place }, socketId); + this.assignments.broadcast(String(tripId), 'assignment:created', { assignment }, socketId); + } + this.assignments.reconcile(tripId, socketId); + return { imported: imported.length }; + } +} diff --git a/server/src/nest/roadtrip/roadtrip-planning.mcp.ts b/server/src/nest/roadtrip/roadtrip-planning.mcp.ts index eac1a9d5e..12065a361 100644 --- a/server/src/nest/roadtrip/roadtrip-planning.mcp.ts +++ b/server/src/nest/roadtrip/roadtrip-planning.mcp.ts @@ -2,7 +2,7 @@ import { ADDON_IDS } from '../../addons'; import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, errorResult, type McpContext } from '../../nest-mcp'; import { addonGate } from '../addons/addon-gate'; import { AddonsService } from '../addons/addons.service'; -import { MapsService } from '../maps/maps.service'; +import { RoadtripSearchService } from './roadtrip-search.service'; import { RoadtripPlanService } from './roadtrip-plan.service'; import { roadtripPlanRequestSchema, @@ -20,7 +20,7 @@ const when = addonGate(ADDON_IDS.ROADTRIP); export class RoadtripPlanningMcp { constructor( private readonly plans: RoadtripPlanService, - private readonly maps: MapsService, + private readonly maps: RoadtripSearchService, readonly addons: AddonsService, ) {} @@ -104,16 +104,18 @@ export class RoadtripPlanningMcp { const tiles = corridorTiles(line, input.widthKm); const hits = new Map< string, - { poi: Awaited>['pois'][number]; alongKm: number; distanceKm: number } + { poi: Awaited>['pois'][number]; alongKm: number; distanceKm: number } >(); const page = tiles.slice(input.offset, input.offset + 6); const failedAreas: number[] = []; let truncatedAreas = 0; const sources = new Set(); + const failedSources = new Set(); for (const bbox of page) { try { - const found = await this.maps.pois(input.category, bbox); - sources.add(found.source); + const found = await this.maps.search({ categories: [input.category], bbox }, ctx.userId); + found.sources.forEach(source => sources.add(source)); + found.failedSources.forEach(source => failedSources.add(source)); if (found.truncated || found.clamped) truncatedAreas++; for (const poi of found.pois) { const projection = projectOntoRoute(poi, line); @@ -146,7 +148,8 @@ export class RoadtripPlanningMcp { return ok({ dayNumber: day.dayNumber, sources: [...sources], - complete: !failedAreas.length && !truncatedAreas && input.offset + page.length >= tiles.length, + failedSources: [...failedSources], + complete: !failedSources.size && !failedAreas.length && !truncatedAreas && input.offset + page.length >= tiles.length, failedAreas, truncatedAreas, totalAreas: tiles.length, diff --git a/server/src/nest/roadtrip/roadtrip-search.controller.ts b/server/src/nest/roadtrip/roadtrip-search.controller.ts new file mode 100644 index 000000000..7c5777706 --- /dev/null +++ b/server/src/nest/roadtrip/roadtrip-search.controller.ts @@ -0,0 +1,23 @@ +import { Body, Controller, HttpCode, Post, Req, UseGuards } from '@nestjs/common'; +import type { Request } from 'express'; +import { roadtripSearchAreaSchema } from '@trek/shared'; +import { createZodDto } from 'nestjs-zod'; +import { ADDON_IDS } from '../../addons'; +import { AddonGuard } from '../addons/addon.guard'; +import { RequireAddon } from '../addons/require-addon.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RoadtripSearchService } from './roadtrip-search.service'; + +class SearchAreaDto extends createZodDto(roadtripSearchAreaSchema) {} + +@Controller('api/roadtrip/search-area') +@UseGuards(JwtAuthGuard, AddonGuard) +@RequireAddon(ADDON_IDS.ROADTRIP, 'Road trip') +export class RoadtripSearchController { + constructor(private readonly search: RoadtripSearchService) {} + @Post() + @HttpCode(200) + read(@Body() input: SearchAreaDto, @Req() req: Request & { user: { id: number } }) { + return this.search.search(input, req.user.id); + } +} diff --git a/server/src/nest/roadtrip/roadtrip-search.service.ts b/server/src/nest/roadtrip/roadtrip-search.service.ts new file mode 100644 index 000000000..98504e9e6 --- /dev/null +++ b/server/src/nest/roadtrip/roadtrip-search.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import type { RoadtripSearchArea, RoadtripSearchAreaResponse } from '@trek/shared'; +import { MapsService } from '../maps/maps.service'; +import { PluginHooks } from '../plugins/plugin-hooks.service'; +import { pluginsEnabled } from '../plugins/kill-switch'; +import { normalizeSearchHits, MAX_HITS } from '../plugins/contributions/plugin-search.helpers'; + +const queries: Record = { + fuel: 'fuel station', charging: 'EV charging station', rest_area: 'rest area', + campsite: 'campsite', restaurant: 'restaurant', sights: 'tourist attraction', hotel: 'hotel', +}; + +@Injectable() +export class RoadtripSearchService { + constructor(private readonly maps: MapsService, private readonly hooks: PluginHooks) {} + + async search(input: RoadtripSearchArea, userId: number): Promise { + const { bbox, lang } = input; + const categories = [...new Set(input.categories)]; + const providers = pluginsEnabled() ? this.hooks.providersOf('searchProvider') : []; + const core = this.maps.pois(categories.join(','), bbox, lang); + const tasks = providers.flatMap(pluginId => categories.map(async category => { + const raw = await this.hooks.searchPlaces(pluginId, { + query: queries[category], category, bounds: bbox, lang, limit: MAX_HITS, + near: { lat: (bbox.south + bbox.north) / 2, lng: (bbox.west + bbox.east) / 2 }, + }, userId); + if (!Array.isArray(raw)) throw new Error('Invalid plugin search answer'); + const normalized = normalizeSearchHits(pluginId, raw); + const pois = normalized.filter(p => p.lat >= bbox.south && p.lat <= bbox.north && p.lng >= bbox.west && p.lng <= bbox.east) + .filter(p => !p.category || !(p.category in queries) || p.category === category) + .map(p => ({ ...p, category, poi_type: category, opening_hours: null, cuisine: null, charging: null })); + return { pois, source: `plugin:${pluginId}`, truncated: raw.length >= MAX_HITS || normalized.length < raw.length }; + })); + const settled = await Promise.allSettled([core, ...tasks]); + const sources = new Set(); + const failedSources = new Set(); + const pois = new Map(); + let truncated = false; + let clamped = false; + settled.forEach((entry, index) => { + if (entry.status === 'rejected') { + failedSources.add(index === 0 ? 'TREK' : `plugin:${providers[Math.floor((index - 1) / categories.length)]}`); + return; + } + sources.add(entry.value.source); + truncated ||= entry.value.truncated; + if ('clamped' in entry.value) clamped ||= entry.value.clamped; + for (const poi of entry.value.pois) pois.set(poi.osm_id, poi); + }); + return { pois: [...pois.values()], sources: [...sources], failedSources: [...failedSources], truncated, clamped }; + } +} diff --git a/server/src/nest/roadtrip/roadtrip.module.ts b/server/src/nest/roadtrip/roadtrip.module.ts index 2b59b5634..4c770af42 100644 --- a/server/src/nest/roadtrip/roadtrip.module.ts +++ b/server/src/nest/roadtrip/roadtrip.module.ts @@ -1,3 +1,8 @@ +import { GoogleRouteService } from './google-route.service'; +import { GoogleRouteController } from './google-route.controller'; +import { GoogleRouteMcp } from './google-route.mcp'; +import { PlacesModule } from '../places/places.module'; +import { AssignmentsModule } from '../assignments/assignments.module'; import { AddonsModule } from '../addons/addons.module'; import { AuthModule } from '../auth/auth.module'; import { MapsModule } from '../maps/maps.module'; @@ -18,6 +23,8 @@ import { RoadtripRouterService } from './roadtrip-router.service'; import { RoadtripController } from './roadtrip.controller'; import { RoadtripMcp } from './roadtrip.mcp'; import { RoadtripService } from './roadtrip.service'; +import { RoadtripSearchService } from './roadtrip-search.service'; +import { RoadtripSearchController } from './roadtrip-search.controller'; import { Module } from '@nestjs/common'; import { RoadtripHazardsController } from './roadtrip-hazards.controller'; import { RoadtripHazardsService } from './roadtrip-hazards.service'; @@ -35,10 +42,11 @@ import { RoadtripHazardsMcp } from './roadtrip-hazards.mcp'; RealtimeModule, SettingsModule, PluginsRuntimeModule, - MapsModule, + MapsModule, PlacesModule, AssignmentsModule, ], - controllers: [RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController], - providers: [ + controllers: [GoogleRouteController, RoadtripSearchController, RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController], + providers: [GoogleRouteService, GoogleRouteMcp, + RoadtripSearchService, RoadtripHazardsService, RoadtripHazardsMcp, RoadtripService, diff --git a/server/tests/e2e/roadtrip.e2e.test.ts b/server/tests/e2e/roadtrip.e2e.test.ts index 4bf48ef2e..663d205be 100644 --- a/server/tests/e2e/roadtrip.e2e.test.ts +++ b/server/tests/e2e/roadtrip.e2e.test.ts @@ -1,3 +1,5 @@ +import { RoadtripSearchService } from '../../src/nest/roadtrip/roadtrip-search.service'; +import { GoogleRouteService } from '../../src/nest/roadtrip/google-route.service'; /** * Road trip module e2e — the real guard chain against a temp SQLite db. * @@ -131,6 +133,39 @@ describe('Roadtrip e2e (real guard chain + temp SQLite)', () => { }); const cookie = () => sessionCookie(1); + it('validates Google route preview and import before executing either service', async () => { + const routes = app.get(GoogleRouteService); + const preview = vi.spyOn(routes, 'preview').mockResolvedValue({ stops: [] }); + const save = vi.spyOn(routes, 'import').mockReturnValue({ imported: 2 }); + const input = { dayId: 3, stops: [{ name: 'A', lat: 48, lng: 11 }, { name: 'B', lat: 41, lng: 12 }] }; + try { + await request(server).post('/api/roadtrip/google-maps-preview').send({ url: 'https://google.com/maps/dir/A/B' }).expect(401); + await request(server).post('/api/roadtrip/google-maps-preview').set('Cookie', cookie()).send({ url: 'bad' }).expect(400); + await request(server).post('/api/roadtrip/google-maps-preview').set('Cookie', cookie()).send({ url: 'https://google.com/maps/dir/A/B' }).expect(200); + await request(server).post('/api/trips/6/roadtrip/google-maps-import').set('Cookie', cookie()).send(input).expect(404); + await request(server).post('/api/trips/5/roadtrip/google-maps-import').set('Cookie', cookie()).send({ ...input, stops: [{ name: 'A', lat: 999, lng: 0 }] }).expect(400); + await request(server).post('/api/trips/5/roadtrip/google-maps-import').set('Cookie', cookie()).send(input).expect(200); + expect(save).toHaveBeenCalledWith(5, 1, input, undefined); + setAddon(false); + await request(server).post('/api/trips/5/roadtrip/google-maps-import').set('Cookie', cookie()).send(input).expect(404); + await request(server).post('/api/roadtrip/google-maps-preview').set('Cookie', cookie()).send({ url: 'https://google.com/maps/dir/A/B' }).expect(404); + expect(save).toHaveBeenCalledTimes(1); + expect(preview).toHaveBeenCalledTimes(1); + } finally { preview.mockRestore(); save.mockRestore(); } + }); + it('validates and gates area search before calling plugins', async () => { + const search = vi.spyOn(app.get(RoadtripSearchService), 'search').mockResolvedValue({ pois: [], sources: [], failedSources: [], truncated: false, clamped: false }); + const input = { categories: ['fuel'], bbox: { south: 48, north: 49, west: 10, east: 11 } }; + try { + await request(server).post('/api/roadtrip/search-area').send(input).expect(401); + await request(server).post('/api/roadtrip/search-area').set('Cookie', cookie()).send({ ...input, categories: ['invalid'] }).expect(400); + await request(server).post('/api/roadtrip/search-area').set('Cookie', cookie()).send(input).expect(200); + expect(search).toHaveBeenCalledWith(input, 1); + setAddon(false); + await request(server).post('/api/roadtrip/search-area').set('Cookie', cookie()).send(input).expect(404); + expect(search).toHaveBeenCalledTimes(1); + } finally { search.mockRestore(); } + }); it('gates live hazards by addon, authentication and trip access', async () => { const read = vi.spyOn(app.get(RoadtripHazardsService), 'read').mockResolvedValue({ fetchedAt: new Date().toISOString(), hazards: [], sources: [] }); try { diff --git a/server/tests/unit/nest/google-route.test.ts b/server/tests/unit/nest/google-route.test.ts new file mode 100644 index 000000000..4f31afd7c --- /dev/null +++ b/server/tests/unit/nest/google-route.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest'; +import Database from 'better-sqlite3'; +import { GoogleRouteService } from '../../../src/nest/roadtrip/google-route.service'; +import { safeFetchFollow } from '../../../src/utils/ssrfGuard'; + +vi.mock('../../../src/utils/ssrfGuard', () => ({ safeFetchFollow: vi.fn() })); + +function setup() { + const maps = { geocodeQuery: vi.fn(), reverseGeocode: vi.fn().mockResolvedValue({ name: null, address: null }) }; + const db = { canAccessTrip: vi.fn(() => ({ user_id: 7 })), get: () => ({ role: 'user' }), transaction: (fn: () => T) => fn() }; + const places = { create: vi.fn((_trip: string, stop: { name: string }) => ({ id: stop.name })), broadcast: vi.fn() }; + const assignments = { dayExists: vi.fn(() => true), createAssignment: vi.fn((dayId: number, placeId: string) => ({ dayId, placeId })), broadcast: vi.fn(), reconcile: vi.fn() }; + const permissions = { checkPermission: vi.fn(() => true) }; + const service = new GoogleRouteService(maps as never, db as never, places as never, assignments as never, permissions as never); + return { service, maps, db, places, assignments, permissions }; +} + +const input = { dayId: 3, stops: [{ name: 'Munich', lat: 48, lng: 11 }, { name: 'Rome', lat: 41, lng: 12 }] }; + +describe('Google route import', () => { + it('reads coordinates in route order without geocoding or writing', async () => { + const { service, maps, places } = setup(); + const preview = await service.preview('https://www.google.com/maps/dir/?api=1&origin=48,11&destination=41,12&waypoints=47,11'); + expect(preview.stops.map(stop => stop.lat)).toEqual([48, 47, 41]); + expect(maps.geocodeQuery).not.toHaveBeenCalled(); + expect(places.create).not.toHaveBeenCalled(); + }); + + it('keeps unresolved and dynamic locations visible in their original position', async () => { + const { service, maps } = setup(); + maps.geocodeQuery.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ lat: 41, lng: 12 }); + const preview = await service.preview('https://www.google.com/maps/dir/Your+location/Munich/Rome'); + expect(preview.stops).toEqual([{ name: 'Your location', lat: null, lng: null }, { name: 'Munich', lat: null, lng: null }, { name: 'Rome', lat: 41, lng: 12 }]); + expect(maps.geocodeQuery.mock.calls).toEqual([['Munich'], ['Rome']]); + }); + + it('names coordinate stops without moving their supplied positions', async () => { + const { service, maps } = setup(); + maps.reverseGeocode.mockResolvedValueOnce({ name: 'Munich', address: 'Munich' }).mockRejectedValueOnce(new Error('unavailable')); + const preview = await service.preview('https://google.com/maps/dir/48,11/41,12'); + expect(preview.stops).toEqual([{ name: 'Munich', lat: 48, lng: 11 }, { name: '41, 12', lat: 41, lng: 12 }]); + expect(maps.reverseGeocode).toHaveBeenCalledWith('48', '11', undefined, { lane: 'background', timeoutMs: 5000, locality: true }); + }); + + it('rejects unrelated URLs and oversized routes without fetching or truncating', async () => { + const { service, maps } = setup(); + for (const url of ['https://example.com/maps/dir/A/B', 'http://google.com/maps/dir/A/B', 'https://user:pass@google.com/maps/dir/A/B', 'https://google.com/search?q=A']) { + await expect(service.preview(url)).rejects.toThrow('Http Exception'); + } + await expect(service.preview(`https://google.com/maps/dir/${Array.from({ length: 31 }, (_, i) => `A${i}`).join('/')}`)).rejects.toThrow(); + expect(maps.geocodeQuery).not.toHaveBeenCalled(); + }); + + it('resolves short links through the safe fetcher and checks the destination', async () => { + const { service } = setup(); + const cancel = vi.fn(); + vi.mocked(safeFetchFollow).mockResolvedValueOnce({ url: 'https://google.com/maps/dir/48,11/41,12', body: { cancel } } as never); + expect((await service.preview('https://maps.app.goo.gl/example')).stops).toHaveLength(2); + expect(cancel).toHaveBeenCalled(); + vi.mocked(safeFetchFollow).mockResolvedValueOnce({ url: 'https://example.com/maps/dir/A/B', body: null } as never); + await expect(service.preview('https://maps.app.goo.gl/example')).rejects.toThrow(); + }); + + it('checks membership, both editing permissions and the target day before writing', () => { + const { service, db, permissions, assignments, places } = setup(); + db.canAccessTrip.mockReturnValueOnce(null as never); + expect(() => service.import(1, 7, input)).toThrow(); + permissions.checkPermission.mockReturnValueOnce(false); + expect(() => service.import(1, 7, input)).toThrow(); + assignments.dayExists.mockReturnValueOnce(false); + expect(() => service.import(1, 7, input)).toThrow(); + expect(places.create).not.toHaveBeenCalled(); + expect(assignments.dayExists).toHaveBeenCalledWith('3', '1'); + }); + + it('appends stops in order with car transport and broadcasts after saving', () => { + const { service, places, assignments, permissions } = setup(); + expect(service.import(1, 7, input)).toEqual({ imported: 2 }); + expect(places.create.mock.calls.map(call => call[1].name)).toEqual(['Munich', 'Rome']); + expect(places.create).toHaveBeenCalledWith('1', expect.objectContaining({ transport_mode: 'car', duration_minutes: 0 })); + expect(assignments.createAssignment.mock.calls).toEqual([[3, 'Munich'], [3, 'Rome']]); + expect(permissions.checkPermission).toHaveBeenCalledWith('day_edit', 'user', 7, 7, false); + expect(assignments.reconcile).toHaveBeenCalledWith(1, undefined); + }); + + it('rolls back every created stop when any assignment fails and sends no events', () => { + const { service, db, places, assignments } = setup(); + const sqlite = new Database(':memory:'); + sqlite.exec('CREATE TABLE stops (name TEXT)'); + db.transaction = fn => sqlite.transaction(fn)(); + places.create.mockImplementation((_trip, stop) => { + sqlite.prepare('INSERT INTO stops VALUES (?)').run(stop.name); + return { id: stop.name }; + }); + assignments.createAssignment.mockImplementationOnce(() => ({ dayId: 3, placeId: 'Munich' })).mockImplementationOnce(() => { throw new Error('write failed'); }); + try { + expect(() => service.import(1, 7, input)).toThrow('write failed'); + expect(sqlite.prepare('SELECT * FROM stops').all()).toEqual([]); + expect(places.broadcast).not.toHaveBeenCalled(); + expect(assignments.broadcast).not.toHaveBeenCalled(); + } finally { sqlite.close(); } + }); +}); diff --git a/server/tests/unit/nest/roadtrip-planning.test.ts b/server/tests/unit/nest/roadtrip-planning.test.ts index 06ed38c2a..3481dab7f 100644 --- a/server/tests/unit/nest/roadtrip-planning.test.ts +++ b/server/tests/unit/nest/roadtrip-planning.test.ts @@ -262,9 +262,9 @@ describe('Roadtrip MCP registration and search', () => { })), }; const maps = { - pois: vi.fn(async () => ({ + search: vi.fn(async () => ({ pois: [{ osm_id: 'n1', name: 'Fuel', brand: 'Example', lat: 48, lng: 10.05, category: 'fuel' }], - source: 'trek-places', + sources: ['trek-places'], failedSources: [], truncated: true, clamped: false, })), @@ -279,7 +279,7 @@ describe('Roadtrip MCP registration and search', () => { expect(body.sources).toEqual(['trek-places']); expect(body.truncatedAreas).toBeGreaterThan(0); expect(body.complete).toBe(false); - maps.pois.mockRejectedValue(new Error('private endpoint')); + maps.search.mockRejectedValue(new Error('private endpoint')); const failed = await mcp.corridor({ tripId: 1, dayNumber: 1, category: 'fuel', widthKm: 5, offset: 0 }, ctx); expect(JSON.stringify(failed)).toContain('failedAreas'); expect(JSON.stringify(failed)).not.toContain('private endpoint'); diff --git a/server/tests/unit/nest/roadtrip-search.test.ts b/server/tests/unit/nest/roadtrip-search.test.ts new file mode 100644 index 000000000..7b2be8a8f --- /dev/null +++ b/server/tests/unit/nest/roadtrip-search.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; +import { RoadtripSearchService } from '../../../src/nest/roadtrip/roadtrip-search.service'; + +vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled: () => true })); +const bbox = { south: 48, north: 49, west: 10, east: 11 }; +const place = { id: 'one', name: 'Station', lat: 48.5, lng: 10.5 }; + +describe('Roadtrip plugin search', () => { + it('passes category and bounds, keeps sources and rejects out-of-area hits', async () => { + const maps = { pois: vi.fn().mockResolvedValue({ pois: [], source: 'trek-places', truncated: false, clamped: false }) }; + const hooks = { providersOf: () => ['stations'], searchPlaces: vi.fn().mockResolvedValue([ + place, { ...place, id: 'far', lat: 51 }, { ...place, id: 'other', category: 'hotel' }, + ]) }; + const service = new RoadtripSearchService(maps as never, hooks as never); + const answer = await service.search({ categories: ['fuel'], bbox }, 7); + expect(hooks.searchPlaces).toHaveBeenCalledWith('stations', expect.objectContaining({ category: 'fuel', bounds: bbox, limit: 20 }), 7); + expect(answer.pois).toHaveLength(1); + expect(answer.pois[0]).toMatchObject({ osm_id: 'plugin:stations:one', source: 'plugin:stations', category: 'fuel' }); + expect(answer.sources).toEqual(['trek-places', 'plugin:stations']); + expect(answer.failedSources).toEqual([]); + }); + + it('keeps successful providers when core and another provider fail', async () => { + const maps = { pois: vi.fn().mockRejectedValue(new Error('private detail')) }; + const hooks = { providersOf: () => ['broken', 'working'], searchPlaces: vi.fn(async (id: string) => { + if (id === 'broken') throw new Error('private plugin failure'); + return [place]; + }) }; + const answer = await new RoadtripSearchService(maps as never, hooks as never).search({ categories: ['fuel'], bbox }, 7); + expect(answer.pois).toHaveLength(1); + expect(answer.failedSources).toEqual(['TREK', 'plugin:broken']); + expect(JSON.stringify(answer)).not.toContain('private'); + }); + + it('reports malformed answers and deduplicates repeated provider IDs', async () => { + const maps = { pois: vi.fn().mockResolvedValue({ pois: [], source: 'trek-places', truncated: false, clamped: true }) }; + const hooks = { providersOf: () => ['bad', 'good'], searchPlaces: vi.fn(async (id: string) => id === 'bad' ? {} : [place, place]) }; + const answer = await new RoadtripSearchService(maps as never, hooks as never).search({ categories: ['fuel', 'fuel'], bbox }, 7); + expect(answer.failedSources).toEqual(['plugin:bad']); + expect(answer.pois).toHaveLength(1); + expect(answer.clamped).toBe(true); + expect(hooks.searchPlaces).toHaveBeenCalledTimes(2); + }); +}); diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index de10a55b4..801abca2e 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': 'استيراد مسار Google Maps', + 'roadtrip.import.note': 'تُضاف المحطات إلى اليوم المحدد. يعيد TREK حساب المسار.', + 'roadtrip.import.unresolved': 'تعذر تحديد الموقع؛ سيتم تخطي هذه المحطة.', + 'roadtrip.import.day': 'اليوم', 'roadtrip.hazards.show': 'عرض مناطق الخطر', 'roadtrip.hazards.current': 'التحذيرات الحالية', 'roadtrip.hazards.note': 'التحذيرات لا تعني إغلاق الطرق. يبقى المسار دون تغيير.', diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index 4fd4e4006..901b6791a 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': 'Importar rota do Google Maps', + 'roadtrip.import.note': 'As paradas são adicionadas ao dia escolhido. O TREK recalcula a rota.', + 'roadtrip.import.unresolved': 'Local não encontrado; esta parada será ignorada.', + 'roadtrip.import.day': 'Dia', 'roadtrip.hazards.show': 'Mostrar áreas de risco', 'roadtrip.hazards.current': 'Alertas atuais', 'roadtrip.hazards.note': 'Alertas não são interdições de vias. A rota não muda.', diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index d7041ef80..8a5763d8c 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': 'Importa una ruta de Google Maps', + 'roadtrip.import.note': 'Les parades s’afegeixen al dia triat. TREK recalcula la ruta.', + 'roadtrip.import.unresolved': 'Ubicació no trobada; aquesta parada s’ometrà.', + 'roadtrip.import.day': 'Dia', 'roadtrip.hazards.show': 'Mostra zones de perill', 'roadtrip.hazards.current': 'Avisos actuals', 'roadtrip.hazards.note': 'Els avisos no són talls de carretera. La ruta no canvia.', diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index 3708ddc67..b32a39a3d 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': 'Importovat trasu z Google Maps', + 'roadtrip.import.note': 'Zastávky se přidají ke zvolenému dni. TREK trasu přepočítá.', + 'roadtrip.import.unresolved': 'Místo nenalezeno; tato zastávka bude přeskočena.', + 'roadtrip.import.day': 'Den', 'roadtrip.hazards.show': 'Zobrazit nebezpečné oblasti', 'roadtrip.hazards.current': 'Aktuální výstrahy', 'roadtrip.hazards.note': 'Výstrahy nejsou uzavírky silnic. Trasa se nemění.', diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index e8e4fc0e1..af11197f0 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': 'Google-Maps-Route importieren', + 'roadtrip.import.note': 'Stopps werden an den gewählten Tag angehängt. TREK berechnet die Route neu.', + 'roadtrip.import.unresolved': 'Ort nicht gefunden; dieser Stopp wird übersprungen.', + 'roadtrip.import.day': 'Tag', 'roadtrip.hazards.show': 'Gefahrengebiete anzeigen', 'roadtrip.hazards.current': 'Aktuelle Warnungen', 'roadtrip.hazards.note': 'Warnungen sind keine Straßensperrungen. Die Route bleibt unverändert.', diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index af0880800..0f43f6a0e 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': 'Import Google Maps route', + 'roadtrip.import.note': 'Stops are appended to the selected day. TREK recalculates the route.', + 'roadtrip.import.unresolved': 'Location unresolved; this stop will be skipped.', + 'roadtrip.import.day': 'Day', 'roadtrip.hazards.show': 'Show hazard areas', 'roadtrip.hazards.current': 'Current warnings', 'roadtrip.hazards.note': 'Warnings are not road closures. Routes stay unchanged.', diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index 655df2f3b..0005ab6ba 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': 'Importar ruta de Google Maps', + 'roadtrip.import.note': 'Las paradas se añaden al día elegido. TREK recalcula la ruta.', + 'roadtrip.import.unresolved': 'Ubicación no encontrada; se omitirá esta parada.', + 'roadtrip.import.day': 'Día', 'roadtrip.hazards.show': 'Mostrar zonas de peligro', 'roadtrip.hazards.current': 'Alertas actuales', 'roadtrip.hazards.note': 'Las alertas no son cierres de carreteras. La ruta no cambia.', diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index 9d2b03a36..bff73a847 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': 'Importer un itinéraire Google Maps', + 'roadtrip.import.note': 'Les étapes sont ajoutées au jour choisi. TREK recalcule le trajet.', + 'roadtrip.import.unresolved': 'Lieu introuvable ; cette étape sera ignorée.', + 'roadtrip.import.day': 'Jour', 'roadtrip.hazards.show': 'Afficher les zones à risque', 'roadtrip.hazards.current': 'Alertes actuelles', 'roadtrip.hazards.note': 'Les alertes ne sont pas des fermetures de routes. Le trajet reste inchangé.', diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index 090224592..4039649a9 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': 'Εισαγωγή διαδρομής Google Maps', + 'roadtrip.import.note': 'Οι στάσεις προστίθενται στην επιλεγμένη ημέρα. Το TREK υπολογίζει ξανά τη διαδρομή.', + 'roadtrip.import.unresolved': 'Η τοποθεσία δεν βρέθηκε· αυτή η στάση θα παραλειφθεί.', + 'roadtrip.import.day': 'Ημέρα', 'roadtrip.hazards.show': 'Εμφάνιση επικίνδυνων περιοχών', 'roadtrip.hazards.current': 'Τρέχουσες προειδοποιήσεις', 'roadtrip.hazards.note': 'Οι προειδοποιήσεις δεν σημαίνουν κλειστούς δρόμους. Η διαδρομή δεν αλλάζει.', diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index 1ac1dcc1c..871aa9b20 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': 'Google Maps-útvonal importálása', + 'roadtrip.import.note': 'A megállók a kiválasztott naphoz kerülnek. A TREK újratervezi az útvonalat.', + 'roadtrip.import.unresolved': 'A hely nem található; ez a megálló kimarad.', + 'roadtrip.import.day': 'Nap', 'roadtrip.hazards.show': 'Veszélyes területek megjelenítése', 'roadtrip.hazards.current': 'Aktuális figyelmeztetések', 'roadtrip.hazards.note': 'A figyelmeztetések nem útlezárások. Az útvonal változatlan marad.', diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index 4f649d4b8..9b9126768 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': 'Impor rute Google Maps', + 'roadtrip.import.note': 'Perhentian ditambahkan ke hari yang dipilih. TREK menghitung ulang rute.', + 'roadtrip.import.unresolved': 'Lokasi tidak ditemukan; perhentian ini akan dilewati.', + 'roadtrip.import.day': 'Hari', 'roadtrip.hazards.show': 'Tampilkan area bahaya', 'roadtrip.hazards.current': 'Peringatan terkini', 'roadtrip.hazards.note': 'Peringatan bukan penutupan jalan. Rute tidak berubah.', diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index 929b9ec39..ea0a184b6 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': 'Importa percorso Google Maps', + 'roadtrip.import.note': 'Le tappe vengono aggiunte al giorno scelto. TREK ricalcola il percorso.', + 'roadtrip.import.unresolved': 'Posizione non trovata; questa tappa verrà saltata.', + 'roadtrip.import.day': 'Giorno', 'roadtrip.hazards.show': 'Mostra zone di pericolo', 'roadtrip.hazards.current': 'Avvisi attuali', 'roadtrip.hazards.note': 'Gli avvisi non indicano strade chiuse. Il percorso non cambia.', diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index a9e503766..c89d9fa06 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': 'Google Mapsのルートをインポート', + 'roadtrip.import.note': '立ち寄り先を選択した日に追加し、TREKでルートを再計算します。', + 'roadtrip.import.unresolved': '場所が見つからないため、この立ち寄り先はスキップされます。', + 'roadtrip.import.day': '日', 'roadtrip.hazards.show': '危険地域を表示', 'roadtrip.hazards.current': '現在の警報', 'roadtrip.hazards.note': '警報は通行止めを意味しません。ルートは変更されません。', diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index c6f45e9f0..1d4946681 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': 'Google Maps 경로 가져오기', + 'roadtrip.import.note': '경유지를 선택한 날짜에 추가합니다. TREK이 경로를 다시 계산합니다.', + 'roadtrip.import.unresolved': '위치를 찾을 수 없어 이 경유지를 건너뜁니다.', + 'roadtrip.import.day': '일', 'roadtrip.hazards.show': '위험 지역 표시', 'roadtrip.hazards.current': '현재 경보', 'roadtrip.hazards.note': '경보는 도로 폐쇄를 의미하지 않습니다. 경로는 변경되지 않습니다.', diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index 222f5e478..cfb5b78fd 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': 'Google Maps-route importeren', + 'roadtrip.import.note': 'Stops worden aan de gekozen dag toegevoegd. TREK berekent de route opnieuw.', + 'roadtrip.import.unresolved': 'Locatie niet gevonden; deze stop wordt overgeslagen.', + 'roadtrip.import.day': 'Dag', 'roadtrip.hazards.show': 'Gevarengebieden tonen', 'roadtrip.hazards.current': 'Actuele waarschuwingen', 'roadtrip.hazards.note': 'Waarschuwingen zijn geen wegafsluitingen. De route blijft ongewijzigd.', diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index 360893fa0..546ea92e9 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': 'Importuj trasę z Google Maps', + 'roadtrip.import.note': 'Przystanki zostaną dodane do wybranego dnia. TREK przeliczy trasę.', + 'roadtrip.import.unresolved': 'Nie znaleziono miejsca; ten przystanek zostanie pominięty.', + 'roadtrip.import.day': 'Dzień', 'roadtrip.hazards.show': 'Pokaż obszary zagrożeń', 'roadtrip.hazards.current': 'Aktualne ostrzeżenia', 'roadtrip.hazards.note': 'Ostrzeżenia nie oznaczają zamknięcia dróg. Trasa pozostaje bez zmian.', diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index 6c4d52ab6..016d70227 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': 'Импорт маршрута Google Maps', + 'roadtrip.import.note': 'Остановки добавляются в выбранный день. TREK пересчитает маршрут.', + 'roadtrip.import.unresolved': 'Место не найдено; эта остановка будет пропущена.', + 'roadtrip.import.day': 'День', 'roadtrip.hazards.show': 'Показывать опасные зоны', 'roadtrip.hazards.current': 'Текущие предупреждения', 'roadtrip.hazards.note': 'Предупреждения не означают закрытие дорог. Маршрут не меняется.', diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index 0c14156d7..80efca11d 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': 'Importera Google Maps-rutt', + 'roadtrip.import.note': 'Stoppen läggs till på vald dag. TREK beräknar rutten på nytt.', + 'roadtrip.import.unresolved': 'Platsen hittades inte; stoppet hoppas över.', + 'roadtrip.import.day': 'Dag', 'roadtrip.hazards.show': 'Visa riskområden', 'roadtrip.hazards.current': 'Aktuella varningar', 'roadtrip.hazards.note': 'Varningar är inte vägavstängningar. Rutten ändras inte.', diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index 4680bd4c8..d01d9e44c 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': 'Google Maps rotasını içe aktar', + 'roadtrip.import.note': 'Duraklar seçilen güne eklenir. TREK rotayı yeniden hesaplar.', + 'roadtrip.import.unresolved': 'Konum bulunamadı; bu durak atlanacak.', + 'roadtrip.import.day': 'Gün', 'roadtrip.hazards.show': 'Tehlike bölgelerini göster', 'roadtrip.hazards.current': 'Güncel uyarılar', 'roadtrip.hazards.note': 'Uyarılar yol kapatmaları değildir. Rota değişmez.', diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index 389edcdab..b9c2dd9a8 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': 'Імпорт маршруту Google Maps', + 'roadtrip.import.note': 'Зупинки додаються до вибраного дня. TREK перерахує маршрут.', + 'roadtrip.import.unresolved': 'Місце не знайдено; цю зупинку буде пропущено.', + 'roadtrip.import.day': 'День', 'roadtrip.hazards.show': 'Показувати небезпечні зони', 'roadtrip.hazards.current': 'Поточні попередження', 'roadtrip.hazards.note': 'Попередження не означають закриття доріг. Маршрут не змінюється.', diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index 2f1cd15d3..df3fcbabd 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': 'Nhập tuyến đường Google Maps', + 'roadtrip.import.note': 'Các điểm dừng được thêm vào ngày đã chọn. TREK tính lại tuyến đường.', + 'roadtrip.import.unresolved': 'Không tìm thấy vị trí; điểm dừng này sẽ bị bỏ qua.', + 'roadtrip.import.day': 'Ngày', 'roadtrip.hazards.show': 'Hiển thị vùng nguy hiểm', 'roadtrip.hazards.current': 'Cảnh báo hiện tại', 'roadtrip.hazards.note': 'Cảnh báo không có nghĩa là đường bị đóng. Tuyến đường không thay đổi.', diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index 1fa2c4ebc..31845fe5d 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': '匯入 Google Maps 路線', + 'roadtrip.import.note': '停靠點將新增至所選日期。TREK 將重新計算路線。', + 'roadtrip.import.unresolved': '找不到位置;將略過此停靠點。', + 'roadtrip.import.day': '天', 'roadtrip.hazards.show': '顯示危險區域', 'roadtrip.hazards.current': '目前警報', 'roadtrip.hazards.note': '警報不代表道路封閉。路線保持不變。', diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index fe92a5810..f3b2c45a9 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -1,6 +1,10 @@ import type { TranslationStrings } from '../types'; const roadtrip: TranslationStrings = { + 'roadtrip.import.title': '导入 Google Maps 路线', + 'roadtrip.import.note': '停靠点将添加到所选日期。TREK 将重新计算路线。', + 'roadtrip.import.unresolved': '未找到位置;将跳过此停靠点。', + 'roadtrip.import.day': '天', 'roadtrip.hazards.show': '显示危险区域', 'roadtrip.hazards.current': '当前警报', 'roadtrip.hazards.note': '警报不代表道路封闭。路线保持不变。', diff --git a/shared/src/index.ts b/shared/src/index.ts index e926e450d..b1f121df2 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -79,3 +79,5 @@ export * from './plugins/plugins.schema'; export * from './roadtrip/preferences.schema'; export * from './roadtrip/hazards.schema'; export * from './roadtrip/planning.schema'; +export * from './roadtrip/search.schema'; +export * from './roadtrip/google-import.schema'; diff --git a/shared/src/roadtrip/google-import.schema.ts b/shared/src/roadtrip/google-import.schema.ts new file mode 100644 index 000000000..21c73b165 --- /dev/null +++ b/shared/src/roadtrip/google-import.schema.ts @@ -0,0 +1,27 @@ +import { idSchema } from '../common/primitives.schema'; + +import { z } from 'zod'; + +export const googleRoutePreviewRequestSchema = z.object({ url: z.string().url().max(12000) }); +export const googleRouteStopSchema = z.object({ + name: z.string().min(1).max(200), + lat: z.number().min(-90).max(90), + lng: z.number().min(-180).max(180), +}); +export const googleRoutePreviewSchema = z.object({ + stops: z + .array( + z.object({ + name: z.string(), + lat: z.number().nullable(), + lng: z.number().nullable(), + }), + ) + .max(30), +}); +export const googleRouteImportSchema = z.object({ + dayId: idSchema, + stops: z.array(googleRouteStopSchema).min(2).max(30), +}); +export type GoogleRouteImport = z.infer; +export type GoogleRoutePreview = z.infer; diff --git a/shared/src/roadtrip/search.schema.spec.ts b/shared/src/roadtrip/search.schema.spec.ts new file mode 100644 index 000000000..c01b7ad38 --- /dev/null +++ b/shared/src/roadtrip/search.schema.spec.ts @@ -0,0 +1,14 @@ +import { roadtripSearchAreaSchema } from './search.schema'; + +import { describe, expect, it } from 'vitest'; + +describe('Roadtrip search area', () => { + const request = { categories: ['fuel'], bbox: { south: 48, north: 49, west: 10, east: 11 } }; + it('validates categories and ordered geographic bounds', () => { + expect(roadtripSearchAreaSchema.safeParse(request).success).toBe(true); + expect(roadtripSearchAreaSchema.safeParse({ ...request, categories: ['unknown'] }).success).toBe(false); + expect(roadtripSearchAreaSchema.safeParse({ ...request, bbox: { ...request.bbox, south: 50 } }).success).toBe( + false, + ); + }); +}); diff --git a/shared/src/roadtrip/search.schema.ts b/shared/src/roadtrip/search.schema.ts new file mode 100644 index 000000000..239c7a39d --- /dev/null +++ b/shared/src/roadtrip/search.schema.ts @@ -0,0 +1,52 @@ +import { roadtripCorridorRequestSchema } from './planning.schema'; + +import { z } from 'zod'; + +export const roadtripSearchAreaSchema = z.object({ + categories: z.array(roadtripCorridorRequestSchema.shape.category).min(1).max(7), + bbox: z + .object({ + south: z.number().min(-90).max(90), + north: z.number().min(-90).max(90), + west: z.number().min(-180).max(180), + east: z.number().min(-180).max(180), + }) + .refine((b) => b.south < b.north && b.west < b.east, 'Invalid search area'), + lang: z.string().max(20).optional(), +}); +export type RoadtripSearchArea = z.infer; + +export const roadtripSearchPoiSchema = z.object({ + osm_id: z.string(), + name: z.string(), + lat: z.number(), + lng: z.number(), + category: z.string(), + poi_type: z.string(), + source: z.string(), + address: z.string().nullable(), + website: z.string().nullable(), + phone: z.string().nullable(), + opening_hours: z.string().nullable(), + cuisine: z.string().nullable(), + brand: z.string().nullable().optional(), + brand_wikidata: z.string().nullable().optional(), + charging: z + .object({ + sockets: z.array(z.object({ type: z.string(), count: z.number().nullable(), kw: z.number().nullable() })), + capacity: z.number().nullable(), + fee: z.boolean().nullable(), + }) + .nullable() + .optional(), + rating: z.number().nullable().optional(), + pluginId: z.string().optional(), +}); +export const roadtripSearchAreaResponseSchema = z.object({ + pois: z.array(roadtripSearchPoiSchema), + sources: z.array(z.string()), + failedSources: z.array(z.string()), + truncated: z.boolean(), + clamped: z.boolean(), +}); +export type RoadtripSearchAreaResponse = z.infer; diff --git a/wiki/MCP-Addon-Tools.md b/wiki/MCP-Addon-Tools.md index f5574b83c..27dc7d6f2 100644 --- a/wiki/MCP-Addon-Tools.md +++ b/wiki/MCP-Addon-Tools.md @@ -243,3 +243,7 @@ Roadtrip also exposes `get_roadtrip_hazards` (`tripId`, trips read scope). It re - [MCP-Scopes](MCP-Scopes) - [MCP-Prompts](MCP-Prompts) - [MCP-Setup](MCP-Setup) + +Roadtrip corridor searches include installed search-provider plugins. Providers receive the category and search bounds, and the host filters hits to the route. The response lists successful and failed sources; a failed provider does not discard the remaining results. + +Google Maps directions links can be reviewed with preview_google_maps_route and saved with import_google_maps_route. Import creates places and visits together in the supplied order, with trip access and place/day permission checks. Unresolved stops must be omitted from the confirmed input. The Google road geometry is not copied. diff --git a/wiki/Trip-Planner-Overview.md b/wiki/Trip-Planner-Overview.md index 78b37679b..659ba90cb 100644 --- a/wiki/Trip-Planner-Overview.md +++ b/wiki/Trip-Planner-Overview.md @@ -105,3 +105,11 @@ Driving settings includes **Show hazard areas**, off by default and shared by th - [Admin-Addons](Admin-Addons) Driving settings also controls **Show in Days too** under **Service stops**. It is on by default and applies to every service stop in the trip, including existing stops. Turn it off to keep service stops exclusively in the Roadtrip view and omit them from the normal Days view and its route. Turning it on restores their visibility without creating duplicates. The setting is shared with fellow travellers and can also be changed through `update_roadtrip_settings` using `roadtrip_service_stops_in_days`. + +**Looking for** in Roadtrip also searches installed place-search plugins. Plugin results show their source and can be added like other stops. Search remains online-only and runs on request. Results outside the chosen corridor are removed, and failed sources are shown beside the remaining results. + +> **AI / MCP:** `search_roadtrip_corridor` uses the same combined sources and reports `failedSources`. + +In Roadtrip, open the three-dot menu beside the day selector to import a Google Maps directions link. Preview the ordered stops, choose a day, then confirm. Resolved stops are appended to that day; unresolved locations are marked and skipped. TREK calculates its own route between the stops. Import needs an online connection and permission to edit places and days. + +> **AI / MCP:** Use preview_google_maps_route to inspect a link, then import_google_maps_route to append the reviewed stops to a trip day.