simplify application and remove window references
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 1m27s
Some checks failed
Sonarqube Scanner / Build and analyze (push) Failing after 1m27s
This commit is contained in:
parent
6a4d84ac6a
commit
32f161483d
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wegwichtel-next",
|
||||
"version": "0.12.8",
|
||||
"version": "0.12.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wegwichtel-next",
|
||||
"version": "0.12.8",
|
||||
"version": "0.12.9",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@file-type/av": "0.2.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wegwichtel-next",
|
||||
"version": "0.12.8",
|
||||
"version": "0.12.9",
|
||||
"private": true,
|
||||
"description": "GPS-gestützte Lern- und Wanderwege mit GPX, POIs, Bildern und Audioansagen.",
|
||||
"type": "module",
|
||||
|
||||
@ -99,7 +99,7 @@ button,
|
||||
padding: .35rem;
|
||||
border-color: rgba(255, 255, 255, .45);
|
||||
border-radius: .45rem;
|
||||
background: rgba(255, 255, 255, .12);
|
||||
background: var(--primary-dark);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@ -289,38 +289,45 @@ button,
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.route-facts {
|
||||
#route-page dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: .5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.route-facts div {
|
||||
#route-page dl > div {
|
||||
padding: .75rem;
|
||||
border-radius: .5rem;
|
||||
background: var(--surface);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.route-facts dt {
|
||||
#route-page dt {
|
||||
color: var(--muted);
|
||||
font-size: .8rem;
|
||||
}
|
||||
|
||||
.route-facts dd {
|
||||
#route-page dd {
|
||||
margin: .25rem 0 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.route-controls {
|
||||
#route-page fieldset,
|
||||
#slideshow > fieldset {
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
#route-page fieldset {
|
||||
display: grid;
|
||||
padding: 0;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: .5rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.route-controls button {
|
||||
#route-page fieldset button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 0;
|
||||
@ -334,23 +341,35 @@ button,
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.route-controls button:focus-visible {
|
||||
#route-page fieldset button:focus-visible {
|
||||
outline: .2rem solid var(--focus);
|
||||
outline-offset: .15rem;
|
||||
}
|
||||
|
||||
.route-controls button:disabled {
|
||||
#route-page fieldset button:disabled {
|
||||
cursor: default;
|
||||
filter: grayscale(.65);
|
||||
opacity: .42;
|
||||
}
|
||||
|
||||
.route-controls img {
|
||||
#route-page fieldset img {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#route-page fieldset > legend,
|
||||
#slideshow > fieldset > legend {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
#start-route {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary-dark);
|
||||
@ -386,8 +405,9 @@ button,
|
||||
background: #111;
|
||||
}
|
||||
|
||||
#slide-controls {
|
||||
#slideshow > fieldset {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
grid-template-columns: var(--icon-button-size) minmax(0, 1fr) var(--icon-button-size);
|
||||
align-items: stretch;
|
||||
gap: .5rem;
|
||||
@ -396,7 +416,7 @@ button,
|
||||
padding: .65rem;
|
||||
}
|
||||
|
||||
#slide-controls .ui-button {
|
||||
#slideshow > fieldset .ui-button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 0;
|
||||
@ -405,13 +425,13 @@ button,
|
||||
padding: .45rem;
|
||||
}
|
||||
|
||||
#slide-controls .ui-button img {
|
||||
#slideshow > fieldset .ui-button img {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slide-meta {
|
||||
#slideshow > fieldset > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: var(--icon-button-size);
|
||||
@ -521,17 +541,17 @@ button,
|
||||
grid-template-columns: minmax(0, 1fr) 3rem;
|
||||
}
|
||||
|
||||
.route-facts {
|
||||
#route-page dl {
|
||||
gap: .35rem;
|
||||
}
|
||||
|
||||
.route-facts div {
|
||||
#route-page dl > div {
|
||||
padding: .6rem .25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 22rem) {
|
||||
.route-facts {
|
||||
#route-page dl {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,8 +8,8 @@
|
||||
<link rel="stylesheet" href="css/bootstrap.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="bootstrap-screen" role="status" aria-live="polite">
|
||||
<section aria-label="Anwendung wird initialisiert">
|
||||
<div id="bootstrap-screen" aria-live="polite" aria-busy="true">
|
||||
<section>
|
||||
<span aria-hidden="true">🧭</span>
|
||||
<h1>Wegwichtel</h1>
|
||||
<p>Der Wegwichtel bereitet Routen und Medien vor.</p>
|
||||
@ -31,20 +31,20 @@
|
||||
</div>
|
||||
|
||||
<main id="app-shell" hidden>
|
||||
<section id="routes-page" class="app-page" aria-label="Routenauswahl">
|
||||
<section id="routes-page" class="app-page">
|
||||
<header>
|
||||
<h1>Wegwichtel</h1>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
<p id="location-hint" class="notice">Standort wird ermittelt.</p>
|
||||
<p id="location-hint" class="notice">Standort wird für nahe Routen lokal auf diesem Gerät ermittelt.</p>
|
||||
|
||||
<section aria-label="Routen in der Nähe">
|
||||
<section>
|
||||
<h2>Routen in deiner Nähe</h2>
|
||||
<ul id="nearby-route-list" class="route-list" aria-live="polite"></ul>
|
||||
</section>
|
||||
|
||||
<section aria-label="Alle Routen">
|
||||
<section>
|
||||
<h2>Alle Routen</h2>
|
||||
<label for="route-search">Routen durchsuchen</label>
|
||||
|
||||
@ -64,7 +64,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="route-page" class="app-page" aria-label="Ausgewählte Route" hidden>
|
||||
<section id="route-page" class="app-page" hidden>
|
||||
<header>
|
||||
<button class="back-button" type="button" data-page-target="#routes-page" aria-label="Zurück zur Routenauswahl" title="Zurück zur Routenauswahl">
|
||||
<img src="images/icons/back.svg" alt="">
|
||||
@ -75,13 +75,14 @@
|
||||
<div class="content">
|
||||
<p id="route-description"></p>
|
||||
|
||||
<dl class="route-facts">
|
||||
<dl>
|
||||
<div><dt>Länge</dt><dd id="route-distance">–</dd></div>
|
||||
<div><dt>Höhenmeter</dt><dd id="route-elevation">–</dd></div>
|
||||
<div><dt>POIs</dt><dd id="route-poi-count">–</dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="route-controls" role="group" aria-label="Routensteuerung">
|
||||
<fieldset>
|
||||
<legend>Routensteuerung</legend>
|
||||
<button id="start-route" type="button" aria-label="Route starten" title="Route starten">
|
||||
<img src="images/icons/play.svg" alt="">
|
||||
</button>
|
||||
@ -91,7 +92,7 @@
|
||||
<button id="stop-route" type="button" aria-label="Route beenden" title="Route beenden" disabled>
|
||||
<img src="images/icons/stop.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
<p id="tracking-status" class="notice" hidden></p>
|
||||
|
||||
<h2>Stationen</h2>
|
||||
@ -99,7 +100,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="poi-page" class="app-page" aria-label="Station" hidden>
|
||||
<section id="poi-page" class="app-page" hidden>
|
||||
<header>
|
||||
<button class="back-button" type="button" data-page-target="#route-page" aria-label="Zurück zur Route" title="Zurück zur Route">
|
||||
<img src="images/icons/back.svg" alt="">
|
||||
@ -113,18 +114,19 @@
|
||||
<img id="slide-image" alt="">
|
||||
</figure>
|
||||
|
||||
<div id="slide-controls" role="group" aria-label="Bildnavigation">
|
||||
<fieldset>
|
||||
<legend>Bildnavigation</legend>
|
||||
<button id="slide-prev" type="button" aria-label="Vorheriges Bild" title="Vorheriges Bild">
|
||||
<img src="images/icons/chevron-left.svg" alt="">
|
||||
</button>
|
||||
<div class="slide-meta">
|
||||
<div>
|
||||
<p id="slide-caption"></p>
|
||||
<span id="slide-position" aria-live="polite">0 / 0</span>
|
||||
<span id="slide-position">0 / 0</span>
|
||||
</div>
|
||||
<button id="slide-next" type="button" aria-label="Nächstes Bild" title="Nächstes Bild">
|
||||
<img src="images/icons/chevron-right.svg" alt="">
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<p id="poi-description"></p>
|
||||
|
||||
@ -8,4 +8,4 @@
|
||||
routes: position => request('/routes' + (position ? `?lat=${encodeURIComponent(position.lat)}&lon=${encodeURIComponent(position.lon)}&radiusKm=${ns.Config.routeRadiusKm}` : '')),
|
||||
route: id => request(`/routes/${encodeURIComponent(id)}`)
|
||||
};
|
||||
}(window.Wegwichtel, window.jQuery));
|
||||
}(globalThis.Wegwichtel, globalThis.jQuery));
|
||||
|
||||
@ -9,7 +9,6 @@
|
||||
routeState: 'idle',
|
||||
activePage: null,
|
||||
routeProgressIndex: null,
|
||||
activePoiId: null,
|
||||
deviceHeading: null
|
||||
};
|
||||
|
||||
@ -21,7 +20,7 @@
|
||||
$(document)
|
||||
.on('pointerup', selector, function (event) {
|
||||
const pointer = event.originalEvent;
|
||||
if (pointer && pointer.isPrimary === false) return;
|
||||
if (pointer?.isPrimary === false) return;
|
||||
if (pointer?.pointerType === 'mouse' && pointer.button !== 0) return;
|
||||
event.preventDefault();
|
||||
handler.call(this, event);
|
||||
@ -38,10 +37,10 @@
|
||||
if (!target.length) throw new Error(`Unbekannte Ansicht: ${pageSelector}`);
|
||||
|
||||
const previous = state.activePage;
|
||||
$('.app-page').attr('hidden', true).removeClass('is-active');
|
||||
target.removeAttr('hidden').addClass('is-active');
|
||||
$('.app-page').attr('hidden', true);
|
||||
target.removeAttr('hidden');
|
||||
state.activePage = pageSelector;
|
||||
window.scrollTo({ top: 0, behavior: options.instant ? 'auto' : 'smooth' });
|
||||
globalThis.scrollTo({ top: 0, behavior: options.instant ? 'auto' : 'smooth' });
|
||||
|
||||
if (options.updateHistory === false) return;
|
||||
const historyState = { page: pageSelector, previous };
|
||||
@ -52,7 +51,7 @@
|
||||
function normalizeSearch(value) {
|
||||
return String(value ?? '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replaceAll(/[\u0300-\u036f]/g, '')
|
||||
.toLocaleLowerCase('de');
|
||||
}
|
||||
|
||||
@ -172,11 +171,10 @@
|
||||
}
|
||||
|
||||
function setActivePoi(poiId) {
|
||||
state.activePoiId = poiId == null ? null : Number(poiId);
|
||||
$('#poi-list [data-poi-id]').removeAttr('aria-current');
|
||||
if (state.activePoiId != null) {
|
||||
$(`#poi-list [data-poi-id="${state.activePoiId}"]`).attr('aria-current', 'step');
|
||||
}
|
||||
if (poiId == null) return;
|
||||
|
||||
$(`#poi-list [data-poi-id="${Number(poiId)}"]`).attr('aria-current', 'step');
|
||||
}
|
||||
|
||||
function endRoute({ announce = true, returnToRoutes = false } = {}) {
|
||||
@ -451,7 +449,7 @@
|
||||
const metrics = updatePoiIndicators(position);
|
||||
if (state.routeState !== 'running') return;
|
||||
|
||||
let nearest = nearestPendingPoi(metrics);
|
||||
const nearest = nearestPendingPoi(metrics);
|
||||
updateNavigation(position);
|
||||
|
||||
$('#tracking-status').text(
|
||||
@ -463,7 +461,6 @@
|
||||
state.triggered.add(nearest.poi.id);
|
||||
ns.Vibration.start(ns.Vibration.Patterns.ACTIVE_POI);
|
||||
showPoi(nearest.poi);
|
||||
nearest = nearestPendingPoi(metrics);
|
||||
updateNavigation(position);
|
||||
}
|
||||
}
|
||||
@ -545,7 +542,7 @@
|
||||
endRoute({ returnToRoutes: true });
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', event => {
|
||||
globalThis.addEventListener('popstate', event => {
|
||||
const page = event.state?.page || '#routes-page';
|
||||
navigate(page, { updateHistory: false, instant: true });
|
||||
});
|
||||
@ -587,4 +584,4 @@
|
||||
hideNavigation();
|
||||
}
|
||||
};
|
||||
}(window.Wegwichtel, window.jQuery));
|
||||
}(globalThis.Wegwichtel, globalThis.jQuery));
|
||||
|
||||
@ -36,4 +36,4 @@
|
||||
|
||||
unload
|
||||
};
|
||||
}(window.Wegwichtel, window.jQuery));
|
||||
}(globalThis.Wegwichtel, globalThis.jQuery));
|
||||
|
||||
12
public/js/bootstrap-loader.js
vendored
12
public/js/bootstrap-loader.js
vendored
@ -41,7 +41,7 @@
|
||||
}
|
||||
|
||||
function initializeJqueryUi() {
|
||||
const $ = window.jQuery;
|
||||
const $ = globalThis.jQuery;
|
||||
$('#bootstrap-retry').button({ icon: 'ui-icon-refresh' });
|
||||
$('.back-button').button();
|
||||
$('#clear-route-search').button({ icon: 'ui-icon-close', showLabel: false });
|
||||
@ -56,7 +56,7 @@
|
||||
try {
|
||||
await loadScript(
|
||||
`vendor/jquery/jquery-${versions.jquery}.min.js`,
|
||||
() => window.jQuery?.fn?.jquery === versions.jquery
|
||||
() => globalThis.jQuery?.fn?.jquery === versions.jquery
|
||||
);
|
||||
mark('jquery', 'done');
|
||||
|
||||
@ -64,20 +64,20 @@
|
||||
await loadStyle('css/app.css');
|
||||
await loadScript(
|
||||
`vendor/jquery-ui/jquery-ui-${versions.jqueryUi}.min.js`,
|
||||
() => window.jQuery?.ui?.version === versions.jqueryUi
|
||||
() => globalThis.jQuery?.ui?.version === versions.jqueryUi
|
||||
);
|
||||
initializeJqueryUi();
|
||||
mark('jquery-ui', 'done');
|
||||
|
||||
for (const module of ['config', 'api-client', 'distance', 'geolocation', 'orientation', 'slideshow', 'audio-player', 'vibration', 'app']) {
|
||||
await loadScript(`js/${module}.js`, () => Boolean(window.Wegwichtel));
|
||||
await loadScript(`js/${module}.js`, () => Boolean(globalThis.Wegwichtel));
|
||||
}
|
||||
mark('modules', 'done');
|
||||
|
||||
await window.Wegwichtel.App.initialize({ markStep: mark });
|
||||
await globalThis.Wegwichtel.App.initialize({ markStep: mark });
|
||||
document.getElementById('bootstrap-screen').hidden = true;
|
||||
document.getElementById('app-shell').hidden = false;
|
||||
window.Wegwichtel.App.navigate('#routes-page', { replace: true });
|
||||
globalThis.Wegwichtel.App.navigate('#routes-page', { replace: true });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
const current = steps.find(step => !document.querySelector(`[data-step="${step}"]`)?.classList.contains('done'));
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
window.Wegwichtel = window.Wegwichtel || {};
|
||||
window.Wegwichtel.Config = Object.freeze({ apiBase: '/api', routeRadiusKm: 25, geolocationTimeoutMs: 12000 });
|
||||
globalThis.Wegwichtel ??= {};
|
||||
globalThis.Wegwichtel.Config = Object.freeze({ apiBase: '/api', routeRadiusKm: 25, geolocationTimeoutMs: 12000 });
|
||||
|
||||
@ -30,4 +30,4 @@
|
||||
return meters < 1000 ? `${Math.round(meters)} m` : `${(meters / 1000).toFixed(1)} km`;
|
||||
}
|
||||
};
|
||||
}(window.Wegwichtel));
|
||||
}(globalThis.Wegwichtel));
|
||||
|
||||
@ -1,8 +1,17 @@
|
||||
(function (ns) {
|
||||
'use strict';
|
||||
|
||||
const WATCH_OPTIONS = Object.freeze({
|
||||
enableHighAccuracy: true,
|
||||
timeout: 20000,
|
||||
maximumAge: 5000
|
||||
});
|
||||
let watchId = null;
|
||||
|
||||
function geolocation() {
|
||||
return globalThis.navigator?.geolocation;
|
||||
}
|
||||
|
||||
function normalize(position) {
|
||||
return {
|
||||
lat: position.coords.latitude,
|
||||
@ -15,19 +24,24 @@
|
||||
};
|
||||
}
|
||||
|
||||
function unsupportedError() {
|
||||
return new Error('Dieses Gerät unterstützt keine Geolokalisierung.');
|
||||
}
|
||||
|
||||
ns.Geo = {
|
||||
current() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error('Dieses Gerät unterstützt keine Geolokalisierung.'));
|
||||
const service = geolocation();
|
||||
if (!service) {
|
||||
reject(unsupportedError());
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
service.getCurrentPosition(
|
||||
position => resolve(normalize(position)),
|
||||
reject,
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
...WATCH_OPTIONS,
|
||||
timeout: ns.Config.geolocationTimeoutMs,
|
||||
maximumAge: 30000
|
||||
}
|
||||
@ -37,25 +51,23 @@
|
||||
|
||||
start(callback, onError) {
|
||||
this.stop();
|
||||
if (!navigator.geolocation) {
|
||||
onError?.(new Error('Dieses Gerät unterstützt keine Geolokalisierung.'));
|
||||
const service = geolocation();
|
||||
if (!service) {
|
||||
onError?.(unsupportedError());
|
||||
return;
|
||||
}
|
||||
|
||||
watchId = navigator.geolocation.watchPosition(
|
||||
watchId = service.watchPosition(
|
||||
position => callback(normalize(position)),
|
||||
onError,
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 20000,
|
||||
maximumAge: 5000
|
||||
}
|
||||
WATCH_OPTIONS
|
||||
);
|
||||
},
|
||||
|
||||
stop() {
|
||||
if (watchId != null) navigator.geolocation.clearWatch(watchId);
|
||||
const service = geolocation();
|
||||
if (watchId != null) service?.clearWatch(watchId);
|
||||
watchId = null;
|
||||
}
|
||||
};
|
||||
}(window.Wegwichtel));
|
||||
}(globalThis.Wegwichtel));
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
}
|
||||
|
||||
async function requestPermission() {
|
||||
const orientation = window.DeviceOrientationEvent;
|
||||
const orientation = globalThis.DeviceOrientationEvent;
|
||||
if (!orientation) return false;
|
||||
|
||||
if (typeof orientation.requestPermission === 'function') {
|
||||
@ -51,14 +51,14 @@
|
||||
if (heading != null) callback?.(heading);
|
||||
};
|
||||
|
||||
eventNames.forEach(name => window.addEventListener(name, listener, true));
|
||||
eventNames.forEach(name => globalThis.addEventListener(name, listener, true));
|
||||
return true;
|
||||
},
|
||||
|
||||
stop() {
|
||||
if (listener) eventNames.forEach(name => window.removeEventListener(name, listener, true));
|
||||
if (listener) eventNames.forEach(name => globalThis.removeEventListener(name, listener, true));
|
||||
listener = null;
|
||||
callback = null;
|
||||
}
|
||||
};
|
||||
}(window.Wegwichtel));
|
||||
}(globalThis.Wegwichtel));
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
$(document)
|
||||
.on('pointerup', selector, function (event) {
|
||||
const pointer = event.originalEvent;
|
||||
if (pointer && pointer.isPrimary === false) return;
|
||||
if (pointer?.isPrimary === false) return;
|
||||
if (pointer?.pointerType === 'mouse' && pointer.button !== 0) return;
|
||||
event.preventDefault();
|
||||
handler.call(this, event);
|
||||
@ -29,7 +29,6 @@
|
||||
function render() {
|
||||
const hasImages = images.length > 0;
|
||||
$('#slideshow').toggle(hasImages);
|
||||
$('#slide-controls').attr('data-image-count', images.length);
|
||||
if (!hasImages) return;
|
||||
|
||||
const image = images[index];
|
||||
@ -86,4 +85,4 @@
|
||||
if (deltaX < 0) ns.Slideshow.next();
|
||||
else ns.Slideshow.previous();
|
||||
});
|
||||
}(window.Wegwichtel, window.jQuery));
|
||||
}(globalThis.Wegwichtel, globalThis.jQuery));
|
||||
|
||||
@ -30,7 +30,7 @@
|
||||
if (!Number.isFinite(duration) || duration < 0) {
|
||||
throw new TypeError('Die Vibrationsdauer muss eine nichtnegative Millisekundenangabe sein.');
|
||||
}
|
||||
return duration;
|
||||
return [duration];
|
||||
}
|
||||
|
||||
function start(pattern = Patterns.LONG) {
|
||||
@ -50,4 +50,4 @@
|
||||
start,
|
||||
stop
|
||||
});
|
||||
}(window.Wegwichtel));
|
||||
}(globalThis.Wegwichtel));
|
||||
|
||||
@ -8,6 +8,25 @@ import { detectAv } from '@file-type/av';
|
||||
import { config } from '../config.js';
|
||||
import { HttpError } from './errors.js';
|
||||
|
||||
const MAX_GPX_PREFIX_BYTES = 131072;
|
||||
const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/;
|
||||
const IGNORED_XML_MARKUP = Object.freeze([
|
||||
Object.freeze({ opening: '<!--', closing: '-->' }),
|
||||
Object.freeze({ opening: '<?', closing: '?>' }),
|
||||
Object.freeze({ opening: '<!', closing: '>' })
|
||||
]);
|
||||
|
||||
const ALLOWED_TYPES = Object.freeze({
|
||||
picture: Object.freeze({
|
||||
mime: new Set(['image/jpeg', 'image/png', 'image/webp']),
|
||||
ext: new Set(['jpg', 'png', 'webp'])
|
||||
}),
|
||||
audio: Object.freeze({
|
||||
mime: new Set(['audio/mpeg', 'audio/mp4', 'audio/aac', 'audio/ogg', 'audio/wav', 'audio/webm']),
|
||||
ext: new Set(['mp3', 'm4a', 'aac', 'ogg', 'opus', 'spx', 'wav', 'webm'])
|
||||
})
|
||||
});
|
||||
|
||||
function sanitizeFilename(value) {
|
||||
return String(value).replaceAll(/[^a-zA-Z0-9._-]/g, '_') || 'upload';
|
||||
}
|
||||
@ -35,38 +54,40 @@ function isXmlNameCharacter(character, first = false) {
|
||||
return !first && ((code >= 48 && code <= 57) || character === '.' || character === '-' || character === ':');
|
||||
}
|
||||
|
||||
function ignoredMarkupEnd(xml, opening) {
|
||||
const markup = IGNORED_XML_MARKUP.find(item => xml.startsWith(item.opening, opening));
|
||||
if (!markup) return null;
|
||||
|
||||
const closing = xml.indexOf(markup.closing, opening + markup.opening.length);
|
||||
return closing < 0 ? -1 : closing + markup.closing.length;
|
||||
}
|
||||
|
||||
function readXmlName(xml, start) {
|
||||
if (start >= xml.length || !isXmlNameCharacter(xml[start], true)) return null;
|
||||
|
||||
let end = start + 1;
|
||||
while (end < xml.length && isXmlNameCharacter(xml[end])) end += 1;
|
||||
return xml.slice(start, end);
|
||||
}
|
||||
|
||||
function rootElementName(xml) {
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < xml.length) {
|
||||
const opening = xml.indexOf('<', cursor);
|
||||
if (opening < 0 || opening + 1 >= xml.length) return null;
|
||||
|
||||
if (xml.startsWith('<!--', opening)) {
|
||||
const end = xml.indexOf('-->', opening + 4);
|
||||
if (end < 0) return null;
|
||||
cursor = end + 3;
|
||||
const nextCursor = ignoredMarkupEnd(xml, opening);
|
||||
if (nextCursor === -1) return null;
|
||||
if (nextCursor != null) {
|
||||
cursor = nextCursor;
|
||||
continue;
|
||||
}
|
||||
if (xml.startsWith('<?', opening)) {
|
||||
const end = xml.indexOf('?>', opening + 2);
|
||||
if (end < 0) return null;
|
||||
cursor = end + 2;
|
||||
continue;
|
||||
}
|
||||
if (xml.startsWith('<!', opening)) {
|
||||
const end = xml.indexOf('>', opening + 2);
|
||||
if (end < 0) return null;
|
||||
cursor = end + 1;
|
||||
continue;
|
||||
}
|
||||
if (xml.startsWith('</', opening)) return null;
|
||||
|
||||
let end = opening + 1;
|
||||
if (!isXmlNameCharacter(xml[end], true)) return null;
|
||||
end += 1;
|
||||
while (end < xml.length && isXmlNameCharacter(xml[end])) end += 1;
|
||||
return xml.slice(opening + 1, end);
|
||||
if (xml.startsWith('</', opening)) return null;
|
||||
return readXmlName(xml, opening + 1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -83,19 +104,29 @@ export const upload = multer({
|
||||
limits: { fileSize: config.maxUploadBytes, files: 25 }
|
||||
});
|
||||
|
||||
const ALLOWED_TYPES = Object.freeze({
|
||||
picture: Object.freeze({
|
||||
mime: new Set(['image/jpeg', 'image/png', 'image/webp']),
|
||||
ext: new Set(['jpg', 'png', 'webp'])
|
||||
}),
|
||||
audio: Object.freeze({
|
||||
mime: new Set(['audio/mpeg', 'audio/mp4', 'audio/aac', 'audio/ogg', 'audio/wav', 'audio/webm']),
|
||||
ext: new Set(['mp3', 'm4a', 'aac', 'ogg', 'opus', 'spx', 'wav', 'webm'])
|
||||
})
|
||||
});
|
||||
|
||||
function removeTemporaryFile(file) {
|
||||
if (file?.path && fs.existsSync(file.path)) fs.rmSync(file.path, { force: true });
|
||||
if (!file?.path || !fs.existsSync(file.path)) return;
|
||||
fs.rmSync(file.path, { force: true });
|
||||
}
|
||||
|
||||
function assertValidBase64(compact, fieldName) {
|
||||
if (!BASE64_PATTERN.test(compact) || compact.length % 4 === 1) {
|
||||
throw new HttpError(400, `${fieldName}.base64 enthält keine gültigen Base64-Daten.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertCanonicalBase64(compact, buffer, fieldName) {
|
||||
const canonicalInput = stripBase64Padding(compact);
|
||||
const canonicalOutput = stripBase64Padding(buffer.toString('base64'));
|
||||
if (canonicalInput !== canonicalOutput) {
|
||||
throw new HttpError(400, `${fieldName}.base64 enthält keine gültigen Base64-Daten.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertUploadSize(buffer) {
|
||||
if (buffer.length <= config.maxUploadBytes) return;
|
||||
const limitMb = Math.floor(config.maxUploadBytes / 1024 / 1024);
|
||||
throw new HttpError(413, `Die Datei überschreitet das Limit von ${limitMb} MB.`);
|
||||
}
|
||||
|
||||
function decodeBase64(value, fieldName) {
|
||||
@ -104,19 +135,11 @@ function decodeBase64(value, fieldName) {
|
||||
}
|
||||
|
||||
const compact = value.replaceAll(/\s/g, '');
|
||||
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(compact) || compact.length % 4 === 1) {
|
||||
throw new HttpError(400, `${fieldName}.base64 enthält keine gültigen Base64-Daten.`);
|
||||
}
|
||||
assertValidBase64(compact, fieldName);
|
||||
|
||||
const buffer = Buffer.from(compact, 'base64');
|
||||
const canonicalInput = stripBase64Padding(compact);
|
||||
const canonicalOutput = stripBase64Padding(buffer.toString('base64'));
|
||||
if (canonicalInput !== canonicalOutput) {
|
||||
throw new HttpError(400, `${fieldName}.base64 enthält keine gültigen Base64-Daten.`);
|
||||
}
|
||||
if (buffer.length > config.maxUploadBytes) {
|
||||
throw new HttpError(413, `Die Datei überschreitet das Limit von ${Math.floor(config.maxUploadBytes / 1024 / 1024)} MB.`);
|
||||
}
|
||||
assertCanonicalBase64(compact, buffer, fieldName);
|
||||
assertUploadSize(buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
@ -126,7 +149,8 @@ function jsonFileDescriptor(value, fieldName) {
|
||||
throw new HttpError(400, `${fieldName} muss ein JSON-Objekt sein.`);
|
||||
}
|
||||
|
||||
const originalname = String(value.filename ?? `${fieldName}.upload`).trim() || `${fieldName}.upload`;
|
||||
const fallbackName = `${fieldName}.upload`;
|
||||
const originalname = String(value.filename ?? fallbackName).trim() || fallbackName;
|
||||
let base64 = value.base64;
|
||||
|
||||
if (value.dataUrl != null) {
|
||||
@ -174,10 +198,9 @@ function detectGpx(file) {
|
||||
throw new HttpError(415, 'Der Upload ist keine gültig kodierte GPX-Datei.');
|
||||
}
|
||||
|
||||
const prefix = xml.slice(0, 131072);
|
||||
const prefix = xml.slice(0, MAX_GPX_PREFIX_BYTES);
|
||||
const start = prefix.startsWith('\uFEFF') ? prefix.slice(1) : prefix;
|
||||
const rootName = rootElementName(start);
|
||||
const localName = rootName?.split(':').at(-1);
|
||||
const localName = rootElementName(start)?.split(':').at(-1);
|
||||
if (localName !== 'gpx') {
|
||||
throw new HttpError(415, 'Der Upload enthält kein GPX-Dokument.');
|
||||
}
|
||||
@ -195,25 +218,34 @@ async function detectBinary(file) {
|
||||
}
|
||||
}
|
||||
|
||||
function unsupportedContentMessage(kind) {
|
||||
return kind === 'picture'
|
||||
? 'Der Dateiinhalt ist kein unterstütztes Bildformat.'
|
||||
: 'Der Dateiinhalt ist kein unterstütztes Audioformat.';
|
||||
}
|
||||
|
||||
function unsupportedFormatMessage(kind, mime) {
|
||||
return kind === 'picture'
|
||||
? `Nicht unterstütztes Bildformat: ${mime}`
|
||||
: `Nicht unterstütztes Audioformat: ${mime}`;
|
||||
}
|
||||
|
||||
function validateDetectedType(detected, kind) {
|
||||
if (kind === 'gpx') return;
|
||||
|
||||
const rules = ALLOWED_TYPES[kind];
|
||||
const baseMime = String(detected.mime).split(';', 1)[0].toLowerCase();
|
||||
const extension = String(detected.ext).toLowerCase();
|
||||
if (!rules?.mime.has(baseMime) || !rules.ext.has(extension)) {
|
||||
throw new HttpError(415, unsupportedFormatMessage(kind, detected.mime));
|
||||
}
|
||||
}
|
||||
|
||||
async function detectAndValidate(file, kind) {
|
||||
const detected = kind === 'gpx' ? detectGpx(file) : await detectBinary(file);
|
||||
if (!detected) {
|
||||
throw new HttpError(415, kind === 'picture'
|
||||
? 'Der Dateiinhalt ist kein unterstütztes Bildformat.'
|
||||
: 'Der Dateiinhalt ist kein unterstütztes Audioformat.');
|
||||
}
|
||||
|
||||
if (kind !== 'gpx') {
|
||||
const rules = ALLOWED_TYPES[kind];
|
||||
const baseMime = String(detected.mime).split(';', 1)[0].toLowerCase();
|
||||
const extension = String(detected.ext).toLowerCase();
|
||||
if (!rules?.mime.has(baseMime) || !rules?.ext.has(extension)) {
|
||||
throw new HttpError(415, kind === 'picture'
|
||||
? `Nicht unterstütztes Bildformat: ${detected.mime}`
|
||||
: `Nicht unterstütztes Audioformat: ${detected.mime}`);
|
||||
}
|
||||
}
|
||||
if (!detected) throw new HttpError(415, unsupportedContentMessage(kind));
|
||||
|
||||
validateDetectedType(detected, kind);
|
||||
file.mimetype = detected.mime;
|
||||
file.detectedMime = detected.mime;
|
||||
file.detectedExtension = detected.ext;
|
||||
@ -225,12 +257,13 @@ export function inspectUpload(fieldName, kind, { allowJson = false } = {}) {
|
||||
try {
|
||||
if (allowJson) materializeJsonUpload(req, fieldName);
|
||||
if (!req.file) return next();
|
||||
|
||||
await detectAndValidate(req.file, kind);
|
||||
next();
|
||||
return next();
|
||||
} catch (error) {
|
||||
removeTemporaryFile(req.file);
|
||||
req.file = undefined;
|
||||
next(error);
|
||||
return next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -2,24 +2,56 @@ import { XMLParser } from 'fast-xml-parser';
|
||||
import { HttpError } from '../middleware/errors.js';
|
||||
import { routeMetrics } from './geo.js';
|
||||
|
||||
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', trimValues: true });
|
||||
const list = value => value == null ? [] : Array.isArray(value) ? value : [value];
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
trimValues: true
|
||||
});
|
||||
|
||||
export function parseGpx(xml) {
|
||||
let parsed;
|
||||
try { parsed = parser.parse(xml); }
|
||||
catch (error) { throw new HttpError(400, 'Die GPX-Datei ist kein gültiges XML.', error.message); }
|
||||
const gpx = parsed?.gpx;
|
||||
if (!gpx) throw new HttpError(400, 'GPX-Wurzelelement fehlt.');
|
||||
const tracks = list(gpx.trk);
|
||||
const points = tracks.flatMap(track => list(track.trkseg)).flatMap(segment => list(segment.trkpt)).map((point, index) => ({
|
||||
sequence: index,
|
||||
function asList(value) {
|
||||
if (value == null) return [];
|
||||
return Array.isArray(value) ? value : [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);
|
||||
}
|
||||
}
|
||||
|
||||
function toRoutePoint(point, sequence) {
|
||||
return {
|
||||
sequence,
|
||||
lat: Number.parseFloat(point['@_lat']),
|
||||
lon: Number.parseFloat(point['@_lon']),
|
||||
elevation: point.ele == null ? null : Number.parseFloat(point.ele),
|
||||
recordedAt: point.time || null
|
||||
})).filter(point => Number.isFinite(point.lat) && Number.isFinite(point.lon));
|
||||
if (!points.length) throw new HttpError(400, 'Die GPX-Datei enthält keine verwertbaren Trackpunkte.');
|
||||
const name = tracks[0]?.name || gpx.metadata?.name || null;
|
||||
return { name, points, metrics: routeMetrics(points) };
|
||||
};
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
export function parseGpx(xml) {
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
@ -6,49 +6,152 @@ import { HttpError } from '../middleware/errors.js';
|
||||
import { parseGpx } from './gpx.js';
|
||||
import { distanceMeters, routeMetrics } from './geo.js';
|
||||
import {
|
||||
ensureRouteDirectories, moveUploadedFile, routeDirectory,
|
||||
softDeleteRouteDirectory, restoreRouteDirectory, resolveStoredFile, removeUpload, removeStoredFile
|
||||
ensureRouteDirectories,
|
||||
moveUploadedFile,
|
||||
routeDirectory,
|
||||
softDeleteRouteDirectory,
|
||||
restoreRouteDirectory,
|
||||
resolveStoredFile,
|
||||
removeUpload,
|
||||
removeStoredFile
|
||||
} from './storage.js';
|
||||
|
||||
const slugify = value => value.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80) || `route-${Date.now()}`;
|
||||
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=?`;
|
||||
|
||||
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 = separated.replaceAll(/(?:^-+|-+$)/g, '');
|
||||
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) {
|
||||
if (!row || (!includeDeleted && row.status !== 'active')) return null;
|
||||
|
||||
return {
|
||||
id: row.id, slug: row.slug, name: row.name, description: row.description,
|
||||
schoolName: row.school_name, status: row.status,
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
schoolName: row.school_name,
|
||||
status: row.status,
|
||||
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 },
|
||||
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,
|
||||
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,
|
||||
gpxUrl: row.gpx_path && row.status === 'active' ? routeGpxUrl(row.id) : null,
|
||||
createdAt: row.created_at, updatedAt: row.updated_at, deletedAt: row.deleted_at
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
deletedAt: row.deleted_at
|
||||
};
|
||||
}
|
||||
|
||||
export function listRoutes(db, { lat, lon, radiusKm = config.defaultRouteRadiusKm, includeDeleted = false }) {
|
||||
const rows = db.prepare(`SELECT * FROM routes ${includeDeleted ? '' : "WHERE status = 'active'"} ORDER BY name COLLATE NOCASE`).all();
|
||||
function routeComparator(origin) {
|
||||
if (origin) return (left, right) => left.proximityM - right.proximityM;
|
||||
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 updateMetrics(db, routeId, metrics) {
|
||||
db.prepare(UPDATE_METRICS_SQL).run(
|
||||
metrics.startLat,
|
||||
metrics.startLon,
|
||||
metrics.centerLat,
|
||||
metrics.centerLon,
|
||||
metrics.minLat,
|
||||
metrics.minLon,
|
||||
metrics.maxLat,
|
||||
metrics.maxLon,
|
||||
metrics.distanceM,
|
||||
metrics.elevationGainM,
|
||||
metrics.pointCount,
|
||||
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 route_points WHERE route_id = ?').run(routeId);
|
||||
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
|
||||
}) {
|
||||
const where = includeDeleted ? '' : "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;
|
||||
return rows.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 <= radiusKm * 1000).sort((a, b) => origin ? a.proximityM - b.proximityM : a.name.localeCompare(b.name, 'de'));
|
||||
const maximumDistance = radiusKm * 1000;
|
||||
|
||||
return rows
|
||||
.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)
|
||||
.sort(routeComparator(origin));
|
||||
}
|
||||
|
||||
export function getRoute(db, id, includeDeleted = false) {
|
||||
const route = rowToRoute(db.prepare('SELECT * FROM routes WHERE id = ?').get(id), includeDeleted);
|
||||
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);
|
||||
const pois = listPois(db, id);
|
||||
return { ...route, points, pois };
|
||||
|
||||
const points = db.prepare(`SELECT sequence, lat, lon, elevation, recorded_at AS recordedAt
|
||||
FROM route_points WHERE route_id = ? ORDER BY sequence`).all(id);
|
||||
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`
|
||||
@ -57,38 +160,49 @@ export function getRouteGpxFile(db, id) {
|
||||
|
||||
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, () => {
|
||||
let slug = slugify(fields.slug || name);
|
||||
let suffix = 2;
|
||||
while (db.prepare('SELECT 1 FROM routes WHERE slug = ?').get(slug)) slug = `${slugify(fields.slug || name)}-${suffix++}`;
|
||||
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, fields.description || '', fields.schoolName || '', 'pending', parsed.metrics.startLat, parsed.metrics.startLon,
|
||||
parsed.metrics.centerLat, parsed.metrics.centerLon, parsed.metrics.minLat, parsed.metrics.minLon, parsed.metrics.maxLat, parsed.metrics.maxLon,
|
||||
parsed.metrics.distanceM, parsed.metrics.elevationGainM, parsed.metrics.pointCount);
|
||||
(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,
|
||||
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
|
||||
);
|
||||
const id = Number(result.lastInsertRowid);
|
||||
const insertPoint = db.prepare('INSERT INTO route_points (route_id, sequence, lat, lon, elevation, recorded_at) VALUES (?, ?, ?, ?, ?, ?)');
|
||||
for (const point of parsed.points) insertPoint.run(id, point.sequence, point.lat, point.lon, point.elevation, point.recordedAt);
|
||||
insertRoutePoints(db, id, parsed.points);
|
||||
return id;
|
||||
});
|
||||
|
||||
ensureRouteDirectories(routeId);
|
||||
const finalGpx = path.join(routeDirectory(routeId), 'route.gpx');
|
||||
const gpxPath = moveUploadedFile(gpxFile, finalGpx);
|
||||
db.prepare("UPDATE routes SET gpx_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(gpxPath, routeId);
|
||||
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) {
|
||||
removeUpload(gpxFile);
|
||||
if (routeId) {
|
||||
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 });
|
||||
}
|
||||
cleanupFailedRoute(db, routeId, gpxFile);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -96,52 +210,63 @@ export function createRoute(db, fields, gpxFile) {
|
||||
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.');
|
||||
let parsed = null;
|
||||
if (gpxFile) parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8'));
|
||||
|
||||
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 (parsed) {
|
||||
db.prepare('DELETE FROM route_points WHERE route_id = ?').run(id);
|
||||
const insertPoint = db.prepare('INSERT INTO route_points (route_id, sequence, lat, lon, elevation, recorded_at) VALUES (?, ?, ?, ?, ?, ?)');
|
||||
for (const point of parsed.points) insertPoint.run(id, point.sequence, point.lat, point.lon, point.elevation, point.recordedAt);
|
||||
const m = parsed.metrics;
|
||||
db.prepare(`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=? WHERE id=?`)
|
||||
.run(m.startLat, m.startLon, m.centerLat, m.centerLon, m.minLat, m.minLon, m.maxLat, m.maxLon, m.distanceM, m.elevationGainM, m.pointCount, id);
|
||||
}
|
||||
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 (!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) moveUploadedFile(gpxFile, path.join(routeDirectory(id), 'route.gpx'));
|
||||
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);
|
||||
|
||||
const existing = db.prepare("SELECT id 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 all = current.concat(parsed.points).map((point, sequence) => ({ ...point, sequence }));
|
||||
const m = routeMetrics(all);
|
||||
const current = db.prepare(`SELECT lat, lon, elevation, recorded_at AS recordedAt
|
||||
FROM route_points WHERE route_id = ? ORDER BY sequence`).all(id);
|
||||
const allPoints = current.concat(parsed.points).map((point, sequence) => ({ ...point, sequence }));
|
||||
|
||||
transaction(db, () => {
|
||||
const insert = db.prepare('INSERT INTO route_points (route_id, sequence, lat, lon, elevation, recorded_at) VALUES (?, ?, ?, ?, ?, ?)');
|
||||
for (let i = 0; i < parsed.points.length; i += 1) {
|
||||
const p = parsed.points[i]; insert.run(id, current.length + i, p.lat, p.lon, p.elevation, p.recordedAt);
|
||||
}
|
||||
db.prepare(`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=?`)
|
||||
.run(m.startLat, m.startLon, m.centerLat, m.centerLon, m.minLat, m.minLon, m.maxLat, m.maxLon, m.distanceM, m.elevationGainM, m.pointCount, id);
|
||||
insertRoutePoints(db, id, parsed.points, current.length);
|
||||
updateMetrics(db, id, routeMetrics(allPoints));
|
||||
});
|
||||
|
||||
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 imageStatement = db.prepare('SELECT id, path, caption, sequence FROM poi_images WHERE poi_id = ? ORDER BY sequence, id');
|
||||
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,
|
||||
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: imageStatement.all(poi.id).map(image => ({
|
||||
images: images.all(poi.id).map(image => ({
|
||||
id: image.id,
|
||||
caption: image.caption,
|
||||
sequence: image.sequence,
|
||||
@ -160,30 +285,51 @@ export function getPoi(db, routeId, poiId) {
|
||||
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 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));
|
||||
|
||||
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)
|
||||
);
|
||||
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 || existing.status !== 'active') throw new HttpError(404, 'POI nicht gefunden.');
|
||||
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 radius = fields.triggerRadiusM == null ? existing.trigger_radius_m : Number.parseFloat(fields.triggerRadiusM);
|
||||
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) {
|
||||
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);
|
||||
|
||||
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
|
||||
);
|
||||
return listPois(db, existing.route_id).find(poi => poi.id === Number(poiId));
|
||||
}
|
||||
|
||||
@ -198,45 +344,47 @@ export function deletePoi(db, routeId, poiId) {
|
||||
db.prepare('DELETE FROM pois WHERE id = ? AND route_id = ?').run(poiId, routeId);
|
||||
});
|
||||
|
||||
for (const image of images) removeStoredFile(image.path);
|
||||
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 * FROM routes WHERE id = ? AND status = 'active'").get(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=?`)
|
||||
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);
|
||||
db.prepare('UPDATE pois SET audio_path = REPLACE(audio_path, ?, ?) WHERE route_id = ? AND audio_path IS NOT NULL').run(moved.sourcePrefix, moved.targetPrefix, id);
|
||||
db.prepare(`UPDATE poi_images SET path = REPLACE(path, ?, ?) WHERE poi_id IN (SELECT id FROM pois WHERE route_id = ?)`)
|
||||
.run(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 * FROM routes WHERE id = ? AND status = 'deleted'").get(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);
|
||||
db.prepare('UPDATE pois SET audio_path = REPLACE(audio_path, ?, ?) WHERE route_id = ? AND audio_path IS NOT NULL').run(moved.sourcePrefix, moved.targetPrefix, id);
|
||||
db.prepare(`UPDATE poi_images SET path = REPLACE(path, ?, ?) WHERE poi_id IN (SELECT id FROM pois WHERE route_id = ?)`)
|
||||
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);
|
||||
}
|
||||
|
||||
@ -5,13 +5,31 @@ import { HttpError } from '../middleware/errors.js';
|
||||
|
||||
const activeRoot = path.join(config.storageDir, 'active', 'routes');
|
||||
const trashRoot = path.join(config.storageDir, 'trash', 'routes');
|
||||
const storageRoot = path.resolve(config.storageDir);
|
||||
|
||||
export const routeDirectory = id => path.join(activeRoot, String(id));
|
||||
export const relativeStoragePath = absolute => path.relative(config.storageDir, absolute).split(path.sep).join('/');
|
||||
|
||||
function storedPath(relativePath) {
|
||||
const target = path.resolve(storageRoot, relativePath);
|
||||
const insideStorage = target !== storageRoot && target.startsWith(`${storageRoot}${path.sep}`);
|
||||
if (!insideStorage) throw new HttpError(500, 'Ungültiger interner Medienpfad.');
|
||||
return target;
|
||||
}
|
||||
|
||||
function isFile(target) {
|
||||
try {
|
||||
return fs.statSync(target).isFile();
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureRouteDirectories(id) {
|
||||
const root = routeDirectory(id);
|
||||
for (const dir of [root, path.join(root, 'images'), path.join(root, 'audio')]) fs.mkdirSync(dir, { recursive: true });
|
||||
[root, path.join(root, 'images'), path.join(root, 'audio')]
|
||||
.forEach(directory => fs.mkdirSync(directory, { recursive: true }));
|
||||
return root;
|
||||
}
|
||||
|
||||
@ -22,16 +40,14 @@ export function moveUploadedFile(uploaded, destination) {
|
||||
}
|
||||
|
||||
export function removeUpload(uploaded) {
|
||||
if (uploaded?.path && fs.existsSync(uploaded.path)) fs.rmSync(uploaded.path, { force: true });
|
||||
if (!uploaded?.path || !fs.existsSync(uploaded.path)) return;
|
||||
fs.rmSync(uploaded.path, { force: true });
|
||||
}
|
||||
|
||||
export function removeStoredFile(relativePath) {
|
||||
if (!relativePath) return false;
|
||||
const storageRoot = path.resolve(config.storageDir);
|
||||
const target = path.resolve(storageRoot, relativePath);
|
||||
if (target !== storageRoot && !target.startsWith(`${storageRoot}${path.sep}`)) {
|
||||
throw new HttpError(500, 'Ungültiger interner Medienpfad.');
|
||||
}
|
||||
|
||||
const target = storedPath(relativePath);
|
||||
if (!fs.existsSync(target)) return false;
|
||||
fs.rmSync(target, { force: true });
|
||||
return true;
|
||||
@ -39,10 +55,18 @@ export function removeStoredFile(relativePath) {
|
||||
|
||||
export function softDeleteRouteDirectory(id) {
|
||||
const source = routeDirectory(id);
|
||||
if (!fs.existsSync(source)) throw new HttpError(409, 'Streckenverzeichnis fehlt; Löschmarkierung wurde nicht ausgeführt.');
|
||||
const destination = path.join(trashRoot, `${id}-${new Date().toISOString().replace(/[:.]/g, '-')}`);
|
||||
if (!fs.existsSync(source)) {
|
||||
throw new HttpError(409, 'Streckenverzeichnis fehlt; Löschmarkierung wurde nicht ausgeführt.');
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-');
|
||||
const destination = path.join(trashRoot, `${id}-${timestamp}`);
|
||||
fs.renameSync(source, destination);
|
||||
return { sourcePrefix: relativeStoragePath(source), targetPrefix: relativeStoragePath(destination), destination };
|
||||
return {
|
||||
sourcePrefix: relativeStoragePath(source),
|
||||
targetPrefix: relativeStoragePath(destination),
|
||||
destination
|
||||
};
|
||||
}
|
||||
|
||||
export function restoreRouteDirectory(id, trashRelativePath) {
|
||||
@ -50,23 +74,22 @@ export function restoreRouteDirectory(id, trashRelativePath) {
|
||||
const destination = routeDirectory(id);
|
||||
if (!fs.existsSync(source)) throw new HttpError(409, 'Papierkorbverzeichnis der Strecke fehlt.');
|
||||
if (fs.existsSync(destination)) throw new HttpError(409, 'Aktives Streckenverzeichnis existiert bereits.');
|
||||
|
||||
fs.renameSync(source, destination);
|
||||
return { sourcePrefix: relativeStoragePath(source), targetPrefix: relativeStoragePath(destination), source, destination };
|
||||
return {
|
||||
sourcePrefix: relativeStoragePath(source),
|
||||
targetPrefix: relativeStoragePath(destination),
|
||||
source,
|
||||
destination
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveStoredFile(relativePath) {
|
||||
if (!relativePath || !relativePath.startsWith('active/')) {
|
||||
if (!relativePath?.startsWith('active/')) {
|
||||
throw new HttpError(404, 'Aktive Mediendatei nicht gefunden.');
|
||||
}
|
||||
|
||||
const storageRoot = path.resolve(config.storageDir);
|
||||
const target = path.resolve(storageRoot, relativePath);
|
||||
if (target === storageRoot || !target.startsWith(`${storageRoot}${path.sep}`)) {
|
||||
throw new HttpError(500, 'Ungültiger interner Medienpfad.');
|
||||
}
|
||||
if (!fs.existsSync(target) || !fs.statSync(target).isFile()) {
|
||||
throw new HttpError(404, 'Mediendatei nicht gefunden.');
|
||||
}
|
||||
const target = storedPath(relativePath);
|
||||
if (!isFile(target)) throw new HttpError(404, 'Mediendatei nicht gefunden.');
|
||||
return target;
|
||||
}
|
||||
|
||||
|
||||
@ -68,15 +68,23 @@ test('mobile navigation footer contains a rotating SVG arrow and exact metre out
|
||||
|
||||
test('client bearing calculation points north and east correctly', async () => {
|
||||
const source = await read('public/js/distance.js');
|
||||
const context = { window: { Wegwichtel: {} } };
|
||||
const context = { Wegwichtel: {} };
|
||||
vm.runInNewContext(source, context);
|
||||
|
||||
const distance = context.window.Wegwichtel.Distance;
|
||||
const distance = context.Wegwichtel.Distance;
|
||||
assert.ok(Math.abs(distance.bearing({ lat: 0, lon: 0 }, { lat: 1, lon: 0 })) < 0.001);
|
||||
assert.ok(Math.abs(distance.bearing({ lat: 0, lon: 0 }, { lat: 0, lon: 1 }) - 90) < 0.001);
|
||||
});
|
||||
|
||||
|
||||
|
||||
test('authored browser modules use the portable global object', async () => {
|
||||
const scripts = (await clientSources()).join('\n');
|
||||
|
||||
assert.match(scripts, /globalThis\.Wegwichtel/);
|
||||
assert.doesNotMatch(scripts, /\bwindow\./);
|
||||
});
|
||||
|
||||
test('route controls use accessible icon buttons with start, pause and stop states', async () => {
|
||||
const html = await read('public/index.html');
|
||||
const app = await read('public/js/app.js');
|
||||
@ -88,13 +96,15 @@ test('route controls use accessible icon buttons with start, pause and stop stat
|
||||
assert.match(html, /aria-label="Route starten"/);
|
||||
assert.match(html, /aria-label="Route pausieren"/);
|
||||
assert.match(html, /aria-label="Route beenden"/);
|
||||
assert.match(html, /<fieldset>[\s\S]*<legend>Routensteuerung<\/legend>/);
|
||||
assert.doesNotMatch(html, /role="group"/);
|
||||
assert.doesNotMatch(html, /aria-type=/);
|
||||
assert.match(app, /routeState:\s*'idle'/);
|
||||
assert.match(app, /state\.routeState = 'paused'/);
|
||||
assert.match(app, /state\.routeState = 'running'/);
|
||||
assert.match(app, /function endRoute/);
|
||||
assert.match(geo, /navigator\.geolocation\.watchPosition/);
|
||||
assert.match(geo, /navigator\.geolocation\.clearWatch/);
|
||||
assert.match(geo, /watchPosition/);
|
||||
assert.match(geo, /clearWatch/);
|
||||
});
|
||||
|
||||
test('route control SVG files are present', async () => {
|
||||
@ -171,9 +181,9 @@ test('POI page uses icon-based back and slideshow controls in a single responsiv
|
||||
assert.match(html, /class="back-button"[\s\S]*images\/icons\/back\.svg/);
|
||||
assert.match(html, /id="slide-prev"[\s\S]*images\/icons\/chevron-left\.svg/);
|
||||
assert.match(html, /id="slide-next"[\s\S]*images\/icons\/chevron-right\.svg/);
|
||||
assert.match(html, /class="slide-meta"/);
|
||||
assert.match(css, /#slide-controls[\s\S]*grid-template-columns:\s*var\(--icon-button-size\) minmax\(0, 1fr\) var\(--icon-button-size\)/);
|
||||
assert.match(css, /\.slide-meta[\s\S]*justify-content:\s*center/);
|
||||
assert.match(html, /<fieldset>[\s\S]*<legend>Bildnavigation<\/legend>/);
|
||||
assert.match(css, /#slideshow > fieldset[\s\S]*grid-template-columns:\s*var\(--icon-button-size\) minmax\(0, 1fr\) var\(--icon-button-size\)/);
|
||||
assert.match(css, /#slideshow > fieldset > div[\s\S]*justify-content:\s*center/);
|
||||
assert.match(css, /#slide-caption[\s\S]*overflow-wrap:\s*anywhere/);
|
||||
assert.doesNotMatch(loader, /controlgroup/);
|
||||
});
|
||||
|
||||
@ -14,11 +14,11 @@ test('legacy vibration patterns are exposed as immutable constants', async () =>
|
||||
const calls = [];
|
||||
const context = {
|
||||
navigator: { vibrate: pattern => { calls.push(pattern); return true; } },
|
||||
window: { Wegwichtel: {} }
|
||||
Wegwichtel: {}
|
||||
};
|
||||
|
||||
vm.runInNewContext(await source(), context);
|
||||
const vibration = context.window.Wegwichtel.Vibration;
|
||||
const vibration = context.Wegwichtel.Vibration;
|
||||
|
||||
assert.equal(vibration.Patterns.MICRO, 50);
|
||||
assert.equal(vibration.Patterns.MINI, 100);
|
||||
@ -34,26 +34,28 @@ test('vibration wrapper starts named patterns and can cancel them', async () =>
|
||||
const calls = [];
|
||||
const context = {
|
||||
navigator: { vibrate: pattern => { calls.push(pattern); return true; } },
|
||||
window: { Wegwichtel: {} }
|
||||
Wegwichtel: {}
|
||||
};
|
||||
|
||||
vm.runInNewContext(await source(), context);
|
||||
const vibration = context.window.Wegwichtel.Vibration;
|
||||
const vibration = context.Wegwichtel.Vibration;
|
||||
|
||||
assert.equal(vibration.start(vibration.Patterns.ACTIVE_POI), true);
|
||||
assert.equal(vibration.start(vibration.Patterns.SHORT), true);
|
||||
assert.equal(vibration.stop(), true);
|
||||
assert.deepEqual(Array.from(calls[0]), [500, 300, 500]);
|
||||
assert.equal(calls[1], 0);
|
||||
assert.deepEqual(Array.from(calls[1]), [200]);
|
||||
assert.equal(calls[2], 0);
|
||||
});
|
||||
|
||||
test('vibration wrapper fails harmlessly when the browser API is unavailable', async () => {
|
||||
const context = {
|
||||
navigator: {},
|
||||
window: { Wegwichtel: {} }
|
||||
Wegwichtel: {}
|
||||
};
|
||||
|
||||
vm.runInNewContext(await source(), context);
|
||||
const vibration = context.window.Wegwichtel.Vibration;
|
||||
const vibration = context.Wegwichtel.Vibration;
|
||||
|
||||
assert.equal(vibration.isSupported(), false);
|
||||
assert.equal(vibration.start(vibration.Patterns.SHORT), false);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user