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