mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
feat(places): unified file import modal with drag-and-drop and deduplication
- Replace separate GPX and KML/KMZ import buttons with a single "Import file" modal accepting all three formats, with a drag-and-drop drop zone - Support dragging files directly onto the Places sidebar panel; overlay appears on hover and pre-loads the file into the modal on drop - Fix [object Object] description bug in KML imports caused by fast-xml-parser returning mixed-content nodes as objects; add stopNodes config and object guard in asTrimmedString - Fix CDATA sections leaking into descriptions (e.g. "text.]]>") by unwrapping CDATA markers before tag stripping - Add import deduplication across all import paths (GPX, KML/KMZ, Google list, Naver list): reimporting skips places already in the trip by name (case-insensitive) or by coordinates (within ~11 m tolerance), with intra-batch dedup so duplicate placemarks within the same file are also collapsed - Fix KML route returning 400 "No valid Placemarks found" when all placemarks were valid but deduplicated; 400 now only fires when the file contains zero placemarks - Show a warning toast "All places were already in the trip" instead of a misleading success toast when a reimport produces zero new places (GPX, KML/KMZ, Google list, Naver list) - Add 8 new i18n keys across all 14 locales; remove 11 keys made unused by the modal consolidation
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useRef, useMemo, useCallback, useEffect } from 'react'
|
||||
import { useState, useMemo, useEffect, useRef } from 'react'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
@@ -12,14 +12,7 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
|
||||
interface PlacesImportSummary {
|
||||
totalPlacemarks: number
|
||||
createdCount: number
|
||||
skippedCount: number
|
||||
warnings: string[]
|
||||
errors: string[]
|
||||
}
|
||||
import FileImportModal from './FileImportModal'
|
||||
|
||||
interface PlacesSidebarProps {
|
||||
tripId: number
|
||||
@@ -47,35 +40,43 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const ctxMenu = useContextMenu()
|
||||
const gpxInputRef = useRef<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]
|
||||
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)
|
||||
@@ -84,68 +85,6 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
|
||||
const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google']
|
||||
const hasMultipleListImportProviders = availableListImportProviders.length > 1
|
||||
const [keyholeMarkupFileOpen, setKeyholeMarkupFileOpen] = useState(false)
|
||||
const [keyholeMarkupFileLoading, setKeyholeMarkupFileLoading] = useState(false)
|
||||
const [keyholeMarkupFile, setKeyholeMarkupFileFile] = useState<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') {
|
||||
@@ -162,7 +101,11 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
? await placesApi.importGoogleList(tripId, listImportUrl.trim())
|
||||
: await placesApi.importNaverList(tripId, listImportUrl.trim())
|
||||
await loadTrip(tripId)
|
||||
toast.success(t(provider === 'google' ? 'places.googleListImported' : 'places.naverListImported', { count: result.count, list: result.listName }))
|
||||
if (result.count === 0 && result.skipped > 0) {
|
||||
toast.warning(t('places.importAllSkipped'))
|
||||
} else {
|
||||
toast.success(t(provider === 'google' ? 'places.googleListImported' : 'places.naverListImported', { count: result.count, list: result.listName }))
|
||||
}
|
||||
setListImportOpen(false)
|
||||
setListImportUrl('')
|
||||
if (result.places?.length > 0) {
|
||||
@@ -214,7 +157,26 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
||||
|
||||
return (
|
||||
<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
|
||||
@@ -229,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,
|
||||
@@ -241,19 +202,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<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')}
|
||||
<Upload size={11} strokeWidth={2} /> {t('places.importFile')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setListImportOpen(true)}
|
||||
@@ -619,122 +568,13 @@ 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
|
||||
)}
|
||||
<FileImportModal
|
||||
isOpen={fileImportOpen}
|
||||
onClose={() => { setFileImportOpen(false); setSidebarDropFile(null) }}
|
||||
tripId={tripId}
|
||||
pushUndo={pushUndo}
|
||||
initialFile={sidebarDropFile}
|
||||
/>
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -892,21 +892,19 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'إضافة مكان/نشاط',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.importKeyholeMarkup': 'KMZ / KML',
|
||||
'places.importFile': 'استيراد ملف',
|
||||
'places.sidebarDrop': 'أفلت للاستيراد',
|
||||
'places.importFileHint': 'استورد ملفات .gpx أو .kml أو .kmz من أدوات مثل Google My Maps وGoogle Earth أو جهاز تتبع GPS.',
|
||||
'places.importFileDropHere': 'انقر لاختيار ملف أو اسحبه وأفلته هنا',
|
||||
'places.importFileDropActive': 'أفلت الملف للاختيار',
|
||||
'places.importFileUnsupported': 'نوع الملف غير مدعوم. استخدم .gpx أو .kml أو .kmz.',
|
||||
'places.importFileTooLarge': 'الملف كبير جدًا. الحد الأقصى لحجم الرفع هو {maxMb} MB.',
|
||||
'places.importFileError': 'فشل الاستيراد',
|
||||
'places.importAllSkipped': 'جميع الأماكن موجودة بالفعل في الرحلة.',
|
||||
'places.gpxImported': 'تم استيراد {count} مكان من GPX',
|
||||
'places.kmlKmzImported': 'تم استيراد {count} مكان من KMZ/KML',
|
||||
'places.urlResolved': 'تم استيراد المكان من الرابط',
|
||||
'places.gpxError': 'فشل استيراد GPX',
|
||||
'places.importList': 'استيراد قائمة',
|
||||
'places.kmlKmzImportError': 'فشل استيراد KMZ/KML',
|
||||
'places.kmlKmzInvalidType': 'يرجى اختيار ملف .kml أو .kmz.',
|
||||
'places.kmlKmzTooLarge': 'الملف كبير جدًا. الحد الأقصى لحجم الرفع هو {maxMb} MB.',
|
||||
'places.kmlKmzHint': 'استورد ملفات الخرائط من أدوات مثل Google My Maps وGoogle Earth.',
|
||||
'places.kmlKmzSizeHint': 'الحد الأقصى لحجم الملف: {maxMb} MB',
|
||||
'places.kmlKmzSelectFile': 'اختيار ملف',
|
||||
'places.kmlKmzSelectedFile': 'الملف المحدد: {name}',
|
||||
'places.kmlKmzSummaryTitle': 'ملخص الاستيراد',
|
||||
'places.kmlKmzSummaryValues': 'علامات المواضع: {total} • تم الاستيراد: {created} • تم التجاوز: {skipped}',
|
||||
'places.importGoogleList': 'قائمة Google',
|
||||
'places.importNaverList': 'قائمة Naver',
|
||||
|
||||
@@ -862,21 +862,19 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Adicionar lugar/atividade',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.importKeyholeMarkup': 'KMZ / KML',
|
||||
'places.importFile': 'Importar arquivo',
|
||||
'places.sidebarDrop': 'Solte para importar',
|
||||
'places.importFileHint': 'Importe arquivos .gpx, .kml ou .kmz de ferramentas como Google My Maps, Google Earth ou um rastreador GPS.',
|
||||
'places.importFileDropHere': 'Clique para selecionar um arquivo ou arraste e solte aqui',
|
||||
'places.importFileDropActive': 'Solte o arquivo para selecionar',
|
||||
'places.importFileUnsupported': 'Tipo de arquivo não suportado. Use .gpx, .kml ou .kmz.',
|
||||
'places.importFileTooLarge': 'O arquivo é muito grande. O tamanho máximo de upload é {maxMb} MB.',
|
||||
'places.importFileError': 'Importação falhou',
|
||||
'places.importAllSkipped': 'Todos os lugares já estavam na viagem.',
|
||||
'places.gpxImported': '{count} lugares importados do GPX',
|
||||
'places.kmlKmzImported': '{count} lugares importados de KMZ/KML',
|
||||
'places.urlResolved': 'Lugar importado da URL',
|
||||
'places.gpxError': 'Falha ao importar GPX',
|
||||
'places.importList': 'Importar lista',
|
||||
'places.kmlKmzImportError': 'Falha na importação de KMZ/KML',
|
||||
'places.kmlKmzInvalidType': 'Selecione um arquivo .kml ou .kmz.',
|
||||
'places.kmlKmzTooLarge': 'O arquivo é muito grande. O tamanho máximo de upload é {maxMb} MB.',
|
||||
'places.kmlKmzHint': 'Importe arquivos de mapa de ferramentas como Google My Maps e Google Earth.',
|
||||
'places.kmlKmzSizeHint': 'Tamanho máximo do arquivo: {maxMb} MB',
|
||||
'places.kmlKmzSelectFile': 'Selecionar arquivo',
|
||||
'places.kmlKmzSelectedFile': 'Arquivo selecionado: {name}',
|
||||
'places.kmlKmzSummaryTitle': 'Resumo da importação',
|
||||
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importados: {created} • Ignorados: {skipped}',
|
||||
'places.importGoogleList': 'Lista Google',
|
||||
'places.importNaverList': 'Lista Naver',
|
||||
|
||||
@@ -890,21 +890,19 @@ 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.importFile': 'Importovat soubor',
|
||||
'places.sidebarDrop': 'Pusťte pro import',
|
||||
'places.importFileHint': 'Importujte soubory .gpx, .kml nebo .kmz z nástrojů jako Google My Maps, Google Earth nebo GPS tracker.',
|
||||
'places.importFileDropHere': 'Klikněte pro výběr souboru nebo jej přetáhněte sem',
|
||||
'places.importFileDropActive': 'Přetáhněte soubor pro výběr',
|
||||
'places.importFileUnsupported': 'Nepodporovaný typ souboru. Použijte .gpx, .kml nebo .kmz.',
|
||||
'places.importFileTooLarge': 'Soubor je příliš velký. Maximální velikost nahrání je {maxMb} MB.',
|
||||
'places.importFileError': 'Import se nezdařil',
|
||||
'places.importAllSkipped': 'Všechna místa již byla v cestě.',
|
||||
'places.gpxImported': '{count} míst importováno z GPX',
|
||||
'places.kmlKmzImported': 'Importováno {count} míst z KMZ/KML',
|
||||
'places.urlResolved': 'Místo importováno z URL',
|
||||
'places.gpxError': 'Import GPX se nezdařil',
|
||||
'places.importList': 'Import seznamu',
|
||||
'places.kmlKmzImportError': 'Import KMZ/KML selhal',
|
||||
'places.kmlKmzInvalidType': 'Vyberte soubor .kml nebo .kmz.',
|
||||
'places.kmlKmzTooLarge': 'Soubor je příliš velký. Maximální velikost nahrání je {maxMb} MB.',
|
||||
'places.kmlKmzHint': 'Importujte mapové soubory z nástrojů jako Google My Maps a Google Earth.',
|
||||
'places.kmlKmzSizeHint': 'Maximální velikost souboru: {maxMb} MB',
|
||||
'places.kmlKmzSelectFile': 'Vybrat soubor',
|
||||
'places.kmlKmzSelectedFile': 'Vybraný soubor: {name}',
|
||||
'places.kmlKmzSummaryTitle': 'Souhrn importu',
|
||||
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importováno: {created} • Přeskočeno: {skipped}',
|
||||
'places.importGoogleList': 'Google Seznam',
|
||||
'places.importNaverList': 'Naver Seznam',
|
||||
|
||||
@@ -893,21 +893,19 @@ 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.importFile': 'Datei importieren',
|
||||
'places.sidebarDrop': 'Ablegen zum Importieren',
|
||||
'places.importFileHint': '.gpx-, .kml- oder .kmz-Dateien aus Tools wie Google My Maps, Google Earth oder einem GPS-Tracker importieren.',
|
||||
'places.importFileDropHere': 'Datei auswählen oder hierher ziehen und ablegen',
|
||||
'places.importFileDropActive': 'Datei ablegen zum Auswählen',
|
||||
'places.importFileUnsupported': 'Nicht unterstützter Dateityp. Verwende .gpx, .kml oder .kmz.',
|
||||
'places.importFileTooLarge': 'Datei ist zu groß. Maximale Upload-Größe ist {maxMb} MB.',
|
||||
'places.importFileError': 'Import fehlgeschlagen',
|
||||
'places.importAllSkipped': 'Alle Orte waren bereits in der Reise.',
|
||||
'places.gpxImported': '{count} Orte aus GPX importiert',
|
||||
'places.kmlKmzImported': '{count} Orte aus KMZ/KML importiert',
|
||||
'places.urlResolved': 'Ort aus URL importiert',
|
||||
'places.gpxError': 'GPX-Import fehlgeschlagen',
|
||||
'places.importList': 'Listenimport',
|
||||
'places.kmlKmzImportError': 'KMZ/KML-Import fehlgeschlagen',
|
||||
'places.kmlKmzInvalidType': 'Bitte eine .kml- oder .kmz-Datei auswählen.',
|
||||
'places.kmlKmzTooLarge': 'Datei ist zu groß. Maximale Upload-Größe ist {maxMb} MB.',
|
||||
'places.kmlKmzHint': 'Importiere Kartendateien aus Tools wie Google My Maps und Google Earth.',
|
||||
'places.kmlKmzSizeHint': 'Max. Dateigröße: {maxMb} MB',
|
||||
'places.kmlKmzSelectFile': 'Datei auswählen',
|
||||
'places.kmlKmzSelectedFile': 'Ausgewählte Datei: {name}',
|
||||
'places.kmlKmzSummaryTitle': 'Importzusammenfassung',
|
||||
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importiert: {created} • Übersprungen: {skipped}',
|
||||
'places.importGoogleList': 'Google Liste',
|
||||
'places.importNaverList': 'Naver Liste',
|
||||
|
||||
@@ -915,21 +915,19 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Add Place/Activity',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.importKeyholeMarkup': 'KMZ / KML',
|
||||
'places.importFile': 'Import file',
|
||||
'places.sidebarDrop': 'Drop to import',
|
||||
'places.importFileHint': 'Import .gpx, .kml or .kmz files from tools like Google My Maps, Google Earth, or a GPS tracker.',
|
||||
'places.importFileDropHere': 'Click to select a file or drag and drop here',
|
||||
'places.importFileDropActive': 'Drop file to select',
|
||||
'places.importFileUnsupported': 'Unsupported file type. Use .gpx, .kml or .kmz.',
|
||||
'places.importFileTooLarge': 'File is too large. Maximum upload size is {maxMb} MB.',
|
||||
'places.importFileError': 'Import failed',
|
||||
'places.importAllSkipped': 'All places were already in the trip.',
|
||||
'places.gpxImported': '{count} places imported from GPX',
|
||||
'places.kmlKmzImported': '{count} places imported from KMZ/KML',
|
||||
'places.urlResolved': 'Place imported from URL',
|
||||
'places.gpxError': 'GPX import failed',
|
||||
'places.importList': 'List Import',
|
||||
'places.kmlKmzImportError': 'KMZ/KML import failed',
|
||||
'places.kmlKmzInvalidType': 'Please select a .kml or .kmz file.',
|
||||
'places.kmlKmzTooLarge': 'File is too large. Maximum upload size is {maxMb} MB.',
|
||||
'places.kmlKmzHint': 'Import map files from tools like Google My Maps and Google Earth.',
|
||||
'places.kmlKmzSizeHint': 'Max file size: {maxMb} MB',
|
||||
'places.kmlKmzSelectFile': 'Select File',
|
||||
'places.kmlKmzSelectedFile': 'Selected file: {name}',
|
||||
'places.kmlKmzSummaryTitle': 'Import summary',
|
||||
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Imported: {created} • Skipped: {skipped}',
|
||||
'places.importGoogleList': 'Google List',
|
||||
'places.importNaverList': 'Naver List',
|
||||
|
||||
@@ -865,21 +865,19 @@ const es: Record<string, string> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Añadir lugar/actividad',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.importKeyholeMarkup': 'KMZ / KML',
|
||||
'places.importFile': 'Importar archivo',
|
||||
'places.sidebarDrop': 'Soltar para importar',
|
||||
'places.importFileHint': 'Importa archivos .gpx, .kml o .kmz de herramientas como Google My Maps, Google Earth o un rastreador GPS.',
|
||||
'places.importFileDropHere': 'Haz clic para seleccionar un archivo o arrástralo aquí',
|
||||
'places.importFileDropActive': 'Suelta el archivo para seleccionarlo',
|
||||
'places.importFileUnsupported': 'Tipo de archivo no compatible. Usa .gpx, .kml o .kmz.',
|
||||
'places.importFileTooLarge': 'El archivo es demasiado grande. El tamaño máximo de carga es {maxMb} MB.',
|
||||
'places.importFileError': 'Importación fallida',
|
||||
'places.importAllSkipped': 'Todos los lugares ya estaban en el viaje.',
|
||||
'places.gpxImported': '{count} lugares importados desde GPX',
|
||||
'places.kmlKmzImported': '{count} lugares importados desde KMZ/KML',
|
||||
'places.urlResolved': 'Lugar importado desde URL',
|
||||
'places.gpxError': 'Error al importar GPX',
|
||||
'places.importList': 'Importar lista',
|
||||
'places.kmlKmzImportError': 'La importación KMZ/KML falló',
|
||||
'places.kmlKmzInvalidType': 'Selecciona un archivo .kml o .kmz.',
|
||||
'places.kmlKmzTooLarge': 'El archivo es demasiado grande. El tamaño máximo de carga es {maxMb} MB.',
|
||||
'places.kmlKmzHint': 'Importa archivos de mapa desde herramientas como Google My Maps y Google Earth.',
|
||||
'places.kmlKmzSizeHint': 'Tamaño máximo de archivo: {maxMb} MB',
|
||||
'places.kmlKmzSelectFile': 'Seleccionar archivo',
|
||||
'places.kmlKmzSelectedFile': 'Archivo seleccionado: {name}',
|
||||
'places.kmlKmzSummaryTitle': 'Resumen de importación',
|
||||
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importados: {created} • Omitidos: {skipped}',
|
||||
'places.importGoogleList': 'Lista Google',
|
||||
'places.importNaverList': 'Lista Naver',
|
||||
|
||||
@@ -889,21 +889,19 @@ const fr: Record<string, string> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Ajouter un lieu/activité',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.importKeyholeMarkup': 'KMZ / KML',
|
||||
'places.importFile': 'Importer un fichier',
|
||||
'places.sidebarDrop': 'Déposer pour importer',
|
||||
'places.importFileHint': 'Importez des fichiers .gpx, .kml ou .kmz depuis des outils comme Google My Maps, Google Earth ou un traceur GPS.',
|
||||
'places.importFileDropHere': 'Cliquez pour sélectionner un fichier ou glissez-déposez ici',
|
||||
'places.importFileDropActive': 'Déposez le fichier pour le sélectionner',
|
||||
'places.importFileUnsupported': 'Type de fichier non pris en charge. Utilisez .gpx, .kml ou .kmz.',
|
||||
'places.importFileTooLarge': 'Le fichier est trop volumineux. La taille maximale est de {maxMb} MB.',
|
||||
'places.importFileError': 'Importation échouée',
|
||||
'places.importAllSkipped': 'Tous les lieux étaient déjà dans le voyage.',
|
||||
'places.gpxImported': '{count} lieux importés depuis GPX',
|
||||
'places.kmlKmzImported': '{count} lieux importés depuis KMZ/KML',
|
||||
'places.urlResolved': 'Lieu importé depuis l\'URL',
|
||||
'places.gpxError': 'L\'import GPX a échoué',
|
||||
'places.importList': 'Import de liste',
|
||||
'places.kmlKmzImportError': 'L\'import KMZ/KML a échoué',
|
||||
'places.kmlKmzInvalidType': 'Veuillez sélectionner un fichier .kml ou .kmz.',
|
||||
'places.kmlKmzTooLarge': 'Le fichier est trop volumineux. La taille maximale est de {maxMb} MB.',
|
||||
'places.kmlKmzHint': 'Importez des fichiers de carte depuis des outils comme Google My Maps et Google Earth.',
|
||||
'places.kmlKmzSizeHint': 'Taille maximale du fichier : {maxMb} MB',
|
||||
'places.kmlKmzSelectFile': 'Sélectionner un fichier',
|
||||
'places.kmlKmzSelectedFile': 'Fichier sélectionné : {name}',
|
||||
'places.kmlKmzSummaryTitle': 'Résumé d\'import',
|
||||
'places.kmlKmzSummaryValues': 'Placemarks : {total} • Importés : {created} • Ignorés : {skipped}',
|
||||
'places.importGoogleList': 'Liste Google',
|
||||
'places.importNaverList': 'Liste Naver',
|
||||
|
||||
@@ -890,21 +890,19 @@ 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.importFile': 'Fájl importálása',
|
||||
'places.sidebarDrop': 'Ejtse el az importáláshoz',
|
||||
'places.importFileHint': '.gpx, .kml vagy .kmz fájlok importálása olyan eszközökből, mint a Google My Maps, Google Earth vagy egy GPS tracker.',
|
||||
'places.importFileDropHere': 'Kattintson egy fájl kiválasztásához, vagy húzza ide',
|
||||
'places.importFileDropActive': 'Ejtse ide a fájlt a kiválasztáshoz',
|
||||
'places.importFileUnsupported': 'Nem támogatott fájltípus. Használjon .gpx, .kml vagy .kmz fájlt.',
|
||||
'places.importFileTooLarge': 'A fájl túl nagy. A maximális feltöltési méret {maxMb} MB.',
|
||||
'places.importFileError': 'Importálás sikertelen',
|
||||
'places.importAllSkipped': 'Minden hely már szerepel az utazásban.',
|
||||
'places.gpxImported': '{count} hely importálva GPX-ből',
|
||||
'places.kmlKmzImported': '{count} hely importálva KMZ/KML-ből',
|
||||
'places.urlResolved': 'Hely importálva URL-ből',
|
||||
'places.gpxError': 'GPX importálás sikertelen',
|
||||
'places.importList': 'Lista importálás',
|
||||
'places.kmlKmzImportError': 'A KMZ/KML importálás sikertelen',
|
||||
'places.kmlKmzInvalidType': 'Válassz egy .kml vagy .kmz fájlt.',
|
||||
'places.kmlKmzTooLarge': 'A fájl túl nagy. A maximális feltöltési méret {maxMb} MB.',
|
||||
'places.kmlKmzHint': 'Térképfájlok importálása olyan eszközökből, mint a Google My Maps és a Google Earth.',
|
||||
'places.kmlKmzSizeHint': 'Maximális fájlméret: {maxMb} MB',
|
||||
'places.kmlKmzSelectFile': 'Fájl kiválasztása',
|
||||
'places.kmlKmzSelectedFile': 'Kiválasztott fájl: {name}',
|
||||
'places.kmlKmzSummaryTitle': 'Import összegzés',
|
||||
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importálva: {created} • Kihagyva: {skipped}',
|
||||
'places.importGoogleList': 'Google Lista',
|
||||
'places.importNaverList': 'Naver Lista',
|
||||
|
||||
@@ -890,21 +890,19 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Aggiungi Luogo/Attività',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.importKeyholeMarkup': 'KMZ / KML',
|
||||
'places.importFile': 'Importa file',
|
||||
'places.sidebarDrop': 'Rilascia per importare',
|
||||
'places.importFileHint': 'Importa file .gpx, .kml o .kmz da strumenti come Google My Maps, Google Earth o un tracker GPS.',
|
||||
'places.importFileDropHere': 'Clicca per selezionare un file o trascina e rilascia qui',
|
||||
'places.importFileDropActive': 'Rilascia il file per selezionarlo',
|
||||
'places.importFileUnsupported': 'Tipo di file non supportato. Usa .gpx, .kml o .kmz.',
|
||||
'places.importFileTooLarge': 'Il file è troppo grande. La dimensione massima di caricamento è {maxMb} MB.',
|
||||
'places.importFileError': 'Importazione non riuscita',
|
||||
'places.importAllSkipped': 'Tutti i luoghi erano già nel viaggio.',
|
||||
'places.gpxImported': '{count} luoghi importati da GPX',
|
||||
'places.kmlKmzImported': '{count} luoghi importati da KMZ/KML',
|
||||
'places.urlResolved': 'Luogo importato dall\'URL',
|
||||
'places.gpxError': 'Importazione GPX non riuscita',
|
||||
'places.importList': 'Importa lista',
|
||||
'places.kmlKmzImportError': 'Importazione KMZ/KML non riuscita',
|
||||
'places.kmlKmzInvalidType': 'Seleziona un file .kml o .kmz.',
|
||||
'places.kmlKmzTooLarge': 'Il file è troppo grande. La dimensione massima di caricamento è {maxMb} MB.',
|
||||
'places.kmlKmzHint': 'Importa file mappa da strumenti come Google My Maps e Google Earth.',
|
||||
'places.kmlKmzSizeHint': 'Dimensione massima file: {maxMb} MB',
|
||||
'places.kmlKmzSelectFile': 'Seleziona file',
|
||||
'places.kmlKmzSelectedFile': 'File selezionato: {name}',
|
||||
'places.kmlKmzSummaryTitle': 'Riepilogo importazione',
|
||||
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Importati: {created} • Saltati: {skipped}',
|
||||
'places.importGoogleList': 'Lista Google',
|
||||
'places.importNaverList': 'Lista Naver',
|
||||
|
||||
@@ -889,21 +889,19 @@ const nl: Record<string, string> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Plaats/activiteit toevoegen',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.importKeyholeMarkup': 'KMZ / KML',
|
||||
'places.importFile': 'Bestand importeren',
|
||||
'places.sidebarDrop': 'Loslaten om te importeren',
|
||||
'places.importFileHint': 'Importeer .gpx-, .kml- of .kmz-bestanden uit tools zoals Google My Maps, Google Earth of een GPS-tracker.',
|
||||
'places.importFileDropHere': 'Klik om een bestand te selecteren of sleep het hier naartoe',
|
||||
'places.importFileDropActive': 'Laat het bestand los om het te selecteren',
|
||||
'places.importFileUnsupported': 'Niet-ondersteund bestandstype. Gebruik .gpx, .kml of .kmz.',
|
||||
'places.importFileTooLarge': 'Bestand is te groot. Maximale uploadgrootte is {maxMb} MB.',
|
||||
'places.importFileError': 'Importeren mislukt',
|
||||
'places.importAllSkipped': 'Alle plaatsen waren al in de reis.',
|
||||
'places.gpxImported': '{count} plaatsen geïmporteerd uit GPX',
|
||||
'places.kmlKmzImported': '{count} plaatsen geïmporteerd uit KMZ/KML',
|
||||
'places.urlResolved': 'Plaats geïmporteerd van URL',
|
||||
'places.gpxError': 'GPX-import mislukt',
|
||||
'places.importList': 'Lijst importeren',
|
||||
'places.kmlKmzImportError': 'KMZ/KML-import mislukt',
|
||||
'places.kmlKmzInvalidType': 'Selecteer een .kml- of .kmz-bestand.',
|
||||
'places.kmlKmzTooLarge': 'Bestand is te groot. Maximale uploadgrootte is {maxMb} MB.',
|
||||
'places.kmlKmzHint': 'Importeer kaartbestanden uit tools zoals Google My Maps en Google Earth.',
|
||||
'places.kmlKmzSizeHint': 'Max. bestandsgrootte: {maxMb} MB',
|
||||
'places.kmlKmzSelectFile': 'Bestand selecteren',
|
||||
'places.kmlKmzSelectedFile': 'Geselecteerd bestand: {name}',
|
||||
'places.kmlKmzSummaryTitle': 'Importoverzicht',
|
||||
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Geïmporteerd: {created} • Overgeslagen: {skipped}',
|
||||
'places.importGoogleList': 'Google Lijst',
|
||||
'places.importNaverList': 'Naver Lijst',
|
||||
|
||||
@@ -856,20 +856,18 @@ 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.importFile': 'Importuj plik',
|
||||
'places.sidebarDrop': 'Upuść, aby zaimportować',
|
||||
'places.importFileHint': 'Importuj pliki .gpx, .kml lub .kmz z narzędzi takich jak Google My Maps, Google Earth lub tracker GPS.',
|
||||
'places.importFileDropHere': 'Kliknij, aby wybrać plik lub przeciągnij i upuść tutaj',
|
||||
'places.importFileDropActive': 'Upuść plik, aby go wybrać',
|
||||
'places.importFileUnsupported': 'Nieobsługiwany typ pliku. Użyj .gpx, .kml lub .kmz.',
|
||||
'places.importFileTooLarge': 'Plik jest za duży. Maksymalny rozmiar przesyłania to {maxMb} MB.',
|
||||
'places.importFileError': 'Import nie powiódł się',
|
||||
'places.importAllSkipped': 'Wszystkie miejsca były już w podróży.',
|
||||
'places.gpxImported': '{count} miejsc zaimportowanych z GPX',
|
||||
'places.kmlKmzImported': 'Zaimportowano {count} miejsc z KMZ/KML',
|
||||
'places.urlResolved': 'Miejsce zaimportowane z URL',
|
||||
'places.gpxError': 'Nie udało się zaimportować pliku GPX',
|
||||
'places.kmlKmzImportError': 'Import KMZ/KML nie powiódł się',
|
||||
'places.kmlKmzInvalidType': 'Wybierz plik .kml lub .kmz.',
|
||||
'places.kmlKmzTooLarge': 'Plik jest za duży. Maksymalny rozmiar przesyłania to {maxMb} MB.',
|
||||
'places.kmlKmzHint': 'Importuj pliki map z narzędzi takich jak Google My Maps i Google Earth.',
|
||||
'places.kmlKmzSizeHint': 'Maksymalny rozmiar pliku: {maxMb} MB',
|
||||
'places.kmlKmzSelectFile': 'Wybierz plik',
|
||||
'places.kmlKmzSelectedFile': 'Wybrany plik: {name}',
|
||||
'places.kmlKmzSummaryTitle': 'Podsumowanie importu',
|
||||
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Zaimportowano: {created} • Pominięto: {skipped}',
|
||||
'places.importGoogleList': 'Lista Google',
|
||||
'places.assignToDay': 'Do którego dnia dodać?',
|
||||
|
||||
@@ -889,21 +889,19 @@ const ru: Record<string, string> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Добавить место/активность',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.importKeyholeMarkup': 'KMZ / KML',
|
||||
'places.importFile': 'Импортировать файл',
|
||||
'places.sidebarDrop': 'Отпустите для импорта',
|
||||
'places.importFileHint': 'Импортируйте файлы .gpx, .kml или .kmz из инструментов, таких как Google My Maps, Google Earth или GPS-трекер.',
|
||||
'places.importFileDropHere': 'Нажмите для выбора файла или перетащите его сюда',
|
||||
'places.importFileDropActive': 'Отпустите файл для выбора',
|
||||
'places.importFileUnsupported': 'Неподдерживаемый тип файла. Используйте .gpx, .kml или .kmz.',
|
||||
'places.importFileTooLarge': 'Файл слишком большой. Максимальный размер загрузки — {maxMb} MB.',
|
||||
'places.importFileError': 'Ошибка импорта',
|
||||
'places.importAllSkipped': 'Все места уже были в поездке.',
|
||||
'places.gpxImported': '{count} мест импортировано из GPX',
|
||||
'places.kmlKmzImported': '{count} мест импортировано из KMZ/KML',
|
||||
'places.urlResolved': 'Место импортировано из URL',
|
||||
'places.gpxError': 'Ошибка импорта GPX',
|
||||
'places.importList': 'Импорт списка',
|
||||
'places.kmlKmzImportError': 'Ошибка импорта KMZ/KML',
|
||||
'places.kmlKmzInvalidType': 'Выберите файл .kml или .kmz.',
|
||||
'places.kmlKmzTooLarge': 'Файл слишком большой. Максимальный размер загрузки — {maxMb} MB.',
|
||||
'places.kmlKmzHint': 'Импортируйте файлы карт из инструментов, таких как Google My Maps и Google Earth.',
|
||||
'places.kmlKmzSizeHint': 'Максимальный размер файла: {maxMb} MB',
|
||||
'places.kmlKmzSelectFile': 'Выбрать файл',
|
||||
'places.kmlKmzSelectedFile': 'Выбранный файл: {name}',
|
||||
'places.kmlKmzSummaryTitle': 'Сводка импорта',
|
||||
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Импортировано: {created} • Пропущено: {skipped}',
|
||||
'places.importGoogleList': 'Список Google',
|
||||
'places.importNaverList': 'Список Naver',
|
||||
|
||||
@@ -889,21 +889,19 @@ const zh: Record<string, string> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': '添加地点/活动',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.importKeyholeMarkup': 'KMZ / KML',
|
||||
'places.importFile': '导入文件',
|
||||
'places.sidebarDrop': '拖放以导入',
|
||||
'places.importFileHint': '从 Google My Maps、Google Earth 或 GPS 追踪器等工具导入 .gpx、.kml 或 .kmz 文件。',
|
||||
'places.importFileDropHere': '点击选择文件或拖放到此处',
|
||||
'places.importFileDropActive': '释放文件以选择',
|
||||
'places.importFileUnsupported': '不支持的文件类型,请使用 .gpx、.kml 或 .kmz。',
|
||||
'places.importFileTooLarge': '文件过大。最大上传大小为 {maxMb} MB。',
|
||||
'places.importFileError': '导入失败',
|
||||
'places.importAllSkipped': '所有地点已在行程中。',
|
||||
'places.gpxImported': '已从 GPX 导入 {count} 个地点',
|
||||
'places.kmlKmzImported': '已从 KMZ/KML 导入 {count} 个地点',
|
||||
'places.urlResolved': '已从 URL 导入地点',
|
||||
'places.gpxError': 'GPX 导入失败',
|
||||
'places.importList': '列表导入',
|
||||
'places.kmlKmzImportError': 'KMZ/KML 导入失败',
|
||||
'places.kmlKmzInvalidType': '请选择 .kml 或 .kmz 文件。',
|
||||
'places.kmlKmzTooLarge': '文件过大。最大上传大小为 {maxMb} MB。',
|
||||
'places.kmlKmzHint': '可从 Google My Maps、Google Earth 等工具导入地图文件。',
|
||||
'places.kmlKmzSizeHint': '最大文件大小:{maxMb} MB',
|
||||
'places.kmlKmzSelectFile': '选择文件',
|
||||
'places.kmlKmzSelectedFile': '已选择文件:{name}',
|
||||
'places.kmlKmzSummaryTitle': '导入摘要',
|
||||
'places.kmlKmzSummaryValues': 'Placemarks:{total} • 已导入:{created} • 已跳过:{skipped}',
|
||||
'places.importGoogleList': 'Google 列表',
|
||||
'places.importNaverList': 'Naver 列表',
|
||||
|
||||
@@ -914,21 +914,19 @@ const zhTw: Record<string, string> = {
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': '新增地點/活動',
|
||||
'places.importGpx': 'GPX',
|
||||
'places.importKeyholeMarkup': 'KMZ / KML',
|
||||
'places.importFile': '匯入檔案',
|
||||
'places.sidebarDrop': '拖放以匯入',
|
||||
'places.importFileHint': '從 Google My Maps、Google Earth 或 GPS 追蹤器等工具匯入 .gpx、.kml 或 .kmz 檔案。',
|
||||
'places.importFileDropHere': '點選以選取檔案或拖放至此處',
|
||||
'places.importFileDropActive': '放開檔案以選取',
|
||||
'places.importFileUnsupported': '不支援的檔案類型,請使用 .gpx、.kml 或 .kmz。',
|
||||
'places.importFileTooLarge': '檔案過大。最大上傳大小為 {maxMb} MB。',
|
||||
'places.importFileError': '匯入失敗',
|
||||
'places.importAllSkipped': '所有地點已在行程中。',
|
||||
'places.gpxImported': '已從 GPX 匯入 {count} 個地點',
|
||||
'places.kmlKmzImported': '已從 KMZ/KML 匯入 {count} 個地點',
|
||||
'places.urlResolved': '已從 URL 匯入地點',
|
||||
'places.gpxError': 'GPX 匯入失敗',
|
||||
'places.importList': '列表匯入',
|
||||
'places.kmlKmzImportError': 'KMZ/KML 匯入失敗',
|
||||
'places.kmlKmzInvalidType': '請選擇 .kml 或 .kmz 檔案。',
|
||||
'places.kmlKmzTooLarge': '檔案過大。最大上傳大小為 {maxMb} MB。',
|
||||
'places.kmlKmzHint': '可從 Google My Maps、Google Earth 等工具匯入地圖檔案。',
|
||||
'places.kmlKmzSizeHint': '最大檔案大小:{maxMb} MB',
|
||||
'places.kmlKmzSelectFile': '選擇檔案',
|
||||
'places.kmlKmzSelectedFile': '已選擇檔案:{name}',
|
||||
'places.kmlKmzSummaryTitle': '匯入摘要',
|
||||
'places.kmlKmzSummaryValues': 'Placemarks:{total} • 已匯入:{created} • 已略過:{skipped}',
|
||||
'places.importGoogleList': 'Google 列表',
|
||||
'places.importNaverList': 'Naver 列表',
|
||||
|
||||
@@ -66,13 +66,13 @@ router.post('/import/gpx', authenticate, requireTripAccess, uploadMulter.single(
|
||||
const file = req.file as Express.Multer.File | undefined;
|
||||
if (!file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const created = importGpx(tripId, file.buffer);
|
||||
if (!created) {
|
||||
const result = importGpx(tripId, file.buffer);
|
||||
if (!result) {
|
||||
return res.status(400).json({ error: 'No waypoints found in GPX file' });
|
||||
}
|
||||
|
||||
res.status(201).json({ places: created, count: created.length });
|
||||
for (const place of created) {
|
||||
res.status(201).json({ places: result.places, count: result.count, skipped: result.skipped });
|
||||
for (const place of result.places) {
|
||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
});
|
||||
@@ -89,7 +89,7 @@ router.post('/import/map', authenticate, requireTripAccess, uploadMulter.single(
|
||||
|
||||
try {
|
||||
const result = await importMapFile(tripId, file.buffer, file.originalname);
|
||||
if (result.count === 0) {
|
||||
if (result.summary?.totalPlacemarks === 0) {
|
||||
return res.status(400).json({ error: 'No valid Placemarks found in map file', summary: result.summary });
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ router.post('/import/google-list', authenticate, requireTripAccess, async (req:
|
||||
return res.status(result.status).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName });
|
||||
res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped });
|
||||
for (const place of result.places) {
|
||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
@@ -150,7 +150,7 @@ router.post('/import/naver-list', authenticate, requireTripAccess, async (req: R
|
||||
return res.status(result.status).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName });
|
||||
res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped });
|
||||
for (const place of result.places) {
|
||||
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,13 @@ function asArray<T>(value: T | T[] | null | undefined): T[] {
|
||||
|
||||
function asTrimmedString(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
// Parsed objects (mixed-content XML parsed without stopNodes) must not
|
||||
// produce "[object Object]" — extract #text if present, else return null.
|
||||
if (typeof value === 'object') {
|
||||
const candidate = (value as Record<string, unknown>)['#text'];
|
||||
if (typeof candidate === 'string') return candidate.trim() || null;
|
||||
return null;
|
||||
}
|
||||
const text = String(value).trim();
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
@@ -73,7 +80,12 @@ export function sanitizeKmlDescription(value: unknown): string | null {
|
||||
const raw = asTrimmedString(value);
|
||||
if (!raw) return null;
|
||||
|
||||
const withLineBreaks = raw.replace(/<br\s*\/?>/gi, '\n');
|
||||
// Unwrap CDATA sections — present when fast-xml-parser returns raw node text
|
||||
// via stopNodes. Must happen before tag-stripping so the CDATA markers are
|
||||
// not mis-parsed by the <[^>]+> regex.
|
||||
const withoutCdata = raw.replace(/<!\[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')
|
||||
|
||||
@@ -255,10 +255,77 @@ const kmlParser = new XMLParser({
|
||||
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;
|
||||
@@ -310,21 +377,28 @@ export function importGpx(tripId: string, fileBuffer: Buffer) {
|
||||
|
||||
if (waypoints.length === 0) return null;
|
||||
|
||||
const dedup = buildDedupSet(tripId);
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, transport_mode, route_geometry)
|
||||
VALUES (?, ?, ?, ?, ?, 'walking', ?)
|
||||
`);
|
||||
const created: any[] = [];
|
||||
let skipped = 0;
|
||||
const insertAll = db.transaction(() => {
|
||||
for (const wp of waypoints) {
|
||||
if (isPlaceDuplicate({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng, wp.routeGeometry || null);
|
||||
const place = getPlaceWithTags(Number(result.lastInsertRowid));
|
||||
created.push(place);
|
||||
trackInsertedInDedupSet({ name: wp.name, lat: wp.lat, lng: wp.lng }, dedup);
|
||||
}
|
||||
});
|
||||
insertAll();
|
||||
|
||||
return created;
|
||||
return { places: created, count: created.length, skipped };
|
||||
}
|
||||
|
||||
export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImportResult {
|
||||
@@ -351,7 +425,9 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImport
|
||||
|
||||
const categories = db.prepare('SELECT id, name FROM categories').all() as { id: number; name: string }[];
|
||||
const categoryLookup = buildCategoryNameLookup(categories);
|
||||
const dedup = buildDedupSet(tripId);
|
||||
const created: any[] = [];
|
||||
let dupCount = 0;
|
||||
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, category_id, transport_mode)
|
||||
@@ -373,6 +449,14 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImport
|
||||
|
||||
const fallbackName = `Placemark ${fallbackIndex}`;
|
||||
const name = parsedPlacemark.name || fallbackName;
|
||||
|
||||
if (isPlaceDuplicate({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup)) {
|
||||
summary.skippedCount += 1;
|
||||
dupCount++;
|
||||
fallbackIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const categoryId = resolveCategoryIdForFolder(parsedPlacemark.folderName, categoryLookup);
|
||||
|
||||
const result = insertStmt.run(
|
||||
@@ -386,6 +470,7 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImport
|
||||
|
||||
const place = getPlaceWithTags(Number(result.lastInsertRowid));
|
||||
created.push(place);
|
||||
trackInsertedInDedupSet({ name, lat: parsedPlacemark.lat, lng: parsedPlacemark.lng }, dedup);
|
||||
summary.createdCount += 1;
|
||||
fallbackIndex += 1;
|
||||
}
|
||||
@@ -393,6 +478,10 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImport
|
||||
|
||||
insertAll();
|
||||
|
||||
if (dupCount > 0) {
|
||||
summary.warnings.push(`${dupCount} place${dupCount > 1 ? 's' : ''} skipped (already in trip).`);
|
||||
}
|
||||
|
||||
if (summary.totalPlacemarks === 0) {
|
||||
summary.errors.push('No Placemarks found in KML file.');
|
||||
}
|
||||
@@ -514,30 +603,23 @@ export async function importGoogleList(tripId: string, url: string) {
|
||||
return { error: 'No places with coordinates found in list', status: 400 };
|
||||
}
|
||||
|
||||
// Skip places that already exist in this trip (same name + coordinates within ~10m)
|
||||
const existingPlaces = db.prepare(
|
||||
'SELECT name, lat, lng FROM places WHERE trip_id = ?'
|
||||
).all(tripId) as { name: string; lat: number; lng: number }[];
|
||||
|
||||
const isDuplicate = (p: { name: string; lat: number; lng: number }) =>
|
||||
existingPlaces.some(e =>
|
||||
e.name === p.name && Math.abs(e.lat - p.lat) < 0.0001 && Math.abs(e.lng - p.lng) < 0.0001
|
||||
);
|
||||
|
||||
const newPlaces = places.filter(p => !isDuplicate(p));
|
||||
const skipped = places.length - newPlaces.length;
|
||||
|
||||
// Insert only new places into trip
|
||||
const dedup = buildDedupSet(tripId);
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO places (trip_id, name, lat, lng, notes, transport_mode)
|
||||
VALUES (?, ?, ?, ?, ?, 'walking')
|
||||
`);
|
||||
const created: any[] = [];
|
||||
let skipped = 0;
|
||||
const insertAll = db.transaction(() => {
|
||||
for (const p of newPlaces) {
|
||||
for (const p of places) {
|
||||
if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.notes);
|
||||
const place = getPlaceWithTags(Number(result.lastInsertRowid));
|
||||
created.push(place);
|
||||
trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup);
|
||||
}
|
||||
});
|
||||
insertAll();
|
||||
@@ -643,21 +725,28 @@ export async function importNaverList(
|
||||
return { error: 'No places with coordinates found in list', status: 400 };
|
||||
}
|
||||
|
||||
const dedup = buildDedupSet(tripId);
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO places (trip_id, name, lat, lng, address, notes, transport_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'walking')
|
||||
`);
|
||||
const created: any[] = [];
|
||||
let skipped = 0;
|
||||
const insertAll = db.transaction(() => {
|
||||
for (const p of places) {
|
||||
if (isPlaceDuplicate({ name: p.name, lat: p.lat, lng: p.lng }, dedup)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const result = insertStmt.run(tripId, p.name, p.lat, p.lng, p.address, p.notes);
|
||||
const place = getPlaceWithTags(Number(result.lastInsertRowid));
|
||||
created.push(place);
|
||||
trackInsertedInDedupSet({ name: p.name, lat: p.lat, lng: p.lng }, dedup);
|
||||
}
|
||||
});
|
||||
insertAll();
|
||||
|
||||
return { places: created, listName };
|
||||
return { places: created, listName, skipped };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -16,6 +16,11 @@ describe('kmlImportUtils', () => {
|
||||
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 });
|
||||
@@ -65,6 +70,18 @@ describe('kmlImportUtils', () => {
|
||||
expect(sanitizeKmlDescription('😀')).toBe('😀');
|
||||
});
|
||||
|
||||
it('does not produce [object Object] when description is a parsed object with #text', () => {
|
||||
// fast-xml-parser can return an object for mixed-content nodes when stopNodes
|
||||
// is not configured; the fallback in asTrimmedString must extract #text.
|
||||
const result = sanitizeKmlDescription({ '#text': 'Hello <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'),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user