diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 308c0750..38203ee2 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -194,6 +194,10 @@ export const placesApi = { const fd = new FormData(); fd.append('file', file) return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data) }, + importMapFile: (tripId: number | string, file: File) => { + const fd = new FormData(); fd.append('file', file) + 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 }).then(r => r.data), importNaverList: (tripId: number | string, url: string) => 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 49f1de50..79f27b10 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -1,19 +1,18 @@ import React from 'react' import ReactDOM from 'react-dom' -import { useState, useRef, useMemo, useCallback, useEffect } from 'react' -import DOM from 'react-dom' +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' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' -import CustomSelect from '../shared/CustomSelect' import { useContextMenu, ContextMenu } from '../shared/ContextMenu' import { placesApi } from '../../api/client' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useAddonStore } from '../../store/addonStore' import type { Place, Category, Day, AssignmentsMap } from '../../types' +import FileImportModal from './FileImportModal' interface PlacesSidebarProps { tripId: number @@ -41,33 +40,43 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ const { t } = useTranslation() const toast = useToast() const ctxMenu = useContextMenu() - const gpxInputRef = 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 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) @@ -92,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) { @@ -144,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 )} + { 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 6e6043d1..92c23eec 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -892,10 +892,20 @@ const ar: Record = { // Places Sidebar 'places.addPlace': 'إضافة مكان/نشاط', - 'places.importGpx': 'GPX', + '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.gpxError': 'فشل استيراد GPX', + 'places.kmlKmzImported': 'تم استيراد {count} مكان من KMZ/KML', + 'places.urlResolved': 'تم استيراد المكان من الرابط', 'places.importList': 'استيراد قائمة', + 'places.kmlKmzSummaryValues': 'علامات المواضع: {total} • تم الاستيراد: {created} • تم التجاوز: {skipped}', 'places.importGoogleList': 'قائمة Google', 'places.importNaverList': 'قائمة Naver', 'places.googleListHint': 'الصق رابط قائمة Google Maps المشتركة لاستيراد جميع الأماكن.', @@ -905,7 +915,6 @@ const ar: Record = { 'places.naverListImported': 'تم استيراد {count} مكان من "{list}"', 'places.naverListError': 'فشل استيراد قائمة Naver Maps', 'places.viewDetails': 'عرض التفاصيل', - 'places.urlResolved': 'تم استيراد المكان من الرابط', 'places.assignToDay': 'إلى أي يوم تريد الإضافة؟', 'places.all': 'الكل', 'places.unplanned': 'غير مخطط', @@ -1717,6 +1726,7 @@ const ar: Record = { 'undo.moveDay': 'تم نقل المكان إلى يوم آخر', 'undo.lock': 'تم تبديل قفل المكان', 'undo.importGpx': 'استيراد GPX', + 'undo.importKeyholeMarkup': 'استيراد KMZ/KML', 'undo.importGoogleList': 'استيراد خرائط Google', 'undo.importNaverList': 'استيراد خرائط Naver', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 16b3d28a..346bafc9 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -862,10 +862,20 @@ const br: Record = { // Places Sidebar 'places.addPlace': 'Adicionar lugar/atividade', - 'places.importGpx': 'GPX', + '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.gpxError': 'Falha ao importar GPX', + 'places.kmlKmzImported': '{count} lugares importados de KMZ/KML', + 'places.urlResolved': 'Lugar importado da URL', 'places.importList': 'Importar lista', + 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importados: {created} • Ignorados: {skipped}', 'places.importGoogleList': 'Lista Google', 'places.importNaverList': 'Lista Naver', 'places.googleListHint': 'Cole um link compartilhado de uma lista do Google Maps para importar todos os lugares.', @@ -875,7 +885,6 @@ const br: Record = { 'places.naverListImported': '{count} lugares importados de "{list}"', 'places.naverListError': 'Falha ao importar lista do Naver Maps', 'places.viewDetails': 'Ver detalhes', - 'places.urlResolved': 'Lugar importado da URL', 'places.assignToDay': 'Adicionar a qual dia?', 'places.all': 'Todos', 'places.unplanned': 'Não planejados', @@ -1666,6 +1675,7 @@ const br: Record = { 'undo.moveDay': 'Local movido para outro dia', 'undo.lock': 'Bloqueio do local alternado', 'undo.importGpx': 'Importação de GPX', + 'undo.importKeyholeMarkup': 'Importação de KMZ/KML', 'undo.importGoogleList': 'Importação do Google Maps', 'undo.importNaverList': 'Importação do Naver Maps', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 8fac6308..283fb7e8 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -890,11 +890,20 @@ const cs: Record = { // Boční panel míst (Places Sidebar) 'places.addPlace': 'Přidat místo/aktivitu', - 'places.importGpx': 'GPX', + '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.kmlKmzSummaryValues': 'Placemarks: {total} • Importováno: {created} • Přeskočeno: {skipped}', 'places.importGoogleList': 'Google Seznam', 'places.importNaverList': 'Naver Seznam', 'places.googleListHint': 'Vložte sdílený odkaz na seznam Google Maps pro import všech míst.', @@ -1669,6 +1678,7 @@ const cs: Record = { 'undo.moveDay': 'Místo přesunuto na jiný den', 'undo.lock': 'Zámek místa přepnut', 'undo.importGpx': 'Import GPX', + 'undo.importKeyholeMarkup': 'Import KMZ/KML', 'undo.importGoogleList': 'Import z Google Maps', 'undo.importNaverList': 'Import z Naver Maps', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index fe771a55..2c9e54ee 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -893,11 +893,20 @@ const de: Record = { // Places Sidebar 'places.addPlace': 'Ort/Aktivität hinzufügen', - 'places.importGpx': 'GPX', + '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.kmlKmzSummaryValues': 'Placemarks: {total} • Importiert: {created} • Übersprungen: {skipped}', 'places.importGoogleList': 'Google Liste', 'places.importNaverList': 'Naver Liste', 'places.googleListHint': 'Geteilten Google Maps Listen-Link einfügen, um alle Orte zu importieren.', @@ -1674,6 +1683,7 @@ const de: Record = { 'undo.moveDay': 'Ort zu anderem Tag verschoben', 'undo.lock': 'Ortssperre umgeschaltet', 'undo.importGpx': 'GPX-Import', + 'undo.importKeyholeMarkup': 'KMZ/KML-Import', 'undo.importGoogleList': 'Google Maps-Import', 'undo.importNaverList': 'Naver Maps-Import', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 28a9e03e..056e3773 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -915,11 +915,20 @@ const en: Record = { // Places Sidebar 'places.addPlace': 'Add Place/Activity', - 'places.importGpx': 'GPX', + '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.kmlKmzSummaryValues': 'Placemarks: {total} • Imported: {created} • Skipped: {skipped}', 'places.importGoogleList': 'Google List', 'places.importNaverList': 'Naver List', 'places.googleListHint': 'Paste a shared Google Maps list link to import all places.', @@ -1708,6 +1717,7 @@ const en: Record = { 'undo.moveDay': 'Place moved to another day', 'undo.lock': 'Place lock toggled', 'undo.importGpx': 'GPX import', + 'undo.importKeyholeMarkup': 'KMZ/KML import', 'undo.importGoogleList': 'Google Maps import', 'undo.importNaverList': 'Naver Maps import', 'undo.addPlace': 'Place added', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index c6aab3f5..1a64e5e2 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -865,10 +865,20 @@ const es: Record = { // Places Sidebar 'places.addPlace': 'Añadir lugar/actividad', - 'places.importGpx': 'GPX', + '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.gpxError': 'Error al importar GPX', + 'places.kmlKmzImported': '{count} lugares importados desde KMZ/KML', + 'places.urlResolved': 'Lugar importado desde URL', 'places.importList': 'Importar lista', + 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importados: {created} • Omitidos: {skipped}', 'places.importGoogleList': 'Lista Google', 'places.importNaverList': 'Lista Naver', 'places.googleListHint': 'Pega un enlace compartido de una lista de Google Maps para importar todos los lugares.', @@ -878,7 +888,6 @@ const es: Record = { 'places.naverListImported': '{count} lugares importados de "{list}"', 'places.naverListError': 'Error al importar la lista de Naver Maps', 'places.viewDetails': 'Ver detalles', - 'places.urlResolved': 'Lugar importado desde URL', 'places.assignToDay': '¿A qué día añadirlo?', 'places.all': 'Todo', 'places.unplanned': 'Sin planificar', @@ -1676,6 +1685,7 @@ const es: Record = { 'undo.moveDay': 'Lugar movido a otro día', 'undo.lock': 'Bloqueo de lugar activado/desactivado', 'undo.importGpx': 'Importación GPX', + 'undo.importKeyholeMarkup': 'Importación KMZ/KML', 'undo.importGoogleList': 'Importación de Google Maps', 'undo.importNaverList': 'Importación de Naver Maps', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 68c8403b..11ce8012 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -889,10 +889,20 @@ const fr: Record = { // Places Sidebar 'places.addPlace': 'Ajouter un lieu/activité', - 'places.importGpx': 'GPX', + '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.gpxError': 'L\'import GPX a échoué', + 'places.kmlKmzImported': '{count} lieux importés depuis KMZ/KML', + 'places.urlResolved': 'Lieu importé depuis l\'URL', 'places.importList': 'Import de liste', + 'places.kmlKmzSummaryValues': 'Placemarks : {total} • Importés : {created} • Ignorés : {skipped}', 'places.importGoogleList': 'Liste Google', 'places.importNaverList': 'Liste Naver', 'places.googleListHint': 'Collez un lien de liste Google Maps partagée pour importer tous les lieux.', @@ -902,7 +912,6 @@ const fr: Record = { 'places.naverListImported': '{count} lieux importés depuis "{list}"', 'places.naverListError': 'Impossible d\'importer la liste Naver Maps', 'places.viewDetails': 'Voir les détails', - 'places.urlResolved': 'Lieu importé depuis l\'URL', 'places.assignToDay': 'Ajouter à quel jour ?', 'places.all': 'Tous', 'places.unplanned': 'Non planifiés', @@ -1670,6 +1679,7 @@ const fr: Record = { 'undo.moveDay': 'Lieu déplacé vers un autre jour', 'undo.lock': 'Verrouillage du lieu modifié', 'undo.importGpx': 'Import GPX', + 'undo.importKeyholeMarkup': 'Import KMZ/KML', 'undo.importGoogleList': 'Import Google Maps', 'undo.importNaverList': 'Import Naver Maps', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index df522967..2d64ae63 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -890,11 +890,20 @@ const hu: Record = { // Helyek oldalsáv 'places.addPlace': 'Hely/Tevékenység hozzáadása', - 'places.importGpx': 'GPX', + '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.kmlKmzSummaryValues': 'Placemarks: {total} • Importálva: {created} • Kihagyva: {skipped}', 'places.importGoogleList': 'Google Lista', 'places.importNaverList': 'Naver Lista', 'places.googleListHint': 'Illessz be egy megosztott Google Maps lista linket az osszes hely importalasahoz.', @@ -1668,6 +1677,7 @@ const hu: Record = { 'undo.moveDay': 'Hely áthelyezve másik napra', 'undo.lock': 'Hely zárolása váltva', 'undo.importGpx': 'GPX importálás', + 'undo.importKeyholeMarkup': 'KMZ/KML importálás', 'undo.importGoogleList': 'Google Maps importálás', 'undo.importNaverList': 'Naver Maps importálás', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 24ce7c49..4c2716c4 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -890,11 +890,20 @@ const it: Record = { // Places Sidebar 'places.addPlace': 'Aggiungi Luogo/Attività', - 'places.importGpx': 'GPX', + '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.kmlKmzSummaryValues': 'Placemarks: {total} • Importati: {created} • Saltati: {skipped}', 'places.importGoogleList': 'Lista Google', 'places.importNaverList': 'Lista Naver', 'places.googleListHint': 'Incolla un link condiviso di una lista Google Maps per importare tutti i luoghi.', @@ -1672,6 +1681,7 @@ const it: Record = { 'undo.moveDay': 'Luogo spostato in altro giorno', 'undo.lock': 'Blocco luogo modificato', 'undo.importGpx': 'Importazione GPX', + 'undo.importKeyholeMarkup': 'Importazione KMZ/KML', 'undo.importGoogleList': 'Importazione Google Maps', 'undo.importNaverList': 'Importazione Naver Maps', 'undo.addPlace': 'Luogo aggiunto', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 1db6c924..72c9a6aa 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -889,10 +889,20 @@ const nl: Record = { // Places Sidebar 'places.addPlace': 'Plaats/activiteit toevoegen', - 'places.importGpx': 'GPX', + '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.gpxError': 'GPX-import mislukt', + 'places.kmlKmzImported': '{count} plaatsen geïmporteerd uit KMZ/KML', + 'places.urlResolved': 'Plaats geïmporteerd van URL', 'places.importList': 'Lijst importeren', + 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Geïmporteerd: {created} • Overgeslagen: {skipped}', 'places.importGoogleList': 'Google Lijst', 'places.importNaverList': 'Naver Lijst', 'places.googleListHint': 'Plak een gedeelde Google Maps lijstlink om alle plaatsen te importeren.', @@ -902,7 +912,6 @@ const nl: Record = { 'places.naverListImported': '{count} plaatsen geimporteerd uit "{list}"', 'places.naverListError': 'Naver Maps lijst importeren mislukt', 'places.viewDetails': 'Details bekijken', - 'places.urlResolved': 'Plaats geïmporteerd van URL', 'places.assignToDay': 'Aan welke dag toevoegen?', 'places.all': 'Alle', 'places.unplanned': 'Ongepland', @@ -1670,6 +1679,7 @@ const nl: Record = { 'undo.moveDay': 'Locatie naar andere dag verplaatst', 'undo.lock': 'Vergrendeling locatie gewijzigd', 'undo.importGpx': 'GPX-import', + 'undo.importKeyholeMarkup': 'KMZ/KML-import', 'undo.importGoogleList': 'Google Maps-import', 'undo.importNaverList': 'Naver Maps-import', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index 2486668d..41d1d899 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -856,10 +856,20 @@ const pl: Record = { // Places Sidebar 'places.addPlace': 'Dodaj miejsce/atrakcję', - 'places.importGpx': 'Importuj GPX', + '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.kmlKmzSummaryValues': 'Placemarks: {total} • Zaimportowano: {created} • Pominięto: {skipped}', + 'places.importGoogleList': 'Lista Google', 'places.assignToDay': 'Do którego dnia dodać?', 'places.all': 'Wszystkie', 'places.unplanned': 'Niezaplanowane', @@ -1608,7 +1618,6 @@ const pl: Record = { 'login.setNewPasswordHint': 'Musisz zmienić hasło.', 'atlas.searchCountry': 'Szukaj kraju...', 'trip.loadingPhotos': 'Ładowanie zdjęć...', - 'places.importGoogleList': 'Lista Google', 'places.importNaverList': 'Lista Naver', 'places.importList': 'Import listy', 'places.googleListHint': 'Wklej link do listy Google Maps.', @@ -1695,6 +1704,7 @@ const pl: Record = { 'undo.moveDay': 'Miejsce przeniesione', 'undo.lock': 'Blokada przełączona', 'undo.importGpx': 'Import GPX', + 'undo.importKeyholeMarkup': 'Import KMZ/KML', 'undo.importGoogleList': 'Import Google Maps', 'undo.importNaverList': 'Import Naver Maps', 'undo.addPlace': 'Miejsce dodane', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 8a3da03a..18373f55 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -889,10 +889,20 @@ const ru: Record = { // Places Sidebar 'places.addPlace': 'Добавить место/активность', - 'places.importGpx': 'GPX', + '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.gpxError': 'Ошибка импорта GPX', + 'places.kmlKmzImported': '{count} мест импортировано из KMZ/KML', + 'places.urlResolved': 'Место импортировано из URL', 'places.importList': 'Импорт списка', + 'places.kmlKmzSummaryValues': 'Placemarks: {total} • Импортировано: {created} • Пропущено: {skipped}', 'places.importGoogleList': 'Список Google', 'places.importNaverList': 'Список Naver', 'places.googleListHint': 'Вставьте ссылку на общий список Google Maps для импорта всех мест.', @@ -902,7 +912,6 @@ const ru: Record = { 'places.naverListImported': '{count} мест импортировано из "{list}"', 'places.naverListError': 'Не удалось импортировать список Naver Maps', 'places.viewDetails': 'Подробности', - 'places.urlResolved': 'Место импортировано из URL', 'places.assignToDay': 'Добавить в какой день?', 'places.all': 'Все', 'places.unplanned': 'Незапланированные', @@ -1667,6 +1676,7 @@ const ru: Record = { 'undo.moveDay': 'Место перемещено в другой день', 'undo.lock': 'Блокировка места изменена', 'undo.importGpx': 'Импорт GPX', + 'undo.importKeyholeMarkup': 'Импорт KMZ/KML', 'undo.importGoogleList': 'Импорт из Google Maps', 'undo.importNaverList': 'Импорт из Naver Maps', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 1390f66c..3312da83 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -889,10 +889,20 @@ const zh: Record = { // Places Sidebar 'places.addPlace': '添加地点/活动', - 'places.importGpx': 'GPX', + '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.gpxError': 'GPX 导入失败', + 'places.kmlKmzImported': '已从 KMZ/KML 导入 {count} 个地点', + 'places.urlResolved': '已从 URL 导入地点', 'places.importList': '列表导入', + 'places.kmlKmzSummaryValues': 'Placemarks:{total} • 已导入:{created} • 已跳过:{skipped}', 'places.importGoogleList': 'Google 列表', 'places.importNaverList': 'Naver 列表', 'places.googleListHint': '粘贴共享的 Google Maps 列表链接以导入所有地点。', @@ -902,7 +912,6 @@ const zh: Record = { 'places.naverListImported': '已从"{list}"导入 {count} 个地点', 'places.naverListError': 'Naver Maps 列表导入失败', 'places.viewDetails': '查看详情', - 'places.urlResolved': '已从 URL 导入地点', 'places.assignToDay': '添加到哪一天?', 'places.all': '全部', 'places.unplanned': '未规划', @@ -1667,6 +1676,7 @@ const zh: Record = { 'undo.moveDay': '地点已移至另一天', 'undo.lock': '地点锁定已切换', 'undo.importGpx': 'GPX 导入', + 'undo.importKeyholeMarkup': 'KMZ/KML 导入', 'undo.importGoogleList': 'Google 地图导入', 'undo.importNaverList': 'Naver 地图导入', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index f1d8bcff..4e6acb6b 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -914,10 +914,20 @@ const zhTw: Record = { // Places Sidebar 'places.addPlace': '新增地點/活動', - 'places.importGpx': 'GPX', + '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.gpxError': 'GPX 匯入失敗', + 'places.kmlKmzImported': '已從 KMZ/KML 匯入 {count} 個地點', + 'places.urlResolved': '已從 URL 匯入地點', 'places.importList': '列表匯入', + 'places.kmlKmzSummaryValues': 'Placemarks:{total} • 已匯入:{created} • 已略過:{skipped}', 'places.importGoogleList': 'Google 列表', 'places.importNaverList': 'Naver 列表', 'places.googleListHint': '貼上共享的 Google Maps 列表連結以匯入所有地點。', @@ -927,7 +937,6 @@ const zhTw: Record = { 'places.naverListImported': '已從"{list}"匯入 {count} 個地點', 'places.naverListError': 'Naver Maps 列表匯入失敗', 'places.viewDetails': '檢視詳情', - 'places.urlResolved': '已從 URL 匯入地點', 'places.assignToDay': '新增到哪一天?', 'places.all': '全部', 'places.unplanned': '未規劃', @@ -1692,6 +1701,7 @@ const zhTw: Record = { 'undo.moveDay': '地點已移至另一天', 'undo.lock': '地點鎖定已切換', 'undo.importGpx': 'GPX 匯入', + 'undo.importKeyholeMarkup': 'KMZ/KML 匯入', 'undo.importGoogleList': 'Google 地圖匯入', 'undo.importNaverList': 'Naver 地圖匯入', diff --git a/server/src/routes/places.ts b/server/src/routes/places.ts index 4e1b1208..72becb7c 100644 --- a/server/src/routes/places.ts +++ b/server/src/routes/places.ts @@ -14,13 +14,14 @@ import { updatePlace, deletePlace, importGpx, + importMapFile, importGoogleList, importNaverList, searchPlaceImage, } from '../services/placeService'; import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../services/journeyService'; -const gpxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }); +const uploadMulter = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }); const router = express.Router({ mergeParams: true }); @@ -56,26 +57,52 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: }); // Import places from GPX file with full track geometry (must be before /:id) -router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('file'), (req: Request, res: Response) => { +router.post('/import/gpx', authenticate, requireTripAccess, uploadMulter.single('file'), (req: Request, res: Response) => { const authReq = req as AuthRequest; if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) return res.status(403).json({ error: 'No permission' }); const { tripId } = req.params; - const file = (req as any).file; + 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); } }); +router.post('/import/map', authenticate, requireTripAccess, uploadMulter.single('file'), async (req: Request, res: Response) => { + const authReq = req as AuthRequest; + if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) { + return res.status(403).json({ error: 'No permission' }); + } + + const { tripId } = req.params; + const file = req.file as Express.Multer.File | undefined; + if (!file) return res.status(400).json({ error: 'No file uploaded' }); + + try { + const result = await importMapFile(tripId, file.buffer, file.originalname); + if (result.summary?.totalPlacemarks === 0) { + return res.status(400).json({ error: 'No valid Placemarks found in map file', summary: result.summary }); + } + + res.status(201).json(result); + for (const place of result.places) { + broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Failed to import map file'; + res.status(400).json({ error: message }); + } +}); + // Import places from a shared Google Maps list URL router.post('/import/google-list', authenticate, requireTripAccess, async (req: Request, res: Response) => { const authReq = req as AuthRequest; @@ -93,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); } @@ -123,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 new file mode 100644 index 00000000..9fe38eb2 --- /dev/null +++ b/server/src/services/kmlImport.ts @@ -0,0 +1,179 @@ +import { TextDecoder } from 'util'; + +export interface ParsedKmlPlacemark { + name: string | null; + description: string | null; + lat: number | null; + lng: number | null; + folderName: string | null; +} + +export interface KmlPlacemarkNode { + placemark: any; + folderName: string | null; +} + +export interface KmlImportSummary { + totalPlacemarks: number; + createdCount: number; + skippedCount: number; + warnings: string[]; + errors: string[]; +} + +const UTF8_DECODER_FATAL = new TextDecoder('utf-8', { fatal: true }); +const UTF8_DECODER_LOOSE = new TextDecoder('utf-8'); + +const ENTITY_MAP: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + ' ': ' ', +}; + +function asArray(value: T | T[] | null | undefined): T[] { + if (value == null) return []; + return Array.isArray(value) ? value : [value]; +} + +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; +} + +function decodeHtmlEntities(value: string): string { + const withNamedEntities = value.replace(/&(amp|lt|gt|quot|#39|nbsp);/g, (m) => ENTITY_MAP[m] || m); + + return withNamedEntities + .replace(/&#(\d+);/g, (_, dec) => { + const code = Number(dec); + return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : _; + }) + .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => { + const code = Number.parseInt(hex, 16); + return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : _; + }); +} + +export function decodeUtf8WithWarning(fileBuffer: Buffer): { text: string; warning: string | null } { + try { + return { text: UTF8_DECODER_FATAL.decode(fileBuffer), warning: null }; + } catch { + return { + text: UTF8_DECODER_LOOSE.decode(fileBuffer), + warning: 'The uploaded file is not valid UTF-8. Some characters may be shown incorrectly.', + }; + } +} + +export function sanitizeKmlDescription(value: unknown): string | null { + const raw = asTrimmedString(value); + if (!raw) return null; + + // 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') + .replace(/\r/g, '\n') + .replace(/[\t\f\v]+/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .trim(); + + return decoded || null; +} + +export function parseKmlPointCoordinates(value: unknown): { lat: number; lng: number } | null { + const coordinates = asTrimmedString(value); + if (!coordinates) return null; + + const firstCoordinate = coordinates.split(/\s+/)[0]; + const [lngRaw, latRaw] = firstCoordinate.split(','); + if (lngRaw == null || latRaw == null) return null; + + const lng = Number.parseFloat(lngRaw); + const lat = Number.parseFloat(latRaw); + + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; + return { lat, lng }; +} + +export function createKmlImportSummary(totalPlacemarks: number): KmlImportSummary { + return { + totalPlacemarks, + createdCount: 0, + skippedCount: 0, + warnings: [], + errors: [], + }; +} + +export function buildCategoryNameLookup(categories: { id: number; name: string }[]): Map { + const lookup = new Map(); + for (const category of categories) { + const normalizedName = category.name.trim().toLowerCase(); + if (!normalizedName) continue; + if (!lookup.has(normalizedName)) { + lookup.set(normalizedName, category.id); + } + } + return lookup; +} + +export function resolveCategoryIdForFolder(folderName: string | null, lookup: Map): number | null { + if (!folderName) return null; + const normalizedFolder = folderName.trim().toLowerCase(); + if (!normalizedFolder) return null; + return lookup.get(normalizedFolder) ?? null; +} + +export function extractKmlPlacemarkNodes(kmlRoot: any): KmlPlacemarkNode[] { + const nodes: KmlPlacemarkNode[] = []; + + const visitNode = (node: any, currentFolderName: string | null): void => { + if (!node || typeof node !== 'object') return; + + for (const placemark of asArray(node.Placemark)) { + nodes.push({ placemark, folderName: currentFolderName }); + } + + for (const folder of asArray(node.Folder)) { + // Nested folders inherit/override folder context used for category matching. + const folderName = asTrimmedString(folder?.name) || currentFolderName; + visitNode(folder, folderName); + } + + for (const childDocument of asArray(node.Document)) { + visitNode(childDocument, currentFolderName); + } + }; + + visitNode(kmlRoot, null); + return nodes; +} + +export function parsePlacemarkNode(node: KmlPlacemarkNode): ParsedKmlPlacemark { + const coordinates = parseKmlPointCoordinates(node.placemark?.Point?.coordinates); + + return { + name: asTrimmedString(node.placemark?.name), + description: sanitizeKmlDescription(node.placemark?.description), + lat: coordinates?.lat ?? null, + lng: coordinates?.lng ?? null, + folderName: node.folderName, + }; +} diff --git a/server/src/services/placeService.ts b/server/src/services/placeService.ts index 63822f5f..f2305bb1 100644 --- a/server/src/services/placeService.ts +++ b/server/src/services/placeService.ts @@ -1,8 +1,18 @@ -import { XMLParser } from 'fast-xml-parser'; +import { XMLParser, XMLValidator } from 'fast-xml-parser'; +import unzipper from 'unzipper'; import { db, getPlaceWithTags } from '../db/database'; import { loadTagsByPlaceIds } from './queryHelpers'; import { checkSsrf } from '../utils/ssrfGuard'; import { Place } from '../types'; +import { + buildCategoryNameLookup, + createKmlImportSummary, + decodeUtf8WithWarning, + extractKmlPlacemarkNodes, + parsePlacemarkNode, + resolveCategoryIdForFolder, + type KmlImportSummary, +} from './kmlImport'; interface PlaceWithCategory extends Place { category_name: string | null; @@ -15,6 +25,12 @@ interface UnsplashSearchResponse { errors?: string[]; } +export interface PlaceImportResult { + places: any[]; + count: number; + summary: KmlImportSummary; +} + // --------------------------------------------------------------------------- // List places // --------------------------------------------------------------------------- @@ -234,6 +250,82 @@ const gpxParser = new XMLParser({ isArray: (name) => ['wpt', 'trkpt', 'rtept', 'trk', 'trkseg', 'rte'].includes(name), }); +const kmlParser = new XMLParser({ + ignoreAttributes: false, + 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; @@ -285,21 +377,153 @@ 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 { + const decoded = decodeUtf8WithWarning(fileBuffer); + + const validationResult = XMLValidator.validate(decoded.text); + if (validationResult !== true) { + throw new Error('Malformed KML: invalid XML structure'); + } + + const parsed = kmlParser.parse(decoded.text); + const kmlRoot = parsed?.kml ?? parsed; + + if (!kmlRoot || typeof kmlRoot !== 'object') { + throw new Error('Malformed KML: could not parse XML'); + } + + const placemarkNodes = extractKmlPlacemarkNodes(kmlRoot); + const summary = createKmlImportSummary(placemarkNodes.length); + + if (decoded.warning) { + summary.warnings.push(decoded.warning); + } + + 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) + VALUES (?, ?, ?, ?, ?, ?, 'walking') + `); + + const insertAll = db.transaction(() => { + let fallbackIndex = 1; + for (const node of placemarkNodes) { + const parsedPlacemark = parsePlacemarkNode(node); + + // KML geometry support is intentionally limited to coordinates. + if (parsedPlacemark.lat === null || parsedPlacemark.lng === null) { + summary.skippedCount += 1; + summary.errors.push(`Skipped Placemark ${fallbackIndex}: missing Point coordinates.`); + fallbackIndex += 1; + continue; + } + + 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( + tripId, + name, + parsedPlacemark.description, + parsedPlacemark.lat, + parsedPlacemark.lng, + categoryId, + ); + + const place = getPlaceWithTags(Number(result.lastInsertRowid)); + created.push(place); + trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup); + summary.createdCount += 1; + fallbackIndex += 1; + } + }); + + 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.'); + } + + return { places: created, count: created.length, summary }; +} + +export async function unpackKmzToKml( + kmzBuffer: Buffer, + decompressedSizeLimit = KMZ_DECOMPRESSED_SIZE_LIMIT, +): Promise { + let zip; + try { + zip = await unzipper.Open.buffer(kmzBuffer); + } catch { + throw new Error('Invalid KMZ archive.'); + } + + const kmlEntries = zip.files.filter((entry) => !entry.path.endsWith('/') && entry.path.toLowerCase().endsWith('.kml')); + if (kmlEntries.length === 0) { + throw new Error('KMZ archive does not contain a KML file.'); + } + + const preferredEntry = kmlEntries.find((entry) => entry.path.toLowerCase().endsWith('doc.kml')) || kmlEntries[0]; + + if (preferredEntry.uncompressedSize > decompressedSizeLimit) { + throw new Error('KMZ archive exceeds the maximum allowed decompressed size.'); + } + + return preferredEntry.buffer(); +} + +export async function importKmzPlaces(tripId: string, kmzBuffer: Buffer): Promise { + const kmlBuffer = await unpackKmzToKml(kmzBuffer); + return importKmlPlaces(tripId, kmlBuffer); +} + +export async function importMapFile(tripId: string, fileBuffer: Buffer, filename: string): Promise { + const ext = filename.toLowerCase().split('.').pop(); + if (ext === 'kmz') return importKmzPlaces(tripId, fileBuffer); + if (ext === 'kml') return importKmlPlaces(tripId, fileBuffer); + throw new Error(`Unsupported map file format: .${ext}. Please upload a .kml or .kmz file.`); } // --------------------------------------------------------------------------- @@ -379,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(); @@ -508,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/fixtures/test-malformed.kml b/server/tests/fixtures/test-malformed.kml new file mode 100644 index 00000000..6d227c24 --- /dev/null +++ b/server/tests/fixtures/test-malformed.kml @@ -0,0 +1,8 @@ + + + + + Broken Placemark + 2.1,48.1,0 + + diff --git a/server/tests/fixtures/test-nested.kml b/server/tests/fixtures/test-nested.kml new file mode 100644 index 00000000..e29b7cb8 --- /dev/null +++ b/server/tests/fixtures/test-nested.kml @@ -0,0 +1,26 @@ + + + + + Food + + Parks + + Nested Place + Nested folder placemark
line 2
+ + 13.4050,52.5200,15 + +
+
+ + Empty Placemark + + + + 13.4010,52.5210,0 + + +
+
+
diff --git a/server/tests/fixtures/test.kml b/server/tests/fixtures/test.kml new file mode 100644 index 00000000..5501fd26 --- /dev/null +++ b/server/tests/fixtures/test.kml @@ -0,0 +1,21 @@ + + + + + Museums + + Eiffel Tower View + for photos and skyline.]]> + + 2.2945,48.8584,0 + + + + Coordinates only placemark + + 2.3333,48.8600,0 + + + + + diff --git a/server/tests/fixtures/test.kmz b/server/tests/fixtures/test.kmz new file mode 100644 index 00000000..34737302 Binary files /dev/null and b/server/tests/fixtures/test.kmz differ diff --git a/server/tests/integration/places.test.ts b/server/tests/integration/places.test.ts index 5b58033d..00832125 100644 --- a/server/tests/integration/places.test.ts +++ b/server/tests/integration/places.test.ts @@ -63,6 +63,10 @@ import { invalidatePermissionsCache } from '../../src/services/permissions'; const app: Application = createApp(); const GPX_FIXTURE = path.join(__dirname, '../fixtures/test.gpx'); +const KML_FIXTURE = path.join(__dirname, '../fixtures/test.kml'); +const KML_NESTED_FIXTURE = path.join(__dirname, '../fixtures/test-nested.kml'); +const KML_MALFORMED_FIXTURE = path.join(__dirname, '../fixtures/test-malformed.kml'); +const KMZ_FIXTURE = path.join(__dirname, '../fixtures/test.kmz'); beforeAll(() => { createTables(testDb); @@ -734,6 +738,125 @@ describe('GPX Import', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// KML / KMZ Import +// ───────────────────────────────────────────────────────────────────────────── + +describe('KML/KMZ Import', () => { + it('PLACE-020 — POST /import/kml with valid KML creates places and returns summary', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + testDb.prepare('INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)') + .run('Museums', '#3b82f6', 'Landmark', user.id); + + const res = await request(app) + .post(`/api/trips/${trip.id}/places/import/map`) + .set('Cookie', authCookie(user.id)) + .attach('file', KML_FIXTURE); + + expect(res.status).toBe(201); + expect(res.body.count).toBe(2); + expect(res.body.summary).toBeDefined(); + expect(res.body.summary.totalPlacemarks).toBe(2); + expect(res.body.summary.createdCount).toBe(2); + + const first = res.body.places.find((p: any) => p.name === 'Eiffel Tower View'); + expect(first).toBeDefined(); + expect(first.description).toContain('Great spot'); + expect(first.description).toContain('\n'); + expect(first.description).not.toContain(''); + expect(first.category?.name).toBe('Museums'); + }); + + it('PLACE-021 — nested folders, empty placemark, and coordinates-only placemark are handled', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + testDb.prepare('INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)') + .run('Parks', '#22c55e', 'Trees', user.id); + + const res = await request(app) + .post(`/api/trips/${trip.id}/places/import/map`) + .set('Cookie', authCookie(user.id)) + .attach('file', KML_NESTED_FIXTURE); + + expect(res.status).toBe(201); + expect(res.body.count).toBe(2); + expect(res.body.summary.totalPlacemarks).toBe(3); + expect(res.body.summary.skippedCount).toBe(1); + expect(Array.isArray(res.body.summary.errors)).toBe(true); + expect(res.body.summary.errors.join(' ')).toContain('missing Point coordinates'); + + const nested = res.body.places.find((p: any) => p.name === 'Nested Place'); + expect(nested).toBeDefined(); + expect(nested.category?.name).toBe('Parks'); + + const fallback = res.body.places.find((p: any) => String(p.name).startsWith('Placemark')); + expect(fallback).toBeDefined(); + }); + + it('PLACE-022 — malformed KML returns 400', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const res = await request(app) + .post(`/api/trips/${trip.id}/places/import/map`) + .set('Cookie', authCookie(user.id)) + .attach('file', KML_MALFORMED_FIXTURE); + + expect(res.status).toBe(400); + expect(res.body.error).toBeDefined(); + }); + + it('PLACE-023 — non-UTF8 KML continues with warning', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const prefix = Buffer.from('Caf'); + const invalidByte = Buffer.from([0xe9]); // invalid UTF-8 sequence when used standalone + const suffix = Buffer.from('2.1,48.1,0'); + const nonUtf8Kml = Buffer.concat([prefix, invalidByte, suffix]); + + const res = await request(app) + .post(`/api/trips/${trip.id}/places/import/map`) + .set('Cookie', authCookie(user.id)) + .attach('file', nonUtf8Kml, 'non-utf8.kml'); + + expect(res.status).toBe(201); + expect(res.body.count).toBe(1); + expect(Array.isArray(res.body.summary.warnings)).toBe(true); + expect(res.body.summary.warnings.join(' ')).toContain('not valid UTF-8'); + }); + + it('PLACE-024 — POST /import/kmz with valid KMZ creates places', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const res = await request(app) + .post(`/api/trips/${trip.id}/places/import/map`) + .set('Cookie', authCookie(user.id)) + .attach('file', KMZ_FIXTURE); + + expect(res.status).toBe(201); + expect(res.body.count).toBeGreaterThan(0); + expect(res.body.summary).toBeDefined(); + }); + + it('PLACE-025 — invalid KMZ returns 400', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + const res = await request(app) + .post(`/api/trips/${trip.id}/places/import/map`) + .set('Cookie', authCookie(user.id)) + .attach('file', Buffer.from('not-a-zip-archive'), 'invalid.kmz'); + + expect(res.status).toBe(400); + expect(String(res.body.error || '')).toContain('KMZ'); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // GPX import — no waypoints // ───────────────────────────────────────────────────────────────────────────── diff --git a/server/tests/unit/services/kmlImportUtils.test.ts b/server/tests/unit/services/kmlImportUtils.test.ts new file mode 100644 index 00000000..47cd40e2 --- /dev/null +++ b/server/tests/unit/services/kmlImportUtils.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { + buildCategoryNameLookup, + decodeUtf8WithWarning, + extractKmlPlacemarkNodes, + parseKmlPointCoordinates, + parsePlacemarkNode, + resolveCategoryIdForFolder, + sanitizeKmlDescription, +} from '../../../src/services/kmlImport'; + +describe('kmlImportUtils', () => { + it('sanitizes HTML descriptions with br to newline', () => { + const input = 'Line 1
Line 2 & more'; + const output = sanitizeKmlDescription(input); + 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 }); + }); + + it('extracts placemarks from nested folders', () => { + const root = { + Document: { + Folder: { + name: 'Parent', + Folder: { + name: 'Child', + Placemark: { name: 'Nested', Point: { coordinates: '13.4,52.5,0' } }, + }, + }, + }, + }; + + const nodes = extractKmlPlacemarkNodes(root); + expect(nodes).toHaveLength(1); + expect(nodes[0].folderName).toBe('Child'); + + const parsed = parsePlacemarkNode(nodes[0]); + expect(parsed.name).toBe('Nested'); + expect(parsed.lat).toBe(52.5); + expect(parsed.lng).toBe(13.4); + }); + + it('builds exact case-insensitive category lookup', () => { + const lookup = buildCategoryNameLookup([ + { id: 3, name: 'Museums' }, + { id: 4, name: 'Parks' }, + ]); + + expect(resolveCategoryIdForFolder('museums', lookup)).toBe(3); + expect(resolveCategoryIdForFolder('Museum', lookup)).toBeNull(); + expect(resolveCategoryIdForFolder('parks', lookup)).toBe(4); + }); + + it('decodes non-BMP decimal HTML entities (emoji)', () => { + // 😀 = U+1F600 = 😀 — requires String.fromCodePoint, not fromCharCode + expect(sanitizeKmlDescription('😀')).toBe('😀'); + }); + + it('decodes non-BMP hex HTML entities (emoji)', () => { + // 😀 = U+1F600 = 😀 + 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'), + Buffer.from([0xe9]), + Buffer.from(''), + ]); + + const decoded = decodeUtf8WithWarning(buffer); + expect(decoded.warning).toContain('not valid UTF-8'); + expect(decoded.text).toContain(''); + }); +}); diff --git a/server/tests/unit/services/kmzUnpack.test.ts b/server/tests/unit/services/kmzUnpack.test.ts new file mode 100644 index 00000000..30e9fdd3 --- /dev/null +++ b/server/tests/unit/services/kmzUnpack.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from 'vitest'; +import path from 'path'; +import fs from 'fs'; + +vi.mock('../../../src/db/database', () => ({ + db: { prepare: vi.fn() }, + getPlaceWithTags: vi.fn(), +})); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +import { unpackKmzToKml, KMZ_DECOMPRESSED_SIZE_LIMIT } from '../../../src/services/placeService'; + +const KMZ_FIXTURE = path.join(__dirname, '../../fixtures/test.kmz'); + +describe('unpackKmzToKml', () => { + it('extracts the KML entry from a valid KMZ', async () => { + const kmzBuffer = fs.readFileSync(KMZ_FIXTURE); + const kmlBuffer = await unpackKmzToKml(kmzBuffer); + expect(kmlBuffer.length).toBeGreaterThan(0); + expect(kmlBuffer.toString('utf-8')).toContain(' { + const kmzBuffer = fs.readFileSync(KMZ_FIXTURE); + // test.kmz contains a KML with uncompressedSize 634 — set limit to 1 byte + await expect(unpackKmzToKml(kmzBuffer, 1)).rejects.toThrow('exceeds the maximum allowed decompressed size'); + }); + + it('rejects a KMZ that contains no KML file', async () => { + // Craft a minimal ZIP containing only a non-KML entry using raw ZIP bytes + // We use the test GPX fixture (a real file) re-zipped via Node's zlib/archiver + // Simplest: a KMZ whose only file has a .txt extension + const Archiver = await import('archiver'); + const archiver = Archiver.default; + const { PassThrough } = await import('stream'); + + const chunks: Buffer[] = []; + const output = new PassThrough(); + output.on('data', (chunk) => chunks.push(chunk)); + + const archive = archiver('zip', { zlib: { level: 1 } }); + archive.pipe(output); + archive.append(Buffer.from('not a kml'), { name: 'data.txt' }); + await archive.finalize(); + + const zipBuffer = Buffer.concat(chunks); + await expect(unpackKmzToKml(zipBuffer)).rejects.toThrow('does not contain a KML file'); + }); + + it('rejects a buffer that is not a valid ZIP archive', async () => { + await expect(unpackKmzToKml(Buffer.from('this is not a zip'))).rejects.toThrow('Invalid KMZ archive'); + }); + + it('exports KMZ_DECOMPRESSED_SIZE_LIMIT as 50 MB', () => { + expect(KMZ_DECOMPRESSED_SIZE_LIMIT).toBe(50 * 1024 * 1024); + }); +}); 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); + }); +});