diff --git a/docs/Bilder-und-Ton-Nutzungsrechte.md b/docs/Bilder-und-Ton-Nutzungsrechte.md
index eb49e99..d134d0e 100644
--- a/docs/Bilder-und-Ton-Nutzungsrechte.md
+++ b/docs/Bilder-und-Ton-Nutzungsrechte.md
@@ -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
diff --git a/docs/CREATOR-ID.md b/docs/CREATOR-ID.md
index 3a1452d..3b5f723 100644
--- a/docs/CREATOR-ID.md
+++ b/docs/CREATOR-ID.md
@@ -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
diff --git a/docs/PUBLICATION-CHECKLIST.md b/docs/PUBLICATION-CHECKLIST.md
index caef136..b39ebc8 100644
--- a/docs/PUBLICATION-CHECKLIST.md
+++ b/docs/PUBLICATION-CHECKLIST.md
@@ -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
diff --git a/docs/Projektverantwortung.md b/docs/Projektverantwortung.md
new file mode 100644
index 0000000..a03bd55
--- /dev/null
+++ b/docs/Projektverantwortung.md
@@ -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.
diff --git a/docs/agents/AGENTS.md b/docs/agents/AGENTS.md
index bf719df..e625123 100644
--- a/docs/agents/AGENTS.md
+++ b/docs/agents/AGENTS.md
@@ -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.
diff --git a/docs/agents/TODO-MEDIENRECHTE.md b/docs/agents/TODO-MEDIENRECHTE.md
index bed82c1..7986fed 100644
--- a/docs/agents/TODO-MEDIENRECHTE.md
+++ b/docs/agents/TODO-MEDIENRECHTE.md
@@ -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.
diff --git a/docs/agents/TODO-MODERATION.md b/docs/agents/TODO-MODERATION.md
index 03fef1b..1faacd2 100644
--- a/docs/agents/TODO-MODERATION.md
+++ b/docs/agents/TODO-MODERATION.md
@@ -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.
diff --git a/docs/agents/TODO-STRECKENDUPLIKATE.md b/docs/agents/TODO-STRECKENDUPLIKATE.md
new file mode 100644
index 0000000..d3410d6
--- /dev/null
+++ b/docs/agents/TODO-STRECKENDUPLIKATE.md
@@ -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.
diff --git a/public/index.html b/public/index.html
index 04d4baf..57ed63c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -312,14 +312,10 @@
- Hinweis: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.
+ Hinweis:Die Anschrift, Angaben zum Hosting und zur Speicherung von Verbindungsdaten müssen noch ergänzt werden.
1. Verantwortliche Stelle und Datenschutzkontakt
-
- Staatliche Grundschule Herpf
Zum Eichig 3
98617 Meiningen / OT Herpf
Telefon: (036943) 63722
E-Mail: info@grundschule-herpf.de
- Datenschutzangaben der Schule:
- Staatliches Schulamt Südthüringen
Datenschutzbeauftragter
Hölderlinstraße 1
98527 Suhl
Telefon: 03681 / 7341 25
Fax: 03681 / 7341 09
E-Mail: datenschutz.suedthueriungen@schulamt.thueringen.de
- Der Datenschutzbeauftragte kann außerdem über die Postadresse der Schule mit dem Zusatz „der Datenschutzbeauftragte“ erreicht werden.
- Für Fragen zu Wegwichtel, Datenschutzanfragen, Widerruf und Entfernung von Aufnahmen erreichst du die Betreiber unter info@wegwichtel.de.
+ Florian Zumpe
[Anschrift von Florian Zumpe ergänzen]
E-Mail: info@wegwichtel.de
+ Florian Zumpe entwickelt, betreibt und pflegt Wegwichtel. Fragen zum Datenschutz, Auskunfts- und Löschanfragen sowie Widerrufe kannst du an die Kontaktadresse oben senden.
2. Wenn du Wegwichtel öffnest
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.
Wer die Website bereitstellt und welche Verbindungsdaten wie lange gespeichert werden:[Anbieter, Anschrift, gespeicherte Daten und Speicherdauer ergänzen]
@@ -356,24 +352,18 @@
Thüringer Landesbeauftragter für den Datenschutz und die Informationsfreiheit (TlfDI)
Postfach 90 04 55, 99107 Erfurt
Häßlerstraße 8, 99096 Erfurt
Telefon: 0361 / 57 311 29 00
Telefax: 0361 / 57 311 29 04
E-Mail: poststelle@datenschutz.thueringen.de
Website: www.tlfdi.de
- Hinweis: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.
- Wer für Wegwichtel verantwortlich ist
-
- Staatliche Grundschule Herpf
Zum Eichig 3
98617 Meiningen / OT Herpf
- Kontakt
- Telefon: (036943) 63722
Telefax: (036943) 63843
E-Mail: info@grundschule-herpf.de
- Kontakt für Wegwichtel: info@wegwichtel.de
- Leitung der Schule
- Frau Wichert, Schulleiterin
- Schulträger
-
- Landkreis Schmalkalden-Meiningen
Landratsamt
Obertshäuser Platz 1
98617 Meiningen
+ Hinweis:Die Anschrift und gegebenenfalls weitere Pflichtangaben müssen vor der Veröffentlichung ergänzt werden.
+ Inhaber und Betreiber von Wegwichtel
+ Florian Zumpe
[Anschrift von Florian Zumpe ergänzen]
E-Mail: info@wegwichtel.de
+ Entwicklung und Pflege
+ 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.
Inhaltlich verantwortlich
- Frau Wichert
+ Florian Zumpe
Technisch verantwortlich
Florian Zumpe
[Anschrift von Florian Zumpe ergänzen]
E-Mail: info@wegwichtel.de
Weitere Pflichtangaben
- 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.
+ [Weiteren unmittelbaren Kontaktweg ergänzen]
+ [Weitere für den tatsächlichen Betrieb erforderliche Pflichtangaben prüfen und gegebenenfalls ergänzen]
Haftung für Inhalte und Verweise
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.
Bildquelle der Gruppensymbole
@@ -382,10 +372,10 @@
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.
- Entwurf: Diese Vereinbarung muss vor ihrer verbindlichen Verwendung rechtlich geprüft werden. Betreiber und das Verfahren zur Zustimmung und Urhebernennung sind noch zu ergänzen.
- Stand: 4. Oktober 2026
+ Entwurf: 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.
+ Stand: 17. Oktober 2026
1. Für wen diese Vereinbarung gilt
- Diese Vereinbarung regelt das Einsenden von Bildern und Tonaufnahmen an Wegwichtel. Nutzungsrechte werden dem Betreiber [vollständiger Name und Anschrift des tatsächlichen Betreibers ergänzen] eingeräumt. Die bloße Nutzung der App oder das Speichern eines Entwurfs auf deinem Gerät räumt keine Rechte ein.
+ Diese Vereinbarung regelt das Einsenden von Bildern und Tonaufnahmen an Wegwichtel. Nutzungsrechte werden dem Betreiber Florian Zumpe, [Anschrift von Florian Zumpe ergänzen] eingeräumt. Die bloße Nutzung der App oder das Speichern eines Entwurfs auf deinem Gerät räumt keine Rechte ein.
2. Erlaubnis für Bilder und Tonaufnahmen
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.
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.
diff --git a/public/js/editor-ui.js b/public/js/editor-ui.js
index 4995a10..4dec663 100644
--- a/public/js/editor-ui.js
+++ b/public/js/editor-ui.js
@@ -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;
- if (existing.activatedAt || existing.activated_at) {
- throw new Error(ns.I18n.t("editor.read-only-hint"));
+ 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 || [] };
}
- 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));
diff --git a/public/js/local-routes.js b/public/js/local-routes.js
index 5a04d37..e0fb2ca 100644
--- a/public/js/local-routes.js
+++ b/public/js/local-routes.js
@@ -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) => ({
diff --git a/public/js/texts-de.js b/public/js/texts-de.js
index 4e8566a..88bc414 100644
--- a/public/js/texts-de.js
+++ b/public/js/texts-de.js
@@ -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.",
diff --git a/public/js/texts-en.js b/public/js/texts-en.js
index c92603d..c26e9da 100644
--- a/public/js/texts-en.js
+++ b/public/js/texts-en.js
@@ -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.",
diff --git a/test/backend-services.test.js b/test/backend-services.test.js
index 47e3be5..9ac0021 100644
--- a/test/backend-services.test.js
+++ b/test/backend-services.test.js
@@ -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);
diff --git a/test/editor-ui.test.js b/test/editor-ui.test.js
index 49b2e8a..b600888 100644
--- a/test/editor-ui.test.js
+++ b/test/editor-ui.test.js
@@ -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: '',
+ 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');
diff --git a/test/legal-ui.test.js b/test/legal-ui.test.js
index 0c5bca1..e5abcc8 100644
--- a/test/legal-ui.test.js
+++ b/test/legal-ui.test.js
@@ -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(`]+data-info-page="${name}"[\\s\\S]*?`))[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/);
+});
diff --git a/test/local-routes.test.js b/test/local-routes.test.js
index edf9aa1..b9a36a7 100644
--- a/test/local-routes.test.js
+++ b/test/local-routes.test.js
@@ -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);
+ }
+});