Multiple improvements
Sonarqube Scanner / Prepare environment (push) Successful in 10s
Sonarqube Scanner / Build and test (push) Successful in 28s
Sonarqube Scanner / SonarQube analysis (push) Successful in 1m8s
Sonarqube Scanner / Deploy to Server (push) Successful in 8s

This commit is contained in:
2026-10-05 01:18:14 +02:00
parent 2b07e9bc47
commit 49a9de0355
27 changed files with 797 additions and 80 deletions
+7 -2
View File
@@ -46,12 +46,17 @@ 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;
- Kontakt für Rechtefragen, Widerruf und Entfernung;
- 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.
Außerdem müssen die Texte und das Zustimmungsverfahren zum tatsächlichen
Betriebsmodell der Schule passen und rechtlich geprüft werden. Die bereits
vorhandenen Kontaktangaben im Impressum wurden nicht verändert. Die frühere
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.
+2 -1
View File
@@ -76,5 +76,6 @@ stehen in [TODO-MODERATION.md](agents/TODO-MODERATION.md) weiterhin aus.
Die GPS-Aufzeichnung läuft nur innerhalb einer Bearbeitungssitzung. Schließen,
Abbrechen, Rückkehr zur Liste, Streckenwechsel und Verlassen der Seite beenden
sie; ein Neustart übernimmt keinen aktiven Aufzeichnungszustand. Beim erneuten
Öffnen eines lokalen Entwurfs beginnt eine neue Sitzung. Einzelheiten stehen in
Öffnen eines lokalen Entwurfs beginnt eine neue Sitzung mit ausgeschalteter
Aufnahme. Der Schalter „Meinen Weg aufnehmen“ aktiviert sie. Einzelheiten stehen in
[GPX-Aufzeichnung.md](agents/GPX-Aufzeichnung.md).
+37
View File
@@ -0,0 +1,37 @@
# Entwürfe ohne Internet bearbeiten
„Meine Entwürfe“ erscheint sofort beim Öffnen der Streckenbearbeitung. Erst danach
werden veröffentlichte Strecken geladen. Du kannst deine Entwürfe auch bearbeiten,
wenn der Server nicht erreichbar ist. Lade die App zuvor auf dein Gerät.
## Den Weg aufnehmen
Schalte „Meinen Weg aufnehmen“ beim Bearbeiten eines Entwurfs ein. Dein Standort
wird sofort und danach alle zwei Minuten auf diesem Gerät gespeichert. Speichere
einen neuen Entwurf zuerst. Beim Verlassen der Bearbeitung endet die Aufnahme.
Beim nächsten Öffnen ist sie wieder aus. Du kannst die Aufnahme auch pausieren.
Erlaube der App den Zugriff auf deinen Standort. Das funktioniert auch ohne Internet,
wenn dein Gerät seinen Standort bestimmen kann. Ohne Standort bleibt ein leerer
Eintrag, den du später ergänzen kannst. Bei gesperrtem Bildschirm oder im Hintergrund
kann der Internetbrowser die Aufnahme verzögern.
## Einen Ort ergänzen
Unter „Orte auf dem Weg ergänzen“ setzt das Zielkreuz deinen aktuellen Standort
in die beiden Felder des gewählten Punktes ein. Speichere danach deine Änderungen.
Die Aufnahme wird dadurch nicht eingeschaltet.
## Bilder und Tonaufnahmen
Beim Öffnen oder Speichern einer Strecke werden die Bilder und Tonaufnahmen
nicht alle geladen. Erst wenn du eine Station ansiehst oder bearbeitest,
werden deren Dateien geöffnet. Auch beim Senden zur Prüfung werden die
benötigten Dateien gelesen.
## Den Weg zur Prüfung senden
Beim Senden werden die aufgenommenen Orte und die Stationen mit bekanntem
Standort zu einem gemeinsamen Weg verbunden. Auch ohne Aufnahme können die
Stationen einen Weg bilden. Fehlende Orte kannst du später ergänzen.
Die Verbindung zeigt nicht automatisch den Verlauf von Straßen oder Wanderwegen.
+2 -1
View File
@@ -7,7 +7,8 @@ Die in der Anwendung enthaltenen Texte sind Vorlagen und ersetzen keine Prüfung
- vollständiger Name und Rechtsform der Betreiberin
- vertretungsberechtigte Person
- ladungsfähige Anschrift
- E-Mail-Adresse und weiterer unmittelbarer Kontaktweg
- eingerichtete Kontaktadresse für Wegwichtel: info@wegwichtel.de
- weiterer unmittelbarer Kontaktweg
- inhaltlich verantwortliche Person
- gegebenenfalls Aufsichtsbehörde, Register und steuerliche Pflichtangaben
+1 -1
View File
@@ -10,7 +10,7 @@ Alle folgenden Befehle werden im Projektverzeichnis ausgeführt.
## Architektur
Die Trennung von Stationen und Weg, die Aufzeichnung alle 30 Sekunden und das
Die Trennung von Stationen und Weg, die zuschaltbare Aufzeichnung alle zwei Minuten und das
nachträgliche Ergänzen fehlender Koordinaten sind in
[`GPX-Aufzeichnung.md`](agents/GPX-Aufzeichnung.md) beschrieben.
+32
View File
@@ -51,6 +51,13 @@ Die App richtet sich an Grundschulkinder und Menschen ohne technische Vorkenntni
und Code-Kommentare. Code-Bezeichner, Dateiformate und Schnittstellen bleiben fachlich präzise.
- Prüfe neue Texte und angepasste Text-Erwartungen in den vorhandenen Tests.
## Kontaktadresse
Die eingerichtete Kontaktadresse für Wegwichtel ist `info@wegwichtel.de`.
Sie gilt für App-Kontakt, technische Fragen, Rechtefragen, Datenschutzanfragen,
Widerruf und Entfernung von Aufnahmen. In Rechtstexten statisch einsetzen.
Schul- und Behördenadressen bleiben die Kontakte der jeweiligen Stellen.
## Texte und Übersetzungen
- Alle eigenen Bedienungstexte liegen zentral in der Klasse `WegwichtelTexts`
@@ -83,3 +90,28 @@ Die App richtet sich an Grundschulkinder und Menschen ohne technische Vorkenntni
- Deutsch und Englisch enthalten dieselben Schlüssel, Platzhalter und Pluralformen.
Texte in beiden Sprachen einfach und sinngemäß schreiben. Sprachwechsel aktualisieren feste Beschriftungen;
bereits erzeugte dynamische Ansichten bei Bedarf neu rendern.
## Ausrichtung von Bedienelementen
Button-Gruppen in Strecken- und Stationslisten am rechten Kartenrand ausrichten.
Icon-Buttons bekommen einheitliche Breiten, Höhen und Abstände, auch wenn sie
inaktiv sind. Titel dürfen umbrechen und verändern die Button-Spalten nicht.
Die Bedienflächen bleiben mindestens 44 × 44 Pixel groß.
## Lokale Bilder und Tonaufnahmen erst bei Bedarf laden
Beim Öffnen oder Speichern einer Strecke und beim Umordnen von Stationen
nur Metadaten lesen. Keine Dateien aller Stationen hydratisieren.
Dateireferenzen bzw. IndexedDB-Dateien erst beim Bearbeiten oder Aufrufen
der jeweiligen Station lesen. Der Upload liest die benötigten Dateien direkt.
Temporäre Blob-URLs beim Verlassen der Ansicht freigeben; verspätete
Ladeergebnisse dürfen eine andere Station oder geschlossene Ansicht nicht ändern.
## Stationsorte in der Routenberechnung
Beim Hochladen und erneuten Berechnen sowohl aufgezeichnete Wegpositionen als
auch `wpt` mit gültigen Koordinaten berücksichtigen. Stationsanker dürfen nicht
durch die GPS-Clusterbildung wegfallen. Nur gültige Koordinaten einbeziehen;
Stationen ohne Standort bleiben bearbeitbar. Berechnete Stationsanker im GPX
kennzeichnen und bei Verschieben/Löschen ersetzen, statt veraltete Orte zu behalten.
Rätsel-Stationen sind für später in `docs/agents/TODO-RAETSEL-STATIONEN.md` vorgemerkt.
+28 -8
View File
@@ -3,12 +3,12 @@
## Stationen und Weg getrennt speichern
- Stationen mit Koordinaten stehen als `wpt` unmittelbar unter `gpx`.
- Beim ersten Speichern eines neuen Entwurfs startet während der Bearbeitung eine Aufzeichnung:
sofort eine Position, danach alle 30 Sekunden eine neue GPS-Abfrage.
- Der Schalter „Meinen Weg aufnehmen“ ist beim Start und beim Öffnen eines
Entwurfs aus. Einschalten fragt sofort den Standort ab, danach alle zwei Minuten.
Bei noch nicht gespeicherten Entwürfen beginnt die Aufnahme nach dem ersten Speichern.
Schließen, Abbrechen, Rückkehr zur Streckenliste oder Wechsel der Strecke
beendet die Aufzeichnung. Nach einem Neustart ist sie inaktiv. Beim erneuten
Öffnen eines lokalen Entwurfs zur Bearbeitung beginnt eine neue Sitzung.
Speichern innerhalb derselben Sitzung hebt Pause oder bewusstes Stoppen nicht auf.
beendet die Aufzeichnung und schaltet sie aus. Speichern innerhalb derselben
Sitzung hebt Pause oder bewusstes Stoppen nicht auf.
- Die sichtbaren Schaltflächen erlauben Pausieren, Fortsetzen und Beenden.
Beim Löschen des Entwurfs endet die Aufzeichnung ebenfalls.
- Tatsächliche Positionen stehen als `trk/trkseg/trkpt` mit Zeitstempel und,
@@ -28,6 +28,9 @@ Positionen. Speicherfehler pausieren die Aufzeichnung und zeigen einen Hinweis.
Entwürfe und Stationen dürfen ohne GPS erstellt und gespeichert werden.
In der Streckenbearbeitung können unter „Orte auf dem Weg ergänzen“ fehlende
Positionen ergänzt, vorhandene geändert und weitere hinzugefügt werden.
Das Zielkreuz an jedem Wegpunkt fragt frische GPS-Koordinaten ab und füllt nur
dessen Felder. Es startet keine Aufnahme; die Änderung muss gespeichert werden.
Verspätete Antworten nach dem Wechseln oder Schließen werden verworfen.
Breiten- und Längengrad müssen gemeinsam angegeben werden oder beide leer bleiben.
Stationen haben eigene Felder unter „Ort ergänzen“.
@@ -56,18 +59,34 @@ Coverage-Lauf geprüft.
## Normalisierung auf dem Server
Beim Einreichen oder Ersetzen einer GPX-Datei normalisiert der Node-Dienst den
aufgezeichneten Weg zu `rte/rtept`. Bereits vorhandene Routenpunkte haben Vorrang.
aufgezeichneten Weg und gültige Stations-Wegpunkte zu `rte/rtept`. Bereits
vorhandene Routenpunkte bilden anstelle von Trackpunkten den Grundverlauf.
Aufeinanderfolgende Trackpunkte innerhalb des eingestellten Abstands werden
zusammengefasst. Der erste Punkt bleibt der Vertreter; der nächste wird erst
übernommen, wenn er weiter entfernt liegt. So werden kleine Schwankungen
während einer Pause nicht fortlaufend zur Weglänge addiert.
Nach dem Vereinfachen des Tracks werden alle gültigen `wpt` ergänzt. Sie werden
nicht durch den Clusterabstand entfernt. Ihre Reihenfolge in der Datei bleibt
erhalten; der Einfügeort im verbleibenden Weg wird anhand der kleinsten
zusätzlichen Weglänge gewählt. Übereinstimmende Orte werden zusammengeführt.
Eine Strecke mit nur Stationen erhält einen Weg in Stationsreihenfolge.
Weglänge, Routenpunktzahl und Suchgrenzen beziehen sich auf den gemeinsamen Weg.
Aus Stationen ergänzte Routenpunkte tragen in ihren GPX-Erweiterungen die
Kennung `wegwichtel:waypointRoutePoint`. Dadurch lassen sich bei erneuter
Normalisierung bzw. Verschieben/Löschen einer Station alte ergänzte Anker
entfernen und anhand der aktuellen `wpt` neu berechnen. Ursprüngliche Wegpunkte
im aufgezeichneten Verlauf bleiben erhalten. Beim Anhängen einer anderen Strecke
wird deren berechneter Weg als zusätzlicher Verlauf übernommen. Wiederholte
Normalisierung darf keine zusätzlichen Stationsanker erzeugen.
`GPX_CLUSTER_METERS` in `.env` steuert den Abstand, standardmäßig 20 Meter.
Das Verfahren ist eine geometrische Vereinfachung ohne externen Routingdienst.
Es berechnet keine Wege auf einer Straßenkarte. GPS-Lücken werden bei der
Routenberechnung übersprungen; zwischen gültigen Nachbarpunkten entsteht eine
direkte Verbindung. Die Lücken bleiben für spätere Bearbeitung als Erweiterungen
erhalten. Gibt es keine gültigen Wegpositionen, bleiben Weglänge und Punktzahl
erhalten. Gibt es weder gültige Wegpositionen noch Stationen mit Koordinaten, bleiben Weglänge und Punktzahl
bei 0 Metern und 0 Punkten; Stationen bleiben trotzdem gespeichert.
Die gespeicherte Serverdatei enthält den normalisierten Weg. Änderungen an
@@ -82,7 +101,8 @@ Zwei Pfeile zeigen gleichzeitig die Richtung:
- zum nächsten Routenpunkt, ohne Entfernungsangabe;
- zur nächsten noch offenen Station mit Koordinaten, mit Metern.
Bei einer Strecke mit ausschließlich Stationen fehlt nur die Wegführung.
Bei einem lokalen Entwurf mit ausschließlich Stationen fehlt vor dem Hochladen
die Wegführung. Nach dem Hochladen verbindet der berechnete Weg die Stationen.
Stationen ohne Koordinaten lösen keine Standort-Erkennung aus. Während einer
Pause werden die Pfeile verborgen.
+5 -1
View File
@@ -4,9 +4,13 @@ Ausgangspunkt: [Recherche und umgesetzte Hinweise](../Bilder-und-Ton-Nutzungsrec
Die statischen Rechtstexte und X-Copyright sind eingebunden; ein belegbares
Zustimmungs- und Namensnennungssystem ist noch nicht umgesetzt.
Kontakt für Wegwichtel, Rechtefragen, Datenschutzanfragen, Widerruf und Entfernung:
`info@wegwichtel.de` (vom Betreiber eingerichtet). Diesen Kontakt nicht erneut
als offenen Platzhalter führen.
## Vor verbindlicher Verwendung und Freigabe zu erledigen
1. Den tatsächlichen Lizenznehmer, Rechtekontakt, Rechtsgrundlagen und Fristen
1. Den tatsächlichen Lizenznehmer, 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
+36
View File
@@ -0,0 +1,36 @@
# TODO: Rätsel-Stationen
Vom Nutzer am 04.10.2026 vorgemerkt. Noch nicht implementieren.
## Gewünschtes Verhalten
- Eine Station kann als Rätsel-Station markiert werden und online gespeichert sein.
- Zunächst Beschreibung mit einem Rätseltext bzw. Hinweis zum Ermitteln der
Koordinaten anzeigen. Dafür ein eigenes Beschreibungsfeld vorsehen.
- Statt der echten Position zunächst Eingabefelder für Breitengrad und Längengrad
anbieten. Bedienungstexte einfach halten und aus DE-/EN-Katalogen beziehen.
- Eingegebene Koordinaten serverseitig mit dem Ziel vergleichen. Liegen sie nahe
genug, die Station freischalten und erst dann die echte Position abrufbar machen.
- Nach der Freischaltung Station wie eine normale Station anzeigen und verwenden.
## Lösungsansätze und offene Entscheidungen
- Rätselart, Hinweistext und akzeptierten Abstand in Metern im Stationsmodell
vorsehen; geeigneten Standardabstand und Wahlmöglichkeit später festlegen.
- Distanz auf dem Server berechnen. Die Prüfung ausschließlich im Client würde
die echte Position vorzeitig offenlegen. Wertebereiche validieren und falsche
Eingaben mit verständlichem Hinweis beantworten.
- Freischaltung an eine Bearbeitungs-/Spielgruppe, Sitzung oder Gerätekennung
binden. Die vorhandene creator_id ist allein keine sichere Authentifizierung.
Entscheiden, ob die Freischaltung gespeichert wird und Gruppen sie teilen.
- Echte Position vorher aus öffentlichen Stationsantworten, GPX-Wegpunkten,
Routenpunkten, Richtungspfeilen und Offline-Paketen heraushalten. Auch Suchgrenzen,
Medienmetadaten und geordnete Wegverläufe können die Lösung verraten. Diese
Auswirkungen auf die neue gemeinsame Routenberechnung ausdrücklich evaluieren.
- Für den späteren Koordinatenabruf eine überprüfbare Freigabe verwenden;
wiederholte Versuche angemessen begrenzen. Kein bloßes UI-Verstecken.
- Offline-Verhalten festlegen: serverseitige Prüfung braucht eine Verbindung;
eine vollständig offline prüfbare Lösung hat andere Schutzgrenzen.
- Tests für Treffer innerhalb des erlaubten Abstands, Grenzfälle, falsche und
ungültige Eingaben, Geräte-/Gruppenwechsel sowie verfrühten Koordinatenzugriff
und Datenlecks in GPX/Offline-Paketen vorsehen.
+21 -5
View File
@@ -465,12 +465,18 @@
background: #fff;
border: 1px solid rgba(49, 93, 58, .18);
border-radius: .9rem;
display: flex;
display: grid;
gap: .75rem;
justify-content: space-between;
grid-template-columns: minmax(0, 1fr) auto;
padding: .75rem;
}
.editor-route-item > :first-child,
.editor-poi-item > :first-child {
min-width: 0;
overflow-wrap: anywhere;
}
.editor-route-item strong,
.editor-poi-item strong {
display: block;
@@ -517,16 +523,23 @@
}
.editor-icon-row {
display: flex;
flex: 0 0 auto;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 2.75rem;
gap: .35rem;
justify-self: end;
}
.editor-icon-button {
align-items: center;
display: inline-flex;
justify-content: center;
min-width: 2.4rem;
box-sizing: border-box;
width: 2.75rem;
min-width: 2.75rem;
height: 2.75rem;
margin: 0;
padding: .35rem;
}
.editor-icon-danger {
@@ -577,3 +590,6 @@
grid-template-columns: 1fr;
}
}
.current-location-button { min-width: 44px; min-height: 44px; }
.current-location-button img { width: 24px; height: 24px; vertical-align: middle; }
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#244c34" stroke-width="2"><circle cx="12" cy="12" r="7"/><circle cx="12" cy="12" r="2"/><path d="M12 1v5m0 12v5M1 12h5m12 0h5"/></svg>

