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)