update editor and add tests
This commit is contained in:
@@ -15,6 +15,7 @@
|
|||||||
"init-db": "node --env-file-if-exists=.env scripts/init-db.js",
|
"init-db": "node --env-file-if-exists=.env scripts/init-db.js",
|
||||||
"check": "node --check server.js && npm test",
|
"check": "node --check server.js && npm test",
|
||||||
"test": "node --test && npm run test:python",
|
"test": "node --test && npm run test:python",
|
||||||
|
"test:runtime-db": "node --env-file-if-exists=.env --test test/runtime-database-integrity.test.js",
|
||||||
"test:coverage": "npm run test:coverage:js && npm run test:coverage:python",
|
"test:coverage": "npm run test:coverage:js && npm run test:coverage:python",
|
||||||
"test:python": "python3 -m unittest discover -s test/python -p 'test_*.py'",
|
"test:python": "python3 -m unittest discover -s test/python -p 'test_*.py'",
|
||||||
"test:coverage:js": "c8 --exclude='test/**' --exclude='test-support/**' --reporter=text-summary --reporter=lcov --reports-dir=coverage/js node --test",
|
"test:coverage:js": "c8 --exclude='test/**' --exclude='test-support/**' --reporter=text-summary --reporter=lcov --reports-dir=coverage/js node --test",
|
||||||
|
|||||||
+206
-31
@@ -1,27 +1,178 @@
|
|||||||
.editor-entry-panel {
|
.route-control {
|
||||||
align-items: center;
|
position: fixed;
|
||||||
background: rgba(255, 255, 255, .92);
|
top: calc(env(safe-area-inset-top) + .45rem);
|
||||||
border: 1px solid rgba(49, 93, 58, .22);
|
right: calc(max(.65rem, env(safe-area-inset-right)) + var(--icon-button-size) + .5rem);
|
||||||
border-radius: 1.2rem;
|
z-index: 10000;
|
||||||
box-shadow: 0 .5rem 1.4rem rgba(20, 40, 24, .08);
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-top: 1.4rem;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-entry-panel h2,
|
.route-control-button,
|
||||||
.editor-entry-panel p {
|
.route-control-button.ui-button {
|
||||||
|
display: grid;
|
||||||
|
width: min(13rem, calc(100vw - var(--icon-button-size) - 5.2rem));
|
||||||
|
min-width: 0;
|
||||||
|
min-height: var(--icon-button-size);
|
||||||
|
box-sizing: border-box;
|
||||||
|
gap: .08rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
padding: .35rem .75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid rgba(255, 255, 255, .9);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #24462b;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 .15rem .55rem rgba(0, 0, 0, .28);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: manipulation;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-entry-panel p {
|
.route-control-button strong,
|
||||||
color: #4e604f;
|
.route-control-button small {
|
||||||
margin-top: .25rem;
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-control-button strong {
|
||||||
|
font-size: .9rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-control-button small {
|
||||||
|
color: rgba(255, 255, 255, .86);
|
||||||
|
font-size: .68rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-control-button:focus-visible,
|
||||||
|
.route-control-button.ui-button:focus-visible {
|
||||||
|
outline: .2rem solid #fff;
|
||||||
|
outline-offset: .15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-control-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(var(--icon-button-size) + .45rem);
|
||||||
|
right: 0;
|
||||||
|
width: min(24rem, calc(100vw - 1.3rem));
|
||||||
|
max-height: min(70dvh, 36rem);
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: .8rem;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
border: 1px solid rgba(49, 93, 58, .28);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #f8f5eb;
|
||||||
|
color: #233326;
|
||||||
|
box-shadow: 0 .8rem 2rem rgba(20, 32, 22, .28);
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-control-dropdown section + section {
|
||||||
|
margin-top: .85rem;
|
||||||
|
padding-top: .75rem;
|
||||||
|
border-top: 1px solid rgba(49, 93, 58, .18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-control-dropdown h2 {
|
||||||
|
margin: 0 0 .45rem;
|
||||||
|
font-size: .95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-control-list {
|
||||||
|
display: grid;
|
||||||
|
gap: .4rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-control-list li > span {
|
||||||
|
display: block;
|
||||||
|
padding: .6rem .7rem;
|
||||||
|
border-radius: .65rem;
|
||||||
|
background: #fff;
|
||||||
|
color: #607160;
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-control-list button,
|
||||||
|
.route-control-list button.ui-button {
|
||||||
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.8rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
gap: .12rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: .55rem .7rem;
|
||||||
|
border: 1px solid rgba(49, 93, 58, .25);
|
||||||
|
border-radius: .7rem;
|
||||||
|
background: #fff;
|
||||||
|
color: #233326;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-control-list button strong,
|
||||||
|
.route-control-list button small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-control-list button small {
|
||||||
|
color: #607160;
|
||||||
|
font-size: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-control-list button[aria-current="true"] {
|
||||||
|
border-color: #315d3a;
|
||||||
|
background: #e7f1e9;
|
||||||
|
box-shadow: inset .25rem 0 0 #315d3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-control-list button:focus-visible,
|
||||||
|
.route-control-list button.ui-button:focus-visible {
|
||||||
|
outline: .2rem solid #1e6fba;
|
||||||
|
outline-offset: .1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-control-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: .5rem;
|
||||||
|
margin-top: .85rem;
|
||||||
|
padding-top: .75rem;
|
||||||
|
border-top: 1px solid rgba(49, 93, 58, .18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-button,
|
.editor-button,
|
||||||
|
.editor-primary-button,
|
||||||
|
.editor-secondary-button,
|
||||||
|
.editor-icon-button {
|
||||||
|
border-radius: .75rem;
|
||||||
|
font: inherit;
|
||||||
|
min-height: 2.4rem;
|
||||||
|
padding: .55rem .85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-button,
|
||||||
|
.editor-primary-button {
|
||||||
|
background: #315d3a;
|
||||||
|
border: 1px solid #315d3a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-button {
|
||||||
|
align-items: center;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-round-button {
|
.editor-round-button {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: #315d3a;
|
background: #315d3a;
|
||||||
@@ -128,6 +279,18 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-route-group {
|
||||||
|
display: grid;
|
||||||
|
gap: .45rem;
|
||||||
|
margin: .2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-route-group > h4 {
|
||||||
|
margin: .45rem 0 0;
|
||||||
|
color: #315d3a;
|
||||||
|
font-size: .95rem;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-poi-list {
|
.editor-poi-list {
|
||||||
counter-reset: editor-poi;
|
counter-reset: editor-poi;
|
||||||
}
|
}
|
||||||
@@ -183,21 +346,6 @@
|
|||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-primary-button,
|
|
||||||
.editor-secondary-button,
|
|
||||||
.editor-icon-button {
|
|
||||||
border-radius: .75rem;
|
|
||||||
font: inherit;
|
|
||||||
min-height: 2.4rem;
|
|
||||||
padding: .55rem .85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-primary-button {
|
|
||||||
background: #315d3a;
|
|
||||||
border: 1px solid #315d3a;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-secondary-button,
|
.editor-secondary-button,
|
||||||
.editor-icon-button {
|
.editor-icon-button {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
@@ -228,6 +376,33 @@
|
|||||||
margin-top: .5rem;
|
margin-top: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 34rem) {
|
||||||
|
.route-control {
|
||||||
|
right: calc(max(.5rem, env(safe-area-inset-right)) + var(--icon-button-size) + .35rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-control-button,
|
||||||
|
.route-control-button.ui-button {
|
||||||
|
width: min(10.5rem, calc(100vw - var(--icon-button-size) - 4.7rem));
|
||||||
|
padding-right: .55rem;
|
||||||
|
padding-left: .55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-control-dropdown {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(env(safe-area-inset-top) + var(--icon-button-size) + .9rem);
|
||||||
|
right: max(.65rem, env(safe-area-inset-right));
|
||||||
|
left: max(.65rem, env(safe-area-inset-left));
|
||||||
|
width: auto;
|
||||||
|
max-height: calc(100dvh - env(safe-area-inset-top) - 5.4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialog {
|
||||||
|
max-width: calc(100vw - 1rem);
|
||||||
|
width: calc(100vw - 1rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 42rem) {
|
@media (min-width: 42rem) {
|
||||||
.editor-coordinate-grid {
|
.editor-coordinate-grid {
|
||||||
grid-template-columns: max-content minmax(0, 1fr);
|
grid-template-columns: max-content minmax(0, 1fr);
|
||||||
|
|||||||
+48
-45
@@ -26,7 +26,6 @@
|
|||||||
<span aria-hidden="true">🧭</span>
|
<span aria-hidden="true">🧭</span>
|
||||||
<h1>Wegwichtel</h1>
|
<h1>Wegwichtel</h1>
|
||||||
<p>Der Wegwichtel bereitet Routen und Medien vor.</p>
|
<p>Der Wegwichtel bereitet Routen und Medien vor.</p>
|
||||||
|
|
||||||
<div class="privacy-summary">
|
<div class="privacy-summary">
|
||||||
<p><strong>Cookies:</strong> Diese Anwendung setzt derzeit keine Cookies und verwendet weder Werbung noch Tracking.</p>
|
<p><strong>Cookies:</strong> Diese Anwendung setzt derzeit keine Cookies und verwendet weder Werbung noch Tracking.</p>
|
||||||
<label class="switch-row switch-disabled">
|
<label class="switch-row switch-disabled">
|
||||||
@@ -47,7 +46,6 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p id="storage-cleanup-status" class="storage-cleanup-status" hidden></p>
|
<p id="storage-cleanup-status" class="storage-cleanup-status" hidden></p>
|
||||||
|
|
||||||
<progress id="bootstrap-progress" max="6" value="0"></progress>
|
<progress id="bootstrap-progress" max="6" value="0"></progress>
|
||||||
<ol id="bootstrap-steps">
|
<ol id="bootstrap-steps">
|
||||||
<li data-step="jquery">jQuery laden</li>
|
<li data-step="jquery">jQuery laden</li>
|
||||||
@@ -64,11 +62,30 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<main id="app-shell" hidden>
|
<main id="app-shell" hidden>
|
||||||
|
<div class="route-control">
|
||||||
|
<button id="route-control-button" class="route-control-button" type="button" aria-label="Strecke auswählen oder wechseln" title="Strecke auswählen oder wechseln" aria-haspopup="true" aria-expanded="false" aria-controls="route-control-dropdown">
|
||||||
|
<strong id="route-control-title">Route wählen</strong>
|
||||||
|
<small id="route-control-next">Aktive und lokale Strecken</small>
|
||||||
|
</button>
|
||||||
|
<nav id="route-control-dropdown" class="route-control-dropdown" aria-label="Streckenauswahl und Streckenverwaltung" hidden>
|
||||||
|
<section aria-labelledby="route-control-server-title">
|
||||||
|
<h2 id="route-control-server-title">Aktive Strecken</h2>
|
||||||
|
<ul id="route-control-server-list" class="route-control-list" aria-live="polite"></ul>
|
||||||
|
</section>
|
||||||
|
<section aria-labelledby="route-control-local-title">
|
||||||
|
<h2 id="route-control-local-title">Lokale Strecken</h2>
|
||||||
|
<ul id="route-control-local-list" class="route-control-list" aria-live="polite"></ul>
|
||||||
|
</section>
|
||||||
|
<div class="route-control-actions">
|
||||||
|
<button id="editor-button" class="editor-button" type="button" aria-label="Streckenverwaltung öffnen" title="Streckenverwaltung öffnen" aria-haspopup="dialog" aria-expanded="false" aria-controls="editor-dialog">Strecken verwalten</button>
|
||||||
|
<button id="route-control-new" class="editor-primary-button" type="button">Neue lokale Strecke</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
<section id="routes-page" class="app-page">
|
<section id="routes-page" class="app-page">
|
||||||
<header>
|
<header>
|
||||||
<h1>Wegwichtel</h1>
|
<h1>Wegwichtel</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p id="location-hint" class="notice">Standort wird für nahe Routen lokal auf diesem Gerät ermittelt.</p>
|
<p id="location-hint" class="notice">Standort wird für nahe Routen lokal auf diesem Gerät ermittelt.</p>
|
||||||
|
|
||||||
@@ -81,7 +98,6 @@
|
|||||||
<section>
|
<section>
|
||||||
<h2>Alle Routen</h2>
|
<h2>Alle Routen</h2>
|
||||||
<label for="route-search">Routen durchsuchen</label>
|
<label for="route-search">Routen durchsuchen</label>
|
||||||
|
|
||||||
<div class="search-row">
|
<div class="search-row">
|
||||||
<input
|
<input
|
||||||
id="route-search"
|
id="route-search"
|
||||||
@@ -96,15 +112,12 @@
|
|||||||
<li><p>Routen werden geladen …</p></li>
|
<li><p>Routen werden geladen …</p></li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
<section aria-labelledby="local-route-title">
|
||||||
<section class="editor-entry-panel" aria-labelledby="editor-entry-title">
|
<h2 id="local-route-title">Lokale Strecken</h2>
|
||||||
<div>
|
<p>Diese Entwürfe bestehen aus GPX- und JSON-Daten und sind ausschließlich in diesem Browser gespeichert.</p>
|
||||||
<h2 id="editor-entry-title">Strecken verwalten</h2>
|
<ul id="local-route-list" class="route-list" aria-live="polite">
|
||||||
<p>Strecken, Stationen, Bilder und Audiodateien bearbeiten.</p>
|
<li><p>Lokale Strecken werden geladen …</p></li>
|
||||||
</div>
|
</ul>
|
||||||
<button id="editor-button" class="editor-button" type="button" aria-label="Streckeneditor öffnen" title="Streckeneditor öffnen" aria-haspopup="dialog" aria-expanded="false" aria-controls="editor-dialog">
|
|
||||||
<span aria-hidden="true">+</span>
|
|
||||||
</button>
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -115,7 +128,6 @@
|
|||||||
</button>
|
</button>
|
||||||
<h1 id="route-title">Route</h1>
|
<h1 id="route-title">Route</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<p id="route-description"></p>
|
<p id="route-description"></p>
|
||||||
<dl>
|
<dl>
|
||||||
@@ -140,7 +152,6 @@
|
|||||||
<ul id="poi-list" class="route-list"></ul>
|
<ul id="poi-list" class="route-list"></ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="poi-page" class="app-page" hidden>
|
<section id="poi-page" class="app-page" hidden>
|
||||||
<header class="has-back-button">
|
<header class="has-back-button">
|
||||||
<button class="back-button" type="button" data-page-target="#route-page" aria-label="Zurück zur Route" title="Zurück zur Route">
|
<button class="back-button" type="button" data-page-target="#route-page" aria-label="Zurück zur Route" title="Zurück zur Route">
|
||||||
@@ -225,11 +236,14 @@
|
|||||||
<input class="progress-storage-toggle" type="checkbox" role="switch" aria-checked="false">
|
<input class="progress-storage-toggle" type="checkbox" role="switch" aria-checked="false">
|
||||||
<span class="switch-track" aria-hidden="true"></span>
|
<span class="switch-track" aria-hidden="true"></span>
|
||||||
</label>
|
</label>
|
||||||
<h3>5. Cookies und Tracking</h3>
|
<h3>5. Lokale Streckenentwürfe</h3>
|
||||||
|
<p>Neu angelegte Strecken werden zunächst ausschließlich im Local Storage dieses Browsers gespeichert. Die GPX-Daten enthalten den Streckenverlauf und die POI-Koordinaten. Ergänzende JSON-Daten ordnen den POIs Texte, Audiodateien und Bilder zu. Medien werden dafür als lokale Browserdaten gespeichert.</p>
|
||||||
|
<p>Lokale Streckenentwürfe sind bewusst von der freiwilligen Fortschrittsspeicherung getrennt. Sie werden nicht durch die 24-Stunden-Bereinigung entfernt und beim Ausschalten der Fortschrittsspeicherung nicht gelöscht. Sie verbleiben im Browser, bis sie ausdrücklich gelöscht werden oder die Browserdaten entfernt werden. Eine Übertragung an den Server findet derzeit nicht statt.</p>
|
||||||
|
<h3>6. Cookies und Tracking</h3>
|
||||||
<p>Die Anwendung setzt derzeit keine Cookies. Sie verwendet keine Reichweitenmessung, keine personalisierte Werbung und keine Trackingdienste. Eine technisch notwendige Local-Storage-Einstellung merkt sich lediglich, ob die freiwillige Speicherung des Streckenfortschritts aktiviert wurde.</p>
|
<p>Die Anwendung setzt derzeit keine Cookies. Sie verwendet keine Reichweitenmessung, keine personalisierte Werbung und keine Trackingdienste. Eine technisch notwendige Local-Storage-Einstellung merkt sich lediglich, ob die freiwillige Speicherung des Streckenfortschritts aktiviert wurde.</p>
|
||||||
<h3>6. Inhalte von Schülerinnen und Schülern</h3>
|
<h3>7. Inhalte von Schülerinnen und Schülern</h3>
|
||||||
<p>Fotos, Texte und Sprachaufnahmen dürfen nur veröffentlicht werden, wenn die erforderlichen Rechte und Einwilligungen vorliegen. Besonders bei erkennbaren Kindern sind die Vorgaben der Schule und die Einwilligungen der Sorgeberechtigten zu beachten.</p>
|
<p>Fotos, Texte und Sprachaufnahmen dürfen nur veröffentlicht werden, wenn die erforderlichen Rechte und Einwilligungen vorliegen. Besonders bei erkennbaren Kindern sind die Vorgaben der Schule und die Einwilligungen der Sorgeberechtigten zu beachten.</p>
|
||||||
<h3>7. Rechte betroffener Personen</h3>
|
<h3>8. Rechte betroffener Personen</h3>
|
||||||
<p>Betroffene Personen können je nach rechtlicher Voraussetzung Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Datenübertragbarkeit und Widerspruch verlangen. Erteilte Einwilligungen können für die Zukunft widerrufen werden. Außerdem besteht das Recht, sich bei einer Datenschutzaufsichtsbehörde über die Verarbeitung personenbezogener Daten zu beschweren.</p>
|
<p>Betroffene Personen können je nach rechtlicher Voraussetzung Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Datenübertragbarkeit und Widerspruch verlangen. Erteilte Einwilligungen können für die Zukunft widerrufen werden. Außerdem besteht das Recht, sich bei einer Datenschutzaufsichtsbehörde über die Verarbeitung personenbezogener Daten zu beschweren.</p>
|
||||||
<p>
|
<p>
|
||||||
Zuständige Datenschutzaufsicht:<br>
|
Zuständige Datenschutzaufsicht:<br>
|
||||||
@@ -250,7 +264,6 @@
|
|||||||
Zum Eichig 3<br>
|
Zum Eichig 3<br>
|
||||||
98617 Meiningen / OT Herpf
|
98617 Meiningen / OT Herpf
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3>Kontakt</h3>
|
<h3>Kontakt</h3>
|
||||||
<p>
|
<p>
|
||||||
Telefon: (036943) 63722<br>
|
Telefon: (036943) 63722<br>
|
||||||
@@ -268,7 +281,6 @@
|
|||||||
</p>
|
</p>
|
||||||
<h3>Inhaltlich verantwortlich</h3>
|
<h3>Inhaltlich verantwortlich</h3>
|
||||||
<p>Frau Wichert</p>
|
<p>Frau Wichert</p>
|
||||||
|
|
||||||
<h3>Weitere Pflichtangaben</h3>
|
<h3>Weitere Pflichtangaben</h3>
|
||||||
<p>Je nach konkretem Betrieb der Wegwichtel-Instanz können zusätzliche Angaben erforderlich sein, zum Beispiel zur technischen Betreiberin, zum Hosting oder zu ergänzenden Zuständigkeiten. Nicht benötigte Angaben sind zu entfernen.</p>
|
<p>Je nach konkretem Betrieb der Wegwichtel-Instanz können zusätzliche Angaben erforderlich sein, zum Beispiel zur technischen Betreiberin, zum Hosting oder zu ergänzenden Zuständigkeiten. Nicht benötigte Angaben sind zu entfernen.</p>
|
||||||
<h3>Haftung für Inhalte und Verweise</h3>
|
<h3>Haftung für Inhalte und Verweise</h3>
|
||||||
@@ -289,6 +301,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<h3>Local Storage</h3>
|
<h3>Local Storage</h3>
|
||||||
<p>Der Browser kann auf Wunsch Streckenfortschritte lokal speichern. Verwendet werden die Schlüssel <code>wegwichtel.privacy.v1</code> für die gewählte Einstellung und <code>wegwichtel.route-progress.v1</code> für die freiwilligen Streckendaten. Die Streckendaten werden nach 24 Stunden bereinigt oder beim Ausschalten sofort entfernt.</p>
|
<p>Der Browser kann auf Wunsch Streckenfortschritte lokal speichern. Verwendet werden die Schlüssel <code>wegwichtel.privacy.v1</code> für die gewählte Einstellung und <code>wegwichtel.route-progress.v1</code> für die freiwilligen Streckendaten. Die Streckendaten werden nach 24 Stunden bereinigt oder beim Ausschalten sofort entfernt.</p>
|
||||||
|
<p>Lokale Streckenentwürfe werden getrennt davon unter <code>wegwichtel.local-routes.v1.*</code> gespeichert. Pro Entwurf werden GPX- und JSON-Daten vorgehalten; Bilder und Audiodateien liegen als Bestandteil der lokalen JSON-Daten im Browser. Diese Entwürfe werden nicht nach 24 Stunden bereinigt und sind nicht vom Schalter für den Streckenfortschritt abhängig. Durch Mediendateien kann das Speicherlimit des Browsers vergleichsweise schnell erreicht werden.</p>
|
||||||
<label class="switch-row compact-switch">
|
<label class="switch-row compact-switch">
|
||||||
<span>
|
<span>
|
||||||
<strong>Streckenfortschritt lokal speichern</strong>
|
<strong>Streckenfortschritt lokal speichern</strong>
|
||||||
@@ -301,7 +314,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li><strong>jQuery 4.0.0:</strong> DOM-Zugriffe, Ereignisbehandlung und API-Anfragen.</li>
|
<li><strong>jQuery 4.0.0:</strong> DOM-Zugriffe, Ereignisbehandlung und API-Anfragen.</li>
|
||||||
<li><strong>jQuery UI 1.14.2:</strong> Schaltflächen und Suchvorschläge.</li>
|
<li><strong>jQuery UI 1.14.2:</strong> Schaltflächen und Suchvorschläge.</li>
|
||||||
<li><strong>Wegwichtel-Module:</strong> Routenanzeige, Navigation, Standort, Geräteausrichtung, Bilder, Audio, Vibration, Datenschutz und lokaler Fortschritt.</li>
|
<li><strong>Wegwichtel-Module:</strong> Routenanzeige, Navigation, Standort, Geräteausrichtung, Bilder, Audio, Vibration, Datenschutz, lokaler Fortschritt und lokale Streckenentwürfe.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>Alle Bibliotheken werden vom eigenen Server ausgeliefert. Die Anwendung lädt keine JavaScripts, Schriftarten oder Analysepixel von externen Anbietern.</p>
|
<p>Alle Bibliotheken werden vom eigenen Server ausgeliefert. Die Anwendung lädt keine JavaScripts, Schriftarten oder Analysepixel von externen Anbietern.</p>
|
||||||
<h3>Browser-Schnittstellen</h3>
|
<h3>Browser-Schnittstellen</h3>
|
||||||
@@ -310,42 +323,38 @@
|
|||||||
<li>Device Orientation API für den Richtungspfeil,</li>
|
<li>Device Orientation API für den Richtungspfeil,</li>
|
||||||
<li>Vibration API für Hinweise bei erreichten Stationen,</li>
|
<li>Vibration API für Hinweise bei erreichten Stationen,</li>
|
||||||
<li>HTML-Audio und HTML-Bilder für lokal bereitgestellte Medien,</li>
|
<li>HTML-Audio und HTML-Bilder für lokal bereitgestellte Medien,</li>
|
||||||
|
<li>FileReader und XML-Schnittstellen zum lokalen Verarbeiten von GPX-, Bild- und Audiodateien,</li>
|
||||||
<li>History API für die Navigation zwischen den Ansichten.</li>
|
<li>History API für die Navigation zwischen den Ansichten.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<h3>Technische Grenzen</h3>
|
<h3>Technische Grenzen</h3>
|
||||||
<p>Browser und Betriebssystem können Standort, Kompass, Audio, Vibration oder Local Storage sperren. Die Anwendung bleibt grundsätzlich nutzbar, einzelne Komfortfunktionen können dann jedoch fehlen.</p>
|
<p>Browser und Betriebssystem können Standort, Kompass, Audio, Vibration oder Local Storage sperren. Die Anwendung bleibt grundsätzlich nutzbar, einzelne Komfortfunktionen können dann jedoch fehlen. Für lokale Streckenentwürfe gilt zusätzlich das vom Browser vorgegebene Local-Storage-Limit.</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<dialog id="editor-dialog" class="editor-dialog" aria-labelledby="editor-dialog-title">
|
<dialog id="editor-dialog" class="editor-dialog" aria-labelledby="editor-dialog-title">
|
||||||
<div class="editor-dialog-shell">
|
<div class="editor-dialog-shell">
|
||||||
<header class="editor-dialog-header">
|
<header class="editor-dialog-header">
|
||||||
<h2 id="editor-dialog-title">Streckeneditor</h2>
|
<h2 id="editor-dialog-title">Streckenverwaltung</h2>
|
||||||
<button id="editor-close" type="button" aria-label="Streckeneditor schließen" title="Schließen">×</button>
|
<button id="editor-close" type="button" aria-label="Streckenverwaltung schließen" title="Schließen">×</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<output id="editor-status" class="editor-status" aria-live="polite" hidden></output>
|
<output id="editor-status" class="editor-status" aria-live="polite" hidden></output>
|
||||||
|
|
||||||
<section id="editor-route-list-view" class="editor-view" aria-labelledby="editor-route-list-title">
|
<section id="editor-route-list-view" class="editor-view" aria-labelledby="editor-route-list-title">
|
||||||
<div class="editor-toolbar">
|
<div class="editor-toolbar">
|
||||||
<h3 id="editor-route-list-title">Vorhandene Strecken</h3>
|
<h3 id="editor-route-list-title">Vorhandene Strecken</h3>
|
||||||
<button id="editor-new-route" class="editor-round-button" type="button" aria-label="Neue Strecke anlegen" title="Neue Strecke anlegen">
|
<button id="editor-new-route" class="editor-round-button" type="button" aria-label="Neue lokale Strecke anlegen" title="Neue lokale Strecke anlegen">
|
||||||
<span aria-hidden="true">+</span>
|
<span aria-hidden="true">+</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="editor-muted">Veröffentlichte Serverstrecken und ausschließlich in diesem Browser gespeicherte Entwürfe werden getrennt dargestellt.</p>
|
||||||
<ul id="editor-route-list" class="editor-route-list" aria-live="polite"></ul>
|
<ul id="editor-route-list" class="editor-route-list" aria-live="polite"></ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="editor-route-detail-view" class="editor-view" aria-labelledby="editor-route-detail-title" hidden>
|
<section id="editor-route-detail-view" class="editor-view" aria-labelledby="editor-route-detail-title" hidden>
|
||||||
<div class="editor-toolbar editor-toolbar-back">
|
<div class="editor-toolbar editor-toolbar-back">
|
||||||
<button id="editor-route-back" class="editor-secondary-button" type="button">Zurück</button>
|
<button id="editor-route-back" class="editor-secondary-button" type="button">Zurück</button>
|
||||||
<h3 id="editor-route-detail-title">Streckendetails</h3>
|
<h3 id="editor-route-detail-title">Streckendetails</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="editor-route-form" class="editor-form">
|
<form id="editor-route-form" class="editor-form">
|
||||||
<input id="editor-route-id" type="hidden">
|
<input id="editor-route-id" type="hidden">
|
||||||
<label for="editor-route-name">Name der Strecke</label>
|
<label for="editor-route-name">Name der Strecke</label>
|
||||||
@@ -353,24 +362,21 @@
|
|||||||
|
|
||||||
<label for="editor-route-description">Beschreibung</label>
|
<label for="editor-route-description">Beschreibung</label>
|
||||||
<textarea id="editor-route-description" rows="4"></textarea>
|
<textarea id="editor-route-description" rows="4"></textarea>
|
||||||
|
<label for="editor-route-gpx">GPX-Datei importieren oder ersetzen</label>
|
||||||
<label for="editor-route-gpx">GPX-Datei optional ersetzen oder ergänzen</label>
|
|
||||||
<input id="editor-route-gpx" type="file" accept=".gpx,application/gpx+xml,text/xml,application/xml">
|
<input id="editor-route-gpx" type="file" accept=".gpx,application/gpx+xml,text/xml,application/xml">
|
||||||
|
|
||||||
<p id="editor-route-activated" class="editor-muted">Neue Strecken bleiben unveröffentlicht, bis sie manuell in der Datenbank aktiviert werden.</p>
|
<p id="editor-route-activated" class="editor-muted">Neue Strecken werden als lokaler GPX-/JSON-Entwurf gespeichert und nicht an den Server übertragen.</p>
|
||||||
|
|
||||||
<button id="editor-route-save" class="editor-primary-button" type="submit">Strecke speichern</button>
|
<button id="editor-route-save" class="editor-primary-button" type="submit">Strecke speichern</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="editor-toolbar editor-poi-toolbar">
|
<div class="editor-toolbar editor-poi-toolbar">
|
||||||
<h3>Stationen</h3>
|
<h3>Stationen</h3>
|
||||||
<button id="editor-add-poi" class="editor-round-button" type="button" aria-label="POI hinzufügen" title="POI hinzufügen">
|
<button id="editor-add-poi" class="editor-round-button" type="button" aria-label="GPX-POI hinzufügen" title="GPX-POI hinzufügen">
|
||||||
<span aria-hidden="true">+</span>
|
<span aria-hidden="true">+</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ol id="editor-poi-list" class="editor-poi-list"></ol>
|
<ol id="editor-poi-list" class="editor-poi-list"></ol>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="editor-poi-form-view" class="editor-view" aria-labelledby="editor-poi-form-title" hidden>
|
<section id="editor-poi-form-view" class="editor-view" aria-labelledby="editor-poi-form-title" hidden>
|
||||||
<div class="editor-toolbar editor-toolbar-back">
|
<div class="editor-toolbar editor-toolbar-back">
|
||||||
<button id="editor-poi-cancel" class="editor-secondary-button" type="button">Zurück zur Strecke</button>
|
<button id="editor-poi-cancel" class="editor-secondary-button" type="button">Zurück zur Strecke</button>
|
||||||
@@ -379,7 +385,6 @@
|
|||||||
|
|
||||||
<form id="editor-poi-form" class="editor-form">
|
<form id="editor-poi-form" class="editor-form">
|
||||||
<input id="editor-poi-id" type="hidden">
|
<input id="editor-poi-id" type="hidden">
|
||||||
|
|
||||||
<label for="editor-poi-title">Titel</label>
|
<label for="editor-poi-title">Titel</label>
|
||||||
<input id="editor-poi-title" type="text" autocomplete="off" required>
|
<input id="editor-poi-title" type="text" autocomplete="off" required>
|
||||||
|
|
||||||
@@ -388,19 +393,17 @@
|
|||||||
|
|
||||||
<div class="editor-coordinate-grid">
|
<div class="editor-coordinate-grid">
|
||||||
<label for="editor-poi-lat">Breite</label>
|
<label for="editor-poi-lat">Breite</label>
|
||||||
<input id="editor-poi-lat" type="number" inputmode="decimal" step="0.000001">
|
<input id="editor-poi-lat" type="number" inputmode="decimal" step="0.000001" required>
|
||||||
|
|
||||||
<label for="editor-poi-lon">Länge</label>
|
<label for="editor-poi-lon">Länge</label>
|
||||||
<input id="editor-poi-lon" type="number" inputmode="decimal" step="0.000001">
|
<input id="editor-poi-lon" type="number" inputmode="decimal" step="0.000001" required>
|
||||||
|
|
||||||
<label for="editor-poi-radius">Auslöseradius in Metern</label>
|
<label for="editor-poi-radius">Auslöseradius in Metern</label>
|
||||||
<input id="editor-poi-radius" type="number" inputmode="numeric" min="1" step="1" value="25">
|
<input id="editor-poi-radius" type="number" inputmode="numeric" min="1" step="1" value="25">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label for="editor-poi-audio">Audiodatei optional hochladen</label>
|
<label for="editor-poi-audio">Audiodatei verknüpfen</label>
|
||||||
<input id="editor-poi-audio" type="file" accept="audio/*">
|
<input id="editor-poi-audio" type="file" accept="audio/*">
|
||||||
|
<label for="editor-poi-images">Bilder verknüpfen</label>
|
||||||
<label for="editor-poi-images">Bilder optional hochladen</label>
|
|
||||||
<input id="editor-poi-images" type="file" accept="image/*" multiple>
|
<input id="editor-poi-images" type="file" accept="image/*" multiple>
|
||||||
|
|
||||||
<button id="editor-poi-save" class="editor-primary-button" type="submit">Station speichern</button>
|
<button id="editor-poi-save" class="editor-primary-button" type="submit">Station speichern</button>
|
||||||
@@ -408,7 +411,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
<script src="js/local-routes.js?v=0.12.14"></script>
|
||||||
<script src="js/editor-ui.js?v=0.12.14"></script>
|
<script src="js/editor-ui.js?v=0.12.14"></script>
|
||||||
<script src="js/privacy-storage.js?v=0.12.14"></script>
|
<script src="js/privacy-storage.js?v=0.12.14"></script>
|
||||||
<script src="js/legal-ui.js?v=0.12.14"></script>
|
<script src="js/legal-ui.js?v=0.12.14"></script>
|
||||||
|
|||||||
+13
-1
@@ -1,11 +1,23 @@
|
|||||||
(function (ns, $) {
|
(function (ns, $) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
function request(path) {
|
function request(path) {
|
||||||
return $.ajax({ url: ns.Config.apiBase + path, method: 'GET', dataType: 'json', cache: false });
|
return $.ajax({ url: ns.Config.apiBase + path, method: 'GET', dataType: 'json', cache: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function routeQuery(position) {
|
||||||
|
const params = new URLSearchParams({ activatedOnly: 'true' });
|
||||||
|
if (position) {
|
||||||
|
params.set('lat', String(position.lat));
|
||||||
|
params.set('lon', String(position.lon));
|
||||||
|
params.set('radiusKm', String(ns.Config.routeRadiusKm));
|
||||||
|
}
|
||||||
|
return `/routes?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
ns.Api = {
|
ns.Api = {
|
||||||
health: () => request('/health'),
|
health: () => request('/health'),
|
||||||
routes: position => request('/routes' + (position ? `?lat=${encodeURIComponent(position.lat)}&lon=${encodeURIComponent(position.lon)}&radiusKm=${ns.Config.routeRadiusKm}` : '')),
|
routes: position => request(routeQuery(position)),
|
||||||
route: id => request(`/routes/${encodeURIComponent(id)}`)
|
route: id => request(`/routes/${encodeURIComponent(id)}`)
|
||||||
};
|
};
|
||||||
}(globalThis.Wegwichtel, globalThis.jQuery));
|
}(globalThis.Wegwichtel, globalThis.jQuery));
|
||||||
|
|||||||
+215
-69
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
routes: [],
|
routes: [],
|
||||||
|
localRoutes: [],
|
||||||
|
localRoutesError: null,
|
||||||
route: null,
|
route: null,
|
||||||
position: null,
|
position: null,
|
||||||
triggered: new Set(),
|
triggered: new Set(),
|
||||||
@@ -20,7 +22,6 @@
|
|||||||
return $('<div>').text(value ?? '').html();
|
return $('<div>').text(value ?? '').html();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function persistRouteProgress() {
|
function persistRouteProgress() {
|
||||||
if (!state.route) return;
|
if (!state.route) return;
|
||||||
if (state.persistTimer != null) {
|
if (state.persistTimer != null) {
|
||||||
@@ -54,7 +55,6 @@
|
|||||||
state.routeState = saved?.routeState === 'running' || saved?.routeState === 'paused'
|
state.routeState = saved?.routeState === 'running' || saved?.routeState === 'paused'
|
||||||
? 'paused'
|
? 'paused'
|
||||||
: 'idle';
|
: 'idle';
|
||||||
|
|
||||||
if (state.routeState === 'paused') {
|
if (state.routeState === 'paused') {
|
||||||
$('#tracking-status')
|
$('#tracking-status')
|
||||||
.prop('hidden', false)
|
.prop('hidden', false)
|
||||||
@@ -89,7 +89,6 @@
|
|||||||
target.removeAttr('hidden');
|
target.removeAttr('hidden');
|
||||||
state.activePage = pageSelector;
|
state.activePage = pageSelector;
|
||||||
globalThis.scrollTo({ top: 0, behavior: options.instant ? 'auto' : 'smooth' });
|
globalThis.scrollTo({ top: 0, behavior: options.instant ? 'auto' : 'smooth' });
|
||||||
|
|
||||||
if (options.updateHistory === false) return;
|
if (options.updateHistory === false) return;
|
||||||
const historyState = { page: pageSelector, previous };
|
const historyState = { page: pageSelector, previous };
|
||||||
if (options.replace) history.replaceState(historyState, '', pageSelector);
|
if (options.replace) history.replaceState(historyState, '', pageSelector);
|
||||||
@@ -112,31 +111,99 @@
|
|||||||
].filter(Boolean).join(' ')).includes(searchTerm);
|
].filter(Boolean).join(' ')).includes(searchTerm);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRouteItems(listSelector, routes, emptyText) {
|
function routeButton(route, source) {
|
||||||
|
const proximity = route.proximityM == null
|
||||||
|
? ''
|
||||||
|
: `<small>${escapeHtml(ns.Distance.format(route.proximityM))} entfernt</small>`;
|
||||||
|
const sourceLabel = source === 'local' ? '<small>Lokaler Entwurf</small>' : '';
|
||||||
|
return `
|
||||||
|
<li>
|
||||||
|
<button type="button" data-route-select data-route-id="${escapeHtml(route.id)}" data-route-source="${source}">
|
||||||
|
<strong>${escapeHtml(route.name)}</strong>
|
||||||
|
<span>${escapeHtml(route.schoolName || route.description || '')}</span>
|
||||||
|
${sourceLabel}
|
||||||
|
${proximity}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRouteItems(listSelector, routes, emptyText, source = 'server') {
|
||||||
const list = $(listSelector).empty();
|
const list = $(listSelector).empty();
|
||||||
const items = Array.isArray(routes) ? routes.filter(Boolean) : [];
|
const items = Array.isArray(routes) ? routes.filter(Boolean) : [];
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
list.append(`<li><p>${escapeHtml(emptyText)}</p></li>`);
|
list.append(`<li><p>${escapeHtml(emptyText)}</p></li>`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
items.forEach(route => list.append(routeButton(route, source)));
|
||||||
|
}
|
||||||
|
|
||||||
items.forEach(route => {
|
function nextPoiMetric() {
|
||||||
const proximity = route.proximityM == null
|
const pois = state.route?.pois || [];
|
||||||
? ''
|
const pending = pois
|
||||||
: `<small>${escapeHtml(ns.Distance.format(route.proximityM))} entfernt</small>`;
|
.filter(poi => !state.triggered.has(poi.id))
|
||||||
|
.slice()
|
||||||
|
.sort((left, right) => Number(left.sequence ?? 0) - Number(right.sequence ?? 0));
|
||||||
|
if (!pending.length) return null;
|
||||||
|
if (!state.position) return { poi: pending[0], distance: null };
|
||||||
|
return nearestPendingPoi(poiMetrics(state.position));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRouteControlSummary() {
|
||||||
|
const title = $('#route-control-title');
|
||||||
|
const next = $('#route-control-next');
|
||||||
|
if (!state.route) {
|
||||||
|
title.text('Route wählen');
|
||||||
|
next.text('Aktive und lokale Strecken');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
title.text(state.route.name || 'Route');
|
||||||
|
const metric = nextPoiMetric();
|
||||||
|
if (!metric) {
|
||||||
|
next.text((state.route.pois || []).length ? 'Alle POIs erreicht' : 'Keine POIs vorhanden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const distance = metric.distance == null ? '' : ` · ${Math.round(metric.distance)} m`;
|
||||||
|
next.text(`Nächster POI: ${metric.poi.title}${distance}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRouteControlList(selector, routes, source, emptyText) {
|
||||||
|
const list = $(selector).empty();
|
||||||
|
if (!routes.length) {
|
||||||
|
list.append(`<li><span>${escapeHtml(emptyText)}</span></li>`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
routes.forEach(route => {
|
||||||
|
const current = state.route && String(state.route.id) === String(route.id)
|
||||||
|
&& (state.route.source || 'server') === source;
|
||||||
list.append(`
|
list.append(`
|
||||||
<li>
|
<li>
|
||||||
<button type="button" data-route-id="${route.id}">
|
<button type="button" data-route-select data-route-id="${escapeHtml(route.id)}" data-route-source="${source}" ${current ? 'aria-current="true"' : ''}>
|
||||||
<strong>${escapeHtml(route.name)}</strong>
|
<strong>${escapeHtml(route.name)}</strong>
|
||||||
<span>${escapeHtml(route.schoolName || route.description || '')}</span>
|
<small>${source === 'local' ? 'Lokaler Entwurf' : 'Veröffentlicht'}</small>
|
||||||
${proximity}
|
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderRouteControl() {
|
||||||
|
updateRouteControlSummary();
|
||||||
|
renderRouteControlList(
|
||||||
|
'#route-control-server-list',
|
||||||
|
state.routes,
|
||||||
|
'server',
|
||||||
|
'Keine veröffentlichte Strecke vorhanden.'
|
||||||
|
);
|
||||||
|
renderRouteControlList(
|
||||||
|
'#route-control-local-list',
|
||||||
|
state.localRoutes,
|
||||||
|
'local',
|
||||||
|
state.localRoutesError || 'Noch keine lokale Strecke vorhanden.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function renderRoutes() {
|
function renderRoutes() {
|
||||||
const routes = Array.isArray(state.routes) ? state.routes.filter(Boolean) : [];
|
const routes = Array.isArray(state.routes) ? state.routes.filter(Boolean) : [];
|
||||||
const radiusM = ns.Config.routeRadiusKm * 1000;
|
const radiusM = ns.Config.routeRadiusKm * 1000;
|
||||||
@@ -152,6 +219,9 @@
|
|||||||
.filter(route => routeMatchesSearch(route, searchTerm))
|
.filter(route => routeMatchesSearch(route, searchTerm))
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'));
|
.sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'));
|
||||||
|
const localRoutes = state.localRoutes
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'));
|
||||||
|
|
||||||
const nearbyEmptyText = state.position
|
const nearbyEmptyText = state.position
|
||||||
? `Keine Route im Umkreis von ${ns.Config.routeRadiusKm} km gefunden. Wähle unten eine beliebige Route aus.`
|
? `Keine Route im Umkreis von ${ns.Config.routeRadiusKm} km gefunden. Wähle unten eine beliebige Route aus.`
|
||||||
@@ -161,20 +231,26 @@
|
|||||||
renderRouteItems(
|
renderRouteItems(
|
||||||
'#all-route-list',
|
'#all-route-list',
|
||||||
allRoutes,
|
allRoutes,
|
||||||
searchTerm ? 'Keine Route entspricht dem Suchbegriff.' : 'Es sind noch keine Routen vorhanden.'
|
searchTerm ? 'Keine Route entspricht dem Suchbegriff.' : 'Es sind noch keine veröffentlichten Routen vorhanden.'
|
||||||
|
);
|
||||||
|
renderRouteItems(
|
||||||
|
'#local-route-list',
|
||||||
|
localRoutes,
|
||||||
|
state.localRoutesError || 'Noch keine lokalen Strecken gespeichert.',
|
||||||
|
'local'
|
||||||
);
|
);
|
||||||
|
|
||||||
$('#all-routes-summary').text(
|
$('#all-routes-summary').text(
|
||||||
searchTerm
|
searchTerm
|
||||||
? `${allRoutes.length} von ${routes.length} Routen gefunden.`
|
? `${allRoutes.length} von ${routes.length} veröffentlichten Routen gefunden.`
|
||||||
: `${routes.length} Routen verfügbar.`
|
: `${routes.length} veröffentlichte Routen verfügbar.`
|
||||||
);
|
);
|
||||||
|
renderRouteControl();
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeRouteSearch() {
|
function initializeRouteSearch() {
|
||||||
const input = $('#route-search');
|
const input = $('#route-search');
|
||||||
if (!input.length || typeof input.autocomplete !== 'function') return;
|
if (!input.length || typeof input.autocomplete !== 'function') return;
|
||||||
|
|
||||||
input.autocomplete({
|
input.autocomplete({
|
||||||
minLength: 0,
|
minLength: 0,
|
||||||
source(request, response) {
|
source(request, response) {
|
||||||
@@ -215,7 +291,6 @@
|
|||||||
const running = state.routeState === 'running';
|
const running = state.routeState === 'running';
|
||||||
const paused = state.routeState === 'paused';
|
const paused = state.routeState === 'paused';
|
||||||
const startLabel = paused ? 'Route fortsetzen' : 'Route starten';
|
const startLabel = paused ? 'Route fortsetzen' : 'Route starten';
|
||||||
|
|
||||||
$('#start-route')
|
$('#start-route')
|
||||||
.prop('disabled', running)
|
.prop('disabled', running)
|
||||||
.attr({ 'aria-label': startLabel, title: startLabel });
|
.attr({ 'aria-label': startLabel, title: startLabel });
|
||||||
@@ -226,7 +301,6 @@
|
|||||||
function setActivePoi(poiId) {
|
function setActivePoi(poiId) {
|
||||||
$('#poi-list [data-poi-id]').removeAttr('aria-current');
|
$('#poi-list [data-poi-id]').removeAttr('aria-current');
|
||||||
if (poiId == null) return;
|
if (poiId == null) return;
|
||||||
|
|
||||||
$(`#poi-list [data-poi-id="${Number(poiId)}"]`).attr('aria-current', 'step');
|
$(`#poi-list [data-poi-id="${Number(poiId)}"]`).attr('aria-current', 'step');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +317,6 @@
|
|||||||
ns.AudioPlayer.unload();
|
ns.AudioPlayer.unload();
|
||||||
setActivePoi(null);
|
setActivePoi(null);
|
||||||
hideNavigation();
|
hideNavigation();
|
||||||
|
|
||||||
if (announce && wasActive && !returnToRoutes) {
|
if (announce && wasActive && !returnToRoutes) {
|
||||||
$('#tracking-status').prop('hidden', false).text('Route beendet.');
|
$('#tracking-status').prop('hidden', false).text('Route beendet.');
|
||||||
} else {
|
} else {
|
||||||
@@ -251,16 +324,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateRouteControls();
|
updateRouteControls();
|
||||||
|
|
||||||
if (returnToRoutes) {
|
if (returnToRoutes) {
|
||||||
state.route = null;
|
state.route = null;
|
||||||
|
updateRouteControlSummary();
|
||||||
|
renderRouteControl();
|
||||||
navigate('#routes-page', { replace: true });
|
navigate('#routes-page', { replace: true });
|
||||||
|
} else {
|
||||||
|
updateRouteControlSummary();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pauseRoute() {
|
function pauseRoute() {
|
||||||
if (state.routeState !== 'running') return;
|
if (state.routeState !== 'running') return;
|
||||||
|
|
||||||
state.routeState = 'paused';
|
state.routeState = 'paused';
|
||||||
persistRouteProgress();
|
persistRouteProgress();
|
||||||
stopRouteSensors();
|
stopRouteSensors();
|
||||||
@@ -270,32 +345,37 @@
|
|||||||
.text('Route pausiert. Die Standortverfolgung ist angehalten.');
|
.text('Route pausiert. Die Standortverfolgung ist angehalten.');
|
||||||
showNavigation('Route pausiert', $('#navigation-distance').text() || '–');
|
showNavigation('Route pausiert', $('#navigation-distance').text() || '–');
|
||||||
updateRouteControls();
|
updateRouteControls();
|
||||||
|
updateRouteControlSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRoute(route) {
|
function suspendCurrentRoute() {
|
||||||
if (state.routeState !== 'idle') endRoute({ announce: false });
|
if (!state.route) return;
|
||||||
|
if (state.routeState === 'running') state.routeState = 'paused';
|
||||||
state.route = route;
|
persistRouteProgress();
|
||||||
|
stopRouteSensors();
|
||||||
|
ns.AudioPlayer.unload();
|
||||||
state.activePoiId = null;
|
state.activePoiId = null;
|
||||||
restoreRouteProgress(route);
|
|
||||||
setActivePoi(null);
|
setActivePoi(null);
|
||||||
|
hideNavigation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRouteView(route) {
|
||||||
$('#route-title').text(route.name);
|
$('#route-title').text(route.name);
|
||||||
$('#route-description').text(route.description || '');
|
$('#route-description').text(route.description || '');
|
||||||
$('#route-distance').text(ns.Distance.format(route.distanceM));
|
$('#route-distance').text(ns.Distance.format(route.distanceM));
|
||||||
$('#route-elevation').text(`${Math.round(route.elevationGainM)} m`);
|
$('#route-elevation').text(`${Math.round(route.elevationGainM || 0)} m`);
|
||||||
$('#route-poi-count').text(route.pois.length);
|
$('#route-poi-count').text(route.pois.length);
|
||||||
updateRouteControls();
|
updateRouteControls();
|
||||||
|
|
||||||
const list = $('#poi-list').empty();
|
const list = $('#poi-list').empty();
|
||||||
if (!route.pois.length) {
|
if (!route.pois.length) {
|
||||||
list.append('<li><p>Diese Route enthält noch keine Stationen.</p></li>');
|
list.append('<li><p>Diese Route enthält noch keine Stationen.</p></li>');
|
||||||
|
updateRouteControlSummary();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
route.pois.forEach(poi => {
|
route.pois.forEach(poi => {
|
||||||
list.append(`
|
list.append(`
|
||||||
<li>
|
<li>
|
||||||
<button type="button" data-poi-id="${poi.id}">
|
<button type="button" data-poi-select data-poi-id="${poi.id}">
|
||||||
<strong>${escapeHtml(poi.title)}</strong>
|
<strong>${escapeHtml(poi.title)}</strong>
|
||||||
<span>${escapeHtml(poi.description || '')}</span>
|
<span>${escapeHtml(poi.description || '')}</span>
|
||||||
<span class="poi-proximity" aria-label="Entfernung wird ermittelt">
|
<span class="poi-proximity" aria-label="Entfernung wird ermittelt">
|
||||||
@@ -308,8 +388,18 @@
|
|||||||
</li>
|
</li>
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
updatePoiIndicators(state.position);
|
updatePoiIndicators(state.position);
|
||||||
|
updateRouteControlSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoute(route) {
|
||||||
|
if (state.route) suspendCurrentRoute();
|
||||||
|
state.route = route;
|
||||||
|
state.activePoiId = null;
|
||||||
|
restoreRouteProgress(route);
|
||||||
|
setActivePoi(null);
|
||||||
|
renderRouteView(route);
|
||||||
|
renderRouteControl();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showPoi(poi) {
|
function showPoi(poi) {
|
||||||
@@ -334,7 +424,6 @@
|
|||||||
start = Math.max(0, state.routeProgressIndex - 3);
|
start = Math.max(0, state.routeProgressIndex - 3);
|
||||||
end = Math.min(points.length, state.routeProgressIndex + 201);
|
end = Math.min(points.length, state.routeProgressIndex + 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
let nearestIndex = start;
|
let nearestIndex = start;
|
||||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||||
for (let index = start; index < end; index += 1) {
|
for (let index = start; index < end; index += 1) {
|
||||||
@@ -344,7 +433,6 @@
|
|||||||
nearestIndex = index;
|
nearestIndex = index;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nearestIndex;
|
return nearestIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +448,6 @@
|
|||||||
function poiMetrics(position) {
|
function poiMetrics(position) {
|
||||||
if (!position || !state.route?.pois?.length) return [];
|
if (!position || !state.route?.pois?.length) return [];
|
||||||
const heading = effectiveHeading(position);
|
const heading = effectiveHeading(position);
|
||||||
|
|
||||||
return state.route.pois.map(poi => {
|
return state.route.pois.map(poi => {
|
||||||
const distance = ns.Distance.meters(position, poi);
|
const distance = ns.Distance.meters(position, poi);
|
||||||
const bearing = ns.Distance.bearing(position, poi);
|
const bearing = ns.Distance.bearing(position, poi);
|
||||||
@@ -377,7 +464,6 @@
|
|||||||
|
|
||||||
function updatePoiIndicators(position) {
|
function updatePoiIndicators(position) {
|
||||||
const metrics = poiMetrics(position);
|
const metrics = poiMetrics(position);
|
||||||
|
|
||||||
metrics.forEach(metric => {
|
metrics.forEach(metric => {
|
||||||
const indicator = $(`#poi-list [data-poi-id="${metric.poi.id}"] .poi-proximity`);
|
const indicator = $(`#poi-list [data-poi-id="${metric.poi.id}"] .poi-proximity`);
|
||||||
const distance = Math.round(metric.distance);
|
const distance = Math.round(metric.distance);
|
||||||
@@ -387,7 +473,6 @@
|
|||||||
indicator.find('svg').css('transform', `rotate(${metric.rotation.toFixed(1)}deg)`);
|
indicator.find('svg').css('transform', `rotate(${metric.rotation.toFixed(1)}deg)`);
|
||||||
indicator.find('small').text(`${distance} m`);
|
indicator.find('small').text(`${distance} m`);
|
||||||
});
|
});
|
||||||
|
|
||||||
return metrics;
|
return metrics;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,20 +488,17 @@
|
|||||||
const previousProgressIndex = state.routeProgressIndex;
|
const previousProgressIndex = state.routeProgressIndex;
|
||||||
const points = state.route?.points || [];
|
const points = state.route?.points || [];
|
||||||
if (!points.length) return null;
|
if (!points.length) return null;
|
||||||
|
|
||||||
const nearestIndex = nearestRoutePointIndex(position);
|
const nearestIndex = nearestRoutePointIndex(position);
|
||||||
if (nearestIndex == null) return null;
|
if (nearestIndex == null) return null;
|
||||||
|
|
||||||
state.routeProgressIndex = state.routeProgressIndex == null
|
state.routeProgressIndex = state.routeProgressIndex == null
|
||||||
? nearestIndex
|
? nearestIndex
|
||||||
: Math.max(state.routeProgressIndex, nearestIndex);
|
: Math.max(state.routeProgressIndex, nearestIndex);
|
||||||
|
|
||||||
const lastIndex = points.length - 1;
|
const lastIndex = points.length - 1;
|
||||||
let targetIndex = Math.min(lastIndex, state.routeProgressIndex + 1);
|
let targetIndex = Math.min(lastIndex, state.routeProgressIndex + 1);
|
||||||
let target = points[targetIndex];
|
let target = points[targetIndex];
|
||||||
let distance = ns.Distance.meters(position, target);
|
let distance = ns.Distance.meters(position, target);
|
||||||
const reachedRadius = Math.max(4, Math.min(12, Math.round(position.accuracy || 4)));
|
const reachedRadius = Math.max(4, Math.min(12, Math.round(position.accuracy || 4)));
|
||||||
|
|
||||||
while (targetIndex < lastIndex && distance <= reachedRadius) {
|
while (targetIndex < lastIndex && distance <= reachedRadius) {
|
||||||
state.routeProgressIndex = targetIndex;
|
state.routeProgressIndex = targetIndex;
|
||||||
targetIndex += 1;
|
targetIndex += 1;
|
||||||
@@ -429,7 +511,6 @@
|
|||||||
if (state.routeProgressIndex !== previousProgressIndex) scheduleRouteProgressPersist();
|
if (state.routeProgressIndex !== previousProgressIndex) scheduleRouteProgressPersist();
|
||||||
return { reached: true, target, distance: 0 };
|
return { reached: true, target, distance: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.routeProgressIndex !== previousProgressIndex) scheduleRouteProgressPersist();
|
if (state.routeProgressIndex !== previousProgressIndex) scheduleRouteProgressPersist();
|
||||||
return { reached: false, target, distance };
|
return { reached: false, target, distance };
|
||||||
}
|
}
|
||||||
@@ -437,7 +518,6 @@
|
|||||||
function showPoiNavigation(position, metric) {
|
function showPoiNavigation(position, metric) {
|
||||||
const heading = effectiveHeading(position);
|
const heading = effectiveHeading(position);
|
||||||
const distance = Math.round(metric.distance);
|
const distance = Math.round(metric.distance);
|
||||||
|
|
||||||
$('#navigation-arrow').css('transform', `rotate(${metric.rotation.toFixed(1)}deg)`);
|
$('#navigation-arrow').css('transform', `rotate(${metric.rotation.toFixed(1)}deg)`);
|
||||||
showNavigation(
|
showNavigation(
|
||||||
heading == null
|
heading == null
|
||||||
@@ -464,14 +544,12 @@
|
|||||||
$('#navigation-footer').attr('aria-label', 'Alle Stationen und das Routenziel erreicht');
|
$('#navigation-footer').attr('aria-label', 'Alle Stationen und das Routenziel erreicht');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bearing = ns.Distance.bearing(position, progress.target);
|
const bearing = ns.Distance.bearing(position, progress.target);
|
||||||
const heading = effectiveHeading(position);
|
const heading = effectiveHeading(position);
|
||||||
const rotation = heading == null
|
const rotation = heading == null
|
||||||
? bearing
|
? bearing
|
||||||
: ns.Distance.normalizeDegrees(bearing - heading);
|
: ns.Distance.normalizeDegrees(bearing - heading);
|
||||||
const distance = Math.round(progress.distance);
|
const distance = Math.round(progress.distance);
|
||||||
|
|
||||||
$('#navigation-arrow').css('transform', `rotate(${rotation.toFixed(1)}deg)`);
|
$('#navigation-arrow').css('transform', `rotate(${rotation.toFixed(1)}deg)`);
|
||||||
showNavigation(
|
showNavigation(
|
||||||
heading == null ? 'Norden oben · nächster Routenpunkt' : 'Nächster Routenpunkt',
|
heading == null ? 'Norden oben · nächster Routenpunkt' : 'Nächster Routenpunkt',
|
||||||
@@ -485,11 +563,9 @@
|
|||||||
|
|
||||||
function updateNavigation(position) {
|
function updateNavigation(position) {
|
||||||
if (state.routeState !== 'running') return;
|
if (state.routeState !== 'running') return;
|
||||||
|
|
||||||
const progress = updateRouteProgress(position);
|
const progress = updateRouteProgress(position);
|
||||||
const metrics = updatePoiIndicators(position);
|
const metrics = updatePoiIndicators(position);
|
||||||
const nextPoi = nearestPendingPoi(metrics);
|
const nextPoi = nearestPendingPoi(metrics);
|
||||||
|
|
||||||
if (nextPoi) showPoiNavigation(position, nextPoi);
|
if (nextPoi) showPoiNavigation(position, nextPoi);
|
||||||
else showRoutePointNavigation(position, progress);
|
else showRoutePointNavigation(position, progress);
|
||||||
}
|
}
|
||||||
@@ -501,37 +577,36 @@
|
|||||||
const delta = ((heading - state.deviceHeading + 540) % 360) - 180;
|
const delta = ((heading - state.deviceHeading + 540) % 360) - 180;
|
||||||
state.deviceHeading = ns.Distance.normalizeDegrees(state.deviceHeading + delta * 0.25);
|
state.deviceHeading = ns.Distance.normalizeDegrees(state.deviceHeading + delta * 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.position) updateNavigation(state.position);
|
if (state.position) updateNavigation(state.position);
|
||||||
}
|
}
|
||||||
|
|
||||||
function evaluatePosition(position) {
|
function evaluatePosition(position) {
|
||||||
state.position = position;
|
state.position = position;
|
||||||
|
updateRouteProximity(position);
|
||||||
|
renderRouteControl();
|
||||||
if (!state.route) return;
|
if (!state.route) return;
|
||||||
|
|
||||||
const metrics = updatePoiIndicators(position);
|
const metrics = updatePoiIndicators(position);
|
||||||
|
updateRouteControlSummary();
|
||||||
if (state.routeState !== 'running') return;
|
if (state.routeState !== 'running') return;
|
||||||
|
|
||||||
const nearest = nearestPendingPoi(metrics);
|
const nearest = nearestPendingPoi(metrics);
|
||||||
updateNavigation(position);
|
updateNavigation(position);
|
||||||
|
|
||||||
$('#tracking-status').text(
|
$('#tracking-status').text(
|
||||||
`GPS aktiv · Genauigkeit ${Math.round(position.accuracy)} m`
|
`GPS aktiv · Genauigkeit ${Math.round(position.accuracy)} m`
|
||||||
+ (nearest ? ` · nächster POI ${Math.round(nearest.distance)} m` : ' · alle POIs erreicht')
|
+ (nearest ? ` · nächster POI ${Math.round(nearest.distance)} m` : ' · alle POIs erreicht')
|
||||||
);
|
);
|
||||||
|
|
||||||
if (nearest && nearest.distance <= nearest.poi.triggerRadiusM) {
|
if (nearest && nearest.distance <= nearest.poi.triggerRadiusM) {
|
||||||
state.triggered.add(nearest.poi.id);
|
state.triggered.add(nearest.poi.id);
|
||||||
persistRouteProgress();
|
persistRouteProgress();
|
||||||
ns.Vibration.start(ns.Vibration.Patterns.ACTIVE_POI);
|
ns.Vibration.start(ns.Vibration.Patterns.ACTIVE_POI);
|
||||||
showPoi(nearest.poi);
|
showPoi(nearest.poi);
|
||||||
updateNavigation(position);
|
updateNavigation(position);
|
||||||
|
updateRouteControlSummary();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startRoute() {
|
async function startRoute() {
|
||||||
if (!state.route || state.routeState === 'running') return;
|
if (!state.route || state.routeState === 'running') return;
|
||||||
|
|
||||||
const resumed = state.routeState === 'paused';
|
const resumed = state.routeState === 'paused';
|
||||||
if (!resumed) {
|
if (!resumed) {
|
||||||
state.routeProgressIndex = null;
|
state.routeProgressIndex = null;
|
||||||
@@ -542,11 +617,11 @@
|
|||||||
state.deviceHeading = null;
|
state.deviceHeading = null;
|
||||||
persistRouteProgress();
|
persistRouteProgress();
|
||||||
updateRouteControls();
|
updateRouteControls();
|
||||||
|
updateRouteControlSummary();
|
||||||
$('#tracking-status')
|
$('#tracking-status')
|
||||||
.prop('hidden', false)
|
.prop('hidden', false)
|
||||||
.text(resumed ? 'Route wird fortgesetzt.' : 'Route wird gestartet. GPS wird aktiviert.');
|
.text(resumed ? 'Route wird fortgesetzt.' : 'Route wird gestartet. GPS wird aktiviert.');
|
||||||
showNavigation(resumed ? 'Route wird fortgesetzt' : 'Position wird ermittelt');
|
showNavigation(resumed ? 'Route wird fortgesetzt' : 'Position wird ermittelt');
|
||||||
|
|
||||||
ns.Geo.start(
|
ns.Geo.start(
|
||||||
evaluatePosition,
|
evaluatePosition,
|
||||||
error => $('#tracking-status').text(`GPS-Fehler: ${error.message}`)
|
error => $('#tracking-status').text(`GPS-Fehler: ${error.message}`)
|
||||||
@@ -557,24 +632,35 @@
|
|||||||
ns.Orientation.stop();
|
ns.Orientation.stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!orientationAvailable) $('#navigation-mode').text('Norden oben · Navigation');
|
||||||
if (!orientationAvailable) {
|
|
||||||
$('#navigation-mode').text('Norden oben · Navigation');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function routesFromResponse(response) {
|
function routesFromResponse(response) {
|
||||||
if (!Array.isArray(response?.routes)) {
|
if (!Array.isArray(response?.routes)) {
|
||||||
throw new TypeError('Die Routen-API hat keine gültige Routenliste geliefert.');
|
throw new TypeError('Die Routen-API hat keine gültige Routenliste geliefert.');
|
||||||
}
|
}
|
||||||
return response.routes.filter(route => route?.id != null && route?.name != null);
|
return response.routes
|
||||||
|
.filter(route => route?.id != null && route?.name != null)
|
||||||
|
.map(route => ({ ...route, source: 'server', local: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLocalRoutes() {
|
||||||
|
try {
|
||||||
|
state.localRoutes = ns.LocalRoutes?.listAppRoutes?.() || [];
|
||||||
|
state.localRoutesError = null;
|
||||||
|
} catch (error) {
|
||||||
|
state.localRoutes = [];
|
||||||
|
state.localRoutesError = error.message || 'Lokale Strecken konnten nicht gelesen werden.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRouteProximity(position) {
|
function updateRouteProximity(position) {
|
||||||
state.routes = state.routes.map(route => ({
|
const withProximity = route => ({
|
||||||
...route,
|
...route,
|
||||||
proximityM: route.start ? ns.Distance.meters(position, route.start) : null
|
proximityM: route.start ? ns.Distance.meters(position, route.start) : null
|
||||||
}));
|
});
|
||||||
|
state.routes = state.routes.map(withProximity);
|
||||||
|
state.localRoutes = state.localRoutes.map(withProximity);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initializeLocation(context) {
|
async function initializeLocation(context) {
|
||||||
@@ -597,7 +683,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function restoreRouteLists() {
|
function restoreRouteLists() {
|
||||||
if (state.routes.length) renderRoutes();
|
loadLocalRoutes();
|
||||||
|
if (state.position) updateRouteProximity(state.position);
|
||||||
|
renderRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRouteControlOpen(open) {
|
||||||
|
$('#route-control-dropdown').prop('hidden', !open);
|
||||||
|
$('#route-control-button').attr('aria-expanded', String(open));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectRoute(source, id) {
|
||||||
|
setRouteControlOpen(false);
|
||||||
|
const route = source === 'local'
|
||||||
|
? ns.LocalRoutes.getAppRoute(id)
|
||||||
|
: { ...(await ns.Api.route(id)), source: 'server', local: false };
|
||||||
|
renderRoute(route);
|
||||||
|
navigate('#route-page');
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCurrentLocalRoute(id) {
|
||||||
|
if (!state.route || state.route.source !== 'local' || String(state.route.id) !== String(id)) return;
|
||||||
|
persistRouteProgress();
|
||||||
|
try {
|
||||||
|
state.route = ns.LocalRoutes.getAppRoute(id);
|
||||||
|
renderRouteView(state.route);
|
||||||
|
renderRouteControl();
|
||||||
|
} catch {
|
||||||
|
state.route = null;
|
||||||
|
state.routeState = 'idle';
|
||||||
|
hideNavigation();
|
||||||
|
navigate('#routes-page', { replace: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
@@ -607,13 +724,11 @@
|
|||||||
$('#route-search').val('').trigger('input').trigger('focus');
|
$('#route-search').val('').trigger('input').trigger('focus');
|
||||||
});
|
});
|
||||||
|
|
||||||
activate('[data-route-id]', async function () {
|
activate('[data-route-select]', async function () {
|
||||||
const button = $(this);
|
const button = $(this);
|
||||||
button.prop('disabled', true);
|
button.prop('disabled', true);
|
||||||
try {
|
try {
|
||||||
const route = await ns.Api.route(button.data('route-id'));
|
await selectRoute(String(button.data('route-source') || 'server'), String(button.data('route-id')));
|
||||||
renderRoute(route);
|
|
||||||
navigate('#route-page');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(error.responseJSON?.message || error.statusText || error.message);
|
alert(error.responseJSON?.message || error.statusText || error.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -621,7 +736,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
activate('[data-poi-id]', function () {
|
activate('[data-poi-select]', function () {
|
||||||
const poi = state.route?.pois.find(item => item.id === Number($(this).data('poi-id')));
|
const poi = state.route?.pois.find(item => item.id === Number($(this).data('poi-id')));
|
||||||
if (poi) showPoi(poi);
|
if (poi) showPoi(poi);
|
||||||
});
|
});
|
||||||
@@ -632,14 +747,33 @@
|
|||||||
else navigate(target, { replace: true });
|
else navigate(target, { replace: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
activate('#route-control-button', function () {
|
||||||
|
setRouteControlOpen($('#route-control-dropdown').prop('hidden'));
|
||||||
|
});
|
||||||
|
|
||||||
|
activate('#route-control-new', function () {
|
||||||
|
setRouteControlOpen(false);
|
||||||
|
ns.EditorUi?.openNewLocalRoute?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
activate('#editor-button', function () {
|
||||||
|
setRouteControlOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on('pointerup', event => {
|
||||||
|
if ($('#route-control-dropdown').prop('hidden')) return;
|
||||||
|
if ($(event.target).closest('.route-control').length) return;
|
||||||
|
setRouteControlOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on('keydown', event => {
|
||||||
|
if (event.key === 'Escape') setRouteControlOpen(false);
|
||||||
|
});
|
||||||
|
|
||||||
activate('#start-route', async function () {
|
activate('#start-route', async function () {
|
||||||
await startRoute();
|
await startRoute();
|
||||||
});
|
});
|
||||||
|
activate('#pause-route', pauseRoute);
|
||||||
activate('#pause-route', function () {
|
|
||||||
pauseRoute();
|
|
||||||
});
|
|
||||||
|
|
||||||
activate('#stop-route', function () {
|
activate('#stop-route', function () {
|
||||||
endRoute({ returnToRoutes: true });
|
endRoute({ returnToRoutes: true });
|
||||||
});
|
});
|
||||||
@@ -654,12 +788,19 @@
|
|||||||
if (event.detail?.enabled) persistRouteProgress();
|
if (event.detail?.enabled) persistRouteProgress();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener('wegwichtel:local-routes-changed', event => {
|
||||||
|
loadLocalRoutes();
|
||||||
|
if (state.position) updateRouteProximity(state.position);
|
||||||
|
refreshCurrentLocalRoute(event.detail?.id);
|
||||||
|
renderRoutes();
|
||||||
|
ns.EditorUi?.refresh?.();
|
||||||
|
});
|
||||||
|
|
||||||
globalThis.addEventListener('pagehide', persistRouteProgress);
|
globalThis.addEventListener('pagehide', persistRouteProgress);
|
||||||
globalThis.addEventListener('popstate', event => {
|
globalThis.addEventListener('popstate', event => {
|
||||||
const page = event.state?.page || '#routes-page';
|
const page = event.state?.page || '#routes-page';
|
||||||
navigate(page, { updateHistory: false, instant: true });
|
navigate(page, { updateHistory: false, instant: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
globalThis.addEventListener('pageshow', restoreRouteLists);
|
globalThis.addEventListener('pageshow', restoreRouteLists);
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.visibilityState === 'visible') restoreRouteLists();
|
if (document.visibilityState === 'visible') restoreRouteLists();
|
||||||
@@ -669,6 +810,9 @@
|
|||||||
|
|
||||||
ns.App = {
|
ns.App = {
|
||||||
navigate,
|
navigate,
|
||||||
|
currentPosition() {
|
||||||
|
return state.position ? { ...state.position } : null;
|
||||||
|
},
|
||||||
|
|
||||||
async initialize(context) {
|
async initialize(context) {
|
||||||
bindEvents();
|
bindEvents();
|
||||||
@@ -677,12 +821,14 @@
|
|||||||
|
|
||||||
const response = await ns.Api.routes(null);
|
const response = await ns.Api.routes(null);
|
||||||
state.routes = routesFromResponse(response);
|
state.routes = routesFromResponse(response);
|
||||||
|
loadLocalRoutes();
|
||||||
context.markStep('routes', 'done');
|
context.markStep('routes', 'done');
|
||||||
|
|
||||||
renderRoutes();
|
renderRoutes();
|
||||||
initializeRouteSearch();
|
initializeRouteSearch();
|
||||||
updateRouteControls();
|
updateRouteControls();
|
||||||
hideNavigation();
|
hideNavigation();
|
||||||
|
setRouteControlOpen(false);
|
||||||
|
|
||||||
await initializeLocation(context);
|
await initializeLocation(context);
|
||||||
}
|
}
|
||||||
|
|||||||
+267
-157
@@ -6,10 +6,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
const ids = Object.freeze({
|
const ids = Object.freeze({
|
||||||
addPoi: 'editor-add-poi',
|
addPoi: 'editor-add-poi',
|
||||||
button: 'editor-button',
|
button: 'editor-button',
|
||||||
coordinateGrid: 'editor-coordinate-grid',
|
|
||||||
close: 'editor-close',
|
close: 'editor-close',
|
||||||
dialog: 'editor-dialog',
|
dialog: 'editor-dialog',
|
||||||
entryTitle: 'editor-entry-title',
|
|
||||||
newRoute: 'editor-new-route',
|
newRoute: 'editor-new-route',
|
||||||
poiAudio: 'editor-poi-audio',
|
poiAudio: 'editor-poi-audio',
|
||||||
poiCancel: 'editor-poi-cancel',
|
poiCancel: 'editor-poi-cancel',
|
||||||
@@ -23,53 +21,33 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
poiList: 'editor-poi-list',
|
poiList: 'editor-poi-list',
|
||||||
poiLon: 'editor-poi-lon',
|
poiLon: 'editor-poi-lon',
|
||||||
poiRadius: 'editor-poi-radius',
|
poiRadius: 'editor-poi-radius',
|
||||||
poiSave: 'editor-poi-save',
|
|
||||||
poiTitle: 'editor-poi-title',
|
poiTitle: 'editor-poi-title',
|
||||||
routeActivated: 'editor-route-activated',
|
routeActivated: 'editor-route-activated',
|
||||||
routeBack: 'editor-route-back',
|
routeBack: 'editor-route-back',
|
||||||
routeDescription: 'editor-route-description',
|
routeDescription: 'editor-route-description',
|
||||||
routeDetailTitle: 'editor-route-detail-title',
|
|
||||||
routeDetailView: 'editor-route-detail-view',
|
routeDetailView: 'editor-route-detail-view',
|
||||||
routeForm: 'editor-route-form',
|
routeForm: 'editor-route-form',
|
||||||
routeGpx: 'editor-route-gpx',
|
routeGpx: 'editor-route-gpx',
|
||||||
routeId: 'editor-route-id',
|
routeId: 'editor-route-id',
|
||||||
routeList: 'editor-route-list',
|
routeList: 'editor-route-list',
|
||||||
routeListTitle: 'editor-route-list-title',
|
|
||||||
routeListView: 'editor-route-list-view',
|
routeListView: 'editor-route-list-view',
|
||||||
routeName: 'editor-route-name',
|
routeName: 'editor-route-name',
|
||||||
routeSave: 'editor-route-save',
|
status: 'editor-status'
|
||||||
status: 'editor-status',
|
|
||||||
title: 'editor-dialog-title'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const classes = Object.freeze({
|
const classes = Object.freeze({
|
||||||
button: 'editor-button',
|
|
||||||
dialog: 'editor-dialog',
|
|
||||||
dialogHeader: 'editor-dialog-header',
|
|
||||||
dialogShell: 'editor-dialog-shell',
|
|
||||||
entryPanel: 'editor-entry-panel',
|
|
||||||
form: 'editor-form',
|
|
||||||
iconButton: 'editor-icon-button',
|
iconButton: 'editor-icon-button',
|
||||||
iconRow: 'editor-icon-row',
|
iconRow: 'editor-icon-row',
|
||||||
muted: 'editor-muted',
|
muted: 'editor-muted',
|
||||||
poiItem: 'editor-poi-item',
|
poiItem: 'editor-poi-item',
|
||||||
poiList: 'editor-poi-list',
|
routeGroup: 'editor-route-group',
|
||||||
poiToolbar: 'editor-poi-toolbar',
|
routeItem: 'editor-route-item'
|
||||||
primaryButton: 'editor-primary-button',
|
|
||||||
routeItem: 'editor-route-item',
|
|
||||||
routeList: 'editor-route-list',
|
|
||||||
roundButton: 'editor-round-button',
|
|
||||||
secondaryButton: 'editor-secondary-button',
|
|
||||||
status: 'editor-status',
|
|
||||||
toolbar: 'editor-toolbar',
|
|
||||||
toolbarBack: 'editor-toolbar-back',
|
|
||||||
view: 'editor-view'
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
currentPoi: null,
|
currentPoi: null,
|
||||||
currentRoute: null,
|
currentRoute: null,
|
||||||
|
currentSource: null,
|
||||||
pois: [],
|
pois: [],
|
||||||
routes: []
|
routes: []
|
||||||
};
|
};
|
||||||
@@ -194,6 +172,22 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function parseJson(response) {
|
||||||
|
const raw = await response.text();
|
||||||
|
if (!raw.trim()) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResponse(response) {
|
||||||
|
if (response.ok) return parseJson(response);
|
||||||
|
const message = await response.text();
|
||||||
|
throw new Error(message || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function readJson(path) {
|
async function readJson(path) {
|
||||||
const response = await fetch(apiUrl(path), {
|
const response = await fetch(apiUrl(path), {
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
@@ -225,22 +219,6 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
return handleResponse(response);
|
return handleResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleResponse(response) {
|
|
||||||
if (response.ok) return parseJson(response);
|
|
||||||
const message = await response.text();
|
|
||||||
throw new Error(message || `HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function parseJson(response) {
|
|
||||||
const raw = await response.text();
|
|
||||||
if (!raw.trim()) return {};
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw);
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function routeArray(payload) {
|
function routeArray(payload) {
|
||||||
if (Array.isArray(payload)) return payload;
|
if (Array.isArray(payload)) return payload;
|
||||||
if (Array.isArray(payload?.routes)) return payload.routes;
|
if (Array.isArray(payload?.routes)) return payload.routes;
|
||||||
@@ -273,55 +251,71 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function routeSource(route) {
|
||||||
|
return route?._source || route?.source || 'server';
|
||||||
|
}
|
||||||
|
|
||||||
function routeItem(route) {
|
function routeItem(route) {
|
||||||
const item = document.createElement('li');
|
const item = document.createElement('li');
|
||||||
item.className = classes.routeItem;
|
item.className = classes.routeItem;
|
||||||
item.dataset.routeId = routeId(route);
|
item.dataset.routeId = routeId(route);
|
||||||
|
item.dataset.routeSource = routeSource(route);
|
||||||
|
|
||||||
const label = document.createElement('div');
|
const label = document.createElement('div');
|
||||||
appendText(label, 'strong', routeName(route));
|
appendText(label, 'strong', routeName(route));
|
||||||
appendText(label, 'small', route?.activatedAt || route?.activated_at ? 'aktiviert' : 'Entwurf oder ohne Aktivierungsdatum');
|
appendText(label, 'small', routeSource(route) === 'local' ? 'Lokaler Entwurf' : 'Veröffentlichte Strecke');
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = classes.iconRow;
|
actions.className = classes.iconRow;
|
||||||
actions.appendChild(iconButton('Strecke bearbeiten', '✎', 'edit-route'));
|
actions.appendChild(iconButton('Strecke bearbeiten', '✎', 'edit-route'));
|
||||||
|
|
||||||
item.append(label, actions);
|
item.append(label, actions);
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function routeGroup(title) {
|
||||||
|
const item = document.createElement('li');
|
||||||
|
item.className = classes.routeGroup;
|
||||||
|
item.textContent = title;
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
function poiItem(poi, index) {
|
function poiItem(poi, index) {
|
||||||
const item = document.createElement('li');
|
const item = document.createElement('li');
|
||||||
item.className = classes.poiItem;
|
item.className = classes.poiItem;
|
||||||
item.dataset.poiId = poiId(poi);
|
item.dataset.poiId = poiId(poi);
|
||||||
|
|
||||||
const title = poiTitle(poi);
|
const title = poiTitle(poi);
|
||||||
const label = document.createElement('div');
|
const label = document.createElement('div');
|
||||||
appendText(label, 'strong', title);
|
appendText(label, 'strong', title);
|
||||||
appendText(label, 'small', `Position ${index + 1}`);
|
appendText(label, 'small', `Position ${index + 1}`);
|
||||||
|
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = classes.iconRow;
|
actions.className = classes.iconRow;
|
||||||
actions.appendChild(iconButton(`${title} nach oben schieben`, '↑', 'move-poi-up', index === 0));
|
actions.appendChild(iconButton(`${title} nach oben schieben`, '↑', 'move-poi-up', index === 0));
|
||||||
actions.appendChild(iconButton(`${title} nach unten schieben`, '↓', 'move-poi-down', index === state.pois.length - 1));
|
actions.appendChild(iconButton(`${title} nach unten schieben`, '↓', 'move-poi-down', index === state.pois.length - 1));
|
||||||
actions.appendChild(iconButton(`${title} bearbeiten`, '✎', 'edit-poi'));
|
actions.appendChild(iconButton(`${title} bearbeiten`, '✎', 'edit-poi'));
|
||||||
|
|
||||||
item.append(label, actions);
|
item.append(label, actions);
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderRouteGroup(list, title, routes, emptyText) {
|
||||||
|
list.appendChild(routeGroup(title));
|
||||||
|
if (!routes.length) {
|
||||||
|
const item = document.createElement('li');
|
||||||
|
item.className = classes.routeItem;
|
||||||
|
appendText(item, 'span', emptyText);
|
||||||
|
list.appendChild(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
routes.forEach(route => list.appendChild(routeItem(route)));
|
||||||
|
}
|
||||||
|
|
||||||
function renderRouteList() {
|
function renderRouteList() {
|
||||||
const list = byId(ids.routeList);
|
const list = byId(ids.routeList);
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
list.textContent = '';
|
list.textContent = '';
|
||||||
if (!state.routes.length) {
|
const serverRoutes = state.routes.filter(route => routeSource(route) === 'server');
|
||||||
const item = document.createElement('li');
|
const localRoutes = state.routes.filter(route => routeSource(route) === 'local');
|
||||||
item.className = classes.routeItem;
|
renderRouteGroup(list, 'Veröffentlichte Strecken', serverRoutes, 'Keine veröffentlichte Strecke vorhanden.');
|
||||||
appendText(item, 'span', 'Noch keine Strecken gefunden.');
|
renderRouteGroup(list, 'Lokale Strecken', localRoutes, 'Noch kein lokaler Entwurf vorhanden.');
|
||||||
list.appendChild(item);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.routes.forEach(route => list.appendChild(routeItem(route)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPoiList() {
|
function renderPoiList() {
|
||||||
@@ -338,12 +332,28 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
state.pois.forEach((poi, index) => list.appendChild(poiItem(poi, index)));
|
state.pois.forEach((poi, index) => list.appendChild(poiItem(poi, index)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function localRouteSummaries() {
|
||||||
|
if (!ns.LocalRoutes) return [];
|
||||||
|
return ns.LocalRoutes.list().map(routePackage => ({
|
||||||
|
...routePackage.route,
|
||||||
|
id: routePackage.id,
|
||||||
|
_source: 'local'
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRoutes() {
|
async function loadRoutes() {
|
||||||
setStatus('Strecken werden geladen …');
|
setStatus('Strecken werden geladen …');
|
||||||
const payload = await readJson('/routes');
|
const payload = await readJson('/routes?activatedOnly=true');
|
||||||
state.routes = routeArray(payload);
|
const serverRoutes = routeArray(payload).map(route => ({ ...route, _source: 'server' }));
|
||||||
|
let localRoutes = [];
|
||||||
|
try {
|
||||||
|
localRoutes = localRouteSummaries();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(error.message, true);
|
||||||
|
}
|
||||||
|
state.routes = [...serverRoutes, ...localRoutes];
|
||||||
renderRouteList();
|
renderRouteList();
|
||||||
setStatus('');
|
if (!byId(ids.status)?.dataset.kind || byId(ids.status).dataset.kind !== 'error') setStatus('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openEditor() {
|
async function openEditor() {
|
||||||
@@ -358,6 +368,11 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
await guarded(loadRoutes);
|
await guarded(loadRoutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openNewLocalRoute() {
|
||||||
|
await openEditor();
|
||||||
|
newRoute();
|
||||||
|
}
|
||||||
|
|
||||||
function closeEditor() {
|
function closeEditor() {
|
||||||
const dialog = byId(ids.dialog);
|
const dialog = byId(ids.dialog);
|
||||||
if (dialog?.open && typeof dialog.close === 'function') dialog.close();
|
if (dialog?.open && typeof dialog.close === 'function') dialog.close();
|
||||||
@@ -377,11 +392,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
return {
|
return {
|
||||||
description: byId(ids.poiDescription)?.value.trim() || '',
|
description: byId(ids.poiDescription)?.value.trim() || '',
|
||||||
lat: numberOrNull(byId(ids.poiLat)?.value),
|
lat: numberOrNull(byId(ids.poiLat)?.value),
|
||||||
latitude: numberOrNull(byId(ids.poiLat)?.value),
|
|
||||||
lon: numberOrNull(byId(ids.poiLon)?.value),
|
lon: numberOrNull(byId(ids.poiLon)?.value),
|
||||||
longitude: numberOrNull(byId(ids.poiLon)?.value),
|
triggerRadiusM: numberOrNull(byId(ids.poiRadius)?.value) || 25,
|
||||||
radiusM: numberOrNull(byId(ids.poiRadius)?.value) || 25,
|
sequence: state.currentPoi?.sequence ?? state.pois.length,
|
||||||
sortOrder: state.currentPoi?.sortOrder ?? state.pois.length,
|
|
||||||
title: byId(ids.poiTitle)?.value.trim() || ''
|
title: byId(ids.poiTitle)?.value.trim() || ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -391,9 +404,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
byId(ids.routeName).value = routeName(route) === 'Unbenannte Strecke' ? '' : routeName(route);
|
byId(ids.routeName).value = routeName(route) === 'Unbenannte Strecke' ? '' : routeName(route);
|
||||||
byId(ids.routeDescription).value = text(route?.description);
|
byId(ids.routeDescription).value = text(route?.description);
|
||||||
byId(ids.routeGpx).value = '';
|
byId(ids.routeGpx).value = '';
|
||||||
|
if (state.currentSource === 'local') {
|
||||||
|
byId(ids.routeActivated).textContent = 'Lokaler Entwurf: GPX, Texte und Medien bleiben ausschließlich im Browser. Eine Server-Freigabe ist noch nicht implementiert.';
|
||||||
|
} else {
|
||||||
byId(ids.routeActivated).textContent = route?.activatedAt || route?.activated_at
|
byId(ids.routeActivated).textContent = route?.activatedAt || route?.activated_at
|
||||||
? `Aktiviert am ${route.activatedAt || route.activated_at}. Änderung der Freigabe erfolgt weiterhin manuell in der Datenbank.`
|
? `Veröffentlicht am ${route.activatedAt || route.activated_at}. Der Editor verändert die Freigabe nicht.`
|
||||||
: 'Nicht veröffentlicht: activated_at ist leer und wird durch den Editor nicht gesetzt.';
|
: 'Server-Strecke ohne Freigabedatum. Der Editor verändert activated_at nicht.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillPoiForm(poi) {
|
function fillPoiForm(poi) {
|
||||||
@@ -402,7 +419,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
byId(ids.poiDescription).value = text(poi?.description || poi?.text);
|
byId(ids.poiDescription).value = text(poi?.description || poi?.text);
|
||||||
byId(ids.poiLat).value = text(poi?.lat ?? poi?.latitude);
|
byId(ids.poiLat).value = text(poi?.lat ?? poi?.latitude);
|
||||||
byId(ids.poiLon).value = text(poi?.lon ?? poi?.longitude);
|
byId(ids.poiLon).value = text(poi?.lon ?? poi?.longitude);
|
||||||
byId(ids.poiRadius).value = text(poi?.radiusM ?? poi?.radius ?? 25);
|
byId(ids.poiRadius).value = text(poi?.triggerRadiusM ?? poi?.radiusM ?? poi?.radius ?? 25);
|
||||||
byId(ids.poiAudio).value = '';
|
byId(ids.poiAudio).value = '';
|
||||||
byId(ids.poiImages).value = '';
|
byId(ids.poiImages).value = '';
|
||||||
}
|
}
|
||||||
@@ -411,23 +428,47 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
const id = routeId(route);
|
const id = routeId(route);
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setStatus('Streckendetails werden geladen …');
|
setStatus('Streckendetails werden geladen …');
|
||||||
|
state.currentSource = routeSource(route);
|
||||||
|
|
||||||
|
if (state.currentSource === 'local') {
|
||||||
|
const routePackage = ns.LocalRoutes.get(id);
|
||||||
|
if (!routePackage) throw new Error('Der lokale Entwurf wurde nicht gefunden.');
|
||||||
|
state.currentRoute = {
|
||||||
|
...routePackage.route,
|
||||||
|
id: routePackage.id,
|
||||||
|
gpx: routePackage.gpx,
|
||||||
|
_source: 'local'
|
||||||
|
};
|
||||||
|
state.pois = [...routePackage.pois].sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
||||||
|
} else {
|
||||||
const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
|
const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
|
||||||
const detail = payload.route || payload;
|
const detail = payload.route || payload;
|
||||||
state.currentRoute = detail;
|
state.currentRoute = { ...detail, _source: 'server' };
|
||||||
state.pois = poiArray(detail);
|
state.pois = poiArray(detail);
|
||||||
fillRouteForm(detail);
|
}
|
||||||
|
|
||||||
|
state.currentPoi = null;
|
||||||
|
fillRouteForm(state.currentRoute);
|
||||||
renderPoiList();
|
renderPoiList();
|
||||||
showRouteView();
|
showRouteView();
|
||||||
setStatus('');
|
setStatus('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function newRoute() {
|
function newRoute() {
|
||||||
state.currentRoute = null;
|
state.currentSource = 'local';
|
||||||
|
state.currentRoute = {
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
gpx: ns.LocalRoutes.emptyGpx('Neue Strecke'),
|
||||||
|
_source: 'local'
|
||||||
|
};
|
||||||
state.currentPoi = null;
|
state.currentPoi = null;
|
||||||
state.pois = [];
|
state.pois = [];
|
||||||
fillRouteForm({});
|
fillRouteForm(state.currentRoute);
|
||||||
renderPoiList();
|
renderPoiList();
|
||||||
showRouteView();
|
showRouteView();
|
||||||
|
setStatus('Neue Strecke wird als lokaler GPX-/JSON-Entwurf angelegt.');
|
||||||
}
|
}
|
||||||
|
|
||||||
function formDataFromRoute(values) {
|
function formDataFromRoute(values) {
|
||||||
@@ -439,52 +480,137 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveLocalRoute(values) {
|
||||||
|
const file = byId(ids.routeGpx)?.files?.[0];
|
||||||
|
let gpx = state.currentRoute?.gpx || ns.LocalRoutes.emptyGpx(values.name);
|
||||||
|
if (file) {
|
||||||
|
gpx = await ns.LocalRoutes.readFileAsText(file);
|
||||||
|
const importedPois = ns.LocalRoutes.parseWaypoints(gpx);
|
||||||
|
if (!state.pois.length && importedPois.length) state.pois = importedPois;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = routeId(state.currentRoute) || ns.LocalRoutes.createRouteId();
|
||||||
|
const saved = ns.LocalRoutes.save({
|
||||||
|
id,
|
||||||
|
gpx,
|
||||||
|
route: {
|
||||||
|
...(state.currentRoute || {}),
|
||||||
|
id,
|
||||||
|
name: values.name,
|
||||||
|
description: values.description
|
||||||
|
},
|
||||||
|
pois: state.pois
|
||||||
|
});
|
||||||
|
state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
|
||||||
|
state.currentSource = 'local';
|
||||||
|
state.pois = saved.pois;
|
||||||
|
setStatus('Lokaler Entwurf gespeichert. Es wurden keine Daten an den Server übertragen.');
|
||||||
|
await loadRoutes();
|
||||||
|
await loadRoute({ id: saved.id, _source: 'local' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveServerRoute(values) {
|
||||||
|
const id = routeId(state.currentRoute);
|
||||||
|
if (!id) throw new Error('Neue Strecken werden ausschließlich lokal angelegt.');
|
||||||
|
const file = byId(ids.routeGpx)?.files?.[0];
|
||||||
|
const path = `/routes/${encodeURIComponent(id)}`;
|
||||||
|
const payload = file
|
||||||
|
? await sendForm(path, 'PUT', formDataFromRoute(values))
|
||||||
|
: await sendJson(path, 'PUT', values);
|
||||||
|
const saved = payload.route || payload;
|
||||||
|
state.currentRoute = { ...saved, _source: 'server' };
|
||||||
|
setStatus('Veröffentlichte Strecke gespeichert. activated_at wurde nicht verändert.');
|
||||||
|
await loadRoutes();
|
||||||
|
await loadRoute({ id, _source: 'server' });
|
||||||
|
}
|
||||||
|
|
||||||
async function saveRoute() {
|
async function saveRoute() {
|
||||||
const values = routeFromForm();
|
const values = routeFromForm();
|
||||||
if (!values.name) throw new Error('Der Name der Strecke ist erforderlich.');
|
if (!values.name) throw new Error('Der Name der Strecke ist erforderlich.');
|
||||||
const id = byId(ids.routeId).value;
|
if (state.currentSource === 'server') await saveServerRoute(values);
|
||||||
const file = byId(ids.routeGpx)?.files?.[0];
|
else await saveLocalRoute(values);
|
||||||
const path = id ? `/routes/${encodeURIComponent(id)}` : '/routes';
|
}
|
||||||
const method = id ? 'PUT' : 'POST';
|
|
||||||
const payload = file ? await sendForm(path, method, formDataFromRoute(values)) : await sendJson(path, method, values);
|
function currentPosition() {
|
||||||
const saved = payload.route || payload;
|
return typeof ns.App?.currentPosition === 'function' ? ns.App.currentPosition() : null;
|
||||||
state.currentRoute = Object.keys(saved).length ? saved : { ...values, id };
|
|
||||||
setStatus('Strecke gespeichert. Neue Strecken bleiben bis zur manuellen Aktivierung unveröffentlicht.');
|
|
||||||
await loadRoutes();
|
|
||||||
if (routeId(state.currentRoute)) await loadRoute(state.currentRoute);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function newPoi() {
|
function newPoi() {
|
||||||
state.currentPoi = null;
|
state.currentPoi = null;
|
||||||
fillPoiForm({ radiusM: 25 });
|
const position = currentPosition();
|
||||||
|
fillPoiForm({
|
||||||
|
lat: position?.lat ?? '',
|
||||||
|
lon: position?.lon ?? '',
|
||||||
|
triggerRadiusM: 25
|
||||||
|
});
|
||||||
|
byId(ids.poiFormTitle).textContent = state.currentSource === 'local' ? 'GPX-POI hinzufügen' : 'Station hinzufügen';
|
||||||
showPoiView();
|
showPoiView();
|
||||||
}
|
}
|
||||||
|
|
||||||
function editPoi(poi) {
|
function editPoi(poi) {
|
||||||
state.currentPoi = poi;
|
state.currentPoi = poi;
|
||||||
fillPoiForm(poi);
|
fillPoiForm(poi);
|
||||||
|
byId(ids.poiFormTitle).textContent = 'Station bearbeiten';
|
||||||
showPoiView();
|
showPoiView();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function savePoi() {
|
async function mediaFromLocalForm(existingPoi, poiIdentifier) {
|
||||||
const route = state.currentRoute;
|
const audioFile = byId(ids.poiAudio)?.files?.[0];
|
||||||
const currentRouteId = routeId(route);
|
const imageFiles = Array.from(byId(ids.poiImages)?.files || []);
|
||||||
if (!currentRouteId) throw new Error('Speichere zuerst die Strecke.');
|
const audio = audioFile ? await ns.LocalRoutes.fileToDataUrl(audioFile) : existingPoi?.audio || null;
|
||||||
const values = poiFromForm();
|
const images = [...(existingPoi?.images || [])];
|
||||||
if (!values.title) throw new Error('Der Titel der Station ist erforderlich.');
|
for (const imageFile of imageFiles) {
|
||||||
|
const image = await ns.LocalRoutes.fileToDataUrl(imageFile);
|
||||||
|
images.push({
|
||||||
|
...image,
|
||||||
|
id: `${poiIdentifier}-image-${Date.now()}-${images.length}`,
|
||||||
|
caption: ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { audio, images };
|
||||||
|
}
|
||||||
|
|
||||||
const currentPoiId = byId(ids.poiId).value;
|
async function persistLocalCurrentRoute() {
|
||||||
const path = currentPoiId
|
const id = routeId(state.currentRoute);
|
||||||
? `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`
|
if (!id) throw new Error('Speichere zuerst die Strecke.');
|
||||||
: `/routes/${encodeURIComponent(currentRouteId)}/pois`;
|
const saved = ns.LocalRoutes.save({
|
||||||
const method = currentPoiId ? 'PUT' : 'POST';
|
id,
|
||||||
const payload = await sendJson(path, method, values);
|
gpx: state.currentRoute.gpx,
|
||||||
const saved = payload.poi || payload;
|
route: state.currentRoute,
|
||||||
const savedPoiId = poiId(saved) || currentPoiId;
|
pois: state.pois
|
||||||
|
});
|
||||||
|
state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
|
||||||
|
state.pois = saved.pois;
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId);
|
async function saveLocalPoi(values) {
|
||||||
setStatus('Station gespeichert.');
|
if (!Number.isFinite(values.lat) || !Number.isFinite(values.lon)) {
|
||||||
await loadRoute(route);
|
throw new Error('Für einen GPX-POI sind gültige Breiten- und Längengrade erforderlich.');
|
||||||
|
}
|
||||||
|
const currentId = Number(byId(ids.poiId).value);
|
||||||
|
const identifier = Number.isSafeInteger(currentId) ? currentId : ns.LocalRoutes.createPoiId();
|
||||||
|
const existing = state.pois.find(poi => Number(poi.id) === identifier) || state.currentPoi;
|
||||||
|
const media = await mediaFromLocalForm(existing, identifier);
|
||||||
|
const savedPoi = {
|
||||||
|
...(existing || {}),
|
||||||
|
...values,
|
||||||
|
...media,
|
||||||
|
id: identifier,
|
||||||
|
sequence: existing?.sequence ?? state.pois.length
|
||||||
|
};
|
||||||
|
const index = state.pois.findIndex(poi => Number(poi.id) === identifier);
|
||||||
|
if (index >= 0) state.pois[index] = savedPoi;
|
||||||
|
else state.pois.push(savedPoi);
|
||||||
|
await persistLocalCurrentRoute();
|
||||||
|
setStatus('GPX-POI und zugehörige JSON-Mediendaten lokal gespeichert.');
|
||||||
|
await loadRoute({ id: routeId(state.currentRoute), _source: 'local' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadOne(path, file) {
|
||||||
|
const data = new FormData();
|
||||||
|
data.append('file', file);
|
||||||
|
return sendForm(path, 'POST', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadPoiMedia(currentRouteId, currentPoiId) {
|
async function uploadPoiMedia(currentRouteId, currentPoiId) {
|
||||||
@@ -496,30 +622,57 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadOne(path, file) {
|
async function saveServerPoi(values) {
|
||||||
const data = new FormData();
|
const currentRouteId = routeId(state.currentRoute);
|
||||||
data.append('file', file);
|
if (!currentRouteId) throw new Error('Speichere zuerst die Strecke.');
|
||||||
return sendForm(path, 'POST', data);
|
const currentPoiId = byId(ids.poiId).value;
|
||||||
|
const path = currentPoiId
|
||||||
|
? `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`
|
||||||
|
: `/routes/${encodeURIComponent(currentRouteId)}/pois`;
|
||||||
|
const method = currentPoiId ? 'PUT' : 'POST';
|
||||||
|
const payload = await sendJson(path, method, values);
|
||||||
|
const saved = payload.poi || payload;
|
||||||
|
const savedPoiId = poiId(saved) || currentPoiId;
|
||||||
|
if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId);
|
||||||
|
setStatus('Station gespeichert.');
|
||||||
|
await loadRoute({ id: currentRouteId, _source: 'server' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePoi() {
|
||||||
|
const values = poiFromForm();
|
||||||
|
if (!values.title) throw new Error('Der Titel der Station ist erforderlich.');
|
||||||
|
if (state.currentSource === 'local') await saveLocalPoi(values);
|
||||||
|
else await saveServerPoi(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reorderPois(index, offset) {
|
||||||
|
const target = index + offset;
|
||||||
|
if (target < 0 || target >= state.pois.length) return false;
|
||||||
|
const reordered = [...state.pois];
|
||||||
|
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
|
||||||
|
state.pois = reordered.map((poi, sequence) => ({ ...poi, sequence }));
|
||||||
|
renderPoiList();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function swapPois(index, offset) {
|
function swapPois(index, offset) {
|
||||||
const target = index + offset;
|
if (!reorderPois(index, offset)) return;
|
||||||
if (target < 0 || target >= state.pois.length) return;
|
|
||||||
const reordered = [...state.pois];
|
|
||||||
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
|
|
||||||
state.pois = reordered;
|
|
||||||
renderPoiList();
|
|
||||||
guarded(savePoiOrder);
|
guarded(savePoiOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function savePoiOrder() {
|
async function savePoiOrder() {
|
||||||
const currentRouteId = routeId(state.currentRoute);
|
const currentRouteId = routeId(state.currentRoute);
|
||||||
if (!currentRouteId) return;
|
if (!currentRouteId) return;
|
||||||
|
if (state.currentSource === 'local') {
|
||||||
|
await persistLocalCurrentRoute();
|
||||||
|
setStatus('Lokale POI-Reihenfolge gespeichert.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const jobs = state.pois.map((poi, index) => {
|
const jobs = state.pois.map((poi, index) => {
|
||||||
const currentPoiId = poiId(poi);
|
const currentPoiId = poiId(poi);
|
||||||
if (!currentPoiId) return null;
|
if (!currentPoiId) return null;
|
||||||
const path = `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`;
|
const path = `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`;
|
||||||
return sendJson(path, 'PUT', { ...poi, position: index, sortOrder: index });
|
return sendJson(path, 'PUT', { ...poi, sequence: index, sortOrder: index });
|
||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
await Promise.all(jobs);
|
await Promise.all(jobs);
|
||||||
setStatus('Reihenfolge gespeichert.');
|
setStatus('Reihenfolge gespeichert.');
|
||||||
@@ -527,7 +680,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
|
|
||||||
function routeForItem(target) {
|
function routeForItem(target) {
|
||||||
const item = target.closest('[data-route-id]');
|
const item = target.closest('[data-route-id]');
|
||||||
return state.routes.find(route => routeId(route) === item?.dataset.routeId);
|
const id = item?.dataset.routeId;
|
||||||
|
const source = item?.dataset.routeSource;
|
||||||
|
return state.routes.find(route => routeId(route) === id && routeSource(route) === source);
|
||||||
}
|
}
|
||||||
|
|
||||||
function poiForItem(target) {
|
function poiForItem(target) {
|
||||||
@@ -580,58 +735,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
submit(byId(ids.poiForm), () => guarded(savePoi));
|
submit(byId(ids.poiForm), () => guarded(savePoi));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function classNames() {
|
|
||||||
return Object.values(classes);
|
|
||||||
}
|
|
||||||
|
|
||||||
function testHooks() {
|
|
||||||
return Object.freeze({
|
|
||||||
apiUrl,
|
|
||||||
classNames,
|
|
||||||
closeEditor,
|
|
||||||
fillPoiForm,
|
|
||||||
fillRouteForm,
|
|
||||||
handleResponse,
|
|
||||||
isActivation,
|
|
||||||
loadRoute,
|
|
||||||
loadRoutes,
|
|
||||||
newPoi,
|
|
||||||
newRoute,
|
|
||||||
poiArray,
|
|
||||||
poiFromForm,
|
|
||||||
poiItem,
|
|
||||||
parseJson,
|
|
||||||
readJson,
|
|
||||||
renderPoiList,
|
|
||||||
renderRouteList,
|
|
||||||
routeArray,
|
|
||||||
routeFromForm,
|
|
||||||
routeItem,
|
|
||||||
savePoi,
|
|
||||||
sendForm,
|
|
||||||
sendJson,
|
|
||||||
setDialogExpanded,
|
|
||||||
setDisabled,
|
|
||||||
setStatus,
|
|
||||||
savePoiOrder,
|
|
||||||
saveRoute,
|
|
||||||
state,
|
|
||||||
swapPois,
|
|
||||||
uploadPoiMedia
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function editorApi() {
|
function editorApi() {
|
||||||
const api = {
|
return Object.freeze({
|
||||||
close: closeEditor,
|
close: closeEditor,
|
||||||
classNames,
|
|
||||||
open: openEditor,
|
open: openEditor,
|
||||||
|
openNewLocalRoute,
|
||||||
refresh: loadRoutes
|
refresh: loadRoutes
|
||||||
};
|
});
|
||||||
|
|
||||||
if (globalThis.WegwichtelEditorTestHooks === true) api.testHooks = testHooks();
|
|
||||||
return Object.freeze(api);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
|
|||||||
@@ -0,0 +1,419 @@
|
|||||||
|
globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||||
|
|
||||||
|
(function (ns) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = 'wegwichtel.local-routes.v1';
|
||||||
|
const INDEX_KEY = `${STORAGE_PREFIX}.index`;
|
||||||
|
const GPX_NAMESPACE = 'http://www.topografix.com/GPX/1/1';
|
||||||
|
const POI_NAMESPACE = 'urn:wegwichtel:poi';
|
||||||
|
let poiCounter = 0;
|
||||||
|
|
||||||
|
function storage() {
|
||||||
|
const target = globalThis.localStorage;
|
||||||
|
if (!target) throw new Error('Lokaler Browser-Speicher ist auf diesem Gerät nicht verfügbar.');
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeKey(id, suffix) {
|
||||||
|
return `${STORAGE_PREFIX}.${encodeURIComponent(String(id))}.${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readIndex() {
|
||||||
|
const raw = storage().getItem(INDEX_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed.map(String) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeIndex(ids) {
|
||||||
|
storage().setItem(INDEX_KEY, JSON.stringify([...new Set(ids.map(String))]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRouteId() {
|
||||||
|
if (globalThis.crypto?.randomUUID) return `local-${globalThis.crypto.randomUUID()}`;
|
||||||
|
return `local-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPoiId() {
|
||||||
|
poiCounter = (poiCounter + 1) % 1000;
|
||||||
|
return (Date.now() * 1000) + poiCounter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyGpx(name = 'Neue Strecke') {
|
||||||
|
const safeName = escapeXml(name);
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>\n`
|
||||||
|
+ `<gpx version="1.1" creator="Wegwichtel" xmlns="${GPX_NAMESPACE}" xmlns:wegwichtel="${POI_NAMESPACE}">\n`
|
||||||
|
+ ` <metadata><name>${safeName}</name></metadata>\n`
|
||||||
|
+ ` <trk><name>${safeName}</name><trkseg></trkseg></trk>\n`
|
||||||
|
+ '</gpx>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseXml(gpx) {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const document = parser.parseFromString(String(gpx || ''), 'application/xml');
|
||||||
|
if (document.querySelector('parsererror') || document.documentElement?.localName !== 'gpx') {
|
||||||
|
throw new Error('Die GPX-Datei ist nicht gültig.');
|
||||||
|
}
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
function directChildrenByLocalName(parent, localName) {
|
||||||
|
return Array.from(parent?.children || []).filter(node => node.localName === localName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstDescendantByLocalName(parent, localName) {
|
||||||
|
return Array.from(parent?.getElementsByTagName('*') || []).find(node => node.localName === localName) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function textOfChild(parent, localName) {
|
||||||
|
return directChildrenByLocalName(parent, localName)[0]?.textContent?.trim() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberAttribute(node, name) {
|
||||||
|
const value = Number.parseFloat(node?.getAttribute(name));
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waypointId(node) {
|
||||||
|
const extension = firstDescendantByLocalName(node, 'poiId');
|
||||||
|
const parsed = Number(extension?.textContent);
|
||||||
|
return Number.isSafeInteger(parsed) ? parsed : createPoiId();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWaypoints(gpx) {
|
||||||
|
const document = parseXml(gpx);
|
||||||
|
return Array.from(document.getElementsByTagNameNS('*', 'wpt')).map((node, index) => ({
|
||||||
|
id: waypointId(node),
|
||||||
|
title: textOfChild(node, 'name') || `Station ${index + 1}`,
|
||||||
|
description: textOfChild(node, 'desc'),
|
||||||
|
lat: numberAttribute(node, 'lat'),
|
||||||
|
lon: numberAttribute(node, 'lon'),
|
||||||
|
triggerRadiusM: 25,
|
||||||
|
sequence: index,
|
||||||
|
audio: null,
|
||||||
|
images: []
|
||||||
|
})).filter(poi => poi.lat != null && poi.lon != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createElement(document, localName, text = null) {
|
||||||
|
const element = document.createElementNS(GPX_NAMESPACE, localName);
|
||||||
|
if (text != null) element.textContent = String(text);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMetadataName(document, name) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
let metadata = directChildrenByLocalName(root, 'metadata')[0];
|
||||||
|
if (!metadata) {
|
||||||
|
metadata = createElement(document, 'metadata');
|
||||||
|
root.insertBefore(metadata, root.firstChild);
|
||||||
|
}
|
||||||
|
let nameElement = directChildrenByLocalName(metadata, 'name')[0];
|
||||||
|
if (!nameElement) {
|
||||||
|
nameElement = createElement(document, 'name');
|
||||||
|
metadata.appendChild(nameElement);
|
||||||
|
}
|
||||||
|
nameElement.textContent = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncGpx(gpx, route, pois) {
|
||||||
|
const document = parseXml(gpx || emptyGpx(route?.name));
|
||||||
|
const root = document.documentElement;
|
||||||
|
updateMetadataName(document, route?.name || 'Neue Strecke');
|
||||||
|
Array.from(document.getElementsByTagNameNS('*', 'wpt')).forEach(node => node.remove());
|
||||||
|
|
||||||
|
const orderedPois = [...(pois || [])].sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
||||||
|
orderedPois.forEach((poi, index) => {
|
||||||
|
if (!Number.isFinite(Number(poi.lat)) || !Number.isFinite(Number(poi.lon))) {
|
||||||
|
throw new Error(`Für ${poi.title || `Station ${index + 1}`} fehlen gültige Koordinaten.`);
|
||||||
|
}
|
||||||
|
const waypoint = createElement(document, 'wpt');
|
||||||
|
waypoint.setAttribute('lat', String(Number(poi.lat)));
|
||||||
|
waypoint.setAttribute('lon', String(Number(poi.lon)));
|
||||||
|
waypoint.appendChild(createElement(document, 'name', poi.title || `Station ${index + 1}`));
|
||||||
|
if (poi.description) waypoint.appendChild(createElement(document, 'desc', poi.description));
|
||||||
|
const extensions = createElement(document, 'extensions');
|
||||||
|
const poiId = document.createElementNS(POI_NAMESPACE, 'wegwichtel:poiId');
|
||||||
|
poiId.textContent = String(poi.id);
|
||||||
|
extensions.appendChild(poiId);
|
||||||
|
waypoint.appendChild(extensions);
|
||||||
|
const routeOrTrack = directChildrenByLocalName(root, 'rte')[0]
|
||||||
|
|| directChildrenByLocalName(root, 'trk')[0]
|
||||||
|
|| null;
|
||||||
|
root.insertBefore(waypoint, routeOrTrack);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new XMLSerializer().serializeToString(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPackage(id) {
|
||||||
|
const target = storage();
|
||||||
|
const gpx = target.getItem(routeKey(id, 'gpx'));
|
||||||
|
const rawJson = target.getItem(routeKey(id, 'json'));
|
||||||
|
if (!gpx || !rawJson) return null;
|
||||||
|
try {
|
||||||
|
const metadata = JSON.parse(rawJson);
|
||||||
|
return {
|
||||||
|
id: String(id),
|
||||||
|
gpx,
|
||||||
|
route: metadata.route || {},
|
||||||
|
pois: Array.isArray(metadata.pois) ? metadata.pois : [],
|
||||||
|
schemaVersion: metadata.schemaVersion || 1
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreValue(target, key, value) {
|
||||||
|
if (value == null) target.removeItem(key);
|
||||||
|
else target.setItem(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(id, action) {
|
||||||
|
if (typeof document === 'undefined' || typeof CustomEvent === 'undefined') return;
|
||||||
|
document.dispatchEvent(new CustomEvent('wegwichtel:local-routes-changed', {
|
||||||
|
detail: { action, id: String(id) }
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePackage(routePackage) {
|
||||||
|
const target = storage();
|
||||||
|
const id = String(routePackage.id || routePackage.route?.id || createRouteId());
|
||||||
|
const previousIndex = target.getItem(INDEX_KEY);
|
||||||
|
const gpxKey = routeKey(id, 'gpx');
|
||||||
|
const jsonKey = routeKey(id, 'json');
|
||||||
|
const previousGpx = target.getItem(gpxKey);
|
||||||
|
const previousJson = target.getItem(jsonKey);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const inputRoute = routePackage.route || {};
|
||||||
|
const route = {
|
||||||
|
id,
|
||||||
|
name: String(inputRoute.name || ''),
|
||||||
|
description: String(inputRoute.description || ''),
|
||||||
|
createdAt: inputRoute.createdAt || now,
|
||||||
|
updatedAt: now
|
||||||
|
};
|
||||||
|
const pois = (routePackage.pois || []).map((poi, index) => ({
|
||||||
|
...poi,
|
||||||
|
id: Number.isSafeInteger(Number(poi.id)) ? Number(poi.id) : createPoiId(),
|
||||||
|
sequence: index
|
||||||
|
}));
|
||||||
|
const gpx = syncGpx(routePackage.gpx || emptyGpx(route.name), route, pois);
|
||||||
|
const metadata = JSON.stringify({ schemaVersion: 1, route, pois });
|
||||||
|
|
||||||
|
try {
|
||||||
|
target.setItem(gpxKey, gpx);
|
||||||
|
target.setItem(jsonKey, metadata);
|
||||||
|
writeIndex([...readIndex(), id]);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
restoreValue(target, gpxKey, previousGpx);
|
||||||
|
restoreValue(target, jsonKey, previousJson);
|
||||||
|
restoreValue(target, INDEX_KEY, previousIndex);
|
||||||
|
} catch {
|
||||||
|
// Die ursprüngliche Speicherfehlermeldung ist aussagekräftiger.
|
||||||
|
}
|
||||||
|
const name = error?.name === 'QuotaExceededError' ? 'Der lokale Browser-Speicher ist voll.' : 'Die lokale Strecke konnte nicht gespeichert werden.';
|
||||||
|
throw new Error(`${name} Bilder und Audiodateien können das Local-Storage-Limit schnell erreichen.`, { cause: error });
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(id, 'saved');
|
||||||
|
return readPackage(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id) {
|
||||||
|
const target = storage();
|
||||||
|
const routeId = String(id);
|
||||||
|
target.removeItem(routeKey(routeId, 'gpx'));
|
||||||
|
target.removeItem(routeKey(routeId, 'json'));
|
||||||
|
writeIndex(readIndex().filter(item => item !== routeId));
|
||||||
|
notify(routeId, 'removed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function listPackages() {
|
||||||
|
return readIndex().map(readPackage).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function routePoints(gpx) {
|
||||||
|
const document = parseXml(gpx);
|
||||||
|
const trackPoints = Array.from(document.getElementsByTagNameNS('*', 'trkpt'));
|
||||||
|
const routePoints = Array.from(document.getElementsByTagNameNS('*', 'rtept'));
|
||||||
|
const selected = trackPoints.length ? trackPoints : routePoints;
|
||||||
|
return selected.map(node => {
|
||||||
|
const elevation = Number.parseFloat(textOfChild(node, 'ele'));
|
||||||
|
return {
|
||||||
|
lat: numberAttribute(node, 'lat'),
|
||||||
|
lon: numberAttribute(node, 'lon'),
|
||||||
|
elevation: Number.isFinite(elevation) ? elevation : null,
|
||||||
|
recordedAt: textOfChild(node, 'time') || null
|
||||||
|
};
|
||||||
|
}).filter(point => point.lat != null && point.lon != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function radians(value) {
|
||||||
|
return value * (Math.PI / 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
function meters(left, right) {
|
||||||
|
const earthRadius = 6371008.8;
|
||||||
|
const latDelta = radians(right.lat - left.lat);
|
||||||
|
const lonDelta = radians(right.lon - left.lon);
|
||||||
|
const leftLat = radians(left.lat);
|
||||||
|
const rightLat = radians(right.lat);
|
||||||
|
const value = Math.sin(latDelta / 2) ** 2
|
||||||
|
+ Math.cos(leftLat) * Math.cos(rightLat) * (Math.sin(lonDelta / 2) ** 2);
|
||||||
|
return earthRadius * 2 * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function metrics(points) {
|
||||||
|
if (!points.length) {
|
||||||
|
return {
|
||||||
|
bounds: null,
|
||||||
|
center: null,
|
||||||
|
distanceM: 0,
|
||||||
|
elevationGainM: 0,
|
||||||
|
start: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let distanceM = 0;
|
||||||
|
let elevationGainM = 0;
|
||||||
|
for (let index = 1; index < points.length; index += 1) {
|
||||||
|
distanceM += meters(points[index - 1], points[index]);
|
||||||
|
const previous = points[index - 1].elevation;
|
||||||
|
const current = points[index].elevation;
|
||||||
|
if (Number.isFinite(previous) && Number.isFinite(current) && current > previous) elevationGainM += current - previous;
|
||||||
|
}
|
||||||
|
const lats = points.map(point => point.lat);
|
||||||
|
const lons = points.map(point => point.lon);
|
||||||
|
const bounds = {
|
||||||
|
minLat: Math.min(...lats),
|
||||||
|
minLon: Math.min(...lons),
|
||||||
|
maxLat: Math.max(...lats),
|
||||||
|
maxLon: Math.max(...lons)
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
bounds,
|
||||||
|
center: {
|
||||||
|
lat: (bounds.minLat + bounds.maxLat) / 2,
|
||||||
|
lon: (bounds.minLon + bounds.maxLon) / 2
|
||||||
|
},
|
||||||
|
distanceM,
|
||||||
|
elevationGainM,
|
||||||
|
start: { lat: points[0].lat, lon: points[0].lon }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appPoi(poi) {
|
||||||
|
return {
|
||||||
|
id: Number(poi.id),
|
||||||
|
title: poi.title || 'Unbenannte Station',
|
||||||
|
description: poi.description || '',
|
||||||
|
lat: Number(poi.lat),
|
||||||
|
lon: Number(poi.lon),
|
||||||
|
triggerRadiusM: Number(poi.triggerRadiusM) || 25,
|
||||||
|
sequence: Number(poi.sequence) || 0,
|
||||||
|
audioUrl: poi.audio?.dataUrl || null,
|
||||||
|
images: (poi.images || []).map((image, index) => ({
|
||||||
|
id: image.id || `${poi.id}-${index}`,
|
||||||
|
caption: image.caption || '',
|
||||||
|
sequence: index,
|
||||||
|
url: image.dataUrl
|
||||||
|
})).filter(image => image.url)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAppRoute(routePackage) {
|
||||||
|
const pois = (routePackage.pois || []).map(appPoi).sort((left, right) => left.sequence - right.sequence);
|
||||||
|
const parsedPoints = routePoints(routePackage.gpx);
|
||||||
|
const points = parsedPoints.length
|
||||||
|
? parsedPoints
|
||||||
|
: pois.map(poi => ({ lat: poi.lat, lon: poi.lon, elevation: null, recordedAt: null }));
|
||||||
|
const routeMetrics = metrics(points);
|
||||||
|
return {
|
||||||
|
id: String(routePackage.id),
|
||||||
|
source: 'local',
|
||||||
|
local: true,
|
||||||
|
name: routePackage.route?.name || 'Unbenannte Strecke',
|
||||||
|
description: routePackage.route?.description || '',
|
||||||
|
schoolName: 'Lokal gespeichert',
|
||||||
|
status: 'local',
|
||||||
|
activatedAt: null,
|
||||||
|
points,
|
||||||
|
pois,
|
||||||
|
pointCount: points.length,
|
||||||
|
gpxUrl: null,
|
||||||
|
createdAt: routePackage.route?.createdAt || null,
|
||||||
|
updatedAt: routePackage.route?.updatedAt || null,
|
||||||
|
...routeMetrics
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listAppRoutes() {
|
||||||
|
return listPackages().map(toAppRoute);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAppRoute(id) {
|
||||||
|
const routePackage = readPackage(id);
|
||||||
|
if (!routePackage) throw new Error('Die lokale Strecke wurde nicht gefunden.');
|
||||||
|
return toAppRoute(routePackage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileAsText(file) {
|
||||||
|
if (!file) return Promise.resolve('');
|
||||||
|
if (typeof file.text === 'function') return file.text();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.addEventListener('load', () => resolve(String(reader.result || '')), { once: true });
|
||||||
|
reader.addEventListener('error', () => reject(reader.error || new Error('Datei konnte nicht gelesen werden.')), { once: true });
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileToDataUrl(file) {
|
||||||
|
if (!file) return Promise.resolve(null);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.addEventListener('load', () => resolve({
|
||||||
|
dataUrl: String(reader.result || ''),
|
||||||
|
name: file.name || 'Datei',
|
||||||
|
size: Number(file.size) || 0,
|
||||||
|
type: file.type || 'application/octet-stream'
|
||||||
|
}), { once: true });
|
||||||
|
reader.addEventListener('error', () => reject(reader.error || new Error('Datei konnte nicht gelesen werden.')), { once: true });
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ns.LocalRoutes = Object.freeze({
|
||||||
|
STORAGE_PREFIX,
|
||||||
|
createPoiId,
|
||||||
|
createRouteId,
|
||||||
|
emptyGpx,
|
||||||
|
fileToDataUrl,
|
||||||
|
get: readPackage,
|
||||||
|
getAppRoute,
|
||||||
|
list: listPackages,
|
||||||
|
listAppRoutes,
|
||||||
|
parseWaypoints,
|
||||||
|
readFileAsText,
|
||||||
|
remove,
|
||||||
|
save: savePackage,
|
||||||
|
syncGpx,
|
||||||
|
toAppRoute
|
||||||
|
});
|
||||||
|
}(globalThis.Wegwichtel));
|
||||||
+3
-1
@@ -51,11 +51,13 @@ export function createApiRouter(db) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
api.get('/routes', (req, res) => {
|
api.get('/routes', (req, res) => {
|
||||||
|
const includeDeleted = req.query.includeDeleted === 'true';
|
||||||
res.json({ routes: listRoutes(db, {
|
res.json({ routes: listRoutes(db, {
|
||||||
lat: numberOrUndefined(req.query.lat),
|
lat: numberOrUndefined(req.query.lat),
|
||||||
lon: numberOrUndefined(req.query.lon),
|
lon: numberOrUndefined(req.query.lon),
|
||||||
radiusKm: numberOrUndefined(req.query.radiusKm),
|
radiusKm: numberOrUndefined(req.query.radiusKm),
|
||||||
includeDeleted: req.query.includeDeleted === 'true'
|
includeDeleted,
|
||||||
|
publishedOnly: req.query.activatedOnly === 'true'
|
||||||
}) });
|
}) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,8 @@ const UPDATE_METRICS_SQL = `UPDATE routes SET
|
|||||||
function trimEdgeHyphens(value) {
|
function trimEdgeHyphens(value) {
|
||||||
let start = 0;
|
let start = 0;
|
||||||
let end = value.length;
|
let end = value.length;
|
||||||
|
|
||||||
while (start < end && value[start] === '-') start += 1;
|
while (start < end && value[start] === '-') start += 1;
|
||||||
while (end > start && value[end - 1] === '-') end -= 1;
|
while (end > start && value[end - 1] === '-') end -= 1;
|
||||||
|
|
||||||
return value.slice(start, end);
|
return value.slice(start, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +43,6 @@ const audioUrl = (routeId, poiId) => `/api/routes/${routeId}/pois/${poiId}/audio
|
|||||||
|
|
||||||
function rowToRoute(row, includeDeleted = false) {
|
function rowToRoute(row, includeDeleted = false) {
|
||||||
if (!row || (!includeDeleted && row.status !== 'active')) return null;
|
if (!row || (!includeDeleted && row.status !== 'active')) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
slug: row.slug,
|
slug: row.slug,
|
||||||
@@ -53,6 +50,7 @@ function rowToRoute(row, includeDeleted = false) {
|
|||||||
description: row.description,
|
description: row.description,
|
||||||
schoolName: row.school_name,
|
schoolName: row.school_name,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
|
activatedAt: row.activated_at,
|
||||||
start: row.start_lat == null ? null : { lat: row.start_lat, lon: row.start_lon },
|
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 },
|
center: row.center_lat == null ? null : { lat: row.center_lat, lon: row.center_lon },
|
||||||
bounds: row.min_lat == null
|
bounds: row.min_lat == null
|
||||||
@@ -101,19 +99,16 @@ function uniqueSlug(db, source) {
|
|||||||
const base = slugify(source);
|
const base = slugify(source);
|
||||||
let candidate = base;
|
let candidate = base;
|
||||||
let suffix = 2;
|
let suffix = 2;
|
||||||
|
|
||||||
while (db.prepare('SELECT 1 FROM routes WHERE slug = ?').get(candidate)) {
|
while (db.prepare('SELECT 1 FROM routes WHERE slug = ?').get(candidate)) {
|
||||||
candidate = `${base}-${suffix}`;
|
candidate = `${base}-${suffix}`;
|
||||||
suffix += 1;
|
suffix += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupFailedRoute(db, routeId, gpxFile) {
|
function cleanupFailedRoute(db, routeId, gpxFile) {
|
||||||
removeUpload(gpxFile);
|
removeUpload(gpxFile);
|
||||||
if (!routeId) return;
|
if (!routeId) return;
|
||||||
|
|
||||||
db.prepare('DELETE FROM route_points WHERE route_id = ?').run(routeId);
|
db.prepare('DELETE FROM route_points WHERE route_id = ?').run(routeId);
|
||||||
db.prepare('DELETE FROM routes WHERE id = ?').run(routeId);
|
db.prepare('DELETE FROM routes WHERE id = ?').run(routeId);
|
||||||
fs.rmSync(routeDirectory(routeId), { recursive: true, force: true });
|
fs.rmSync(routeDirectory(routeId), { recursive: true, force: true });
|
||||||
@@ -131,13 +126,18 @@ export function listRoutes(db, {
|
|||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
radiusKm = config.defaultRouteRadiusKm,
|
radiusKm = config.defaultRouteRadiusKm,
|
||||||
includeDeleted = false
|
includeDeleted = false,
|
||||||
|
publishedOnly = false
|
||||||
}) {
|
}) {
|
||||||
const where = includeDeleted ? '' : "WHERE status = 'active'";
|
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 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 origin = Number.isFinite(lat) && Number.isFinite(lon) ? { lat, lon } : null;
|
||||||
const maximumDistance = radiusKm * 1000;
|
const maximumDistance = radiusKm * 1000;
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
.map(row => {
|
.map(row => {
|
||||||
const route = rowToRoute(row, includeDeleted);
|
const route = rowToRoute(row, includeDeleted);
|
||||||
@@ -161,7 +161,6 @@ export function getRoute(db, id, includeDeleted = false) {
|
|||||||
export function getRouteGpxFile(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);
|
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.');
|
if (!route) throw new HttpError(404, 'Strecke nicht gefunden.');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
absolutePath: resolveStoredFile(route.gpx_path),
|
absolutePath: resolveStoredFile(route.gpx_path),
|
||||||
filename: `${route.slug}.gpx`
|
filename: `${route.slug}.gpx`
|
||||||
@@ -170,13 +169,11 @@ export function getRouteGpxFile(db, id) {
|
|||||||
|
|
||||||
export function createRoute(db, fields, gpxFile) {
|
export function createRoute(db, fields, gpxFile) {
|
||||||
if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.');
|
if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.');
|
||||||
|
|
||||||
let routeId;
|
let routeId;
|
||||||
try {
|
try {
|
||||||
const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8'));
|
const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8'));
|
||||||
const name = String(fields.name || parsed.name || '').trim();
|
const name = String(fields.name || parsed.name || '').trim();
|
||||||
if (!name) throw new HttpError(400, 'Ein Streckenname ist erforderlich.');
|
if (!name) throw new HttpError(400, 'Ein Streckenname ist erforderlich.');
|
||||||
|
|
||||||
routeId = transaction(db, () => {
|
routeId = transaction(db, () => {
|
||||||
const slug = uniqueSlug(db, fields.slug || name);
|
const slug = uniqueSlug(db, fields.slug || name);
|
||||||
const metrics = parsed.metrics;
|
const metrics = parsed.metrics;
|
||||||
@@ -205,7 +202,6 @@ export function createRoute(db, fields, gpxFile) {
|
|||||||
insertRoutePoints(db, id, parsed.points);
|
insertRoutePoints(db, id, parsed.points);
|
||||||
return id;
|
return id;
|
||||||
});
|
});
|
||||||
|
|
||||||
ensureRouteDirectories(routeId);
|
ensureRouteDirectories(routeId);
|
||||||
const gpxPath = moveUploadedFile(gpxFile, path.join(routeDirectory(routeId), 'route.gpx'));
|
const gpxPath = moveUploadedFile(gpxFile, path.join(routeDirectory(routeId), 'route.gpx'));
|
||||||
db.prepare('UPDATE routes SET gpx_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
db.prepare('UPDATE routes SET gpx_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
||||||
@@ -220,7 +216,6 @@ export function createRoute(db, fields, gpxFile) {
|
|||||||
export function updateRoute(db, id, fields, gpxFile) {
|
export function updateRoute(db, id, fields, gpxFile) {
|
||||||
const existing = db.prepare("SELECT * 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.');
|
if (!existing) throw new HttpError(404, 'Strecke nicht gefunden.');
|
||||||
|
|
||||||
const parsed = gpxFile ? parseGpx(fs.readFileSync(gpxFile.path, 'utf8')) : null;
|
const parsed = gpxFile ? parseGpx(fs.readFileSync(gpxFile.path, 'utf8')) : null;
|
||||||
transaction(db, () => {
|
transaction(db, () => {
|
||||||
db.prepare(`UPDATE routes SET name = ?, description = ?, school_name = ?, updated_at = CURRENT_TIMESTAMP
|
db.prepare(`UPDATE routes SET name = ?, description = ?, school_name = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
@@ -230,7 +225,6 @@ export function updateRoute(db, id, fields, gpxFile) {
|
|||||||
fields.schoolName ?? existing.school_name,
|
fields.schoolName ?? existing.school_name,
|
||||||
id
|
id
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!parsed) return;
|
if (!parsed) return;
|
||||||
db.prepare('DELETE FROM route_points WHERE route_id = ?').run(id);
|
db.prepare('DELETE FROM route_points WHERE route_id = ?').run(id);
|
||||||
insertRoutePoints(db, id, parsed.points);
|
insertRoutePoints(db, id, parsed.points);
|
||||||
@@ -243,7 +237,6 @@ export function updateRoute(db, id, fields, gpxFile) {
|
|||||||
|
|
||||||
export function appendRoute(db, id, gpxFile) {
|
export function appendRoute(db, id, gpxFile) {
|
||||||
if (!gpxFile) throw new HttpError(400, 'Eine GPX-Datei ist erforderlich.');
|
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 id FROM routes WHERE id = ? AND status = 'active'").get(id);
|
||||||
if (!existing) throw new HttpError(404, 'Strecke nicht gefunden.');
|
if (!existing) throw new HttpError(404, 'Strecke nicht gefunden.');
|
||||||
|
|
||||||
@@ -251,7 +244,6 @@ export function appendRoute(db, id, gpxFile) {
|
|||||||
const current = db.prepare(`SELECT lat, lon, elevation, recorded_at AS recordedAt
|
const current = db.prepare(`SELECT lat, lon, elevation, recorded_at AS recordedAt
|
||||||
FROM route_points WHERE route_id = ? ORDER BY sequence`).all(id);
|
FROM route_points WHERE route_id = ? ORDER BY sequence`).all(id);
|
||||||
const allPoints = current.concat(parsed.points).map((point, sequence) => ({ ...point, sequence }));
|
const allPoints = current.concat(parsed.points).map((point, sequence) => ({ ...point, sequence }));
|
||||||
|
|
||||||
transaction(db, () => {
|
transaction(db, () => {
|
||||||
insertRoutePoints(db, id, parsed.points, current.length);
|
insertRoutePoints(db, id, parsed.points, current.length);
|
||||||
updateMetrics(db, id, routeMetrics(allPoints));
|
updateMetrics(db, id, routeMetrics(allPoints));
|
||||||
@@ -265,7 +257,6 @@ export function listPois(db, routeId) {
|
|||||||
const pois = db.prepare('SELECT * FROM pois WHERE route_id = ? ORDER BY sequence, id').all(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
|
const images = db.prepare(`SELECT id, path, caption, sequence FROM poi_images
|
||||||
WHERE poi_id = ? ORDER BY sequence, id`);
|
WHERE poi_id = ? ORDER BY sequence, id`);
|
||||||
|
|
||||||
return pois.map(poi => ({
|
return pois.map(poi => ({
|
||||||
id: poi.id,
|
id: poi.id,
|
||||||
routeId: poi.route_id,
|
routeId: poi.route_id,
|
||||||
@@ -301,7 +292,6 @@ export function createPoi(db, routeId, fields) {
|
|||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||||
throw new HttpError(400, 'Gültige POI-Koordinaten sind erforderlich.');
|
throw new HttpError(400, 'Gültige POI-Koordinaten sind erforderlich.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = db.prepare(`INSERT INTO pois
|
const result = db.prepare(`INSERT INTO pois
|
||||||
(route_id, title, description, lat, lon, trigger_radius_m, sequence) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
|
(route_id, title, description, lat, lon, trigger_radius_m, sequence) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(
|
||||||
routeId,
|
routeId,
|
||||||
@@ -319,7 +309,6 @@ 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
|
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);
|
WHERE p.id = ? AND p.route_id = ?`).get(poiId, routeId);
|
||||||
if (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 lat = fields.lat == null ? existing.lat : Number.parseFloat(fields.lat);
|
||||||
const lon = fields.lon == null ? existing.lon : Number.parseFloat(fields.lon);
|
const lon = fields.lon == null ? existing.lon : Number.parseFloat(fields.lon);
|
||||||
const radius = fields.triggerRadiusM == null
|
const radius = fields.triggerRadiusM == null
|
||||||
@@ -329,7 +318,6 @@ export function updatePoi(db, routeId, poiId, fields) {
|
|||||||
if (![lat, lon, radius].every(Number.isFinite) || !Number.isInteger(sequence) || sequence < 0) {
|
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.');
|
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=?,
|
db.prepare(`UPDATE pois SET title=?, description=?, lat=?, lon=?, trigger_radius_m=?, sequence=?,
|
||||||
updated_at=CURRENT_TIMESTAMP WHERE id=?`).run(
|
updated_at=CURRENT_TIMESTAMP WHERE id=?`).run(
|
||||||
fields.title ?? existing.title,
|
fields.title ?? existing.title,
|
||||||
@@ -347,7 +335,6 @@ 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
|
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);
|
WHERE p.id = ? AND p.route_id = ? AND r.status = 'active'`).get(poiId, routeId);
|
||||||
if (!existing) throw new HttpError(404, 'POI nicht gefunden.');
|
if (!existing) throw new HttpError(404, 'POI nicht gefunden.');
|
||||||
|
|
||||||
const images = db.prepare('SELECT path FROM poi_images WHERE poi_id = ?').all(poiId);
|
const images = db.prepare('SELECT path FROM poi_images WHERE poi_id = ?').all(poiId);
|
||||||
transaction(db, () => {
|
transaction(db, () => {
|
||||||
db.prepare('DELETE FROM poi_images WHERE poi_id = ?').run(poiId);
|
db.prepare('DELETE FROM poi_images WHERE poi_id = ?').run(poiId);
|
||||||
@@ -362,7 +349,6 @@ export function deletePoi(db, routeId, poiId) {
|
|||||||
export function softDeleteRoute(db, id) {
|
export function softDeleteRoute(db, id) {
|
||||||
const route = db.prepare("SELECT id 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.');
|
if (!route) throw new HttpError(404, 'Aktive Strecke nicht gefunden.');
|
||||||
|
|
||||||
const moved = softDeleteRouteDirectory(id);
|
const moved = softDeleteRouteDirectory(id);
|
||||||
try {
|
try {
|
||||||
transaction(db, () => {
|
transaction(db, () => {
|
||||||
@@ -375,14 +361,12 @@ export function softDeleteRoute(db, id) {
|
|||||||
fs.renameSync(moved.destination, routeDirectory(id));
|
fs.renameSync(moved.destination, routeDirectory(id));
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return rowToRoute(db.prepare('SELECT * FROM routes WHERE id = ?').get(id), true);
|
return rowToRoute(db.prepare('SELECT * FROM routes WHERE id = ?').get(id), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restoreRoute(db, id) {
|
export function restoreRoute(db, id) {
|
||||||
const route = db.prepare("SELECT trash_path 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.');
|
if (!route) throw new HttpError(404, 'Gelöschte Strecke nicht gefunden.');
|
||||||
|
|
||||||
const moved = restoreRouteDirectory(id, route.trash_path);
|
const moved = restoreRouteDirectory(id, route.trash_path);
|
||||||
try {
|
try {
|
||||||
transaction(db, () => {
|
transaction(db, () => {
|
||||||
@@ -395,6 +379,5 @@ export function restoreRoute(db, id) {
|
|||||||
fs.renameSync(moved.destination, moved.source);
|
fs.renameSync(moved.destination, moved.source);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return getRoute(db, id);
|
return getRoute(db, id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,58 +9,41 @@ async function read(relativePath) {
|
|||||||
return fs.readFile(path.join(root, relativePath), 'utf8');
|
return fs.readFile(path.join(root, relativePath), 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizedTokens(line) {
|
test('fresh routes schema stores timestamps as STRICT-compatible TEXT values', async () => {
|
||||||
return line
|
|
||||||
.trim()
|
|
||||||
.replaceAll(',', ' ')
|
|
||||||
.replaceAll(';', ' ')
|
|
||||||
.replaceAll('(', ' ')
|
|
||||||
.replaceAll(')', ' ')
|
|
||||||
.split(' ')
|
|
||||||
.map(token => token.trim().toUpperCase())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function routeTableLines(schema) {
|
|
||||||
const lines = schema.split('\n');
|
|
||||||
const start = lines.findIndex(line => normalizedTokens(line).join(' ') === 'CREATE TABLE IF NOT EXISTS ROUTES');
|
|
||||||
assert.notEqual(start, -1, 'database/schema.sql must define the routes table');
|
|
||||||
|
|
||||||
const end = lines.findIndex((line, index) => index > start && normalizedTokens(line).includes('STRICT'));
|
|
||||||
assert.notEqual(end, -1, 'the routes table must be declared STRICT');
|
|
||||||
return lines.slice(start, end + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasActivatedAtTextDefaultNull(lines) {
|
|
||||||
return lines.some(line => {
|
|
||||||
const tokens = normalizedTokens(line);
|
|
||||||
return tokens[0] === 'ACTIVATED_AT'
|
|
||||||
&& tokens[1] === 'TEXT'
|
|
||||||
&& tokens.includes('DEFAULT')
|
|
||||||
&& tokens.includes('NULL');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function containsTokenSequence(sql, sequence) {
|
|
||||||
const tokens = sql
|
|
||||||
.split('\n')
|
|
||||||
.flatMap(normalizedTokens);
|
|
||||||
|
|
||||||
return tokens.some((_, index) => sequence.every((token, offset) => tokens[index + offset] === token));
|
|
||||||
}
|
|
||||||
|
|
||||||
test('initial database schema creates routes with a nullable activation timestamp', async () => {
|
|
||||||
const schema = await read('database/schema.sql');
|
const schema = await read('database/schema.sql');
|
||||||
const migration = await read('database/migrations/20260901_add_route_activated_at.sql');
|
const routesTable = schema.match(/CREATE TABLE IF NOT EXISTS routes\s*\((?<body>[\s\S]*?)\)\s*STRICT\s*;/i);
|
||||||
const routes = routeTableLines(schema);
|
|
||||||
|
|
||||||
assert.equal(hasActivatedAtTextDefaultNull(routes), true);
|
assert.ok(routesTable?.groups?.body, 'routes must be a STRICT table');
|
||||||
assert.equal(containsTokenSequence(routes.join('\n'), ['ACTIVATED_AT', 'DATETIME']), false);
|
const body = routesTable.groups.body;
|
||||||
assert.equal(containsTokenSequence(routes.join('\n'), ['ON', 'UPDATE', 'CURRENT_TIMESTAMP']), false);
|
|
||||||
assert.equal(containsTokenSequence(schema, ['CREATE', 'INDEX', 'IF', 'NOT', 'EXISTS', 'IDX_ROUTES_ACTIVATION', 'ON', 'ROUTES']), true);
|
assert.match(body, /created_at\s+TEXT\s+NOT NULL\s+DEFAULT\s+CURRENT_TIMESTAMP/i);
|
||||||
assert.equal(containsTokenSequence(schema, ['CREATE', 'TRIGGER', 'IF', 'NOT', 'EXISTS', 'POIS_UPDATED_AT']), true);
|
assert.match(body, /updated_at\s+TEXT\s+NOT NULL\s+DEFAULT\s+CURRENT_TIMESTAMP/i);
|
||||||
assert.equal(containsTokenSequence(migration, ['ALTER', 'TABLE', 'ROUTES', 'ADD', 'COLUMN', 'ACTIVATED_AT', 'TEXT']), true);
|
assert.match(body, /deleted_at\s+TEXT\s+DEFAULT\s+NULL/i);
|
||||||
assert.equal(containsTokenSequence(migration, ['CREATE', 'INDEX', 'IF', 'NOT', 'EXISTS', 'IDX_ROUTES_ACTIVATION', 'ON', 'ROUTES']), true);
|
assert.match(body, /activated_at\s+TEXT\s+DEFAULT\s+NULL/i);
|
||||||
assert.equal(containsTokenSequence(migration, ['ACTIVATED_AT', 'DATETIME']), false);
|
assert.doesNotMatch(body, /activated_at\s+DATETIME/i);
|
||||||
assert.equal(migration.includes('activated_at is NULL'), true);
|
assert.doesNotMatch(body, /ON\s+UPDATE\s+CURRENT_TIMESTAMP/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fresh schema owns timestamp triggers and activation index', async () => {
|
||||||
|
const schema = await read('database/schema.sql');
|
||||||
|
|
||||||
|
assert.match(schema, /CREATE TRIGGER IF NOT EXISTS routes_updated_at/i);
|
||||||
|
assert.match(schema, /AFTER UPDATE ON routes/i);
|
||||||
|
assert.match(schema, /SET updated_at\s*=\s*CURRENT_TIMESTAMP/i);
|
||||||
|
assert.match(schema, /CREATE TRIGGER IF NOT EXISTS pois_updated_at/i);
|
||||||
|
assert.match(schema, /AFTER UPDATE ON pois/i);
|
||||||
|
assert.match(schema, /CREATE INDEX IF NOT EXISTS idx_routes_activation\s+ON routes\s*\(\s*status\s*,\s*activated_at\s*\)/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('public route listing can require activated routes without changing base service semantics', async () => {
|
||||||
|
const service = await read('src/services/routes-service.js');
|
||||||
|
const api = await read('src/routes/api.js');
|
||||||
|
const client = await read('public/js/api-client.js');
|
||||||
|
|
||||||
|
assert.match(service, /publishedOnly\s*=\s*false/);
|
||||||
|
assert.match(service, /activated_at\s+IS\s+NOT\s+NULL/i);
|
||||||
|
assert.match(service, /activatedAt:\s*row\.activated_at/);
|
||||||
|
|
||||||
|
assert.match(api, /publishedOnly:\s*req\.query\.activatedOnly\s*===\s*['"]true['"]/);
|
||||||
|
assert.match(client, /activatedOnly:\s*['"]true['"]/);
|
||||||
});
|
});
|
||||||
|
|||||||
+155
-172
@@ -10,60 +10,40 @@ async function read(relativePath) {
|
|||||||
return fs.readFile(path.join(root, relativePath), 'utf8');
|
return fs.readFile(path.join(root, relativePath), 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
test('route editor entry point, dialog and forms are present', async () => {
|
function tick() {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('editor markup and source describe local draft creation instead of immediate publication', async () => {
|
||||||
const html = await read('public/index.html');
|
const html = await read('public/index.html');
|
||||||
const css = await read('public/css/editor.css');
|
const css = await read('public/css/editor.css');
|
||||||
const ui = await read('public/js/editor-ui.js');
|
const ui = await read('public/js/editor-ui.js');
|
||||||
|
|
||||||
assert.match(html, /id="editor-button"/);
|
assert.match(html, /id="editor-button"/);
|
||||||
assert.match(html, /aria-label="Streckeneditor öffnen"/);
|
|
||||||
assert.match(html, /aria-haspopup="dialog"/);
|
|
||||||
assert.match(html, /aria-controls="editor-dialog"/);
|
|
||||||
assert.match(html, /<dialog id="editor-dialog"/);
|
assert.match(html, /<dialog id="editor-dialog"/);
|
||||||
assert.match(html, /<output id="editor-status" class="editor-status" aria-live="polite" hidden><\/output>/);
|
|
||||||
assert.doesNotMatch(html, /id="editor-status"[^>]*role="status"/);
|
|
||||||
assert.match(html, /id="editor-route-list"/);
|
|
||||||
assert.match(html, /id="editor-route-form"/);
|
assert.match(html, /id="editor-route-form"/);
|
||||||
assert.match(html, /id="editor-poi-form"/);
|
assert.match(html, /id="editor-poi-form"/);
|
||||||
|
assert.match(html, /id="editor-route-gpx"[^>]+accept="[^\"]*\.gpx/);
|
||||||
assert.match(html, /id="editor-poi-audio"[^>]+accept="audio\/\*"/);
|
assert.match(html, /id="editor-poi-audio"[^>]+accept="audio\/\*"/);
|
||||||
assert.match(html, /id="editor-poi-images"[^>]+accept="image\/\*"[^>]+multiple/);
|
assert.match(html, /id="editor-poi-images"[^>]+accept="image\/\*"[^>]+multiple/);
|
||||||
assert.match(html, /Neue Strecken bleiben unveröffentlicht/);
|
assert.match(html, /ausschließlich in diesem Browser/i);
|
||||||
|
|
||||||
assert.match(css, /\.editor-entry-panel/);
|
|
||||||
assert.match(css, /\.editor-dialog/);
|
assert.match(css, /\.editor-dialog/);
|
||||||
assert.match(css, /\.editor-poi-list/);
|
assert.match(css, /\.editor-poi-list/);
|
||||||
|
assert.match(css, /\.editor-route-group/);
|
||||||
|
|
||||||
assert.match(ui, /pointerup/);
|
assert.match(ui, /readJson\(['"]\/routes\?activatedOnly=true['"]\)/);
|
||||||
assert.doesNotMatch(ui, /addEventListener\(['"]click['"]/);
|
assert.match(ui, /ns\.LocalRoutes\.save\(/);
|
||||||
});
|
assert.match(ui, /ns\.LocalRoutes\.parseWaypoints\(/);
|
||||||
|
assert.match(ui, /ns\.LocalRoutes\.fileToDataUrl\(/);
|
||||||
test('route editor references all static editor classes for the project markup checks', async () => {
|
|
||||||
const html = await read('public/index.html');
|
|
||||||
const ui = await read('public/js/editor-ui.js');
|
|
||||||
const classNames = [...html.matchAll(/class="([^"]*\beditor-[^"]*)"/g)]
|
|
||||||
.flatMap(match => match[1].split(/\s+/))
|
|
||||||
.filter(name => name.startsWith('editor-'));
|
|
||||||
|
|
||||||
for (const className of new Set(classNames)) {
|
|
||||||
assert.match(ui, new RegExp(className), `editor class is not referenced by editor-ui.js: ${className}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('route editor supports route creation, route editing, POI sorting and POI media uploads', async () => {
|
|
||||||
const ui = await read('public/js/editor-ui.js');
|
|
||||||
|
|
||||||
assert.match(ui, /readJson\('\/routes'\)/);
|
|
||||||
assert.match(ui, /readJson\(`\/routes\/\$\{encodeURIComponent\(id\)\}`\)/);
|
|
||||||
assert.match(ui, /const method = id \? 'PUT' : 'POST'/);
|
|
||||||
assert.match(ui, /const method = currentPoiId \? 'PUT' : 'POST'/);
|
|
||||||
assert.doesNotMatch(ui, /activated_at', ''/);
|
|
||||||
assert.doesNotMatch(ui, /activatedAt: null/);
|
|
||||||
assert.match(ui, /move-poi-up/);
|
assert.match(ui, /move-poi-up/);
|
||||||
assert.match(ui, /move-poi-down/);
|
assert.match(ui, /move-poi-down/);
|
||||||
assert.match(ui, /sortOrder: index/);
|
assert.match(ui, /sortOrder:\s*index/);
|
||||||
assert.match(ui, /uploadPoiMedia/);
|
assert.match(ui, /pointerup/);
|
||||||
assert.match(ui, /\/audio`/);
|
assert.doesNotMatch(ui, /sendJson\(['"]\/routes['"],\s*['"]POST['"]/);
|
||||||
assert.match(ui, /\/images`/);
|
assert.doesNotMatch(ui, /activated_at['"],\s*['"]/);
|
||||||
|
assert.doesNotMatch(ui, /activatedAt:\s*null/);
|
||||||
|
assert.doesNotMatch(ui, /addEventListener\(['"]click['"]/);
|
||||||
});
|
});
|
||||||
|
|
||||||
class FakeElement extends EventTarget {
|
class FakeElement extends EventTarget {
|
||||||
@@ -74,13 +54,25 @@ class FakeElement extends EventTarget {
|
|||||||
this.hidden = false;
|
this.hidden = false;
|
||||||
this.open = false;
|
this.open = false;
|
||||||
this.value = '';
|
this.value = '';
|
||||||
this.textContent = '';
|
|
||||||
this.className = '';
|
this.className = '';
|
||||||
this.children = [];
|
this.children = [];
|
||||||
this.dataset = {};
|
this.dataset = {};
|
||||||
this.files = [];
|
this.files = [];
|
||||||
this.parentElement = null;
|
this.parentElement = null;
|
||||||
this.attributes = new Map();
|
this.attributes = new Map();
|
||||||
|
this.disabled = false;
|
||||||
|
this.title = '';
|
||||||
|
this.type = '';
|
||||||
|
this._textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
get textContent() {
|
||||||
|
return this._textContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
set textContent(value) {
|
||||||
|
this._textContent = String(value ?? '');
|
||||||
|
if (this._textContent === '') this.children = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
append(...children) {
|
append(...children) {
|
||||||
@@ -138,24 +130,21 @@ function jsonResponse(value, ok = true, status = 200) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function textResponse(value, ok = false, status = 500) {
|
function pointerEvent(target = null, { pointerType = 'touch', button = 0, isPrimary = true } = {}) {
|
||||||
return {
|
const event = new Event('pointerup', { cancelable: true });
|
||||||
ok,
|
|
||||||
status,
|
|
||||||
text: async () => value
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pointerEvent({ pointerType = 'touch', button = 0, isPrimary = true } = {}) {
|
|
||||||
const event = new Event('pointerup', { bubbles: true, cancelable: true });
|
|
||||||
Object.defineProperties(event, {
|
Object.defineProperties(event, {
|
||||||
button: { value: button },
|
button: { value: button },
|
||||||
isPrimary: { value: isPrimary },
|
isPrimary: { value: isPrimary },
|
||||||
pointerType: { value: pointerType }
|
pointerType: { value: pointerType }
|
||||||
});
|
});
|
||||||
|
if (target) Object.defineProperty(event, 'target', { value: target });
|
||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submitEvent() {
|
||||||
|
return new Event('submit', { cancelable: true });
|
||||||
|
}
|
||||||
|
|
||||||
function buildEditorContext() {
|
function buildEditorContext() {
|
||||||
const ids = [
|
const ids = [
|
||||||
'editor-add-poi',
|
'editor-add-poi',
|
||||||
@@ -167,6 +156,7 @@ function buildEditorContext() {
|
|||||||
'editor-poi-cancel',
|
'editor-poi-cancel',
|
||||||
'editor-poi-description',
|
'editor-poi-description',
|
||||||
'editor-poi-form',
|
'editor-poi-form',
|
||||||
|
'editor-poi-form-title',
|
||||||
'editor-poi-form-view',
|
'editor-poi-form-view',
|
||||||
'editor-poi-id',
|
'editor-poi-id',
|
||||||
'editor-poi-images',
|
'editor-poi-images',
|
||||||
@@ -188,42 +178,73 @@ function buildEditorContext() {
|
|||||||
'editor-status'
|
'editor-status'
|
||||||
];
|
];
|
||||||
const elements = new Map(ids.map(id => [id, new FakeElement(id)]));
|
const elements = new Map(ids.map(id => [id, new FakeElement(id)]));
|
||||||
elements.get('editor-dialog').open = false;
|
|
||||||
elements.get('editor-route-gpx').files = [];
|
|
||||||
elements.get('editor-poi-audio').files = [];
|
|
||||||
elements.get('editor-poi-images').files = [];
|
|
||||||
|
|
||||||
const document = new EventTarget();
|
const document = new EventTarget();
|
||||||
document.readyState = 'complete';
|
document.readyState = 'complete';
|
||||||
document.getElementById = id => elements.get(id) ?? null;
|
document.getElementById = id => elements.get(id) ?? null;
|
||||||
document.createElement = tagName => new FakeElement('', tagName);
|
document.createElement = tagName => new FakeElement('', tagName);
|
||||||
|
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const routes = [
|
const saves = [];
|
||||||
{ id: 1, name: 'Waldweg', description: 'Route im Wald', activated_at: null }
|
const packages = new Map([
|
||||||
];
|
['local-existing', {
|
||||||
const routeDetail = {
|
id: 'local-existing',
|
||||||
|
gpx: '<gpx/>',
|
||||||
|
route: { id: 'local-existing', name: 'Lokaler Waldweg', description: 'Lokal' },
|
||||||
|
pois: []
|
||||||
|
}]
|
||||||
|
]);
|
||||||
|
|
||||||
|
const serverRoute = {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Waldweg',
|
name: 'Freigegebener Waldweg',
|
||||||
description: 'Route im Wald',
|
description: 'Serverroute',
|
||||||
pois: [
|
activatedAt: '2026-09-01 08:00:00',
|
||||||
{ id: 10, title: 'Start', description: 'Losgehen', lat: 50.1, lon: 10.1, radiusM: 20, sortOrder: 0 },
|
pois: [{ id: 10, title: 'Start', description: 'Los', lat: 50.1, lon: 10.1, triggerRadiusM: 20, sequence: 0 }]
|
||||||
{ id: 11, title: 'Ziel', description: 'Ankommen', lat: 50.2, lon: 10.2, radiusM: 30, sortOrder: 1 }
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function fetch(url, options = {}) {
|
async function fetch(url, options = {}) {
|
||||||
calls.push({ body: options.body, method: options.method || 'GET', url });
|
const method = options.method || 'GET';
|
||||||
if (url === '/api/routes' && (!options.method || options.method === 'GET')) return jsonResponse({ routes });
|
calls.push({ body: options.body, method, url });
|
||||||
if (url === '/api/routes/1' && (!options.method || options.method === 'GET')) return jsonResponse({ route: routeDetail });
|
if (url === '/api/routes?activatedOnly=true' && method === 'GET') return jsonResponse({ routes: [serverRoute] });
|
||||||
if (url === '/api/routes/2' && (!options.method || options.method === 'GET')) return jsonResponse({ route: { id: 2, name: 'Neue Strecke', description: 'Neu', pois: [] } });
|
if (url === '/api/routes/1' && method === 'GET') return jsonResponse({ route: serverRoute });
|
||||||
if (url === '/api/routes' && options.method === 'POST') return jsonResponse({ route: { id: 2, name: 'Neue Strecke', description: 'Neu' } });
|
if (url === '/api/routes/1' && method === 'PUT') {
|
||||||
if (url === '/api/routes/1' && options.method === 'PUT') return jsonResponse({ route: routeDetail });
|
const body = JSON.parse(options.body);
|
||||||
if (url === '/api/routes/1/pois' && options.method === 'POST') return jsonResponse({ poi: { id: 12, title: 'Neue Station' } });
|
return jsonResponse({ route: { ...serverRoute, ...body } });
|
||||||
if (/^\/api\/routes\/1\/pois\/\d+$/.test(url) && options.method === 'PUT') return jsonResponse({ poi: { id: Number(url.split('/').pop()), title: 'Station' } });
|
|
||||||
if (/^\/api\/routes\/1\/pois\/\d+\/(audio|images)$/.test(url) && options.method === 'POST') return jsonResponse({ ok: true });
|
|
||||||
return textResponse(`unexpected ${options.method || 'GET'} ${url}`, false, 404);
|
|
||||||
}
|
}
|
||||||
|
if (url === '/api/routes/1/pois' && method === 'POST') return jsonResponse({ poi: { id: 12, title: 'Neue Serverstation' } });
|
||||||
|
if (/^\/api\/routes\/1\/pois\/\d+$/.test(url) && method === 'PUT') return jsonResponse({ poi: { id: Number(url.split('/').pop()), title: 'Station' } });
|
||||||
|
if (/^\/api\/routes\/1\/pois\/\d+\/(audio|images)$/.test(url) && method === 'POST') return jsonResponse({ ok: true });
|
||||||
|
return { ok: false, status: 404, text: async () => `unexpected ${method} ${url}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocalRoutes = {
|
||||||
|
createPoiId: () => 9001,
|
||||||
|
createRouteId: () => 'local-new',
|
||||||
|
emptyGpx: name => `<gpx><metadata><name>${name}</name></metadata></gpx>`,
|
||||||
|
fileToDataUrl: async file => ({
|
||||||
|
dataUrl: `data:${file.type || 'application/octet-stream'};base64,TEST`,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size || 1,
|
||||||
|
type: file.type || 'application/octet-stream'
|
||||||
|
}),
|
||||||
|
get: id => packages.get(String(id)) || null,
|
||||||
|
list: () => [...packages.values()],
|
||||||
|
parseWaypoints: () => [],
|
||||||
|
readFileAsText: file => file.text(),
|
||||||
|
save: input => {
|
||||||
|
const id = String(input.id);
|
||||||
|
const now = '2026-09-19T14:00:00.000Z';
|
||||||
|
const saved = {
|
||||||
|
id,
|
||||||
|
gpx: input.gpx,
|
||||||
|
route: { ...input.route, id, updatedAt: now },
|
||||||
|
pois: input.pois.map((poi, sequence) => ({ ...poi, sequence }))
|
||||||
|
};
|
||||||
|
packages.set(id, saved);
|
||||||
|
saves.push(structuredClone(saved));
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
document,
|
document,
|
||||||
@@ -232,134 +253,96 @@ function buildEditorContext() {
|
|||||||
fetch,
|
fetch,
|
||||||
FormData: FakeFormData,
|
FormData: FakeFormData,
|
||||||
globalThis: null,
|
globalThis: null,
|
||||||
|
structuredClone,
|
||||||
Wegwichtel: {
|
Wegwichtel: {
|
||||||
Config: { apiBase: '/api/' }
|
App: { currentPosition: () => ({ lat: 50.5, lon: 10.5 }) },
|
||||||
},
|
Config: { apiBase: '/api/' },
|
||||||
WegwichtelEditorTestHooks: true
|
LocalRoutes
|
||||||
|
}
|
||||||
};
|
};
|
||||||
context.globalThis = context;
|
context.globalThis = context;
|
||||||
return { calls, context, elements };
|
return { calls, context, elements, packages, saves };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('route editor workflow is executable with draft routes and media uploads', async () => {
|
test('public editor workflow saves new route and GPX POI locally without POSTing a route', async () => {
|
||||||
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
||||||
const source = await fs.readFile(sourcePath, 'utf8');
|
const source = await fs.readFile(sourcePath, 'utf8');
|
||||||
const { calls, context, elements } = buildEditorContext();
|
const { calls, context, elements, packages, saves } = buildEditorContext();
|
||||||
|
|
||||||
vm.runInNewContext(source, context, { filename: sourcePath });
|
vm.runInNewContext(source, context, { filename: sourcePath });
|
||||||
const hooks = context.Wegwichtel.EditorUi.testHooks;
|
|
||||||
|
|
||||||
assert.deepEqual(context.Wegwichtel.EditorUi.classNames(), hooks.classNames());
|
|
||||||
assert.equal(hooks.apiUrl('/routes'), '/api/routes');
|
|
||||||
assert.equal(hooks.isActivation(pointerEvent()), true);
|
|
||||||
assert.equal(hooks.isActivation(pointerEvent({ pointerType: 'mouse', button: 1 })), false);
|
|
||||||
|
|
||||||
await context.Wegwichtel.EditorUi.open();
|
await context.Wegwichtel.EditorUi.open();
|
||||||
assert.equal(elements.get('editor-dialog').open, true);
|
assert.equal(elements.get('editor-dialog').open, true);
|
||||||
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'true');
|
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'true');
|
||||||
assert.equal(elements.get('editor-route-list-view').hidden, false);
|
assert.ok(calls.some(call => call.method === 'GET' && call.url === '/api/routes?activatedOnly=true'));
|
||||||
assert.equal(elements.get('editor-route-detail-view').hidden, true);
|
|
||||||
assert.equal(elements.get('editor-route-list').children.length, 1);
|
|
||||||
|
|
||||||
await hooks.loadRoute({ id: 1 });
|
await context.Wegwichtel.EditorUi.openNewLocalRoute();
|
||||||
assert.equal(elements.get('editor-route-id').value, '1');
|
elements.get('editor-route-name').value = 'Neue lokale Strecke';
|
||||||
assert.equal(elements.get('editor-route-name').value, 'Waldweg');
|
elements.get('editor-route-description').value = 'Nur im Browser';
|
||||||
assert.match(elements.get('editor-route-activated').textContent, /Nicht veröffentlicht/);
|
elements.get('editor-route-form').dispatchEvent(submitEvent());
|
||||||
assert.equal(elements.get('editor-poi-list').children.length, 2);
|
await tick();
|
||||||
|
await tick();
|
||||||
|
|
||||||
hooks.swapPois(1, -1);
|
assert.ok(packages.has('local-new'));
|
||||||
assert.equal(hooks.state.pois[0].id, 11);
|
assert.equal(saves.at(-1).route.name, 'Neue lokale Strecke');
|
||||||
await hooks.savePoiOrder();
|
assert.equal(calls.some(call => call.method === 'POST' && call.url === '/api/routes'), false);
|
||||||
assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/1/pois/11'));
|
assert.match(elements.get('editor-route-activated').textContent, /Lokaler Entwurf/);
|
||||||
|
|
||||||
hooks.newRoute();
|
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
|
||||||
elements.get('editor-route-name').value = 'Neue Strecke';
|
elements.get('editor-poi-title').value = 'Aussicht';
|
||||||
elements.get('editor-route-description').value = 'Neu';
|
elements.get('editor-poi-description').value = 'Text zur Station';
|
||||||
await hooks.saveRoute();
|
elements.get('editor-poi-lat').value = '50.51';
|
||||||
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes'));
|
elements.get('editor-poi-lon').value = '10.52';
|
||||||
|
elements.get('editor-poi-radius').value = '35';
|
||||||
|
elements.get('editor-poi-audio').files = [{ name: 'aussicht.mp3', size: 5, type: 'audio/mpeg' }];
|
||||||
|
elements.get('editor-poi-images').files = [{ name: 'aussicht.jpg', size: 7, type: 'image/jpeg' }];
|
||||||
|
elements.get('editor-poi-form').dispatchEvent(submitEvent());
|
||||||
|
await tick();
|
||||||
|
await tick();
|
||||||
|
|
||||||
await hooks.loadRoute({ id: 1 });
|
const local = packages.get('local-new');
|
||||||
hooks.newPoi();
|
assert.equal(local.pois.length, 1);
|
||||||
elements.get('editor-poi-title').value = 'Neue Station';
|
assert.equal(local.pois[0].title, 'Aussicht');
|
||||||
elements.get('editor-poi-description').value = 'Text';
|
assert.equal(local.pois[0].lat, 50.51);
|
||||||
elements.get('editor-poi-lat').value = '50.123';
|
assert.equal(local.pois[0].lon, 10.52);
|
||||||
elements.get('editor-poi-lon').value = '10.456';
|
assert.equal(local.pois[0].triggerRadiusM, 35);
|
||||||
elements.get('editor-poi-radius').value = '42';
|
assert.match(local.pois[0].audio.dataUrl, /^data:audio\/mpeg/);
|
||||||
elements.get('editor-poi-audio').files = [{ name: 'station.mp3' }];
|
assert.equal(local.pois[0].images.length, 1);
|
||||||
elements.get('editor-poi-images').files = [{ name: 'eins.jpg' }, { name: 'zwei.jpg' }];
|
assert.match(local.pois[0].images[0].dataUrl, /^data:image\/jpeg/);
|
||||||
await hooks.savePoi();
|
|
||||||
|
|
||||||
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/1/pois'));
|
|
||||||
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/1/pois/12/audio'));
|
|
||||||
assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes/1/pois/12/images').length, 2);
|
|
||||||
|
|
||||||
context.Wegwichtel.EditorUi.close();
|
context.Wegwichtel.EditorUi.close();
|
||||||
assert.equal(elements.get('editor-dialog').open, false);
|
assert.equal(elements.get('editor-dialog').open, false);
|
||||||
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false');
|
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('editing an activated server route uses PUT and never writes activation state', async () => {
|
||||||
test('route editor test hooks cover fallback payloads, errors and form branches', async () => {
|
|
||||||
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
const sourcePath = path.join(root, 'public/js/editor-ui.js');
|
||||||
const source = await fs.readFile(sourcePath, 'utf8');
|
const source = await fs.readFile(sourcePath, 'utf8');
|
||||||
const { calls, context, elements } = buildEditorContext();
|
const { calls, context, elements } = buildEditorContext();
|
||||||
|
|
||||||
vm.runInNewContext(source, context, { filename: sourcePath });
|
vm.runInNewContext(source, context, { filename: sourcePath });
|
||||||
const hooks = context.Wegwichtel.EditorUi.testHooks;
|
await context.Wegwichtel.EditorUi.open();
|
||||||
|
|
||||||
assert.equal(hooks.routeArray([{ id: 'x' }]).length, 1);
|
const routeList = elements.get('editor-route-list');
|
||||||
assert.equal(hooks.routeArray({ items: [{ id: 'y' }] })[0].id, 'y');
|
const serverItem = routeList.children.find(child => child.dataset.routeSource === 'server');
|
||||||
assert.equal(hooks.routeArray(null).length, 0);
|
const editButton = serverItem.children[1].children[0];
|
||||||
assert.equal(hooks.poiArray({ pointsOfInterest: [{ id: 3 }] })[0].id, 3);
|
routeList.dispatchEvent(pointerEvent(editButton));
|
||||||
assert.equal(hooks.poiArray({}).length, 0);
|
await tick();
|
||||||
|
await tick();
|
||||||
|
|
||||||
const unnamedRoute = hooks.routeItem({ id: 5 });
|
assert.equal(elements.get('editor-route-id').value, '1');
|
||||||
assert.equal(unnamedRoute.dataset.routeId, '5');
|
assert.match(elements.get('editor-route-activated').textContent, /Veröffentlicht am/);
|
||||||
assert.equal(unnamedRoute.children[0].children[0].textContent, 'Unbenannte Strecke');
|
|
||||||
hooks.state.pois = [{ id: 6 }, { id: 7 }];
|
|
||||||
const unnamedPoi = hooks.poiItem({ id: 6 }, 0);
|
|
||||||
assert.equal(unnamedPoi.dataset.poiId, '6');
|
|
||||||
assert.equal(unnamedPoi.children[0].children[0].textContent, 'Unbenannte Station');
|
|
||||||
assert.equal(unnamedPoi.children[1].children[0].disabled, true);
|
|
||||||
assert.equal(unnamedPoi.children[1].children[1].disabled, false);
|
|
||||||
|
|
||||||
hooks.setStatus('Fehler', true);
|
elements.get('editor-route-name').value = 'Freigegebener Waldweg geändert';
|
||||||
assert.equal(elements.get('editor-status').hidden, false);
|
elements.get('editor-route-description').value = 'Bearbeitet';
|
||||||
assert.equal(elements.get('editor-status').dataset.kind, 'error');
|
elements.get('editor-route-form').dispatchEvent(submitEvent());
|
||||||
hooks.setStatus('');
|
await tick();
|
||||||
assert.equal(elements.get('editor-status').hidden, true);
|
await tick();
|
||||||
|
|
||||||
assert.equal(Object.keys(await hooks.parseJson({ text: async () => '' })).length, 0);
|
const update = calls.find(call => call.method === 'PUT' && call.url === '/api/routes/1');
|
||||||
assert.equal(Object.keys(await hooks.parseJson({ text: async () => 'kein json' })).length, 0);
|
assert.ok(update);
|
||||||
await assert.rejects(
|
const body = JSON.parse(update.body);
|
||||||
hooks.handleResponse({ ok: false, status: 418, text: async () => '' }),
|
assert.deepEqual(Object.keys(body).sort(), ['description', 'name']);
|
||||||
/HTTP 418/
|
assert.equal('activated_at' in body, false);
|
||||||
);
|
assert.equal('activatedAt' in body, false);
|
||||||
|
|
||||||
await hooks.loadRoute({ id: 1 });
|
|
||||||
elements.get('editor-route-id').value = '1';
|
|
||||||
elements.get('editor-route-name').value = 'Waldweg geändert';
|
|
||||||
elements.get('editor-route-description').value = 'Mit GPX';
|
|
||||||
assert.deepEqual(Object.keys(hooks.routeFromForm()).sort(), ['description', 'name']);
|
|
||||||
elements.get('editor-route-gpx').files = [{ name: 'route.gpx' }];
|
|
||||||
await hooks.saveRoute();
|
|
||||||
const gpxUpdate = calls.find(call => call.method === 'PUT' && call.url === '/api/routes/1' && call.body instanceof FakeFormData);
|
|
||||||
assert.ok(gpxUpdate);
|
|
||||||
assert.equal(gpxUpdate.body.entries.some(([name]) => name === 'activated_at'), false);
|
|
||||||
|
|
||||||
elements.get('editor-poi-id').value = '10';
|
|
||||||
elements.get('editor-poi-title').value = 'Start bearbeitet';
|
|
||||||
elements.get('editor-poi-description').value = 'Text';
|
|
||||||
elements.get('editor-poi-lat').value = '50.5';
|
|
||||||
elements.get('editor-poi-lon').value = '10.5';
|
|
||||||
elements.get('editor-poi-radius').value = '';
|
|
||||||
elements.get('editor-poi-audio').files = [];
|
|
||||||
elements.get('editor-poi-images').files = [];
|
|
||||||
await hooks.savePoi();
|
|
||||||
assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/1/pois/10'));
|
|
||||||
|
|
||||||
assert.equal(elements.get('editor-route-list-view').hidden, true);
|
|
||||||
elements.get('editor-route-back').dispatchEvent(pointerEvent());
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 0));
|
|
||||||
assert.equal(elements.get('editor-route-list-view').hidden, false);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '..');
|
||||||
|
const GPX_NAMESPACE = 'http://www.topografix.com/GPX/1/1';
|
||||||
|
const POI_NAMESPACE = 'urn:wegwichtel:poi';
|
||||||
|
|
||||||
|
function escapeXml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeXml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll(''', "'")
|
||||||
|
.replaceAll('&', '&');
|
||||||
|
}
|
||||||
|
|
||||||
|
class XmlElement {
|
||||||
|
constructor(namespaceURI, tagName) {
|
||||||
|
this.namespaceURI = namespaceURI;
|
||||||
|
this.tagName = tagName;
|
||||||
|
this.localName = tagName.includes(':') ? tagName.split(':').at(-1) : tagName;
|
||||||
|
this.attributes = new Map();
|
||||||
|
this.children = [];
|
||||||
|
this.parentNode = null;
|
||||||
|
this._text = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
get firstChild() {
|
||||||
|
return this.children[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get textContent() {
|
||||||
|
return this._text + this.children.map(child => child.textContent).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
set textContent(value) {
|
||||||
|
this.children = [];
|
||||||
|
this._text = String(value ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
appendChild(child) {
|
||||||
|
child.parentNode = this;
|
||||||
|
this.children.push(child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
insertBefore(child, reference) {
|
||||||
|
child.parentNode = this;
|
||||||
|
const index = reference ? this.children.indexOf(reference) : -1;
|
||||||
|
if (index < 0) this.children.push(child);
|
||||||
|
else this.children.splice(index, 0, child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove() {
|
||||||
|
if (!this.parentNode) return;
|
||||||
|
this.parentNode.children = this.parentNode.children.filter(child => child !== this);
|
||||||
|
this.parentNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttribute(name, value) {
|
||||||
|
this.attributes.set(name, String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
getAttribute(name) {
|
||||||
|
return this.attributes.has(name) ? this.attributes.get(name) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getElementsByTagName(name) {
|
||||||
|
const result = [];
|
||||||
|
for (const child of this.children) {
|
||||||
|
if (name === '*' || child.tagName === name || child.localName === name) result.push(child);
|
||||||
|
result.push(...child.getElementsByTagName(name));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
getElementsByTagNameNS(namespaceURI, localName) {
|
||||||
|
const result = [];
|
||||||
|
for (const child of this.children) {
|
||||||
|
const namespaceMatches = namespaceURI === '*' || child.namespaceURI === namespaceURI;
|
||||||
|
if (namespaceMatches && child.localName === localName) result.push(child);
|
||||||
|
result.push(...child.getElementsByTagNameNS(namespaceURI, localName));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class XmlDocument {
|
||||||
|
constructor(rootElement = null) {
|
||||||
|
this.documentElement = rootElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
querySelector(selector) {
|
||||||
|
return selector === 'parsererror' ? null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
createElementNS(namespaceURI, tagName) {
|
||||||
|
return new XmlElement(namespaceURI, tagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
getElementsByTagName(name) {
|
||||||
|
if (!this.documentElement) return [];
|
||||||
|
const own = name === '*' || this.documentElement.tagName === name || this.documentElement.localName === name
|
||||||
|
? [this.documentElement]
|
||||||
|
: [];
|
||||||
|
return [...own, ...this.documentElement.getElementsByTagName(name)];
|
||||||
|
}
|
||||||
|
|
||||||
|
getElementsByTagNameNS(namespaceURI, localName) {
|
||||||
|
if (!this.documentElement) return [];
|
||||||
|
const ownNamespaceMatches = namespaceURI === '*' || this.documentElement.namespaceURI === namespaceURI;
|
||||||
|
const own = ownNamespaceMatches && this.documentElement.localName === localName ? [this.documentElement] : [];
|
||||||
|
return [...own, ...this.documentElement.getElementsByTagNameNS(namespaceURI, localName)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeDOMParser {
|
||||||
|
parseFromString(xml) {
|
||||||
|
const tokens = String(xml)
|
||||||
|
.replace(/<\?xml[\s\S]*?\?>/gi, '')
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, '')
|
||||||
|
.match(/<[^>]+>|[^<]+/g) || [];
|
||||||
|
const stack = [];
|
||||||
|
let rootElement = null;
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (!token.trim()) continue;
|
||||||
|
if (token.startsWith('</')) {
|
||||||
|
stack.pop();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (token.startsWith('<!')) continue;
|
||||||
|
if (!token.startsWith('<')) {
|
||||||
|
if (stack.length) stack.at(-1)._text += decodeXml(token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selfClosing = /\/\s*>$/.test(token);
|
||||||
|
const match = token.match(/^<\s*([^\s/>]+)([\s\S]*?)\/?\s*>$/);
|
||||||
|
if (!match) return new XmlDocument(new XmlElement('', 'parsererror'));
|
||||||
|
const [, tagName, attributeText] = match;
|
||||||
|
const namespaceURI = tagName.startsWith('wegwichtel:') ? POI_NAMESPACE : GPX_NAMESPACE;
|
||||||
|
const element = new XmlElement(namespaceURI, tagName);
|
||||||
|
const attributePattern = /([^\s=]+)\s*=\s*(["'])(.*?)\2/g;
|
||||||
|
for (const attribute of attributeText.matchAll(attributePattern)) {
|
||||||
|
element.setAttribute(attribute[1], decodeXml(attribute[3]));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stack.length) stack.at(-1).appendChild(element);
|
||||||
|
else if (!rootElement) rootElement = element;
|
||||||
|
else return new XmlDocument(new XmlElement('', 'parsererror'));
|
||||||
|
if (!selfClosing) stack.push(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rootElement || stack.length) return new XmlDocument(new XmlElement('', 'parsererror'));
|
||||||
|
return new XmlDocument(rootElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeElement(element) {
|
||||||
|
const attributes = [...element.attributes]
|
||||||
|
.map(([name, value]) => ` ${name}="${escapeXml(value)}"`)
|
||||||
|
.join('');
|
||||||
|
const content = escapeXml(element._text) + element.children.map(serializeElement).join('');
|
||||||
|
return `<${element.tagName}${attributes}>${content}</${element.tagName}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeXMLSerializer {
|
||||||
|
serializeToString(document) {
|
||||||
|
return serializeElement(document.documentElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeStorage {
|
||||||
|
constructor() {
|
||||||
|
this.values = new Map();
|
||||||
|
this.failNext = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getItem(key) {
|
||||||
|
return this.values.has(key) ? this.values.get(key) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setItem(key, value) {
|
||||||
|
if (this.failNext) {
|
||||||
|
this.failNext = false;
|
||||||
|
const error = new Error('quota');
|
||||||
|
error.name = 'QuotaExceededError';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
this.values.set(String(key), String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
removeItem(key) {
|
||||||
|
this.values.delete(String(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeCustomEvent extends Event {
|
||||||
|
constructor(type, options = {}) {
|
||||||
|
super(type);
|
||||||
|
this.detail = options.detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeFileReader extends EventTarget {
|
||||||
|
readAsText(file) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
this.result = file.content || '';
|
||||||
|
this.dispatchEvent(new Event('load'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
readAsDataURL(file) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const type = file.type || 'application/octet-stream';
|
||||||
|
this.result = `data:${type};base64,VEVTVA==`;
|
||||||
|
this.dispatchEvent(new Event('load'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLocalRoutes() {
|
||||||
|
const sourcePath = path.join(root, 'public/js/local-routes.js');
|
||||||
|
const source = await fs.readFile(sourcePath, 'utf8');
|
||||||
|
const localStorage = new FakeStorage();
|
||||||
|
const document = new EventTarget();
|
||||||
|
const events = [];
|
||||||
|
document.addEventListener('wegwichtel:local-routes-changed', event => events.push(event.detail));
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
CustomEvent: FakeCustomEvent,
|
||||||
|
DOMParser: FakeDOMParser,
|
||||||
|
Event,
|
||||||
|
EventTarget,
|
||||||
|
FileReader: FakeFileReader,
|
||||||
|
XMLSerializer: FakeXMLSerializer,
|
||||||
|
crypto: { randomUUID: () => '11111111-2222-4333-8444-555555555555' },
|
||||||
|
document,
|
||||||
|
globalThis: null,
|
||||||
|
localStorage,
|
||||||
|
queueMicrotask,
|
||||||
|
Wegwichtel: {}
|
||||||
|
};
|
||||||
|
context.globalThis = context;
|
||||||
|
vm.runInNewContext(source, context, { filename: sourcePath });
|
||||||
|
return { api: context.Wegwichtel.LocalRoutes, events, localStorage };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleGpx = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<gpx version="1.1" creator="Test" xmlns="${GPX_NAMESPACE}" xmlns:wegwichtel="${POI_NAMESPACE}">
|
||||||
|
<metadata><name>Alter Name</name></metadata>
|
||||||
|
<wpt lat="50.1000" lon="10.1000"><name>Import</name><extensions><wegwichtel:poiId>77</wegwichtel:poiId></extensions></wpt>
|
||||||
|
<trk><name>Weg</name><trkseg>
|
||||||
|
<trkpt lat="50.1000" lon="10.1000"><ele>100</ele></trkpt>
|
||||||
|
<trkpt lat="50.1010" lon="10.1020"><ele>112</ele></trkpt>
|
||||||
|
</trkseg></trk>
|
||||||
|
</gpx>`;
|
||||||
|
|
||||||
|
test('local route package stores GPX and JSON separately and reconstructs an app route', async () => {
|
||||||
|
const { api, events, localStorage } = await loadLocalRoutes();
|
||||||
|
const routeId = api.createRouteId();
|
||||||
|
assert.equal(routeId, 'local-11111111-2222-4333-8444-555555555555');
|
||||||
|
|
||||||
|
const saved = api.save({
|
||||||
|
id: routeId,
|
||||||
|
gpx: sampleGpx,
|
||||||
|
route: { id: routeId, name: 'Neue Route', description: 'Lokal gespeichert' },
|
||||||
|
pois: [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
title: 'Start',
|
||||||
|
description: 'Erster POI',
|
||||||
|
lat: 50.1,
|
||||||
|
lon: 10.1,
|
||||||
|
triggerRadiusM: 30,
|
||||||
|
audio: { dataUrl: 'data:audio/mpeg;base64,QQ==', name: 'start.mp3' },
|
||||||
|
images: [{ id: 'img-1', dataUrl: 'data:image/jpeg;base64,Qg==', caption: 'Bild' }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 102,
|
||||||
|
title: 'Ziel',
|
||||||
|
description: 'Zweiter POI',
|
||||||
|
lat: 50.101,
|
||||||
|
lon: 10.102,
|
||||||
|
triggerRadiusM: 25,
|
||||||
|
images: []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(saved.route.name, 'Neue Route');
|
||||||
|
assert.equal(saved.pois.length, 2);
|
||||||
|
assert.equal(events.at(-1).action, 'saved');
|
||||||
|
assert.equal(events.at(-1).id, routeId);
|
||||||
|
|
||||||
|
const prefix = api.STORAGE_PREFIX;
|
||||||
|
const encodedId = encodeURIComponent(routeId);
|
||||||
|
const gpx = localStorage.getItem(`${prefix}.${encodedId}.gpx`);
|
||||||
|
const metadata = JSON.parse(localStorage.getItem(`${prefix}.${encodedId}.json`));
|
||||||
|
|
||||||
|
assert.match(gpx, /<wpt lat="50\.1" lon="10\.1">/);
|
||||||
|
assert.match(gpx, /<wegwichtel:poiId>101<\/wegwichtel:poiId>/);
|
||||||
|
assert.ok(gpx.indexOf('<wpt') < gpx.indexOf('<trk>'), 'GPX waypoints must be serialized before the track');
|
||||||
|
assert.equal(metadata.route.name, 'Neue Route');
|
||||||
|
assert.equal(metadata.pois[0].audio.name, 'start.mp3');
|
||||||
|
assert.equal('gpx' in metadata, false);
|
||||||
|
assert.equal('_source' in metadata.route, false);
|
||||||
|
|
||||||
|
const appRoute = api.getAppRoute(routeId);
|
||||||
|
assert.equal(appRoute.source, 'local');
|
||||||
|
assert.equal(appRoute.local, true);
|
||||||
|
assert.equal(appRoute.pois.length, 2);
|
||||||
|
assert.equal(appRoute.pois[0].audioUrl, 'data:audio/mpeg;base64,QQ==');
|
||||||
|
assert.equal(appRoute.pois[0].images[0].url, 'data:image/jpeg;base64,Qg==');
|
||||||
|
assert.equal(appRoute.points.length, 2);
|
||||||
|
assert.ok(appRoute.distanceM > 0);
|
||||||
|
assert.equal(Math.round(appRoute.elevationGainM), 12);
|
||||||
|
const listed = api.listAppRoutes();
|
||||||
|
assert.equal(listed.length, 1);
|
||||||
|
assert.equal(listed[0].id, routeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GPX waypoint import reads stable POI ids and validates coordinates', async () => {
|
||||||
|
const { api } = await loadLocalRoutes();
|
||||||
|
const imported = api.parseWaypoints(sampleGpx);
|
||||||
|
|
||||||
|
assert.equal(imported.length, 1);
|
||||||
|
assert.equal(imported[0].id, 77);
|
||||||
|
assert.equal(imported[0].title, 'Import');
|
||||||
|
assert.equal(imported[0].lat, 50.1);
|
||||||
|
assert.equal(imported[0].lon, 10.1);
|
||||||
|
|
||||||
|
assert.match(api.emptyGpx('A & B'), /<name>A & B<\/name>/);
|
||||||
|
assert.throws(() => api.parseWaypoints('<not-gpx></not-gpx>'), /GPX-Datei ist nicht gültig/);
|
||||||
|
assert.throws(() => api.syncGpx(sampleGpx, { name: 'Fehler' }, [{ id: 1, title: 'Ohne Position' }]), /gültige Koordinaten/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remove updates index and dispatches a local-route change event', async () => {
|
||||||
|
const { api, events } = await loadLocalRoutes();
|
||||||
|
api.save({
|
||||||
|
id: 'local-remove',
|
||||||
|
gpx: sampleGpx,
|
||||||
|
route: { name: 'Entfernen' },
|
||||||
|
pois: []
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(api.list().length, 1);
|
||||||
|
api.remove('local-remove');
|
||||||
|
assert.equal(api.list().length, 0);
|
||||||
|
assert.equal(events.at(-1).action, 'removed');
|
||||||
|
assert.equal(events.at(-1).id, 'local-remove');
|
||||||
|
assert.throws(() => api.getAppRoute('local-remove'), /nicht gefunden/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quota failure rolls back package writes and keeps the previous package readable', async () => {
|
||||||
|
const { api, localStorage } = await loadLocalRoutes();
|
||||||
|
api.save({
|
||||||
|
id: 'local-quota',
|
||||||
|
gpx: sampleGpx,
|
||||||
|
route: { name: 'Vorher' },
|
||||||
|
pois: []
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.failNext = true;
|
||||||
|
assert.throws(() => api.save({
|
||||||
|
id: 'local-quota',
|
||||||
|
gpx: sampleGpx,
|
||||||
|
route: { name: 'Nachher' },
|
||||||
|
pois: []
|
||||||
|
}), /Browser-Speicher ist voll/);
|
||||||
|
|
||||||
|
assert.equal(api.get('local-quota').route.name, 'Vorher');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('file helpers support native text and FileReader-backed media data URLs', async () => {
|
||||||
|
const { api } = await loadLocalRoutes();
|
||||||
|
|
||||||
|
assert.equal(await api.readFileAsText({ text: async () => '<gpx></gpx>' }), '<gpx></gpx>');
|
||||||
|
assert.equal(await api.readFileAsText(null), '');
|
||||||
|
|
||||||
|
const media = await api.fileToDataUrl({ name: 'ton.mp3', size: 4, type: 'audio/mpeg' });
|
||||||
|
assert.equal(media.name, 'ton.mp3');
|
||||||
|
assert.equal(media.size, 4);
|
||||||
|
assert.equal(media.type, 'audio/mpeg');
|
||||||
|
assert.match(media.dataUrl, /^data:audio\/mpeg;base64,/);
|
||||||
|
assert.equal(await api.fileToDataUrl(null), null);
|
||||||
|
});
|
||||||
@@ -9,42 +9,76 @@ async function read(relativePath) {
|
|||||||
return fs.readFile(path.join(root, relativePath), 'utf8');
|
return fs.readFile(path.join(root, relativePath), 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
test('start page keeps nearby recommendations and the complete route list', async () => {
|
test('main screen contains a persistent route control next to the information control', async () => {
|
||||||
const html = await read('public/index.html');
|
const html = await read('public/index.html');
|
||||||
const app = await read('public/js/app.js');
|
const css = await read('public/css/editor.css');
|
||||||
|
|
||||||
assert.match(html, /id="nearby-route-list"/);
|
assert.match(html, /class="route-control"/);
|
||||||
assert.match(html, /id="all-route-list"/);
|
assert.match(html, /id="route-control-button"/);
|
||||||
assert.match(html, /id="route-search"/);
|
assert.match(html, /id="route-control-dropdown"/);
|
||||||
assert.match(app, /renderRouteItems\('#nearby-route-list'/);
|
assert.match(html, /id="route-control-title"/);
|
||||||
assert.match(app, /renderRouteItems\(\s*'#all-route-list'/);
|
assert.match(html, /id="route-control-next"/);
|
||||||
assert.doesNotMatch(app, /state\.routes\s*=\s*state\.routes[\s\S]*?\.filter\(route => route\.proximityM/);
|
assert.match(html, /id="route-control-server-list"/);
|
||||||
|
assert.match(html, /id="route-control-local-list"/);
|
||||||
|
assert.match(html, /id="route-control-new"/);
|
||||||
|
assert.match(html, /id="info-button"/);
|
||||||
|
|
||||||
|
assert.match(css, /\.route-control\s*\{[\s\S]*position:\s*fixed/i);
|
||||||
|
assert.match(css, /\.route-control\s*\{[\s\S]*right:\s*calc\([\s\S]*--icon-button-size/i);
|
||||||
|
assert.match(css, /\.route-control-dropdown/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('route selection renders independently from geolocation and restores after mobile page resume', async () => {
|
test('route selection separates activated server routes from local browser routes', async () => {
|
||||||
const html = await read('public/index.html');
|
const html = await read('public/index.html');
|
||||||
const app = await read('public/js/app.js');
|
const app = await read('public/js/app.js');
|
||||||
const loader = await read('public/js/bootstrap-loader.js');
|
const client = await read('public/js/api-client.js');
|
||||||
const css = await read('public/css/app.css');
|
|
||||||
const server = await read('server.js');
|
|
||||||
const api = await read('src/routes/api.js');
|
const api = await read('src/routes/api.js');
|
||||||
|
|
||||||
assert.match(html, /vendor\/jquery-ui\/jquery-ui-1\.14\.2\.min\.css\?v=0\.12\.14/);
|
assert.match(html, /id="all-route-list"/);
|
||||||
assert.match(html, /css\/app\.css\?v=0\.12\.14/);
|
assert.match(html, /id="local-route-list"/);
|
||||||
assert.match(html, /id="all-routes-summary"[^>]*>Routen werden geladen/);
|
assert.match(html, />Lokale Strecken</);
|
||||||
assert.doesNotMatch(loader, /loadStyle\(/);
|
|
||||||
assert.doesNotMatch(css, /:has\(/);
|
|
||||||
assert.match(html, /<header class="has-back-button">/);
|
|
||||||
|
|
||||||
const firstRender = app.indexOf('renderRoutes();', app.indexOf('async initialize(context)'));
|
assert.match(client, /activatedOnly:\s*['"]true['"]/);
|
||||||
const locationStart = app.indexOf('await initializeLocation(context);', app.indexOf('async initialize(context)'));
|
assert.match(api, /publishedOnly:\s*req\.query\.activatedOnly\s*===\s*['"]true['"]/);
|
||||||
assert.ok(firstRender >= 0 && locationStart > firstRender, 'Routen müssen vor der Standortabfrage gerendert werden.');
|
assert.match(app, /renderRouteItems\(\s*['"]#local-route-list['"]/);
|
||||||
assert.match(app, /globalThis\.addEventListener\('pageshow', restoreRouteLists\)/);
|
assert.match(app, /renderRouteControlList\(\s*['"]#route-control-local-list['"]/);
|
||||||
assert.match(app, /document\.visibilityState === 'visible'/);
|
assert.match(app, /ns\.LocalRoutes\.getAppRoute\(id\)/);
|
||||||
assert.match(app, /Array\.isArray\(response\?\.routes\)/);
|
assert.match(app, /\[data-route-select\]/);
|
||||||
assert.match(app, /throw new TypeError\('Die Routen-API hat keine gültige Routenliste geliefert\.'\)/);
|
});
|
||||||
assert.match(app, /route\?\.id != null && route\?\.name != null/);
|
|
||||||
assert.doesNotMatch(app, /route && route\.id/);
|
test('persistent route control displays the next unvisited POI and preserves progress when switching', async () => {
|
||||||
assert.match(server, /no-cache, no-store, must-revalidate/);
|
const app = await read('public/js/app.js');
|
||||||
assert.match(api, /api\.use\(\(req, res, next\)/);
|
|
||||||
|
assert.match(app, /function nextPoiMetric\(/);
|
||||||
|
assert.match(app, /Nächster POI:/);
|
||||||
|
assert.match(app, /state\.triggered\.has\(poi\.id\)/);
|
||||||
|
assert.match(app, /function suspendCurrentRoute\(/);
|
||||||
|
assert.match(app, /persistRouteProgress\(\)/);
|
||||||
|
assert.match(app, /await selectRoute/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('local route storage is loaded independently from optional route progress storage', async () => {
|
||||||
|
const html = await read('public/index.html');
|
||||||
|
const app = await read('public/js/app.js');
|
||||||
|
const localRoutes = await read('public/js/local-routes.js');
|
||||||
|
|
||||||
|
const localScript = html.indexOf('js/local-routes.js');
|
||||||
|
const editorScript = html.indexOf('js/editor-ui.js');
|
||||||
|
assert.ok(localScript >= 0 && editorScript > localScript, 'local route storage must load before the editor');
|
||||||
|
|
||||||
|
assert.match(localRoutes, /wegwichtel\.local-routes\.v1/);
|
||||||
|
assert.match(localRoutes, /routeKey\(id, ['"]gpx['"]\)/);
|
||||||
|
assert.match(localRoutes, /routeKey\(id, ['"]json['"]\)/);
|
||||||
|
assert.match(localRoutes, /wegwichtel:local-routes-changed/);
|
||||||
|
assert.match(app, /wegwichtel:local-routes-changed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('route selection remains pointer-oriented and avoids unsupported relational selectors', async () => {
|
||||||
|
const app = await read('public/js/app.js');
|
||||||
|
const editor = await read('public/js/editor-ui.js');
|
||||||
|
|
||||||
|
assert.match(app, /pointerup/);
|
||||||
|
assert.match(editor, /pointerup/);
|
||||||
|
assert.doesNotMatch(editor, /addEventListener\(['"]click['"]/);
|
||||||
|
assert.doesNotMatch(app, /:has\(/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
|
import { config } from '../src/config.js';
|
||||||
|
|
||||||
|
const databasePath = path.join(config.dataDir, 'wegwichtel.sqlite');
|
||||||
|
const activeRoutesDirectory = path.join(config.storageDir, 'active', 'routes');
|
||||||
|
const trashRoutesDirectory = path.join(config.storageDir, 'trash', 'routes');
|
||||||
|
|
||||||
|
const EXPECTED_SCHEMA = Object.freeze({
|
||||||
|
routes: {
|
||||||
|
id: { type: 'INTEGER', pk: 1 },
|
||||||
|
slug: { type: 'TEXT', notnull: 1 },
|
||||||
|
name: { type: 'TEXT', notnull: 1 },
|
||||||
|
status: { type: 'TEXT', notnull: 1 },
|
||||||
|
gpx_path: { type: 'TEXT', notnull: 1 },
|
||||||
|
point_count: { type: 'INTEGER', notnull: 1 },
|
||||||
|
created_at: { type: 'TEXT', notnull: 1 },
|
||||||
|
updated_at: { type: 'TEXT', notnull: 1 },
|
||||||
|
deleted_at: { type: 'TEXT' },
|
||||||
|
activated_at: { type: 'TEXT' },
|
||||||
|
trash_path: { type: 'TEXT' }
|
||||||
|
},
|
||||||
|
route_points: {
|
||||||
|
id: { type: 'INTEGER', pk: 1 },
|
||||||
|
route_id: { type: 'INTEGER', notnull: 1 },
|
||||||
|
sequence: { type: 'INTEGER', notnull: 1 },
|
||||||
|
lat: { type: 'REAL', notnull: 1 },
|
||||||
|
lon: { type: 'REAL', notnull: 1 }
|
||||||
|
},
|
||||||
|
pois: {
|
||||||
|
id: { type: 'INTEGER', pk: 1 },
|
||||||
|
route_id: { type: 'INTEGER', notnull: 1 },
|
||||||
|
title: { type: 'TEXT', notnull: 1 },
|
||||||
|
lat: { type: 'REAL', notnull: 1 },
|
||||||
|
lon: { type: 'REAL', notnull: 1 },
|
||||||
|
trigger_radius_m: { type: 'REAL', notnull: 1 },
|
||||||
|
sequence: { type: 'INTEGER', notnull: 1 },
|
||||||
|
audio_path: { type: 'TEXT' },
|
||||||
|
created_at: { type: 'TEXT', notnull: 1 },
|
||||||
|
updated_at: { type: 'TEXT', notnull: 1 }
|
||||||
|
},
|
||||||
|
poi_images: {
|
||||||
|
id: { type: 'INTEGER', pk: 1 },
|
||||||
|
poi_id: { type: 'INTEGER', notnull: 1 },
|
||||||
|
path: { type: 'TEXT', notnull: 1 },
|
||||||
|
caption: { type: 'TEXT', notnull: 1 },
|
||||||
|
sequence: { type: 'INTEGER', notnull: 1 },
|
||||||
|
created_at: { type: 'TEXT', notnull: 1 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function assertInsideStorage(relativePath, description) {
|
||||||
|
assert.equal(typeof relativePath, 'string', `${description}: Pfad fehlt.`);
|
||||||
|
assert.ok(relativePath.length > 0, `${description}: Pfad ist leer.`);
|
||||||
|
assert.equal(path.isAbsolute(relativePath), false, `${description}: Pfad muss relativ zu STORAGE_DIR sein.`);
|
||||||
|
|
||||||
|
const storageRoot = path.resolve(config.storageDir);
|
||||||
|
const target = path.resolve(storageRoot, relativePath);
|
||||||
|
assert.ok(
|
||||||
|
target.startsWith(`${storageRoot}${path.sep}`),
|
||||||
|
`${description}: Pfad liegt außerhalb von STORAGE_DIR: ${relativePath}`
|
||||||
|
);
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertFileExists(relativePath, description) {
|
||||||
|
const target = assertInsideStorage(relativePath, description);
|
||||||
|
assert.ok(fs.existsSync(target), `${description}: Datei fehlt: ${relativePath}`);
|
||||||
|
assert.ok(fs.statSync(target).isFile(), `${description}: Erwartete Datei ist keine Datei: ${relativePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDirectoryExists(relativePath, description) {
|
||||||
|
const target = assertInsideStorage(relativePath, description);
|
||||||
|
assert.ok(fs.existsSync(target), `${description}: Verzeichnis fehlt: ${relativePath}`);
|
||||||
|
assert.ok(fs.statSync(target).isDirectory(), `${description}: Erwarteter Pfad ist kein Verzeichnis: ${relativePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertTableSchema(db, tableName, expectedColumns) {
|
||||||
|
const columns = db.prepare(`PRAGMA table_info(${tableName})`).all();
|
||||||
|
assert.ok(columns.length > 0, `Tabelle ${tableName} fehlt.`);
|
||||||
|
|
||||||
|
const byName = new Map(columns.map(column => [column.name, column]));
|
||||||
|
for (const [columnName, expected] of Object.entries(expectedColumns)) {
|
||||||
|
const actual = byName.get(columnName);
|
||||||
|
assert.ok(actual, `Spalte ${tableName}.${columnName} fehlt.`);
|
||||||
|
assert.equal(String(actual.type).toUpperCase(), expected.type, `Falscher Typ für ${tableName}.${columnName}.`);
|
||||||
|
|
||||||
|
if (Object.hasOwn(expected, 'notnull')) {
|
||||||
|
assert.equal(actual.notnull, expected.notnull, `Falsche NOT NULL-Eigenschaft für ${tableName}.${columnName}.`);
|
||||||
|
}
|
||||||
|
if (Object.hasOwn(expected, 'pk')) {
|
||||||
|
assert.equal(actual.pk, expected.pk, `Falsche PRIMARY KEY-Eigenschaft für ${tableName}.${columnName}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertStrictTables(db) {
|
||||||
|
const tableList = db.prepare('PRAGMA table_list').all();
|
||||||
|
const byName = new Map(tableList.map(table => [table.name, table]));
|
||||||
|
|
||||||
|
for (const tableName of Object.keys(EXPECTED_SCHEMA)) {
|
||||||
|
const table = byName.get(tableName);
|
||||||
|
assert.ok(table, `Tabelle ${tableName} fehlt in PRAGMA table_list.`);
|
||||||
|
assert.equal(table.strict, 1, `Tabelle ${tableName} ist nicht STRICT.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDatabaseIntegrity(db) {
|
||||||
|
const integrity = db.prepare('PRAGMA integrity_check').all();
|
||||||
|
assert.deepEqual(integrity, [{ integrity_check: 'ok' }], 'SQLite integrity_check ist fehlgeschlagen.');
|
||||||
|
|
||||||
|
const foreignKeyErrors = db.prepare('PRAGMA foreign_key_check').all();
|
||||||
|
assert.deepEqual(foreignKeyErrors, [], 'Die Datenbank enthält verletzte Foreign-Key-Beziehungen.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRoutePointCounts(db) {
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT r.id, r.point_count AS expected_count, COUNT(rp.id) AS actual_count
|
||||||
|
FROM routes r
|
||||||
|
LEFT JOIN route_points rp ON rp.route_id = r.id
|
||||||
|
GROUP BY r.id, r.point_count
|
||||||
|
ORDER BY r.id
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
assert.equal(
|
||||||
|
row.actual_count,
|
||||||
|
row.expected_count,
|
||||||
|
`Strecke ${row.id}: point_count=${row.expected_count}, tatsächlich vorhanden=${row.actual_count}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertActiveRoutes(db) {
|
||||||
|
const activeRoutes = db.prepare(`
|
||||||
|
SELECT id, gpx_path
|
||||||
|
FROM routes
|
||||||
|
WHERE status = 'active'
|
||||||
|
ORDER BY id
|
||||||
|
`).all();
|
||||||
|
const activeIds = new Set(activeRoutes.map(route => Number(route.id)));
|
||||||
|
|
||||||
|
for (const route of activeRoutes) {
|
||||||
|
const id = Number(route.id);
|
||||||
|
const expectedDirectory = `active/routes/${id}`;
|
||||||
|
const expectedGpxPath = `${expectedDirectory}/route.gpx`;
|
||||||
|
|
||||||
|
assertDirectoryExists(expectedDirectory, `Strecke ${id}`);
|
||||||
|
assert.equal(route.gpx_path, expectedGpxPath, `Strecke ${id}: gpx_path stimmt nicht mit der Strecken-ID überein.`);
|
||||||
|
assertFileExists(route.gpx_path, `Strecke ${id} GPX`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(activeRoutesDirectory)) return;
|
||||||
|
|
||||||
|
for (const entry of fs.readdirSync(activeRoutesDirectory, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
assert.match(entry.name, /^\d+$/, `Unerwartetes Streckenverzeichnis unter storage/active/routes: ${entry.name}`);
|
||||||
|
const id = Number(entry.name);
|
||||||
|
assert.ok(activeIds.has(id), `Aktives Streckenverzeichnis ${entry.name} hat keinen aktiven Datenbankeintrag.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDeletedRoutes(db) {
|
||||||
|
const deletedRoutes = db.prepare(`
|
||||||
|
SELECT id, gpx_path, trash_path
|
||||||
|
FROM routes
|
||||||
|
WHERE status = 'deleted'
|
||||||
|
ORDER BY id
|
||||||
|
`).all();
|
||||||
|
const trashPaths = new Set();
|
||||||
|
|
||||||
|
for (const route of deletedRoutes) {
|
||||||
|
const id = Number(route.id);
|
||||||
|
assert.equal(typeof route.trash_path, 'string', `Gelöschte Strecke ${id}: trash_path fehlt.`);
|
||||||
|
assert.ok(route.trash_path.startsWith(`trash/routes/${id}-`), `Gelöschte Strecke ${id}: trash_path passt nicht zur ID.`);
|
||||||
|
assertDirectoryExists(route.trash_path, `Gelöschte Strecke ${id}`);
|
||||||
|
assert.ok(route.gpx_path.startsWith(`${route.trash_path}/`), `Gelöschte Strecke ${id}: gpx_path liegt nicht im Papierkorbpfad.`);
|
||||||
|
assertFileExists(route.gpx_path, `Gelöschte Strecke ${id} GPX`);
|
||||||
|
trashPaths.add(route.trash_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(trashRoutesDirectory)) return;
|
||||||
|
|
||||||
|
for (const entry of fs.readdirSync(trashRoutesDirectory, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const relativePath = `trash/routes/${entry.name}`;
|
||||||
|
assert.ok(trashPaths.has(relativePath), `Papierkorb-Streckenverzeichnis ${entry.name} hat keinen passenden Datenbankeintrag.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeStoragePrefix(route) {
|
||||||
|
if (route.status === 'active') return `active/routes/${route.id}/`;
|
||||||
|
assert.equal(route.status, 'deleted', `Strecke ${route.id}: unbekannter Status ${route.status}.`);
|
||||||
|
assert.equal(typeof route.trash_path, 'string', `Gelöschte Strecke ${route.id}: trash_path fehlt.`);
|
||||||
|
return `${route.trash_path}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertMediaPaths(db) {
|
||||||
|
const audioRows = db.prepare(`
|
||||||
|
SELECT p.id AS poi_id, p.audio_path, r.id AS route_id, r.status, r.trash_path
|
||||||
|
FROM pois p
|
||||||
|
JOIN routes r ON r.id = p.route_id
|
||||||
|
WHERE p.audio_path IS NOT NULL
|
||||||
|
ORDER BY p.id
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
for (const row of audioRows) {
|
||||||
|
const prefix = routeStoragePrefix({ id: row.route_id, status: row.status, trash_path: row.trash_path });
|
||||||
|
assert.ok(row.audio_path.startsWith(`${prefix}audio/`), `POI ${row.poi_id}: Audio-Pfad passt nicht zu Strecke ${row.route_id}.`);
|
||||||
|
assertFileExists(row.audio_path, `POI ${row.poi_id} Audio`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageRows = db.prepare(`
|
||||||
|
SELECT pi.id AS image_id, pi.path, p.id AS poi_id, r.id AS route_id, r.status, r.trash_path
|
||||||
|
FROM poi_images pi
|
||||||
|
JOIN pois p ON p.id = pi.poi_id
|
||||||
|
JOIN routes r ON r.id = p.route_id
|
||||||
|
ORDER BY pi.id
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
for (const row of imageRows) {
|
||||||
|
const prefix = routeStoragePrefix({ id: row.route_id, status: row.status, trash_path: row.trash_path });
|
||||||
|
assert.ok(row.path.startsWith(`${prefix}images/`), `Bild ${row.image_id}: Pfad passt nicht zu Strecke ${row.route_id}.`);
|
||||||
|
assertFileExists(row.path, `Bild ${row.image_id} von POI ${row.poi_id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Laufzeitdatenbank und gespeicherte Streckendaten sind konsistent', () => {
|
||||||
|
if (!fs.existsSync(databasePath)) {
|
||||||
|
console.log(`Keine Datenbank vorhanden (${databasePath}); Integritätsprüfung ist nicht erforderlich.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ok(fs.statSync(databasePath).isFile(), `Datenbankpfad ist keine Datei: ${databasePath}`);
|
||||||
|
|
||||||
|
const db = new DatabaseSync(databasePath, {
|
||||||
|
readOnly: true,
|
||||||
|
enableForeignKeyConstraints: true,
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertDatabaseIntegrity(db);
|
||||||
|
|
||||||
|
for (const [tableName, columns] of Object.entries(EXPECTED_SCHEMA)) {
|
||||||
|
assertTableSchema(db, tableName, columns);
|
||||||
|
}
|
||||||
|
assertStrictTables(db);
|
||||||
|
assertRoutePointCounts(db);
|
||||||
|
assertActiveRoutes(db);
|
||||||
|
assertDeletedRoutes(db);
|
||||||
|
assertMediaPaths(db);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user