From e12ae950dfad10952b6d858bbcbef7d341700ce3 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 21 Sep 2026 22:14:44 +0200 Subject: [PATCH] feat(places): send a search to Google, once or always Since 4.3.0 the TREK index and OpenStreetMap answer every search first and Google is only asked when they find nothing, so a list with the wrong place on it never reached Google, whatever key the instance had, and the provider choice only decided who fills the empty case. Two ways to change that, both only where Google holds the key slot. Under a result list that did not come from Google, one quiet line sends the same query to Google Places alone (`provider: 'google'` on the search contract, the MCP search tool takes it too). And beside the key, an admin switch "Search with Google only" hands every search and every suggestion to Google and asks nothing of the index or OpenStreetMap; off by default, read by the search after the keyed provider so an install without a key, or one on Amap or OpenStreetMap, is untouched. The panel says so beside the switch. Both shells share the one rule for when the line is offered (placeSource), and the wiki names both switches. --- client/src/api/client.ts | 11 ++- .../Planner/PlaceFormModal.test.tsx | 47 ++++++++++++ .../src/components/Planner/PlaceFormModal.tsx | 23 +++++- .../screens/admin/MAdminSettingsSection.tsx | 12 +++ .../screens/trip/sheets/PlPlaceSearch.tsx | 29 +++++-- .../src/pages/admin/AdminSettingsTab.test.tsx | 14 ++++ client/src/pages/admin/AdminSettingsTab.tsx | 20 ++++- client/src/pages/admin/useAdmin.ts | 11 +++ client/src/utils/placeSource.test.ts | 27 +++++++ client/src/utils/placeSource.ts | 15 ++++ client/tests/helpers/mobileAdmin.ts | 2 + server/src/nest/addons/addons.service.ts | 9 +++ server/src/nest/admin/admin.controller.ts | 10 +++ server/src/nest/maps/maps.controller.ts | 2 +- server/src/nest/maps/maps.service.ts | 55 ++++++++++++-- server/src/nest/places/places.mcp.ts | 7 +- server/tests/unit/nest/addons.service.test.ts | 30 ++++++++ .../tests/unit/nest/admin.controller.test.ts | 10 +++ .../tests/unit/nest/maps.controller.test.ts | 10 ++- .../tests/unit/nest/maps.search-merge.test.ts | 76 ++++++++++++++++++- server/tests/unit/nest/maps.service.test.ts | 4 +- shared/src/i18n/ar/admin.ts | 3 + shared/src/i18n/ar/places.ts | 1 + shared/src/i18n/br/admin.ts | 3 + shared/src/i18n/br/places.ts | 1 + shared/src/i18n/ca/admin.ts | 3 + shared/src/i18n/ca/places.ts | 1 + shared/src/i18n/cs/admin.ts | 3 + shared/src/i18n/cs/places.ts | 1 + shared/src/i18n/de/admin.ts | 3 + shared/src/i18n/de/places.ts | 1 + shared/src/i18n/en/admin.ts | 3 + shared/src/i18n/en/places.ts | 1 + shared/src/i18n/es/admin.ts | 3 + shared/src/i18n/es/places.ts | 1 + shared/src/i18n/fr/admin.ts | 3 + shared/src/i18n/fr/places.ts | 1 + shared/src/i18n/gr/admin.ts | 3 + shared/src/i18n/gr/places.ts | 1 + shared/src/i18n/hu/admin.ts | 3 + shared/src/i18n/hu/places.ts | 1 + shared/src/i18n/id/admin.ts | 3 + shared/src/i18n/id/places.ts | 1 + shared/src/i18n/it/admin.ts | 3 + shared/src/i18n/it/places.ts | 1 + shared/src/i18n/ja/admin.ts | 3 + shared/src/i18n/ja/places.ts | 1 + shared/src/i18n/ko/admin.ts | 3 + shared/src/i18n/ko/places.ts | 1 + shared/src/i18n/nl/admin.ts | 3 + shared/src/i18n/nl/places.ts | 1 + shared/src/i18n/pl/admin.ts | 3 + shared/src/i18n/pl/places.ts | 1 + shared/src/i18n/ru/admin.ts | 3 + shared/src/i18n/ru/places.ts | 1 + shared/src/i18n/sv/admin.ts | 3 + shared/src/i18n/sv/places.ts | 1 + shared/src/i18n/tr/admin.ts | 3 + shared/src/i18n/tr/places.ts | 1 + shared/src/i18n/uk/admin.ts | 3 + shared/src/i18n/uk/places.ts | 1 + shared/src/i18n/vi/admin.ts | 3 + shared/src/i18n/vi/places.ts | 1 + shared/src/i18n/zh-TW/admin.ts | 3 + shared/src/i18n/zh-TW/places.ts | 1 + shared/src/i18n/zh/admin.ts | 3 + shared/src/i18n/zh/places.ts | 1 + shared/src/maps/maps.schema.spec.ts | 26 +++++-- shared/src/maps/maps.schema.ts | 12 ++- wiki/Places-and-Search.md | 10 ++- 70 files changed, 525 insertions(+), 39 deletions(-) create mode 100644 client/src/utils/placeSource.test.ts diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 4d5809557..4d093ba64 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -671,6 +671,8 @@ export const adminApi = { updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data), getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data), updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data), + getPlacesGoogleOnly: () => apiClient.get('/admin/places-google-only').then(r => r.data), + updatePlacesGoogleOnly: (enabled: boolean) => apiClient.put('/admin/places-google-only', { 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), @@ -1215,13 +1217,18 @@ export const mapsApi = { * than interleaved: the core list is ordered by relevance and has earned that order, * and a plugin's row carries its own `source` for a caller that wants to mark it. */ - search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) => + /** + * `provider: 'google'` sends this one search to Google alone, the "search Google + * instead" link under a list the index answered with the wrong place. The + * server ignores it on an instance without a Google key. + */ + search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }, provider?: 'google') => withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => { // Side by side, so the wait is the slower of the two rather than their sum. Only // the core call may reject: that rejection is what hands withCachedPlaces the // offline path, and a plugin failure must never trigger it. const [core, extra] = await Promise.all([ - apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')), + apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias, ...(provider ? { provider } : {}) }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')), pluginSearchPlaces(query, lang, locationBias), ]) if (extra.length === 0) return core diff --git a/client/src/components/Planner/PlaceFormModal.test.tsx b/client/src/components/Planner/PlaceFormModal.test.tsx index ed99d04b1..1a33b363d 100644 --- a/client/src/components/Planner/PlaceFormModal.test.tsx +++ b/client/src/components/Planner/PlaceFormModal.test.tsx @@ -186,6 +186,53 @@ describe('PlaceFormModal', () => { expect(await screen.findByText('Eiffel Tower')).toBeInTheDocument(); }); + it('FE-PLANNER-PLACEFORM-018b: a list the index answered offers Google instead, and the link sends the same query there alone', async () => { + const user = userEvent.setup(); + seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: true }); + const bodies: Record[] = []; + server.use( + http.post('/api/maps/search', async ({ request }) => { + const body = (await request.json()) as Record; + bodies.push(body); + return body.provider === 'google' + ? HttpResponse.json({ places: [{ name: 'Tokyo Station', address: 'Chiyoda', lat: '35.68', lng: '139.77' }], source: 'google' }) + : HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' }); + }), + ); + + render(); + const searchInput = screen.getByPlaceholderText('Search places...'); + await user.type(searchInput, 'Tokyo Station'); + const searchRow = searchInput.closest('.flex') as HTMLElement; + await user.click(within(searchRow).getByRole('button')); + expect(await screen.findByText('Weigh station')).toBeInTheDocument(); + + // The quiet line under the list, only because the instance has a key and + // this list did not come from Google. + const retry = screen.getByRole('button', { name: /Search Google instead/ }); + await user.click(retry); + expect(await screen.findByText('Tokyo Station')).toBeInTheDocument(); + expect(bodies[0]).not.toHaveProperty('provider'); + expect(bodies[1]).toMatchObject({ query: 'Tokyo Station', provider: 'google' }); + // A list Google produced has nowhere further to go. + expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull(); + }); + + it('FE-PLANNER-PLACEFORM-018c: without a Google key the list offers nothing', async () => { + const user = userEvent.setup(); + server.use( + http.post('/api/maps/search', () => + HttpResponse.json({ places: [{ name: 'Weigh station', address: 'Ritzville', lat: '47.1', lng: '-118.4' }], source: 'trek-places+openstreetmap' }), + ), + ); + render(); + const searchInput = screen.getByPlaceholderText('Search places...'); + await user.type(searchInput, 'Tokyo Station'); + await user.click(within(searchInput.closest('.flex') as HTMLElement).getByRole('button')); + expect(await screen.findByText('Weigh station')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Search Google instead/ })).toBeNull(); + }); + it('FE-PLANNER-PLACEFORM-019: pressing Enter in search input triggers search', async () => { const user = userEvent.setup(); server.use( diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index a8bf6428c..d4aea6b19 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -13,12 +13,12 @@ import { useSettingsStore } from '../../store/settingsStore' import CollectionPicker from '../Collections/CollectionPicker' import PlaceDetailsColumn, { type PlaceDetailsSelection } from './PlaceDetailsColumn' import { useToast } from '../shared/Toast' -import { Search, Paperclip, X, AlertTriangle, Loader2, Plus } from 'lucide-react' +import { Search, Paperclip, X, AlertTriangle, Loader2, Plus, RotateCcw } from 'lucide-react' import { useTranslation } from '../../i18n' import CustomTimePicker from '../shared/CustomTimePicker' import { DEFAULT_FORM, isMapUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers' import { getApiErrorMessage } from '../../utils/apiError' -import { sourceLabelFor } from '../../utils/placeSource' +import { offersGoogleRetry, sourceLabelFor } from '../../utils/placeSource' import { useLocationBias } from '../../hooks/useLocationBias' import { BookingCostsSection } from './BookingCostsSection' import type { BookingExpenseRequest } from './BookingCostsSection.types' @@ -396,7 +396,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { setForm(prev => ({ ...prev, [field]: value })) } - const handleMapsSearch = async () => { + const handleMapsSearch = async (provider?: 'google') => { if (!mapsSearch.trim()) return setIsSearchingMaps(true) try { @@ -419,7 +419,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { return } } - const result = await mapsApi.search(mapsSearch, language, locationBiasPoint) + const result = await mapsApi.search(mapsSearch, language, locationBiasPoint, provider) searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' } setMapsResults(result.places || []) setSearchSource(result.source || '') @@ -1002,6 +1002,21 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { ))} )} + {/* The index answers first and Google only when it finds nothing, so a + list with the wrong place on it never reaches Google by itself. One + quiet line under the list sends the same query there, on an instance + that has a key and for a list Google did not already produce. */} + {mapsResults.length > 0 && offersGoogleRetry(searchSource, hasMapsKey) && ( + + )} {/* Name */} diff --git a/client/src/mobile/screens/admin/MAdminSettingsSection.tsx b/client/src/mobile/screens/admin/MAdminSettingsSection.tsx index c60306c0f..08b421ab9 100644 --- a/client/src/mobile/screens/admin/MAdminSettingsSection.tsx +++ b/client/src/mobile/screens/admin/MAdminSettingsSection.tsx @@ -34,6 +34,7 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio placesAutocompleteEnabled, setPlacesAutocompleteEnabledState, placesDetailsEnabled, setPlacesDetailsEnabledState, placesEnrichEnabled, setPlacesEnrichEnabledState, + placesGoogleOnly, handleTogglePlacesGoogleOnly, placeShadowEnabled, setPlaceShadowEnabledState, oidcConfig, setOidcConfig, savingOidc, setSavingOidc, passwordLogin, setPasswordLogin, passwordRegistration, setPasswordRegistration, @@ -350,6 +351,17 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio /> } /> + handleTogglePlacesGoogleOnly()} + /> + } + /> diff --git a/client/src/mobile/screens/trip/sheets/PlPlaceSearch.tsx b/client/src/mobile/screens/trip/sheets/PlPlaceSearch.tsx index 382d5f32f..0bc955593 100644 --- a/client/src/mobile/screens/trip/sheets/PlPlaceSearch.tsx +++ b/client/src/mobile/screens/trip/sheets/PlPlaceSearch.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { Loader2, Search } from 'lucide-react' +import { Loader2, RotateCcw, Search } from 'lucide-react' import { mapsApi } from '../../../../api/client' -import { sourceLabelFor } from '../../../../utils/placeSource' +import { useAuthStore } from '../../../../store/authStore' +import { offersGoogleRetry, sourceLabelFor } from '../../../../utils/placeSource' import { recordPlacePick } from '../../../../api/placeShadow' import { PlacesSession } from '../../../../utils/placesSession' import { isMapUrl } from '../../../../components/Planner/PlaceFormModal.helpers' @@ -95,6 +96,9 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi const acMetaRef = useRef<{ query: string; source: string } | null>(null) // The name the whole list carries, for rows that do not name their own index. const [acSource, setAcSource] = useState('') + // What answered the last full search, for the line that offers Google instead. + const [searchSource, setSearchSource] = useState('') + const hasMapsKey = useAuthStore(s => s.hasMapsKey) const setResolving = useCallback( (v: boolean) => { @@ -168,7 +172,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi setQuery('') } - const handleSearch = async () => { + const handleSearch = async (provider?: 'google') => { const trimmed = query.trim() if (!trimmed) return setSuggestions([]) @@ -200,9 +204,10 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi } // Derselbe Hinweis, den die Vervollstaendigung schon bekommt: die Suche // braucht ihn genauso, nur als Punkt statt als Kasten. - const result = await mapsApi.search(trimmed, language, pointFromBox(locationBias)) + const result = await mapsApi.search(trimmed, language, pointFromBox(locationBias), provider) searchMetaRef.current = { query: trimmed, source: result.source || 'unknown' } setResults(result.places || []) + setSearchSource(result.source || '') } catch (err: unknown) { toast.error(getApiErrorMessage(err, t('places.mapsSearchError'))) } finally { @@ -284,7 +289,7 @@ export default function PlPlaceSearch({ planner, locationBias, onPick, onResolvi /> + )} ) } diff --git a/client/src/pages/admin/AdminSettingsTab.test.tsx b/client/src/pages/admin/AdminSettingsTab.test.tsx index 91d28cd10..33be20e5a 100644 --- a/client/src/pages/admin/AdminSettingsTab.test.tsx +++ b/client/src/pages/admin/AdminSettingsTab.test.tsx @@ -435,6 +435,20 @@ describe('AdminSettingsTab', () => { expect(toggleFor('Place Enrichment')).toHaveAttribute('aria-label', 'Place Enrichment'); }); + it('FE-ADMSET-030d: the Google-only switch sits with the Google options, says what it needs without a key, and toggles through the hook', () => { + const admin = renderTab({ placesGoogleOnly: false, hasMapsKey: false }); + expect(screen.getByText(/Needs a Google Maps API key/)).toBeInTheDocument(); + expect(toggleFor('Search with Google only')).toHaveAttribute('aria-pressed', 'false'); + fireEvent.click(toggleFor('Search with Google only')); + expect(admin.handleTogglePlacesGoogleOnly).toHaveBeenCalledTimes(1); + }); + + it('FE-ADMSET-030e: with a key the Google-only row explains what it changes', () => { + renderTab({ placesGoogleOnly: true, hasMapsKey: true }); + expect(screen.getByText(/Every search and every suggestion goes to Google Places/)).toBeInTheDocument(); + expect(toggleFor('Search with Google only')).toHaveAttribute('aria-pressed', 'true'); + }); + it('FE-ADMSET-031: the API keys card leads with the TREK index and no weather panel', () => { renderTab(); diff --git a/client/src/pages/admin/AdminSettingsTab.tsx b/client/src/pages/admin/AdminSettingsTab.tsx index 668ad3caa..7cae1719c 100644 --- a/client/src/pages/admin/AdminSettingsTab.tsx +++ b/client/src/pages/admin/AdminSettingsTab.tsx @@ -25,6 +25,7 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R placesAutocompleteEnabled, setPlacesAutocompleteEnabledState, placesDetailsEnabled, setPlacesDetailsEnabledState, placesEnrichEnabled, setPlacesEnrichEnabledState, + placesGoogleOnly, handleTogglePlacesGoogleOnly, transitProvider, setTransitProviderState, transitGoogleKeySource, setTransitGoogleKeySource, placeShadowEnabled, setPlaceShadowEnabledState, @@ -448,8 +449,8 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
@@ -520,6 +521,21 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R }} />
+ + {/* The one row here that is about where a search goes rather than + what the key may be spent on. Without a key it is a promise the + search cannot keep, and the subtitle says so. */} +
+
+

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

