diff --git a/client/src/api/client.ts b/client/src/api/client.ts index cf6cdc845..11d75ea5b 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1,6 +1,7 @@ import axios, { AxiosInstance } from 'axios' import type { z } from 'zod' import type { Place } from '../types' +import type { TransitProvider } from '@trek/shared' import { randomId } from '../utils/randomId' import { weatherResultSchema, type WeatherResult, @@ -652,6 +653,8 @@ export const adminApi = { updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data), getPlacesEnrich: () => apiClient.get('/admin/places-enrich').then(r => r.data), updatePlacesEnrich: (enabled: boolean) => apiClient.put('/admin/places-enrich', { enabled }).then(r => r.data), + getTransitProvider: () => apiClient.get('/admin/transit-provider').then(r => r.data), + updateTransitProvider: (provider: TransitProvider) => apiClient.put('/admin/transit-provider', { provider }).then(r => r.data), getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data), updateCollabFeatures: (features: Record) => apiClient.put('/admin/collab-features', features).then(r => r.data), packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data), @@ -1230,11 +1233,13 @@ export const shareApi = { getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data), } -// Public transit routing (#1065) — Transitous/MOTIS proxied through the server. +// Public transit routing (#1065) — proxied through the server, which picks the +// backend (Transitous/MOTIS, or Google since #1699). `lang` reaches the Google +// backend as the languageCode, so station names come back in the user's script. export const transitApi = { geocode: (q: string, opts?: { lang?: string; near?: string }) => apiClient.get('/transit/geocode', { params: { q, lang: opts?.lang, near: opts?.near } }).then(r => r.data), - plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number }) => + plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number; lang?: string }) => apiClient.get('/transit/plan', { params }).then(r => r.data), } diff --git a/client/src/components/Planner/TransitSearchPanel.test.tsx b/client/src/components/Planner/TransitSearchPanel.test.tsx index eb2296a0c..c0fa35c30 100644 --- a/client/src/components/Planner/TransitSearchPanel.test.tsx +++ b/client/src/components/Planner/TransitSearchPanel.test.tsx @@ -175,6 +175,29 @@ describe('TransitSearchPanel', () => { await user.click(await screen.findByText(/London Victoria/)) } + /** + * An empty result is ambiguous without the backend's name: the fallback to + * Transitous is silent, so "no connections" could mean the provider has no + * data here or that the provider you picked never ran (#1699). + */ + it('FE-PLANNER-TRANSIT-006b: the empty state names the backend that answered', async () => { + const user = userEvent.setup() + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [], provider: 'transitous' }) + render() + await pickFromAndLondon(user) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + expect(await screen.findByText(/No connections found via Transitous/)).toBeInTheDocument() + }) + + it('FE-PLANNER-TRANSIT-006c: falls back to the unnamed message when the server sends no provider', async () => { + const user = userEvent.setup() + transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] }) + render() + await pickFromAndLondon(user) + await user.click(screen.getByRole('button', { name: /^Search$/ })) + expect(await screen.findByText(/^No connections found\./)).toBeInTheDocument() + }) + it('FE-PLANNER-TRANSIT-007: depart-by anchors the entered time to the origin timezone', async () => { const user = userEvent.setup() transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] }) diff --git a/client/src/components/Planner/TransitSearchPanel.tsx b/client/src/components/Planner/TransitSearchPanel.tsx index e08f2534a..15c2ba216 100644 --- a/client/src/components/Planner/TransitSearchPanel.tsx +++ b/client/src/components/Planner/TransitSearchPanel.tsx @@ -8,6 +8,7 @@ import { useSettingsStore } from '../../store/settingsStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import type { Day, Place, Accommodation } from '../../types' +import type { TransitProvider } from '@trek/shared' /** * Public transit route search (#1065), backed by Transitous (MOTIS) through the @@ -36,6 +37,14 @@ interface TransitPlaceResult { name: string; lat: number; lng: number; type: str export interface PickedPlace { name: string; lat: number; lng: number } +// Backend names as they are written, not translated — the empty state says +// which one answered so "nothing here" can be told apart from "not the one you +// picked" (#1699). +const PROVIDER_NAMES: Record = { + transitous: 'Transitous', + google: 'Google', +} + // ── helpers ────────────────────────────────────────────────────────────────── const MODE_GROUPS: { key: string; labelKey: string; Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; modes: string }[] = [ @@ -340,7 +349,7 @@ interface TransitSearchPanelProps { } export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null }: TransitSearchPanelProps) { - const { t } = useTranslation() + const { t, language } = useTranslation() const toast = useToast() const is12h = useSettingsStore(s => s.settings.time_format) === '12h' const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 @@ -355,6 +364,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations = const [loading, setLoading] = useState(false) const [expandedIdx, setExpandedIdx] = useState(null) const [addingIdx, setAddingIdx] = useState(null) + const [provider, setProvider] = useState(null) // Quick picks: the day's located places, plus the trip's located accommodations. const quickPicks = useMemo(() => { @@ -387,6 +397,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations = setLoading(true) setItineraries(null) setExpandedIdx(null) + setProvider(null) try { const tzFrom = tzAt(from.lat, from.lng) const tzTo = tzAt(to.lat, to.lng) @@ -395,7 +406,11 @@ export default function TransitSearchPanel({ day, days, places, accommodations = const timeIso = localToUtcIso(day.date, time, arriveBy ? tzTo : tzFrom) const allModes = activeModes.size === MODE_GROUPS.length const modes = allModes ? undefined : MODE_GROUPS.filter(m => activeModes.has(m.key)).map(m => m.modes).join(',') - const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes }) + const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes, lang: language }) + // Which backend actually answered (#1699). Named in the empty state so + // "nothing found" can be told apart from "the provider you picked never + // ran" — the fallback to Transitous is silent by design. + setProvider(d.provider ?? null) // MOTIS names the request coordinates START/END — swap in the places the // user actually picked so walks read "Walk to Zoologischer Garten". const cleanStop = (n: string) => (n === 'START' ? from.name : n === 'END' ? to.name : n) @@ -606,7 +621,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations = )} {!loading && ranked && ranked.length === 0 && (
- {t('transit.noResults')} + {provider + ? t('transit.noResultsVia', { provider: PROVIDER_NAMES[provider] }) + : t('transit.noResults')}
)} {!loading && ranked && ranked.length > 0 && ( diff --git a/client/src/pages/AdminPage.test.tsx b/client/src/pages/AdminPage.test.tsx index c4b64da8b..8c2fd0a5e 100644 --- a/client/src/pages/AdminPage.test.tsx +++ b/client/src/pages/AdminPage.test.tsx @@ -517,6 +517,65 @@ describe('AdminPage', () => { }); }); + describe('FE-PAGE-ADMIN-023b: Transit provider select in Settings tab (#1699)', () => { + it('choosing Google calls PUT /api/admin/transit-provider', async () => { + let capturedBody: Record | null = null; + server.use( + http.get('/api/admin/transit-provider', () => + HttpResponse.json({ provider: 'transitous', googleKeySource: 'instance' })), + http.put('/api/admin/transit-provider', async ({ request }) => { + capturedBody = (await request.json()) as Record; + return HttpResponse.json({ provider: 'google', googleKeySource: 'instance' }); + }) + ); + + seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); + render(); + + await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /settings/i })); + + const select = await screen.findByRole('combobox', { name: /transit provider/i }); + await waitFor(() => expect(select).toHaveValue('transitous')); + fireEvent.change(select, { target: { value: 'google' } }); + + await waitFor(() => expect(capturedBody).toEqual({ provider: 'google' })); + expect(select).toHaveValue('google'); + }); + }); + + describe('FE-PAGE-ADMIN-023c: Transit provider key warnings (#1699)', () => { + async function openSettingsWith(googleKeySource: string | null) { + server.use( + http.get('/api/admin/transit-provider', () => + HttpResponse.json({ provider: 'google', googleKeySource })), + ); + seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); + render(); + await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /settings/i })); + await screen.findByRole('combobox', { name: /transit provider/i }); + } + + it('warns that Google is selected but no key is configured', async () => { + await openSettingsWith(null); + expect(await screen.findByText(/no google api key is configured/i)).toBeInTheDocument(); + }); + + it('warns that only the admin\'s own key is set, so others fall back', async () => { + await openSettingsWith('user-row'); + expect(await screen.findByText(/only your own google key is set/i)).toBeInTheDocument(); + }); + + it('stays quiet when an instance-wide key resolves', async () => { + await openSettingsWith('instance'); + await waitFor(() => + expect(screen.getByRole('combobox', { name: /transit provider/i })).toHaveValue('google')); + expect(screen.queryByText(/no google api key is configured/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/only your own google key is set/i)).not.toBeInTheDocument(); + }); + }); + describe('FE-PAGE-ADMIN-024: JWT rotation modal opens from Danger Zone', () => { it('clicking Rotate in Danger Zone opens the JWT rotation confirmation modal', async () => { seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); diff --git a/client/src/pages/admin/AdminSettingsTab.tsx b/client/src/pages/admin/AdminSettingsTab.tsx index fa164d6f3..adcb4371f 100644 --- a/client/src/pages/admin/AdminSettingsTab.tsx +++ b/client/src/pages/admin/AdminSettingsTab.tsx @@ -21,6 +21,8 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R placesAutocompleteEnabled, setPlacesAutocompleteEnabledState, placesDetailsEnabled, setPlacesDetailsEnabledState, placesEnrichEnabled, setPlacesEnrichEnabledState, + transitProvider, setTransitProviderState, + transitGoogleKeySource, setTransitGoogleKeySource, oidcConfig, setOidcConfig, savingOidc, setSavingOidc, passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration, oidcLogin, setOidcLogin, oidcRegistration, setOidcRegistration, @@ -420,6 +422,52 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R /> + {/* Transit Backend (#1699) — Transitous has no GTFS for much of Asia, + so an install can point transit search at Google instead, on the + same key. Falls back to Transitous when no key resolves. */} +
+

{t('admin.transitProvider.title')}

+

{t('admin.transitProvider.subtitle')}

+ +

+ {transitProvider === 'google' ? t('admin.transitProvider.googleHint') : t('admin.transitProvider.transitousHint')} +

+ + {/* Picking Google without a key that resolves changes nothing — the + request-time fallback is silent, so this is the only place it can + be said. 'user-row' is the subtler half: the resolver's last step + is the caller's own row, so a personal key serves this admin and + nobody else. */} + {transitProvider === 'google' && transitGoogleKeySource === null && ( +

+ + {t('admin.transitProvider.noKeyWarning')} +

+ )} + {transitProvider === 'google' && transitGoogleKeySource === 'user-row' && ( +

+ + {t('admin.transitProvider.personalKeyWarning')} +

+ )} +
+ {/* Open-Meteo Weather Info */}
diff --git a/client/src/pages/admin/useAdmin.ts b/client/src/pages/admin/useAdmin.ts index bc03d9ee4..1a42adf96 100644 --- a/client/src/pages/admin/useAdmin.ts +++ b/client/src/pages/admin/useAdmin.ts @@ -9,6 +9,7 @@ import { getApiErrorMessage } from '../../types' import { useToast } from '../../components/shared/Toast' import { managedAdminTabs } from '../../managed' import type { AdminUser, AdminStats, OidcConfig, UpdateInfo } from './adminModel' +import type { TransitProvider, TransitKeySource } from '@trek/shared' /** * Every tab id AdminPage can render a panel for, whatever this install offers. @@ -99,6 +100,17 @@ export function useAdmin() { useEffect(() => { adminApi.getPlacesDetails().then(d => setPlacesDetailsEnabledState(d.enabled)).catch(() => {}) }, []) useEffect(() => { adminApi.getPlacesEnrich().then(d => setPlacesEnrichEnabledState(d.enabled)).catch(() => {}) }, []) + // Transit backend (#1699). googleKeySource says where a Google key would come + // from for this admin — null means picking Google changes nothing, since the + // request-time fallback to Transitous is silent by design. + const [transitProvider, setTransitProviderState] = useState('transitous') + const [transitGoogleKeySource, setTransitGoogleKeySource] = useState(null) + useEffect(() => { + adminApi.getTransitProvider() + .then(d => { setTransitProviderState(d.provider); setTransitGoogleKeySource(d.googleKeySource) }) + .catch(() => {}) + }, []) + // Collab features const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, polls: true, whatsnext: true }) useEffect(() => { adminApi.getCollabFeatures().then(d => setCollabFeatures(d)).catch(() => {}) }, []) @@ -430,6 +442,8 @@ export function useAdmin() { placesAutocompleteEnabled, setPlacesAutocompleteEnabledState, placesDetailsEnabled, setPlacesDetailsEnabledState, placesEnrichEnabled, setPlacesEnrichEnabledState, + transitProvider, setTransitProviderState, + transitGoogleKeySource, setTransitGoogleKeySource, collabFeatures, setCollabFeatures, oidcConfig, setOidcConfig, savingOidc, setSavingOidc, passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration, diff --git a/server/src/nest/addons/addons.service.ts b/server/src/nest/addons/addons.service.ts index 173950cbe..f0d05f055 100644 --- a/server/src/nest/addons/addons.service.ts +++ b/server/src/nest/addons/addons.service.ts @@ -3,6 +3,10 @@ import { ADDON_IDS } from '../../addons'; import { DatabaseService } from '../database/database.service'; import type { Addon } from '../../types'; import { getPhotoProviderConfig } from '../memories/memories.helpers'; +import { readTransitProvider, writeTransitProvider } from '../transit/transit-provider'; +import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys'; +import { readEnv } from '../../app-config'; +import type { TransitProvider } from '@trek/shared'; /** * Thin wrapper around the enabled-addons + photo-provider read that the legacy @@ -203,4 +207,35 @@ export class AddonsService { } updatePlacesEnrich(enabled: boolean) { return this.writeFlag('places_enrich_enabled', enabled); } + + // ── Transit backend (#1699) ──────────────────────────────────────────────── + // Not a flag: two named backends, so it stores the name rather than a + // boolean. The read/write pair lives in transit/transit-provider.ts because + // TransitService reads the same row on every request — one key, one reader, + // one writer. + + /** + * Where the Google key would come from for this caller, or null if nowhere. + * + * Reported alongside the provider so the admin panel can say that picking + * Google changed nothing. The fallback to Transitous is deliberate and + * silent at request time (GoogleTransitProvider.isActive), which means the + * only place it can be surfaced is here, before a search is ever run. + * + * The distinction between 'instance' and 'user-row' is the one that matters: + * the resolver's last step is the caller's OWN row, so an admin holding a + * personal key gets Google while every other member silently gets Transitous + * — the #1939 shape, one layer up. + */ + private googleKeySource(userId: number): ApiKeySource | null { + return resolveApiKey(this.dbs, 'maps_api_key', userId, readEnv().maps.placesApiKey).source; + } + + getTransitProvider(userId = 0) { + return { provider: readTransitProvider(this.dbs), googleKeySource: this.googleKeySource(userId) }; + } + + updateTransitProvider(provider: TransitProvider, userId = 0) { + return { provider: writeTransitProvider(this.dbs, provider), googleKeySource: this.googleKeySource(userId) }; + } } diff --git a/server/src/nest/admin/admin.controller.ts b/server/src/nest/admin/admin.controller.ts index c6dd18354..22b1f773d 100644 --- a/server/src/nest/admin/admin.controller.ts +++ b/server/src/nest/admin/admin.controller.ts @@ -19,6 +19,7 @@ import { AdminNotificationPreferencesDto, AdminDefaultUserSettingsDto, AdminTestNotificationDto, + AdminTransitProviderDto, } from './admin.dto'; import { PluginRuntimeService } from '../plugins/plugin-runtime.service'; import { AddonsService } from '../addons/addons.service'; @@ -255,6 +256,18 @@ export class AdminController { return features; } + // Which backend answers /api/transit (#1699). Transitous unless an admin + // says otherwise, and still Transitous if Google is picked without a key. + @Get('transit-provider') + getTransitProvider(@CurrentUser() user: User) { return this.addons.getTransitProvider(user.id); } + + @Put('transit-provider') + updateTransitProvider(@CurrentUser() user: User, @Body() body: AdminTransitProviderDto, @Req() req: Request) { + const result = this.addons.updateTransitProvider(body.provider, user.id); + this.audit.writeAudit({ userId: user.id, action: 'admin.transit_provider', ip: getClientIp(req), details: { provider: result.provider } }); + return result; + } + // ── Addons ── @Get('addons') listAddons() { return { addons: this.admin.listAddons() }; } diff --git a/server/src/nest/admin/admin.dto.ts b/server/src/nest/admin/admin.dto.ts index d795ba011..73ce83438 100644 --- a/server/src/nest/admin/admin.dto.ts +++ b/server/src/nest/admin/admin.dto.ts @@ -12,6 +12,7 @@ import { adminNotificationPreferencesRequestSchema, adminDefaultUserSettingsRequestSchema, adminTestNotificationRequestSchema, + adminTransitProviderRequestSchema, } from '@trek/shared'; /** @@ -20,7 +21,7 @@ import { * parameter typed with one of these classes by metatype — the Zod schemas in * shared/ remain the single source of truth for the wire contract. * - * Twelve classes cover the twenty grandfathered AdminController body contracts: + * Thirteen classes cover the AdminController body contracts: * the four feature toggles share AdminFeatureToggleDto and the six * packing-template create/update routes share AdminTemplateNameDto. */ @@ -36,3 +37,4 @@ export class AdminCollabFeaturesDto extends createZodDto(adminCollabFeaturesRequ export class AdminNotificationPreferencesDto extends createZodDto(adminNotificationPreferencesRequestSchema) {} export class AdminDefaultUserSettingsDto extends createZodDto(adminDefaultUserSettingsRequestSchema) {} export class AdminTestNotificationDto extends createZodDto(adminTestNotificationRequestSchema) {} +export class AdminTransitProviderDto extends createZodDto(adminTransitProviderRequestSchema) {} diff --git a/server/src/nest/settings/instance-api-keys.ts b/server/src/nest/settings/instance-api-keys.ts index a5c4d4bd9..082c67dd2 100644 --- a/server/src/nest/settings/instance-api-keys.ts +++ b/server/src/nest/settings/instance-api-keys.ts @@ -1,5 +1,6 @@ import { DatabaseService } from '../database/database.service'; import { decrypt_api_key, maybe_encrypt_api_key } from '../common/crypto/apiKeyCrypto'; +import type { ApiKeySource } from '@trek/shared'; /** * The third-party keys that belong to the instance rather than to a person. @@ -26,8 +27,11 @@ export const INSTANCE_API_KEY_NAMES: readonly InstanceApiKeyName[] = ['maps_api_ * Where a resolved key came from. Logged beside a provider error so "works for * the admin, 403 for everyone else" is one line in the log rather than a * guessing game; the key itself is never logged. + * + * Defined in @trek/shared because the admin transit-provider response (#1699) + * puts it on the wire — re-declaring the union here would fork the contract. */ -export type ApiKeySource = 'operator-env' | 'instance' | 'user-row'; +export type { ApiKeySource }; // Full statements rather than an interpolated column: the name doubles as the // users column AND the app_settings key, and identifiers only ever come from a diff --git a/server/src/nest/transit/google-transit.provider.ts b/server/src/nest/transit/google-transit.provider.ts new file mode 100644 index 000000000..d4fba0e2c --- /dev/null +++ b/server/src/nest/transit/google-transit.provider.ts @@ -0,0 +1,541 @@ +import { Injectable } from '@nestjs/common'; +import { readEnv } from '../../app-config'; +import { DatabaseService } from '../database/database.service'; +import { toApiLang } from '../maps/maps.helpers'; +import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys'; +import { readTransitProvider } from './transit-provider'; +import { + decodePolyline, + deriveTransitStats, + encodePolyline, + type PlanQuery, + type TransitItinerary, + type TransitLeg, + type TransitLegStop, + type TransitPlace, +} from './transit.helpers'; + +/** + * Google as the transit backend (#1699), for the regions Transitous has no GTFS + * for — most of Asia, where a search between two Osaka subway stops returns + * nothing because the stops are not in the feed at all. + * + * Two calls stand behind the two /api/transit routes: Places Text Search for + * the from/to picker, and the Routes API for the plan. The Routes API, not the + * legacy Directions API the request named: every other Google call in this repo + * already speaks the v1/X-Goog-Api-Key family, and Google closed the legacy + * endpoint to Cloud projects created after March 2025 — a new self-hoster + * cannot enable it. + * + * The key is the one the install already has (`maps_api_key`, operator env → + * instance → the caller's own row), so nothing new to configure. + * + * Cost is the design constraint, because unlike Transitous this one bills: + * + * - Both field masks are cut to the cheapest SKU tier that still answers the + * question. The picker deliberately does NOT reuse maps.helpers' + * SEARCH_TEXT_FIELD_MASK: its rating/website/phone fields bill Text Search at + * Enterprise, and a station picker shows none of them. + * - The cache below is much longer-lived than TransitService's 60s one. A + * station's coordinates do not move, and transit search here plans trips days + * out rather than catching the next train, so minutes-old departure times are + * the right trade for not paying twice. + * - `computeAlternativeRoutes` is on: alternatives ride along in the one + * request that was already billed. + */ + +const ROUTES_ENDPOINT = 'https://routes.googleapis.com/directions/v2:computeRoutes'; +const PLACES_SEARCH_ENDPOINT = 'https://places.googleapis.com/v1/places:searchText'; + +// Text Search at Pro: names, address, coordinates and types, and nothing that +// would push the call into the Enterprise tier. +const STATION_FIELD_MASK = 'places.displayName,places.formattedAddress,places.location,places.types'; + +// Routes at Pro — steps and transitDetails are what a transit itinerary IS, so +// the tier is unavoidable; everything beyond it (travelAdvisory, viewport, +// localizedValues) is left out. +const ROUTES_FIELD_MASK = [ + 'routes.legs.steps.travelMode', + 'routes.legs.steps.staticDuration', + 'routes.legs.steps.distanceMeters', + 'routes.legs.steps.startLocation.latLng', + 'routes.legs.steps.endLocation.latLng', + 'routes.legs.steps.polyline.encodedPolyline', + 'routes.legs.steps.transitDetails.stopDetails', + 'routes.legs.steps.transitDetails.headsign', + 'routes.legs.steps.transitDetails.stopCount', + 'routes.legs.steps.transitDetails.transitLine.name', + 'routes.legs.steps.transitDetails.transitLine.nameShort', + 'routes.legs.steps.transitDetails.transitLine.color', + 'routes.legs.steps.transitDetails.transitLine.textColor', + 'routes.legs.steps.transitDetails.transitLine.agencies.name', + 'routes.legs.steps.transitDetails.transitLine.vehicle.type', +].join(','); + +const TIMEOUT_MS = 10_000; +const MAX_RESPONSE_BYTES = 5_000_000; + +// Google encodes polylines at precision 5; MOTIS uses 6. The client reads the +// precision off the leg, so both render correctly. +const GOOGLE_POLYLINE_PRECISION = 5; + +const GEOCODE_TTL = 30 * 60 * 1000; +const PLAN_TTL = 5 * 60 * 1000; +const CACHE_MAX = 200; + +const cache = new Map(); + +function cacheGet(key: string): unknown | null { + const hit = cache.get(key); + if (!hit) return null; + if (Date.now() - hit.at > hit.ttl) { + cache.delete(key); + return null; + } + cache.delete(key); + cache.set(key, hit); + return hit.data; +} + +function cacheSet(key: string, ttl: number, data: unknown): void { + if (cache.size >= CACHE_MAX) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } + cache.set(key, { at: Date.now(), ttl, data }); +} + +/** Exposed for tests — the cache is module-scoped for the same reason TransitService's is. */ +export function clearGoogleTransitCache(): void { + cache.clear(); +} + +/** + * What a caller may filter by → what `transitPreferences.allowedTravelModes` + * can express. Google has five buckets; TREK offers thirteen, so ferry, cable + * and the fine-grained rail splits have no wire equivalent and are dropped from + * the request and enforced on the response instead (see `matchesRequestedModes`). + */ +const GOOGLE_TRAVEL_MODES: Record = { + BUS: 'BUS', + COACH: 'BUS', + SUBWAY: 'SUBWAY', + TRAM: 'LIGHT_RAIL', + RAIL: 'TRAIN', + HIGHSPEED_RAIL: 'TRAIN', + LONG_DISTANCE: 'TRAIN', + NIGHT_RAIL: 'TRAIN', + REGIONAL_RAIL: 'TRAIN', + SUBURBAN: 'TRAIN', +}; + +/** Google's vehicle taxonomy → the mode tokens the client already has icons for. */ +const VEHICLE_MODES: Record = { + BUS: 'BUS', + INTERCITY_BUS: 'COACH', + TROLLEYBUS: 'BUS', + SHARE_TAXI: 'BUS', + TRAM: 'TRAM', + CABLE_CAR: 'TRAM', + SUBWAY: 'SUBWAY', + METRO_RAIL: 'SUBWAY', + MONORAIL: 'SUBWAY', + COMMUTER_TRAIN: 'SUBURBAN', + HEAVY_RAIL: 'RAIL', + RAIL: 'RAIL', + HIGH_SPEED_TRAIN: 'HIGHSPEED_RAIL', + LONG_DISTANCE_TRAIN: 'LONG_DISTANCE', + FERRY: 'FERRY', + FUNICULAR: 'FUNICULAR', + GONDOLA_LIFT: 'AERIAL_LIFT', +}; + +/** '600s' → 600. Google's Duration JSON encoding; anything else is 0. */ +function parseDuration(value: unknown): number { + if (typeof value !== 'string') return 0; + const seconds = Number(value.replace(/s$/, '')); + return Number.isFinite(seconds) && seconds > 0 ? Math.round(seconds) : 0; +} + +function safeColor(v: unknown): string | null { + if (typeof v !== 'string') return null; + const hex = v.trim().replace(/^#/, ''); + return /^[0-9a-fA-F]{6}$/.test(hex) || /^[0-9a-fA-F]{3}$/.test(hex) ? `#${hex}` : null; +} + +function parseCoord(value: string): { latitude: number; longitude: number } { + const [lat, lng] = value.split(',').map(Number); + return { latitude: lat, longitude: lng }; +} + +function isoOrNull(value: unknown): string | null { + if (typeof value !== 'string' || !value) return null; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString(); +} + +function shiftIso(iso: string, seconds: number): string { + return new Date(new Date(iso).getTime() + seconds * 1000).toISOString(); +} + +interface GoogleLatLng { latitude?: number; longitude?: number } +interface GoogleStop { name?: string; location?: { latLng?: GoogleLatLng } } +interface GoogleStep { + travelMode?: string; + staticDuration?: string; + distanceMeters?: number; + startLocation?: { latLng?: GoogleLatLng }; + endLocation?: { latLng?: GoogleLatLng }; + polyline?: { encodedPolyline?: string }; + transitDetails?: { + stopDetails?: { + departureStop?: GoogleStop; + arrivalStop?: GoogleStop; + departureTime?: string; + arrivalTime?: string; + }; + headsign?: string; + stopCount?: number; + transitLine?: { + name?: string; + nameShort?: string; + color?: string; + textColor?: string; + agencies?: Array<{ name?: string }>; + vehicle?: { type?: string }; + }; + }; +} + +function stopFrom(stop: GoogleStop | undefined, fallback: GoogleLatLng | undefined, name: string): TransitLegStop { + return { + name: stop?.name || name, + + lat: stop?.location?.latLng?.latitude ?? fallback?.latitude ?? 0, + lng: stop?.location?.latLng?.longitude ?? fallback?.longitude ?? 0, + time: null, + scheduledTime: null, + // The Routes API carries no platform/track field — MOTIS's `track` has no + // Google equivalent, so it stays null rather than being faked from headsign. + track: null, + }; +} + +@Injectable() +export class GoogleTransitProvider { + constructor(private readonly database: DatabaseService) {} + + private resolveKey(userId: number): { key: string | null; source: ApiKeySource | null } { + return resolveApiKey(this.database, 'maps_api_key', userId, readEnv().maps.placesApiKey); + } + + /** + * True only when the admin picked Google AND a key actually resolves for this + * caller. "Fall back to Transitous if no Google key is set" is the requested + * behaviour and the safe one: the alternative is every transit search 403ing + * on an install that flipped the switch before pasting a key. + */ + isActive(userId: number): boolean { + if (readTransitProvider(this.database) !== 'google') return false; + return !!this.resolveKey(userId).key; + } + + private async call(endpoint: string, label: string, apiKey: string, body: unknown, fieldMask: string): Promise { + console.debug(`[Google API] ${label} → ${endpoint}`); + const res = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': apiKey, + 'X-Goog-FieldMask': fieldMask, + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + + const length = Number(res.headers?.get('content-length') ?? 0); + if (length > MAX_RESPONSE_BYTES) { + const err = new Error('Transit provider error (response too large)') as Error & { status: number }; + err.status = 502; + throw err; + } + + const data = (await res.json()) as { error?: { message?: string } }; + if (!res.ok) { + const err = new Error(data?.error?.message || `Transit provider error (HTTP ${res.status})`) as Error & { + status: number; + }; + err.status = res.status === 429 ? 429 : 502; + throw err; + } + return data; + } + + /** Station/place search for the from/to pickers. `near` biases results. */ + async geocode(text: string, language: string | undefined, near: string | undefined, userId: number): Promise<{ results: TransitPlace[] }> { + const { key: apiKey, source } = this.resolveKey(userId); + if (!apiKey) { + const err = new Error('Transit provider error (no Google API key configured)') as Error & { status: number }; + err.status = 502; + throw err; + } + + const key = `g:geo:${text}|${language ?? ''}|${near ?? ''}`; + const cached = cacheGet(key); + if (cached) return cached as { results: TransitPlace[] }; + + const body: Record = { + textQuery: text, + languageCode: toApiLang(language), + // Capped at what the picker renders — the response is smaller and the + // request bills the same either way. + pageSize: 8, + }; + if (near) { + const { latitude, longitude } = parseCoord(near); + body.locationBias = { circle: { center: { latitude, longitude }, radius: 50000 } }; + } + + let data: { places?: Array<{ displayName?: { text?: string }; formattedAddress?: string; location?: GoogleLatLng; types?: string[] }> }; + try { + data = (await this.call(PLACES_SEARCH_ENDPOINT, 'transitGeocode', apiKey, body, STATION_FIELD_MASK)) as typeof data; + } catch (err) { + console.error(`[Transit] google geocode failed userId=${userId} keySource=${source}`); + throw err; + } + + const results: TransitPlace[] = (data.places || []).slice(0, 8).flatMap((place) => { + const lat = place.location?.latitude; + const lng = place.location?.longitude; + const name = place.displayName?.text; + if (typeof lat !== 'number' || typeof lng !== 'number' || !name) return []; + // MOTIS answers STOP for a station and PLACE for anything else; the picker + // renders the two differently, so map Google's type list onto the same pair. + const isStop = (place.types || []).some((t) => t.includes('station') || t.includes('transit') || t.includes('stop')); + return [{ name, lat, lng, type: isStop ? 'STOP' : 'PLACE', area: place.formattedAddress || null }]; + }); + + const payload = { results }; + cacheSet(key, GEOCODE_TTL, payload); + return payload; + } + + /** Route search between two coordinates. Returns the same compact shape MOTIS is mapped to. */ + async plan(q: PlanQuery, language: string | undefined, userId: number): Promise<{ itineraries: TransitItinerary[] }> { + const { key: apiKey, source } = this.resolveKey(userId); + if (!apiKey) { + const err = new Error('Transit provider error (no Google API key configured)') as Error & { status: number }; + err.status = 502; + throw err; + } + + const requested = (q.modes || '') + .split(',') + .map((m) => m.trim().toUpperCase()) + .filter(Boolean) + .filter((m) => m !== 'TRANSIT'); + + const body: Record = { + origin: { location: { latLng: parseCoord(q.from) } }, + destination: { location: { latLng: parseCoord(q.to) } }, + travelMode: 'TRANSIT', + // Free extra options inside a request that is already billed. + computeAlternativeRoutes: true, + languageCode: toApiLang(language), + }; + + if (q.time) { + body[q.arriveBy ? 'arrivalTime' : 'departureTime'] = new Date(q.time).toISOString(); + } + + const transitPreferences: Record = {}; + const allowed = [...new Set(requested.map((m) => GOOGLE_TRAVEL_MODES[m]).filter(Boolean))]; + if (allowed.length > 0) transitPreferences.allowedTravelModes = allowed; + // Google has no maxTransfers. Asking it to prefer fewer transfers makes the + // one billed call likelier to return something the filter below keeps. + if (typeof q.maxTransfers === 'number' && q.maxTransfers <= 1) { + transitPreferences.routingPreference = 'FEWER_TRANSFERS'; + } + if (Object.keys(transitPreferences).length > 0) body.transitPreferences = transitPreferences; + + const key = `g:plan:${JSON.stringify(body)}`; + const cached = cacheGet(key); + if (cached) return cached as { itineraries: TransitItinerary[] }; + + let data: { routes?: Array<{ legs?: Array<{ steps?: GoogleStep[] }> }> }; + try { + data = (await this.call(ROUTES_ENDPOINT, 'transitPlan', apiKey, body, ROUTES_FIELD_MASK)) as typeof data; + } catch (err) { + console.error(`[Transit] google plan failed userId=${userId} keySource=${source}`); + throw err; + } + + const itineraries = (data.routes || []) + .flatMap((route) => { + const steps = (route.legs || []).flatMap((leg) => leg.steps || []); + const built = buildItinerary(steps); + return built ? [built] : []; + }) + .filter((it) => matchesRequestedModes(it, requested)) + .filter((it) => q.maxTransfers === undefined || q.maxTransfers === null || it.transfers <= q.maxTransfers) + .slice(0, 8); + + const payload = { itineraries }; + cacheSet(key, PLAN_TTL, payload); + return payload; + } +} + +/** + * Steps → one itinerary, or null when there is nothing scheduled in it. + * + * A walk-only route is dropped for the same reason the MOTIS call passes + * `directModes=WALK`: footpath routing is already OSRM's job, and a walking + * "itinerary" in the transit picker is noise. + */ +function buildItinerary(steps: GoogleStep[]): TransitItinerary | null { + if (steps.length === 0) return null; + + const legs: TransitLeg[] = steps.map((step, index) => { + const details = step.transitDetails; + const line = details?.transitLine; + const vehicle = line?.vehicle?.type; + const isFirst = index === 0; + const isLast = index === steps.length - 1; + + // MOTIS labels the journey's own endpoints START/END and both the web + // client and the MCP itinerary builder swap the user's chosen names back + // in, so match that. Interior walk endpoints are left blank here and + // filled from the scheduled neighbour they touch. + const from = stopFrom(details?.stopDetails?.departureStop, step.startLocation?.latLng, isFirst ? 'START' : ''); + const to = stopFrom(details?.stopDetails?.arrivalStop, step.endLocation?.latLng, isLast ? 'END' : ''); + from.time = isoOrNull(details?.stopDetails?.departureTime); + from.scheduledTime = from.time; + to.time = isoOrNull(details?.stopDetails?.arrivalTime); + to.scheduledTime = to.time; + + return { + mode: details ? (vehicle ? VEHICLE_MODES[vehicle] || 'RAIL' : 'RAIL') : 'WALK', + from, + to, + duration: parseDuration(step.staticDuration), + distance: typeof step.distanceMeters === 'number' ? Math.round(step.distanceMeters) : null, + headsign: details?.headsign || null, + // nameShort is the public line identifier ("M4", "JR-Y"); the long name is + // the route description and only used when there is no short one. + line: line?.nameShort || line?.name || null, + lineColor: safeColor(line?.color), + lineTextColor: safeColor(line?.textColor), + agency: line?.agencies?.[0]?.name || null, + intermediateStops: typeof details?.stopCount === 'number' ? Math.max(0, details.stopCount - 1) : 0, + geometry: step.polyline?.encodedPolyline || null, + geometryPrecision: GOOGLE_POLYLINE_PRECISION, + }; + }); + + if (!legs.some((leg) => leg.mode !== 'WALK')) return null; + + const walked = mergeWalkLegs(legs); + fillWalkGaps(walked); + + const startTime = walked[0].from.time; + const endTime = walked[walked.length - 1].to.time; + if (!startTime || !endTime) return null; + + return { startTime, endTime, ...deriveTransitStats(startTime, endTime, walked), legs: walked }; +} + +/** + * Collapse each run of consecutive walking steps into one leg. + * + * The Routes API returns walking as turn-by-turn navigation steps — "head north + * on Rue de Rivoli", "turn right" — so a two-minute walk to the platform + * arrives as half a dozen steps. MOTIS gives one leg per walking segment, and + * everything downstream is built for that shape: the panel renders a row per + * leg, and transitItinerarySchema caps an itinerary at 20 legs, so a + * finely-sliced walk would push a real journey over the limit and get the whole + * itinerary dropped at the MCP boundary. + */ +function mergeWalkLegs(legs: TransitLeg[]): TransitLeg[] { + const merged: TransitLeg[] = []; + for (const leg of legs) { + const previous = merged[merged.length - 1]; + if (leg.mode !== 'WALK' || previous?.mode !== 'WALK') { + merged.push(leg); + continue; + } + previous.to = leg.to; + previous.duration += leg.duration; + previous.distance = + previous.distance === null && leg.distance === null ? null : (previous.distance ?? 0) + (leg.distance ?? 0); + previous.geometry = joinPolylines(previous.geometry, leg.geometry); + } + return merged; +} + +/** + * Two step polylines into one. They cannot be concatenated as strings — each is + * delta-encoded from its own first point — so the pair is decoded, joined and + * re-encoded. Consecutive steps repeat the point where one ends and the next + * begins; that seam is dropped rather than drawn twice. + */ +function joinPolylines(head: string | null, tail: string | null): string | null { + if (!head) return tail; + if (!tail) return head; + const first = decodePolyline(head, GOOGLE_POLYLINE_PRECISION); + const second = decodePolyline(tail, GOOGLE_POLYLINE_PRECISION); + if (first.length === 0) return tail; + if (second.length === 0) return head; + const last = first[first.length - 1]; + const seam = second[0][0] === last[0] && second[0][1] === last[1] ? 1 : 0; + return encodePolyline([...first, ...second.slice(seam)], GOOGLE_POLYLINE_PRECISION); +} + +/** + * Only transit steps come back with a clock and a stop name. A walking step is + * anchored to the scheduled neighbour it touches — forwards from the previous + * arrival, or backwards from the next departure when the walk opens the + * journey — which is also what makes a walk read "…to Nakanoshima". + */ +function fillWalkGaps(legs: TransitLeg[]): void { + for (let i = 1; i < legs.length; i++) { + const previous = legs[i - 1].to; + const leg = legs[i]; + if (!leg.from.name) leg.from.name = previous.name; + if (!leg.from.time && previous.time) { + leg.from.time = previous.time; + leg.to.time ??= shiftIso(previous.time, leg.duration); + } + } + + for (let i = legs.length - 2; i >= 0; i--) { + const next = legs[i + 1].from; + const leg = legs[i]; + if (!leg.to.name) leg.to.name = next.name; + if (!leg.to.time && next.time) { + leg.to.time = next.time; + leg.from.time ??= shiftIso(next.time, -leg.duration); + } + } + + for (const leg of legs) { + // transitStopSchema wants a non-empty name; a walk between two unnamed + // points has nothing better to be called. + if (!leg.from.name) leg.from.name = 'Transfer'; + if (!leg.to.name) leg.to.name = 'Transfer'; + leg.from.scheduledTime ??= leg.from.time; + leg.to.scheduledTime ??= leg.to.time; + } +} + +/** + * The response-side half of the mode filter. Ferry, funicular and the aerial + * lifts have no `allowedTravelModes` equivalent, and Google's five buckets are + * coarser than TREK's thirteen — so a user who asked for "subway only" is held + * to it here rather than being handed a bus. + */ +function matchesRequestedModes(itinerary: TransitItinerary, requested: string[]): boolean { + if (requested.length === 0) return true; + const wanted = new Set(requested); + return itinerary.legs.every((leg) => leg.mode === 'WALK' || wanted.has(leg.mode)); +} diff --git a/server/src/nest/transit/transit-provider.ts b/server/src/nest/transit/transit-provider.ts new file mode 100644 index 000000000..56fd613c4 --- /dev/null +++ b/server/src/nest/transit/transit-provider.ts @@ -0,0 +1,38 @@ +import { TRANSIT_PROVIDERS, type TransitProvider } from '@trek/shared'; +import type { DatabaseService } from '../database/database.service'; + +/** + * Which backend answers /api/transit (#1699), stored as one `app_settings` row. + * + * Kept as plain functions rather than a service (the instance-api-keys.ts + * precedent) because two modules need the value and neither should own the + * other: AddonsService writes it from the admin panel, TransitService reads it + * per request. One reader, one writer, one key — no mirrored copy. + */ +export const TRANSIT_PROVIDER_SETTING = 'transit_provider'; + +/** Free, keyless, and what every install has today. Also the fallback. */ +export const DEFAULT_TRANSIT_PROVIDER: TransitProvider = 'transitous'; + +function isTransitProvider(value: unknown): value is TransitProvider { + return typeof value === 'string' && (TRANSIT_PROVIDERS as readonly string[]).includes(value); +} + +/** + * Reads fail-safe: a missing row, or a value written by a newer version that + * this one does not know, resolves to Transitous. The alternative is billing an + * admin's Google key because a string did not parse. + */ +export function readTransitProvider(db: DatabaseService): TransitProvider { + const row = db.get<{ value: string | null }>('SELECT value FROM app_settings WHERE key = ?', TRANSIT_PROVIDER_SETTING); + return isTransitProvider(row?.value) ? row.value : DEFAULT_TRANSIT_PROVIDER; +} + +export function writeTransitProvider(db: DatabaseService, provider: TransitProvider): TransitProvider { + db.run( + `INSERT INTO app_settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + TRANSIT_PROVIDER_SETTING, provider + ); + return provider; +} diff --git a/server/src/nest/transit/transit.controller.ts b/server/src/nest/transit/transit.controller.ts index 7f6293f7d..255507fd0 100644 --- a/server/src/nest/transit/transit.controller.ts +++ b/server/src/nest/transit/transit.controller.ts @@ -1,16 +1,24 @@ import { Controller, Get, HttpException, Query, Req, UseGuards } from '@nestjs/common'; import type { Request } from 'express'; +import { CurrentUser } from '../auth/current-user.decorator'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { RateLimitService } from '../common/rate-limit.service'; import { TransitService } from './transit.service'; +import type { User } from '../../types'; const RL_WINDOW = 15 * 60 * 1000; /** * /api/transit — public transit routing (#1065) proxied through Transitous - * (or a self-hosted MOTIS via TRANSIT_API_URL). JWT-guarded and rate-limited: - * the Transitous usage policy asks integrators to keep expensive routing - * traffic reasonable, so planning gets a tighter bucket than geocoding. + * (or a self-hosted MOTIS via TRANSIT_API_URL, or Google since #1699). + * JWT-guarded and rate-limited: the Transitous usage policy asks integrators to + * keep expensive routing traffic reasonable, so planning gets a tighter bucket + * than geocoding — and the same buckets cap what a Google-backed install can be + * made to spend. + * + * The caller's id goes to the service so the Google backend can resolve the + * install's Places key the way every other Google call does (operator env → + * instance → the caller's own row). The Transitous path ignores it. */ @Controller('api/transit') @UseGuards(JwtAuthGuard) @@ -37,11 +45,12 @@ export class TransitController { @Query('q') q: string | undefined, @Query('lang') lang: string | undefined, @Query('near') near: string | undefined, + @CurrentUser() user: User, @Req() req: Request, ) { this.limit('transit_geocode', req, 300); try { - return await this.transit.geocode(q || '', lang, near); + return await this.transit.geocode(q || '', lang, near, user.id); } catch (err) { this.rethrow(err); } } @@ -53,6 +62,8 @@ export class TransitController { @Query('arriveBy') arriveBy: string | undefined, @Query('modes') modes: string | undefined, @Query('maxTransfers') maxTransfers: string | undefined, + @Query('lang') lang: string | undefined, + @CurrentUser() user: User, @Req() req: Request, ) { this.limit('transit_plan', req, 60); @@ -64,7 +75,7 @@ export class TransitController { arriveBy: arriveBy === 'true' || arriveBy === '1', modes, maxTransfers: maxTransfers !== undefined && maxTransfers !== '' ? Number(maxTransfers) : undefined, - }); + }, lang, user.id); } catch (err) { this.rethrow(err); } } } diff --git a/server/src/nest/transit/transit.helpers.ts b/server/src/nest/transit/transit.helpers.ts index 12f30cb31..ee18b3bba 100644 --- a/server/src/nest/transit/transit.helpers.ts +++ b/server/src/nest/transit/transit.helpers.ts @@ -97,3 +97,64 @@ export function deriveTransitStats( walkSeconds: legs.filter((leg) => leg.mode === 'WALK').reduce((total, leg) => total + leg.duration, 0), }; } + +/** + * Google-encoded polyline codec. + * + * The client carries its own decoder for drawing a journey on the map + * (client/src/components/Map/transitGeometry.ts). This pair exists for a + * different job the client never has: the Google backend receives a walk split + * across several navigation steps, each with its own polyline, and has to + * MERGE them into the single walk leg the rest of TREK expects — which needs an + * encoder to put the joined path back on the wire. + */ +export function decodePolyline(encoded: string, precision: number): [number, number][] { + const factor = 10 ** precision; + const points: [number, number][] = []; + let index = 0; + let lat = 0; + let lng = 0; + while (index < encoded.length) { + for (const axis of [0, 1] as const) { + let result = 0; + let shift = 0; + let byte = 0x20; + while (byte >= 0x20) { + if (index >= encoded.length) return points; + byte = (encoded.codePointAt(index++) ?? 0) - 63; + result |= (byte & 0x1f) << shift; + shift += 5; + } + const delta = result & 1 ? ~(result >> 1) : result >> 1; + if (axis === 0) lat += delta; + else lng += delta; + } + points.push([lat / factor, lng / factor]); + } + return points; +} + +function encodeSigned(value: number): string { + let remaining = value < 0 ? ~(value << 1) : value << 1; + let out = ''; + while (remaining >= 0x20) { + out += String.fromCharCode((0x20 | (remaining & 0x1f)) + 63); + remaining >>= 5; + } + return out + String.fromCharCode(remaining + 63); +} + +export function encodePolyline(points: [number, number][], precision: number): string { + const factor = 10 ** precision; + let out = ''; + let lastLat = 0; + let lastLng = 0; + for (const [lat, lng] of points) { + const scaledLat = Math.round(lat * factor); + const scaledLng = Math.round(lng * factor); + out += encodeSigned(scaledLat - lastLat) + encodeSigned(scaledLng - lastLng); + lastLat = scaledLat; + lastLng = scaledLng; + } + return out; +} diff --git a/server/src/nest/transit/transit.mcp.ts b/server/src/nest/transit/transit.mcp.ts index 3517b1c25..a76408d4a 100644 --- a/server/src/nest/transit/transit.mcp.ts +++ b/server/src/nest/transit/transit.mcp.ts @@ -90,7 +90,7 @@ export class TransitMcp { const limited = rateLimit(ctx.userId, 'mcp_transit_geocode', 300); if (limited) return limited; try { - return ok(await this.transit.geocode(query, language, near ? `${near.lat},${near.lng}` : undefined)); + return ok(await this.transit.geocode(query, language, near ? `${near.lat},${near.lng}` : undefined, ctx.userId)); } catch (err) { return errorResult(err, 'Transit stop search failed.'); } @@ -99,7 +99,7 @@ export class TransitMcp { @Tool({ name: 'search_transit_routes', description: - 'Search scheduled public-transit routes via Transitous between two coordinates. Returns itineraries that can be passed unchanged to create_transit_journey. `dropped` counts provider itineraries that failed validation and are therefore absent from the results — a non-zero value means the provider offered routes this tool could not represent.', + 'Search scheduled public-transit routes between two coordinates, via whichever backend the instance is configured for. Returns itineraries that can be passed unchanged to create_transit_journey. `dropped` counts provider itineraries that failed validation and are therefore absent from the results — a non-zero value means the provider offered routes this tool could not represent.', inputSchema: { from: transitPlaceSchema, to: transitPlaceSchema, @@ -136,7 +136,7 @@ export class TransitMcp { arriveBy, modes: modes?.join(','), maxTransfers, - }); + }, undefined, ctx.userId); const itineraries = result.itineraries.flatMap((itinerary) => { const parsed = transitItinerarySchema.safeParse(cleanTransitItineraryNames(itinerary, from.name, to.name)); if (!parsed.success) return []; diff --git a/server/src/nest/transit/transit.module.ts b/server/src/nest/transit/transit.module.ts index 7ebafab01..2a482f56e 100644 --- a/server/src/nest/transit/transit.module.ts +++ b/server/src/nest/transit/transit.module.ts @@ -3,13 +3,15 @@ import { Module } from '@nestjs/common'; import { TransitController } from './transit.controller'; import { TransitService } from './transit.service'; import { TransitMcp } from './transit.mcp'; +import { GoogleTransitProvider } from './google-transit.provider'; import { DaysModule } from '../days/days.module'; import { ReservationsModule } from '../reservations/reservations.module'; import { AuthModule } from '../auth/auth.module'; import { McpSharedModule } from '../mcp-shared/mcp-shared.module'; /** - * Transit domain (#1065) — the Transitous/MOTIS proxy. TransitMcp carries the + * Transit domain (#1065) — the Transitous/MOTIS proxy, with the optional + * Google backend (#1699) behind it. TransitMcp carries the * decorator-registered MCP tools; DaysModule/ReservationsModule feed * create_transit_journey. Exports TransitService for in-container consumers. */ @@ -17,7 +19,7 @@ import { McpSharedModule } from '../mcp-shared/mcp-shared.module'; // DaysModule + ReservationsModule: TransitMcp's create_transit_journey injects both. imports: [McpSharedModule, RateLimitModule, DaysModule, ReservationsModule, AuthModule], controllers: [TransitController], - providers: [TransitService, TransitMcp], + providers: [TransitService, TransitMcp, GoogleTransitProvider], exports: [TransitService], }) export class TransitModule {} diff --git a/server/src/nest/transit/transit.service.ts b/server/src/nest/transit/transit.service.ts index beff0957c..e634b4de8 100644 --- a/server/src/nest/transit/transit.service.ts +++ b/server/src/nest/transit/transit.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; import { getAppUrl, readEnv } from '../../app-config'; import { buildUserAgent } from '../maps/maps.helpers'; +import { GoogleTransitProvider } from './google-transit.provider'; +import type { TransitProvider } from '@trek/shared'; import { deriveTransitStats, SCHEDULED_TRANSIT_MODES, @@ -17,6 +19,19 @@ import { * TREK's no-paid-providers rule. Self-hosters can point TRANSIT_API_URL at their * own MOTIS instance instead. * + * Since #1699 an admin can point the two routes at Google instead, for the + * regions Transitous has no feed for. This class stays the entry point and the + * validator either way: the request is checked here, then dispatched, so both + * backends are held to the same coordinate/mode/transfer contract and answer in + * the same compact shape. Transitous remains the default and the fallback — + * see GoogleTransitProvider.isActive. + * + * Both responses name the backend that answered. The fallback is silent by + * design, so without this an empty result is indistinguishable between "this + * provider has no data here" and "the provider you picked never ran" — which is + * exactly the guessing game a Paris search ran into when the Île-de-France feed + * ended before the trip date. + * * This service is a thin, validating proxy: the browser never talks to * Transitous directly (their usage policy wants an identifying User-Agent with * contact info, which we send once from the server), and responses are mapped @@ -139,23 +154,43 @@ function mapStop(p: MotisPlaceRaw | undefined, kind: 'departure' | 'arrival'): T @Injectable() export class TransitService { - /** Station/place search for the from/to pickers. `near` biases results. */ - async geocode(query: string, language?: string, near?: string): Promise<{ results: TransitPlace[] }> { + constructor(private readonly google: GoogleTransitProvider) {} + + /** + * Station/place search for the from/to pickers. `near` biases results. + * + * `userId` resolves the Google key (operator env → instance → own row) and is + * only read when Google is the active backend; the Transitous path is keyless + * and identical for every caller. + */ + async geocode( + query: string, + language?: string, + near?: string, + userId = 0, + ): Promise<{ results: TransitPlace[]; provider: TransitProvider }> { const text = (query || '').trim(); - if (text.length < 2) return { results: [] }; + // Answered before either backend is consulted, so it reports the one that + // WOULD have been asked rather than claiming nobody was. + const provider: TransitProvider = this.google.isActive(userId) ? 'google' : 'transitous'; + if (text.length < 2) return { results: [], provider }; if (text.length > 200) { const e = new Error('Query too long') as Error & { status: number }; e.status = 400; throw e; } + if (provider === 'google') { + return { ...(await this.google.geocode(text, language, near && isCoord(near) ? near : undefined, userId)), provider }; + } + const params = new URLSearchParams({ text }); if (language) params.set('language', language.slice(0, 5)); if (near && isCoord(near)) params.set('place', near); const key = `geo:${params.toString()}`; const cached = cacheGet(key); - if (cached) return cached as { results: TransitPlace[] }; + if (cached) return { ...(cached as { results: TransitPlace[] }), provider }; const raw = (await upstream('/api/v1/geocode', params)) as Array<{ name?: string; @@ -173,11 +208,15 @@ export class TransitService { const data = { results }; cacheSet(key, data); - return data; + return { ...data, provider }; } /** Route search between two coordinates. Returns compact itineraries for the picker. */ - async plan(q: PlanQuery): Promise<{ itineraries: TransitItinerary[] }> { + async plan( + q: PlanQuery, + language?: string, + userId = 0, + ): Promise<{ itineraries: TransitItinerary[]; provider: TransitProvider }> { const bad = (msg: string) => { const e = new Error(msg) as Error & { status: number }; e.status = 400; @@ -212,9 +251,15 @@ export class TransitService { // "direct" connection is what the existing OSRM footpath routing is for. params.set('directModes', 'WALK'); + // After validation on purpose: whichever backend answers, the caller is held + // to the same coordinate/mode/transfer contract and gets the same 400s. + if (this.google.isActive(userId)) { + return { ...(await this.google.plan(q, language, userId)), provider: 'google' }; + } + const key = `plan:${params.toString()}`; const cached = cacheGet(key); - if (cached) return cached as { itineraries: TransitItinerary[] }; + if (cached) return { ...(cached as { itineraries: TransitItinerary[] }), provider: 'transitous' }; const raw = (await upstream('/api/v6/plan', params)) as { itineraries?: Array<{ @@ -276,6 +321,6 @@ export class TransitService { const data = { itineraries }; cacheSet(key, data); - return data; + return { ...data, provider: 'transitous' }; } } diff --git a/server/tests/e2e/transit-provider.e2e.test.ts b/server/tests/e2e/transit-provider.e2e.test.ts new file mode 100644 index 000000000..3827680e9 --- /dev/null +++ b/server/tests/e2e/transit-provider.e2e.test.ts @@ -0,0 +1,127 @@ +/** + * Transit backend switch e2e (#1699) — the whole path a self-hoster actually + * walks, through the real Nest stack: an admin flips Admin → Settings → Transit + * Provider, and the next /api/transit/plan leaves for a different upstream. + * + * Unlike transit.e2e.test.ts, TransitService is NOT stubbed here — only the + * outbound `fetch` is, so what is asserted is the URL the install would really + * have called and the setting/key state that decided it. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import cookieParser from 'cookie-parser'; +import type { Server } from 'http'; +import { Test } from '@nestjs/testing'; +import { seedUser, sessionCookie } from './harness'; + +const { db } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + const tmp = new Database(':memory:'); + tmp.exec('PRAGMA journal_mode = WAL'); + tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0, + maps_api_key TEXT, unsplash_api_key TEXT);`); + tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);'); + return { db: tmp }; +}); +vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); + +import { TransitModule } from '../../src/nest/transit/transit.module'; +import { DatabaseModule } from '../../src/nest/database/database.module'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { clearGoogleTransitCache } from '../../src/nest/transit/google-transit.provider'; + +const ADMIN = 1; + +// TransitService's Transitous cache is module-scoped with no reset hook, so +// each case plans between its own pair of Osaka coordinates rather than being +// answered by the previous one. +let planNo = 0; +const nextPlanUrl = () => `/api/transit/plan?from=34.69${planNo},135.4900&to=34.68${planNo++},135.5155`; + +describe('Transit backend switch e2e (#1699)', () => { + let server: Server; + let app: Awaited>; + const fetchMock = vi.fn(); + + async function build() { + const moduleRef = await Test.createTestingModule({ + imports: [DatabaseModule, RealtimeModule, TransitModule], + }).compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalFilters(new TrekExceptionFilter()); + await nest.init(); + return nest; + } + + beforeAll(async () => { + seedUser(db as never, { id: ADMIN, role: 'admin' }); + app = await build(); + server = app.getHttpServer(); + }); + afterAll(async () => { await app?.close(); }); + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockReset(); + // MOTIS and Routes shapes both parse as "no itineraries", which is all this + // suite needs — it asserts where the request went, not how it mapped. + fetchMock.mockResolvedValue({ + ok: true, status: 200, headers: { get: () => null }, json: async () => ({}), + }); + db.prepare('DELETE FROM app_settings').run(); + db.prepare('UPDATE users SET maps_api_key = NULL WHERE id = ?').run(ADMIN); + clearGoogleTransitCache(); + }); + + let lastBody: Record = {}; + + async function planUpstream(): Promise { + const res = await request(server).get(nextPlanUrl()).set('Cookie', sessionCookie(ADMIN)); + expect(res.status).toBe(200); + lastBody = res.body; + return String(fetchMock.mock.calls[0][0]); + } + + it('TRANSIT-PROV-E2E-001: an untouched install plans through Transitous', async () => { + expect(await planUpstream()).toContain('transitous.org'); + expect(lastBody.provider).toBe('transitous'); + }); + + it('TRANSIT-PROV-E2E-002: selecting Google without a key still plans through Transitous', async () => { + db.prepare("INSERT INTO app_settings (key, value) VALUES ('transit_provider', 'google')").run(); + expect(await planUpstream()).toContain('transitous.org'); + // The response says who really answered, so the empty state cannot blame + // Google for a Transitous result. + expect(lastBody.provider).toBe('transitous'); + }); + + it('TRANSIT-PROV-E2E-003: with the switch on and a key set, the plan leaves for the Routes API', async () => { + db.prepare("INSERT INTO app_settings (key, value) VALUES ('transit_provider', 'google')").run(); + db.prepare("INSERT INTO app_settings (key, value) VALUES ('maps_api_key', 'instance-key')").run(); + expect(await planUpstream()).toBe('https://routes.googleapis.com/directions/v2:computeRoutes'); + expect(lastBody.provider).toBe('google'); + }); + + it('TRANSIT-PROV-E2E-004: the picker follows the switch to Google Places', async () => { + db.prepare("INSERT INTO app_settings (key, value) VALUES ('transit_provider', 'google')").run(); + db.prepare("INSERT INTO app_settings (key, value) VALUES ('maps_api_key', 'instance-key')").run(); + + const res = await request(server) + .get('/api/transit/geocode?q=Nakanoshima&lang=ja') + .set('Cookie', sessionCookie(ADMIN)); + expect(res.status).toBe(200); + expect(String(fetchMock.mock.calls[0][0])).toBe('https://places.googleapis.com/v1/places:searchText'); + }); + + it("TRANSIT-PROV-E2E-005: a member's own key is used when the instance has none", async () => { + db.prepare("INSERT INTO app_settings (key, value) VALUES ('transit_provider', 'google')").run(); + db.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('personal-key', ADMIN); + + expect(await planUpstream()).toBe('https://routes.googleapis.com/directions/v2:computeRoutes'); + expect(fetchMock.mock.calls[0][1].headers['X-Goog-Api-Key']).toBe('personal-key'); + }); +}); diff --git a/server/tests/e2e/transit.e2e.test.ts b/server/tests/e2e/transit.e2e.test.ts index 709cd8bc2..4234ac6a6 100644 --- a/server/tests/e2e/transit.e2e.test.ts +++ b/server/tests/e2e/transit.e2e.test.ts @@ -76,18 +76,20 @@ describe('Transit proxy e2e (real auth guard + temp SQLite)', () => { const res = await request(server).get('/api/transit/geocode?q=alex&lang=de&near=52.5,13.4').set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body.results[0].name).toBe('Alexanderplatz'); - expect(geocodeSpy).toHaveBeenCalledWith('alex', 'de', '52.5,13.4'); + // The caller's id trails the query so the Google backend (#1699) can resolve + // the install's Places key; the Transitous path ignores it. + expect(geocodeSpy).toHaveBeenCalledWith('alex', 'de', '52.5,13.4', 1); }); it('plan passes all params through (arriveBy + maxTransfers coerced)', async () => { planSpy.mockResolvedValueOnce({ itineraries: [] }); const res = await request(server) - .get('/api/transit/plan?from=52.5,13.4&to=52.6,13.5&time=2026-07-13T09:00:00Z&arriveBy=true&modes=BUS&maxTransfers=2') + .get('/api/transit/plan?from=52.5,13.4&to=52.6,13.5&time=2026-07-13T09:00:00Z&arriveBy=true&modes=BUS&maxTransfers=2&lang=de') .set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(planSpy).toHaveBeenCalledWith({ from: '52.5,13.4', to: '52.6,13.5', time: '2026-07-13T09:00:00Z', arriveBy: true, modes: 'BUS', maxTransfers: 2, - }); + }, 'de', 1); }); it('service validation errors propagate with their status', async () => { diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 9da06000a..b3e6a2854 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -46,6 +46,7 @@ import { ShareService } from '../../src/nest/share/share.service'; import { TodoMcp } from '../../src/nest/todo/todo.mcp'; import { TodoService } from '../../src/nest/todo/todo.service'; import { TransitMcp } from '../../src/nest/transit/transit.mcp'; +import { GoogleTransitProvider } from '../../src/nest/transit/google-transit.provider'; import { TransitService } from '../../src/nest/transit/transit.service'; import { FilesService } from '../../src/nest/files/files.service'; import { FilesMcp } from '../../src/nest/files/files.mcp'; @@ -224,7 +225,7 @@ export function createMcpTestRegistry(): McpRegistry { new MapsMcp(mapsService), new PlacesMcp(placesService, mapsService, dbService, authService, journeyDomain, assignmentsService, guards), new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage), dbService, authService, addonsService), - new TransitMcp(new TransitService(), daysService, reservationsService, dbService, authService, guards), + new TransitMcp(new TransitService(new GoogleTransitProvider(dbService)), daysService, reservationsService, dbService, authService, guards), new AtlasMcp(new AtlasService(dbService), addonsService, authService), new JourneyMcp(journeyDomain, new JourneyShareService(dbService, journeyDomain, new SettingsService(dbService)), addonsService, authService, captureBackfill), new MemoriesMcp(immichService, synologyService, dbService, addonsService), diff --git a/server/tests/unit/mcp/tools-transit.test.ts b/server/tests/unit/mcp/tools-transit.test.ts index 2ef2cda1f..991841adc 100644 --- a/server/tests/unit/mcp/tools-transit.test.ts +++ b/server/tests/unit/mcp/tools-transit.test.ts @@ -169,8 +169,9 @@ describe('MCP transit tools', () => { // `from` is a tool-input place (name/lat/lng). A geocode result is a TransitPlace, // which also carries `type` and `area`; the stop search only reads the name back out, // so the fixture stays as it is instead of growing fields nothing here looks at. - geocodeMock.mockResolvedValue({ results: [from as TransitPlace] }); + geocodeMock.mockResolvedValue({ results: [from as TransitPlace], provider: 'transitous' }); planMock.mockResolvedValue({ + provider: 'transitous', itineraries: [ itinerary, { ...itinerary, legs: [itinerary.legs[0]] }, @@ -188,7 +189,9 @@ describe('MCP transit tools', () => { }), ) as any; expect(stops.results[0].name).toBe('Namba'); - expect(geocodeMock).toHaveBeenCalledWith('Namba', 'ja', '34.67,135.5'); + // The caller's id trails the query so the Google backend (#1699) can + // resolve the install's key; the Transitous path ignores it. + expect(geocodeMock).toHaveBeenCalledWith('Namba', 'ja', '34.67,135.5', user.id); const routes = parseToolResult( await harness.client.callTool({ @@ -199,7 +202,7 @@ describe('MCP transit tools', () => { expect(routes.itineraries[0].legs[0].from.name).toBe('Namba'); expect(routes.itineraries[0].legs[1].to.name).toBe('Umeda'); expect(routes.itineraries).toHaveLength(1); - expect(planMock).toHaveBeenCalledWith(expect.objectContaining({ modes: 'SUBWAY' })); + expect(planMock).toHaveBeenCalledWith(expect.objectContaining({ modes: 'SUBWAY' }), undefined, user.id); const invalidNear = await harness.client.callTool({ name: 'search_transit_stops', @@ -496,7 +499,7 @@ describe('MCP transit tools', () => { ...itinerary, legs: itinerary.legs.map((leg) => (leg.mode === 'WALK' ? leg : { ...leg, mode: 'AIRPLANE' })), }; - planMock.mockResolvedValue({ itineraries: [flying] }); + planMock.mockResolvedValue({ itineraries: [flying], provider: 'transitous' }); await withHarness(user.id, ['geo:read'], async (harness) => { const routes = parseToolResult( @@ -523,7 +526,7 @@ describe('MCP transit tools', () => { it('reports how many provider itineraries failed validation', async () => { const { user } = createUser(testDb); const walkOnly = { ...itinerary, legs: [itinerary.legs[0]] }; - planMock.mockResolvedValue({ itineraries: [itinerary, walkOnly] }); + planMock.mockResolvedValue({ itineraries: [itinerary, walkOnly], provider: 'transitous' }); await withHarness(user.id, ['geo:read'], async (harness) => { const routes = parseToolResult( diff --git a/server/tests/unit/nest/addons.service.test.ts b/server/tests/unit/nest/addons.service.test.ts index e1b145641..a12a8eeec 100644 --- a/server/tests/unit/nest/addons.service.test.ts +++ b/server/tests/unit/nest/addons.service.test.ts @@ -421,3 +421,64 @@ describe('AddonsService places enrichment flag', () => { expect(dbMock._stmt.run).toHaveBeenLastCalledWith('places_enrich_enabled', 'true'); }); }); + +/** + * The transit backend (#1699) is a name, not a flag, so it needs the + * unrecognised-value case the booleans get for free: anything that is not a + * known provider must read as Transitous rather than silently billing the + * install's Google key. + */ +describe('AddonsService transit provider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('ADDONS-SVC-086 an unset provider reads as Transitous, with no key anywhere', () => { + dbMock._stmt.get.mockReturnValue(undefined); + expect(svc().getTransitProvider()).toEqual({ provider: 'transitous', googleKeySource: null }); + }); + + it('ADDONS-SVC-087 only a known provider name is honoured', () => { + dbMock._stmt.get.mockReturnValueOnce({ value: 'google' }).mockReturnValue(undefined); + expect(svc().getTransitProvider().provider).toBe('google'); + + for (const value of ['someday-maps', '', 'GOOGLE']) { + dbMock._stmt.get.mockReset(); + dbMock._stmt.get.mockReturnValueOnce({ value }).mockReturnValue(undefined); + expect(svc().getTransitProvider().provider).toBe('transitous'); + } + }); + + it('ADDONS-SVC-088 the setter persists the name and echoes it back', () => { + dbMock._stmt.get.mockReturnValue(undefined); + expect(svc().updateTransitProvider('google').provider).toBe('google'); + expect(dbMock._stmt.run).toHaveBeenLastCalledWith('transit_provider', 'google'); + expect(svc().updateTransitProvider('transitous').provider).toBe('transitous'); + expect(dbMock._stmt.run).toHaveBeenLastCalledWith('transit_provider', 'transitous'); + }); + + /** + * The warning the admin panel renders is driven entirely by this field, so + * the instance/user-row split is the part worth pinning: only 'user-row' + * means "works for this admin, Transitous for everybody else". + */ + it('ADDONS-SVC-089 reports where the Google key resolved from', () => { + // provider row, then the instance maps_api_key row. + dbMock._stmt.get.mockReset(); + dbMock._stmt.get.mockReturnValueOnce({ value: 'google' }).mockReturnValueOnce({ value: 'instance-key' }); + expect(svc().getTransitProvider(7).googleKeySource).toBe('instance'); + + // No instance row, but the caller's own users column has one. + dbMock._stmt.get.mockReset(); + dbMock._stmt.get + .mockReturnValueOnce({ value: 'google' }) + .mockReturnValueOnce(undefined) + .mockReturnValueOnce({ maps_api_key: 'personal-key' }); + expect(svc().getTransitProvider(7).googleKeySource).toBe('user-row'); + + // Nothing anywhere. + dbMock._stmt.get.mockReset(); + dbMock._stmt.get.mockReturnValue(undefined); + expect(svc().getTransitProvider(7).googleKeySource).toBeNull(); + }); +}); diff --git a/server/tests/unit/nest/admin.controller.test.ts b/server/tests/unit/nest/admin.controller.test.ts index c1fbc3030..c39fbddd7 100644 --- a/server/tests/unit/nest/admin.controller.test.ts +++ b/server/tests/unit/nest/admin.controller.test.ts @@ -47,6 +47,8 @@ const addonsStub = () => ({ updatePlacesEnrich: vi.fn((enabled: boolean) => ({ enabled })), getCollabFeatures: vi.fn(() => ({ chat: false })), updateCollabFeatures: vi.fn(() => ({ features: { chat: true }, changed: true })), + getTransitProvider: vi.fn(() => ({ provider: 'transitous', googleKeySource: null })), + updateTransitProvider: vi.fn((provider: string) => ({ provider, googleKeySource: null })), }) as unknown as AddonsService; // The MCP-token routes read TokenService now, not AdminService. Stubbed via a @@ -278,6 +280,20 @@ describe('AdminController feature toggles', () => { expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.places_enrich' })); }); + it('ADMIN-TOGGLE-002c transit-provider reads and writes through the addons domain and is audited (#1699)', () => { + const addons = addonsStub(); + const c = adminCtl(svc(), undefined, addons); + expect(c.getTransitProvider(user)).toEqual({ provider: 'transitous', googleKeySource: null }); + expect(c.updateTransitProvider(user, { provider: 'google' }, req)).toEqual({ provider: 'google', googleKeySource: null }); + // The acting admin's id drives key resolution — the panel warns about the + // key THEY would search with. + expect(addons.getTransitProvider).toHaveBeenCalledWith(user.id); + expect(addons.updateTransitProvider).toHaveBeenCalledWith('google', user.id); + expect(writeAudit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'admin.transit_provider', details: { provider: 'google' } }), + ); + }); + it('ADMIN-TOGGLE-003 collab-features invalidates MCP sessions only when a flag flipped (#1414)', () => { const invalidateMcpSessions = vi.fn(); const c = adminCtl(svc({ invalidateMcpSessions } as Partial)); diff --git a/server/tests/unit/nest/google-transit.provider.test.ts b/server/tests/unit/nest/google-transit.provider.test.ts new file mode 100644 index 000000000..1a723946a --- /dev/null +++ b/server/tests/unit/nest/google-transit.provider.test.ts @@ -0,0 +1,665 @@ +/** + * Unit tests for the optional Google transit backend (#1699) — the Routes API + * plan and the Places Text Search picker that answer /api/transit when an admin + * switches the instance over. + * + * Coverage centres on the three things that are easy to get wrong and expensive + * to get wrong: the activation chain (setting AND key, else Transitous), the + * mapping from Google's step list onto TREK's compact itinerary — Google gives + * no journey-level clock, so start/end are derived from the scheduled legs — + * and the request shape, whose field masks are what the install is billed for. + * + * The response cache is module-scoped, so each case uses its own coordinates + * and clearGoogleTransitCache() runs between tests. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { GoogleTransitProvider, clearGoogleTransitCache } from '../../../src/nest/transit/google-transit.provider'; +import { decodePolyline, encodePolyline } from '../../../src/nest/transit/transit.helpers'; +import { TransitService } from '../../../src/nest/transit/transit.service'; +import type { DatabaseService } from '../../../src/nest/database/database.service'; + +vi.mock('../../../src/app-config', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getAppUrl: () => 'https://trek.example.com', + // No operator-env key, so resolveApiKey falls through to the stub DB below. + readEnv: () => ({ ...actual.readEnv(), maps: { ...actual.readEnv().maps, placesApiKey: undefined } }), + }; +}); + +const fetchMock = vi.fn(); + +/** app_settings reads only — the transit provider name and the instance Places key. */ +function stubDb(settings: Record): DatabaseService { + return { + get: (_sql: string, ...params: unknown[]) => { + const value = settings[String(params[0])]; + return value === undefined ? undefined : { value }; + }, + run: () => undefined, + } as unknown as DatabaseService; +} + +const googleDb = () => stubDb({ transit_provider: 'google', maps_api_key: 'test-key' }); + +beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + fetchMock.mockReset(); + clearGoogleTransitCache(); +}); +afterEach(() => vi.unstubAllGlobals()); + +function okJson(data: unknown) { + return { ok: true, status: 200, headers: { get: () => null }, json: async () => data }; +} + +function lastBody(): Record { + return JSON.parse(fetchMock.mock.calls[fetchMock.mock.calls.length - 1][1].body); +} + +function lastHeaders(): Record { + return fetchMock.mock.calls[fetchMock.mock.calls.length - 1][1].headers; +} + +/** + * The fixture's own shape, spelled out rather than inferred: cases below drop a + * stop name or a whole stopDetails to exercise the fallbacks, which an inferred + * literal type forbids. + */ +type FixtureLatLng = { latLng: { latitude: number; longitude: number } }; +type FixtureStop = { name?: string; location?: FixtureLatLng }; +interface FixtureRoutes { + routes: Array<{ + legs: Array<{ + steps: Array<{ + travelMode: string; + staticDuration?: string; + distanceMeters?: number; + startLocation?: FixtureLatLng; + endLocation?: FixtureLatLng; + polyline?: { encodedPolyline: string }; + transitDetails?: { + stopDetails: { + departureStop?: FixtureStop; + departureTime?: string; + arrivalStop?: FixtureStop; + arrivalTime?: string; + }; + headsign?: string; + stopCount?: number; + transitLine: { + name?: string; + nameShort?: string; + color?: string; + textColor?: string; + agencies?: Array<{ name: string }>; + vehicle: { type: string }; + }; + }; + }>; + }>; + }>; +} + +// Nakanoshima → Temmabashi, the Osaka pair the request was filed about. +const FROM = '34.6937,135.4900'; +const TO = '34.6875,135.5155'; + +function subwayRoute(): FixtureRoutes { + return { + routes: [ + { + legs: [ + { + steps: [ + { + travelMode: 'WALK', + staticDuration: '180s', + distanceMeters: 210, + startLocation: { latLng: { latitude: 34.6937, longitude: 135.49 } }, + endLocation: { latLng: { latitude: 34.6939, longitude: 135.4915 } }, + polyline: { encodedPolyline: 'walkpoly' }, + }, + { + travelMode: 'TRANSIT', + staticDuration: '480s', + distanceMeters: 2100, + polyline: { encodedPolyline: 'railpoly' }, + transitDetails: { + stopDetails: { + departureStop: { name: 'Nakanoshima', location: { latLng: { latitude: 34.6939, longitude: 135.4915 } } }, + departureTime: '2026-09-10T09:00:00Z', + arrivalStop: { name: 'Temmabashi', location: { latLng: { latitude: 34.6871, longitude: 135.5142 } } }, + arrivalTime: '2026-09-10T09:08:00Z', + }, + headsign: 'Kadoma-shi', + stopCount: 3, + transitLine: { + name: 'Keihan Nakanoshima Line', + nameShort: 'KH', + color: '00A0E9', + textColor: 'FFFFFF', + agencies: [{ name: 'Keihan Electric Railway' }], + vehicle: { type: 'SUBWAY' }, + }, + }, + }, + { + travelMode: 'WALK', + staticDuration: '120s', + distanceMeters: 150, + startLocation: { latLng: { latitude: 34.6871, longitude: 135.5142 } }, + endLocation: { latLng: { latitude: 34.6875, longitude: 135.5155 } }, + polyline: { encodedPolyline: 'walkpoly2' }, + }, + ], + }, + ], + }, + ], + }; +} + +describe('activation', () => { + it('GTRANSIT-001: stays off while the instance is on Transitous, even with a key', () => { + const provider = new GoogleTransitProvider(stubDb({ maps_api_key: 'test-key' })); + expect(provider.isActive(1)).toBe(false); + }); + + it('GTRANSIT-002: stays off when Google is selected but no key resolves', () => { + const provider = new GoogleTransitProvider(stubDb({ transit_provider: 'google' })); + expect(provider.isActive(1)).toBe(false); + }); + + it('GTRANSIT-003: is on only with both the setting and a key', () => { + expect(new GoogleTransitProvider(googleDb()).isActive(1)).toBe(true); + }); + + it('GTRANSIT-004: an unknown provider value falls back to Transitous', () => { + const provider = new GoogleTransitProvider(stubDb({ transit_provider: 'someday-maps', maps_api_key: 'test-key' })); + expect(provider.isActive(1)).toBe(false); + }); + + it('GTRANSIT-005: TransitService routes to Google only when it is active', async () => { + fetchMock.mockResolvedValue(okJson(subwayRoute())); + const service = new TransitService(new GoogleTransitProvider(googleDb())); + await service.plan({ from: FROM, to: TO }); + expect(fetchMock.mock.calls[0][0]).toBe('https://routes.googleapis.com/directions/v2:computeRoutes'); + }); + + it('GTRANSIT-006: TransitService still validates the request before dispatching', async () => { + const service = new TransitService(new GoogleTransitProvider(googleDb())); + await expect(service.plan({ from: 'nowhere', to: TO })).rejects.toThrow('from must be "lat,lng"'); + await expect(service.plan({ from: FROM, to: TO, modes: 'ROCKET' })).rejects.toThrow('unsupported transit mode'); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('plan mapping', () => { + it('GTRANSIT-007: maps a walk/subway/walk route and derives the journey clock', async () => { + fetchMock.mockResolvedValue(okJson(subwayRoute())); + const { itineraries } = await new GoogleTransitProvider(googleDb()).plan({ from: FROM, to: TO }, 'en', 1); + + expect(itineraries).toHaveLength(1); + const it0 = itineraries[0]; + // Google gives no journey-level clock: the opening walk is 180s before the + // 09:00 departure and the closing one 120s after the 09:08 arrival. + expect(it0.startTime).toBe('2026-09-10T08:57:00.000Z'); + expect(it0.endTime).toBe('2026-09-10T09:10:00.000Z'); + expect(it0.duration).toBe(780); + expect(it0.transfers).toBe(0); + expect(it0.walkSeconds).toBe(300); + + expect(it0.legs.map((l) => l.mode)).toEqual(['WALK', 'SUBWAY', 'WALK']); + // START/END are the sentinels the web client and the MCP builder replace + // with the places the user actually picked. + expect(it0.legs[0].from.name).toBe('START'); + expect(it0.legs[0].to.name).toBe('Nakanoshima'); + expect(it0.legs[2].from.name).toBe('Temmabashi'); + expect(it0.legs[2].to.name).toBe('END'); + + const rail = it0.legs[1]; + expect(rail.line).toBe('KH'); + expect(rail.agency).toBe('Keihan Electric Railway'); + expect(rail.headsign).toBe('Kadoma-shi'); + expect(rail.lineColor).toBe('#00A0E9'); + expect(rail.lineTextColor).toBe('#FFFFFF'); + // stopCount counts the arrival stop; intermediateStops does not. + expect(rail.intermediateStops).toBe(2); + expect(rail.geometry).toBe('railpoly'); + // Google encodes at precision 5, MOTIS at 6 — the client reads it per leg. + expect(rail.geometryPrecision).toBe(5); + }); + + it('GTRANSIT-008: drops a walk-only route rather than offering it as transit', async () => { + fetchMock.mockResolvedValue( + okJson({ routes: [{ legs: [{ steps: [{ travelMode: 'WALK', staticDuration: '900s' }] }] }] }), + ); + const { itineraries } = await new GoogleTransitProvider(googleDb()).plan({ from: FROM, to: TO }, 'en', 1); + expect(itineraries).toEqual([]); + }); + + it('GTRANSIT-009: maps Google vehicle types onto TREK modes', async () => { + const withVehicle = (type: string) => { + const route = subwayRoute(); + const details = route.routes[0].legs[0].steps[1].transitDetails; + if (details) details.transitLine.vehicle.type = type; + return route; + }; + const provider = new GoogleTransitProvider(googleDb()); + for (const [google, trek] of [ + ['HEAVY_RAIL', 'RAIL'], + ['HIGH_SPEED_TRAIN', 'HIGHSPEED_RAIL'], + ['COMMUTER_TRAIN', 'SUBURBAN'], + ['INTERCITY_BUS', 'COACH'], + ['GONDOLA_LIFT', 'AERIAL_LIFT'], + ['MONORAIL', 'SUBWAY'], + ['SOMETHING_NEW', 'RAIL'], + ]) { + clearGoogleTransitCache(); + fetchMock.mockResolvedValue(okJson(withVehicle(google))); + const { itineraries } = await provider.plan({ from: FROM, to: TO }, 'en', 1); + expect(itineraries[0].legs[1].mode, google).toBe(trek); + } + }); +}); + +describe('request shape', () => { + it('GTRANSIT-010: asks for transit, alternatives and the caller language', async () => { + fetchMock.mockResolvedValue(okJson(subwayRoute())); + await new GoogleTransitProvider(googleDb()).plan({ from: FROM, to: TO, time: '2026-09-10T09:00:00Z' }, 'ja', 1); + + const body = lastBody(); + expect(body.travelMode).toBe('TRANSIT'); + // Alternatives ride along inside the one request that is already billed. + expect(body.computeAlternativeRoutes).toBe(true); + expect(body.languageCode).toBe('ja'); + expect(body.departureTime).toBe('2026-09-10T09:00:00.000Z'); + expect(body.arrivalTime).toBeUndefined(); + expect(lastHeaders()['X-Goog-Api-Key']).toBe('test-key'); + }); + + it('GTRANSIT-011: arriveBy anchors the time at the destination', async () => { + fetchMock.mockResolvedValue(okJson(subwayRoute())); + await new GoogleTransitProvider(googleDb()).plan( + { from: FROM, to: TO, time: '2026-09-10T09:00:00Z', arriveBy: true }, 'en', 1, + ); + expect(lastBody().arrivalTime).toBe('2026-09-10T09:00:00.000Z'); + expect(lastBody().departureTime).toBeUndefined(); + }); + + it('GTRANSIT-012: the field mask stays inside the tier the itinerary needs', async () => { + fetchMock.mockResolvedValue(okJson(subwayRoute())); + await new GoogleTransitProvider(googleDb()).plan({ from: FROM, to: TO }, 'en', 1); + + const mask = lastHeaders()['X-Goog-FieldMask']; + expect(mask).toContain('routes.legs.steps.transitDetails.stopDetails'); + // Enterprise-tier extras are what make a free-tier install pay. + expect(mask).not.toContain('travelAdvisory'); + expect(mask).not.toContain('localizedValues'); + expect(mask).not.toContain('routes.viewport'); + }); + + it('GTRANSIT-013: sends the expressible modes and holds the rest on the response', async () => { + fetchMock.mockResolvedValue(okJson(subwayRoute())); + const provider = new GoogleTransitProvider(googleDb()); + + const subwayOnly = await provider.plan({ from: FROM, to: TO, modes: 'SUBWAY' }, 'en', 1); + expect(lastBody().transitPreferences).toEqual({ allowedTravelModes: ['SUBWAY'] }); + expect(subwayOnly.itineraries).toHaveLength(1); + + clearGoogleTransitCache(); + // Google cannot express a ferry-only filter, so nothing is sent for it and + // the subway itinerary it answers with is refused here instead. + const ferryOnly = await provider.plan({ from: FROM, to: TO, modes: 'FERRY' }, 'en', 1); + expect(lastBody().transitPreferences).toBeUndefined(); + expect(ferryOnly.itineraries).toEqual([]); + }); + + it('GTRANSIT-014: enforces maxTransfers, which the Routes API has no parameter for', async () => { + const twoTransfers = subwayRoute(); + const rail = twoTransfers.routes[0].legs[0].steps[1]; + twoTransfers.routes[0].legs[0].steps.splice(2, 0, JSON.parse(JSON.stringify(rail))); + fetchMock.mockResolvedValue(okJson(twoTransfers)); + + const provider = new GoogleTransitProvider(googleDb()); + const capped = await provider.plan({ from: FROM, to: TO, maxTransfers: 0 }, 'en', 1); + expect(capped.itineraries).toEqual([]); + // A low cap also nudges Google itself, so the one billed call is likelier + // to come back with something the filter keeps. + expect((lastBody().transitPreferences as Record).routingPreference).toBe('FEWER_TRANSFERS'); + + clearGoogleTransitCache(); + const allowed = await provider.plan({ from: FROM, to: TO, maxTransfers: 2 }, 'en', 1); + expect(allowed.itineraries[0].transfers).toBe(1); + }); + + it('GTRANSIT-015: an identical plan is answered from cache, unbilled', async () => { + fetchMock.mockResolvedValue(okJson(subwayRoute())); + const provider = new GoogleTransitProvider(googleDb()); + await provider.plan({ from: FROM, to: TO }, 'en', 1); + await provider.plan({ from: FROM, to: TO }, 'en', 1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('GTRANSIT-016: a provider error surfaces with Google\'s own message', async () => { + fetchMock.mockResolvedValue({ + ok: false, + status: 403, + headers: { get: () => null }, + json: async () => ({ error: { message: 'Routes API has not been used in project 1' } }), + }); + await expect(new GoogleTransitProvider(googleDb()).plan({ from: FROM, to: TO }, 'en', 1)).rejects.toThrow( + 'Routes API has not been used in project 1', + ); + }); + + it('GTRANSIT-017: upstream rate limiting stays a 429', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 429, headers: { get: () => null }, json: async () => ({}) }); + await expect( + new GoogleTransitProvider(googleDb()).plan({ from: FROM, to: TO }, 'en', 1), + ).rejects.toMatchObject({ status: 429 }); + }); +}); + +describe('geocode', () => { + const places = { + places: [ + { + displayName: { text: 'Nakanoshima Station' }, + formattedAddress: '2 Chome Nakanoshima, Kita Ward, Osaka', + location: { latitude: 34.6939, longitude: 135.4915 }, + types: ['subway_station', 'transit_station'], + }, + { + displayName: { text: 'Nakanoshima Park' }, + formattedAddress: '1 Nakanoshima, Kita Ward, Osaka', + location: { latitude: 34.6921, longitude: 135.5069 }, + types: ['park'], + }, + { displayName: { text: 'No coordinates' }, types: ['park'] }, + ], + }; + + it('GTRANSIT-018: maps places, marking stations STOP and everything else PLACE', async () => { + fetchMock.mockResolvedValue(okJson(places)); + const { results } = await new GoogleTransitProvider(googleDb()).geocode('Nakanoshima', 'en', undefined, 1); + + expect(results).toEqual([ + { name: 'Nakanoshima Station', lat: 34.6939, lng: 135.4915, type: 'STOP', area: '2 Chome Nakanoshima, Kita Ward, Osaka' }, + { name: 'Nakanoshima Park', lat: 34.6921, lng: 135.5069, type: 'PLACE', area: '1 Nakanoshima, Kita Ward, Osaka' }, + ]); + }); + + it('GTRANSIT-019: biases on `near` and asks only for the cheap Text Search fields', async () => { + fetchMock.mockResolvedValue(okJson(places)); + await new GoogleTransitProvider(googleDb()).geocode('Temmabashi', 'ja', '34.6875,135.5155', 1); + + const body = lastBody(); + expect(body.languageCode).toBe('ja'); + expect(body.pageSize).toBe(8); + expect(body.locationBias).toEqual({ circle: { center: { latitude: 34.6875, longitude: 135.5155 }, radius: 50000 } }); + + // Rating/website/phone would bill Text Search at Enterprise, and a station + // picker shows none of them. + const mask = lastHeaders()['X-Goog-FieldMask']; + expect(mask).toBe('places.displayName,places.formattedAddress,places.location,places.types'); + }); + + it('GTRANSIT-020: repeat lookups of the same station are answered from cache', async () => { + fetchMock.mockResolvedValue(okJson(places)); + const service = new TransitService(new GoogleTransitProvider(googleDb())); + await service.geocode('Kyobashi', 'en', undefined, 1); + await service.geocode('Kyobashi', 'en', undefined, 1); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('GTRANSIT-021: a short query never reaches Google', async () => { + const service = new TransitService(new GoogleTransitProvider(googleDb())); + // The provider is still named: the answer comes from the length guard, not + // from a backend that declined. + expect(await service.geocode('a', 'en', undefined, 1)).toEqual({ results: [], provider: 'google' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('guards', () => { + it('GTRANSIT-022: refuses rather than calling Google when no key resolves', async () => { + const provider = new GoogleTransitProvider(stubDb({ transit_provider: 'google' })); + await expect(provider.plan({ from: FROM, to: TO }, 'en', 1)).rejects.toThrow('no Google API key configured'); + await expect(provider.geocode('Namba', 'en', undefined, 1)).rejects.toThrow('no Google API key configured'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('GTRANSIT-023: an oversized response is refused before it is parsed', async () => { + const json = vi.fn(); + fetchMock.mockResolvedValue({ ok: true, status: 200, headers: { get: () => '9000000' }, json }); + await expect(new GoogleTransitProvider(googleDb()).plan({ from: FROM, to: TO }, 'en', 1)).rejects.toThrow( + 'response too large', + ); + expect(json).not.toHaveBeenCalled(); + }); + + it('GTRANSIT-024: a route with no steps, and one whose transit leg has no clock, are both dropped', async () => { + const provider = new GoogleTransitProvider(googleDb()); + + fetchMock.mockResolvedValue(okJson({ routes: [{ legs: [{ steps: [] }] }] })); + expect((await provider.plan({ from: FROM, to: TO }, 'en', 1)).itineraries).toEqual([]); + + clearGoogleTransitCache(); + const timeless = subwayRoute(); + const details = timeless.routes[0].legs[0].steps[1].transitDetails; + if (details) details.stopDetails = { departureStop: { name: 'Nakanoshima' }, arrivalStop: { name: 'Temmabashi' } }; + fetchMock.mockResolvedValue(okJson(timeless)); + expect((await provider.plan({ from: FROM, to: TO, arriveBy: true }, 'en', 1)).itineraries).toEqual([]); + }); + + it('GTRANSIT-025: an unnamed interior walk endpoint falls back to a usable name', async () => { + const unnamed = subwayRoute(); + const details = unnamed.routes[0].legs[0].steps[1].transitDetails; + if (details) { + details.stopDetails.departureStop = { location: { latLng: { latitude: 34.6939, longitude: 135.4915 } } }; + details.stopDetails.arrivalStop = { location: { latLng: { latitude: 34.6871, longitude: 135.5142 } } }; + } + fetchMock.mockResolvedValue(okJson(unnamed)); + + const { itineraries } = await new GoogleTransitProvider(googleDb()).plan({ from: FROM, to: TO }, 'en', 1); + // Every name is non-empty — transitStopSchema rejects a blank one, which + // would drop the itinerary at the MCP boundary. + for (const leg of itineraries[0].legs) { + expect(leg.from.name).not.toBe(''); + expect(leg.to.name).not.toBe(''); + } + expect(itineraries[0].legs[1].from.name).toBe('Transfer'); + }); + + it('GTRANSIT-026: a missing or malformed step duration counts as zero, never NaN', async () => { + const noDuration = subwayRoute(); + delete noDuration.routes[0].legs[0].steps[0].staticDuration; + noDuration.routes[0].legs[0].steps[2].staticDuration = 'not-a-duration'; + fetchMock.mockResolvedValue(okJson(noDuration)); + + const { itineraries } = await new GoogleTransitProvider(googleDb()).plan({ from: FROM, to: TO }, 'en', 1); + expect(itineraries[0].legs[0].duration).toBe(0); + expect(itineraries[0].legs[2].duration).toBe(0); + expect(itineraries[0].walkSeconds).toBe(0); + }); + + it('GTRANSIT-027: a cache entry past its TTL is re-fetched, not served stale', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000); + fetchMock.mockResolvedValue(okJson(subwayRoute())); + const provider = new GoogleTransitProvider(googleDb()); + + await provider.plan({ from: FROM, to: TO }, 'en', 1); + now.mockReturnValue(1_000_000 + 6 * 60 * 1000); + await provider.plan({ from: FROM, to: TO }, 'en', 1); + expect(fetchMock).toHaveBeenCalledTimes(2); + now.mockRestore(); + }); + + it('GTRANSIT-028: the cache evicts rather than growing without bound', async () => { + fetchMock.mockResolvedValue(okJson({ places: [] })); + const provider = new GoogleTransitProvider(googleDb()); + for (let i = 0; i < 220; i++) await provider.geocode(`station-${i}`, 'en', undefined, 1); + expect(fetchMock).toHaveBeenCalledTimes(220); + + // The oldest entry is gone; the newest is still a cache hit. + await provider.geocode('station-219', 'en', undefined, 1); + expect(fetchMock).toHaveBeenCalledTimes(220); + await provider.geocode('station-0', 'en', undefined, 1); + expect(fetchMock).toHaveBeenCalledTimes(221); + }); +}); + +/** + * The Routes API returns walking as turn-by-turn navigation steps, so a single + * walk to the platform arrives as several. Everything downstream expects MOTIS's + * one-leg-per-walk shape — including transitItinerarySchema's 20-leg cap, which + * a finely-sliced walk would blow through, dropping the whole itinerary. + */ +describe('walk coalescing', () => { + /** A walk step whose polyline runs between the two given points. */ + function walkStep(seconds: number, metres: number, from: [number, number], to: [number, number]) { + return { + travelMode: 'WALK', + staticDuration: `${seconds}s`, + distanceMeters: metres, + startLocation: { latLng: { latitude: from[0], longitude: from[1] } }, + endLocation: { latLng: { latitude: to[0], longitude: to[1] } }, + polyline: { encodedPolyline: encodePolyline([from, to], 5) }, + }; + } + + function routeWithSlicedWalks() { + const route = subwayRoute(); + const steps = route.routes[0].legs[0].steps; + const rail = steps[1]; + route.routes[0].legs[0].steps = [ + walkStep(60, 70, [34.6937, 135.49], [34.6938, 135.4905]), + walkStep(60, 70, [34.6938, 135.4905], [34.69385, 135.491]), + walkStep(60, 70, [34.69385, 135.491], [34.6939, 135.4915]), + rail, + walkStep(45, 60, [34.6871, 135.5142], [34.6873, 135.5149]), + walkStep(75, 90, [34.6873, 135.5149], [34.6875, 135.5155]), + ]; + return route; + } + + it('GTRANSIT-029: a walk split across navigation steps becomes one leg', async () => { + fetchMock.mockResolvedValue(okJson(routeWithSlicedWalks())); + const { itineraries } = await new GoogleTransitProvider(googleDb()).plan({ from: FROM, to: TO }, 'en', 1); + + const legs = itineraries[0].legs; + expect(legs.map((l) => l.mode)).toEqual(['WALK', 'SUBWAY', 'WALK']); + // Durations and distances add up rather than being lost with the extra legs. + expect(legs[0].duration).toBe(180); + expect(legs[0].distance).toBe(210); + expect(legs[2].duration).toBe(120); + expect(legs[2].distance).toBe(150); + expect(itineraries[0].walkSeconds).toBe(300); + }); + + it('GTRANSIT-030: the merged walk keeps the endpoints of the whole run', async () => { + fetchMock.mockResolvedValue(okJson(routeWithSlicedWalks())); + const { itineraries } = await new GoogleTransitProvider(googleDb()).plan({ from: FROM, to: TO }, 'en', 1); + + const [opening, , closing] = itineraries[0].legs; + expect(opening.from.name).toBe('START'); + expect(opening.to.name).toBe('Nakanoshima'); + expect(closing.from.name).toBe('Temmabashi'); + expect(closing.to.name).toBe('END'); + // The merged leg starts where the run started and ends where it ended. + expect(opening.from.lat).toBeCloseTo(34.6937, 4); + expect(closing.to.lng).toBeCloseTo(135.5155, 4); + }); + + it('GTRANSIT-031: the merged walk draws the whole path, with no doubled seam', async () => { + fetchMock.mockResolvedValue(okJson(routeWithSlicedWalks())); + const { itineraries } = await new GoogleTransitProvider(googleDb()).plan({ from: FROM, to: TO }, 'en', 1); + + const points = decodePolyline(itineraries[0].legs[0].geometry ?? '', 5); + // Three two-point steps sharing their seams — four points, not six. + expect(points).toHaveLength(4); + expect(points[0][0]).toBeCloseTo(34.6937, 5); + expect(points[3][1]).toBeCloseTo(135.4915, 5); + }); + + it('GTRANSIT-032: a walk run keeps its shape when only some steps carry geometry', async () => { + const partial = routeWithSlicedWalks(); + delete partial.routes[0].legs[0].steps[1].polyline; + fetchMock.mockResolvedValue(okJson(partial)); + + const { itineraries } = await new GoogleTransitProvider(googleDb()).plan({ from: FROM, to: TO }, 'en', 1); + expect(decodePolyline(itineraries[0].legs[0].geometry ?? '', 5).length).toBeGreaterThan(0); + expect(itineraries[0].legs[0].duration).toBe(180); + }); + + it('GTRANSIT-033: a heavily sliced walk stays inside the 20-leg itinerary cap', async () => { + const many = subwayRoute(); + const rail = many.routes[0].legs[0].steps[1]; + const crumbs = Array.from({ length: 15 }, (_, i) => + walkStep(20, 25, [34.6937 + i * 0.0001, 135.49], [34.6937 + (i + 1) * 0.0001, 135.49])); + many.routes[0].legs[0].steps = [...crumbs, rail, ...crumbs]; + fetchMock.mockResolvedValue(okJson(many)); + + const { itineraries } = await new GoogleTransitProvider(googleDb()).plan({ from: FROM, to: TO }, 'en', 1); + expect(itineraries[0].legs).toHaveLength(3); + }); +}); + +/** + * The empty state reads "No connections found via ", so an empty + * result has to carry the name of whichever backend produced it — that is the + * whole point of the field. + */ +describe('provider reporting', () => { + it('GTRANSIT-036: names Google when Google answered', async () => { + fetchMock.mockResolvedValue(okJson(subwayRoute())); + const service = new TransitService(new GoogleTransitProvider(googleDb())); + const planned = await service.plan({ from: FROM, to: TO }, 'en', 1); + expect(planned.provider).toBe('google'); + + fetchMock.mockResolvedValue(okJson({ places: [] })); + expect((await service.geocode('Namba', 'en', undefined, 1)).provider).toBe('google'); + }); + + it('GTRANSIT-037: names Transitous when the silent fallback took over', async () => { + fetchMock.mockResolvedValue(okJson({ itineraries: [] })); + // Google selected, but no key resolves — the request goes to Transitous and + // says so, instead of leaving an empty result to be blamed on Google. + const service = new TransitService(new GoogleTransitProvider(stubDb({ transit_provider: 'google' }))); + const planned = await service.plan({ from: '48.8583,2.3470', to: '48.8809,2.3553' }, 'en', 1); + expect(planned.provider).toBe('transitous'); + expect(planned.itineraries).toEqual([]); + expect(String(fetchMock.mock.calls[0][0])).toContain('transitous'); + }); + + it('GTRANSIT-038: a cached answer still names its backend', async () => { + fetchMock.mockResolvedValue(okJson({ itineraries: [] })); + const service = new TransitService(new GoogleTransitProvider(stubDb({}))); + const first = await service.plan({ from: '48.1,2.1', to: '48.2,2.2' }, 'en', 1); + const second = await service.plan({ from: '48.1,2.1', to: '48.2,2.2' }, 'en', 1); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(second.provider).toBe(first.provider); + expect(second.provider).toBe('transitous'); + }); +}); + +describe('polyline codec', () => { + it('GTRANSIT-034: round-trips coordinates at the precision it was given', () => { + const path: [number, number][] = [[34.6937, 135.49], [34.69385, 135.4915], [-34.6, -135.5155]]; + const back = decodePolyline(encodePolyline(path, 5), 5); + expect(back).toHaveLength(3); + for (const [i, [lat, lng]] of path.entries()) { + expect(back[i][0]).toBeCloseTo(lat, 5); + expect(back[i][1]).toBeCloseTo(lng, 5); + } + }); + + it('GTRANSIT-035: a truncated polyline decodes to what it can, without hanging', () => { + const encoded = encodePolyline([[34.6937, 135.49], [34.7, 135.5]], 5); + expect(decodePolyline(encoded.slice(0, 3), 5).length).toBeLessThanOrEqual(1); + expect(decodePolyline('', 5)).toEqual([]); + }); +}); diff --git a/server/tests/unit/nest/transit.service.test.ts b/server/tests/unit/nest/transit.service.test.ts index 7940918e4..77c39ed2a 100644 --- a/server/tests/unit/nest/transit.service.test.ts +++ b/server/tests/unit/nest/transit.service.test.ts @@ -10,7 +10,9 @@ * every case uses its own coordinates/query to stay isolated. */ import { deriveTransitStats, type TransitLeg } from '../../../src/nest/transit/transit.helpers'; +import { GoogleTransitProvider } from '../../../src/nest/transit/google-transit.provider'; import { TransitService } from '../../../src/nest/transit/transit.service'; +import type { DatabaseService } from '../../../src/nest/database/database.service'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -21,7 +23,10 @@ vi.mock('../../../src/app-config', async (importOriginal) => { vi.mock('../../../src/nest/maps/maps.helpers', () => ({ buildUserAgent: () => 'TREK-Test-UA' })); const fetchMock = vi.fn(); -const svc = new TransitService(); +// No `transit_provider` row means Transitous, so every case below keeps +// exercising the MOTIS path — the Google branch has its own suite. +const db = { get: () => undefined, run: () => undefined } as unknown as DatabaseService; +const svc = new TransitService(new GoogleTransitProvider(db)); beforeEach(() => { vi.stubGlobal('fetch', fetchMock); diff --git a/shared/src/admin/admin.schema.ts b/shared/src/admin/admin.schema.ts index d0482557e..737ba7bbc 100644 --- a/shared/src/admin/admin.schema.ts +++ b/shared/src/admin/admin.schema.ts @@ -110,3 +110,28 @@ export const adminTestNotificationRequestSchema = z.object({ inApp: z.union([z.boolean(), z.record(z.string(), z.unknown())]).optional(), }); export type AdminTestNotificationRequest = z.infer; + +// Which backend answers /api/transit (#1699). Transitous stays the default and +// the fallback: it is free and keyless, so an install that never opens this +// switch — or that picks Google without a key — keeps costing nothing. +export const TRANSIT_PROVIDERS = ['transitous', 'google'] as const; +export type TransitProvider = (typeof TRANSIT_PROVIDERS)[number]; + +export const adminTransitProviderRequestSchema = z.object({ + provider: z.enum(TRANSIT_PROVIDERS), +}); +export type AdminTransitProviderRequest = z.infer; + +/** + * Which of the three credential slots a third-party key resolved out of — + * operator env, the instance-wide row, or the caller's own. The server's + * ApiKeySource aliases this rather than restating it: the admin transit + * response puts the value on the wire, and two copies of the union would be + * exactly the hand-mirrored contract the project forbids. + * + * `null` on the wire means no key resolved at all. + */ +export const API_KEY_SOURCES = ['operator-env', 'instance', 'user-row'] as const; +export type ApiKeySource = (typeof API_KEY_SOURCES)[number]; +/** The nullable form the admin transit-provider response carries. */ +export type TransitKeySource = ApiKeySource | null; diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index 79cea6400..0a08927f2 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -145,6 +145,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'إثراء الأماكن', 'admin.placesEnrich.subtitle': 'يعرض صورًا ووصفًا أثناء إضافة مكان. تُستخدم ويكيبيديا وOpenStreetMap دائمًا، وتُضاف Google عند تفعيل صور الأماكن أو تفاصيل الأماكن.', + 'admin.transitProvider.title': 'مزود النقل العام', + 'admin.transitProvider.subtitle': 'الخدمة التي تجيب على بحث النقل العام.', + 'admin.transitProvider.transitous': 'Transitous (مجاني)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': 'تغذيات GTFS مجتمعية. مجانية وبدون مفتاح، وأفضل تغطية في أوروبا.', + 'admin.transitProvider.googleHint': 'يستخدم مفتاح Google أعلاه للمناطق التي لا تتوفر لها بيانات في Transitous. يُحاسب على كل بحث — ويُستخدم Transitous ما دام لا يوجد مفتاح.', + 'admin.transitProvider.noKeyWarning': 'تم اختيار Google، لكن لا يوجد مفتاح Google مُهيأ — لا يزال بحث النقل يستخدم Transitous. أضف مفتاحًا ضمن مفاتيح API أعلاه.', + 'admin.transitProvider.personalKeyWarning': 'مفتاح Google الخاص بك وحده مضبوط، لذا يعود بحث النقل لبقية الأعضاء إلى Transitous. احفظ المفتاح أعلاه كمسؤول لتطبيقه على مستوى المثيل.', 'admin.bagTracking.title': 'تتبع الأمتعة', 'admin.bagTracking.subtitle': 'تفعيل الوزن وتعيين الأمتعة للعناصر', 'admin.collab.chat.title': 'الدردشة', diff --git a/shared/src/i18n/ar/trip.ts b/shared/src/i18n/ar/trip.ts index 686bda08f..80fab2148 100644 --- a/shared/src/i18n/ar/trip.ts +++ b/shared/src/i18n/ar/trip.ts @@ -63,6 +63,7 @@ const trip: TranslationStrings = { 'transit.search': 'بحث', 'transit.searching': 'جارٍ البحث…', 'transit.searchError': 'فشل البحث عن المسار. يُرجى المحاولة مرة أخرى.', + 'transit.noResultsVia': 'لم يتم العثور على أي رحلات عبر {provider}. جرّب وقتًا أو عوامل تصفية مختلفة.', 'transit.noResults': 'لم يُعثر على أي رحلات. جرّب وقتًا أو عوامل تصفية مختلفة.', 'transit.direct': 'مباشر', 'transit.transfers': '{count} تحويلات', diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 2c01c90f7..2dd298bdf 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -124,6 +124,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'Enriquecimento de lugares', 'admin.placesEnrich.subtitle': 'Mostra imagens e uma descrição ao adicionar um lugar. Wikipédia e OpenStreetMap são sempre usados; o Google entra quando Fotos de lugares ou Detalhes de lugares estão ativos.', + 'admin.transitProvider.title': 'Provedor de transporte público', + 'admin.transitProvider.subtitle': 'Qual serviço responde à busca de transporte público.', + 'admin.transitProvider.transitous': 'Transitous (grátis)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': 'Feeds GTFS da comunidade. Gratuito e sem chave, com a melhor cobertura na Europa.', + 'admin.transitProvider.googleHint': 'Usa a chave do Google acima, para regiões sem dados no Transitous. Cobrado por busca — o Transitous é usado enquanto nenhuma chave estiver configurada.', + 'admin.transitProvider.noKeyWarning': 'O Google está selecionado, mas nenhuma chave do Google está configurada — a busca de transporte ainda usa o Transitous. Adicione uma chave em Chaves de API acima.', + 'admin.transitProvider.personalKeyWarning': 'Apenas a sua própria chave do Google está definida, então a busca dos outros membros ainda cai no Transitous. Salve a chave acima como admin para aplicá-la a toda a instância.', 'admin.bagTracking.title': 'Rastreamento de malas', 'admin.bagTracking.subtitle': 'Ativar peso e atribuição de mala para itens da lista', 'admin.collab.chat.title': 'Chat', diff --git a/shared/src/i18n/br/trip.ts b/shared/src/i18n/br/trip.ts index 98da34897..48c6dbc4e 100644 --- a/shared/src/i18n/br/trip.ts +++ b/shared/src/i18n/br/trip.ts @@ -63,6 +63,7 @@ const trip: TranslationStrings = { 'transit.search': 'Buscar', 'transit.searching': 'Buscando…', 'transit.searchError': 'Falha na busca de rota. Tente novamente.', + 'transit.noResultsVia': 'Nenhuma conexão encontrada via {provider}. Tente outro horário ou outros filtros.', 'transit.noResults': 'Nenhuma conexão encontrada. Tente outro horário ou filtros.', 'transit.direct': 'Direto', 'transit.transfers': '{count} baldeações', diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index 81d542056..5d9adc30b 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -128,6 +128,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'Enriquiment de llocs', 'admin.placesEnrich.subtitle': "Mostra imatges i una descripció en afegir un lloc. Wikipedia i OpenStreetMap s'usen sempre; Google s'hi afegeix si Fotos de llocs o Detalls de llocs estan actius.", + 'admin.transitProvider.title': 'Proveïdor de transport públic', + 'admin.transitProvider.subtitle': 'Quin servei respon la cerca de transport públic.', + 'admin.transitProvider.transitous': 'Transitous (gratuït)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': 'Fluxos GTFS de la comunitat. Gratuït i sense clau, amb la millor cobertura a Europa.', + 'admin.transitProvider.googleHint': 'Utilitza la clau de Google anterior, per a regions sense dades a Transitous. Es factura per cerca: mentre no hi hagi clau s\'utilitza Transitous.', + 'admin.transitProvider.noKeyWarning': 'Google està seleccionat, però no hi ha cap clau de Google configurada: la cerca de transport encara utilitza Transitous. Afegeix una clau a Claus d\'API a dalt.', + 'admin.transitProvider.personalKeyWarning': 'Només hi ha la teva pròpia clau de Google, de manera que la cerca dels altres membres continua recorrent a Transitous. Desa la clau a dalt com a administrador per aplicar-la a tota la instància.', 'admin.bagTracking.title': "Seguiment d'equipatge", 'admin.bagTracking.subtitle': "Activar pes i assignació d'equipatge per a articles de la llista", 'admin.collab.chat.title': 'Xat', diff --git a/shared/src/i18n/ca/trip.ts b/shared/src/i18n/ca/trip.ts index d67e245e7..caa24f908 100644 --- a/shared/src/i18n/ca/trip.ts +++ b/shared/src/i18n/ca/trip.ts @@ -59,6 +59,7 @@ const trip: TranslationStrings = { 'transit.search': 'Cercar', 'transit.searching': 'Cercant…', 'transit.searchError': 'Ha fallat la cerca de la ruta. Torna-ho a provar.', + 'transit.noResultsVia': 'No s\'ha trobat cap connexió mitjançant {provider}. Prova una altra hora o altres filtres.', 'transit.noResults': "No s'han trobat connexions. Prova amb un altre horari o filtres.", 'transit.direct': 'Directe', 'transit.transfers': '{count} transbords', diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index 3337cc8b4..6cca82bd5 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -125,6 +125,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'Obohacení míst', 'admin.placesEnrich.subtitle': 'Zobrazí obrázky a popis při přidávání místa. Wikipedie a OpenStreetMap se používají vždy; Google se přidá, když jsou zapnuté Fotky míst nebo Detaily míst.', + 'admin.transitProvider.title': 'Poskytovatel veřejné dopravy', + 'admin.transitProvider.subtitle': 'Která služba odpovídá na vyhledávání veřejné dopravy.', + 'admin.transitProvider.transitous': 'Transitous (zdarma)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': 'Komunitní GTFS zdroje. Zdarma a bez klíče, s nejlepším pokrytím v Evropě.', + 'admin.transitProvider.googleHint': 'Použije výše uvedený klíč Google pro regiony, pro které Transitous nemá data. Účtuje se za každé hledání – dokud není klíč nastaven, používá se Transitous.', + 'admin.transitProvider.noKeyWarning': 'Je vybrán Google, ale není nastaven žádný klíč Google – vyhledávání dopravy stále používá Transitous. Přidejte klíč v sekci API klíče výše.', + 'admin.transitProvider.personalKeyWarning': 'Je nastaven jen váš vlastní klíč Google, takže ostatním členům se vyhledávání stále vrací k Transitous. Uložte klíč výše jako správce, aby platil pro celou instanci.', 'admin.bagTracking.title': 'Sledování zavazadel', 'admin.bagTracking.subtitle': 'Povolit váhu a přiřazení k zavazadlům u položek balení', 'admin.collab.chat.title': 'Chat', diff --git a/shared/src/i18n/cs/trip.ts b/shared/src/i18n/cs/trip.ts index e7855ab2a..5298554e3 100644 --- a/shared/src/i18n/cs/trip.ts +++ b/shared/src/i18n/cs/trip.ts @@ -63,6 +63,7 @@ const trip: TranslationStrings = { 'transit.search': 'Hledat', 'transit.searching': 'Hledání…', 'transit.searchError': 'Vyhledání spojení selhalo. Zkuste to prosím znovu.', + 'transit.noResultsVia': 'Přes {provider} nebylo nalezeno žádné spojení. Zkuste jiný čas nebo filtry.', 'transit.noResults': 'Nebyla nalezena žádná spojení. Zkuste jiný čas nebo filtry.', 'transit.direct': 'Přímé', 'transit.transfers': '{count} přestupů', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index 9e0e72d3e..c3debb0b9 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -127,6 +127,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'Ortsanreicherung', 'admin.placesEnrich.subtitle': 'Bilder und eine Beschreibung beim Hinzufügen eines Ortes anzeigen. Wikipedia und OpenStreetMap werden immer genutzt; Google kommt zusätzlich dazu, wenn Ortsfotos oder Ortsdetails aktiv sind.', + 'admin.transitProvider.title': 'Verkehrsanbieter', + 'admin.transitProvider.subtitle': 'Welcher Dienst die ÖPNV-Suche beantwortet.', + 'admin.transitProvider.transitous': 'Transitous (kostenlos)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': 'Community-GTFS-Feeds. Kostenlos und ohne Schlüssel, mit der besten Abdeckung in Europa.', + 'admin.transitProvider.googleHint': 'Nutzt den obigen Google-Schlüssel, für Regionen ohne Transitous-Daten. Wird pro Suche abgerechnet – solange kein Schlüssel gesetzt ist, wird Transitous verwendet.', + 'admin.transitProvider.noKeyWarning': 'Google ist ausgewählt, aber es ist kein Google-Schlüssel hinterlegt — die ÖPNV-Suche nutzt weiterhin Transitous. Trage oben unter API-Schlüssel einen Schlüssel ein.', + 'admin.transitProvider.personalKeyWarning': 'Es ist nur dein eigener Google-Schlüssel gesetzt, daher fällt die Suche der anderen Mitglieder weiterhin auf Transitous zurück. Speichere den Schlüssel oben als Admin, damit er instanzweit gilt.', 'admin.bagTracking.title': 'Gepäck-Tracking', 'admin.bagTracking.subtitle': 'Gewicht und Gepäckstück-Zuordnung für Packlisteneinträge aktivieren', 'admin.collab.chat.title': 'Chat', diff --git a/shared/src/i18n/de/trip.ts b/shared/src/i18n/de/trip.ts index d91276dea..f49779719 100644 --- a/shared/src/i18n/de/trip.ts +++ b/shared/src/i18n/de/trip.ts @@ -63,6 +63,7 @@ const trip: TranslationStrings = { 'transit.search': 'Suchen', 'transit.searching': 'Suche …', 'transit.searchError': 'Routensuche fehlgeschlagen. Bitte erneut versuchen.', + 'transit.noResultsVia': 'Keine Verbindungen über {provider} gefunden. Versuche eine andere Zeit oder andere Filter.', 'transit.noResults': 'Keine Verbindungen gefunden. Andere Zeit oder Filter probieren.', 'transit.direct': 'Direkt', 'transit.transfers': '{count} Umstiege', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index e874bec91..4bbb276a6 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -166,6 +166,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'Place Enrichment', 'admin.placesEnrich.subtitle': 'Show pictures and a description while adding a place. Wikipedia and OpenStreetMap are always used; Google is added on top when Place Photos or Place Details are on.', + 'admin.transitProvider.title': 'Transit Provider', + 'admin.transitProvider.subtitle': 'Which service answers public transit search.', + 'admin.transitProvider.transitous': 'Transitous (free)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': 'Community GTFS feeds. Free and keyless, with the best coverage in Europe.', + 'admin.transitProvider.googleHint': 'Uses the Google API key above, for regions Transitous has no data for. Billed per search — Transitous is used while no key is set.', + 'admin.transitProvider.noKeyWarning': 'Google is selected, but no Google API key is configured — transit search is still using Transitous. Add a key under API Keys above.', + 'admin.transitProvider.personalKeyWarning': 'Only your own Google key is set, so other members\' transit searches still fall back to Transitous. Save the key above as an admin to apply it instance-wide.', 'admin.bagTracking.title': 'Bag Tracking', 'admin.bagTracking.subtitle': 'Enable weight and bag assignment for packing items', 'admin.collab.chat.title': 'Chat', diff --git a/shared/src/i18n/en/trip.ts b/shared/src/i18n/en/trip.ts index fc4e88d28..1c63d19c4 100644 --- a/shared/src/i18n/en/trip.ts +++ b/shared/src/i18n/en/trip.ts @@ -63,6 +63,7 @@ const trip: TranslationStrings = { 'transit.search': 'Search', 'transit.searching': 'Searching…', 'transit.searchError': 'Route search failed. Please try again.', + 'transit.noResultsVia': 'No connections found via {provider}. Try a different time or filters.', 'transit.noResults': 'No connections found. Try a different time or filters.', 'transit.direct': 'Direct', 'transit.transfers': '{count} transfers', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index 12656ba11..47a326d2a 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -126,6 +126,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'Enriquecimiento de lugares', 'admin.placesEnrich.subtitle': 'Muestra imágenes y una descripción al añadir un lugar. Wikipedia y OpenStreetMap se usan siempre; Google se añade cuando Fotos de lugares o Detalles de lugares están activados.', + 'admin.transitProvider.title': 'Proveedor de transporte público', + 'admin.transitProvider.subtitle': 'Qué servicio responde a la búsqueda de transporte público.', + 'admin.transitProvider.transitous': 'Transitous (gratis)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': 'Fuentes GTFS de la comunidad. Gratis y sin clave, con la mejor cobertura en Europa.', + 'admin.transitProvider.googleHint': 'Usa la clave de Google anterior, para regiones sin datos en Transitous. Se cobra por búsqueda: mientras no haya clave se usa Transitous.', + 'admin.transitProvider.noKeyWarning': 'Google está seleccionado, pero no hay ninguna clave de Google configurada: la búsqueda de transporte sigue usando Transitous. Añade una clave en Claves de API arriba.', + 'admin.transitProvider.personalKeyWarning': 'Solo está configurada tu propia clave de Google, así que la búsqueda de los demás miembros sigue recurriendo a Transitous. Guarda la clave arriba como administrador para aplicarla a toda la instancia.', 'admin.bagTracking.title': 'Seguimiento de equipaje', 'admin.bagTracking.subtitle': 'Activar peso y asignación de equipaje para artículos de la lista', 'admin.collab.chat.title': 'Chat', diff --git a/shared/src/i18n/es/trip.ts b/shared/src/i18n/es/trip.ts index 03f642768..86ce081f0 100644 --- a/shared/src/i18n/es/trip.ts +++ b/shared/src/i18n/es/trip.ts @@ -63,6 +63,7 @@ const trip: TranslationStrings = { 'transit.search': 'Buscar', 'transit.searching': 'Buscando…', 'transit.searchError': 'No se pudo buscar la ruta. Inténtalo de nuevo.', + 'transit.noResultsVia': 'No se han encontrado conexiones mediante {provider}. Prueba con otra hora u otros filtros.', 'transit.noResults': 'No se encontraron conexiones. Prueba con otra hora u otros filtros.', 'transit.direct': 'Directo', 'transit.transfers': '{count} transbordos', diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index 2ad349276..7fc42a24c 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -128,6 +128,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'Enrichissement des lieux', 'admin.placesEnrich.subtitle': "Affiche des images et une description lors de l'ajout d'un lieu. Wikipédia et OpenStreetMap sont toujours utilisés ; Google s'y ajoute si Photos de lieux ou Détails de lieux sont activés.", + 'admin.transitProvider.title': 'Fournisseur de transports en commun', + 'admin.transitProvider.subtitle': 'Quel service répond à la recherche de transports en commun.', + 'admin.transitProvider.transitous': 'Transitous (gratuit)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': 'Flux GTFS communautaires. Gratuit et sans clé, avec la meilleure couverture en Europe.', + 'admin.transitProvider.googleHint': 'Utilise la clé Google ci-dessus, pour les régions sans données Transitous. Facturé à la recherche — tant qu\'aucune clé n\'est définie, Transitous est utilisé.', + 'admin.transitProvider.noKeyWarning': 'Google est sélectionné, mais aucune clé Google n\'est configurée — la recherche de transports utilise toujours Transitous. Ajoutez une clé dans Clés d\'API ci-dessus.', + 'admin.transitProvider.personalKeyWarning': 'Seule votre propre clé Google est définie : la recherche des autres membres continue de basculer sur Transitous. Enregistrez la clé ci-dessus en tant qu\'administrateur pour l\'appliquer à toute l\'instance.', 'admin.bagTracking.title': 'Suivi des bagages', 'admin.bagTracking.subtitle': "Activer le poids et l'attribution de bagages pour les articles", 'admin.collab.chat.title': 'Chat', diff --git a/shared/src/i18n/fr/trip.ts b/shared/src/i18n/fr/trip.ts index 0d0df8ffb..50aa864fe 100644 --- a/shared/src/i18n/fr/trip.ts +++ b/shared/src/i18n/fr/trip.ts @@ -63,6 +63,7 @@ const trip: TranslationStrings = { 'transit.search': 'Rechercher', 'transit.searching': 'Recherche…', 'transit.searchError': "Échec de la recherche d'itinéraire. Veuillez réessayer.", + 'transit.noResultsVia': 'Aucune liaison trouvée via {provider}. Essayez une autre heure ou d\'autres filtres.', 'transit.noResults': "Aucune connexion trouvée. Essayez un autre horaire ou d'autres filtres.", 'transit.direct': 'Direct', 'transit.transfers': '{count} correspondances', diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index 5a3f82d92..36f7e0be6 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -173,6 +173,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'Εμπλουτισμός τοποθεσιών', 'admin.placesEnrich.subtitle': 'Εμφανίζει εικόνες και περιγραφή κατά την προσθήκη τοποθεσίας. Η Wikipedia και το OpenStreetMap χρησιμοποιούνται πάντα· η Google προστίθεται όταν είναι ενεργές οι Φωτογραφίες ή οι Λεπτομέρειες τοποθεσίας.', + 'admin.transitProvider.title': 'Πάροχος μέσων μεταφοράς', + 'admin.transitProvider.subtitle': 'Ποια υπηρεσία απαντά στην αναζήτηση μέσων μεταφοράς.', + 'admin.transitProvider.transitous': 'Transitous (δωρεάν)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': 'Κοινοτικές ροές GTFS. Δωρεάν και χωρίς κλειδί, με την καλύτερη κάλυψη στην Ευρώπη.', + 'admin.transitProvider.googleHint': 'Χρησιμοποιεί το παραπάνω κλειδί Google, για περιοχές χωρίς δεδομένα στο Transitous. Χρεώνεται ανά αναζήτηση — όσο δεν υπάρχει κλειδί, χρησιμοποιείται το Transitous.', + 'admin.transitProvider.noKeyWarning': 'Έχει επιλεγεί το Google, αλλά δεν έχει ρυθμιστεί κλειδί Google — η αναζήτηση μέσων μεταφοράς εξακολουθεί να χρησιμοποιεί το Transitous. Προσθέστε κλειδί στα Κλειδιά API παραπάνω.', + 'admin.transitProvider.personalKeyWarning': 'Έχει οριστεί μόνο το δικό σας κλειδί Google, οπότε η αναζήτηση των υπόλοιπων μελών επιστρέφει στο Transitous. Αποθηκεύστε το κλειδί παραπάνω ως διαχειριστής για να ισχύει σε όλη την εγκατάσταση.', 'admin.bagTracking.title': 'Παρακολούθηση Αποσκευών', 'admin.bagTracking.subtitle': 'Ενεργοποίηση βάρους και ανάθεσης τσάντας για αντικείμενα πακεταρίσματος', 'admin.collab.chat.title': 'Συνομιλία', diff --git a/shared/src/i18n/gr/trip.ts b/shared/src/i18n/gr/trip.ts index d85905361..2a2f2956c 100644 --- a/shared/src/i18n/gr/trip.ts +++ b/shared/src/i18n/gr/trip.ts @@ -63,6 +63,7 @@ const trip: TranslationStrings = { 'transit.search': 'Αναζήτηση', 'transit.searching': 'Αναζήτηση…', 'transit.searchError': 'Η αναζήτηση διαδρομής απέτυχε. Δοκιμάστε ξανά.', + 'transit.noResultsVia': 'Δεν βρέθηκαν συνδέσεις μέσω {provider}. Δοκιμάστε άλλη ώρα ή άλλα φίλτρα.', 'transit.noResults': 'Δεν βρέθηκαν συνδέσεις. Δοκιμάστε άλλη ώρα ή φίλτρα.', 'transit.direct': 'Απευθείας', 'transit.transfers': '{count} μετεπιβιβάσεις', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index 07849e66d..e55bc61ef 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -127,6 +127,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'Helyek gazdagítása', 'admin.placesEnrich.subtitle': 'Képeket és leírást mutat hely hozzáadásakor. A Wikipédia és az OpenStreetMap mindig használatban van; a Google akkor jön hozzá, ha a Helyfotók vagy a Helyadatok be vannak kapcsolva.', + 'admin.transitProvider.title': 'Tömegközlekedési szolgáltató', + 'admin.transitProvider.subtitle': 'Melyik szolgáltatás válaszol a tömegközlekedési keresésre.', + 'admin.transitProvider.transitous': 'Transitous (ingyenes)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': 'Közösségi GTFS adatforrások. Ingyenes és kulcs nélküli, Európában a legjobb lefedettséggel.', + 'admin.transitProvider.googleHint': 'A fenti Google-kulcsot használja azokhoz a régiókhoz, amelyekhez a Transitousnak nincs adata. Keresésenként számlázódik – amíg nincs kulcs, a Transitous fut.', + 'admin.transitProvider.noKeyWarning': 'A Google van kiválasztva, de nincs beállítva Google-kulcs – a közlekedési keresés továbbra is a Transitous-t használja. Adj hozzá kulcsot a fenti API-kulcsoknál.', + 'admin.transitProvider.personalKeyWarning': 'Csak a saját Google-kulcsod van beállítva, így a többi tag keresése továbbra is a Transitous-ra esik vissza. Mentsd a kulcsot fent adminként, hogy az egész példányra érvényes legyen.', 'admin.bagTracking.title': 'Poggyászkövetés', 'admin.bagTracking.subtitle': 'Súly- és táskahozzárendelés engedélyezése csomagolási tételeknél', 'admin.collab.chat.title': 'Chat', diff --git a/shared/src/i18n/hu/trip.ts b/shared/src/i18n/hu/trip.ts index c4d60b75a..203e34c9a 100644 --- a/shared/src/i18n/hu/trip.ts +++ b/shared/src/i18n/hu/trip.ts @@ -63,6 +63,7 @@ const trip: TranslationStrings = { 'transit.search': 'Keresés', 'transit.searching': 'Keresés…', 'transit.searchError': 'Az útvonalkeresés nem sikerült. Kérjük, próbáld újra.', + 'transit.noResultsVia': 'Nem található összeköttetés a következőn keresztül: {provider}. Próbálj más időpontot vagy szűrőket.', 'transit.noResults': 'Nem található kapcsolat. Próbálj másik időpontot vagy szűrőket.', 'transit.direct': 'Közvetlen', 'transit.transfers': '{count} átszállás', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index 78d33e604..4cb7d3047 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -169,6 +169,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'Pengayaan tempat', 'admin.placesEnrich.subtitle': 'Menampilkan gambar dan deskripsi saat menambahkan tempat. Wikipedia dan OpenStreetMap selalu digunakan; Google ditambahkan bila Foto Tempat atau Detail Tempat aktif.', + 'admin.transitProvider.title': 'Penyedia transportasi umum', + 'admin.transitProvider.subtitle': 'Layanan mana yang menjawab pencarian transportasi umum.', + 'admin.transitProvider.transitous': 'Transitous (gratis)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': 'Umpan GTFS komunitas. Gratis dan tanpa kunci, dengan cakupan terbaik di Eropa.', + 'admin.transitProvider.googleHint': 'Menggunakan kunci Google di atas, untuk wilayah yang tidak memiliki data Transitous. Ditagih per pencarian — selama belum ada kunci, Transitous yang dipakai.', + 'admin.transitProvider.noKeyWarning': 'Google dipilih, tetapi belum ada kunci Google yang dikonfigurasi — pencarian transportasi masih memakai Transitous. Tambahkan kunci di Kunci API di atas.', + 'admin.transitProvider.personalKeyWarning': 'Hanya kunci Google milikmu yang disetel, sehingga pencarian anggota lain tetap kembali ke Transitous. Simpan kunci di atas sebagai admin agar berlaku untuk seluruh instance.', 'admin.bagTracking.title': 'Pelacak Tas', 'admin.bagTracking.subtitle': 'Aktifkan berat dan penugasan tas untuk item packing', 'admin.collab.chat.title': 'Chat', diff --git a/shared/src/i18n/id/trip.ts b/shared/src/i18n/id/trip.ts index ff4a341bc..0cccd756a 100644 --- a/shared/src/i18n/id/trip.ts +++ b/shared/src/i18n/id/trip.ts @@ -63,6 +63,7 @@ const trip: TranslationStrings = { 'transit.search': 'Cari', 'transit.searching': 'Mencari…', 'transit.searchError': 'Pencarian rute gagal. Silakan coba lagi.', + 'transit.noResultsVia': 'Tidak ada koneksi yang ditemukan via {provider}. Coba waktu atau filter lain.', 'transit.noResults': 'Tidak ada koneksi ditemukan. Coba waktu atau filter lain.', 'transit.direct': 'Langsung', 'transit.transfers': '{count} transit', diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index db0099017..612cd081a 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -126,6 +126,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'Arricchimento luoghi', 'admin.placesEnrich.subtitle': 'Mostra immagini e una descrizione mentre aggiungi un luogo. Wikipedia e OpenStreetMap sono sempre usati; Google si aggiunge se Foto luoghi o Dettagli luoghi sono attivi.', + 'admin.transitProvider.title': 'Provider del trasporto pubblico', + 'admin.transitProvider.subtitle': 'Quale servizio risponde alla ricerca di trasporto pubblico.', + 'admin.transitProvider.transitous': 'Transitous (gratuito)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': 'Feed GTFS della comunità. Gratuito e senza chiave, con la migliore copertura in Europa.', + 'admin.transitProvider.googleHint': 'Usa la chiave Google qui sopra, per le regioni senza dati Transitous. Addebitato per ricerca: finché non è impostata una chiave viene usato Transitous.', + 'admin.transitProvider.noKeyWarning': 'Google è selezionato, ma non è configurata alcuna chiave Google: la ricerca del trasporto usa ancora Transitous. Aggiungi una chiave in Chiavi API qui sopra.', + 'admin.transitProvider.personalKeyWarning': 'È impostata solo la tua chiave Google, quindi la ricerca degli altri membri continua a ripiegare su Transitous. Salva la chiave qui sopra come amministratore per applicarla all\'intera istanza.', 'admin.bagTracking.title': 'Tracciamento valigia', 'admin.bagTracking.subtitle': "Abilita il peso e l'assegnazione della valigia per gli elementi della lista valigia", 'admin.collab.chat.title': 'Chat', diff --git a/shared/src/i18n/it/trip.ts b/shared/src/i18n/it/trip.ts index 8526e85b2..a32af2a24 100644 --- a/shared/src/i18n/it/trip.ts +++ b/shared/src/i18n/it/trip.ts @@ -63,6 +63,7 @@ const trip: TranslationStrings = { 'transit.search': 'Cerca', 'transit.searching': 'Ricerca in corso…', 'transit.searchError': 'Ricerca del percorso non riuscita. Riprova.', + 'transit.noResultsVia': 'Nessun collegamento trovato tramite {provider}. Prova un altro orario o altri filtri.', 'transit.noResults': 'Nessun collegamento trovato. Prova un altro orario o filtri.', 'transit.direct': 'Diretto', 'transit.transfers': '{count} cambi', diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index 9f75ec5d5..be8b235b9 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -158,6 +158,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': '場所の情報補完', 'admin.placesEnrich.subtitle': '場所を追加するときに写真と説明を表示します。WikipediaとOpenStreetMapは常に使用され、「場所の写真」または「場所の詳細」が有効な場合はGoogleも追加されます。', + 'admin.transitProvider.title': '公共交通のプロバイダー', + 'admin.transitProvider.subtitle': '公共交通の検索に応答するサービス。', + 'admin.transitProvider.transitous': 'Transitous(無料)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': 'コミュニティの GTFS フィード。無料でキー不要、ヨーロッパのカバー率が最も高い。', + 'admin.transitProvider.googleHint': '上記の Google キーを使用し、Transitous にデータがない地域に対応します。検索ごとに課金されます。キーが未設定の間は Transitous を使用します。', + 'admin.transitProvider.noKeyWarning': 'Google が選択されていますが、Google キーが設定されていません。公共交通の検索は引き続き Transitous を使用します。上の「API キー」でキーを追加してください。', + 'admin.transitProvider.personalKeyWarning': 'あなた自身の Google キーのみが設定されているため、他のメンバーの検索は引き続き Transitous になります。インスタンス全体に適用するには、管理者として上でキーを保存してください。', 'admin.bagTracking.title': 'バッグ管理', 'admin.bagTracking.subtitle': '持ち物の重量とバッグ割り当てを有効化', 'admin.collab.chat.title': 'チャット', diff --git a/shared/src/i18n/ja/trip.ts b/shared/src/i18n/ja/trip.ts index c11cbe913..73334682c 100644 --- a/shared/src/i18n/ja/trip.ts +++ b/shared/src/i18n/ja/trip.ts @@ -63,6 +63,7 @@ const trip: TranslationStrings = { 'transit.search': '検索', 'transit.searching': '検索中…', 'transit.searchError': 'ルート検索に失敗しました。もう一度お試しください。', + 'transit.noResultsVia': '{provider} では経路が見つかりませんでした。時間やフィルターを変えてお試しください。', 'transit.noResults': '接続が見つかりませんでした。時間やフィルターを変更してお試しください。', 'transit.direct': '直通', 'transit.transfers': '乗り換え{count}回', diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index d9f496fab..20dab5251 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -164,6 +164,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': '장소 정보 보강', 'admin.placesEnrich.subtitle': '장소를 추가할 때 사진과 설명을 표시합니다. Wikipedia와 OpenStreetMap은 항상 사용되며, 장소 사진 또는 장소 세부정보가 켜져 있으면 Google도 함께 사용됩니다.', + 'admin.transitProvider.title': '대중교통 제공자', + 'admin.transitProvider.subtitle': '대중교통 검색에 응답하는 서비스입니다.', + 'admin.transitProvider.transitous': 'Transitous (무료)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': '커뮤니티 GTFS 피드. 무료이며 키가 필요 없고 유럽 커버리지가 가장 좋습니다.', + 'admin.transitProvider.googleHint': '위의 Google 키를 사용하여 Transitous에 데이터가 없는 지역을 처리합니다. 검색당 과금되며, 키가 없으면 Transitous를 사용합니다.', + 'admin.transitProvider.noKeyWarning': 'Google이 선택되었지만 Google 키가 설정되지 않았습니다 — 대중교통 검색은 여전히 Transitous를 사용합니다. 위의 API 키에서 키를 추가하세요.', + 'admin.transitProvider.personalKeyWarning': '본인의 Google 키만 설정되어 있어 다른 멤버의 검색은 여전히 Transitous로 대체됩니다. 인스턴스 전체에 적용하려면 관리자로 위에서 키를 저장하세요.', 'admin.bagTracking.title': '가방 추적', 'admin.bagTracking.subtitle': '짐 항목에 무게 및 가방 배정을 활성화합니다', 'admin.collab.chat.title': '채팅', diff --git a/shared/src/i18n/ko/trip.ts b/shared/src/i18n/ko/trip.ts index 71f0c02e9..e915ef9d3 100644 --- a/shared/src/i18n/ko/trip.ts +++ b/shared/src/i18n/ko/trip.ts @@ -63,6 +63,7 @@ const trip: TranslationStrings = { 'transit.search': '검색', 'transit.searching': '검색 중…', 'transit.searchError': '경로 검색에 실패했습니다. 다시 시도해 주세요.', + 'transit.noResultsVia': '{provider}에서 연결편을 찾지 못했습니다. 다른 시간이나 필터를 사용해 보세요.', 'transit.noResults': '연결편을 찾을 수 없습니다. 시간이나 필터를 변경해 보세요.', 'transit.direct': '직행', 'transit.transfers': '환승 {count}회', diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index d60e27685..41feaedb0 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -127,6 +127,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'Plaatsverrijking', 'admin.placesEnrich.subtitle': "Toont afbeeldingen en een beschrijving bij het toevoegen van een plaats. Wikipedia en OpenStreetMap worden altijd gebruikt; Google komt erbij als Plaatsfoto's of Plaatsdetails aanstaan.", + 'admin.transitProvider.title': 'Ov-provider', + 'admin.transitProvider.subtitle': 'Welke dienst het ov-zoeken beantwoordt.', + 'admin.transitProvider.transitous': 'Transitous (gratis)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': 'GTFS-feeds van de community. Gratis en zonder sleutel, met de beste dekking in Europa.', + 'admin.transitProvider.googleHint': 'Gebruikt de Google-sleutel hierboven, voor regio\'s zonder Transitous-data. Wordt per zoekopdracht afgerekend — zolang er geen sleutel is, wordt Transitous gebruikt.', + 'admin.transitProvider.noKeyWarning': 'Google is geselecteerd, maar er is geen Google-sleutel ingesteld — het ov-zoeken gebruikt nog steeds Transitous. Voeg hierboven een sleutel toe bij API-sleutels.', + 'admin.transitProvider.personalKeyWarning': 'Alleen je eigen Google-sleutel is ingesteld, dus het zoeken van andere leden valt nog steeds terug op Transitous. Sla de sleutel hierboven op als beheerder om hem instantiebreed toe te passen.', 'admin.bagTracking.title': 'Bagagetracking', 'admin.bagTracking.subtitle': 'Gewicht en bagagetoewijzing inschakelen voor paklijstitems', 'admin.collab.chat.title': 'Chat', diff --git a/shared/src/i18n/nl/trip.ts b/shared/src/i18n/nl/trip.ts index ab75a829a..e661947f4 100644 --- a/shared/src/i18n/nl/trip.ts +++ b/shared/src/i18n/nl/trip.ts @@ -63,6 +63,7 @@ const trip: TranslationStrings = { 'transit.search': 'Zoeken', 'transit.searching': 'Zoeken…', 'transit.searchError': 'Routezoeken mislukt. Probeer het opnieuw.', + 'transit.noResultsVia': 'Geen verbindingen gevonden via {provider}. Probeer een andere tijd of andere filters.', 'transit.noResults': 'Geen verbindingen gevonden. Probeer een andere tijd of andere filters.', 'transit.direct': 'Direct', 'transit.transfers': '{count} keer overstappen', diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index 3d4a2f2ec..45ccb06fe 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -120,6 +120,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'Wzbogacanie miejsc', 'admin.placesEnrich.subtitle': 'Pokazuje zdjęcia i opis podczas dodawania miejsca. Wikipedia i OpenStreetMap są używane zawsze; Google dochodzi, gdy włączone są Zdjęcia miejsc lub Szczegóły miejsc.', + 'admin.transitProvider.title': 'Dostawca transportu publicznego', + 'admin.transitProvider.subtitle': 'Która usługa odpowiada na wyszukiwanie transportu publicznego.', + 'admin.transitProvider.transitous': 'Transitous (bezpłatnie)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': 'Społecznościowe źródła GTFS. Bezpłatne i bez klucza, z najlepszym zasięgiem w Europie.', + 'admin.transitProvider.googleHint': 'Używa powyższego klucza Google w regionach, dla których Transitous nie ma danych. Rozliczane za wyszukiwanie — dopóki nie ustawiono klucza, działa Transitous.', + 'admin.transitProvider.noKeyWarning': 'Wybrano Google, ale nie skonfigurowano klucza Google — wyszukiwanie transportu nadal korzysta z Transitous. Dodaj klucz w sekcji Klucze API powyżej.', + 'admin.transitProvider.personalKeyWarning': 'Ustawiono tylko Twój własny klucz Google, więc wyszukiwanie pozostałych członków nadal wraca do Transitous. Zapisz klucz powyżej jako administrator, aby obowiązywał w całej instancji.', 'admin.bagTracking.title': 'Kontrola bagażu', 'admin.bagTracking.subtitle': 'Włącz wagę i przypisywanie do toreb dla przedmiotów do pakowania', 'admin.collab.chat.title': 'Czat', diff --git a/shared/src/i18n/pl/trip.ts b/shared/src/i18n/pl/trip.ts index d43d5bbed..2d9d1b4af 100644 --- a/shared/src/i18n/pl/trip.ts +++ b/shared/src/i18n/pl/trip.ts @@ -63,6 +63,7 @@ const trip: TranslationStrings = { 'transit.search': 'Szukaj', 'transit.searching': 'Wyszukiwanie…', 'transit.searchError': 'Wyszukiwanie trasy nie powiodło się. Spróbuj ponownie.', + 'transit.noResultsVia': 'Nie znaleziono połączeń przez {provider}. Spróbuj innej godziny lub innych filtrów.', 'transit.noResults': 'Nie znaleziono połączeń. Zmień godzinę lub filtry.', 'transit.direct': 'Bezpośrednio', 'transit.transfers': 'Przesiadki: {count}', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index 75243e82c..3a0d38546 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -127,6 +127,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'Обогащение мест', 'admin.placesEnrich.subtitle': 'Показывает изображения и описание при добавлении места. Wikipedia и OpenStreetMap используются всегда; Google добавляется, если включены «Фото мест» или «Детали мест».', + 'admin.transitProvider.title': 'Поставщик общественного транспорта', + 'admin.transitProvider.subtitle': 'Какая служба отвечает на поиск общественного транспорта.', + 'admin.transitProvider.transitous': 'Transitous (бесплатно)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': 'GTFS-фиды сообщества. Бесплатно и без ключа, с лучшим покрытием в Европе.', + 'admin.transitProvider.googleHint': 'Использует указанный выше ключ Google для регионов, по которым у Transitous нет данных. Оплачивается за каждый поиск — пока ключ не задан, используется Transitous.', + 'admin.transitProvider.noKeyWarning': 'Выбран Google, но ключ Google не настроен — поиск транспорта по-прежнему использует Transitous. Добавьте ключ в разделе «Ключи API» выше.', + 'admin.transitProvider.personalKeyWarning': 'Задан только ваш собственный ключ Google, поэтому поиск других участников по-прежнему возвращается к Transitous. Сохраните ключ выше от имени администратора, чтобы он действовал для всей установки.', 'admin.bagTracking.title': 'Отслеживание багажа', 'admin.bagTracking.subtitle': 'Включить вес и привязку к багажу для вещей', 'admin.collab.chat.title': 'Чат', diff --git a/shared/src/i18n/ru/trip.ts b/shared/src/i18n/ru/trip.ts index 6d4cf06b7..03ab8fa3e 100644 --- a/shared/src/i18n/ru/trip.ts +++ b/shared/src/i18n/ru/trip.ts @@ -63,6 +63,7 @@ const trip: TranslationStrings = { 'transit.search': 'Поиск', 'transit.searching': 'Поиск…', 'transit.searchError': 'Не удалось найти маршрут. Попробуйте снова.', + 'transit.noResultsVia': 'Через {provider} маршруты не найдены. Попробуйте другое время или фильтры.', 'transit.noResults': 'Маршруты не найдены. Измените время или фильтры.', 'transit.direct': 'Без пересадок', 'transit.transfers': 'Пересадок: {count}', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index 08e344bae..c74df39bb 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -170,6 +170,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'Platsberikning', 'admin.placesEnrich.subtitle': 'Visar bilder och en beskrivning när du lägger till en plats. Wikipedia och OpenStreetMap används alltid; Google tillkommer när Platsfoton eller Platsdetaljer är på.', + 'admin.transitProvider.title': 'Kollektivtrafikleverantör', + 'admin.transitProvider.subtitle': 'Vilken tjänst som besvarar sökningar i kollektivtrafiken.', + 'admin.transitProvider.transitous': 'Transitous (gratis)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': 'GTFS-flöden från communityn. Gratis och utan nyckel, med bäst täckning i Europa.', + 'admin.transitProvider.googleHint': 'Använder Google-nyckeln ovan, för regioner som Transitous saknar data för. Debiteras per sökning – så länge ingen nyckel är angiven används Transitous.', + 'admin.transitProvider.noKeyWarning': 'Google är valt, men ingen Google-nyckel är konfigurerad – kollektivtrafiksökningen använder fortfarande Transitous. Lägg till en nyckel under API-nycklar ovan.', + 'admin.transitProvider.personalKeyWarning': 'Bara din egen Google-nyckel är angiven, så andra medlemmars sökningar faller fortfarande tillbaka på Transitous. Spara nyckeln ovan som administratör för att den ska gälla hela instansen.', 'admin.bagTracking.title': 'Spårning av väskor', 'admin.bagTracking.subtitle': 'Aktivera vikt- och väskfördelning för packningsartiklar', 'admin.collab.chat.title': 'Chatt', diff --git a/shared/src/i18n/sv/trip.ts b/shared/src/i18n/sv/trip.ts index a9b91090f..5698736a7 100644 --- a/shared/src/i18n/sv/trip.ts +++ b/shared/src/i18n/sv/trip.ts @@ -63,6 +63,7 @@ const trip: TranslationStrings = { 'transit.search': 'Sök', 'transit.searching': 'Söker…', 'transit.searchError': 'Reseökningen misslyckades. Försök igen.', + 'transit.noResultsVia': 'Inga förbindelser hittades via {provider}. Prova en annan tid eller andra filter.', 'transit.noResults': 'Inga förbindelser hittades. Prova en annan tid eller andra filter.', 'transit.direct': 'Direkt', 'transit.transfers': '{count} byten', diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index b444d0427..64e3c91d9 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -171,6 +171,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'Yer zenginleştirme', 'admin.placesEnrich.subtitle': 'Bir yer eklerken görseller ve açıklama gösterir. Wikipedia ve OpenStreetMap her zaman kullanılır; Yer Fotoğrafları veya Yer Ayrıntıları açıkken Google da eklenir.', + 'admin.transitProvider.title': 'Toplu taşıma sağlayıcısı', + 'admin.transitProvider.subtitle': 'Toplu taşıma aramasını hangi hizmetin yanıtlayacağı.', + 'admin.transitProvider.transitous': 'Transitous (ücretsiz)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': 'Topluluk GTFS akışları. Ücretsiz ve anahtarsız, Avrupa\'da en iyi kapsama.', + 'admin.transitProvider.googleHint': 'Transitous\'un verisi olmayan bölgeler için yukarıdaki Google anahtarını kullanır. Arama başına ücretlendirilir — anahtar ayarlanmadığı sürece Transitous kullanılır.', + 'admin.transitProvider.noKeyWarning': 'Google seçili, ancak yapılandırılmış bir Google anahtarı yok — toplu taşıma araması hâlâ Transitous kullanıyor. Yukarıdaki API Anahtarları bölümünden bir anahtar ekleyin.', + 'admin.transitProvider.personalKeyWarning': 'Yalnızca kendi Google anahtarınız ayarlı, bu yüzden diğer üyelerin araması hâlâ Transitous\'a düşüyor. Tüm kuruluma uygulamak için anahtarı yukarıda yönetici olarak kaydedin.', 'admin.bagTracking.title': 'Çanta Takibi', 'admin.bagTracking.subtitle': 'Paketleme öğeleri için ağırlık ve torba atamasını etkinleştirin', 'admin.collab.chat.title': 'Sohbet', diff --git a/shared/src/i18n/tr/trip.ts b/shared/src/i18n/tr/trip.ts index 81a41f0dd..dea377b73 100644 --- a/shared/src/i18n/tr/trip.ts +++ b/shared/src/i18n/tr/trip.ts @@ -63,6 +63,7 @@ const trip: TranslationStrings = { 'transit.search': 'Ara', 'transit.searching': 'Aranıyor…', 'transit.searchError': 'Rota araması başarısız oldu. Lütfen tekrar deneyin.', + 'transit.noResultsVia': '{provider} üzerinden bağlantı bulunamadı. Farklı bir saat veya filtre deneyin.', 'transit.noResults': 'Bağlantı bulunamadı. Farklı bir saat veya filtre deneyin.', 'transit.direct': 'Aktarmasız', 'transit.transfers': '{count} aktarma', diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index 8ad81d475..68d666bb4 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -127,6 +127,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'Збагачення місць', 'admin.placesEnrich.subtitle': 'Показує зображення та опис під час додавання місця. Wikipedia та OpenStreetMap використовуються завжди; Google додається, якщо ввімкнено «Фото місць» або «Деталі місць».', + 'admin.transitProvider.title': 'Постачальник громадського транспорту', + 'admin.transitProvider.subtitle': 'Яка служба відповідає на пошук громадського транспорту.', + 'admin.transitProvider.transitous': 'Transitous (безкоштовно)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': 'Спільнотні GTFS-стрічки. Безкоштовно та без ключа, з найкращим покриттям у Європі.', + 'admin.transitProvider.googleHint': 'Використовує наведений вище ключ Google для регіонів, де Transitous не має даних. Оплата за кожен пошук — доки ключ не задано, використовується Transitous.', + 'admin.transitProvider.noKeyWarning': 'Вибрано Google, але ключ Google не налаштовано — пошук транспорту досі використовує Transitous. Додайте ключ у розділі «Ключі API» вище.', + 'admin.transitProvider.personalKeyWarning': 'Задано лише ваш власний ключ Google, тож пошук інших учасників усе одно повертається до Transitous. Збережіть ключ вище як адміністратор, щоб він діяв для всієї інсталяції.', 'admin.bagTracking.title': 'Відстеження багажу', 'admin.bagTracking.subtitle': 'Увімкнути вагу та прив’язку до багажу для речей', 'admin.collab.chat.title': 'Чат', diff --git a/shared/src/i18n/uk/trip.ts b/shared/src/i18n/uk/trip.ts index 6a83fb4fd..26daa19a1 100644 --- a/shared/src/i18n/uk/trip.ts +++ b/shared/src/i18n/uk/trip.ts @@ -63,6 +63,7 @@ const trip: TranslationStrings = { 'transit.search': 'Пошук', 'transit.searching': 'Пошук…', 'transit.searchError': 'Не вдалося знайти маршрут. Спробуйте ще раз.', + 'transit.noResultsVia': 'Через {provider} сполучень не знайдено. Спробуйте інший час або фільтри.', 'transit.noResults': "З'єднань не знайдено. Спробуйте інший час або фільтри.", 'transit.direct': 'Прямий', 'transit.transfers': 'Пересадок: {count}', diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index 59f335536..84cec64d2 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -169,6 +169,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'Làm giàu địa điểm', 'admin.placesEnrich.subtitle': 'Hiển thị hình ảnh và mô tả khi thêm địa điểm. Wikipedia và OpenStreetMap luôn được dùng; Google được thêm vào khi bật Ảnh địa điểm hoặc Chi tiết địa điểm.', + 'admin.transitProvider.title': 'Nhà cung cấp giao thông công cộng', + 'admin.transitProvider.subtitle': 'Dịch vụ nào trả lời tìm kiếm giao thông công cộng.', + 'admin.transitProvider.transitous': 'Transitous (miễn phí)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': 'Nguồn GTFS cộng đồng. Miễn phí và không cần khóa, phủ sóng tốt nhất ở châu Âu.', + 'admin.transitProvider.googleHint': 'Dùng khóa Google ở trên, cho những khu vực Transitous không có dữ liệu. Tính phí theo mỗi lượt tìm kiếm — khi chưa đặt khóa thì vẫn dùng Transitous.', + 'admin.transitProvider.noKeyWarning': 'Đã chọn Google, nhưng chưa cấu hình khóa Google — tìm kiếm giao thông vẫn dùng Transitous. Thêm khóa ở mục Khóa API phía trên.', + 'admin.transitProvider.personalKeyWarning': 'Chỉ khóa Google của riêng bạn được đặt, nên tìm kiếm của các thành viên khác vẫn quay về Transitous. Lưu khóa ở trên với quyền quản trị để áp dụng cho toàn bộ máy chủ.', 'admin.bagTracking.title': 'Theo dõi túi', 'admin.bagTracking.subtitle': 'Cho phép phân bổ trọng lượng và túi để đóng gói các mặt hàng', 'admin.collab.chat.title': 'Trò chuyện', diff --git a/shared/src/i18n/vi/trip.ts b/shared/src/i18n/vi/trip.ts index 60580ff0b..f65b205b5 100644 --- a/shared/src/i18n/vi/trip.ts +++ b/shared/src/i18n/vi/trip.ts @@ -63,6 +63,7 @@ const trip: TranslationStrings = { 'transit.search': 'Tìm kiếm', 'transit.searching': 'Đang tìm…', 'transit.searchError': 'Tìm tuyến thất bại. Vui lòng thử lại.', + 'transit.noResultsVia': 'Không tìm thấy tuyến nào qua {provider}. Hãy thử giờ khác hoặc bộ lọc khác.', 'transit.noResults': 'Không tìm thấy tuyến. Hãy thử thời gian hoặc bộ lọc khác.', 'transit.direct': 'Trực tiếp', 'transit.transfers': '{count} lần chuyển tuyến', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index 5fff565df..0fa1b1c92 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -161,6 +161,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': '地點資訊補充', 'admin.placesEnrich.subtitle': '新增地點時顯示圖片與描述。一律使用維基百科與 OpenStreetMap;開啟「地點照片」或「地點詳細資料」時會額外使用 Google。', + 'admin.transitProvider.title': '公共運輸資料來源', + 'admin.transitProvider.subtitle': '由哪個服務回應大眾運輸搜尋。', + 'admin.transitProvider.transitous': 'Transitous(免費)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': '社群 GTFS 資料來源。免費且免金鑰,在歐洲的涵蓋率最佳。', + 'admin.transitProvider.googleHint': '使用上方的 Google 金鑰,涵蓋 Transitous 沒有資料的地區。依每次搜尋計費——未設定金鑰時仍使用 Transitous。', + 'admin.transitProvider.noKeyWarning': '已選擇 Google,但尚未設定 Google 金鑰——大眾運輸搜尋仍在使用 Transitous。請在上方的 API 金鑰中新增金鑰。', + 'admin.transitProvider.personalKeyWarning': '只設定了你自己的 Google 金鑰,因此其他成員的搜尋仍會回退到 Transitous。請以管理員身分在上方儲存金鑰,使其套用至整個執行個體。', 'admin.bagTracking.title': '行李追蹤', 'admin.bagTracking.subtitle': '為打包物品啟用重量和行李分配', 'admin.collab.chat.title': '聊天', diff --git a/shared/src/i18n/zh-TW/trip.ts b/shared/src/i18n/zh-TW/trip.ts index 16d139828..db60279bc 100644 --- a/shared/src/i18n/zh-TW/trip.ts +++ b/shared/src/i18n/zh-TW/trip.ts @@ -62,6 +62,7 @@ const trip: TranslationStrings = { 'transit.search': '搜尋', 'transit.searching': '搜尋中…', 'transit.searchError': '路線搜尋失敗,請重試。', + 'transit.noResultsVia': '透過 {provider} 找不到路線。請嘗試其他時間或篩選條件。', 'transit.noResults': '找不到路線。請嘗試其他時間或篩選條件。', 'transit.direct': '直達', 'transit.transfers': '轉乘 {count} 次', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index 4b7b99611..6a742838c 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -122,6 +122,14 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': '地点信息补充', 'admin.placesEnrich.subtitle': '添加地点时显示图片和描述。始终使用维基百科和 OpenStreetMap;开启「地点照片」或「地点详情」时会额外使用 Google。', + 'admin.transitProvider.title': '公共交通数据源', + 'admin.transitProvider.subtitle': '由哪个服务响应公共交通搜索。', + 'admin.transitProvider.transitous': 'Transitous(免费)', + 'admin.transitProvider.google': 'Google', + 'admin.transitProvider.transitousHint': '社区 GTFS 数据源。免费且无需密钥,在欧洲覆盖最好。', + 'admin.transitProvider.googleHint': '使用上方的 Google 密钥,覆盖 Transitous 没有数据的地区。按每次搜索计费——未设置密钥时仍使用 Transitous。', + 'admin.transitProvider.noKeyWarning': '已选择 Google,但未配置 Google 密钥——公共交通搜索仍在使用 Transitous。请在上方的 API 密钥中添加密钥。', + 'admin.transitProvider.personalKeyWarning': '只设置了你自己的 Google 密钥,因此其他成员的搜索仍会回退到 Transitous。请以管理员身份在上方保存密钥,使其对整个实例生效。', 'admin.bagTracking.title': '行李追踪', 'admin.bagTracking.subtitle': '为打包物品启用重量和行李分配', 'admin.collab.chat.title': '聊天', diff --git a/shared/src/i18n/zh/trip.ts b/shared/src/i18n/zh/trip.ts index a395bdf8a..43c95cfcb 100644 --- a/shared/src/i18n/zh/trip.ts +++ b/shared/src/i18n/zh/trip.ts @@ -62,6 +62,7 @@ const trip: TranslationStrings = { 'transit.search': '搜索', 'transit.searching': '搜索中…', 'transit.searchError': '路线搜索失败,请重试。', + 'transit.noResultsVia': '通过 {provider} 未找到线路。请尝试其他时间或筛选条件。', 'transit.noResults': '未找到线路,请尝试其他时间或筛选条件。', 'transit.direct': '直达', 'transit.transfers': '换乘 {count} 次',