feat: GPX file import for places — closes #98

Upload a GPX file to automatically create places from waypoints.
Supports <wpt>, <rtept>, and <trkpt> elements with CDATA handling.
Handles lat/lon in any attribute order. Track-only files import
start and end points with the track name.

- New server endpoint POST /places/import/gpx
- Import GPX button in PlacesSidebar below Add Place
- i18n keys for DE and EN
This commit is contained in:
Maurice
2026-03-30 11:35:28 +02:00
parent ee54308819
commit da5e77f78d
6 changed files with 139 additions and 4 deletions

View File

@@ -91,6 +91,10 @@ export const placesApi = {
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data), update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data), delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data), searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
importGpx: (tripId: number | string, file: File) => {
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)
},
} }
export const assignmentsApi = { export const assignmentsApi = {

View File

@@ -1,15 +1,19 @@
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import { useState } from 'react' import { useState, useRef } from 'react'
import DOM from 'react-dom' import DOM from 'react-dom'
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation } from 'lucide-react' import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar' import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons' import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect' import CustomSelect from '../shared/CustomSelect'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu' import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import { placesApi } from '../../api/client'
import { useTripStore } from '../../store/tripStore'
import type { Place, Category, Day, AssignmentsMap } from '../../types' import type { Place, Category, Day, AssignmentsMap } from '../../types'
interface PlacesSidebarProps { interface PlacesSidebarProps {
tripId: number
places: Place[] places: Place[]
categories: Category[] categories: Category[]
assignments: AssignmentsMap assignments: AssignmentsMap
@@ -26,11 +30,27 @@ interface PlacesSidebarProps {
} }
export default function PlacesSidebar({ export default function PlacesSidebar({
places, categories, assignments, selectedDayId, selectedPlaceId, tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange, onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange,
}: PlacesSidebarProps) { }: PlacesSidebarProps) {
const { t } = useTranslation() const { t } = useTranslation()
const toast = useToast()
const ctxMenu = useContextMenu() const ctxMenu = useContextMenu()
const gpxInputRef = useRef<HTMLInputElement>(null)
const tripStore = useTripStore()
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 tripStore.loadTrip(tripId)
toast.success(t('places.gpxImported', { count: result.count }))
} catch (err: any) {
toast.error(err?.response?.data?.error || t('places.gpxError'))
}
}
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [filter, setFilter] = useState('all') const [filter, setFilter] = useState('all')
const [categoryFilter, setCategoryFilterLocal] = useState('') const [categoryFilter, setCategoryFilterLocal] = useState('')
@@ -72,6 +92,19 @@ export default function PlacesSidebar({
> >
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')} <Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
</button> </button>
<input ref={gpxInputRef} type="file" accept=".gpx" style={{ display: 'none' }} onChange={handleGpxImport} />
<button
onClick={() => gpxInputRef.current?.click()}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
width: '100%', padding: '5px 12px', borderRadius: 8, marginBottom: 10,
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.importGpx')}
</button>
{/* Filter-Tabs */} {/* Filter-Tabs */}
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}> <div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>

View File

@@ -638,6 +638,9 @@ const de: Record<string, string | { name: string; category: string }[]> = {
// Places Sidebar // Places Sidebar
'places.addPlace': 'Ort/Aktivität hinzufügen', 'places.addPlace': 'Ort/Aktivität hinzufügen',
'places.importGpx': 'GPX importieren',
'places.gpxImported': '{count} Orte aus GPX importiert',
'places.gpxError': 'GPX-Import fehlgeschlagen',
'places.assignToDay': 'Zu welchem Tag hinzufügen?', 'places.assignToDay': 'Zu welchem Tag hinzufügen?',
'places.all': 'Alle', 'places.all': 'Alle',
'places.unplanned': 'Ungeplant', 'places.unplanned': 'Ungeplant',

View File

@@ -638,6 +638,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
// Places Sidebar // Places Sidebar
'places.addPlace': 'Add Place/Activity', 'places.addPlace': 'Add Place/Activity',
'places.importGpx': 'Import GPX',
'places.gpxImported': '{count} places imported from GPX',
'places.gpxError': 'GPX import failed',
'places.assignToDay': 'Add to which day?', 'places.assignToDay': 'Add to which day?',
'places.all': 'All', 'places.all': 'All',
'places.unplanned': 'Unplanned', 'places.unplanned': 'Unplanned',

View File

@@ -495,6 +495,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
)} )}
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', paddingLeft: 4 }}> <div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', paddingLeft: 4 }}>
<PlacesSidebar <PlacesSidebar
tripId={tripId}
places={places} places={places}
categories={categories} categories={categories}
assignments={assignments} assignments={assignments}
@@ -604,7 +605,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
<div style={{ flex: 1, overflow: 'auto' }}> <div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left' {mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} /> ? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} />
: <PlacesSidebar places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} /> : <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} />
} }
</div> </div>
</div> </div>

View File

