mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(transit): let an admin route transit search through Google
Transitous has no GTFS coverage for much of Asia, so a search between two Osaka subway stops finds nothing — the stops are not in the feed at all. Add Google as an optional backend behind an admin setting, reusing the install's existing Places key, and fall back to Transitous whenever no key resolves. Uses the Routes API rather than the legacy Directions endpoint the request named: every other Google call here already speaks the v1/X-Goog-Api-Key family, and Google closed the legacy endpoint to Cloud projects created after March 2025. Google returns walking as turn-by-turn navigation steps where MOTIS returns one leg per walking segment, so consecutive walk steps are coalesced into a single leg — otherwise a finely-sliced walk pushed a real journey past the 20-leg cap and the whole itinerary was dropped. Both responses now name the backend that answered. The fallback is silent by design, which left an empty result ambiguous between "no data here" and "the provider you picked never ran", and the admin panel warns when Google is selected but no key resolves — or when only the admin's own key is set, which serves them and nobody else. Cost shaped the implementation, since Google bills where Transitous does not: both field masks are cut to the cheapest SKU tier that still answers the question, cache TTLs are longer than the Transitous path's, and route alternatives ride along inside the already-billed request. Addresses #1699
This commit is contained in:
committed by
Maurice
parent
8e2840b29d
commit
2a0ceb2687
@@ -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<string, boolean>) => 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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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(<TransitSearchPanel {...makeProps()} />)
|
||||
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(<TransitSearchPanel {...makeProps()} />)
|
||||
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: [] })
|
||||
|
||||
@@ -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<TransitProvider, string> = {
|
||||
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<number | null>(null)
|
||||
const [addingIdx, setAddingIdx] = useState<number | null>(null)
|
||||
const [provider, setProvider] = useState<TransitProvider | null>(null)
|
||||
|
||||
// Quick picks: the day's located places, plus the trip's located accommodations.
|
||||
const quickPicks = useMemo<PickedPlace[]>(() => {
|
||||
@@ -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 && (
|
||||
<div className="text-content-faint" style={{ textAlign: 'center', padding: '24px 0', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
{t('transit.noResults')}
|
||||
{provider
|
||||
? t('transit.noResultsVia', { provider: PROVIDER_NAMES[provider] })
|
||||
: t('transit.noResults')}
|
||||
</div>
|
||||
)}
|
||||
{!loading && ranked && ranked.length > 0 && (
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown>;
|
||||
return HttpResponse.json({ provider: 'google', googleKeySource: 'instance' });
|
||||
})
|
||||
);
|
||||
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
||||
render(<AdminPage />);
|
||||
|
||||
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(<AdminPage />);
|
||||
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() });
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="py-3 border-t border-slate-100">
|
||||
<p className="text-sm font-medium text-slate-700">{t('admin.transitProvider.title')}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('admin.transitProvider.subtitle')}</p>
|
||||
<select
|
||||
value={transitProvider}
|
||||
aria-label={t('admin.transitProvider.title')}
|
||||
onChange={async e => {
|
||||
const next = e.target.value === 'google' ? 'google' : 'transitous'
|
||||
const previous = transitProvider
|
||||
setTransitProviderState(next)
|
||||
try {
|
||||
const saved = await adminApi.updateTransitProvider(next)
|
||||
setTransitGoogleKeySource(saved.googleKeySource)
|
||||
} catch { setTransitProviderState(previous) }
|
||||
}}
|
||||
className="mt-2 w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
>
|
||||
<option value="transitous">{t('admin.transitProvider.transitous')}</option>
|
||||
<option value="google">{t('admin.transitProvider.google')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
{transitProvider === 'google' ? t('admin.transitProvider.googleHint') : t('admin.transitProvider.transitousHint')}
|
||||
</p>
|
||||
|
||||
{/* 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 && (
|
||||
<p className="flex items-start gap-2 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mt-2">
|
||||
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
|
||||
{t('admin.transitProvider.noKeyWarning')}
|
||||
</p>
|
||||
)}
|
||||
{transitProvider === 'google' && transitGoogleKeySource === 'user-row' && (
|
||||
<p className="flex items-start gap-2 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mt-2">
|
||||
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
|
||||
{t('admin.transitProvider.personalKeyWarning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open-Meteo Weather Info */}
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 dark:bg-emerald-950/30 dark:border-emerald-800 overflow-hidden">
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
|
||||
@@ -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<TransitProvider>('transitous')
|
||||
const [transitGoogleKeySource, setTransitGoogleKeySource] = useState<TransitKeySource>(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,
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }; }
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, { at: number; ttl: number; data: unknown }>();
|
||||
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<unknown> {
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> = {};
|
||||
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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ReturnType<typeof build>>;
|
||||
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<string, unknown> = {};
|
||||
|
||||
async function planUpstream(): Promise<string> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<AdminService>));
|
||||
|
||||
@@ -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<typeof import('../../../src/app-config')>();
|
||||
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<string, string | undefined>): 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<string, unknown> {
|
||||
return JSON.parse(fetchMock.mock.calls[fetchMock.mock.calls.length - 1][1].body);
|
||||
}
|
||||
|
||||
function lastHeaders(): Record<string, string> {
|
||||
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<string, unknown>).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 <provider>", 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof adminTestNotificationRequestSchema>;
|
||||
|
||||
// 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<typeof adminTransitProviderRequestSchema>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -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': 'الدردشة',
|
||||
|
||||
@@ -63,6 +63,7 @@ const trip: TranslationStrings = {
|
||||
'transit.search': 'بحث',
|
||||
'transit.searching': 'جارٍ البحث…',
|
||||
'transit.searchError': 'فشل البحث عن المسار. يُرجى المحاولة مرة أخرى.',
|
||||
'transit.noResultsVia': 'لم يتم العثور على أي رحلات عبر {provider}. جرّب وقتًا أو عوامل تصفية مختلفة.',
|
||||
'transit.noResults': 'لم يُعثر على أي رحلات. جرّب وقتًا أو عوامل تصفية مختلفة.',
|
||||
'transit.direct': 'مباشر',
|
||||
'transit.transfers': '{count} تحويلات',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ů',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Συνομιλία',
|
||||
|
||||
@@ -63,6 +63,7 @@ const trip: TranslationStrings = {
|
||||
'transit.search': 'Αναζήτηση',
|
||||
'transit.searching': 'Αναζήτηση…',
|
||||
'transit.searchError': 'Η αναζήτηση διαδρομής απέτυχε. Δοκιμάστε ξανά.',
|
||||
'transit.noResultsVia': 'Δεν βρέθηκαν συνδέσεις μέσω {provider}. Δοκιμάστε άλλη ώρα ή άλλα φίλτρα.',
|
||||
'transit.noResults': 'Δεν βρέθηκαν συνδέσεις. Δοκιμάστε άλλη ώρα ή φίλτρα.',
|
||||
'transit.direct': 'Απευθείας',
|
||||
'transit.transfers': '{count} μετεπιβιβάσεις',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'チャット',
|
||||
|
||||
@@ -63,6 +63,7 @@ const trip: TranslationStrings = {
|
||||
'transit.search': '検索',
|
||||
'transit.searching': '検索中…',
|
||||
'transit.searchError': 'ルート検索に失敗しました。もう一度お試しください。',
|
||||
'transit.noResultsVia': '{provider} では経路が見つかりませんでした。時間やフィルターを変えてお試しください。',
|
||||
'transit.noResults': '接続が見つかりませんでした。時間やフィルターを変更してお試しください。',
|
||||
'transit.direct': '直通',
|
||||
'transit.transfers': '乗り換え{count}回',
|
||||
|
||||
@@ -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': '채팅',
|
||||
|
||||
@@ -63,6 +63,7 @@ const trip: TranslationStrings = {
|
||||
'transit.search': '검색',
|
||||
'transit.searching': '검색 중…',
|
||||
'transit.searchError': '경로 검색에 실패했습니다. 다시 시도해 주세요.',
|
||||
'transit.noResultsVia': '{provider}에서 연결편을 찾지 못했습니다. 다른 시간이나 필터를 사용해 보세요.',
|
||||
'transit.noResults': '연결편을 찾을 수 없습니다. 시간이나 필터를 변경해 보세요.',
|
||||
'transit.direct': '직행',
|
||||
'transit.transfers': '환승 {count}회',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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': 'Чат',
|
||||
|
||||
@@ -63,6 +63,7 @@ const trip: TranslationStrings = {
|
||||
'transit.search': 'Поиск',
|
||||
'transit.searching': 'Поиск…',
|
||||
'transit.searchError': 'Не удалось найти маршрут. Попробуйте снова.',
|
||||
'transit.noResultsVia': 'Через {provider} маршруты не найдены. Попробуйте другое время или фильтры.',
|
||||
'transit.noResults': 'Маршруты не найдены. Измените время или фильтры.',
|
||||
'transit.direct': 'Без пересадок',
|
||||
'transit.transfers': 'Пересадок: {count}',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Чат',
|
||||
|
||||
@@ -63,6 +63,7 @@ const trip: TranslationStrings = {
|
||||
'transit.search': 'Пошук',
|
||||
'transit.searching': 'Пошук…',
|
||||
'transit.searchError': 'Не вдалося знайти маршрут. Спробуйте ще раз.',
|
||||
'transit.noResultsVia': 'Через {provider} сполучень не знайдено. Спробуйте інший час або фільтри.',
|
||||
'transit.noResults': "З'єднань не знайдено. Спробуйте інший час або фільтри.",
|
||||
'transit.direct': 'Прямий',
|
||||
'transit.transfers': 'Пересадок: {count}',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '聊天',
|
||||
|
||||
@@ -62,6 +62,7 @@ const trip: TranslationStrings = {
|
||||
'transit.search': '搜尋',
|
||||
'transit.searching': '搜尋中…',
|
||||
'transit.searchError': '路線搜尋失敗,請重試。',
|
||||
'transit.noResultsVia': '透過 {provider} 找不到路線。請嘗試其他時間或篩選條件。',
|
||||
'transit.noResults': '找不到路線。請嘗試其他時間或篩選條件。',
|
||||
'transit.direct': '直達',
|
||||
'transit.transfers': '轉乘 {count} 次',
|
||||
|
||||
@@ -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': '聊天',
|
||||
|
||||
@@ -62,6 +62,7 @@ const trip: TranslationStrings = {
|
||||
'transit.search': '搜索',
|
||||
'transit.searching': '搜索中…',
|
||||
'transit.searchError': '路线搜索失败,请重试。',
|
||||
'transit.noResultsVia': '通过 {provider} 未找到线路。请尝试其他时间或筛选条件。',
|
||||
'transit.noResults': '未找到线路,请尝试其他时间或筛选条件。',
|
||||
'transit.direct': '直达',
|
||||
'transit.transfers': '换乘 {count} 次',
|
||||
|
||||
Reference in New Issue
Block a user