mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
* 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:
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<div
|
||||
@@ -55,6 +57,15 @@ export function ListImportModal(S: SidebarState) {
|
||||
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' }}>
|
||||
<button
|
||||
onClick={() => { setListImportOpen(false); setListImportUrl('') }}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<typeof svc.importGoogleList>[2]) {
|
||||
return svc.importGoogleList(tripId, url, opts);
|
||||
}
|
||||
|
||||
importNaverList(tripId: string, url: string) {
|
||||
return svc.importNaverList(tripId, url);
|
||||
importNaverList(tripId: string, url: string, opts?: Parameters<typeof svc.importNaverList>[2]) {
|
||||
return svc.importNaverList(tripId, url, opts);
|
||||
}
|
||||
|
||||
searchImage(tripId: string, id: string, userId: number) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -88,5 +88,8 @@ const places: TranslationStrings = {
|
||||
'places.saveError': 'فشل الحفظ',
|
||||
'places.duplicateExists': "'{name}' موجود بالفعل في هذه الرحلة.",
|
||||
'places.addAnyway': 'الإضافة على أي حال',
|
||||
'places.enrichOnImport': 'إثراء الأماكن عبر Google',
|
||||
'places.enrichOnImportHint':
|
||||
'يبحث عن كل مكان مستورد لإضافة الصور والعنوان وبيانات الاتصال. يتطلب مفتاح خرائط Google.',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -92,5 +92,8 @@ const places: TranslationStrings = {
|
||||
'places.saveError': 'Αποτυχία αποθήκευσης',
|
||||
'places.duplicateExists': "Το '{name}' υπάρχει ήδη σε αυτό το ταξίδι.",
|
||||
'places.addAnyway': 'Προσθήκη ούτως ή άλλως',
|
||||
'places.enrichOnImport': 'Εμπλουτισμός τόπων μέσω Google',
|
||||
'places.enrichOnImportHint':
|
||||
'Αναζητά κάθε εισαγόμενο μέρος για να προσθέσει φωτογραφίες, διεύθυνση και στοιχεία επικοινωνίας. Απαιτεί κλειδί Google Maps.',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -91,5 +91,8 @@ const places: TranslationStrings = {
|
||||
'places.saveError': '保存に失敗しました',
|
||||
'places.duplicateExists': '「{name}」はすでにこの旅程に含まれています。',
|
||||
'places.addAnyway': 'それでも追加',
|
||||
'places.enrichOnImport': 'Googleで場所を補完',
|
||||
'places.enrichOnImportHint':
|
||||
'インポートした各場所を検索して、写真・住所・連絡先を追加します。Google Maps キーが必要です。',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -88,5 +88,8 @@ const places: TranslationStrings = {
|
||||
'places.saveError': '저장 실패',
|
||||
'places.duplicateExists': "'{name}'은(는) 이미 이 여행에 있습니다.",
|
||||
'places.addAnyway': '그래도 추가',
|
||||
'places.enrichOnImport': 'Google로 장소 정보 보강',
|
||||
'places.enrichOnImportHint':
|
||||
'가져온 각 장소를 검색해 사진, 주소, 연락처를 추가합니다. Google Maps 키가 필요합니다.',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
|
||||
'places.saveError': 'Ошибка сохранения',
|
||||
'places.duplicateExists': "'{name}' уже есть в этой поездке.",
|
||||
'places.addAnyway': 'Всё равно добавить',
|
||||
'places.enrichOnImport': 'Обогатить места через Google',
|
||||
'places.enrichOnImportHint':
|
||||
'Находит каждое импортированное место и добавляет фото, адрес и контакты. Требуется ключ Google Maps.',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -90,5 +90,8 @@ const places: TranslationStrings = {
|
||||
'places.saveError': 'Помилка збереження',
|
||||
'places.duplicateExists': "'{name}' вже є в цій подорожі.",
|
||||
'places.addAnyway': 'Все одно додати',
|
||||
'places.enrichOnImport': 'Збагатити місця через Google',
|
||||
'places.enrichOnImportHint':
|
||||
'Знаходить кожне імпортоване місце й додає фото, адресу та контакти. Потрібен ключ Google Maps.',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -85,5 +85,8 @@ const places: TranslationStrings = {
|
||||
'places.saveError': '儲存失敗',
|
||||
'places.duplicateExists': "'{name}' 已在此行程中。",
|
||||
'places.addAnyway': '仍要新增',
|
||||
'places.enrichOnImport': '透過 Google 豐富地點資訊',
|
||||
'places.enrichOnImportHint':
|
||||
'查詢每個匯入的地點以補上照片、地址與聯絡資訊。需要 Google Maps 金鑰。',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -85,5 +85,8 @@ const places: TranslationStrings = {
|
||||
'places.saveError': '保存失败',
|
||||
'places.duplicateExists': "'{name}' 已在此行程中。",
|
||||
'places.addAnyway': '仍然添加',
|
||||
'places.enrichOnImport': '通过 Google 丰富地点信息',
|
||||
'places.enrichOnImportHint':
|
||||
'查找每个导入的地点以补充照片、地址和联系方式。需要 Google Maps 密钥。',
|
||||
};
|
||||
export default places;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user