@@ -1,5 +1,6 @@
import express, { Request, Response } from 'express'; import express, { Request, Response } from 'express';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import multer from 'multer';
import { db, getPlaceWithTags } from '../db/database'; import { db, getPlaceWithTags } from '../db/database';
import { authenticate } from '../middleware/auth'; import { authenticate } from '../middleware/auth';
import { requireTripAccess } from '../middleware/tripAccess'; import { requireTripAccess } from '../middleware/tripAccess';
@@ -8,6 +9,8 @@ import { loadTagsByPlaceIds } from '../services/queryHelpers';
import { validateStringLengths } from '../middleware/validate'; import { validateStringLengths } from '../middleware/validate';
import { AuthRequest, Place } from '../types'; import { AuthRequest, Place } from '../types';
const gpxUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
interface PlaceWithCategory extends Place { interface PlaceWithCategory extends Place {
category_name: string | null; category_name: string | null;
category_color: string | null; category_color: string | null;
@@ -112,6 +115,94 @@ router.post('/', authenticate, requireTripAccess, validateStringLengths({ name:
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
}); });
// Import places from GPX file (must be before /:id)
router.post('/import/gpx', authenticate, requireTripAccess, gpxUpload.single('file'), (req: Request, res: Response) => {
const { tripId } = req.params;
const file = (req as any).file;
if (!file) return res.status(400).json({ error: 'No file uploaded' });
const xml = file.buffer.toString('utf-8');
const parseCoords = (attrs: string): { lat: number; lng: number } | null => {
const latMatch = attrs.match(/lat=["']([^"']+)["']/i);
const lonMatch = attrs.match(/lon=["']([^"']+)["']/i);
if (!latMatch || !lonMatch) return null;
const lat = parseFloat(latMatch[1]);
const lng = parseFloat(lonMatch[1]);
return (!isNaN(lat) && !isNaN(lng)) ? { lat, lng } : null;
};
const stripCdata = (s: string) => s.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').trim();
const extractName = (body: string) => { const m = body.match(/<name[^>]*>([\s\S]*?)<\/name>/i); return m ? stripCdata(m[1]) : null };
const extractDesc = (body: string) => { const m = body.match(/<desc[^>]*>([\s\S]*?)<\/desc>/i); return m ? stripCdata(m[1]) : null };
const waypoints: { name: string; lat: number; lng: number; description: string | null }[] = [];
// 1) Parse <wpt> elements (named waypoints / POIs)
const wptRegex = /<wpt\s([^>]+)>([\s\S]*?)<\/wpt>/gi;
let match;
while ((match = wptRegex.exec(xml)) !== null) {
const coords = parseCoords(match[1]);
if (!coords) continue;
const name = extractName(match[2]) || `Waypoint ${waypoints.length + 1}`;
waypoints.push({ ...coords, name, description: extractDesc(match[2]) });
}
// 2) If no <wpt>, try <rtept> (route points)
if (waypoints.length === 0) {
const rteptRegex = /<rtept\s([^>]+)>([\s\S]*?)<\/rtept>/gi;
while ((match = rteptRegex.exec(xml)) !== null) {
const coords = parseCoords(match[1]);
if (!coords) continue;
const name = extractName(match[2]) || `Route Point ${waypoints.length + 1}`;
waypoints.push({ ...coords, name, description: extractDesc(match[2]) });
}
}
// 3) If still nothing, extract track name + start/end points from <trkpt>
if (waypoints.length === 0) {
const trackNameMatch = xml.match(/<trk[^>]*>[\s\S]*?<name[^>]*>([\s\S]*?)<\/name>/i);
const trackName = trackNameMatch?.[1]?.trim() || 'GPX Track';
const trkptRegex = /<trkpt\s([^>]*?)(?:\/>|>([\s\S]*?)<\/trkpt>)/gi;
const trackPoints: { lat: number; lng: number }[] = [];
while ((match = trkptRegex.exec(xml)) !== null) {
const coords = parseCoords(match[1]);
if (coords) trackPoints.push(coords);
}
if (trackPoints.length > 0) {
const start = trackPoints[0];
waypoints.push({ ...start, name: `${trackName} — Start`, description: null });
if (trackPoints.length > 1) {
const end = trackPoints[trackPoints.length - 1];
waypoints.push({ ...end, name: `${trackName} — End`, description: null });
}
}
}
if (waypoints.length === 0) {
return res.status(400).json({ error: 'No waypoints found in GPX file' });
}
const insertStmt = db.prepare(`
INSERT INTO places (trip_id, name, description, lat, lng, transport_mode)
VALUES (?, ?, ?, ?, ?, 'walking')
`);
const created: any[] = [];
const insertAll = db.transaction(() => {
for (const wp of waypoints) {
const result = insertStmt.run(tripId, wp.name, wp.description, wp.lat, wp.lng);
const place = getPlaceWithTags(Number(result.lastInsertRowid));
created.push(place);
}
});
insertAll();
res.status(201).json({ places: created, count: created.length });
for (const place of created) {
broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string);
}
});
router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
const { tripId, id } = req.params const { tripId, id } = req.params