After

Width:  |  Height:  |  Size: 215 B

+14 -4
View File
@@ -319,6 +319,7 @@
<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>
<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>
@@ -348,7 +349,7 @@
<p>Die Nutzungsvereinbarung regelt, welche Nutzungsrechte du für diese Zwecke einräumst. Die Urheberrechte bleiben bei den jeweiligen Urhebern. Eine Nutzungserlaubnis für ein Bild oder eine Tonaufnahme ersetzt keine erforderliche Einwilligung der darin erkennbaren oder hörbaren Personen. Diese Datenschutzerklärung allein erteilt keine solche Erlaubnis.</p>
<p>Personenbezogene Aufnahmen werden nur veröffentlicht, wenn eine passende Rechtsgrundlage vorliegt. Soweit eine Einwilligung erforderlich ist, muss sie vor der Veröffentlichung eingeholt werden. Bei Kindern sind insbesondere die erforderlichen Zustimmungen ihrer Sorgeberechtigten zu beachten. Eine Einwilligung kann jederzeit für die Zukunft widerrufen werden; die Rechtmäßigkeit der bis dahin erfolgten Verarbeitung bleibt davon unberührt.</p>
<p>Bei einem Widerruf oder einer berechtigten Löschanfrage prüfen die Betreiber die weitere Nutzung und entfernen die betroffenen Aufnahmen, soweit dies rechtlich erforderlich ist, aus der öffentlichen Bereitstellung. Bereits gespeicherte Kopien auf fremden Geräten können sie nicht vollständig zurückholen. Nicht öffentliche Sicherungskopien und Nachweise dürfen nur im rechtlich erforderlichen Umfang und für die dafür festgelegte Dauer aufbewahrt werden.</p>
<p><strong>[Vor Veröffentlichung ergänzen: Rechtsgrundlagen für Einsendung, Prüfung, Veröffentlichung und Rechtenachweise; Empfänger und Hostinganbieter; Lösch- und Sicherungsfristen; Kontakt für Widerruf und Entfernung.]</strong></p>
<p><strong>[Vor Veröffentlichung ergänzen: Rechtsgrundlagen für Einsendung, Prüfung, Veröffentlichung und Rechtenachweise; Empfänger und Hostinganbieter; Lösch- und Sicherungsfristen.]</strong></p>
<h3>9. Deine Rechte</h3>
<p>Du kannst fragen, welche Daten über dich gespeichert sind. Unter den gesetzlichen Voraussetzungen kannst du verlangen, dass sie berichtigt, gelöscht, nur noch eingeschränkt genutzt oder dir zur Weitergabe bereitgestellt werden. Du kannst der Nutzung widersprechen und eine erteilte Erlaubnis für die Zukunft zurücknehmen. Wenn du dich über den Umgang mit deinen Daten beschweren möchtest, kannst du dich an die folgende Datenschutzbehörde wenden.</p>
<p><span>Zuständige Datenschutzaufsicht:</span><br>
@@ -361,6 +362,7 @@
<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>
@@ -369,7 +371,7 @@
<h3>Inhaltlich verantwortlich</h3>
<p>Frau Wichert</p>
<h3>Technisch verantwortlich</h3>
<p>Florian Zumpe<br><span>[Anschrift von Florian Zumpe ergänzen]</span><br><span>E-Mail: [E-Mail-Adresse von Florian Zumpe ergänzen]</span></p>
<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>
<h3>Haftung für Inhalte und Verweise</h3>
@@ -380,7 +382,7 @@
<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, Kontakt und das Verfahren zur Zustimmung und Urhebernennung sind noch zu ergänzen.</p>
<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>
<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>
@@ -395,7 +397,7 @@
<p>Du darfst nur Aufnahmen senden, die du selbst erstellt hast oder für die du alle erforderlichen Erlaubnisse besitzt, um diese Nutzungsrechte einzuräumen. Bei Tonaufnahmen sind auch die Rechte an Musik, gesprochenen Texten, Darbietungen und der Aufnahme selbst zu beachten. Die Erlaubnis zum privaten Anhören oder zum eigenen Gebrauch reicht für eine Veröffentlichung in Wegwichtel nicht aus.</p>
<p>Auch die Rechte erkennbarer oder hörbarer Personen müssen beachtet werden. Eine Nutzungserlaubnis des Fotografen ersetzt nicht die erforderliche Einwilligung der abgebildeten Person. Kinder brauchen für die Rechteeinräumung die erforderliche Zustimmung ihrer gesetzlichen Vertreter. Sorgeberechtigte dürfen Rechte anderer Urheber nicht ohne deren Erlaubnis einräumen.</p>
<h3>5. Prüfung, Entfernung und Beschwerden</h3>
<p>Die Betreiber sind nicht verpflichtet, eingesandte Aufnahmen zu veröffentlichen. Wenn Rechte unklar sind oder eine berechtigte Beanstandung vorliegt, dürfen sie die Veröffentlichung aussetzen oder die Aufnahme entfernen. Fragen zu Urheberschaft, Nutzungsrechten, Namensnennung oder Entfernung richtest du an <strong>[Kontakt für Rechtefragen ergänzen]</strong>. Datenschutzrechtliche Einwilligungen und Widerrufe werden getrennt von den urheberrechtlichen Nutzungsrechten geprüft.</p>
<p>Die Betreiber sind nicht verpflichtet, eingesandte Aufnahmen zu veröffentlichen. Wenn Rechte unklar sind oder eine berechtigte Beanstandung vorliegt, dürfen sie die Veröffentlichung aussetzen oder die Aufnahme entfernen. Fragen zu Urheberschaft, Nutzungsrechten, Namensnennung oder Entfernung richtest du an <a href="mailto:info@wegwichtel.de">info@wegwichtel.de</a>. Datenschutzrechtliche Einwilligungen und Widerrufe werden getrennt von den urheberrechtlichen Nutzungsrechten geprüft.</p>
<h3>6. Hinweis bei ausgelieferten Bildern</h3>
<p>Der technische Rechtehinweis bei Bildern informiert über diese Nutzungsregeln. Er ersetzt weder die Urhebernennung bei der Aufnahme noch einen Nachweis über die tatsächlich erteilte Nutzungserlaubnis. Er verhindert auch nicht das Kopieren einer Aufnahme.</p>
</article>
@@ -485,6 +487,14 @@
<input id="editor-route-gpx" type="file" accept=".gpx,application/gpx+xml,text/xml,application/xml">
</div>
<section id="editor-recording-options">
<label class="switch-row" for="route-recording-toggle">
<span data-i18n="recorder.toggle">Meinen Weg aufnehmen</span>
<input id="route-recording-toggle" type="checkbox" role="switch" aria-checked="false">
<span class="switch-track" aria-hidden="true"></span>
</label>
<p class="editor-muted" data-i18n="recorder.toggle-help">Alle zwei Minuten wird dein Standort auf diesem Gerät gespeichert. Die Aufnahme endet, wenn du die Bearbeitung verlässt.</p>
</section>
<details id="editor-path-positions">
<summary data-i18n="recorder.edit-path">Orte auf dem Weg ergänzen</summary>
<p data-i18n="recorder.edit-help">Fehlende Orte kannst du später eintragen. Leere Felder bleiben gespeichert.</p>
+39 -1
View File
@@ -22,6 +22,19 @@
persistTimer: null
};
let poiMediaGeneration = 0;
let localPoiMedia = null;
function releaseLocalPoiMedia() {
poiMediaGeneration += 1;
if (localPoiMedia) {
ns.AudioPlayer.unload();
ns.Slideshow.show([]);
ns.LocalRoutes.releasePoiMedia(localPoiMedia);
localPoiMedia = null;
}
}
function escapeHtml(value) {
return $('<div>').text(value ?? '').html();
}
@@ -88,6 +101,7 @@
const target = $(pageSelector);
if (!target.length) throw new Error(ns.I18n.t("app.page.missing"));
if (pageSelector !== '#poi-page') releaseLocalPoiMedia();
const previous = state.activePage;
$('.app-page').attr('hidden', true);
target.removeAttr('hidden');
@@ -287,6 +301,7 @@
}
function endRoute({ announce = true, returnToRoutes = false } = {}) {
releaseLocalPoiMedia();
const wasActive = state.routeState !== 'idle';
state.routeState = 'idle';
persistRouteProgress();
@@ -330,6 +345,7 @@
}
function suspendCurrentRoute() {
releaseLocalPoiMedia();
if (!state.route) return;
if (state.routeState === 'running') state.routeState = 'paused';
persistRouteProgress();
@@ -383,6 +399,7 @@
}
function showPoi(poi) {
releaseLocalPoiMedia();
state.activePoiId = Number(poi.id);
state.viewed.add(state.activePoiId);
persistRouteProgress();
@@ -392,6 +409,27 @@
ns.Slideshow.show(poi.images);
ns.AudioPlayer.load(poi.audioUrl);
navigate('#poi-page');
if (state.route?.source === 'local') void loadLocalPoiMedia(poi);
}
async function loadLocalPoiMedia(poi) {
const route = state.route;
const requestGeneration = poiMediaGeneration;
try {
const loaded = await ns.LocalRoutes.getAppPoi(route.id, poi.id, { requestPermission: true });
if (requestGeneration !== poiMediaGeneration || state.route !== route) {
ns.LocalRoutes.releasePoiMedia(loaded);
return;
}
if (!loaded) return;
localPoiMedia = loaded;
ns.Slideshow.show(loaded.images);
ns.AudioPlayer.load(loaded.audioUrl);
} catch (error) {
if (requestGeneration === poiMediaGeneration) {
$('#tracking-status').prop('hidden', false).text(ns.UserMessages?.error(error) || ns.I18n.t('app.error.open-draft'));
}
}
}
function nearestRoutePointIndex(position) {
@@ -680,7 +718,7 @@
async function selectRoute(source, id) {
setRouteControlOpen(false);
const route = source === 'local'
? await ns.LocalRoutes.getAppRoute(id, { requestPermission: true })
? await ns.LocalRoutes.getAppRoute(id)
: { ...(await ns.Api.route(id)), source: 'server', local: false };
renderRoute(route);
navigate('#route-page');
+73 -24
View File
@@ -202,7 +202,17 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
return ns.I18n.t("editor.view-route");
}
let poiMediaGeneration = 0;
let previewPoi = null;
function releasePoiPreview() {
poiMediaGeneration += 1;
ns.LocalRoutes?.releasePoiMedia?.(previewPoi);
previewPoi = null;
}
function showListView() {
releasePoiPreview();
ns.RouteRecorder?.endEditing();
setDialogTitle('Streckenverwaltung');
setHidden(byId(ids.routeListView), false);
@@ -211,6 +221,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
function showRouteView() {
releasePoiPreview();
setDialogTitle(routeDialogTitle());
setHidden(byId(ids.routeListView), true);
setHidden(byId(ids.routeDetailView), false);
@@ -411,8 +422,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
list.textContent = '';
const serverRoutes = state.routes.filter(route => routeSource(route) === 'server');
const localRoutes = state.routes.filter(route => routeSource(route) === 'local');
renderRouteGroup(list, ns.I18n.t("editor.public-routes"), serverRoutes, ns.I18n.t("app.no-public-route"));
renderRouteGroup(list, ns.I18n.t("editor.drafts"), localRoutes, ns.I18n.t("editor.no-draft"));
renderRouteGroup(list, ns.I18n.t("editor.public-routes"), serverRoutes, ns.I18n.t("app.no-public-route"));
}
function renderPoiList() {
@@ -438,19 +449,30 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}));
}
let routeListGeneration = 0;
async function loadRoutes() {
setStatus(ns.I18n.t("editor.loading-routes"));
const payload = await readJson('/routes?activatedOnly=true');
const serverRoutes = routeArray(payload).map(route => ({ ...route, _source: 'server' }));
let localRoutes = [];
try {
localRoutes = localRouteSummaries();
} catch (error) {
setStatus(error.message, true);
}
state.routes = [...serverRoutes, ...localRoutes];
const requestGeneration = ++routeListGeneration;
state.routes = [...localRouteSummaries(), ...state.routes.filter(route => routeSource(route) === 'server')];
renderRouteList();
if (!byId(ids.status)?.dataset.kind || byId(ids.status).dataset.kind !== 'error') setStatus('');
setStatus('');
// Local drafts are usable even while this request is waiting for a connection.
void refreshPublicRoutes(requestGeneration);
}
async function refreshPublicRoutes(requestGeneration) {
const listIsVisible = () => byId(ids.dialog)?.open && !byId(ids.routeListView)?.hidden;
try {
const payload = await readJson('/routes?activatedOnly=true');
if (requestGeneration !== routeListGeneration) return;
const serverRoutes = routeArray(payload).map(route => ({ ...route, _source: 'server' }));
state.routes = [...localRouteSummaries(), ...serverRoutes];
renderRouteList();
} catch {
if (requestGeneration === routeListGeneration && listIsVisible()) {
setStatus(ns.I18n.t('editor.public-unavailable'));
}
}
}
async function openEditor() {
@@ -471,6 +493,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
function closeEditor() {
releasePoiPreview();
ns.RouteRecorder?.endEditing();
const dialog = byId(ids.dialog);
if (dialog?.open && typeof dialog.close === 'function') dialog.close();
@@ -499,6 +522,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function fillRouteForm(route) {
setHidden(byId('editor-path-positions'), state.currentSource !== 'local');
setHidden(byId('editor-recording-options'), state.currentSource !== 'local');
if (state.currentSource === 'local') ns.RouteRecorder?.renderPoints(route.gpx || ns.LocalRoutes.emptyGpx(route.name));
byId(ids.routeId).value = routeId(route);
byId(ids.routeName).value = routeName(route) === ns.I18n.t("editor.untitled-route") ? '' : routeName(route);
@@ -764,14 +788,14 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function loadRoute(route) {
const id = routeId(route);
if (!id) return;
if (ns.RouteRecorder?.routeId && (routeSource(route) !== 'local' || ns.RouteRecorder.routeId !== id)) {
ns.RouteRecorder.endEditing();
if (state.currentRoute && (routeSource(route) !== state.currentSource || routeId(state.currentRoute) !== id)) {
ns.RouteRecorder?.endEditing();
}
setStatus(ns.I18n.t("editor.route.loading"));
state.currentSource = routeSource(route);
if (state.currentSource === 'local') {
const routePackage = await ns.LocalRoutes.getWithMedia(id, { requestPermission: true });
const routePackage = ns.LocalRoutes.get(id);
if (!routePackage) throw new Error(ns.I18n.t("editor.error.draft-missing"));
state.currentRoute = {
...routePackage.route,
@@ -1048,6 +1072,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
function newPoi() {
releasePoiPreview();
const position = currentPosition();
const lat = position?.lat ?? null;
const lon = position?.lon ?? null;
@@ -1065,11 +1090,26 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
showPoiView();
}
function editPoi(poi) {
async function editPoi(poi) {
if (!poi) return;
releasePoiPreview();
state.currentPoi = poi;
fillPoiForm(poi);
byId(ids.poiFormTitle).textContent = ns.I18n.t("editor.edit-station");
showPoiView();
if (state.currentSource !== 'local') return;
const requestGeneration = poiMediaGeneration;
const hydrated = await ns.LocalRoutes.getPoiWithMedia(routeId(state.currentRoute), poi.id, { requestPermission: true });
if (requestGeneration !== poiMediaGeneration || state.currentPoi !== poi) {
ns.LocalRoutes.releasePoiMedia(hydrated);
return;
}
if (!hydrated) return;
previewPoi = hydrated;
state.currentPoi = hydrated;
// Preserve any text edits and newly selected files while existing previews load.
renderPoiMediaPreview();
updatePoiMediaStatus();
}
async function mediaFromLocalForm(existingPoi, poiIdentifier) {
@@ -1122,13 +1162,16 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
route: state.currentRoute,
pois: state.pois
});
const hydrated = await ns.LocalRoutes.getWithMedia(id) || saved;
state.currentRoute = { ...hydrated.route, id: hydrated.id, gpx: hydrated.gpx, _source: 'local' };
state.pois = hydrated.pois;
const previousPoi = state.currentPoi;
state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
state.pois = saved.pois;
state.currentPoi = currentPoiId
? state.pois.find(poi => poiId(poi) === currentPoiId) || null
: null;
return hydrated;
if (state.currentPoi && previousPoi) {
state.currentPoi = { ...state.currentPoi, audio: previousPoi.audio, images: previousPoi.images };
}
return saved;
}
async function saveLocalPoi(values) {
@@ -1241,7 +1284,10 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
updateCurrentPoi(updated);
await persistLocalCurrentRoute();
if (removedMedia) await ns.LocalRoutes.removeMedia(removedMedia);
if (removedMedia) {
ns.LocalRoutes.releasePoiMedia({ audio: removedMedia });
await ns.LocalRoutes.removeMedia(removedMedia);
}
state.selectedMediaKey = null;
renderPoiMediaPreview();
updatePoiMediaStatus();
@@ -1416,7 +1462,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function poiListAction(event) {
const action = event.target.closest('[data-editor-action]')?.dataset.editorAction;
if (action === 'edit-poi') {
editPoi(poiForItem(event.target));
await editPoi(poiForItem(event.target));
return;
}
if (action === 'delete-poi') {
@@ -1442,12 +1488,15 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function bind() {
for (const eventName of ['cancel', 'close']) {
byId(ids.dialog)?.addEventListener(eventName, () => ns.RouteRecorder?.endEditing());
byId(ids.dialog)?.addEventListener(eventName, () => {
releasePoiPreview();
ns.RouteRecorder?.endEditing();
});
}
activate(byId(ids.button), () => guarded(openEditor));
activate(byId(ids.close), closeEditor);
activate(byId(ids.newRoute), newRoute);
activate(byId(ids.routeBack), () => guarded(loadRoutes).then(showListView));
activate(byId(ids.routeBack), () => { showListView(); void guarded(loadRoutes); });
activate(byId(ids.routeUpload), () => guarded(uploadLocalRoute));
activate(byId(ids.rightsOpen), () => ns.LegalUi?.open('terms'));
activate(byId(ids.addPoi), newPoi);
+36 -7
View File
@@ -12,6 +12,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const MEDIA_STORE_NAME = 'media';
let mediaDatabasePromise = null;
let poiCounter = 0;
const objectUrls = new Set();
function storage() {
const target = globalThis.localStorage;
@@ -195,7 +196,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const blob = await mediaBlob(media, options);
if (!blob) return { ...media, unavailable: true };
if (typeof globalThis.URL?.createObjectURL === 'function') {
return { ...media, url: globalThis.URL.createObjectURL(blob), unavailable: false };
const url = globalThis.URL.createObjectURL(blob);
objectUrls.add(url);
return { ...media, url, unavailable: false };
}
const fallback = await fileToDataUrl(blob);
return { ...media, dataUrl: fallback.dataUrl, unavailable: false };
@@ -204,16 +207,39 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
}
async function withMedia(routePackage, options = {}) {
if (!routePackage) return null;
const pois = await Promise.all((routePackage.pois || []).map(async poi => ({
async function poiWithMedia(poi, options = {}) {
if (!poi) return null;
return {
...poi,
audio: await mediaForUse(poi.audio, options),
images: await Promise.all((poi.images || []).map(media => mediaForUse(media, options)))
})));
};
}
function releasePoiMedia(poi) {
const urls = [poi?.audio?.url, poi?.audioUrl, ...(poi?.images || []).map(image => image.url)];
for (const url of urls) {
if (!objectUrls.delete(url)) continue;
globalThis.URL?.revokeObjectURL?.(url);
}
}
async function withMedia(routePackage, options = {}) {
if (!routePackage) return null;
const pois = await Promise.all((routePackage.pois || []).map(poi => poiWithMedia(poi, options)));
return { ...routePackage, pois };
}
async function getPoiWithMedia(id, poiId, options = {}) {
const poi = readPackage(id)?.pois.find(item => String(item.id) === String(poiId));
return poiWithMedia(poi, options);
}
async function getAppPoi(id, poiId, options = {}) {
const poi = await getPoiWithMedia(id, poiId, options);
return poi ? appPoi(poi) : null;
}
function mediaForStorage(media) {
if (!media) return null;
const stored = { ...media };
@@ -709,8 +735,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
return listPackages().map(toAppRoute);
}
async function getAppRoute(id, options = {}) {
const routePackage = await withMedia(readPackage(id), options);
async function getAppRoute(id) {
const routePackage = readPackage(id);
if (!routePackage) throw new Error(ns.I18n.t("local.error.route-missing"));
return toAppRoute(routePackage);
}
@@ -762,6 +788,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
get: readPackage,
getAppRoute,
getWithMedia,
getPoiWithMedia,
getAppPoi,
releasePoiMedia,
gpxForUpload,
list: listPackages,
listAppRoutes,
+66 -9
View File
@@ -1,18 +1,22 @@
globalThis.Wegwichtel ??= {};
(function (ns) {
'use strict';
const INTERVAL_MS = 30000;
const INTERVAL_MS = 120000;
let id = null;
let editingId = null;
let status = 'idle';
let timer = null;
let generation = 0;
let pending = false;
let pendingGeneration = null;
let enabled = false;
let manualGeneration = 0;
let message = '';
let manualFields = [];
let lastPosition = null;
function render() {
const toggle = document.getElementById('route-recording-toggle');
if (toggle) { toggle.checked = enabled; toggle.setAttribute('aria-checked', String(enabled)); }
const control = document.getElementById('route-recording-control');
if (!control) return;
control.hidden = status === 'idle';
@@ -21,9 +25,9 @@ globalThis.Wegwichtel ??= {};
}
async function sample() {
if (pending || status !== 'recording') return;
pending = true;
if (pendingGeneration === generation || status !== 'recording') return;
const currentGeneration = generation;
pendingGeneration = currentGeneration;
try {
let position;
try { position = await ns.Geo.current({ maximumAge: 0 }); }
@@ -40,7 +44,7 @@ globalThis.Wegwichtel ??= {};
ns.StorageUsage?.reportError(error);
message = ns.I18n.t('recorder.save-error');
} finally {
pending = false;
if (pendingGeneration === currentGeneration) pendingGeneration = null;
render();
}
}
@@ -69,6 +73,7 @@ globalThis.Wegwichtel ??= {};
}
function stop() {
enabled = false;
clearTimer();
id = null;
lastPosition = null;
@@ -78,6 +83,7 @@ globalThis.Wegwichtel ??= {};
}
function start(routeId) {
enabled = true;
lastPosition = null;
id = String(routeId);
editingId = id;
@@ -85,16 +91,46 @@ globalThis.Wegwichtel ??= {};
}
function beginEditing(routeId) {
if (editingId === String(routeId)) return;
stop();
start(routeId);
const nextId = String(routeId);
if (editingId === nextId) return;
if (editingId !== null) endEditing();
editingId = nextId;
if (enabled) { id = editingId; resume(); }
render();
}
function setEnabled(value) {
if (!value) { stop(); return; }
enabled = true;
if (editingId) { id = editingId; resume(); }
render();
}
function endEditing() {
editingId = null;
manualGeneration += 1;
stop();
}
async function fillCurrentLocation(field, button, notice) {
if (button.disabled) return;
const currentGeneration = manualGeneration;
button.disabled = true;
notice.textContent = ns.I18n.t('recorder.locating');
try {
const position = await ns.Geo.current({ maximumAge: 0 });
if (currentGeneration !== manualGeneration || !manualFields.includes(field)) return;
if (!Number.isFinite(position.lat) || !Number.isFinite(position.lon) || Math.abs(position.lat) > 90 || Math.abs(position.lon) > 180) throw new Error('Invalid location');
field.lat.value = String(position.lat);
field.lon.value = String(position.lon);
notice.textContent = ns.I18n.t('recorder.location-set');
} catch {
if (currentGeneration === manualGeneration && manualFields.includes(field)) notice.textContent = ns.I18n.t('recorder.location-error');
} finally {
button.disabled = false;
}
}
function addPositionField(position = {}, index = null) {
const container = document.getElementById('editor-recorded-positions');
if (!container) return;
@@ -114,13 +150,30 @@ globalThis.Wegwichtel ??= {};
row.appendChild(label); row.appendChild(input);
fields[key] = input;
}
manualFields.push({ index, original: position, ...fields });
const field = { index, original: position, ...fields };
manualFields.push(field);
const button = document.createElement('button');
button.type = 'button';
button.className = 'current-location-button';
button.setAttribute('aria-label', ns.I18n.t('recorder.use-location'));
button.title = ns.I18n.t('recorder.use-location');
const icon = document.createElement('img');
icon.src = '/images/icons/current-location.svg';
icon.alt = '';
button.appendChild(icon);
const notice = document.createElement('p');
notice.className = 'editor-muted';
notice.setAttribute('aria-live', 'polite');
button.addEventListener('click', () => { void fillCurrentLocation(field, button, notice); });
row.appendChild(button);
row.appendChild(notice);
container.appendChild(row);
}
function renderPoints(gpx) {
const container = document.getElementById('editor-recorded-positions');
if (!container) return;
manualGeneration += 1;
container.replaceChildren();
manualFields = [];
ns.LocalRoutes.recordedPositions(gpx).forEach(addPositionField);
@@ -146,6 +199,10 @@ globalThis.Wegwichtel ??= {};
function bind() {
globalThis.addEventListener?.('pagehide', endEditing);
document.getElementById('route-recording-toggle')?.addEventListener('change', event => setEnabled(event.target.checked));
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && status === 'recording') void sample();
});
const activate = (elementId, action) => {
const element = document.getElementById(elementId);
element?.addEventListener('pointerup', event => {
+8
View File
@@ -129,6 +129,7 @@ globalThis.Wegwichtel ??= {};
"drafts": "Meine Entwürfe",
"no-draft": "Noch kein eigener Entwurf vorhanden.",
"no-stations": "Noch keine Stationen angelegt.",
"public-unavailable": "Die veröffentlichten Strecken sind gerade nicht erreichbar. Deine Entwürfe auf diesem Gerät kannst du weiter bearbeiten.",
"loading-routes": "Strecken werden geladen …",
"submitted": "Erneut zur Prüfung senden",
"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.",
@@ -452,6 +453,13 @@ globalThis.Wegwichtel ??= {};
}
},
"recorder": {
"toggle": "Meinen Weg aufnehmen",
"toggle-help": "Alle zwei Minuten wird dein Standort auf diesem Gerät gespeichert. Die Aufnahme endet, wenn du die Bearbeitung verlässt.",
"use-location": "Meinen Standort einsetzen",
"locating": "Dein Standort wird gesucht …",
"location-set": "Dein Standort ist eingetragen. Speichere jetzt deine Änderungen.",
"location-error": "Dein Standort konnte nicht gefunden werden. Erlaube den Zugriff auf deinen Standort und versuche es noch einmal.",
"recording": "Dein Weg wird aufgenommen.",
"paused": "Die Aufnahme macht eine Pause.",
"pause": "Aufnahme pausieren",
+8
View File
@@ -129,6 +129,7 @@ globalThis.Wegwichtel ??= {};
"drafts": "My drafts",
"no-draft": "There are no drafts yet.",
"no-stations": "There are no stops yet.",
"public-unavailable": "Published trails are currently unavailable. You can still edit your drafts on this device.",
"loading-routes": "Loading trails …",
"submitted": "Send changes for review",
"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.",
@@ -452,6 +453,13 @@ globalThis.Wegwichtel ??= {};
}
},
"recorder": {
"toggle": "Record my path",
"toggle-help": "Your location is saved on this device every two minutes. Recording stops when you leave the editor.",
"use-location": "Use my current location",
"locating": "Finding your location …",
"location-set": "Your location has been entered. Save your changes now.",
"location-error": "Your location could not be found. Allow access to your location and try again.",
"recording": "Your trail is being recorded.",
"paused": "Recording is paused.",
"pause": "Pause recording",
+53 -6
View File
@@ -3,6 +3,8 @@ import { HttpError } from '../middleware/errors.js';
import { distanceMeters, routeMetrics } from './geo.js';
import { config } from '../config.js';
const WAYPOINT_DERIVED = Symbol('waypointDerived');
const xmlOptions = { ignoreAttributes: false, attributeNamePrefix: '@_', trimValues: true };
const parser = new XMLParser(xmlOptions);
const builder = new XMLBuilder({ ...xmlOptions, format: true });
@@ -20,6 +22,7 @@ function parseXml(xml) {
function toRoutePoint(point, sequence) {
return {
sequence,
...(point.extensions?.['wegwichtel:waypointRoutePoint'] === true ? { [WAYPOINT_DERIVED]: true } : {}),
lat: Number.parseFloat(point['@_lat']),
lon: Number.parseFloat(point['@_lon']),
elevation: point.ele == null ? null : Number.parseFloat(point.ele),
@@ -42,6 +45,49 @@ export function simplifyTrack(points, radiusM = config.gpxClusterMeters) {
return result.map((point, sequence) => ({ ...point, sequence }));
}
function sameLocation(left, right) {
return left.lat === right.lat && left.lon === right.lon;
}
function insertionDistance(path, slot, waypoint) {
const before = path[slot - 1];
const after = path[slot];
return (before ? distanceMeters(before, waypoint) : 0)
+ (after ? distanceMeters(waypoint, after) : 0)
- (before && after ? distanceMeters(before, after) : 0);
}
function waypointSlot(path, waypoint, firstSlot) {
let slot = firstSlot;
let bestDistance = Number.POSITIVE_INFINITY;
for (let index = firstSlot; index <= path.length; index += 1) {
const addedDistance = insertionDistance(path, index, waypoint);
if (addedDistance < bestDistance) {
bestDistance = addedDistance;
slot = index;
}
}
return slot;
}
// Keep the recorded path and station order. Among remaining gaps choose the
// insertion with the smallest extra walking distance; no road routing is used.
function mergeWaypoints(path, waypoints) {
const result = [...path];
let firstSlot = 0;
for (const waypoint of waypoints) {
const existing = result.findIndex((point, index) => index >= firstSlot && sameLocation(point, waypoint));
if (existing !== -1) {
firstSlot = existing;
continue;
}
const slot = waypointSlot(result, waypoint, firstSlot);
result.splice(slot, 0, { ...waypoint, [WAYPOINT_DERIVED]: true });
firstSlot = slot + 1;
}
return result.map((point, sequence) => ({ ...point, sequence }));
}
export function parseGpx(xml, replacementPoints = null, { allowEmpty = false } = {}) {
const gpx = parseXml(xml)?.gpx;
if (!gpx) throw new HttpError(400, 'GPX-Wurzelelement fehlt.');
@@ -49,16 +95,16 @@ export function parseGpx(xml, replacementPoints = null, { allowEmpty = false } =
const routes = asList(gpx.rte);
const routed = validPoints(routes.flatMap(route => asList(route.rtept)));
const recorded = validPoints(tracks.flatMap(track => asList(track.trkseg)).flatMap(segment => asList(segment.trkpt)));
const points = replacementPoints ?? (routed.length ? routed : simplifyTrack(recorded));
const waypoints = validPoints(asList(gpx.wpt));
const path = (replacementPoints ?? (routed.length ? routed : simplifyTrack(recorded)))
.filter(point => !point[WAYPOINT_DERIVED]);
const points = mergeWaypoints(path, waypoints);
if (!allowEmpty && !points.length && !waypoints.length && !gpx.extensions?.['wegwichtel:gpsGap'] && !gpx.extensions?.['wegwichtel:pendingWaypoint']) throw new HttpError(400, 'Die GPX-Datei enthält keine verwertbaren Punkte.');
const metricPoints = points.length ? points : waypoints;
const metrics = metricPoints.length ? routeMetrics(metricPoints) : {
const metrics = points.length ? routeMetrics(points) : {
startLat: null, startLon: null, centerLat: null, centerLon: null,
minLat: null, minLon: null, maxLat: null, maxLon: null,
distanceM: 0, elevationGainM: 0, pointCount: 0
};
if (!points.length) { metrics.distanceM = 0; metrics.elevationGainM = 0; metrics.pointCount = 0; }
const name = routes[0]?.name || tracks[0]?.name || gpx.metadata?.name || null;
delete gpx.trk;
delete gpx.rte;
@@ -66,11 +112,12 @@ export function parseGpx(xml, replacementPoints = null, { allowEmpty = false } =
const { extensions, ...base } = gpx;
// GPX 1.1 namespace identifier: this URI is written into XML, never fetched.
// Its exact spelling is part of the GPX standard; HTTPS would be another namespace.
const normalized = { ...base, '@_version': '1.1', '@_creator': 'Wegwichtel', '@_xmlns': 'http://www.topografix.com/GPX/1/1' };
const normalized = { ...base, '@_xmlns:wegwichtel': 'urn:wegwichtel:poi', '@_version': '1.1', '@_creator': 'Wegwichtel', '@_xmlns': 'http://www.topografix.com/GPX/1/1' };
if (points.length) normalized.rte = { ...(name ? { name } : {}), rtept: points.map(point => ({
'@_lat': point.lat, '@_lon': point.lon,
...(Number.isFinite(point.elevation) ? { ele: point.elevation } : {}),
...(point.recordedAt ? { time: point.recordedAt } : {})
...(point.recordedAt ? { time: point.recordedAt } : {}),
...(point[WAYPOINT_DERIVED] ? { extensions: { 'wegwichtel:waypointRoutePoint': true } } : {})
})) };
if (extensions) normalized.extensions = extensions;
return { name, points, metrics, normalizedXml: '<?xml version="1.0" encoding="UTF-8"?>\n' + builder.build({ gpx: normalized }) };
+7 -3
View File
@@ -246,7 +246,10 @@ export function appendRoute(db, id, gpxFile) {
const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8'));
const current = readRouteGpx(existing).points;
const allPoints = current.concat(parsed.points).map((point, sequence) => ({ ...point, sequence }));
// An appended route contributes its complete path, including its stations.
// Those points are now part of the path independently of this route's POIs.
const appendedPoints = parsed.points.map(({ lat, lon, elevation, recordedAt }) => ({ lat, lon, elevation, recordedAt }));
const allPoints = current.concat(appendedPoints).map((point, sequence) => ({ ...point, sequence }));
const target = resolveStoredFile(existing.gpx_path);
const original = fs.readFileSync(target, 'utf8');
const normalized = parseGpx(original, allPoints);
@@ -290,8 +293,9 @@ function syncRouteWaypoints(db, routeId) {
const row = db.prepare('SELECT gpx_path FROM routes WHERE id = ?').get(routeId);
const filename = resolveStoredFile(row.gpx_path);
const xml = fs.readFileSync(filename, 'utf8');
fs.writeFileSync(filename, gpxWithWaypoints(xml, listPois(db, routeId)));
updateBounds(db, routeId, parseGpx(fs.readFileSync(filename, 'utf8'), null, { allowEmpty: true }).metrics);
const normalized = parseGpx(gpxWithWaypoints(xml, listPois(db, routeId)), null, { allowEmpty: true });
fs.writeFileSync(filename, normalized.normalizedXml);
updateBounds(db, routeId, normalized.metrics);
}
function poiCoordinates(fields, existing = {}) {
+42
View File
@@ -277,6 +277,8 @@ async function loadApp({ language = 'de-DE' } = {}) {
stop() {}
},
LocalRoutes: {
async getAppPoi(_id, poiId) { return structuredClone(localRoute?.pois.find(poi => Number(poi.id) === Number(poiId)) || null); },
releasePoiMedia() {},
getAppRoute(id) {
if (String(id) !== 'local-1' || !localRoute) throw new Error('lokal nicht gefunden');
return structuredClone(localRoute);
@@ -640,3 +642,43 @@ test('invalid API responses reject initialization and route selection errors res
assert.deepEqual(errors, ['Strecke entfernt']);
assert.equal(button.properties.get('disabled'), false);
});
test('local station playback loads only the selected station and ignores media arriving after leaving', async () => {
const setup = await loadApp();
const { context, harness } = setup;
const ns = context.Wegwichtel;
await ns.App.initialize({ markStep() {} });
const button = new FakeNode('local-select');
button.dataValues.set('route-source', 'local');
button.dataValues.set('route-id', 'local-1');
const requests = [];
const released = [];
ns.LocalRoutes.getAppPoi = (routeId, poiId) => new Promise(resolve => requests.push({ routeId, poiId, resolve }));
ns.LocalRoutes.releasePoiMedia = poi => released.push(poi);
const calls = observeSensors(setup);
await harness.activate('[data-route-select]', button);
assert.equal(requests.length, 0);
const poiButton = new FakeNode('station');
poiButton.dataValues.set('poi-id', 10);
// Use the first actual local station ID rather than assuming server IDs.
const local = ns.LocalRoutes.getAppRoute('local-1');
poiButton.dataValues.set('poi-id', local.pois[0].id);
await harness.activate('[data-poi-select]', poiButton);
assert.equal(requests.length, 1);
assert.equal(requests[0].routeId, 'local-1');
assert.equal(requests[0].poiId, local.pois[0].id);
const firstLoaded = { images: [{ url: 'blob:shown' }], audioUrl: 'blob:shown-audio' };
requests[0].resolve(firstLoaded);
await new Promise(resolve => setImmediate(resolve));
assert.equal(calls.audio.at(-1), 'blob:shown-audio');
assert.equal(calls.slides.at(-1)[0].url, 'blob:shown');
await harness.activate('[data-poi-select]', poiButton);
assert.equal(released[0], firstLoaded);
await harness.activate('#stop-route', new FakeNode('stop'));
const loaded = { images: [{ url: 'blob:late' }], audioUrl: 'blob:late-audio' };
requests[1].resolve(loaded);
await new Promise(resolve => setImmediate(resolve));
assert.equal(released[1], loaded);
assert.equal(calls.audio.includes('blob:late-audio'), false);
});
+6
View File
@@ -149,7 +149,13 @@ test('route, POI, media and storage services cover successful and rejected lifec
const indoorStation = routes.createPoi(db, indoor.id, { title: 'Ohne GPS', lat: null, lon: null });
assert.equal(routes.getRoute(db, indoor.id).pois[0].lat, null);
routes.updatePoi(db, indoor.id, indoorStation.id, { lat: 50, lon: 10 });
assert.equal(routes.getRoute(db, indoor.id).pointCount, 1);
assert.equal(db.prepare('SELECT min_lat FROM routes WHERE id=?').get(indoor.id).min_lat, 50);
routes.updatePoi(db, indoor.id, indoorStation.id, { lat: 51, lon: 11 });
const movedStationRoute = routes.getRoute(db, indoor.id);
assert.equal(movedStationRoute.pointCount, 1);
assert.equal(movedStationRoute.points[0].lat, 51);
assert.equal(db.prepare('SELECT min_lat FROM routes WHERE id=?').get(indoor.id).min_lat, 51);
routes.deletePoi(db, indoor.id, indoorStation.id);
assert.equal(routes.getRoute(db, indoor.id).pointCount, 0);
assert.equal(db.prepare('SELECT min_lat FROM routes WHERE id=?').get(indoor.id).min_lat, null);
+99
View File
@@ -302,6 +302,13 @@ function buildEditorContext() {
type: file.type || 'application/octet-stream'
}),
get: id => packages.get(String(id)) || null,
getPoiWithMedia: async (id, poiId) => {
const value = packages.get(String(id))?.pois.find(poi => String(poi.id) === String(poiId));
if (!value) return null;
return { ...value, audio: value.audio ? { ...value.audio, url: 'blob:test-audio' } : null,
images: (value.images || []).map(image => ({ ...image, url: 'blob:test-image' })) };
},
releasePoiMedia() {},
getWithMedia: async id => {
const value = packages.get(String(id));
if (!value) return null;
@@ -659,6 +666,7 @@ test('editing an activated server route uses PUT and never writes activation sta
await context.Wegwichtel.EditorUi.open();
const routeList = elements.get('editor-route-list');
await tick();
const serverItem = routeList.children.find(child => child.dataset.routeSource === 'server');
const serverActions = serverItem.children[1];
assert.equal(serverActions.children.length, 1);
@@ -737,3 +745,94 @@ test('local media picker keeps File System Access handles for later upload', asy
assert.equal(imageStore.handle, handle);
assert.equal(imageStore.file, imageFile);
});
test('local drafts can be opened and saved while the server request is still pending', async () => {
const { context, elements, packages } = buildEditorContext();
context.fetch = () => new Promise(() => {});
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/editor-ui.js'), 'utf8'), context);
await context.Wegwichtel.EditorUi.open();
const list = elements.get('editor-route-list');
const item = list.children.find(child => child.dataset.routeId === 'local-existing');
assert.ok(item);
list.dispatchEvent(pointerEvent(item.children[1].children[0]));
await tick();
assert.equal(elements.get('editor-route-id').value, 'local-existing');
elements.get('editor-route-name').value = 'Offline geändert';
elements.get('editor-route-form').dispatchEvent(submitEvent());
await tick(); await tick();
assert.equal(packages.get('local-existing').route.name, 'Offline geändert');
assert.equal(elements.get('editor-status').textContent, 'Gespeichert.');
});
test('a failed public route request preserves local drafts and late replies use the latest local data', async () => {
const { context, elements, packages } = buildEditorContext();
const requests = [];
context.fetch = () => new Promise((resolve, reject) => requests.push({ resolve, reject }));
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/editor-ui.js'), 'utf8'), context);
await context.Wegwichtel.EditorUi.open();
requests[0].reject(new Error('offline')); await tick();
assert.match(elements.get('editor-status').textContent, /nicht erreichbar/);
assert.ok(elements.get('editor-route-list').children.some(child => child.dataset.routeId === 'local-existing'));
await context.Wegwichtel.EditorUi.refresh();
packages.delete('local-existing');
requests[1].resolve(jsonResponse({ routes: [{ id: 7, name: 'Veröffentlicht' }] }));
await tick(); await tick();
assert.equal(elements.get('editor-route-list').children.some(child => child.dataset.routeId === 'local-existing'), false);
assert.ok(elements.get('editor-route-list').children.some(child => child.dataset.routeSource === 'server'));
});
test('opening and saving a draft does not load files; editing a station loads only that station', async () => {
const { context, elements, packages } = buildEditorContext();
const local = packages.get('local-existing');
local.pois = [0, 1].map(id => ({ id, title: `Station ${id}`, sequence: id, images: [{ id: `image-${id}`, mediaKey: `key-${id}` }] }));
const loads = [];
context.Wegwichtel.LocalRoutes.getWithMedia = () => { throw new Error('Full route hydration must not run'); };
const original = context.Wegwichtel.LocalRoutes.getPoiWithMedia;
context.Wegwichtel.LocalRoutes.getPoiWithMedia = (...args) => { loads.push(args); return original(...args); };
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/editor-ui.js'), 'utf8'), context);
await context.Wegwichtel.EditorUi.open();
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])); await tick();
assert.equal(loads.length, 0);
elements.get('editor-route-name').value = 'Nur Name geändert';
elements.get('editor-route-form').dispatchEvent(submitEvent()); await tick(); await tick();
assert.equal(loads.length, 0);
const poiList = elements.get('editor-poi-list');
const poiItem = poiList.children.find(child => child.dataset.poiId === '1');
poiList.dispatchEvent(pointerEvent(poiItem.children[1].children[2])); await tick();
assert.equal(loads.length, 1);
assert.equal(loads[0][1], 1);
assert.equal(elements.get('editor-poi-media-preview').children.length, 1);
elements.get('editor-poi-title').value = 'Titel geändert';
elements.get('editor-poi-form').dispatchEvent(submitEvent()); await tick(); await tick();
assert.equal(loads.length, 1);
assert.equal(packages.get('local-existing').pois[1].title, 'Titel geändert');
});
test('late station previews are released after the editor closes without replacing unsaved input', async () => {
const { context, elements, packages } = buildEditorContext();
packages.get('local-existing').pois = [{ id: 0, title: 'Station', images: [] }];
let finish;
const released = [];
context.Wegwichtel.LocalRoutes.getPoiWithMedia = () => new Promise(resolve => { finish = resolve; });
context.Wegwichtel.LocalRoutes.releasePoiMedia = poi => { if(poi) released.push(poi); };
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/editor-ui.js'), 'utf8'), context);
await context.Wegwichtel.EditorUi.open();
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])); await tick();
const poiList = elements.get('editor-poi-list');
poiList.dispatchEvent(pointerEvent(poiList.children[0].children[1].children[2])); await tick();
elements.get('editor-poi-title').value = 'Noch nicht gespeichert';
context.Wegwichtel.EditorUi.close();
finish({ id: 0, title: 'Alter Titel', images: [{ url: 'blob:late' }] }); await tick();
assert.equal(released.length, 1);
assert.equal(elements.get('editor-poi-title').value, 'Noch nicht gespeichert');
});
+71 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseGpx } from '../src/services/gpx.js';
import { parseGpx, gpxWithWaypoints } from '../src/services/gpx.js';
import { SAMPLE_GPX } from '../test-support/gpx-fixtures.js';
test('parseGpx reads synthetic track data without repository example files', () => {
@@ -11,3 +11,73 @@ test('parseGpx reads synthetic track data without repository example files', ()
assert.equal(parsed.points[0].lat, 52.5200);
assert.equal(parsed.points[2].lon, 13.4090);
});
const point = (tag, lat, lon, content = '') => `<${tag} lat="${lat}" lon="${lon}">${content}</${tag}>`;
const gpx = (waypoints, path = '', extra = '') => `<gpx version="1.1" creator="Test" xmlns:wegwichtel="urn:wegwichtel:poi">${waypoints}${path}${extra}</gpx>`;
const track = points => `<trk><trkseg>${points}</trkseg></trk>`;
const locations = points => points.map(({ lat, lon }) => [lat, lon]);
test('waypoints alone become route points in station order and contribute to distance and bounds', () => {
const parsed = parseGpx(gpx(point('wpt', 50, 10) + point('wpt', 50.01, 10.02)));
assert.deepEqual(locations(parsed.points), [[50, 10], [50.01, 10.02]]);
assert.equal(parsed.metrics.pointCount, 2);
assert.ok(parsed.metrics.distanceM > 0);
assert.equal(parsed.metrics.maxLon, 10.02);
assert.match(parsed.normalizedXml, /<wpt\b/);
assert.match(parsed.normalizedXml, /<rtept\b/);
assert.deepEqual(locations(parseGpx(parsed.normalizedXml).points), locations(parsed.points));
});
test('track points and stations both shape the path without dropping nearby station anchors', () => {
const wp = point('wpt', 50, 10.00001) + point('wpt', 50.005, 10.015);
const recorded = point('trkpt', 50, 10) + point('trkpt', 50, 10.00002)
+ point('trkpt', 50, 10.01) + point('trkpt', 50, 10.02);
const parsed = parseGpx(gpx(wp, track(recorded)));
assert.deepEqual(locations(parsed.points), [[50, 10], [50, 10.00001], [50, 10.01], [50.005, 10.015], [50, 10.02]]);
assert.equal(parsed.metrics.maxLat, 50.005);
assert.equal(parsed.points.length, 5);
assert.deepEqual(parsed.points.map(p => p.sequence), [0, 1, 2, 3, 4]);
assert.deepEqual(locations(parseGpx(parsed.normalizedXml).points), locations(parsed.points));
});
test('duplicate station locations do not multiply route points and station order is retained on loops', () => {
const stations = point('wpt', 50, 10.02) + point('wpt', 50, 10.02) + point('wpt', 50, 10.01);
const recorded = [10, 10.01, 10.02, 10.01, 10].map(lon => point('trkpt', 50, lon)).join('');
const parsed = parseGpx(gpx(stations, track(recorded)));
assert.deepEqual(locations(parsed.points), [[50, 10], [50, 10.01], [50, 10.02], [50, 10.01], [50, 10]]);
assert.deepEqual(locations(parseGpx(parsed.normalizedXml).points), locations(parsed.points));
});
test('existing routes and replacement points incorporate stations, including stations before and after the path', () => {
const stations = [9.99, 10.005, 10.02].map(lon => point('wpt', 50, lon)).join('');
const route = `<rte>${point('rtept', 50, 10)}${point('rtept', 50, 10.01)}</rte>`;
const xml = gpx(stations, route);
const parsed = parseGpx(xml);
assert.deepEqual(locations(parsed.points), [[50, 9.99], [50, 10], [50, 10.005], [50, 10.01], [50, 10.02]]);
assert.deepEqual(locations(parseGpx(xml, [{ lat: 50, lon: 10 }, { lat: 50, lon: 10.01 }]).points), locations(parsed.points));
});
test('missing and invalid station coordinates are excluded while GPS gaps remain available for editing', () => {
const stations = '<wpt><name>Ohne Ort</name></wpt>' + point('wpt', 91, 10) + point('wpt', 50, 181) + point('wpt', 50, 10);
const extra = '<extensions><wegwichtel:gpsGap time="2026-10-04T10:00:00Z"/><wegwichtel:pendingWaypoint><name>Später</name></wegwichtel:pendingWaypoint></extensions>';
const parsed = parseGpx(gpx(stations, '', extra));
assert.deepEqual(locations(parsed.points), [[50, 10]]);
assert.match(parsed.normalizedXml, /wegwichtel:gpsGap/);
assert.match(parsed.normalizedXml, /wegwichtel:pendingWaypoint/);
const empty = parseGpx(gpx('', '', extra));
assert.equal(empty.metrics.pointCount, 0);
assert.equal(empty.metrics.minLat, null);
});
test('moving or deleting a station replaces its generated route anchor without removing recorded points', () => {
const original = parseGpx(gpx(point('wpt', 50.01, 10.005), track(point('trkpt', 50, 10) + point('trkpt', 50, 10.01))));
assert.match(original.normalizedXml, /wegwichtel:waypointRoutePoint/);
const updated = parseGpx(gpxWithWaypoints(original.normalizedXml, [{ id: 1, title: 'Neu', lat: 50.02, lon: 10.006 }]));
assert.equal(updated.points.length, 3);
assert.equal(updated.metrics.maxLat, 50.02);
assert.equal(updated.points.some(p => p.lat === 50.01), false);
const removed = parseGpx(gpxWithWaypoints(updated.normalizedXml, []));
assert.deepEqual(locations(removed.points), [[50, 10], [50, 10.01]]);
assert.deepEqual(locations(parseGpx(updated.normalizedXml).points), locations(updated.points));
});
+2 -1
View File
@@ -45,7 +45,8 @@ test('information button opens scrollable overlay pages for privacy, imprint, te
assert.match(html, /data-info-target="technology"/);
assert.match(html, /<h3>Technisch verantwortlich<\/h3>\s*<p>Florian Zumpe<br>/);
assert.match(html, /\[Anschrift von Florian Zumpe ergänzen\]/);
assert.match(html, /\[E-Mail-Adresse von Florian Zumpe ergänzen\]/);
assert.match(html, /E-Mail: <a href="mailto:info@wegwichtel\.de">info@wegwichtel\.de<\/a>/);
assert.doesNotMatch(html, /\[E-Mail-Adresse von Florian Zumpe ergänzen\]|\[Kontakt für Rechtefragen ergänzen\]/);
const storage = await read('public/js/privacy-storage.js');
assert.match(storage, /wegwichtel\.privacy\.v1/);
assert.match(storage, /wegwichtel\.route-progress\.v1/);
+32
View File
@@ -730,3 +730,35 @@ test('creator identity never falls back to insecure randomness or replaces corru
assert.throws(() => api.creatorId(), /Gerätekennung/);
assert.equal(localStorage.getItem('wegwichtel.creator-id.v1'), 'broken');
});
test('route selection reads metadata only and station media is loaded and released separately', async () => {
const { api, context } = await loadLocalRoutes();
const reads = [0, 0];
const revoked = [];
let urlNumber = 0;
context.URL.createObjectURL = () => `blob:lazy-${++urlNumber}`;
context.URL.revokeObjectURL = url => revoked.push(url);
const files = [];
for (let id = 0; id < 2; id++) {
const file = Object.assign(new Blob(['image']), { name: `${id}.jpg` });
const handle = { async getFile() { reads[id]++; return file; } };
files.push(await api.storeMediaFile('lazy', id, 'image', id, file, handle));
}
api.save({ id: 'lazy', route: { name: 'Lazy' }, pois: files.map((image, id) => ({ id, images: [image] })) });
await api.getAppRoute('lazy');
api.get('lazy');
assert.deepEqual(reads, [0, 0]);
const first = await api.getPoiWithMedia('lazy', 0, { requestPermission: true });
assert.deepEqual(reads, [1, 0]);
assert.equal(first.images[0].url, 'blob:lazy-1');
const second = await api.getAppPoi('lazy', 1);
assert.deepEqual(reads, [1, 1]);
assert.equal(second.images[0].url, 'blob:lazy-2');
api.releasePoiMedia(first); api.releasePoiMedia(first); api.releasePoiMedia(second);
api.releasePoiMedia({ images: [{ url: 'https://example.org/image.jpg' }] });
assert.deepEqual(revoked, ['blob:lazy-1', 'blob:lazy-2']);
assert.equal(await api.getPoiWithMedia('lazy', 9), null);
assert.equal(await api.getAppPoi('missing', 0), null);
assert.equal(api.get('lazy').pois[0].images[0].url, undefined);
});
+69 -5
View File
@@ -14,7 +14,7 @@ async function setup() {
appendChild(child) { this.children.push(child); }
replaceChildren() { this.children = []; }
}
const ids = ['route-recording-control', 'route-recording-status', 'route-recording-pause', 'route-recording-stop', 'editor-recorded-positions', 'editor-add-position'];
const ids = ['route-recording-control', 'route-recording-status', 'route-recording-pause', 'route-recording-stop', 'editor-recorded-positions', 'editor-add-position', 'route-recording-toggle'];
const nodes = new Map(ids.map(id => [id, new Element()]));
const document = new EventTarget();
document.readyState = 'complete';
@@ -23,7 +23,7 @@ async function setup() {
const intervals = new Map();
const packages = new Map([['draft', { id: 'draft', route: { name: 'Entwurf' }, gpx: '[]', pois: [] }]]);
const context = { document, Date, console,
setInterval(callback, delay) { assert.equal(delay, 30000); intervals.set(1, callback); return 1; },
setInterval(callback, delay) { assert.equal(delay, 120000); intervals.set(1, callback); return 1; },
clearInterval(id) { intervals.delete(id); },
Wegwichtel: { Geo: { current: async () => ({ lat: 50, lon: 10, timestamp: Date.now() }) },
LocalRoutes: {
@@ -47,7 +47,7 @@ function activate(node, type = 'pointerup', key) {
node.dispatchEvent(event);
}
test('recording persists every 30 seconds during an editing session, pauses and finishes', async () => {
test('recording persists every two minutes during an editing session, pauses and finishes', async () => {
const { recorder, packages, intervals, nodes } = await setup();
recorder.start('draft'); await tick();
assert.equal(recorder.status, 'recording');
@@ -106,10 +106,14 @@ test('missing recorded locations can be filled manually while newer samples rema
test('ending editing stops sampling, reopening permits recording and a fresh app remains inactive', async () => {
const { recorder, intervals, packages } = await setup();
const { recorder, intervals, packages, nodes } = await setup();
assert.equal(recorder.status, 'idle');
recorder.beginEditing('draft');
await tick();
assert.equal(intervals.size, 0);
nodes.get('route-recording-toggle').checked = true;
nodes.get('route-recording-toggle').dispatchEvent(new Event('change'));
await tick();
assert.equal(intervals.size, 1);
recorder.pause();
recorder.beginEditing('draft');
@@ -121,8 +125,68 @@ test('ending editing stops sampling, reopening permits recording and a fresh app
assert.equal(JSON.parse(packages.get('draft').gpx).length, 1);
recorder.beginEditing('draft');
await tick();
assert.equal(recorder.status, 'recording');
assert.equal(recorder.status, 'idle');
assert.equal(nodes.get('route-recording-toggle').checked, false);
const fresh = await setup();
assert.equal(fresh.recorder.status, 'idle');
assert.equal(fresh.intervals.size, 0);
});
test('the switch can prepare a new draft and late GPS from an older session is ignored', async () => {
const { context, recorder, nodes, packages, intervals } = await setup();
const answers = [];
context.Wegwichtel.Geo.current = () => new Promise(resolve => answers.push(resolve));
const toggle = nodes.get('route-recording-toggle');
toggle.checked = true; toggle.dispatchEvent(new Event('change'));
assert.equal(answers.length, 0);
recorder.beginEditing('draft');
assert.equal(answers.length, 1);
toggle.checked = false; toggle.dispatchEvent(new Event('change'));
toggle.checked = true; toggle.dispatchEvent(new Event('change'));
assert.equal(answers.length, 2);
answers[1]({ lat: 52, lon: 12 }); await tick();
answers[0]({ lat: 50, lon: 10 }); await tick();
assert.equal(JSON.parse(packages.get('draft').gpx).length, 1);
assert.equal(JSON.parse(packages.get('draft').gpx)[0].lat, 52);
context.Wegwichtel.Geo.current = async () => ({ lat: 53, lon: 13 });
context.document.visibilityState = 'visible';
context.document.dispatchEvent(new Event('visibilitychange')); await tick();
assert.equal(JSON.parse(packages.get('draft').gpx).length, 2);
recorder.endEditing();
assert.equal(intervals.size, 0);
assert.equal(toggle.checked, false);
});
test('each crosshair fills only its own point without starting recording or saving', async () => {
const { recorder, nodes, packages } = await setup();
recorder.renderPoints('[{"lat":1,"lon":2},{"lat":3,"lon":4}]');
const [first, second] = nodes.get('editor-recorded-positions').children;
second.children[5].dispatchEvent(new Event('click')); await tick();
assert.equal(first.children[2].value, '1');
assert.equal(second.children[2].value, '50');
assert.equal(second.children[4].value, '10');
assert.equal(recorder.status, 'idle');
assert.equal(packages.get('draft').gpx, '[]');
assert.match(second.children[6].textContent, /Speichere/);
});
test('manual location errors preserve coordinates and stale or duplicate requests cannot overwrite points', async () => {
const { context, recorder, nodes } = await setup();
recorder.renderPoints('[{"lat":1,"lon":2}]');
const row = nodes.get('editor-recorded-positions').children[0];
context.Wegwichtel.Geo.current = async () => { throw new Error('denied'); };
row.children[5].dispatchEvent(new Event('click')); await tick();
assert.equal(row.children[2].value, '1');
assert.match(row.children[6].textContent, /nicht gefunden/);
let finish;
let requests = 0;
context.Wegwichtel.Geo.current = () => { requests++; return new Promise(resolve => { finish = resolve; }); };
row.children[5].dispatchEvent(new Event('click'));
row.children[5].dispatchEvent(new Event('click'));
assert.equal(requests, 1);
recorder.renderPoints('[]');
finish({ lat: 51, lon: 11 }); await tick();
assert.equal(row.children[2].value, '1');
assert.equal(row.children[5].disabled, false);
});