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:
@@ -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 = {
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user