editor overhaul
Sonarqube Scanner / Prepare environment (push) Successful in 7s
Sonarqube Scanner / Build and test (push) Successful in 23s
Sonarqube Scanner / SonarQube analysis (push) Successful in 56s
Sonarqube Scanner / Deploy to Server (push) Successful in 8s

This commit is contained in:
Florian Zumpe
2026-10-18 14:26:25 +02:00
parent d33a67b8ae
commit 9c4d5f6324
17 changed files with 359 additions and 55 deletions
+10 -9
View File
@@ -45,20 +45,21 @@ Die notwendigen Arbeiten stehen in
Vor verbindlicher Verwendung sind insbesondere diese Platzhalter zu ersetzen:
- vollständiger Name und Anschrift des tatsächlichen Betreibers als Lizenznehmer;
- Anschrift von Florian Zumpe als Betreiber und Lizenznehmer;
- Rechtsgrundlagen, Empfänger, Hostinganbieter sowie Lösch- und Sicherungsfristen.
Kontakt für Wegwichtel, Rechtefragen, Datenschutzanfragen, Widerruf und Entfernung:
[info@wegwichtel.de](mailto:info@wegwichtel.de). Die Adresse ist eingerichtet
und ersetzt die bisherigen Kontaktplatzhalter. Die Kontaktdaten der Schule
und der Datenschutzstellen bleiben als Kontakte dieser Stellen erhalten.
und ersetzt die bisherigen Kontaktplatzhalter.
Außerdem müssen die Texte und das Zustimmungsverfahren zum tatsächlichen
Betriebsmodell der Schule passen und rechtlich geprüft werden. Die bereits
vorhandenen Schul- und Behördenkontakte im Impressum wurden nicht verändert;
die Kontaktadresse für Wegwichtel und die technische Verantwortung wurde ergänzt. Die frühere
pauschale Rechtezuordnung zur Schule wurde durch die Trennung von Urheberschaft
und Nutzungsrechten ersetzt.
Seit dem 17. Oktober 2026 ist Florian Zumpe als Projektinhaber, Betreiber und
Lizenznehmer für den Betrieb von Wegwichtel festgelegt. Er übernimmt Entwicklung,
Betrieb und Pflege. Die Grundschule Herpf soll eine gesonderte Nutzungsfreigabe
erhalten. Die bisherigen Schul- und Behördenkontakte wurden als Betreiberangaben
aus Impressum und Datenschutz entfernt. Die Anschrift von Florian Zumpe ist noch
zu ergänzen. Die Rechte an fremden Beiträgen und Envato-Grafiken bleiben bei den
jeweiligen Berechtigten. Texte und Zustimmungsverfahren müssen zum tatsächlichen
Betriebsmodell passen; siehe [Projektverantwortung.md](Projektverantwortung.md).
## HTTP-Rechtehinweise
+26
View File
@@ -48,6 +48,32 @@ verlorenen HTTP-Erfolg nach serverseitigem POST ist zusätzlich eine spätere
Idempotenzlösung erforderlich. Parallele Bearbeitung ist noch nicht freigegeben;
die aktuelle Übertragung behandelt den lokalen Entwurf als vollständigen Stand.
## Fehlende Server-Strecke, Papierkorb und lokaler Status
Beim Öffnen der Entwurfsliste werden lokale Strecken sofort angezeigt. Bereits
abgesandte Strecken werden im Hintergrund mit `includeDeleted=true` geprüft.
Liegt die Strecke im Papierkorb, wird lokal „Abgelehnt“ angezeigt. Existiert die
Server-ID auch mit dieser Abfrage nicht mehr (HTTP 404), erscheint „Entfernt“.
Der Status wird auf dem Gerät gespeichert und bleibt ohne Internet sichtbar.
Fehlende Berechtigung, Netzwerk- und Serverfehler verändern ihn nicht.
Eine erfolgreiche Abfrage nach manueller Wiederherstellung entfernt den alten
Ablehnungsstatus. Ungespeicherte Eingaben werden durch die Prüfung nicht ersetzt.
Beim bewussten erneuten Einsenden einer entfernten oder abgelehnten Strecke
werden alte Server-Zuordnungen für Strecke, Stationen, Bilder und Ton entfernt.
Der lokale Inhalt und seine Dateireferenzen bleiben erhalten. Eine neue Strecke
mit neuer ID und derselben Erstellerkennung wird zur Prüfung angelegt. Der alte
Papierkorb-Eintrag wird nicht wiederhergestellt oder verändert. Die neue Strecke
muss erneut freigegeben werden. Bestätigte neue IDs werden auch bei einem späteren
Übertragungsfehler gespeichert; weitere Versuche setzen dort fort.
Nach erfolgreicher neuer Einreichung wird der Status gelöscht und angezeigt:
„Die Strecke wurde neu angelegt und zur Prüfung gesendet.“ Eine vorhandene,
noch nicht freigegebene Strecke wird unter derselben ID aktualisiert und erhält
die normale Erfolgsmeldung. Freigegebene Strecken bleiben gegen Änderungen gesperrt.
Nur eine bestätigte 404-Antwort oder ein bestätigter Papierkorb-Status löst eine
neue Einreichung aus, keine Netzwerk-, Berechtigungs- oder anderen Serverfehler.
## Datenbank und Betrieb
Das Schema und die Startprüfung enthalten die nullable Spalte
+3 -3
View File
@@ -4,8 +4,8 @@ Die in der Anwendung enthaltenen Texte sind Vorlagen und ersetzen keine Prüfung
## Betreiber und Impressum
- vollständiger Name und Rechtsform der Betreiberin
- vertretungsberechtigte Person
- Betreiber und Projektinhaber: Florian Zumpe (festgelegt); gegebenenfalls erforderliche weitere Angaben zum tatsächlichen Betriebsmodell prüfen
- Vertretungsangaben nur, falls für das tatsächliche Betriebsmodell erforderlich
- ladungsfähige Anschrift
- eingerichtete Kontaktadresse für Wegwichtel: info@wegwichtel.de
- weiterer unmittelbarer Kontaktweg
@@ -14,7 +14,7 @@ Die in der Anwendung enthaltenen Texte sind Vorlagen und ersetzen keine Prüfung
## Datenschutz
- Verantwortliche Stelle und Datenschutzkontakt
- Verantwortliche Stelle für den App-Betrieb: Florian Zumpe; Kontakt info@wegwichtel.de; Anschrift ergänzen
- Hostinganbieter und Ort der Verarbeitung
- Art und Dauer der Serverprotokollierung
- Rechtsgrundlagen für Serverbetrieb und freiwillige Browser-Speicherung
+16
View File
@@ -0,0 +1,16 @@
# Projektverantwortung
Projektinhaber und Betreiber von Wegwichtel ist Florian Zumpe. Er entwickelt
und erhält das Projekt und übernimmt Betrieb und Pflege. Kontakt:
info@wegwichtel.de. Seine ladungsfähige Anschrift ist in den statischen
Rechtstexten noch als Platzhalter eingetragen.
Die Staatliche Grundschule Herpf soll nach einer gesonderten Freigabe die App
nutzen können. Umfang, Dauer und Bedingungen dieser Freigabe sind noch
festzulegen. Aus dieser Absicht folgt keine Übertragung des Projekts auf die
Schule. Im Impressum und für den App-Betrieb steht Florian Zumpe.
Die Rechte an Beiträgen anderer Personen und an lizenzierten Grafiken bleiben
bei den jeweiligen Berechtigten. Die Betreiberangabe ersetzt keine Lizenz und
keinen Nachweis der Zustimmung. Die Texte von Impressum, Datenschutz und
Nutzungsvereinbarung bleiben statisch auf Deutsch in public/index.html.
+10
View File
@@ -163,3 +163,13 @@ Bestätigter Ausgangsstand am 5. Oktober 2026: Commit
erfolgreich; Quality Gate OK; alle 121 Findings CLOSED/FIXED; beide Security
Hotspots REVIEWED/SAFE. Dieser Stand ist eine historische Referenz und ersetzt
nicht die Prüfung einer neuen Analyse.
## Projektinhaber und Betreiber
Florian Zumpe ist Projektinhaber, Betreiber und Verantwortlicher für die
Entwicklung, den Betrieb und die Pflege von Wegwichtel. Die Grundschule Herpf
soll nach einer gesonderten Freigabe zur Nutzung berechtigt werden. Schule,
Schulleitung, Schulträger und Schulamt nicht als Betreiber der Website führen.
Die konkrete Nutzungsfreigabe für die Schule bleibt gesondert festzulegen.
Kontakt: info@wegwichtel.de. Die Anschrift ist noch zu ergänzen.
Rechte fremder Urheber und bestehende Lizenzen bleiben davon unberührt.
+2 -2
View File
@@ -10,8 +10,8 @@ als offenen Platzhalter führen.
## Vor verbindlicher Verwendung und Freigabe zu erledigen
1. Den tatsächlichen Lizenznehmer, Rechtsgrundlagen und Fristen
ergänzen. Den Entwurf und die Zustimmung für Kinder rechtlich prüfen lassen.
1. Lizenznehmer für den Betrieb von Wegwichtel ist Florian Zumpe. Seine
Anschrift, Rechtsgrundlagen und Fristen ergänzen. Den Entwurf und die Zustimmung für Kinder rechtlich prüfen lassen.
2. Die relevante Vereinbarung vor dem Einsenden zugänglich machen und eine
nicht vorausgewählte, ausdrückliche Zustimmung einholen. Eine bloße
Datenschutzerklärung und eine zufällige Gerätekennung sind kein Rechtenachweis.
+4
View File
@@ -18,3 +18,7 @@ Noch offen für gemeinsame Bearbeitung:
Die Ersteller-ID ersetzt keine Anmeldung. Der Moderationszugang ist separat
über Nginx und eine Passwortdatei geschützt (siehe [MODERATION-MEDIEN.md](../MODERATION-MEDIEN.md)).
Zusätzlich vorgemerkt: [mögliche Streckenduplikate online prüfen](TODO-STRECKENDUPLIKATE.md).
Identische Dateien, ähnliche Wege und bewusst neue Einreichungen nach Ablehnung
oder Entfernung müssen getrennt beurteilt werden.
+30
View File
@@ -0,0 +1,30 @@
# TODO: Mögliche Streckenduplikate in der Moderation prüfen
Am 5. Oktober 2026 vorgemerkt, noch nicht implementiert.
## Ziel
Online mögliche Duplikate zur manuellen Moderation anzeigen. Erneute Einreichungen
nach Ablehnung oder Entfernung bleiben erlaubt. Ein Treffer darf nicht automatisch
eine Freigabe übernehmen, eine alte Strecke wiederherstellen oder einen Upload blockieren.
## Lösungsansätze
- SHA-256 der GPX-Datei für bytegenau identische Dateien berechnen. Änderungen an
XML-Schreibweise, Zeitstempeln oder Leerzeichen ändern bereits diese Prüfsumme.
- Zusätzlich eine stabile Darstellung der Routen- und Stationskoordinaten hashen.
XML-Schreibweise, Zeitstempel, Titel und interne IDs nicht als Wegidentität werten.
Reihenfolge, Rundung, fehlende Koordinaten und Stationsanker ausdrücklich definieren.
- Für GPS-Abweichungen einen geometrischen Ähnlichkeitsvergleich evaluieren.
Zunächst Kandidaten über die vorhandenen Min-/Max-Grenzen auswählen, dann
Wegverlauf, Start, Ende, Länge und Stationen mit festgelegter Toleranz vergleichen.
- Gleiche Wege und gleiche Inhalte unterscheiden: Derselbe Weg kann andere
Aufgaben, Bilder und Tonaufnahmen enthalten.
- Betroffene IDs, Status und Grund der Ähnlichkeit in der Moderation anzeigen.
Entscheiden, ob Papierkorb-Einträge in den Vergleich einbezogen werden.
- Fehlende GPS-Daten berücksichtigen; leere Dateien nicht pauschal als sinnvolle
Duplikate werten. Tests für identische Dateien, XML-/Zeitabweichungen, GPS-Rauschen,
Rückwege, andere Reihenfolge und Stationen sowie erneute Einreichungen vorsehen.
Die GPX-Datei bleibt Datenquelle; keine zweite vollständige Geometrie in SQLite
ablegen. Hashes sind Vergleichswerte, kein Berechtigungs- oder Urhebernachweis.
+14 -24
View File
@@ -312,14 +312,10 @@
</header>
<div class="info-dialog-content">
<article class="info-page" data-info-page="privacy" lang="de" hidden>
<p class="legal-warning"><strong>Hinweis:</strong><span>Diese Angaben sind noch nicht vollständig. Die Kontakte stammen von der Grundschule Herpf. Wer die Website betreibt, welche Verbindungsdaten gespeichert werden und wie lange sie bleiben, muss noch ergänzt werden.</span></p>
<p class="legal-warning"><strong>Hinweis:</strong><span>Die Anschrift, Angaben zum Hosting und zur Speicherung von Verbindungsdaten müssen noch ergänzt werden.</span></p>
<h3>1. Verantwortliche Stelle und Datenschutzkontakt</h3>
<p>
<strong>Staatliche Grundschule Herpf</strong><br><span>Zum Eichig 3</span><br><span>98617 Meiningen / OT Herpf</span><br><span>Telefon: (036943) 63722</span><br><span>E-Mail: info@grundschule-herpf.de</span></p>
<p><span>Datenschutzangaben der Schule:</span><br>
<strong>Staatliches Schulamt Südthüringen</strong><br><span>Datenschutzbeauftragter</span><br><span>Hölderlinstraße 1</span><br><span>98527 Suhl</span><br><span>Telefon: 03681 / 7341 25</span><br><span>Fax: 03681 / 7341 09</span><br><span>E-Mail: datenschutz.suedthueriungen@schulamt.thueringen.de</span></p>
<p>Der Datenschutzbeauftragte kann außerdem über die Postadresse der Schule mit dem Zusatz „der Datenschutzbeauftragte“ erreicht werden.</p>
<p>Für Fragen zu Wegwichtel, Datenschutzanfragen, Widerruf und Entfernung von Aufnahmen erreichst du die Betreiber unter <a href="mailto:info@wegwichtel.de">info@wegwichtel.de</a>.</p>
<p><strong>Florian Zumpe</strong><br><span>[Anschrift von Florian Zumpe ergänzen]</span><br><span>E-Mail: <a href="mailto:info@wegwichtel.de">info@wegwichtel.de</a></span></p>
<p>Florian Zumpe entwickelt, betreibt und pflegt Wegwichtel. Fragen zum Datenschutz, Auskunfts- und Löschanfragen sowie Widerrufe kannst du an die Kontaktadresse oben senden.</p>
<h3>2. Wenn du Wegwichtel öffnest</h3>
<p>Wenn du Wegwichtel öffnest, können Verbindungsdaten verarbeitet werden. Dazu gehören zum Beispiel die Internetadresse deines Geräts, Datum, Uhrzeit und Zeitzone, die aufgerufene Seite, übertragene Daten und ob der Aufruf geklappt hat. Auch die vorherige Seite und Angaben zu deinem Gerät, Internetbrowser und dessen Sprache können dazugehören. Diese Daten helfen dabei, die Website bereitzustellen, Fehler zu finden und sie zu schützen.</p>
<p><span>Wer die Website bereitstellt und welche Verbindungsdaten wie lange gespeichert werden:</span><strong>[Anbieter, Anschrift, gespeicherte Daten und Speicherdauer ergänzen]</strong></p>
@@ -356,24 +352,18 @@
<strong>Thüringer Landesbeauftragter für den Datenschutz und die Informationsfreiheit (TlfDI)</strong><br><span>Postfach 90 04 55, 99107 Erfurt</span><br><span>Häßlerstraße 8, 99096 Erfurt</span><br><span>Telefon: 0361 / 57 311 29 00</span><br><span>Telefax: 0361 / 57 311 29 04</span><br><span>E-Mail: poststelle@datenschutz.thueringen.de</span><br><span>Website: www.tlfdi.de</span></p>
</article>
<article class="info-page" data-info-page="imprint" lang="de" hidden>
<p class="legal-warning"><strong>Hinweis:</strong><span>Diese Angaben stammen von der Grundschule Herpf. Vor der Veröffentlichung muss geprüft werden, ob für Wegwichtel noch weitere oder andere Angaben nötig sind.</span></p>
<h3>Wer für Wegwichtel verantwortlich ist</h3>
<p>
<strong>Staatliche Grundschule Herpf</strong><br><span>Zum Eichig 3</span><br><span>98617 Meiningen / OT Herpf</span></p>
<h3>Kontakt</h3>
<p><span>Telefon: (036943) 63722</span><br><span>Telefax: (036943) 63843</span><br><span>E-Mail: info@grundschule-herpf.de</span></p>
<p>Kontakt für Wegwichtel: <a href="mailto:info@wegwichtel.de">info@wegwichtel.de</a></p>
<h3>Leitung der Schule</h3>
<p>Frau Wichert, Schulleiterin</p>
<h3>Schulträger</h3>
<p>
<strong>Landkreis Schmalkalden-Meiningen</strong><br><span>Landratsamt</span><br><span>Obertshäuser Platz 1</span><br><span>98617 Meiningen</span></p>
<p class="legal-warning"><strong>Hinweis:</strong><span>Die Anschrift und gegebenenfalls weitere Pflichtangaben müssen vor der Veröffentlichung ergänzt werden.</span></p>
<h3>Inhaber und Betreiber von Wegwichtel</h3>
<p><strong>Florian Zumpe</strong><br><span>[Anschrift von Florian Zumpe ergänzen]</span><br><span>E-Mail: <a href="mailto:info@wegwichtel.de">info@wegwichtel.de</a></span></p>
<h3>Entwicklung und Pflege</h3>
<p>Florian Zumpe setzt das Projekt um und übernimmt den Betrieb und die weitere Pflege. Die Staatliche Grundschule Herpf soll die App nach einer gesonderten Nutzungsfreigabe verwenden können.</p>
<h3>Inhaltlich verantwortlich</h3>
<p>Frau Wichert</p>
<p>Florian Zumpe</p>
<h3>Technisch verantwortlich</h3>
<p>Florian Zumpe<br><span>[Anschrift von Florian Zumpe ergänzen]</span><br><span>E-Mail: <a href="mailto:info@wegwichtel.de">info@wegwichtel.de</a></span></p>
<h3>Weitere Pflichtangaben</h3>
<p>Je nachdem, wer Wegwichtel betreibt, können weitere Angaben nötig sein. Dazu gehören zum Beispiel die Verantwortlichen für den Betrieb der Website. Diese Angaben müssen noch geprüft werden.</p>
<p><strong>[Weiteren unmittelbaren Kontaktweg ergänzen]</strong></p>
<p><strong>[Weitere für den tatsächlichen Betrieb erforderliche Pflichtangaben prüfen und gegebenenfalls ergänzen]</strong></p>
<h3>Haftung für Inhalte und Verweise</h3>
<p>Die Inhalte werden sorgfältig erstellt und regelmäßig geprüft. Falls etwas falsch, veraltet oder nicht erlaubt ist, melde dich beim Kontakt oben. Für andere verlinkte Seiten sind deren Betreiber verantwortlich.</p>
<h3>Bildquelle der Gruppensymbole</h3>
@@ -382,10 +372,10 @@
<p>Die Urheberrechte an Bildern, Tonaufnahmen und anderen Beiträgen bleiben bei den jeweiligen Urhebern. Die Betreiber nutzen eingesandte Aufnahmen im Rahmen der vereinbarten Nutzungsrechte. Die Veröffentlichung erlaubt anderen Personen keine freie Weiterverwendung. Gesetzlich erlaubte Nutzungen und ausdrücklich erteilte Lizenzen bleiben unberührt.</p>
</article>
<article class="info-page" data-info-page="terms" lang="de" hidden>
<p class="legal-warning"><strong>Entwurf:</strong> Diese Vereinbarung muss vor ihrer verbindlichen Verwendung rechtlich geprüft werden. Betreiber und das Verfahren zur Zustimmung und Urhebernennung sind noch zu ergänzen.</p>
<p>Stand: 4. Oktober 2026</p>
<p class="legal-warning"><strong>Entwurf:</strong> Diese Vereinbarung muss vor ihrer verbindlichen Verwendung rechtlich geprüft werden. Die Anschrift des Betreibers und das Verfahren zur Zustimmung und Urhebernennung sind noch zu ergänzen.</p>
<p>Stand: 17. Oktober 2026</p>
<h3>1. Für wen diese Vereinbarung gilt</h3>
<p>Diese Vereinbarung regelt das Einsenden von Bildern und Tonaufnahmen an Wegwichtel. Nutzungsrechte werden dem Betreiber <strong>[vollständiger Name und Anschrift des tatsächlichen Betreibers ergänzen]</strong> eingeräumt. Die bloße Nutzung der App oder das Speichern eines Entwurfs auf deinem Gerät räumt keine Rechte ein.</p>
<p>Diese Vereinbarung regelt das Einsenden von Bildern und Tonaufnahmen an Wegwichtel. Nutzungsrechte werden dem Betreiber <strong>Florian Zumpe, [Anschrift von Florian Zumpe ergänzen]</strong> eingeräumt. Die bloße Nutzung der App oder das Speichern eines Entwurfs auf deinem Gerät räumt keine Rechte ein.</p>
<h3>2. Erlaubnis für Bilder und Tonaufnahmen</h3>
<p>Mit dem Hochladen deiner Bilder und Tonaufnahmen unter Zustimmung zu dieser Nutzungsvereinbarung räumst du dem Betreiber ein einfaches, nicht ausschließliches, räumlich unbeschränktes Nutzungsrecht für den Betrieb von Wegwichtel ein. Es umfasst das Speichern und Vervielfältigen, die redaktionelle Prüfung sowie nach Freigabe das öffentliche Zugänglichmachen und Wiedergeben im Zusammenhang mit den Strecken der App. Dazu gehören die Anzeige von Bildern, das Abspielen von Tonaufnahmen sowie die von der App angebotene Speicherung ausgewählter Strecken auf Geräten für die Nutzung ohne Internet.</p>
<p>Der Betreiber darf die dafür notwendigen technischen Anpassungen vornehmen, etwa Dateiformate umwandeln, Dateien komprimieren und Bildgrößen anpassen. Inhaltliche Veränderungen oder entstellende Bearbeitungen sind dadurch nicht erlaubt. Beauftragte Hosting- und technische Dienstleister dürfen die Aufnahmen nur soweit verwenden, wie dies zur Bereitstellung von Wegwichtel erforderlich ist. Besucher erhalten nur die für die Nutzung innerhalb der App und ihrer vorgesehenen Offline-Funktion erforderliche Erlaubnis.</p>
+74 -12
View File
@@ -368,6 +368,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const label = document.createElement('div');
appendText(label, 'strong', routeName(route));
appendText(label, 'small', routeSource(route) === 'local' ? ns.I18n.t("app.draft") : ns.I18n.t("editor.published-route"));
if (routeSource(route) === 'local' && route.submissionStatus) {
appendText(label, 'small', ns.I18n.t(`editor.${route.submissionStatus}`));
}
const actions = document.createElement('div');
actions.className = classes.iconRow;
@@ -458,6 +461,36 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
setStatus('');
// Local drafts are usable even while this request is waiting for a connection.
void refreshPublicRoutes(requestGeneration);
void refreshSubmittedRoutes(requestGeneration);
}
async function refreshSubmittedRoutes(requestGeneration) {
const submitted = localRouteSummaries().filter(route => route.serverRouteId);
await Promise.allSettled(submitted.map(async route => {
const existing = await findSubmittedRoute(route.serverRouteId);
if (requestGeneration !== routeListGeneration) return;
const latest = ns.LocalRoutes.get(route.id);
if (!latest || String(latest.route.serverRouteId) !== String(route.serverRouteId)) return;
const submissionStatus = submittedStatus(existing);
if ((latest.route.submissionStatus || null) === submissionStatus) return;
latest.route.submissionStatus = submissionStatus;
const saved = ns.LocalRoutes.save(latest);
if (state.currentSource === 'local' && routeId(state.currentRoute) === route.id) {
state.currentRoute.submissionStatus = submissionStatus;
renderLocalSubmissionStatus(saved.route);
}
}));
if (requestGeneration !== routeListGeneration) return;
state.routes = [...localRouteSummaries(), ...state.routes.filter(route => routeSource(route) === 'server')];
renderRouteList();
}
function renderLocalSubmissionStatus(route) {
const uploaded = Boolean(route?.serverRouteId);
const uploadButton = byId(ids.routeUpload);
if (uploadButton) uploadButton.textContent = ns.I18n.t(uploaded ? 'editor.submitted' : 'editor.submit');
const hint = uploaded ? 'editor.submitted-hint' : 'editor.draft-hint';
byId(ids.routeActivated).textContent = ns.I18n.t(route?.submissionStatus ? `editor.${route.submissionStatus}-hint` : hint);
}
async function refreshPublicRoutes(requestGeneration) {
@@ -532,13 +565,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const uploadButton = byId(ids.routeUpload);
if (state.currentSource === 'local') {
const uploaded = Boolean(route?.serverRouteId);
setHidden(uploadButton, false);
setDisabled(uploadButton, false);
if (uploadButton) uploadButton.textContent = uploaded ? ns.I18n.t("editor.submitted") : ns.I18n.t("editor.submit");
byId(ids.routeActivated).textContent = uploaded
? ns.I18n.t("editor.submitted-hint")
: ns.I18n.t("editor.draft-hint");
renderLocalSubmissionStatus(route);
} else {
setHidden(uploadButton, true);
setDisabled(uploadButton, true);
@@ -946,23 +975,56 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
return ns.LocalRoutes.save(routePackage);
}
function submittedStatus(existing) {
if (!existing) return 'removed';
return existing.status === 'deleted' ? 'rejected' : null;
}
async function findSubmittedRoute(id) {
try {
const payload = await readJson(`/routes/${encodeURIComponent(id)}?includeDeleted=true`);
return payload.route || payload;
} catch (error) {
if (error.status === 404) return null;
throw error;
}
}
function clearSubmittedReferences(routePackage) {
routePackage.route.serverRouteId = null;
routePackage.route.uploadedAt = null;
for (const poi of routePackage.pois || []) {
delete poi.serverPoiId;
delete poi.serverAudio;
for (const image of poi.images || []) delete image.serverPictureId;
}
checkpointLocalUpload(routePackage);
}
async function prepareServerRoute(routePackage) {
const id = routePackage.route.serverRouteId;
const replacedSubmission = Boolean(id || routePackage.route.submissionStatus);
if (id) {
const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
const existing = payload.route || payload;
const existing = await findSubmittedRoute(id);
if (!existing || existing.status === 'deleted') {
// Preserve the old trash entry; submit a new draft with fresh references.
routePackage.route.submissionStatus = submittedStatus(existing);
clearSubmittedReferences(routePackage);
} else {
if (existing.activatedAt || existing.activated_at) {
throw new Error(ns.I18n.t("editor.read-only-hint"));
}
await sendForm(`/routes/${encodeURIComponent(id)}`, 'PUT', localRouteUploadForm(routePackage));
return { id, previousPois: existing.pois || [] };
}
}
const payload = await sendForm('/routes', 'POST', localRouteUploadForm(routePackage));
const createdId = routeId(payload.route || payload);
if (!createdId) throw new Error(ns.I18n.t("editor.error.send-route"));
routePackage.route.serverRouteId = createdId;
routePackage.route.submissionStatus = null;
checkpointLocalUpload(routePackage);
return { id: createdId, previousPois: [] };
return { id: createdId, previousPois: [], recreated: replacedSubmission };
}
async function syncLocalPoi(base, poi, index, routePackage) {
@@ -1011,7 +1073,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
async function createServerRouteFromLocal(routePackage) {
const { id, previousPois } = await prepareServerRoute(routePackage);
const { id, previousPois, recreated } = await prepareServerRoute(routePackage);
const base = `/routes/${encodeURIComponent(id)}/pois`;
const orderedPois = [...(routePackage.pois || [])]
.sort((left, right) => Number(left.sequence) - Number(right.sequence));
@@ -1023,7 +1085,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
await syncLocalPictures(mediaBase, poi, oldPoi, routePackage);
}
await removeMissingServerItems(previousPois, orderedPois, 'serverPoiId', base);
return id;
return { id, recreated };
}
async function uploadLocalRoute() {
@@ -1039,7 +1101,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const routePackage = ns.LocalRoutes.get(localId);
if (!routePackage) throw new Error(ns.I18n.t("editor.error.draft-missing"));
const serverRouteId = await createServerRouteFromLocal(routePackage);
const { id: serverRouteId, recreated } = await createServerRouteFromLocal(routePackage);
const uploadedAt = new Date().toISOString();
const saved = ns.LocalRoutes.save({
...routePackage,
@@ -1053,7 +1115,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
state.pois = saved.pois;
fillRouteForm(state.currentRoute);
await loadRoutes();
setStatus(ns.I18n.t("editor.submit-success"));
setStatus(ns.I18n.t(recreated ? 'editor.resubmit-success' : 'editor.submit-success'));
} finally {
// Keep acknowledged server IDs even if a later media request failed.
const latest = ns.LocalRoutes.get(routeId(state.currentRoute));
+1
View File
@@ -556,6 +556,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
createdAt: inputRoute.createdAt || now,
updatedAt: now,
serverRouteId: inputRoute.serverRouteId ?? null,
submissionStatus: ['rejected', 'removed'].includes(inputRoute.submissionStatus) ? inputRoute.submissionStatus : null,
uploadedAt: inputRoute.uploadedAt || null
};
const pois = (routePackage.pois || []).map((poi, index) => ({
+5
View File
@@ -135,6 +135,11 @@ globalThis.Wegwichtel ??= {};
"rights-hint": "Wenn du Bilder oder Tonaufnahmen sendest, erlaubst du Wegwichtel, sie nach der Nutzungsvereinbarung zu verwenden. Sende nur Aufnahmen, für die du diese Erlaubnis geben darfst. Kinder brauchen die Zustimmung ihrer Sorgeberechtigten.",
"rights-open": "Vereinbarung für Bilder und Ton lesen",
"submit": "Zur Prüfung senden",
"rejected": "Abgelehnt",
"rejected-hint": "Diese Strecke wurde abgelehnt. Du kannst deinen Entwurf überarbeiten und erneut zur Prüfung senden.",
"removed": "Entfernt",
"removed-hint": "Diese Strecke ist nicht mehr online. Du kannst deinen Entwurf erneut zur Prüfung senden.",
"resubmit-success": "Die Strecke wurde neu angelegt und zur Prüfung gesendet.",
"submitted-hint": "Du kannst Änderungen erneut zur Prüfung senden, solange die Strecke noch nicht freigegeben wurde.",
"draft-hint": "Dieser Entwurf bleibt auf deinem Gerät. Mit „Zur Prüfung senden“ reichst du ihn zur Freigabe ein.",
"read-only-hint": "Diese Strecke ist für alle freigegeben und kann hier nicht mehr verändert werden.",
+5
View File
@@ -135,6 +135,11 @@ globalThis.Wegwichtel ??= {};
"rights-hint": "By sending pictures or recordings, you allow Wegwichtel to use them under its terms of use. Only send recordings you are allowed to share this way. Children need their parent or guardian’s permission.",
"rights-open": "Read the agreement for pictures and recordings",
"submit": "Send for review",
"rejected": "Not approved",
"rejected-hint": "This trail was not approved. You can improve your draft and send it for review again.",
"removed": "Removed",
"removed-hint": "This trail is no longer online. You can send your draft for review again.",
"resubmit-success": "A new trail was created and sent for review.",
"submitted-hint": "You can send changes for review until the route has been approved.",
"draft-hint": "This draft stays on your device. Choose “Send for review” to submit it for approval.",
"read-only-hint": "This trail is available to everyone and can no longer be changed here.",
+2
View File
@@ -535,6 +535,8 @@ test('Express API router executes route, POI, file and restore handlers in-proce
const deleted = await json(`/api/routes/${routeId}`, { method: 'DELETE' });
assert.equal(deleted.body.softDeleted, true);
await json(`/api/routes/${routeId}`, {}, 404);
assert.equal((await json(`/api/routes/${routeId}?includeDeleted=true`)).body.status, 'deleted');
const restored = await json(`/api/routes/${routeId}/restore`, { method: 'POST' });
assert.equal(restored.body.restored, true);
+128 -1
View File
@@ -268,7 +268,7 @@ function buildEditorContext() {
if (url === '/api/routes' && method === 'POST') return jsonResponse({ id: 22, name: 'Neue lokale Strecke', pois: [] }, true, 201);
if (url === '/api/routes/22/pois' && method === 'POST') return jsonResponse({ id: 23, title: 'Aussicht' }, true, 201);
if (/^\/api\/routes\/22\/pois\/23\/(audio|pictures)(?:\/24)?$/.test(url) && (method === 'POST' || method === 'PUT')) return jsonResponse({ id: 24 }, true, 201);
if (url === '/api/routes/22' && method === 'GET') return jsonResponse({ id: 22, activatedAt: controls.approved ? '2026-10-03' : null, pois: [{ id: 23, audioUrl: '/audio', images: [{ id: 24 }] }] });
if (url === '/api/routes/22?includeDeleted=true' && method === 'GET') return jsonResponse({ id: 22, activatedAt: controls.approved ? '2026-10-03' : null, pois: [{ id: 23, audioUrl: '/audio', images: [{ id: 24 }] }] });
if (url === '/api/routes/22' && method === 'PUT') return jsonResponse({ id: 22 });
if (url === '/api/routes/22/pois/23' && method === 'PUT') return jsonResponse({ id: 23 });
if (/^\/api\/routes\/22\/pois\/23(?:\/audio|\/pictures\/24)?$/.test(url) && method === 'DELETE') return jsonResponse({ deleted: true });
@@ -389,6 +389,133 @@ function buildEditorContext() {
return { calls, context, elements, packages, saves, storedMediaCalls, controls };
}
async function submittedEditor(response) {
const harness = buildEditorContext();
const { context, packages, calls, elements } = harness;
packages.set('local-existing', {
id: 'local-existing', gpx: '<gpx/>',
route: { id: 'local-existing', name: 'Waldweg', creator_id: 'a'.repeat(64),
serverRouteId: '99', uploadedAt: '2026-10-01T10:00:00Z' },
pois: [{ id: 7, title: 'Aussicht', description: 'Wiese', sequence: 0,
lat: 50, lon: 10, serverPoiId: 98, serverAudio: true,
audio: { name: 'ton.mp3', mediaKey: 'audio-key', type: 'audio/mpeg' },
images: [{ id: 'local-image', name: 'bild.png', mediaKey: 'picture-key',
type: 'image/png', serverPictureId: 97 }] }]
});
const originalFetch = context.fetch;
context.fetch = async (url, options = {}) => {
if (url === '/api/routes/99?includeDeleted=true') {
calls.push({ url, method: 'GET' });
return response();
}
return originalFetch(url, options);
};
await loadI18n(context);
const sourcePath = path.join(root, 'public/js/editor-ui.js');
vm.runInNewContext(await fs.readFile(sourcePath, 'utf8'), context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.open();
for (let i = 0; i < 4; i += 1) await tick();
const list = elements.get('editor-route-list');
const item = list.children.find(child => child.dataset.routeId === 'local-existing');
list.dispatchEvent(pointerEvent(item.children[1].children[0]));
for (let i = 0; i < 4; i += 1) await tick();
return harness;
}
async function submitDraft(harness) {
harness.elements.get('editor-route-upload').dispatchEvent(pointerEvent());
for (let i = 0; i < 12; i += 1) await tick();
}
test('removed and rejected submissions show their saved status and create fresh route and media IDs', async t => {
for (const status of ['removed', 'rejected']) {
await t.test(status, async () => {
const response = status === 'removed'
? () => jsonResponse({ message: 'Strecke nicht gefunden.' }, false, 404)
: () => jsonResponse({ id: 99, status: 'deleted', activatedAt: '2026-10-01', pois: [] });
const harness = await submittedEditor(response);
const { calls, packages, elements } = harness;
assert.equal(packages.get('local-existing').route.submissionStatus, status);
const expectedLabel = status === 'removed' ? 'Entfernt' : 'Abgelehnt';
const item = elements.get('editor-route-list').children.find(child => child.dataset.routeId === 'local-existing');
assert.ok(item.children[0].children.some(child => child.textContent === expectedLabel));
assert.match(elements.get('editor-route-activated').textContent, status === 'removed' ? /nicht mehr online/ : /abgelehnt/);
await submitDraft(harness);
const saved = packages.get('local-existing');
assert.equal(saved.route.serverRouteId, '22');
assert.equal(saved.route.submissionStatus, null);
assert.equal(saved.route.creator_id, 'a'.repeat(64));
assert.equal(saved.pois[0].serverPoiId, '23');
assert.equal(saved.pois[0].images[0].serverPictureId, 24);
assert.equal(saved.pois[0].images[0].mediaKey, 'picture-key');
assert.equal(saved.pois[0].audio.mediaKey, 'audio-key');
assert.equal(elements.get('editor-status').textContent, 'Die Strecke wurde neu angelegt und zur Prüfung gesendet.');
assert.deepEqual(calls.filter(call => call.method === 'POST').map(call => call.url), [
'/api/routes', '/api/routes/22/pois', '/api/routes/22/pois/23/audio', '/api/routes/22/pois/23/pictures'
]);
assert.equal(calls.some(call => call.method === 'PUT' || call.method === 'DELETE' || call.url.includes('/restore')), false);
await submitDraft(harness);
assert.equal(calls.filter(call => call.method === 'POST' && call.url === '/api/routes').length, 1);
assert.ok(calls.some(call => call.method === 'PUT' && call.url === '/api/routes/22'));
});
}
});
test('submission status refresh preserves unsaved form text and clears rejection after restoration', async () => {
let status = 'active';
const harness = await submittedEditor(() => jsonResponse({ id: 99, status }));
harness.elements.get('editor-route-name').value = 'Noch nicht gespeichert';
for (const serverStatus of ['deleted', 'active']) {
status = serverStatus;
await harness.context.Wegwichtel.EditorUi.refresh();
for (let i = 0; i < 4; i += 1) await tick();
assert.equal(harness.packages.get('local-existing').route.submissionStatus, status === 'deleted' ? 'rejected' : null);
assert.equal(harness.elements.get('editor-route-name').value, 'Noch nicht gespeichert');
}
});
test('authorization, server and network failures preserve the submitted IDs and never create a route', async t => {
for (const status of [401, 403, 500, 'offline']) {
await t.test(String(status), async () => {
const harness = await submittedEditor(() => {
if (status === 'offline') throw new TypeError('Failed to fetch');
return jsonResponse({ message: 'Testfehler' }, false, status);
});
await submitDraft(harness);
assert.equal(harness.calls.some(call => call.method !== 'GET'), false);
const saved = harness.packages.get('local-existing');
assert.equal(saved.route.serverRouteId, '99');
assert.equal(saved.pois[0].serverPoiId, 98);
assert.equal(saved.pois[0].images[0].serverPictureId, 97);
assert.equal(harness.elements.get('editor-route-upload').disabled, false);
});
}
});
test('failed fresh creation preserves local files and retries without obsolete server references', async () => {
const harness = await submittedEditor(() => jsonResponse({}, false, 404));
const originalFetch = harness.context.fetch;
let fail = true;
harness.context.fetch = async (url, options = {}) => {
if (fail && url === '/api/routes' && options.method === 'POST') {
fail = false;
return jsonResponse({ message: 'Testfehler' }, false, 500);
}
return originalFetch(url, options);
};
await submitDraft(harness);
const saved = harness.packages.get('local-existing');
assert.equal(saved.route.serverRouteId, null);
assert.equal(saved.route.uploadedAt, null);
assert.equal(saved.route.submissionStatus, 'removed');
assert.equal(saved.pois[0].serverPoiId, undefined);
assert.equal(saved.pois[0].serverAudio, undefined);
assert.equal(saved.pois[0].images[0].serverPictureId, undefined);
assert.equal(saved.pois[0].audio.mediaKey, 'audio-key');
await submitDraft(harness);
assert.equal(harness.packages.get('local-existing').route.serverRouteId, '22');
});
test('public editor workflow saves new route and GPX Station locally without POSTing a route', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8');
+12
View File
@@ -262,3 +262,15 @@ test('legal overlay handlers synchronize switches without advancing the start sc
elements.get('info-close').dispatchEvent(pointerEvent());
assert.equal(elements.get('info-dialog').open, false);
});
test('Florian Zumpe is the operator across static legal pages and school contacts are not used', async () => {
const html = await read('public/index.html');
for (const name of ['privacy', 'imprint', 'terms']) {
const article = html.match(new RegExp(`<article[^>]+data-info-page="${name}"[\\s\\S]*?</article>`))[0];
assert.match(article, /Florian Zumpe/);
assert.match(article, /\[Anschrift von Florian Zumpe ergänzen\]/);
assert.match(article, /info@wegwichtel\.de/);
assert.doesNotMatch(article, /info@grundschule-herpf|Frau Wichert|Landkreis Schmalkalden|Staatliches Schulamt|data-i18n/);
}
assert.match(html, /Grundschule Herpf soll die App nach einer gesonderten Nutzungsfreigabe verwenden/);
});
+13
View File
@@ -762,3 +762,16 @@ test('route selection reads metadata only and station media is loaded and releas
assert.equal(await api.getAppPoi('missing', 0), null);
assert.equal(api.get('lazy').pois[0].images[0].url, undefined);
});
test('submission status persists with the server ID and rejects unknown status values', async () => {
const { api, localStorage } = await loadLocalRoutes();
const id = api.createRouteId();
for (const submissionStatus of ['rejected', 'removed', null, 'invalid']) {
const expected = submissionStatus === 'invalid' ? null : submissionStatus;
api.save({ id, gpx: api.emptyGpx('Waldweg'), route: { name: 'Waldweg', serverRouteId: 42, submissionStatus }, pois: [] });
assert.equal(api.get(id).route.submissionStatus, expected);
assert.equal(api.get(id).route.serverRouteId, 42);
const stored = JSON.parse(localStorage.getItem(`${api.STORAGE_PREFIX}.${encodeURIComponent(id)}.json`));
assert.equal(stored.route.submissionStatus, expected);
}
});