Merge pull request #430 from mauriceboe/fix/gpx-import-tracks-and-xml-parser
fix(gpx): replace regex parsing with fast-xml-parser and import tracks alongside waypoints
This commit is contained in:
63
server/package-lock.json
generated
63
server/package-lock.json
generated
@@ -16,6 +16,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.1",
|
"dotenv": "^16.4.1",
|
||||||
"express": "^4.18.3",
|
"express": "^4.18.3",
|
||||||
|
"fast-xml-parser": "^5.5.10",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
@@ -3251,6 +3252,41 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/file-uri-to-path": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||||
@@ -4585,6 +4621,21 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/path-key": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||||
@@ -5437,6 +5488,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/superagent": {
|
||||||
"version": "10.3.0",
|
"version": "10.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.1",
|
"dotenv": "^16.4.1",
|
||||||
"express": "^4.18.3",
|
"express": "^4.18.3",
|
||||||
|
"fast-xml-parser": "^5.5.10",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
|
import { XMLParser } from 'fast-xml-parser';
|
||||||
import { db, getPlaceWithTags } from '../db/database';
|
import { db, getPlaceWithTags } from '../db/database';
|
||||||
import { loadTagsByPlaceIds } from './queryHelpers';
|
import { loadTagsByPlaceIds } from './queryHelpers';
|
||||||
import { Place } from '../types';
|
import { Place } from '../types';
|
||||||
@@ -14,33 +15,6 @@ interface UnsplashSearchResponse {
|
|||||||
errors?: string[];
|
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(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractName(body: string) {
|
|
||||||
const m = body.match(/<name[^>]*>([\s\S]*?)<\/name>/i);
|
|
||||||
return m ? stripCdata(m[1]) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractDesc(body: string) {
|
|
||||||
const m = body.match(/<desc[^>]*>([\s\S]*?)<\/desc>/i);
|
|
||||||
return m ? stripCdata(m[1]) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// List places
|
// List places
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -244,57 +218,62 @@ export function deletePlace(tripId: string, placeId: string): boolean {
|
|||||||
// Import GPX
|
// Import GPX
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function importGpx(tripId: string, fileBuffer: Buffer) {
|
const gpxParser = new XMLParser({
|
||||||
const xml = fileBuffer.toString('utf-8');
|
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 <wpt> elements (named waypoints / POIs)
|
// 1) Parse <wpt> elements (named waypoints / POIs)
|
||||||
const wptRegex = /<wpt\s([^>]+)>([\s\S]*?)<\/wpt>/gi;
|
for (const wpt of gpx.wpt ?? []) {
|
||||||
let match;
|
const lat = num(wpt['@_lat']);
|
||||||
while ((match = wptRegex.exec(xml)) !== null) {
|
const lng = num(wpt['@_lon']);
|
||||||
const coords = parseCoords(match[1]);
|
if (lat === null || lng === null) continue;
|
||||||
if (!coords) continue;
|
waypoints.push({ lat, lng, name: str(wpt.name) || `Waypoint ${waypoints.length + 1}`, description: str(wpt.desc) });
|
||||||
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)
|
// 2) If no <wpt>, try <rte> route points as individual places
|
||||||
if (waypoints.length === 0) {
|
if (waypoints.length === 0) {
|
||||||
const rteptRegex = /<rtept\s([^>]+)>([\s\S]*?)<\/rtept>/gi;
|
for (const rte of gpx.rte ?? []) {
|
||||||
while ((match = rteptRegex.exec(xml)) !== null) {
|
for (const rtept of rte.rtept ?? []) {
|
||||||
const coords = parseCoords(match[1]);
|
const lat = num(rtept['@_lat']);
|
||||||
if (!coords) continue;
|
const lng = num(rtept['@_lon']);
|
||||||
const name = extractName(match[2]) || `Route Point ${waypoints.length + 1}`;
|
if (lat === null || lng === null) continue;
|
||||||
waypoints.push({ ...coords, name, description: extractDesc(match[2]) });
|
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 <trkpt>
|
// 3) Extract full track geometry from <trk> (always, even if <wpt> were found)
|
||||||
if (waypoints.length === 0) {
|
for (const trk of gpx.trk ?? []) {
|
||||||
const trackNameMatch = xml.match(/<trk[^>]*>[\s\S]*?<name[^>]*>([\s\S]*?)<\/name>/i);
|
|
||||||
const trackName = trackNameMatch?.[1]?.trim() || 'GPX Track';
|
|
||||||
const trackDesc = (() => { const m = xml.match(/<trk[^>]*>[\s\S]*?<desc[^>]*>([\s\S]*?)<\/desc>/i); return m ? stripCdata(m[1]) : null; })();
|
|
||||||
const trkptRegex = /<trkpt\s([^>]*?)(?:\/>|>([\s\S]*?)<\/trkpt>)/gi;
|
|
||||||
const trackPoints: { lat: number; lng: number; ele: number | null }[] = [];
|
const trackPoints: { lat: number; lng: number; ele: number | null }[] = [];
|
||||||
while ((match = trkptRegex.exec(xml)) !== null) {
|
for (const seg of trk.trkseg ?? []) {
|
||||||
const coords = parseCoords(match[1]);
|
for (const pt of seg.trkpt ?? []) {
|
||||||
if (!coords) continue;
|
const lat = num(pt['@_lat']);
|
||||||
const eleMatch = match[2]?.match(/<ele[^>]*>([\s\S]*?)<\/ele>/i);
|
const lng = num(pt['@_lon']);
|
||||||
const ele = eleMatch ? parseFloat(eleMatch[1]) : null;
|
if (lat === null || lng === null) continue;
|
||||||
trackPoints.push({ ...coords, ele: (ele !== null && !isNaN(ele)) ? ele : null });
|
trackPoints.push({ lat, lng, ele: num(pt.ele) });
|
||||||
}
|
}
|
||||||
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) });
|
|
||||||
}
|
}
|
||||||
|
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) {
|
if (waypoints.length === 0) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const insertStmt = db.prepare(`
|
const insertStmt = db.prepare(`
|
||||||
INSERT INTO places (trip_id, name, description, lat, lng, transport_mode, route_geometry)
|
INSERT INTO places (trip_id, name, description, lat, lng, transport_mode, route_geometry)
|
||||||
|
|||||||
Reference in New Issue
Block a user