overhaul gpx creation and usage
This commit is contained in:
@@ -6,12 +6,15 @@ const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '
|
||||
const resolveFromRoot = (value, fallback) => path.resolve(projectRoot, value || fallback);
|
||||
const maxUploadBytes = Number.parseInt(process.env.MAX_UPLOAD_MB || '50', 10) * 1024 * 1024;
|
||||
|
||||
const clusterMeters = Number.parseFloat(process.env.GPX_CLUSTER_METERS ?? '20');
|
||||
|
||||
export const config = Object.freeze({
|
||||
projectRoot,
|
||||
host: process.env.HOST || '127.0.0.1',
|
||||
port: Number.parseInt(process.env.PORT || '47145', 10),
|
||||
dataDir: resolveFromRoot(process.env.DATA_DIR, 'data'),
|
||||
storageDir: resolveFromRoot(process.env.STORAGE_DIR, 'storage'),
|
||||
gpxClusterMeters: Number.isFinite(clusterMeters) ? Math.max(0, clusterMeters) : 20,
|
||||
maxUploadBytes,
|
||||
maxJsonBodyBytes: Math.ceil(maxUploadBytes * 4 / 3) + 1024 * 1024,
|
||||
defaultRouteRadiusKm: Number.parseFloat(process.env.DEFAULT_ROUTE_RADIUS_KM || '25'),
|
||||
|
||||
+64
-29
@@ -1,12 +1,11 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { XMLParser, XMLBuilder, XMLValidator } from 'fast-xml-parser';
|
||||
import { HttpError } from '../middleware/errors.js';
|
||||
import { routeMetrics } from './geo.js';
|
||||
import { distanceMeters, routeMetrics } from './geo.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
trimValues: true
|
||||
});
|
||||
const xmlOptions = { ignoreAttributes: false, attributeNamePrefix: '@_', trimValues: true };
|
||||
const parser = new XMLParser(xmlOptions);
|
||||
const builder = new XMLBuilder({ ...xmlOptions, format: true });
|
||||
|
||||
function asList(value) {
|
||||
if (value == null) return [];
|
||||
@@ -14,11 +13,8 @@ function asList(value) {
|
||||
}
|
||||
|
||||
function parseXml(xml) {
|
||||
try {
|
||||
return parser.parse(xml);
|
||||
} catch (error) {
|
||||
throw new HttpError(400, 'Die GPX-Datei ist kein gültiges XML.', error.message);
|
||||
}
|
||||
if (XMLValidator.validate(xml) !== true) throw new HttpError(400, 'Die GPX-Datei ist kein gültiges XML.');
|
||||
return parser.parse(xml);
|
||||
}
|
||||
|
||||
function toRoutePoint(point, sequence) {
|
||||
@@ -31,27 +27,66 @@ function toRoutePoint(point, sequence) {
|
||||
};
|
||||
}
|
||||
|
||||
function trackPoints(tracks) {
|
||||
return tracks
|
||||
.flatMap(track => asList(track.trkseg))
|
||||
.flatMap(segment => asList(segment.trkpt))
|
||||
.map(toRoutePoint)
|
||||
.filter(point => Number.isFinite(point.lat) && Number.isFinite(point.lon));
|
||||
function validPoints(raw) {
|
||||
return raw.map(toRoutePoint).filter(point => Number.isFinite(point.lat) && Number.isFinite(point.lon)
|
||||
&& Math.abs(point.lat) <= 90 && Math.abs(point.lon) <= 180).map((point, sequence) => ({ ...point, sequence }));
|
||||
}
|
||||
|
||||
export function parseGpx(xml) {
|
||||
// Each cluster stays within the radius of its first point, rather than chaining
|
||||
// neighbouring points together and accidentally collapsing a walked section.
|
||||
export function simplifyTrack(points, radiusM = config.gpxClusterMeters) {
|
||||
const result = [];
|
||||
for (const point of points) {
|
||||
if (!result.length || distanceMeters(result.at(-1), point) > radiusM) result.push(point);
|
||||
}
|
||||
return result.map((point, sequence) => ({ ...point, sequence }));
|
||||
}
|
||||
|
||||
export function parseGpx(xml, replacementPoints = null) {
|
||||
const gpx = parseXml(xml)?.gpx;
|
||||
if (!gpx) throw new HttpError(400, 'GPX-Wurzelelement fehlt.');
|
||||
|
||||
const tracks = asList(gpx.trk);
|
||||
const points = trackPoints(tracks);
|
||||
if (!points.length) {
|
||||
throw new HttpError(400, 'Die GPX-Datei enthält keine verwertbaren Trackpunkte.');
|
||||
}
|
||||
|
||||
return {
|
||||
name: tracks[0]?.name || gpx.metadata?.name || null,
|
||||
points,
|
||||
metrics: routeMetrics(points)
|
||||
const routes = asList(gpx.rte);
|
||||
const routed = validPoints(routes.flatMap(route => asList(route.rtept)));
|
||||
const recorded = validPoints(tracks.flatMap(track => asList(track.trkseg)).flatMap(segment => asList(segment.trkpt)));
|
||||
const points = replacementPoints ?? (routed.length ? routed : simplifyTrack(recorded));
|
||||
const waypoints = validPoints(asList(gpx.wpt));
|
||||
if (!points.length && !waypoints.length && !gpx.extensions?.['wegwichtel:gpsGap'] && !gpx.extensions?.['wegwichtel:pendingWaypoint']) throw new HttpError(400, 'Die GPX-Datei enthält keine verwertbaren Punkte.');
|
||||
const metrics = points.length || waypoints.length ? routeMetrics(points.length ? points : waypoints) : {
|
||||
startLat: null, startLon: null, centerLat: null, centerLon: null,
|
||||
minLat: null, minLon: null, maxLat: null, maxLon: null,
|
||||
distanceM: 0, elevationGainM: 0, pointCount: 0
|
||||
};
|
||||
if (!points.length) { metrics.distanceM = 0; metrics.elevationGainM = 0; metrics.pointCount = 0; }
|
||||
const name = routes[0]?.name || tracks[0]?.name || gpx.metadata?.name || null;
|
||||
delete gpx.trk;
|
||||
delete gpx.rte;
|
||||
// GPX 1.1 order: metadata, waypoints, route, extensions.
|
||||
const { extensions, ...base } = gpx;
|
||||
const normalized = { ...base, '@_version': '1.1', '@_creator': 'Wegwichtel', '@_xmlns': 'http://www.topografix.com/GPX/1/1' };
|
||||
if (points.length) normalized.rte = { ...(name ? { name } : {}), rtept: points.map(point => ({
|
||||
'@_lat': point.lat, '@_lon': point.lon,
|
||||
...(Number.isFinite(point.elevation) ? { ele: point.elevation } : {}),
|
||||
...(point.recordedAt ? { time: point.recordedAt } : {})
|
||||
})) };
|
||||
if (extensions) normalized.extensions = extensions;
|
||||
return { name, points, metrics, normalizedXml: '<?xml version="1.0" encoding="UTF-8"?>\n' + builder.build({ gpx: normalized }) };
|
||||
}
|
||||
|
||||
export function gpxWithWaypoints(xml, pois) {
|
||||
const gpx = parseXml(xml).gpx;
|
||||
const { wpt: _oldWaypoints, rte, trk, extensions = {}, ...base } = gpx;
|
||||
const waypoint = poi => ({ '@_lat': poi.lat, '@_lon': poi.lon, name: poi.title,
|
||||
...(poi.description ? { desc: poi.description } : {}),
|
||||
extensions: { 'wegwichtel:poiId': poi.id } });
|
||||
const located = pois.filter(poi => Number.isFinite(poi.lat) && Number.isFinite(poi.lon));
|
||||
const pending = pois.filter(poi => !Number.isFinite(poi.lat) || !Number.isFinite(poi.lon));
|
||||
const { 'wegwichtel:pendingWaypoint': _oldPending, ...extra } = extensions;
|
||||
if (pending.length) extra['wegwichtel:pendingWaypoint'] = pending.map(poi => ({ name: poi.title,
|
||||
...(poi.description ? { desc: poi.description } : {}), extensions: { 'wegwichtel:poiId': poi.id } }));
|
||||
const normalized = { ...base, '@_xmlns:wegwichtel': 'urn:wegwichtel:poi',
|
||||
...(located.length ? { wpt: located.map(waypoint) } : {}),
|
||||
...(rte ? { rte } : {}), ...(trk ? { trk } : {}),
|
||||
...(Object.keys(extra).length ? { extensions: extra } : {}) };
|
||||
return '<?xml version="1.0" encoding="UTF-8"?>\n' + builder.build({ gpx: normalized });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from 'node:path';
|
||||
import { transaction } from '../database.js';
|
||||
import { config } from '../config.js';
|
||||
import { HttpError } from '../middleware/errors.js';
|
||||
import { parseGpx } from './gpx.js';
|
||||
import { parseGpx, gpxWithWaypoints } from './gpx.js';
|
||||
import { distanceMeters, routeMetrics } from './geo.js';
|
||||
import {
|
||||
ensureRouteDirectories,
|
||||
@@ -203,6 +203,7 @@ export function createRoute(db, fields, gpxFile) {
|
||||
return id;
|
||||
});
|
||||
ensureRouteDirectories(routeId);
|
||||
fs.writeFileSync(gpxFile.path, parsed.normalizedXml);
|
||||
const gpxPath = moveUploadedFile(gpxFile, path.join(routeDirectory(routeId), 'route.gpx'));
|
||||
db.prepare('UPDATE routes SET gpx_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
||||
.run(gpxPath, routeId);
|
||||
@@ -231,7 +232,11 @@ export function updateRoute(db, id, fields, gpxFile) {
|
||||
updateMetrics(db, id, parsed.metrics);
|
||||
});
|
||||
|
||||
if (gpxFile) moveUploadedFile(gpxFile, path.join(routeDirectory(id), 'route.gpx'));
|
||||
if (gpxFile) {
|
||||
fs.writeFileSync(gpxFile.path, parsed.normalizedXml);
|
||||
moveUploadedFile(gpxFile, path.join(routeDirectory(id), 'route.gpx'));
|
||||
syncRouteWaypoints(db, id);
|
||||
}
|
||||
return getRoute(db, id);
|
||||
}
|
||||
|
||||
@@ -250,6 +255,9 @@ export function appendRoute(db, id, gpxFile) {
|
||||
});
|
||||
|
||||
removeUpload(gpxFile);
|
||||
const target = resolveStoredFile(db.prepare('SELECT gpx_path FROM routes WHERE id = ?').get(id).gpx_path);
|
||||
const original = fs.readFileSync(target, 'utf8');
|
||||
fs.writeFileSync(target, parseGpx(original, allPoints).normalizedXml);
|
||||
return getRoute(db, id);
|
||||
}
|
||||
|
||||
@@ -283,15 +291,28 @@ export function getPoi(db, routeId, poiId) {
|
||||
return listPois(db, routeId).find(poi => poi.id === Number(poiId));
|
||||
}
|
||||
|
||||
function syncRouteWaypoints(db, routeId) {
|
||||
const row = db.prepare('SELECT gpx_path FROM routes WHERE id = ?').get(routeId);
|
||||
const filename = resolveStoredFile(row.gpx_path);
|
||||
const xml = fs.readFileSync(filename, 'utf8');
|
||||
fs.writeFileSync(filename, gpxWithWaypoints(xml, listPois(db, routeId)));
|
||||
}
|
||||
|
||||
function poiCoordinates(fields, existing = {}) {
|
||||
const values = ['lat', 'lon'].map(key => Object.hasOwn(fields, key) ? fields[key] : existing[key]);
|
||||
const [lat, lon] = values.map(value => value == null || value === '' ? null : Number.parseFloat(value));
|
||||
const absent = lat === null && lon === null;
|
||||
if (!absent && (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180)) {
|
||||
throw new HttpError(400, 'Gültige POI-Koordinaten sind erforderlich.');
|
||||
}
|
||||
return { lat, lon };
|
||||
}
|
||||
|
||||
export function createPoi(db, routeId, fields) {
|
||||
const route = db.prepare("SELECT id FROM routes WHERE id = ? AND status = 'active'").get(routeId);
|
||||
if (!route) throw new HttpError(404, 'Strecke nicht gefunden.');
|
||||
|
||||
const lat = Number.parseFloat(fields.lat);
|
||||
const lon = Number.parseFloat(fields.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
throw new HttpError(400, 'Gültige POI-Koordinaten sind erforderlich.');
|
||||
}
|
||||
const { lat, lon } = poiCoordinates(fields);
|
||||
const result = db.prepare(`INSERT INTO pois
|
||||
(route_id, title, description, lat, lon, trigger_radius_m, sequence) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
routeId,
|
||||
@@ -302,6 +323,7 @@ export function createPoi(db, routeId, fields) {
|
||||
Number.parseFloat(fields.triggerRadiusM || config.defaultPoiTriggerMeters),
|
||||
Number.parseInt(fields.sequence || '0', 10)
|
||||
);
|
||||
syncRouteWaypoints(db, routeId);
|
||||
return listPois(db, routeId).find(poi => poi.id === Number(result.lastInsertRowid));
|
||||
}
|
||||
|
||||
@@ -309,13 +331,12 @@ export function updatePoi(db, routeId, poiId, fields) {
|
||||
const existing = db.prepare(`SELECT p.*, r.status FROM pois p JOIN routes r ON r.id = p.route_id
|
||||
WHERE p.id = ? AND p.route_id = ?`).get(poiId, routeId);
|
||||
if (existing?.status !== 'active') throw new HttpError(404, 'POI nicht gefunden.');
|
||||
const lat = fields.lat == null ? existing.lat : Number.parseFloat(fields.lat);
|
||||
const lon = fields.lon == null ? existing.lon : Number.parseFloat(fields.lon);
|
||||
const { lat, lon } = poiCoordinates(fields, existing);
|
||||
const radius = fields.triggerRadiusM == null
|
||||
? existing.trigger_radius_m
|
||||
: Number.parseFloat(fields.triggerRadiusM);
|
||||
const sequence = fields.sequence == null ? existing.sequence : Number.parseInt(fields.sequence, 10);
|
||||
if (![lat, lon, radius].every(Number.isFinite) || !Number.isInteger(sequence) || sequence < 0) {
|
||||
if (!Number.isFinite(radius) || !Number.isInteger(sequence) || sequence < 0) {
|
||||
throw new HttpError(400, 'POI-Koordinaten, Auslöseradius oder Reihenfolge sind ungültig.');
|
||||
}
|
||||
db.prepare(`UPDATE pois SET title=?, description=?, lat=?, lon=?, trigger_radius_m=?, sequence=?,
|
||||
@@ -328,6 +349,7 @@ export function updatePoi(db, routeId, poiId, fields) {
|
||||
sequence,
|
||||
poiId
|
||||
);
|
||||
syncRouteWaypoints(db, existing.route_id);
|
||||
return listPois(db, existing.route_id).find(poi => poi.id === Number(poiId));
|
||||
}
|
||||
|
||||
@@ -341,6 +363,7 @@ export function deletePoi(db, routeId, poiId) {
|
||||
db.prepare('DELETE FROM pois WHERE id = ? AND route_id = ?').run(poiId, routeId);
|
||||
});
|
||||
|
||||
syncRouteWaypoints(db, routeId);
|
||||
images.forEach(image => removeStoredFile(image.path));
|
||||
removeStoredFile(existing.audio_path);
|
||||
return { id: poiId, routeId, deleted: true };
|
||||
|
||||
Reference in New Issue
Block a user