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/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index 49f1de50..6f5e3fc3 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -1,13 +1,11 @@ import React from 'react' import ReactDOM from 'react-dom' import { useState, useRef, useMemo, useCallback, useEffect } from 'react' -import DOM from 'react-dom' 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' @@ -15,6 +13,14 @@ 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[] +} + interface PlacesSidebarProps { tripId: number places: Place[] @@ -42,11 +48,13 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ 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] @@ -76,6 +84,68 @@ 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') { @@ -173,6 +243,18 @@ const PlacesSidebar = React.memo(function PlacesSidebar({ > {t('places.importGpx')} + + + {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 + )} ) diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 6e6043d1..42dfb7dd 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -1,4 +1,4 @@ -import en from './en' +import en from './en' const ar: Record = { ...en, @@ -893,9 +893,21 @@ const ar: Record = { // Places Sidebar 'places.addPlace': 'إضافة مكان/نشاط', 'places.importGpx': 'GPX', + 'places.importKeyholeMarkup': 'KMZ / KML', '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', 'places.googleListHint': 'الصق رابط قائمة Google Maps المشتركة لاستيراد جميع الأماكن.', @@ -905,7 +917,6 @@ const ar: Record = { 'places.naverListImported': 'تم استيراد {count} مكان من "{list}"', 'places.naverListError': 'فشل استيراد قائمة Naver Maps', 'places.viewDetails': 'عرض التفاصيل', - 'places.urlResolved': 'تم استيراد المكان من الرابط', 'places.assignToDay': 'إلى أي يوم تريد الإضافة؟', 'places.all': 'الكل', 'places.unplanned': 'غير مخطط', @@ -1717,6 +1728,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..b9eae0ce 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -1,4 +1,4 @@ -const br: Record = { +const br: Record = { // Common 'common.save': 'Salvar', 'common.showMore': 'Mostrar mais', @@ -863,9 +863,21 @@ const br: Record = { // Places Sidebar 'places.addPlace': 'Adicionar lugar/atividade', 'places.importGpx': 'GPX', + 'places.importKeyholeMarkup': 'KMZ / KML', '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', 'places.googleListHint': 'Cole um link compartilhado de uma lista do Google Maps para importar todos os lugares.', @@ -875,7 +887,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 +1677,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..bafaaa54 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -1,4 +1,4 @@ -const cs: Record = { +const cs: Record = { // Společné (Common) 'common.save': 'Uložit', 'common.showMore': 'Zobrazit více', @@ -891,10 +891,21 @@ 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.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', 'places.googleListHint': 'Vložte sdílený odkaz na seznam Google Maps pro import všech míst.', @@ -1669,6 +1680,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..40a36bc3 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -1,4 +1,4 @@ -const de: Record = { +const de: Record = { // Allgemein 'common.save': 'Speichern', 'common.showMore': 'Mehr anzeigen', @@ -894,10 +894,21 @@ const de: Record = { // Places Sidebar 'places.addPlace': 'Ort/Aktivität hinzufügen', 'places.importGpx': 'GPX', + 'places.importKeyholeMarkup': 'KMZ / KML', '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', 'places.googleListHint': 'Geteilten Google Maps Listen-Link einfügen, um alle Orte zu importieren.', @@ -1674,6 +1685,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..2d2ddbef 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1,4 +1,4 @@ -const en: Record = { +const en: Record = { // Common 'common.save': 'Save', 'common.showMore': 'Show more', @@ -916,10 +916,21 @@ const en: Record = { // Places Sidebar 'places.addPlace': 'Add Place/Activity', 'places.importGpx': 'GPX', + 'places.importKeyholeMarkup': 'KMZ / KML', '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', 'places.googleListHint': 'Paste a shared Google Maps list link to import all places.', @@ -1708,6 +1719,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..da9d07a3 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -1,4 +1,4 @@ -const es: Record = { +const es: Record = { // Common 'common.save': 'Guardar', 'common.showMore': 'Ver más', @@ -866,9 +866,21 @@ const es: Record = { // Places Sidebar 'places.addPlace': 'Añadir lugar/actividad', 'places.importGpx': 'GPX', + 'places.importKeyholeMarkup': 'KMZ / KML', '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', 'places.googleListHint': 'Pega un enlace compartido de una lista de Google Maps para importar todos los lugares.', @@ -878,7 +890,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 +1687,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..d6f89ea9 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -1,4 +1,4 @@ -const fr: Record = { +const fr: Record = { // Common 'common.save': 'Enregistrer', 'common.showMore': 'Voir plus', @@ -890,9 +890,21 @@ const fr: Record = { // Places Sidebar 'places.addPlace': 'Ajouter un lieu/activité', 'places.importGpx': 'GPX', + 'places.importKeyholeMarkup': 'KMZ / KML', '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', 'places.googleListHint': 'Collez un lien de liste Google Maps partagée pour importer tous les lieux.', @@ -902,7 +914,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 +1681,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..91391f26 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -1,4 +1,4 @@ -const hu: Record = { +const hu: Record = { // Általános 'common.save': 'Mentés', 'common.showMore': 'Továbbiak', @@ -891,10 +891,21 @@ const hu: Record = { // Helyek oldalsáv 'places.addPlace': 'Hely/Tevékenység hozzáadása', 'places.importGpx': 'GPX', + 'places.importKeyholeMarkup': 'KMZ / KML', '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', 'places.googleListHint': 'Illessz be egy megosztott Google Maps lista linket az osszes hely importalasahoz.', @@ -1668,6 +1679,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..5ad06408 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -1,4 +1,4 @@ -const it: Record = { +const it: Record = { // Common 'common.save': 'Salva', 'common.showMore': 'Mostra di più', @@ -891,10 +891,21 @@ const it: Record = { // Places Sidebar 'places.addPlace': 'Aggiungi Luogo/Attività', 'places.importGpx': 'GPX', + 'places.importKeyholeMarkup': 'KMZ / KML', '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', 'places.googleListHint': 'Incolla un link condiviso di una lista Google Maps per importare tutti i luoghi.', @@ -1672,6 +1683,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..6fb8b852 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -1,4 +1,4 @@ -const nl: Record = { +const nl: Record = { // Common 'common.save': 'Opslaan', 'common.showMore': 'Meer tonen', @@ -890,9 +890,21 @@ const nl: Record = { // Places Sidebar 'places.addPlace': 'Plaats/activiteit toevoegen', 'places.importGpx': 'GPX', + 'places.importKeyholeMarkup': 'KMZ / KML', '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', 'places.googleListHint': 'Plak een gedeelde Google Maps lijstlink om alle plaatsen te importeren.', @@ -902,7 +914,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 +1681,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..d1e6bc56 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -1,4 +1,4 @@ -const pl: Record = { +const pl: Record = { // Common 'common.save': 'Zapisz', 'common.showMore': 'Pokaż więcej', @@ -857,9 +857,21 @@ const pl: Record = { // Places Sidebar 'places.addPlace': 'Dodaj miejsce/atrakcję', 'places.importGpx': 'Importuj GPX', + 'places.importKeyholeMarkup': 'KMZ / KML', '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ć?', 'places.all': 'Wszystkie', 'places.unplanned': 'Niezaplanowane', @@ -1608,7 +1620,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 +1706,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..9659d2f6 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -1,4 +1,4 @@ -const ru: Record = { +const ru: Record = { // Common 'common.save': 'Сохранить', 'common.showMore': 'Показать больше', @@ -890,9 +890,21 @@ const ru: Record = { // Places Sidebar 'places.addPlace': 'Добавить место/активность', 'places.importGpx': 'GPX', + 'places.importKeyholeMarkup': 'KMZ / KML', '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', 'places.googleListHint': 'Вставьте ссылку на общий список Google Maps для импорта всех мест.', @@ -902,7 +914,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 +1678,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..e514daa3 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -1,4 +1,4 @@ -const zh: Record = { +const zh: Record = { // Common 'common.save': '保存', 'common.showMore': '显示更多', @@ -890,9 +890,21 @@ const zh: Record = { // Places Sidebar 'places.addPlace': '添加地点/活动', 'places.importGpx': 'GPX', + 'places.importKeyholeMarkup': 'KMZ / KML', '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 列表', 'places.googleListHint': '粘贴共享的 Google Maps 列表链接以导入所有地点。', @@ -902,7 +914,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 +1678,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..27fff651 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -1,4 +1,4 @@ -const zhTw: Record = { +const zhTw: Record = { // Common 'common.save': '儲存', 'common.showMore': '顯示更多', @@ -915,9 +915,21 @@ const zhTw: Record = { // Places Sidebar 'places.addPlace': '新增地點/活動', 'places.importGpx': 'GPX', + 'places.importKeyholeMarkup': 'KMZ / KML', '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 列表', 'places.googleListHint': '貼上共享的 Google Maps 列表連結以匯入所有地點。', @@ -927,7 +939,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 +1703,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..4eb726aa 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,7 +57,7 @@ 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' }); @@ -76,6 +77,32 @@ router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('fi } }); +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 as any).file; + if (!file) return res.status(400).json({ error: 'No file uploaded' }); + + try { + const result = await importMapFile(tripId, file.buffer, file.originalname); + if (result.count === 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; diff --git a/server/src/services/kmlImport.ts b/server/src/services/kmlImport.ts new file mode 100644 index 00000000..4489d54b --- /dev/null +++ b/server/src/services/kmlImport.ts @@ -0,0 +1,175 @@ +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; + 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) ? String.fromCharCode(code) : _; + }) + .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => { + const code = Number.parseInt(hex, 16); + return Number.isFinite(code) ? String.fromCharCode(code) : _; + }); +} + +export function stripXmlNamespaces(xml: string): string { + // KML exports vary heavily; stripping namespace declarations/prefixes makes parsing resilient. + return xml + .replace(/\sxmlns(:\w+)?="[^"]*"/g, '') + .replace(/\sxmlns(:\w+)?='[^']*'/g, '') + .replace(/<(\/?)\w+:/g, '<$1'); +} + +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; + + const withLineBreaks = raw.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..434232a1 100644 --- a/server/src/services/placeService.ts +++ b/server/src/services/placeService.ts @@ -1,8 +1,19 @@ -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, + stripXmlNamespaces, + type KmlImportSummary, +} from './kmlImport'; interface PlaceWithCategory extends Place { category_name: string | null; @@ -15,6 +26,12 @@ interface UnsplashSearchResponse { errors?: string[]; } +export interface PlaceImportResult { + places: any[]; + count: number; + summary: KmlImportSummary; +} + // --------------------------------------------------------------------------- // List places // --------------------------------------------------------------------------- @@ -234,6 +251,12 @@ const gpxParser = new XMLParser({ isArray: (name) => ['wpt', 'trkpt', 'rtept', 'trk', 'trkseg', 'rte'].includes(name), }); +const kmlParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + isArray: (name) => ['Placemark', 'Folder', 'Document'].includes(name), +}); + export function importGpx(tripId: string, fileBuffer: Buffer) { const parsed = gpxParser.parse(fileBuffer.toString('utf-8')); const gpx = parsed?.gpx; @@ -302,6 +325,110 @@ export function importGpx(tripId: string, fileBuffer: Buffer) { return created; } +export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImportResult { + const decoded = decodeUtf8WithWarning(fileBuffer); + const xmlWithoutNamespaces = stripXmlNamespaces(decoded.text); + + const validationResult = XMLValidator.validate(xmlWithoutNamespaces); + if (validationResult !== true) { + throw new Error('Malformed KML: invalid XML structure'); + } + + const parsed = kmlParser.parse(xmlWithoutNamespaces); + 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 created: any[] = []; + + 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; + 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); + summary.createdCount += 1; + fallbackIndex += 1; + } + }); + + insertAll(); + summary.skippedCount = summary.totalPlacemarks - summary.createdCount; + + 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): 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]; + 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.`); +} + // --------------------------------------------------------------------------- // Import Google Maps list // --------------------------------------------------------------------------- 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..7ac8dd7d --- /dev/null +++ b/server/tests/unit/services/kmlImportUtils.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { + buildCategoryNameLookup, + decodeUtf8WithWarning, + extractKmlPlacemarkNodes, + parseKmlPointCoordinates, + parsePlacemarkNode, + resolveCategoryIdForFolder, + sanitizeKmlDescription, + stripXmlNamespaces, +} from '../../../src/services/kmlImport'; + +describe('kmlImportUtils', () => { + it('strips KML namespaces and prefixes', () => { + const xml = ''; + const stripped = stripXmlNamespaces(xml); + expect(stripped).not.toContain('xmlns'); + expect(stripped).toContain(''); + expect(stripped).toContain(' { + const input = 'Line 1
Line 2 & more'; + const output = sanitizeKmlDescription(input); + expect(output).toBe('Line 1\nLine 2 & more'); + }); + + 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('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(''); + }); +});