feat(places): enrich list-imported places via the Places API (#886) (#1161)

* 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.
This commit is contained in:
Maurice
2026-06-14 00:54:11 +02:00
committed by GitHub
parent 3398da633b
commit 3e9626fce9
29 changed files with 331 additions and 18 deletions
+4 -4
View File
@@ -366,10 +366,10 @@ export const placesApi = {
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths)) 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) 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) => importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url } satisfies PlaceImportListRequest).then(r => r.data), apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
importNaverList: (tripId: number | string, url: string) => importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data), apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
bulkDelete: (tripId: number | string, ids: number[]) => bulkDelete: (tripId: number | string, ids: number[]) =>
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data), apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
} }
@@ -1,10 +1,12 @@
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import ToggleSwitch from '../Settings/ToggleSwitch'
import type { SidebarState } from './usePlacesSidebar' import type { SidebarState } from './usePlacesSidebar'
export function ListImportModal(S: SidebarState) { export function ListImportModal(S: SidebarState) {
const { const {
setListImportOpen, setListImportUrl, t, hasMultipleListImportProviders, availableListImportProviders, setListImportOpen, setListImportUrl, t, hasMultipleListImportProviders, availableListImportProviders,
listImportProvider, setListImportProvider, listImportUrl, listImportLoading, handleListImport, listImportProvider, setListImportProvider, listImportUrl, listImportLoading, handleListImport,
listImportEnrich, setListImportEnrich, canEnrichImport,
} = S } = S
return ReactDOM.createPortal( return ReactDOM.createPortal(
<div <div
@@ -55,6 +57,15 @@ export function ListImportModal(S: SidebarState) {
fontFamily: 'inherit', boxSizing: 'border-box', fontFamily: 'inherit', boxSizing: 'border-box',
}} }}
/> />
{canEnrichImport && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, marginTop: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 12, fontWeight: 600 }}>{t('places.enrichOnImport')}</div>
<div className="text-content-faint" style={{ fontSize: 12, marginTop: 2 }}>{t('places.enrichOnImportHint')}</div>
</div>
<ToggleSwitch on={listImportEnrich} onToggle={() => setListImportEnrich(!listImportEnrich)} />
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}> <div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
<button <button
onClick={() => { setListImportOpen(false); setListImportUrl('') }} onClick={() => { setListImportOpen(false); setListImportUrl('') }}
@@ -7,6 +7,7 @@ import { useContextMenu } from '../shared/ContextMenu'
import { placesApi } from '../../api/client' import { placesApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore' import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore' import { useCanDo } from '../../store/permissionsStore'
import { useAuthStore } from '../../store/authStore'
import type { Place, Category, Day, AssignmentsMap } from '../../types' import type { Place, Category, Day, AssignmentsMap } from '../../types'
export interface PlacesSidebarProps { export interface PlacesSidebarProps {
@@ -49,6 +50,8 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const loadTrip = useTripStore((s) => s.loadTrip) const loadTrip = useTripStore((s) => s.loadTrip)
const can = useCanDo() const can = useCanDo()
const canEditPlaces = can('place_edit', trip) 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 isNaverListImportEnabled = true
const [fileImportOpen, setFileImportOpen] = useState(false) const [fileImportOpen, setFileImportOpen] = useState(false)
@@ -94,6 +97,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
const [listImportUrl, setListImportUrl] = useState('') const [listImportUrl, setListImportUrl] = useState('')
const [listImportLoading, setListImportLoading] = useState(false) const [listImportLoading, setListImportLoading] = useState(false)
const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google') const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
const [listImportEnrich, setListImportEnrich] = useState(false)
const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google'] const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google']
const hasMultipleListImportProviders = availableListImportProviders.length > 1 const hasMultipleListImportProviders = availableListImportProviders.length > 1
@@ -108,9 +112,10 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
setListImportLoading(true) setListImportLoading(true)
const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google' const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google'
try { try {
const enrich = listImportEnrich && canEnrichImport
const result = provider === 'google' const result = provider === 'google'
? await placesApi.importGoogleList(tripId, listImportUrl.trim()) ? await placesApi.importGoogleList(tripId, listImportUrl.trim(), enrich)
: await placesApi.importNaverList(tripId, listImportUrl.trim()) : await placesApi.importNaverList(tripId, listImportUrl.trim(), enrich)
await loadTrip(tripId) await loadTrip(tripId)
if (result.count === 0 && result.skipped > 0) { if (result.count === 0 && result.skipped > 0) {
toast.warning(t('places.importAllSkipped')) toast.warning(t('places.importAllSkipped'))
@@ -223,6 +228,7 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
scrollContainerRef, onScrollTopChange, scrollContainerRef, onScrollTopChange,
listImportOpen, setListImportOpen, listImportUrl, setListImportUrl, listImportOpen, setListImportOpen, listImportUrl, setListImportUrl,
listImportLoading, listImportProvider, setListImportProvider, listImportLoading, listImportProvider, setListImportProvider,
listImportEnrich, setListImportEnrich, canEnrichImport,
availableListImportProviders, hasMultipleListImportProviders, handleListImport, availableListImportProviders, hasMultipleListImportProviders, handleListImport,
search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal, search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal,
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds, selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
+10 -7
View File
@@ -163,27 +163,30 @@ export class PlacesController {
} }
@Post('import/google-list') @Post('import/google-list')
async importGoogle(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Headers('x-socket-id') socketId?: string) { 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, socketId); return this.importList('google', user, tripId, url, enrich, socketId);
} }
@Post('import/naver-list') @Post('import/naver-list')
async importNaver(@CurrentUser() user: User, @Param('tripId') tripId: string, @Body('url') url: unknown, @Headers('x-socket-id') socketId?: string) { 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, socketId); return this.importList('naver', user, tripId, url, enrich, socketId);
} }
/** Shared google/naver list import — identical flow, different provider + error string. */ /** 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); const trip = this.requireTrip(tripId, user);
this.requireEdit(trip, user); this.requireEdit(trip, user);
if (!url || typeof url !== 'string') { if (!url || typeof url !== 'string') {
throw new HttpException({ error: 'URL is required' }, 400); 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'; const label = provider === 'google' ? 'Google' : 'Naver';
try { try {
const result = provider === 'google' const result = provider === 'google'
? await this.places.importGoogleList(tripId, url) ? await this.places.importGoogleList(tripId, url, opts)
: await this.places.importNaverList(tripId, url); : await this.places.importNaverList(tripId, url, opts);
if ('error' in result) { if ('error' in result) {
throw new HttpException({ error: result.error }, result.status); throw new HttpException({ error: result.error }, result.status);
} }
+4 -4
View File
@@ -64,12 +64,12 @@ export class PlacesService {
return svc.importMapFile(tripId, buffer, filename, opts); return svc.importMapFile(tripId, buffer, filename, opts);
} }
importGoogleList(tripId: string, url: string) { importGoogleList(tripId: string, url: string, opts?: Parameters<typeof svc.importGoogleList>[2]) {
return svc.importGoogleList(tripId, url); return svc.importGoogleList(tripId, url, opts);
} }
importNaverList(tripId: string, url: string) { importNaverList(tripId: string, url: string, opts?: Parameters<typeof svc.importNaverList>[2]) {
return svc.importNaverList(tripId, url); return svc.importNaverList(tripId, url, opts);
} }
searchImage(tripId: string, id: string, userId: number) { searchImage(tripId: string, id: string, userId: number) {
+164
View File
@@ -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<string, unknown>[],
target: { lat: number; lng: number },
maxMeters: number = MATCH_RADIUS_METERS,
): Record<string, unknown> | null {
let best: { c: Record<string, unknown>; 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<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
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<void> {
// 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<void> {
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);
}
}
+18 -1
View File
@@ -13,6 +13,14 @@ import {
resolveCategoryIdForFolder, resolveCategoryIdForFolder,
type KmlImportSummary, type KmlImportSummary,
} from './kmlImport'; } 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 { interface PlaceWithCategory extends Place {
category_name: string | null; category_name: string | null;
@@ -595,7 +603,7 @@ export async function importMapFile(tripId: string, fileBuffer: Buffer, filename
// Import Google Maps list // 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 listId: string | null = null;
let resolvedUrl = url; let resolvedUrl = url;
@@ -697,6 +705,10 @@ export async function importGoogleList(tripId: string, url: string) {
}); });
insertAll(); insertAll();
if (opts?.enrich && opts.userId && created.length) {
void enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang);
}
return { places: created, listName, skipped }; return { places: created, listName, skipped };
} }
@@ -707,6 +719,7 @@ export async function importGoogleList(tripId: string, url: string) {
export async function importNaverList( export async function importNaverList(
tripId: string, tripId: string,
url: string, url: string,
opts?: ListImportOptions,
): Promise<{ places: any[]; listName: string; skipped: number } | { error: string; status: number }> { ): Promise<{ places: any[]; listName: string; skipped: number } | { error: string; status: number }> {
let resolvedUrl = url; let resolvedUrl = url;
const limit = 20; const limit = 20;
@@ -826,6 +839,10 @@ export async function importNaverList(
}); });
insertAll(); insertAll();
if (opts?.enrich && opts.userId && created.length) {
void enrichImportedPlaces(tripId, opts.userId, created as EnrichablePlace[], opts.lang);
}
return { places: created, listName, skipped }; return { places: created, listName, skipped };
} }
@@ -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();
});
});
+3
View File
@@ -88,5 +88,8 @@ const places: TranslationStrings = {
'places.saveError': 'فشل الحفظ', 'places.saveError': 'فشل الحفظ',
'places.duplicateExists': "'{name}' موجود بالفعل في هذه الرحلة.", 'places.duplicateExists': "'{name}' موجود بالفعل في هذه الرحلة.",
'places.addAnyway': 'الإضافة على أي حال', 'places.addAnyway': 'الإضافة على أي حال',
'places.enrichOnImport': 'إثراء الأماكن عبر Google',
'places.enrichOnImportHint':
'يبحث عن كل مكان مستورد لإضافة الصور والعنوان وبيانات الاتصال. يتطلب مفتاح خرائط Google.',
}; };
export default places; export default places;
+3
View File
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.saveError': 'Falha ao salvar', 'places.saveError': 'Falha ao salvar',
'places.duplicateExists': "'{name}' já está nesta viagem.", 'places.duplicateExists': "'{name}' já está nesta viagem.",
'places.addAnyway': 'Adicionar mesmo assim', '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; export default places;
+3
View File
@@ -89,5 +89,8 @@ const places: TranslationStrings = {
'places.saveError': 'Uložení se nezdařilo', 'places.saveError': 'Uložení se nezdařilo',
'places.duplicateExists': "'{name}' už v tomto výletu existuje.", 'places.duplicateExists': "'{name}' už v tomto výletu existuje.",
'places.addAnyway': 'Přesto přidat', '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; export default places;
+3
View File
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.saveError': 'Fehler beim Speichern', 'places.saveError': 'Fehler beim Speichern',
'places.duplicateExists': "'{name}' ist bereits in dieser Reise.", 'places.duplicateExists': "'{name}' ist bereits in dieser Reise.",
'places.addAnyway': 'Trotzdem hinzufügen', '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; export default places;
+3
View File
@@ -89,5 +89,8 @@ const places: TranslationStrings = {
'places.saveError': 'Failed to save', 'places.saveError': 'Failed to save',
'places.duplicateExists': "'{name}' is already in this trip.", 'places.duplicateExists': "'{name}' is already in this trip.",
'places.addAnyway': 'Add anyway', '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; export default places;
+3
View File
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.saveError': 'No se pudo guardar', 'places.saveError': 'No se pudo guardar',
'places.duplicateExists': "'{name}' ya está en este viaje.", 'places.duplicateExists': "'{name}' ya está en este viaje.",
'places.addAnyway': 'Añadir de todos modos', '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; export default places;
+3
View File
@@ -91,5 +91,8 @@ const places: TranslationStrings = {
'places.saveError': "Échec de l'enregistrement", 'places.saveError': "Échec de l'enregistrement",
'places.duplicateExists': "'{name}' est déjà dans ce voyage.", 'places.duplicateExists': "'{name}' est déjà dans ce voyage.",
'places.addAnyway': 'Ajouter quand même', '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; export default places;
+3
View File
@@ -92,5 +92,8 @@ const places: TranslationStrings = {
'places.saveError': 'Αποτυχία αποθήκευσης', 'places.saveError': 'Αποτυχία αποθήκευσης',
'places.duplicateExists': "Το '{name}' υπάρχει ήδη σε αυτό το ταξίδι.", 'places.duplicateExists': "Το '{name}' υπάρχει ήδη σε αυτό το ταξίδι.",
'places.addAnyway': 'Προσθήκη ούτως ή άλλως', 'places.addAnyway': 'Προσθήκη ούτως ή άλλως',
'places.enrichOnImport': 'Εμπλουτισμός τόπων μέσω Google',
'places.enrichOnImportHint':
'Αναζητά κάθε εισαγόμενο μέρος για να προσθέσει φωτογραφίες, διεύθυνση και στοιχεία επικοινωνίας. Απαιτεί κλειδί Google Maps.',
}; };
export default places; export default places;
+3
View File
@@ -91,5 +91,8 @@ const places: TranslationStrings = {
'places.saveError': 'Nem sikerült menteni', 'places.saveError': 'Nem sikerült menteni',
'places.duplicateExists': "A(z) '{name}' már szerepel ebben az utazásban.", 'places.duplicateExists': "A(z) '{name}' már szerepel ebben az utazásban.",
'places.addAnyway': 'Hozzáadás mindenképp', '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; export default places;
+3
View File
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.saveError': 'Gagal menyimpan', 'places.saveError': 'Gagal menyimpan',
'places.duplicateExists': "'{name}' sudah ada di perjalanan ini.", 'places.duplicateExists': "'{name}' sudah ada di perjalanan ini.",
'places.addAnyway': 'Tetap tambahkan', '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; export default places;
+3
View File
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.saveError': 'Impossibile salvare', 'places.saveError': 'Impossibile salvare',
'places.duplicateExists': "'{name}' è già in questo viaggio.", 'places.duplicateExists': "'{name}' è già in questo viaggio.",
'places.addAnyway': 'Aggiungi comunque', '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; export default places;
+3
View File
@@ -91,5 +91,8 @@ const places: TranslationStrings = {
'places.saveError': '保存に失敗しました', 'places.saveError': '保存に失敗しました',
'places.duplicateExists': '「{name}」はすでにこの旅程に含まれています。', 'places.duplicateExists': '「{name}」はすでにこの旅程に含まれています。',
'places.addAnyway': 'それでも追加', 'places.addAnyway': 'それでも追加',
'places.enrichOnImport': 'Googleで場所を補完',
'places.enrichOnImportHint':
'インポートした各場所を検索して、写真・住所・連絡先を追加します。Google Maps キーが必要です。',
}; };
export default places; export default places;
+3
View File
@@ -88,5 +88,8 @@ const places: TranslationStrings = {
'places.saveError': '저장 실패', 'places.saveError': '저장 실패',
'places.duplicateExists': "'{name}'은(는) 이미 이 여행에 있습니다.", 'places.duplicateExists': "'{name}'은(는) 이미 이 여행에 있습니다.",
'places.addAnyway': '그래도 추가', 'places.addAnyway': '그래도 추가',
'places.enrichOnImport': 'Google로 장소 정보 보강',
'places.enrichOnImportHint':
'가져온 각 장소를 검색해 사진, 주소, 연락처를 추가합니다. Google Maps 키가 필요합니다.',
}; };
export default places; export default places;
+3
View File
@@ -91,5 +91,8 @@ const places: TranslationStrings = {
'places.saveError': 'Opslaan mislukt', 'places.saveError': 'Opslaan mislukt',
'places.duplicateExists': "'{name}' staat al in deze reis.", 'places.duplicateExists': "'{name}' staat al in deze reis.",
'places.addAnyway': 'Toch toevoegen', '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; export default places;
+3
View File
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.naverListImported': 'Zaimportowano {count} miejsc z "{list}"', 'places.naverListImported': 'Zaimportowano {count} miejsc z "{list}"',
'places.naverListError': 'Nie udało się zaimportować listy Naver Maps', 'places.naverListError': 'Nie udało się zaimportować listy Naver Maps',
'places.viewDetails': 'Zobacz szczegóły', '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; export default places;
+3
View File
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.saveError': 'Ошибка сохранения', 'places.saveError': 'Ошибка сохранения',
'places.duplicateExists': "'{name}' уже есть в этой поездке.", 'places.duplicateExists': "'{name}' уже есть в этой поездке.",
'places.addAnyway': 'Всё равно добавить', 'places.addAnyway': 'Всё равно добавить',
'places.enrichOnImport': 'Обогатить места через Google',
'places.enrichOnImportHint':
'Находит каждое импортированное место и добавляет фото, адрес и контакты. Требуется ключ Google Maps.',
}; };
export default places; export default places;
+3
View File
@@ -89,5 +89,8 @@ const places: TranslationStrings = {
'places.saveError': 'Kaydedilemedi', 'places.saveError': 'Kaydedilemedi',
'places.duplicateExists': "'{name}' zaten bu gezide var.", 'places.duplicateExists': "'{name}' zaten bu gezide var.",
'places.addAnyway': 'Yine de ekle', '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; export default places;
+3
View File
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
'places.saveError': 'Помилка збереження', 'places.saveError': 'Помилка збереження',
'places.duplicateExists': "'{name}' вже є в цій подорожі.", 'places.duplicateExists': "'{name}' вже є в цій подорожі.",
'places.addAnyway': 'Все одно додати', 'places.addAnyway': 'Все одно додати',
'places.enrichOnImport': 'Збагатити місця через Google',
'places.enrichOnImportHint':
'Знаходить кожне імпортоване місце й додає фото, адресу та контакти. Потрібен ключ Google Maps.',
}; };
export default places; export default places;
+3
View File
@@ -85,5 +85,8 @@ const places: TranslationStrings = {
'places.saveError': '儲存失敗', 'places.saveError': '儲存失敗',
'places.duplicateExists': "'{name}' 已在此行程中。", 'places.duplicateExists': "'{name}' 已在此行程中。",
'places.addAnyway': '仍要新增', 'places.addAnyway': '仍要新增',
'places.enrichOnImport': '透過 Google 豐富地點資訊',
'places.enrichOnImportHint':
'查詢每個匯入的地點以補上照片、地址與聯絡資訊。需要 Google Maps 金鑰。',
}; };
export default places; export default places;
+3
View File
@@ -85,5 +85,8 @@ const places: TranslationStrings = {
'places.saveError': '保存失败', 'places.saveError': '保存失败',
'places.duplicateExists': "'{name}' 已在此行程中。", 'places.duplicateExists': "'{name}' 已在此行程中。",
'places.addAnyway': '仍然添加', 'places.addAnyway': '仍然添加',
'places.enrichOnImport': '通过 Google 丰富地点信息',
'places.enrichOnImportHint':
'查找每个导入的地点以补充照片、地址和联系方式。需要 Google Maps 密钥。',
}; };
export default places; export default places;
+3
View File
@@ -117,6 +117,9 @@ export type PlaceBulkDeleteRequest = z.infer<
export const placeImportListRequestSchema = z.object({ export const placeImportListRequestSchema = z.object({
url: z.string().min(1), 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< export type PlaceImportListRequest = z.infer<
typeof placeImportListRequestSchema typeof placeImportListRequestSchema