From 875c91e5ff4532bc5f45239d0d394235e04c5bac Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 15 Apr 2026 06:07:26 +0200 Subject: [PATCH] feat(places): unified file import modal with drag-and-drop and deduplication - Replace separate GPX and KML/KMZ import buttons with a single "Import file" modal accepting all three formats, with a drag-and-drop drop zone - Support dragging files directly onto the Places sidebar panel; overlay appears on hover and pre-loads the file into the modal on drop - Fix [object Object] description bug in KML imports caused by fast-xml-parser returning mixed-content nodes as objects; add stopNodes config and object guard in asTrimmedString - Fix CDATA sections leaking into descriptions (e.g. "text.]]>") by unwrapping CDATA markers before tag stripping - Add import deduplication across all import paths (GPX, KML/KMZ, Google list, Naver list): reimporting skips places already in the trip by name (case-insensitive) or by coordinates (within ~11 m tolerance), with intra-batch dedup so duplicate placemarks within the same file are also collapsed - Fix KML route returning 400 "No valid Placemarks found" when all placemarks were valid but deduplicated; 400 now only fires when the file contains zero placemarks - Show a warning toast "All places were already in the trip" instead of a misleading success toast when a reimport produces zero new places (GPX, KML/KMZ, Google list, Naver list) - Add 8 new i18n keys across all 14 locales; remove 11 keys made unused by the modal consolidation --- .../components/Planner/FileImportModal.tsx | 304 ++++++++++++++++++ .../components/Planner/PlacesSidebar.test.tsx | 18 +- .../src/components/Planner/PlacesSidebar.tsx | 294 ++++------------- client/src/i18n/translations/ar.ts | 20 +- client/src/i18n/translations/br.ts | 20 +- client/src/i18n/translations/cs.ts | 20 +- client/src/i18n/translations/de.ts | 20 +- client/src/i18n/translations/en.ts | 20 +- client/src/i18n/translations/es.ts | 20 +- client/src/i18n/translations/fr.ts | 20 +- client/src/i18n/translations/hu.ts | 20 +- client/src/i18n/translations/it.ts | 20 +- client/src/i18n/translations/nl.ts | 20 +- client/src/i18n/translations/pl.ts | 20 +- client/src/i18n/translations/ru.ts | 20 +- client/src/i18n/translations/zh.ts | 20 +- client/src/i18n/translations/zhTw.ts | 20 +- server/src/routes/places.ts | 14 +- server/src/services/kmlImport.ts | 14 +- server/src/services/placeService.ts | 123 ++++++- .../unit/services/kmlImportUtils.test.ts | 17 + .../tests/unit/services/placeService.test.ts | 108 ++++++- 22 files changed, 741 insertions(+), 431 deletions(-) create mode 100644 client/src/components/Planner/FileImportModal.tsx diff --git a/client/src/components/Planner/FileImportModal.tsx b/client/src/components/Planner/FileImportModal.tsx new file mode 100644 index 00000000..687e1d14 --- /dev/null +++ b/client/src/components/Planner/FileImportModal.tsx @@ -0,0 +1,304 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import { useState, useRef, useEffect } from 'react' +import { Upload } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { useToast } from '../shared/Toast' +import { placesApi } from '../../api/client' +import { useTripStore } from '../../store/tripStore' + +interface PlacesImportSummary { + totalPlacemarks: number + createdCount: number + skippedCount: number + warnings: string[] + errors: string[] +} + +interface FileImportModalProps { + isOpen: boolean + onClose: () => void + tripId: number + pushUndo?: (label: string, undoFn: () => Promise | void) => void + initialFile?: File | null +} + +const MAX_FILE_BYTES = 10 * 1024 * 1024 + +export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, initialFile }: FileImportModalProps) { + const { t } = useTranslation() + const toast = useToast() + const loadTrip = useTripStore((s) => s.loadTrip) + const fileInputRef = useRef(null) + + const [file, setFile] = useState(null) + const [isDragOver, setIsDragOver] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [summary, setSummary] = useState(null) + + const validateFile = (f: File): string | null => { + const ext = f.name.toLowerCase().split('.').pop() + if (ext !== 'gpx' && ext !== 'kml' && ext !== 'kmz') { + return t('places.importFileUnsupported') + } + if (f.size > MAX_FILE_BYTES) { + return t('places.importFileTooLarge', { maxMb: 10 }) + } + return null + } + + const reset = () => { + setFile(null) + setIsDragOver(false) + setLoading(false) + setError('') + setSummary(null) + } + + // When the modal opens, reset state and pre-load any file dropped from the sidebar. + useEffect(() => { + if (!isOpen) return + setIsDragOver(false) + setLoading(false) + setSummary(null) + if (initialFile) { + const err = validateFile(initialFile) + if (err) { + setFile(null) + setError(err) + } else { + setFile(initialFile) + setError('') + } + } else { + setFile(null) + setError('') + } + // validateFile uses t() which is stable — intentionally omitted from deps + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, initialFile]) + + const handleClose = () => { + reset() + onClose() + } + + const selectFile = (f: File) => { + const validationError = validateFile(f) + if (validationError) { + setError(validationError) + setFile(null) + return + } + setFile(f) + setError('') + setSummary(null) + } + + const handleInputChange = (e: React.ChangeEvent) => { + const f = e.target.files?.[0] + e.target.value = '' + if (f) selectFile(f) + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + if (e.target === e.currentTarget) setIsDragOver(false) + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(false) + const f = e.dataTransfer.files[0] + if (f) selectFile(f) + } + + const handleImport = async () => { + if (!file || loading) return + const ext = file.name.toLowerCase().split('.').pop() + setLoading(true) + setError('') + setSummary(null) + + try { + if (ext === 'gpx') { + const result = await placesApi.importGpx(tripId, file) + await loadTrip(tripId) + if (result.count === 0 && result.skipped > 0) { + toast.warning(t('places.importAllSkipped')) + } else { + toast.success(t('places.gpxImported', { count: result.count })) + } + if (result.places?.length > 0) { + const importedIds: number[] = result.places.map((p: { id: number }) => p.id) + pushUndo?.(t('undo.importGpx'), async () => { + for (const id of importedIds) { + try { await placesApi.delete(tripId, id) } catch {} + } + await loadTrip(tripId) + }) + } + handleClose() + } else { + const result = await placesApi.importMapFile(tripId, file) + await loadTrip(tripId) + setSummary(result.summary || null) + if (result.count === 0 && (result.summary?.skippedCount ?? 0) > 0) { + toast.warning(t('places.importAllSkipped')) + } else { + toast.success(t('places.kmlKmzImported', { count: result.count })) + } + if (result.summary?.errors?.length > 0) { + setError(result.summary.errors.join('\n')) + } + if (result.places?.length > 0) { + const importedIds: number[] = result.places.map((p: { id: number }) => p.id) + pushUndo?.(t('undo.importKeyholeMarkup'), async () => { + for (const id of importedIds) { + try { await placesApi.delete(tripId, id) } catch {} + } + await loadTrip(tripId) + }) + } + } + } catch (err: any) { + const responseSummary = err?.response?.data?.summary as PlacesImportSummary | undefined + if (responseSummary) setSummary(responseSummary) + const message = err?.response?.data?.error || t('places.importFileError') + setError(message) + toast.error(message) + } finally { + setLoading(false) + } + } + + const canImport = !!file && !loading + + if (!isOpen) return null + + return ReactDOM.createPortal( +
+
e.stopPropagation()} + style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 520, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} + > +
+ {t('places.importFile')} +
+
+ {t('places.importFileHint')} +
+ + + +
fileInputRef.current?.click()} + onDragOver={handleDragOver} + onDragEnter={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} + style={{ + width: '100%', + minHeight: 88, + borderRadius: 12, + border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`, + background: isDragOver ? 'var(--bg-tertiary)' : 'transparent', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + fontSize: 13, + fontWeight: 500, + cursor: 'pointer', + marginBottom: 12, + fontFamily: 'inherit', + transition: 'border-color 0.15s, background 0.15s', + boxSizing: 'border-box', + padding: 16, + }} + > + + {isDragOver ? ( + {t('places.importFileDropActive')} + ) : file ? ( + {file.name} + ) : ( + {t('places.importFileDropHere')} + )} +
+ + {summary && ( +
+
+ {t('places.kmlKmzSummaryValues', { + total: summary.totalPlacemarks, + created: summary.createdCount, + skipped: summary.skippedCount, + })} +
+ {summary.warnings?.length > 0 && ( +
+ {summary.warnings.join('\n')} +
+ )} +
+ )} + + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
, + document.body + ) +} diff --git a/client/src/components/Planner/PlacesSidebar.test.tsx b/client/src/components/Planner/PlacesSidebar.test.tsx index 79b52c17..dc25a418 100644 --- a/client/src/components/Planner/PlacesSidebar.test.tsx +++ b/client/src/components/Planner/PlacesSidebar.test.tsx @@ -433,29 +433,29 @@ describe('Mobile day-picker (portal)', () => { // ── GPX import ──────────────────────────────────────────────────────────────── describe('GPX import', () => { - it('FE-PLANNER-SIDEBAR-038: GPX import button triggers file input click', async () => { + it('FE-PLANNER-SIDEBAR-038: "Import file" button opens the file import modal', async () => { const user = userEvent.setup(); render(); - const fileInput = document.querySelector('input[type="file"][accept=".gpx"]') as HTMLInputElement; - expect(fileInput).toBeTruthy(); - const clickSpy = vi.spyOn(fileInput, 'click'); - await user.click(screen.getByText(/GPX/i)); - expect(clickSpy).toHaveBeenCalled(); + await user.click(screen.getByText(/Import file/i)); + expect(await screen.findByText(/\.gpx.*\.kml.*\.kmz/i)).toBeInTheDocument(); }); - it('FE-PLANNER-SIDEBAR-039: successful GPX import shows success toast', async () => { - // FormData POST hangs on CI — mock at the API boundary instead of MSW. + it('FE-PLANNER-SIDEBAR-039: successful GPX import via modal shows success toast', async () => { const importSpy = vi.spyOn(placesApi, 'importGpx').mockResolvedValueOnce({ count: 2, places: [{ id: 10 }, { id: 11 }] }); const loadTrip = vi.fn().mockResolvedValue(undefined); seedStore(useTripStore, { loadTrip }); const addToast = vi.fn(); (window as any).__addToast = addToast; + const user = userEvent.setup(); render(); - const fileInput = document.querySelector('input[type="file"][accept=".gpx"]') as HTMLInputElement; + await user.click(screen.getByText(/Import file/i)); + const fileInput = document.querySelector('input[type="file"][accept=".gpx,.kml,.kmz"]') as HTMLInputElement; + expect(fileInput).toBeTruthy(); const file = new File(['track data'], 'route.gpx', { type: 'application/gpx+xml' }); await act(async () => { fireEvent.change(fileInput, { target: { files: [file] } }); }); + await user.click(screen.getByRole('button', { name: /^import$/i })); await waitFor(() => { expect(addToast).toHaveBeenCalledWith( expect.stringContaining('2'), diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index 6f5e3fc3..79f27b10 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -1,6 +1,6 @@ import React from 'react' import ReactDOM from 'react-dom' -import { useState, useRef, useMemo, useCallback, useEffect } from 'react' +import { useState, useMemo, useEffect, useRef } from 'react' import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react' import PlaceAvatar from '../shared/PlaceAvatar' import { getCategoryIcon } from '../shared/categoryIcons' @@ -12,14 +12,7 @@ import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useAddonStore } from '../../store/addonStore' import type { Place, Category, Day, AssignmentsMap } from '../../types' - -interface PlacesImportSummary { - totalPlacemarks: number - createdCount: number - skippedCount: number - warnings: string[] - errors: string[] -} +import FileImportModal from './FileImportModal' interface PlacesSidebarProps { tripId: number @@ -47,35 +40,43 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ const { t } = useTranslation() const toast = useToast() const ctxMenu = useContextMenu() - const gpxInputRef = useRef(null) - const keyholeMarkupFileInputRef = useRef(null) const trip = useTripStore((s) => s.trip) const loadTrip = useTripStore((s) => s.loadTrip) const can = useCanDo() const canEditPlaces = can('place_edit', trip) const isNaverListImportEnabled = useAddonStore((s) => s.isEnabled('naver_list_import')) - const importFileLimitBytes = 10 * 1024 * 1024 - const handleGpxImport = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (!file) return - e.target.value = '' - try { - const result = await placesApi.importGpx(tripId, file) - await loadTrip(tripId) - toast.success(t('places.gpxImported', { count: result.count })) - if (result.places?.length > 0) { - const importedIds: number[] = result.places.map((p: { id: number }) => p.id) - pushUndo?.(t('undo.importGpx'), async () => { - for (const id of importedIds) { - try { await placesApi.delete(tripId, id) } catch {} - } - await loadTrip(tripId) - }) - } - } catch (err: any) { - toast.error(err?.response?.data?.error || t('places.gpxError')) - } + const [fileImportOpen, setFileImportOpen] = useState(false) + const [sidebarDropFile, setSidebarDropFile] = useState(null) + const [sidebarDragOver, setSidebarDragOver] = useState(false) + const sidebarDragCounter = useRef(0) + + const handleSidebarDragEnter = (e: React.DragEvent) => { + if (!canEditPlaces) return + e.preventDefault() + sidebarDragCounter.current++ + setSidebarDragOver(true) + } + + const handleSidebarDragOver = (e: React.DragEvent) => { + if (!canEditPlaces) return + e.preventDefault() + } + + const handleSidebarDragLeave = () => { + sidebarDragCounter.current-- + if (sidebarDragCounter.current === 0) setSidebarDragOver(false) + } + + const handleSidebarDrop = (e: React.DragEvent) => { + e.preventDefault() + sidebarDragCounter.current = 0 + setSidebarDragOver(false) + if (!canEditPlaces) return + const f = e.dataTransfer.files[0] + if (!f) return + setSidebarDropFile(f) + setFileImportOpen(true) } const [listImportOpen, setListImportOpen] = useState(false) @@ -84,68 +85,6 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google') const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google'] const hasMultipleListImportProviders = availableListImportProviders.length > 1 - const [keyholeMarkupFileOpen, setKeyholeMarkupFileOpen] = useState(false) - const [keyholeMarkupFileLoading, setKeyholeMarkupFileLoading] = useState(false) - const [keyholeMarkupFile, setKeyholeMarkupFileFile] = useState(null) - const [keyholeMarkupFileSummary, setKeyholeMarkupFileSummary] = useState(null) - const [keyholeMarkupFileError, setKeyholeMarkupFileError] = useState('') - - const resetKeyholeMarkupFileDialog = () => { - setKeyholeMarkupFileFile(null) - setKeyholeMarkupFileSummary(null) - setKeyholeMarkupFileError('') - setKeyholeMarkupFileLoading(false) - } - - const handleKeyholeMarkupFileImport = async () => { - if (!keyholeMarkupFile) return - - const ext = keyholeMarkupFile.name.toLowerCase().split('.').pop() - if (ext !== 'kml' && ext !== 'kmz') { - setKeyholeMarkupFileError(t('places.kmlKmzInvalidType')) - return - } - if (keyholeMarkupFile.size > importFileLimitBytes) { - setKeyholeMarkupFileError(t('places.kmlKmzTooLarge', { maxMb: 10 })) - return - } - - setKeyholeMarkupFileLoading(true) - setKeyholeMarkupFileError('') - setKeyholeMarkupFileSummary(null) - - try { - const result = await placesApi.importMapFile(tripId, keyholeMarkupFile) - - await loadTrip(tripId) - setKeyholeMarkupFileSummary(result.summary || null) - toast.success(t('places.kmlKmzImported', { count: result.count })) - - if (result.summary?.errors?.length > 0) { - setKeyholeMarkupFileError(result.summary.errors.join('\n')) - } - - if (result.places?.length > 0) { - const importedIds: number[] = result.places.map((p: { id: number }) => p.id) - pushUndo?.(t('undo.importKeyholeMarkup'), async () => { - for (const id of importedIds) { - try { await placesApi.delete(tripId, id) } catch {} - } - await loadTrip(tripId) - }) - } - } catch (err: any) { - const responseSummary = err?.response?.data?.summary as PlacesImportSummary | undefined - if (responseSummary) { - setKeyholeMarkupFileSummary(responseSummary) - } - const message = err?.response?.data?.error || t('places.kmlKmzImportError') - setKeyholeMarkupFileError(message) - toast.error(message) - } finally { - setKeyholeMarkupFileLoading(false) - } - } useEffect(() => { if (!isNaverListImportEnabled && listImportProvider === 'naver') { @@ -162,7 +101,11 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ ? await placesApi.importGoogleList(tripId, listImportUrl.trim()) : await placesApi.importNaverList(tripId, listImportUrl.trim()) await loadTrip(tripId) - toast.success(t(provider === 'google' ? 'places.googleListImported' : 'places.naverListImported', { count: result.count, list: result.listName })) + if (result.count === 0 && result.skipped > 0) { + toast.warning(t('places.importAllSkipped')) + } else { + toast.success(t(provider === 'google' ? 'places.googleListImported' : 'places.naverListImported', { count: result.count, list: result.listName })) + } setListImportOpen(false) setListImportUrl('') if (result.places?.length > 0) { @@ -214,7 +157,26 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId) return ( -
+
+ {sidebarDragOver && ( +
+ + {t('places.sidebarDrop')} +
+ )} {/* Kopfbereich */}
{canEditPlaces && } {canEditPlaces && <> -
-
, document.body )} - {keyholeMarkupFileOpen && ReactDOM.createPortal( -
{ setKeyholeMarkupFileOpen(false); resetKeyholeMarkupFileDialog() }} - style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} - > -
e.stopPropagation()} - style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 520, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }} - > -
- {t('places.importKeyholeMarkup')} -
-
- {t('places.kmlKmzHint')} -
- - { - const file = e.target.files?.[0] || null - setKeyholeMarkupFileFile(file) - setKeyholeMarkupFileSummary(null) - setKeyholeMarkupFileError('') - }} - /> - - - - {keyholeMarkupFileSummary && ( -
-
- {t('places.kmlKmzSummaryValues', { - total: keyholeMarkupFileSummary.totalPlacemarks, - created: keyholeMarkupFileSummary.createdCount, - skipped: keyholeMarkupFileSummary.skippedCount, - })} -
- {keyholeMarkupFileSummary.warnings?.length > 0 && ( -
- {keyholeMarkupFileSummary.warnings.join('\n')} -
- )} -
- )} - - {keyholeMarkupFileError && ( -
- {keyholeMarkupFileError} -
- )} - -
- {t('places.kmlKmzSizeHint', { maxMb: 10 })} -
- -
- - -
-
-
, - document.body - )} + { setFileImportOpen(false); setSidebarDropFile(null) }} + tripId={tripId} + pushUndo={pushUndo} + initialFile={sidebarDropFile} + />
) diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index b766e2b5..92c23eec 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -892,21 +892,19 @@ const ar: Record = { // Places Sidebar 'places.addPlace': 'إضافة مكان/نشاط', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'استيراد ملف', + 'places.sidebarDrop': 'أفلت للاستيراد', + 'places.importFileHint': 'استورد ملفات .gpx أو .kml أو .kmz من أدوات مثل Google My Maps وGoogle Earth أو جهاز تتبع GPS.', + 'places.importFileDropHere': 'انقر لاختيار ملف أو اسحبه وأفلته هنا', + 'places.importFileDropActive': 'أفلت الملف للاختيار', + 'places.importFileUnsupported': 'نوع الملف غير مدعوم. استخدم .gpx أو .kml أو .kmz.', + 'places.importFileTooLarge': 'الملف كبير جدًا. الحد الأقصى لحجم الرفع هو {maxMb} MB.', + 'places.importFileError': 'فشل الاستيراد', + 'places.importAllSkipped': 'جميع الأماكن موجودة بالفعل في الرحلة.', 'places.gpxImported': 'تم استيراد {count} مكان من GPX', 'places.kmlKmzImported': 'تم استيراد {count} مكان من KMZ/KML', 'places.urlResolved': 'تم استيراد المكان من الرابط', - 'places.gpxError': 'فشل استيراد GPX', 'places.importList': 'استيراد قائمة', - 'places.kmlKmzImportError': 'فشل استيراد KMZ/KML', - 'places.kmlKmzInvalidType': 'يرجى اختيار ملف .kml أو .kmz.', - 'places.kmlKmzTooLarge': 'الملف كبير جدًا. الحد الأقصى لحجم الرفع هو {maxMb} MB.', - 'places.kmlKmzHint': 'استورد ملفات الخرائط من أدوات مثل Google My Maps وGoogle Earth.', - 'places.kmlKmzSizeHint': 'الحد الأقصى لحجم الملف: {maxMb} MB', - 'places.kmlKmzSelectFile': 'اختيار ملف', - 'places.kmlKmzSelectedFile': 'الملف المحدد: {name}', - 'places.kmlKmzSummaryTitle': 'ملخص الاستيراد', 'places.kmlKmzSummaryValues': 'علامات المواضع: {total} • تم الاستيراد: {created} • تم التجاوز: {skipped}', 'places.importGoogleList': 'قائمة Google', 'places.importNaverList': 'قائمة Naver', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 0dbb37e9..346bafc9 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -862,21 +862,19 @@ const br: Record = { // Places Sidebar 'places.addPlace': 'Adicionar lugar/atividade', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Importar arquivo', + 'places.sidebarDrop': 'Solte para importar', + 'places.importFileHint': 'Importe arquivos .gpx, .kml ou .kmz de ferramentas como Google My Maps, Google Earth ou um rastreador GPS.', + 'places.importFileDropHere': 'Clique para selecionar um arquivo ou arraste e solte aqui', + 'places.importFileDropActive': 'Solte o arquivo para selecionar', + 'places.importFileUnsupported': 'Tipo de arquivo não suportado. Use .gpx, .kml ou .kmz.', + 'places.importFileTooLarge': 'O arquivo é muito grande. O tamanho máximo de upload é {maxMb} MB.', + 'places.importFileError': 'Importação falhou', + 'places.importAllSkipped': 'Todos os lugares já estavam na viagem.', 'places.gpxImported': '{count} lugares importados do GPX', 'places.kmlKmzImported': '{count} lugares importados de KMZ/KML', 'places.urlResolved': 'Lugar importado da URL', - 'places.gpxError': 'Falha ao importar GPX', 'places.importList': 'Importar lista', - 'places.kmlKmzImportError': 'Falha na importação de KMZ/KML', - 'places.kmlKmzInvalidType': 'Selecione um arquivo .kml ou .kmz.', - 'places.kmlKmzTooLarge': 'O arquivo é muito grande. O tamanho máximo de upload é {maxMb} MB.', - 'places.kmlKmzHint': 'Importe arquivos de mapa de ferramentas como Google My Maps e Google Earth.', - 'places.kmlKmzSizeHint': 'Tamanho máximo do arquivo: {maxMb} MB', - 'places.kmlKmzSelectFile': 'Selecionar arquivo', - 'places.kmlKmzSelectedFile': 'Arquivo selecionado: {name}', - 'places.kmlKmzSummaryTitle': 'Resumo da importação', 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importados: {created} • Ignorados: {skipped}', 'places.importGoogleList': 'Lista Google', 'places.importNaverList': 'Lista Naver', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index e6caf7bd..283fb7e8 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -890,21 +890,19 @@ const cs: Record = { // Boční panel míst (Places Sidebar) 'places.addPlace': 'Přidat místo/aktivitu', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Importovat soubor', + 'places.sidebarDrop': 'Pusťte pro import', + 'places.importFileHint': 'Importujte soubory .gpx, .kml nebo .kmz z nástrojů jako Google My Maps, Google Earth nebo GPS tracker.', + 'places.importFileDropHere': 'Klikněte pro výběr souboru nebo jej přetáhněte sem', + 'places.importFileDropActive': 'Přetáhněte soubor pro výběr', + 'places.importFileUnsupported': 'Nepodporovaný typ souboru. Použijte .gpx, .kml nebo .kmz.', + 'places.importFileTooLarge': 'Soubor je příliš velký. Maximální velikost nahrání je {maxMb} MB.', + 'places.importFileError': 'Import se nezdařil', + 'places.importAllSkipped': 'Všechna místa již byla v cestě.', 'places.gpxImported': '{count} míst importováno z GPX', 'places.kmlKmzImported': 'Importováno {count} míst z KMZ/KML', 'places.urlResolved': 'Místo importováno z URL', - 'places.gpxError': 'Import GPX se nezdařil', 'places.importList': 'Import seznamu', - 'places.kmlKmzImportError': 'Import KMZ/KML selhal', - 'places.kmlKmzInvalidType': 'Vyberte soubor .kml nebo .kmz.', - 'places.kmlKmzTooLarge': 'Soubor je příliš velký. Maximální velikost nahrání je {maxMb} MB.', - 'places.kmlKmzHint': 'Importujte mapové soubory z nástrojů jako Google My Maps a Google Earth.', - 'places.kmlKmzSizeHint': 'Maximální velikost souboru: {maxMb} MB', - 'places.kmlKmzSelectFile': 'Vybrat soubor', - 'places.kmlKmzSelectedFile': 'Vybraný soubor: {name}', - 'places.kmlKmzSummaryTitle': 'Souhrn importu', 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importováno: {created} • Přeskočeno: {skipped}', 'places.importGoogleList': 'Google Seznam', 'places.importNaverList': 'Naver Seznam', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index aada158a..2c9e54ee 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -893,21 +893,19 @@ const de: Record = { // Places Sidebar 'places.addPlace': 'Ort/Aktivität hinzufügen', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Datei importieren', + 'places.sidebarDrop': 'Ablegen zum Importieren', + 'places.importFileHint': '.gpx-, .kml- oder .kmz-Dateien aus Tools wie Google My Maps, Google Earth oder einem GPS-Tracker importieren.', + 'places.importFileDropHere': 'Datei auswählen oder hierher ziehen und ablegen', + 'places.importFileDropActive': 'Datei ablegen zum Auswählen', + 'places.importFileUnsupported': 'Nicht unterstützter Dateityp. Verwende .gpx, .kml oder .kmz.', + 'places.importFileTooLarge': 'Datei ist zu groß. Maximale Upload-Größe ist {maxMb} MB.', + 'places.importFileError': 'Import fehlgeschlagen', + 'places.importAllSkipped': 'Alle Orte waren bereits in der Reise.', 'places.gpxImported': '{count} Orte aus GPX importiert', 'places.kmlKmzImported': '{count} Orte aus KMZ/KML importiert', 'places.urlResolved': 'Ort aus URL importiert', - 'places.gpxError': 'GPX-Import fehlgeschlagen', 'places.importList': 'Listenimport', - 'places.kmlKmzImportError': 'KMZ/KML-Import fehlgeschlagen', - 'places.kmlKmzInvalidType': 'Bitte eine .kml- oder .kmz-Datei auswählen.', - 'places.kmlKmzTooLarge': 'Datei ist zu groß. Maximale Upload-Größe ist {maxMb} MB.', - 'places.kmlKmzHint': 'Importiere Kartendateien aus Tools wie Google My Maps und Google Earth.', - 'places.kmlKmzSizeHint': 'Max. Dateigröße: {maxMb} MB', - 'places.kmlKmzSelectFile': 'Datei auswählen', - 'places.kmlKmzSelectedFile': 'Ausgewählte Datei: {name}', - 'places.kmlKmzSummaryTitle': 'Importzusammenfassung', 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importiert: {created} • Übersprungen: {skipped}', 'places.importGoogleList': 'Google Liste', 'places.importNaverList': 'Naver Liste', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index d9f3ce5a..056e3773 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -915,21 +915,19 @@ const en: Record = { // Places Sidebar 'places.addPlace': 'Add Place/Activity', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Import file', + 'places.sidebarDrop': 'Drop to import', + 'places.importFileHint': 'Import .gpx, .kml or .kmz files from tools like Google My Maps, Google Earth, or a GPS tracker.', + 'places.importFileDropHere': 'Click to select a file or drag and drop here', + 'places.importFileDropActive': 'Drop file to select', + 'places.importFileUnsupported': 'Unsupported file type. Use .gpx, .kml or .kmz.', + 'places.importFileTooLarge': 'File is too large. Maximum upload size is {maxMb} MB.', + 'places.importFileError': 'Import failed', + 'places.importAllSkipped': 'All places were already in the trip.', 'places.gpxImported': '{count} places imported from GPX', 'places.kmlKmzImported': '{count} places imported from KMZ/KML', 'places.urlResolved': 'Place imported from URL', - 'places.gpxError': 'GPX import failed', 'places.importList': 'List Import', - 'places.kmlKmzImportError': 'KMZ/KML import failed', - 'places.kmlKmzInvalidType': 'Please select a .kml or .kmz file.', - 'places.kmlKmzTooLarge': 'File is too large. Maximum upload size is {maxMb} MB.', - 'places.kmlKmzHint': 'Import map files from tools like Google My Maps and Google Earth.', - 'places.kmlKmzSizeHint': 'Max file size: {maxMb} MB', - 'places.kmlKmzSelectFile': 'Select File', - 'places.kmlKmzSelectedFile': 'Selected file: {name}', - 'places.kmlKmzSummaryTitle': 'Import summary', 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Imported: {created} • Skipped: {skipped}', 'places.importGoogleList': 'Google List', 'places.importNaverList': 'Naver List', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 971093fd..1a64e5e2 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -865,21 +865,19 @@ const es: Record = { // Places Sidebar 'places.addPlace': 'Añadir lugar/actividad', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Importar archivo', + 'places.sidebarDrop': 'Soltar para importar', + 'places.importFileHint': 'Importa archivos .gpx, .kml o .kmz de herramientas como Google My Maps, Google Earth o un rastreador GPS.', + 'places.importFileDropHere': 'Haz clic para seleccionar un archivo o arrástralo aquí', + 'places.importFileDropActive': 'Suelta el archivo para seleccionarlo', + 'places.importFileUnsupported': 'Tipo de archivo no compatible. Usa .gpx, .kml o .kmz.', + 'places.importFileTooLarge': 'El archivo es demasiado grande. El tamaño máximo de carga es {maxMb} MB.', + 'places.importFileError': 'Importación fallida', + 'places.importAllSkipped': 'Todos los lugares ya estaban en el viaje.', 'places.gpxImported': '{count} lugares importados desde GPX', 'places.kmlKmzImported': '{count} lugares importados desde KMZ/KML', 'places.urlResolved': 'Lugar importado desde URL', - 'places.gpxError': 'Error al importar GPX', 'places.importList': 'Importar lista', - 'places.kmlKmzImportError': 'La importación KMZ/KML falló', - 'places.kmlKmzInvalidType': 'Selecciona un archivo .kml o .kmz.', - 'places.kmlKmzTooLarge': 'El archivo es demasiado grande. El tamaño máximo de carga es {maxMb} MB.', - 'places.kmlKmzHint': 'Importa archivos de mapa desde herramientas como Google My Maps y Google Earth.', - 'places.kmlKmzSizeHint': 'Tamaño máximo de archivo: {maxMb} MB', - 'places.kmlKmzSelectFile': 'Seleccionar archivo', - 'places.kmlKmzSelectedFile': 'Archivo seleccionado: {name}', - 'places.kmlKmzSummaryTitle': 'Resumen de importación', 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importados: {created} • Omitidos: {skipped}', 'places.importGoogleList': 'Lista Google', 'places.importNaverList': 'Lista Naver', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 5e5d2aa6..11ce8012 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -889,21 +889,19 @@ const fr: Record = { // Places Sidebar 'places.addPlace': 'Ajouter un lieu/activité', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Importer un fichier', + 'places.sidebarDrop': 'Déposer pour importer', + 'places.importFileHint': 'Importez des fichiers .gpx, .kml ou .kmz depuis des outils comme Google My Maps, Google Earth ou un traceur GPS.', + 'places.importFileDropHere': 'Cliquez pour sélectionner un fichier ou glissez-déposez ici', + 'places.importFileDropActive': 'Déposez le fichier pour le sélectionner', + 'places.importFileUnsupported': 'Type de fichier non pris en charge. Utilisez .gpx, .kml ou .kmz.', + 'places.importFileTooLarge': 'Le fichier est trop volumineux. La taille maximale est de {maxMb} MB.', + 'places.importFileError': 'Importation échouée', + 'places.importAllSkipped': 'Tous les lieux étaient déjà dans le voyage.', 'places.gpxImported': '{count} lieux importés depuis GPX', 'places.kmlKmzImported': '{count} lieux importés depuis KMZ/KML', 'places.urlResolved': 'Lieu importé depuis l\'URL', - 'places.gpxError': 'L\'import GPX a échoué', 'places.importList': 'Import de liste', - 'places.kmlKmzImportError': 'L\'import KMZ/KML a échoué', - 'places.kmlKmzInvalidType': 'Veuillez sélectionner un fichier .kml ou .kmz.', - 'places.kmlKmzTooLarge': 'Le fichier est trop volumineux. La taille maximale est de {maxMb} MB.', - 'places.kmlKmzHint': 'Importez des fichiers de carte depuis des outils comme Google My Maps et Google Earth.', - 'places.kmlKmzSizeHint': 'Taille maximale du fichier : {maxMb} MB', - 'places.kmlKmzSelectFile': 'Sélectionner un fichier', - 'places.kmlKmzSelectedFile': 'Fichier sélectionné : {name}', - 'places.kmlKmzSummaryTitle': 'Résumé d\'import', 'places.kmlKmzSummaryValues': 'Placemarks : {total} • Importés : {created} • Ignorés : {skipped}', 'places.importGoogleList': 'Liste Google', 'places.importNaverList': 'Liste Naver', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 539509a7..2d64ae63 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -890,21 +890,19 @@ const hu: Record = { // Helyek oldalsáv 'places.addPlace': 'Hely/Tevékenység hozzáadása', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Fájl importálása', + 'places.sidebarDrop': 'Ejtse el az importáláshoz', + 'places.importFileHint': '.gpx, .kml vagy .kmz fájlok importálása olyan eszközökből, mint a Google My Maps, Google Earth vagy egy GPS tracker.', + 'places.importFileDropHere': 'Kattintson egy fájl kiválasztásához, vagy húzza ide', + 'places.importFileDropActive': 'Ejtse ide a fájlt a kiválasztáshoz', + 'places.importFileUnsupported': 'Nem támogatott fájltípus. Használjon .gpx, .kml vagy .kmz fájlt.', + 'places.importFileTooLarge': 'A fájl túl nagy. A maximális feltöltési méret {maxMb} MB.', + 'places.importFileError': 'Importálás sikertelen', + 'places.importAllSkipped': 'Minden hely már szerepel az utazásban.', 'places.gpxImported': '{count} hely importálva GPX-ből', 'places.kmlKmzImported': '{count} hely importálva KMZ/KML-ből', 'places.urlResolved': 'Hely importálva URL-ből', - 'places.gpxError': 'GPX importálás sikertelen', 'places.importList': 'Lista importálás', - 'places.kmlKmzImportError': 'A KMZ/KML importálás sikertelen', - 'places.kmlKmzInvalidType': 'Válassz egy .kml vagy .kmz fájlt.', - 'places.kmlKmzTooLarge': 'A fájl túl nagy. A maximális feltöltési méret {maxMb} MB.', - 'places.kmlKmzHint': 'Térképfájlok importálása olyan eszközökből, mint a Google My Maps és a Google Earth.', - 'places.kmlKmzSizeHint': 'Maximális fájlméret: {maxMb} MB', - 'places.kmlKmzSelectFile': 'Fájl kiválasztása', - 'places.kmlKmzSelectedFile': 'Kiválasztott fájl: {name}', - 'places.kmlKmzSummaryTitle': 'Import összegzés', 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importálva: {created} • Kihagyva: {skipped}', 'places.importGoogleList': 'Google Lista', 'places.importNaverList': 'Naver Lista', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 88b5576c..4c2716c4 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -890,21 +890,19 @@ const it: Record = { // Places Sidebar 'places.addPlace': 'Aggiungi Luogo/Attività', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Importa file', + 'places.sidebarDrop': 'Rilascia per importare', + 'places.importFileHint': 'Importa file .gpx, .kml o .kmz da strumenti come Google My Maps, Google Earth o un tracker GPS.', + 'places.importFileDropHere': 'Clicca per selezionare un file o trascina e rilascia qui', + 'places.importFileDropActive': 'Rilascia il file per selezionarlo', + 'places.importFileUnsupported': 'Tipo di file non supportato. Usa .gpx, .kml o .kmz.', + 'places.importFileTooLarge': 'Il file è troppo grande. La dimensione massima di caricamento è {maxMb} MB.', + 'places.importFileError': 'Importazione non riuscita', + 'places.importAllSkipped': 'Tutti i luoghi erano già nel viaggio.', 'places.gpxImported': '{count} luoghi importati da GPX', 'places.kmlKmzImported': '{count} luoghi importati da KMZ/KML', 'places.urlResolved': 'Luogo importato dall\'URL', - 'places.gpxError': 'Importazione GPX non riuscita', 'places.importList': 'Importa lista', - 'places.kmlKmzImportError': 'Importazione KMZ/KML non riuscita', - 'places.kmlKmzInvalidType': 'Seleziona un file .kml o .kmz.', - 'places.kmlKmzTooLarge': 'Il file è troppo grande. La dimensione massima di caricamento è {maxMb} MB.', - 'places.kmlKmzHint': 'Importa file mappa da strumenti come Google My Maps e Google Earth.', - 'places.kmlKmzSizeHint': 'Dimensione massima file: {maxMb} MB', - 'places.kmlKmzSelectFile': 'Seleziona file', - 'places.kmlKmzSelectedFile': 'File selezionato: {name}', - 'places.kmlKmzSummaryTitle': 'Riepilogo importazione', 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importati: {created} • Saltati: {skipped}', 'places.importGoogleList': 'Lista Google', 'places.importNaverList': 'Lista Naver', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 1e372ab1..72c9a6aa 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -889,21 +889,19 @@ const nl: Record = { // Places Sidebar 'places.addPlace': 'Plaats/activiteit toevoegen', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Bestand importeren', + 'places.sidebarDrop': 'Loslaten om te importeren', + 'places.importFileHint': 'Importeer .gpx-, .kml- of .kmz-bestanden uit tools zoals Google My Maps, Google Earth of een GPS-tracker.', + 'places.importFileDropHere': 'Klik om een bestand te selecteren of sleep het hier naartoe', + 'places.importFileDropActive': 'Laat het bestand los om het te selecteren', + 'places.importFileUnsupported': 'Niet-ondersteund bestandstype. Gebruik .gpx, .kml of .kmz.', + 'places.importFileTooLarge': 'Bestand is te groot. Maximale uploadgrootte is {maxMb} MB.', + 'places.importFileError': 'Importeren mislukt', + 'places.importAllSkipped': 'Alle plaatsen waren al in de reis.', 'places.gpxImported': '{count} plaatsen geïmporteerd uit GPX', 'places.kmlKmzImported': '{count} plaatsen geïmporteerd uit KMZ/KML', 'places.urlResolved': 'Plaats geïmporteerd van URL', - 'places.gpxError': 'GPX-import mislukt', 'places.importList': 'Lijst importeren', - 'places.kmlKmzImportError': 'KMZ/KML-import mislukt', - 'places.kmlKmzInvalidType': 'Selecteer een .kml- of .kmz-bestand.', - 'places.kmlKmzTooLarge': 'Bestand is te groot. Maximale uploadgrootte is {maxMb} MB.', - 'places.kmlKmzHint': 'Importeer kaartbestanden uit tools zoals Google My Maps en Google Earth.', - 'places.kmlKmzSizeHint': 'Max. bestandsgrootte: {maxMb} MB', - 'places.kmlKmzSelectFile': 'Bestand selecteren', - 'places.kmlKmzSelectedFile': 'Geselecteerd bestand: {name}', - 'places.kmlKmzSummaryTitle': 'Importoverzicht', 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Geïmporteerd: {created} • Overgeslagen: {skipped}', 'places.importGoogleList': 'Google Lijst', 'places.importNaverList': 'Naver Lijst', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 4b81e680..41d1d899 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -856,20 +856,18 @@ const pl: Record = { // Places Sidebar 'places.addPlace': 'Dodaj miejsce/atrakcję', - 'places.importGpx': 'Importuj GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Importuj plik', + 'places.sidebarDrop': 'Upuść, aby zaimportować', + 'places.importFileHint': 'Importuj pliki .gpx, .kml lub .kmz z narzędzi takich jak Google My Maps, Google Earth lub tracker GPS.', + 'places.importFileDropHere': 'Kliknij, aby wybrać plik lub przeciągnij i upuść tutaj', + 'places.importFileDropActive': 'Upuść plik, aby go wybrać', + 'places.importFileUnsupported': 'Nieobsługiwany typ pliku. Użyj .gpx, .kml lub .kmz.', + 'places.importFileTooLarge': 'Plik jest za duży. Maksymalny rozmiar przesyłania to {maxMb} MB.', + 'places.importFileError': 'Import nie powiódł się', + 'places.importAllSkipped': 'Wszystkie miejsca były już w podróży.', 'places.gpxImported': '{count} miejsc zaimportowanych z GPX', 'places.kmlKmzImported': 'Zaimportowano {count} miejsc z KMZ/KML', 'places.urlResolved': 'Miejsce zaimportowane z URL', - 'places.gpxError': 'Nie udało się zaimportować pliku GPX', - 'places.kmlKmzImportError': 'Import KMZ/KML nie powiódł się', - 'places.kmlKmzInvalidType': 'Wybierz plik .kml lub .kmz.', - 'places.kmlKmzTooLarge': 'Plik jest za duży. Maksymalny rozmiar przesyłania to {maxMb} MB.', - 'places.kmlKmzHint': 'Importuj pliki map z narzędzi takich jak Google My Maps i Google Earth.', - 'places.kmlKmzSizeHint': 'Maksymalny rozmiar pliku: {maxMb} MB', - 'places.kmlKmzSelectFile': 'Wybierz plik', - 'places.kmlKmzSelectedFile': 'Wybrany plik: {name}', - 'places.kmlKmzSummaryTitle': 'Podsumowanie importu', 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Zaimportowano: {created} • Pominięto: {skipped}', 'places.importGoogleList': 'Lista Google', 'places.assignToDay': 'Do którego dnia dodać?', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 2db4b31e..18373f55 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -889,21 +889,19 @@ const ru: Record = { // Places Sidebar 'places.addPlace': 'Добавить место/активность', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': 'Импортировать файл', + 'places.sidebarDrop': 'Отпустите для импорта', + 'places.importFileHint': 'Импортируйте файлы .gpx, .kml или .kmz из инструментов, таких как Google My Maps, Google Earth или GPS-трекер.', + 'places.importFileDropHere': 'Нажмите для выбора файла или перетащите его сюда', + 'places.importFileDropActive': 'Отпустите файл для выбора', + 'places.importFileUnsupported': 'Неподдерживаемый тип файла. Используйте .gpx, .kml или .kmz.', + 'places.importFileTooLarge': 'Файл слишком большой. Максимальный размер загрузки — {maxMb} MB.', + 'places.importFileError': 'Ошибка импорта', + 'places.importAllSkipped': 'Все места уже были в поездке.', 'places.gpxImported': '{count} мест импортировано из GPX', 'places.kmlKmzImported': '{count} мест импортировано из KMZ/KML', 'places.urlResolved': 'Место импортировано из URL', - 'places.gpxError': 'Ошибка импорта GPX', 'places.importList': 'Импорт списка', - 'places.kmlKmzImportError': 'Ошибка импорта KMZ/KML', - 'places.kmlKmzInvalidType': 'Выберите файл .kml или .kmz.', - 'places.kmlKmzTooLarge': 'Файл слишком большой. Максимальный размер загрузки — {maxMb} MB.', - 'places.kmlKmzHint': 'Импортируйте файлы карт из инструментов, таких как Google My Maps и Google Earth.', - 'places.kmlKmzSizeHint': 'Максимальный размер файла: {maxMb} MB', - 'places.kmlKmzSelectFile': 'Выбрать файл', - 'places.kmlKmzSelectedFile': 'Выбранный файл: {name}', - 'places.kmlKmzSummaryTitle': 'Сводка импорта', 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Импортировано: {created} • Пропущено: {skipped}', 'places.importGoogleList': 'Список Google', 'places.importNaverList': 'Список Naver', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 2b6d782f..3312da83 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -889,21 +889,19 @@ const zh: Record = { // Places Sidebar 'places.addPlace': '添加地点/活动', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': '导入文件', + 'places.sidebarDrop': '拖放以导入', + 'places.importFileHint': '从 Google My Maps、Google Earth 或 GPS 追踪器等工具导入 .gpx、.kml 或 .kmz 文件。', + 'places.importFileDropHere': '点击选择文件或拖放到此处', + 'places.importFileDropActive': '释放文件以选择', + 'places.importFileUnsupported': '不支持的文件类型,请使用 .gpx、.kml 或 .kmz。', + 'places.importFileTooLarge': '文件过大。最大上传大小为 {maxMb} MB。', + 'places.importFileError': '导入失败', + 'places.importAllSkipped': '所有地点已在行程中。', 'places.gpxImported': '已从 GPX 导入 {count} 个地点', 'places.kmlKmzImported': '已从 KMZ/KML 导入 {count} 个地点', 'places.urlResolved': '已从 URL 导入地点', - 'places.gpxError': 'GPX 导入失败', 'places.importList': '列表导入', - 'places.kmlKmzImportError': 'KMZ/KML 导入失败', - 'places.kmlKmzInvalidType': '请选择 .kml 或 .kmz 文件。', - 'places.kmlKmzTooLarge': '文件过大。最大上传大小为 {maxMb} MB。', - 'places.kmlKmzHint': '可从 Google My Maps、Google Earth 等工具导入地图文件。', - 'places.kmlKmzSizeHint': '最大文件大小:{maxMb} MB', - 'places.kmlKmzSelectFile': '选择文件', - 'places.kmlKmzSelectedFile': '已选择文件:{name}', - 'places.kmlKmzSummaryTitle': '导入摘要', 'places.kmlKmzSummaryValues': 'Placemarks:{total} • 已导入:{created} • 已跳过:{skipped}', 'places.importGoogleList': 'Google 列表', 'places.importNaverList': 'Naver 列表', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index e6b28a85..4e6acb6b 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -914,21 +914,19 @@ const zhTw: Record = { // Places Sidebar 'places.addPlace': '新增地點/活動', - 'places.importGpx': 'GPX', - 'places.importKeyholeMarkup': 'KMZ / KML', + 'places.importFile': '匯入檔案', + 'places.sidebarDrop': '拖放以匯入', + 'places.importFileHint': '從 Google My Maps、Google Earth 或 GPS 追蹤器等工具匯入 .gpx、.kml 或 .kmz 檔案。', + 'places.importFileDropHere': '點選以選取檔案或拖放至此處', + 'places.importFileDropActive': '放開檔案以選取', + 'places.importFileUnsupported': '不支援的檔案類型,請使用 .gpx、.kml 或 .kmz。', + 'places.importFileTooLarge': '檔案過大。最大上傳大小為 {maxMb} MB。', + 'places.importFileError': '匯入失敗', + 'places.importAllSkipped': '所有地點已在行程中。', 'places.gpxImported': '已從 GPX 匯入 {count} 個地點', 'places.kmlKmzImported': '已從 KMZ/KML 匯入 {count} 個地點', 'places.urlResolved': '已從 URL 匯入地點', - 'places.gpxError': 'GPX 匯入失敗', 'places.importList': '列表匯入', - 'places.kmlKmzImportError': 'KMZ/KML 匯入失敗', - 'places.kmlKmzInvalidType': '請選擇 .kml 或 .kmz 檔案。', - 'places.kmlKmzTooLarge': '檔案過大。最大上傳大小為 {maxMb} MB。', - 'places.kmlKmzHint': '可從 Google My Maps、Google Earth 等工具匯入地圖檔案。', - 'places.kmlKmzSizeHint': '最大檔案大小:{maxMb} MB', - 'places.kmlKmzSelectFile': '選擇檔案', - 'places.kmlKmzSelectedFile': '已選擇檔案:{name}', - 'places.kmlKmzSummaryTitle': '匯入摘要', 'places.kmlKmzSummaryValues': 'Placemarks:{total} • 已匯入:{created} • 已略過:{skipped}', 'places.importGoogleList': 'Google 列表', 'places.importNaverList': 'Naver 列表', diff --git a/server/src/routes/places.ts b/server/src/routes/places.ts index 48d9f4e4..72becb7c 100644 --- a/server/src/routes/places.ts +++ b/server/src/routes/places.ts @@ -66,13 +66,13 @@ router.post('/import/gpx', authenticate, requireTripAccess, uploadMulter.single( const file = req.file as Express.Multer.File | undefined; if (!file) return res.status(400).json({ error: 'No file uploaded' }); - const created = importGpx(tripId, file.buffer); - if (!created) { + const result = importGpx(tripId, file.buffer); + if (!result) { return res.status(400).json({ error: 'No waypoints found in GPX file' }); } - res.status(201).json({ places: created, count: created.length }); - for (const place of created) { + res.status(201).json({ places: result.places, count: result.count, skipped: result.skipped }); + for (const place of result.places) { broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); } }); @@ -89,7 +89,7 @@ router.post('/import/map', authenticate, requireTripAccess, uploadMulter.single( try { const result = await importMapFile(tripId, file.buffer, file.originalname); - if (result.count === 0) { + if (result.summary?.totalPlacemarks === 0) { return res.status(400).json({ error: 'No valid Placemarks found in map file', summary: result.summary }); } @@ -120,7 +120,7 @@ router.post('/import/google-list', authenticate, requireTripAccess, async (req: return res.status(result.status).json({ error: result.error }); } - res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName }); + res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped }); for (const place of result.places) { broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); } @@ -150,7 +150,7 @@ router.post('/import/naver-list', authenticate, requireTripAccess, async (req: R return res.status(result.status).json({ error: result.error }); } - res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName }); + res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped }); for (const place of result.places) { broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); } diff --git a/server/src/services/kmlImport.ts b/server/src/services/kmlImport.ts index 9c7f2790..9fe38eb2 100644 --- a/server/src/services/kmlImport.ts +++ b/server/src/services/kmlImport.ts @@ -40,6 +40,13 @@ function asArray(value: T | T[] | null | undefined): T[] { function asTrimmedString(value: unknown): string | null { if (value == null) return null; + // Parsed objects (mixed-content XML parsed without stopNodes) must not + // produce "[object Object]" — extract #text if present, else return null. + if (typeof value === 'object') { + const candidate = (value as Record)['#text']; + if (typeof candidate === 'string') return candidate.trim() || null; + return null; + } const text = String(value).trim(); return text.length > 0 ? text : null; } @@ -73,7 +80,12 @@ export function sanitizeKmlDescription(value: unknown): string | null { const raw = asTrimmedString(value); if (!raw) return null; - const withLineBreaks = raw.replace(//gi, '\n'); + // Unwrap CDATA sections — present when fast-xml-parser returns raw node text + // via stopNodes. Must happen before tag-stripping so the CDATA markers are + // not mis-parsed by the <[^>]+> regex. + const withoutCdata = raw.replace(//g, '$1'); + + const withLineBreaks = withoutCdata.replace(//gi, '\n'); const stripped = withLineBreaks.replace(/<[^>]+>/g, ''); const decoded = decodeHtmlEntities(stripped) .replace(/\r\n/g, '\n') diff --git a/server/src/services/placeService.ts b/server/src/services/placeService.ts index 35e7e84c..f2305bb1 100644 --- a/server/src/services/placeService.ts +++ b/server/src/services/placeService.ts @@ -255,10 +255,77 @@ const kmlParser = new XMLParser({ attributeNamePrefix: '@_', removeNSPrefix: true, isArray: (name) => ['Placemark', 'Folder', 'Document'].includes(name), + // Treat as raw text so mixed-content HTML (e.g.
, ) + // is returned as a string instead of a parsed object. + stopNodes: ['*.description'], }); export const KMZ_DECOMPRESSED_SIZE_LIMIT = 50 * 1024 * 1024; // 50 MB +// --------------------------------------------------------------------------- +// Import deduplication helpers +// --------------------------------------------------------------------------- + +const COORD_DEDUP_TOLERANCE = 0.0001; // ≈ 11 m + +interface DedupSet { + names: Set; + coords: Array<{ lat: number; lng: number }>; +} + +/** Build a lookup of names/coords for places already in a trip. */ +function buildDedupSet(tripId: string): DedupSet { + const rows = db.prepare('SELECT name, lat, lng FROM places WHERE trip_id = ?').all(tripId) as Array<{ + name: string | null; + lat: number | null; + lng: number | null; + }>; + const names = new Set(); + const coords: Array<{ lat: number; lng: number }> = []; + for (const row of rows) { + if (row.name) { + names.add(row.name.trim().toLowerCase()); + } else if (row.lat != null && row.lng != null) { + coords.push({ lat: row.lat, lng: row.lng }); + } + } + return { names, coords }; +} + +/** + * Returns true if a candidate place is already represented in the dedup set. + * Named places match by case-insensitive name; unnamed places fall back to + * coordinate proximity. + */ +function isPlaceDuplicate( + candidate: { name: string | null | undefined; lat: number | null; lng: number | null }, + dedup: DedupSet, +): boolean { + const normalizedName = candidate.name?.trim().toLowerCase(); + if (normalizedName) return dedup.names.has(normalizedName); + if (candidate.lat != null && candidate.lng != null) { + return dedup.coords.some( + (c) => + Math.abs(c.lat - candidate.lat!) <= COORD_DEDUP_TOLERANCE && + Math.abs(c.lng - candidate.lng!) <= COORD_DEDUP_TOLERANCE, + ); + } + return false; +} + +/** Record a newly inserted place so subsequent candidates in the same batch are checked against it. */ +function trackInsertedInDedupSet( + place: { name: string | null | undefined; lat: number | null; lng: number | null }, + dedup: DedupSet, +): void { + const normalizedName = place.name?.trim().toLowerCase(); + if (normalizedName) { + dedup.names.add(normalizedName); + } else if (place.lat != null && place.lng != null) { + dedup.coords.push({ lat: place.lat, lng: place.lng }); + } +} + export function importGpx(tripId: string, fileBuffer: Buffer) { const parsed = gpxParser.parse(fileBuffer.toString('utf-8')); const gpx = parsed?.gpx; @@ -310,21 +377,28 @@ export function importGpx(tripId: string, fileBuffer: Buffer) { if (waypoints.length === 0) return null; + const dedup = buildDedupSet(tripId); const insertStmt = db.prepare(` INSERT INTO places (trip_id, name, description, lat, lng, transport_mode, route_geometry) VALUES (?, ?, ?, ?, ?, 'walking', ?) `); const created: any[] = []; + let skipped = 0; const insertAll = db.transaction(() => { for (const wp of waypoints) { + if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) { + skipped++; + continue; + } const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng, wp.routeGeometry || null); const place = getPlaceWithTags(Number(result.lastInsertRowid)); created.push(place); + trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup); } }); insertAll(); - return created; + return { places: created, count: created.length, skipped }; } export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImportResult { @@ -351,7 +425,9 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImport const categories = db.prepare('SELECT id, name FROM categories').all() as { id: number; name: string }[]; const categoryLookup = buildCategoryNameLookup(categories); + const dedup = buildDedupSet(tripId); const created: any[] = []; + let dupCount = 0; const insertStmt = db.prepare(` INSERT INTO places (trip_id, name, description, lat, lng, category_id, transport_mode) @@ -373,6 +449,14 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImport const fallbackName = `Placemark ${fallbackIndex}`; const name = parsedPlacemark.name || fallbackName; + + if (isPlaceDuplicate({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup)) { + summary.skippedCount += 1; + dupCount++; + fallbackIndex += 1; + continue; + } + const categoryId = resolveCategoryIdForFolder(parsedPlacemark.folderName, categoryLookup); const result = insertStmt.run( @@ -386,6 +470,7 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImport const place = getPlaceWithTags(Number(result.lastInsertRowid)); created.push(place); + trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup); summary.createdCount += 1; fallbackIndex += 1; } @@ -393,6 +478,10 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImport insertAll(); + if (dupCount > 0) { + summary.warnings.push(`${dupCount} place${dupCount > 1 ? 's' : ''} skipped (already in trip).`); + } + if (summary.totalPlacemarks === 0) { summary.errors.push('No Placemarks found in KML file.'); } @@ -514,30 +603,23 @@ export async function importGoogleList(tripId: string, url: string) { return { error: 'No places with coordinates found in list', status: 400 }; } - // Skip places that already exist in this trip (same name + coordinates within ~10m) - const existingPlaces = db.prepare( - 'SELECT name, lat, lng FROM places WHERE trip_id = ?' - ).all(tripId) as { name: string; lat: number; lng: number }[]; - - const isDuplicate = (p: { name: string; lat: number; lng: number }) => - existingPlaces.some(e => - e.name === p.name && Math.abs(e.lat - p.lat) < 0.0001 && Math.abs(e.lng - p.lng) < 0.0001 - ); - - const newPlaces = places.filter(p => !isDuplicate(p)); - const skipped = places.length - newPlaces.length; - - // Insert only new places into trip + const dedup = buildDedupSet(tripId); const insertStmt = db.prepare(` INSERT INTO places (trip_id, name, lat, lng, notes, transport_mode) VALUES (?, ?, ?, ?, ?, 'walking') `); const created: any[] = []; + let skipped = 0; const insertAll = db.transaction(() => { - for (const p of newPlaces) { + for (const p of places) { + if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) { + skipped++; + continue; + } const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes); const place = getPlaceWithTags(Number(result.lastInsertRowid)); created.push(place); + trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup); } }); insertAll(); @@ -643,21 +725,28 @@ export async function importNaverList( return { error: 'No places with coordinates found in list', status: 400 }; } + const dedup = buildDedupSet(tripId); const insertStmt = db.prepare(` INSERT INTO places (trip_id, name, lat, lng, address, notes, transport_mode) VALUES (?, ?, ?, ?, ?, ?, 'walking') `); const created: any[] = []; + let skipped = 0; const insertAll = db.transaction(() => { for (const p of places) { + if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) { + skipped++; + continue; + } const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.address, p.notes); const place = getPlaceWithTags(Number(result.lastInsertRowid)); created.push(place); + trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup); } }); insertAll(); - return { places: created, listName }; + return { places: created, listName, skipped }; } // --------------------------------------------------------------------------- diff --git a/server/tests/unit/services/kmlImportUtils.test.ts b/server/tests/unit/services/kmlImportUtils.test.ts index 330543d3..47cd40e2 100644 --- a/server/tests/unit/services/kmlImportUtils.test.ts +++ b/server/tests/unit/services/kmlImportUtils.test.ts @@ -16,6 +16,11 @@ describe('kmlImportUtils', () => { expect(output).toBe('Line 1\nLine 2 & more'); }); + it('unwraps CDATA sections before stripping tags', () => { + const input = 'for photos and skyline.]]>'; + expect(sanitizeKmlDescription(input)).toBe('Great spot\nfor photos and skyline.'); + }); + it('parses KML coordinate order lng,lat,alt', () => { const parsed = parseKmlPointCoordinates('13.4050,52.5200,15'); expect(parsed).toEqual({ lat: 52.52, lng: 13.405 }); @@ -65,6 +70,18 @@ describe('kmlImportUtils', () => { expect(sanitizeKmlDescription('😀')).toBe('😀'); }); + it('does not produce [object Object] when description is a parsed object with #text', () => { + // fast-xml-parser can return an object for mixed-content nodes when stopNodes + // is not configured; the fallback in asTrimmedString must extract #text. + const result = sanitizeKmlDescription({ '#text': 'Hello world' } as any); + expect(result).not.toBe('[object Object]'); + expect(result).toBe('Hello world'); + }); + + it('returns null when description object has no #text', () => { + expect(sanitizeKmlDescription({ i: 'bold' } as any)).toBeNull(); + }); + it('returns warning for non-UTF8 payload', () => { const buffer = Buffer.concat([ Buffer.from('Caf'), diff --git a/server/tests/unit/services/placeService.test.ts b/server/tests/unit/services/placeService.test.ts index 51b84c6f..4d6a8fc8 100644 --- a/server/tests/unit/services/placeService.test.ts +++ b/server/tests/unit/services/placeService.test.ts @@ -45,7 +45,12 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, createPlace, createCategory, createTag } from '../../helpers/factories'; -import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, deletePlace, importGpx, importGoogleList, searchPlaceImage } from '../../../src/services/placeService'; +import path from 'path'; +import fs from 'fs'; +import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, deletePlace, importGpx, importKmlPlaces, importGoogleList, searchPlaceImage } from '../../../src/services/placeService'; + +const GPX_FIXTURE = path.join(__dirname, '../../fixtures/test.gpx'); +const KML_FIXTURE = path.join(__dirname, '../../fixtures/test.kml'); beforeAll(() => { createTables(testDb); @@ -266,10 +271,10 @@ describe('importGpx', () => { Paris London `); - const places = importGpx(String(trip.id), gpx) as any[]; - expect(places).toHaveLength(2); - expect(places[0].name).toBe('Paris'); - expect(places[1].name).toBe('London'); + const result = importGpx(String(trip.id), gpx) as any; + expect(result.places).toHaveLength(2); + expect(result.places[0].name).toBe('Paris'); + expect(result.places[1].name).toBe('London'); }); it('PLACE-SVC-022 — falls back to route points when no elements exist', () => { @@ -281,10 +286,10 @@ describe('importGpx', () => { End `); - const places = importGpx(String(trip.id), gpx) as any[]; - expect(places).toHaveLength(2); - expect(places[0].name).toBe('Start'); - expect(places[1].name).toBe('End'); + const result = importGpx(String(trip.id), gpx) as any; + expect(result.places).toHaveLength(2); + expect(result.places[0].name).toBe('Start'); + expect(result.places[1].name).toBe('End'); }); it('PLACE-SVC-023 — imports track as a single place with routeGeometry', () => { @@ -299,10 +304,10 @@ describe('importGpx', () => { `); - const places = importGpx(String(trip.id), gpx) as any[]; - expect(places).toHaveLength(1); - expect(places[0].name).toBe('My Track'); - const geometry = JSON.parse(places[0].route_geometry); + const result = importGpx(String(trip.id), gpx) as any; + expect(result.places).toHaveLength(1); + expect(result.places[0].name).toBe('My Track'); + const geometry = JSON.parse(result.places[0].route_geometry); expect(Array.isArray(geometry)).toBe(true); expect(geometry).toHaveLength(2); }); @@ -320,10 +325,10 @@ describe('importGpx', () => { `); - const places = importGpx(String(trip.id), gpx) as any[]; + const result = importGpx(String(trip.id), gpx) as any; // 1 wpt + 1 trk - expect(places).toHaveLength(2); - const trackPlace = places.find((p: any) => p.name === 'Track') as any; + expect(result.places).toHaveLength(2); + const trackPlace = result.places.find((p: any) => p.name === 'Track') as any; expect(trackPlace).toBeDefined(); const geometry = JSON.parse(trackPlace.route_geometry); expect(geometry).toHaveLength(2); @@ -449,3 +454,74 @@ describe('searchPlaceImage', () => { expect(result.photos[0].photographer).toBe('Photographer'); }); }); + +// ── Import deduplication ────────────────────────────────────────────────────── + +describe('importGpx deduplication', () => { + it('PLACE-SVC-033 — skips waypoints already in trip by name', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const buf = fs.readFileSync(GPX_FIXTURE); + + // First import + const first = importGpx(String(trip.id), buf) as any; + expect(first.count).toBeGreaterThan(0); + + // Second import — all names already present, nothing new created + const second = importGpx(String(trip.id), buf) as any; + expect(second.count).toBe(0); + expect(second.skipped).toBe(first.count); + + // Total places in DB should equal first import count + const total = (listPlaces(String(trip.id), {}) as any[]).length; + expect(total).toBe(first.count); + }); + + it('PLACE-SVC-034 — imports new places while skipping existing ones', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const buf = fs.readFileSync(GPX_FIXTURE); + + const first = importGpx(String(trip.id), buf) as any; + // Manually add a brand-new place so total > first.count + createPlace(testDb, trip.id, { name: 'Unique Extra Place', lat: 99, lng: 99 }); + + // Re-import: the fixture places are skipped, the extra place remains untouched + const second = importGpx(String(trip.id), buf) as any; + expect(second.count).toBe(0); + + const total = (listPlaces(String(trip.id), {}) as any[]).length; + expect(total).toBe(first.count + 1); + }); +}); + +describe('importKmlPlaces deduplication', () => { + it('PLACE-SVC-035 — skips placemarks already in trip by name', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const buf = fs.readFileSync(KML_FIXTURE); + + const first = importKmlPlaces(String(trip.id), buf); + expect(first.count).toBeGreaterThan(0); + + const second = importKmlPlaces(String(trip.id), buf); + expect(second.count).toBe(0); + expect(second.summary.skippedCount).toBeGreaterThanOrEqual(first.count); + expect(second.summary.warnings.some((w: string) => w.includes('skipped'))).toBe(true); + }); + + it('PLACE-SVC-036 — deduplicates within the same file (intra-batch)', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + // Craft a KML with two placemarks sharing the same name + const kml = Buffer.from(` + + Dupe Place2.0,48.0,0 + Dupe Place2.1,48.1,0 +`); + + const result = importKmlPlaces(String(trip.id), kml); + expect(result.count).toBe(1); + expect(result.summary.skippedCount).toBe(1); + }); +});