From 3e9626fce99e661d4eefc422763b089a2591c611 Mon Sep 17 00:00:00 2001
From: Maurice <61554723+mauriceboe@users.noreply.github.com>
Date: Sun, 14 Jun 2026 00:54:11 +0200
Subject: [PATCH] feat(places): enrich list-imported places via the Places API
(#886) (#1161)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(places): enrich list-imported places via the Places API (#886)
Google/Naver list imports only carry a name and coordinates, so the places open
as bare pins — the Maps tab jumps to coordinates, with no photo, address or
open/closed. Add an opt-in "Enrich places via Google" toggle to the list-import
dialog, shown only when a Google Maps key is configured.
When enabled, after the (fast, unchanged) import the server runs a background
pass that re-resolves each place by name — biased to and validated against the
imported coordinates so a common-name search cannot overwrite the wrong place —
and fills the empty address/website/phone/photo columns plus the resolved
google_place_id, pushing each row over the live sync. Opening hours and the
proper Maps link then work on demand from the stored id.
Enrichment only fills empty fields, runs detached so a long list never blocks
the import, and no-ops when no key is configured.
* fix(places): use the ToggleSwitch component for the enrich toggle
Match the rest of the app — the import-enrichment opt-in used a raw checkbox;
swap it for the shared ToggleSwitch (text left, switch right) like the settings
toggles.
---
client/src/api/client.ts | 8 +-
.../Planner/PlacesSidebarListImportModal.tsx | 11 ++
.../components/Planner/usePlacesSidebar.ts | 10 +-
server/src/nest/places/places.controller.ts | 17 +-
server/src/nest/places/places.service.ts | 8 +-
server/src/services/placeEnrichment.ts | 164 ++++++++++++++++++
server/src/services/placeService.ts | 19 +-
.../unit/services/placeEnrichment.test.ts | 49 ++++++
shared/src/i18n/ar/places.ts | 3 +
shared/src/i18n/br/places.ts | 3 +
shared/src/i18n/cs/places.ts | 3 +
shared/src/i18n/de/places.ts | 3 +
shared/src/i18n/en/places.ts | 3 +
shared/src/i18n/es/places.ts | 3 +
shared/src/i18n/fr/places.ts | 3 +
shared/src/i18n/gr/places.ts | 3 +
shared/src/i18n/hu/places.ts | 3 +
shared/src/i18n/id/places.ts | 3 +
shared/src/i18n/it/places.ts | 3 +
shared/src/i18n/ja/places.ts | 3 +
shared/src/i18n/ko/places.ts | 3 +
shared/src/i18n/nl/places.ts | 3 +
shared/src/i18n/pl/places.ts | 3 +
shared/src/i18n/ru/places.ts | 3 +
shared/src/i18n/tr/places.ts | 3 +
shared/src/i18n/uk/places.ts | 3 +
shared/src/i18n/zh-TW/places.ts | 3 +
shared/src/i18n/zh/places.ts | 3 +
shared/src/place/place.schema.ts | 3 +
29 files changed, 331 insertions(+), 18 deletions(-)
create mode 100644 server/src/services/placeEnrichment.ts
create mode 100644 server/tests/unit/services/placeEnrichment.test.ts
diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index 086294f4..899453c8 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -366,10 +366,10 @@ export const placesApi = {
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
- importGoogleList: (tripId: number | string, url: string) =>
- apiClient.post(`/trips/${tripId}/places/import/google-list`, { url } satisfies PlaceImportListRequest).then(r => r.data),
- importNaverList: (tripId: number | string, url: string) =>
- apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
+ importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
+ apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
+ importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
+ apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
bulkDelete: (tripId: number | string, ids: number[]) =>
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
}
diff --git a/client/src/components/Planner/PlacesSidebarListImportModal.tsx b/client/src/components/Planner/PlacesSidebarListImportModal.tsx
index 1dffc549..731b1ec4 100644
--- a/client/src/components/Planner/PlacesSidebarListImportModal.tsx
+++ b/client/src/components/Planner/PlacesSidebarListImportModal.tsx
@@ -1,10 +1,12 @@
import ReactDOM from 'react-dom'
+import ToggleSwitch from '../Settings/ToggleSwitch'
import type { SidebarState } from './usePlacesSidebar'
export function ListImportModal(S: SidebarState) {
const {
setListImportOpen, setListImportUrl, t, hasMultipleListImportProviders, availableListImportProviders,
listImportProvider, setListImportProvider, listImportUrl, listImportLoading, handleListImport,
+ listImportEnrich, setListImportEnrich, canEnrichImport,
} = S
return ReactDOM.createPortal(
+ {canEnrichImport && (
+
+
+
{t('places.enrichOnImport')}
+
{t('places.enrichOnImportHint')}
+
+
setListImportEnrich(!listImportEnrich)} />
+
+ )}
{ setListImportOpen(false); setListImportUrl('') }}
diff --git a/client/src/components/Planner/usePlacesSidebar.ts b/client/src/components/Planner/usePlacesSidebar.ts
index 927c2f97..8518702c 100644
--- a/client/src/components/Planner/usePlacesSidebar.ts
+++ b/client/src/components/Planner/usePlacesSidebar.ts
@@ -7,6 +7,7 @@ import { useContextMenu } from '../shared/ContextMenu'
import { placesApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
+import { useAuthStore } from '../../store/authStore'
import type { Place, Category, Day, AssignmentsMap } from '../../types'
export interface PlacesSidebarProps {
@@ -49,6 +50,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const loadTrip = useTripStore((s) => s.loadTrip)
const can = useCanDo()
const canEditPlaces = can('place_edit', trip)
+ // Places-API enrichment (#886) needs a Google Maps key; gate the toggle on it.
+ const canEnrichImport = useAuthStore((s) => s.hasMapsKey)
const isNaverListImportEnabled = true
const [fileImportOpen, setFileImportOpen] = useState(false)
@@ -94,6 +97,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const [listImportUrl, setListImportUrl] = useState('')
const [listImportLoading, setListImportLoading] = useState(false)
const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
+ const [listImportEnrich, setListImportEnrich] = useState(false)
const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google']
const hasMultipleListImportProviders = availableListImportProviders.length > 1
@@ -108,9 +112,10 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
setListImportLoading(true)
const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google'
try {
+ const enrich = listImportEnrich && canEnrichImport
const result = provider === 'google'
- ? await placesApi.importGoogleList(tripId, listImportUrl.trim())
- : await placesApi.importNaverList(tripId, listImportUrl.trim())
+ ? await placesApi.importGoogleList(tripId, listImportUrl.trim(), enrich)
+ : await placesApi.importNaverList(tripId, listImportUrl.trim(), enrich)
await loadTrip(tripId)
if (result.count === 0 && result.skipped > 0) {
toast.warning(t('places.importAllSkipped'))
@@ -223,6 +228,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
scrollContainerRef, onScrollTopChange,
listImportOpen, setListImportOpen, listImportUrl, setListImportUrl,
listImportLoading, listImportProvider, setListImportProvider,
+ listImportEnrich, setListImportEnrich, canEnrichImport,
availableListImportProviders, hasMultipleListImportProviders, handleListImport,
search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal,
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
diff --git a/server/src/nest/places/places.controller.ts b/server/src/nest/places/places.controller.ts
index 7e930e3e..cb8fbe5e 100644
--- a/server/src/nest/places/places.controller.ts
+++ b/server/src/nest/places/places.controller.ts
@@ -163,27 +163,30 @@ export class PlacesController {
}
@Post('import/google-list')
- async importGoogle(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Headers('x-socket-id') socketId?: string) {
- return this.importList('google', user, tripId, url, socketId);
+ async importGoogle(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Body('enrich') enrich: unknown, @Headers('x-socket-id') socketId?: string) {
+ return this.importList('google', user, tripId, url, enrich, socketId);
}
@Post('import/naver-list')
- async importNaver(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Headers('x-socket-id') socketId?: string) {
- return this.importList('naver', user, tripId, url, socketId);
+ async importNaver(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Body('enrich') enrich: unknown, @Headers('x-socket-id') socketId?: string) {
+ return this.importList('naver', user, tripId, url, enrich, socketId);
}
/** Shared google/naver list import — identical flow, different provider + error string. */
- private async importList(provider: 'google' | 'naver', user: User, tripId: string, url: unknown, socketId?: string) {
+ private async importList(provider: 'google' | 'naver', user: User, tripId: string, url: unknown, enrich: unknown, socketId?: string) {
const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user);
if (!url || typeof url !== 'string') {
throw new HttpException({ error: 'URL is required' }, 400);
}
+ // Opt-in: re-resolve each imported place via the Places API to fill in
+ // photo / address / website / phone and persist a google_place_id (#886).
+ 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)
- : await this.places.importNaverList(tripId, url);
+ ? await this.places.importGoogleList(tripId, url, opts)
+ : await this.places.importNaverList(tripId, url, opts);
if ('error' in result) {
throw new HttpException({ error: result.error }, result.status);
}
diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts
index eedf767d..869e852e 100644
--- a/server/src/nest/places/places.service.ts
+++ b/server/src/nest/places/places.service.ts
@@ -64,12 +64,12 @@ export class PlacesService {
return svc.importMapFile(tripId, buffer, filename, opts);
}
- importGoogleList(tripId: string, url: string) {
- return svc.importGoogleList(tripId, url);
+ importGoogleList(tripId: string, url: string, opts?: Parameters[2]) {
+ return svc.importGoogleList(tripId, url, opts);
}
- importNaverList(tripId: string, url: string) {
- return svc.importNaverList(tripId, url);
+ importNaverList(tripId: string, url: string, opts?: Parameters[2]) {
+ return svc.importNaverList(tripId, url, opts);
}
searchImage(tripId: string, id: string, userId: number) {
diff --git a/server/src/services/placeEnrichment.ts b/server/src/services/placeEnrichment.ts
new file mode 100644
index 00000000..fa4be26f
--- /dev/null
+++ b/server/src/services/placeEnrichment.ts
@@ -0,0 +1,164 @@
+import { db, getPlaceWithTags } from '../db/database';
+import { broadcast } from '../websocket';
+import { getMapsKey, searchPlaces, getPlacePhoto } from './mapsService';
+
+/**
+ * Background enrichment for list-imported places (#886).
+ *
+ * Google/Naver list imports only carry name + coordinates, so the imported
+ * places open as bare pins (the Maps tab jumps to coordinates, no photo, no
+ * open/closed). When the importer opts in and a Google Maps key is configured,
+ * we re-resolve each place by name — biased to and validated against the
+ * imported coordinates — to a real Google place, then fill in the empty fields
+ * and persist the resolved `google_place_id` (which is what powers on-demand
+ * opening hours / the proper Maps link going forward).
+ *
+ * This runs detached from the import request (fire-and-forget) so a long list
+ * never blocks the response, and pushes each enriched row over the websocket so
+ * the sidebar fills in progressively. It only ever fills EMPTY columns, so it
+ * can never clobber data the import already captured (e.g. a Naver address).
+ */
+
+/** A place the import produced — only the fields enrichment reads/writes. */
+export interface EnrichablePlace {
+ id: number;
+ name: string;
+ lat: number;
+ lng: number;
+ google_place_id?: string | null;
+ address?: string | null;
+ website?: string | null;
+ phone?: string | null;
+ image_url?: string | null;
+}
+
+/** How close a search hit must be to the imported coordinates to be trusted. */
+const MATCH_RADIUS_METERS = 250;
+/** Bias the text search to roughly the imported area. */
+const SEARCH_BIAS_RADIUS_METERS = 2000;
+/** Concurrent enrichment lookups — small, to stay friendly to the Maps quota. */
+const ENRICH_CONCURRENCY = 3;
+
+function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
+ const R = 6371000;
+ const toRad = (d: number) => (d * Math.PI) / 180;
+ const dLat = toRad(b.lat - a.lat);
+ const dLng = toRad(b.lng - a.lng);
+ const lat1 = toRad(a.lat);
+ const lat2 = toRad(b.lat);
+ const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
+ return 2 * R * Math.asin(Math.sqrt(h));
+}
+
+/**
+ * Pick the search result that is the same place as the import: it must be a
+ * Google result (have a google_place_id) with coordinates within
+ * MATCH_RADIUS_METERS of the imported point. Returns the closest such hit, or
+ * null when nothing is close enough — in which case the place is left as
+ * imported rather than risking a wrong-place overwrite (common-name / romanized
+ * lists). Exported for unit testing.
+ */
+export function pickEnrichmentMatch(
+ candidates: Record[],
+ target: { lat: number; lng: number },
+ maxMeters: number = MATCH_RADIUS_METERS,
+): Record | null {
+ let best: { c: Record; dist: number } | null = null;
+ for (const c of candidates || []) {
+ const gpid = c.google_place_id;
+ const lat = c.lat;
+ const lng = c.lng;
+ if (typeof gpid !== 'string' || !gpid) continue;
+ if (typeof lat !== 'number' || typeof lng !== 'number') continue;
+ const dist = haversineMeters(target, { lat, lng });
+ if (dist > maxMeters) continue;
+ if (!best || dist < best.dist) best = { c, dist };
+ }
+ return best?.c ?? null;
+}
+
+async function mapWithConcurrency(items: T[], limit: number, fn: (item: T) => Promise): Promise {
+ let cursor = 0;
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
+ while (cursor < items.length) {
+ const item = items[cursor++];
+ await fn(item);
+ }
+ });
+ await Promise.all(workers);
+}
+
+const str = (v: unknown): string | null => (typeof v === 'string' && v.trim() ? v.trim() : null);
+
+async function enrichOne(tripId: string, userId: number, place: EnrichablePlace, lang?: string): Promise {
+ // Already linked (shouldn't happen for list imports) — nothing to resolve.
+ if (place.google_place_id) return;
+ if (typeof place.lat !== 'number' || typeof place.lng !== 'number') return;
+
+ const { places: results } = await searchPlaces(userId, place.name, lang, {
+ lat: place.lat,
+ lng: place.lng,
+ radius: SEARCH_BIAS_RADIUS_METERS,
+ });
+ const match = pickEnrichmentMatch(results, { lat: place.lat, lng: place.lng });
+ if (!match) return;
+
+ const gpid = str(match.google_place_id);
+ if (!gpid) return;
+
+ // COALESCE so enrichment only fills empty columns — never overwrites data the
+ // import already captured (e.g. Naver's address) or anything the user edited.
+ db.prepare(
+ `UPDATE places
+ SET google_place_id = COALESCE(google_place_id, ?),
+ address = COALESCE(address, ?),
+ website = COALESCE(website, ?),
+ phone = COALESCE(phone, ?),
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ? AND trip_id = ?`,
+ ).run(gpid, str(match.address), str(match.website), str(match.phone), place.id, tripId);
+
+ // Photo is best-effort: Google often has none, and getPlacePhoto throws 404 in
+ // that case — a missing photo must never abort the rest of the enrichment.
+ try {
+ const photo = await getPlacePhoto(userId, gpid, place.lat, place.lng, place.name);
+ if (photo?.photoUrl) {
+ db.prepare(
+ 'UPDATE places SET image_url = COALESCE(image_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?',
+ ).run(photo.photoUrl, place.id, tripId);
+ }
+ } catch {
+ /* no photo — leave image_url as-is */
+ }
+
+ // Push the enriched row to every connected client (no socket exclusion: the
+ // importer's own client should also receive the late update).
+ const updated = getPlaceWithTags(place.id);
+ if (updated) broadcast(tripId, 'place:updated', { place: updated }, undefined);
+}
+
+/**
+ * Enrich a batch of just-imported places in the background. Never throws —
+ * any per-place failure is swallowed so one bad lookup can't take down the
+ * detached task or the process. No-ops when no Google Maps key is configured.
+ */
+export async function enrichImportedPlaces(
+ tripId: string,
+ userId: number,
+ places: EnrichablePlace[],
+ lang?: string,
+): Promise {
+ try {
+ if (!places.length) return;
+ if (!getMapsKey(userId)) return;
+ await mapWithConcurrency(places, ENRICH_CONCURRENCY, async (place) => {
+ try {
+ await enrichOne(tripId, userId, place, lang);
+ } catch (err) {
+ console.error(`[Places] enrichment failed for place ${place.id}:`, err instanceof Error ? err.message : err);
+ }
+ });
+ } catch (err) {
+ console.error('[Places] import enrichment pass failed:', err instanceof Error ? err.message : err);
+ }
+}
diff --git a/server/src/services/placeService.ts b/server/src/services/placeService.ts
index ed91c3eb..a8e6ab05 100644
--- a/server/src/services/placeService.ts
+++ b/server/src/services/placeService.ts
@@ -13,6 +13,14 @@ import {
resolveCategoryIdForFolder,
type KmlImportSummary,
} from './kmlImport';
+import { enrichImportedPlaces, type EnrichablePlace } from './placeEnrichment';
+
+/** Opt-in Places-API enrichment for list imports (#886). */
+export interface ListImportOptions {
+ enrich?: boolean;
+ userId?: number;
+ lang?: string;
+}
interface PlaceWithCategory extends Place {
category_name: string | null;
@@ -595,7 +603,7 @@ export async function importMapFile(tripId: string, fileBuffer: Buffer, filename
// Import Google Maps list
// ---------------------------------------------------------------------------
-export async function importGoogleList(tripId: string, url: string) {
+export async function importGoogleList(tripId: string, url: string, opts?: ListImportOptions) {
let listId: string | null = null;
let resolvedUrl = url;
@@ -697,6 +705,10 @@ export async function importGoogleList(tripId: string, url: string) {
});
insertAll();
+ if (opts?.enrich && opts.userId && created.length) {
+ void enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang);
+ }
+
return { places: created, listName, skipped };
}
@@ -707,6 +719,7 @@ export async function importGoogleList(tripId: string, url: string) {
export async function importNaverList(
tripId: string,
url: string,
+ opts?: ListImportOptions,
): Promise<{ places: any[]; listName: string; skipped: number } | { error: string; status: number }> {
let resolvedUrl = url;
const limit = 20;
@@ -826,6 +839,10 @@ export async function importNaverList(
});
insertAll();
+ if (opts?.enrich && opts.userId && created.length) {
+ void enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang);
+ }
+
return { places: created, listName, skipped };
}
diff --git a/server/tests/unit/services/placeEnrichment.test.ts b/server/tests/unit/services/placeEnrichment.test.ts
new file mode 100644
index 00000000..2cfb61ff
--- /dev/null
+++ b/server/tests/unit/services/placeEnrichment.test.ts
@@ -0,0 +1,49 @@
+/**
+ * Unit tests for the import-enrichment match selector (#886).
+ * Covers PENRICH-001 to PENRICH-004 — the coordinate-validation guard that
+ * prevents a name search from overwriting an imported place with the wrong POI.
+ */
+import { describe, it, expect, vi } from 'vitest';
+
+// placeEnrichment pulls in the DB, websocket and maps service at import time;
+// stub them so the pure match selector can be tested in isolation.
+vi.mock('../../../src/db/database', () => ({ db: {}, getPlaceWithTags: () => null }));
+vi.mock('../../../src/websocket', () => ({ broadcast: () => {} }));
+vi.mock('../../../src/services/mapsService', () => ({
+ getMapsKey: () => null,
+ searchPlaces: async () => ({ places: [], source: 'none' }),
+ getPlacePhoto: async () => ({ photoUrl: '', attribution: null }),
+}));
+
+import { pickEnrichmentMatch } from '../../../src/services/placeEnrichment';
+
+const target = { lat: 48.85, lng: 2.35 };
+
+describe('pickEnrichmentMatch', () => {
+ it('PENRICH-001: picks the closest Google candidate within the radius', () => {
+ const candidates = [
+ { google_place_id: 'far', lat: 48.8512, lng: 2.3512 }, // ~170 m
+ { google_place_id: 'near', lat: 48.85, lng: 2.35 }, // exact
+ ];
+ const match = pickEnrichmentMatch(candidates, target);
+ expect(match?.google_place_id).toBe('near');
+ });
+
+ it('PENRICH-002: returns null when every candidate is beyond the radius', () => {
+ const candidates = [{ google_place_id: 'A', lat: 48.86, lng: 2.36 }]; // ~1.2 km
+ expect(pickEnrichmentMatch(candidates, target)).toBeNull();
+ });
+
+ it('PENRICH-003: ignores candidates without a google_place_id (e.g. OSM results)', () => {
+ const candidates = [
+ { google_place_id: null, lat: 48.85, lng: 2.35 },
+ { name: 'no id', lat: 48.85, lng: 2.35 },
+ ];
+ expect(pickEnrichmentMatch(candidates, target)).toBeNull();
+ });
+
+ it('PENRICH-004: ignores candidates with non-numeric coordinates', () => {
+ const candidates = [{ google_place_id: 'A', lat: 'x', lng: 'y' }];
+ expect(pickEnrichmentMatch(candidates as never, target)).toBeNull();
+ });
+});
diff --git a/shared/src/i18n/ar/places.ts b/shared/src/i18n/ar/places.ts
index 8cf4b75c..8efc9b9b 100644
--- a/shared/src/i18n/ar/places.ts
+++ b/shared/src/i18n/ar/places.ts
@@ -88,5 +88,8 @@ const places: TranslationStrings = {
'places.saveError': 'فشل الحفظ',
'places.duplicateExists': "'{name}' موجود بالفعل في هذه الرحلة.",
'places.addAnyway': 'الإضافة على أي حال',
+ 'places.enrichOnImport': 'إثراء الأماكن عبر Google',
+ 'places.enrichOnImportHint':
+ 'يبحث عن كل مكان مستورد لإضافة الصور والعنوان وبيانات الاتصال. يتطلب مفتاح خرائط Google.',
};
export default places;
diff --git a/shared/src/i18n/br/places.ts b/shared/src/i18n/br/places.ts
index 171934ba..804ac48b 100644
--- a/shared/src/i18n/br/places.ts
+++ b/shared/src/i18n/br/places.ts
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.saveError': 'Falha ao salvar',
'places.duplicateExists': "'{name}' já está nesta viagem.",
'places.addAnyway': 'Adicionar mesmo assim',
+ 'places.enrichOnImport': 'Enriquecer lugares via Google',
+ 'places.enrichOnImportHint':
+ 'Busca cada lugar importado para adicionar fotos, endereço e contato. Usa sua chave do Google Maps.',
};
export default places;
diff --git a/shared/src/i18n/cs/places.ts b/shared/src/i18n/cs/places.ts
index ecb1fda7..0f749cf3 100644
--- a/shared/src/i18n/cs/places.ts
+++ b/shared/src/i18n/cs/places.ts
@@ -89,5 +89,8 @@ const places: TranslationStrings = {
'places.saveError': 'Uložení se nezdařilo',
'places.duplicateExists': "'{name}' už v tomto výletu existuje.",
'places.addAnyway': 'Přesto přidat',
+ 'places.enrichOnImport': 'Obohatit místa přes Google',
+ 'places.enrichOnImportHint':
+ 'Vyhledá každé importované místo a doplní fotky, adresu a kontakty. Vyžaduje klíč Google Maps.',
};
export default places;
diff --git a/shared/src/i18n/de/places.ts b/shared/src/i18n/de/places.ts
index a678c86e..e1ddeb6f 100644
--- a/shared/src/i18n/de/places.ts
+++ b/shared/src/i18n/de/places.ts
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.saveError': 'Fehler beim Speichern',
'places.duplicateExists': "'{name}' ist bereits in dieser Reise.",
'places.addAnyway': 'Trotzdem hinzufügen',
+ 'places.enrichOnImport': 'Orte über Google anreichern',
+ 'places.enrichOnImportHint':
+ 'Sucht jeden importierten Ort nach, um Fotos, Adresse und Kontaktdaten zu ergänzen. Nutzt deinen Google-Maps-Key.',
};
export default places;
diff --git a/shared/src/i18n/en/places.ts b/shared/src/i18n/en/places.ts
index 9739c46c..d9ad546c 100644
--- a/shared/src/i18n/en/places.ts
+++ b/shared/src/i18n/en/places.ts
@@ -89,5 +89,8 @@ const places: TranslationStrings = {
'places.saveError': 'Failed to save',
'places.duplicateExists': "'{name}' is already in this trip.",
'places.addAnyway': 'Add anyway',
+ 'places.enrichOnImport': 'Enrich places via Google',
+ 'places.enrichOnImportHint':
+ 'Look up each imported place to fill in photos, address and contact details. Uses your Google Maps key.',
};
export default places;
diff --git a/shared/src/i18n/es/places.ts b/shared/src/i18n/es/places.ts
index c5225a06..906193a4 100644
--- a/shared/src/i18n/es/places.ts
+++ b/shared/src/i18n/es/places.ts
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.saveError': 'No se pudo guardar',
'places.duplicateExists': "'{name}' ya está en este viaje.",
'places.addAnyway': 'Añadir de todos modos',
+ 'places.enrichOnImport': 'Enriquecer lugares con Google',
+ 'places.enrichOnImportHint':
+ 'Busca cada lugar importado para añadir fotos, dirección y datos de contacto. Usa tu clave de Google Maps.',
};
export default places;
diff --git a/shared/src/i18n/fr/places.ts b/shared/src/i18n/fr/places.ts
index 3c45225d..1e8a3f1b 100644
--- a/shared/src/i18n/fr/places.ts
+++ b/shared/src/i18n/fr/places.ts
@@ -91,5 +91,8 @@ const places: TranslationStrings = {
'places.saveError': "Échec de l'enregistrement",
'places.duplicateExists': "'{name}' est déjà dans ce voyage.",
'places.addAnyway': 'Ajouter quand même',
+ 'places.enrichOnImport': 'Enrichir les lieux via Google',
+ 'places.enrichOnImportHint':
+ 'Recherche chaque lieu importé pour ajouter photos, adresse et coordonnées. Utilise votre clé Google Maps.',
};
export default places;
diff --git a/shared/src/i18n/gr/places.ts b/shared/src/i18n/gr/places.ts
index 6aa701fe..8da867eb 100644
--- a/shared/src/i18n/gr/places.ts
+++ b/shared/src/i18n/gr/places.ts
@@ -92,5 +92,8 @@ const places: TranslationStrings = {
'places.saveError': 'Αποτυχία αποθήκευσης',
'places.duplicateExists': "Το '{name}' υπάρχει ήδη σε αυτό το ταξίδι.",
'places.addAnyway': 'Προσθήκη ούτως ή άλλως',
+ 'places.enrichOnImport': 'Εμπλουτισμός τόπων μέσω Google',
+ 'places.enrichOnImportHint':
+ 'Αναζητά κάθε εισαγόμενο μέρος για να προσθέσει φωτογραφίες, διεύθυνση και στοιχεία επικοινωνίας. Απαιτεί κλειδί Google Maps.',
};
export default places;
diff --git a/shared/src/i18n/hu/places.ts b/shared/src/i18n/hu/places.ts
index 628bcec2..aecdc91f 100644
--- a/shared/src/i18n/hu/places.ts
+++ b/shared/src/i18n/hu/places.ts
@@ -91,5 +91,8 @@ const places: TranslationStrings = {
'places.saveError': 'Nem sikerült menteni',
'places.duplicateExists': "A(z) '{name}' már szerepel ebben az utazásban.",
'places.addAnyway': 'Hozzáadás mindenképp',
+ 'places.enrichOnImport': 'Helyek gazdagítása a Google-lel',
+ 'places.enrichOnImportHint':
+ 'Minden importált helyet megkeres, hogy fotókat, címet és elérhetőséget adjon hozzá. Google Maps-kulcs szükséges.',
};
export default places;
diff --git a/shared/src/i18n/id/places.ts b/shared/src/i18n/id/places.ts
index 3c3b8f68..6d4a2f3f 100644
--- a/shared/src/i18n/id/places.ts
+++ b/shared/src/i18n/id/places.ts
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.saveError': 'Gagal menyimpan',
'places.duplicateExists': "'{name}' sudah ada di perjalanan ini.",
'places.addAnyway': 'Tetap tambahkan',
+ 'places.enrichOnImport': 'Perkaya tempat via Google',
+ 'places.enrichOnImportHint':
+ 'Mencari setiap tempat yang diimpor untuk menambahkan foto, alamat, dan kontak. Memerlukan kunci Google Maps.',
};
export default places;
diff --git a/shared/src/i18n/it/places.ts b/shared/src/i18n/it/places.ts
index 591a2248..34980397 100644
--- a/shared/src/i18n/it/places.ts
+++ b/shared/src/i18n/it/places.ts
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.saveError': 'Impossibile salvare',
'places.duplicateExists': "'{name}' è già in questo viaggio.",
'places.addAnyway': 'Aggiungi comunque',
+ 'places.enrichOnImport': 'Arricchisci i luoghi con Google',
+ 'places.enrichOnImportHint':
+ 'Cerca ogni luogo importato per aggiungere foto, indirizzo e contatti. Usa la tua chiave Google Maps.',
};
export default places;
diff --git a/shared/src/i18n/ja/places.ts b/shared/src/i18n/ja/places.ts
index dee4ba1b..6e7e81c7 100644
--- a/shared/src/i18n/ja/places.ts
+++ b/shared/src/i18n/ja/places.ts
@@ -91,5 +91,8 @@ const places: TranslationStrings = {
'places.saveError': '保存に失敗しました',
'places.duplicateExists': '「{name}」はすでにこの旅程に含まれています。',
'places.addAnyway': 'それでも追加',
+ 'places.enrichOnImport': 'Googleで場所を補完',
+ 'places.enrichOnImportHint':
+ 'インポートした各場所を検索して、写真・住所・連絡先を追加します。Google Maps キーが必要です。',
};
export default places;
diff --git a/shared/src/i18n/ko/places.ts b/shared/src/i18n/ko/places.ts
index 2275c385..663aa875 100644
--- a/shared/src/i18n/ko/places.ts
+++ b/shared/src/i18n/ko/places.ts
@@ -88,5 +88,8 @@ const places: TranslationStrings = {
'places.saveError': '저장 실패',
'places.duplicateExists': "'{name}'은(는) 이미 이 여행에 있습니다.",
'places.addAnyway': '그래도 추가',
+ 'places.enrichOnImport': 'Google로 장소 정보 보강',
+ 'places.enrichOnImportHint':
+ '가져온 각 장소를 검색해 사진, 주소, 연락처를 추가합니다. Google Maps 키가 필요합니다.',
};
export default places;
diff --git a/shared/src/i18n/nl/places.ts b/shared/src/i18n/nl/places.ts
index 2353936f..04f47890 100644
--- a/shared/src/i18n/nl/places.ts
+++ b/shared/src/i18n/nl/places.ts
@@ -91,5 +91,8 @@ const places: TranslationStrings = {
'places.saveError': 'Opslaan mislukt',
'places.duplicateExists': "'{name}' staat al in deze reis.",
'places.addAnyway': 'Toch toevoegen',
+ 'places.enrichOnImport': 'Plaatsen verrijken via Google',
+ 'places.enrichOnImportHint':
+ 'Zoekt elke geïmporteerde plaats op om fotos, adres en contactgegevens toe te voegen. Gebruikt je Google Maps-sleutel.',
};
export default places;
diff --git a/shared/src/i18n/pl/places.ts b/shared/src/i18n/pl/places.ts
index c0b5d09d..6a6ae691 100644
--- a/shared/src/i18n/pl/places.ts
+++ b/shared/src/i18n/pl/places.ts
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.naverListImported': 'Zaimportowano {count} miejsc z "{list}"',
'places.naverListError': 'Nie udało się zaimportować listy Naver Maps',
'places.viewDetails': 'Zobacz szczegóły',
+ 'places.enrichOnImport': 'Wzbogać miejsca przez Google',
+ 'places.enrichOnImportHint':
+ 'Wyszukuje każde zaimportowane miejsce, aby dodać zdjęcia, adres i dane kontaktowe. Wymaga klucza Google Maps.',
};
export default places;
diff --git a/shared/src/i18n/ru/places.ts b/shared/src/i18n/ru/places.ts
index 7d6fd2bb..566a9840 100644
--- a/shared/src/i18n/ru/places.ts
+++ b/shared/src/i18n/ru/places.ts
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.saveError': 'Ошибка сохранения',
'places.duplicateExists': "'{name}' уже есть в этой поездке.",
'places.addAnyway': 'Всё равно добавить',
+ 'places.enrichOnImport': 'Обогатить места через Google',
+ 'places.enrichOnImportHint':
+ 'Находит каждое импортированное место и добавляет фото, адрес и контакты. Требуется ключ Google Maps.',
};
export default places;
diff --git a/shared/src/i18n/tr/places.ts b/shared/src/i18n/tr/places.ts
index 11e1eb03..0ffd63b2 100644
--- a/shared/src/i18n/tr/places.ts
+++ b/shared/src/i18n/tr/places.ts
@@ -89,5 +89,8 @@ const places: TranslationStrings = {
'places.saveError': 'Kaydedilemedi',
'places.duplicateExists': "'{name}' zaten bu gezide var.",
'places.addAnyway': 'Yine de ekle',
+ 'places.enrichOnImport': 'Yerleri Google ile zenginleştir',
+ 'places.enrichOnImportHint':
+ 'İçe aktarılan her yeri arayarak fotoğraf, adres ve iletişim bilgilerini ekler. Google Maps anahtarı gerekir.',
};
export default places;
diff --git a/shared/src/i18n/uk/places.ts b/shared/src/i18n/uk/places.ts
index d7487115..38fa79f5 100644
--- a/shared/src/i18n/uk/places.ts
+++ b/shared/src/i18n/uk/places.ts
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.saveError': 'Помилка збереження',
'places.duplicateExists': "'{name}' вже є в цій подорожі.",
'places.addAnyway': 'Все одно додати',
+ 'places.enrichOnImport': 'Збагатити місця через Google',
+ 'places.enrichOnImportHint':
+ 'Знаходить кожне імпортоване місце й додає фото, адресу та контакти. Потрібен ключ Google Maps.',
};
export default places;
diff --git a/shared/src/i18n/zh-TW/places.ts b/shared/src/i18n/zh-TW/places.ts
index 046bf95b..4676ccf4 100644
--- a/shared/src/i18n/zh-TW/places.ts
+++ b/shared/src/i18n/zh-TW/places.ts
@@ -85,5 +85,8 @@ const places: TranslationStrings = {
'places.saveError': '儲存失敗',
'places.duplicateExists': "'{name}' 已在此行程中。",
'places.addAnyway': '仍要新增',
+ 'places.enrichOnImport': '透過 Google 豐富地點資訊',
+ 'places.enrichOnImportHint':
+ '查詢每個匯入的地點以補上照片、地址與聯絡資訊。需要 Google Maps 金鑰。',
};
export default places;
diff --git a/shared/src/i18n/zh/places.ts b/shared/src/i18n/zh/places.ts
index ec93c06a..724b9afa 100644
--- a/shared/src/i18n/zh/places.ts
+++ b/shared/src/i18n/zh/places.ts
@@ -85,5 +85,8 @@ const places: TranslationStrings = {
'places.saveError': '保存失败',
'places.duplicateExists': "'{name}' 已在此行程中。",
'places.addAnyway': '仍然添加',
+ 'places.enrichOnImport': '通过 Google 丰富地点信息',
+ 'places.enrichOnImportHint':
+ '查找每个导入的地点以补充照片、地址和联系方式。需要 Google Maps 密钥。',
};
export default places;
diff --git a/shared/src/place/place.schema.ts b/shared/src/place/place.schema.ts
index 87ae31e1..b61c9cb6 100644
--- a/shared/src/place/place.schema.ts
+++ b/shared/src/place/place.schema.ts
@@ -117,6 +117,9 @@ export type PlaceBulkDeleteRequest = z.infer<
export const placeImportListRequestSchema = z.object({
url: z.string().min(1),
+ // Opt-in: enrich imported places via the Places API (#886). Requires a Google
+ // Maps key; runs as a background pass after the import returns.
+ enrich: z.boolean().optional(),
});
export type PlaceImportListRequest = z.infer<
typeof placeImportListRequestSchema