+

{t(hasMapsKey ? 'admin.placesGoogleOnly.subtitle' : 'admin.placesGoogleOnly.missingKey')}

+
+ +
diff --git a/client/src/pages/admin/useAdmin.ts b/client/src/pages/admin/useAdmin.ts index 99f250f95..34a5c5197 100644 --- a/client/src/pages/admin/useAdmin.ts +++ b/client/src/pages/admin/useAdmin.ts @@ -105,6 +105,16 @@ export function useAdmin() { useEffect(() => { adminApi.getPlacesDetails().then(d => setPlacesDetailsEnabledState(d.enabled)).catch(() => {}) }, []) useEffect(() => { adminApi.getPlacesEnrich().then(d => setPlacesEnrichEnabledState(d.enabled)).catch(() => {}) }, []) + // Search and suggestions from Google alone. Admin-only state: the search itself + // reads the switch on the server, so nothing else in the client needs it. + const [placesGoogleOnly, setPlacesGoogleOnlyState] = useState(false) + useEffect(() => { adminApi.getPlacesGoogleOnly().then(d => setPlacesGoogleOnlyState(d.enabled)).catch(() => {}) }, []) + const handleTogglePlacesGoogleOnly = async () => { + const next = !placesGoogleOnly + setPlacesGoogleOnlyState(next) + try { await adminApi.updatePlacesGoogleOnly(next) } catch { setPlacesGoogleOnlyState(!next) } + } + // 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. @@ -481,6 +491,7 @@ export function useAdmin() { placesAutocompleteEnabled, setPlacesAutocompleteEnabledState, placesDetailsEnabled, setPlacesDetailsEnabledState, placesEnrichEnabled, setPlacesEnrichEnabledState, + placesGoogleOnly, handleTogglePlacesGoogleOnly, transitProvider, setTransitProviderState, transitGoogleKeySource, setTransitGoogleKeySource, placeShadowEnabled, setPlaceShadowEnabledState, diff --git a/client/src/utils/placeSource.test.ts b/client/src/utils/placeSource.test.ts new file mode 100644 index 000000000..95cec4125 --- /dev/null +++ b/client/src/utils/placeSource.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { offersGoogleRetry, sourceLabelFor } from './placeSource' + +describe('offersGoogleRetry', () => { + it('FE-PLACESOURCE-001: offers Google for a list the index or OpenStreetMap answered, only where a key exists', () => { + expect(offersGoogleRetry('trek-places+openstreetmap', true)).toBe(true) + expect(offersGoogleRetry('openstreetmap', true)).toBe(true) + expect(offersGoogleRetry('trek-places+plugin', true)).toBe(true) + expect(offersGoogleRetry('trek-places+openstreetmap', false)).toBe(false) + }) + + it('FE-PLACESOURCE-002: a list Google produced, the offline cache and no list at all have nowhere further to go', () => { + expect(offersGoogleRetry('google', true)).toBe(false) + expect(offersGoogleRetry('google+plugin', true)).toBe(false) + expect(offersGoogleRetry('offline-cache', true)).toBe(false) + expect(offersGoogleRetry('', true)).toBe(false) + }) +}) + +describe('sourceLabelFor', () => { + it('FE-PLACESOURCE-003: a row names its own index, else the list it came from', () => { + const t = (k: string) => k + expect(sourceLabelFor({ source: 'openstreetmap' }, 'trek-places+openstreetmap', t)).toBe('OpenStreetMap') + expect(sourceLabelFor({}, 'google', t)).toBe('Google') + expect(sourceLabelFor({}, 'amap', t)).toBe('places.source.amap') + }) +}) diff --git a/client/src/utils/placeSource.ts b/client/src/utils/placeSource.ts index fa09a0b55..771fe5b87 100644 --- a/client/src/utils/placeSource.ts +++ b/client/src/utils/placeSource.ts @@ -40,6 +40,21 @@ const SOURCE_KEYS: Record = { * answered the call: Google never marks its places, and a merged list marks only * the index side, so an unmarked row in one is OpenStreetMap by elimination. */ +/** + * Whether a result list can be sent to Google instead. + * + * The index and OpenStreetMap answer first, and Google is only asked when they + * find nothing, so a list that has the wrong place on it never reaches Google + * on its own. The link that sends it there is offered when the instance has a + * Google key and this list did not already come from Google: a list Google + * produced, alone or as the empty-case fallback, has nowhere further to go, and + * the offline cache is not a search at all. + */ +export function offersGoogleRetry(listSource: string, hasMapsKey: boolean): boolean { + if (!hasMapsKey || !listSource) return false + return !listSource.split('+').includes('google') && listSource !== 'offline-cache' +} + export function sourceLabelFor(place: unknown, listSource: string, t: TranslationFn): string | null { const own = (place as { source?: unknown } | null)?.source const source = typeof own === 'string' && own diff --git a/client/tests/helpers/mobileAdmin.ts b/client/tests/helpers/mobileAdmin.ts index d7c4ecb05..3fef172bd 100644 --- a/client/tests/helpers/mobileAdmin.ts +++ b/client/tests/helpers/mobileAdmin.ts @@ -76,6 +76,8 @@ export function buildAdminHook(overrides: Record = {}): AdminHo placesDetailsEnabled: false, setPlacesDetailsEnabledState: vi.fn(), placesEnrichEnabled: true, + placesGoogleOnly: false, + handleTogglePlacesGoogleOnly: vi.fn(), setPlacesEnrichEnabledState: vi.fn(), // Fail-open on the server, so the harness starts where an unset row lands. trekPlacesEnabled: true, diff --git a/server/src/nest/addons/addons.service.ts b/server/src/nest/addons/addons.service.ts index a1bcdf55f..ff97fd247 100644 --- a/server/src/nest/addons/addons.service.ts +++ b/server/src/nest/addons/addons.service.ts @@ -198,6 +198,15 @@ export class AddonsService { getPlaceShadow() { return this.readFlag('place_shadow_enabled'); } updatePlaceShadow(enabled: boolean) { return this.writeFlag('place_shadow_enabled', enabled); } + /** + * Search and suggestions from Google alone, skipping the index and + * OpenStreetMap. Fail-closed for the same reason as the shadow log: nobody had + * this before it existed, so an absent row correctly means off. MapsService + * reads the same key the same way, and only once Google holds the key slot. + */ + getPlacesGoogleOnly() { return this.readFlag('places_google_only'); } + updatePlacesGoogleOnly(enabled: boolean) { return this.writeFlag('places_google_only', enabled); } + /** * Enrichment reads fail-OPEN, unlike the three switches above. * diff --git a/server/src/nest/admin/admin.controller.ts b/server/src/nest/admin/admin.controller.ts index 0706992c3..59fa1f5f7 100644 --- a/server/src/nest/admin/admin.controller.ts +++ b/server/src/nest/admin/admin.controller.ts @@ -243,6 +243,16 @@ export class AdminController { return result; } + @Get('places-google-only') + getPlacesGoogleOnly() { return this.addons.getPlacesGoogleOnly(); } + + @Put('places-google-only') + updatePlacesGoogleOnly(@CurrentUser() user: User, @Body() body: AdminFeatureToggleDto, @Req() req: Request) { + const result = this.addons.updatePlacesGoogleOnly(body.enabled); + this.audit.writeAudit({ userId: user.id, action: 'admin.places_google_only', ip: getClientIp(req), details: { enabled: result.enabled } }); + return result; + } + @Get('places-enrich') getPlacesEnrich() { return this.addons.getPlacesEnrich(); } diff --git a/server/src/nest/maps/maps.controller.ts b/server/src/nest/maps/maps.controller.ts index c574f8f7a..3e3d55f07 100644 --- a/server/src/nest/maps/maps.controller.ts +++ b/server/src/nest/maps/maps.controller.ts @@ -76,7 +76,7 @@ export class MapsController { @Query('lang') lang?: string, ): Promise { try { - return await this.maps.search(user.id, body.query, lang, body.locationBias); + return await this.maps.search(user.id, body.query, lang, body.locationBias, body.provider); } catch (err: unknown) { console.error('Maps search error:', err); throw toHttpException(err, 'Search error', 500); diff --git a/server/src/nest/maps/maps.service.ts b/server/src/nest/maps/maps.service.ts index d5c2d0290..edf5de8f3 100644 --- a/server/src/nest/maps/maps.service.ts +++ b/server/src/nest/maps/maps.service.ts @@ -664,6 +664,12 @@ type LocationBias = { low: { lat: number; lng: number }; high: { lat: number; ln * predates Amap, means `auto`, which keeps Google. */ export const PLACES_PROVIDER_SETTING = 'places_provider'; +/** + * The admin switch that hands search and suggestions to Google alone. Off, the + * index and OpenStreetMap answer first and Google is only asked when they find + * nothing, which is what every install has had since 4.3.0. + */ +export const PLACES_GOOGLE_ONLY_SETTING = 'places_google_only'; /** * Whoever holds the keyed slot beside the index for one request: Google's @@ -761,8 +767,14 @@ export class MapsService { // ── Controller-facing surface (unchanged signatures) ─────────────────────── - search(userId: number, query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }): Promise { - return this.searchPlaces(userId, query, lang, locationBias) as Promise; + search( + userId: number, + query: string, + lang?: string, + locationBias?: { lat: number; lng: number; radius?: number }, + provider?: 'google', + ): Promise { + return this.searchPlaces(userId, query, lang, locationBias, { googleOnly: provider === 'google' }) as Promise; } autocomplete(userId: number, input: string, lang?: string, locationBias?: LocationBias, sessionToken?: string): Promise { @@ -1065,6 +1077,33 @@ export class MapsService { : null; } + /** + * Whether this search goes to Google and nowhere else. + * + * Two ways to ask for that, both born of the same moment: the index answered + * a query with something that is not the place the traveller meant, and with + * the index and OpenStreetMap answering first, Google was never consulted as + * long as they found anything at all. The caller can send one search to + * Google (`requested`, the "search Google instead" link under the results), + * and the admin can make that the rule for every search and suggestion (the + * switch beside the key). Either way it only holds when Google holds the key + * slot: on an install without a Google key, or one that picked Amap or + * OpenStreetMap, both change nothing, and the admin panel says so. + * + * Read after the keyed provider on purpose: that lookup already walked the + * key chain, and a setting read ahead of it would shift the order of the + * app_settings reads every test of the chain stubs by position. + */ + private googleOnly(keyed: KeyedProvider | null, requested = false): boolean { + if (keyed?.id !== 'google') return false; + if (requested) return true; + const row = this.database.get<{ value: string }>( + 'SELECT value FROM app_settings WHERE key = ?', + PLACES_GOOGLE_ONLY_SETTING, + ); + return row?.value === 'true'; + } + /** The Amap provider, when Amap holds the keyed slot; null otherwise. */ resolvePlacesProvider(userId: number): AmapPlacesProvider | null { const keyed = this.keyedProvider(userId); @@ -2021,7 +2060,7 @@ export class MapsService { query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }, - opts: { googleIdentityOnly?: boolean } = {}, + opts: { googleIdentityOnly?: boolean; googleOnly?: boolean } = {}, ): Promise<{ places: Record[]; source: string }> { const keyed = this.keyedProvider(userId); const { key: apiKey, source: keySource } = keyed?.id === 'google' ? keyed : { key: null, source: null }; @@ -2048,7 +2087,10 @@ export class MapsService { // the same question twice. `null` means it never ran. let osmAnswer: Record[] | null = null; - if (this.trekPlacesEnabled() && !(opts.googleIdentityOnly && apiKey)) { + // A search sent to Google on purpose, or the admin's "Google only" switch, + // skips the pair the same way: the search then reads exactly as it did + // before 4.3.0 on an install with a key. + if (this.trekPlacesEnabled() && !(opts.googleIdentityOnly && apiKey) && !this.googleOnly(keyed, opts.googleOnly)) { // Both at once. The index is a dataset of businesses and is very good // at those; OpenStreetMap is where the temples, bridges, riverside // walks and viewpoints are, and a travel search asks for those @@ -2188,8 +2230,9 @@ export class MapsService { // could get every instance blocked at once. The index removes that. // // Same contract as search: never throws upward, falls through to what this - // method did before. - if (this.trekPlacesEnabled()) { + // method did before. The admin's "Google only" switch skips the index here + // too, so the suggestions and the search agree on where they come from. + if (this.trekPlacesEnabled() && !this.googleOnly(keyed)) { try { const centre = locationBias ? { diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index 740559c5a..b8cd1bb95 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -306,18 +306,19 @@ export class PlacesMcp { query: z.string().min(1).max(500).describe('Place name or address to search for'), locationBias: mapsSearchRequestSchema.shape.locationBias.describe('Centre the search on a coordinate: { lat, lng, radius? } with radius in metres (default 50000). Only the Google provider honours it; the OpenStreetMap fallback ignores it'), lang: z.string().max(35).optional().describe('BCP 47 language for the result names, e.g. "de" or "ja". Defaults to English'), + provider: mapsSearchRequestSchema.shape.provider.describe('"google" sends this one search to Google Places alone instead of the TREK index and OpenStreetMap, the same as the "search Google instead" link under the results in the app. Ignored when the instance has no Google key'), }, annotations: TOOL_ANNOTATIONS_READONLY, access: { group: 'places', mode: 'read' }, }) async searchPlace( - { query, locationBias, lang }: { - query: string; locationBias?: { lat: number; lng: number; radius?: number }; lang?: string; + { query, locationBias, lang, provider }: { + query: string; locationBias?: { lat: number; lng: number; radius?: number }; lang?: string; provider?: 'google'; }, ctx: McpContext, ) { try { - const result = await this.maps.searchPlaces(ctx.userId, query, lang, locationBias); + const result = await this.maps.searchPlaces(ctx.userId, query, lang, locationBias, { googleOnly: provider === 'google' }); return ok(result); } catch { return errorResult('Place search failed.'); diff --git a/server/tests/unit/nest/addons.service.test.ts b/server/tests/unit/nest/addons.service.test.ts index 87e3809bb..c603c7ee1 100644 --- a/server/tests/unit/nest/addons.service.test.ts +++ b/server/tests/unit/nest/addons.service.test.ts @@ -495,6 +495,36 @@ describe('AddonsService transit provider', () => { * PlaceShadowService.enabled(), which reads the same key itself — if the two * diverge the admin panel shows "off" while the log keeps collecting picks. */ +/** + * The Google-only switch reads like the shadow log: nobody had it before it + * existed, so an absent row is off, and MapsService.googleOnly() compares the + * same key against the same literal. + */ +describe('AddonsService places Google-only flag', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('ADDONS-SVC-095 an unset flag reads as OFF, only the literal "true" switches it on', () => { + dbMock._stmt.get.mockReturnValueOnce(undefined); + expect(svc().getPlacesGoogleOnly()).toEqual({ enabled: false }); + expect(dbMock._stmt.get).toHaveBeenLastCalledWith('places_google_only'); + for (const value of ['false', 'TRUE', '1', '']) { + dbMock._stmt.get.mockReturnValueOnce({ value }); + expect(svc().getPlacesGoogleOnly()).toEqual({ enabled: false }); + } + dbMock._stmt.get.mockReturnValueOnce({ value: 'true' }); + expect(svc().getPlacesGoogleOnly()).toEqual({ enabled: true }); + }); + + it('ADDONS-SVC-096 the setter persists the literal string under its own key', () => { + expect(svc().updatePlacesGoogleOnly(true)).toEqual({ enabled: true }); + expect(dbMock._stmt.run).toHaveBeenLastCalledWith('places_google_only', 'true'); + expect(svc().updatePlacesGoogleOnly(false)).toEqual({ enabled: false }); + expect(dbMock._stmt.run).toHaveBeenLastCalledWith('places_google_only', 'false'); + }); +}); + describe('AddonsService place shadow flag', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/server/tests/unit/nest/admin.controller.test.ts b/server/tests/unit/nest/admin.controller.test.ts index df5934b71..e8be67592 100644 --- a/server/tests/unit/nest/admin.controller.test.ts +++ b/server/tests/unit/nest/admin.controller.test.ts @@ -48,6 +48,9 @@ const addonsStub = () => ({ // Fail-open, unlike its three neighbours — see AddonsService.getPlacesEnrich. getPlacesEnrich: vi.fn(() => ({ enabled: true })), updatePlacesEnrich: vi.fn((enabled: boolean) => ({ enabled })), + // Fail-closed with nothing to backfill, like the shadow log. + getPlacesGoogleOnly: vi.fn(() => ({ enabled: false })), + updatePlacesGoogleOnly: vi.fn((enabled: boolean) => ({ enabled })), // Fail-open too, and the only switch here about egress rather than spending. getCollabFeatures: vi.fn(() => ({ chat: false })), updateCollabFeatures: vi.fn(() => ({ features: { chat: true }, changed: true })), @@ -277,9 +280,16 @@ describe('AdminController feature toggles', () => { expect(c.getPlacesDetails()).toEqual({ enabled: false }); expect(c.getPlaceShadow()).toEqual({ enabled: false }); expect(c.getPlacesEnrich()).toEqual({ enabled: true }); + expect(c.getPlacesGoogleOnly()).toEqual({ enabled: false }); expect(c.getCollabFeatures()).toEqual({ chat: false }); }); + it('ADMIN-TOGGLE-002d places-google-only updates through the addons domain and is audited', () => { + const c = adminCtl(svc()); + expect(c.updatePlacesGoogleOnly(user, { enabled: true }, req)).toEqual({ enabled: true }); + expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.places_google_only', details: { enabled: true } })); + }); + it('ADMIN-TOGGLE-002b places-enrich updates through the addons domain and is audited', () => { const c = adminCtl(svc()); expect(c.updatePlacesEnrich(user, { enabled: false }, req)).toEqual({ enabled: false }); diff --git a/server/tests/unit/nest/maps.controller.test.ts b/server/tests/unit/nest/maps.controller.test.ts index 9ec209b96..9b98a9911 100644 --- a/server/tests/unit/nest/maps.controller.test.ts +++ b/server/tests/unit/nest/maps.controller.test.ts @@ -49,14 +49,20 @@ describe('MapsController (parity with the legacy /api/maps route)', () => { const search = vi.fn().mockResolvedValue({ places: [], source: 'osm' }); const res = await makeController({ search }).search(user, { query: 'berlin' }, 'de'); expect(res).toEqual({ places: [], source: 'osm' }); - expect(search).toHaveBeenCalledWith(3, 'berlin', 'de', undefined); + expect(search).toHaveBeenCalledWith(3, 'berlin', 'de', undefined, undefined); + }); + + it('forwards the provider a search is sent to alone', async () => { + const search = vi.fn().mockResolvedValue({ places: [], source: 'google' }); + await makeController({ search }).search(user, { query: 'x', provider: 'google' }, 'de'); + expect(search).toHaveBeenCalledWith(3, 'x', 'de', undefined, 'google'); }); it('forwards a valid locationBias to the service', async () => { const search = vi.fn().mockResolvedValue({ places: [], source: 'osm' }); const bias = { lat: 1, lng: 2, radius: 5000 }; await makeController({ search }).search(user, { query: 'x', locationBias: bias }, 'de'); - expect(search).toHaveBeenCalledWith(3, 'x', 'de', bias); + expect(search).toHaveBeenCalledWith(3, 'x', 'de', bias, undefined); }); it('maps a service error to its status + message', async () => { diff --git a/server/tests/unit/nest/maps.search-merge.test.ts b/server/tests/unit/nest/maps.search-merge.test.ts index cba176ab8..0b306c5ee 100644 --- a/server/tests/unit/nest/maps.search-merge.test.ts +++ b/server/tests/unit/nest/maps.search-merge.test.ts @@ -76,13 +76,27 @@ const osmAnswer = (rows: unknown[]) => ({ ok: true, json: async () => rows }); /** * Keyed on the statement so the kill switch can be driven without also handing * `'false'` to the API-key resolver, which would send the fallback at Google. + * + * `rows` are the app_settings the instance holds, by key: a Google key, the + * provider choice, the Google-only switch. Anything else reads as absent. */ -function make(enabled = true) { +function make(enabled = true, rows: Record = {}) { trekPlaces.on = enabled; - const database = { get: vi.fn(() => undefined) } as unknown as DatabaseService; + const database = { + get: vi.fn((sql: string, key?: unknown) => + typeof key === 'string' && sql.includes('app_settings') && rows[key] !== undefined ? { value: rows[key] } : undefined, + ), + } as unknown as DatabaseService; return new MapsService(database, {} as PlacePhotoCacheService); } +const googleAnswer = (name: string) => ({ + ok: true, + json: async () => ({ + places: [{ id: 'g1', displayName: { text: name }, formattedAddress: 'Tokyo', location: { latitude: 35.68, longitude: 139.77 } }], + }), +}); + beforeEach(() => { mockSearch.mockReset(); mockSearch.mockResolvedValue([]); @@ -195,6 +209,64 @@ describe('MapsService.searchPlaces — the merged path', () => { expect(out.places[2].name).toBe('Index 1'); }); + it('MAPS-SEARCH-011: the Google-only switch sends the search to Google and asks neither the index nor OpenStreetMap', async () => { + mockSearch.mockResolvedValue([indexHit('Weigh station, Ritzville')]); + mockNominatim.mockResolvedValue(osmAnswer([osmRow('Some station')])); + const fetchMock = vi.fn().mockResolvedValue(googleAnswer('Tokyo Station')); + vi.stubGlobal('fetch', fetchMock); + + const out = await make(true, { maps_api_key: 'key', places_google_only: 'true' }).searchPlaces(1, 'Tokyo Station'); + + expect(mockSearch).not.toHaveBeenCalled(); + expect(mockNominatim).not.toHaveBeenCalled(); + expect(out.source).toBe('google'); + expect(out.places[0].name).toBe('Tokyo Station'); + expect(String(fetchMock.mock.calls[0][0])).toContain('places:searchText'); + vi.unstubAllGlobals(); + }); + + it('MAPS-SEARCH-012: the switch changes nothing without a Google key, or when Google does not hold the slot', async () => { + mockSearch.mockResolvedValue([indexHit("L'Osteria")]); + mockNominatim.mockResolvedValue(osmAnswer([osmRow('Steinstrasse')])); + + // No key at all: the index and OpenStreetMap answer, as before. + const keyless = await make(true, { places_google_only: 'true' }).searchPlaces(1, "L'Osteria"); + expect(keyless.source).toBe('trek-places+openstreetmap'); + + // A key, but the admin picked OpenStreetMap as the provider: Google holds + // no slot, so there is nothing for the switch to hand the search to. + mockSearch.mockClear(); + const osmOnly = await make(true, { maps_api_key: 'key', places_google_only: 'true', places_provider: 'openstreetmap' }) + .searchPlaces(1, "L'Osteria"); + expect(mockSearch).toHaveBeenCalled(); + expect(osmOnly.source).toBe('trek-places+openstreetmap'); + + // A key and the switch off: the index still answers first. + mockSearch.mockClear(); + const off = await make(true, { maps_api_key: 'key', places_google_only: 'false' }).searchPlaces(1, "L'Osteria"); + expect(mockSearch).toHaveBeenCalled(); + expect(off.source).toBe('trek-places+openstreetmap'); + }); + + it('MAPS-SEARCH-013: one search sent to Google on purpose goes there alone, and only where Google holds the slot', async () => { + mockSearch.mockResolvedValue([indexHit('Weigh station, Ritzville')]); + mockNominatim.mockResolvedValue(osmAnswer([osmRow('Some station')])); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(googleAnswer('Tokyo Station'))); + + const asked = await make(true, { maps_api_key: 'key' }).searchPlaces(1, 'Tokyo Station', undefined, undefined, { googleOnly: true }); + expect(mockSearch).not.toHaveBeenCalled(); + expect(asked.source).toBe('google'); + + // The same request on an install without a key is an ordinary search: the + // link that sends it is never shown there, and a client that sends it + // anyway gets what everybody else gets. + mockSearch.mockClear(); + const keyless = await make(true, {}).searchPlaces(1, 'Tokyo Station', undefined, undefined, { googleOnly: true }); + expect(mockSearch).toHaveBeenCalled(); + expect(keyless.source).toBe('trek-places+openstreetmap'); + vi.unstubAllGlobals(); + }); + it('MAPS-SEARCH-007: with the index switched off the query never leaves for it', async () => { mockSearch.mockResolvedValue([indexHit("L'Osteria")]); mockNominatim.mockResolvedValue(osmAnswer([osmRow('Steinstrasse')])); diff --git a/server/tests/unit/nest/maps.service.test.ts b/server/tests/unit/nest/maps.service.test.ts index 00472067b..04aaa8196 100644 --- a/server/tests/unit/nest/maps.service.test.ts +++ b/server/tests/unit/nest/maps.service.test.ts @@ -2721,7 +2721,9 @@ describe('controller-facing wrappers delegate to the folded methods', () => { try { const circleBias = { lat: 1, lng: 2, radius: 5 }; await svc.search(3, 'berlin', 'de', circleBias); - expect(spies.searchPlaces).toHaveBeenCalledWith(3, 'berlin', 'de', circleBias); + expect(spies.searchPlaces).toHaveBeenCalledWith(3, 'berlin', 'de', circleBias, { googleOnly: false }); + await svc.search(3, 'berlin', 'de', circleBias, 'google'); + expect(spies.searchPlaces).toHaveBeenLastCalledWith(3, 'berlin', 'de', circleBias, { googleOnly: true }); const rectBias = { low: { lat: 1, lng: 2 }, high: { lat: 3, lng: 4 } }; await svc.autocomplete(3, 'be', 'en', rectBias); diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index 950c0a098..c6e2384a8 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -195,6 +195,9 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'إثراء الأماكن', 'admin.placesEnrich.subtitle': 'يعرض صورًا ووصفًا أثناء إضافة مكان. تُستخدم ويكيبيديا وOpenStreetMap دائمًا، وتُضاف Google عند تفعيل صور الأماكن أو تفاصيل الأماكن.', + 'admin.placesGoogleOnly.title': 'البحث عبر Google فقط', + 'admin.placesGoogleOnly.subtitle': 'كل بحث وكل اقتراح يذهب إلى Google Places. عند الإيقاف يجيب فهرس TREK وOpenStreetMap أولًا، ولا يُسأل Google إلا إذا لم يجدا شيئًا.', + 'admin.placesGoogleOnly.missingKey': 'يتطلب مفتاح Google Maps API. من دونه يعمل البحث عبر فهرس TREK وOpenStreetMap مهما كان وضع هذا المفتاح.', 'admin.transitProvider.title': 'مزود النقل العام', 'admin.transitProvider.subtitle': 'الخدمة التي تجيب على بحث النقل العام.', 'admin.transitProvider.transitous': 'Transitous (مجاني)', diff --git a/shared/src/i18n/ar/places.ts b/shared/src/i18n/ar/places.ts index 35b8300df..cbc935fcd 100644 --- a/shared/src/i18n/ar/places.ts +++ b/shared/src/i18n/ar/places.ts @@ -89,6 +89,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': 'ملاحظات الحجز، رقم التأكيد...', 'places.mapsSearchPlaceholder': 'ابحث عن أماكن...', 'places.mapsSearchError': 'فشل البحث عن المكان.', + 'places.searchGoogleInstead': 'ليس المكان الصحيح؟ ابحث في Google بدلًا من ذلك', 'places.loadingDetails': 'جارٍ تحميل تفاصيل المكان…', 'places.osmHint': 'يتم البحث عبر OpenStreetMap (بدون صور أو ساعات عمل أو تقييمات). أضف مفتاح Google API في الإعدادات للحصول على جميع التفاصيل.', diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 7a550e0bb..926cd1ef1 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -173,6 +173,9 @@ 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.placesGoogleOnly.title': 'Pesquisar apenas com o Google', + 'admin.placesGoogleOnly.subtitle': 'Toda pesquisa e toda sugestão vão para o Google Places. Desligado, o índice do TREK e o OpenStreetMap respondem primeiro, e o Google só é consultado se eles não encontrarem nada.', + 'admin.placesGoogleOnly.missingKey': 'Precisa de uma chave de API do Google Maps. Sem ela, a pesquisa usa o índice do TREK e o OpenStreetMap, independentemente desta chave.', '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)', diff --git a/shared/src/i18n/br/places.ts b/shared/src/i18n/br/places.ts index 67362676f..bda8ad319 100644 --- a/shared/src/i18n/br/places.ts +++ b/shared/src/i18n/br/places.ts @@ -89,6 +89,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': 'Notas da reserva, código de confirmação...', 'places.mapsSearchPlaceholder': 'Buscar lugares...', 'places.mapsSearchError': 'Falha na busca de lugares.', + 'places.searchGoogleInstead': 'Não é o lugar certo? Pesquisar no Google', 'places.loadingDetails': 'Carregando detalhes do lugar…', 'places.osmHint': 'Busca via OpenStreetMap (sem fotos, horários ou avaliações). Adicione uma chave Google nas configurações para detalhes completos.', diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index eae86f3a1..91cf3e46b 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -166,6 +166,9 @@ 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.placesGoogleOnly.title': 'Cercar només amb Google', + 'admin.placesGoogleOnly.subtitle': 'Cada cerca i cada suggeriment van a Google Places. Desactivat, responen primer l\'índex de TREK i OpenStreetMap, i Google només es consulta si no troben res.', + 'admin.placesGoogleOnly.missingKey': 'Cal una clau d\'API de Google Maps. Sense clau, la cerca fa servir l\'índex de TREK i OpenStreetMap, sigui com sigui aquest interruptor.', '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)', diff --git a/shared/src/i18n/ca/places.ts b/shared/src/i18n/ca/places.ts index 6c6062822..a825fff6b 100644 --- a/shared/src/i18n/ca/places.ts +++ b/shared/src/i18n/ca/places.ts @@ -87,6 +87,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': 'Notes de reserva, número de confirmació...', 'places.mapsSearchPlaceholder': 'Cerca llocs...', 'places.mapsSearchError': 'La cerca de llocs ha fallat.', + 'places.searchGoogleInstead': 'No és el lloc correcte? Cerca a Google', 'places.loadingDetails': 'Carregant els detalls del lloc…', 'places.osmHint': "S'està utilitzant la cerca amb OpenStreetMap (sense fotos, horaris ni valoracions). Afegeix una clau API de Google a Configuració per obtenir tots els detalls.", diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index f45a1a4eb..23000b113 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -175,6 +175,9 @@ 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.placesGoogleOnly.title': 'Hledat pouze přes Google', + 'admin.placesGoogleOnly.subtitle': 'Každé hledání a každý návrh jde do Google Places. Vypnuto: nejprve odpovídá index TREK a OpenStreetMap, Google se ptáme jen tehdy, když nic nenajdou.', + 'admin.placesGoogleOnly.missingKey': 'Vyžaduje klíč Google Maps API. Bez něj hledání běží přes index TREK a OpenStreetMap bez ohledu na tento přepínač.', '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)', diff --git a/shared/src/i18n/cs/places.ts b/shared/src/i18n/cs/places.ts index 5f6980349..42a48ece5 100644 --- a/shared/src/i18n/cs/places.ts +++ b/shared/src/i18n/cs/places.ts @@ -89,6 +89,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': 'Poznámky k rezervaci, potvrzovací kód...', 'places.mapsSearchPlaceholder': 'Hledat místa...', 'places.mapsSearchError': 'Hledání místa se nezdařilo.', + 'places.searchGoogleInstead': 'Není to správné místo? Hledat na Googlu', 'places.loadingDetails': 'Načítání podrobností místa…', 'places.osmHint': 'Používáte hledání přes OpenStreetMap (bez fotek a hodnocení). Pro plné detaily přidejte Google API klíč v nastavení.', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index e2cb027d8..2849a63ed 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -176,6 +176,9 @@ 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.placesGoogleOnly.title': 'Nur mit Google suchen', + 'admin.placesGoogleOnly.subtitle': 'Jede Suche und jeder Vorschlag geht an Google Places. Aus, antworten zuerst der TREK-Index und OpenStreetMap, Google wird nur gefragt, wenn beide nichts finden.', + 'admin.placesGoogleOnly.missingKey': 'Braucht einen Google-Maps-API-Schlüssel. Ohne ihn läuft die Suche über den TREK-Index und OpenStreetMap, egal wie dieser Schalter steht.', 'admin.transitProvider.title': 'Verkehrsanbieter', 'admin.transitProvider.subtitle': 'Welcher Dienst die ÖPNV-Suche beantwortet.', 'admin.transitProvider.transitous': 'Transitous (kostenlos)', diff --git a/shared/src/i18n/de/places.ts b/shared/src/i18n/de/places.ts index b2d6d888a..5b166cb25 100644 --- a/shared/src/i18n/de/places.ts +++ b/shared/src/i18n/de/places.ts @@ -89,6 +89,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': 'Reservierungsnotizen, Bestätigungsnummer...', 'places.mapsSearchPlaceholder': 'Ortssuche...', 'places.mapsSearchError': 'Ortssuche fehlgeschlagen.', + 'places.searchGoogleInstead': 'Nicht der richtige Ort? Stattdessen bei Google suchen', 'places.loadingDetails': 'Ortsdetails werden geladen…', 'places.osmHint': 'OpenStreetMap-Suche aktiv (ohne Bilder, Öffnungszeiten, Bewertungen). Für erweiterte Daten Google API Key in den Einstellungen hinterlegen.', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 3f5907373..ba16e1eb6 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -215,6 +215,9 @@ 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.placesGoogleOnly.title': 'Search with Google only', + 'admin.placesGoogleOnly.subtitle': 'Every search and every suggestion goes to Google Places. Off, TREK\\\\\\\'s own index and OpenStreetMap answer first and Google is only asked when they find nothing.', + 'admin.placesGoogleOnly.missingKey': 'Needs a Google Maps API key. Without one, search runs on TREK\\\\\\\'s own index and OpenStreetMap whatever this switch says.', 'admin.transitProvider.title': 'Transit Provider', 'admin.transitProvider.subtitle': 'Which service answers public transit search.', 'admin.transitProvider.transitous': 'Transitous (free)', diff --git a/shared/src/i18n/en/places.ts b/shared/src/i18n/en/places.ts index 1151931ca..9cf880926 100644 --- a/shared/src/i18n/en/places.ts +++ b/shared/src/i18n/en/places.ts @@ -90,6 +90,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': 'Reservation notes, confirmation number...', 'places.mapsSearchPlaceholder': 'Search places...', 'places.mapsSearchError': 'Place search failed.', + 'places.searchGoogleInstead': 'Not the right place? Search Google instead', 'places.loadingDetails': 'Loading place details…', 'places.osmHint': 'Using OpenStreetMap search (no photos, opening hours, or ratings). Add a Google API key in settings for full details.', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index dcda22477..e4861e457 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -175,6 +175,9 @@ 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.placesGoogleOnly.title': 'Buscar solo con Google', + 'admin.placesGoogleOnly.subtitle': 'Cada búsqueda y cada sugerencia van a Google Places. Desactivado, responden primero el índice de TREK y OpenStreetMap, y Google solo se consulta si no encuentran nada.', + 'admin.placesGoogleOnly.missingKey': 'Necesita una clave de API de Google Maps. Sin ella, la búsqueda usa el índice de TREK y OpenStreetMap, esté como esté este interruptor.', '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)', diff --git a/shared/src/i18n/es/places.ts b/shared/src/i18n/es/places.ts index fd1823593..7a9429d0b 100644 --- a/shared/src/i18n/es/places.ts +++ b/shared/src/i18n/es/places.ts @@ -89,6 +89,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': 'Notas de reserva, número de confirmación...', 'places.mapsSearchPlaceholder': 'Buscar lugares...', 'places.mapsSearchError': 'La búsqueda de lugares falló.', + 'places.searchGoogleInstead': '¿No es el lugar correcto? Buscar en Google', 'places.loadingDetails': 'Cargando detalles del lugar…', 'places.osmHint': 'Usando búsqueda con OpenStreetMap (sin fotos, horarios ni valoraciones). Añade una clave API de Google en Ajustes para obtener todos los detalles.', diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index 495c03e67..0a7ed9601 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -177,6 +177,9 @@ 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.placesGoogleOnly.title': 'Rechercher uniquement avec Google', + 'admin.placesGoogleOnly.subtitle': 'Chaque recherche et chaque suggestion passent par Google Places. Désactivé, l\'index de TREK et OpenStreetMap répondent d\'abord, Google n\'est interrogé que s\'ils ne trouvent rien.', + 'admin.placesGoogleOnly.missingKey': 'Nécessite une clé API Google Maps. Sans elle, la recherche passe par l\'index de TREK et OpenStreetMap, quelle que soit la position de cet interrupteur.', '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)', diff --git a/shared/src/i18n/fr/places.ts b/shared/src/i18n/fr/places.ts index a9324a00a..87f3c8455 100644 --- a/shared/src/i18n/fr/places.ts +++ b/shared/src/i18n/fr/places.ts @@ -89,6 +89,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': 'Notes de réservation, numéro de confirmation…', 'places.mapsSearchPlaceholder': 'Rechercher des lieux…', 'places.mapsSearchError': 'La recherche de lieu a échoué.', + 'places.searchGoogleInstead': 'Ce n\'est pas le bon lieu ? Rechercher sur Google', 'places.loadingDetails': 'Chargement des détails du lieu…', 'places.osmHint': 'Recherche via OpenStreetMap (pas de photos, horaires ni notes). Ajoutez une clé API Google dans les paramètres pour plus de détails.', diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index abbd2d089..a7064be6a 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -223,6 +223,9 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'Εμπλουτισμός τοποθεσιών', 'admin.placesEnrich.subtitle': 'Εμφανίζει εικόνες και περιγραφή κατά την προσθήκη τοποθεσίας. Η Wikipedia και το OpenStreetMap χρησιμοποιούνται πάντα· η Google προστίθεται όταν είναι ενεργές οι Φωτογραφίες ή οι Λεπτομέρειες τοποθεσίας.', + 'admin.placesGoogleOnly.title': 'Αναζήτηση μόνο με Google', + 'admin.placesGoogleOnly.subtitle': 'Κάθε αναζήτηση και κάθε πρόταση πηγαίνει στο Google Places. Απενεργοποιημένο, απαντούν πρώτα το ευρετήριο του TREK και το OpenStreetMap, και το Google ρωτιέται μόνο αν δεν βρουν τίποτα.', + 'admin.placesGoogleOnly.missingKey': 'Χρειάζεται κλειδί Google Maps API. Χωρίς αυτό η αναζήτηση τρέχει στο ευρετήριο του TREK και στο OpenStreetMap, όπως κι αν είναι ο διακόπτης.', 'admin.transitProvider.title': 'Πάροχος μέσων μεταφοράς', 'admin.transitProvider.subtitle': 'Ποια υπηρεσία απαντά στην αναζήτηση μέσων μεταφοράς.', 'admin.transitProvider.transitous': 'Transitous (δωρεάν)', diff --git a/shared/src/i18n/gr/places.ts b/shared/src/i18n/gr/places.ts index dc0efdffd..88cb65dcc 100644 --- a/shared/src/i18n/gr/places.ts +++ b/shared/src/i18n/gr/places.ts @@ -89,6 +89,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': 'Σημειώσεις κράτησης, αριθμός επιβεβαίωσης...', 'places.mapsSearchPlaceholder': 'Αναζήτηση μερών...', 'places.mapsSearchError': 'Η αναζήτηση μέρους απέτυχε.', + 'places.searchGoogleInstead': 'Δεν είναι το σωστό μέρος; Αναζήτηση στο Google', 'places.loadingDetails': 'Φόρτωση λεπτομερειών μέρους…', 'places.osmHint': 'Χρήση αναζήτησης OpenStreetMap (χωρίς φωτογραφίες, ώρες λειτουργίας ή αξιολογήσεις). Προσθέστε ένα κλειδί Google API στις ρυθμίσεις για πλήρεις λεπτομέρειες.', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index 45d7a02d0..64e0637e9 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -177,6 +177,9 @@ 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.placesGoogleOnly.title': 'Keresés csak a Google-lel', + 'admin.placesGoogleOnly.subtitle': 'Minden keresés és minden javaslat a Google Places-hez megy. Kikapcsolva előbb a TREK saját indexe és az OpenStreetMap válaszol, a Google-t csak akkor kérdezzük, ha ők nem találnak semmit.', + 'admin.placesGoogleOnly.missingKey': 'Google Maps API-kulcs kell hozzá. Nélküle a keresés a TREK indexén és az OpenStreetMapen fut, bárhogy áll is ez a kapcsoló.', '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)', diff --git a/shared/src/i18n/hu/places.ts b/shared/src/i18n/hu/places.ts index 8f47b1ce2..8560023d5 100644 --- a/shared/src/i18n/hu/places.ts +++ b/shared/src/i18n/hu/places.ts @@ -89,6 +89,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': 'Foglalási jegyzetek, visszaigazolási szám...', 'places.mapsSearchPlaceholder': 'Helyek keresése...', 'places.mapsSearchError': 'Helykeresés sikertelen.', + 'places.searchGoogleInstead': 'Nem a megfelelő hely? Keresés inkább a Google-ben', 'places.loadingDetails': 'Hely adatainak betöltése…', 'places.osmHint': 'OpenStreetMap keresés aktív (képek, nyitvatartás és értékelések nélkül). Bővített adatokhoz add meg a Google API kulcsot a beállításokban.', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index 7b67a18f9..addb67821 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -219,6 +219,9 @@ 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.placesGoogleOnly.title': 'Cari hanya dengan Google', + 'admin.placesGoogleOnly.subtitle': 'Setiap pencarian dan setiap saran dikirim ke Google Places. Nonaktif, indeks TREK dan OpenStreetMap menjawab lebih dulu, dan Google hanya ditanya jika keduanya tidak menemukan apa pun.', + 'admin.placesGoogleOnly.missingKey': 'Memerlukan kunci API Google Maps. Tanpa kunci, pencarian memakai indeks TREK dan OpenStreetMap, apa pun posisi sakelar ini.', 'admin.transitProvider.title': 'Penyedia transportasi umum', 'admin.transitProvider.subtitle': 'Layanan mana yang menjawab pencarian transportasi umum.', 'admin.transitProvider.transitous': 'Transitous (gratis)', diff --git a/shared/src/i18n/id/places.ts b/shared/src/i18n/id/places.ts index 18681353c..6025faa16 100644 --- a/shared/src/i18n/id/places.ts +++ b/shared/src/i18n/id/places.ts @@ -88,6 +88,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': 'Catatan reservasi, nomor konfirmasi...', 'places.mapsSearchPlaceholder': 'Cari tempat...', 'places.mapsSearchError': 'Pencarian tempat gagal.', + 'places.searchGoogleInstead': 'Bukan tempat yang tepat? Cari di Google saja', 'places.loadingDetails': 'Memuat detail tempat…', 'places.osmHint': 'Menggunakan pencarian OpenStreetMap (tanpa foto, jam buka, atau penilaian). Tambahkan Google API key di pengaturan untuk detail lengkap.', diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index a2b7dbea6..5cd3b6852 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -175,6 +175,9 @@ 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.placesGoogleOnly.title': 'Cerca solo con Google', + 'admin.placesGoogleOnly.subtitle': 'Ogni ricerca e ogni suggerimento vanno a Google Places. Spento, rispondono prima l\'indice di TREK e OpenStreetMap, e Google viene interrogato solo se non trovano nulla.', + 'admin.placesGoogleOnly.missingKey': 'Richiede una chiave API di Google Maps. Senza, la ricerca usa l\'indice di TREK e OpenStreetMap, comunque sia impostato questo interruttore.', 'admin.transitProvider.title': 'Provider del trasporto pubblico', 'admin.transitProvider.subtitle': 'Quale servizio risponde alla ricerca di trasporto pubblico.', 'admin.transitProvider.transitous': 'Transitous (gratuito)', diff --git a/shared/src/i18n/it/places.ts b/shared/src/i18n/it/places.ts index dfabf319f..b60458d8a 100644 --- a/shared/src/i18n/it/places.ts +++ b/shared/src/i18n/it/places.ts @@ -89,6 +89,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': 'Note della prenotazione, numero di conferma...', 'places.mapsSearchPlaceholder': 'Cerca luoghi...', 'places.mapsSearchError': 'Impossibile cercare i luoghi.', + 'places.searchGoogleInstead': 'Non è il posto giusto? Cerca su Google', 'places.loadingDetails': 'Caricamento dettagli del luogo…', 'places.osmHint': 'Uso della ricerca OpenStreetMap (senza foto, orari di apertura o valutazioni). Aggiungi una chiave API Google nelle impostazioni per i dettagli completi.', diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index 0788a23e2..c3fe2f90b 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -208,6 +208,9 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': '場所の情報補完', 'admin.placesEnrich.subtitle': '場所を追加するときに写真と説明を表示します。WikipediaとOpenStreetMapは常に使用され、「場所の写真」または「場所の詳細」が有効な場合はGoogleも追加されます。', + 'admin.placesGoogleOnly.title': 'Google だけで検索', + 'admin.placesGoogleOnly.subtitle': 'すべての検索と候補が Google Places に送られます。オフの場合は TREK のインデックスと OpenStreetMap が先に答え、何も見つからないときだけ Google に問い合わせます。', + 'admin.placesGoogleOnly.missingKey': 'Google Maps API キーが必要です。キーがない場合、このスイッチに関係なく検索は TREK のインデックスと OpenStreetMap で行われます。', 'admin.transitProvider.title': '公共交通のプロバイダー', 'admin.transitProvider.subtitle': '公共交通の検索に応答するサービス。', 'admin.transitProvider.transitous': 'Transitous(無料)', diff --git a/shared/src/i18n/ja/places.ts b/shared/src/i18n/ja/places.ts index 46d7e4aa1..56621f892 100644 --- a/shared/src/i18n/ja/places.ts +++ b/shared/src/i18n/ja/places.ts @@ -89,6 +89,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': '予約メモ、確認番号など…', 'places.mapsSearchPlaceholder': '場所を検索…', 'places.mapsSearchError': '場所の検索に失敗しました。', + 'places.searchGoogleInstead': '目的の場所ではない? Google で検索', 'places.loadingDetails': '詳細を読み込み中…', 'places.osmHint': 'OpenStreetMapで検索しています(写真・営業時間・評価なし)。設定でGoogle APIキーを追加すると詳細が表示されます。', diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index 9adba5abc..343a9916b 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -214,6 +214,9 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': '장소 정보 보강', 'admin.placesEnrich.subtitle': '장소를 추가할 때 사진과 설명을 표시합니다. Wikipedia와 OpenStreetMap은 항상 사용되며, 장소 사진 또는 장소 세부정보가 켜져 있으면 Google도 함께 사용됩니다.', + 'admin.placesGoogleOnly.title': 'Google로만 검색', + 'admin.placesGoogleOnly.subtitle': '모든 검색과 제안이 Google Places로 갑니다. 끄면 TREK 자체 색인과 OpenStreetMap이 먼저 답하고, 둘 다 찾지 못할 때만 Google에 묻습니다.', + 'admin.placesGoogleOnly.missingKey': 'Google Maps API 키가 필요합니다. 키가 없으면 이 스위치와 상관없이 TREK 색인과 OpenStreetMap으로 검색합니다.', 'admin.transitProvider.title': '대중교통 제공자', 'admin.transitProvider.subtitle': '대중교통 검색에 응답하는 서비스입니다.', 'admin.transitProvider.transitous': 'Transitous (무료)', diff --git a/shared/src/i18n/ko/places.ts b/shared/src/i18n/ko/places.ts index 33c6f0131..db36caa84 100644 --- a/shared/src/i18n/ko/places.ts +++ b/shared/src/i18n/ko/places.ts @@ -88,6 +88,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': '예약 메모, 확인 번호...', 'places.mapsSearchPlaceholder': '장소 검색...', 'places.mapsSearchError': '장소 검색 실패.', + 'places.searchGoogleInstead': '찾는 장소가 아닌가요? Google에서 검색', 'places.loadingDetails': '장소 상세 정보 불러오는 중…', 'places.osmHint': 'OpenStreetMap 검색 사용 중 (사진, 영업 시간, 평점 없음). 전체 정보를 위해 설정에서 Google API 키를 추가하세요.', diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index fa5e9e40a..230842753 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -176,6 +176,9 @@ 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.placesGoogleOnly.title': 'Alleen met Google zoeken', + 'admin.placesGoogleOnly.subtitle': 'Elke zoekopdracht en elke suggestie gaat naar Google Places. Uit, antwoorden eerst de TREK-index en OpenStreetMap; Google wordt alleen gevraagd als die niets vinden.', + 'admin.placesGoogleOnly.missingKey': 'Vereist een Google Maps API-sleutel. Zonder sleutel zoekt TREK via de eigen index en OpenStreetMap, wat deze schakelaar ook zegt.', 'admin.transitProvider.title': 'Ov-provider', 'admin.transitProvider.subtitle': 'Welke dienst het ov-zoeken beantwoordt.', 'admin.transitProvider.transitous': 'Transitous (gratis)', diff --git a/shared/src/i18n/nl/places.ts b/shared/src/i18n/nl/places.ts index ddaf811ed..7002d7dff 100644 --- a/shared/src/i18n/nl/places.ts +++ b/shared/src/i18n/nl/places.ts @@ -89,6 +89,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': 'Reserveringsnotities, bevestigingsnummer...', 'places.mapsSearchPlaceholder': 'Plaatsen zoeken...', 'places.mapsSearchError': 'Zoeken naar plaatsen mislukt.', + 'places.searchGoogleInstead': 'Niet de juiste plek? Zoek in plaats daarvan op Google', 'places.loadingDetails': 'Plaatsgegevens laden…', 'places.osmHint': "Zoeken via OpenStreetMap (geen foto's, openingstijden of beoordelingen). Voeg een Google API-sleutel toe in instellingen voor volledige details.", diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index 3bb2e7a57..868435bf3 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -170,6 +170,9 @@ 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.placesGoogleOnly.title': 'Szukaj tylko przez Google', + 'admin.placesGoogleOnly.subtitle': 'Każde wyszukiwanie i każda podpowiedź trafia do Google Places. Wyłączone: najpierw odpowiadają indeks TREK i OpenStreetMap, a Google jest pytany tylko wtedy, gdy nic nie znajdą.', + 'admin.placesGoogleOnly.missingKey': 'Wymaga klucza Google Maps API. Bez niego wyszukiwanie działa przez indeks TREK i OpenStreetMap, niezależnie od tego przełącznika.', 'admin.transitProvider.title': 'Dostawca transportu publicznego', 'admin.transitProvider.subtitle': 'Która usługa odpowiada na wyszukiwanie transportu publicznego.', 'admin.transitProvider.transitous': 'Transitous (bezpłatnie)', diff --git a/shared/src/i18n/pl/places.ts b/shared/src/i18n/pl/places.ts index f1ded22a8..e231029b9 100644 --- a/shared/src/i18n/pl/places.ts +++ b/shared/src/i18n/pl/places.ts @@ -79,6 +79,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': 'Notatki z rezerwacji, numer potwierdzenia...', 'places.mapsSearchPlaceholder': 'Szukaj miejsc...', 'places.mapsSearchError': 'Nie udało się wyszukać miejsca.', + 'places.searchGoogleInstead': 'To nie to miejsce? Poszukaj w Google', 'places.loadingDetails': 'Ładowanie szczegółów miejsca…', 'places.osmHint': 'Korzystając z OpenStreetMap (brak zdjęć, godzin otwarcia czy ocen). Dodaj klucz API Google w ustawieniach aby uzyskać pełne dane.', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index 50b9304cc..abc78adae 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -177,6 +177,9 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'Обогащение мест', 'admin.placesEnrich.subtitle': 'Показывает изображения и описание при добавлении места. Wikipedia и OpenStreetMap используются всегда; Google добавляется, если включены «Фото мест» или «Детали мест».', + 'admin.placesGoogleOnly.title': 'Искать только через Google', + 'admin.placesGoogleOnly.subtitle': 'Каждый поиск и каждая подсказка идут в Google Places. Выключено: сначала отвечают индекс TREK и OpenStreetMap, Google спрашивается, только если они ничего не нашли.', + 'admin.placesGoogleOnly.missingKey': 'Нужен ключ Google Maps API. Без него поиск идёт через индекс TREK и OpenStreetMap независимо от этого переключателя.', 'admin.transitProvider.title': 'Поставщик общественного транспорта', 'admin.transitProvider.subtitle': 'Какая служба отвечает на поиск общественного транспорта.', 'admin.transitProvider.transitous': 'Transitous (бесплатно)', diff --git a/shared/src/i18n/ru/places.ts b/shared/src/i18n/ru/places.ts index 11167ce08..bb68c6539 100644 --- a/shared/src/i18n/ru/places.ts +++ b/shared/src/i18n/ru/places.ts @@ -89,6 +89,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': 'Заметки о бронировании, номер подтверждения...', 'places.mapsSearchPlaceholder': 'Поиск мест...', 'places.mapsSearchError': 'Ошибка поиска мест.', + 'places.searchGoogleInstead': 'Не то место? Поискать в Google', 'places.loadingDetails': 'Загрузка данных о месте…', 'places.osmHint': 'Поиск через OpenStreetMap (без фото, часов работы и рейтингов). Добавьте API-ключ Google в настройках для полной информации.', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index 7ed85bd3e..fe86e6961 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -221,6 +221,9 @@ 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.placesGoogleOnly.title': 'Sök bara med Google', + 'admin.placesGoogleOnly.subtitle': 'Varje sökning och varje förslag går till Google Places. Av svarar TREK:s eget index och OpenStreetMap först, och Google tillfrågas bara när de inte hittar något.', + 'admin.placesGoogleOnly.missingKey': 'Kräver en Google Maps API-nyckel. Utan nyckel söker TREK via sitt eget index och OpenStreetMap, oavsett hur den här brytaren står.', 'admin.transitProvider.title': 'Kollektivtrafikleverantör', 'admin.transitProvider.subtitle': 'Vilken tjänst som besvarar sökningar i kollektivtrafiken.', 'admin.transitProvider.transitous': 'Transitous (gratis)', diff --git a/shared/src/i18n/sv/places.ts b/shared/src/i18n/sv/places.ts index 777f44557..f691c566a 100644 --- a/shared/src/i18n/sv/places.ts +++ b/shared/src/i18n/sv/places.ts @@ -89,6 +89,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': 'Bokningsinformation, bekräftelsenummer...', 'places.mapsSearchPlaceholder': 'Sök efter platser...', 'places.mapsSearchError': 'Sökningen efter plats misslyckades.', + 'places.searchGoogleInstead': 'Inte rätt plats? Sök på Google i stället', 'places.loadingDetails': 'Hämtar information om platsen…', 'places.osmHint': 'Använder OpenStreetMap-sökningen (inga bilder, öppettider eller betyg). Lägg till en Google API-nyckel i inställningarna för fullständig information.', diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index ef76abe9b..596def412 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -221,6 +221,9 @@ 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.placesGoogleOnly.title': 'Yalnızca Google ile ara', + 'admin.placesGoogleOnly.subtitle': 'Her arama ve her öneri Google Places\'e gider. Kapalıyken önce TREK dizini ve OpenStreetMap yanıtlar, Google yalnızca hiçbir şey bulamadıklarında sorulur.', + 'admin.placesGoogleOnly.missingKey': 'Google Maps API anahtarı gerektirir. Anahtar yoksa arama, bu anahtar ne olursa olsun TREK dizini ve OpenStreetMap üzerinden çalışır.', '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)', diff --git a/shared/src/i18n/tr/places.ts b/shared/src/i18n/tr/places.ts index a34b759d3..90700052b 100644 --- a/shared/src/i18n/tr/places.ts +++ b/shared/src/i18n/tr/places.ts @@ -91,6 +91,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': 'Rezervasyon notları, onay numarası...', 'places.mapsSearchPlaceholder': 'Yerleri ara...', 'places.mapsSearchError': 'Yer arama başarısız oldu.', + 'places.searchGoogleInstead': 'Doğru yer değil mi? Bunun yerine Google\'da ara', 'places.loadingDetails': 'Yer ayrıntıları yükleniyor…', 'places.osmHint': 'OpenStreetMap aramasını kullanma (fotoğraf, açılış saatleri veya derecelendirme yok). Tüm ayrıntılar için ayarlara bir Google API anahtarı ekleyin.', diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index e39577877..fd6cbed43 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -177,6 +177,9 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': 'Збагачення місць', 'admin.placesEnrich.subtitle': 'Показує зображення та опис під час додавання місця. Wikipedia та OpenStreetMap використовуються завжди; Google додається, якщо ввімкнено «Фото місць» або «Деталі місць».', + 'admin.placesGoogleOnly.title': 'Шукати лише через Google', + 'admin.placesGoogleOnly.subtitle': 'Кожен пошук і кожна підказка йдуть до Google Places. Вимкнено: спершу відповідають індекс TREK та OpenStreetMap, Google запитується лише тоді, коли вони нічого не знайшли.', + 'admin.placesGoogleOnly.missingKey': 'Потрібен ключ Google Maps API. Без нього пошук іде через індекс TREK та OpenStreetMap незалежно від цього перемикача.', 'admin.transitProvider.title': 'Постачальник громадського транспорту', 'admin.transitProvider.subtitle': 'Яка служба відповідає на пошук громадського транспорту.', 'admin.transitProvider.transitous': 'Transitous (безкоштовно)', diff --git a/shared/src/i18n/uk/places.ts b/shared/src/i18n/uk/places.ts index eaeb2a12a..ef163da10 100644 --- a/shared/src/i18n/uk/places.ts +++ b/shared/src/i18n/uk/places.ts @@ -89,6 +89,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': 'Нотатки про бронювання, номер підтвердження...', 'places.mapsSearchPlaceholder': 'Пошук місць...', 'places.mapsSearchError': 'Помилка пошуку місць.', + 'places.searchGoogleInstead': 'Не те місце? Пошукати в Google', 'places.loadingDetails': 'Завантаження даних про місце…', 'places.osmHint': 'Пошук через OpenStreetMap (без фото, годин роботи та рейтингів). Додайте API-ключ Google у налаштуваннях для повної інформації.', diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index cdbe91163..fe123c25a 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -220,6 +220,9 @@ 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.placesGoogleOnly.title': 'Chỉ tìm bằng Google', + 'admin.placesGoogleOnly.subtitle': 'Mọi tìm kiếm và mọi gợi ý đều đi tới Google Places. Khi tắt, chỉ mục của TREK và OpenStreetMap trả lời trước, Google chỉ được hỏi khi cả hai không tìm thấy gì.', + 'admin.placesGoogleOnly.missingKey': 'Cần khóa API Google Maps. Không có khóa, tìm kiếm chạy trên chỉ mục của TREK và OpenStreetMap, bất kể công tắc này.', '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í)', diff --git a/shared/src/i18n/vi/places.ts b/shared/src/i18n/vi/places.ts index ab072b202..013a0696a 100644 --- a/shared/src/i18n/vi/places.ts +++ b/shared/src/i18n/vi/places.ts @@ -89,6 +89,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': 'Phiếu đặt chỗ, số xác nhận...', 'places.mapsSearchPlaceholder': 'Tìm kiếm địa điểm...', 'places.mapsSearchError': 'Tìm kiếm địa điểm không thành công.', + 'places.searchGoogleInstead': 'Không đúng địa điểm? Tìm trên Google', 'places.loadingDetails': 'Đang tải chi tiết địa điểm…', 'places.osmHint': 'Sử dụng tìm kiếm OpenStreetMap (không có ảnh, giờ mở cửa hoặc xếp hạng). Thêm khóa Google API trong cài đặt để biết đầy đủ chi tiết.', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index 3eab64e75..f94e48953 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -211,6 +211,9 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': '地點資訊補充', 'admin.placesEnrich.subtitle': '新增地點時顯示圖片與描述。一律使用維基百科與 OpenStreetMap;開啟「地點照片」或「地點詳細資料」時會額外使用 Google。', + 'admin.placesGoogleOnly.title': '僅使用 Google 搜尋', + 'admin.placesGoogleOnly.subtitle': '所有搜尋和建議都會送到 Google Places。關閉時,TREK 自有索引和 OpenStreetMap 先回答,只有兩者都沒有結果時才詢問 Google。', + 'admin.placesGoogleOnly.missingKey': '需要 Google Maps API 金鑰。沒有金鑰時,無論此開關如何,搜尋都透過 TREK 索引和 OpenStreetMap 進行。', 'admin.transitProvider.title': '公共運輸資料來源', 'admin.transitProvider.subtitle': '由哪個服務回應大眾運輸搜尋。', 'admin.transitProvider.transitous': 'Transitous(免費)', diff --git a/shared/src/i18n/zh-TW/places.ts b/shared/src/i18n/zh-TW/places.ts index 90bab3b92..619094e10 100644 --- a/shared/src/i18n/zh-TW/places.ts +++ b/shared/src/i18n/zh-TW/places.ts @@ -88,6 +88,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': '預訂備註、確認號...', 'places.mapsSearchPlaceholder': '搜尋地點...', 'places.mapsSearchError': '地點搜尋失敗。', + 'places.searchGoogleInstead': '不是想找的地點?改用 Google 搜尋', 'places.loadingDetails': '正在載入地點詳情…', 'places.osmHint': '使用 OpenStreetMap 搜尋(無照片、營業時間或評分)。在設定中新增 Google API 金鑰以獲取完整資訊。', 'places.source.amap': '高德地圖', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index 68a7ea637..9a9fb0074 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -172,6 +172,9 @@ const admin: TranslationStrings = { 'admin.placesEnrich.title': '地点信息补充', 'admin.placesEnrich.subtitle': '添加地点时显示图片和描述。始终使用维基百科和 OpenStreetMap;开启「地点照片」或「地点详情」时会额外使用 Google。', + 'admin.placesGoogleOnly.title': '仅使用 Google 搜索', + 'admin.placesGoogleOnly.subtitle': '所有搜索和建议都发送到 Google Places。关闭时,TREK 自有索引和 OpenStreetMap 先回答,只有二者都没有结果时才询问 Google。', + 'admin.placesGoogleOnly.missingKey': '需要 Google Maps API 密钥。没有密钥时,无论此开关如何,搜索都通过 TREK 索引和 OpenStreetMap 进行。', 'admin.transitProvider.title': '公共交通数据源', 'admin.transitProvider.subtitle': '由哪个服务响应公共交通搜索。', 'admin.transitProvider.transitous': 'Transitous(免费)', diff --git a/shared/src/i18n/zh/places.ts b/shared/src/i18n/zh/places.ts index 0befd6993..239a3f8c8 100644 --- a/shared/src/i18n/zh/places.ts +++ b/shared/src/i18n/zh/places.ts @@ -88,6 +88,7 @@ const places: TranslationStrings = { 'places.reservationNotesPlaceholder': '预订备注、确认号...', 'places.mapsSearchPlaceholder': '搜索地点...', 'places.mapsSearchError': '地点搜索失败。', + 'places.searchGoogleInstead': '不是想找的地点?改用 Google 搜索', 'places.loadingDetails': '正在加载地点详情…', 'places.osmHint': '使用 OpenStreetMap 搜索(无照片、营业时间或评分)。在设置中添加 Google API 密钥以获取完整信息。', 'places.source.amap': '高德地图', diff --git a/shared/src/maps/maps.schema.spec.ts b/shared/src/maps/maps.schema.spec.ts index d1370b5dc..ea5d916f0 100644 --- a/shared/src/maps/maps.schema.spec.ts +++ b/shared/src/maps/maps.schema.spec.ts @@ -18,18 +18,24 @@ describe('mapsSearchRequestSchema', () => { expect(mapsSearchRequestSchema.safeParse({}).success).toBe(false); }); + it('takes "google" as the one provider a search can be sent to alone, and nothing else', () => { + expect(mapsSearchRequestSchema.safeParse({ query: 'berlin', provider: 'google' }).success).toBe(true); + expect(mapsSearchRequestSchema.safeParse({ query: 'berlin', provider: 'osm' }).success).toBe(false); + expect(mapsSearchRequestSchema.safeParse({ query: 'berlin', provider: '' }).success).toBe(false); + }); + it('allows an optional circle locationBias with numeric lat/lng and optional radius', () => { - expect( - mapsSearchRequestSchema.safeParse({ query: 'berlin', locationBias: { lat: 52.5, lng: 13.4 } }).success, - ).toBe(true); + expect(mapsSearchRequestSchema.safeParse({ query: 'berlin', locationBias: { lat: 52.5, lng: 13.4 } }).success).toBe( + true, + ); expect( mapsSearchRequestSchema.safeParse({ query: 'berlin', locationBias: { lat: 52.5, lng: 13.4, radius: 50000 } }) .success, ).toBe(true); // NaN arrives as null over JSON; either way a non-numeric lat must fail. - expect( - mapsSearchRequestSchema.safeParse({ query: 'berlin', locationBias: { lat: null, lng: 13.4 } }).success, - ).toBe(false); + expect(mapsSearchRequestSchema.safeParse({ query: 'berlin', locationBias: { lat: null, lng: 13.4 } }).success).toBe( + false, + ); expect(mapsSearchRequestSchema.safeParse({ query: 'berlin', locationBias: { lat: 52.5 } }).success).toBe(false); }); }); @@ -172,8 +178,12 @@ describe('mapsPlaceEnrichmentResultSchema', () => { ).toBe(true); // Google's search results carry a rating but no count. expect( - mapsPlaceEnrichmentResultSchema.safeParse({ photos: [], description: null, facts: [], rating: { value: 4, count: null } }) - .success, + mapsPlaceEnrichmentResultSchema.safeParse({ + photos: [], + description: null, + facts: [], + rating: { value: 4, count: null }, + }).success, ).toBe(true); }); diff --git a/shared/src/maps/maps.schema.ts b/shared/src/maps/maps.schema.ts index f4b082631..af821f8ad 100644 --- a/shared/src/maps/maps.schema.ts +++ b/shared/src/maps/maps.schema.ts @@ -26,6 +26,13 @@ export const mapsSearchRequestSchema = z.object({ // foreign-region queries. z.number() is finite-only (zod v4), matching the // legacy Number.isFinite() check; radius was never validated beyond "number". locationBias: latLng.extend({ radius: z.number().optional() }).optional(), + /** + * Ask one provider alone for this search. The index and OpenStreetMap answer + * first by default and Google is only asked when they find nothing; a caller + * whose results were not the place they meant can send the same query to + * Google instead. Ignored on an instance where Google holds no key. + */ + provider: z.enum(['google']).optional(), }); export type MapsSearchRequest = z.infer; @@ -39,7 +46,10 @@ export const mapsAutocompleteRequestSchema = z.object({ * characters; anything else is dropped rather than forwarded, so a bad token * degrades to per-request billing instead of failing the search. */ - sessionToken: z.string().regex(/^[A-Za-z0-9_-]{1,36}$/).optional(), + sessionToken: z + .string() + .regex(/^[A-Za-z0-9_-]{1,36}$/) + .optional(), }); export type MapsAutocompleteRequest = z.infer; diff --git a/wiki/Places-and-Search.md b/wiki/Places-and-Search.md index 1c93b7132..0d9e8b47b 100644 --- a/wiki/Places-and-Search.md +++ b/wiki/Places-and-Search.md @@ -53,9 +53,14 @@ The place form on the desktop and the search sheet on the phone use the same hin > **Admin:** The Google Maps API key is instance-wide, set in **Admin → Settings → API Keys → Google Maps API Key**. It is stored encrypted at rest and used for every member of the instance. -A key does not buy a different search. Google fills the slot that answers once the TREK API and OpenStreetMap both have nothing, and it adds what only a commercial provider has: ratings and photos. No open dataset carries either of those for ordinary businesses. A place found through Google keeps its Google id, so its details, rating and photos keep coming from Google. +A key does not buy a different search by itself. Google fills the slot that answers once the TREK API and OpenStreetMap both have nothing, and it adds what only a commercial provider has: ratings and photos. No open dataset carries either of those for ordinary businesses. A place found through Google keeps its Google id, so its details, rating and photos keep coming from Google. -The key's block carries four switches under **What the key may be used for**: +That order means a search the index answered with the wrong place never reaches Google on its own. Two ways to send it there: + +- **Per search:** under a result list that did not come from Google, a small line reads **Not the right place? Search Google instead**. It sends the same query to Google Places alone, once, and the results carry the Google mark. The line only appears on an instance with a Google key, on the desktop form and the phone's search sheet alike. +- **For every search:** the switch **Search with Google only** in the key's block (below) sends every search and every suggestion to Google Places and asks nothing of the TREK API or OpenStreetMap. Off, the order above applies. The switch does nothing without a key, and nothing while Amap or OpenStreetMap is picked as the provider. + +The key's block carries five switches under **What the key may be used for**: | Switch | What it covers | |---|---| @@ -63,6 +68,7 @@ The key's block carries four switches under **What the key may be used for**: | **Place Autocomplete** | The suggestions while you type. | | **Place Details** | The details of a place: hours, rating, website. | | **Place Enrichment** | The **Place details** column in the place form, see [below](#place-details-while-searching). | +| **Search with Google only** | Every search and every suggestion goes to Google Places instead of the TREK API and OpenStreetMap. Off by default; the per-search line above stays available either way. | > **Place Autocomplete and Place Details act on every provider**, not only on Google. Switched off, the suggestion dropdown stays empty and details lookups stop for the TREK API and OpenStreetMap too; the full search keeps working. Leave both on unless that is what you want.