overhaul gpx creation and usage
Sonarqube Scanner / Prepare environment (push) Successful in 8s
Sonarqube Scanner / Build and test (push) Failing after 22s
Sonarqube Scanner / SonarQube analysis (push) Skipped
Sonarqube Scanner / Deploy to Server (push) Successful in 6s

This commit is contained in:
Florian Zumpe
2026-10-03 12:40:26 +02:00
parent 1cb3977269
commit 171ea88d55
29 changed files with 969 additions and 344 deletions
+3
View File
@@ -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
View File
@@ -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 });
}
+33 -10
View File
@@ -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 };