Merge pull request #663 from mauriceboe/feat/places-kmz-kml-import

feat(places): unified file import modal, drag-and-drop, and deduplication
This commit is contained in:
Julien G.
2026-04-15 06:14:39 +02:00
committed by GitHub
29 changed files with 1445 additions and 118 deletions
+4
View File
@@ -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) =>
@@ -0,0 +1,304 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { useState, useRef, useEffect } from 'react'
import { Upload } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { placesApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
interface PlacesImportSummary {
totalPlacemarks: number
createdCount: number
skippedCount: number
warnings: string[]
errors: string[]
}
interface FileImportModalProps {
isOpen: boolean
onClose: () => void
tripId: number
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
initialFile?: File | null
}
const MAX_FILE_BYTES = 10 * 1024 * 1024
export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, initialFile }: FileImportModalProps) {
const { t } = useTranslation()
const toast = useToast()
const loadTrip = useTripStore((s) => s.loadTrip)
const fileInputRef = useRef<HTMLInputElement>(null)
const [file, setFile] = useState<File | null>(null)
const [isDragOver, setIsDragOver] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [summary, setSummary] = useState<PlacesImportSummary | null>(null)
const validateFile = (f: File): string | null => {
const ext = f.name.toLowerCase().split('.').pop()
if (ext !== 'gpx' && ext !== 'kml' && ext !== 'kmz') {
return t('places.importFileUnsupported')
}
if (f.size > MAX_FILE_BYTES) {
return t('places.importFileTooLarge', { maxMb: 10 })
}
return null
}
const reset = () => {
setFile(null)
setIsDragOver(false)
setLoading(false)
setError('')
setSummary(null)
}
// When the modal opens, reset state and pre-load any file dropped from the sidebar.
useEffect(() => {
if (!isOpen) return
setIsDragOver(false)
setLoading(false)
setSummary(null)
if (initialFile) {
const err = validateFile(initialFile)
if (err) {
setFile(null)
setError(err)
} else {
setFile(initialFile)
setError('')
}
} else {
setFile(null)
setError('')
}
// validateFile uses t() which is stable — intentionally omitted from deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, initialFile])
const handleClose = () => {
reset()
onClose()
}
const selectFile = (f: File) => {
const validationError = validateFile(f)
if (validationError) {
setError(validationError)
setFile(null)
return
}
setFile(f)
setError('')
setSummary(null)
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0]
e.target.value = ''
if (f) selectFile(f)
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(true)
}
const handleDragLeave = (e: React.DragEvent) => {
if (e.target === e.currentTarget) setIsDragOver(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(false)
const f = e.dataTransfer.files[0]
if (f) selectFile(f)
}
const handleImport = async () => {
if (!file || loading) return
const ext = file.name.toLowerCase().split('.').pop()
setLoading(true)
setError('')
setSummary(null)
try {
if (ext === 'gpx') {
const result = await placesApi.importGpx(tripId, file)
await loadTrip(tripId)
if (result.count === 0 && result.skipped > 0) {
toast.warning(t('places.importAllSkipped'))
} else {
toast.success(t('places.gpxImported', { count: result.count }))
}
if (result.places?.length > 0) {
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
pushUndo?.(t('undo.importGpx'), async () => {
for (const id of importedIds) {
try { await placesApi.delete(tripId, id) } catch {}
}
await loadTrip(tripId)
})
}
handleClose()
} else {
const result = await placesApi.importMapFile(tripId, file)
await loadTrip(tripId)
setSummary(result.summary || null)
if (result.count === 0 && (result.summary?.skippedCount ?? 0) > 0) {
toast.warning(t('places.importAllSkipped'))
} else {
toast.success(t('places.kmlKmzImported', { count: result.count }))
}
if (result.summary?.errors?.length > 0) {
setError(result.summary.errors.join('\n'))
}
if (result.places?.length > 0) {
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
pushUndo?.(t('undo.importKeyholeMarkup'), async () => {
for (const id of importedIds) {
try { await placesApi.delete(tripId, id) } catch {}
}
await loadTrip(tripId)
})
}
}
} catch (err: any) {
const responseSummary = err?.response?.data?.summary as PlacesImportSummary | undefined
if (responseSummary) setSummary(responseSummary)
const message = err?.response?.data?.error || t('places.importFileError')
setError(message)
toast.error(message)
} finally {
setLoading(false)
}
}
const canImport = !!file && !loading
if (!isOpen) return null
return ReactDOM.createPortal(
<div
onClick={handleClose}
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)', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}
>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', marginBottom: 6 }}>
{t('places.importFile')}
</div>
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginBottom: 14, lineHeight: 1.45 }}>
{t('places.importFileHint')}
</div>
<input
ref={fileInputRef}
type="file"
accept=".gpx,.kml,.kmz"
style={{ display: 'none' }}
onChange={handleInputChange}
/>
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{
width: '100%',
minHeight: 88,
borderRadius: 12,
border: `2px dashed ${isDragOver ? 'var(--accent)' : 'var(--border-primary)'}`,
background: isDragOver ? 'var(--bg-tertiary)' : 'transparent',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
fontSize: 13,
fontWeight: 500,
cursor: 'pointer',
marginBottom: 12,
fontFamily: 'inherit',
transition: 'border-color 0.15s, background 0.15s',
boxSizing: 'border-box',
padding: 16,
}}
>
<Upload size={18} strokeWidth={1.8} color={isDragOver ? 'var(--accent)' : 'var(--text-faint)'} style={{ pointerEvents: 'none' }} />
{isDragOver ? (
<span style={{ color: 'var(--accent)', pointerEvents: 'none' }}>{t('places.importFileDropActive')}</span>
) : file ? (
<span style={{ color: 'var(--text-primary)', textAlign: 'center', wordBreak: 'break-all', pointerEvents: 'none' }}>{file.name}</span>
) : (
<span style={{ color: 'var(--text-faint)', textAlign: 'center', pointerEvents: 'none' }}>{t('places.importFileDropHere')}</span>
)}
</div>
{summary && (
<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: summary.totalPlacemarks,
created: summary.createdCount,
skipped: summary.skippedCount,
})}
</div>
{summary.warnings?.length > 0 && (
<div style={{ marginTop: 8, fontSize: 12, color: '#b45309', whiteSpace: 'pre-wrap' }}>
{summary.warnings.join('\n')}
</div>
)}
</div>
)}
{error && (
<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,
}}>
{error}
</div>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
onClick={handleClose}
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={handleImport}
disabled={!canImport}
style={{
padding: '8px 16px', borderRadius: 10, border: 'none',
background: canImport ? 'var(--accent)' : 'var(--bg-tertiary)',
color: canImport ? 'var(--accent-text)' : 'var(--text-faint)',
fontSize: 13, fontWeight: 500, cursor: canImport ? 'pointer' : 'default',
fontFamily: 'inherit',
}}
>
{loading ? t('common.loading') : t('common.import')}
</button>
</div>
</div>
</div>,
document.body
)
}
@@ -433,29 +433,29 @@ describe('Mobile day-picker (portal)', () => {
// ── GPX import ────────────────────────────────────────────────────────────────
describe('GPX import', () => {
it('FE-PLANNER-SIDEBAR-038: GPX import button triggers file input click', async () => {
it('FE-PLANNER-SIDEBAR-038: "Import file" button opens the file import modal', async () => {
const user = userEvent.setup();
render(<PlacesSidebar {...defaultProps} />);
const fileInput = document.querySelector('input[type="file"][accept=".gpx"]') as HTMLInputElement;
expect(fileInput).toBeTruthy();
const clickSpy = vi.spyOn(fileInput, 'click');
await user.click(screen.getByText(/GPX/i));
expect(clickSpy).toHaveBeenCalled();
await user.click(screen.getByText(/Import file/i));
expect(await screen.findByText(/\.gpx.*\.kml.*\.kmz/i)).toBeInTheDocument();
});
it('FE-PLANNER-SIDEBAR-039: successful GPX import shows success toast', async () => {
// FormData POST hangs on CI — mock at the API boundary instead of MSW.
it('FE-PLANNER-SIDEBAR-039: successful GPX import via modal shows success toast', async () => {
const importSpy = vi.spyOn(placesApi, 'importGpx').mockResolvedValueOnce({ count: 2, places: [{ id: 10 }, { id: 11 }] });
const loadTrip = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, { loadTrip });
const addToast = vi.fn();
(window as any).__addToast = addToast;
const user = userEvent.setup();
render(<PlacesSidebar {...defaultProps} pushUndo={vi.fn()} />);
const fileInput = document.querySelector('input[type="file"][accept=".gpx"]') as HTMLInputElement;
await user.click(screen.getByText(/Import file/i));
const fileInput = document.querySelector('input[type="file"][accept=".gpx,.kml,.kmz"]') as HTMLInputElement;
expect(fileInput).toBeTruthy();
const file = new File(['track data'], 'route.gpx', { type: 'application/gpx+xml' });
await act(async () => {
fireEvent.change(fileInput, { target: { files: [file] } });
});
await user.click(screen.getByRole('button', { name: /^import$/i }));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining('2'),
+67 -29
View File
@@ -1,19 +1,18 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { useState, useRef, useMemo, useCallback, useEffect } from 'react'
import DOM from 'react-dom'
import { useState, useMemo, useEffect, useRef } from 'react'
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import { placesApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useAddonStore } from '../../store/addonStore'
import type { Place, Category, Day, AssignmentsMap } from '../../types'
import FileImportModal from './FileImportModal'
interface PlacesSidebarProps {
tripId: number
@@ -41,33 +40,43 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
const { t } = useTranslation()
const toast = useToast()
const ctxMenu = useContextMenu()
const gpxInputRef = useRef<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 handleGpxImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
try {
const result = await placesApi.importGpx(tripId, file)
await loadTrip(tripId)
toast.success(t('places.gpxImported', { count: result.count }))
if (result.places?.length > 0) {
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
pushUndo?.(t('undo.importGpx'), async () => {
for (const id of importedIds) {
try { await placesApi.delete(tripId, id) } catch {}
}
await loadTrip(tripId)
})
}
} catch (err: any) {
toast.error(err?.response?.data?.error || t('places.gpxError'))
}
const [fileImportOpen, setFileImportOpen] = useState(false)
const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null)
const [sidebarDragOver, setSidebarDragOver] = useState(false)
const sidebarDragCounter = useRef(0)
const handleSidebarDragEnter = (e: React.DragEvent) => {
if (!canEditPlaces) return
e.preventDefault()
sidebarDragCounter.current++
setSidebarDragOver(true)
}
const handleSidebarDragOver = (e: React.DragEvent) => {
if (!canEditPlaces) return
e.preventDefault()
}
const handleSidebarDragLeave = () => {
sidebarDragCounter.current--
if (sidebarDragCounter.current === 0) setSidebarDragOver(false)
}
const handleSidebarDrop = (e: React.DragEvent) => {
e.preventDefault()
sidebarDragCounter.current = 0
setSidebarDragOver(false)
if (!canEditPlaces) return
const f = e.dataTransfer.files[0]
if (!f) return
setSidebarDropFile(f)
setFileImportOpen(true)
}
const [listImportOpen, setListImportOpen] = useState(false)
@@ -92,7 +101,11 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
? await placesApi.importGoogleList(tripId, listImportUrl.trim())
: await placesApi.importNaverList(tripId, listImportUrl.trim())
await loadTrip(tripId)
toast.success(t(provider === 'google' ? 'places.googleListImported' : 'places.naverListImported', { count: result.count, list: result.listName }))
if (result.count === 0 && result.skipped > 0) {
toast.warning(t('places.importAllSkipped'))
} else {
toast.success(t(provider === 'google' ? 'places.googleListImported' : 'places.naverListImported', { count: result.count, list: result.listName }))
}
setListImportOpen(false)
setListImportUrl('')
if (result.places?.length > 0) {
@@ -144,7 +157,26 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
<div
onDragEnter={handleSidebarDragEnter}
onDragOver={handleSidebarDragOver}
onDragLeave={handleSidebarDragLeave}
onDrop={handleSidebarDrop}
style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}
>
{sidebarDragOver && (
<div style={{
position: 'absolute', inset: 0, zIndex: 10,
background: 'color-mix(in srgb, var(--accent) 12%, transparent)',
border: '2px dashed var(--accent)',
borderRadius: 4,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
gap: 10, pointerEvents: 'none',
}}>
<Upload size={28} strokeWidth={1.5} color="var(--accent)" />
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)' }}>{t('places.sidebarDrop')}</span>
</div>
)}
{/* Kopfbereich */}
<div style={{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
{canEditPlaces && <button
@@ -159,10 +191,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
</button>}
{canEditPlaces && <>
<input ref={gpxInputRef} type="file" accept=".gpx" style={{ display: 'none' }} onChange={handleGpxImport} />
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
<button
onClick={() => gpxInputRef.current?.click()}
onClick={() => setFileImportOpen(true)}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
flex: 1, padding: '5px 12px', borderRadius: 8,
@@ -171,7 +202,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<Upload size={11} strokeWidth={2} /> {t('places.importGpx')}
<Upload size={11} strokeWidth={2} /> {t('places.importFile')}
</button>
<button
onClick={() => setListImportOpen(true)}
@@ -537,6 +568,13 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
</div>,
document.body
)}
<FileImportModal
isOpen={fileImportOpen}
onClose={() => { setFileImportOpen(false); setSidebarDropFile(null) }}
tripId={tripId}
pushUndo={pushUndo}
initialFile={sidebarDropFile}
/>
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
</div>
)
+13 -3
View File
@@ -892,10 +892,20 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
// Places Sidebar
'places.addPlace': 'إضافة مكان/نشاط',
'places.importGpx': 'GPX',
'places.importFile': 'استيراد ملف',
'places.sidebarDrop': 'أفلت للاستيراد',
'places.importFileHint': 'استورد ملفات .gpx أو .kml أو .kmz من أدوات مثل Google My Maps وGoogle Earth أو جهاز تتبع GPS.',
'places.importFileDropHere': 'انقر لاختيار ملف أو اسحبه وأفلته هنا',
'places.importFileDropActive': 'أفلت الملف للاختيار',
'places.importFileUnsupported': 'نوع الملف غير مدعوم. استخدم .gpx أو .kml أو .kmz.',
'places.importFileTooLarge': 'الملف كبير جدًا. الحد الأقصى لحجم الرفع هو {maxMb} MB.',
'places.importFileError': 'فشل الاستيراد',
'places.importAllSkipped': 'جميع الأماكن موجودة بالفعل في الرحلة.',
'places.gpxImported': 'تم استيراد {count} مكان من GPX',
'places.gpxError': 'فشل استيراد GPX',
'places.kmlKmzImported': 'تم استيراد {count} مكان من KMZ/KML',
'places.urlResolved': 'تم استيراد المكان من الرابط',
'places.importList': 'استيراد قائمة',
'places.kmlKmzSummaryValues': 'علامات المواضع: {total} • تم الاستيراد: {created} • تم التجاوز: {skipped}',
'places.importGoogleList': 'قائمة Google',
'places.importNaverList': 'قائمة Naver',
'places.googleListHint': 'الصق رابط قائمة Google Maps المشتركة لاستيراد جميع الأماكن.',
@@ -905,7 +915,6 @@ const ar: Record<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 +1726,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',
+13 -3
View File
@@ -862,10 +862,20 @@ const br: Record<string, string | { name: string; category: string }[]> = {
// Places Sidebar
'places.addPlace': 'Adicionar lugar/atividade',
'places.importGpx': 'GPX',
'places.importFile': 'Importar arquivo',
'places.sidebarDrop': 'Solte para importar',
'places.importFileHint': 'Importe arquivos .gpx, .kml ou .kmz de ferramentas como Google My Maps, Google Earth ou um rastreador GPS.',
'places.importFileDropHere': 'Clique para selecionar um arquivo ou arraste e solte aqui',
'places.importFileDropActive': 'Solte o arquivo para selecionar',
'places.importFileUnsupported': 'Tipo de arquivo não suportado. Use .gpx, .kml ou .kmz.',
'places.importFileTooLarge': 'O arquivo é muito grande. O tamanho máximo de upload é {maxMb} MB.',
'places.importFileError': 'Importação falhou',
'places.importAllSkipped': 'Todos os lugares já estavam na viagem.',
'places.gpxImported': '{count} lugares importados do GPX',
'places.gpxError': 'Falha ao importar GPX',
'places.kmlKmzImported': '{count} lugares importados de KMZ/KML',
'places.urlResolved': 'Lugar importado da URL',
'places.importList': 'Importar lista',
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importados: {created} • Ignorados: {skipped}',
'places.importGoogleList': 'Lista Google',
'places.importNaverList': 'Lista Naver',
'places.googleListHint': 'Cole um link compartilhado de uma lista do Google Maps para importar todos os lugares.',
@@ -875,7 +885,6 @@ const br: Record<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 +1675,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',
+12 -2
View File
@@ -890,11 +890,20 @@ 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.importFile': 'Importovat soubor',
'places.sidebarDrop': 'Pusťte pro import',
'places.importFileHint': 'Importujte soubory .gpx, .kml nebo .kmz z nástrojů jako Google My Maps, Google Earth nebo GPS tracker.',
'places.importFileDropHere': 'Klikněte pro výběr souboru nebo jej přetáhněte sem',
'places.importFileDropActive': 'Přetáhněte soubor pro výběr',
'places.importFileUnsupported': 'Nepodporovaný typ souboru. Použijte .gpx, .kml nebo .kmz.',
'places.importFileTooLarge': 'Soubor je příliš velký. Maximální velikost nahrání je {maxMb} MB.',
'places.importFileError': 'Import se nezdařil',
'places.importAllSkipped': 'Všechna místa již byla v cestě.',
'places.gpxImported': '{count} míst importováno z GPX',
'places.kmlKmzImported': 'Importováno {count} míst z KMZ/KML',
'places.urlResolved': 'Místo importováno z URL',
'places.gpxError': 'Import GPX se nezdařil',
'places.importList': 'Import seznamu',
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importováno: {created} • Přeskočeno: {skipped}',
'places.importGoogleList': 'Google Seznam',
'places.importNaverList': 'Naver Seznam',
'places.googleListHint': 'Vložte sdílený odkaz na seznam Google Maps pro import všech míst.',
@@ -1669,6 +1678,7 @@ const cs: Record<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',
+12 -2
View File
@@ -893,11 +893,20 @@ const de: Record<string, string | { name: string; category: string }[]> = {
// Places Sidebar
'places.addPlace': 'Ort/Aktivität hinzufügen',
'places.importGpx': 'GPX',
'places.importFile': 'Datei importieren',
'places.sidebarDrop': 'Ablegen zum Importieren',
'places.importFileHint': '.gpx-, .kml- oder .kmz-Dateien aus Tools wie Google My Maps, Google Earth oder einem GPS-Tracker importieren.',
'places.importFileDropHere': 'Datei auswählen oder hierher ziehen und ablegen',
'places.importFileDropActive': 'Datei ablegen zum Auswählen',
'places.importFileUnsupported': 'Nicht unterstützter Dateityp. Verwende .gpx, .kml oder .kmz.',
'places.importFileTooLarge': 'Datei ist zu groß. Maximale Upload-Größe ist {maxMb} MB.',
'places.importFileError': 'Import fehlgeschlagen',
'places.importAllSkipped': 'Alle Orte waren bereits in der Reise.',
'places.gpxImported': '{count} Orte aus GPX importiert',
'places.kmlKmzImported': '{count} Orte aus KMZ/KML importiert',
'places.urlResolved': 'Ort aus URL importiert',
'places.gpxError': 'GPX-Import fehlgeschlagen',
'places.importList': 'Listenimport',
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importiert: {created} • Übersprungen: {skipped}',
'places.importGoogleList': 'Google Liste',
'places.importNaverList': 'Naver Liste',
'places.googleListHint': 'Geteilten Google Maps Listen-Link einfügen, um alle Orte zu importieren.',
@@ -1674,6 +1683,7 @@ const de: Record<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',
+12 -2
View File
@@ -915,11 +915,20 @@ const en: Record<string, string | { name: string; category: string }[]> = {
// Places Sidebar
'places.addPlace': 'Add Place/Activity',
'places.importGpx': 'GPX',
'places.importFile': 'Import file',
'places.sidebarDrop': 'Drop to import',
'places.importFileHint': 'Import .gpx, .kml or .kmz files from tools like Google My Maps, Google Earth, or a GPS tracker.',
'places.importFileDropHere': 'Click to select a file or drag and drop here',
'places.importFileDropActive': 'Drop file to select',
'places.importFileUnsupported': 'Unsupported file type. Use .gpx, .kml or .kmz.',
'places.importFileTooLarge': 'File is too large. Maximum upload size is {maxMb} MB.',
'places.importFileError': 'Import failed',
'places.importAllSkipped': 'All places were already in the trip.',
'places.gpxImported': '{count} places imported from GPX',
'places.kmlKmzImported': '{count} places imported from KMZ/KML',
'places.urlResolved': 'Place imported from URL',
'places.gpxError': 'GPX import failed',
'places.importList': 'List Import',
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Imported: {created} • Skipped: {skipped}',
'places.importGoogleList': 'Google List',
'places.importNaverList': 'Naver List',
'places.googleListHint': 'Paste a shared Google Maps list link to import all places.',
@@ -1708,6 +1717,7 @@ const en: Record<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',
+13 -3
View File
@@ -865,10 +865,20 @@ const es: Record<string, string> = {
// Places Sidebar
'places.addPlace': 'Añadir lugar/actividad',
'places.importGpx': 'GPX',
'places.importFile': 'Importar archivo',
'places.sidebarDrop': 'Soltar para importar',
'places.importFileHint': 'Importa archivos .gpx, .kml o .kmz de herramientas como Google My Maps, Google Earth o un rastreador GPS.',
'places.importFileDropHere': 'Haz clic para seleccionar un archivo o arrástralo aquí',
'places.importFileDropActive': 'Suelta el archivo para seleccionarlo',
'places.importFileUnsupported': 'Tipo de archivo no compatible. Usa .gpx, .kml o .kmz.',
'places.importFileTooLarge': 'El archivo es demasiado grande. El tamaño máximo de carga es {maxMb} MB.',
'places.importFileError': 'Importación fallida',
'places.importAllSkipped': 'Todos los lugares ya estaban en el viaje.',
'places.gpxImported': '{count} lugares importados desde GPX',
'places.gpxError': 'Error al importar GPX',
'places.kmlKmzImported': '{count} lugares importados desde KMZ/KML',
'places.urlResolved': 'Lugar importado desde URL',
'places.importList': 'Importar lista',
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importados: {created} • Omitidos: {skipped}',
'places.importGoogleList': 'Lista Google',
'places.importNaverList': 'Lista Naver',
'places.googleListHint': 'Pega un enlace compartido de una lista de Google Maps para importar todos los lugares.',
@@ -878,7 +888,6 @@ const es: Record<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 +1685,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',
+13 -3
View File
@@ -889,10 +889,20 @@ const fr: Record<string, string> = {
// Places Sidebar
'places.addPlace': 'Ajouter un lieu/activité',
'places.importGpx': 'GPX',
'places.importFile': 'Importer un fichier',
'places.sidebarDrop': 'Déposer pour importer',
'places.importFileHint': 'Importez des fichiers .gpx, .kml ou .kmz depuis des outils comme Google My Maps, Google Earth ou un traceur GPS.',
'places.importFileDropHere': 'Cliquez pour sélectionner un fichier ou glissez-déposez ici',
'places.importFileDropActive': 'Déposez le fichier pour le sélectionner',
'places.importFileUnsupported': 'Type de fichier non pris en charge. Utilisez .gpx, .kml ou .kmz.',
'places.importFileTooLarge': 'Le fichier est trop volumineux. La taille maximale est de {maxMb} MB.',
'places.importFileError': 'Importation échouée',
'places.importAllSkipped': 'Tous les lieux étaient déjà dans le voyage.',
'places.gpxImported': '{count} lieux importés depuis GPX',
'places.gpxError': 'L\'import GPX a échoué',
'places.kmlKmzImported': '{count} lieux importés depuis KMZ/KML',
'places.urlResolved': 'Lieu importé depuis l\'URL',
'places.importList': 'Import de liste',
'places.kmlKmzSummaryValues': 'Placemarks : {total} • Importés : {created} • Ignorés : {skipped}',
'places.importGoogleList': 'Liste Google',
'places.importNaverList': 'Liste Naver',
'places.googleListHint': 'Collez un lien de liste Google Maps partagée pour importer tous les lieux.',
@@ -902,7 +912,6 @@ const fr: Record<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 +1679,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',
+12 -2
View File
@@ -890,11 +890,20 @@ 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.importFile': 'Fájl importálása',
'places.sidebarDrop': 'Ejtse el az importáláshoz',
'places.importFileHint': '.gpx, .kml vagy .kmz fájlok importálása olyan eszközökből, mint a Google My Maps, Google Earth vagy egy GPS tracker.',
'places.importFileDropHere': 'Kattintson egy fájl kiválasztásához, vagy húzza ide',
'places.importFileDropActive': 'Ejtse ide a fájlt a kiválasztáshoz',
'places.importFileUnsupported': 'Nem támogatott fájltípus. Használjon .gpx, .kml vagy .kmz fájlt.',
'places.importFileTooLarge': 'A fájl túl nagy. A maximális feltöltési méret {maxMb} MB.',
'places.importFileError': 'Importálás sikertelen',
'places.importAllSkipped': 'Minden hely már szerepel az utazásban.',
'places.gpxImported': '{count} hely importálva GPX-ből',
'places.kmlKmzImported': '{count} hely importálva KMZ/KML-ből',
'places.urlResolved': 'Hely importálva URL-ből',
'places.gpxError': 'GPX importálás sikertelen',
'places.importList': 'Lista importálás',
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importálva: {created} • Kihagyva: {skipped}',
'places.importGoogleList': 'Google Lista',
'places.importNaverList': 'Naver Lista',
'places.googleListHint': 'Illessz be egy megosztott Google Maps lista linket az osszes hely importalasahoz.',
@@ -1668,6 +1677,7 @@ const hu: Record<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',
+12 -2
View File
@@ -890,11 +890,20 @@ const it: Record<string, string | { name: string; category: string }[]> = {
// Places Sidebar
'places.addPlace': 'Aggiungi Luogo/Attività',
'places.importGpx': 'GPX',
'places.importFile': 'Importa file',
'places.sidebarDrop': 'Rilascia per importare',
'places.importFileHint': 'Importa file .gpx, .kml o .kmz da strumenti come Google My Maps, Google Earth o un tracker GPS.',
'places.importFileDropHere': 'Clicca per selezionare un file o trascina e rilascia qui',
'places.importFileDropActive': 'Rilascia il file per selezionarlo',
'places.importFileUnsupported': 'Tipo di file non supportato. Usa .gpx, .kml o .kmz.',
'places.importFileTooLarge': 'Il file è troppo grande. La dimensione massima di caricamento è {maxMb} MB.',
'places.importFileError': 'Importazione non riuscita',
'places.importAllSkipped': 'Tutti i luoghi erano già nel viaggio.',
'places.gpxImported': '{count} luoghi importati da GPX',
'places.kmlKmzImported': '{count} luoghi importati da KMZ/KML',
'places.urlResolved': 'Luogo importato dall\'URL',
'places.gpxError': 'Importazione GPX non riuscita',
'places.importList': 'Importa lista',
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importati: {created} • Saltati: {skipped}',
'places.importGoogleList': 'Lista Google',
'places.importNaverList': 'Lista Naver',
'places.googleListHint': 'Incolla un link condiviso di una lista Google Maps per importare tutti i luoghi.',
@@ -1672,6 +1681,7 @@ const it: Record<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',
+13 -3
View File
@@ -889,10 +889,20 @@ const nl: Record<string, string> = {
// Places Sidebar
'places.addPlace': 'Plaats/activiteit toevoegen',
'places.importGpx': 'GPX',
'places.importFile': 'Bestand importeren',
'places.sidebarDrop': 'Loslaten om te importeren',
'places.importFileHint': 'Importeer .gpx-, .kml- of .kmz-bestanden uit tools zoals Google My Maps, Google Earth of een GPS-tracker.',
'places.importFileDropHere': 'Klik om een bestand te selecteren of sleep het hier naartoe',
'places.importFileDropActive': 'Laat het bestand los om het te selecteren',
'places.importFileUnsupported': 'Niet-ondersteund bestandstype. Gebruik .gpx, .kml of .kmz.',
'places.importFileTooLarge': 'Bestand is te groot. Maximale uploadgrootte is {maxMb} MB.',
'places.importFileError': 'Importeren mislukt',
'places.importAllSkipped': 'Alle plaatsen waren al in de reis.',
'places.gpxImported': '{count} plaatsen geïmporteerd uit GPX',
'places.gpxError': 'GPX-import mislukt',
'places.kmlKmzImported': '{count} plaatsen geïmporteerd uit KMZ/KML',
'places.urlResolved': 'Plaats geïmporteerd van URL',
'places.importList': 'Lijst importeren',
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Geïmporteerd: {created} • Overgeslagen: {skipped}',
'places.importGoogleList': 'Google Lijst',
'places.importNaverList': 'Naver Lijst',
'places.googleListHint': 'Plak een gedeelde Google Maps lijstlink om alle plaatsen te importeren.',
@@ -902,7 +912,6 @@ const nl: Record<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 +1679,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',
+13 -3
View File
@@ -856,10 +856,20 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
// Places Sidebar
'places.addPlace': 'Dodaj miejsce/atrakcję',
'places.importGpx': 'Importuj GPX',
'places.importFile': 'Importuj plik',
'places.sidebarDrop': 'Upuść, aby zaimportować',
'places.importFileHint': 'Importuj pliki .gpx, .kml lub .kmz z narzędzi takich jak Google My Maps, Google Earth lub tracker GPS.',
'places.importFileDropHere': 'Kliknij, aby wybrać plik lub przeciągnij i upuść tutaj',
'places.importFileDropActive': 'Upuść plik, aby go wybrać',
'places.importFileUnsupported': 'Nieobsługiwany typ pliku. Użyj .gpx, .kml lub .kmz.',
'places.importFileTooLarge': 'Plik jest za duży. Maksymalny rozmiar przesyłania to {maxMb} MB.',
'places.importFileError': 'Import nie powiódł się',
'places.importAllSkipped': 'Wszystkie miejsca były już w podróży.',
'places.gpxImported': '{count} miejsc zaimportowanych z GPX',
'places.kmlKmzImported': 'Zaimportowano {count} miejsc z KMZ/KML',
'places.urlResolved': 'Miejsce zaimportowane z URL',
'places.gpxError': 'Nie udało się zaimportować pliku GPX',
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Zaimportowano: {created} • Pominięto: {skipped}',
'places.importGoogleList': 'Lista Google',
'places.assignToDay': 'Do którego dnia dodać?',
'places.all': 'Wszystkie',
'places.unplanned': 'Niezaplanowane',
@@ -1608,7 +1618,6 @@ const pl: Record<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 +1704,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',
+13 -3
View File
@@ -889,10 +889,20 @@ const ru: Record<string, string> = {
// Places Sidebar
'places.addPlace': 'Добавить место/активность',
'places.importGpx': 'GPX',
'places.importFile': 'Импортировать файл',
'places.sidebarDrop': 'Отпустите для импорта',
'places.importFileHint': 'Импортируйте файлы .gpx, .kml или .kmz из инструментов, таких как Google My Maps, Google Earth или GPS-трекер.',
'places.importFileDropHere': 'Нажмите для выбора файла или перетащите его сюда',
'places.importFileDropActive': 'Отпустите файл для выбора',
'places.importFileUnsupported': 'Неподдерживаемый тип файла. Используйте .gpx, .kml или .kmz.',
'places.importFileTooLarge': 'Файл слишком большой. Максимальный размер загрузки — {maxMb} MB.',
'places.importFileError': 'Ошибка импорта',
'places.importAllSkipped': 'Все места уже были в поездке.',
'places.gpxImported': '{count} мест импортировано из GPX',
'places.gpxError': 'Ошибка импорта GPX',
'places.kmlKmzImported': '{count} мест импортировано из KMZ/KML',
'places.urlResolved': 'Место импортировано из URL',
'places.importList': 'Импорт списка',
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Импортировано: {created} • Пропущено: {skipped}',
'places.importGoogleList': 'Список Google',
'places.importNaverList': 'Список Naver',
'places.googleListHint': 'Вставьте ссылку на общий список Google Maps для импорта всех мест.',
@@ -902,7 +912,6 @@ const ru: Record<string, string> = {
'places.naverListImported': '{count} мест импортировано из "{list}"',
'places.naverListError': 'Не удалось импортировать список Naver Maps',
'places.viewDetails': 'Подробности',
'places.urlResolved': 'Место импортировано из URL',
'places.assignToDay': 'Добавить в какой день?',
'places.all': 'Все',
'places.unplanned': 'Незапланированные',
@@ -1667,6 +1676,7 @@ const ru: Record<string, string> = {
'undo.moveDay': 'Место перемещено в другой день',
'undo.lock': 'Блокировка места изменена',
'undo.importGpx': 'Импорт GPX',
'undo.importKeyholeMarkup': 'Импорт KMZ/KML',
'undo.importGoogleList': 'Импорт из Google Maps',
'undo.importNaverList': 'Импорт из Naver Maps',
+13 -3
View File
@@ -889,10 +889,20 @@ const zh: Record<string, string> = {
// Places Sidebar
'places.addPlace': '添加地点/活动',
'places.importGpx': 'GPX',
'places.importFile': '导入文件',
'places.sidebarDrop': '拖放以导入',
'places.importFileHint': '从 Google My Maps、Google Earth 或 GPS 追踪器等工具导入 .gpx、.kml 或 .kmz 文件。',
'places.importFileDropHere': '点击选择文件或拖放到此处',
'places.importFileDropActive': '释放文件以选择',
'places.importFileUnsupported': '不支持的文件类型,请使用 .gpx、.kml 或 .kmz。',
'places.importFileTooLarge': '文件过大。最大上传大小为 {maxMb} MB。',
'places.importFileError': '导入失败',
'places.importAllSkipped': '所有地点已在行程中。',
'places.gpxImported': '已从 GPX 导入 {count} 个地点',
'places.gpxError': 'GPX 导入失败',
'places.kmlKmzImported': '已从 KMZ/KML 导入 {count} 个地点',
'places.urlResolved': '已从 URL 导入地点',
'places.importList': '列表导入',
'places.kmlKmzSummaryValues': 'Placemarks{total} • 已导入:{created} • 已跳过:{skipped}',
'places.importGoogleList': 'Google 列表',
'places.importNaverList': 'Naver 列表',
'places.googleListHint': '粘贴共享的 Google Maps 列表链接以导入所有地点。',
@@ -902,7 +912,6 @@ const zh: Record<string, string> = {
'places.naverListImported': '已从"{list}"导入 {count} 个地点',
'places.naverListError': 'Naver Maps 列表导入失败',
'places.viewDetails': '查看详情',
'places.urlResolved': '已从 URL 导入地点',
'places.assignToDay': '添加到哪一天?',
'places.all': '全部',
'places.unplanned': '未规划',
@@ -1667,6 +1676,7 @@ const zh: Record<string, string> = {
'undo.moveDay': '地点已移至另一天',
'undo.lock': '地点锁定已切换',
'undo.importGpx': 'GPX 导入',
'undo.importKeyholeMarkup': 'KMZ/KML 导入',
'undo.importGoogleList': 'Google 地图导入',
'undo.importNaverList': 'Naver 地图导入',
+13 -3
View File
@@ -914,10 +914,20 @@ const zhTw: Record<string, string> = {
// Places Sidebar
'places.addPlace': '新增地點/活動',
'places.importGpx': 'GPX',
'places.importFile': '匯入檔案',
'places.sidebarDrop': '拖放以匯入',
'places.importFileHint': '從 Google My Maps、Google Earth 或 GPS 追蹤器等工具匯入 .gpx、.kml 或 .kmz 檔案。',
'places.importFileDropHere': '點選以選取檔案或拖放至此處',
'places.importFileDropActive': '放開檔案以選取',
'places.importFileUnsupported': '不支援的檔案類型,請使用 .gpx、.kml 或 .kmz。',
'places.importFileTooLarge': '檔案過大。最大上傳大小為 {maxMb} MB。',
'places.importFileError': '匯入失敗',
'places.importAllSkipped': '所有地點已在行程中。',
'places.gpxImported': '已從 GPX 匯入 {count} 個地點',
'places.gpxError': 'GPX 匯入失敗',
'places.kmlKmzImported': '已從 KMZ/KML 匯入 {count} 個地點',
'places.urlResolved': '已從 URL 匯入地點',
'places.importList': '列表匯入',
'places.kmlKmzSummaryValues': 'Placemarks{total} • 已匯入:{created} • 已略過:{skipped}',
'places.importGoogleList': 'Google 列表',
'places.importNaverList': 'Naver 列表',
'places.googleListHint': '貼上共享的 Google Maps 列表連結以匯入所有地點。',
@@ -927,7 +937,6 @@ const zhTw: Record<string, string> = {
'places.naverListImported': '已從"{list}"匯入 {count} 個地點',
'places.naverListError': 'Naver Maps 列表匯入失敗',
'places.viewDetails': '檢視詳情',
'places.urlResolved': '已從 URL 匯入地點',
'places.assignToDay': '新增到哪一天?',
'places.all': '全部',
'places.unplanned': '未規劃',
@@ -1692,6 +1701,7 @@ const zhTw: Record<string, string> = {
'undo.moveDay': '地點已移至另一天',
'undo.lock': '地點鎖定已切換',
'undo.importGpx': 'GPX 匯入',
'undo.importKeyholeMarkup': 'KMZ/KML 匯入',
'undo.importGoogleList': 'Google 地圖匯入',
'undo.importNaverList': 'Naver 地圖匯入',
+36 -9
View File
@@ -14,13 +14,14 @@ import {
updatePlace,
deletePlace,
importGpx,
importMapFile,
importGoogleList,
importNaverList,
searchPlaceImage,
} from '../services/placeService';
import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../services/journeyService';
const gpxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
const uploadMulter = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
const router = express.Router({ mergeParams: true });
@@ -56,26 +57,52 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
});
// Import places from GPX file with full track geometry (must be before /:id)
router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('file'), (req: Request, res: Response) => {
router.post('/import/gpx', authenticate, requireTripAccess, uploadMulter.single('file'), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { tripId } = req.params;
const file = (req as any).file;
const file = req.file as Express.Multer.File | undefined;
if (!file) return res.status(400).json({ error: 'No file uploaded' });
const created = importGpx(tripId, file.buffer);
if (!created) {
const result = importGpx(tripId, file.buffer);
if (!result) {
return res.status(400).json({ error: 'No waypoints found in GPX file' });
}
res.status(201).json({ places: created, count: created.length });
for (const place of created) {
res.status(201).json({ places: result.places, count: result.count, skipped: result.skipped });
for (const place of result.places) {
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
}
});
router.post('/import/map', authenticate, requireTripAccess, uploadMulter.single('file'), async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) {
return res.status(403).json({ error: 'No permission' });
}
const { tripId } = req.params;
const file = req.file as Express.Multer.File | undefined;
if (!file) return res.status(400).json({ error: 'No file uploaded' });
try {
const result = await importMapFile(tripId, file.buffer, file.originalname);
if (result.summary?.totalPlacemarks === 0) {
return res.status(400).json({ error: 'No valid Placemarks found in map file', summary: result.summary });
}
res.status(201).json(result);
for (const place of result.places) {
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to import map file';
res.status(400).json({ error: message });
}
});
// Import places from a shared Google Maps list URL
router.post('/import/google-list', authenticate, requireTripAccess, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
@@ -93,7 +120,7 @@ router.post('/import/google-list', authenticate, requireTripAccess, async (req:
return res.status(result.status).json({ error: result.error });
}
res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName });
res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped });
for (const place of result.places) {
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
}
@@ -123,7 +150,7 @@ router.post('/import/naver-list', authenticate, requireTripAccess, async (req: R
return res.status(result.status).json({ error: result.error });
}
res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName });
res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped });
for (const place of result.places) {
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
}
+179
View File
@@ -0,0 +1,179 @@
import { TextDecoder } from 'util';
export interface ParsedKmlPlacemark {
name: string | null;
description: string | null;
lat: number | null;
lng: number | null;
folderName: string | null;
}
export interface KmlPlacemarkNode {
placemark: any;
folderName: string | null;
}
export interface KmlImportSummary {
totalPlacemarks: number;
createdCount: number;
skippedCount: number;
warnings: string[];
errors: string[];
}
const UTF8_DECODER_FATAL = new TextDecoder('utf-8', { fatal: true });
const UTF8_DECODER_LOOSE = new TextDecoder('utf-8');
const ENTITY_MAP: Record<string, string> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
};
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;
// Parsed objects (mixed-content XML parsed without stopNodes) must not
// produce "[object Object]" — extract #text if present, else return null.
if (typeof value === 'object') {
const candidate = (value as Record<string, unknown>)['#text'];
if (typeof candidate === 'string') return candidate.trim() || null;
return null;
}
const text = String(value).trim();
return text.length > 0 ? text : null;
}
function decodeHtmlEntities(value: string): string {
const withNamedEntities = value.replace(/&(amp|lt|gt|quot|#39|nbsp);/g, (m) => ENTITY_MAP[m] || m);
return withNamedEntities
.replace(/&#(\d+);/g, (_, dec) => {
const code = Number(dec);
return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : _;
})
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
const code = Number.parseInt(hex, 16);
return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : _;
});
}
export function decodeUtf8WithWarning(fileBuffer: Buffer): { text: string; warning: string | null } {
try {
return { text: UTF8_DECODER_FATAL.decode(fileBuffer), warning: null };
} catch {
return {
text: UTF8_DECODER_LOOSE.decode(fileBuffer),
warning: 'The uploaded file is not valid UTF-8. Some characters may be shown incorrectly.',
};
}
}
export function sanitizeKmlDescription(value: unknown): string | null {
const raw = asTrimmedString(value);
if (!raw) return null;
// Unwrap CDATA sections — present when fast-xml-parser returns raw node text
// via stopNodes. Must happen before tag-stripping so the CDATA markers are
// not mis-parsed by the <[^>]+> regex.
const withoutCdata = raw.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1');
const withLineBreaks = withoutCdata.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,
};
}
+242 -18
View File
@@ -1,8 +1,18 @@
import { XMLParser } from 'fast-xml-parser';
import { XMLParser, XMLValidator } from 'fast-xml-parser';
import unzipper from 'unzipper';
import { db, getPlaceWithTags } from '../db/database';
import { loadTagsByPlaceIds } from './queryHelpers';
import { checkSsrf } from '../utils/ssrfGuard';
import { Place } from '../types';
import {
buildCategoryNameLookup,
createKmlImportSummary,
decodeUtf8WithWarning,
extractKmlPlacemarkNodes,
parsePlacemarkNode,
resolveCategoryIdForFolder,
type KmlImportSummary,
} from './kmlImport';
interface PlaceWithCategory extends Place {
category_name: string | null;
@@ -15,6 +25,12 @@ interface UnsplashSearchResponse {
errors?: string[];
}
export interface PlaceImportResult {
places: any[];
count: number;
summary: KmlImportSummary;
}
// ---------------------------------------------------------------------------
// List places
// ---------------------------------------------------------------------------
@@ -234,6 +250,82 @@ const gpxParser = new XMLParser({
isArray: (name) => ['wpt', 'trkpt', 'rtept', 'trk', 'trkseg', 'rte'].includes(name),
});
const kmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
removeNSPrefix: true,
isArray: (name) => ['Placemark', 'Folder', 'Document'].includes(name),
// Treat <description> as raw text so mixed-content HTML (e.g. <br/>, <i>)
// is returned as a string instead of a parsed object.
stopNodes: ['*.description'],
});
export const KMZ_DECOMPRESSED_SIZE_LIMIT = 50 * 1024 * 1024; // 50 MB
// ---------------------------------------------------------------------------
// Import deduplication helpers
// ---------------------------------------------------------------------------
const COORD_DEDUP_TOLERANCE = 0.0001; // ≈ 11 m
interface DedupSet {
names: Set<string>;
coords: Array<{ lat: number; lng: number }>;
}
/** Build a lookup of names/coords for places already in a trip. */
function buildDedupSet(tripId: string): DedupSet {
const rows = db.prepare('SELECT name, lat, lng FROM places WHERE trip_id = ?').all(tripId) as Array<{
name: string | null;
lat: number | null;
lng: number | null;
}>;
const names = new Set<string>();
const coords: Array<{ lat: number; lng: number }> = [];
for (const row of rows) {
if (row.name) {
names.add(row.name.trim().toLowerCase());
} else if (row.lat != null && row.lng != null) {
coords.push({ lat: row.lat, lng: row.lng });
}
}
return { names, coords };
}
/**
* Returns true if a candidate place is already represented in the dedup set.
* Named places match by case-insensitive name; unnamed places fall back to
* coordinate proximity.
*/
function isPlaceDuplicate(
candidate: { name: string | null | undefined; lat: number | null; lng: number | null },
dedup: DedupSet,
): boolean {
const normalizedName = candidate.name?.trim().toLowerCase();
if (normalizedName) return dedup.names.has(normalizedName);
if (candidate.lat != null && candidate.lng != null) {
return dedup.coords.some(
(c) =>
Math.abs(c.lat - candidate.lat!) <= COORD_DEDUP_TOLERANCE &&
Math.abs(c.lng - candidate.lng!) <= COORD_DEDUP_TOLERANCE,
);
}
return false;
}
/** Record a newly inserted place so subsequent candidates in the same batch are checked against it. */
function trackInsertedInDedupSet(
place: { name: string | null | undefined; lat: number | null; lng: number | null },
dedup: DedupSet,
): void {
const normalizedName = place.name?.trim().toLowerCase();
if (normalizedName) {
dedup.names.add(normalizedName);
} else if (place.lat != null && place.lng != null) {
dedup.coords.push({ lat: place.lat, lng: place.lng });
}
}
export function importGpx(tripId: string, fileBuffer: Buffer) {
const parsed = gpxParser.parse(fileBuffer.toString('utf-8'));
const gpx = parsed?.gpx;
@@ -285,21 +377,153 @@ export function importGpx(tripId: string, fileBuffer: Buffer) {
if (waypoints.length === 0) return null;
const dedup = buildDedupSet(tripId);
const insertStmt = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, transport_mode, route_geometry)
VALUES (?, ?, ?, ?, ?, 'walking', ?)
`);
const created: any[] = [];
let skipped = 0;
const insertAll = db.transaction(() => {
for (const wp of waypoints) {
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) {
skipped++;
continue;
}
const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng, wp.routeGeometry || null);
const place = getPlaceWithTags(Number(result.lastInsertRowid));
created.push(place);
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup);
}
});
insertAll();
return created;
return { places: created, count: created.length, skipped };
}
export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImportResult {
const decoded = decodeUtf8WithWarning(fileBuffer);
const validationResult = XMLValidator.validate(decoded.text);
if (validationResult !== true) {
throw new Error('Malformed KML: invalid XML structure');
}
const parsed = kmlParser.parse(decoded.text);
const kmlRoot = parsed?.kml ?? parsed;
if (!kmlRoot || typeof kmlRoot !== 'object') {
throw new Error('Malformed KML: could not parse XML');
}
const placemarkNodes = extractKmlPlacemarkNodes(kmlRoot);
const summary = createKmlImportSummary(placemarkNodes.length);
if (decoded.warning) {
summary.warnings.push(decoded.warning);
}
const categories = db.prepare('SELECT id, name FROM categories').all() as { id: number; name: string }[];
const categoryLookup = buildCategoryNameLookup(categories);
const dedup = buildDedupSet(tripId);
const created: any[] = [];
let dupCount = 0;
const insertStmt = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, category_id, transport_mode)
VALUES (?, ?, ?, ?, ?, ?, 'walking')
`);
const insertAll = db.transaction(() => {
let fallbackIndex = 1;
for (const node of placemarkNodes) {
const parsedPlacemark = parsePlacemarkNode(node);
// KML geometry support is intentionally limited to <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;
if (isPlaceDuplicate({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup)) {
summary.skippedCount += 1;
dupCount++;
fallbackIndex += 1;
continue;
}
const categoryId = resolveCategoryIdForFolder(parsedPlacemark.folderName, categoryLookup);
const result = insertStmt.run(
tripId,
name,
parsedPlacemark.description,
parsedPlacemark.lat,
parsedPlacemark.lng,
categoryId,
);
const place = getPlaceWithTags(Number(result.lastInsertRowid));
created.push(place);
trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup);
summary.createdCount += 1;
fallbackIndex += 1;
}
});
insertAll();
if (dupCount > 0) {
summary.warnings.push(`${dupCount} place${dupCount > 1 ? 's' : ''} skipped (already in trip).`);
}
if (summary.totalPlacemarks === 0) {
summary.errors.push('No Placemarks found in KML file.');
}
return { places: created, count: created.length, summary };
}
export async function unpackKmzToKml(
kmzBuffer: Buffer,
decompressedSizeLimit = KMZ_DECOMPRESSED_SIZE_LIMIT,
): Promise<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];
if (preferredEntry.uncompressedSize > decompressedSizeLimit) {
throw new Error('KMZ archive exceeds the maximum allowed decompressed size.');
}
return preferredEntry.buffer();
}
export async function importKmzPlaces(tripId: string, kmzBuffer: Buffer): Promise<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.`);
}
// ---------------------------------------------------------------------------
@@ -379,30 +603,23 @@ export async function importGoogleList(tripId: string, url: string) {
return { error: 'No places with coordinates found in list', status: 400 };
}
// Skip places that already exist in this trip (same name + coordinates within ~10m)
const existingPlaces = db.prepare(
'SELECT name, lat, lng FROM places WHERE trip_id = ?'
).all(tripId) as { name: string; lat: number; lng: number }[];
const isDuplicate = (p: { name: string; lat: number; lng: number }) =>
existingPlaces.some(e =>
e.name === p.name && Math.abs(e.lat - p.lat) < 0.0001 && Math.abs(e.lng - p.lng) < 0.0001
);
const newPlaces = places.filter(p => !isDuplicate(p));
const skipped = places.length - newPlaces.length;
// Insert only new places into trip
const dedup = buildDedupSet(tripId);
const insertStmt = db.prepare(`
INSERT INTO places (trip_id, name, lat, lng, notes, transport_mode)
VALUES (?, ?, ?, ?, ?, 'walking')
`);
const created: any[] = [];
let skipped = 0;
const insertAll = db.transaction(() => {
for (const p of newPlaces) {
for (const p of places) {
if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) {
skipped++;
continue;
}
const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes);
const place = getPlaceWithTags(Number(result.lastInsertRowid));
created.push(place);
trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup);
}
});
insertAll();
@@ -508,21 +725,28 @@ export async function importNaverList(
return { error: 'No places with coordinates found in list', status: 400 };
}
const dedup = buildDedupSet(tripId);
const insertStmt = db.prepare(`
INSERT INTO places (trip_id, name, lat, lng, address, notes, transport_mode)
VALUES (?, ?, ?, ?, ?, ?, 'walking')
`);
const created: any[] = [];
let skipped = 0;
const insertAll = db.transaction(() => {
for (const p of places) {
if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) {
skipped++;
continue;
}
const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.address, p.notes);
const place = getPlaceWithTags(Number(result.lastInsertRowid));
created.push(place);
trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup);
}
});
insertAll();
return { places: created, listName };
return { places: created, listName, skipped };
}
// ---------------------------------------------------------------------------
+8
View File
@@ -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
View File
@@ -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>
+21
View File
@@ -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>
BIN
View File
Binary file not shown.
+123
View File
@@ -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,96 @@
import { describe, it, expect } from 'vitest';
import {
buildCategoryNameLookup,
decodeUtf8WithWarning,
extractKmlPlacemarkNodes,
parseKmlPointCoordinates,
parsePlacemarkNode,
resolveCategoryIdForFolder,
sanitizeKmlDescription,
} from '../../../src/services/kmlImport';
describe('kmlImportUtils', () => {
it('sanitizes HTML descriptions with br to newline', () => {
const input = 'Line 1<br>Line <b>2</b> &amp; more';
const output = sanitizeKmlDescription(input);
expect(output).toBe('Line 1\nLine 2 & more');
});
it('unwraps CDATA sections before stripping tags', () => {
const input = '<![CDATA[Great spot<br>for photos <b>and</b> skyline.]]>';
expect(sanitizeKmlDescription(input)).toBe('Great spot\nfor photos and skyline.');
});
it('parses KML coordinate order lng,lat,alt', () => {
const parsed = parseKmlPointCoordinates('13.4050,52.5200,15');
expect(parsed).toEqual({ lat: 52.52, lng: 13.405 });
});
it('extracts placemarks from nested folders', () => {
const root = {
Document: {
Folder: {
name: 'Parent',
Folder: {
name: 'Child',
Placemark: { name: 'Nested', Point: { coordinates: '13.4,52.5,0' } },
},
},
},
};
const nodes = extractKmlPlacemarkNodes(root);
expect(nodes).toHaveLength(1);
expect(nodes[0].folderName).toBe('Child');
const parsed = parsePlacemarkNode(nodes[0]);
expect(parsed.name).toBe('Nested');
expect(parsed.lat).toBe(52.5);
expect(parsed.lng).toBe(13.4);
});
it('builds exact case-insensitive category lookup', () => {
const lookup = buildCategoryNameLookup([
{ id: 3, name: 'Museums' },
{ id: 4, name: 'Parks' },
]);
expect(resolveCategoryIdForFolder('museums', lookup)).toBe(3);
expect(resolveCategoryIdForFolder('Museum', lookup)).toBeNull();
expect(resolveCategoryIdForFolder('parks', lookup)).toBe(4);
});
it('decodes non-BMP decimal HTML entities (emoji)', () => {
// &#128512; = U+1F600 = 😀 — requires String.fromCodePoint, not fromCharCode
expect(sanitizeKmlDescription('&#128512;')).toBe('😀');
});
it('decodes non-BMP hex HTML entities (emoji)', () => {
// &#x1F600; = U+1F600 = 😀
expect(sanitizeKmlDescription('&#x1F600;')).toBe('😀');
});
it('does not produce [object Object] when description is a parsed object with #text', () => {
// fast-xml-parser can return an object for mixed-content nodes when stopNodes
// is not configured; the fallback in asTrimmedString must extract #text.
const result = sanitizeKmlDescription({ '#text': 'Hello <b>world</b>' } as any);
expect(result).not.toBe('[object Object]');
expect(result).toBe('Hello world');
});
it('returns null when description object has no #text', () => {
expect(sanitizeKmlDescription({ i: 'bold' } as any)).toBeNull();
});
it('returns warning for non-UTF8 payload', () => {
const buffer = Buffer.concat([
Buffer.from('<?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>');
});
});
@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from 'vitest';
import path from 'path';
import fs from 'fs';
vi.mock('../../../src/db/database', () => ({
db: { prepare: vi.fn() },
getPlaceWithTags: vi.fn(),
}));
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { unpackKmzToKml, KMZ_DECOMPRESSED_SIZE_LIMIT } from '../../../src/services/placeService';
const KMZ_FIXTURE = path.join(__dirname, '../../fixtures/test.kmz');
describe('unpackKmzToKml', () => {
it('extracts the KML entry from a valid KMZ', async () => {
const kmzBuffer = fs.readFileSync(KMZ_FIXTURE);
const kmlBuffer = await unpackKmzToKml(kmzBuffer);
expect(kmlBuffer.length).toBeGreaterThan(0);
expect(kmlBuffer.toString('utf-8')).toContain('<kml');
});
it('rejects a KMZ whose KML entry exceeds the decompressed size limit', async () => {
const kmzBuffer = fs.readFileSync(KMZ_FIXTURE);
// test.kmz contains a KML with uncompressedSize 634 — set limit to 1 byte
await expect(unpackKmzToKml(kmzBuffer, 1)).rejects.toThrow('exceeds the maximum allowed decompressed size');
});
it('rejects a KMZ that contains no KML file', async () => {
// Craft a minimal ZIP containing only a non-KML entry using raw ZIP bytes
// We use the test GPX fixture (a real file) re-zipped via Node's zlib/archiver
// Simplest: a KMZ whose only file has a .txt extension
const Archiver = await import('archiver');
const archiver = Archiver.default;
const { PassThrough } = await import('stream');
const chunks: Buffer[] = [];
const output = new PassThrough();
output.on('data', (chunk) => chunks.push(chunk));
const archive = archiver('zip', { zlib: { level: 1 } });
archive.pipe(output);
archive.append(Buffer.from('not a kml'), { name: 'data.txt' });
await archive.finalize();
const zipBuffer = Buffer.concat(chunks);
await expect(unpackKmzToKml(zipBuffer)).rejects.toThrow('does not contain a KML file');
});
it('rejects a buffer that is not a valid ZIP archive', async () => {
await expect(unpackKmzToKml(Buffer.from('this is not a zip'))).rejects.toThrow('Invalid KMZ archive');
});
it('exports KMZ_DECOMPRESSED_SIZE_LIMIT as 50 MB', () => {
expect(KMZ_DECOMPRESSED_SIZE_LIMIT).toBe(50 * 1024 * 1024);
});
});
+92 -16
View File
@@ -45,7 +45,12 @@ import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createPlace, createCategory, createTag } from '../../helpers/factories';
import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, deletePlace, importGpx, importGoogleList, searchPlaceImage } from '../../../src/services/placeService';
import path from 'path';
import fs from 'fs';
import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, deletePlace, importGpx, importKmlPlaces, importGoogleList, searchPlaceImage } from '../../../src/services/placeService';
const GPX_FIXTURE = path.join(__dirname, '../../fixtures/test.gpx');
const KML_FIXTURE = path.join(__dirname, '../../fixtures/test.kml');
beforeAll(() => {
createTables(testDb);
@@ -266,10 +271,10 @@ describe('importGpx', () => {
<wpt lat="48.8566" lon="2.3522"><name>Paris</name></wpt>
<wpt lat="51.5074" lon="-0.1278"><name>London</name></wpt>
</gpx>`);
const places = importGpx(String(trip.id), gpx) as any[];
expect(places).toHaveLength(2);
expect(places[0].name).toBe('Paris');
expect(places[1].name).toBe('London');
const result = importGpx(String(trip.id), gpx) as any;
expect(result.places).toHaveLength(2);
expect(result.places[0].name).toBe('Paris');
expect(result.places[1].name).toBe('London');
});
it('PLACE-SVC-022 — falls back to <rte> route points when no <wpt> elements exist', () => {
@@ -281,10 +286,10 @@ describe('importGpx', () => {
<rtept lat="51.5074" lon="-0.1278"><name>End</name></rtept>
</rte>
</gpx>`);
const places = importGpx(String(trip.id), gpx) as any[];
expect(places).toHaveLength(2);
expect(places[0].name).toBe('Start');
expect(places[1].name).toBe('End');
const result = importGpx(String(trip.id), gpx) as any;
expect(result.places).toHaveLength(2);
expect(result.places[0].name).toBe('Start');
expect(result.places[1].name).toBe('End');
});
it('PLACE-SVC-023 — imports <trk> track as a single place with routeGeometry', () => {
@@ -299,10 +304,10 @@ describe('importGpx', () => {
</trkseg>
</trk>
</gpx>`);
const places = importGpx(String(trip.id), gpx) as any[];
expect(places).toHaveLength(1);
expect(places[0].name).toBe('My Track');
const geometry = JSON.parse(places[0].route_geometry);
const result = importGpx(String(trip.id), gpx) as any;
expect(result.places).toHaveLength(1);
expect(result.places[0].name).toBe('My Track');
const geometry = JSON.parse(result.places[0].route_geometry);
expect(Array.isArray(geometry)).toBe(true);
expect(geometry).toHaveLength(2);
});
@@ -320,10 +325,10 @@ describe('importGpx', () => {
</trkseg>
</trk>
</gpx>`);
const places = importGpx(String(trip.id), gpx) as any[];
const result = importGpx(String(trip.id), gpx) as any;
// 1 wpt + 1 trk
expect(places).toHaveLength(2);
const trackPlace = places.find((p: any) => p.name === 'Track') as any;
expect(result.places).toHaveLength(2);
const trackPlace = result.places.find((p: any) => p.name === 'Track') as any;
expect(trackPlace).toBeDefined();
const geometry = JSON.parse(trackPlace.route_geometry);
expect(geometry).toHaveLength(2);
@@ -449,3 +454,74 @@ describe('searchPlaceImage', () => {
expect(result.photos[0].photographer).toBe('Photographer');
});
});
// ── Import deduplication ──────────────────────────────────────────────────────
describe('importGpx deduplication', () => {
it('PLACE-SVC-033 — skips waypoints already in trip by name', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const buf = fs.readFileSync(GPX_FIXTURE);
// First import
const first = importGpx(String(trip.id), buf) as any;
expect(first.count).toBeGreaterThan(0);
// Second import — all names already present, nothing new created
const second = importGpx(String(trip.id), buf) as any;
expect(second.count).toBe(0);
expect(second.skipped).toBe(first.count);
// Total places in DB should equal first import count
const total = (listPlaces(String(trip.id), {}) as any[]).length;
expect(total).toBe(first.count);
});
it('PLACE-SVC-034 — imports new places while skipping existing ones', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const buf = fs.readFileSync(GPX_FIXTURE);
const first = importGpx(String(trip.id), buf) as any;
// Manually add a brand-new place so total > first.count
createPlace(testDb, trip.id, { name: 'Unique Extra Place', lat: 99, lng: 99 });
// Re-import: the fixture places are skipped, the extra place remains untouched
const second = importGpx(String(trip.id), buf) as any;
expect(second.count).toBe(0);
const total = (listPlaces(String(trip.id), {}) as any[]).length;
expect(total).toBe(first.count + 1);
});
});
describe('importKmlPlaces deduplication', () => {
it('PLACE-SVC-035 — skips placemarks already in trip by name', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const buf = fs.readFileSync(KML_FIXTURE);
const first = importKmlPlaces(String(trip.id), buf);
expect(first.count).toBeGreaterThan(0);
const second = importKmlPlaces(String(trip.id), buf);
expect(second.count).toBe(0);
expect(second.summary.skippedCount).toBeGreaterThanOrEqual(first.count);
expect(second.summary.warnings.some((w: string) => w.includes('skipped'))).toBe(true);
});
it('PLACE-SVC-036 — deduplicates within the same file (intra-batch)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Craft a KML with two placemarks sharing the same name
const kml = Buffer.from(`<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2"><Document>
<Placemark><name>Dupe Place</name><Point><coordinates>2.0,48.0,0</coordinates></Point></Placemark>
<Placemark><name>Dupe Place</name><Point><coordinates>2.1,48.1,0</coordinates></Point></Placemark>
</Document></kml>`);
const result = importKmlPlaces(String(trip.id), kml);
expect(result.count).toBe(1);
expect(result.summary.skippedCount).toBe(1);
});
});