From 6fca2384409cb5c599ecf1e5e63941ab52d40c92 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 5 Sep 2026 00:39:11 +0200 Subject: [PATCH] feat(places): import the stops of a shared Google Maps route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pasting a /maps/dir/ link into the list-import box answered "could not extract list ID", which is the whole of the complaint: people share a drive far more often than they share a list, and it is the same gesture — somebody pressed Share in Google Maps. Which screen they were on is the URL's business, so the box takes both and the controller decides. No API key and no call to Google for the link itself: the stops are in the URL. Three shapes arrive — the path form with its data blob, the documented ?api=1&origin= form, and a short link that is one of those after a redirect — and a stop the link spells out in coordinates needs no geocoder at all. One that is only a name is geocoded, keeping the name from the link rather than the one the geocoder answers with: somebody who typed a nickname into Google should not find a street address on their trip. A name nobody can place is left out and counted rather than failing the import, because a route of six stops with five findable is five stops more than the traveller had. Coordinates are read as 1d/2d, longitude before latitude, which is the reverse of a place link and the one way to put Berlin in Somalia. A blob that does not line up with the stops is ignored entirely: half a link read confidently is worse than one read as names. --- .../Planner/PlacesSidebarListImportModal.tsx | 3 + .../screens/trip/sheets/ImpListStep.tsx | 1 + server/src/nest/maps/maps.service.ts | 4 +- server/src/nest/places/maps-dir.helpers.ts | 146 ++++++++++++ server/src/nest/places/places.controller.ts | 13 +- server/src/nest/places/places.mcp.ts | 7 +- server/src/nest/places/places.service.ts | 116 +++++++++- server/tests/unit/mcp/tools-places.test.ts | 26 +++ .../tests/unit/nest/maps-dir.helpers.test.ts | 111 +++++++++ .../tests/unit/nest/places.controller.test.ts | 23 ++ .../tests/unit/nest/places.directions.test.ts | 211 ++++++++++++++++++ shared/src/i18n/ar/places.ts | 1 + shared/src/i18n/br/places.ts | 1 + shared/src/i18n/ca/places.ts | 1 + shared/src/i18n/cs/places.ts | 1 + shared/src/i18n/de/places.ts | 1 + shared/src/i18n/en/places.ts | 1 + shared/src/i18n/es/places.ts | 1 + shared/src/i18n/fr/places.ts | 1 + shared/src/i18n/gr/places.ts | 1 + shared/src/i18n/hu/places.ts | 1 + shared/src/i18n/id/places.ts | 1 + shared/src/i18n/it/places.ts | 1 + shared/src/i18n/ja/places.ts | 1 + shared/src/i18n/ko/places.ts | 1 + shared/src/i18n/nl/places.ts | 1 + shared/src/i18n/pl/places.ts | 1 + shared/src/i18n/ru/places.ts | 1 + shared/src/i18n/sv/places.ts | 1 + shared/src/i18n/tr/places.ts | 1 + shared/src/i18n/uk/places.ts | 1 + shared/src/i18n/vi/places.ts | 1 + shared/src/i18n/zh-TW/places.ts | 1 + shared/src/i18n/zh/places.ts | 1 + 34 files changed, 675 insertions(+), 9 deletions(-) create mode 100644 server/src/nest/places/maps-dir.helpers.ts create mode 100644 server/tests/unit/nest/maps-dir.helpers.test.ts create mode 100644 server/tests/unit/nest/places.directions.test.ts diff --git a/client/src/components/Planner/PlacesSidebarListImportModal.tsx b/client/src/components/Planner/PlacesSidebarListImportModal.tsx index 9582ae162..58ebdd13b 100644 --- a/client/src/components/Planner/PlacesSidebarListImportModal.tsx +++ b/client/src/components/Planner/PlacesSidebarListImportModal.tsx @@ -43,6 +43,9 @@ export function ListImportModal(S: SidebarState) { )}
{t(listImportProvider === 'google' ? 'places.googleListHint' : 'places.naverListHint')} + {/* Same box, same Share button: which screen the link came from is the URL's + business, and until it said so nobody knew a route could be pasted here. */} + {listImportProvider === 'google' ?
{t('places.googleDirHint')}
: null}
{t(provider === 'google' ? 'places.googleListHint' : 'places.naverListHint')} + {provider === 'google' ?
{t('places.googleDirHint')}
: null} !2d`, the reverse of the `!3d!4d` + * a single-place link uses — the numbers are the same protobuf fields read by a + * different message, and getting the two the wrong way round puts Berlin in Somalia. + */ +const DATA_LNG_LAT = /!1d(-?\d+(?:\.\d+)?)!2d(-?\d+(?:\.\d+)?)/g; + +/** Is this a directions link at all, rather than a list or a single place? */ +export function isDirectionsUrl(raw: string): boolean { + try { + const url = new URL(raw); + return /\/maps\/dir\/?/.test(url.pathname) || /\/dir\/?$/.test(url.pathname); + } catch { + return false; + } +} + +/** Somewhere on Earth, rather than a pair of numbers that happen to be next to a comma. */ +function onEarth(lat: number, lng: number): boolean { + return Number.isFinite(lat) && Number.isFinite(lng) && Math.abs(lat) <= 90 && Math.abs(lng) <= 180; +} + +/** A decoded stop: a coordinate pair becomes one, anything else stays a name. */ +function asWaypoint(decoded: string): DirWaypoint | null { + const text = decoded.trim(); + if (!text) return null; + const coords = text.match(COORD_PAIR); + if (coords) { + const lat = Number.parseFloat(coords[1]); + const lng = Number.parseFloat(coords[2]); + if (onEarth(lat, lng)) return { name: null, lat, lng }; + } + return { name: text, lat: null, lng: null }; +} + +/** A path segment still carries its escapes, and spaces as plus signs. */ +function decodeSegment(raw: string): string { + try { + return decodeURIComponent(raw.replaceAll('+', ' ')); + } catch { + // A stray percent is somebody else's malformed link, not a reason to fail the import. + return raw.replaceAll('+', ' '); + } +} + +/** The `?api=1&origin=…` form, which names its parts instead of ordering them. */ +function fromQuery(url: URL): DirWaypoint[] { + const origin = url.searchParams.get('origin'); + const destination = url.searchParams.get('destination'); + if (!origin && !destination) return []; + const between = url.searchParams.get('waypoints')?.split('|') ?? []; + // Already decoded by URLSearchParams, so these go straight in. + return [origin, ...between, destination] + .filter((part): part is string => !!part && part.trim().length > 0) + .map((part) => asWaypoint(part)) + .filter((wp): wp is DirWaypoint => wp !== null); +} + +/** The path form, where the stops are the segments and the blob holds their positions. */ +function fromPath(url: URL): DirWaypoint[] { + const parts = url.pathname.split('/').filter(Boolean); + const at = parts.indexOf('dir'); + if (at < 0) return []; + + const names: DirWaypoint[] = []; + let data = ''; + for (const part of parts.slice(at + 1)) { + // The viewport and the blob end the list of stops; everything after them belongs to + // how the map was framed, not to where the car goes. + if (part.startsWith('@')) continue; + if (part.startsWith('data=')) { data = part; continue; } + if (data) continue; + const wp = asWaypoint(decodeSegment(part)); + if (wp) names.push(wp); + } + if (!names.length) return []; + + // Positions from the blob, but only when there is exactly one for each stop. A partial + // match cannot be lined up — Google drops the pair for "your location" and adds pairs + // of its own for a route through several countries — and half a link read confidently + // is worse than a link read as names and geocoded. + const pairs = [...(data.matchAll(DATA_LNG_LAT))] + .map((m) => ({ lng: Number.parseFloat(m[1]), lat: Number.parseFloat(m[2]) })) + .filter((p) => onEarth(p.lat, p.lng)); + if (pairs.length !== names.length) return names; + + return names.map((wp, i) => (wp.lat !== null ? wp : { name: wp.name, lat: pairs[i].lat, lng: pairs[i].lng })); +} + +/** + * The stops of a shared directions link, in the order they are driven. + * + * Empty when the link is not a route, or holds fewer than two stops — one stop is a + * place, and the place search box already takes those. + */ +export function parseDirectionsUrl(raw: string): DirWaypoint[] { + let url: URL; + try { + url = new URL(raw); + } catch { + return []; + } + const fromParams = fromQuery(url); + const found = fromParams.length ? fromParams : fromPath(url); + return found.length >= 2 ? found.slice(0, MAX_DIR_WAYPOINTS) : []; +} diff --git a/server/src/nest/places/places.controller.ts b/server/src/nest/places/places.controller.ts index 9e9a78548..7b16818f3 100644 --- a/server/src/nest/places/places.controller.ts +++ b/server/src/nest/places/places.controller.ts @@ -24,6 +24,7 @@ import { memoryStorage } from 'multer'; import { hexColorSchema, placeImageUrlSchema, placeWebsiteSchema } from '@trek/shared'; import type { User } from '../../types'; import { PlacesService } from './places.service'; +import { isDirectionsUrl } from './maps-dir.helpers'; import { isUpdateConflict } from '../common/conflictResult'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; @@ -274,9 +275,15 @@ export class PlacesController { const opts = { enrich: parseBool(enrich, false), userId: user.id }; const label = provider === 'google' ? 'Google' : 'Naver'; try { - const result = provider === 'google' - ? await this.places.importGoogleList(tripId, url, opts) - : await this.places.importNaverList(tripId, url, opts); + // A directions link and a list link arrive through the same box because they are + // the same gesture: somebody pressed Share in Google Maps. Which screen they were + // on is the URL's business, not the traveller's, and answering a pasted route with + // "could not extract list ID" was the whole of the complaint. + const result = provider !== 'google' + ? await this.places.importNaverList(tripId, url, opts) + : isDirectionsUrl(url) + ? await this.places.importGoogleDirections(tripId, url, opts) + : await this.places.importGoogleList(tripId, url, opts); if ('error' in result) { throw new HttpException({ error: result.error }, result.status); } diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index 995abf9d8..03325847b 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -21,6 +21,7 @@ import { noAccess, permissionDenied } from '../../mcp/tools/_shared'; import { DatabaseService } from '../database/database.service'; import { MapsService } from '../maps/maps.service'; import { PlacesService } from './places.service'; +import { isDirectionsUrl } from './maps-dir.helpers'; function parseId(value: string | string[]): number | null { const n = Number(Array.isArray(value) ? value[0] : value); @@ -317,7 +318,7 @@ export class PlacesMcp { description: 'Import places from a shared Google Maps or Naver Maps list URL. Returns the imported places and count. The list must be shared publicly.', inputSchema: { tripId: z.number().int().positive(), - url: z.string().url().describe('Publicly shared Google Maps list URL (maps.app.goo.gl/...) or Naver Maps list URL'), + url: z.string().url().describe('Publicly shared Google Maps list URL, a Google Maps directions link (/maps/dir/...), or a Naver Maps list URL. A directions link is read straight out of the URL: its stops become places in driving order, and the ones written as names are geocoded.'), source: z.enum(['google-list', 'naver-list']).describe('List source: "google-list" for Google Maps saved places, "naver-list" for Naver Maps'), enrich: placeImportListRequestSchema.shape.enrich.describe('Re-resolve every imported place through the Places API afterwards to fill in photo, address, website and phone (#886). Needs a Google Maps key on the instance, costs a lookup per place, and runs in the background: the tool returns the bare import and the places fill in over the websocket. Off by default'), }, @@ -336,7 +337,9 @@ export class PlacesMcp { // user because it spends that user's Places credential. const opts = { enrich: enrich ?? false, userId: ctx.userId }; const result = source === 'google-list' - ? await this.places.importGoogleList(String(tripId), url, opts) + ? (isDirectionsUrl(url) + ? await this.places.importGoogleDirections(String(tripId), url, opts) + : await this.places.importGoogleList(String(tripId), url, opts)) : await this.places.importNaverList(String(tripId), url, opts); if ('error' in result) { diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index 5a7a21f7f..2914beaf7 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -6,7 +6,8 @@ import { RealtimeService } from '../realtime/realtime.service'; import { DatabaseService, type TripAccess } from '../database/database.service'; import type { PlaceWithTags } from '../database/database.service'; import { PermissionsService } from '../permissions/permissions.service'; -import { MapsService } from '../maps/maps.service'; +import { MapsService, GOOGLE_SHORT_HOSTS, isGoogleMapsHost } from '../maps/maps.service'; +import { parseDirectionsUrl } from './maps-dir.helpers'; import type { Place, User } from '../../types'; import { QueryHelpersService } from '../query-helpers/query-helpers.service'; import { ratingAggregate } from '../common/rowShape'; @@ -1033,6 +1034,26 @@ export class PlacesService { return { error: 'No places with coordinates found in list', status: 400 }; } + const { created, skipped } = this.storeGooglePlaces(tripId, places); + + if (created.length) { + void this.enrichImportedList(tripId, created as EnrichablePlace[], opts); + } + + return { places: created, listName, skipped }; + } + + /** + * The insert half every Google-shaped import shares: dedupe, write, collect. + * + * One implementation for the list and for the directions link, because the rules are + * the same rules — a place already on the trip is skipped rather than doubled, and a + * row that matches but carries no provider id is given the one this import knows. + */ + private storeGooglePlaces( + tripId: string, + places: { name: string; lat: number; lng: number; notes: string | null; googleFtid: string | null }[], + ): { created: PlaceWithTags[]; skipped: number } { const dedup = this.buildDedupSet(tripId); const insertStmt = this.dbs.prepare(` INSERT INTO places (trip_id, name, lat, lng, notes, google_ftid, transport_mode) @@ -1063,12 +1084,103 @@ export class PlacesService { trackInsertedInDedupSet(candidate, dedup); } }); + return { created, skipped }; + } + // ------------------------------------------------------------------------- + // Import a shared Google Maps directions link + // ------------------------------------------------------------------------- + + /** + * The stops of a route somebody else planned. + * + * The other half of the request the list import answers: people share a drive far more + * often than they share a list, and until now a pasted `/maps/dir/` link came back as a + * cryptic "could not extract list ID". No API key is involved and no call is made to + * Google for the link itself — the stops are in the URL, which is the whole reason + * this is possible at all. + * + * A stop the link spells out in coordinates is taken as it stands; one that is only a + * name is geocoded, one request each. A name nobody can place is left out rather than + * failing the import, because a route of six stops with five findable is five stops + * more than the traveller had. + */ + async importGoogleDirections(tripId: string, url: string, opts?: ListImportOptions): Promise { + const ssrf = await checkSsrf(url); + if (!ssrf.allowed) return { error: 'URL is not allowed', status: 400 }; + + let parsed: URL; + try { parsed = new URL(url); } catch { return { error: 'Invalid URL', status: 400 }; } + + // Short links are resolved hop by hop through the guard, exactly as the list import + // does it: a maps.app.goo.gl that 302s to an internal address is still blocked. + let resolvedUrl = url; + if (GOOGLE_SHORT_HOSTS.includes(parsed.hostname)) { + try { + const redirectRes = await safeFetchFollow(url, { signal: AbortSignal.timeout(10000) }); + resolvedUrl = redirectRes.url; + } catch (err) { + if (err instanceof SsrfBlockedError) return { error: 'URL is not allowed', status: 400 }; + throw err; + } + } + + // Checked after resolving, not before: the host that counts is the one the link lands + // on, and `/maps/dir/` is a path anybody could serve. + let host = ''; + try { host = new URL(resolvedUrl).hostname; } catch { /* an unparseable hop fails the check below */ } + if (!isGoogleMapsHost(host)) { + return { error: 'That link is not a Google Maps link.', status: 400 }; + } + + const waypoints = parseDirectionsUrl(resolvedUrl); + if (waypoints.length < 2) { + return { error: 'Could not read any stops from that directions link. Open the route in Google Maps and use its Share button.', status: 400 }; + } + + const places: { name: string; lat: number; lng: number; notes: string | null; googleFtid: string | null }[] = []; + let unplaceable = 0; + for (const wp of waypoints) { + if (wp.lat !== null && wp.lng !== null) { + // A stop written as coordinates has no name of its own, and the coordinates are a + // better label than "Stop 3": they are what the traveller can look up. + places.push({ + name: wp.name || `${wp.lat.toFixed(5)}, ${wp.lng.toFixed(5)}`, + lat: wp.lat, + lng: wp.lng, + notes: null, + googleFtid: null, + }); + continue; + } + if (!wp.name) continue; + try { + const hits = await this.maps.searchNominatim(wp.name); + const hit = hits.find((h) => h.lat !== null && h.lng !== null); + // The name from the link, not the one the geocoder answers with: somebody who + // typed a nickname into Google should not find a street address on their trip. + if (hit) places.push({ name: wp.name, lat: hit.lat!, lng: hit.lng!, notes: null, googleFtid: null }); + else unplaceable++; + } catch { + // A geocoder that is down or rate-limited costs this one stop, not the import. + unplaceable++; + } + } + + if (places.length < 2) { + return { error: 'None of the stops in that link could be placed on the map.', status: 400 }; + } + + const { created, skipped } = this.storeGooglePlaces(tripId, places); if (created.length) { void this.enrichImportedList(tripId, created as EnrichablePlace[], opts); } - return { places: created, listName, skipped }; + // The route reads as what it is in the toast: where it starts and where it ends. + const listName = `${places[0].name} → ${places[places.length - 1].name}`; + // Both kinds of "not added" under one number, because from the traveller's side they + // are one thing: this many stops in the link are not on the trip. + return { places: created, listName, skipped: skipped + unplaceable }; } // ------------------------------------------------------------------------- diff --git a/server/tests/unit/mcp/tools-places.test.ts b/server/tests/unit/mcp/tools-places.test.ts index 2885b10c0..e86cdf11e 100644 --- a/server/tests/unit/mcp/tools-places.test.ts +++ b/server/tests/unit/mcp/tools-places.test.ts @@ -896,6 +896,32 @@ describe('Tool: import_places_from_url', () => { spy.mockRestore(); }); + it('sends a directions link to the directions importer, the same way the REST route does', async () => { + // The parity that matters here is not the wire shape but the dispatch: a pasted + // route has to reach the same importer through the tool as through the box. + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const dir = vi.spyOn(PlacesService.prototype, 'importGoogleDirections').mockResolvedValue({ + places: [{ id: 7, name: 'Berlin' }] as never, + listName: 'Berlin \u2192 Prague', + skipped: 1, + }); + const list = vi.spyOn(PlacesService.prototype, 'importGoogleList'); + + await withHarness(user.id, async (h) => { + const url = 'https://www.google.com/maps/dir/Berlin/Dresden/Prague'; + const result = await h.client.callTool({ + name: 'import_places_from_url', + arguments: { tripId: trip.id, url, source: 'google-list' }, + }); + expect(parseToolResult(result) as any).toMatchObject({ count: 1, skipped: 1 }); + expect(dir).toHaveBeenCalledWith(String(trip.id), url, { enrich: false, userId: user.id }); + expect(list).not.toHaveBeenCalled(); + }); + dir.mockRestore(); + list.mockRestore(); + }); + it('surfaces the naver importer\'s { error, status } as a tool error', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); diff --git a/server/tests/unit/nest/maps-dir.helpers.test.ts b/server/tests/unit/nest/maps-dir.helpers.test.ts new file mode 100644 index 000000000..b90159ed0 --- /dev/null +++ b/server/tests/unit/nest/maps-dir.helpers.test.ts @@ -0,0 +1,111 @@ +/** + * Reading a shared Google Maps directions link (MAPS-DIR-*). + * + * The links here are the real shapes, shortened: what the Share button produces on the + * web, what the URL builder in Google's own docs produces, and what somebody gets when + * they drop two pins and copy the address bar. The data blobs are trimmed to the parts + * that matter, because the full ones run to four hundred characters of protobuf. + */ +import { describe, it, expect } from 'vitest'; +import { isDirectionsUrl, parseDirectionsUrl, MAX_DIR_WAYPOINTS } from '../../../src/nest/places/maps-dir.helpers'; + +describe('isDirectionsUrl', () => { + it('MAPS-DIR-001: tells a route apart from a list and from a single place', () => { + expect(isDirectionsUrl('https://www.google.com/maps/dir/Berlin/Dresden')).toBe(true); + expect(isDirectionsUrl('https://www.google.com/maps/dir/?api=1&origin=A&destination=B')).toBe(true); + expect(isDirectionsUrl('https://www.google.com/maps/place/Brandenburger+Tor/@52.5,13.3,17z')).toBe(false); + expect(isDirectionsUrl('https://www.google.com/maps/placelists/list/abc123')).toBe(false); + expect(isDirectionsUrl('not a url')).toBe(false); + }); +}); + +describe('parseDirectionsUrl', () => { + it('MAPS-DIR-010: reads the stops out of the path, in driving order', () => { + const stops = parseDirectionsUrl('https://www.google.com/maps/dir/Berlin/Dresden/Prague/@50.9,13.5,8z'); + expect(stops.map((s) => s.name)).toEqual(['Berlin', 'Dresden', 'Prague']); + // Nothing invented: without a data blob there are no coordinates to be had, and the + // caller geocodes rather than guessing. + expect(stops.every((s) => s.lat === null)).toBe(true); + }); + + it('MAPS-DIR-011: takes the coordinates out of the data blob when there is one per stop', () => { + const url = 'https://www.google.com/maps/dir/Berlin/Dresden/@51.5,13.5,8z/' + + 'data=!4m14!4m13!1m5!1m1!1s0x0:0x0!2m2!1d13.404954!2d52.520008!1m5!1m1!1s0x0:0x0!2m2!1d13.737262!2d51.050409'; + // 1d is longitude and 2d is latitude, the reverse of a place link. Getting the two + // the wrong way round is the failure this pins. + expect(parseDirectionsUrl(url)).toEqual([ + { name: 'Berlin', lat: 52.520008, lng: 13.404954 }, + { name: 'Dresden', lat: 51.050409, lng: 13.737262 }, + ]); + }); + + it('MAPS-DIR-012: a blob that does not line up leaves every stop to the geocoder', () => { + // Google drops the pair for "your location" and adds pairs of its own on a route + // through several countries. Half a link read confidently is worse than one read as + // names, so a mismatched count means names. + const url = 'https://www.google.com/maps/dir/Berlin/Dresden/Prague/@51,13,7z/' + + 'data=!4m8!1m5!1m1!1s0x0:0x0!2m2!1d13.404954!2d52.520008'; + const stops = parseDirectionsUrl(url); + expect(stops).toHaveLength(3); + expect(stops.every((s) => s.lat === null)).toBe(true); + }); + + it('MAPS-DIR-013: a stop written as coordinates needs no geocoder at all', () => { + expect(parseDirectionsUrl('https://www.google.com/maps/dir/52.520008,13.404954/51.050409,13.737262')).toEqual([ + { name: null, lat: 52.520008, lng: 13.404954 }, + { name: null, lat: 51.050409, lng: 13.737262 }, + ]); + }); + + it('MAPS-DIR-014: escapes and plus signs come back as the name somebody typed', () => { + const stops = parseDirectionsUrl('https://www.google.com/maps/dir/Frankfurt+am+Main/K%C3%B6ln+Hbf'); + expect(stops.map((s) => s.name)).toEqual(['Frankfurt am Main', 'Köln Hbf']); + }); + + it('MAPS-DIR-015: the documented api=1 form names its parts instead of ordering them', () => { + const url = 'https://www.google.com/maps/dir/?api=1&origin=Berlin&destination=Prague' + + '&waypoints=Dresden%7CLeipzig&travelmode=driving'; + expect(parseDirectionsUrl(url).map((s) => s.name)).toEqual(['Berlin', 'Dresden', 'Leipzig', 'Prague']); + }); + + it('MAPS-DIR-016: api=1 coordinates are coordinates, however they were written', () => { + const url = 'https://www.google.com/maps/dir/?api=1&origin=52.52,13.405&destination=Prague'; + expect(parseDirectionsUrl(url)).toEqual([ + { name: null, lat: 52.52, lng: 13.405 }, + { name: 'Prague', lat: null, lng: null }, + ]); + }); + + it('MAPS-DIR-017: a country domain is still Google Maps', () => { + expect(parseDirectionsUrl('https://www.google.de/maps/dir/Berlin/Dresden').map((s) => s.name)) + .toEqual(['Berlin', 'Dresden']); + }); + + it('MAPS-DIR-018: one stop is a place, not a route', () => { + // The place search box already takes those, and answering with a one-place import + // would be a worse version of what it does. + expect(parseDirectionsUrl('https://www.google.com/maps/dir/Berlin')).toEqual([]); + expect(parseDirectionsUrl('https://www.google.com/maps/dir/')).toEqual([]); + }); + + it('MAPS-DIR-019: what is not a route reads as nothing', () => { + expect(parseDirectionsUrl('https://www.google.com/maps/place/Berlin/@52.5,13.4,12z')).toEqual([]); + expect(parseDirectionsUrl('gibberish')).toEqual([]); + }); + + it('MAPS-DIR-020: a link with more stops than anybody drives is cut to the cap', () => { + // The URL is user input, and every name past the cap costs a geocoding request. + const many = Array.from({ length: 50 }, (_, i) => `Stop${i}`).join('/'); + expect(parseDirectionsUrl(`https://www.google.com/maps/dir/${many}`)).toHaveLength(MAX_DIR_WAYPOINTS); + }); + + it('MAPS-DIR-021: numbers that are not on Earth are read as a name, not a position', () => { + const stops = parseDirectionsUrl('https://www.google.com/maps/dir/999.5,13.4/Dresden'); + expect(stops[0]).toEqual({ name: '999.5,13.4', lat: null, lng: null }); + }); + + it('MAPS-DIR-022: a malformed escape is somebody else\'s bad link, not a throw', () => { + expect(() => parseDirectionsUrl('https://www.google.com/maps/dir/%E0%A4%A/Dresden')).not.toThrow(); + expect(parseDirectionsUrl('https://www.google.com/maps/dir/%E0%A4%A/Dresden')).toHaveLength(2); + }); +}); diff --git a/server/tests/unit/nest/places.controller.test.ts b/server/tests/unit/nest/places.controller.test.ts index 446920591..fa661234a 100644 --- a/server/tests/unit/nest/places.controller.test.ts +++ b/server/tests/unit/nest/places.controller.test.ts @@ -190,6 +190,29 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou expect(importGoogleList).toHaveBeenCalledWith('5', 'http://x', { enrich: true, userId: 1 }); expect(broadcast).toHaveBeenCalledTimes(2); }); + it('sends a directions link to the directions importer, not to the list one', async () => { + // Same box, same gesture: somebody pressed Share in Google Maps, and which screen + // they were on is the URL's business rather than the traveller's. + const importGoogleDirections = vi.fn().mockResolvedValue({ places: [{ id: 9 }], listName: 'Berlin → Prague', skipped: 1 }); + const importGoogleList = vi.fn(); + const s = svc({ importGoogleDirections, importGoogleList, broadcast: vi.fn() } as Partial); + const url = 'https://www.google.com/maps/dir/Berlin/Dresden/Prague'; + + expect(await new PlacesController(s, new RuntimeEnvService(), storageStub).importGoogle(user, '5', { url })).toEqual({ + places: [{ id: 9 }], count: 1, listName: 'Berlin → Prague', skipped: 1, + }); + expect(importGoogleDirections).toHaveBeenCalledWith('5', url, { enrich: false, userId: 1 }); + expect(importGoogleList).not.toHaveBeenCalled(); + }); + it('leaves a list link with the list importer', async () => { + const importGoogleDirections = vi.fn(); + const importGoogleList = vi.fn().mockResolvedValue({ places: [], listName: 'L', skipped: 0 }); + const s = svc({ importGoogleDirections, importGoogleList, broadcast: vi.fn() } as Partial); + + await new PlacesController(s, new RuntimeEnvService(), storageStub).importGoogle(user, '5', { url: 'https://maps.app.goo.gl/abc' }); + expect(importGoogleList).toHaveBeenCalled(); + expect(importGoogleDirections).not.toHaveBeenCalled(); + }); it('wraps a thrown Error in the provider-specific 400 (Google)', async () => { const s = svc({ importGoogleList: vi.fn().mockRejectedValue(new Error('network down')) } as Partial); expect(await thrownAsync(() => new PlacesController(s, new RuntimeEnvService(), storageStub).importGoogle(user, '5', { url: 'http://x' }))).toEqual({ diff --git a/server/tests/unit/nest/places.directions.test.ts b/server/tests/unit/nest/places.directions.test.ts new file mode 100644 index 000000000..3fa5040f9 --- /dev/null +++ b/server/tests/unit/nest/places.directions.test.ts @@ -0,0 +1,211 @@ +/** + * Importing a shared Google Maps directions link (PLACES-DIR-*). + * + * A real in-memory DB, so the dedupe and the insert are exercised as they run; the SSRF + * guard and the geocoder are stubbed, because neither a DNS lookup nor Nominatim belongs + * in a unit test. What is under test is the step between the two: which stops the link + * yields, which of them get geocoded, and what a partly readable link leaves behind. + */ +import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA foreign_keys = ON'); + // DatabaseService delegates the joined read straight through to the module, so the + // mock has to answer it: without this the insert half of every import throws. + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: (placeId: any) => { + const place: any = db.prepare('SELECT * FROM places WHERE id = ?').get(placeId); + return place ? { ...place, category: null, tags: [] } : null; + }, + }; + return { testDb: db, dbMock: mock }; +}); +vi.mock('../../../src/db/database', () => dbMock); + +const { checkSsrf, safeFetchFollow } = vi.hoisted(() => ({ + checkSsrf: vi.fn(async () => ({ allowed: true, resolvedIp: '1.2.3.4' })), + safeFetchFollow: vi.fn(), +})); +vi.mock('../../../src/utils/ssrfGuard', () => ({ + checkSsrf, + safeFetchFollow, + createPinnedDispatcher: vi.fn(() => ({})), + SsrfBlockedError: class SsrfBlockedError extends Error {}, +})); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { createUser, createTrip, createPlace } from '../../helpers/factories'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; +import { PlacesService } from '../../../src/nest/places/places.service'; +import type { MapsService } from '../../../src/nest/maps/maps.service'; +import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service'; +import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service'; +import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; +import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service'; +import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service'; +import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; +import { makeStorageFixture } from '../../helpers/storage-fixture'; + +const dbs = new DatabaseService(testDb); +const photoCacheStub = { removeIfUnreferenced: vi.fn() } as unknown as PlacePhotoCacheService; +const storageFx = makeStorageFixture(''); + +/** Nominatim's answer shape, cut to the fields this path reads. */ +const hit = (name: string, lat: number, lng: number) => ({ + google_place_id: null, google_ftid: null, osm_id: `node:${name}`, name, address: name, + lat, lng, rating: null, website: null, phone: null, source: 'openstreetmap' as const, +}); + +function svc(searchNominatim: MapsService['searchNominatim']): PlacesService { + return new PlacesService( + dbs, + new PermissionsService(dbs), + new RealtimeService(), + // The address backfill runs fire-and-forget after every import, so the stub answers + // it too — otherwise every passing test prints a rejected promise. + { searchNominatim, reverseGeocode: vi.fn(async () => null) } as unknown as MapsService, + new QueryHelpersService(dbs), + new UnsplashService(dbs, new RuntimeEnvService(), storageFx.storage), + photoCacheStub, + new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs)), + storageFx.storage, + ); +} + +const geocoder = () => vi.fn(async (query: string) => { + const known: Record = { + Berlin: [52.52, 13.405], + Dresden: [51.05, 13.737], + Prague: [50.075, 14.437], + }; + const found = known[query]; + return found ? [hit(query, found[0], found[1])] : []; +}) as unknown as MapsService['searchNominatim']; + +let tripId: string; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + testDb.exec('DELETE FROM places; DELETE FROM trips; DELETE FROM users'); + const { user } = createUser(testDb, { email: 'dir@example.com' }); + tripId = String(createTrip(testDb, user.id, { title: 'Road trip' }).id); + checkSsrf.mockResolvedValue({ allowed: true, resolvedIp: '1.2.3.4' }); + safeFetchFollow.mockReset(); +}); + +describe('PlacesService.importGoogleDirections', () => { + it('PLACES-DIR-001: a link whose blob carries every stop needs no geocoder at all', async () => { + const search = geocoder(); + const url = 'https://www.google.com/maps/dir/Berlin/Dresden/@51.5,13.5,8z/' + + 'data=!4m14!1m5!1m1!1s0x0:0x0!2m2!1d13.404954!2d52.520008!1m5!1m1!1s0x0:0x0!2m2!1d13.737262!2d51.050409'; + const result = await svc(search).importGoogleDirections(tripId, url); + + expect('error' in result).toBe(false); + if ('error' in result) return; + expect(result.places.map((p) => p.name)).toEqual(['Berlin', 'Dresden']); + expect(result.places[0].lat).toBeCloseTo(52.520008, 5); + expect(result.listName).toBe('Berlin → Dresden'); + expect(search).not.toHaveBeenCalled(); + }); + + it('PLACES-DIR-002: a stop that is only a name is geocoded, and keeps the name from the link', async () => { + const search = geocoder(); + const result = await svc(search).importGoogleDirections( + tripId, + 'https://www.google.com/maps/dir/Berlin/Dresden/Prague', + ); + + expect('error' in result).toBe(false); + if ('error' in result) return; + expect(result.places.map((p) => p.name)).toEqual(['Berlin', 'Dresden', 'Prague']); + expect(search).toHaveBeenCalledTimes(3); + }); + + it('PLACES-DIR-003: a stop nobody can place is left out, not made up, and is counted', async () => { + const search = geocoder(); + const result = await svc(search).importGoogleDirections( + tripId, + 'https://www.google.com/maps/dir/Berlin/Somewhere+Nobody+Knows/Prague', + ); + + expect('error' in result).toBe(false); + if ('error' in result) return; + expect(result.places.map((p) => p.name)).toEqual(['Berlin', 'Prague']); + // Skipped covers both "already on the trip" and "could not be placed": from the + // traveller's side they are one number, this many stops did not arrive. + expect(result.skipped).toBe(1); + }); + + it('PLACES-DIR-004: a geocoder that is down costs its stop, never the import', async () => { + const search = vi.fn(async (query: string) => { + if (query === 'Dresden') throw new Error('Nominatim 429'); + return [hit(query, 52.52, 13.405)]; + }) as unknown as MapsService['searchNominatim']; + const result = await svc(search).importGoogleDirections(tripId, 'https://www.google.com/maps/dir/Berlin/Dresden/Prague'); + + expect('error' in result).toBe(false); + if ('error' in result) return; + expect(result.places).toHaveLength(2); + expect(result.skipped).toBe(1); + }); + + it('PLACES-DIR-005: a stop already on the trip is skipped rather than doubled', async () => { + createPlace(testDb, Number(tripId), { name: 'Berlin', lat: 52.520008, lng: 13.404954 }); + const url = 'https://www.google.com/maps/dir/Berlin/Dresden/@51.5,13.5,8z/' + + 'data=!4m14!1m5!1m1!1s0x0:0x0!2m2!1d13.404954!2d52.520008!1m5!1m1!1s0x0:0x0!2m2!1d13.737262!2d51.050409'; + const result = await svc(geocoder()).importGoogleDirections(tripId, url); + + expect('error' in result).toBe(false); + if ('error' in result) return; + expect(result.places.map((p) => p.name)).toEqual(['Dresden']); + expect(result.skipped).toBe(1); + }); + + it('PLACES-DIR-006: a link that is not Google\'s is refused after it is resolved', async () => { + const result = await svc(geocoder()).importGoogleDirections(tripId, 'https://evil.example.com/maps/dir/Berlin/Dresden'); + expect(result).toEqual({ error: 'That link is not a Google Maps link.', status: 400 }); + }); + + it('PLACES-DIR-007: a short link is followed once, through the guard', async () => { + safeFetchFollow.mockResolvedValue({ + url: 'https://www.google.com/maps/dir/Berlin/Dresden', + } as unknown as Response); + const result = await svc(geocoder()).importGoogleDirections(tripId, 'https://maps.app.goo.gl/abc123'); + + expect(safeFetchFollow).toHaveBeenCalledTimes(1); + expect('error' in result).toBe(false); + if ('error' in result) return; + expect(result.places).toHaveLength(2); + }); + + it('PLACES-DIR-008: a blocked URL never reaches the parser', async () => { + checkSsrf.mockResolvedValue({ allowed: false } as never); + const result = await svc(geocoder()).importGoogleDirections(tripId, 'https://www.google.com/maps/dir/Berlin/Dresden'); + expect(result).toEqual({ error: 'URL is not allowed', status: 400 }); + }); + + it('PLACES-DIR-009: a link with nothing to read says what to do instead', async () => { + const result = await svc(geocoder()).importGoogleDirections(tripId, 'https://www.google.com/maps/dir/Berlin'); + expect(result).toMatchObject({ status: 400 }); + expect((result as { error: string }).error).toMatch(/Share button/); + }); + + it('PLACES-DIR-010: a route where only one stop can be placed is not half an import', async () => { + const search = vi.fn(async () => []) as unknown as MapsService['searchNominatim']; + const url = 'https://www.google.com/maps/dir/52.52,13.405/Nowhere/Nowhere+Else'; + const result = await svc(search).importGoogleDirections(tripId, url); + expect(result).toEqual({ error: 'None of the stops in that link could be placed on the map.', status: 400 }); + }); +}); diff --git a/shared/src/i18n/ar/places.ts b/shared/src/i18n/ar/places.ts index 83f55fbbe..2fc2c43aa 100644 --- a/shared/src/i18n/ar/places.ts +++ b/shared/src/i18n/ar/places.ts @@ -34,6 +34,7 @@ const places: TranslationStrings = { 'places.importGoogleList': 'قائمة Google', 'places.importNaverList': 'قائمة Naver', 'places.googleListHint': 'الصق رابط قائمة Google Maps المشتركة لاستيراد جميع الأماكن.', + 'places.googleDirHint': 'رابط الاتجاهات يعمل أيضاً: تتحوّل محطاته إلى أماكن، بترتيب القيادة.', 'places.googleListImported': 'تم استيراد {count} أماكن من "{list}"', 'places.googleListError': 'فشل استيراد قائمة Google Maps', 'places.naverListHint': 'الصق رابط قائمة Naver Maps مشتركة لاستيراد جميع الأماكن.', diff --git a/shared/src/i18n/br/places.ts b/shared/src/i18n/br/places.ts index 98254b099..cf4df189c 100644 --- a/shared/src/i18n/br/places.ts +++ b/shared/src/i18n/br/places.ts @@ -34,6 +34,7 @@ const places: TranslationStrings = { 'places.importGoogleList': 'Lista Google', 'places.importNaverList': 'Lista Naver', 'places.googleListHint': 'Cole um link compartilhado de uma lista do Google Maps para importar todos os lugares.', + 'places.googleDirHint': 'Um link de rotas também serve: suas paradas viram locais, na ordem do percurso.', 'places.googleListImported': '{count} lugares importados de "{list}"', 'places.googleListError': 'Falha ao importar lista do Google Maps', 'places.naverListHint': 'Cole um link compartilhado de uma lista do Naver Maps para importar todos os lugares.', diff --git a/shared/src/i18n/ca/places.ts b/shared/src/i18n/ca/places.ts index 71385f065..edde04ddc 100644 --- a/shared/src/i18n/ca/places.ts +++ b/shared/src/i18n/ca/places.ts @@ -33,6 +33,7 @@ const places: TranslationStrings = { 'places.importNaverList': 'Llista de Naver', 'places.googleListHint': "Enganxa un enllaç compartit d'una llista de Google Maps per importar tots els llocs.", 'places.googleListImported': '{count} llocs importats de "{list}"', + 'places.googleDirHint': 'Un enllaç d’indicacions també serveix: les seves parades esdevenen llocs, en ordre de conducció.', 'places.googleListError': 'Error en importar la llista de Google Maps', 'places.naverListHint': "Enganxa un enllaç compartit d'una llista de Naver Maps per importar tots els llocs.", 'places.naverListImported': '{count} llocs importats de "{list}"', diff --git a/shared/src/i18n/cs/places.ts b/shared/src/i18n/cs/places.ts index 4c08561fd..f19a09b85 100644 --- a/shared/src/i18n/cs/places.ts +++ b/shared/src/i18n/cs/places.ts @@ -34,6 +34,7 @@ const places: TranslationStrings = { 'places.importGoogleList': 'Google Seznam', 'places.importNaverList': 'Naver Seznam', 'places.googleListHint': 'Vložte sdílený odkaz na seznam Google Maps pro import všech míst.', + 'places.googleDirHint': 'Funguje i odkaz na trasu: jeho zastávky se stanou místy, v pořadí jízdy.', 'places.googleListImported': '{count} míst importováno ze seznamu "{list}"', 'places.googleListError': 'Import seznamu Google Maps se nezdařil', 'places.naverListHint': 'Vložte sdílený odkaz na seznam Naver Maps pro import všech míst.', diff --git a/shared/src/i18n/de/places.ts b/shared/src/i18n/de/places.ts index 053f72adb..e1c331c34 100644 --- a/shared/src/i18n/de/places.ts +++ b/shared/src/i18n/de/places.ts @@ -34,6 +34,7 @@ const places: TranslationStrings = { 'places.importGoogleList': 'Google Liste', 'places.importNaverList': 'Naver Liste', 'places.googleListHint': 'Geteilten Google Maps Listen-Link einfügen, um alle Orte zu importieren.', + 'places.googleDirHint': 'Ein Routen-Link geht auch: seine Stopps werden zu Orten, in Fahrreihenfolge.', 'places.googleListImported': '{count} Orte aus "{list}" importiert', 'places.googleListError': 'Google Maps Liste konnte nicht importiert werden', 'places.naverListHint': 'Geteilten Naver Maps Listen-Link einfügen, um alle Orte zu importieren.', diff --git a/shared/src/i18n/en/places.ts b/shared/src/i18n/en/places.ts index 5130c43ca..165f02bfa 100644 --- a/shared/src/i18n/en/places.ts +++ b/shared/src/i18n/en/places.ts @@ -34,6 +34,7 @@ const places: TranslationStrings = { 'places.importGoogleList': 'Google List', 'places.importNaverList': 'Naver List', 'places.googleListHint': 'Paste a shared Google Maps list link to import all places.', + 'places.googleDirHint': 'A directions link works too: its stops become places, in driving order.', 'places.googleListImported': '{count} places imported from "{list}"', 'places.googleListError': 'Failed to import Google Maps list', 'places.naverListHint': 'Paste a shared Naver Maps list link to import all places.', diff --git a/shared/src/i18n/es/places.ts b/shared/src/i18n/es/places.ts index fd805ecb1..4e05adbf4 100644 --- a/shared/src/i18n/es/places.ts +++ b/shared/src/i18n/es/places.ts @@ -34,6 +34,7 @@ const places: TranslationStrings = { 'places.importGoogleList': 'Lista Google', 'places.importNaverList': 'Lista Naver', 'places.googleListHint': 'Pega un enlace compartido de una lista de Google Maps para importar todos los lugares.', + 'places.googleDirHint': 'Un enlace de indicaciones también sirve: sus paradas se convierten en lugares, en orden de conducción.', 'places.googleListImported': '{count} lugares importados de "{list}"', 'places.googleListError': 'Error al importar la lista de Google Maps', 'places.naverListHint': 'Pega un enlace compartido de una lista de Naver Maps para importar todos los lugares.', diff --git a/shared/src/i18n/fr/places.ts b/shared/src/i18n/fr/places.ts index a077d9d51..8b0858af0 100644 --- a/shared/src/i18n/fr/places.ts +++ b/shared/src/i18n/fr/places.ts @@ -34,6 +34,7 @@ const places: TranslationStrings = { 'places.importGoogleList': 'Liste Google', 'places.importNaverList': 'Liste Naver', 'places.googleListHint': 'Collez un lien de liste Google Maps partagée pour importer tous les lieux.', + 'places.googleDirHint': 'Un lien d’itinéraire fonctionne aussi : ses étapes deviennent des lieux, dans l’ordre du trajet.', 'places.googleListImported': '{count} lieux importés depuis "{list}"', 'places.googleListError': "Impossible d'importer la liste Google Maps", 'places.naverListHint': 'Collez un lien de liste Naver Maps partagée pour importer tous les lieux.', diff --git a/shared/src/i18n/gr/places.ts b/shared/src/i18n/gr/places.ts index 1aa6cd7c8..54e7e3c9f 100644 --- a/shared/src/i18n/gr/places.ts +++ b/shared/src/i18n/gr/places.ts @@ -34,6 +34,7 @@ const places: TranslationStrings = { 'places.importGoogleList': 'Λίστα Google', 'places.importNaverList': 'Λίστα Naver', 'places.googleListHint': 'Επικολλήστε έναν κοινοποιημένο σύνδεσμο λίστας Google Maps για να εισάγετε όλα τα μέρη.', + 'places.googleDirHint': 'Λειτουργεί και ένας σύνδεσμος διαδρομής: οι στάσεις του γίνονται τοποθεσίες, με τη σειρά οδήγησης.', 'places.googleListImported': '{count} μέρη εισήχθησαν από "{list}"', 'places.googleListError': 'Αποτυχία εισαγωγής λίστας Google Maps', 'places.naverListHint': 'Επικολλήστε έναν κοινοποιημένο σύνδεσμο λίστας Naver Maps για να εισάγετε όλα τα μέρη.', diff --git a/shared/src/i18n/hu/places.ts b/shared/src/i18n/hu/places.ts index 8f777e5b1..b102e0395 100644 --- a/shared/src/i18n/hu/places.ts +++ b/shared/src/i18n/hu/places.ts @@ -34,6 +34,7 @@ const places: TranslationStrings = { 'places.importGoogleList': 'Google Lista', 'places.importNaverList': 'Naver Lista', 'places.googleListHint': 'Illessz be egy megosztott Google Maps lista linket az osszes hely importalasahoz.', + 'places.googleDirHint': 'Egy útvonal-hivatkozás is jó: megállói helyekké válnak, haladási sorrendben.', 'places.googleListImported': '{count} hely importalva a(z) "{list}" listabol', 'places.googleListError': 'Google Maps lista importalasa sikertelen', 'places.naverListHint': 'Illessz be egy megosztott Naver Maps lista linket az összes hely importálásához.', diff --git a/shared/src/i18n/id/places.ts b/shared/src/i18n/id/places.ts index d39fc925c..f1c704346 100644 --- a/shared/src/i18n/id/places.ts +++ b/shared/src/i18n/id/places.ts @@ -33,6 +33,7 @@ const places: TranslationStrings = { 'places.importGoogleList': 'Daftar Google', 'places.importNaverList': 'Daftar Naver', 'places.googleListHint': 'Tempel tautan daftar Google Maps yang dibagikan untuk mengimpor semua tempat.', + 'places.googleDirHint': 'Tautan rute juga bisa: perhentiannya menjadi tempat, sesuai urutan berkendara.', 'places.googleListImported': '{count} tempat diimpor dari "{list}"', 'places.googleListError': 'Gagal mengimpor daftar Google Maps', 'places.naverListHint': 'Tempel tautan daftar Naver Maps yang dibagikan untuk mengimpor semua tempat.', diff --git a/shared/src/i18n/it/places.ts b/shared/src/i18n/it/places.ts index e6278239b..781602ca6 100644 --- a/shared/src/i18n/it/places.ts +++ b/shared/src/i18n/it/places.ts @@ -34,6 +34,7 @@ const places: TranslationStrings = { 'places.importGoogleList': 'Lista Google', 'places.importNaverList': 'Lista Naver', 'places.googleListHint': 'Incolla un link condiviso di una lista Google Maps per importare tutti i luoghi.', + 'places.googleDirHint': 'Funziona anche un link di indicazioni: le sue tappe diventano luoghi, nell’ordine di guida.', 'places.googleListImported': '{count} luoghi importati da "{list}"', 'places.googleListError': 'Importazione lista Google Maps non riuscita', 'places.naverListHint': 'Incolla un link condiviso di una lista Naver Maps per importare tutti i luoghi.', diff --git a/shared/src/i18n/ja/places.ts b/shared/src/i18n/ja/places.ts index 27f820975..eed5c1305 100644 --- a/shared/src/i18n/ja/places.ts +++ b/shared/src/i18n/ja/places.ts @@ -34,6 +34,7 @@ const places: TranslationStrings = { 'places.importGoogleList': 'Google リスト', 'places.importNaverList': 'Naver リスト', 'places.googleListHint': '共有されたGoogleマップのリストリンクを貼り付けてください。', + 'places.googleDirHint': 'ルートのリンクでも構いません。立ち寄り地が走行順に場所として取り込まれます。', 'places.googleListImported': '「{list}」から {count} 件の場所をインポートしました', 'places.googleListError': 'Googleマップのリストをインポートできませんでした', 'places.naverListHint': '共有されたNaverマップのリストリンクを貼り付けてください。', diff --git a/shared/src/i18n/ko/places.ts b/shared/src/i18n/ko/places.ts index 902d88056..4021138b3 100644 --- a/shared/src/i18n/ko/places.ts +++ b/shared/src/i18n/ko/places.ts @@ -33,6 +33,7 @@ const places: TranslationStrings = { 'places.importGoogleList': 'Google 목록', 'places.importNaverList': '네이버 목록', 'places.googleListHint': '공유된 Google Maps 목록 링크를 붙여넣어 모든 장소를 가져옵니다.', + 'places.googleDirHint': '길찾기 링크도 됩니다. 그 정차지가 주행 순서대로 장소가 됩니다.', 'places.googleListImported': '"{list}"에서 {count}개 장소를 가져왔습니다', 'places.googleListError': 'Google Maps 목록 가져오기 실패', 'places.naverListHint': '공유된 네이버 지도 목록 링크를 붙여넣어 모든 장소를 가져옵니다.', diff --git a/shared/src/i18n/nl/places.ts b/shared/src/i18n/nl/places.ts index aa85d89b8..e89c1150a 100644 --- a/shared/src/i18n/nl/places.ts +++ b/shared/src/i18n/nl/places.ts @@ -34,6 +34,7 @@ const places: TranslationStrings = { 'places.importGoogleList': 'Google Lijst', 'places.importNaverList': 'Naver Lijst', 'places.googleListHint': 'Plak een gedeelde Google Maps lijstlink om alle plaatsen te importeren.', + 'places.googleDirHint': 'Een routelink werkt ook: de stops worden plaatsen, in rijvolgorde.', 'places.googleListImported': '{count} plaatsen geimporteerd uit "{list}"', 'places.googleListError': 'Google Maps lijst importeren mislukt', 'places.naverListHint': 'Plak een gedeelde Naver Maps lijstlink om alle plaatsen te importeren.', diff --git a/shared/src/i18n/pl/places.ts b/shared/src/i18n/pl/places.ts index 2af9728ac..4aa308375 100644 --- a/shared/src/i18n/pl/places.ts +++ b/shared/src/i18n/pl/places.ts @@ -114,6 +114,7 @@ const places: TranslationStrings = { 'places.importNaverList': 'Lista Naver', 'places.importList': 'Import listy', 'places.googleListHint': 'Wklej link do listy Google Maps.', + 'places.googleDirHint': 'Link z trasą też zadziała: jego przystanki staną się miejscami, w kolejności jazdy.', 'places.googleListImported': 'Zaimportowano {count} miejsc z „{list}”', 'places.googleListError': 'Nie udało się zaimportować listy', 'places.naverListHint': 'Wklej link do udostępnionej listy Naver Maps, aby zaimportować wszystkie miejsca.', diff --git a/shared/src/i18n/ru/places.ts b/shared/src/i18n/ru/places.ts index 3c75bf876..a35fb115c 100644 --- a/shared/src/i18n/ru/places.ts +++ b/shared/src/i18n/ru/places.ts @@ -34,6 +34,7 @@ const places: TranslationStrings = { 'places.importGoogleList': 'Список Google', 'places.importNaverList': 'Список Naver', 'places.googleListHint': 'Вставьте ссылку на общий список Google Maps для импорта всех мест.', + 'places.googleDirHint': 'Ссылка на маршрут тоже подойдёт: его остановки станут местами, в порядке движения.', 'places.googleListImported': '{count} мест импортировано из "{list}"', 'places.googleListError': 'Не удалось импортировать список Google Maps', 'places.naverListHint': 'Вставьте ссылку на общий список Naver Maps для импорта всех мест.', diff --git a/shared/src/i18n/sv/places.ts b/shared/src/i18n/sv/places.ts index 8af744e5c..0c2640a59 100644 --- a/shared/src/i18n/sv/places.ts +++ b/shared/src/i18n/sv/places.ts @@ -34,6 +34,7 @@ const places: TranslationStrings = { 'places.importGoogleList': 'Google-lista', 'places.importNaverList': 'Naver-lista', 'places.googleListHint': 'Klistra in en länk till en delad lista i Google Maps för att importera alla platser.', + 'places.googleDirHint': 'En vägbeskrivningslänk fungerar också: dess stopp blir platser, i körordning.', 'places.googleListImported': '{count} platser som importerats från "{list}"', 'places.googleListError': 'Det gick inte att importera listan från Google Maps', 'places.naverListHint': 'Klistra in en länk till en delad lista på Naver Maps för att importera alla platser.', diff --git a/shared/src/i18n/tr/places.ts b/shared/src/i18n/tr/places.ts index b9b8438c8..74d68fa4c 100644 --- a/shared/src/i18n/tr/places.ts +++ b/shared/src/i18n/tr/places.ts @@ -35,6 +35,7 @@ const places: TranslationStrings = { 'places.importNaverList': 'Naver Listesi', 'places.googleListHint': 'Tüm yerleri içe aktarmak için paylaşılan bir Google Haritalar listesi bağlantısını yapıştırın.', + 'places.googleDirHint': 'Yol tarifi bağlantısı da olur: durakları, sürüş sırasına göre yerlere dönüşür.', 'places.googleListImported': '"{list}"ten {count} yer içe aktarıldı', 'places.googleListError': 'Google Haritalar listesi içe aktarılamadı', 'places.naverListHint': diff --git a/shared/src/i18n/uk/places.ts b/shared/src/i18n/uk/places.ts index de432df5f..1055132c0 100644 --- a/shared/src/i18n/uk/places.ts +++ b/shared/src/i18n/uk/places.ts @@ -34,6 +34,7 @@ const places: TranslationStrings = { 'places.importGoogleList': 'Список Google', 'places.importNaverList': 'Список Naver', 'places.googleListHint': 'Вставте посилання на спільний список Google Maps для імпорту всіх місць.', + 'places.googleDirHint': 'Посилання на маршрут теж підійде: його зупинки стануть місцями, у порядку руху.', 'places.googleListImported': '{count} місць імпортовано з "{list}"', 'places.googleListError': 'Не вдалося імпортувати список Google Maps', 'places.naverListHint': 'Вставте посилання на спільний список Naver Maps для імпорту всіх місць.', diff --git a/shared/src/i18n/vi/places.ts b/shared/src/i18n/vi/places.ts index 857a3bf3e..9c79e2ab9 100644 --- a/shared/src/i18n/vi/places.ts +++ b/shared/src/i18n/vi/places.ts @@ -34,6 +34,7 @@ const places: TranslationStrings = { 'places.importGoogleList': 'Danh sách Google', 'places.importNaverList': 'Danh sách Naver', 'places.googleListHint': 'Dán liên kết danh sách Google Maps được chia sẻ để nhập tất cả các địa điểm.', + 'places.googleDirHint': 'Liên kết chỉ đường cũng được: các điểm dừng của nó trở thành địa điểm, theo thứ tự lái xe.', 'places.googleListImported': '{count} địa điểm được nhập từ "{list}"', 'places.googleListError': 'Không thể nhập danh sách Google Maps', 'places.naverListHint': 'Dán liên kết danh sách Naver Maps được chia sẻ để nhập tất cả các địa điểm.', diff --git a/shared/src/i18n/zh-TW/places.ts b/shared/src/i18n/zh-TW/places.ts index f08dcc2d0..2cdfa68af 100644 --- a/shared/src/i18n/zh-TW/places.ts +++ b/shared/src/i18n/zh-TW/places.ts @@ -33,6 +33,7 @@ const places: TranslationStrings = { 'places.importGoogleList': 'Google 列表', 'places.importNaverList': 'Naver 列表', 'places.googleListHint': '貼上共享的 Google Maps 列表連結以匯入所有地點。', + 'places.googleDirHint': '路線連結也可以:它的途經點會依行駛順序變成地點。', 'places.googleListImported': '已從"{list}"匯入 {count} 個地點', 'places.googleListError': 'Google Maps 列表匯入失敗', 'places.naverListHint': '貼上共享的 Naver Maps 列表連結以匯入所有地點。', diff --git a/shared/src/i18n/zh/places.ts b/shared/src/i18n/zh/places.ts index 814a3eebd..fdfcead2c 100644 --- a/shared/src/i18n/zh/places.ts +++ b/shared/src/i18n/zh/places.ts @@ -33,6 +33,7 @@ const places: TranslationStrings = { 'places.importGoogleList': 'Google 列表', 'places.importNaverList': 'Naver 列表', 'places.googleListHint': '粘贴共享的 Google Maps 列表链接以导入所有地点。', + 'places.googleDirHint': '路线链接也可以:它的途经点会按行驶顺序变成地点。', 'places.googleListImported': '已从"{list}"导入 {count} 个地点', 'places.googleListError': 'Google Maps 列表导入失败', 'places.naverListHint': '粘贴共享的 Naver Maps 列表链接以导入所有地点。',