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 列表链接以导入所有地点。',