Internationalization
Sonarqube Scanner / Prepare environment (push) Successful in 20s
Sonarqube Scanner / Build and test (push) Failing after 36s
Sonarqube Scanner / SonarQube analysis (push) Skipped
Sonarqube Scanner / Deploy to Server (push) Successful in 9s

This commit is contained in:
Florian Zumpe
2026-10-01 09:41:01 +02:00
parent 607e5450e1
commit aff88eba1b
41 changed files with 1368 additions and 636 deletions
+42
View File
@@ -0,0 +1,42 @@
# Wegwichtel: Sprache der App
Diese Vorgabe des Nutzers gilt für alle künftigen Änderungen an der Oberfläche.
Die App richtet sich an Grundschulkinder und Menschen ohne technische Vorkenntnisse.
- Schreibe kurze, verständliche Sätze. Sprich die Nutzer mit „du“ an.
- Erkläre, was eine Aktion bewirkt und was als Nächstes zu tun ist.
- Verwende durchgängig „Strecke“, „Station“, „Meine Entwürfe“ und „für unterwegs speichern“.
- Technische Begriffe wie GPX, JSON, Cache, API, OPFS, IndexedDB, Local Storage,
Server, Token oder interne Feldnamen gehören nicht in Bedienungstexte,
Hilfe, Statusmeldungen, Dateiauswahl-Beschreibungen oder vorgelesene Beschriftungen.
- Bezeichne einen Wegexport als „Weg als Datei herunterladen“. Erkläre bei Bedarf,
dass die Datei für eine andere Wander-App gedacht ist und keine Bilder oder Ton enthält.
- Verwende „Bilder und Tonaufnahmen“ statt „Medien“, „Zur Prüfung senden“ statt „Upload“
und „auf diesem Gerät“ statt „lokal im Browser“.
- Fehlermeldungen nennen das Problem und einen verständlichen nächsten Schritt.
Zeige keine rohen Fehler aus dem Betriebssystem, Netzwerkantworten oder Dateipfade.
- Vereinfache auch Hilfen zu Daten und Speicher. Behalte wichtige Angaben zu
Löschung, Erlaubnissen, Grenzen und Speicher-Schätzungen bei. Mache keine
Zusicherungen, die die App nicht einhalten kann.
- Rechtliche Texte dürfen notwendige Rechtsbegriffe behalten. Erkläre sie einfach;
ändere dabei nicht die Bedeutung, Verantwortlichen oder Kontaktdaten.
- Fachliche Details für Entwicklung und Betrieb gehören in Projekt-Dokumentation
und Code-Kommentare. Code-Bezeichner, Dateiformate und Schnittstellen bleiben fachlich präzise.
- Prüfe neue Texte und angepasste Text-Erwartungen in den vorhandenen Tests.
## Texte und Übersetzungen
- Alle eigenen Bedienungstexte liegen zentral in der Klasse `WegwichtelTextsDe`
in `public/js/texts-de.js`. Keine neuen sichtbaren Texte direkt in HTML oder
Funktionscode schreiben.
- JavaScript bezieht Texte über `Wegwichtel.I18n.t(textKey, variables)`.
HTML verwendet `data-i18n` bzw. `data-i18n-aria-label`, `data-i18n-title`,
`data-i18n-placeholder` und `data-i18n-alt`.
- Zahlen und Namen sind Platzhalter; ganze Sätze übersetzen, keine Sätze aus
deutschen Wortteilen zusammensetzen. Für Mengen Einzahl und Mehrzahl verwenden.
- Nutzerinhalte wie Streckennamen, Beschreibungen und Aufnahmen nicht automatisch
übersetzen oder verändern. Interne Fehlerdiagnosen dürfen technisch bleiben;
die sichtbare Meldung muss verständlich sein.
- Zusätzliche Sprachen über `I18n.register` ergänzen. Fehlende Übersetzungen
fallen auf Deutsch zurück. Sprachwechsel aktualisieren feste Beschriftungen;
bereits erzeugte dynamische Ansichten bei Bedarf neu rendern.
+228 -276
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#315d3a"> <meta name="theme-color" content="#315d3a">
<title>Wegwichtel</title> <title data-i18n="html.title.wegwichtel"></title>
<link rel="stylesheet" href="css/bootstrap.css?v=0.12.14"> <link rel="stylesheet" href="css/bootstrap.css?v=0.12.14">
<link rel="stylesheet" href="vendor/jquery-ui/jquery-ui-1.14.2.min.css?v=0.12.14"> <link rel="stylesheet" href="vendor/jquery-ui/jquery-ui-1.14.2.min.css?v=0.12.14">
<link rel="stylesheet" href="css/app.css?v=0.12.14"> <link rel="stylesheet" href="css/app.css?v=0.12.14">
@@ -12,51 +12,51 @@
</head> </head>
<body> <body>
<div class="info-control"> <div class="info-control">
<button id="info-button" class="info-button" type="button" aria-label="Informationsmenü öffnen" title="Informationen" aria-haspopup="true" aria-expanded="false" aria-controls="info-dropdown"> <button id="info-button" class="info-button" type="button" aria-label="" title="" aria-haspopup="true" aria-expanded="false" aria-controls="info-dropdown" data-i18n-title="legal-ui.informationen" data-i18n-aria-label="legal-ui.hilfe.und.informationen.offnen">
<span aria-hidden="true">i</span> <span aria-hidden="true" data-i18n="html.span.i"></span>
</button> </button>
<nav id="info-dropdown" class="info-dropdown" aria-label="Rechtliche und technische Informationen" hidden> <nav id="info-dropdown" class="info-dropdown" aria-label="" hidden data-i18n-aria-label="html.info-dropdown.hilfe.und.informationen">
<button type="button" data-info-target="privacy">Datenschutzerklärung</button> <button type="button" data-info-target="privacy" data-i18n="legal-ui.datenschutzerklarung"></button>
<button type="button" data-info-target="imprint">Impressum</button> <button type="button" data-info-target="imprint" data-i18n="legal-ui.impressum"></button>
<button type="button" data-info-target="technology">Cookies und verwendete JavaScripts</button> <button type="button" data-info-target="technology" data-i18n="legal-ui.speichern.und.berechtigungen"></button>
</nav> </nav>
</div> </div>
<div id="bootstrap-screen" aria-live="polite" aria-busy="true"> <div id="bootstrap-screen" aria-live="polite" aria-busy="true">
<section class="bootstrap-card"> <section class="bootstrap-card">
<header class="bootstrap-brand"> <header class="bootstrap-brand">
<svg class="bootstrap-logo" viewBox="0 0 96 96" aria-hidden="true" focusable="false"> <svg class="bootstrap-logo" viewbox="0 0 96 96" aria-hidden="true" focusable="false">
<circle cx="48" cy="48" r="39" fill="#fff7d7" stroke="#f4b400" stroke-width="6"/> <circle cx="48" cy="48" r="39" fill="#fff7d7" stroke="#f4b400" stroke-width="6"></circle>
<path d="M48 15 57 39 81 48 57 57 48 81 39 57 15 48 39 39Z" fill="#d9ddd8"/> <path d="M48 15 57 39 81 48 57 57 48 81 39 57 15 48 39 39Z" fill="#d9ddd8"></path>
<path d="m48 48 24-23-13 31Z" fill="#e53935"/> <path d="m48 48 24-23-13 31Z" fill="#e53935"></path>
<path d="m48 48-24 23 13-31Z" fill="#2d9cdb"/> <path d="m48 48-24 23 13-31Z" fill="#2d9cdb"></path>
<circle cx="48" cy="48" r="6" fill="#f8fbf7" stroke="#315d3a" stroke-width="3"/> <circle cx="48" cy="48" r="6" fill="#f8fbf7" stroke="#315d3a" stroke-width="3"></circle>
<circle cx="80" cy="16" r="8" fill="#fff" stroke="#f4b400" stroke-width="5"/> <circle cx="80" cy="16" r="8" fill="#fff" stroke="#f4b400" stroke-width="5"></circle>
</svg> </svg>
<div> <div>
<h1>Wegwichtel</h1> <h1 data-i18n="html.title.wegwichtel"></h1>
<p>Der Wegwichtel bereitet Routen und Medien vor.</p> <p data-i18n="html.p.gleich.kannst.du.mit.dem.wegwichtel.losziehen"></p>
</div> </div>
</header> </header>
<div class="bootstrap-hero" aria-hidden="true"> <div class="bootstrap-hero" aria-hidden="true">
<svg viewBox="0 0 720 300" preserveAspectRatio="xMidYMid slice" focusable="false"> <svg viewbox="0 0 720 300" preserveaspectratio="xMidYMid slice" focusable="false">
<rect width="720" height="300" rx="32" fill="#eef7f1"/> <rect width="720" height="300" rx="32" fill="#eef7f1"></rect>
<circle cx="255" cy="68" r="27" fill="#ffd66b"/> <circle cx="255" cy="68" r="27" fill="#ffd66b"></circle>
<path d="M0 150C92 84 172 113 250 156c81 44 139-14 228-36 82-20 149 2 242 62v118H0Z" fill="#a9d4ca"/> <path d="M0 150C92 84 172 113 250 156c81 44 139-14 228-36 82-20 149 2 242 62v118H0Z" fill="#a9d4ca"></path>
<path d="M0 191c111-67 217-16 306 25 89 40 179-50 283-49 53 0 92 15 131 43v90H0Z" fill="#74aa62"/> <path d="M0 191c111-67 217-16 306 25 89 40 179-50 283-49 53 0 92 15 131 43v90H0Z" fill="#74aa62"></path>
<path d="M0 224c93-51 194-19 283 17 85 34 189-35 279-31 60 3 110 18 158 51v39H0Z" fill="#5e914e"/> <path d="M0 224c93-51 194-19 283 17 85 34 189-35 279-31 60 3 110 18 158 51v39H0Z" fill="#5e914e"></path>
<g fill="#477d49"> <g fill="#477d49">
<circle cx="75" cy="200" r="34"/><circle cx="118" cy="210" r="26"/><circle cx="610" cy="210" r="31"/><circle cx="654" cy="218" r="25"/> <circle cx="75" cy="200" r="34"></circle><circle cx="118" cy="210" r="26"></circle><circle cx="610" cy="210" r="31"></circle><circle cx="654" cy="218" r="25"></circle>
</g> </g>
<g fill="#607d8b" opacity=".9"> <g fill="#607d8b" opacity=".9">
<path d="M305 174h28v36h-28zM339 161h35v49h-35zM381 180h34v30h-34z"/> <path d="M305 174h28v36h-28zM339 161h35v49h-35zM381 180h34v30h-34z"></path>
<path d="m350 160 8-24 8 24Z"/> <path d="m350 160 8-24 8 24Z"></path>
</g> </g>
<path d="M250 300c34-52 88-69 137-74 53-5 113 5 153 22-65 9-127 29-155 52Z" fill="#f6dfb7"/> <path d="M250 300c34-52 88-69 137-74 53-5 113 5 153 22-65 9-127 29-155 52Z" fill="#f6dfb7"></path>
<g transform="translate(538 117)"> <g transform="translate(538 117)">
<rect x="36" y="24" width="13" height="121" rx="6" fill="#7b5231"/> <rect x="36" y="24" width="13" height="121" rx="6" fill="#7b5231"></rect>
<path d="M0 17h105l25 21-25 21H0Z" fill="#f5a332"/> <path d="M0 17h105l25 21-25 21H0Z" fill="#f5a332"></path>
<path d="M14 65h91l22 19-22 19H14Z" fill="#4f914b"/> <path d="M14 65h91l22 19-22 19H14Z" fill="#4f914b"></path>
</g> </g>
</svg> </svg>
</div> </div>
@@ -64,49 +64,49 @@
<div class="bootstrap-action-slot"> <div class="bootstrap-action-slot">
<output id="bootstrap-action-status" class="bootstrap-action-status" aria-live="polite"> <output id="bootstrap-action-status" class="bootstrap-action-status" aria-live="polite">
<span class="bootstrap-loading-dot" aria-hidden="true"></span> <span class="bootstrap-loading-dot" aria-hidden="true"></span>
<span id="bootstrap-status-text" class="bootstrap-status-text">Wegwichtel wird vorbereitet …</span> <span id="bootstrap-status-text" class="bootstrap-status-text" data-i18n="html.bootstrap-status-text.wegwichtel.wird.vorbereitet"></span>
</output> </output>
<button id="bootstrap-enter" class="bootstrap-enter" type="button" hidden> <button id="bootstrap-enter" class="bootstrap-enter" type="button" hidden>
<span>Los geht’s</span> <span data-i18n="html.span.los.gehts"></span>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m9 5 7 7-7 7"/></svg> <svg viewbox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m9 5 7 7-7 7"></path></svg>
</button> </button>
</div> </div>
<p id="bootstrap-error" class="bootstrap-error" hidden></p> <p id="bootstrap-error" class="bootstrap-error" hidden></p>
<button id="bootstrap-retry" class="bootstrap-retry" type="button" hidden>Erneut versuchen</button> <button id="bootstrap-retry" class="bootstrap-retry" type="button" hidden data-i18n="html.bootstrap-retry.erneut.versuchen"></button>
<p id="bootstrap-ready" class="bootstrap-ready" hidden>Alles ist bereit.</p> <p id="bootstrap-ready" class="bootstrap-ready" hidden data-i18n="html.bootstrap-ready.alles.ist.bereit"></p>
<div class="bootstrap-features" aria-label="Wegwichtel Funktionen"> <div class="bootstrap-features" aria-label="" data-i18n-aria-label="html.div.wegwichtel.funktionen">
<div> <div>
<svg viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="m3 7 8-3 10 4 8-3v20l-8 3-10-4-8 3Zm8-3v20m10-16v20"/></svg> <svg viewbox="0 0 32 32" aria-hidden="true" focusable="false"><path d="m3 7 8-3 10 4 8-3v20l-8 3-10-4-8 3Zm8-3v20m10-16v20"></path></svg>
<span>Routen<br>entdecken</span> <span><span data-i18n="html.span.strecken"></span><br><span data-i18n="html.span.entdecken"></span></span>
</div> </div>
<div> <div>
<svg viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M7 18v-4a9 9 0 0 1 18 0v4M7 18H4v7h5v-7Zm18 0h3v7h-5v-7Z"/></svg> <svg viewbox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M7 18v-4a9 9 0 0 1 18 0v4M7 18H4v7h5v-7Zm18 0h3v7h-5v-7Z"></path></svg>
<span>Spannende<br>Geschichten</span> <span><span data-i18n="html.span.spannende"></span><br><span data-i18n="html.span.geschichten"></span></span>
</div> </div>
<div> <div>
<svg viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="m16 3 3.9 8 8.8 1.3-6.4 6.2 1.5 8.8-7.8-4.1-7.8 4.1 1.5-8.8-6.4-6.2 8.8-1.3Z"/></svg> <svg viewbox="0 0 32 32" aria-hidden="true" focusable="false"><path d="m16 3 3.9 8 8.8 1.3-6.4 6.2 1.5 8.8-7.8-4.1-7.8 4.1 1.5-8.8-6.4-6.2 8.8-1.3Z"></path></svg>
<span>Kindgerechte<br>Erlebnisse</span> <span><span data-i18n="html.span.kindgerechte"></span><br><span data-i18n="html.span.erlebnisse"></span></span>
</div> </div>
</div> </div>
<p id="android-browser-hint" class="android-browser-hint" hidden></p> <p id="android-browser-hint" class="android-browser-hint" hidden></p>
<div class="privacy-summary"> <div class="privacy-summary">
<p><strong>Cookies:</strong> Diese Anwendung setzt derzeit keine Cookies und verwendet weder Werbung noch Tracking.</p> <p data-i18n="html.p.hier.gibt.es.keine.werbung.wir.verfolgen"></p>
<label class="switch-row switch-disabled"> <label class="switch-row switch-disabled">
<span> <span>
<strong>Optionale Cookies</strong> <strong data-i18n="html.strong.optionale.cookies"></strong>
<small>Nicht verwendet und deshalb dauerhaft ausgeschaltet.</small> <small data-i18n="html.small.diese.funktion.wird.nicht.verwendet"></small>
</span> </span>
<input class="cookie-toggle" type="checkbox" role="switch" aria-checked="false" disabled> <input class="cookie-toggle" type="checkbox" role="switch" aria-checked="false" disabled>
<span class="switch-track" aria-hidden="true"></span> <span class="switch-track" aria-hidden="true"></span>
</label> </label>
<label class="switch-row"> <label class="switch-row">
<span> <span>
<strong>Streckenfortschritt lokal speichern</strong> <strong data-i18n="html.strong.meinen.fortschritt.merken"></strong>
<small>Speichert ausgewählte Routen und besuchte Streckenpunkte ausschließlich in diesem Browser.</small> <small data-i18n="html.small.merkt.sich.auf.diesem.gerat.welche.stationen"></small>
</span> </span>
<input class="progress-storage-toggle" type="checkbox" role="switch" aria-checked="false"> <input class="progress-storage-toggle" type="checkbox" role="switch" aria-checked="false">
<span class="switch-track" aria-hidden="true"></span> <span class="switch-track" aria-hidden="true"></span>
@@ -114,156 +114,151 @@
<div class="offline-option-row"> <div class="offline-option-row">
<label class="switch-row"> <label class="switch-row">
<span> <span>
<strong>App offline speichern</strong> <strong data-i18n="html.strong.app.ohne.internet.offnen"></strong>
<small>Speichert nur die Dateien zur Anzeige der App. Strecken werden separat ausgewählt und gespeichert.</small> <small data-i18n="html.small.damit.kannst.du.wegwichtel.auch.ohne.internet"></small>
</span> </span>
<input id="offline-cache-toggle" type="checkbox" role="switch" aria-checked="false" aria-describedby="offline-cache-status"> <input id="offline-cache-toggle" type="checkbox" role="switch" aria-checked="false" aria-describedby="offline-cache-status">
<span class="switch-track" aria-hidden="true"></span> <span class="switch-track" aria-hidden="true"></span>
</label> </label>
<button id="offline-cache-help-button" class="offline-help-button" type="button" aria-label="Hilfe zur Offline-Speicherung" title="Was wird offline gespeichert?" aria-expanded="false" aria-controls="offline-cache-help">?</button> <button id="offline-cache-help-button" class="offline-help-button" type="button" aria-label="" title="" aria-expanded="false" aria-controls="offline-cache-help" data-i18n-title="html.offline-cache-help-button.was.wird.fur.unterwegs.gespeichert" data-i18n-aria-label="html.offline-cache-help-button.hilfe.zum.speichern.fur.unterwegs">?</button>
</div> </div>
<section id="offline-cache-help" class="offline-help" aria-labelledby="offline-cache-help-title" hidden> <section id="offline-cache-help" class="offline-help" aria-labelledby="offline-cache-help-title" hidden>
<h2 id="offline-cache-help-title">Was wird offline gespeichert?</h2> <h2 id="offline-cache-help-title" data-i18n="html.offline-cache-help-button.was.wird.fur.unterwegs.gespeichert"></h2>
<p>Dieser Schalter speichert nur die App-Dateien: Startseite, Darstellung, Programme, mitgelieferte Bibliotheken, Symbole und Wichtel-Grafiken. Strecken, GPX-Dateien und Streckenmedien werden dabei nicht gespeichert.</p> <p data-i18n="html.p.mit.diesem.schalter.kannst.du.wegwichtel.auch"></p>
<p>Öffne eine Strecke und wähle „Strecke offline speichern“, um genau diese Strecke mit GPX, Wegpunkten, Bildern und Audio für die App zu speichern. „GPX herunterladen“ legt eine normale GPX-Datei im Download-Ordner deines Geräts ab.</p> <p data-i18n="html.p.offne.eine.strecke.und.tippe.auf.fur"></p>
<p>Die Offline-Kopien für die App liegen im geschützten Speicher dieses Browsers auf diesem Gerät. Dafür musst du keinen Ordner auswählen. Dieser Schalter speichert keine GPS-Aufzeichnung.</p> <p data-i18n="html.p.warte.vor.dem.losgehen.bis.alles.gespeichert"></p>
<p>Warte vor dem Losgehen, bis die Downloads abgeschlossen sind. Aktualisieren braucht Internet. Beim Ausschalten dieses Schalters werden nur die App-Kopien entfernt; separat gespeicherte Strecken, lokale Entwürfe und Streckenfortschritt bleiben erhalten.</p> <p data-i18n="html.p.alles.bleibt.auf.diesem.gerat.der.schalter"></p>
<p>Die Speicheranzeige schätzt die Belegung dieser Website. Local Storage für Entwürfe hat zusätzlich eine eigene Grenze. Der Browser kann Offline-Dateien bei knappem Speicher entfernen; beim Löschen der Website-Daten gehen sie ebenfalls verloren. Externe Karten werden nicht automatisch gespeichert.</p> <p data-i18n="html.p.wenn.du.die.gespeicherten.daten.von.wegwichtel"></p>
</section> </section>
<output id="offline-cache-status" aria-live="polite"></output> <output id="offline-cache-status" aria-live="polite"></output>
<button id="offline-cache-refresh" type="button" hidden>App-Dateien aktualisieren</button> <button id="offline-cache-refresh" type="button" hidden data-i18n="html.offline-cache-refresh.gespeicherte.app.erneuern"></button>
</div> </div>
<section class="storage-usage" data-storage-usage aria-label="Lokaler Speicher"> <section class="storage-usage" data-storage-usage aria-label="" data-i18n-aria-label="html.section.platz.fur.wegwichtel">
<h3>Lokaler Speicher</h3> <h3 data-i18n="html.section.platz.fur.wegwichtel"></h3>
<meter data-storage-site-meter min="0" max="1" value="0" aria-label="Belegung des Website-Speichers" hidden></meter> <meter data-storage-site-meter min="0" max="1" value="0" aria-label="" hidden data-i18n-aria-label="html.meter.belegter.platz.fur.app.bilder.und.ton"></meter>
<p data-storage-site-text>Speicherbelegung wird ermittelt …</p> <p data-storage-site-text data-i18n="html.p.wir.schauen.wie.viel.platz.belegt.ist"></p>
<meter data-storage-local-meter min="0" max="5242880" value="0" aria-label="Local Storage im Verhältnis zum Richtwert von 5 MiB"></meter> <meter data-storage-local-meter min="0" max="5242880" value="0" aria-label="" data-i18n-aria-label="html.meter.belegter.platz.fur.texte.und.einstellungen.der"></meter>
<p data-storage-local-text></p> <p data-storage-local-text></p>
<p>Website-Speicher: Browser-Schätzung für App-Dateien, Offline-Strecken und Medien. Local Storage: separater Speicher für Entwürfe und Einstellungen. Beide Anzeigen beziehen sich nur auf diese Website, nicht auf die gesamte Festplatte.</p> <p data-i18n="html.p.der.erste.balken.zeigt.den.platz.fur"></p>
<output data-storage-warning hidden></output> <output data-storage-warning hidden></output>
<button data-storage-refresh type="button">Speicheranzeige aktualisieren</button> <button data-storage-refresh type="button" data-i18n="html.button.platz.prufen"></button>
</section> </section>
<p id="storage-cleanup-status" class="storage-cleanup-status" hidden></p> <p id="storage-cleanup-status" class="storage-cleanup-status" hidden></p>
<progress id="bootstrap-progress" class="bootstrap-state-model" max="7" value="0" hidden></progress> <progress id="bootstrap-progress" class="bootstrap-state-model" max="7" value="0" hidden></progress>
<ol class="bootstrap-state-model" hidden> <ol class="bootstrap-state-model" hidden>
<li data-step="storage">Lokalen Speicher prüfen</li> <li data-step="storage" data-i18n="html.li.gespeicherte.inhalte.prufen"></li>
<li data-step="jquery">jQuery laden</li> <li data-step="jquery" data-i18n="html.li.wegwichtel.vorbereiten"></li>
<li data-step="jquery-ui">jQuery UI laden</li> <li data-step="jquery-ui" data-i18n="html.li.bedienung.vorbereiten"></li>
<li data-step="modules">Anwendungsmodule laden</li> <li data-step="modules" data-i18n="html.li.app.vorbereiten"></li>
<li data-step="server">Server prüfen</li> <li data-step="server" data-i18n="html.li.verbindung.prufen"></li>
<li data-step="routes">Routen laden</li> <li data-step="routes" data-i18n="html.li.strecken.laden"></li>
<li data-step="location">Standort ermitteln</li> <li data-step="location" data-i18n="html.li.deinen.standort.suchen"></li>
</ol> </ol>
</section> </section>
</div> </div>
<main id="app-shell" hidden> <main id="app-shell" hidden>
<div class="route-control"> <div class="route-control">
<div class="route-control-summary" aria-live="polite"> <div class="route-control-summary" aria-live="polite">
<strong id="route-control-title">Route wählen</strong> <strong id="route-control-title" data-i18n="app.strecke.wahlen"></strong>
<small id="route-control-next">Aktive und lokale Strecken</small> <small id="route-control-next" data-i18n="app.strecken.fur.alle.und.meine.entwurfe"></small>
</div> </div>
<button id="route-control-button" class="route-control-button" type="button" aria-label="Strecke auswählen oder wechseln" title="Strecke auswählen oder wechseln" aria-haspopup="true" aria-expanded="false" aria-controls="route-control-dropdown"> <button id="route-control-button" class="route-control-button" type="button" aria-label="" title="" aria-haspopup="true" aria-expanded="false" aria-controls="route-control-dropdown" data-i18n-title="html.route-control-button.strecke.auswahlen.oder.wechseln" data-i18n-aria-label="html.route-control-button.strecke.auswahlen.oder.wechseln">
<img src="images/icons/route-management.svg" alt="" aria-hidden="true"> <img src="images/icons/route-management.svg" alt="" aria-hidden="true">
</button> </button>
<nav id="route-control-dropdown" class="route-control-dropdown" aria-label="Streckenauswahl und Streckenverwaltung" hidden> <nav id="route-control-dropdown" class="route-control-dropdown" aria-label="" hidden data-i18n-aria-label="html.route-control-dropdown.strecken.auswahlen.und.bearbeiten">
<section aria-labelledby="route-control-server-title"> <section aria-labelledby="route-control-server-title">
<h2 id="route-control-server-title">Aktive Strecken</h2> <h2 id="route-control-server-title" data-i18n="editor-ui.strecken.fur.alle"></h2>
<ul id="route-control-server-list" class="route-control-list" aria-live="polite"></ul> <ul id="route-control-server-list" class="route-control-list" aria-live="polite"></ul>
</section> </section>
<section aria-labelledby="route-control-local-title"> <section aria-labelledby="route-control-local-title">
<h2 id="route-control-local-title">Lokale Strecken</h2> <h2 id="route-control-local-title" data-i18n="editor-ui.meine.entwurfe"></h2>
<ul id="route-control-local-list" class="route-control-list" aria-live="polite"></ul> <ul id="route-control-local-list" class="route-control-list" aria-live="polite"></ul>
</section> </section>
<div class="route-control-actions"> <div class="route-control-actions">
<button id="editor-button" class="editor-button" type="button" aria-label="Strecken verwalten" title="Strecken verwalten" aria-haspopup="dialog" aria-expanded="false" aria-controls="editor-dialog">Strecken verwalten</button> <button id="editor-button" class="editor-button" type="button" aria-label="" title="" aria-haspopup="dialog" aria-expanded="false" aria-controls="editor-dialog" data-i18n-title="html.editor-button.strecken.bearbeiten" data-i18n-aria-label="html.editor-button.strecken.bearbeiten" data-i18n="html.editor-button.strecken.bearbeiten"></button>
<button id="route-control-new" class="editor-primary-button" type="button">Neue lokale Strecke</button> <button id="route-control-new" class="editor-primary-button" type="button" data-i18n="editor-ui.neue.strecke"></button>
</div> </div>
</nav> </nav>
</div> </div>
<section id="routes-page" class="app-page"> <section id="routes-page" class="app-page">
<header> <header>
<h1>Wegwichtel</h1> <h1 data-i18n="html.title.wegwichtel"></h1>
</header> </header>
<div class="content"> <div class="content">
<p id="location-hint" class="notice">Standort wird für nahe Routen lokal auf diesem Gerät ermittelt.</p> <p id="location-hint" class="notice" data-i18n="html.location-hint.wir.suchen.strecken.in.deiner.nahe.dein"></p>
<section> <section>
<h2>Routen in deiner Nähe</h2> <h2 data-i18n="html.h2.strecken.in.deiner.nahe"></h2>
<ul id="nearby-route-list" class="route-list" aria-live="polite"> <ul id="nearby-route-list" class="route-list" aria-live="polite">
<li><p>Routen werden geladen …</p></li> <li><p data-i18n="editor-ui.strecken.werden.geladen"></p></li>
</ul> </ul>
</section> </section>
<section> <section>
<h2>Alle Routen</h2> <h2 data-i18n="html.h2.alle.strecken"></h2>
<label for="route-search">Routen durchsuchen</label> <label for="route-search" data-i18n="html.label.strecken.durchsuchen"></label>
<div class="search-row"> <div class="search-row">
<input <input id="route-search" type="search" autocomplete="off" inputmode="search" placeholder="" data-i18n-placeholder="html.route-search.name.schule.oder.beschreibung">
id="route-search" <button id="clear-route-search" type="button" data-i18n="html.clear-route-search.suche.leeren"></button>
type="search"
autocomplete="off"
inputmode="search"
placeholder="Name, Schule oder Beschreibung">
<button id="clear-route-search" type="button">Suche leeren</button>
</div> </div>
<p id="all-routes-summary" aria-live="polite">Routen werden geladen …</p> <p id="all-routes-summary" aria-live="polite" data-i18n="editor-ui.strecken.werden.geladen"></p>
<ul id="all-route-list" class="route-list" aria-live="polite"> <ul id="all-route-list" class="route-list" aria-live="polite">
<li><p>Routen werden geladen …</p></li> <li><p data-i18n="editor-ui.strecken.werden.geladen"></p></li>
</ul> </ul>
</section> </section>
<section aria-labelledby="local-route-title"> <section aria-labelledby="local-route-title">
<h2 id="local-route-title">Lokale Strecken</h2> <h2 id="local-route-title" data-i18n="editor-ui.meine.entwurfe"></h2>
<p>Diese Entwürfe bestehen aus GPX- und JSON-Daten und sind ausschließlich in diesem Browser gespeichert.</p> <p data-i18n="html.p.diese.strecken.hast.du.auf.diesem.gerat"></p>
<ul id="local-route-list" class="route-list" aria-live="polite"> <ul id="local-route-list" class="route-list" aria-live="polite">
<li><p>Lokale Strecken werden geladen …</p></li> <li><p data-i18n="html.p.meine.entwurfe.werden.geladen"></p></li>
</ul> </ul>
</section> </section>
</div> </div>
</section> </section>
<section id="route-page" class="app-page" hidden> <section id="route-page" class="app-page" hidden>
<header class="has-back-button"> <header class="has-back-button">
<button class="back-button" type="button" data-page-target="#routes-page" aria-label="Zurück zur Routenauswahl" title="Zurück zur Routenauswahl"> <button class="back-button" type="button" data-page-target="#routes-page" aria-label="" title="" data-i18n-title="html.button.zuruck.zur.streckenauswahl" data-i18n-aria-label="html.button.zuruck.zur.streckenauswahl">
<img src="images/icons/back.svg" alt=""> <img src="images/icons/back.svg" alt="">
</button> </button>
<h1 id="route-title">Route</h1> <h1 id="route-title" data-i18n="app.strecke"></h1>
</header> </header>
<div class="content"> <div class="content">
<p id="route-description"></p> <p id="route-description"></p>
<section id="route-offline-controls" class="route-offline-controls" aria-label="Strecke auf dem Gerät speichern" hidden> <section id="route-offline-controls" class="route-offline-controls" aria-label="" hidden data-i18n-aria-label="html.route-offline-controls.strecke.auf.dem.gerat.speichern">
<button id="route-offline-save" type="button">Strecke offline speichern</button> <button id="route-offline-save" type="button" data-i18n="offline-cache.fur.unterwegs.speichern"></button>
<button id="route-offline-remove" type="button" hidden>Offline-Strecke entfernen</button> <button id="route-offline-remove" type="button" hidden data-i18n="html.route-offline-remove.gespeicherte.strecke.entfernen"></button>
<a id="route-gpx-download" download hidden>GPX herunterladen</a> <a id="route-gpx-download" download hidden data-i18n="html.route-gpx-download.weg.als.datei.herunterladen"></a>
<output id="route-offline-status" aria-live="polite"></output> <output id="route-offline-status" aria-live="polite"></output>
<p>Die Offline-Funktion speichert diese Strecke samt GPX, Bildern und Audio im Browser. Der GPX-Download speichert nur die GPX-Datei im Download-Ordner.</p> <p data-i18n="html.p.fur.unterwegs.speichern.nimmt.diese.strecke.mit"></p>
</section> </section>
<dl> <dl>
<div><dt>Länge</dt><dd id="route-distance">–</dd></div> <div><dt data-i18n="html.dt.lange"></dt><dd id="route-distance">–</dd></div>
<div><dt>Höhenmeter</dt><dd id="route-elevation">–</dd></div> <div><dt data-i18n="html.dt.hohenmeter"></dt><dd id="route-elevation">–</dd></div>
<div><dt>Streckenpunkte</dt><dd id="route-poi-count">–</dd></div> <div><dt data-i18n="html.dt.stationen"></dt><dd id="route-poi-count">–</dd></div>
</dl> </dl>
<fieldset> <fieldset>
<legend>Routensteuerung</legend> <legend data-i18n="html.legend.unterwegs"></legend>
<button id="start-route" type="button" aria-label="Route starten" title="Route starten"> <button id="start-route" type="button" aria-label="" title="" data-i18n-title="app.strecke.starten" data-i18n-aria-label="app.strecke.starten">
<img src="images/icons/play.svg" alt="Route starten"> <img src="images/icons/play.svg" alt="" data-i18n-alt="app.strecke.starten">
</button> </button>
<button id="pause-route" type="button" aria-label="Route pausieren" title="Route pausieren" disabled> <button id="pause-route" type="button" aria-label="" title="" disabled data-i18n-title="html.pause-route.strecke.pausieren" data-i18n-aria-label="html.pause-route.strecke.pausieren">
<img src="images/icons/pause.svg" alt="Route pausieren"> <img src="images/icons/pause.svg" alt="" data-i18n-alt="html.pause-route.strecke.pausieren">
</button> </button>
<button id="stop-route" type="button" aria-label="Route beenden" title="Route beenden" disabled> <button id="stop-route" type="button" aria-label="" title="" disabled data-i18n-title="html.stop-route.strecke.beenden" data-i18n-aria-label="html.stop-route.strecke.beenden">
<img src="images/icons/stop.svg" alt="Route beenden"> <img src="images/icons/stop.svg" alt="" data-i18n-alt="html.stop-route.strecke.beenden">
</button> </button>
</fieldset> </fieldset>
<p id="tracking-status" class="notice" hidden></p> <p id="tracking-status" class="notice" hidden></p>
<h2>Streckenpunkte</h2> <h2 data-i18n="html.dt.stationen"></h2>
<ul id="poi-list" class="route-list"></ul> <ul id="poi-list" class="route-list"></ul>
</div> </div>
</section> </section>
<section id="poi-page" class="app-page" hidden> <section id="poi-page" class="app-page" hidden>
<header class="has-back-button"> <header class="has-back-button">
<button class="back-button" type="button" data-page-target="#route-page" aria-label="Zurück zur Route" title="Zurück zur Route"> <button class="back-button" type="button" data-page-target="#route-page" aria-label="" title="" data-i18n-title="html.button.zuruck.zur.strecke" data-i18n-aria-label="html.button.zuruck.zur.strecke">
<img src="images/icons/back.svg" alt=""> <img src="images/icons/back.svg" alt="">
</button> </button>
<h1 id="poi-title">Streckenpunkt</h1> <h1 id="poi-title" data-i18n="html.poi-title.station"></h1>
</header> </header>
<div class="content"> <div class="content">
<div id="slideshow" aria-live="polite"> <div id="slideshow" aria-live="polite">
@@ -271,29 +266,29 @@
<img id="slide-image" alt=""> <img id="slide-image" alt="">
</figure> </figure>
<fieldset> <fieldset>
<legend>Bildnavigation</legend> <legend data-i18n="html.legend.bildnavigation"></legend>
<button id="slide-prev" type="button" aria-label="Vorheriges Bild" title="Vorheriges Bild"> <button id="slide-prev" type="button" aria-label="" title="" data-i18n-title="html.slide-prev.vorheriges.bild" data-i18n-aria-label="html.slide-prev.vorheriges.bild">
<img src="images/icons/chevron-left.svg" alt="Vorheriges Bild"> <img src="images/icons/chevron-left.svg" alt="" data-i18n-alt="html.slide-prev.vorheriges.bild">
</button> </button>
<div> <div>
<p id="slide-caption"></p> <p id="slide-caption"></p>
<span id="slide-position">0 / 0</span> <span id="slide-position">0 / 0</span>
</div> </div>
<button id="slide-next" type="button" aria-label="Nächstes Bild" title="Nächstes Bild"> <button id="slide-next" type="button" aria-label="" title="" data-i18n-title="html.slide-next.nachstes.bild" data-i18n-aria-label="html.slide-next.nachstes.bild">
<img src="images/icons/chevron-right.svg" alt="Nächstes Bild"> <img src="images/icons/chevron-right.svg" alt="" data-i18n-alt="html.slide-next.nachstes.bild">
</button> </button>
</fieldset> </fieldset>
</div> </div>
<p id="poi-description"></p> <p id="poi-description"></p>
<audio id="poi-audio" controls preload="metadata" aria-label="Audiowiedergabe zum Streckenpunkt" hidden>Audio kann auf diesem Gerät nicht wiedergegeben werden.</audio> <audio id="poi-audio" controls preload="metadata" aria-label="" hidden data-i18n-aria-label="html.poi-audio.ton.zur.station" data-i18n="html.poi-audio.dieses.gerat.kann.den.ton.leider.nicht"></audio>
</div> </div>
</section> </section>
<footer id="navigation-footer" hidden aria-live="polite"> <footer id="navigation-footer" hidden aria-live="polite">
<svg id="navigation-arrow" viewBox="0 0 64 64" aria-hidden="true" focusable="false"> <svg id="navigation-arrow" viewbox="0 0 64 64" aria-hidden="true" focusable="false">
<path d="M32 4 53 54 32 44 11 54Z"/> <path d="M32 4 53 54 32 44 11 54Z"></path>
</svg> </svg>
<p> <p>
<span id="navigation-mode">Richtung zum nächsten Streckenpunkt</span> <span id="navigation-mode" data-i18n="html.navigation-mode.richtung.zum.nachsten.station"></span>
<strong id="navigation-distance">–</strong> <strong id="navigation-distance">–</strong>
</p> </p>
</footer> </footer>
@@ -301,140 +296,94 @@
<dialog id="info-dialog" class="info-dialog" aria-labelledby="info-dialog-title"> <dialog id="info-dialog" class="info-dialog" aria-labelledby="info-dialog-title">
<div class="info-dialog-shell"> <div class="info-dialog-shell">
<header class="info-dialog-header"> <header class="info-dialog-header">
<h2 id="info-dialog-title">Informationen</h2> <h2 id="info-dialog-title" data-i18n="legal-ui.informationen"></h2>
<button id="info-close" type="button" aria-label="Informationsfenster schließen" title="Schließen">×</button> <button id="info-close" type="button" aria-label="" title="" data-i18n-title="legal-ui.schliessen" data-i18n-aria-label="legal-ui.informationsfenster.schliessen">×</button>
</header> </header>
<div class="info-dialog-content"> <div class="info-dialog-content">
<article class="info-page" data-info-page="privacy" hidden> <article class="info-page" data-info-page="privacy" hidden>
<p class="legal-warning"><strong>Hinweis:</strong> Diese Datenschutzhinweise sind mit den öffentlich angegebenen Kontaktdaten der Staatlichen Grundschule Herpf vorbefüllt. Hostinganbieter, konkrete Serverprotokollierung und Speicherdauer müssen für die tatsächlich betriebene Wegwichtel-Instanz noch technisch geprüft und ergänzt werden.</p> <p class="legal-warning"><strong data-i18n="html.strong.hinweis"></strong><span data-i18n="html.p.diese.angaben.sind.noch.nicht.vollstandig.die"></span></p>
<h3>1. Verantwortliche Stelle und Datenschutzkontakt</h3> <h3 data-i18n="html.h3.1.verantwortliche.stelle.und.datenschutzkontakt"></h3>
<p> <p>
<strong>Staatliche Grundschule Herpf</strong><br> <strong data-i18n="html.strong.staatliche.grundschule.herpf"></strong><br><span data-i18n="html.p.zum.eichig.3"></span><br><span data-i18n="html.p.98617.meiningen.ot.herpf"></span><br><span data-i18n="html.p.telefon.036943.63722"></span><br><span data-i18n="html.p.e.mail.info.grundschule.herpf.de"></span></p>
Zum Eichig 3<br> <p><span data-i18n="html.p.datenschutzangaben.der.schule"></span><br>
98617 Meiningen / OT Herpf<br> <strong data-i18n="html.strong.staatliches.schulamt.sudthuringen"></strong><br><span data-i18n="html.p.datenschutzbeauftragter"></span><br><span data-i18n="html.p.holderlinstrae.1"></span><br><span data-i18n="html.p.98527.suhl"></span><br><span data-i18n="html.p.telefon.03681.7341.25"></span><br><span data-i18n="html.p.fax.03681.7341.09"></span><br><span data-i18n="html.p.e.mail.datenschutz.suedthueriungen.schulamt.thueringen.de"></span></p>
Telefon: (036943) 63722<br> <p data-i18n="html.p.der.datenschutzbeauftragte.kann.auerdem.uber.die.postadresse"></p>
E-Mail: info@grundschule-herpf.de <h3 data-i18n="html.h3.2.wenn.du.wegwichtel.offnest"></h3>
</p> <p data-i18n="html.p.wenn.du.wegwichtel.offnest.konnen.verbindungsdaten.verarbeitet"></p>
<p> <p><span data-i18n="html.p.wer.die.website.bereitstellt.und.welche.verbindungsdaten"></span><strong data-i18n="html.strong.anbieter.anschrift.gespeicherte.daten.und.speicherdauer.erganzen"></strong></p>
Datenschutzangaben der Schule:<br> <h3 data-i18n="html.h3.3.standortdaten"></h3>
<strong>Staatliches Schulamt Südthüringen</strong><br> <p data-i18n="html.p.du.entscheidest.ob.wegwichtel.deinen.standort.nutzen"></p>
Datenschutzbeauftragter<br> <h3 data-i18n="html.h3.4.deinen.fortschritt.merken"></h3>
Hölderlinstraße 1<br> <p data-i18n="html.p.wenn.du.meinen.fortschritt.merken.einschaltest.speichert"></p>
98527 Suhl<br> <p data-i18n="html.p.wenn.du.deinen.fortschritt.mindestens.einen.tag"></p>
Telefon: 03681 / 7341 25<br>
Fax: 03681 / 7341 09<br>
E-Mail: datenschutz.suedthueriungen@schulamt.thueringen.de
</p>
<p>Der Datenschutzbeauftragte kann außerdem über die Postadresse der Schule mit dem Zusatz „der Datenschutzbeauftragte“ erreicht werden.</p>
<h3>2. Bereitstellung der Website</h3>
<p>Beim Aufruf der Website können durch den Webserver technisch erforderliche Verbindungsdaten verarbeitet werden. Dazu können IP-Adresse, Datum und Uhrzeit der Anfrage, Zeitzonendifferenz zur Greenwich Mean Time, aufgerufene Seite, Zugriffsstatus oder HTTP-Statuscode, übertragene Datenmenge, Referrer, Browser, Betriebssystem, Oberfläche sowie Sprache und Version der Browsersoftware gehören. Diese Verarbeitung dient der technischen Bereitstellung, Stabilität und Sicherheit der Website.</p>
<p>Hostinganbieter, konkrete Protokolldaten und Speicherdauer dieser Wegwichtel-Instanz: <strong>[Anbieter, Anschrift, Art der Serverlogs und Speicherdauer ergänzen]</strong></p>
<h3>3. Standortdaten</h3>
<p>Die Standortfreigabe ist freiwillig. Sie wird benötigt, um nahe Routen vorzuschlagen und während einer gestarteten Route Entfernungen und Richtungen zu berechnen. Die Anwendung verarbeitet die laufende Position im Browser. Sie legt daraus keinen serverseitigen Standortverlauf an.</p>
<h3>4. Lokaler Streckenfortschritt</h3>
<p>Nach ausdrücklicher Aktivierung speichert die Anwendung ein JSON-Objekt im Local Storage dieses Browsers. Es enthält die aktuell ausgewählte Strecke, besuchte und angesehene Streckenpunkte, abgespielte Audioeinträge, den Routenstatus sowie Zeitstempel. Diese Angaben bleiben auf dem verwendeten Gerät und werden nicht mit dem Server synchronisiert.</p>
<p>Gespeicherte Streckendaten werden beim nächsten Öffnen entfernt, sobald ihre letzte Aktualisierung mindestens 24 Stunden zurückliegt. Beim Ausschalten der Funktion werden die Streckendaten sofort gelöscht.</p>
<label class="switch-row compact-switch"> <label class="switch-row compact-switch">
<span> <span>
<strong>Streckenfortschritt lokal speichern</strong> <strong data-i18n="html.strong.meinen.fortschritt.merken"></strong>
<small>Freiwillige Browser-Speicherung ein- oder ausschalten.</small> <small data-i18n="html.small.du.entscheidest.ob.sich.wegwichtel.deinen.fortschritt"></small>
</span> </span>
<input class="progress-storage-toggle" type="checkbox" role="switch" aria-checked="false"> <input class="progress-storage-toggle" type="checkbox" role="switch" aria-checked="false">
<span class="switch-track" aria-hidden="true"></span> <span class="switch-track" aria-hidden="true"></span>
</label> </label>
<h3>5. Lokale Streckenentwürfe</h3> <h3 data-i18n="html.h3.5.deine.eigenen.strecken"></h3>
<p>Neu angelegte Strecken werden zunächst ausschließlich im Local Storage dieses Browsers gespeichert. Die GPX-Daten enthalten den Streckenverlauf und die Koordinaten der Streckenpunkte. Ergänzende JSON-Daten ordnen den Streckenpunkten Texte und Medienreferenzen zu. Bilder und Audiodateien werden platzsparend im lokalen Browser-Medienspeicher gespeichert.</p> <p data-i18n="html.p.neue.strecken.bleiben.zuerst.auf.deinem.gerat"></p>
<p>Lokale Streckenentwürfe sind bewusst von der freiwilligen Fortschrittsspeicherung getrennt. Sie werden nicht durch die 24-Stunden-Bereinigung entfernt und beim Ausschalten der Fortschrittsspeicherung nicht gelöscht. Sie verbleiben im Browser, bis sie ausdrücklich gelöscht werden oder die Browserdaten entfernt werden. Eine Übertragung an den Server erfolgt nur nach ausdrücklicher Betätigung des Upload-Buttons im Editor.</p> <p data-i18n="html.p.deine.entwurfe.bleiben.erhalten.wenn.alter.fortschritt"></p>
<h3>6. Cookies und Tracking</h3> <h3 data-i18n="html.h3.6.werbung.und.gespeicherte.einstellungen"></h3>
<p>Die Anwendung setzt derzeit keine Cookies. Sie verwendet keine Reichweitenmessung, keine personalisierte Werbung und keine Trackingdienste. Eine technisch notwendige Local-Storage-Einstellung merkt sich lediglich, ob die freiwillige Speicherung des Streckenfortschritts aktiviert wurde.</p> <p data-i18n="html.p.wegwichtel.verwendet.keine.cookies.es.zahlt.keine"></p>
<h3>7. Inhalte von Schülerinnen und Schülern</h3> <h3 data-i18n="html.h3.7.inhalte.von.schulerinnen.und.schulern"></h3>
<p>Fotos, Texte und Sprachaufnahmen dürfen nur veröffentlicht werden, wenn die erforderlichen Rechte und Einwilligungen vorliegen. Besonders bei erkennbaren Kindern sind die Vorgaben der Schule und die Einwilligungen der Sorgeberechtigten zu beachten.</p> <p data-i18n="html.p.bilder.texte.und.tonaufnahmen.durfen.nur.geteilt"></p>
<h3>8. Rechte betroffener Personen</h3> <h3 data-i18n="html.h3.8.deine.rechte"></h3>
<p>Betroffene Personen können je nach rechtlicher Voraussetzung Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Datenübertragbarkeit und Widerspruch verlangen. Erteilte Einwilligungen können für die Zukunft widerrufen werden. Außerdem besteht das Recht, sich bei einer Datenschutzaufsichtsbehörde über die Verarbeitung personenbezogener Daten zu beschweren.</p> <p data-i18n="html.p.du.kannst.fragen.welche.daten.uber.dich"></p>
<p> <p><span data-i18n="html.p.zustandige.datenschutzaufsicht"></span><br>
Zuständige Datenschutzaufsicht:<br> <strong data-i18n="html.strong.thuringer.landesbeauftragter.fur.den.datenschutz.und.die"></strong><br><span data-i18n="html.p.postfach.90.04.55.99107.erfurt"></span><br><span data-i18n="html.p.halerstrae.8.99096.erfurt"></span><br><span data-i18n="html.p.telefon.0361.57.311.29.00"></span><br><span data-i18n="html.p.telefax.0361.57.311.29.04"></span><br><span data-i18n="html.p.e.mail.poststelle.datenschutz.thueringen.de"></span><br><span data-i18n="html.p.website.www.tlfdi.de"></span></p>
<strong>Thüringer Landesbeauftragter für den Datenschutz und die Informationsfreiheit (TlfDI)</strong><br>
Postfach 90 04 55, 99107 Erfurt<br>
Häßlerstraße 8, 99096 Erfurt<br>
Telefon: 0361 / 57 311 29 00<br>
Telefax: 0361 / 57 311 29 04<br>
E-Mail: poststelle@datenschutz.thueringen.de<br>
Website: www.tlfdi.de
</p>
</article> </article>
<article class="info-page" data-info-page="imprint" hidden> <article class="info-page" data-info-page="imprint" hidden>
<p class="legal-warning"><strong>Hinweis:</strong> Dieses Impressum ist mit den öffentlich angegebenen Angaben der Staatlichen Grundschule Herpf vorbefüllt. Vor Veröffentlichung der Wegwichtel-Instanz sollte geprüft werden, ob für dieses konkrete Angebot zusätzliche oder abweichende Pflichtangaben erforderlich sind.</p> <p class="legal-warning"><strong data-i18n="html.strong.hinweis"></strong><span data-i18n="html.p.diese.angaben.stammen.von.der.grundschule.herpf"></span></p>
<h3>Anbieter und verantwortlich für dieses Angebot</h3> <h3 data-i18n="html.h3.wer.fur.wegwichtel.verantwortlich.ist"></h3>
<p> <p>
<strong>Staatliche Grundschule Herpf</strong><br> <strong data-i18n="html.strong.staatliche.grundschule.herpf"></strong><br><span data-i18n="html.p.zum.eichig.3"></span><br><span data-i18n="html.p.98617.meiningen.ot.herpf"></span></p>
Zum Eichig 3<br> <h3 data-i18n="html.h3.kontakt"></h3>
98617 Meiningen / OT Herpf <p><span data-i18n="html.p.telefon.036943.63722"></span><br><span data-i18n="html.p.telefax.036943.63843"></span><br><span data-i18n="html.p.e.mail.info.grundschule.herpf.de"></span></p>
</p> <h3 data-i18n="html.h3.leitung.der.schule"></h3>
<h3>Kontakt</h3> <p data-i18n="html.p.frau.wichert.schulleiterin"></p>
<h3 data-i18n="html.h3.schultrager"></h3>
<p> <p>
Telefon: (036943) 63722<br> <strong data-i18n="html.strong.landkreis.schmalkalden.meiningen"></strong><br><span data-i18n="html.p.landratsamt"></span><br><span data-i18n="html.p.obertshauser.platz.1"></span><br><span data-i18n="html.p.98617.meiningen"></span></p>
Telefax: (036943) 63843<br> <h3 data-i18n="html.h3.inhaltlich.verantwortlich"></h3>
E-Mail: info@grundschule-herpf.de <p data-i18n="html.p.frau.wichert"></p>
</p> <h3 data-i18n="html.h3.weitere.pflichtangaben"></h3>
<h3>Vertretungsberechtigte Person</h3> <p data-i18n="html.p.je.nachdem.wer.wegwichtel.betreibt.konnen.weitere"></p>
<p>Frau Wichert, Schulleiterin</p> <h3 data-i18n="html.h3.haftung.fur.inhalte.und.verweise"></h3>
<h3>Schulträger</h3> <p data-i18n="html.p.die.inhalte.werden.sorgfaltig.erstellt.und.regelmaig"></p>
<p> <h3 data-i18n="html.h3.urheber.und.nutzungsrechte"></h3>
<strong>Landkreis Schmalkalden-Meiningen</strong><br> <p data-i18n="html.p.wenn.nichts.anderes.angegeben.ist.liegen.die"></p>
Landratsamt<br>
Obertshäuser Platz 1<br>
98617 Meiningen
</p>
<h3>Inhaltlich verantwortlich</h3>
<p>Frau Wichert</p>
<h3>Weitere Pflichtangaben</h3>
<p>Je nach konkretem Betrieb der Wegwichtel-Instanz können zusätzliche Angaben erforderlich sein, zum Beispiel zur technischen Betreiberin, zum Hosting oder zu ergänzenden Zuständigkeiten. Nicht benötigte Angaben sind zu entfernen.</p>
<h3>Haftung für Inhalte und Verweise</h3>
<p>Die Inhalte werden sorgfältig erstellt und regelmäßig geprüft. Für Hinweise auf fehlerhafte, veraltete oder rechtsverletzende Inhalte steht der oben genannte Kontakt zur Verfügung. Für Inhalte externer Seiten sind deren Betreiber verantwortlich.</p>
<h3>Urheber- und Nutzungsrechte</h3>
<p>Soweit nicht anders angegeben, liegen die Rechte bei der Grundschule Herpf. Texte, Bilder, Tonaufnahmen und Routendaten dürfen nur im Rahmen der jeweils eingeräumten Nutzungsrechte verwendet werden. Die Rechte der beteiligten Kinder, Familien, Lehrkräfte und weiteren Urheberinnen und Urheber bleiben unberührt.</p>
</article> </article>
<article class="info-page" data-info-page="technology" hidden> <article class="info-page" data-info-page="technology" hidden>
<h3>Cookies</h3> <h3 data-i18n="html.h3.was.merkt.sich.wegwichtel"></h3>
<p>Wegwichtel setzt derzeit keine Cookies. Die Anwendung besitzt keine Werbe-, Analyse- oder Trackingfunktionen. Der Cookie-Schalter bleibt deshalb ausgeschaltet.</p> <p data-i18n="html.p.du.kannst.deinen.fortschritt.auf.diesem.gerat"></p>
<label class="switch-row switch-disabled compact-switch"> <p data-i18n="html.p.wenn.du.einen.tag.lang.nichts.mehr"></p>
<span>
<strong>Optionale Cookies</strong>
<small>Derzeit nicht vorhanden.</small>
</span>
<input class="cookie-toggle" type="checkbox" role="switch" aria-checked="false" disabled>
<span class="switch-track" aria-hidden="true"></span>
</label>
<h3>Local Storage</h3>
<p>Der Browser kann auf Wunsch Streckenfortschritte lokal speichern. Verwendet werden die Schlüssel <code>wegwichtel.privacy.v1</code> für die gewählte Einstellung und <code>wegwichtel.route-progress.v1</code> für die freiwilligen Streckendaten. Die Streckendaten werden nach 24 Stunden bereinigt oder beim Ausschalten sofort entfernt.</p>
<p>Mit „App offline speichern“ werden ausschließlich App-Dateien auf diesem Gerät gespeichert, keine Strecken. „Strecke offline speichern“ lädt eine einzeln gewählte veröffentlichte Strecke mit GPX, Bildern und Audio in den geschützten Browser-Speicher (OPFS oder CacheStorage). Beide Speicherstände können unabhängig entfernt werden. „GPX herunterladen“ speichert eine GPX-Datei im Download-Ordner des Geräts. Lokale Entwürfe und der optionale Fortschritt werden separat verwaltet; ihre 24-Stunden-Bereinigung betrifft keine Offline-Dateien.</p>
<p>Lokale Streckenentwürfe werden getrennt davon unter <code>wegwichtel.local-routes.v1.*</code> gespeichert. Pro Entwurf werden GPX- und JSON-Daten im Local Storage vorgehalten; Bilder und Audiodateien liegen getrennt davon im lokalen Browser-Medienspeicher (IndexedDB). Diese Entwürfe werden nicht nach 24 Stunden bereinigt und sind nicht vom Schalter für den Streckenfortschritt abhängig. Durch Mediendateien kann das Speicherlimit des Browsers vergleichsweise schnell erreicht werden. Eine Serverübertragung erfolgt ausschließlich nach ausdrücklicher Betätigung des Upload-Buttons im Editor.</p>
<label class="switch-row compact-switch"> <label class="switch-row compact-switch">
<span> <span>
<strong>Streckenfortschritt lokal speichern</strong> <strong data-i18n="html.strong.meinen.fortschritt.merken"></strong>
<small>Die Änderung gilt sofort auf diesem Gerät.</small> <small data-i18n="html.small.die.anderung.gilt.sofort.auf.diesem.gerat"></small>
</span> </span>
<input class="progress-storage-toggle" type="checkbox" role="switch" aria-checked="false"> <input class="progress-storage-toggle" type="checkbox" role="switch" aria-checked="false">
<span class="switch-track" aria-hidden="true"></span> <span class="switch-track" aria-hidden="true"></span>
</label> </label>
<h3>Lokal eingebundene JavaScripts</h3> <h3 data-i18n="html.h3.ohne.internet.unterwegs"></h3>
<p data-i18n="html.p.mit.app.ohne.internet.offnen.speicherst.du"></p>
<p data-i18n="html.p.mit.weg.als.datei.herunterladen.kannst.du"></p>
<h3 data-i18n="html.h3.deine.eigenen.strecken"></h3>
<p data-i18n="html.p.neue.strecken.bleiben.zuerst.auf.diesem.gerat"></p>
<p data-i18n="html.p.deine.entwurfe.bleiben.gespeichert.bis.du.sie"></p>
<h3 data-i18n="html.h3.welche.erlaubnisse.braucht.die.app"></h3>
<ul> <ul>
<li><strong>jQuery 4.0.0:</strong> DOM-Zugriffe, Ereignisbehandlung und API-Anfragen.</li> <li data-i18n="html.li.deinen.standort.um.strecken.in.deiner.nahe"></li>
<li><strong>jQuery UI 1.14.2:</strong> Schaltflächen und Suchvorschläge.</li> <li data-i18n="html.li.die.richtung.deines.gerats.damit.sich.der"></li>
<li><strong>Wegwichtel-Module:</strong> Routenanzeige, Navigation, Standort, Geräteausrichtung, Bilder, Audio, Vibration, Datenschutz, lokaler Fortschritt und lokale Streckenentwürfe.</li> <li data-i18n="html.li.zugriff.auf.ausgewahlte.bilder.und.tonaufnahmen.wenn"></li>
</ul> </ul>
<p>Alle Bibliotheken werden vom eigenen Server ausgeliefert. Die Anwendung lädt keine JavaScripts, Schriftarten oder Analysepixel von externen Anbietern.</p> <p data-i18n="html.p.du.kannst.diese.erlaubnisse.ablehnen.dann.fehlen"></p>
<h3>Browser-Schnittstellen</h3> <h3 data-i18n="html.h3.werbung.und.andere.anbieter"></h3>
<ul> <p data-i18n="html.p.wegwichtel.zeigt.keine.werbung.und.verfolgt.nicht"></p>
<li>Geolocation API für freiwillige Standortdaten,</li>
<li>Device Orientation API für den Richtungspfeil,</li>
<li>Vibration API für Hinweise bei erreichten Streckenpunkten,</li>
<li>HTML-Audio und HTML-Bilder für lokal bereitgestellte Medien,</li>
<li>File System Access API und IndexedDB für lokale Medienreferenzen sowie FileReader und XML-Schnittstellen zum Verarbeiten von GPX-Dateien,</li>
<li>History API für die Navigation zwischen den Ansichten.</li>
</ul>
<h3>Technische Grenzen</h3>
<p>Browser und Betriebssystem können Standort, Kompass, Audio, Vibration oder lokale Speicherfunktionen sperren. Die Anwendung bleibt grundsätzlich nutzbar, einzelne Komfortfunktionen können dann jedoch fehlen. Lokale Streckenmetadaten liegen im Local Storage; Medienreferenzen beziehungsweise der kompatible Blob-Fallback liegen getrennt in IndexedDB.</p>
</article> </article>
</div> </div>
</div> </div>
@@ -442,58 +391,58 @@
<dialog id="editor-dialog" class="editor-dialog" aria-labelledby="editor-dialog-title"> <dialog id="editor-dialog" class="editor-dialog" aria-labelledby="editor-dialog-title">
<div class="editor-dialog-shell"> <div class="editor-dialog-shell">
<header class="editor-dialog-header"> <header class="editor-dialog-header">
<h2 id="editor-dialog-title">Streckenverwaltung</h2> <h2 id="editor-dialog-title" data-i18n="html.editor-button.strecken.bearbeiten"></h2>
<button id="editor-close" type="button" aria-label="Streckenverwaltung schließen" title="Schließen">×</button> <button id="editor-close" type="button" aria-label="" title="" data-i18n-title="legal-ui.schliessen" data-i18n-aria-label="html.editor-close.fenster.zum.bearbeiten.schlieen">×</button>
</header> </header>
<output id="editor-status" class="editor-status" aria-live="polite" hidden></output> <output id="editor-status" class="editor-status" aria-live="polite" hidden></output>
<section id="editor-route-list-view" class="editor-view" aria-labelledby="editor-route-list-title"> <section id="editor-route-list-view" class="editor-view" aria-labelledby="editor-route-list-title">
<div class="editor-toolbar"> <div class="editor-toolbar">
<h3 id="editor-route-list-title">Vorhandene Strecken</h3> <h3 id="editor-route-list-title" data-i18n="html.editor-route-list-title.deine.strecken"></h3>
<button id="editor-new-route" class="editor-round-button" type="button" aria-label="Neue lokale Strecke anlegen" title="Neue lokale Strecke anlegen"> <button id="editor-new-route" class="editor-round-button" type="button" aria-label="" title="" data-i18n-title="html.editor-new-route.neue.strecke.anlegen" data-i18n-aria-label="html.editor-new-route.neue.strecke.anlegen">
<span aria-hidden="true">+</span> <span aria-hidden="true">+</span>
</button> </button>
</div> </div>
<section class="storage-usage" data-storage-usage aria-label="Lokaler Speicher"> <section class="storage-usage" data-storage-usage aria-label="" data-i18n-aria-label="html.section.platz.fur.wegwichtel">
<h3>Lokaler Speicher</h3> <h3 data-i18n="html.section.platz.fur.wegwichtel"></h3>
<meter data-storage-site-meter min="0" max="1" value="0" aria-label="Belegung des Website-Speichers" hidden></meter> <meter data-storage-site-meter min="0" max="1" value="0" aria-label="" hidden data-i18n-aria-label="html.meter.belegter.platz.fur.app.bilder.und.ton"></meter>
<p data-storage-site-text>Speicherbelegung wird ermittelt …</p> <p data-storage-site-text data-i18n="html.p.wir.schauen.wie.viel.platz.belegt.ist"></p>
<meter data-storage-local-meter min="0" max="5242880" value="0" aria-label="Local Storage im Verhältnis zum Richtwert von 5 MiB"></meter> <meter data-storage-local-meter min="0" max="5242880" value="0" aria-label="" data-i18n-aria-label="html.meter.belegter.platz.fur.texte.und.einstellungen.der"></meter>
<p data-storage-local-text></p> <p data-storage-local-text></p>
<p>Website-Speicher: Browser-Schätzung für App-Dateien, Offline-Strecken und Medien. Local Storage: separater Speicher für Entwürfe und Einstellungen. Beide Anzeigen beziehen sich nur auf diese Website, nicht auf die gesamte Festplatte.</p> <p data-i18n="html.p.der.erste.balken.zeigt.den.platz.fur"></p>
<output data-storage-warning hidden></output> <output data-storage-warning hidden></output>
<button data-storage-refresh type="button">Speicheranzeige aktualisieren</button> <button data-storage-refresh type="button" data-i18n="html.button.platz.prufen"></button>
</section> </section>
<p class="editor-muted">Veröffentlichte Serverstrecken und ausschließlich in diesem Browser gespeicherte Entwürfe werden getrennt dargestellt.</p> <p class="editor-muted" data-i18n="html.p.hier.findest.du.strecken.fur.alle.und"></p>
<ul id="editor-route-list" class="editor-route-list" aria-live="polite"></ul> <ul id="editor-route-list" class="editor-route-list" aria-live="polite"></ul>
</section> </section>
<section id="editor-route-detail-view" class="editor-view" aria-labelledby="editor-route-detail-title" hidden> <section id="editor-route-detail-view" class="editor-view" aria-labelledby="editor-route-detail-title" hidden>
<div class="editor-toolbar editor-toolbar-back"> <div class="editor-toolbar editor-toolbar-back">
<button id="editor-route-back" class="editor-secondary-button" type="button">Zurück</button> <button id="editor-route-back" class="editor-secondary-button" type="button" data-i18n="html.editor-route-back.zuruck"></button>
<h3 id="editor-route-detail-title">Streckendetails</h3> <h3 id="editor-route-detail-title" data-i18n="html.editor-route-detail-title.deine.strecke"></h3>
</div> </div>
<form id="editor-route-form" class="editor-form"> <form id="editor-route-form" class="editor-form">
<input id="editor-route-id" type="hidden"> <input id="editor-route-id" type="hidden">
<label for="editor-route-name">Name der Strecke</label> <label for="editor-route-name" data-i18n="html.label.name.der.strecke"></label>
<input id="editor-route-name" type="text" autocomplete="off" required> <input id="editor-route-name" type="text" autocomplete="off" required>
<label for="editor-route-description">Beschreibung</label> <label for="editor-route-description" data-i18n="html.label.beschreibung"></label>
<textarea id="editor-route-description" rows="4"></textarea> <textarea id="editor-route-description" rows="4"></textarea>
<div id="editor-route-gpx-group" class="editor-gpx-group"> <div id="editor-route-gpx-group" class="editor-gpx-group">
<label for="editor-route-gpx">GPX-Datei importieren oder ersetzen</label> <label for="editor-route-gpx" data-i18n="html.label.weg.aus.einer.datei.ubernehmen"></label>
<input id="editor-route-gpx" type="file" accept=".gpx,application/gpx+xml,text/xml,application/xml"> <input id="editor-route-gpx" type="file" accept=".gpx,application/gpx+xml,text/xml,application/xml">
</div> </div>
<p id="editor-route-activated" class="editor-muted">Neue Strecken werden zunächst lokal gespeichert. Der Upload-Button überträgt den Entwurf als unveröffentlichte Strecke zur Prüfung an den Server.</p> <p id="editor-route-activated" class="editor-muted" data-i18n="html.editor-route-activated.deine.neue.strecke.bleibt.zuerst.auf.diesem"></p>
<div class="editor-form-actions"> <div class="editor-form-actions">
<button id="editor-route-save" class="editor-primary-button" type="submit">Strecke speichern</button> <button id="editor-route-save" class="editor-primary-button" type="submit" data-i18n="html.editor-route-save.strecke.speichern"></button>
<button id="editor-route-upload" class="editor-secondary-button" type="button" hidden>An Server übertragen</button> <button id="editor-route-upload" class="editor-secondary-button" type="button" hidden data-i18n="editor-ui.zur.prufung.senden"></button>
</div> </div>
</form> </form>
<div class="editor-toolbar editor-poi-toolbar"> <div class="editor-toolbar editor-poi-toolbar">
<h3>Streckenpunkte</h3> <h3 data-i18n="html.dt.stationen"></h3>
<button id="editor-add-poi" class="editor-round-button" type="button" aria-label="Streckenpunkt hinzufügen" title="Streckenpunkt hinzufügen"> <button id="editor-add-poi" class="editor-round-button" type="button" aria-label="" title="" data-i18n-title="editor-ui.station.hinzufugen" data-i18n-aria-label="editor-ui.station.hinzufugen">
<span aria-hidden="true">+</span> <span aria-hidden="true">+</span>
</button> </button>
</div> </div>
@@ -501,55 +450,58 @@
</section> </section>
<section id="editor-poi-form-view" class="editor-view" aria-labelledby="editor-poi-form-title" hidden> <section id="editor-poi-form-view" class="editor-view" aria-labelledby="editor-poi-form-title" hidden>
<div class="editor-toolbar editor-toolbar-back"> <div class="editor-toolbar editor-toolbar-back">
<button id="editor-poi-cancel" class="editor-secondary-button" type="button">Zurück zur Strecke</button> <button id="editor-poi-cancel" class="editor-secondary-button" type="button" data-i18n="html.button.zuruck.zur.strecke"></button>
<h3 id="editor-poi-form-title">Streckenpunkt bearbeiten</h3> <h3 id="editor-poi-form-title" data-i18n="editor-ui.station.bearbeiten"></h3>
</div> </div>
<form id="editor-poi-form" class="editor-form"> <form id="editor-poi-form" class="editor-form">
<input id="editor-poi-id" type="hidden"> <input id="editor-poi-id" type="hidden">
<label for="editor-poi-title">Titel</label> <label for="editor-poi-title" data-i18n="html.label.name"></label>
<input id="editor-poi-title" type="text" autocomplete="off" required> <input id="editor-poi-title" type="text" autocomplete="off" required>
<label for="editor-poi-description">Text</label> <label for="editor-poi-description" data-i18n="html.label.text"></label>
<textarea id="editor-poi-description" rows="6"></textarea> <textarea id="editor-poi-description" rows="6"></textarea>
<input id="editor-poi-lat" type="hidden"> <input id="editor-poi-lat" type="hidden">
<input id="editor-poi-lon" type="hidden"> <input id="editor-poi-lon" type="hidden">
<input id="editor-poi-radius" type="hidden" value="25"> <input id="editor-poi-radius" type="hidden" value="25">
<div class="editor-media-actions" aria-label="Medien zum Streckenpunkt hinzufügen"> <div class="editor-media-actions" aria-label="" data-i18n-aria-label="html.div.bilder.und.ton.zur.station.hinzufugen">
<button id="editor-poi-audio-add" class="editor-media-button" type="button" title="Audio hinzufügen"> <button id="editor-poi-audio-add" class="editor-media-button" type="button" title="" data-i18n-title="html.editor-poi-audio-add.ton.hinzufugen">
<svg class="editor-media-icon" viewBox="0 0 32 32" aria-hidden="true" focusable="false"> <svg class="editor-media-icon" viewbox="0 0 32 32" aria-hidden="true" focusable="false">
<path d="M18 5v15.2a5 5 0 1 1-2-4V9l10-2v9.2a5 5 0 1 1-2-4V4.6z"/> <path d="M18 5v15.2a5 5 0 1 1-2-4V9l10-2v9.2a5 5 0 1 1-2-4V4.6z"></path>
<path d="M6 4v6M3 7h6"/> <path d="M6 4v6M3 7h6"></path>
</svg> </svg>
<span>Audio hinzufügen</span> <span data-i18n="html.editor-poi-audio-add.ton.hinzufugen"></span>
</button> </button>
<button id="editor-poi-images-add" class="editor-media-button" type="button" title="Bild hinzufügen"> <button id="editor-poi-images-add" class="editor-media-button" type="button" title="" data-i18n-title="html.editor-poi-images-add.bild.hinzufugen">
<svg class="editor-media-icon" viewBox="0 0 32 32" aria-hidden="true" focusable="false"> <svg class="editor-media-icon" viewbox="0 0 32 32" aria-hidden="true" focusable="false">
<path d="M6 10h4l2-3h8l2 3h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V12a2 2 0 0 1 2-2z"/> <path d="M6 10h4l2-3h8l2 3h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V12a2 2 0 0 1 2-2z"></path>
<circle cx="16" cy="18" r="5"/> <circle cx="16" cy="18" r="5"></circle>
<path d="M6 3v6M3 6h6"/> <path d="M6 3v6M3 6h6"></path>
</svg> </svg>
<span>Bild hinzufügen</span> <span data-i18n="html.editor-poi-images-add.bild.hinzufugen"></span>
</button> </button>
</div> </div>
<input id="editor-poi-audio" type="file" accept="audio/*" hidden> <input id="editor-poi-audio" type="file" accept="audio/*" hidden>
<input id="editor-poi-images" type="file" accept="image/*" multiple hidden> <input id="editor-poi-images" type="file" accept="image/*" multiple hidden>
<div id="editor-poi-media-preview" class="editor-media-preview" aria-label="Medienvorschau"></div> <div id="editor-poi-media-preview" class="editor-media-preview" aria-label="" data-i18n-aria-label="html.editor-poi-media-preview.bilder.und.ton.ansehen"></div>
<p id="editor-poi-media-status" class="editor-muted" aria-live="polite"></p> <p id="editor-poi-media-status" class="editor-muted" aria-live="polite"></p>
<div class="editor-form-actions"> <div class="editor-form-actions">
<button id="editor-poi-save" class="editor-primary-button" type="submit">Streckenpunkt speichern</button> <button id="editor-poi-save" class="editor-primary-button" type="submit" data-i18n="html.editor-poi-save.station.speichern"></button>
<button id="editor-poi-delete" class="editor-danger-button" type="button" hidden> <button id="editor-poi-delete" class="editor-danger-button" type="button" hidden>
<span class="editor-danger-icon" aria-hidden="true">−</span> <span class="editor-danger-icon" aria-hidden="true">−</span>
<span>Streckenpunkt entfernen</span> <span data-i18n="html.span.station.entfernen"></span>
</button> </button>
</div> </div>
</form> </form>
</section> </section>
</div> </div>
</dialog> </dialog>
<script src="js/texts-de.js?v=0.12.14"></script>
<script src="js/i18n.js?v=0.12.14"></script>
<script src="js/user-messages.js?v=0.12.14"></script>
<script src="js/local-routes.js?v=0.12.14"></script> <script src="js/local-routes.js?v=0.12.14"></script>
<script src="js/editor-ui.js?v=0.12.14"></script> <script src="js/editor-ui.js?v=0.12.14"></script>
<script src="js/privacy-storage.js?v=0.12.14"></script> <script src="js/privacy-storage.js?v=0.12.14"></script>
+49 -49
View File
@@ -58,7 +58,7 @@
if (state.routeState === 'paused') { if (state.routeState === 'paused') {
$('#tracking-status') $('#tracking-status')
.prop('hidden', false) .prop('hidden', false)
.text('Gespeicherter Streckenfortschritt gefunden. Die Route kann fortgesetzt werden.'); .text(ns.I18n.t("app.du.kannst.dort.weitermachen.wo.du.aufgehort"));
} else { } else {
$('#tracking-status').prop('hidden', true).text(''); $('#tracking-status').prop('hidden', true).text('');
} }
@@ -82,7 +82,7 @@
function navigate(pageSelector, options = {}) { function navigate(pageSelector, options = {}) {
const target = $(pageSelector); const target = $(pageSelector);
if (!target.length) throw new Error(`Unbekannte Ansicht: ${pageSelector}`); if (!target.length) throw new Error(ns.I18n.t("app.page.missing"));
const previous = state.activePage; const previous = state.activePage;
$('.app-page').attr('hidden', true); $('.app-page').attr('hidden', true);
@@ -115,7 +115,7 @@
const proximity = route.proximityM == null const proximity = route.proximityM == null
? '' ? ''
: `<small>${escapeHtml(ns.Distance.format(route.proximityM))} entfernt</small>`; : `<small>${escapeHtml(ns.Distance.format(route.proximityM))} entfernt</small>`;
const sourceLabel = source === 'local' ? '<small>Lokaler Entwurf</small>' : ''; const sourceLabel = source === 'local' ? ("<small>" + escapeHtml(ns.I18n.t("app.eigener.entwurf")) + "</small>") : '';
return ` return `
<li> <li>
<button type="button" data-route-select data-route-id="${escapeHtml(route.id)}" data-route-source="${source}"> <button type="button" data-route-select data-route-id="${escapeHtml(route.id)}" data-route-source="${source}">
@@ -157,21 +157,21 @@
function updateRouteControlSummary() { function updateRouteControlSummary() {
if (!state.route) { if (!state.route) {
setRouteControlSummary('Route wählen', 'Aktive und lokale Strecken'); setRouteControlSummary(ns.I18n.t("app.strecke.wahlen"), ns.I18n.t("app.strecken.fur.alle.und.meine.entwurfe"));
return; return;
} }
const titleText = state.route.name || 'Route'; const titleText = state.route.name || ns.I18n.t("app.strecke");
const metric = nextPoiMetric(); const metric = nextPoiMetric();
if (!metric) { if (!metric) {
const detailText = (state.route.pois || []).length const detailText = (state.route.pois || []).length
? 'Alle Streckenpunkte erreicht' ? ns.I18n.t("app.alle.stationen.erreicht")
: 'Keine Streckenpunkte vorhanden'; : ns.I18n.t("app.keine.stationen.vorhanden");
setRouteControlSummary(titleText, detailText); setRouteControlSummary(titleText, detailText);
return; return;
} }
const distance = metric.distance == null ? '' : ` · ${Math.round(metric.distance)} m`; const distance = metric.distance == null ? '' : ` · ${Math.round(metric.distance)} m`;
setRouteControlSummary(titleText, `Nächster Streckenpunkt: ${metric.poi.title}${distance}`); setRouteControlSummary(titleText, ns.I18n.t("app.nachste.station", { value1: metric.poi.title, value2: distance }));
} }
function renderRouteControlList(selector, routes, source, emptyText) { function renderRouteControlList(selector, routes, source, emptyText) {
@@ -187,7 +187,7 @@
<li> <li>
<button type="button" data-route-select data-route-id="${escapeHtml(route.id)}" data-route-source="${source}" ${current ? 'aria-current="true"' : ''}> <button type="button" data-route-select data-route-id="${escapeHtml(route.id)}" data-route-source="${source}" ${current ? 'aria-current="true"' : ''}>
<strong>${escapeHtml(route.name)}</strong> <strong>${escapeHtml(route.name)}</strong>
<small>${source === 'local' ? 'Lokaler Entwurf' : 'Veröffentlicht'}</small> <small>${source === 'local' ? ns.I18n.t("app.eigener.entwurf") : ns.I18n.t("app.veroffentlicht")}</small>
</button> </button>
</li> </li>
`); `);
@@ -198,22 +198,22 @@
const routeControlLists = $('.route-control-list'); const routeControlLists = $('.route-control-list');
routeControlLists.attr('aria-busy', 'true'); routeControlLists.attr('aria-busy', 'true');
$('#route-control-server-title').text(`Aktive Strecken (${state.routes.length})`); $('#route-control-server-title').text(ns.I18n.t("app.strecken.fur.alle", { value1: state.routes.length }));
$('#route-control-local-title').text(`Lokale Strecken (${state.localRoutes.length})`); $('#route-control-local-title').text(ns.I18n.t("app.meine.entwurfe", { value1: state.localRoutes.length }));
$('#local-route-title').text(`Lokale Strecken (${state.localRoutes.length})`); $('#local-route-title').text(ns.I18n.t("app.meine.entwurfe", { value1: state.localRoutes.length }));
updateRouteControlSummary(); updateRouteControlSummary();
renderRouteControlList( renderRouteControlList(
'#route-control-server-list', '#route-control-server-list',
state.routes, state.routes,
'server', 'server',
'Keine veröffentlichte Strecke vorhanden.' ns.I18n.t("app.hier.gibt.es.noch.keine.strecken.fur")
); );
renderRouteControlList( renderRouteControlList(
'#route-control-local-list', '#route-control-local-list',
state.localRoutes, state.localRoutes,
'local', 'local',
state.localRoutesError || 'Noch keine lokale Strecke vorhanden.' state.localRoutesError || ns.I18n.t("app.du.hast.noch.keine.eigene.strecke.angelegt")
); );
routeControlLists.attr('aria-busy', 'false'); routeControlLists.attr('aria-busy', 'false');
@@ -239,26 +239,26 @@
.sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de')); .sort((a, b) => String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'));
const nearbyEmptyText = state.position const nearbyEmptyText = state.position
? `Keine Route im Umkreis von ${ns.Config.routeRadiusKm} km gefunden. Wähle unten eine beliebige Route aus.` ? ns.I18n.t("app.keine.strecke.im.umkreis.von.km.gefunden", { value1: ns.Config.routeRadiusKm })
: 'Für Empfehlungen in der Nähe ist ein Standort erforderlich. Alle Routen bleiben unten auswählbar.'; : ns.I18n.t("app.erlaube.deinen.standort.um.strecken.in.deiner");
renderRouteItems('#nearby-route-list', nearbyRoutes, nearbyEmptyText); renderRouteItems('#nearby-route-list', nearbyRoutes, nearbyEmptyText);
renderRouteItems( renderRouteItems(
'#all-route-list', '#all-route-list',
allRoutes, allRoutes,
searchTerm ? 'Keine Route entspricht dem Suchbegriff.' : 'Es sind noch keine veröffentlichten Routen vorhanden.' searchTerm ? ns.I18n.t("app.keine.passende.strecke.gefunden.versuche.ein.anderes") : ns.I18n.t("app.hier.gibt.es.noch.keine.strecken.fur")
); );
renderRouteItems( renderRouteItems(
'#local-route-list', '#local-route-list',
localRoutes, localRoutes,
state.localRoutesError || 'Noch keine lokalen Strecken gespeichert.', state.localRoutesError || ns.I18n.t("app.du.hast.noch.keine.eigene.strecke.angelegt"),
'local' 'local'
); );
$('#all-routes-summary').text( $('#all-routes-summary').text(
searchTerm searchTerm
? `${allRoutes.length} von ${routes.length} veröffentlichten Routen gefunden.` ? ns.I18n.t("app.von.strecken.gefunden", { value1: allRoutes.length, value2: routes.length })
: `${routes.length} veröffentlichte Routen verfügbar.` : ns.I18n.t("app.strecken.zur.auswahl", { value1: routes.length })
); );
renderRouteControl(); renderRouteControl();
} }
@@ -288,7 +288,7 @@
$('#app-shell').removeClass('has-navigation'); $('#app-shell').removeClass('has-navigation');
} }
function showNavigation(message = 'Position wird ermittelt', distance = '–') { function showNavigation(message = ns.I18n.t("app.location.searching"), distance = '–') {
$('#navigation-mode').text(message); $('#navigation-mode').text(message);
$('#navigation-distance').text(distance); $('#navigation-distance').text(distance);
$('#navigation-footer').prop('hidden', false); $('#navigation-footer').prop('hidden', false);
@@ -305,7 +305,7 @@
function updateRouteControls() { function updateRouteControls() {
const running = state.routeState === 'running'; const running = state.routeState === 'running';
const paused = state.routeState === 'paused'; const paused = state.routeState === 'paused';
const startLabel = paused ? 'Route fortsetzen' : 'Route starten'; const startLabel = paused ? ns.I18n.t("app.strecke.fortsetzen") : ns.I18n.t("app.strecke.starten");
$('#start-route') $('#start-route')
.prop('disabled', running) .prop('disabled', running)
.attr({ 'aria-label': startLabel, title: startLabel }); .attr({ 'aria-label': startLabel, title: startLabel });
@@ -333,7 +333,7 @@
setActivePoi(null); setActivePoi(null);
hideNavigation(); hideNavigation();
if (announce && wasActive && !returnToRoutes) { if (announce && wasActive && !returnToRoutes) {
$('#tracking-status').prop('hidden', false).text('Route beendet.'); $('#tracking-status').prop('hidden', false).text(ns.I18n.t("app.strecke.beendet"));
} else { } else {
$('#tracking-status').prop('hidden', true).text(''); $('#tracking-status').prop('hidden', true).text('');
} }
@@ -357,8 +357,8 @@
ns.AudioPlayer.pause(); ns.AudioPlayer.pause();
$('#tracking-status') $('#tracking-status')
.prop('hidden', false) .prop('hidden', false)
.text('Route pausiert. Die Standortverfolgung ist angehalten.'); .text(ns.I18n.t("app.du.machst.eine.pause.die.app.sucht"));
showNavigation('Route pausiert', $('#navigation-distance').text() || '–'); showNavigation(ns.I18n.t("app.strecke.pausiert"), $('#navigation-distance').text() || '–');
updateRouteControls(); updateRouteControls();
updateRouteControlSummary(); updateRouteControlSummary();
} }
@@ -384,7 +384,7 @@
updateRouteControls(); updateRouteControls();
const list = $('#poi-list').empty(); const list = $('#poi-list').empty();
if (!route.pois.length) { if (!route.pois.length) {
list.append('<li><p>Diese Route enthält noch keine Streckenpunkte.</p></li>'); list.append(("<li><p>" + escapeHtml(ns.I18n.t("app.diese.strecke.hat.noch.keine.stationen")) + "</p></li>"));
updateRouteControlSummary(); updateRouteControlSummary();
return; return;
} }
@@ -484,8 +484,8 @@
const indicator = $(`#poi-list [data-poi-id="${metric.poi.id}"] .poi-proximity`); const indicator = $(`#poi-list [data-poi-id="${metric.poi.id}"] .poi-proximity`);
const distance = Math.round(metric.distance); const distance = Math.round(metric.distance);
indicator indicator
.attr('aria-label', `${distance} Meter entfernt`) .attr('aria-label', ns.I18n.t("app.distance.spoken", { value1: distance }))
.attr('title', `${distance} m entfernt`); .attr('title', ns.I18n.t("app.distance.short", { value1: distance }));
indicator.find('svg').css('transform', `rotate(${metric.rotation.toFixed(1)}deg)`); indicator.find('svg').css('transform', `rotate(${metric.rotation.toFixed(1)}deg)`);
indicator.find('small').text(`${distance} m`); indicator.find('small').text(`${distance} m`);
}); });
@@ -537,27 +537,27 @@
$('#navigation-arrow').css('transform', `rotate(${metric.rotation.toFixed(1)}deg)`); $('#navigation-arrow').css('transform', `rotate(${metric.rotation.toFixed(1)}deg)`);
showNavigation( showNavigation(
heading == null heading == null
? `Norden oben · nächster Streckenpunkt: ${metric.poi.title}` ? ns.I18n.t("app.norden.oben.nachste.station", { value1: metric.poi.title })
: `Nächster Streckenpunkt: ${metric.poi.title}`, : ns.I18n.t("app.nachste.station.2", { value1: metric.poi.title }),
`${distance} m` `${distance} m`
); );
$('#navigation-footer').attr( $('#navigation-footer').attr(
'aria-label', 'aria-label',
`Nächster Streckenpunkt ${metric.poi.title} in ${distance} Metern` ns.I18n.t("app.nachste.station.in.metern", { value1: metric.poi.title, value2: distance })
); );
} }
function showRoutePointNavigation(position, progress) { function showRoutePointNavigation(position, progress) {
if (!progress) { if (!progress) {
$('#navigation-arrow').css('transform', 'rotate(0deg)'); $('#navigation-arrow').css('transform', 'rotate(0deg)');
showNavigation('Keine Navigationspunkte vorhanden', '–'); showNavigation(ns.I18n.t("app.path.missing"), '–');
return; return;
} }
if (progress.reached) { if (progress.reached) {
$('#navigation-arrow').css('transform', 'rotate(0deg)'); $('#navigation-arrow').css('transform', 'rotate(0deg)');
showNavigation('Alle Streckenpunkte und das Routenziel erreicht', '0 m'); showNavigation(ns.I18n.t("app.alle.stationen.und.das.ziel.erreicht"), '0 m');
$('#navigation-footer').attr('aria-label', 'Alle Streckenpunkte und das Routenziel erreicht'); $('#navigation-footer').attr('aria-label', ns.I18n.t("app.alle.stationen.und.das.ziel.erreicht"));
return; return;
} }
const bearing = ns.Distance.bearing(position, progress.target); const bearing = ns.Distance.bearing(position, progress.target);
@@ -568,12 +568,12 @@
const distance = Math.round(progress.distance); const distance = Math.round(progress.distance);
$('#navigation-arrow').css('transform', `rotate(${rotation.toFixed(1)}deg)`); $('#navigation-arrow').css('transform', `rotate(${rotation.toFixed(1)}deg)`);
showNavigation( showNavigation(
heading == null ? 'Norden oben · nächster Routenpunkt' : 'Nächster Routenpunkt', heading == null ? ns.I18n.t("app.norden.oben.nachster.punkt.auf.dem.weg") : ns.I18n.t("app.nachster.punkt.auf.dem.weg"),
`${distance} m` `${distance} m`
); );
$('#navigation-footer').attr( $('#navigation-footer').attr(
'aria-label', 'aria-label',
`Nächster Routenpunkt in ${distance} Metern` ns.I18n.t("app.nachster.punkt.auf.dem.weg.in.metern", { value1: distance })
); );
} }
@@ -608,8 +608,8 @@
const nearest = nearestPendingPoi(metrics); const nearest = nearestPendingPoi(metrics);
updateNavigation(position); updateNavigation(position);
$('#tracking-status').text( $('#tracking-status').text(
`GPS aktiv · Genauigkeit ${Math.round(position.accuracy)} m` ns.I18n.t("app.dein.standort.wird.gesucht.auf.etwa.m", { value1: Math.round(position.accuracy) })
+ (nearest ? ` · nächster Streckenpunkt ${Math.round(nearest.distance)} m` : ' · alle Streckenpunkte erreicht') + (nearest ? ns.I18n.t("app.nachste.station.m", { value1: Math.round(nearest.distance) }) : ns.I18n.t("app.alle.stationen.erreicht.2"))
); );
if (nearest && nearest.distance <= nearest.poi.triggerRadiusM) { if (nearest && nearest.distance <= nearest.poi.triggerRadiusM) {
state.triggered.add(nearest.poi.id); state.triggered.add(nearest.poi.id);
@@ -636,11 +636,11 @@
updateRouteControlSummary(); updateRouteControlSummary();
$('#tracking-status') $('#tracking-status')
.prop('hidden', false) .prop('hidden', false)
.text(resumed ? 'Route wird fortgesetzt.' : 'Route wird gestartet. GPS wird aktiviert.'); .text(resumed ? ns.I18n.t("app.strecke.wird.fortgesetzt") : ns.I18n.t("app.location.start"));
showNavigation(resumed ? 'Route wird fortgesetzt' : 'Position wird ermittelt'); showNavigation(resumed ? ns.I18n.t("app.strecke.wird.fortgesetzt.2") : ns.I18n.t("app.location.searching"));
ns.Geo.start( ns.Geo.start(
evaluatePosition, evaluatePosition,
error => $('#tracking-status').text(`GPS-Fehler: ${error.message}`) () => $('#tracking-status').text(ns.I18n.t("app.dein.standort.konnte.nicht.gefunden.werden.prufe"))
); );
const orientationAvailable = await ns.Orientation.start(updateHeading); const orientationAvailable = await ns.Orientation.start(updateHeading);
@@ -648,12 +648,12 @@
ns.Orientation.stop(); ns.Orientation.stop();
return; return;
} }
if (!orientationAvailable) $('#navigation-mode').text('Norden oben · Navigation'); if (!orientationAvailable) $('#navigation-mode').text(ns.I18n.t("app.norden.oben.navigation"));
} }
function routesFromResponse(response) { function routesFromResponse(response) {
if (!Array.isArray(response?.routes)) { if (!Array.isArray(response?.routes)) {
throw new TypeError('Die Routen-API hat keine gültige Routenliste geliefert.'); throw new TypeError(ns.I18n.t("app.die.strecken.konnten.nicht.geladen.werden.bitte"));
} }
return response.routes return response.routes
.filter(route => route?.id != null && route?.name != null) .filter(route => route?.id != null && route?.name != null)
@@ -666,7 +666,7 @@
state.localRoutesError = null; state.localRoutesError = null;
} catch (error) { } catch (error) {
state.localRoutes = []; state.localRoutes = [];
state.localRoutesError = error.message || 'Lokale Strecken konnten nicht gelesen werden.'; state.localRoutesError = ns.UserMessages?.error(error, ns.I18n.t("app.deine.entwurfe.konnten.nicht.geoffnet.werden.bitte")) || ns.I18n.t("app.deine.entwurfe.konnten.nicht.geoffnet.werden.bitte");
} }
} }
@@ -685,12 +685,12 @@
state.position = position; state.position = position;
updateRouteProximity(position); updateRouteProximity(position);
$('#location-hint').text( $('#location-hint').text(
`Standort ermittelt (Genauigkeit ${Math.round(position.accuracy)} m). ` ns.I18n.t("app.dein.standort.wurde.auf.etwa.m.genau", { value1: Math.round(position.accuracy) })
+ `Nahe Routen werden bis ${ns.Config.routeRadiusKm} km empfohlen.` + ns.I18n.t("app.wir.zeigen.dir.strecken.bis.km.entfernung", { value1: ns.Config.routeRadiusKm })
); );
} catch (error) { } catch {
$('#location-hint').text( $('#location-hint').text(
`Standort nicht verfügbar: ${error.message}. Alle Routen können weiterhin ausgewählt werden.` ns.I18n.t("app.dein.standort.konnte.nicht.gefunden.werden.du")
); );
} finally { } finally {
context.markStep('location', 'done'); context.markStep('location', 'done');
@@ -746,7 +746,7 @@
try { try {
await selectRoute(String(button.data('route-source') || 'server'), String(button.data('route-id'))); await selectRoute(String(button.data('route-source') || 'server'), String(button.data('route-id')));
} catch (error) { } catch (error) {
alert(error.responseJSON?.message || error.statusText || error.message); alert(ns.UserMessages?.error(error, ns.I18n.t("app.die.strecke.konnte.nicht.geoffnet.werden.prufe")) || ns.I18n.t("app.die.strecke.konnte.nicht.geoffnet.werden.bitte"));
} finally { } finally {
button.prop('disabled', false); button.prop('disabled', false);
} }
+15 -15
View File
@@ -4,13 +4,13 @@
const versions = Object.freeze({ app: '0.12.14', jquery: '4.0.0', jqueryUi: '1.14.2' }); const versions = Object.freeze({ app: '0.12.14', jquery: '4.0.0', jqueryUi: '1.14.2' });
const steps = ['storage', 'jquery', 'jquery-ui', 'modules', 'server', 'routes', 'location']; const steps = ['storage', 'jquery', 'jquery-ui', 'modules', 'server', 'routes', 'location'];
const stepLabels = Object.freeze({ const stepLabels = Object.freeze({
storage: 'Lokalen Speicher prüfen …', get storage() { return globalThis.Wegwichtel.I18n.t("bootstrap-loader.gespeicherte.inhalte.prufen"); },
jquery: 'jQuery laden …', get jquery() { return globalThis.Wegwichtel.I18n.t("bootstrap-loader.wegwichtel.vorbereiten"); },
'jquery-ui': 'jQuery UI laden …', get 'jquery-ui'() { return globalThis.Wegwichtel.I18n.t("bootstrap-loader.die.bedienung.vorbereiten"); },
modules: 'Anwendungsmodule laden …', get modules() { return globalThis.Wegwichtel.I18n.t("bootstrap-loader.die.app.vorbereiten"); },
server: 'Server prüfen …', get server() { return globalThis.Wegwichtel.I18n.t("bootstrap-loader.verbindung.prufen"); },
routes: 'Routen laden …', get routes() { return globalThis.Wegwichtel.I18n.t("bootstrap-loader.strecken.laden"); },
location: 'Standort ermitteln …' get location() { return globalThis.Wegwichtel.I18n.t("bootstrap.location"); }
}); });
const screen = document.getElementById('bootstrap-screen'); const screen = document.getElementById('bootstrap-screen');
const progress = document.getElementById('bootstrap-progress'); const progress = document.getElementById('bootstrap-progress');
@@ -43,8 +43,8 @@
androidBrowserHint.hidden = false; androidBrowserHint.hidden = false;
androidBrowserHint.dataset.browser = isChrome ? 'chrome' : 'other'; androidBrowserHint.dataset.browser = isChrome ? 'chrome' : 'other';
androidBrowserHint.textContent = isChrome androidBrowserHint.textContent = isChrome
? 'Android mit Chrome erkannt. Lokale Bilder und Audiodateien können als Dateireferenzen verwendet werden.' ? globalThis.Wegwichtel.I18n.t("bootstrap-loader.auf.diesem.gerat.kannst.du.bilder.und")
: 'Android erkannt. Für lokale Bilder und Audiodateien bitte Chrome verwenden; dort können Dateireferenzen ohne Kopie in den Local Storage genutzt werden.'; : globalThis.Wegwichtel.I18n.t("bootstrap-loader.tipp.fur.android.mit.chrome.lassen.sich");
} }
function schedule(callback, delay = 0) { function schedule(callback, delay = 0) {
@@ -93,7 +93,7 @@
if (progress) progress.value = completedSteps.size; if (progress) progress.value = completedSteps.size;
if (state === 'failed') { if (state === 'failed') {
setStatusText(`Fehler bei: ${stepLabels[step] || step}`); setStatusText(globalThis.Wegwichtel.I18n.t("bootstrap.step.failed", { value1: stepLabels[step] || step }));
return; return;
} }
if (state === 'done') { if (state === 'done') {
@@ -108,10 +108,10 @@
script.src = versioned(src); script.src = versioned(src);
script.async = false; script.async = false;
script.onload = () => { script.onload = () => {
if (test && !test()) reject(new Error(`Selbsttest fehlgeschlagen: ${src}`)); if (test && !test()) reject(new Error(globalThis.Wegwichtel.I18n.t("bootstrap-loader.ein.teil.der.app.konnte.nicht.starten")));
else resolve(); else resolve();
}; };
script.onerror = () => reject(new Error(`Datei konnte nicht geladen werden: ${src}`)); script.onerror = () => reject(new Error(globalThis.Wegwichtel.I18n.t("bootstrap-loader.ein.teil.der.app.konnte.nicht.geladen")));
document.head.appendChild(script); document.head.appendChild(script);
}); });
} }
@@ -127,7 +127,7 @@
} }
function updateCleanupProgress(removed, total) { function updateCleanupProgress(removed, total) {
cleanupStatus.textContent = `Entferne alte Streckendaten aus dem Speicher (${removed} / ${total})`; cleanupStatus.textContent = globalThis.Wegwichtel.I18n.t("bootstrap-loader.alten.fortschritt.loschen", { value1: removed, value2: total });
cleanupStatus.hidden = false; cleanupStatus.hidden = false;
} }
@@ -161,7 +161,7 @@
screen.classList.add('ready'); screen.classList.add('ready');
readyText.hidden = false; readyText.hidden = false;
setStatusText('Vorbereitung abgeschlossen.'); setStatusText(globalThis.Wegwichtel.I18n.t("bootstrap.ready"));
actionStatus?.classList.add('is-leaving'); actionStatus?.classList.add('is-leaving');
enter.hidden = false; enter.hidden = false;
const showEnter = () => enter.classList.add('is-visible'); const showEnter = () => enter.classList.add('is-visible');
@@ -222,7 +222,7 @@
console.error(error); console.error(error);
const current = nextPendingStep(); const current = nextPendingStep();
if (current) mark(current, 'failed'); if (current) mark(current, 'failed');
errorBox.textContent = error.message; errorBox.textContent = globalThis.Wegwichtel.UserMessages?.error(error, globalThis.Wegwichtel.I18n.t("bootstrap-loader.wegwichtel.konnte.nicht.starten.prufe.deine.verbindung")) || globalThis.Wegwichtel.I18n.t("bootstrap-loader.wegwichtel.konnte.nicht.starten.bitte.versuche.es");
errorBox.hidden = false; errorBox.hidden = false;
retry.hidden = false; retry.hidden = false;
} }
+77 -77
View File
@@ -141,17 +141,17 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
const next = highest + 1; const next = highest + 1;
if (!Number.isSafeInteger(next)) { if (!Number.isSafeInteger(next)) {
throw new TypeError('Für diese lokale Strecke kann keine weitere Streckenpunkt-ID vergeben werden.'); throw new TypeError(ns.I18n.t("editor-ui.zu.dieser.strecke.kann.gerade.keine.weitere"));
} }
return next; return next;
} }
function routeName(route) { function routeName(route) {
return text(route?.name || route?.title || 'Unbenannte Strecke'); return text(route?.name || route?.title || ns.I18n.t("editor-ui.unbenannte.strecke"));
} }
function poiTitle(poi) { function poiTitle(poi) {
return text(poi?.title || poi?.name || 'Unbenannter Streckenpunkt'); return text(poi?.title || poi?.name || ns.I18n.t("editor-ui.station.ohne.namen"));
} }
function apiBase() { function apiBase() {
@@ -184,7 +184,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function setStatus(message, isError = false) { function setStatus(message, isError = false) {
const status = byId(ids.status); const status = byId(ids.status);
if (!status) return; if (!status) return;
status.textContent = message; status.textContent = isError ? (ns.UserMessages?.error(message) || message) : message;
status.hidden = !message; status.hidden = !message;
status.dataset.kind = isError ? 'error' : 'notice'; status.dataset.kind = isError ? 'error' : 'notice';
} }
@@ -196,9 +196,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function routeDialogTitle() { function routeDialogTitle() {
if (state.currentSource === 'local') { if (state.currentSource === 'local') {
return routeId(state.currentRoute) ? 'Lokale Strecke bearbeiten' : 'Neue lokale Strecke'; return routeId(state.currentRoute) ? ns.I18n.t("editor-ui.eigene.strecke.bearbeiten") : ns.I18n.t("editor-ui.neue.strecke");
} }
return 'Veröffentlichte Strecke bearbeiten'; return ns.I18n.t("editor-ui.strecke.ansehen");
} }
function showListView() { function showListView() {
@@ -216,7 +216,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
function showPoiView() { function showPoiView() {
setDialogTitle(state.currentPoi ? 'Streckenpunkt bearbeiten' : 'Streckenpunkt hinzufügen'); setDialogTitle(state.currentPoi ? ns.I18n.t("editor-ui.station.bearbeiten") : ns.I18n.t("editor-ui.station.hinzufugen"));
setHidden(byId(ids.routeListView), true); setHidden(byId(ids.routeListView), true);
setHidden(byId(ids.routeDetailView), true); setHidden(byId(ids.routeDetailView), true);
setHidden(byId(ids.poiFormView), false); setHidden(byId(ids.poiFormView), false);
@@ -264,8 +264,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function handleResponse(response) { async function handleResponse(response) {
if (response.ok) return parseJson(response); if (response.ok) return parseJson(response);
const message = await response.text(); const payload = await parseJson(response);
const error = new Error(message || `HTTP ${response.status}`); const error = new Error(payload.message || ns.I18n.t("editor-ui.die.aktion.konnte.nicht.ausgefuhrt.werden.bitte"));
error.status = response.status; error.status = response.status;
throw error; throw error;
} }
@@ -354,13 +354,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const label = document.createElement('div'); const label = document.createElement('div');
appendText(label, 'strong', routeName(route)); appendText(label, 'strong', routeName(route));
appendText(label, 'small', routeSource(route) === 'local' ? 'Lokaler Entwurf' : 'Veröffentlichte Strecke'); appendText(label, 'small', routeSource(route) === 'local' ? ns.I18n.t("app.eigener.entwurf") : ns.I18n.t("editor-ui.veroffentlichte.strecke"));
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = classes.iconRow; actions.className = classes.iconRow;
actions.appendChild(iconButton('Strecke bearbeiten', '✎', 'edit-route')); actions.appendChild(iconButton(ns.I18n.t("editor-ui.strecke.bearbeiten"), '✎', 'edit-route'));
if (routeSource(route) === 'local') { if (routeSource(route) === 'local') {
actions.appendChild(iconButton('Strecke löschen', '×', 'delete-route', false, true)); actions.appendChild(iconButton(ns.I18n.t("editor-ui.strecke.loschen"), '×', 'delete-route', false, true));
} }
item.append(label, actions); item.append(label, actions);
return item; return item;
@@ -380,13 +380,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const title = poiTitle(poi); const title = poiTitle(poi);
const label = document.createElement('div'); const label = document.createElement('div');
appendText(label, 'strong', title); appendText(label, 'strong', title);
appendText(label, 'small', `Streckenpunkt ${index + 1}`); appendText(label, 'small', ns.I18n.t("editor-ui.station", { value1: index + 1 }));
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = classes.iconRow; actions.className = classes.iconRow;
actions.appendChild(iconButton(`${title} nach oben schieben`, '↑', 'move-poi-up', index === 0)); actions.appendChild(iconButton(ns.I18n.t("editor.station.up", { value1: title }), '↑', 'move-poi-up', index === 0));
actions.appendChild(iconButton(`${title} nach unten schieben`, '↓', 'move-poi-down', index === state.pois.length - 1)); actions.appendChild(iconButton(ns.I18n.t("editor.station.down", { value1: title }), '↓', 'move-poi-down', index === state.pois.length - 1));
actions.appendChild(iconButton(`${title} bearbeiten`, '✎', 'edit-poi')); actions.appendChild(iconButton(`${title} bearbeiten`, '✎', 'edit-poi'));
actions.appendChild(iconButton(`${title} löschen`, '×', 'delete-poi', false, true)); actions.appendChild(iconButton(ns.I18n.t("editor-ui.loschen", { value1: title }), '×', 'delete-poi', false, true));
item.append(label, actions); item.append(label, actions);
return item; return item;
} }
@@ -409,8 +409,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
list.textContent = ''; list.textContent = '';
const serverRoutes = state.routes.filter(route => routeSource(route) === 'server'); const serverRoutes = state.routes.filter(route => routeSource(route) === 'server');
const localRoutes = state.routes.filter(route => routeSource(route) === 'local'); const localRoutes = state.routes.filter(route => routeSource(route) === 'local');
renderRouteGroup(list, 'Veröffentlichte Strecken', serverRoutes, 'Keine veröffentlichte Strecke vorhanden.'); renderRouteGroup(list, ns.I18n.t("editor-ui.strecken.fur.alle"), serverRoutes, ns.I18n.t("app.hier.gibt.es.noch.keine.strecken.fur"));
renderRouteGroup(list, 'Lokale Strecken', localRoutes, 'Noch kein lokaler Entwurf vorhanden.'); renderRouteGroup(list, ns.I18n.t("editor-ui.meine.entwurfe"), localRoutes, ns.I18n.t("editor-ui.noch.kein.eigener.entwurf.vorhanden"));
} }
function renderPoiList() { function renderPoiList() {
@@ -420,7 +420,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
if (!state.pois.length) { if (!state.pois.length) {
const item = document.createElement('li'); const item = document.createElement('li');
item.className = classes.poiItem; item.className = classes.poiItem;
appendText(item, 'span', 'Noch keine Streckenpunkte angelegt.'); appendText(item, 'span', ns.I18n.t("editor-ui.noch.keine.stationen.angelegt"));
list.appendChild(item); list.appendChild(item);
return; return;
} }
@@ -437,7 +437,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
async function loadRoutes() { async function loadRoutes() {
setStatus('Strecken werden geladen …'); setStatus(ns.I18n.t("editor-ui.strecken.werden.geladen"));
const payload = await readJson('/routes?activatedOnly=true'); const payload = await readJson('/routes?activatedOnly=true');
const serverRoutes = routeArray(payload).map(route => ({ ...route, _source: 'server' })); const serverRoutes = routeArray(payload).map(route => ({ ...route, _source: 'server' }));
let localRoutes = []; let localRoutes = [];
@@ -496,7 +496,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function fillRouteForm(route) { function fillRouteForm(route) {
byId(ids.routeId).value = routeId(route); byId(ids.routeId).value = routeId(route);
byId(ids.routeName).value = routeName(route) === 'Unbenannte Strecke' ? '' : routeName(route); byId(ids.routeName).value = routeName(route) === ns.I18n.t("editor-ui.unbenannte.strecke") ? '' : routeName(route);
byId(ids.routeDescription).value = text(route?.description); byId(ids.routeDescription).value = text(route?.description);
byId(ids.routeGpx).value = ''; byId(ids.routeGpx).value = '';
setHidden(byId(ids.routeGpxGroup), state.currentSource !== 'server'); setHidden(byId(ids.routeGpxGroup), state.currentSource !== 'server');
@@ -506,16 +506,16 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const uploaded = Boolean(route?.serverRouteId); const uploaded = Boolean(route?.serverRouteId);
setHidden(uploadButton, false); setHidden(uploadButton, false);
setDisabled(uploadButton, uploaded); setDisabled(uploadButton, uploaded);
if (uploadButton) uploadButton.textContent = uploaded ? 'Bereits übertragen' : 'An Server übertragen'; if (uploadButton) uploadButton.textContent = uploaded ? ns.I18n.t("editor-ui.schon.zur.prufung.gesendet") : ns.I18n.t("editor-ui.zur.prufung.senden");
byId(ids.routeActivated).textContent = uploaded byId(ids.routeActivated).textContent = uploaded
? `Lokaler Entwurf. Bereits als unveröffentlichte Server-Strecke ${route.serverRouteId} zur Prüfung übertragen.` ? ns.I18n.t("editor-ui.diese.strecke.wurde.zur.prufung.gesendet.andere")
: 'Lokaler Entwurf: GPX, Texte und Medien bleiben im Browser, bis „An Server übertragen“ gewählt wird.'; : ns.I18n.t("editor-ui.dieser.entwurf.bleibt.auf.deinem.gerat.mit");
} else { } else {
setHidden(uploadButton, true); setHidden(uploadButton, true);
setDisabled(uploadButton, true); setDisabled(uploadButton, true);
byId(ids.routeActivated).textContent = route?.activatedAt || route?.activated_at byId(ids.routeActivated).textContent = route?.activatedAt || route?.activated_at
? `Veröffentlicht am ${route.activatedAt || route.activated_at}. Der Editor verändert die Freigabe nicht.` ? ns.I18n.t("editor-ui.diese.strecke.ist.fur.alle.freigegeben.und")
: 'Server-Strecke ohne Freigabedatum. Der Editor verändert activated_at nicht.'; : ns.I18n.t("editor-ui.diese.strecke.wurde.noch.nicht.fur.alle");
} }
} }
@@ -559,7 +559,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
select.dataset.mediaKey = key; select.dataset.mediaKey = key;
select.type = 'button'; select.type = 'button';
select.setAttribute('aria-expanded', String(state.selectedMediaKey === key)); select.setAttribute('aria-expanded', String(state.selectedMediaKey === key));
select.setAttribute('aria-label', `${label}: Entfernen-Schaltfläche ${state.selectedMediaKey === key ? 'ausblenden' : 'anzeigen'}`); select.setAttribute('aria-label', ns.I18n.t("editor-ui.knopf.zum.entfernen", { value1: label, value2: state.selectedMediaKey === key ? 'ausblenden' : 'anzeigen' }));
if (kind === 'image' && previewUrl) { if (kind === 'image' && previewUrl) {
const image = document.createElement('img'); const image = document.createElement('img');
@@ -603,7 +603,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
preview.appendChild(mediaPreviewCard({ preview.appendChild(mediaPreviewCard({
key: 'existing-audio', key: 'existing-audio',
kind: 'audio', kind: 'audio',
label: poi?.audio?.name || 'Audioaufnahme', label: poi?.audio?.name || 'Tonaufnahme',
source: 'existing' source: 'existing'
})); }));
} }
@@ -612,7 +612,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
preview.appendChild(mediaPreviewCard({ preview.appendChild(mediaPreviewCard({
key: 'pending-audio', key: 'pending-audio',
kind: 'audio', kind: 'audio',
label: state.pendingAudioFile.name || 'Neue Audioaufnahme', label: state.pendingAudioFile.name || ns.I18n.t("editor-ui.neue.tonaufnahme"),
source: 'pending' source: 'pending'
})); }));
} }
@@ -622,7 +622,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
preview.appendChild(mediaPreviewCard({ preview.appendChild(mediaPreviewCard({
key: `existing-image-${image.id ?? index}`, key: `existing-image-${image.id ?? index}`,
kind: 'image', kind: 'image',
label: image.name || image.caption || `Bild ${index + 1}`, label: image.name || image.caption || ns.I18n.t("editor-ui.bild", { value1: index + 1 }),
mediaId: image.id ?? '', mediaId: image.id ?? '',
mediaIndex: index, mediaIndex: index,
previewUrl: imagePreviewUrl(image), previewUrl: imagePreviewUrl(image),
@@ -634,7 +634,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
preview.appendChild(mediaPreviewCard({ preview.appendChild(mediaPreviewCard({
key: pending.key, key: pending.key,
kind: 'image', kind: 'image',
label: pending.file?.name || `Neues Bild ${index + 1}`, label: pending.file?.name || ns.I18n.t("editor-ui.neues.bild", { value1: index + 1 }),
mediaIndex: index, mediaIndex: index,
previewUrl: pending.previewUrl || null, previewUrl: pending.previewUrl || null,
source: 'pending' source: 'pending'
@@ -651,12 +651,12 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const imageCount = existingImages + state.pendingImages.length; const imageCount = existingImages + state.pendingImages.length;
const parts = []; const parts = [];
if (audioCount) parts.push(`${audioCount} Audio${audioCount === 1 ? 'aufnahme' : 'aufnahmen'}`); if (audioCount) parts.push(ns.I18n.t('media.audio.count', { count: audioCount }));
if (imageCount) parts.push(`${imageCount} Bild${imageCount === 1 ? '' : 'er'}`); if (imageCount) parts.push(ns.I18n.t('media.image.count', { count: imageCount }));
status.textContent = parts.length status.textContent = parts.length
? `${parts.join(' · ')}. Vorschau antippen, um die Entfernen-Schaltfläche einzublenden.` ? ns.I18n.t("editor-ui.tippe.auf.ein.bild.oder.eine.tonaufnahme", { value1: parts.join(' · ') })
: 'Noch keine Medien vorhanden.'; : ns.I18n.t("editor-ui.noch.keine.bilder.oder.tonaufnahmen.vorhanden");
} }
function openFilePicker(inputId) { function openFilePicker(inputId) {
@@ -668,12 +668,12 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
if (kind === 'audio') { if (kind === 'audio') {
return { return {
multiple: false, multiple: false,
types: [{ description: 'Audio', accept: { 'audio/*': ['.mp3', '.m4a', '.wav', '.ogg', '.webm'] } }] types: [{ description: ns.I18n.t("editor-ui.tonaufnahmen"), accept: { 'audio/*': ['.mp3', '.m4a', '.wav', '.ogg', '.webm'] } }]
}; };
} }
return { return {
multiple: true, multiple: true,
types: [{ description: 'Bilder', accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.webp'] } }] types: [{ description: ns.I18n.t("editor-ui.bilder"), accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.webp'] } }]
}; };
} }
@@ -721,7 +721,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function fillPoiForm(poi) { function fillPoiForm(poi) {
resetPendingMedia(); resetPendingMedia();
byId(ids.poiId).value = poiId(poi); byId(ids.poiId).value = poiId(poi);
byId(ids.poiTitle).value = poiTitle(poi) === 'Unbenannter Streckenpunkt' ? '' : poiTitle(poi); byId(ids.poiTitle).value = poiTitle(poi) === ns.I18n.t("editor-ui.station.ohne.namen") ? '' : poiTitle(poi);
byId(ids.poiDescription).value = text(poi?.description || poi?.text); byId(ids.poiDescription).value = text(poi?.description || poi?.text);
byId(ids.poiLat).value = text(poi?.lat ?? poi?.latitude); byId(ids.poiLat).value = text(poi?.lat ?? poi?.latitude);
byId(ids.poiLon).value = text(poi?.lon ?? poi?.longitude); byId(ids.poiLon).value = text(poi?.lon ?? poi?.longitude);
@@ -759,12 +759,12 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function loadRoute(route) { async function loadRoute(route) {
const id = routeId(route); const id = routeId(route);
if (!id) return; if (!id) return;
setStatus('Streckendetails werden geladen …'); setStatus(ns.I18n.t("editor.route.loading"));
state.currentSource = routeSource(route); state.currentSource = routeSource(route);
if (state.currentSource === 'local') { if (state.currentSource === 'local') {
const routePackage = await ns.LocalRoutes.getWithMedia(id, { requestPermission: true }); const routePackage = await ns.LocalRoutes.getWithMedia(id, { requestPermission: true });
if (!routePackage) throw new Error('Der lokale Entwurf wurde nicht gefunden.'); if (!routePackage) throw new Error(ns.I18n.t("editor-ui.dein.entwurf.wurde.nicht.gefunden"));
state.currentRoute = { state.currentRoute = {
...routePackage.route, ...routePackage.route,
id: routePackage.id, id: routePackage.id,
@@ -792,7 +792,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
id: '', id: '',
name: '', name: '',
description: '', description: '',
gpx: ns.LocalRoutes.emptyGpx('Neue Strecke'), gpx: ns.LocalRoutes.emptyGpx(ns.I18n.t("editor-ui.neue.strecke")),
_source: 'local' _source: 'local'
}; };
state.currentPoi = null; state.currentPoi = null;
@@ -800,7 +800,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
fillRouteForm(state.currentRoute); fillRouteForm(state.currentRoute);
renderPoiList(); renderPoiList();
showRouteView(); showRouteView();
setStatus('Neue Strecke wird als lokaler GPX-/JSON-Entwurf angelegt.'); setStatus(ns.I18n.t("editor-ui.deine.neue.strecke.bleibt.zuerst.auf.diesem"));
} }
function formDataFromRoute(values) { function formDataFromRoute(values) {
@@ -838,12 +838,12 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
state.pois = saved.pois; state.pois = saved.pois;
await loadRoutes(); await loadRoutes();
await loadRoute({ id: saved.id, _source: 'local' }); await loadRoute({ id: saved.id, _source: 'local' });
setStatus('Speichern erfolgreich.'); setStatus(ns.I18n.t("editor-ui.gespeichert"));
} }
async function saveServerRoute(values) { async function saveServerRoute(values) {
const id = routeId(state.currentRoute); const id = routeId(state.currentRoute);
if (!id) throw new Error('Neue Strecken werden ausschließlich lokal angelegt.'); if (!id) throw new Error(ns.I18n.t("editor-ui.lege.eine.neue.strecke.zuerst.auf.diesem"));
const file = byId(ids.routeGpx)?.files?.[0]; const file = byId(ids.routeGpx)?.files?.[0];
const path = `/routes/${encodeURIComponent(id)}`; const path = `/routes/${encodeURIComponent(id)}`;
const payload = file const payload = file
@@ -853,12 +853,12 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
state.currentRoute = { ...saved, _source: 'server' }; state.currentRoute = { ...saved, _source: 'server' };
await loadRoutes(); await loadRoutes();
await loadRoute({ id, _source: 'server' }); await loadRoute({ id, _source: 'server' });
setStatus('Speichern erfolgreich.'); setStatus(ns.I18n.t("editor-ui.gespeichert"));
} }
async function saveRoute() { async function saveRoute() {
const values = routeFromForm(); const values = routeFromForm();
if (!values.name) throw new Error('Der Name der Strecke ist erforderlich.'); if (!values.name) throw new Error(ns.I18n.t("editor-ui.gib.deiner.strecke.einen.namen"));
if (state.currentSource === 'server') await saveServerRoute(values); if (state.currentSource === 'server') await saveServerRoute(values);
else await saveLocalRoute(values); else await saveLocalRoute(values);
} }
@@ -877,7 +877,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function localRouteUploadForm(routePackage) { function localRouteUploadForm(routePackage) {
const data = new FormData(); const data = new FormData();
data.append('name', routePackage.route?.name || 'Unbenannte Strecke'); data.append('name', routePackage.route?.name || ns.I18n.t("editor-ui.unbenannte.strecke"));
data.append('description', routePackage.route?.description || ''); data.append('description', routePackage.route?.description || '');
const uploadGpx = ns.LocalRoutes.gpxForUpload(routePackage); const uploadGpx = ns.LocalRoutes.gpxForUpload(routePackage);
const gpx = new Blob([uploadGpx], { type: 'application/gpx+xml' }); const gpx = new Blob([uploadGpx], { type: 'application/gpx+xml' });
@@ -888,7 +888,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function storedMediaBlob(media) { async function storedMediaBlob(media) {
const blob = await ns.LocalRoutes.mediaBlob(media, { requestPermission: true }); const blob = await ns.LocalRoutes.mediaBlob(media, { requestPermission: true });
if (!blob) { if (!blob) {
throw new Error(`Die Mediendatei ${media?.name || ''} konnte nicht für den Upload vorbereitet werden.`.trim()); throw new Error(ns.I18n.t("editor-ui.die.datei.konnte.nicht.gesendet.werden.bitte", { value1: media?.name || '' }).trim());
} }
return blob; return blob;
} }
@@ -918,12 +918,12 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const payload = await sendForm('/routes', 'POST', localRouteUploadForm(routePackage)); const payload = await sendForm('/routes', 'POST', localRouteUploadForm(routePackage));
const serverRoute = payload.route || payload; const serverRoute = payload.route || payload;
createdRouteId = routeId(serverRoute); createdRouteId = routeId(serverRoute);
if (!createdRouteId) throw new Error('Der Server hat keine Strecken-ID für den Upload zurückgegeben.'); if (!createdRouteId) throw new Error(ns.I18n.t("editor-ui.die.strecke.konnte.nicht.gesendet.werden.bitte"));
const orderedPois = [...(routePackage.pois || [])] const orderedPois = [...(routePackage.pois || [])]
.sort((left, right) => Number(left.sequence) - Number(right.sequence)); .sort((left, right) => Number(left.sequence) - Number(right.sequence));
for (const [index, poi] of orderedPois.entries()) { for (const [index, poi] of orderedPois.entries()) {
const poiTitle = poi.title || `Streckenpunkt ${index + 1}`; const poiTitle = poi.title || ns.I18n.t("editor-ui.station", { value1: index + 1 });
const poiPayload = await sendJson(`/routes/${encodeURIComponent(createdRouteId)}/pois`, 'POST', { const poiPayload = await sendJson(`/routes/${encodeURIComponent(createdRouteId)}/pois`, 'POST', {
description: poi.description || '', description: poi.description || '',
lat: poi.lat, lat: poi.lat,
@@ -934,7 +934,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}); });
const serverPoi = poiPayload.poi || poiPayload; const serverPoi = poiPayload.poi || poiPayload;
const serverPoiId = poiId(serverPoi); const serverPoiId = poiId(serverPoi);
if (!serverPoiId) throw new Error(`Für ${poiTitle} wurde keine Server-ID zurückgegeben.`); if (!serverPoiId) throw new Error(ns.I18n.t("editor-ui.die.station.konnte.nicht.gesendet.werden.bitte", { value1: poiTitle }));
await uploadLocalPoiMedia(createdRouteId, serverPoiId, poi); await uploadLocalPoiMedia(createdRouteId, serverPoiId, poi);
} }
return createdRouteId; return createdRouteId;
@@ -951,11 +951,11 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
async function uploadLocalRoute() { async function uploadLocalRoute() {
if (state.currentSource !== 'local') throw new Error('Nur lokale Strecken können hochgeladen werden.'); if (state.currentSource !== 'local') throw new Error(ns.I18n.t("editor-ui.du.kannst.nur.deine.eigenen.entwurfe.zur"));
const values = routeFromForm(); const values = routeFromForm();
if (!values.name) throw new Error('Der Name der Strecke ist erforderlich.'); if (!values.name) throw new Error(ns.I18n.t("editor-ui.gib.deiner.strecke.einen.namen"));
if (state.currentRoute?.serverRouteId) { if (state.currentRoute?.serverRouteId) {
setStatus('Diese Strecke wurde bereits an den Server übertragen.'); setStatus(ns.I18n.t("editor-ui.diese.strecke.wurde.schon.zur.prufung.gesendet"));
return; return;
} }
@@ -963,10 +963,10 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
setDisabled(uploadButton, true); setDisabled(uploadButton, true);
try { try {
await saveLocalRoute(values); await saveLocalRoute(values);
setStatus('Übertragung läuft …'); setStatus(ns.I18n.t("editor-ui.deine.strecke.wird.gesendet"));
const localId = routeId(state.currentRoute); const localId = routeId(state.currentRoute);
const routePackage = ns.LocalRoutes.get(localId); const routePackage = ns.LocalRoutes.get(localId);
if (!routePackage) throw new Error('Der lokale Streckenentwurf wurde nicht gefunden.'); if (!routePackage) throw new Error(ns.I18n.t("editor-ui.dein.entwurf.wurde.nicht.gefunden"));
const serverRouteId = await createServerRouteFromLocal(routePackage); const serverRouteId = await createServerRouteFromLocal(routePackage);
const uploadedAt = new Date().toISOString(); const uploadedAt = new Date().toISOString();
@@ -982,7 +982,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
state.pois = saved.pois; state.pois = saved.pois;
fillRouteForm(state.currentRoute); fillRouteForm(state.currentRoute);
await loadRoutes(); await loadRoutes();
setStatus('Upload erfolgreich.'); setStatus(ns.I18n.t("editor-ui.zur.prufung.gesendet"));
} finally { } finally {
if (!state.currentRoute?.serverRouteId) setDisabled(uploadButton, false); if (!state.currentRoute?.serverRouteId) setDisabled(uploadButton, false);
} }
@@ -997,7 +997,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const lat = Number(position?.lat); const lat = Number(position?.lat);
const lon = Number(position?.lon); const lon = Number(position?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) { if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
setStatus('Für einen neuen Streckenpunkt muss zuerst eine aktuelle GPS-Position verfügbar sein.', true); setStatus(ns.I18n.t("editor-ui.fur.eine.neue.station.brauchen.wir.deinen"), true);
return; return;
} }
@@ -1010,20 +1010,20 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
lon, lon,
triggerRadiusM: Number.isFinite(accuracy) && accuracy > 0 ? accuracy : 25 triggerRadiusM: Number.isFinite(accuracy) && accuracy > 0 ? accuracy : 25
}); });
byId(ids.poiFormTitle).textContent = 'Streckenpunkt hinzufügen'; byId(ids.poiFormTitle).textContent = ns.I18n.t("editor-ui.station.hinzufugen");
showPoiView(); showPoiView();
} }
function editPoi(poi) { function editPoi(poi) {
state.currentPoi = poi; state.currentPoi = poi;
fillPoiForm(poi); fillPoiForm(poi);
byId(ids.poiFormTitle).textContent = 'Streckenpunkt bearbeiten'; byId(ids.poiFormTitle).textContent = ns.I18n.t("editor-ui.station.bearbeiten");
showPoiView(); showPoiView();
} }
async function mediaFromLocalForm(existingPoi, poiIdentifier) { async function mediaFromLocalForm(existingPoi, poiIdentifier) {
const routeIdentifier = routeId(state.currentRoute); const routeIdentifier = routeId(state.currentRoute);
if (!routeIdentifier) throw new Error('Speichere zuerst die Strecke.'); if (!routeIdentifier) throw new Error(ns.I18n.t("editor-ui.speichere.zuerst.die.strecke"));
const storedNow = []; const storedNow = [];
const replacedAfterSave = []; const replacedAfterSave = [];
@@ -1063,7 +1063,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function persistLocalCurrentRoute() { async function persistLocalCurrentRoute() {
const id = routeId(state.currentRoute); const id = routeId(state.currentRoute);
if (!id) throw new Error('Speichere zuerst die Strecke.'); if (!id) throw new Error(ns.I18n.t("editor-ui.speichere.zuerst.die.strecke"));
const currentPoiId = poiId(state.currentPoi); const currentPoiId = poiId(state.currentPoi);
const saved = ns.LocalRoutes.save({ const saved = ns.LocalRoutes.save({
id, id,
@@ -1082,7 +1082,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function saveLocalPoi(values) { async function saveLocalPoi(values) {
if (!Number.isFinite(values.lat) || !Number.isFinite(values.lon)) { if (!Number.isFinite(values.lat) || !Number.isFinite(values.lon)) {
throw new TypeError('Für einen Streckenpunkt sind gültige Breiten- und Längengrade erforderlich.'); throw new TypeError(ns.I18n.t("editor-ui.der.ort.dieser.station.fehlt.erlaube.deinen"));
} }
const currentId = numericPoiId(byId(ids.poiId).value); const currentId = numericPoiId(byId(ids.poiId).value);
const identifier = currentId ?? nextLocalPoiId(); const identifier = currentId ?? nextLocalPoiId();
@@ -1109,7 +1109,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
await Promise.allSettled(media.replacedAfterSave.map(ns.LocalRoutes.removeMedia)); await Promise.allSettled(media.replacedAfterSave.map(ns.LocalRoutes.removeMedia));
resetPendingMedia(); resetPendingMedia();
setStatus('Streckenpunkt und zugehörige Mediendaten lokal gespeichert.'); setStatus(ns.I18n.t("editor-ui.station.mit.bildern.und.ton.auf.diesem"));
await loadRoute({ id: routeId(state.currentRoute), _source: 'local' }); await loadRoute({ id: routeId(state.currentRoute), _source: 'local' });
} }
@@ -1132,7 +1132,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function saveServerPoi(values) { async function saveServerPoi(values) {
const currentRouteId = routeId(state.currentRoute); const currentRouteId = routeId(state.currentRoute);
if (!currentRouteId) throw new Error('Speichere zuerst die Strecke.'); if (!currentRouteId) throw new Error(ns.I18n.t("editor-ui.speichere.zuerst.die.strecke"));
const currentPoiId = byId(ids.poiId).value; const currentPoiId = byId(ids.poiId).value;
const path = currentPoiId const path = currentPoiId
? `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}` ? `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`
@@ -1142,13 +1142,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const saved = payload.poi || payload; const saved = payload.poi || payload;
const savedPoiId = poiId(saved) || currentPoiId; const savedPoiId = poiId(saved) || currentPoiId;
if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId); if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId);
setStatus('Streckenpunkt gespeichert.'); setStatus(ns.I18n.t("editor-ui.station.gespeichert"));
await loadRoute({ id: currentRouteId, _source: 'server' }); await loadRoute({ id: currentRouteId, _source: 'server' });
} }
async function savePoi() { async function savePoi() {
const values = poiFromForm(); const values = poiFromForm();
if (!values.title) throw new Error('Der Titel des Streckenpunkts ist erforderlich.'); if (!values.title) throw new Error(ns.I18n.t("editor-ui.gib.deiner.station.einen.namen"));
if (state.currentSource === 'local') await saveLocalPoi(values); if (state.currentSource === 'local') await saveLocalPoi(values);
else await saveServerPoi(values); else await saveServerPoi(values);
} }
@@ -1196,13 +1196,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
state.selectedMediaKey = null; state.selectedMediaKey = null;
renderPoiMediaPreview(); renderPoiMediaPreview();
updatePoiMediaStatus(); updatePoiMediaStatus();
setStatus(kind === 'audio' ? 'Audioaufnahme entfernt.' : 'Bild entfernt.'); setStatus(kind === 'audio' ? ns.I18n.t("editor.audio.removed") : ns.I18n.t("editor-ui.bild.entfernt"));
} }
async function removeExistingServerMedia(kind, mediaId) { async function removeExistingServerMedia(kind, mediaId) {
const currentRouteId = routeId(state.currentRoute); const currentRouteId = routeId(state.currentRoute);
const currentPoiId = poiId(state.currentPoi); const currentPoiId = poiId(state.currentPoi);
if (!currentRouteId || !currentPoiId) throw new Error('Der Streckenpunkt ist noch nicht gespeichert.'); if (!currentRouteId || !currentPoiId) throw new Error(ns.I18n.t("editor-ui.die.station.ist.noch.nicht.gespeichert"));
const basePath = `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`; const basePath = `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`;
const updated = { ...state.currentPoi }; const updated = { ...state.currentPoi };
@@ -1212,7 +1212,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
updated.audioUrl = null; updated.audioUrl = null;
updated.audio_path = null; updated.audio_path = null;
} else { } else {
if (!mediaId) throw new Error('Das Bild besitzt keine Server-ID und kann nicht entfernt werden.'); if (!mediaId) throw new Error(ns.I18n.t("editor-ui.das.bild.konnte.nicht.entfernt.werden.lade"));
await sendDelete(`${basePath}/pictures/${encodeURIComponent(mediaId)}`); await sendDelete(`${basePath}/pictures/${encodeURIComponent(mediaId)}`);
updated.images = (state.currentPoi.images || []).filter(image => text(image.id) !== text(mediaId)); updated.images = (state.currentPoi.images || []).filter(image => text(image.id) !== text(mediaId));
} }
@@ -1221,7 +1221,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
state.selectedMediaKey = null; state.selectedMediaKey = null;
renderPoiMediaPreview(); renderPoiMediaPreview();
updatePoiMediaStatus(); updatePoiMediaStatus();
setStatus(kind === 'audio' ? 'Audioaufnahme entfernt.' : 'Bild entfernt.'); setStatus(kind === 'audio' ? ns.I18n.t("editor.audio.removed") : ns.I18n.t("editor-ui.bild.entfernt"));
} }
async function removeMedia(control) { async function removeMedia(control) {
@@ -1266,13 +1266,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
await Promise.allSettled(removedMedia.map(ns.LocalRoutes.removeMedia)); await Promise.allSettled(removedMedia.map(ns.LocalRoutes.removeMedia));
renderPoiList(); renderPoiList();
showRouteView(); showRouteView();
setStatus('Streckenpunkt entfernt und lokale Strecke aktualisiert.'); setStatus(ns.I18n.t("editor-ui.station.entfernt.deine.strecke.wurde.gespeichert"));
return; return;
} }
await sendDelete(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`); await sendDelete(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`);
await loadRoute({ id: currentRouteId, _source: 'server' }); await loadRoute({ id: currentRouteId, _source: 'server' });
setStatus('Streckenpunkt entfernt.'); setStatus(ns.I18n.t("editor-ui.station.entfernt"));
} }
function reorderPois(index, offset) { function reorderPois(index, offset) {
@@ -1295,7 +1295,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
if (!currentRouteId) return; if (!currentRouteId) return;
if (state.currentSource === 'local') { if (state.currentSource === 'local') {
await persistLocalCurrentRoute(); await persistLocalCurrentRoute();
setStatus('Lokale Streckenpunkt-Reihenfolge gespeichert.'); setStatus(ns.I18n.t("editor-ui.reihenfolge.der.stationen.gespeichert"));
return; return;
} }
const jobs = state.pois.map((poi, index) => { const jobs = state.pois.map((poi, index) => {
@@ -1305,7 +1305,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
return sendJson(path, 'PUT', { ...poi, sequence: index, sortOrder: index }); return sendJson(path, 'PUT', { ...poi, sequence: index, sortOrder: index });
}).filter(Boolean); }).filter(Boolean);
await Promise.all(jobs); await Promise.all(jobs);
setStatus('Reihenfolge gespeichert.'); setStatus(ns.I18n.t("editor.order.saved"));
} }
function routeForItem(target) { function routeForItem(target) {
@@ -1340,7 +1340,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
await loadRoutes(); await loadRoutes();
showListView(); showListView();
setStatus('Strecke gelöscht.'); setStatus(ns.I18n.t("editor-ui.strecke.geloscht"));
} }
async function routeListAction(event) { async function routeListAction(event) {
@@ -1387,7 +1387,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
await ns.StorageUsage?.refresh(); await ns.StorageUsage?.refresh();
} catch (error) { } catch (error) {
ns.StorageUsage?.reportError(error); ns.StorageUsage?.reportError(error);
setStatus(error.message || 'Die Aktion konnte nicht ausgeführt werden.', true); setStatus(ns.UserMessages?.error(error) || ns.I18n.t("editor-ui.das.hat.leider.nicht.geklappt.bitte.versuche"), true);
} }
} }
+1 -1
View File
@@ -25,7 +25,7 @@
} }
function unsupportedError() { function unsupportedError() {
return new Error('Dieses Gerät unterstützt keine Geolokalisierung.'); return new Error(ns.I18n.t("geolocation.dieses.gerat.kann.deinen.standort.nicht.finden"));
} }
ns.Geo = { ns.Geo = {
+11 -11
View File
@@ -4,18 +4,18 @@ globalThis.Wegwichtel ??= {};
'use strict'; 'use strict';
const entries = Object.freeze([ const entries = Object.freeze([
['🪵', 'Holz'], ['🪵', "group-symbols.holz"],
['🪨', 'Felsen'], ['🪨', "group-symbols.felsen"],
['🏮', 'Laterne'], ['🏮', "group-symbols.laterne"],
['🌼', 'Blume'], ['🌼', "group-symbols.blume"],
['🚬', 'Pfeife'], ['🚬', "group-symbols.pfeife"],
['🐦', 'Vogel'], ['🐦', "group-symbols.vogel"],
['🍄', 'Pilz'], ['🍄', "group-symbols.pilz"],
['🍓', 'Erdbeere'], ['🍓', "group-symbols.erdbeere"],
['🎃', 'Kürbis'] ['🎃', "group-symbols.kurbis"]
].map(([unicode, label]) => Object.freeze({ ].map(([unicode, labelKey]) => Object.freeze({
unicode, unicode,
label, get label() { return ns.I18n.t(labelKey); },
imageUrl: `/images/group-symbols/${unicode}.png` imageUrl: `/images/group-symbols/${unicode}.png`
}))); })));
+54
View File
@@ -0,0 +1,54 @@
globalThis.Wegwichtel ??= {};
(function (ns) {
'use strict';
class WegwichtelI18n {
static #languages = new Map([['de', ns.TextsDe.messages]]);
static #language = 'de';
static get language() { return this.#language; }
static register(language, messages) {
this.#languages.set(language.toLowerCase(), Object.freeze({ ...messages }));
}
static setLanguage(language, root = globalThis.document) {
const requested = String(language).toLowerCase();
const base = requested.split('-')[0];
this.#language = 'de';
if (this.#languages.has(base)) this.#language = base;
if (this.#languages.has(requested)) this.#language = requested;
this.apply(root);
return this.#language;
}
static t(key, variables = {}) {
let value = this.#languages.get(this.#language)?.[key] ?? ns.TextsDe.messages[key];
if (value == null) throw new RangeError(`Missing translation: ${key}`);
if (typeof value === 'object') {
const plural = new Intl.PluralRules(this.#language).select(Number(variables.count));
value = value[plural] ?? value.other;
}
return value.replace(/\{([a-zA-Z0-9_]+)\}/g, (placeholder, name) => {
if (!Object.hasOwn(variables, name)) return placeholder;
return String(variables[name] ?? '');
});
}
static apply(root = globalThis.document) {
if (!root?.querySelectorAll) return;
if (root.documentElement) root.documentElement.lang = this.#language;
for (const element of root.querySelectorAll('[data-i18n]')) {
if (typeof element.getAttribute === 'function') element.textContent = this.t(element.getAttribute('data-i18n'));
}
for (const attribute of ['title', 'aria-label', 'placeholder', 'alt']) {
for (const element of root.querySelectorAll(`[data-i18n-${attribute}]`)) {
if (typeof element.getAttribute === 'function' && typeof element.setAttribute === 'function') element.setAttribute(attribute, this.t(element.getAttribute(`data-i18n-${attribute}`)));
}
}
}
}
ns.I18n = WegwichtelI18n;
WegwichtelI18n.apply();
}(globalThis.Wegwichtel));
+20 -20
View File
@@ -5,15 +5,15 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const DROPDOWN_ANIMATION_MS = 180; const DROPDOWN_ANIMATION_MS = 180;
const pageTitles = Object.freeze({ const pageTitles = Object.freeze({
privacy: 'Datenschutzerklärung', get privacy() { return ns.I18n.t("legal-ui.datenschutzerklarung"); },
imprint: 'Impressum', get imprint() { return ns.I18n.t("legal-ui.impressum"); },
technology: 'Cookies und Technik' get technology() { return ns.I18n.t("legal-ui.speichern.und.berechtigungen"); }
}); });
const menuEntries = Object.freeze([ function menuEntries() { return [
['privacy', 'Datenschutzerklärung'], ['privacy', ns.I18n.t("legal-ui.datenschutzerklarung")],
['imprint', 'Impressum'], ['imprint', ns.I18n.t("legal-ui.impressum")],
['technology', 'Cookies und verwendete JavaScripts'] ['technology', ns.I18n.t("legal-ui.speichern.und.berechtigungen")]
]); ]; }
const selectors = Object.freeze({ const selectors = Object.freeze({
content: '.info-dialog-content', content: '.info-dialog-content',
cookieToggle: '.cookie-toggle', cookieToggle: '.cookie-toggle',
@@ -159,8 +159,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
id: 'info-button', id: 'info-button',
className: 'info-button', className: 'info-button',
type: 'button', type: 'button',
'aria-label': 'Informationsmenü öffnen', 'aria-label': ns.I18n.t("legal-ui.hilfe.und.informationen.offnen"),
title: 'Informationen' title: ns.I18n.t("legal-ui.informationen")
}); });
append(infoButton, createElement('span', { 'aria-hidden': 'true' }, 'i')); append(infoButton, createElement('span', { 'aria-hidden': 'true' }, 'i'));
append(control, infoButton); append(control, infoButton);
@@ -173,7 +173,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const dropdown = createElement('nav', { const dropdown = createElement('nav', {
id: 'info-dropdown', id: 'info-dropdown',
className: 'info-dropdown', className: 'info-dropdown',
'aria-label': 'Rechtliche und technische Informationen' 'aria-label': ns.I18n.t("legal-ui.rechtliche.und.technische.informationen")
}); });
append(control, dropdown); append(control, dropdown);
return dropdown; return dropdown;
@@ -191,7 +191,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function setInfoButtonLabel(infoButton) { function setInfoButtonLabel(infoButton) {
if (typeof infoButton.getAttribute === 'function' && infoButton.getAttribute('aria-label')) return; if (typeof infoButton.getAttribute === 'function' && infoButton.getAttribute('aria-label')) return;
setAttribute(infoButton, 'aria-label', 'Informationsmenü öffnen'); setAttribute(infoButton, 'aria-label', ns.I18n.t("legal-ui.hilfe.und.informationen.offnen"));
} }
function ensureInfoButtonIcon(infoButton) { function ensureInfoButtonIcon(infoButton) {
@@ -201,9 +201,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function configureDropdown(dropdown) { function configureDropdown(dropdown) {
if (!dropdown) return; if (!dropdown) return;
setAttribute(dropdown, 'aria-label', 'Rechtliche und technische Informationen'); setAttribute(dropdown, 'aria-label', ns.I18n.t("legal-ui.rechtliche.und.technische.informationen"));
setHidden(dropdown, true); setHidden(dropdown, true);
menuEntries.forEach(([target, label]) => ensureMenuButton(dropdown, target, label)); menuEntries().forEach(([target, label]) => ensureMenuButton(dropdown, target, label));
} }
function ensureMenuButton(dropdown, target, label) { function ensureMenuButton(dropdown, target, label) {
@@ -277,7 +277,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function configureBackButton(backButton) { function configureBackButton(backButton) {
setAttribute(backButton, 'type', 'button'); setAttribute(backButton, 'type', 'button');
setAttribute(backButton, 'aria-label', 'Zurück zum Informationsmenü'); setAttribute(backButton, 'aria-label', ns.I18n.t("legal-ui.zuruck.zum.informationsmenu"));
setHidden(backButton, true); setHidden(backButton, true);
return backButton; return backButton;
} }
@@ -293,8 +293,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const closeButton = createElement('button', { const closeButton = createElement('button', {
id: 'info-close', id: 'info-close',
type: 'button', type: 'button',
'aria-label': 'Informationsfenster schließen', 'aria-label': ns.I18n.t("legal-ui.informationsfenster.schliessen"),
title: 'Schließen' title: ns.I18n.t("legal-ui.schliessen")
}, '×'); }, '×');
append(header, closeButton); append(header, closeButton);
return configureCloseButton(closeButton); return configureCloseButton(closeButton);
@@ -302,7 +302,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function configureCloseButton(closeButton) { function configureCloseButton(closeButton) {
setAttribute(closeButton, 'type', 'button'); setAttribute(closeButton, 'type', 'button');
setAttribute(closeButton, 'aria-label', 'Informationsfenster schließen'); setAttribute(closeButton, 'aria-label', ns.I18n.t("legal-ui.informationsfenster.schliessen"));
if (closeButton && !String(closeButton.textContent ?? '').trim()) closeButton.textContent = '×'; if (closeButton && !String(closeButton.textContent ?? '').trim()) closeButton.textContent = '×';
return closeButton; return closeButton;
} }
@@ -335,7 +335,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function createFallbackPage(content, name) { function createFallbackPage(content, name) {
const page = createElement('article', { className: 'info-page', dataset: { infoPage: name } }); const page = createElement('article', { className: 'info-page', dataset: { infoPage: name } });
append(page, createElement('p', {}, `${pageTitles[name]} ist noch nicht hinterlegt.`)); append(page, createElement('p', {}, ns.I18n.t("info.missing", { value1: pageTitles[name] })));
append(content, page); append(content, page);
return page; return page;
} }
@@ -436,7 +436,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
function updateDialogTitle(pageName) { function updateDialogTitle(pageName) {
if (elements?.title) elements.title.textContent = pageTitles[pageName] ?? 'Informationen'; if (elements?.title) elements.title.textContent = pageTitles[pageName] ?? ns.I18n.t("legal-ui.informationen");
} }
function focusSafely(element) { function focusSafely(element) {
+32 -32
View File
@@ -15,7 +15,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function storage() { function storage() {
const target = globalThis.localStorage; const target = globalThis.localStorage;
if (!target) throw new Error('Lokaler Browser-Speicher ist auf diesem Gerät nicht verfügbar.'); if (!target) throw new Error(ns.I18n.t("local-routes.deine.strecke.kann.auf.diesem.gerat.gerade"));
return target; return target;
} }
@@ -27,7 +27,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
if (mediaDatabasePromise) return mediaDatabasePromise; if (mediaDatabasePromise) return mediaDatabasePromise;
const indexedDb = globalThis.indexedDB; const indexedDb = globalThis.indexedDB;
if (!indexedDb || typeof indexedDb.open !== 'function') { if (!indexedDb || typeof indexedDb.open !== 'function') {
return Promise.reject(new Error('Der lokale Medienspeicher (IndexedDB) ist in diesem Browser nicht verfügbar.')); return Promise.reject(new Error(ns.I18n.t("local-routes.bilder.und.ton.konnen.auf.diesem.gerat")));
} }
mediaDatabasePromise = new Promise((resolve, reject) => { mediaDatabasePromise = new Promise((resolve, reject) => {
@@ -41,11 +41,11 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
request.onsuccess = () => resolve(request.result); request.onsuccess = () => resolve(request.result);
request.onerror = () => { request.onerror = () => {
mediaDatabasePromise = null; mediaDatabasePromise = null;
reject(request.error || new Error('Der lokale Medienspeicher konnte nicht geöffnet werden.')); reject(request.error || new Error(ns.I18n.t("local-routes.gespeicherte.bilder.und.ton.konnten.nicht.geoffnet")));
}; };
request.onblocked = () => { request.onblocked = () => {
mediaDatabasePromise = null; mediaDatabasePromise = null;
reject(new Error('Der lokale Medienspeicher ist durch eine andere Browser-Sitzung blockiert.')); reject(new Error(ns.I18n.t("local-routes.schliesse.andere.fenster.mit.wegwichtel.und.versuche")));
}; };
}); });
return mediaDatabasePromise; return mediaDatabasePromise;
@@ -59,8 +59,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const transaction = database.transaction(MEDIA_STORE_NAME, mode); const transaction = database.transaction(MEDIA_STORE_NAME, mode);
request = operation(transaction.objectStore(MEDIA_STORE_NAME)); request = operation(transaction.objectStore(MEDIA_STORE_NAME));
request.onsuccess = () => resolve(request.result ?? null); request.onsuccess = () => resolve(request.result ?? null);
request.onerror = () => reject(request.error || new Error('Der lokale Medienspeicher konnte nicht gelesen oder geschrieben werden.')); request.onerror = () => reject(request.error || new Error(ns.I18n.t("local-routes.bilder.und.ton.konnten.nicht.geoffnet.oder")));
transaction.onabort = () => reject(transaction.error || new Error('Die Medien-Speicheroperation wurde abgebrochen.')); transaction.onabort = () => reject(transaction.error || new Error(ns.I18n.t("local-routes.das.speichern.von.bildern.und.ton.wurde")));
} catch (error) { } catch (error) {
reject(error); reject(error);
} }
@@ -100,7 +100,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}; };
} catch (error) { } catch (error) {
if (error?.name === 'QuotaExceededError') { if (error?.name === 'QuotaExceededError') {
throw new Error('Der lokale Medienspeicher ist voll. Entferne nicht mehr benötigte lokale Medien oder Strecken.', { cause: error }); throw new Error(ns.I18n.t("local-routes.der.platz.fur.bilder.und.ton.ist"), { cause: error });
} }
// Manche Browser können FileSystemFileHandle noch nicht in IndexedDB serialisieren. // Manche Browser können FileSystemFileHandle noch nicht in IndexedDB serialisieren.
// In diesem Fall wird die Datei als Blob in IndexedDB gespeichert. // In diesem Fall wird die Datei als Blob in IndexedDB gespeichert.
@@ -111,9 +111,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
await putMediaRecord({ ...metadata, storage: 'indexeddb', blob: file }); await putMediaRecord({ ...metadata, storage: 'indexeddb', blob: file });
} catch (error) { } catch (error) {
const prefix = error?.name === 'QuotaExceededError' const prefix = error?.name === 'QuotaExceededError'
? 'Der lokale Medienspeicher ist voll.' ? ns.I18n.t("local-routes.der.platz.fur.bilder.und.ton.ist.2")
: 'Die Mediendatei konnte nicht lokal gespeichert werden.'; : ns.I18n.t("local-routes.das.bild.oder.die.tonaufnahme.konnte.nicht");
throw new Error(`${prefix} Entferne nicht mehr benötigte lokale Medien oder Strecken.`, { cause: error }); throw new Error(ns.I18n.t("local-routes.entferne.bilder.tonaufnahmen.oder.eigene.strecken.die", { value1: prefix }), { cause: error });
} }
return { return {
id: String(mediaId), id: String(mediaId),
@@ -135,12 +135,12 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
permission = await handle.requestPermission({ mode: 'read' }); permission = await handle.requestPermission({ mode: 'read' });
} }
if (permission !== 'granted') { if (permission !== 'granted') {
throw new Error('Der Zugriff auf eine lokale Mediendatei muss erneut erlaubt werden.'); throw new Error(ns.I18n.t("local-routes.erlaube.noch.einmal.den.zugriff.auf.das"));
} }
try { try {
return await handle.getFile(); return await handle.getFile();
} catch (error) { } catch (error) {
throw new Error('Eine referenzierte Mediendatei ist auf dem Gerät nicht mehr verfügbar.', { cause: error }); throw new Error(ns.I18n.t("local-routes.ein.ausgewahltes.bild.oder.eine.tonaufnahme.wurde"), { cause: error });
} }
} }
@@ -238,7 +238,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
if (typeof cryptoApi?.getRandomValues === 'function') { if (typeof cryptoApi?.getRandomValues === 'function') {
return `local-${uuidFromRandomValues(cryptoApi)}`; return `local-${uuidFromRandomValues(cryptoApi)}`;
} }
throw new Error('Keine sichere Zufallsquelle für eine lokale Strecken-ID verfügbar.'); throw new Error(ns.I18n.t("local-routes.auf.diesem.gerat.kann.gerade.keine.neue"));
} }
function createPoiId() { function createPoiId() {
@@ -255,7 +255,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
.replaceAll("'", '&apos;'); .replaceAll("'", '&apos;');
} }
function emptyGpx(name = 'Neue Strecke') { function emptyGpx(name = ns.I18n.t("editor-ui.neue.strecke")) {
const safeName = escapeXml(name); const safeName = escapeXml(name);
return `<?xml version="1.0" encoding="UTF-8"?>\n` return `<?xml version="1.0" encoding="UTF-8"?>\n`
+ `<gpx version="1.1" creator="Wegwichtel" xmlns="${GPX_NAMESPACE}" xmlns:wegwichtel="${POI_NAMESPACE}">\n` + `<gpx version="1.1" creator="Wegwichtel" xmlns="${GPX_NAMESPACE}" xmlns:wegwichtel="${POI_NAMESPACE}">\n`
@@ -268,7 +268,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const parser = new DOMParser(); const parser = new DOMParser();
const document = parser.parseFromString(String(gpx || ''), 'application/xml'); const document = parser.parseFromString(String(gpx || ''), 'application/xml');
if (document.querySelector('parsererror') || document.documentElement?.localName !== 'gpx') { if (document.querySelector('parsererror') || document.documentElement?.localName !== 'gpx') {
throw new Error('Die GPX-Datei ist nicht gültig.'); throw new Error(ns.I18n.t("local-routes.diese.datei.enthalt.keinen.lesbaren.weg.wahle"));
} }
return document; return document;
} }
@@ -300,7 +300,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const document = parseXml(gpx); const document = parseXml(gpx);
return Array.from(document.getElementsByTagNameNS('*', 'wpt')).map((node, index) => ({ return Array.from(document.getElementsByTagNameNS('*', 'wpt')).map((node, index) => ({
id: waypointId(node), id: waypointId(node),
title: textOfChild(node, 'name') || `Streckenpunkt ${index + 1}`, title: textOfChild(node, 'name') || ns.I18n.t("editor-ui.station", { value1: index + 1 }),
description: textOfChild(node, 'desc'), description: textOfChild(node, 'desc'),
lat: numberAttribute(node, 'lat'), lat: numberAttribute(node, 'lat'),
lon: numberAttribute(node, 'lon'), lon: numberAttribute(node, 'lon'),
@@ -360,11 +360,11 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
function syncGpx(gpx, route, pois) { function syncGpx(gpx, route, pois) {
const routeName = route?.name || 'Neue Strecke'; const routeName = route?.name || ns.I18n.t("editor-ui.neue.strecke");
const document = parseXml(gpx || emptyGpx(routeName)); const document = parseXml(gpx || emptyGpx(routeName));
updateMetadataName(document, routeName); updateMetadataName(document, routeName);
// Wegwichtel verwaltet neue Streckenpunkte direkt als Punkte im GPX-Track. // Wegwichtel verwaltet neue Stationen direkt als Punkte im GPX-Track.
// Fremde/importierte GPX-Punkte ohne Wegwichtel-ID bleiben erhalten. // Fremde/importierte GPX-Punkte ohne Wegwichtel-ID bleiben erhalten.
Array.from(document.getElementsByTagNameNS('*', 'wpt')).forEach(node => node.remove()); Array.from(document.getElementsByTagNameNS('*', 'wpt')).forEach(node => node.remove());
Array.from(document.getElementsByTagNameNS('*', 'trkpt')) Array.from(document.getElementsByTagNameNS('*', 'trkpt'))
@@ -374,9 +374,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const segment = ensureTrackSegment(document, routeName); const segment = ensureTrackSegment(document, routeName);
const orderedPois = [...(pois || [])].sort((left, right) => Number(left.sequence) - Number(right.sequence)); const orderedPois = [...(pois || [])].sort((left, right) => Number(left.sequence) - Number(right.sequence));
orderedPois.forEach((poi, index) => { orderedPois.forEach((poi, index) => {
const title = poi.title || `Streckenpunkt ${index + 1}`; const title = poi.title || ns.I18n.t("editor-ui.station", { value1: index + 1 });
if (!Number.isFinite(Number(poi.lat)) || !Number.isFinite(Number(poi.lon))) { if (!Number.isFinite(Number(poi.lat)) || !Number.isFinite(Number(poi.lon))) {
throw new TypeError(`Für ${title} fehlen gültige Koordinaten.`); throw new TypeError(ns.I18n.t("local-routes.der.ort.von.fehlt", { value1: title }));
} }
const gpxPoint = createElement(document, 'trkpt'); const gpxPoint = createElement(document, 'trkpt');
@@ -469,9 +469,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
ns.StorageUsage?.reportError(error); ns.StorageUsage?.reportError(error);
const name = error?.name === 'QuotaExceededError' const name = error?.name === 'QuotaExceededError'
? 'Der lokale Speicher für Streckendaten ist voll.' ? ns.I18n.t("local-routes.der.platz.fur.deine.strecken.ist.voll")
: 'Die lokale Strecke konnte nicht gespeichert werden.'; : ns.I18n.t("local-routes.deine.strecke.konnte.nicht.gespeichert.werden");
throw new Error(`${name} Entferne nicht mehr benötigte lokale Strecken.`, { cause: error }); throw new Error(ns.I18n.t("local-routes.entferne.eigene.strecken.die.du.nicht.mehr", { value1: name }), { cause: error });
} }
notify(id, 'saved'); notify(id, 'saved');
@@ -514,9 +514,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
function gpxForUpload(routePackage) { function gpxForUpload(routePackage) {
if (!routePackage?.gpx) throw new Error('Für die lokale Strecke ist keine GPX-Datei vorhanden.'); if (!routePackage?.gpx) throw new Error(ns.I18n.t("local-routes.der.weg.deiner.strecke.fehlt"));
if (!routePoints(routePackage.gpx).length) { if (!routePoints(routePackage.gpx).length) {
throw new Error('Die lokale Strecke enthält noch keine Streckenpunkte im GPX-Track.'); throw new Error(ns.I18n.t("local-routes.deine.strecke.hat.noch.keinen.aufgezeichneten.weg"));
} }
return routePackage.gpx; return routePackage.gpx;
} }
@@ -577,7 +577,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function appPoi(poi) { function appPoi(poi) {
return { return {
id: Number(poi.id), id: Number(poi.id),
title: poi.title || 'Unbenannter Streckenpunkt', title: poi.title || ns.I18n.t("editor-ui.station.ohne.namen"),
description: poi.description || '', description: poi.description || '',
lat: Number(poi.lat), lat: Number(poi.lat),
lon: Number(poi.lon), lon: Number(poi.lon),
@@ -604,9 +604,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
id: String(routePackage.id), id: String(routePackage.id),
source: 'local', source: 'local',
local: true, local: true,
name: routePackage.route?.name || 'Unbenannte Strecke', name: routePackage.route?.name || ns.I18n.t("editor-ui.unbenannte.strecke"),
description: routePackage.route?.description || '', description: routePackage.route?.description || '',
schoolName: 'Lokal gespeichert', schoolName: ns.I18n.t("local.device"),
status: 'local', status: 'local',
activatedAt: null, activatedAt: null,
points, points,
@@ -625,7 +625,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function getAppRoute(id, options = {}) { async function getAppRoute(id, options = {}) {
const routePackage = await withMedia(readPackage(id), options); const routePackage = await withMedia(readPackage(id), options);
if (!routePackage) throw new Error('Die lokale Strecke wurde nicht gefunden.'); if (!routePackage) throw new Error(ns.I18n.t("local-routes.deine.strecke.wurde.nicht.gefunden"));
return toAppRoute(routePackage); return toAppRoute(routePackage);
} }
@@ -636,7 +636,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function readFileAsText(file) { function readFileAsText(file) {
if (!file) return Promise.resolve(''); if (!file) return Promise.resolve('');
if (typeof file.text !== 'function') { if (typeof file.text !== 'function') {
return Promise.reject(new TypeError('Die ausgewählte Datei unterstützt Blob.text() nicht.')); return Promise.reject(new TypeError(ns.I18n.t("local-routes.diese.datei.kann.auf.deinem.gerat.nicht")));
} }
return file.text(); return file.text();
} }
@@ -647,7 +647,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const reader = new FileReader(); const reader = new FileReader();
reader.addEventListener('load', () => { reader.addEventListener('load', () => {
if (typeof reader.result !== 'string') { if (typeof reader.result !== 'string') {
reject(new TypeError('Die Datei konnte nicht als Data-URL gelesen werden.')); reject(new TypeError(ns.I18n.t("local-routes.diese.datei.konnte.nicht.geoffnet.werden.wahle")));
return; return;
} }
resolve({ resolve({
@@ -657,7 +657,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
type: file.type || 'application/octet-stream' type: file.type || 'application/octet-stream'
}); });
}, { once: true }); }, { once: true });
reader.addEventListener('error', () => reject(reader.error || new Error('Datei konnte nicht gelesen werden.')), { once: true }); reader.addEventListener('error', () => reject(reader.error || new Error(ns.I18n.t("file.read.failed"))), { once: true });
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
} }
+25 -25
View File
@@ -44,7 +44,7 @@ globalThis.Wegwichtel ??= {};
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
navigator.serviceWorker.removeEventListener('controllerchange', changed); navigator.serviceWorker.removeEventListener('controllerchange', changed);
reject(new Error('Offline-Speicher konnte nicht aktiviert werden. Bitte erneut versuchen.')); reject(new Error(ns.I18n.t("offline-cache.das.speichern.fur.unterwegs.hat.nicht.geklappt")));
}, 10000); }, 10000);
function changed() { function changed() {
clearTimeout(timeout); clearTimeout(timeout);
@@ -64,32 +64,32 @@ globalThis.Wegwichtel ??= {};
let stage = null; let stage = null;
try { try {
await worker(); await worker();
announce('Offline-Dateien werden vorbereitet …'); announce(ns.I18n.t("offline-cache.wegwichtel.wird.fur.unterwegs.vorbereitet"));
// Persistent storage is best-effort; a denial must not block downloads. // Persistent storage is best-effort; a denial must not block downloads.
await navigator.storage?.persist?.().catch(() => false); await navigator.storage?.persist?.().catch(() => false);
const options = { cache: 'no-store', headers: { 'X-Wegwichtel-Offline-Download': 'true' }, signal }; const options = { cache: 'no-store', headers: { 'X-Wegwichtel-Offline-Download': 'true' }, signal };
const manifestResponse = await fetch('/api/offline-manifest', options); const manifestResponse = await fetch('/api/offline-manifest', options);
if (!manifestResponse.ok) throw new Error('Die Liste der Offline-Dateien konnte nicht geladen werden.'); if (!manifestResponse.ok) throw new Error(ns.I18n.t("offline-cache.wegwichtel.konnte.nicht.fur.unterwegs.geladen.werden"));
const manifest = await manifestResponse.json(); const manifest = await manifestResponse.json();
if (!Array.isArray(manifest.resources) || !manifest.resources.length) throw new TypeError('Ungültige Offline-Dateiliste.'); if (!Array.isArray(manifest.resources) || !manifest.resources.length) throw new TypeError(ns.I18n.t("offline-cache.wegwichtel.konnte.nicht.fur.unterwegs.geladen.werden.2"));
stage = await ns.OfflineStore.create(); stage = await ns.OfflineStore.create();
const urls = [...new Set(manifest.resources.map(url => ns.OfflineStore.key(url)))]; const urls = [...new Set(manifest.resources.map(url => ns.OfflineStore.key(url)))];
for (const [index, url] of urls.entries()) { for (const [index, url] of urls.entries()) {
signal.throwIfAborted(); signal.throwIfAborted();
announce(`Offline-Dateien speichern: ${index + 1} von ${urls.length}`); announce(ns.I18n.t("offline-cache.fur.unterwegs.speichern.von", { value1: index + 1, value2: urls.length }));
const response = await fetch(url, options); const response = await fetch(url, options);
await ns.OfflineStore.put(stage, url, response); await ns.OfflineStore.put(stage, url, response);
} }
signal.throwIfAborted(); signal.throwIfAborted();
const saved = await ns.OfflineStore.commit(stage); const saved = await ns.OfflineStore.commit(stage);
announce(`${saved.count} Dateien sind offline verfügbar.`); announce(ns.I18n.t("offline-cache.wegwichtel.ist.fur.unterwegs.bereit"));
return saved; return saved;
} catch (error) { } catch (error) {
if (stage) await ns.OfflineStore.remove(stage.id); if (stage) await ns.OfflineStore.remove(stage.id);
ns.StorageUsage?.reportError(error); ns.StorageUsage?.reportError(error);
const saved = await ns.OfflineStore.state(); const saved = await ns.OfflineStore.state();
if (error.name === 'AbortError') announce('Download abgebrochen.'); if (error.name === 'AbortError') announce(ns.I18n.t("offline-cache.speichern.abgebrochen"));
else announce(`${error.message} ${saved ? 'Der bisherige Offline-Stand bleibt erhalten.' : 'Die App ist noch nicht vollständig offline verfügbar.'}`); else announce(`${ns.UserMessages?.error(error) || ns.I18n.t("offline-cache.das.speichern.hat.nicht.geklappt")} ${saved ? ns.I18n.t("offline.previous.kept") : ns.I18n.t("offline-cache.wegwichtel.ist.noch.nicht.ohne.internet.bereit")}`);
throw error; throw error;
} finally { } finally {
task = null; task = null;
@@ -109,7 +109,7 @@ globalThis.Wegwichtel ??= {};
if (task) await task.catch(() => {}); if (task) await task.catch(() => {});
await ns.OfflineStore.clear(); await ns.OfflineStore.clear();
await updateControls(); await updateControls();
announce('App-Dateien wurden entfernt. Gespeicherte Strecken bleiben erhalten.'); announce(ns.I18n.t("offline-cache.die.gespeicherte.app.wurde.entfernt.deine.strecken"));
await ns.StorageUsage?.refresh(); await ns.StorageUsage?.refresh();
} }
@@ -126,12 +126,12 @@ globalThis.Wegwichtel ??= {};
const message = document.getElementById('route-offline-status'); const message = document.getElementById('route-offline-status');
if (save) { if (save) {
save.disabled = !supported() || Boolean(routeTask); save.disabled = !supported() || Boolean(routeTask);
save.textContent = saved ? 'Offline-Strecke aktualisieren' : 'Strecke offline speichern'; save.textContent = saved ? ns.I18n.t("offline-cache.gespeicherte.strecke.erneuern") : ns.I18n.t("offline-cache.fur.unterwegs.speichern");
} }
if (remove) { remove.hidden = !saved; remove.disabled = Boolean(routeTask); } if (remove) { remove.hidden = !saved; remove.disabled = Boolean(routeTask); }
if (message && !routeTask) message.textContent = saved if (message && !routeTask) message.textContent = saved
? `${saved.count} Dateien dieser Strecke sind auf dem Gerät gespeichert.` ? ns.I18n.t("offline-cache.diese.strecke.ist.fur.unterwegs.bereit")
: 'Speichert nur diese Strecke mit GPX, Bildern und Audio für die App.'; : ns.I18n.t("offline-cache.nimm.diese.strecke.mit.bildern.und.tonaufnahmen");
updateGpxDownload(route); updateGpxDownload(route);
} }
@@ -147,9 +147,9 @@ globalThis.Wegwichtel ??= {};
} }
async function saveRoute(route) { async function saveRoute(route) {
if (routeTask) throw new Error('Eine Strecke wird bereits gespeichert.'); if (routeTask) throw new Error(ns.I18n.t("offline-cache.bitte.warte.bis.die.andere.strecke.gespeichert"));
if (!supported()) throw new Error('Offline-Speicherung benötigt HTTPS und Browser-Unterstützung.'); if (!supported()) throw new Error(ns.I18n.t("offline-cache.auf.diesem.gerat.kannst.du.strecken.gerade"));
if (!route || route.source === 'local') throw new Error('Bitte eine veröffentlichte Strecke auswählen.'); if (!route || route.source === 'local') throw new Error(ns.I18n.t("offline-cache.wahle.zuerst.eine.strecke.die.fur.alle"));
routeTask = (async () => { routeTask = (async () => {
let stage = null; let stage = null;
const message = document.getElementById('route-offline-status'); const message = document.getElementById('route-offline-status');
@@ -159,10 +159,10 @@ globalThis.Wegwichtel ??= {};
const url = `/api/routes/${encodeURIComponent(route.id)}`; const url = `/api/routes/${encodeURIComponent(route.id)}`;
const options = { cache: 'no-store', headers: { 'X-Wegwichtel-Offline-Download': 'true' } }; const options = { cache: 'no-store', headers: { 'X-Wegwichtel-Offline-Download': 'true' } };
const response = await fetch(url, options); const response = await fetch(url, options);
if (!response.ok) throw new Error('Die Strecke konnte nicht geladen werden.'); if (!response.ok) throw new Error(ns.I18n.t("offline-cache.die.strecke.konnte.nicht.geladen.werden"));
const fresh = await response.clone().json(); const fresh = await response.clone().json();
if (String(fresh.id) !== String(route.id) || !fresh.activatedAt) throw new Error('Nur veröffentlichte Strecken können offline gespeichert werden.'); if (String(fresh.id) !== String(route.id) || !fresh.activatedAt) throw new Error(ns.I18n.t("offline-cache.du.kannst.nur.freigegebene.strecken.fur.unterwegs"));
if (!fresh.gpxUrl) throw new Error('Für diese Strecke ist keine GPX-Datei vorhanden.'); if (!fresh.gpxUrl) throw new Error(ns.I18n.t("offline-cache.der.weg.dieser.strecke.fehlt.sie.kann"));
const resources = [fresh.gpxUrl]; const resources = [fresh.gpxUrl];
for (const poi of fresh.pois || []) { for (const poi of fresh.pois || []) {
if (poi.audioUrl) resources.push(poi.audioUrl); if (poi.audioUrl) resources.push(poi.audioUrl);
@@ -172,7 +172,7 @@ globalThis.Wegwichtel ??= {};
stage = await ns.OfflineStore.create(); stage = await ns.OfflineStore.create();
await ns.OfflineStore.put(stage, url, response); await ns.OfflineStore.put(stage, url, response);
for (const [index, resource] of urls.entries()) { for (const [index, resource] of urls.entries()) {
if (message && selectedRoute === route) message.textContent = `Strecke speichern: ${index + 1} von ${urls.length}`; if (message && selectedRoute === route) message.textContent = ns.I18n.t("offline-cache.strecke.speichern.von", { value1: index + 1, value2: urls.length });
await ns.OfflineStore.put(stage, resource, await fetch(resource, options)); await ns.OfflineStore.put(stage, resource, await fetch(resource, options));
await ns.StorageUsage?.refresh(); await ns.StorageUsage?.refresh();
} }
@@ -193,7 +193,7 @@ globalThis.Wegwichtel ??= {};
} }
async function removeRoute(route) { async function removeRoute(route) {
if (routeTask) throw new Error('Bitte den laufenden Download abwarten.'); if (routeTask) throw new Error(ns.I18n.t("offline-cache.bitte.warte.bis.das.speichern.fertig.ist"));
await ns.OfflineStore.clearRoute(route.id); await ns.OfflineStore.clearRoute(route.id);
await renderRoute(selectedRoute); await renderRoute(selectedRoute);
await ns.StorageUsage?.refresh(); await ns.StorageUsage?.refresh();
@@ -206,7 +206,7 @@ globalThis.Wegwichtel ??= {};
return action(route).catch(error => { return action(route).catch(error => {
if (selectedRoute !== route) return; if (selectedRoute !== route) return;
const message = document.getElementById('route-offline-status'); const message = document.getElementById('route-offline-status');
if (message) message.textContent = `${error.message} Ein bisheriger Offline-Stand bleibt erhalten.`; if (message) message.textContent = ns.I18n.t("offline.error.previous.kept", { value1: ns.UserMessages?.error(error) || ns.I18n.t("offline-cache.das.speichern.hat.nicht.geklappt") });
}); });
}; };
document.getElementById('route-offline-save')?.addEventListener('click', () => run(saveRoute)); document.getElementById('route-offline-save')?.addEventListener('click', () => run(saveRoute));
@@ -226,7 +226,7 @@ globalThis.Wegwichtel ??= {};
} }
if (!supported()) { if (!supported()) {
if (toggle) toggle.disabled = true; if (toggle) toggle.disabled = true;
announce('Offline-Speicherung ist in diesem Browser oder ohne HTTPS nicht verfügbar.'); announce(ns.I18n.t("offline-cache.auf.diesem.gerat.lasst.sich.wegwichtel.gerade"));
return; return;
} }
// Remove old v1 snapshots that also included all routes automatically. // Remove old v1 snapshots that also included all routes automatically.
@@ -236,9 +236,9 @@ globalThis.Wegwichtel ??= {};
if ((await ns.OfflineStore.routes()).length) await worker(); if ((await ns.OfflineStore.routes()).length) await worker();
if (saved) { if (saved) {
await worker(); await worker();
announce(`${saved.count} Dateien sind offline verfügbar.`); announce(ns.I18n.t("offline-cache.wegwichtel.ist.fur.unterwegs.bereit"));
} else { } else {
announce('Speichert nur die App-Dateien auf diesem Gerät. Strecken kannst du einzeln speichern.'); announce(ns.I18n.t("offline-cache.speichere.zuerst.die.app.danach.kannst.du"));
} }
toggle?.addEventListener('change', () => { toggle?.addEventListener('change', () => {
toggle.setAttribute('aria-checked', String(toggle.checked)); toggle.setAttribute('aria-checked', String(toggle.checked));
@@ -249,7 +249,7 @@ globalThis.Wegwichtel ??= {};
ns.OfflineCache = Object.freeze({ ns.OfflineCache = Object.freeze({
initialize() { initialize() {
initialized ??= setup().catch(error => { announce(error.message); }); initialized ??= setup().catch(error => { announce(ns.UserMessages?.error(error) || ns.I18n.t("offline-cache.das.speichern.fur.unterwegs.hat.nicht.geklappt")); });
return initialized; return initialized;
}, },
download, download,
+2 -2
View File
@@ -10,7 +10,7 @@ globalThis.Wegwichtel ??= {};
function key(value) { function key(value) {
const url = new URL(value, globalThis.location.origin); const url = new URL(value, globalThis.location.origin);
if (url.origin !== globalThis.location.origin) throw new TypeError('Nur Ressourcen dieser App sind zulässig.'); if (url.origin !== globalThis.location.origin) throw new TypeError(ns.I18n.t("offline-storage.inhalte.von.anderen.seiten.konnen.hier.nicht"));
url.searchParams.delete('v'); url.searchParams.delete('v');
if (url.pathname.startsWith('/api/')) url.searchParams.delete('_'); if (url.pathname.startsWith('/api/')) url.searchParams.delete('_');
url.hash = ''; url.hash = '';
@@ -60,7 +60,7 @@ globalThis.Wegwichtel ??= {};
} }
async function put(stage, url, response) { async function put(stage, url, response) {
if (!response.ok || response.type === 'opaque') throw new Error(`Download fehlgeschlagen: ${url}`); if (!response.ok || response.type === 'opaque') throw new Error(ns.I18n.t("offline-storage.ein.teil.konnte.nicht.gespeichert.werden.prufe"));
const normalized = key(url); const normalized = key(url);
if (stage.mode === 'cache') { if (stage.mode === 'cache') {
const cache = await caches.open(stage.id); const cache = await caches.open(stage.id);
+2 -2
View File
@@ -41,10 +41,10 @@
const image = images[index]; const image = images[index];
$('#slide-image') $('#slide-image')
.attr('src', mediaUrl(image.url)) .attr('src', mediaUrl(image.url))
.attr('alt', image.caption || 'Bild zum Streckenpunkt') .attr('alt', image.caption || ns.I18n.t("slideshow.bild.zur.station"))
.off('error.wegwichtel') .off('error.wegwichtel')
.on('error.wegwichtel', function () { .on('error.wegwichtel', function () {
$(this).removeAttr('src').attr('alt', 'Bild konnte nicht geladen werden'); $(this).removeAttr('src').attr('alt', ns.I18n.t("slideshow.bild.konnte.nicht.geladen.werden"));
}); });
$('#slide-caption').text(image.caption || ''); $('#slide-caption').text(image.caption || '');
$('#slide-position').text(`${index + 1} / ${images.length}`); $('#slide-position').text(`${index + 1} / ${images.length}`);
+8 -8
View File
@@ -7,7 +7,7 @@ globalThis.Wegwichtel ??= {};
let refreshTask = null; let refreshTask = null;
function format(bytes) { function format(bytes) {
return `${(bytes / 1024 / 1024).toLocaleString('de-DE', { maximumFractionDigits: 1 })} MiB`; return `${(bytes / 1000000).toLocaleString(ns.I18n.language, { maximumFractionDigits: 1 })} MB`;
} }
function localBytes() { function localBytes() {
@@ -34,15 +34,15 @@ globalThis.Wegwichtel ??= {};
} }
function siteDescription(usage, quota, known) { function siteDescription(usage, quota, known) {
if (!known) return 'Der Browser meldet keine Speicherkapazität.'; if (!known) return ns.I18n.t("storage-usage.der.freie.platz.konnte.nicht.gemessen.werden");
const warning = usage / quota >= .9 ? ' Speicher fast voll.' : ''; const warning = usage / quota >= .9 ? ns.I18n.t("storage-usage.fast.voll.entferne.inhalte.die.du.nicht") : '';
return `Website-Speicher: ${format(usage)} von ${format(quota)} belegt (${Math.round(usage / quota * 100)} %). Etwa ${format(Math.max(0, quota - usage))} verfügbar.${warning}`; return ns.I18n.t("storage-usage.app.bilder.und.ton.des.verfugbaren.platzes", { value1: Math.round(usage / quota * 100), value2: format(Math.max(0, quota - usage)), value3: warning });
} }
function localDescription(local) { function localDescription(local) {
if (local == null) return 'Local Storage ist nicht verfügbar.'; if (local == null) return ns.I18n.t("storage-usage.texte.und.einstellungen.konnen.hier.gerade.nicht");
const warning = local >= LOCAL_REFERENCE * .9 ? ' Richtwert fast erreicht: Große Bilder können das Speichern verhindern.' : ''; const warning = local >= LOCAL_REFERENCE * .9 ? ns.I18n.t("storage-usage.der.balken.ist.fast.voll.grosse.bilder") : '';
return `Local Storage: ungefähr ${format(local)} belegt. Balken bezogen auf einen Richtwert von 5 MiB; die tatsächliche Grenze hängt vom Browser ab.${warning}`; return ns.I18n.t("storage-usage.texte.und.einstellungen.ungefahr.belegt.der.balken", { value1: format(local), value2: warning });
} }
async function refresh() { async function refresh() {
@@ -69,7 +69,7 @@ globalThis.Wegwichtel ??= {};
let current = error; let current = error;
while (current) { while (current) {
if (current.name === 'QuotaExceededError') { if (current.name === 'QuotaExceededError') {
lastError = 'Speicher voll: Entferne nicht mehr benötigte Offline-Strecken oder lokale Entwürfe. Eine Browser-Quote oder die separate Local-Storage-Grenze kann erreicht sein.'; lastError = ns.I18n.t("storage-usage.der.platz.ist.voll.entferne.gespeicherte.strecken");
break; break;
} }
current = current.cause; current = current.cause;
+419
View File
@@ -0,0 +1,419 @@
globalThis.Wegwichtel ??= {};
(function (ns) {
'use strict';
class WegwichtelTextsDe {
static language = 'de';
static messages = Object.freeze({
"app.du.kannst.dort.weitermachen.wo.du.aufgehort": "Du kannst dort weitermachen, wo du aufgehört hast.",
"app.eigener.entwurf": "Eigener Entwurf",
"app.strecke.wahlen": "Strecke wählen",
"app.strecken.fur.alle.und.meine.entwurfe": "Strecken für alle und meine Entwürfe",
"app.strecke": "Strecke",
"app.alle.stationen.erreicht": "Alle Stationen erreicht",
"app.keine.stationen.vorhanden": "Keine Stationen vorhanden",
"app.nachste.station": "Nächste Station: {value1}{value2}",
"app.veroffentlicht": "Veröffentlicht",
"app.strecken.fur.alle": "Strecken für alle ({value1})",
"app.meine.entwurfe": "Meine Entwürfe ({value1})",
"app.hier.gibt.es.noch.keine.strecken.fur": "Hier gibt es noch keine Strecken für alle.",
"app.du.hast.noch.keine.eigene.strecke.angelegt": "Du hast noch keine eigene Strecke angelegt.",
"app.keine.strecke.im.umkreis.von.km.gefunden": "Keine Strecke im Umkreis von {value1} km gefunden. Wähle unten eine beliebige Strecke aus.",
"app.erlaube.deinen.standort.um.strecken.in.deiner": "Erlaube deinen Standort, um Strecken in deiner Nähe zu finden. Unten kannst du alle Strecken auswählen.",
"app.keine.passende.strecke.gefunden.versuche.ein.anderes": "Keine passende Strecke gefunden. Versuche ein anderes Wort.",
"app.von.strecken.gefunden": "{value1} von {value2} Strecken gefunden.",
"app.strecken.zur.auswahl": "{value1} Strecken zur Auswahl.",
"app.strecke.fortsetzen": "Strecke fortsetzen",
"app.strecke.starten": "Strecke starten",
"app.strecke.beendet": "Strecke beendet.",
"app.du.machst.eine.pause.die.app.sucht": "Du machst eine Pause. Die App sucht gerade nicht nach deinem Standort.",
"app.strecke.pausiert": "Strecke pausiert",
"app.diese.strecke.hat.noch.keine.stationen": "Diese Strecke hat noch keine Stationen.",
"app.norden.oben.nachste.station": "Norden oben · nächste Station: {value1}",
"app.nachste.station.2": "Nächste Station: {value1}",
"app.nachste.station.in.metern": "Nächste Station {value1} in {value2} Metern",
"app.alle.stationen.und.das.ziel.erreicht": "Alle Stationen und das Ziel erreicht",
"app.norden.oben.nachster.punkt.auf.dem.weg": "Norden oben · nächster Punkt auf dem Weg",
"app.nachster.punkt.auf.dem.weg": "Nächster Punkt auf dem Weg",
"app.nachster.punkt.auf.dem.weg.in.metern": "Nächster Punkt auf dem Weg in {value1} Metern",
"app.dein.standort.wird.gesucht.auf.etwa.m": "Dein Standort wird gesucht · auf etwa {value1} m genau",
"app.nachste.station.m": " · nächste Station {value1} m",
"app.alle.stationen.erreicht.2": " · alle Stationen erreicht",
"app.strecke.wird.fortgesetzt": "Strecke wird fortgesetzt.",
"app.strecke.wird.fortgesetzt.2": "Strecke wird fortgesetzt",
"app.dein.standort.konnte.nicht.gefunden.werden.prufe": "Dein Standort konnte nicht gefunden werden. Prüfe die Standort-Erlaubnis auf deinem Gerät.",
"app.norden.oben.navigation": "Norden oben · Navigation",
"app.die.strecken.konnten.nicht.geladen.werden.bitte": "Die Strecken konnten nicht geladen werden. Bitte versuche es noch einmal.",
"app.deine.entwurfe.konnten.nicht.geoffnet.werden.bitte": "Deine Entwürfe konnten nicht geöffnet werden. Bitte versuche es noch einmal.",
"app.dein.standort.wurde.auf.etwa.m.genau": "Dein Standort wurde auf etwa {value1} m genau gefunden. ",
"app.wir.zeigen.dir.strecken.bis.km.entfernung": "Wir zeigen dir Strecken bis {value1} km Entfernung.",
"app.dein.standort.konnte.nicht.gefunden.werden.du": "Dein Standort konnte nicht gefunden werden. Du kannst trotzdem alle Strecken auswählen.",
"app.die.strecke.konnte.nicht.geoffnet.werden.prufe": "Die Strecke konnte nicht geöffnet werden. Prüfe deine Verbindung und versuche es noch einmal.",
"app.die.strecke.konnte.nicht.geoffnet.werden.bitte": "Die Strecke konnte nicht geöffnet werden. Bitte versuche es noch einmal.",
"bootstrap-loader.gespeicherte.inhalte.prufen": "Gespeicherte Inhalte prüfen …",
"bootstrap-loader.wegwichtel.vorbereiten": "Wegwichtel vorbereiten …",
"bootstrap-loader.die.bedienung.vorbereiten": "Die Bedienung vorbereiten …",
"bootstrap-loader.die.app.vorbereiten": "Die App vorbereiten …",
"bootstrap-loader.verbindung.prufen": "Verbindung prüfen …",
"bootstrap-loader.strecken.laden": "Strecken laden …",
"bootstrap-loader.auf.diesem.gerat.kannst.du.bilder.und": "Auf diesem Gerät kannst du Bilder und Tonaufnahmen direkt auswählen.",
"bootstrap-loader.tipp.fur.android.mit.chrome.lassen.sich": "Tipp für Android: Mit Chrome lassen sich Bilder und Tonaufnahmen auswählen, ohne sie noch einmal zu speichern.",
"bootstrap-loader.ein.teil.der.app.konnte.nicht.starten": "Ein Teil der App konnte nicht starten. Bitte versuche es noch einmal.",
"bootstrap-loader.ein.teil.der.app.konnte.nicht.geladen": "Ein Teil der App konnte nicht geladen werden. Prüfe deine Internetverbindung und versuche es noch einmal.",
"bootstrap-loader.alten.fortschritt.loschen": "Alten Fortschritt löschen ({value1} / {value2})",
"bootstrap-loader.wegwichtel.konnte.nicht.starten.prufe.deine.verbindung": "Wegwichtel konnte nicht starten. Prüfe deine Verbindung und versuche es noch einmal.",
"bootstrap-loader.wegwichtel.konnte.nicht.starten.bitte.versuche.es": "Wegwichtel konnte nicht starten. Bitte versuche es noch einmal.",
"editor-ui.zu.dieser.strecke.kann.gerade.keine.weitere": "Zu dieser Strecke kann gerade keine weitere Station hinzugefügt werden.",
"editor-ui.unbenannte.strecke": "Unbenannte Strecke",
"editor-ui.station.ohne.namen": "Station ohne Namen",
"editor-ui.eigene.strecke.bearbeiten": "Eigene Strecke bearbeiten",
"editor-ui.neue.strecke": "Neue Strecke",
"editor-ui.strecke.ansehen": "Strecke ansehen",
"editor-ui.station.bearbeiten": "Station bearbeiten",
"editor-ui.station.hinzufugen": "Station hinzufügen",
"editor-ui.die.aktion.konnte.nicht.ausgefuhrt.werden.bitte": "Die Aktion konnte nicht ausgeführt werden. Bitte versuche es noch einmal.",
"editor-ui.veroffentlichte.strecke": "Veröffentlichte Strecke",
"editor-ui.strecke.bearbeiten": "Strecke bearbeiten",
"editor-ui.strecke.loschen": "Strecke löschen",
"editor-ui.station": "Station {value1}",
"editor-ui.loschen": "{value1} löschen",
"editor-ui.strecken.fur.alle": "Strecken für alle",
"editor-ui.meine.entwurfe": "Meine Entwürfe",
"editor-ui.noch.kein.eigener.entwurf.vorhanden": "Noch kein eigener Entwurf vorhanden.",
"editor-ui.noch.keine.stationen.angelegt": "Noch keine Stationen angelegt.",
"editor-ui.strecken.werden.geladen": "Strecken werden geladen …",
"editor-ui.schon.zur.prufung.gesendet": "Schon zur Prüfung gesendet",
"editor-ui.zur.prufung.senden": "Zur Prüfung senden",
"editor-ui.diese.strecke.wurde.zur.prufung.gesendet.andere": "Diese Strecke wurde zur Prüfung gesendet. Andere können sie erst nach der Freigabe sehen.",
"editor-ui.dieser.entwurf.bleibt.auf.deinem.gerat.mit": "Dieser Entwurf bleibt auf deinem Gerät. Mit „Zur Prüfung senden“ reichst du ihn zur Freigabe ein.",
"editor-ui.diese.strecke.ist.fur.alle.freigegeben.und": "Diese Strecke ist für alle freigegeben und kann hier nicht mehr verändert werden.",
"editor-ui.diese.strecke.wurde.noch.nicht.fur.alle": "Diese Strecke wurde noch nicht für alle freigegeben.",
"editor-ui.knopf.zum.entfernen": "{value1}: Knopf zum Entfernen {value2}",
"editor-ui.neue.tonaufnahme": "Neue Tonaufnahme",
"editor-ui.bild": "Bild {value1}",
"editor-ui.neues.bild": "Neues Bild {value1}",
"editor-ui.bild.2": "{value1} Bild{value2}",
"editor-ui.tippe.auf.ein.bild.oder.eine.tonaufnahme": "{value1}. Tippe auf ein Bild oder eine Tonaufnahme, um sie zu entfernen.",
"editor-ui.noch.keine.bilder.oder.tonaufnahmen.vorhanden": "Noch keine Bilder oder Tonaufnahmen vorhanden.",
"editor-ui.tonaufnahmen": "Tonaufnahmen",
"editor-ui.bilder": "Bilder",
"editor-ui.dein.entwurf.wurde.nicht.gefunden": "Dein Entwurf wurde nicht gefunden.",
"editor-ui.deine.neue.strecke.bleibt.zuerst.auf.diesem": "Deine neue Strecke bleibt zuerst auf diesem Gerät.",
"editor-ui.gespeichert": "Gespeichert.",
"editor-ui.lege.eine.neue.strecke.zuerst.auf.diesem": "Lege eine neue Strecke zuerst auf diesem Gerät an.",
"editor-ui.gib.deiner.strecke.einen.namen": "Gib deiner Strecke einen Namen.",
"editor-ui.die.datei.konnte.nicht.gesendet.werden.bitte": "Die Datei {value1} konnte nicht gesendet werden. Bitte wähle sie noch einmal aus.",
"editor-ui.die.strecke.konnte.nicht.gesendet.werden.bitte": "Die Strecke konnte nicht gesendet werden. Bitte versuche es noch einmal.",
"editor-ui.die.station.konnte.nicht.gesendet.werden.bitte": "Die Station {value1} konnte nicht gesendet werden. Bitte versuche es noch einmal.",
"editor-ui.du.kannst.nur.deine.eigenen.entwurfe.zur": "Du kannst nur deine eigenen Entwürfe zur Prüfung senden.",
"editor-ui.diese.strecke.wurde.schon.zur.prufung.gesendet": "Diese Strecke wurde schon zur Prüfung gesendet.",
"editor-ui.deine.strecke.wird.gesendet": "Deine Strecke wird gesendet …",
"editor-ui.zur.prufung.gesendet": "Zur Prüfung gesendet.",
"editor-ui.fur.eine.neue.station.brauchen.wir.deinen": "Für eine neue Station brauchen wir deinen Standort. Erlaube den Standort und versuche es noch einmal.",
"editor-ui.speichere.zuerst.die.strecke": "Speichere zuerst die Strecke.",
"editor-ui.der.ort.dieser.station.fehlt.erlaube.deinen": "Der Ort dieser Station fehlt. Erlaube deinen Standort und versuche es noch einmal.",
"editor-ui.station.mit.bildern.und.ton.auf.diesem": "Station mit Bildern und Ton auf diesem Gerät gespeichert.",
"editor-ui.station.gespeichert": "Station gespeichert.",
"editor-ui.gib.deiner.station.einen.namen": "Gib deiner Station einen Namen.",
"editor-ui.bild.entfernt": "Bild entfernt.",
"editor-ui.die.station.ist.noch.nicht.gespeichert": "Die Station ist noch nicht gespeichert.",
"editor-ui.das.bild.konnte.nicht.entfernt.werden.lade": "Das Bild konnte nicht entfernt werden. Lade die Strecke noch einmal.",
"editor-ui.station.entfernt.deine.strecke.wurde.gespeichert": "Station entfernt. Deine Strecke wurde gespeichert.",
"editor-ui.station.entfernt": "Station entfernt.",
"editor-ui.reihenfolge.der.stationen.gespeichert": "Reihenfolge der Stationen gespeichert.",
"editor-ui.strecke.geloscht": "Strecke gelöscht.",
"editor-ui.das.hat.leider.nicht.geklappt.bitte.versuche": "Das hat leider nicht geklappt. Bitte versuche es noch einmal.",
"geolocation.dieses.gerat.kann.deinen.standort.nicht.finden": "Dieses Gerät kann deinen Standort nicht finden.",
"group-symbols.holz": "Holz",
"group-symbols.felsen": "Felsen",
"group-symbols.laterne": "Laterne",
"group-symbols.blume": "Blume",
"group-symbols.pfeife": "Pfeife",
"group-symbols.vogel": "Vogel",
"group-symbols.pilz": "Pilz",
"group-symbols.erdbeere": "Erdbeere",
"group-symbols.kurbis": "Kürbis",
"legal-ui.datenschutzerklarung": "Datenschutzerklärung",
"legal-ui.impressum": "Impressum",
"legal-ui.speichern.und.berechtigungen": "Speichern und Berechtigungen",
"legal-ui.hilfe.und.informationen.offnen": "Hilfe und Informationen öffnen",
"legal-ui.informationen": "Informationen",
"legal-ui.rechtliche.und.technische.informationen": "Rechtliche und technische Informationen",
"legal-ui.zuruck.zum.informationsmenu": "Zurück zum Informationsmenü",
"legal-ui.informationsfenster.schliessen": "Informationsfenster schließen",
"legal-ui.schliessen": "Schließen",
"local-routes.deine.strecke.kann.auf.diesem.gerat.gerade": "Deine Strecke kann auf diesem Gerät gerade nicht gespeichert werden.",
"local-routes.bilder.und.ton.konnen.auf.diesem.gerat": "Bilder und Ton können auf diesem Gerät gerade nicht gespeichert werden.",
"local-routes.gespeicherte.bilder.und.ton.konnten.nicht.geoffnet": "Gespeicherte Bilder und Ton konnten nicht geöffnet werden.",
"local-routes.schliesse.andere.fenster.mit.wegwichtel.und.versuche": "Schließe andere Fenster mit Wegwichtel und versuche es noch einmal.",
"local-routes.bilder.und.ton.konnten.nicht.geoffnet.oder": "Bilder und Ton konnten nicht geöffnet oder gespeichert werden.",
"local-routes.das.speichern.von.bildern.und.ton.wurde": "Das Speichern von Bildern und Ton wurde abgebrochen.",
"local-routes.der.platz.fur.bilder.und.ton.ist": "Der Platz für Bilder und Ton ist voll. Entferne Bilder, Tonaufnahmen oder eigene Strecken, die du nicht mehr brauchst.",
"local-routes.der.platz.fur.bilder.und.ton.ist.2": "Der Platz für Bilder und Ton ist voll.",
"local-routes.das.bild.oder.die.tonaufnahme.konnte.nicht": "Das Bild oder die Tonaufnahme konnte nicht gespeichert werden.",
"local-routes.entferne.bilder.tonaufnahmen.oder.eigene.strecken.die": "{value1} Entferne Bilder, Tonaufnahmen oder eigene Strecken, die du nicht mehr brauchst.",
"local-routes.erlaube.noch.einmal.den.zugriff.auf.das": "Erlaube noch einmal den Zugriff auf das ausgewählte Bild oder die Tonaufnahme.",
"local-routes.ein.ausgewahltes.bild.oder.eine.tonaufnahme.wurde": "Ein ausgewähltes Bild oder eine Tonaufnahme wurde verschoben oder gelöscht. Wähle die Datei noch einmal aus.",
"local-routes.auf.diesem.gerat.kann.gerade.keine.neue": "Auf diesem Gerät kann gerade keine neue Strecke angelegt werden.",
"local-routes.diese.datei.enthalt.keinen.lesbaren.weg.wahle": "Diese Datei enthält keinen lesbaren Weg. Wähle eine andere Datei.",
"local-routes.der.ort.von.fehlt": "Der Ort von {value1} fehlt.",
"local-routes.der.platz.fur.deine.strecken.ist.voll": "Der Platz für deine Strecken ist voll.",
"local-routes.deine.strecke.konnte.nicht.gespeichert.werden": "Deine Strecke konnte nicht gespeichert werden.",
"local-routes.entferne.eigene.strecken.die.du.nicht.mehr": "{value1} Entferne eigene Strecken, die du nicht mehr brauchst.",
"local-routes.der.weg.deiner.strecke.fehlt": "Der Weg deiner Strecke fehlt.",
"local-routes.deine.strecke.hat.noch.keinen.aufgezeichneten.weg": "Deine Strecke hat noch keinen aufgezeichneten Weg.",
"local-routes.deine.strecke.wurde.nicht.gefunden": "Deine Strecke wurde nicht gefunden.",
"local-routes.diese.datei.kann.auf.deinem.gerat.nicht": "Diese Datei kann auf deinem Gerät nicht gelesen werden.",
"local-routes.diese.datei.konnte.nicht.geoffnet.werden.wahle": "Diese Datei konnte nicht geöffnet werden. Wähle sie noch einmal aus.",
"offline-cache.das.speichern.fur.unterwegs.hat.nicht.geklappt": "Das Speichern für unterwegs hat nicht geklappt. Bitte versuche es noch einmal.",
"offline-cache.wegwichtel.wird.fur.unterwegs.vorbereitet": "Wegwichtel wird für unterwegs vorbereitet …",
"offline-cache.wegwichtel.konnte.nicht.fur.unterwegs.geladen.werden": "Wegwichtel konnte nicht für unterwegs geladen werden. Prüfe deine Internetverbindung.",
"offline-cache.wegwichtel.konnte.nicht.fur.unterwegs.geladen.werden.2": "Wegwichtel konnte nicht für unterwegs geladen werden. Bitte versuche es noch einmal.",
"offline-cache.fur.unterwegs.speichern.von": "Für unterwegs speichern: {value1} von {value2}",
"offline-cache.wegwichtel.ist.fur.unterwegs.bereit": "Wegwichtel ist für unterwegs bereit.",
"offline-cache.speichern.abgebrochen": "Speichern abgebrochen.",
"offline-cache.das.speichern.hat.nicht.geklappt": "Das Speichern hat nicht geklappt.",
"offline-cache.wegwichtel.ist.noch.nicht.ohne.internet.bereit": "Wegwichtel ist noch nicht ohne Internet bereit.",
"offline-cache.die.gespeicherte.app.wurde.entfernt.deine.strecken": "Die gespeicherte App wurde entfernt. Deine Strecken bleiben erhalten.",
"offline-cache.gespeicherte.strecke.erneuern": "Gespeicherte Strecke erneuern",
"offline-cache.fur.unterwegs.speichern": "Für unterwegs speichern",
"offline-cache.diese.strecke.ist.fur.unterwegs.bereit": "Diese Strecke ist für unterwegs bereit.",
"offline-cache.nimm.diese.strecke.mit.bildern.und.tonaufnahmen": "Nimm diese Strecke mit Bildern und Tonaufnahmen mit.",
"offline-cache.bitte.warte.bis.die.andere.strecke.gespeichert": "Bitte warte, bis die andere Strecke gespeichert ist.",
"offline-cache.auf.diesem.gerat.kannst.du.strecken.gerade": "Auf diesem Gerät kannst du Strecken gerade nicht für unterwegs speichern.",
"offline-cache.wahle.zuerst.eine.strecke.die.fur.alle": "Wähle zuerst eine Strecke, die für alle freigegeben ist.",
"offline-cache.die.strecke.konnte.nicht.geladen.werden": "Die Strecke konnte nicht geladen werden.",
"offline-cache.du.kannst.nur.freigegebene.strecken.fur.unterwegs": "Du kannst nur freigegebene Strecken für unterwegs speichern.",
"offline-cache.der.weg.dieser.strecke.fehlt.sie.kann": "Der Weg dieser Strecke fehlt. Sie kann noch nicht für unterwegs gespeichert werden.",
"offline-cache.strecke.speichern.von": "Strecke speichern: {value1} von {value2}",
"offline-cache.bitte.warte.bis.das.speichern.fertig.ist": "Bitte warte, bis das Speichern fertig ist.",
"offline-cache.auf.diesem.gerat.lasst.sich.wegwichtel.gerade": "Auf diesem Gerät lässt sich Wegwichtel gerade nicht für unterwegs speichern. Du kannst es mit Internet weiter nutzen.",
"offline-cache.speichere.zuerst.die.app.danach.kannst.du": "Speichere zuerst die App. Danach kannst du die Strecken auswählen, die du mitnehmen möchtest.",
"offline-storage.inhalte.von.anderen.seiten.konnen.hier.nicht": "Inhalte von anderen Seiten können hier nicht gespeichert werden.",
"offline-storage.ein.teil.konnte.nicht.gespeichert.werden.prufe": "Ein Teil konnte nicht gespeichert werden. Prüfe deine Internetverbindung und versuche es noch einmal.",
"slideshow.bild.zur.station": "Bild zur Station",
"slideshow.bild.konnte.nicht.geladen.werden": "Bild konnte nicht geladen werden",
"storage-usage.der.freie.platz.konnte.nicht.gemessen.werden": "Der freie Platz konnte nicht gemessen werden.",
"storage-usage.fast.voll.entferne.inhalte.die.du.nicht": " Fast voll: Entferne Inhalte, die du nicht mehr brauchst.",
"storage-usage.app.bilder.und.ton.des.verfugbaren.platzes": "App, Bilder und Ton: {value1} % des verfügbaren Platzes belegt. Ungefähr {value2} frei.{value3}",
"storage-usage.texte.und.einstellungen.konnen.hier.gerade.nicht": "Texte und Einstellungen können hier gerade nicht gespeichert werden.",
"storage-usage.der.balken.ist.fast.voll.grosse.bilder": " Der Balken ist fast voll. Große Bilder können das Speichern verhindern.",
"storage-usage.texte.und.einstellungen.ungefahr.belegt.der.balken": "Texte und Einstellungen: ungefähr {value1} belegt. Der Balken zeigt einen Richtwert von etwa 5 MB. Wie viel wirklich hineinpasst, hängt von deinem Internetprogramm ab.{value2}",
"storage-usage.der.platz.ist.voll.entferne.gespeicherte.strecken": "Der Platz ist voll. Entferne gespeicherte Strecken, Entwürfe, Bilder oder Tonaufnahmen, die du nicht mehr brauchst. Auch bei einem fast leeren ersten Balken kann der Platz für Texte und Einstellungen voll sein.",
"user-messages.diese.strecke.kann.nur.die.person.loschen": "Diese Strecke kann nur die Person löschen, die Wegwichtel betreut.",
"user-messages.diese.strecke.ist.fur.alle.freigegeben.du": "Diese Strecke ist für alle freigegeben. Du kannst sie hier nicht mehr verändern.",
"user-messages.die.datei.enthalt.keinen.lesbaren.weg.wahle": "Die Datei enthält keinen lesbaren Weg. Wähle eine andere Datei.",
"user-messages.diese.station.wurde.nicht.gefunden.offne.die": "Diese Station wurde nicht gefunden. Öffne die Strecke noch einmal.",
"user-messages.der.ort.dieser.station.fehlt.oder.konnte": "Der Ort dieser Station fehlt oder konnte nicht gelesen werden.",
"user-messages.dieses.bild.kann.nicht.geoffnet.werden.wahle": "Dieses Bild kann nicht geöffnet werden. Wähle ein anderes Bild.",
"user-messages.diese.tonaufnahme.kann.nicht.geoffnet.werden.wahle": "Diese Tonaufnahme kann nicht geöffnet werden. Wähle eine andere Aufnahme.",
"user-messages.diese.datei.ist.zu.gross.wahle.eine": "Diese Datei ist zu groß. Wähle eine kleinere Datei.",
"user-messages.du.darfst.diese.aktion.hier.nicht.ausfuhren": "Du darfst diese Aktion hier nicht ausführen.",
"user-messages.der.platz.ist.voll.entferne.gespeicherte.strecken": "Der Platz ist voll. Entferne gespeicherte Strecken, Bilder oder Tonaufnahmen, die du nicht mehr brauchst.",
"user-messages.wegwichtel.ist.gerade.nicht.erreichbar.bitte.versuche": "Wegwichtel ist gerade nicht erreichbar. Bitte versuche es später noch einmal.",
"vibration.das.vibrationsmuster.muss.aus.nichtnegativen.millisekunden.bestehen": "Das Vibrationsmuster muss aus nichtnegativen Millisekunden bestehen.",
"vibration.die.vibrationsdauer.muss.eine.nichtnegative.millisekundenangabe.sein": "Die Vibrationsdauer muss eine nichtnegative Millisekundenangabe sein.",
"html.title.wegwichtel": "Wegwichtel",
"html.span.i": "i",
"html.info-dropdown.hilfe.und.informationen": "Hilfe und Informationen",
"html.p.gleich.kannst.du.mit.dem.wegwichtel.losziehen": "Gleich kannst du mit dem Wegwichtel losziehen.",
"html.bootstrap-status-text.wegwichtel.wird.vorbereitet": "Wegwichtel wird vorbereitet …",
"html.span.los.gehts": "Los geht’s",
"html.bootstrap-retry.erneut.versuchen": "Erneut versuchen",
"html.bootstrap-ready.alles.ist.bereit": "Alles ist bereit.",
"html.div.wegwichtel.funktionen": "Wegwichtel Funktionen",
"html.span.strecken": "Strecken",
"html.span.entdecken": "entdecken",
"html.span.spannende": "Spannende",
"html.span.geschichten": "Geschichten",
"html.span.kindgerechte": "Kindgerechte",
"html.span.erlebnisse": "Erlebnisse",
"html.p.hier.gibt.es.keine.werbung.wir.verfolgen": "Hier gibt es keine Werbung. Wir verfolgen nicht, welche anderen Seiten du besuchst.",
"html.strong.optionale.cookies": "Optionale Cookies",
"html.small.diese.funktion.wird.nicht.verwendet": "Diese Funktion wird nicht verwendet.",
"html.strong.meinen.fortschritt.merken": "Meinen Fortschritt merken",
"html.small.merkt.sich.auf.diesem.gerat.welche.stationen": "Merkt sich auf diesem Gerät, welche Stationen du schon besucht hast.",
"html.strong.app.ohne.internet.offnen": "App ohne Internet öffnen",
"html.small.damit.kannst.du.wegwichtel.auch.ohne.internet": "Damit kannst du Wegwichtel auch ohne Internet öffnen. Wähle danach die Strecken aus, die du mitnehmen möchtest.",
"html.offline-cache-help-button.was.wird.fur.unterwegs.gespeichert": "Was wird für unterwegs gespeichert?",
"html.offline-cache-help-button.hilfe.zum.speichern.fur.unterwegs": "Hilfe zum Speichern für unterwegs",
"html.p.mit.diesem.schalter.kannst.du.wegwichtel.auch": "Mit diesem Schalter kannst du Wegwichtel auch ohne Internet öffnen. Strecken werden dabei noch nicht gespeichert.",
"html.p.offne.eine.strecke.und.tippe.auf.fur": "Öffne eine Strecke und tippe auf „Für unterwegs speichern“. So nimmst du den Weg, die Stationen, Bilder und Tonaufnahmen mit.",
"html.p.warte.vor.dem.losgehen.bis.alles.gespeichert": "Warte vor dem Losgehen, bis alles gespeichert ist. Zum Speichern und Erneuern brauchst du Internet.",
"html.p.alles.bleibt.auf.diesem.gerat.der.schalter": "Alles bleibt auf diesem Gerät. Der Schalter zeichnet deinen Weg nicht auf. Beim Ausschalten bleiben deine gespeicherten Strecken und Entwürfe erhalten.",
"html.p.wenn.du.die.gespeicherten.daten.von.wegwichtel": "Wenn du die gespeicherten Daten von Wegwichtel in deinem Internetprogramm löschst, gehen auch deine Strecken verloren. Bei wenig Platz kann das Gerät gespeicherte Inhalte entfernen. Karten von anderen Anbietern werden nicht mitgenommen.",
"html.offline-cache-refresh.gespeicherte.app.erneuern": "Gespeicherte App erneuern",
"html.section.platz.fur.wegwichtel": "Platz für Wegwichtel",
"html.meter.belegter.platz.fur.app.bilder.und.ton": "Belegter Platz für App, Bilder und Ton",
"html.p.wir.schauen.wie.viel.platz.belegt.ist": "Wir schauen, wie viel Platz belegt ist …",
"html.meter.belegter.platz.fur.texte.und.einstellungen.der": "Belegter Platz für Texte und Einstellungen; der Balken zeigt einen ungefähren Richtwert",
"html.p.der.erste.balken.zeigt.den.platz.fur": "Der erste Balken zeigt den Platz für die App, Bilder und Ton. Der zweite zeigt den Platz für Texte und Einstellungen. Die Balken zeigen nur den Platz für Wegwichtel.",
"html.button.platz.prufen": "Platz prüfen",
"html.li.gespeicherte.inhalte.prufen": "Gespeicherte Inhalte prüfen",
"html.li.wegwichtel.vorbereiten": "Wegwichtel vorbereiten",
"html.li.bedienung.vorbereiten": "Bedienung vorbereiten",
"html.li.app.vorbereiten": "App vorbereiten",
"html.li.verbindung.prufen": "Verbindung prüfen",
"html.li.strecken.laden": "Strecken laden",
"html.li.deinen.standort.suchen": "Deinen Standort suchen",
"html.route-control-button.strecke.auswahlen.oder.wechseln": "Strecke auswählen oder wechseln",
"html.route-control-dropdown.strecken.auswahlen.und.bearbeiten": "Strecken auswählen und bearbeiten",
"html.editor-button.strecken.bearbeiten": "Strecken bearbeiten",
"html.location-hint.wir.suchen.strecken.in.deiner.nahe.dein": "Wir suchen Strecken in deiner Nähe. Dein Standort bleibt auf diesem Gerät.",
"html.h2.strecken.in.deiner.nahe": "Strecken in deiner Nähe",
"html.h2.alle.strecken": "Alle Strecken",
"html.label.strecken.durchsuchen": "Strecken durchsuchen",
"html.route-search.name.schule.oder.beschreibung": "Name, Schule oder Beschreibung",
"html.clear-route-search.suche.leeren": "Suche leeren",
"html.p.diese.strecken.hast.du.auf.diesem.gerat": "Diese Strecken hast du auf diesem Gerät angelegt. Andere können sie noch nicht sehen.",
"html.p.meine.entwurfe.werden.geladen": "Meine Entwürfe werden geladen …",
"html.button.zuruck.zur.streckenauswahl": "Zurück zur Streckenauswahl",
"html.route-offline-controls.strecke.auf.dem.gerat.speichern": "Strecke auf dem Gerät speichern",
"html.route-offline-remove.gespeicherte.strecke.entfernen": "Gespeicherte Strecke entfernen",
"html.route-gpx-download.weg.als.datei.herunterladen": "Weg als Datei herunterladen",
"html.p.fur.unterwegs.speichern.nimmt.diese.strecke.mit": "„Für unterwegs speichern“ nimmt diese Strecke mit Bildern und Ton mit. „Weg als Datei herunterladen“ speichert nur den Weg für eine andere Wander-App.",
"html.dt.lange": "Länge",
"html.dt.hohenmeter": "Höhenmeter",
"html.dt.stationen": "Stationen",
"html.legend.unterwegs": "Unterwegs",
"html.pause-route.strecke.pausieren": "Strecke pausieren",
"html.stop-route.strecke.beenden": "Strecke beenden",
"html.button.zuruck.zur.strecke": "Zurück zur Strecke",
"html.poi-title.station": "Station",
"html.legend.bildnavigation": "Bildnavigation",
"html.slide-prev.vorheriges.bild": "Vorheriges Bild",
"html.slide-next.nachstes.bild": "Nächstes Bild",
"html.poi-audio.ton.zur.station": "Ton zur Station",
"html.poi-audio.dieses.gerat.kann.den.ton.leider.nicht": "Dieses Gerät kann den Ton leider nicht abspielen.",
"html.navigation-mode.richtung.zum.nachsten.station": "Richtung zum nächsten Station",
"html.p.diese.angaben.sind.noch.nicht.vollstandig.die": "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.",
"html.strong.hinweis": "Hinweis:",
"html.h3.1.verantwortliche.stelle.und.datenschutzkontakt": "1. Verantwortliche Stelle und Datenschutzkontakt",
"html.p.zum.eichig.3": "Zum Eichig 3",
"html.p.98617.meiningen.ot.herpf": "98617 Meiningen / OT Herpf",
"html.p.telefon.036943.63722": "Telefon: (036943) 63722",
"html.p.e.mail.info.grundschule.herpf.de": "E-Mail: info@grundschule-herpf.de",
"html.strong.staatliche.grundschule.herpf": "Staatliche Grundschule Herpf",
"html.p.datenschutzangaben.der.schule": "Datenschutzangaben der Schule:",
"html.p.datenschutzbeauftragter": "Datenschutzbeauftragter",
"html.p.holderlinstrae.1": "Hölderlinstraße 1",
"html.p.98527.suhl": "98527 Suhl",
"html.p.telefon.03681.7341.25": "Telefon: 03681 / 7341 25",
"html.p.fax.03681.7341.09": "Fax: 03681 / 7341 09",
"html.p.e.mail.datenschutz.suedthueriungen.schulamt.thueringen.de": "E-Mail: datenschutz.suedthueriungen@schulamt.thueringen.de",
"html.strong.staatliches.schulamt.sudthuringen": "Staatliches Schulamt Südthüringen",
"html.p.der.datenschutzbeauftragte.kann.auerdem.uber.die.postadresse": "Der Datenschutzbeauftragte kann außerdem über die Postadresse der Schule mit dem Zusatz „der Datenschutzbeauftragte“ erreicht werden.",
"html.h3.2.wenn.du.wegwichtel.offnest": "2. Wenn du Wegwichtel öffnest",
"html.p.wenn.du.wegwichtel.offnest.konnen.verbindungsdaten.verarbeitet": "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, Internetprogramm und dessen Sprache können dazugehören. Diese Daten helfen dabei, die Website bereitzustellen, Fehler zu finden und sie zu schützen.",
"html.p.wer.die.website.bereitstellt.und.welche.verbindungsdaten": "Wer die Website bereitstellt und welche Verbindungsdaten wie lange gespeichert werden:",
"html.strong.anbieter.anschrift.gespeicherte.daten.und.speicherdauer.erganzen": "[Anbieter, Anschrift, gespeicherte Daten und Speicherdauer ergänzen]",
"html.h3.3.standortdaten": "3. Standortdaten",
"html.p.du.entscheidest.ob.wegwichtel.deinen.standort.nutzen": "Du entscheidest, ob Wegwichtel deinen Standort nutzen darf. Damit findest du Strecken in deiner Nähe und siehst den Weg zur nächsten Station. Dein laufender Standort wird auf deinem Gerät verarbeitet. Wegwichtel speichert daraus keinen Verlauf auf der Website.",
"html.h3.4.deinen.fortschritt.merken": "4. Deinen Fortschritt merken",
"html.p.wenn.du.meinen.fortschritt.merken.einschaltest.speichert": "Wenn du „Meinen Fortschritt merken“ einschaltest, speichert Wegwichtel deine gewählte Strecke, besuchte und angesehene Stationen sowie angehörte Tonaufnahmen. Auch ob du unterwegs bist, eine Pause machst oder fertig bist und der Zeitpunkt werden gespeichert. Diese Angaben bleiben auf deinem Gerät und werden nicht an die Website gesendet.",
"html.p.wenn.du.deinen.fortschritt.mindestens.einen.tag": "Wenn du deinen Fortschritt mindestens einen Tag lang nicht verändert hast, wird er beim nächsten Öffnen gelöscht. Beim Ausschalten wird er sofort gelöscht. Deine eigenen und für unterwegs gespeicherten Strecken bleiben erhalten.",
"html.small.du.entscheidest.ob.sich.wegwichtel.deinen.fortschritt": "Du entscheidest, ob sich Wegwichtel deinen Fortschritt merkt.",
"html.h3.5.deine.eigenen.strecken": "5. Deine eigenen Strecken",
"html.p.neue.strecken.bleiben.zuerst.auf.deinem.gerat": "Neue Strecken bleiben zuerst auf deinem Gerät. Gespeichert werden der Weg, die Orte der Stationen, Texte sowie ausgewählte Bilder und Tonaufnahmen.",
"html.p.deine.entwurfe.bleiben.erhalten.wenn.alter.fortschritt": "Deine Entwürfe bleiben erhalten, wenn alter Fortschritt gelöscht wird oder du „Meinen Fortschritt merken“ ausschaltest. Sie bleiben auf diesem Gerät, bis du sie löschst oder die gespeicherten Daten von Wegwichtel in deinem Internetprogramm entfernst. Nur wenn du „Zur Prüfung senden“ wählst, wird deine Strecke an die Website gesendet.",
"html.h3.6.werbung.und.gespeicherte.einstellungen": "6. Werbung und gespeicherte Einstellungen",
"html.p.wegwichtel.verwendet.keine.cookies.es.zahlt.keine": "Wegwichtel verwendet keine Cookies. Es zählt keine Besuche für Werbung und verfolgt nicht, welche anderen Seiten du besuchst. Auf deinem Gerät wird gespeichert, ob du „Meinen Fortschritt merken“ eingeschaltet hast.",
"html.h3.7.inhalte.von.schulerinnen.und.schulern": "7. Inhalte von Schülerinnen und Schülern",
"html.p.bilder.texte.und.tonaufnahmen.durfen.nur.geteilt": "Bilder, Texte und Tonaufnahmen dürfen nur geteilt werden, wenn das erlaubt ist. Wenn Kinder auf Bildern oder in Aufnahmen erkannt werden können, müssen die Regeln der Schule beachtet werden. Auch die Eltern oder anderen Sorgeberechtigten müssen die nötige Erlaubnis geben.",
"html.h3.8.deine.rechte": "8. Deine Rechte",
"html.p.du.kannst.fragen.welche.daten.uber.dich": "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.",
"html.p.zustandige.datenschutzaufsicht": "Zuständige Datenschutzaufsicht:",
"html.p.postfach.90.04.55.99107.erfurt": "Postfach 90 04 55, 99107 Erfurt",
"html.p.halerstrae.8.99096.erfurt": "Häßlerstraße 8, 99096 Erfurt",
"html.p.telefon.0361.57.311.29.00": "Telefon: 0361 / 57 311 29 00",
"html.p.telefax.0361.57.311.29.04": "Telefax: 0361 / 57 311 29 04",
"html.p.e.mail.poststelle.datenschutz.thueringen.de": "E-Mail: poststelle@datenschutz.thueringen.de",
"html.p.website.www.tlfdi.de": "Website: www.tlfdi.de",
"html.strong.thuringer.landesbeauftragter.fur.den.datenschutz.und.die": "Thüringer Landesbeauftragter für den Datenschutz und die Informationsfreiheit (TlfDI)",
"html.p.diese.angaben.stammen.von.der.grundschule.herpf": "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.",
"html.h3.wer.fur.wegwichtel.verantwortlich.ist": "Wer für Wegwichtel verantwortlich ist",
"html.h3.kontakt": "Kontakt",
"html.p.telefax.036943.63843": "Telefax: (036943) 63843",
"html.h3.leitung.der.schule": "Leitung der Schule",
"html.p.frau.wichert.schulleiterin": "Frau Wichert, Schulleiterin",
"html.h3.schultrager": "Schulträger",
"html.p.landratsamt": "Landratsamt",
"html.p.obertshauser.platz.1": "Obertshäuser Platz 1",
"html.p.98617.meiningen": "98617 Meiningen",
"html.strong.landkreis.schmalkalden.meiningen": "Landkreis Schmalkalden-Meiningen",
"html.h3.inhaltlich.verantwortlich": "Inhaltlich verantwortlich",
"html.p.frau.wichert": "Frau Wichert",
"html.h3.weitere.pflichtangaben": "Weitere Pflichtangaben",
"html.p.je.nachdem.wer.wegwichtel.betreibt.konnen.weitere": "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.",
"html.h3.haftung.fur.inhalte.und.verweise": "Haftung für Inhalte und Verweise",
"html.p.die.inhalte.werden.sorgfaltig.erstellt.und.regelmaig": "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.",
"html.h3.urheber.und.nutzungsrechte": "Urheber- und Nutzungsrechte",
"html.p.wenn.nichts.anderes.angegeben.ist.liegen.die": "Wenn nichts anderes angegeben ist, liegen die Rechte bei der Grundschule Herpf. Du darfst Texte, Bilder, Tonaufnahmen und Wege nur so nutzen, wie es jeweils erlaubt wurde. Auch die Rechte der beteiligten Kinder, Familien, Lehrkräfte und anderen Personen bleiben bestehen.",
"html.h3.was.merkt.sich.wegwichtel": "Was merkt sich Wegwichtel?",
"html.p.du.kannst.deinen.fortschritt.auf.diesem.gerat": "Du kannst deinen Fortschritt auf diesem Gerät speichern. Dann weiß Wegwichtel beim nächsten Öffnen, welche Stationen du schon besucht oder angesehen hast und welche Tonaufnahmen du angehört hast.",
"html.p.wenn.du.einen.tag.lang.nichts.mehr": "Wenn du einen Tag lang nichts mehr daran geändert hast, wird dein Fortschritt beim nächsten Öffnen gelöscht. Beim Ausschalten wird er sofort gelöscht. Deine selbst angelegten und für unterwegs gespeicherten Strecken bleiben erhalten.",
"html.small.die.anderung.gilt.sofort.auf.diesem.gerat": "Die Änderung gilt sofort auf diesem Gerät.",
"html.h3.ohne.internet.unterwegs": "Ohne Internet unterwegs",
"html.p.mit.app.ohne.internet.offnen.speicherst.du": "Mit „App ohne Internet öffnen“ speicherst du Wegwichtel auf diesem Gerät. Danach kannst du einzelne Strecken mit „Für unterwegs speichern“ mitnehmen. Warte vor dem Losgehen, bis alles gespeichert ist.",
"html.p.mit.weg.als.datei.herunterladen.kannst.du": "Mit „Weg als Datei herunterladen“ kannst du den Weg in einer anderen Wander-App öffnen. Bilder und Tonaufnahmen sind in dieser Datei nicht enthalten.",
"html.h3.deine.eigenen.strecken": "Deine eigenen Strecken",
"html.p.neue.strecken.bleiben.zuerst.auf.diesem.gerat": "Neue Strecken bleiben zuerst auf diesem Gerät. Mit „Zur Prüfung senden“ reichst du eine Strecke zur Freigabe ein. Erst nach der Freigabe können andere sie sehen.",
"html.p.deine.entwurfe.bleiben.gespeichert.bis.du.sie": "Deine Entwürfe bleiben gespeichert, bis du sie löschst oder die gespeicherten Daten von Wegwichtel in deinem Internetprogramm entfernst. Viele große Bilder brauchen viel Platz. Die Anzeige „Platz für Wegwichtel“ hilft dir, den belegten Platz im Blick zu behalten.",
"html.h3.welche.erlaubnisse.braucht.die.app": "Welche Erlaubnisse braucht die App?",
"html.li.deinen.standort.um.strecken.in.deiner.nahe": "Deinen Standort, um Strecken in deiner Nähe und den Weg zur nächsten Station zu zeigen.",
"html.li.die.richtung.deines.gerats.damit.sich.der": "Die Richtung deines Geräts, damit sich der Pfeil mitdrehen kann.",
"html.li.zugriff.auf.ausgewahlte.bilder.und.tonaufnahmen.wenn": "Zugriff auf ausgewählte Bilder und Tonaufnahmen, wenn du sie zu einer Strecke hinzufügen möchtest.",
"html.p.du.kannst.diese.erlaubnisse.ablehnen.dann.fehlen": "Du kannst diese Erlaubnisse ablehnen. Dann fehlen die jeweiligen Funktionen. Strecken und Stationen kannst du weiterhin selbst auswählen.",
"html.h3.werbung.und.andere.anbieter": "Werbung und andere Anbieter",
"html.p.wegwichtel.zeigt.keine.werbung.und.verfolgt.nicht": "Wegwichtel zeigt keine Werbung und verfolgt nicht, welche anderen Seiten du besuchst. Die App lädt ihre eigenen Programme und Bilder von Wegwichtel. Es werden keine Cookies verwendet.",
"html.editor-close.fenster.zum.bearbeiten.schlieen": "Fenster zum Bearbeiten schließen",
"html.editor-route-list-title.deine.strecken": "Deine Strecken",
"html.editor-new-route.neue.strecke.anlegen": "Neue Strecke anlegen",
"html.p.hier.findest.du.strecken.fur.alle.und": "Hier findest du Strecken für alle und deine eigenen Entwürfe.",
"html.editor-route-back.zuruck": "Zurück",
"html.editor-route-detail-title.deine.strecke": "Deine Strecke",
"html.label.name.der.strecke": "Name der Strecke",
"html.label.beschreibung": "Beschreibung",
"html.label.weg.aus.einer.datei.ubernehmen": "Weg aus einer Datei übernehmen",
"html.editor-route-activated.deine.neue.strecke.bleibt.zuerst.auf.diesem": "Deine neue Strecke bleibt zuerst auf diesem Gerät. Mit „Zur Prüfung senden“ kannst du sie zur Freigabe einreichen.",
"html.editor-route-save.strecke.speichern": "Strecke speichern",
"html.label.name": "Name",
"html.label.text": "Text",
"html.div.bilder.und.ton.zur.station.hinzufugen": "Bilder und Ton zur Station hinzufügen",
"html.editor-poi-audio-add.ton.hinzufugen": "Ton hinzufügen",
"html.editor-poi-images-add.bild.hinzufugen": "Bild hinzufügen",
"html.editor-poi-media-preview.bilder.und.ton.ansehen": "Bilder und Ton ansehen",
"html.editor-poi-save.station.speichern": "Station speichern",
"html.span.station.entfernen": "Station entfernen",
"media.audio.count": {
"one": "{count} Tonaufnahme",
"other": "{count} Tonaufnahmen"
},
"media.image.count": {
"one": "{count} Bild",
"other": "{count} Bilder"
},
"app.page.missing": "Diese Seite konnte nicht geöffnet werden.",
"app.location.searching": "Deinen Standort suchen",
"app.distance.spoken": "{value1} Meter entfernt",
"app.distance.short": "{value1} m entfernt",
"app.path.missing": "Der Weg dieser Strecke fehlt.",
"app.location.start": "Los geht’s! Wir suchen deinen Standort.",
"bootstrap.location": "Deinen Standort suchen …",
"bootstrap.step.failed": "Das hat nicht geklappt: {value1}",
"bootstrap.ready": "Alles ist bereit.",
"editor.station.up": "{value1} nach oben schieben",
"editor.station.down": "{value1} nach unten schieben",
"editor.route.loading": "Deine Strecke wird geöffnet …",
"editor.audio.removed": "Tonaufnahme entfernt.",
"editor.order.saved": "Reihenfolge gespeichert.",
"info.missing": "{value1}: Diese Information fehlt noch.",
"local.device": "Auf diesem Gerät",
"file.read.failed": "Die Datei konnte nicht geöffnet werden. Wähle sie noch einmal aus.",
"offline.previous.kept": "Was du vorher gespeichert hast, bleibt erhalten.",
"offline.error.previous.kept": "{value1} Was du vorher gespeichert hast, bleibt erhalten."
});
}
ns.TextsDe = WegwichtelTextsDe;
}(globalThis.Wegwichtel));
+50
View File
@@ -0,0 +1,50 @@
globalThis.Wegwichtel ??= {};
(function (ns) {
'use strict';
function messageText(error) {
const value = error?.responseJSON?.message || error?.message || error;
if (typeof value !== 'string') return '';
try {
const parsed = JSON.parse(value);
return typeof parsed.message === 'string' ? parsed.message : '';
} catch {
return value;
}
}
const explanations = [
[/Freigegebene Strecken dürfen nur/, "user-messages.diese.strecke.kann.nur.die.person.loschen"],
[/Freigegebene Strecken können/, "user-messages.diese.strecke.ist.fur.alle.freigegeben.du"],
[/GPX|XML|Trackpunkte/, "user-messages.die.datei.enthalt.keinen.lesbaren.weg.wahle"],
[/POI.*nicht gefunden/, "user-messages.diese.station.wurde.nicht.gefunden.offne.die"],
[/POI-Koordinaten/, "user-messages.der.ort.dieser.station.fehlt.oder.konnte"],
[/Bildformat|Bilddatei/, "user-messages.dieses.bild.kann.nicht.geoffnet.werden.wahle"],
[/Audioformat|Audiodatei/, "user-messages.diese.tonaufnahme.kann.nicht.geoffnet.werden.wahle"]
];
const statusExplanations = new Map([
[413, "user-messages.diese.datei.ist.zu.gross.wahle.eine"],
[401, "user-messages.du.darfst.diese.aktion.hier.nicht.ausfuhren"],
[403, "user-messages.du.darfst.diese.aktion.hier.nicht.ausfuhren"]
]);
function readableMessage(message, fallback) {
if (/\b(?:HTTP|API|HTTPS|JSON|IndexedDB|OPFS|CacheStorage|Base64|Blob|Server|sequence|dataUrl|PUT|POST)\b|https?:\/\/|[{}]/i.test(message)) return fallback;
if (/\b(?:Failed|NetworkError|TypeError|AbortError|SecurityError|NotAllowedError|undefined)\b/i.test(message)) return fallback;
if (message && /[äöüÄÖÜß]|\b(?:Die|Der|Das|Dein|Deine|Diese|Dieser|Dieses|Bitte|Wähle|Erlaube|Ein|Eine|Kein|Keine|Speicher|Strecke|Station|Wegwichtel|Gespeichert|Entferne)\b/.test(message)) return message;
return fallback;
}
function errorMessage(error, fallback = ns.I18n.t("editor-ui.das.hat.leider.nicht.geklappt.bitte.versuche")) {
const message = messageText(error);
if (error?.name === 'QuotaExceededError' || error?.cause?.name === 'QuotaExceededError') return ns.I18n.t("user-messages.der.platz.ist.voll.entferne.gespeicherte.strecken");
const explanation = explanations.find(([pattern]) => pattern.test(message));
if (explanation) return ns.I18n.t(explanation[1]);
const statusExplanation = statusExplanations.get(error?.status);
if (statusExplanation) return ns.I18n.t(statusExplanation);
if (error?.status >= 500) return ns.I18n.t("user-messages.wegwichtel.ist.gerade.nicht.erreichbar.bitte.versuche");
return readableMessage(message, fallback);
}
ns.UserMessages = Object.freeze({ error: errorMessage });
}(globalThis.Wegwichtel));
+2 -2
View File
@@ -21,14 +21,14 @@
if (Array.isArray(selected)) { if (Array.isArray(selected)) {
const values = selected.map(Number); const values = selected.map(Number);
if (!values.length || values.some(value => !Number.isFinite(value) || value < 0)) { if (!values.length || values.some(value => !Number.isFinite(value) || value < 0)) {
throw new TypeError('Das Vibrationsmuster muss aus nichtnegativen Millisekunden bestehen.'); throw new TypeError(ns.I18n.t("vibration.das.vibrationsmuster.muss.aus.nichtnegativen.millisekunden.bestehen"));
} }
return values; return values;
} }
const duration = Number(selected); const duration = Number(selected);
if (!Number.isFinite(duration) || duration < 0) { if (!Number.isFinite(duration) || duration < 0) {
throw new TypeError('Die Vibrationsdauer muss eine nichtnegative Millisekundenangabe sein.'); throw new TypeError(ns.I18n.t("vibration.die.vibrationsdauer.muss.eine.nichtnegative.millisekundenangabe.sein"));
} }
return [duration]; return [duration];
} }
+1 -1
View File
@@ -1,6 +1,6 @@
'use strict'; 'use strict';
// Serve app files and individually selected route snapshots independently (v2). // Serve app files and individually selected route snapshots independently (v2).
importScripts('/js/offline-storage.js'); importScripts('/js/texts-de.js', '/js/i18n.js', '/js/offline-storage.js');
globalThis.addEventListener('install', event => event.waitUntil(globalThis.skipWaiting())); globalThis.addEventListener('install', event => event.waitUntil(globalThis.skipWaiting()));
globalThis.addEventListener('activate', event => event.waitUntil(globalThis.clients.claim())); globalThis.addEventListener('activate', event => event.waitUntil(globalThis.clients.claim()));
+40
View File
@@ -0,0 +1,40 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import vm from 'node:vm';
const root = path.resolve(import.meta.dirname, '..');
export async function loadI18n(context) {
for (const name of ['texts-de', 'i18n']) {
const filename = path.join(root, `public/js/${name}.js`);
vm.runInNewContext(await fs.readFile(filename, 'utf8'), context, { filename });
}
return context.Wegwichtel.I18n;
}
const escapeText = value => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
export async function localizedHtml(source) {
const i18n = await loadI18n({});
let result = source.replace(/(<[^>]+\bdata-i18n="([^"]+)"[^>]*>)([^<]*)(<\/)/g,
(_match, opening, key, _text, closing) => opening + escapeText(i18n.t(key)) + closing);
result = result.replace(/<[^>]+>/g, opening => {
let translated = opening;
for (const attribute of ['title', 'aria-label', 'placeholder', 'alt']) {
const key = new RegExp(`data-i18n-${attribute}="([^"]+)"`).exec(opening)?.[1];
if (key) translated = translated.replace(new RegExp(`\\b${attribute}="[^"]*"`), `${attribute}="${escapeText(i18n.t(key)).replaceAll('"', '&quot;')}"`);
}
return translated;
});
return result.replace(/\sdata-i18n(?:-[\w-]+)?="[^"]*"/g, '');
}
export async function readAppFile(relativePath) {
const source = await fs.readFile(path.join(root, relativePath), 'utf8');
if (relativePath === 'public/index.html') return localizedHtml(source);
if (relativePath.startsWith('public/js/')) {
// Static text assertions read the centralized wording; runtime tests use the real class.
return source + '\n' + await fs.readFile(path.join(root, 'public/js/texts-de.js'), 'utf8');
}
return source;
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { readAppFile } from '../test-support/i18n.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -13,7 +14,7 @@ const ignoredDirectories = new Set([
]); ]);
async function read(relativePath) { async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8'); return readAppFile(relativePath);
} }
async function markdownFiles(directory = root) { async function markdownFiles(directory = root) {
+24 -19
View File
@@ -1,3 +1,4 @@
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -299,6 +300,10 @@ async function loadApp() {
context.addEventListener = (name, handler) => harness.globalListeners.set(name, handler); context.addEventListener = (name, handler) => harness.globalListeners.set(name, handler);
context.globalThis = context; context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') });
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath }); vm.runInNewContext(source, context, { filename: sourcePath });
return { return {
@@ -322,9 +327,9 @@ test('route control runtime loads server/local routes and supports switching wit
assert.deepEqual(apiCalls.slice(0, 2), ['health', 'routes']); assert.deepEqual(apiCalls.slice(0, 2), ['health', 'routes']);
assert.deepEqual(setup.steps, [['server', 'done'], ['routes', 'done'], ['location', 'done']]); assert.deepEqual(setup.steps, [['server', 'done'], ['routes', 'done'], ['location', 'done']]);
assert.match(harness.node('#location-hint').textValue, /Standort nicht verfügbar/); assert.match(harness.node('#location-hint').textValue, /Standort konnte nicht gefunden werden/);
assert.match(harness.node('#route-control-server-title').textValue, /Aktive Strecken \(1\)/); assert.match(harness.node('#route-control-server-title').textValue, /Strecken für alle \(1\)/);
assert.match(harness.node('#route-control-local-title').textValue, /Lokale Strecken \(1\)/); assert.match(harness.node('#route-control-local-title').textValue, /Meine Entwürfe \(1\)/);
assert.ok(harness.node('#all-route-list').appended.join('').includes('Serverweg')); assert.ok(harness.node('#all-route-list').appended.join('').includes('Serverweg'));
assert.ok(harness.node('#local-route-list').appended.join('').includes('Lokaler Weg')); assert.ok(harness.node('#local-route-list').appended.join('').includes('Lokaler Weg'));
@@ -344,7 +349,7 @@ test('route control runtime loads server/local routes and supports switching wit
await harness.activate('[data-route-select]', localButton); await harness.activate('[data-route-select]', localButton);
assert.equal(harness.node('#route-control-title').textValue, 'Lokaler Weg'); assert.equal(harness.node('#route-control-title').textValue, 'Lokaler Weg');
assert.match(harness.node('#route-control-next').textValue, /Nächster Streckenpunkt: Lokaler Weg Start/); assert.match(harness.node('#route-control-next').textValue, /Nächste Station: Lokaler Weg Start/);
assert.equal(harness.node('#route-page').attributes.has('hidden'), false); assert.equal(harness.node('#route-page').attributes.has('hidden'), false);
const serverButton = new FakeNode('server-select'); const serverButton = new FakeNode('server-select');
@@ -354,7 +359,7 @@ test('route control runtime loads server/local routes and supports switching wit
assert.ok(apiCalls.includes('route:server-1')); assert.ok(apiCalls.includes('route:server-1'));
assert.equal(harness.node('#route-control-title').textValue, 'Serverweg'); assert.equal(harness.node('#route-control-title').textValue, 'Serverweg');
assert.match(harness.node('#route-control-next').textValue, /Nächster Streckenpunkt: Serverweg Start/); assert.match(harness.node('#route-control-next').textValue, /Nächste Station: Serverweg Start/);
}); });
test('local-route change refreshes an active draft and handles its later removal', async () => { test('local-route change refreshes an active draft and handles its later removal', async () => {
@@ -383,7 +388,7 @@ test('local-route change refreshes an active draft and handles its later removal
harness.document.dispatchEvent(removeEvent); harness.document.dispatchEvent(removeEvent);
await tick(); await tick();
assert.equal(harness.node('#route-control-title').textValue, 'Route wählen'); assert.equal(harness.node('#route-control-title').textValue, 'Strecke wählen');
assert.equal(harness.node('#routes-page').attributes.has('hidden'), false); assert.equal(harness.node('#routes-page').attributes.has('hidden'), false);
assert.equal(editor.refreshed, 2); assert.equal(editor.refreshed, 2);
}); });
@@ -400,7 +405,7 @@ test('route control closes on escape and reports local storage failures', async
context.Wegwichtel.LocalRoutes.listAppRoutes = () => { throw new Error('LocalStorage nicht lesbar'); }; context.Wegwichtel.LocalRoutes.listAppRoutes = () => { throw new Error('LocalStorage nicht lesbar'); };
harness.globalListeners.get('pageshow')?.({}); harness.globalListeners.get('pageshow')?.({});
assert.ok(harness.node('#route-control-local-list').appended.join('').includes('LocalStorage nicht lesbar')); assert.ok(harness.node('#route-control-local-list').appended.join('').includes('Deine Entwürfe konnten nicht geöffnet werden'));
}); });
async function selectServer(setup) { async function selectServer(setup) {
@@ -442,18 +447,18 @@ test('tracking triggers each POI once, records media progress and pauses/resumes
calls.heading(10); calls.heading(10);
assert.match(harness.node('#navigation-arrow').styles.get('transform'), /rotate\(50\.0deg\)/); assert.match(harness.node('#navigation-arrow').styles.get('transform'), /rotate\(50\.0deg\)/);
calls.error(new Error('Signal verloren')); calls.error(new Error('Signal verloren'));
assert.equal(harness.node('#tracking-status').textValue, 'GPS-Fehler: Signal verloren'); assert.equal(harness.node('#tracking-status').textValue, 'Dein Standort konnte nicht gefunden werden. Prüfe die Standort-Erlaubnis auf deinem Gerät.');
await harness.activate('#pause-route', new FakeNode('pause')); await harness.activate('#pause-route', new FakeNode('pause'));
assert.match(harness.node('#tracking-status').textValue, /pausiert/); assert.match(harness.node('#tracking-status').textValue, /Du machst eine Pause/);
assert.equal(savedStates.at(-1)[1], 'paused'); assert.equal(savedStates.at(-1)[1], 'paused');
await harness.activate('#start-route', new FakeNode('resume')); await harness.activate('#start-route', new FakeNode('resume'));
calls.position({ lat: 50.001, lon: 10.001, accuracy: 5 }); calls.position({ lat: 50.001, lon: 10.001, accuracy: 5 });
assert.equal(calls.vibrations, 2); assert.equal(calls.vibrations, 2);
assert.equal(harness.node('#route-control-next').textValue, 'Alle Streckenpunkte erreicht'); assert.equal(harness.node('#route-control-next').textValue, 'Alle Stationen erreicht');
assert.equal(harness.node('#navigation-distance').textValue, '0 m'); assert.equal(harness.node('#navigation-distance').textValue, '0 m');
await harness.activate('#stop-route', new FakeNode('stop')); await harness.activate('#stop-route', new FakeNode('stop'));
assert.equal(savedStates.at(-1)[1], 'idle'); assert.equal(savedStates.at(-1)[1], 'idle');
assert.equal(harness.node('#route-control-title').textValue, 'Route wählen'); assert.equal(harness.node('#route-control-title').textValue, 'Strecke wählen');
assert.equal(harness.node('#navigation-footer').properties.get('hidden'), true); assert.equal(harness.node('#navigation-footer').properties.get('hidden'), true);
assert.ok(calls.stopped >= 4); assert.ok(calls.stopped >= 4);
}); });
@@ -468,8 +473,8 @@ test('saved route resumes paused, manual POI viewing and lifecycle events persis
const calls = observeSensors(setup); const calls = observeSensors(setup);
await context.Wegwichtel.App.initialize({ markStep() {} }); await context.Wegwichtel.App.initialize({ markStep() {} });
await selectServer(setup); await selectServer(setup);
assert.match(harness.node('#tracking-status').textValue, /Gespeicherter/); assert.match(harness.node('#tracking-status').textValue, /weitermachen/);
assert.equal(harness.node('#start-route').attributes.get('title'), 'Route fortsetzen'); assert.equal(harness.node('#start-route').attributes.get('title'), 'Strecke fortsetzen');
const poi = new FakeNode('poi'); const poi = new FakeNode('poi');
poi.dataValues.set('poi-id', 2); poi.dataValues.set('poi-id', 2);
await harness.key('[data-poi-select]', poi, ' '); await harness.key('[data-poi-select]', poi, ' ');
@@ -501,16 +506,16 @@ test('tracking handles an empty route and orientation permission resolving after
context.Wegwichtel.Orientation.start = () => new Promise(resolve => { grant = resolve; }); context.Wegwichtel.Orientation.start = () => new Promise(resolve => { grant = resolve; });
await context.Wegwichtel.App.initialize({ markStep() {} }); await context.Wegwichtel.App.initialize({ markStep() {} });
await selectServer(setup); await selectServer(setup);
assert.match(harness.node('#poi-list').appended.join(''), /keine Streckenpunkte/); assert.match(harness.node('#poi-list').appended.join(''), /keine Stationen/);
await harness.activate('#start-route', new FakeNode('start')); await harness.activate('#start-route', new FakeNode('start'));
calls.position({ lat: 50, lon: 10, accuracy: 10 }); calls.position({ lat: 50, lon: 10, accuracy: 10 });
assert.equal(harness.node('#navigation-mode').textValue, 'Keine Navigationspunkte vorhanden'); assert.equal(harness.node('#navigation-mode').textValue, 'Der Weg dieser Strecke fehlt.');
await harness.activate('#pause-route', new FakeNode('pause')); await harness.activate('#pause-route', new FakeNode('pause'));
const stopped = calls.stopped; const stopped = calls.stopped;
grant(true); grant(true);
await tick(); await tick();
assert.equal(calls.stopped, stopped + 1); assert.equal(calls.stopped, stopped + 1);
assert.equal(harness.node('#navigation-mode').textValue, 'Route pausiert'); assert.equal(harness.node('#navigation-mode').textValue, 'Strecke pausiert');
await harness.activate('#stop-route', new FakeNode('stop')); await harness.activate('#stop-route', new FakeNode('stop'));
}); });
@@ -522,7 +527,7 @@ test('route recommendations and accent-insensitive search use location without c
far.start = { lat: 51, lon: 10 }; far.start = { lat: 51, lon: 10 };
context.Wegwichtel.Api.routes = async () => ({ routes: [far, setup.serverRoute, null, {}] }); context.Wegwichtel.Api.routes = async () => ({ routes: [far, setup.serverRoute, null, {}] });
await context.Wegwichtel.App.initialize({ markStep() {} }); await context.Wegwichtel.App.initialize({ markStep() {} });
assert.match(harness.node('#location-hint').textValue, /Standort ermittelt/); assert.match(harness.node('#location-hint').textValue, /Standort wurde/);
assert.match(harness.node('#nearby-route-list').appended.join(''), /Serverweg/); assert.match(harness.node('#nearby-route-list').appended.join(''), /Serverweg/);
assert.doesNotMatch(harness.node('#nearby-route-list').appended.join(''), /Äpfelweg/); assert.doesNotMatch(harness.node('#nearby-route-list').appended.join(''), /Äpfelweg/);
const position = context.Wegwichtel.App.currentPosition(); const position = context.Wegwichtel.App.currentPosition();
@@ -537,7 +542,7 @@ test('route recommendations and accent-insensitive search use location without c
autocomplete.select({}, { item: { value: 'Serverweg' } }); autocomplete.select({}, { item: { value: 'Serverweg' } });
assert.match(harness.node('#all-route-list').appended.join(''), /Serverweg/); assert.match(harness.node('#all-route-list').appended.join(''), /Serverweg/);
harness.$('#route-search').val('unbekannt').trigger('input'); harness.$('#route-search').val('unbekannt').trigger('input');
assert.match(harness.node('#all-route-list').appended.join(''), /Keine Route entspricht/); assert.match(harness.node('#all-route-list').appended.join(''), /Keine passende Strecke gefunden/);
await harness.activate('#clear-route-search', new FakeNode('clear')); await harness.activate('#clear-route-search', new FakeNode('clear'));
assert.equal(harness.node('#route-search').value, ''); assert.equal(harness.node('#route-search').value, '');
}); });
@@ -546,7 +551,7 @@ test('invalid API responses reject initialization and route selection errors res
const setup = await loadApp(); const setup = await loadApp();
const { context, harness } = setup; const { context, harness } = setup;
context.Wegwichtel.Api.routes = async () => ({ routes: null }); context.Wegwichtel.Api.routes = async () => ({ routes: null });
await assert.rejects(context.Wegwichtel.App.initialize({ markStep() {} }), /gültige Routenliste/); await assert.rejects(context.Wegwichtel.App.initialize({ markStep() {} }), /Strecken konnten nicht geladen werden/);
const errors = []; const errors = [];
context.alert = message => errors.push(message); context.alert = message => errors.push(message);
context.Wegwichtel.Api.route = async () => { throw { responseJSON: { message: 'Strecke entfernt' } }; }; context.Wegwichtel.Api.route = async () => { throw { responseJSON: { message: 'Strecke entfernt' } }; };
+3 -1
View File
@@ -1,3 +1,4 @@
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -148,13 +149,14 @@ test('bootstrap remains visible after loading and opens the app only after delib
location: { reload() {} } location: { reload() {} }
}; };
context.globalThis = context; context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath }); vm.runInNewContext(source, context, { filename: sourcePath });
await new Promise(resolve => setTimeout(resolve, 20)); await new Promise(resolve => setTimeout(resolve, 20));
assert.equal(elements.get('bootstrap-screen').hidden, false); assert.equal(elements.get('bootstrap-screen').hidden, false);
assert.equal(elements.get('bootstrap-screen').attributes.get('aria-busy'), 'false'); assert.equal(elements.get('bootstrap-screen').attributes.get('aria-busy'), 'false');
assert.equal(elements.get('bootstrap-enter').hidden, false); assert.equal(elements.get('bootstrap-enter').hidden, false);
assert.equal(elements.get('storage-cleanup-status').textContent, 'Entferne alte Streckendaten aus dem Speicher (1 / 1)'); assert.equal(elements.get('storage-cleanup-status').textContent, 'Alten Fortschritt löschen (1 / 1)');
assert.equal(navigations.length, 0); assert.equal(navigations.length, 0);
elements.get('bootstrap-screen').dispatchEvent(pointerEvent()); elements.get('bootstrap-screen').dispatchEvent(pointerEvent());
+2 -1
View File
@@ -1,3 +1,4 @@
import { readAppFile } from '../test-support/i18n.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -6,7 +7,7 @@ import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..'); const root = path.resolve(import.meta.dirname, '..');
async function read(relativePath) { async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8'); return readAppFile(relativePath);
} }
test('bootstrap screen remains vertically scrollable on small displays', async () => { test('bootstrap screen remains vertically scrollable on small displays', async () => {
+8 -7
View File
@@ -1,3 +1,4 @@
import { readAppFile } from '../test-support/i18n.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -6,7 +7,7 @@ import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..'); const root = path.resolve(import.meta.dirname, '..');
async function read(relativePath) { async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8'); return readAppFile(relativePath);
} }
test('start screen uses the presentation-style hero and one action slot', async () => { test('start screen uses the presentation-style hero and one action slot', async () => {
@@ -35,10 +36,10 @@ test('bootstrap status text fades between loading steps and is replaced by the s
assert.match(fadingRule, /opacity:\s*0/); assert.match(fadingRule, /opacity:\s*0/);
assert.match(loader, /statusText\.classList\.add\('is-fading'\)/); assert.match(loader, /statusText\.classList\.add\('is-fading'\)/);
assert.match(loader, /statusText\.classList\.remove\('is-fading'\)/); assert.match(loader, /statusText\.classList\.remove\('is-fading'\)/);
assert.match(loader, /storage:\s*'Lokalen Speicher prüfen …'/); assert.match(loader, /get storage\(\) \{ return globalThis\.Wegwichtel\.I18n\.t\(/);
assert.match(loader, /server:\s*'Server prüfen …'/); assert.match(loader, /get server\(\) \{ return globalThis\.Wegwichtel\.I18n\.t\(/);
assert.match(loader, /routes:\s*'Routen laden …'/); assert.match(loader, /get routes\(\) \{ return globalThis\.Wegwichtel\.I18n\.t\(/);
assert.match(loader, /location:\s*'Standort ermitteln …'/); assert.match(loader, /get location\(\) \{ return globalThis\.Wegwichtel\.I18n\.t\(/);
assert.match(loader, /actionStatus\?\.classList\.add\('is-leaving'\)/); assert.match(loader, /actionStatus\?\.classList\.add\('is-leaving'\)/);
assert.match(loader, /enter\.classList\.add\('is-visible'\)/); assert.match(loader, /enter\.classList\.add\('is-visible'\)/);
assert.match(loader, /const completedSteps = new Set\(\)/); assert.match(loader, /const completedSteps = new Set\(\)/);
@@ -58,6 +59,6 @@ test('Android start hint distinguishes Chrome from other Android browsers', asyn
assert.match(loader, /getElementById\('android-browser-hint'\)/); assert.match(loader, /getElementById\('android-browser-hint'\)/);
assert.match(loader, /\/Android\/i/); assert.match(loader, /\/Android\/i/);
assert.match(loader, /\/Chrome\\\//i); assert.match(loader, /\/Chrome\\\//i);
assert.match(loader, /Android mit Chrome erkannt/); assert.match(loader, /Auf diesem Gerät kannst du Bilder und Tonaufnahmen direkt auswählen/);
assert.match(loader, /Android erkannt\. Für lokale Bilder und Audiodateien bitte Chrome verwenden/); assert.match(loader, /Tipp für Android: Mit Chrome/);
}); });
+4 -2
View File
@@ -1,3 +1,4 @@
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -8,6 +9,7 @@ async function load(name, options = {}) {
const filename = path.resolve(import.meta.dirname, '../public/js', name); const filename = path.resolve(import.meta.dirname, '../public/js', name);
const context = { Wegwichtel: { Config: { apiBase: '/api', routeRadiusKm: 5, geolocationTimeoutMs: 7000 } }, ...options }; const context = { Wegwichtel: { Config: { apiBase: '/api', routeRadiusKm: 5, geolocationTimeoutMs: 7000 } }, ...options };
context.globalThis = context; context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(await fs.readFile(filename, 'utf8'), context, { filename }); vm.runInNewContext(await fs.readFile(filename, 'utf8'), context, { filename });
return context; return context;
} }
@@ -63,10 +65,10 @@ test('GPS normalizes positions, forwards errors and clears replaced watches incl
test('GPS unsupported devices reject current location and optionally report tracking errors', async () => { test('GPS unsupported devices reject current location and optionally report tracking errors', async () => {
for (const navigator of [undefined, {}]) { for (const navigator of [undefined, {}]) {
const { Wegwichtel: ns } = await load('geolocation.js', { navigator }); const { Wegwichtel: ns } = await load('geolocation.js', { navigator });
await assert.rejects(ns.Geo.current(), /keine Geolokalisierung/); await assert.rejects(ns.Geo.current(), /Standort nicht finden/);
const errors = []; const errors = [];
ns.Geo.start(() => assert.fail('unexpected position'), error => errors.push(error)); ns.Geo.start(() => assert.fail('unexpected position'), error => errors.push(error));
assert.match(errors[0].message, /keine Geolokalisierung/); assert.match(errors[0].message, /Standort nicht finden/);
ns.Geo.start(() => {}); ns.Geo.start(() => {});
ns.Geo.stop(); ns.Geo.stop();
} }
+2 -1
View File
@@ -1,3 +1,4 @@
import { readAppFile } from '../test-support/i18n.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -6,7 +7,7 @@ import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..'); const root = path.resolve(import.meta.dirname, '..');
async function read(relativePath) { async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8'); return readAppFile(relativePath);
} }
test('fresh routes schema stores timestamps as STRICT-compatible TEXT values', async () => { test('fresh routes schema stores timestamps as STRICT-compatible TEXT values', async () => {
+42 -20
View File
@@ -1,3 +1,5 @@
import { readAppFile } from '../test-support/i18n.js';
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -7,7 +9,7 @@ import vm from 'node:vm';
const root = path.resolve(import.meta.dirname, '..'); const root = path.resolve(import.meta.dirname, '..');
async function read(relativePath) { async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8'); return readAppFile(relativePath);
} }
function tick() { function tick() {
@@ -19,7 +21,7 @@ test('editor markup and source describe local draft creation instead of immediat
const css = await read('public/css/editor.css'); const css = await read('public/css/editor.css');
const ui = await read('public/js/editor-ui.js'); const ui = await read('public/js/editor-ui.js');
assert.match(html, /id="editor-button"[^>]+aria-label="Strecken verwalten"/); assert.match(html, /id="editor-button"[^>]+aria-label="Strecken bearbeiten"/);
assert.match(html, /id="route-control-button"[\s\S]*?<img[^>]+src="images\/icons\/route-management\.svg"[^>]+alt=""[^>]*>/); assert.match(html, /id="route-control-button"[\s\S]*?<img[^>]+src="images\/icons\/route-management\.svg"[^>]+alt=""[^>]*>/);
assert.doesNotMatch(html, /class="editor-button-icon"/); assert.doesNotMatch(html, /class="editor-button-icon"/);
assert.match(html, /<dialog id="editor-dialog"/); assert.match(html, /<dialog id="editor-dialog"/);
@@ -36,11 +38,11 @@ test('editor markup and source describe local draft creation instead of immediat
assert.doesNotMatch(html, /id="editor-poi-(?:lat|lon)"[^>]+step=/); assert.doesNotMatch(html, /id="editor-poi-(?:lat|lon)"[^>]+step=/);
assert.match(html, /id="editor-poi-media-preview"/); assert.match(html, /id="editor-poi-media-preview"/);
assert.match(html, /id="editor-poi-delete"[^>]+editor-danger-button/); assert.match(html, /id="editor-poi-delete"[^>]+editor-danger-button/);
assert.match(html, />Streckenpunkte<\/h3>/); assert.match(html, />Stationen<\/h3>/);
assert.match(html, /aria-label="Streckenpunkt hinzufügen"/); assert.match(html, /aria-label="Station hinzufügen"/);
assert.match(html, /id="editor-route-upload"[^>]+type="button"[^>]+hidden/); assert.match(html, /id="editor-route-upload"[^>]+type="button"[^>]+hidden/);
assert.doesNotMatch(html, /accept="video\//); assert.doesNotMatch(html, /accept="video\//);
assert.match(html, /ausschließlich in diesem Browser/i); assert.match(html, /auf diesem Gerät/i);
await fs.access(path.join(root, 'public/images/icons/route-management.svg')); await fs.access(path.join(root, 'public/images/icons/route-management.svg'));
assert.match(css, /\.editor-dialog/); assert.match(css, /\.editor-dialog/);
@@ -359,11 +361,15 @@ function buildEditorContext() {
return { calls, context, elements, packages, saves, storedMediaCalls }; return { calls, context, elements, packages, saves, storedMediaCalls };
} }
test('public editor workflow saves new route and GPX Streckenpunkt locally without POSTing a route', async () => { 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 sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8'); const source = await fs.readFile(sourcePath, 'utf8');
const { calls, context, elements, packages, saves } = buildEditorContext(); const { calls, context, elements, packages, saves } = buildEditorContext();
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') });
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath }); vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.open(); await context.Wegwichtel.EditorUi.open();
@@ -382,8 +388,8 @@ test('public editor workflow saves new route and GPX Streckenpunkt locally witho
assert.ok(packages.has('local-new')); assert.ok(packages.has('local-new'));
assert.equal(saves.at(-1).route.name, 'Neue lokale Strecke'); assert.equal(saves.at(-1).route.name, 'Neue lokale Strecke');
assert.equal(calls.some(call => call.method === 'POST' && call.url === '/api/routes'), false); assert.equal(calls.some(call => call.method === 'POST' && call.url === '/api/routes'), false);
assert.match(elements.get('editor-route-activated').textContent, /Lokaler Entwurf/); assert.match(elements.get('editor-route-activated').textContent, /Dieser Entwurf bleibt auf deinem Gerät/);
assert.equal(elements.get('editor-status').textContent, 'Speichern erfolgreich.'); assert.equal(elements.get('editor-status').textContent, 'Gespeichert.');
assert.equal(elements.get('editor-route-upload').hidden, false); assert.equal(elements.get('editor-route-upload').hidden, false);
assert.equal(elements.get('editor-route-upload').disabled, false); assert.equal(elements.get('editor-route-upload').disabled, false);
assert.equal(elements.get('editor-route-gpx-group').hidden, true); assert.equal(elements.get('editor-route-gpx-group').hidden, true);
@@ -399,7 +405,7 @@ test('public editor workflow saves new route and GPX Streckenpunkt locally witho
assert.equal(elements.get('editor-poi-lon').value, '10.987654321'); assert.equal(elements.get('editor-poi-lon').value, '10.987654321');
assert.equal(elements.get('editor-poi-radius').value, '4.321'); assert.equal(elements.get('editor-poi-radius').value, '4.321');
elements.get('editor-poi-title').value = 'Aussicht'; elements.get('editor-poi-title').value = 'Aussicht';
elements.get('editor-poi-description').value = 'Text zum Streckenpunkt'; elements.get('editor-poi-description').value = 'Text zum Station';
elements.get('editor-poi-audio').files = [{ name: 'aussicht.mp3', size: 5, type: 'audio/mpeg' }]; elements.get('editor-poi-audio').files = [{ name: 'aussicht.mp3', size: 5, type: 'audio/mpeg' }];
elements.get('editor-poi-audio').dispatchEvent(new Event('change')); elements.get('editor-poi-audio').dispatchEvent(new Event('change'));
elements.get('editor-poi-images').files = [{ name: 'aussicht.jpg', size: 7, type: 'image/jpeg' }]; elements.get('editor-poi-images').files = [{ name: 'aussicht.jpg', size: 7, type: 'image/jpeg' }];
@@ -447,8 +453,8 @@ test('public editor workflow saves new route and GPX Streckenpunkt locally witho
assert.equal(local.route.serverRouteId, '22'); assert.equal(local.route.serverRouteId, '22');
assert.ok(local.route.uploadedAt); assert.ok(local.route.uploadedAt);
assert.equal(elements.get('editor-route-upload').disabled, true); assert.equal(elements.get('editor-route-upload').disabled, true);
assert.equal(elements.get('editor-route-upload').textContent, 'Bereits übertragen'); assert.equal(elements.get('editor-route-upload').textContent, 'Schon zur Prüfung gesendet');
assert.equal(elements.get('editor-status').textContent, 'Upload erfolgreich.'); assert.equal(elements.get('editor-status').textContent, 'Zur Prüfung gesendet.');
const poiList = elements.get('editor-poi-list'); const poiList = elements.get('editor-poi-list');
const poiItem = poiList.children.find(child => child.dataset.poiId); const poiItem = poiList.children.find(child => child.dataset.poiId);
@@ -488,11 +494,15 @@ test('public editor workflow saves new route and GPX Streckenpunkt locally witho
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false'); assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false');
}); });
test('local Streckenpunkt ids start at zero and increment from the highest existing id', async () => { test('local Station ids start at zero and increment from the highest existing id', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js'); const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8'); const source = await fs.readFile(sourcePath, 'utf8');
const { context, elements, packages } = buildEditorContext(); const { context, elements, packages } = buildEditorContext();
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') });
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath }); vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.openNewLocalRoute(); await context.Wegwichtel.EditorUi.openNewLocalRoute();
elements.get('editor-route-name').value = 'ID-Test'; elements.get('editor-route-name').value = 'ID-Test';
@@ -502,14 +512,14 @@ test('local Streckenpunkt ids start at zero and increment from the highest exist
elements.get('editor-add-poi').dispatchEvent(pointerEvent()); elements.get('editor-add-poi').dispatchEvent(pointerEvent());
assert.equal(elements.get('editor-poi-id').value, '0'); assert.equal(elements.get('editor-poi-id').value, '0');
elements.get('editor-poi-title').value = 'Erster Streckenpunkt'; elements.get('editor-poi-title').value = 'Erster Station';
elements.get('editor-poi-form').dispatchEvent(submitEvent()); elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick(); await tick();
await tick(); await tick();
elements.get('editor-add-poi').dispatchEvent(pointerEvent()); elements.get('editor-add-poi').dispatchEvent(pointerEvent());
assert.equal(elements.get('editor-poi-id').value, '1'); assert.equal(elements.get('editor-poi-id').value, '1');
elements.get('editor-poi-title').value = 'Zweiter Streckenpunkt'; elements.get('editor-poi-title').value = 'Zweiter Station';
elements.get('editor-poi-form').dispatchEvent(submitEvent()); elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick(); await tick();
await tick(); await tick();
@@ -517,18 +527,22 @@ test('local Streckenpunkt ids start at zero and increment from the highest exist
const saved = packages.get('local-new'); const saved = packages.get('local-new');
assert.equal(saved.pois.length, 2); assert.equal(saved.pois.length, 2);
assert.deepEqual(Array.from(saved.pois, poi => poi.id), [0, 1]); assert.deepEqual(Array.from(saved.pois, poi => poi.id), [0, 1]);
assert.deepEqual(Array.from(saved.pois, poi => poi.title), ['Erster Streckenpunkt', 'Zweiter Streckenpunkt']); assert.deepEqual(Array.from(saved.pois, poi => poi.title), ['Erster Station', 'Zweiter Station']);
const pointItems = elements.get('editor-poi-list').children.filter(child => child.dataset.poiId); const pointItems = elements.get('editor-poi-list').children.filter(child => child.dataset.poiId);
assert.equal(pointItems.length, 2); assert.equal(pointItems.length, 2);
}); });
test('route and Streckenpunkt list expose red delete actions and remove local data', async () => { test('route and Station list expose red delete actions and remove local data', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js'); const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8'); const source = await fs.readFile(sourcePath, 'utf8');
const { context, elements, packages } = buildEditorContext(); const { context, elements, packages } = buildEditorContext();
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') });
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath }); vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.openNewLocalRoute(); await context.Wegwichtel.EditorUi.openNewLocalRoute();
elements.get('editor-route-name').value = 'Löschtest'; elements.get('editor-route-name').value = 'Löschtest';
@@ -537,7 +551,7 @@ test('route and Streckenpunkt list expose red delete actions and remove local da
await tick(); await tick();
elements.get('editor-add-poi').dispatchEvent(pointerEvent()); elements.get('editor-add-poi').dispatchEvent(pointerEvent());
elements.get('editor-poi-title').value = 'Streckenpunkt zum Löschen'; elements.get('editor-poi-title').value = 'Station zum Löschen';
elements.get('editor-poi-form').dispatchEvent(submitEvent()); elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick(); await tick();
await tick(); await tick();
@@ -573,6 +587,10 @@ test('editing an activated server route uses PUT and never writes activation sta
const source = await fs.readFile(sourcePath, 'utf8'); const source = await fs.readFile(sourcePath, 'utf8');
const { calls, context, elements } = buildEditorContext(); const { calls, context, elements } = buildEditorContext();
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') });
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath }); vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.open(); await context.Wegwichtel.EditorUi.open();
@@ -597,7 +615,7 @@ test('editing an activated server route uses PUT and never writes activation sta
assert.equal(elements.get('editor-route-id').value, '1'); assert.equal(elements.get('editor-route-id').value, '1');
assert.equal(elements.get('editor-route-gpx-group').hidden, false); assert.equal(elements.get('editor-route-gpx-group').hidden, false);
assert.match(elements.get('editor-route-activated').textContent, /Veröffentlicht am/); assert.match(elements.get('editor-route-activated').textContent, /für alle freigegeben/);
assert.equal(elements.get('editor-route-upload').hidden, true); assert.equal(elements.get('editor-route-upload').hidden, true);
elements.get('editor-route-name').value = 'Freigegebener Waldweg geändert'; elements.get('editor-route-name').value = 'Freigegebener Waldweg geändert';
@@ -612,7 +630,7 @@ test('editing an activated server route uses PUT and never writes activation sta
assert.deepEqual(Object.keys(body).sort(), ['description', 'name']); assert.deepEqual(Object.keys(body).sort(), ['description', 'name']);
assert.equal('activated_at' in body, false); assert.equal('activated_at' in body, false);
assert.equal('activatedAt' in body, false); assert.equal('activatedAt' in body, false);
assert.equal(elements.get('editor-status').textContent, 'Speichern erfolgreich.'); assert.equal(elements.get('editor-status').textContent, 'Gespeichert.');
}); });
@@ -630,6 +648,10 @@ test('local media picker keeps File System Access handles for later upload', asy
return [handle]; return [handle];
}; };
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') });
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath }); vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.openNewLocalRoute(); await context.Wegwichtel.EditorUi.openNewLocalRoute();
elements.get('editor-route-name').value = 'Handle-Strecke'; elements.get('editor-route-name').value = 'Handle-Strecke';
@@ -638,7 +660,7 @@ test('local media picker keeps File System Access handles for later upload', asy
await tick(); await tick();
elements.get('editor-add-poi').dispatchEvent(pointerEvent()); elements.get('editor-add-poi').dispatchEvent(pointerEvent());
elements.get('editor-poi-title').value = 'Streckenpunkt mit Bild'; elements.get('editor-poi-title').value = 'Station mit Bild';
elements.get('editor-poi-images-add').dispatchEvent(pointerEvent()); elements.get('editor-poi-images-add').dispatchEvent(pointerEvent());
await tick(); await tick();
assert.equal(elements.get('editor-poi-images').clickCount, 0); assert.equal(elements.get('editor-poi-images').clickCount, 0);
+2
View File
@@ -1,3 +1,4 @@
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -10,6 +11,7 @@ async function catalogue(namespace) {
const filename = path.join(root, 'public/js/group-symbols.js'); const filename = path.join(root, 'public/js/group-symbols.js');
const context = { Wegwichtel: namespace }; const context = { Wegwichtel: namespace };
context.globalThis = context; context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(await fs.readFile(filename, 'utf8'), context, { filename }); vm.runInNewContext(await fs.readFile(filename, 'utf8'), context, { filename });
return context.Wegwichtel.GroupSymbols; return context.Wegwichtel.GroupSymbols;
} }
+56
View File
@@ -0,0 +1,56 @@
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..');
test('translations support placeholders, plurals and German fallback without treating text as HTML', async () => {
const context = {};
const i18n = await loadI18n(context);
assert.equal(i18n.t('app.strecke.wahlen'), 'Strecke wählen');
assert.equal(i18n.t('media.image.count', { count: 1 }), '1 Bild');
assert.equal(i18n.t('media.image.count', { count: 2 }), '2 Bilder');
assert.equal(i18n.t('app.nachste.station', { value1: '<script>', value2: ' · 3 m' }), 'Nächste Station: <script> · 3 m');
i18n.register('en', { 'app.strecke.wahlen': 'Choose a trail', 'media.image.count': { one: '{count} picture', other: '{count} pictures' } });
assert.equal(i18n.setLanguage('en-GB', null), 'en');
assert.equal(i18n.t('app.strecke.wahlen'), 'Choose a trail');
assert.equal(i18n.t('media.image.count', { count: 0 }), '0 pictures');
assert.equal(i18n.t('media.audio.count', { count: 1 }), '1 Tonaufnahme');
assert.equal(i18n.setLanguage('fr', null), 'de');
assert.throws(() => i18n.t('missing.key'), /Missing translation/);
});
test('language changes translate visible and spoken labels through textContent and retain text markers', async () => {
const i18n = await loadI18n({});
const make = key => ({ textContent: '', attributes: new Map(key), getAttribute(name) { return this.attributes.get(name); }, setAttribute(name, value) { this.attributes.set(name, value); } });
const text = make([['data-i18n', 'app.strecke.wahlen']]);
const label = make([['data-i18n-aria-label', 'app.strecke.wahlen']]);
const root = { documentElement: {}, querySelectorAll(selector) {
if (selector === '[data-i18n]') return [text];
if (selector === '[data-i18n-aria-label]') return [label];
return [];
} };
i18n.apply(root);
assert.equal(text.textContent, 'Strecke wählen');
assert.equal(label.attributes.get('aria-label'), 'Strecke wählen');
i18n.register('en', { 'app.strecke.wahlen': '<Choose a trail>' });
i18n.setLanguage('en', root);
assert.equal(text.textContent, '<Choose a trail>');
assert.equal(root.documentElement.lang, 'en');
assert.equal(text.attributes.get('data-i18n'), 'app.strecke.wahlen');
});
test('every static HTML and JavaScript text key exists in the central catalog and translations load before app modules', async () => {
const i18n = await loadI18n({});
const html = await fs.readFile(path.join(root, 'public/index.html'), 'utf8');
for (const match of html.matchAll(/\bdata-i18n(?:-[\w-]+)?="([^"]+)"/g)) assert.equal(typeof i18n.t(match[1]), 'string', match[1]);
for (const name of await fs.readdir(path.join(root, 'public/js'))) {
if (!name.endsWith('.js')) continue;
const source = await fs.readFile(path.join(root, 'public/js', name), 'utf8');
for (const match of source.matchAll(/I18n\.t\(["']([^"']+)["']/g)) assert.equal(typeof i18n.t(match[1], { count: 2 }), 'string', match[1]);
}
assert.ok(html.indexOf('js/texts-de.js') < html.indexOf('js/i18n.js'));
assert.ok(html.indexOf('js/i18n.js') < html.indexOf('js/local-routes.js'));
});
+11 -6
View File
@@ -1,3 +1,5 @@
import { readAppFile } from '../test-support/i18n.js';
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -7,7 +9,7 @@ import vm from 'node:vm';
const root = path.resolve(import.meta.dirname, '..'); const root = path.resolve(import.meta.dirname, '..');
async function read(relativePath) { async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8'); return readAppFile(relativePath);
} }
test('start screen exposes cookie status, optional local progress and deliberate entry', async () => { test('start screen exposes cookie status, optional local progress and deliberate entry', async () => {
@@ -15,13 +17,13 @@ test('start screen exposes cookie status, optional local progress and deliberate
const loader = await read('public/js/bootstrap-loader.js'); const loader = await read('public/js/bootstrap-loader.js');
const storage = await read('public/js/privacy-storage.js'); const storage = await read('public/js/privacy-storage.js');
assert.match(html, /Diese Anwendung setzt derzeit keine Cookies/); assert.match(html, /Wegwichtel verwendet keine Cookies/);
assert.match(html, /class="cookie-toggle"[^>]*disabled/); assert.match(html, /class="cookie-toggle"[^>]*disabled/);
assert.match(html, /class="progress-storage-toggle"/); assert.match(html, /class="progress-storage-toggle"/);
assert.match(html, /role="switch"[^>]*aria-checked="false"/); assert.match(html, /role="switch"[^>]*aria-checked="false"/);
assert.match(html, /id="storage-cleanup-status"/); assert.match(html, /id="storage-cleanup-status"/);
assert.match(html, /id="bootstrap-enter"[^>]*hidden/); assert.match(html, /id="bootstrap-enter"[^>]*hidden/);
assert.match(loader, /Entferne alte Streckendaten aus dem Speicher \(\$\{removed\} \/ \$\{total\}\)/); assert.match(loader, /Alten Fortschritt löschen \(\{value1\} \/ \{value2\}\)/);
assert.match(loader, /showReadyState\(\)/); assert.match(loader, /showReadyState\(\)/);
assert.doesNotMatch(loader, /getElementById\('bootstrap-screen'\)\.hidden = true/); assert.doesNotMatch(loader, /getElementById\('bootstrap-screen'\)\.hidden = true/);
assert.match(loader, /event\.target\.closest\('button, input, label, a, dialog'\)/); assert.match(loader, /event\.target\.closest\('button, input, label, a, dialog'\)/);
@@ -40,9 +42,11 @@ test('information button opens scrollable overlay pages for privacy, imprint and
assert.match(html, /data-info-target="privacy"/); assert.match(html, /data-info-target="privacy"/);
assert.match(html, /data-info-target="imprint"/); assert.match(html, /data-info-target="imprint"/);
assert.match(html, /data-info-target="technology"/); assert.match(html, /data-info-target="technology"/);
assert.match(html, /wegwichtel\.privacy\.v1/); const storage = await read('public/js/privacy-storage.js');
assert.match(html, /wegwichtel\.route-progress\.v1/); assert.match(storage, /wegwichtel\.privacy\.v1/);
assert.match(html, /jQuery 4\.0\.0/); assert.match(storage, /wegwichtel\.route-progress\.v1/);
assert.match(html, /Speichern und Berechtigungen/);
assert.doesNotMatch(html, /Local Storage|GPX|jQuery 4\.0\.0/);
assert.match(css, /\.info-dialog-content[\s\S]*overflow-y:\s*auto/); assert.match(css, /\.info-dialog-content[\s\S]*overflow-y:\s*auto/);
assert.match(css, /\.info-dialog-content[\s\S]*touch-action:\s*pan-y/); assert.match(css, /\.info-dialog-content[\s\S]*touch-action:\s*pan-y/);
assert.match(ui, /pointerup/); assert.match(ui, /pointerup/);
@@ -177,6 +181,7 @@ test('legal overlay handlers synchronize switches without advancing the start sc
CustomEvent: TestCustomEvent CustomEvent: TestCustomEvent
}; };
context.globalThis = context; context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath }); vm.runInNewContext(source, context, { filename: sourcePath });
elements.get('info-button').dispatchEvent(pointerEvent()); elements.get('info-button').dispatchEvent(pointerEvent());
+2
View File
@@ -1,3 +1,4 @@
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -91,6 +92,7 @@ function createHarness() {
async function loadModule(relativePath, harness) { async function loadModule(relativePath, harness) {
const sourcePath = path.join(root, relativePath); const sourcePath = path.join(root, relativePath);
const source = await fs.readFile(sourcePath, 'utf8'); const source = await fs.readFile(sourcePath, 'utf8');
await loadI18n(harness.context);
vm.runInNewContext(source, harness.context, { filename: sourcePath }); vm.runInNewContext(source, harness.context, { filename: sourcePath });
} }
+24 -22
View File
@@ -1,3 +1,4 @@
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -345,6 +346,7 @@ async function loadLocalRoutes(options = {}) {
Wegwichtel: {} Wegwichtel: {}
}; };
context.globalThis = context; context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(source, context, { filename: sourcePath }); vm.runInNewContext(source, context, { filename: sourcePath });
return { api: context.Wegwichtel.LocalRoutes, events, indexedDB, localStorage }; return { api: context.Wegwichtel.LocalRoutes, events, indexedDB, localStorage };
} }
@@ -394,7 +396,7 @@ test('route id generation fails when no secure crypto source is available', asyn
assert.throws( assert.throws(
() => api.createRouteId(), () => api.createRouteId(),
/Keine sichere Zufallsquelle/ /keine neue Strecke angelegt/
); );
}); });
@@ -405,10 +407,10 @@ test('local route package stores GPX and JSON separately and reconstructs an app
const saved = api.save({ const saved = api.save({
id: routeId, id: routeId,
gpx: api.emptyGpx('Neue Route'), gpx: api.emptyGpx('Neue Strecke'),
route: { route: {
id: routeId, id: routeId,
name: 'Neue Route', name: 'Neue Strecke',
description: 'Lokal gespeichert', description: 'Lokal gespeichert',
serverRouteId: 42, serverRouteId: 42,
uploadedAt: '2026-09-27T18:00:00.000Z' uploadedAt: '2026-09-27T18:00:00.000Z'
@@ -436,7 +438,7 @@ test('local route package stores GPX and JSON separately and reconstructs an app
] ]
}); });
assert.equal(saved.route.name, 'Neue Route'); assert.equal(saved.route.name, 'Neue Strecke');
assert.equal(saved.pois.length, 2); assert.equal(saved.pois.length, 2);
assert.equal(events.at(-1).action, 'saved'); assert.equal(events.at(-1).action, 'saved');
assert.equal(events.at(-1).id, routeId); assert.equal(events.at(-1).id, routeId);
@@ -450,7 +452,7 @@ test('local route package stores GPX and JSON separately and reconstructs an app
assert.match(gpx, /<wegwichtel:poiId>101<\/wegwichtel:poiId>/); assert.match(gpx, /<wegwichtel:poiId>101<\/wegwichtel:poiId>/);
assert.doesNotMatch(gpx, /<wpt/); assert.doesNotMatch(gpx, /<wpt/);
assert.equal((gpx.match(/<trkpt /g) || []).length, 2); assert.equal((gpx.match(/<trkpt /g) || []).length, 2);
assert.equal(metadata.route.name, 'Neue Route'); assert.equal(metadata.route.name, 'Neue Strecke');
assert.equal(metadata.route.serverRouteId, 42); assert.equal(metadata.route.serverRouteId, 42);
assert.equal(metadata.route.uploadedAt, '2026-09-27T18:00:00.000Z'); assert.equal(metadata.route.uploadedAt, '2026-09-27T18:00:00.000Z');
assert.equal(metadata.pois[0].audio.name, 'start.mp3'); assert.equal(metadata.pois[0].audio.name, 'start.mp3');
@@ -482,19 +484,19 @@ test('GPX waypoint import reads stable POI ids and validates coordinates', async
assert.equal(imported[0].lon, 10.1); assert.equal(imported[0].lon, 10.1);
assert.match(api.emptyGpx('A & B'), /<name>A &amp; B<\/name>/); assert.match(api.emptyGpx('A & B'), /<name>A &amp; B<\/name>/);
assert.throws(() => api.parseWaypoints('<not-gpx></not-gpx>'), /GPX-Datei ist nicht gültig/); assert.throws(() => api.parseWaypoints('<not-gpx></not-gpx>'), /keinen lesbaren Weg/);
assert.throws(() => api.syncGpx(sampleGpx, { name: 'Fehler' }, [{ id: 1, title: 'Ohne Position' }]), /gültige Koordinaten/); assert.throws(() => api.syncGpx(sampleGpx, { name: 'Fehler' }, [{ id: 1, title: 'Ohne Position' }]), /Ort von Ohne Position fehlt/);
const waypointOnly = api.syncGpx( const waypointOnly = api.syncGpx(
`<gpx version="1.1" xmlns="${GPX_NAMESPACE}"></gpx>`, `<gpx version="1.1" xmlns="${GPX_NAMESPACE}"></gpx>`,
{ name: 'Nur Streckenpunkt' }, { name: 'Nur Station' },
[{ id: 2, title: 'Einzelner Streckenpunkt', lat: 50.2, lon: 10.2, sequence: 0 }] [{ id: 2, title: 'Einzelner Station', lat: 50.2, lon: 10.2, sequence: 0 }]
); );
assert.match(waypointOnly, /<trkpt lat="50.2" lon="10.2">/); assert.match(waypointOnly, /<trkpt lat="50.2" lon="10.2">/);
assert.doesNotMatch(waypointOnly, /<wpt\b/); assert.doesNotMatch(waypointOnly, /<wpt\b/);
}); });
test('local points are stored directly as managed GPX Streckenpunkte and uploaded unchanged', async () => { test('local points are stored directly as managed GPX Stationen and uploaded unchanged', async () => {
const { api } = await loadLocalRoutes(); const { api } = await loadLocalRoutes();
const saved = api.save({ const saved = api.save({
id: 'local-upload', id: 'local-upload',
@@ -514,10 +516,10 @@ test('local points are stored directly as managed GPX Streckenpunkte and uploade
assert.equal(api.gpxForUpload(saved), saved.gpx, 'upload must use the already stored track without conversion'); assert.equal(api.gpxForUpload(saved), saved.gpx, 'upload must use the already stored track without conversion');
}); });
test('sync keeps imported foreign Streckenpunkte and upload rejects a route without Streckenpunkte', async () => { test('sync keeps imported foreign Stationen and upload rejects a route without Stationen', async () => {
const { api } = await loadLocalRoutes(); const { api } = await loadLocalRoutes();
const synced = api.syncGpx(sampleGpx, { name: 'Import' }, [ const synced = api.syncGpx(sampleGpx, { name: 'Import' }, [
{ id: 200, title: 'Neuer Streckenpunkt', lat: 50.2, lon: 10.2, sequence: 0 } { id: 200, title: 'Neuer Station', lat: 50.2, lon: 10.2, sequence: 0 }
]); ]);
assert.match(synced, /<ele>100<\/ele>/); assert.match(synced, /<ele>100<\/ele>/);
@@ -529,7 +531,7 @@ test('sync keeps imported foreign Streckenpunkte and upload rejects a route with
gpx: api.emptyGpx('Leer'), gpx: api.emptyGpx('Leer'),
route: { name: 'Leer' }, route: { name: 'Leer' },
pois: [] pois: []
}), /noch keine Streckenpunkte im GPX-Track/); }), /noch keinen aufgezeichneten Weg/);
}); });
test('remove updates index and dispatches a local-route change event', async () => { test('remove updates index and dispatches a local-route change event', async () => {
@@ -564,7 +566,7 @@ test('quota failure rolls back package writes and keeps the previous package rea
gpx: sampleGpx, gpx: sampleGpx,
route: { name: 'Nachher' }, route: { name: 'Nachher' },
pois: [] pois: []
}), /Speicher für Streckendaten ist voll/); }), /Platz für deine Strecken ist voll/);
assert.equal(api.get('local-quota').route.name, 'Vorher'); assert.equal(api.get('local-quota').route.name, 'Vorher');
}); });
@@ -581,7 +583,7 @@ test('local media uses IndexedDB so route JSON does not consume Local Storage qu
id: routeId, id: routeId,
gpx: api.emptyGpx('Medientest'), gpx: api.emptyGpx('Medientest'),
route: { name: 'Medientest' }, route: { name: 'Medientest' },
pois: [{ id: 1, title: 'Streckenpunkt 1', lat: 50.1, lon: 10.1, audio, images: [image] }] pois: [{ id: 1, title: 'Station 1', lat: 50.1, lon: 10.1, audio, images: [image] }]
}); });
const metadata = localStorage.getItem(`${api.STORAGE_PREFIX}.${encodeURIComponent(routeId)}.json`); const metadata = localStorage.getItem(`${api.STORAGE_PREFIX}.${encodeURIComponent(routeId)}.json`);
@@ -640,7 +642,7 @@ test('local media stores a File System Access handle instead of copying file byt
id: 'route-handle', id: 'route-handle',
gpx: api.emptyGpx('Handle'), gpx: api.emptyGpx('Handle'),
route: { name: 'Handle' }, route: { name: 'Handle' },
pois: [{ id: 7, title: 'Streckenpunkt', lat: 50.1, lon: 10.1, images: [media] }] pois: [{ id: 7, title: 'Station', lat: 50.1, lon: 10.1, images: [media] }]
}); });
const metadata = localStorage.getItem(`${api.STORAGE_PREFIX}.${encodeURIComponent('route-handle')}.json`); const metadata = localStorage.getItem(`${api.STORAGE_PREFIX}.${encodeURIComponent('route-handle')}.json`);
assert.match(metadata, /"storage":"file-handle"/); assert.match(metadata, /"storage":"file-handle"/);
@@ -648,12 +650,12 @@ test('local media stores a File System Access handle instead of copying file byt
assert.equal(await api.mediaBlob(media), file); assert.equal(await api.mediaBlob(media), file);
permission = 'prompt'; permission = 'prompt';
await assert.rejects(api.mediaBlob(media), /erneut erlaubt/); await assert.rejects(api.mediaBlob(media), /Erlaube noch einmal/);
assert.equal(await api.mediaBlob(media, { requestPermission: true }), file); assert.equal(await api.mediaBlob(media, { requestPermission: true }), file);
assert.equal(permissionRequests, 1); assert.equal(permissionRequests, 1);
}); });
test('multiple Streckenpunkte with large local media keep Local Storage metadata small', async () => { test('multiple Stationen with large local media keep Local Storage metadata small', async () => {
const { api, localStorage } = await loadLocalRoutes(); const { api, localStorage } = await loadLocalRoutes();
const routeId = 'multi-media'; const routeId = 'multi-media';
const large = new Uint8Array(1024 * 1024); const large = new Uint8Array(1024 * 1024);
@@ -664,11 +666,11 @@ test('multiple Streckenpunkte with large local media keep Local Storage metadata
api.save({ api.save({
id: routeId, id: routeId,
gpx: api.emptyGpx('Mehrere Streckenpunkte'), gpx: api.emptyGpx('Mehrere Stationen'),
route: { name: 'Mehrere Streckenpunkte' }, route: { name: 'Mehrere Stationen' },
pois: [ pois: [
{ id: 1, title: 'Erster Streckenpunkt', lat: 50.1, lon: 10.1, images: [first] }, { id: 1, title: 'Erster Station', lat: 50.1, lon: 10.1, images: [first] },
{ id: 2, title: 'Zweiter Streckenpunkt', lat: 50.2, lon: 10.2, images: [second] } { id: 2, title: 'Zweiter Station', lat: 50.2, lon: 10.2, images: [second] }
] ]
}); });
+16 -13
View File
@@ -1,3 +1,5 @@
import { readAppFile } from '../test-support/i18n.js';
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -7,7 +9,7 @@ import vm from 'node:vm';
const root = path.resolve(import.meta.dirname, '..'); const root = path.resolve(import.meta.dirname, '..');
async function read(relativePath) { async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8'); return readAppFile(relativePath);
} }
async function clientSources() { async function clientSources() {
@@ -72,6 +74,7 @@ test('mobile navigation footer contains a rotating SVG arrow and exact metre out
test('client bearing calculation points north and east correctly', async () => { test('client bearing calculation points north and east correctly', async () => {
const source = await read('public/js/distance.js'); const source = await read('public/js/distance.js');
const context = { Wegwichtel: {} }; const context = { Wegwichtel: {} };
await loadI18n(context);
vm.runInNewContext(source, context); vm.runInNewContext(source, context);
const distance = context.Wegwichtel.Distance; const distance = context.Wegwichtel.Distance;
@@ -96,10 +99,10 @@ test('route controls use accessible icon buttons with start, pause and stop stat
assert.match(html, /id="start-route"[\s\S]*images\/icons\/play\.svg/); assert.match(html, /id="start-route"[\s\S]*images\/icons\/play\.svg/);
assert.match(html, /id="pause-route"[\s\S]*images\/icons\/pause\.svg/); assert.match(html, /id="pause-route"[\s\S]*images\/icons\/pause\.svg/);
assert.match(html, /id="stop-route"[\s\S]*images\/icons\/stop\.svg/); assert.match(html, /id="stop-route"[\s\S]*images\/icons\/stop\.svg/);
assert.match(html, /aria-label="Route starten"/); assert.match(html, /aria-label="Strecke starten"/);
assert.match(html, /aria-label="Route pausieren"/); assert.match(html, /aria-label="Strecke pausieren"/);
assert.match(html, /aria-label="Route beenden"/); assert.match(html, /aria-label="Strecke beenden"/);
assert.match(html, /<fieldset>[\s\S]*<legend>Routensteuerung<\/legend>/); assert.match(html, /<fieldset>[\s\S]*<legend>Unterwegs<\/legend>/);
assert.doesNotMatch(html, /role="group"/); assert.doesNotMatch(html, /role="group"/);
assert.doesNotMatch(html, /aria-type=/); assert.doesNotMatch(html, /aria-type=/);
assert.match(app, /routeState:\s*'idle'/); assert.match(app, /routeState:\s*'idle'/);
@@ -131,7 +134,7 @@ test('pause and stop control audio and stop returns to route selection', async (
assert.match(audio, /audio\.load\(\)/); assert.match(audio, /audio\.load\(\)/);
}); });
test('the selected or triggered Streckenpunkt is highlighted semantically', async () => { test('the selected or triggered Station is highlighted semantically', async () => {
const app = await read('public/js/app.js'); const app = await read('public/js/app.js');
const css = await read('public/css/app.css'); const css = await read('public/css/app.css');
@@ -141,7 +144,7 @@ test('the selected or triggered Streckenpunkt is highlighted semantically', asyn
assert.match(css, /aktuelle Station/); assert.match(css, /aktuelle Station/);
}); });
test('Streckenpunkte have live direction and metre indicators and take navigation priority', async () => { test('Stationen have live direction and metre indicators and take navigation priority', async () => {
const app = await read('public/js/app.js'); const app = await read('public/js/app.js');
const css = await read('public/css/app.css'); const css = await read('public/css/app.css');
const html = await read('public/index.html'); const html = await read('public/index.html');
@@ -150,16 +153,16 @@ test('Streckenpunkte have live direction and metre indicators and take navigatio
assert.match(app, /function updatePoiIndicators\(position\)/); assert.match(app, /function updatePoiIndicators\(position\)/);
assert.match(app, /function nearestPendingPoi\(metrics\)/); assert.match(app, /function nearestPendingPoi\(metrics\)/);
assert.match(app, /if \(nextPoi\) showPoiNavigation\(position, nextPoi\)/); assert.match(app, /if \(nextPoi\) showPoiNavigation\(position, nextPoi\)/);
assert.match(app, /Nächster Streckenpunkt:/); assert.match(app, /Nächste Station:/);
assert.match(app, /find\('small'\)\.text\(`\$\{distance\} m`\)/); assert.match(app, /find\('small'\)\.text\(`\$\{distance\} m`\)/);
assert.match(app, /find\('svg'\)\.css\('transform'/); assert.match(app, /find\('svg'\)\.css\('transform'/);
assert.match(css, /\.poi-proximity[\s\S]*grid-column:\s*2/); assert.match(css, /\.poi-proximity[\s\S]*grid-column:\s*2/);
assert.match(css, /\.poi-proximity svg[\s\S]*transition:\s*transform/); assert.match(css, /\.poi-proximity svg[\s\S]*transition:\s*transform/);
assert.match(html, /Richtung zum nächsten Streckenpunkt/); assert.match(html, /Richtung zum nächsten Station/);
}); });
test('reaching a Streckenpunkt preloads audio without autoplay and uses the vibration wrapper', async () => { test('reaching a Station preloads audio without autoplay and uses the vibration wrapper', async () => {
const app = await read('public/js/app.js'); const app = await read('public/js/app.js');
const audio = await read('public/js/audio-player.js'); const audio = await read('public/js/audio-player.js');
const html = await read('public/index.html'); const html = await read('public/index.html');
@@ -171,14 +174,14 @@ test('reaching a Streckenpunkt preloads audio without autoplay and uses the vibr
assert.doesNotMatch(app, /navigator\.vibrate/); assert.doesNotMatch(app, /navigator\.vibrate/);
assert.match(app, /function stopRouteSensors\(\)[\s\S]*ns\.Vibration\.stop\(\)/); assert.match(app, /function stopRouteSensors\(\)[\s\S]*ns\.Vibration\.stop\(\)/);
assert.match(audio, /audio\.load\(\)/); assert.match(audio, /audio\.load\(\)/);
assert.match(html, /<audio id="poi-audio" controls preload="metadata" aria-label="Audiowiedergabe zum Streckenpunkt" hidden>/); assert.match(html, /<audio id="poi-audio" controls preload="metadata" aria-label="Ton zur Station" hidden>/);
assert.match(audio, /removeAttr\('hidden'\)/); assert.match(audio, /removeAttr\('hidden'\)/);
assert.match(audio, /Date\.now\(\)/); assert.match(audio, /Date\.now\(\)/);
assert.match(loader, /'vibration'/); assert.match(loader, /'vibration'/);
}); });
test('Streckenpunkt page uses icon-based back and slideshow controls in a single responsive row', async () => { test('Station page uses icon-based back and slideshow controls in a single responsive row', async () => {
const html = await read('public/index.html'); const html = await read('public/index.html');
const css = await read('public/css/app.css'); const css = await read('public/css/app.css');
const loader = await read('public/js/bootstrap-loader.js'); const loader = await read('public/js/bootstrap-loader.js');
@@ -196,7 +199,7 @@ test('Streckenpunkt page uses icon-based back and slideshow controls in a single
assert.match(css, /#slide-caption[\s\S]*overflow-wrap:\s*anywhere/); assert.match(css, /#slide-caption[\s\S]*overflow-wrap:\s*anywhere/);
assert.match(css, /#slide-image[\s\S]*object-fit:\s*contain/); assert.match(css, /#slide-image[\s\S]*object-fit:\s*contain/);
assert.doesNotMatch(css, /#slideshow img\s*\{/); assert.doesNotMatch(css, /#slideshow img\s*\{/);
assert.match(html, /images\/icons\/play\.svg" alt="Route starten"/); assert.match(html, /images\/icons\/play\.svg" alt="Strecke starten"/);
assert.match(html, /images\/icons\/chevron-left\.svg" alt="Vorheriges Bild"/); assert.match(html, /images\/icons\/chevron-left\.svg" alt="Vorheriges Bild"/);
assert.doesNotMatch(loader, /controlgroup/); assert.doesNotMatch(loader, /controlgroup/);
}); });
+21 -13
View File
@@ -1,3 +1,4 @@
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -65,6 +66,7 @@ async function loadStore(filesystem = false) {
caches: memoryCaches(), crypto, URL, Response, Headers, Blob, Date caches: memoryCaches(), crypto, URL, Response, Headers, Blob, Date
}; };
context.globalThis = context; context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/offline-storage.js'), 'utf8'), context, { filename: path.join(root, 'public/js/offline-storage.js') }); vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/offline-storage.js'), 'utf8'), context, { filename: path.join(root, 'public/js/offline-storage.js') });
return { context, directory, store: context.Wegwichtel.OfflineStore }; return { context, directory, store: context.Wegwichtel.OfflineStore };
} }
@@ -120,10 +122,10 @@ for (const filesystem of [false, true]) {
assert.equal(await (await store.match('/image.png?v=99')).text(), 'first'); assert.equal(await (await store.match('/image.png?v=99')).text(), 'first');
assert.equal((await store.match('/image.png')).headers.get('Content-Type'), 'image/png'); assert.equal((await store.match('/image.png')).headers.get('Content-Type'), 'image/png');
const failed = await store.create(); const failed = await store.create();
await assert.rejects(store.put(failed, '/image.png', new Response('missing', { status: 404 })), /Download fehlgeschlagen/); await assert.rejects(store.put(failed, '/image.png', new Response('missing', { status: 404 })), /Teil konnte nicht gespeichert werden/);
await store.remove(failed.id); await store.remove(failed.id);
assert.equal(await (await store.match('/image.png')).text(), 'first'); assert.equal(await (await store.match('/image.png')).text(), 'first');
assert.throws(() => store.key('https://outside.test/image.png'), /dieser App/); assert.throws(() => store.key('https://outside.test/image.png'), /anderen Seiten/);
const second = await store.create(); const second = await store.create();
await store.put(second, '/image.png', new Response('second')); await store.put(second, '/image.png', new Response('second'));
await store.commit(second); await store.commit(second);
@@ -150,6 +152,7 @@ async function workerHarness() {
skipWaiting: async () => {}, clients: { claim: async () => {} } skipWaiting: async () => {}, clients: { claim: async () => {} }
}; };
context.self = context; context.self = context;
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/offline-worker.js'), 'utf8'), context, { filename: path.join(root, 'public/offline-worker.js') }); vm.runInNewContext(await fs.readFile(path.join(root, 'public/offline-worker.js'), 'utf8'), context, { filename: path.join(root, 'public/offline-worker.js') });
async function request(url, options) { async function request(url, options) {
let response; let response;
@@ -222,6 +225,9 @@ async function managerHarness({ supported = true, saved = false } = {}) {
await store.put(stage, '/', new Response('old')); await store.put(stage, '/', new Response('old'));
await store.commit(stage); await store.commit(stage);
} }
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/user-messages.js'), 'utf8'), context, { filename: path.join(root, 'public/js/user-messages.js') });
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/offline-cache.js'), 'utf8'), context, { filename: path.join(root, 'public/js/offline-cache.js') }); vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/offline-cache.js'), 'utf8'), context, { filename: path.join(root, 'public/js/offline-cache.js') });
return { context, store, elements, downloaded, manager: context.Wegwichtel.OfflineCache }; return { context, store, elements, downloaded, manager: context.Wegwichtel.OfflineCache };
} }
@@ -241,7 +247,7 @@ test('offline switch prepares all resources once, persists opt-in and clears onl
assert.equal(downloaded.length, 3, 'duplicate resources must not be stored twice'); assert.equal(downloaded.length, 3, 'duplicate resources must not be stored twice');
assert.equal(elements.get('offline-cache-toggle').checked, true); assert.equal(elements.get('offline-cache-toggle').checked, true);
assert.equal(elements.get('offline-cache-refresh').hidden, false); assert.equal(elements.get('offline-cache-refresh').hidden, false);
assert.match(elements.get('offline-cache-status').textContent, /3 Dateien sind offline verfügbar/); assert.match(elements.get('offline-cache-status').textContent, /Wegwichtel ist für unterwegs bereit/);
assert.ok(await store.match('/image.png')); assert.ok(await store.match('/image.png'));
await manager.disable(); await manager.disable();
assert.equal(await store.state(), null); assert.equal(await store.state(), null);
@@ -257,9 +263,9 @@ test('failed refresh preserves the old snapshot and download can be cancelled wi
assert.equal(elements.get('offline-cache-toggle').checked, true); assert.equal(elements.get('offline-cache-toggle').checked, true);
const fetchResource = context.fetch; const fetchResource = context.fetch;
context.fetch = async (url, options) => url.endsWith('/image.png') ? new Response('missing', { status: 404 }) : fetchResource(url, options); context.fetch = async (url, options) => url.endsWith('/image.png') ? new Response('missing', { status: 404 }) : fetchResource(url, options);
await assert.rejects(manager.download(), /Download fehlgeschlagen/); await assert.rejects(manager.download(), /Teil konnte nicht gespeichert werden/);
assert.equal(await (await store.match('/')).text(), 'old'); assert.equal(await (await store.match('/')).text(), 'old');
assert.match(elements.get('offline-cache-status').textContent, /bisherige Offline-Stand/); assert.match(elements.get('offline-cache-status').textContent, /vorher gespeichert hast/);
let started; let started;
const downloading = new Promise(resolve => { started = resolve; }); const downloading = new Promise(resolve => { started = resolve; });
context.fetch = (url, options) => new Promise((resolve, reject) => { context.fetch = (url, options) => new Promise((resolve, reject) => {
@@ -279,14 +285,14 @@ test('offline UI handles unsupported browsers and malformed manifests without en
const unsupported = await managerHarness({ supported: false }); const unsupported = await managerHarness({ supported: false });
await unsupported.manager.initialize(); await unsupported.manager.initialize();
assert.equal(unsupported.elements.get('offline-cache-toggle').disabled, true); assert.equal(unsupported.elements.get('offline-cache-toggle').disabled, true);
assert.match(unsupported.elements.get('offline-cache-status').textContent, /nicht verfügbar/); assert.match(unsupported.elements.get('offline-cache-status').textContent, /nicht für unterwegs speichern/);
const setup = await managerHarness(); const setup = await managerHarness();
await setup.manager.initialize(); await setup.manager.initialize();
setup.context.fetch = async () => Response.json({ resources: [] }); setup.context.fetch = async () => Response.json({ resources: [] });
await assert.rejects(setup.manager.download(), /Ungültige Offline/); await assert.rejects(setup.manager.download(), /nicht für unterwegs geladen/);
assert.equal(await setup.store.state(), null); assert.equal(await setup.store.state(), null);
setup.context.fetch = async () => new Response('error', { status: 500 }); setup.context.fetch = async () => new Response('error', { status: 500 });
await assert.rejects(setup.manager.download(), /Liste der Offline-Dateien/); await assert.rejects(setup.manager.download(), /nicht für unterwegs geladen/);
assert.equal(setup.elements.get('offline-cache-toggle').checked, false); assert.equal(setup.elements.get('offline-cache-toggle').checked, false);
}); });
@@ -300,7 +306,7 @@ for (const filesystem of [false, true]) {
const stage = await store.create(); const stage = await store.create();
await store.put(stage, `/api/routes/${id}`, Response.json({ id })); await store.put(stage, `/api/routes/${id}`, Response.json({ id }));
await store.put(stage, `/api/routes/${id}/gpx`, new Response(`gpx${id}`)); await store.put(stage, `/api/routes/${id}/gpx`, new Response(`gpx${id}`));
await store.commit(stage, { id, name: `Route ${id}`, pois: [], points: [] }); await store.commit(stage, { id, name: `Strecke ${id}`, pois: [], points: [] });
} }
assert.equal((await store.routes()).length, 2); assert.equal((await store.routes()).length, 2);
assert.deepEqual(await (await store.match('/api/routes/1?_=123')).json(), { id: 1 }); assert.deepEqual(await (await store.match('/api/routes/1?_=123')).json(), { id: 1 });
@@ -332,7 +338,7 @@ test('explicit route download stores only selected GPX/media and failed refresh
assert.equal(await (await store.match('/api/routes/7/gpx')).text(), 'bytes:' + origin + '/api/routes/7/gpx'); assert.equal(await (await store.match('/api/routes/7/gpx')).text(), 'bytes:' + origin + '/api/routes/7/gpx');
const previous = (await store.routes())[0].id; const previous = (await store.routes())[0].id;
context.fetch = async url => url === '/api/routes/7' ? Response.json(route) : new Response('full', { status: 507 }); context.fetch = async url => url === '/api/routes/7' ? Response.json(route) : new Response('full', { status: 507 });
await assert.rejects(manager.saveRoute(route), /Download fehlgeschlagen/); await assert.rejects(manager.saveRoute(route), /Teil konnte nicht gespeichert werden/);
assert.equal((await store.routes())[0].id, previous); assert.equal((await store.routes())[0].id, previous);
await manager.disable(); await manager.disable();
assert.equal((await store.routes()).length, 1); assert.equal((await store.routes()).length, 1);
@@ -360,6 +366,7 @@ test('API starts from selected offline routes and an empty list for app-only sto
const app = await store.create(); const app = await store.create();
await store.put(app, '/', new Response('shell')); await store.put(app, '/', new Response('shell'));
await store.commit(app); await store.commit(app);
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/api-client.js'), 'utf8'), context); vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/api-client.js'), 'utf8'), context);
const api = context.Wegwichtel.Api; const api = context.Wegwichtel.Api;
assert.equal((await api.health()).offline, true); assert.equal((await api.health()).offline, true);
@@ -388,15 +395,16 @@ test('storage gauge uses browser estimate, separates Local Storage and reports q
navigator: { storage: { estimate: async () => ({ usage: 95 * 1024 * 1024, quota: 100 * 1024 * 1024 }) } }, navigator: { storage: { estimate: async () => ({ usage: 95 * 1024 * 1024, quota: 100 * 1024 * 1024 }) } },
localStorage: { length: 1, key: () => 'image', getItem: () => 'x'.repeat(2400000) } localStorage: { length: 1, key: () => 'image', getItem: () => 'x'.repeat(2400000) }
}; };
await loadI18n(context);
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/storage-usage.js'), 'utf8'), context); vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/storage-usage.js'), 'utf8'), context);
const usage = context.Wegwichtel.StorageUsage; const usage = context.Wegwichtel.StorageUsage;
await usage.refresh(); await usage.refresh();
assert.equal(elements.get('[data-storage-site-meter]').value, 95 * 1024 * 1024); assert.equal(elements.get('[data-storage-site-meter]').value, 95 * 1024 * 1024);
assert.match(elements.get('[data-storage-site-text]').textContent, /95 %.*5 MiB verfügbar.*fast voll/); assert.match(elements.get('[data-storage-site-text]').textContent, /95 %.*5,2 MB frei.*Fast voll/);
assert.match(elements.get('[data-storage-local-text]').textContent, /Richtwert.*tatsächliche Grenze/); assert.match(elements.get('[data-storage-local-text]').textContent, /Richtwert.*wirklich hineinpasst/);
usage.reportError({ cause: { name: 'QuotaExceededError' } }); usage.reportError({ cause: { name: 'QuotaExceededError' } });
await usage.refresh(); await usage.refresh();
assert.match(elements.get('[data-storage-warning]').textContent, /Speicher voll/); assert.match(elements.get('[data-storage-warning]').textContent, /Platz ist voll/);
context.navigator.storage.estimate = async () => { throw new Error('Unavailable'); }; context.navigator.storage.estimate = async () => { throw new Error('Unavailable'); };
await usage.refresh(); await usage.refresh();
assert.equal(elements.get('[data-storage-site-meter]').hidden, true); assert.equal(elements.get('[data-storage-site-meter]').hidden, true);
+2 -1
View File
@@ -1,3 +1,4 @@
import { readAppFile } from '../test-support/i18n.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -6,7 +7,7 @@ import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..'); const root = path.resolve(import.meta.dirname, '..');
async function read(relativePath) { async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8'); return readAppFile(relativePath);
} }
test('presentation example uses real GPX Streckenpunkte and the existing upload API', async () => { test('presentation example uses real GPX Streckenpunkte and the existing upload API', async () => {
+2
View File
@@ -1,3 +1,4 @@
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -41,6 +42,7 @@ async function loadModule() {
clearTimeout clearTimeout
}; };
context.globalThis = context; context.globalThis = context;
await loadI18n(context);
vm.runInNewContext(source, context, { filename: path.join(root, 'public/js/privacy-storage.js') }); vm.runInNewContext(source, context, { filename: path.join(root, 'public/js/privacy-storage.js') });
return { storage: context.Wegwichtel.PrivacyStorage, localStorage }; return { storage: context.Wegwichtel.PrivacyStorage, localStorage };
} }
+6 -5
View File
@@ -1,3 +1,4 @@
import { readAppFile } from '../test-support/i18n.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -6,7 +7,7 @@ import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..'); const root = path.resolve(import.meta.dirname, '..');
async function read(relativePath) { async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8'); return readAppFile(relativePath);
} }
test('main screen contains a persistent route control next to the information control', async () => { test('main screen contains a persistent route control next to the information control', async () => {
@@ -24,7 +25,7 @@ test('main screen contains a persistent route control next to the information co
assert.match(html, /id="route-control-new"/); assert.match(html, /id="route-control-new"/);
assert.match(html, /id="info-button"/); assert.match(html, /id="info-button"/);
assert.match(html, /id="route-control-button"[\s\S]*?<img[^>]+route-management\.svg/); assert.match(html, /id="route-control-button"[\s\S]*?<img[^>]+route-management\.svg/);
assert.match(html, /id="editor-button"[^>]*>Strecken verwalten<\/button>/); assert.match(html, /id="editor-button"[^>]*>Strecken bearbeiten<\/button>/);
assert.match(icon, /C[^\"]+/); assert.match(icon, /C[^\"]+/);
assert.match(icon, /stroke-dasharray/); assert.match(icon, /stroke-dasharray/);
@@ -41,7 +42,7 @@ test('route selection separates activated server routes from local browser route
assert.match(html, /id="all-route-list"/); assert.match(html, /id="all-route-list"/);
assert.match(html, /id="local-route-list"/); assert.match(html, /id="local-route-list"/);
assert.match(html, />Lokale Strecken</); assert.match(html, />Meine Entwürfe</);
assert.match(client, /activatedOnly:\s*['"]true['"]/); assert.match(client, /activatedOnly:\s*['"]true['"]/);
assert.match(api, /publishedOnly:\s*req\.query\.activatedOnly\s*===\s*['"]true['"]/); assert.match(api, /publishedOnly:\s*req\.query\.activatedOnly\s*===\s*['"]true['"]/);
@@ -51,11 +52,11 @@ test('route selection separates activated server routes from local browser route
assert.match(app, /\[data-route-select\]/); assert.match(app, /\[data-route-select\]/);
}); });
test('persistent route control displays the next unvisited Streckenpunkt and preserves progress when switching', async () => { test('persistent route control displays the next unvisited Station and preserves progress when switching', async () => {
const app = await read('public/js/app.js'); const app = await read('public/js/app.js');
assert.match(app, /function nextPoiMetric\(/); assert.match(app, /function nextPoiMetric\(/);
assert.match(app, /Nächster Streckenpunkt:/); assert.match(app, /Nächste Station:/);
assert.match(app, /state\.triggered\.has\(poi\.id\)/); assert.match(app, /state\.triggered\.has\(poi\.id\)/);
assert.match(app, /function suspendCurrentRoute\(/); assert.match(app, /function suspendCurrentRoute\(/);
assert.match(app, /persistRouteProgress\(\)/); assert.match(app, /persistRouteProgress\(\)/);
+2 -1
View File
@@ -1,3 +1,4 @@
import { readAppFile } from '../test-support/i18n.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -6,7 +7,7 @@ import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..'); const root = path.resolve(import.meta.dirname, '..');
async function read(relativePath) { async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8'); return readAppFile(relativePath);
} }
test('default server socket uses the local Wegwichtel endpoint', async () => { test('default server socket uses the local Wegwichtel endpoint', async () => {
+49
View File
@@ -0,0 +1,49 @@
import { localizedHtml } from '../test-support/i18n.js';
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import vm from 'node:vm';
import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..');
async function messages() {
const context = {};
const filename = path.join(root, 'public/js/user-messages.js');
await loadI18n(context);
vm.runInNewContext(await fs.readFile(filename, 'utf8'), context, { filename });
return context.Wegwichtel.UserMessages;
}
test('visible errors explain file, permission and storage problems without exposing technical replies', async () => {
const ui = await messages();
assert.match(ui.error({ message: 'Die GPX-Datei ist kein gültiges XML.' }), /keinen lesbaren Weg/);
assert.match(ui.error({ message: JSON.stringify({ message: 'POI nicht gefunden.', details: 'internal' }) }), /Diese Station wurde nicht gefunden/);
assert.match(ui.error({ responseJSON: { message: 'Freigegebene Strecken dürfen nur durch einen direkten lokalen Serveraufruf gelöscht werden.' } }), /Person löschen/);
assert.match(ui.error({ responseJSON: { message: 'Freigegebene Strecken können nicht mehr verändert werden.' } }), /nicht mehr verändern/);
assert.match(ui.error({ message: 'Nicht unterstütztes Bildformat: image/unknown' }), /anderes Bild/);
assert.match(ui.error({ message: 'Nicht unterstütztes Audioformat: audio/unknown' }), /andere Aufnahme/);
assert.match(ui.error({ message: 'POI-Koordinaten sind ungültig.' }), /Ort dieser Station/);
assert.match(ui.error({ status: 413 }), /zu groß/);
for (const status of [401, 403]) assert.match(ui.error({ status }), /Du darfst/);
assert.match(ui.error({ status: 500 }), /später/);
assert.match(ui.error({ name: 'QuotaExceededError' }), /Platz ist voll/);
assert.match(ui.error({ cause: { name: 'QuotaExceededError' } }), /Platz ist voll/);
assert.equal(ui.error(new Error('Gib deiner Station einen Namen.')), 'Gib deiner Station einen Namen.');
for (const error of [new Error('Failed to fetch'), { message: 'sequence muss eine Ganzzahl sein.' }, { message: 'HTTP 500 /internal/path' }, {}, 'undefined', '{"details":"raw"}']) {
assert.equal(ui.error(error, 'Bitte noch einmal versuchen.'), 'Bitte noch einmal versuchen.');
}
});
test('public help and spoken labels omit implementation terms and preserve meaningful storage guidance', async () => {
const html = await localizedHtml(await fs.readFile(path.join(root, 'public/index.html'), 'utf8'));
const text = html.replace(/<[^>]*>/g, ' ');
const labels = [...html.matchAll(/(?:aria-label|title|placeholder)="([^"]+)"/g)].map(match => match[1]).join(' ');
assert.doesNotMatch(text + labels, /\b(?:GPX|JSON|Cache|API|OPFS|IndexedDB|Local Storage|Server|Token|HTTP|JavaScripts|jQuery)\b/i);
assert.match(text, /Für unterwegs speichern/);
assert.match(text, /Weg als Datei herunterladen/);
assert.match(text, /Bilder und Tonaufnahmen sind in dieser Datei nicht enthalten/);
assert.match(text, /Fortschritt.*gelöscht/s);
assert.match(text, /Meine Entwürfe/);
});
+2 -1
View File
@@ -1,3 +1,4 @@
import { readAppFile } from '../test-support/i18n.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -6,7 +7,7 @@ import test from 'node:test';
const root = path.resolve(import.meta.dirname, '..'); const root = path.resolve(import.meta.dirname, '..');
async function read(relativePath) { async function read(relativePath) {
return fs.readFile(path.join(root, relativePath), 'utf8'); return readAppFile(relativePath);
} }
test('current local jQuery and jQuery UI distributions are present', async () => { test('current local jQuery and jQuery UI distributions are present', async () => {
+7
View File
@@ -1,3 +1,4 @@
import { loadI18n } from '../test-support/i18n.js';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
@@ -17,6 +18,8 @@ test('legacy vibration patterns are exposed as immutable constants', async () =>
Wegwichtel: {} Wegwichtel: {}
}; };
await loadI18n(context);
vm.runInNewContext(await source(), context); vm.runInNewContext(await source(), context);
const vibration = context.Wegwichtel.Vibration; const vibration = context.Wegwichtel.Vibration;
@@ -37,6 +40,8 @@ test('vibration wrapper starts named patterns and can cancel them', async () =>
Wegwichtel: {} Wegwichtel: {}
}; };
await loadI18n(context);
vm.runInNewContext(await source(), context); vm.runInNewContext(await source(), context);
const vibration = context.Wegwichtel.Vibration; const vibration = context.Wegwichtel.Vibration;
@@ -54,6 +59,8 @@ test('vibration wrapper fails harmlessly when the browser API is unavailable', a
Wegwichtel: {} Wegwichtel: {}
}; };
await loadI18n(context);
vm.runInNewContext(await source(), context); vm.runInNewContext(await source(), context);
const vibration = context.Wegwichtel.Vibration; const vibration = context.Wegwichtel.Vibration;