From 262905e357c90ab01f80d842a2e38d19efa18f54 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 30 Mar 2026 15:18:22 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20import=20places=20from=20Google=20Maps?= =?UTF-8?q?=20URLs=20=E2=80=94=20closes=20#141?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paste a Google Maps URL into the place search bar to automatically import name, coordinates, and address. No API key required. Supported URL formats: - Short URLs: maps.app.goo.gl/..., goo.gl/maps/... - Full URLs: google.com/maps/place/.../@lat,lng - Data params: !3dlat!4dlng embedded coordinates Server resolves short URL redirects and extracts coordinates. Reverse geocoding via Nominatim provides name and address. --- client/src/api/client.ts | 1 + .../src/components/Planner/PlaceFormModal.tsx | 18 ++++++ client/src/i18n/translations/de.ts | 1 + client/src/i18n/translations/en.ts | 1 + server/src/routes/maps.ts | 64 +++++++++++++++++++ 5 files changed, 85 insertions(+) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 8f09515..7a2668a 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -179,6 +179,7 @@ export const mapsApi = { details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data), placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data), reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data), + resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => r.data), } export const budgetApi = { diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index ef4ec5f..40c4d9b 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -104,6 +104,24 @@ export default function PlaceFormModal({ if (!mapsSearch.trim()) return setIsSearchingMaps(true) try { + // Detect Google Maps URLs and resolve them directly + const trimmed = mapsSearch.trim() + if (trimmed.match(/^https?:\/\/(www\.)?(google\.[a-z.]+\/maps|maps\.google\.[a-z.]+|maps\.app\.goo\.gl|goo\.gl)/i)) { + const resolved = await mapsApi.resolveUrl(trimmed) + if (resolved.lat && resolved.lng) { + setForm(prev => ({ + ...prev, + name: resolved.name || prev.name, + address: resolved.address || prev.address, + lat: String(resolved.lat), + lng: String(resolved.lng), + })) + setMapsResults([]) + setMapsSearch('') + toast.success(t('places.urlResolved')) + return + } + } const result = await mapsApi.search(mapsSearch, language) setMapsResults(result.places || []) } catch (err: unknown) { diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 3bb9785..b3af280 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -645,6 +645,7 @@ const de: Record = { 'places.addPlace': 'Ort/Aktivität hinzufügen', 'places.importGpx': 'GPX importieren', 'places.gpxImported': '{count} Orte aus GPX importiert', + 'places.urlResolved': 'Ort aus URL importiert', 'places.gpxError': 'GPX-Import fehlgeschlagen', 'places.assignToDay': 'Zu welchem Tag hinzufügen?', 'places.all': 'Alle', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 9463693..acb0a5c 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -645,6 +645,7 @@ const en: Record = { 'places.addPlace': 'Add Place/Activity', 'places.importGpx': 'Import GPX', 'places.gpxImported': '{count} places imported from GPX', + 'places.urlResolved': 'Place imported from URL', 'places.gpxError': 'GPX import failed', 'places.assignToDay': 'Add to which day?', 'places.all': 'All', diff --git a/server/src/routes/maps.ts b/server/src/routes/maps.ts index b71e80d..76b81bc 100644 --- a/server/src/routes/maps.ts +++ b/server/src/routes/maps.ts @@ -474,4 +474,68 @@ router.get('/reverse', authenticate, async (req: Request, res: Response) => { } }); +// Resolve a Google Maps URL to place data (coordinates, name, address) +router.post('/resolve-url', authenticate, async (req: Request, res: Response) => { + const { url } = req.body; + if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' }); + + try { + let resolvedUrl = url; + + // Follow redirects for short URLs (goo.gl, maps.app.goo.gl) + if (url.includes('goo.gl') || url.includes('maps.app')) { + const redirectRes = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(10000) }); + resolvedUrl = redirectRes.url; + } + + // Extract coordinates from Google Maps URL patterns: + // /@48.8566,2.3522,15z or /place/.../@48.8566,2.3522 + // ?q=48.8566,2.3522 or ?ll=48.8566,2.3522 + let lat: number | null = null; + let lng: number | null = null; + let placeName: string | null = null; + + // Pattern: /@lat,lng + const atMatch = resolvedUrl.match(/@(-?\d+\.?\d*),(-?\d+\.?\d*)/); + if (atMatch) { lat = parseFloat(atMatch[1]); lng = parseFloat(atMatch[2]); } + + // Pattern: !3dlat!4dlng (Google Maps data params) + if (!lat) { + const dataMatch = resolvedUrl.match(/!3d(-?\d+\.?\d*)!4d(-?\d+\.?\d*)/); + if (dataMatch) { lat = parseFloat(dataMatch[1]); lng = parseFloat(dataMatch[2]); } + } + + // Pattern: ?q=lat,lng or &q=lat,lng + if (!lat) { + const qMatch = resolvedUrl.match(/[?&]q=(-?\d+\.?\d*),(-?\d+\.?\d*)/); + if (qMatch) { lat = parseFloat(qMatch[1]); lng = parseFloat(qMatch[2]); } + } + + // Extract place name from URL path: /place/Place+Name/@... + const placeMatch = resolvedUrl.match(/\/place\/([^/@]+)/); + if (placeMatch) { + placeName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' ')); + } + + if (!lat || !lng || isNaN(lat) || isNaN(lng)) { + return res.status(400).json({ error: 'Could not extract coordinates from URL' }); + } + + // Reverse geocode to get address + const nominatimRes = await fetch( + `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`, + { headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, signal: AbortSignal.timeout(8000) } + ); + const nominatim = await nominatimRes.json() as { display_name?: string; name?: string; address?: Record }; + + const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null; + const address = nominatim.display_name || null; + + res.json({ lat, lng, name, address }); + } catch (err: unknown) { + console.error('[Maps] URL resolve error:', err instanceof Error ? err.message : err); + res.status(400).json({ error: 'Failed to resolve URL' }); + } +}); + export default router;