390 lines
15 KiB
JavaScript
390 lines
15 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { transaction } from '../database.js';
|
|
import { config } from '../config.js';
|
|
import { HttpError } from '../middleware/errors.js';
|
|
import { parseGpx, gpxWithWaypoints } from './gpx.js';
|
|
import { distanceMeters } from './geo.js';
|
|
import {
|
|
ensureRouteDirectories,
|
|
moveUploadedFile,
|
|
routeDirectory,
|
|
softDeleteRouteDirectory,
|
|
restoreRouteDirectory,
|
|
resolveStoredFile,
|
|
removeUpload,
|
|
removeStoredFile
|
|
} from './storage.js';
|
|
|
|
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;
|
|
let end = value.length;
|
|
while (start < end && value[start] === '-') start += 1;
|
|
while (end > start && value[end - 1] === '-') end -= 1;
|
|
return value.slice(start, end);
|
|
}
|
|
|
|
function slugify(value) {
|
|
const normalized = String(value).toLowerCase().normalize('NFKD');
|
|
const withoutMarks = normalized.replaceAll(/[\u0300-\u036f]/g, '');
|
|
const separated = withoutMarks.replaceAll(/[^a-z0-9]+/g, '-');
|
|
const trimmed = trimEdgeHyphens(separated);
|
|
return trimmed.slice(0, 80) || `route-${Date.now()}`;
|
|
}
|
|
|
|
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, parsed = null) {
|
|
if (!row || (!includeDeleted && row.status !== 'active')) return null;
|
|
const { metrics } = parsed ?? readRouteGpx(row);
|
|
return {
|
|
id: row.id,
|
|
slug: row.slug,
|
|
name: row.name,
|
|
description: row.description,
|
|
schoolName: row.school_name,
|
|
status: row.status,
|
|
activatedAt: row.activated_at,
|
|
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: 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,
|
|
deletedAt: row.deleted_at
|
|
};
|
|
}
|
|
|
|
function routeComparator(origin) {
|
|
if (origin) return (left, right) => left.proximityM - right.proximityM;
|
|
return (left, right) => left.name.localeCompare(right.name, 'de');
|
|
}
|
|
|
|
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 updateBounds(db, routeId, metrics) {
|
|
db.prepare(UPDATE_BOUNDS_SQL).run(
|
|
metrics.minLat,
|
|
metrics.minLon,
|
|
metrics.maxLat,
|
|
metrics.maxLon,
|
|
routeId
|
|
);
|
|
}
|
|
|
|
function uniqueSlug(db, source) {
|
|
const base = slugify(source);
|
|
let candidate = base;
|
|
let suffix = 2;
|
|
while (db.prepare('SELECT 1 FROM routes WHERE slug = ?').get(candidate)) {
|
|
candidate = `${base}-${suffix}`;
|
|
suffix += 1;
|
|
}
|
|
return candidate;
|
|
}
|
|
|
|
function cleanupFailedRoute(db, routeId, gpxFile) {
|
|
removeUpload(gpxFile);
|
|
if (!routeId) return;
|
|
db.prepare('DELETE FROM routes WHERE id = ?').run(routeId);
|
|
fs.rmSync(routeDirectory(routeId), { recursive: true, force: true });
|
|
}
|
|
|
|
function rewriteMediaPaths(db, routeId, sourcePrefix, targetPrefix) {
|
|
db.prepare('UPDATE pois SET audio_path = REPLACE(audio_path, ?, ?) WHERE route_id = ? AND audio_path IS NOT NULL')
|
|
.run(sourcePrefix, targetPrefix, routeId);
|
|
db.prepare(`UPDATE poi_images SET path = REPLACE(path, ?, ?)
|
|
WHERE poi_id IN (SELECT id FROM pois WHERE route_id = ?)`)
|
|
.run(sourcePrefix, targetPrefix, routeId);
|
|
}
|
|
|
|
export function listRoutes(db, {
|
|
lat,
|
|
lon,
|
|
radiusKm = config.defaultRouteRadiusKm,
|
|
includeDeleted = false,
|
|
publishedOnly = false
|
|
}) {
|
|
let where = '';
|
|
if (!includeDeleted) {
|
|
where = publishedOnly
|
|
? "WHERE status = 'active' AND activated_at IS NOT NULL"
|
|
: "WHERE status = 'active'";
|
|
}
|
|
const rows = db.prepare(`SELECT * FROM routes ${where} ORDER BY name COLLATE NOCASE`).all();
|
|
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 != 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);
|
|
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) };
|
|
}
|
|
|
|
export function getRouteGpxFile(db, id) {
|
|
const route = db.prepare("SELECT id, slug, gpx_path FROM routes WHERE id = ? AND status = 'active'").get(id);
|
|
if (!route) throw new HttpError(404, 'Strecke nicht gefunden.');
|
|
return {
|
|
absolutePath: resolveStoredFile(route.gpx_path),
|
|
filename: `${route.slug}.gpx`
|
|
};
|
|
}
|
|
|
|
export function createRoute(db, fields, gpxFile) {
|
|
if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.');
|
|
let routeId;
|
|
try {
|
|
const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8'));
|
|
const name = String(fields.name || parsed.name || '').trim();
|
|
if (!name) throw new HttpError(400, 'Ein Streckenname ist erforderlich.');
|
|
routeId = transaction(db, () => {
|
|
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, min_lat, min_lon, max_lat, max_lon)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
|
slug,
|
|
name,
|
|
fields.description || '',
|
|
fields.schoolName || '',
|
|
'pending',
|
|
metrics.minLat,
|
|
metrics.minLon,
|
|
metrics.maxLat,
|
|
metrics.maxLon
|
|
);
|
|
const id = Number(result.lastInsertRowid);
|
|
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);
|
|
return getRoute(db, routeId);
|
|
} catch (error) {
|
|
cleanupFailedRoute(db, routeId, gpxFile);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function updateRoute(db, id, fields, gpxFile) {
|
|
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 = gpxFile ? parseGpx(fs.readFileSync(gpxFile.path, 'utf8')) : null;
|
|
transaction(db, () => {
|
|
db.prepare(`UPDATE routes SET name = ?, description = ?, school_name = ?, updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?`).run(
|
|
fields.name ?? existing.name,
|
|
fields.description ?? existing.description,
|
|
fields.schoolName ?? existing.school_name,
|
|
id
|
|
);
|
|
});
|
|
|
|
if (gpxFile) {
|
|
fs.writeFileSync(gpxFile.path, parsed.normalizedXml);
|
|
moveUploadedFile(gpxFile, path.join(routeDirectory(id), 'route.gpx'));
|
|
syncRouteWaypoints(db, id);
|
|
}
|
|
return getRoute(db, id);
|
|
}
|
|
|
|
export function appendRoute(db, id, gpxFile) {
|
|
if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.');
|
|
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 = readRouteGpx(existing).points;
|
|
const allPoints = current.concat(parsed.points).map((point, sequence) => ({ ...point, sequence }));
|
|
const target = resolveStoredFile(existing.gpx_path);
|
|
const original = fs.readFileSync(target, 'utf8');
|
|
const normalized = parseGpx(original, allPoints);
|
|
fs.writeFileSync(target, normalized.normalizedXml);
|
|
updateBounds(db, id, normalized.metrics);
|
|
removeUpload(gpxFile);
|
|
return getRoute(db, id);
|
|
}
|
|
|
|
export function listPois(db, routeId) {
|
|
const pois = db.prepare('SELECT * FROM pois WHERE route_id = ? ORDER BY sequence, id').all(routeId);
|
|
const images = db.prepare(`SELECT id, path, caption, sequence FROM poi_images
|
|
WHERE poi_id = ? ORDER BY sequence, id`);
|
|
return pois.map(poi => ({
|
|
id: poi.id,
|
|
routeId: poi.route_id,
|
|
title: poi.title,
|
|
description: poi.description,
|
|
lat: poi.lat,
|
|
lon: poi.lon,
|
|
triggerRadiusM: poi.trigger_radius_m,
|
|
sequence: poi.sequence,
|
|
audioUrl: poi.audio_path ? audioUrl(poi.route_id, poi.id) : null,
|
|
images: images.all(poi.id).map(image => ({
|
|
id: image.id,
|
|
caption: image.caption,
|
|
sequence: image.sequence,
|
|
url: pictureUrl(poi.route_id, poi.id, image.id)
|
|
}))
|
|
}));
|
|
}
|
|
|
|
export function getPoi(db, routeId, poiId) {
|
|
const row = db.prepare(`SELECT p.id FROM pois p JOIN routes r ON r.id = p.route_id
|
|
WHERE p.id = ? AND p.route_id = ? AND r.status = 'active'`).get(poiId, routeId);
|
|
if (!row) throw new HttpError(404, 'POI nicht gefunden.');
|
|
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)));
|
|
updateBounds(db, routeId, parseGpx(fs.readFileSync(filename, 'utf8'), null, { allowEmpty: true }).metrics);
|
|
}
|
|
|
|
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, lon } = poiCoordinates(fields);
|
|
const result = db.prepare(`INSERT INTO pois
|
|
(route_id, title, description, lat, lon, trigger_radius_m, sequence) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
|
|
routeId,
|
|
fields.title || 'Unbenannter POI',
|
|
fields.description || '',
|
|
lat,
|
|
lon,
|
|
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));
|
|
}
|
|
|
|
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, 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 (!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=?,
|
|
updated_at=CURRENT_TIMESTAMP WHERE id=?`).run(
|
|
fields.title ?? existing.title,
|
|
fields.description ?? existing.description,
|
|
lat,
|
|
lon,
|
|
radius,
|
|
sequence,
|
|
poiId
|
|
);
|
|
syncRouteWaypoints(db, existing.route_id);
|
|
return listPois(db, existing.route_id).find(poi => poi.id === Number(poiId));
|
|
}
|
|
|
|
export function deletePoi(db, routeId, poiId) {
|
|
const existing = db.prepare(`SELECT p.id, p.audio_path FROM pois p JOIN routes r ON r.id = p.route_id
|
|
WHERE p.id = ? AND p.route_id = ? AND r.status = 'active'`).get(poiId, routeId);
|
|
if (!existing) throw new HttpError(404, 'POI nicht gefunden.');
|
|
const images = db.prepare('SELECT path FROM poi_images WHERE poi_id = ?').all(poiId);
|
|
transaction(db, () => {
|
|
db.prepare('DELETE FROM poi_images WHERE poi_id = ?').run(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 };
|
|
}
|
|
|
|
export function softDeleteRoute(db, id) {
|
|
const route = db.prepare("SELECT id FROM routes WHERE id = ? AND status = 'active'").get(id);
|
|
if (!route) throw new HttpError(404, 'Aktive Strecke nicht gefunden.');
|
|
const moved = softDeleteRouteDirectory(id);
|
|
try {
|
|
transaction(db, () => {
|
|
db.prepare(`UPDATE routes SET status='deleted', deleted_at=CURRENT_TIMESTAMP, trash_path=?,
|
|
gpx_path=REPLACE(gpx_path, ?, ?), updated_at=CURRENT_TIMESTAMP WHERE id=?`)
|
|
.run(moved.targetPrefix, moved.sourcePrefix, moved.targetPrefix, id);
|
|
rewriteMediaPaths(db, id, moved.sourcePrefix, moved.targetPrefix);
|
|
});
|
|
} catch (error) {
|
|
fs.renameSync(moved.destination, routeDirectory(id));
|
|
throw error;
|
|
}
|
|
return rowToRoute(db.prepare('SELECT * FROM routes WHERE id = ?').get(id), true);
|
|
}
|
|
|
|
export function restoreRoute(db, id) {
|
|
const route = db.prepare("SELECT trash_path FROM routes WHERE id = ? AND status = 'deleted'").get(id);
|
|
if (!route) throw new HttpError(404, 'Gelöschte Strecke nicht gefunden.');
|
|
const moved = restoreRouteDirectory(id, route.trash_path);
|
|
try {
|
|
transaction(db, () => {
|
|
db.prepare(`UPDATE routes SET status='active', deleted_at=NULL, trash_path=NULL,
|
|
gpx_path=REPLACE(gpx_path, ?, ?), updated_at=CURRENT_TIMESTAMP WHERE id=?`)
|
|
.run(moved.sourcePrefix, moved.targetPrefix, id);
|
|
rewriteMediaPaths(db, id, moved.sourcePrefix, moved.targetPrefix);
|
|
});
|
|
} catch (error) {
|
|
fs.renameSync(moved.destination, moved.source);
|
|
throw error;
|
|
}
|
|
return getRoute(db, id);
|
|
}
|