renew schema and tests for new dtabases
Sonarqube Scanner / Prepare environment (push) Successful in 10s
Sonarqube Scanner / Build and test (push) Successful in 32s
Sonarqube Scanner / SonarQube analysis (push) Successful in 1m41s
Sonarqube Scanner / Deploy to Server (push) Successful in 10s

This commit is contained in:
Florian Zumpe
2026-10-03 13:57:17 +02:00
parent edfe227641
commit 5fe79067b0
9 changed files with 189 additions and 108 deletions
+2 -2
View File
@@ -42,7 +42,7 @@ export function simplifyTrack(points, radiusM = config.gpxClusterMeters) {
return result.map((point, sequence) => ({ ...point, sequence }));
}
export function parseGpx(xml, replacementPoints = null) {
export function parseGpx(xml, replacementPoints = null, { allowEmpty = false } = {}) {
const gpx = parseXml(xml)?.gpx;
if (!gpx) throw new HttpError(400, 'GPX-Wurzelelement fehlt.');
const tracks = asList(gpx.trk);
@@ -51,7 +51,7 @@ export function parseGpx(xml, replacementPoints = null) {
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.');
if (!allowEmpty && !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 metricPoints = points.length ? points : waypoints;
const metrics = metricPoints.length ? routeMetrics(metricPoints) : {
startLat: null, startLon: null, centerLat: null, centerLon: null,
+42 -59
View File
@@ -4,7 +4,7 @@ import { transaction } from '../database.js';
import { config } from '../config.js';
import { HttpError } from '../middleware/errors.js';
import { parseGpx, gpxWithWaypoints } from './gpx.js';
import { distanceMeters, routeMetrics } from './geo.js';
import { distanceMeters } from './geo.js';
import {
ensureRouteDirectories,
moveUploadedFile,
@@ -16,10 +16,13 @@ import {
removeStoredFile
} from './storage.js';
const INSERT_POINT_SQL = 'INSERT INTO route_points (route_id, sequence, lat, lon, elevation, recorded_at) VALUES (?, ?, ?, ?, ?, ?)';
const UPDATE_METRICS_SQL = `UPDATE routes SET
start_lat=?, start_lon=?, center_lat=?, center_lon=?, min_lat=?, min_lon=?, max_lat=?, max_lon=?,
distance_m=?, elevation_gain_m=?, point_count=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`;
const UPDATE_BOUNDS_SQL = `UPDATE routes SET
min_lat=?, min_lon=?, max_lat=?, max_lon=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`;
function readRouteGpx(row) {
const filename = resolveStoredFile(row.gpx_path, { allowTrash: row.status === 'deleted' });
return parseGpx(fs.readFileSync(filename, 'utf8'), null, { allowEmpty: true });
}
function trimEdgeHyphens(value) {
let start = 0;
@@ -41,8 +44,9 @@ const routeGpxUrl = routeId => `/api/routes/${routeId}/gpx`;
const pictureUrl = (routeId, poiId, pictureId) => `/api/routes/${routeId}/pois/${poiId}/pictures/${pictureId}`;
const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/pois/${poiId}/audio`;
function rowToRoute(row, includeDeleted = false) {
function rowToRoute(row, includeDeleted = false, parsed = null) {
if (!row || (!includeDeleted && row.status !== 'active')) return null;
const { metrics } = parsed ?? readRouteGpx(row);
return {
id: row.id,
slug: row.slug,
@@ -51,14 +55,14 @@ function rowToRoute(row, includeDeleted = false) {
schoolName: row.school_name,
status: row.status,
activatedAt: row.activated_at,
start: row.start_lat == null ? null : { lat: row.start_lat, lon: row.start_lon },
center: row.center_lat == null ? null : { lat: row.center_lat, lon: row.center_lon },
start: metrics.startLat == null ? null : { lat: metrics.startLat, lon: metrics.startLon },
center: metrics.centerLat == null ? null : { lat: metrics.centerLat, lon: metrics.centerLon },
bounds: row.min_lat == null
? null
: { minLat: row.min_lat, minLon: row.min_lon, maxLat: row.max_lat, maxLon: row.max_lon },
distanceM: row.distance_m,
elevationGainM: row.elevation_gain_m,
pointCount: row.point_count,
distanceM: metrics.distanceM,
elevationGainM: metrics.elevationGainM,
pointCount: metrics.pointCount,
gpxUrl: row.gpx_path && row.status === 'active' ? routeGpxUrl(row.id) : null,
createdAt: row.created_at,
updatedAt: row.updated_at,
@@ -71,26 +75,22 @@ function routeComparator(origin) {
return (left, right) => left.name.localeCompare(right.name, 'de');
}
function insertRoutePoints(db, routeId, points, offset = 0) {
const insert = db.prepare(INSERT_POINT_SQL);
points.forEach((point, index) => {
insert.run(routeId, offset + index, point.lat, point.lon, point.elevation, point.recordedAt);
});
function withinRouteBounds(row, origin, maximumDistance) {
if (!origin) return true;
if (row.min_lat == null || row.min_lon == null || row.max_lat == null || row.max_lon == null) return false;
const nearest = {
lat: Math.max(row.min_lat, Math.min(row.max_lat, origin.lat)),
lon: Math.max(row.min_lon, Math.min(row.max_lon, origin.lon))
};
return distanceMeters(origin, nearest) <= maximumDistance;
}
function updateMetrics(db, routeId, metrics) {
db.prepare(UPDATE_METRICS_SQL).run(
metrics.startLat,
metrics.startLon,
metrics.centerLat,
metrics.centerLon,
function updateBounds(db, routeId, metrics) {
db.prepare(UPDATE_BOUNDS_SQL).run(
metrics.minLat,
metrics.minLon,
metrics.maxLat,
metrics.maxLon,
metrics.distanceM,
metrics.elevationGainM,
metrics.pointCount,
routeId
);
}
@@ -109,7 +109,6 @@ function uniqueSlug(db, source) {
function cleanupFailedRoute(db, routeId, gpxFile) {
removeUpload(gpxFile);
if (!routeId) return;
db.prepare('DELETE FROM route_points WHERE route_id = ?').run(routeId);
db.prepare('DELETE FROM routes WHERE id = ?').run(routeId);
fs.rmSync(routeDirectory(routeId), { recursive: true, force: true });
}
@@ -139,22 +138,22 @@ export function listRoutes(db, {
const origin = Number.isFinite(lat) && Number.isFinite(lon) ? { lat, lon } : null;
const maximumDistance = radiusKm * 1000;
return rows
.filter(row => withinRouteBounds(row, origin, maximumDistance))
.map(row => {
const route = rowToRoute(row, includeDeleted);
const proximityM = origin && route?.start ? distanceMeters(origin, route.start) : null;
return { ...route, proximityM };
})
.filter(route => !origin || route.proximityM <= maximumDistance)
.filter(route => !origin || (route.proximityM != null && route.proximityM <= maximumDistance))
.sort(routeComparator(origin));
}
export function getRoute(db, id, includeDeleted = false) {
const row = db.prepare('SELECT * FROM routes WHERE id = ?').get(id);
const route = rowToRoute(row, includeDeleted);
if (!route) throw new HttpError(404, 'Strecke nicht gefunden.');
const points = db.prepare(`SELECT sequence, lat, lon, elevation, recorded_at AS recordedAt
FROM route_points WHERE route_id = ? ORDER BY sequence`).all(id);
if (!row || (!includeDeleted && row.status !== 'active')) throw new HttpError(404, 'Strecke nicht gefunden.');
const parsed = readRouteGpx(row);
const route = rowToRoute(row, includeDeleted, parsed);
const { points } = parsed;
return { ...route, points, pois: listPois(db, id) };
}
@@ -178,28 +177,19 @@ export function createRoute(db, fields, gpxFile) {
const slug = uniqueSlug(db, fields.slug || name);
const metrics = parsed.metrics;
const result = db.prepare(`INSERT INTO routes
(slug, name, description, school_name, gpx_path, start_lat, start_lon, center_lat, center_lon,
min_lat, min_lon, max_lat, max_lon, distance_m, elevation_gain_m, point_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
(slug, name, description, school_name, gpx_path, min_lat, min_lon, max_lat, max_lon)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
slug,
name,
fields.description || '',
fields.schoolName || '',
'pending',
metrics.startLat,
metrics.startLon,
metrics.centerLat,
metrics.centerLon,
metrics.minLat,
metrics.minLon,
metrics.maxLat,
metrics.maxLon,
metrics.distanceM,
metrics.elevationGainM,
metrics.pointCount
metrics.maxLon
);
const id = Number(result.lastInsertRowid);
insertRoutePoints(db, id, parsed.points);
return id;
});
ensureRouteDirectories(routeId);
@@ -226,10 +216,6 @@ export function updateRoute(db, id, fields, gpxFile) {
fields.schoolName ?? existing.school_name,
id
);
if (!parsed) return;
db.prepare('DELETE FROM route_points WHERE route_id = ?').run(id);
insertRoutePoints(db, id, parsed.points);
updateMetrics(db, id, parsed.metrics);
});
if (gpxFile) {
@@ -242,22 +228,18 @@ export function updateRoute(db, id, fields, gpxFile) {
export function appendRoute(db, id, gpxFile) {
if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.');
const existing = db.prepare("SELECT id FROM routes WHERE id = ? AND status = 'active'").get(id);
const existing = db.prepare("SELECT * FROM routes WHERE id = ? AND status = 'active'").get(id);
if (!existing) throw new HttpError(404, 'Strecke nicht gefunden.');
const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8'));
const current = db.prepare(`SELECT lat, lon, elevation, recorded_at AS recordedAt
FROM route_points WHERE route_id = ? ORDER BY sequence`).all(id);
const current = readRouteGpx(existing).points;
const allPoints = current.concat(parsed.points).map((point, sequence) => ({ ...point, sequence }));
transaction(db, () => {
insertRoutePoints(db, id, parsed.points, current.length);
updateMetrics(db, id, routeMetrics(allPoints));
});
removeUpload(gpxFile);
const target = resolveStoredFile(db.prepare('SELECT gpx_path FROM routes WHERE id = ?').get(id).gpx_path);
const target = resolveStoredFile(existing.gpx_path);
const original = fs.readFileSync(target, 'utf8');
fs.writeFileSync(target, parseGpx(original, allPoints).normalizedXml);
const normalized = parseGpx(original, allPoints);
fs.writeFileSync(target, normalized.normalizedXml);
updateBounds(db, id, normalized.metrics);
removeUpload(gpxFile);
return getRoute(db, id);
}
@@ -296,6 +278,7 @@ function syncRouteWaypoints(db, routeId) {
const filename = resolveStoredFile(row.gpx_path);
const xml = fs.readFileSync(filename, 'utf8');
fs.writeFileSync(filename, gpxWithWaypoints(xml, listPois(db, routeId)));
updateBounds(db, routeId, parseGpx(fs.readFileSync(filename, 'utf8'), null, { allowEmpty: true }).metrics);
}
function poiCoordinates(fields, existing = {}) {
+3 -2
View File
@@ -84,8 +84,9 @@ export function restoreRouteDirectory(id, trashRelativePath) {
};
}
export function resolveStoredFile(relativePath) {
if (!relativePath?.startsWith('active/')) {
export function resolveStoredFile(relativePath, { allowTrash = false } = {}) {
const inTrash = allowTrash && relativePath?.startsWith('trash/routes/');
if (!relativePath?.startsWith('active/') && !inTrash) {
throw new HttpError(404, 'Aktive Mediendatei nicht gefunden.');
}