mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
add Google Maps route import and plugin corridor search
This commit is contained in:
@@ -70,5 +70,7 @@ export interface Poi {
|
|||||||
capacity: number | null
|
capacity: number | null
|
||||||
fee: boolean | null
|
fee: boolean | null
|
||||||
} | null
|
} | null
|
||||||
source: 'openstreetmap'
|
source: string
|
||||||
|
pluginId?: string
|
||||||
|
rating?: number | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(<TranslationProvider><GoogleRouteImport tripId={1} dayId={3} /></TranslationProvider>)
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<GoogleRoutePreview | null>(null)
|
||||||
|
const [target, setTarget] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [failure, setFailure] = useState('')
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const controller = useRef<AbortController | null>(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 <>
|
||||||
|
<Tooltip label={t('roadtrip.import.title')}>
|
||||||
|
<button type="button" aria-label={t('roadtrip.import.title')} className="shrink-0 rounded-lg p-2 text-content-muted hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
onClick={e => menu.open(e, [{ label: t('roadtrip.import.title'), icon: Link2, onClick: show }], true)}>
|
||||||
|
<MoreHorizontal size={18} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<ContextMenu menu={menu.menu} onClose={menu.close} />
|
||||||
|
<Modal isOpen={open} onClose={() => { if (!busy) setOpen(false) }} title={t('roadtrip.import.title')} size="lg">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-caption text-content-muted">{t('roadtrip.import.note')}</p>
|
||||||
|
{!preview ? <input type="url" value={url} disabled={busy} onChange={e => 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" /> : <>
|
||||||
|
<CustomSelect value={target} onChange={value => { 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} />
|
||||||
|
<ol className="max-h-64 space-y-2 overflow-auto">
|
||||||
|
{preview.stops.map((stop, index) => <li key={index} className="flex items-baseline gap-3 text-body text-content">
|
||||||
|
<span className="text-caption tabular-nums text-content-muted">{index + 1}</span>
|
||||||
|
<span className="min-w-0">{stop.name}{stop.name !== `${stop.lat}, ${stop.lng}` && <span className="block text-caption text-content-muted">{stop.lat == null || stop.lng == null ? t('roadtrip.import.unresolved') : `${stop.lat.toFixed(5)}, ${stop.lng.toFixed(5)}`}</span>}</span>
|
||||||
|
</li>)}
|
||||||
|
</ol>
|
||||||
|
</>}
|
||||||
|
{failure && <p role="alert" className="text-caption text-danger">{saved ? t('common.saved') + ': ' : ''}{failure}</p>}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button type="button" disabled={busy} className="rounded-lg px-3 py-2 text-body text-content-secondary" onClick={() => setOpen(false)}>{t('common.cancel')}</button>
|
||||||
|
<button type="button" disabled={busy || saved || (!preview ? !url.trim() : resolved.length < 2 || !target)} onClick={() => void (preview ? save() : inspect())}
|
||||||
|
className="rounded-lg bg-accent px-4 py-2 text-body font-medium text-accent-text disabled:opacity-50">
|
||||||
|
{busy ? t('common.loading') : t(preview ? 'common.import' : 'common.preview')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
}
|
||||||
@@ -279,6 +279,14 @@ describe('RoadtripCorridorPanel', () => {
|
|||||||
expect(screen.getByText('Aral')).toBeInTheDocument()
|
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(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||||
|
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', () => {
|
it('FE-ROADTRIP-PANEL-015: a total outage reads as a failure, not as an empty road', () => {
|
||||||
const c = corridor({}, { error: true, failedAreas: 4 })
|
const c = corridor({}, { error: true, failedAreas: 4 })
|
||||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import GoogleRouteImport from './GoogleRouteImport'
|
||||||
import React, { useMemo, useState } from 'react'
|
import React, { useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin, ChevronDown,
|
Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin, ChevronDown,
|
||||||
@@ -17,6 +18,8 @@ import type { CorridorPoi } from './useCorridorPois'
|
|||||||
import type { RoadtripRoutes } from './useRoadtripRoutes'
|
import type { RoadtripRoutes } from './useRoadtripRoutes'
|
||||||
|
|
||||||
interface RoadtripCorridorPanelProps {
|
interface RoadtripCorridorPanelProps {
|
||||||
|
tripId?: number
|
||||||
|
canImport?: boolean
|
||||||
corridor: RoadtripCorridor
|
corridor: RoadtripCorridor
|
||||||
routes: RoadtripRoutes
|
routes: RoadtripRoutes
|
||||||
/** Opens the place form prefilled from a POI, on a day and at a position in it. */
|
/** 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
|
|||||||
<div className="truncate font-semibold tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
|
<div className="truncate font-semibold tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
|
||||||
{poi.name}
|
{poi.name}
|
||||||
</div>
|
</div>
|
||||||
|
{poi.pluginId && <p className="truncate text-caption text-content-muted">{poi.pluginId}</p>}
|
||||||
{/* Two facts, two chips. Where it is off the road and how far into the drive it
|
{/* 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
|
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
|
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
|
* header rather than among the filters — it is the question's subject, not one of its
|
||||||
* conditions.
|
* 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 { t } = useTranslation()
|
||||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||||
const { search } = corridor
|
const { search } = corridor
|
||||||
@@ -299,6 +303,7 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: Ro
|
|||||||
: 0
|
: 0
|
||||||
|
|
||||||
const warnings: [string, string][] = []
|
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.capped) warnings.push(['capped', t('roadtrip.poi.capped')])
|
||||||
if (search.error) warnings.push(['failed', t('roadtrip.poi.failed')])
|
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
|
// 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
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{tripId && canImport && <GoogleRouteImport tripId={tripId} dayId={corridor.day?.dayId} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* What to look for. */}
|
{/* What to look for. */}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|||||||
import type { LatLng } from './corridor'
|
import type { LatLng } from './corridor'
|
||||||
|
|
||||||
const { pois } = vi.hoisted(() => ({ pois: vi.fn() }))
|
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' }) }))
|
vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en-US' }) }))
|
||||||
|
|
||||||
import { useCorridorPois } from './useCorridorPois'
|
import { useCorridorPois } from './useCorridorPois'
|
||||||
@@ -30,7 +30,7 @@ const hit = (id: string, lat: number, lng: number, category = 'fuel') => ({
|
|||||||
source: 'openstreetmap',
|
source: 'openstreetmap',
|
||||||
})
|
})
|
||||||
|
|
||||||
const answer = (...items: ReturnType<typeof hit>[]) => ({ pois: items, source: 'openstreetmap', truncated: false, clamped: false })
|
const answer = (...items: ReturnType<typeof hit>[]) => ({ pois: items, sources: ['openstreetmap'], failedSources: [], truncated: false, clamped: false })
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
pois.mockReset()
|
pois.mockReset()
|
||||||
@@ -38,6 +38,14 @@ beforeEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('useCorridorPois', () => {
|
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 () => {
|
it('FE-ROADTRIP-CORRIDOR-001: asks each box once for every category together', async () => {
|
||||||
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel', 'charging', 'rest_area'], 10))
|
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel', 'charging', 'rest_area'], 10))
|
||||||
act(() => { result.current.search() })
|
act(() => { result.current.search() })
|
||||||
@@ -45,7 +53,7 @@ describe('useCorridorPois', () => {
|
|||||||
|
|
||||||
expect(pois).toHaveBeenCalled()
|
expect(pois).toHaveBeenCalled()
|
||||||
// The whole point of the batching: the kinds ride along in one query per box.
|
// 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)
|
expect(result.current.progress.total).toBe(pois.mock.calls.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { mapsApi } from '../../api/client'
|
import { roadtripSearchRepo } from '../../repo/roadtripSearchRepo'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { corridorTiles, projectOntoRoute, simplifyLine, type Bbox, type LatLng } from './corridor'
|
import { corridorTiles, projectOntoRoute, simplifyLine, type Bbox, type LatLng } from './corridor'
|
||||||
import type { Poi } from '../Map/poiCategories'
|
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. */
|
/** True when the route was longer than the request budget and the tail was left out. */
|
||||||
capped: boolean
|
capped: boolean
|
||||||
/** Boxes the place search could not answer — a partial result is still shown. */
|
/** Boxes the place search could not answer — a partial result is still shown. */
|
||||||
|
failedSources?: string[]
|
||||||
failedAreas: number
|
failedAreas: number
|
||||||
/**
|
/**
|
||||||
* Boxes where the server hit its own per-request ceiling and dropped the rest.
|
* 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 [capped, setCapped] = useState(false)
|
||||||
const [failedAreas, setFailedAreas] = useState(0)
|
const [failedAreas, setFailedAreas] = useState(0)
|
||||||
const [truncatedAreas, setTruncatedAreas] = useState(0)
|
const [truncatedAreas, setTruncatedAreas] = useState(0)
|
||||||
|
const [failedSources, setFailedSources] = useState<string[]>([])
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
const abortRef = useRef<AbortController | null>(null)
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
const runIdRef = useRef(0)
|
const runIdRef = useRef(0)
|
||||||
@@ -91,6 +93,7 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
|
|||||||
setProgress({ done: 0, total: 0 })
|
setProgress({ done: 0, total: 0 })
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setCapped(false)
|
setCapped(false)
|
||||||
|
setFailedSources([])
|
||||||
setFailedAreas(0)
|
setFailedAreas(0)
|
||||||
setTruncatedAreas(0)
|
setTruncatedAreas(0)
|
||||||
setError(false)
|
setError(false)
|
||||||
@@ -112,6 +115,7 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
|
|||||||
const tiles = allTiles.slice(0, MAX_TILES)
|
const tiles = allTiles.slice(0, MAX_TILES)
|
||||||
|
|
||||||
setCapped(allTiles.length > tiles.length)
|
setCapped(allTiles.length > tiles.length)
|
||||||
|
setFailedSources([])
|
||||||
setFailedAreas(0)
|
setFailedAreas(0)
|
||||||
setTruncatedAreas(0)
|
setTruncatedAreas(0)
|
||||||
setError(false)
|
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
|
// 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
|
// 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.
|
// mirror to time out — which is what made a search both slow and patchy.
|
||||||
const wanted = categories.join(',')
|
|
||||||
const jobs = [...tiles]
|
const jobs = [...tiles]
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const seen = new Map<string, CorridorPoi>()
|
const seen = new Map<string, CorridorPoi>()
|
||||||
|
const unavailable = new Set<string>()
|
||||||
let failures = 0
|
let failures = 0
|
||||||
let shortened = 0
|
let shortened = 0
|
||||||
let done = 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.
|
// cheaply; the small gap afterwards keeps one worker from queueing straight up.
|
||||||
const collect = async (tile: Bbox): Promise<boolean> => {
|
const collect = async (tile: Bbox): Promise<boolean> => {
|
||||||
try {
|
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
|
// 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.
|
// 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) {
|
for (const poi of data.pois) {
|
||||||
if (seen.has(poi.osm_id)) continue
|
if (seen.has(poi.osm_id)) continue
|
||||||
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine)
|
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine)
|
||||||
if (!hit || hit.offRouteKm > widthKm) continue
|
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
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
@@ -193,5 +209,5 @@ export function useCorridorPois(line: LatLng[], categories: string[], widthKm: n
|
|||||||
|
|
||||||
useEffect(() => () => abortRef.current?.abort(), [])
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { createPortal } from 'react-dom'
|
||||||
import { LucideIcon } from 'lucide-react'
|
import { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
@@ -14,15 +14,17 @@ interface MenuState {
|
|||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
items: MenuItem[]
|
items: MenuItem[]
|
||||||
|
alignEnd?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useContextMenu() {
|
export function useContextMenu() {
|
||||||
const [menu, setMenu] = useState<MenuState | null>(null)
|
const [menu, setMenu] = useState<MenuState | null>(null)
|
||||||
|
|
||||||
const open = (e: React.MouseEvent, items: MenuItem[]) => {
|
const open = (e: React.MouseEvent, items: MenuItem[], alignEnd = false) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
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)
|
const close = () => setMenu(null)
|
||||||
@@ -49,11 +51,12 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
|||||||
}
|
}
|
||||||
}, [menu, onClose])
|
}, [menu, onClose])
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!menu || !ref.current) return
|
if (!menu || !ref.current) return
|
||||||
const el = ref.current
|
const el = ref.current
|
||||||
const rect = el.getBoundingClientRect()
|
const rect = el.getBoundingClientRect()
|
||||||
let { x, y } = menu
|
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 (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 (y + rect.height > window.innerHeight - 8) y = window.innerHeight - rect.height - 8
|
||||||
if (x !== menu.x || y !== menu.y) {
|
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)',
|
boxShadow: '0 8px 30px rgba(0,0,0,0.15)',
|
||||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||||
minWidth: 160,
|
minWidth: 160,
|
||||||
|
width: menu.alignEnd ? 'max-content' : undefined,
|
||||||
|
whiteSpace: menu.alignEnd ? 'nowrap' : undefined,
|
||||||
fontFamily: "var(--font-system)",
|
fontFamily: "var(--font-system)",
|
||||||
transformOrigin: 'top left',
|
transformOrigin: menu.alignEnd ? 'top right' : 'top left',
|
||||||
}}>
|
}}>
|
||||||
{menu.items.filter(Boolean).map((item, i) => {
|
{menu.items.filter(Boolean).map((item, i) => {
|
||||||
if (item.divider) return <div key={i} style={{ height: 1, background: 'var(--border-faint)', margin: '3px 6px' }} />
|
if (item.divider) return <div key={i} style={{ height: 1, background: 'var(--border-faint)', margin: '3px 6px' }} />
|
||||||
|
|||||||
@@ -680,6 +680,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
|||||||
returns silently without the permission, which reads as a broken
|
returns silently without the permission, which reads as a broken
|
||||||
button rather than a missing one. */}
|
button rather than a missing one. */}
|
||||||
<RoadtripCorridorPanel
|
<RoadtripCorridorPanel
|
||||||
|
tripId={Number(tripId)} canImport={can('place_edit', trip) && can('day_edit', trip)}
|
||||||
corridor={roadtripCorridor}
|
corridor={roadtripCorridor}
|
||||||
routes={roadtripRoutes}
|
routes={roadtripRoutes}
|
||||||
onAddPoi={can('place_edit', trip) ? handlePoiClick : undefined}
|
onAddPoi={can('place_edit', trip) ? handlePoiClick : undefined}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { googleRoutePreviewSchema, type GoogleRouteImport } from '@trek/shared'
|
||||||
|
import { apiClient } from '../api/client'
|
||||||
|
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||||
|
|
||||||
|
export const googleRouteRepo = {
|
||||||
|
async preview(url: string, signal: AbortSignal) {
|
||||||
|
if (isEffectivelyOffline()) throw new Error('Import requires a connection')
|
||||||
|
const reply = await apiClient.post('/roadtrip/google-maps-preview', { url }, { signal, timeout: 120000 })
|
||||||
|
return googleRoutePreviewSchema.parse(reply.data)
|
||||||
|
},
|
||||||
|
async append(tripId: number, input: GoogleRouteImport, idempotencyKey: string) {
|
||||||
|
if (isEffectivelyOffline()) throw new Error('Import requires a connection')
|
||||||
|
await apiClient.post(`/trips/${tripId}/roadtrip/google-maps-import`, input, {
|
||||||
|
headers: { 'X-Idempotency-Key': idempotencyKey },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { roadtripSearchAreaResponseSchema } from '@trek/shared'
|
||||||
|
import { apiClient } from '../api/client'
|
||||||
|
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||||
|
|
||||||
|
export const roadtripSearchRepo = {
|
||||||
|
async search(categories: string[], bbox: { south: number; west: number; north: number; east: number }, lang: string, signal: AbortSignal) {
|
||||||
|
if (isEffectivelyOffline()) throw new Error('Search requires a connection')
|
||||||
|
const reply = await apiClient.post('/roadtrip/search-area', { categories, bbox, lang }, { signal, timeout: 20000 })
|
||||||
|
return roadtripSearchAreaResponseSchema.parse(reply.data)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -407,3 +407,7 @@ It is already silent in CI (any `CI` env var), under `NODE_ENV=test`, and whenev
|
|||||||
stdout isn't a terminal (i.e. when piped or redirected).
|
stdout isn't a terminal (i.e. when piped or redirected).
|
||||||
|
|
||||||
The SDK tooling in this repo is MIT. Your plugin is your own code under your own license.
|
The SDK tooling in this repo is MIT. Your plugin is your own code under your own license.
|
||||||
|
|
||||||
|
### Roadtrip category searches
|
||||||
|
|
||||||
|
The `searchProvider.search` request can include `category` and `bounds` (south, west, north, east). Search within that rectangle for the category; `query` remains a readable category query and `near` its centre, so existing providers continue to work. These optional fields are absent on older hosts and ordinary name searches. The host validates and filters coordinates, namespaces IDs, and applies the exact route corridor after combining sources. The existing permission and two-second hook timeout still apply.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-plugin-sdk",
|
"name": "trek-plugin-sdk",
|
||||||
"version": "1.7.0",
|
"version": "1.7.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trek-plugin-sdk",
|
"name": "trek-plugin-sdk",
|
||||||
"version": "1.7.0",
|
"version": "1.7.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.7.0",
|
"@clack/prompts": "^1.7.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trek-plugin-sdk",
|
"name": "trek-plugin-sdk",
|
||||||
"version": "1.7.0",
|
"version": "1.7.1",
|
||||||
"description": "SDK for building TREK plugins: types, definePlugin, a mock host, manifest validation, and the create-trek-plugin / trek-plugin CLIs.",
|
"description": "SDK for building TREK plugins: types, definePlugin, a mock host, manifest validation, and the create-trek-plugin / trek-plugin CLIs.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -505,6 +505,9 @@ export interface SearchRequest {
|
|||||||
lang?: string;
|
lang?: string;
|
||||||
/** Where the person is looking, when there is somewhere to bias toward. */
|
/** Where the person is looking, when there is somewhere to bias toward. */
|
||||||
near?: { lat: number; lng: number };
|
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 {
|
export interface SearchProvider {
|
||||||
/** Places for a query, drawn into TREK's own search list beside the core results.
|
/** Places for a query, drawn into TREK's own search list beside the core results.
|
||||||
|
|||||||
@@ -2645,21 +2645,23 @@ export class MapsService {
|
|||||||
lat: string,
|
lat: string,
|
||||||
lng: string,
|
lng: string,
|
||||||
lang?: string,
|
lang?: string,
|
||||||
opts?: { lane?: GeoLane; timeoutMs?: number },
|
opts?: { lane?: GeoLane; timeoutMs?: number; locality?: boolean },
|
||||||
): Promise<{ name: string | null; address: string | null }> {
|
): Promise<{ name: string | null; address: string | null }> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
lat,
|
lat,
|
||||||
lon: lng,
|
lon: lng,
|
||||||
format: 'json',
|
format: 'json',
|
||||||
addressdetails: '1',
|
addressdetails: '1',
|
||||||
zoom: '18',
|
zoom: opts?.locality ? '10' : '18',
|
||||||
'accept-language': toApiLang(lang),
|
'accept-language': toApiLang(lang),
|
||||||
});
|
});
|
||||||
const response = await nominatimFetch('reverse', params, opts);
|
const response = await nominatimFetch('reverse', params, opts);
|
||||||
if (!response.ok) return { name: null, address: null };
|
if (!response.ok) return { name: null, address: null };
|
||||||
const data = (await response.json()) as { name?: string; display_name?: string; address?: Record<string, string> };
|
const data = (await response.json()) as { name?: string; display_name?: string; address?: Record<string, string> };
|
||||||
const addr = data.address || {};
|
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 };
|
return { name, address: data.display_name || null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* 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.
|
* 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;
|
let url: URL;
|
||||||
try {
|
try {
|
||||||
url = new URL(raw);
|
url = new URL(raw);
|
||||||
@@ -142,5 +142,5 @@ export function parseDirectionsUrl(raw: string): DirWaypoint[] {
|
|||||||
}
|
}
|
||||||
const fromParams = fromQuery(url);
|
const fromParams = fromQuery(url);
|
||||||
const found = fromParams.length ? fromParams : fromPath(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) : [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export interface HookSearchRequest {
|
|||||||
/** The caller's language tag, for providers that return localized names. */
|
/** The caller's language tag, for providers that return localized names. */
|
||||||
lang?: string;
|
lang?: string;
|
||||||
near?: { lat: number; lng: number };
|
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. */
|
/** The waypoint request a route provider is asked to solve. */
|
||||||
|
|||||||
@@ -443,6 +443,9 @@ export interface SearchRequest {
|
|||||||
lang?: string;
|
lang?: string;
|
||||||
/** Where the person is looking, when there is somewhere to bias toward. */
|
/** Where the person is looking, when there is somewhere to bias toward. */
|
||||||
near?: { lat: number; lng: number };
|
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 {
|
export interface SearchProvider {
|
||||||
search(request: SearchRequest, ctx: PluginContext): Promise<SearchResultPlace[]>;
|
search(request: SearchRequest, ctx: PluginContext): Promise<SearchResultPlace[]>;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)); }
|
||||||
|
}
|
||||||
@@ -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<GoogleRoutePreview> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { ADDON_IDS } from '../../addons';
|
|||||||
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, errorResult, type McpContext } from '../../nest-mcp';
|
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, errorResult, type McpContext } from '../../nest-mcp';
|
||||||
import { addonGate } from '../addons/addon-gate';
|
import { addonGate } from '../addons/addon-gate';
|
||||||
import { AddonsService } from '../addons/addons.service';
|
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 { RoadtripPlanService } from './roadtrip-plan.service';
|
||||||
import {
|
import {
|
||||||
roadtripPlanRequestSchema,
|
roadtripPlanRequestSchema,
|
||||||
@@ -20,7 +20,7 @@ const when = addonGate(ADDON_IDS.ROADTRIP);
|
|||||||
export class RoadtripPlanningMcp {
|
export class RoadtripPlanningMcp {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly plans: RoadtripPlanService,
|
private readonly plans: RoadtripPlanService,
|
||||||
private readonly maps: MapsService,
|
private readonly maps: RoadtripSearchService,
|
||||||
readonly addons: AddonsService,
|
readonly addons: AddonsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -104,16 +104,18 @@ export class RoadtripPlanningMcp {
|
|||||||
const tiles = corridorTiles(line, input.widthKm);
|
const tiles = corridorTiles(line, input.widthKm);
|
||||||
const hits = new Map<
|
const hits = new Map<
|
||||||
string,
|
string,
|
||||||
{ poi: Awaited<ReturnType<MapsService['pois']>>['pois'][number]; alongKm: number; distanceKm: number }
|
{ poi: Awaited<ReturnType<RoadtripSearchService['search']>>['pois'][number]; alongKm: number; distanceKm: number }
|
||||||
>();
|
>();
|
||||||
const page = tiles.slice(input.offset, input.offset + 6);
|
const page = tiles.slice(input.offset, input.offset + 6);
|
||||||
const failedAreas: number[] = [];
|
const failedAreas: number[] = [];
|
||||||
let truncatedAreas = 0;
|
let truncatedAreas = 0;
|
||||||
const sources = new Set<string>();
|
const sources = new Set<string>();
|
||||||
|
const failedSources = new Set<string>();
|
||||||
for (const bbox of page) {
|
for (const bbox of page) {
|
||||||
try {
|
try {
|
||||||
const found = await this.maps.pois(input.category, bbox);
|
const found = await this.maps.search({ categories: [input.category], bbox }, ctx.userId);
|
||||||
sources.add(found.source);
|
found.sources.forEach(source => sources.add(source));
|
||||||
|
found.failedSources.forEach(source => failedSources.add(source));
|
||||||
if (found.truncated || found.clamped) truncatedAreas++;
|
if (found.truncated || found.clamped) truncatedAreas++;
|
||||||
for (const poi of found.pois) {
|
for (const poi of found.pois) {
|
||||||
const projection = projectOntoRoute(poi, line);
|
const projection = projectOntoRoute(poi, line);
|
||||||
@@ -146,7 +148,8 @@ export class RoadtripPlanningMcp {
|
|||||||
return ok({
|
return ok({
|
||||||
dayNumber: day.dayNumber,
|
dayNumber: day.dayNumber,
|
||||||
sources: [...sources],
|
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,
|
failedAreas,
|
||||||
truncatedAreas,
|
truncatedAreas,
|
||||||
totalAreas: tiles.length,
|
totalAreas: tiles.length,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
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<RoadtripSearchAreaResponse> {
|
||||||
|
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<string>();
|
||||||
|
const failedSources = new Set<string>();
|
||||||
|
const pois = new Map<string, RoadtripSearchAreaResponse['pois'][number]>();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { AddonsModule } from '../addons/addons.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { MapsModule } from '../maps/maps.module';
|
import { MapsModule } from '../maps/maps.module';
|
||||||
@@ -18,6 +23,8 @@ import { RoadtripRouterService } from './roadtrip-router.service';
|
|||||||
import { RoadtripController } from './roadtrip.controller';
|
import { RoadtripController } from './roadtrip.controller';
|
||||||
import { RoadtripMcp } from './roadtrip.mcp';
|
import { RoadtripMcp } from './roadtrip.mcp';
|
||||||
import { RoadtripService } from './roadtrip.service';
|
import { RoadtripService } from './roadtrip.service';
|
||||||
|
import { RoadtripSearchService } from './roadtrip-search.service';
|
||||||
|
import { RoadtripSearchController } from './roadtrip-search.controller';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { RoadtripHazardsController } from './roadtrip-hazards.controller';
|
import { RoadtripHazardsController } from './roadtrip-hazards.controller';
|
||||||
import { RoadtripHazardsService } from './roadtrip-hazards.service';
|
import { RoadtripHazardsService } from './roadtrip-hazards.service';
|
||||||
@@ -35,10 +42,11 @@ import { RoadtripHazardsMcp } from './roadtrip-hazards.mcp';
|
|||||||
RealtimeModule,
|
RealtimeModule,
|
||||||
SettingsModule,
|
SettingsModule,
|
||||||
PluginsRuntimeModule,
|
PluginsRuntimeModule,
|
||||||
MapsModule,
|
MapsModule, PlacesModule, AssignmentsModule,
|
||||||
],
|
],
|
||||||
controllers: [RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController],
|
controllers: [GoogleRouteController, RoadtripSearchController, RoadtripPreferencesController, RoadtripController, DayBoundariesController, RoadtripHazardsController],
|
||||||
providers: [
|
providers: [GoogleRouteService, GoogleRouteMcp,
|
||||||
|
RoadtripSearchService,
|
||||||
RoadtripHazardsService,
|
RoadtripHazardsService,
|
||||||
RoadtripHazardsMcp,
|
RoadtripHazardsMcp,
|
||||||
RoadtripService,
|
RoadtripService,
|
||||||
|
|||||||
@@ -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.
|
* 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);
|
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 () => {
|
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: [] });
|
const read = vi.spyOn(app.get(RoadtripHazardsService), 'read').mockResolvedValue({ fetchedAt: new Date().toISOString(), hazards: [], sources: [] });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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: <T>(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(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -262,9 +262,9 @@ describe('Roadtrip MCP registration and search', () => {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
const maps = {
|
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' }],
|
pois: [{ osm_id: 'n1', name: 'Fuel', brand: 'Example', lat: 48, lng: 10.05, category: 'fuel' }],
|
||||||
source: 'trek-places',
|
sources: ['trek-places'], failedSources: [],
|
||||||
truncated: true,
|
truncated: true,
|
||||||
clamped: false,
|
clamped: false,
|
||||||
})),
|
})),
|
||||||
@@ -279,7 +279,7 @@ describe('Roadtrip MCP registration and search', () => {
|
|||||||
expect(body.sources).toEqual(['trek-places']);
|
expect(body.sources).toEqual(['trek-places']);
|
||||||
expect(body.truncatedAreas).toBeGreaterThan(0);
|
expect(body.truncatedAreas).toBeGreaterThan(0);
|
||||||
expect(body.complete).toBe(false);
|
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);
|
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)).toContain('failedAreas');
|
||||||
expect(JSON.stringify(failed)).not.toContain('private endpoint');
|
expect(JSON.stringify(failed)).not.toContain('private endpoint');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
const roadtrip: TranslationStrings = {
|
||||||
|
'roadtrip.import.title': 'استيراد مسار Google Maps',
|
||||||
|
'roadtrip.import.note': 'تُضاف المحطات إلى اليوم المحدد. يعيد TREK حساب المسار.',
|
||||||
|
'roadtrip.import.unresolved': 'تعذر تحديد الموقع؛ سيتم تخطي هذه المحطة.',
|
||||||
|
'roadtrip.import.day': 'اليوم',
|
||||||
'roadtrip.hazards.show': 'عرض مناطق الخطر',
|
'roadtrip.hazards.show': 'عرض مناطق الخطر',
|
||||||
'roadtrip.hazards.current': 'التحذيرات الحالية',
|
'roadtrip.hazards.current': 'التحذيرات الحالية',
|
||||||
'roadtrip.hazards.note': 'التحذيرات لا تعني إغلاق الطرق. يبقى المسار دون تغيير.',
|
'roadtrip.hazards.note': 'التحذيرات لا تعني إغلاق الطرق. يبقى المسار دون تغيير.',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
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.show': 'Mostrar áreas de risco',
|
||||||
'roadtrip.hazards.current': 'Alertas atuais',
|
'roadtrip.hazards.current': 'Alertas atuais',
|
||||||
'roadtrip.hazards.note': 'Alertas não são interdições de vias. A rota não muda.',
|
'roadtrip.hazards.note': 'Alertas não são interdições de vias. A rota não muda.',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
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.show': 'Mostra zones de perill',
|
||||||
'roadtrip.hazards.current': 'Avisos actuals',
|
'roadtrip.hazards.current': 'Avisos actuals',
|
||||||
'roadtrip.hazards.note': 'Els avisos no són talls de carretera. La ruta no canvia.',
|
'roadtrip.hazards.note': 'Els avisos no són talls de carretera. La ruta no canvia.',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
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.show': 'Zobrazit nebezpečné oblasti',
|
||||||
'roadtrip.hazards.current': 'Aktuální výstrahy',
|
'roadtrip.hazards.current': 'Aktuální výstrahy',
|
||||||
'roadtrip.hazards.note': 'Výstrahy nejsou uzavírky silnic. Trasa se nemění.',
|
'roadtrip.hazards.note': 'Výstrahy nejsou uzavírky silnic. Trasa se nemění.',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
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.show': 'Gefahrengebiete anzeigen',
|
||||||
'roadtrip.hazards.current': 'Aktuelle Warnungen',
|
'roadtrip.hazards.current': 'Aktuelle Warnungen',
|
||||||
'roadtrip.hazards.note': 'Warnungen sind keine Straßensperrungen. Die Route bleibt unverändert.',
|
'roadtrip.hazards.note': 'Warnungen sind keine Straßensperrungen. Die Route bleibt unverändert.',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
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.show': 'Show hazard areas',
|
||||||
'roadtrip.hazards.current': 'Current warnings',
|
'roadtrip.hazards.current': 'Current warnings',
|
||||||
'roadtrip.hazards.note': 'Warnings are not road closures. Routes stay unchanged.',
|
'roadtrip.hazards.note': 'Warnings are not road closures. Routes stay unchanged.',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
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.show': 'Mostrar zonas de peligro',
|
||||||
'roadtrip.hazards.current': 'Alertas actuales',
|
'roadtrip.hazards.current': 'Alertas actuales',
|
||||||
'roadtrip.hazards.note': 'Las alertas no son cierres de carreteras. La ruta no cambia.',
|
'roadtrip.hazards.note': 'Las alertas no son cierres de carreteras. La ruta no cambia.',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
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.show': 'Afficher les zones à risque',
|
||||||
'roadtrip.hazards.current': 'Alertes actuelles',
|
'roadtrip.hazards.current': 'Alertes actuelles',
|
||||||
'roadtrip.hazards.note': 'Les alertes ne sont pas des fermetures de routes. Le trajet reste inchangé.',
|
'roadtrip.hazards.note': 'Les alertes ne sont pas des fermetures de routes. Le trajet reste inchangé.',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
const roadtrip: TranslationStrings = {
|
||||||
|
'roadtrip.import.title': 'Εισαγωγή διαδρομής Google Maps',
|
||||||
|
'roadtrip.import.note': 'Οι στάσεις προστίθενται στην επιλεγμένη ημέρα. Το TREK υπολογίζει ξανά τη διαδρομή.',
|
||||||
|
'roadtrip.import.unresolved': 'Η τοποθεσία δεν βρέθηκε· αυτή η στάση θα παραλειφθεί.',
|
||||||
|
'roadtrip.import.day': 'Ημέρα',
|
||||||
'roadtrip.hazards.show': 'Εμφάνιση επικίνδυνων περιοχών',
|
'roadtrip.hazards.show': 'Εμφάνιση επικίνδυνων περιοχών',
|
||||||
'roadtrip.hazards.current': 'Τρέχουσες προειδοποιήσεις',
|
'roadtrip.hazards.current': 'Τρέχουσες προειδοποιήσεις',
|
||||||
'roadtrip.hazards.note': 'Οι προειδοποιήσεις δεν σημαίνουν κλειστούς δρόμους. Η διαδρομή δεν αλλάζει.',
|
'roadtrip.hazards.note': 'Οι προειδοποιήσεις δεν σημαίνουν κλειστούς δρόμους. Η διαδρομή δεν αλλάζει.',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
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.show': 'Veszélyes területek megjelenítése',
|
||||||
'roadtrip.hazards.current': 'Aktuális figyelmeztetések',
|
'roadtrip.hazards.current': 'Aktuális figyelmeztetések',
|
||||||
'roadtrip.hazards.note': 'A figyelmeztetések nem útlezárások. Az útvonal változatlan marad.',
|
'roadtrip.hazards.note': 'A figyelmeztetések nem útlezárások. Az útvonal változatlan marad.',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
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.show': 'Tampilkan area bahaya',
|
||||||
'roadtrip.hazards.current': 'Peringatan terkini',
|
'roadtrip.hazards.current': 'Peringatan terkini',
|
||||||
'roadtrip.hazards.note': 'Peringatan bukan penutupan jalan. Rute tidak berubah.',
|
'roadtrip.hazards.note': 'Peringatan bukan penutupan jalan. Rute tidak berubah.',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
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.show': 'Mostra zone di pericolo',
|
||||||
'roadtrip.hazards.current': 'Avvisi attuali',
|
'roadtrip.hazards.current': 'Avvisi attuali',
|
||||||
'roadtrip.hazards.note': 'Gli avvisi non indicano strade chiuse. Il percorso non cambia.',
|
'roadtrip.hazards.note': 'Gli avvisi non indicano strade chiuse. Il percorso non cambia.',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
const roadtrip: TranslationStrings = {
|
||||||
|
'roadtrip.import.title': 'Google Mapsのルートをインポート',
|
||||||
|
'roadtrip.import.note': '立ち寄り先を選択した日に追加し、TREKでルートを再計算します。',
|
||||||
|
'roadtrip.import.unresolved': '場所が見つからないため、この立ち寄り先はスキップされます。',
|
||||||
|
'roadtrip.import.day': '日',
|
||||||
'roadtrip.hazards.show': '危険地域を表示',
|
'roadtrip.hazards.show': '危険地域を表示',
|
||||||
'roadtrip.hazards.current': '現在の警報',
|
'roadtrip.hazards.current': '現在の警報',
|
||||||
'roadtrip.hazards.note': '警報は通行止めを意味しません。ルートは変更されません。',
|
'roadtrip.hazards.note': '警報は通行止めを意味しません。ルートは変更されません。',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
const roadtrip: TranslationStrings = {
|
||||||
|
'roadtrip.import.title': 'Google Maps 경로 가져오기',
|
||||||
|
'roadtrip.import.note': '경유지를 선택한 날짜에 추가합니다. TREK이 경로를 다시 계산합니다.',
|
||||||
|
'roadtrip.import.unresolved': '위치를 찾을 수 없어 이 경유지를 건너뜁니다.',
|
||||||
|
'roadtrip.import.day': '일',
|
||||||
'roadtrip.hazards.show': '위험 지역 표시',
|
'roadtrip.hazards.show': '위험 지역 표시',
|
||||||
'roadtrip.hazards.current': '현재 경보',
|
'roadtrip.hazards.current': '현재 경보',
|
||||||
'roadtrip.hazards.note': '경보는 도로 폐쇄를 의미하지 않습니다. 경로는 변경되지 않습니다.',
|
'roadtrip.hazards.note': '경보는 도로 폐쇄를 의미하지 않습니다. 경로는 변경되지 않습니다.',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
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.show': 'Gevarengebieden tonen',
|
||||||
'roadtrip.hazards.current': 'Actuele waarschuwingen',
|
'roadtrip.hazards.current': 'Actuele waarschuwingen',
|
||||||
'roadtrip.hazards.note': 'Waarschuwingen zijn geen wegafsluitingen. De route blijft ongewijzigd.',
|
'roadtrip.hazards.note': 'Waarschuwingen zijn geen wegafsluitingen. De route blijft ongewijzigd.',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
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.show': 'Pokaż obszary zagrożeń',
|
||||||
'roadtrip.hazards.current': 'Aktualne ostrzeżenia',
|
'roadtrip.hazards.current': 'Aktualne ostrzeżenia',
|
||||||
'roadtrip.hazards.note': 'Ostrzeżenia nie oznaczają zamknięcia dróg. Trasa pozostaje bez zmian.',
|
'roadtrip.hazards.note': 'Ostrzeżenia nie oznaczają zamknięcia dróg. Trasa pozostaje bez zmian.',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
const roadtrip: TranslationStrings = {
|
||||||
|
'roadtrip.import.title': 'Импорт маршрута Google Maps',
|
||||||
|
'roadtrip.import.note': 'Остановки добавляются в выбранный день. TREK пересчитает маршрут.',
|
||||||
|
'roadtrip.import.unresolved': 'Место не найдено; эта остановка будет пропущена.',
|
||||||
|
'roadtrip.import.day': 'День',
|
||||||
'roadtrip.hazards.show': 'Показывать опасные зоны',
|
'roadtrip.hazards.show': 'Показывать опасные зоны',
|
||||||
'roadtrip.hazards.current': 'Текущие предупреждения',
|
'roadtrip.hazards.current': 'Текущие предупреждения',
|
||||||
'roadtrip.hazards.note': 'Предупреждения не означают закрытие дорог. Маршрут не меняется.',
|
'roadtrip.hazards.note': 'Предупреждения не означают закрытие дорог. Маршрут не меняется.',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
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.show': 'Visa riskområden',
|
||||||
'roadtrip.hazards.current': 'Aktuella varningar',
|
'roadtrip.hazards.current': 'Aktuella varningar',
|
||||||
'roadtrip.hazards.note': 'Varningar är inte vägavstängningar. Rutten ändras inte.',
|
'roadtrip.hazards.note': 'Varningar är inte vägavstängningar. Rutten ändras inte.',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
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.show': 'Tehlike bölgelerini göster',
|
||||||
'roadtrip.hazards.current': 'Güncel uyarılar',
|
'roadtrip.hazards.current': 'Güncel uyarılar',
|
||||||
'roadtrip.hazards.note': 'Uyarılar yol kapatmaları değildir. Rota değişmez.',
|
'roadtrip.hazards.note': 'Uyarılar yol kapatmaları değildir. Rota değişmez.',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
const roadtrip: TranslationStrings = {
|
||||||
|
'roadtrip.import.title': 'Імпорт маршруту Google Maps',
|
||||||
|
'roadtrip.import.note': 'Зупинки додаються до вибраного дня. TREK перерахує маршрут.',
|
||||||
|
'roadtrip.import.unresolved': 'Місце не знайдено; цю зупинку буде пропущено.',
|
||||||
|
'roadtrip.import.day': 'День',
|
||||||
'roadtrip.hazards.show': 'Показувати небезпечні зони',
|
'roadtrip.hazards.show': 'Показувати небезпечні зони',
|
||||||
'roadtrip.hazards.current': 'Поточні попередження',
|
'roadtrip.hazards.current': 'Поточні попередження',
|
||||||
'roadtrip.hazards.note': 'Попередження не означають закриття доріг. Маршрут не змінюється.',
|
'roadtrip.hazards.note': 'Попередження не означають закриття доріг. Маршрут не змінюється.',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
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.show': 'Hiển thị vùng nguy hiểm',
|
||||||
'roadtrip.hazards.current': 'Cảnh báo hiện tại',
|
'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.',
|
'roadtrip.hazards.note': 'Cảnh báo không có nghĩa là đường bị đóng. Tuyến đường không thay đổi.',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
const roadtrip: TranslationStrings = {
|
||||||
|
'roadtrip.import.title': '匯入 Google Maps 路線',
|
||||||
|
'roadtrip.import.note': '停靠點將新增至所選日期。TREK 將重新計算路線。',
|
||||||
|
'roadtrip.import.unresolved': '找不到位置;將略過此停靠點。',
|
||||||
|
'roadtrip.import.day': '天',
|
||||||
'roadtrip.hazards.show': '顯示危險區域',
|
'roadtrip.hazards.show': '顯示危險區域',
|
||||||
'roadtrip.hazards.current': '目前警報',
|
'roadtrip.hazards.current': '目前警報',
|
||||||
'roadtrip.hazards.note': '警報不代表道路封閉。路線保持不變。',
|
'roadtrip.hazards.note': '警報不代表道路封閉。路線保持不變。',
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import type { TranslationStrings } from '../types';
|
import type { TranslationStrings } from '../types';
|
||||||
|
|
||||||
const roadtrip: TranslationStrings = {
|
const roadtrip: TranslationStrings = {
|
||||||
|
'roadtrip.import.title': '导入 Google Maps 路线',
|
||||||
|
'roadtrip.import.note': '停靠点将添加到所选日期。TREK 将重新计算路线。',
|
||||||
|
'roadtrip.import.unresolved': '未找到位置;将跳过此停靠点。',
|
||||||
|
'roadtrip.import.day': '天',
|
||||||
'roadtrip.hazards.show': '显示危险区域',
|
'roadtrip.hazards.show': '显示危险区域',
|
||||||
'roadtrip.hazards.current': '当前警报',
|
'roadtrip.hazards.current': '当前警报',
|
||||||
'roadtrip.hazards.note': '警报不代表道路封闭。路线保持不变。',
|
'roadtrip.hazards.note': '警报不代表道路封闭。路线保持不变。',
|
||||||
|
|||||||
@@ -79,3 +79,5 @@ export * from './plugins/plugins.schema';
|
|||||||
export * from './roadtrip/preferences.schema';
|
export * from './roadtrip/preferences.schema';
|
||||||
export * from './roadtrip/hazards.schema';
|
export * from './roadtrip/hazards.schema';
|
||||||
export * from './roadtrip/planning.schema';
|
export * from './roadtrip/planning.schema';
|
||||||
|
export * from './roadtrip/search.schema';
|
||||||
|
export * from './roadtrip/google-import.schema';
|
||||||
|
|||||||
@@ -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<typeof googleRouteImportSchema>;
|
||||||
|
export type GoogleRoutePreview = z.infer<typeof googleRoutePreviewSchema>;
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof roadtripSearchAreaSchema>;
|
||||||
|
|
||||||
|
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<typeof roadtripSearchAreaResponseSchema>;
|
||||||
@@ -243,3 +243,7 @@ Roadtrip also exposes `get_roadtrip_hazards` (`tripId`, trips read scope). It re
|
|||||||
- [MCP-Scopes](MCP-Scopes)
|
- [MCP-Scopes](MCP-Scopes)
|
||||||
- [MCP-Prompts](MCP-Prompts)
|
- [MCP-Prompts](MCP-Prompts)
|
||||||
- [MCP-Setup](MCP-Setup)
|
- [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.
|
||||||
|
|||||||
@@ -105,3 +105,11 @@ Driving settings includes **Show hazard areas**, off by default and shared by th
|
|||||||
- [Admin-Addons](Admin-Addons)
|
- [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`.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user