From 6ba08352ed0939888395b815ab2864351c370849 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sun, 5 Apr 2026 15:54:26 +0200 Subject: [PATCH] fix(gpx): replace regex parsing with fast-xml-parser and import tracks alongside waypoints GPX files containing both and elements would only import waypoints, silently discarding track geometry. The fallback chain only parsed when no waypoints were found. Replaced all regex-based XML parsing helpers with fast-xml-parser for correctness (namespaces, CDATA, attribute ordering). Tracks are now always parsed independently of waypoints, with each element becoming its own place with route geometry. Fixes #427. --- server/package-lock.json | 63 ++++++++++++++++ server/package.json | 1 + server/src/services/placeService.ts | 109 +++++++++++----------------- 3 files changed, 108 insertions(+), 65 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 0b016fc..d8286e5 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -16,6 +16,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.1", "express": "^4.18.3", + "fast-xml-parser": "^5.5.10", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", "multer": "^2.1.1", @@ -3251,6 +3252,41 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.10", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.10.tgz", + "integrity": "sha512-go2J2xODMc32hT+4Xr/bBGXMaIoiCwrwp2mMtAvKyvEFW6S/v5Gn2pBmE4nvbwNjGhpcAiOwEv7R6/GZ6XRa9w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.2.1", + "strnum": "^2.2.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -4585,6 +4621,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.1.tgz", + "integrity": "sha512-d7gQQmLvAKXKXE2GeP9apIGbMYKz88zWdsn/BN2HRWVQsDFdUY36WSLTY0Jvd4HWi7Fb30gQ62oAOzdgJA6fZw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5437,6 +5488,18 @@ "dev": true, "license": "MIT" }, + "node_modules/strnum": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", + "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/superagent": { "version": "10.3.0", "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", diff --git a/server/package.json b/server/package.json index b60559f..dd4c616 100644 --- a/server/package.json +++ b/server/package.json @@ -21,6 +21,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.1", "express": "^4.18.3", + "fast-xml-parser": "^5.5.10", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", "multer": "^2.1.1", diff --git a/server/src/services/placeService.ts b/server/src/services/placeService.ts index 438bb60..c89cbac 100644 --- a/server/src/services/placeService.ts +++ b/server/src/services/placeService.ts @@ -1,4 +1,5 @@ import fetch from 'node-fetch'; +import { XMLParser } from 'fast-xml-parser'; import { db, getPlaceWithTags } from '../db/database'; import { loadTagsByPlaceIds } from './queryHelpers'; import { Place } from '../types'; @@ -14,33 +15,6 @@ interface UnsplashSearchResponse { errors?: string[]; } -// --------------------------------------------------------------------------- -// GPX helpers -// --------------------------------------------------------------------------- - -function 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; -} - -function stripCdata(s: string) { - return s.replace(//g, '$1').trim(); -} - -function extractName(body: string) { - const m = body.match(/]*>([\s\S]*?)<\/name>/i); - return m ? stripCdata(m[1]) : null; -} - -function extractDesc(body: string) { - const m = body.match(/]*>([\s\S]*?)<\/desc>/i); - return m ? stripCdata(m[1]) : null; -} - // --------------------------------------------------------------------------- // List places // --------------------------------------------------------------------------- @@ -244,57 +218,62 @@ export function deletePlace(tripId: string, placeId: string): boolean { // Import GPX // --------------------------------------------------------------------------- -export function importGpx(tripId: string, fileBuffer: Buffer) { - const xml = fileBuffer.toString('utf-8'); +const gpxParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + isArray: (name) => ['wpt', 'trkpt', 'rtept', 'trk', 'trkseg', 'rte'].includes(name), +}); - const waypoints: { name: string; lat: number; lng: number; description: string | null; routeGeometry?: string }[] = []; +export function importGpx(tripId: string, fileBuffer: Buffer) { + const parsed = gpxParser.parse(fileBuffer.toString('utf-8')); + const gpx = parsed?.gpx; + if (!gpx) return null; + + const str = (v: unknown) => (v != null ? String(v).trim() : null); + const num = (v: unknown) => { const n = parseFloat(String(v)); return isNaN(n) ? null : n; }; + + type WaypointEntry = { name: string; lat: number; lng: number; description: string | null; routeGeometry?: string }; + const waypoints: WaypointEntry[] = []; // 1) Parse elements (named waypoints / POIs) - const wptRegex = /]+)>([\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]) }); + for (const wpt of gpx.wpt ?? []) { + const lat = num(wpt['@_lat']); + const lng = num(wpt['@_lon']); + if (lat === null || lng === null) continue; + waypoints.push({ lat, lng, name: str(wpt.name) || `Waypoint ${waypoints.length + 1}`, description: str(wpt.desc) }); } - // 2) If no , try (route points) + // 2) If no , try route points as individual places if (waypoints.length === 0) { - const rteptRegex = /]+)>([\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]) }); + for (const rte of gpx.rte ?? []) { + for (const rtept of rte.rtept ?? []) { + const lat = num(rtept['@_lat']); + const lng = num(rtept['@_lon']); + if (lat === null || lng === null) continue; + waypoints.push({ lat, lng, name: str(rtept.name) || `Route Point ${waypoints.length + 1}`, description: str(rtept.desc) }); + } } } - // 3) If still nothing, extract full track geometry from - if (waypoints.length === 0) { - const trackNameMatch = xml.match(/]*>[\s\S]*?]*>([\s\S]*?)<\/name>/i); - const trackName = trackNameMatch?.[1]?.trim() || 'GPX Track'; - const trackDesc = (() => { const m = xml.match(/]*>[\s\S]*?]*>([\s\S]*?)<\/desc>/i); return m ? stripCdata(m[1]) : null; })(); - const trkptRegex = /]*?)(?:\/>|>([\s\S]*?)<\/trkpt>)/gi; + // 3) Extract full track geometry from (always, even if were found) + for (const trk of gpx.trk ?? []) { const trackPoints: { lat: number; lng: number; ele: number | null }[] = []; - while ((match = trkptRegex.exec(xml)) !== null) { - const coords = parseCoords(match[1]); - if (!coords) continue; - const eleMatch = match[2]?.match(/]*>([\s\S]*?)<\/ele>/i); - const ele = eleMatch ? parseFloat(eleMatch[1]) : null; - trackPoints.push({ ...coords, ele: (ele !== null && !isNaN(ele)) ? ele : null }); - } - if (trackPoints.length > 0) { - const start = trackPoints[0]; - const hasAllEle = trackPoints.every(p => p.ele !== null); - const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]); - waypoints.push({ ...start, name: trackName, description: trackDesc, routeGeometry: JSON.stringify(routeGeometry) }); + for (const seg of trk.trkseg ?? []) { + for (const pt of seg.trkpt ?? []) { + const lat = num(pt['@_lat']); + const lng = num(pt['@_lon']); + if (lat === null || lng === null) continue; + trackPoints.push({ lat, lng, ele: num(pt.ele) }); + } } + if (trackPoints.length === 0) continue; + const start = trackPoints[0]; + const hasAllEle = trackPoints.every(p => p.ele !== null); + const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]); + waypoints.push({ lat: start.lat, lng: start.lng, name: str(trk.name) || 'GPX Track', description: str(trk.desc), routeGeometry: JSON.stringify(routeGeometry) }); } - if (waypoints.length === 0) { - return null; - } + if (waypoints.length === 0) return null; const insertStmt = db.prepare(` INSERT INTO places (trip_id, name, description, lat, lng, transport_mode, route_geometry)