mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Merge PR #488: KMZ/KML place import
Resolves conflicts with Naver list import (PR #662) — kept both unified list-import dialog and new KMZ/KML dialog. Dropped duplicate react-dom import and unused CustomSelect import from PlacesSidebar.
This commit is contained in:
@@ -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) =>
|
||||
|
||||
@@ -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<HTMLInputElement>(null)
|
||||
const keyholeMarkupFileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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<File | null>(null)
|
||||
const [keyholeMarkupFileSummary, setKeyholeMarkupFileSummary] = useState<PlacesImportSummary | null>(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({
|
||||
>
|
||||
<Upload size={11} strokeWidth={2} /> {t('places.importGpx')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { resetKeyholeMarkupFileDialog(); setKeyholeMarkupFileOpen(true) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
flex: 1, padding: '5px 12px', borderRadius: 8,
|
||||
border: '1px dashed var(--border-primary)', background: 'none',
|
||||
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Upload size={11} strokeWidth={2} /> {t('places.importKeyholeMarkup')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setListImportOpen(true)}
|
||||
style={{
|
||||
@@ -537,6 +619,122 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{keyholeMarkupFileOpen && ReactDOM.createPortal(
|
||||
<div
|
||||
onClick={() => { setKeyholeMarkupFileOpen(false); resetKeyholeMarkupFileDialog() }}
|
||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
>
|
||||
<div
|
||||
onClick={e => 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)' }}
|
||||
>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 6 }}>
|
||||
{t('places.importKeyholeMarkup')}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
|
||||
{t('places.kmlKmzHint')}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={keyholeMarkupFileInputRef}
|
||||
type="file"
|
||||
accept=".kml,.kmz"
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => {
|
||||
const file = e.target.files?.[0] || null
|
||||
setKeyholeMarkupFileFile(file)
|
||||
setKeyholeMarkupFileSummary(null)
|
||||
setKeyholeMarkupFileError('')
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => keyholeMarkupFileInputRef.current?.click()}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
border: '1px dashed var(--border-primary)',
|
||||
background: 'transparent',
|
||||
color: 'var(--text-primary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
marginBottom: 12,
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Upload size={14} strokeWidth={2} />
|
||||
{keyholeMarkupFile ? t('places.kmlKmzSelectedFile', { name: keyholeMarkupFile.name }) : t('places.kmlKmzSelectFile')}
|
||||
</button>
|
||||
|
||||
{keyholeMarkupFileSummary && (
|
||||
<div style={{
|
||||
border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
background: 'var(--bg-tertiary)', padding: 10, marginBottom: 10,
|
||||
}}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{t('places.kmlKmzSummaryValues', {
|
||||
total: keyholeMarkupFileSummary.totalPlacemarks,
|
||||
created: keyholeMarkupFileSummary.createdCount,
|
||||
skipped: keyholeMarkupFileSummary.skippedCount,
|
||||
})}
|
||||
</div>
|
||||
{keyholeMarkupFileSummary.warnings?.length > 0 && (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#b45309', whiteSpace: 'pre-wrap' }}>
|
||||
{keyholeMarkupFileSummary.warnings.join('\n')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{keyholeMarkupFileError && (
|
||||
<div style={{
|
||||
border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10,
|
||||
background: 'rgba(239,68,68,0.08)', padding: '8px 10px',
|
||||
fontSize: 12, color: '#b91c1c', whiteSpace: 'pre-wrap', marginBottom: 10,
|
||||
}}>
|
||||
{keyholeMarkupFileError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 12 }}>
|
||||
{t('places.kmlKmzSizeHint', { maxMb: 10 })}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => { setKeyholeMarkupFileOpen(false); resetKeyholeMarkupFileDialog() }}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)',
|
||||
background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleKeyholeMarkupFileImport}
|
||||
disabled={!keyholeMarkupFile || keyholeMarkupFileLoading}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: 10, border: 'none',
|
||||
background: !keyholeMarkupFile || keyholeMarkupFileLoading ? 'var(--bg-tertiary)' : 'var(--accent)',
|
||||
color: !keyholeMarkupFile || keyholeMarkupFileLoading ? 'var(--text-faint)' : 'var(--accent-text)',
|
||||
fontSize: 13, fontWeight: 500, cursor: !keyholeMarkupFile || keyholeMarkupFileLoading ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{keyholeMarkupFileLoading ? t('common.loading') : t('common.import')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import en from './en'
|
||||
import en from './en'
|
||||
|
||||
const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
...en,
|
||||
@@ -893,9 +893,21 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
// 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<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'undo.moveDay': 'تم نقل المكان إلى يوم آخر',
|
||||
'undo.lock': 'تم تبديل قفل المكان',
|
||||
'undo.importGpx': 'استيراد GPX',
|
||||
'undo.importKeyholeMarkup': 'استيراد KMZ/KML',
|
||||
'undo.importGoogleList': 'استيراد خرائط Google',
|
||||
'undo.importNaverList': 'استيراد خرائط Naver',
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Common
|
||||
'common.save': 'Salvar',
|
||||
'common.showMore': 'Mostrar mais',
|
||||
@@ -863,9 +863,21 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
// 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<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Společné (Common)
|
||||
'common.save': 'Uložit',
|
||||
'common.showMore': 'Zobrazit více',
|
||||
@@ -891,10 +891,21 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
// 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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Allgemein
|
||||
'common.save': 'Speichern',
|
||||
'common.showMore': 'Mehr anzeigen',
|
||||
@@ -894,10 +894,21 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
// 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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Common
|
||||
'common.save': 'Save',
|
||||
'common.showMore': 'Show more',
|
||||
@@ -916,10 +916,21 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
// 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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const es: Record<string, string> = {
|
||||
const es: Record<string, string> = {
|
||||
// Common
|
||||
'common.save': 'Guardar',
|
||||
'common.showMore': 'Ver más',
|
||||
@@ -866,9 +866,21 @@ const es: Record<string, string> = {
|
||||
// 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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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',
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const fr: Record<string, string> = {
|
||||
const fr: Record<string, string> = {
|
||||
// Common
|
||||
'common.save': 'Enregistrer',
|
||||
'common.showMore': 'Voir plus',
|
||||
@@ -890,9 +890,21 @@ const fr: Record<string, string> = {
|
||||
// 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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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',
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Általános
|
||||
'common.save': 'Mentés',
|
||||
'common.showMore': 'Továbbiak',
|
||||
@@ -891,10 +891,21 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
// 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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Common
|
||||
'common.save': 'Salva',
|
||||
'common.showMore': 'Mostra di più',
|
||||
@@ -891,10 +891,21 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
// 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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const nl: Record<string, string> = {
|
||||
const nl: Record<string, string> = {
|
||||
// Common
|
||||
'common.save': 'Opslaan',
|
||||
'common.showMore': 'Meer tonen',
|
||||
@@ -890,9 +890,21 @@ const nl: Record<string, string> = {
|
||||
// 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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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',
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
// Common
|
||||
'common.save': 'Zapisz',
|
||||
'common.showMore': 'Pokaż więcej',
|
||||
@@ -857,9 +857,21 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
// 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<string, string | { name: string; category: string }[]> = {
|
||||
'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<string, string | { name: string; category: string }[]> = {
|
||||
'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',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const ru: Record<string, string> = {
|
||||
const ru: Record<string, string> = {
|
||||
// Common
|
||||
'common.save': 'Сохранить',
|
||||
'common.showMore': 'Показать больше',
|
||||
@@ -890,9 +890,21 @@ const ru: Record<string, string> = {
|
||||
// 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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'undo.moveDay': 'Место перемещено в другой день',
|
||||
'undo.lock': 'Блокировка места изменена',
|
||||
'undo.importGpx': 'Импорт GPX',
|
||||
'undo.importKeyholeMarkup': 'Импорт KMZ/KML',
|
||||
'undo.importGoogleList': 'Импорт из Google Maps',
|
||||
'undo.importNaverList': 'Импорт из Naver Maps',
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const zh: Record<string, string> = {
|
||||
const zh: Record<string, string> = {
|
||||
// Common
|
||||
'common.save': '保存',
|
||||
'common.showMore': '显示更多',
|
||||
@@ -890,9 +890,21 @@ const zh: Record<string, string> = {
|
||||
// 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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'undo.moveDay': '地点已移至另一天',
|
||||
'undo.lock': '地点锁定已切换',
|
||||
'undo.importGpx': 'GPX 导入',
|
||||
'undo.importKeyholeMarkup': 'KMZ/KML 导入',
|
||||
'undo.importGoogleList': 'Google 地图导入',
|
||||
'undo.importNaverList': 'Naver 地图导入',
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const zhTw: Record<string, string> = {
|
||||
const zhTw: Record<string, string> = {
|
||||
// Common
|
||||
'common.save': '儲存',
|
||||
'common.showMore': '顯示更多',
|
||||
@@ -915,9 +915,21 @@ const zhTw: Record<string, string> = {
|
||||
// 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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'undo.moveDay': '地點已移至另一天',
|
||||
'undo.lock': '地點鎖定已切換',
|
||||
'undo.importGpx': 'GPX 匯入',
|
||||
'undo.importKeyholeMarkup': 'KMZ/KML 匯入',
|
||||
'undo.importGoogleList': 'Google 地圖匯入',
|
||||
'undo.importNaverList': 'Naver 地圖匯入',
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
' ': ' ',
|
||||
};
|
||||
|
||||
function asArray<T>(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(/<br\s*\/?>/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<string, number> {
|
||||
const lookup = new Map<string, number>();
|
||||
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<string, number>): 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,
|
||||
};
|
||||
}
|
||||
@@ -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 <Placemark><Point> 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<Buffer> {
|
||||
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<PlaceImportResult> {
|
||||
const kmlBuffer = await unpackKmzToKml(kmzBuffer);
|
||||
return importKmlPlaces(tripId, kmlBuffer);
|
||||
}
|
||||
|
||||
export async function importMapFile(tripId: string, fileBuffer: Buffer, filename: string): Promise<PlaceImportResult> {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml>
|
||||
<Document>
|
||||
<Placemark>
|
||||
<name>Broken Placemark</name>
|
||||
<Point><coordinates>2.1,48.1,0</coordinates></Point>
|
||||
</Document>
|
||||
</kml>
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2">
|
||||
<Document>
|
||||
<Folder>
|
||||
<name>Food</name>
|
||||
<Folder>
|
||||
<name>Parks</name>
|
||||
<Placemark>
|
||||
<name>Nested Place</name>
|
||||
<description>Nested <i>folder</i> placemark<br/>line 2</description>
|
||||
<Point>
|
||||
<coordinates>13.4050,52.5200,15</coordinates>
|
||||
</Point>
|
||||
</Placemark>
|
||||
</Folder>
|
||||
<Placemark>
|
||||
<name>Empty Placemark</name>
|
||||
</Placemark>
|
||||
<Placemark>
|
||||
<Point>
|
||||
<coordinates>13.4010,52.5210,0</coordinates>
|
||||
</Point>
|
||||
</Placemark>
|
||||
</Folder>
|
||||
</Document>
|
||||
</kml>
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<kml xmlns="http://www.opengis.net/kml/2.2">
|
||||
<Document>
|
||||
<Folder>
|
||||
<name>Museums</name>
|
||||
<Placemark>
|
||||
<name>Eiffel Tower View</name>
|
||||
<description><![CDATA[Great spot<br>for photos <b>and</b> skyline.]]></description>
|
||||
<Point>
|
||||
<coordinates>2.2945,48.8584,0</coordinates>
|
||||
</Point>
|
||||
</Placemark>
|
||||
<Placemark>
|
||||
<description>Coordinates only placemark</description>
|
||||
<Point>
|
||||
<coordinates>2.3333,48.8600,0</coordinates>
|
||||
</Point>
|
||||
</Placemark>
|
||||
</Folder>
|
||||
</Document>
|
||||
</kml>
|
||||
Vendored
BIN
Binary file not shown.
@@ -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('<b>');
|
||||
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('<?xml version="1.0"?><kml><Document><Placemark><name>Caf');
|
||||
const invalidByte = Buffer.from([0xe9]); // invalid UTF-8 sequence when used standalone
|
||||
const suffix = Buffer.from('</name><Point><coordinates>2.1,48.1,0</coordinates></Point></Placemark></Document></kml>');
|
||||
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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 = '<kml xmlns="http://www.opengis.net/kml/2.2"><kml:Document><kml:Placemark /></kml:Document></kml>';
|
||||
const stripped = stripXmlNamespaces(xml);
|
||||
expect(stripped).not.toContain('xmlns');
|
||||
expect(stripped).toContain('<Document>');
|
||||
expect(stripped).toContain('<Placemark');
|
||||
});
|
||||
|
||||
it('sanitizes HTML descriptions with br to newline', () => {
|
||||
const input = 'Line 1<br>Line <b>2</b> & 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('<?xml version="1.0"?><kml><Document><Placemark><name>Caf'),
|
||||
Buffer.from([0xe9]),
|
||||
Buffer.from('</name></Placemark></Document></kml>'),
|
||||
]);
|
||||
|
||||
const decoded = decodeUtf8WithWarning(buffer);
|
||||
expect(decoded.warning).toContain('not valid UTF-8');
|
||||
expect(decoded.text).toContain('<kml>');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user