add offline support
This commit is contained in:
Vendored
+73
@@ -358,3 +358,76 @@
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.offline-option-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-option-row > .switch-row {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-help-button {
|
||||||
|
flex: none;
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
border: 1px solid #315d3a;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
color: #315d3a;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-help-button:focus-visible {
|
||||||
|
outline: 3px solid #315d3a;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-help {
|
||||||
|
padding: .85rem;
|
||||||
|
border: 1px solid var(--bootstrap-border);
|
||||||
|
border-radius: .65rem;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--bootstrap-muted);
|
||||||
|
font-size: .92rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-help h2 {
|
||||||
|
margin: 0 0 .5rem;
|
||||||
|
color: #315d3a;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-help ul {
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-help p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.storage-usage {
|
||||||
|
margin: .75rem 0;
|
||||||
|
padding: .85rem;
|
||||||
|
border: 1px solid #c8d3ca;
|
||||||
|
border-radius: .65rem;
|
||||||
|
background: #f9fbf6;
|
||||||
|
color: #315d3a;
|
||||||
|
font-size: .9rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.storage-usage h3 { margin: 0 0 .5rem; font-size: 1rem; }
|
||||||
|
.storage-usage meter { width: 100%; height: 1.25rem; }
|
||||||
|
.storage-usage p { margin: .4rem 0; }
|
||||||
|
.storage-usage [data-storage-warning] { color: #861d1d; font-weight: 700; }
|
||||||
|
.route-offline-controls { margin: .75rem 0; }
|
||||||
|
.route-offline-controls > a { display: inline-block; padding: .75rem; }
|
||||||
|
.route-offline-controls > a[hidden] { display: none; }
|
||||||
|
|||||||
@@ -111,7 +111,38 @@
|
|||||||
<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>
|
||||||
|
<div class="offline-option-row">
|
||||||
|
<label class="switch-row">
|
||||||
|
<span>
|
||||||
|
<strong>App offline speichern</strong>
|
||||||
|
<small>Speichert nur die Dateien zur Anzeige der App. Strecken werden separat ausgewählt und gespeichert.</small>
|
||||||
|
</span>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<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>Ö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>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>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>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>
|
||||||
|
</section>
|
||||||
|
<p id="offline-cache-status" role="status" aria-live="polite"></p>
|
||||||
|
<button id="offline-cache-refresh" type="button" hidden>App-Dateien aktualisieren</button>
|
||||||
</div>
|
</div>
|
||||||
|
<section class="storage-usage" data-storage-usage aria-label="Lokaler Speicher">
|
||||||
|
<h3>Lokaler Speicher</h3>
|
||||||
|
<meter data-storage-site-meter min="0" max="1" value="0" aria-label="Belegung des Website-Speichers" hidden></meter>
|
||||||
|
<p data-storage-site-text>Speicherbelegung wird ermittelt …</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>
|
||||||
|
<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-storage-warning role="status" hidden></p>
|
||||||
|
<button data-storage-refresh type="button">Speicheranzeige aktualisieren</button>
|
||||||
|
</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>
|
||||||
@@ -198,6 +229,13 @@
|
|||||||
</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>
|
||||||
|
<button id="route-offline-save" type="button">Strecke offline speichern</button>
|
||||||
|
<button id="route-offline-remove" type="button" hidden>Offline-Strecke entfernen</button>
|
||||||
|
<a id="route-gpx-download" download hidden>GPX herunterladen</a>
|
||||||
|
<p id="route-offline-status" role="status" aria-live="polite"></p>
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
<dl>
|
<dl>
|
||||||
<div><dt>Länge</dt><dd id="route-distance">–</dd></div>
|
<div><dt>Länge</dt><dd id="route-distance">–</dd></div>
|
||||||
<div><dt>Höhenmeter</dt><dd id="route-elevation">–</dd></div>
|
<div><dt>Höhenmeter</dt><dd id="route-elevation">–</dd></div>
|
||||||
@@ -369,6 +407,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<h3>Local Storage</h3>
|
<h3>Local Storage</h3>
|
||||||
<p>Der Browser kann auf Wunsch Streckenfortschritte lokal speichern. Verwendet werden die Schlüssel <code>wegwichtel.privacy.v1</code> für die gewählte Einstellung und <code>wegwichtel.route-progress.v1</code> für die freiwilligen Streckendaten. Die Streckendaten werden nach 24 Stunden bereinigt oder beim Ausschalten sofort entfernt.</p>
|
<p>Der Browser kann auf Wunsch Streckenfortschritte lokal speichern. Verwendet werden die Schlüssel <code>wegwichtel.privacy.v1</code> für die gewählte Einstellung und <code>wegwichtel.route-progress.v1</code> für die freiwilligen Streckendaten. Die Streckendaten werden nach 24 Stunden bereinigt oder beim Ausschalten sofort entfernt.</p>
|
||||||
|
<p>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>
|
<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>
|
||||||
@@ -415,6 +454,16 @@
|
|||||||
<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">
|
||||||
|
<h3>Lokaler Speicher</h3>
|
||||||
|
<meter data-storage-site-meter min="0" max="1" value="0" aria-label="Belegung des Website-Speichers" hidden></meter>
|
||||||
|
<p data-storage-site-text>Speicherbelegung wird ermittelt …</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>
|
||||||
|
<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-storage-warning role="status" hidden></p>
|
||||||
|
<button data-storage-refresh type="button">Speicheranzeige aktualisieren</button>
|
||||||
|
</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">Veröffentlichte Serverstrecken und ausschließlich in diesem Browser gespeicherte Entwürfe werden getrennt dargestellt.</p>
|
||||||
<ul id="editor-route-list" class="editor-route-list" aria-live="polite"></ul>
|
<ul id="editor-route-list" class="editor-route-list" aria-live="polite"></ul>
|
||||||
</section>
|
</section>
|
||||||
@@ -505,6 +554,9 @@
|
|||||||
<script src="js/editor-ui.js?v=0.12.14"></script>
|
<script src="js/editor-ui.js?v=0.12.14"></script>
|
||||||
<script src="js/privacy-storage.js?v=0.12.14"></script>
|
<script src="js/privacy-storage.js?v=0.12.14"></script>
|
||||||
<script src="js/legal-ui.js?v=0.12.14"></script>
|
<script src="js/legal-ui.js?v=0.12.14"></script>
|
||||||
|
<script src="js/offline-storage.js?v=0.12.14"></script>
|
||||||
|
<script src="js/storage-usage.js?v=0.12.14"></script>
|
||||||
|
<script src="js/offline-cache.js?v=0.12.14"></script>
|
||||||
<script src="js/bootstrap-loader.js?v=0.12.14"></script>
|
<script src="js/bootstrap-loader.js?v=0.12.14"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+27
-2
@@ -1,8 +1,33 @@
|
|||||||
(function (ns, $) {
|
(function (ns, $) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
function request(path) {
|
async function offlineResponse(path) {
|
||||||
return $.ajax({ url: ns.Config.apiBase + path, method: 'GET', dataType: 'json', cache: false });
|
if (!ns.OfflineStore) return null;
|
||||||
|
if (path === '/health') {
|
||||||
|
const available = await ns.OfflineStore.state() || (await ns.OfflineStore.routes()).length;
|
||||||
|
return available ? { ok: true, offline: true } : null;
|
||||||
|
}
|
||||||
|
if (path.startsWith('/routes?')) {
|
||||||
|
if (!await ns.OfflineStore.state() && !(await ns.OfflineStore.routes()).length) return null;
|
||||||
|
return { routes: (await ns.OfflineStore.routes()).map(item => item.route), offline: true };
|
||||||
|
}
|
||||||
|
const response = await ns.OfflineStore.match(ns.Config.apiBase + path);
|
||||||
|
return response ? response.json() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path) {
|
||||||
|
if (globalThis.navigator?.onLine === false) {
|
||||||
|
const response = await offlineResponse(path);
|
||||||
|
if (response) return response;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await $.ajax({ url: ns.Config.apiBase + path, method: 'GET', dataType: 'json', cache: false, timeout: 10000 });
|
||||||
|
} catch (error) {
|
||||||
|
if (error.status > 0) throw error;
|
||||||
|
const response = await offlineResponse(path);
|
||||||
|
if (response) return response;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function routeQuery(position) {
|
function routeQuery(position) {
|
||||||
|
|||||||
@@ -375,6 +375,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderRouteView(route) {
|
function renderRouteView(route) {
|
||||||
|
ns.OfflineCache?.renderRoute?.(route)?.catch?.(() => {});
|
||||||
$('#route-title').text(route.name);
|
$('#route-title').text(route.name);
|
||||||
$('#route-description').text(route.description || '');
|
$('#route-description').text(route.description || '');
|
||||||
$('#route-distance').text(ns.Distance.format(route.distanceM));
|
$('#route-distance').text(ns.Distance.format(route.distanceM));
|
||||||
|
|||||||
Vendored
+3
-1
@@ -144,8 +144,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openApplication(event) {
|
function openApplication(event) {
|
||||||
if (!ready || !isActivation(event)) return;
|
if (!ready || globalThis.Wegwichtel.OfflineCache?.busy || !isActivation(event)) return;
|
||||||
if (event.target !== enter && event.target.closest('button, input, label, a, dialog')) return;
|
if (event.target !== enter && event.target.closest('button, input, label, a, dialog')) return;
|
||||||
|
if (event.target.closest('.offline-help, .storage-usage')) return;
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation?.();
|
event.stopPropagation?.();
|
||||||
@@ -192,6 +193,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await globalThis.Wegwichtel.OfflineCache?.initialize();
|
||||||
setStatusText(stepLabels.storage, true);
|
setStatusText(stepLabels.storage, true);
|
||||||
await cleanupOldProgress();
|
await cleanupOldProgress();
|
||||||
mark('storage', 'done');
|
mark('storage', 'done');
|
||||||
|
|||||||
@@ -1384,7 +1384,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
async function guarded(action) {
|
async function guarded(action) {
|
||||||
try {
|
try {
|
||||||
await action();
|
await action();
|
||||||
|
await ns.StorageUsage?.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
ns.StorageUsage?.reportError(error);
|
||||||
setStatus(error.message || 'Die Aktion konnte nicht ausgeführt werden.', true);
|
setStatus(error.message || 'Die Aktion konnte nicht ausgeführt werden.', true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -467,6 +467,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|||||||
} catch {
|
} catch {
|
||||||
// Die ursprüngliche Speicherfehlermeldung ist aussagekräftiger.
|
// Die ursprüngliche Speicherfehlermeldung ist aussagekräftiger.
|
||||||
}
|
}
|
||||||
|
ns.StorageUsage?.reportError(error);
|
||||||
const name = error?.name === 'QuotaExceededError'
|
const name = error?.name === 'QuotaExceededError'
|
||||||
? 'Der lokale Speicher für Streckendaten ist voll.'
|
? 'Der lokale Speicher für Streckendaten ist voll.'
|
||||||
: 'Die lokale Strecke konnte nicht gespeichert werden.';
|
: 'Die lokale Strecke konnte nicht gespeichert werden.';
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
globalThis.Wegwichtel ??= {};
|
||||||
|
(function (ns) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const toggle = document.getElementById('offline-cache-toggle');
|
||||||
|
const status = document.getElementById('offline-cache-status');
|
||||||
|
const refresh = document.getElementById('offline-cache-refresh');
|
||||||
|
const enter = document.getElementById('bootstrap-enter');
|
||||||
|
let controller = null;
|
||||||
|
let task = null;
|
||||||
|
let initialized = null;
|
||||||
|
let selectedRoute = null;
|
||||||
|
let routeTask = null;
|
||||||
|
|
||||||
|
const supported = () => Boolean(globalThis.isSecureContext && navigator.serviceWorker && globalThis.caches);
|
||||||
|
|
||||||
|
function announce(message) {
|
||||||
|
if (status) status.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateControls() {
|
||||||
|
const saved = supported() ? await ns.OfflineStore.state() : null;
|
||||||
|
if (toggle) {
|
||||||
|
toggle.checked = Boolean(saved);
|
||||||
|
toggle.setAttribute('aria-checked', String(Boolean(saved)));
|
||||||
|
}
|
||||||
|
if (refresh) {
|
||||||
|
refresh.hidden = !saved;
|
||||||
|
refresh.disabled = Boolean(task);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
const existing = await navigator.serviceWorker.getRegistration?.('/');
|
||||||
|
if (!existing?.active || new URL(existing.active.scriptURL).pathname !== '/offline-worker.js') {
|
||||||
|
await navigator.serviceWorker.register('/offline-worker.js', { scope: '/', updateViaCache: 'none' });
|
||||||
|
} else if (navigator.onLine !== false) {
|
||||||
|
// Check imported storage code for updates without delaying an offline restart.
|
||||||
|
void existing.update?.().catch(() => {});
|
||||||
|
}
|
||||||
|
await navigator.serviceWorker.ready;
|
||||||
|
if (navigator.serviceWorker.controller) return;
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
navigator.serviceWorker.removeEventListener('controllerchange', changed);
|
||||||
|
reject(new Error('Offline-Speicher konnte nicht aktiviert werden. Bitte erneut versuchen.'));
|
||||||
|
}, 10000);
|
||||||
|
function changed() {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
navigator.serviceWorker.removeEventListener('controllerchange', changed);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
navigator.serviceWorker.addEventListener('controllerchange', changed);
|
||||||
|
if (navigator.serviceWorker.controller) changed();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function download() {
|
||||||
|
if (task) return task;
|
||||||
|
controller = new AbortController();
|
||||||
|
const signal = controller.signal;
|
||||||
|
task = (async () => {
|
||||||
|
let stage = null;
|
||||||
|
try {
|
||||||
|
await worker();
|
||||||
|
announce('Offline-Dateien werden vorbereitet …');
|
||||||
|
// Persistent storage is best-effort; a denial must not block downloads.
|
||||||
|
await navigator.storage?.persist?.().catch(() => false);
|
||||||
|
const options = { cache: 'no-store', headers: { 'X-Wegwichtel-Offline-Download': 'true' }, signal };
|
||||||
|
const manifestResponse = await fetch('/api/offline-manifest', options);
|
||||||
|
if (!manifestResponse.ok) throw new Error('Die Liste der Offline-Dateien konnte nicht geladen werden.');
|
||||||
|
const manifest = await manifestResponse.json();
|
||||||
|
if (!Array.isArray(manifest.resources) || !manifest.resources.length) throw new TypeError('Ungültige Offline-Dateiliste.');
|
||||||
|
stage = await ns.OfflineStore.create();
|
||||||
|
const urls = [...new Set(manifest.resources.map(url => ns.OfflineStore.key(url)))];
|
||||||
|
for (const [index, url] of urls.entries()) {
|
||||||
|
signal.throwIfAborted();
|
||||||
|
announce(`Offline-Dateien speichern: ${index + 1} von ${urls.length}`);
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
await ns.OfflineStore.put(stage, url, response);
|
||||||
|
}
|
||||||
|
signal.throwIfAborted();
|
||||||
|
const saved = await ns.OfflineStore.commit(stage);
|
||||||
|
announce(`${saved.count} Dateien sind offline verfügbar.`);
|
||||||
|
return saved;
|
||||||
|
} catch (error) {
|
||||||
|
if (stage) await ns.OfflineStore.remove(stage.id);
|
||||||
|
ns.StorageUsage?.reportError(error);
|
||||||
|
const saved = await ns.OfflineStore.state();
|
||||||
|
if (error.name === 'AbortError') announce('Download abgebrochen.');
|
||||||
|
else announce(`${error.message} ${saved ? 'Der bisherige Offline-Stand bleibt erhalten.' : 'Die App ist noch nicht vollständig offline verfügbar.'}`);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
task = null;
|
||||||
|
controller = null;
|
||||||
|
if (enter) enter.disabled = false;
|
||||||
|
await updateControls();
|
||||||
|
await ns.StorageUsage?.refresh();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (enter) enter.disabled = true;
|
||||||
|
if (refresh) refresh.disabled = true;
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disable() {
|
||||||
|
controller?.abort();
|
||||||
|
if (task) await task.catch(() => {});
|
||||||
|
await ns.OfflineStore.clear();
|
||||||
|
await updateControls();
|
||||||
|
announce('App-Dateien wurden entfernt. Gespeicherte Strecken bleiben erhalten.');
|
||||||
|
await ns.StorageUsage?.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderRoute(route) {
|
||||||
|
selectedRoute = route;
|
||||||
|
const section = document.getElementById('route-offline-controls');
|
||||||
|
if (!section) return;
|
||||||
|
section.hidden = route?.source === 'local' || !route;
|
||||||
|
if (section.hidden) return;
|
||||||
|
const saved = supported() ? (await ns.OfflineStore.routes()).find(item => String(item.route.id) === String(route.id)) : null;
|
||||||
|
if (selectedRoute !== route) return;
|
||||||
|
const save = document.getElementById('route-offline-save');
|
||||||
|
const remove = document.getElementById('route-offline-remove');
|
||||||
|
const message = document.getElementById('route-offline-status');
|
||||||
|
if (save) {
|
||||||
|
save.disabled = !supported() || Boolean(routeTask);
|
||||||
|
save.textContent = saved ? 'Offline-Strecke aktualisieren' : 'Strecke offline speichern';
|
||||||
|
}
|
||||||
|
if (remove) { remove.hidden = !saved; remove.disabled = Boolean(routeTask); }
|
||||||
|
if (message && !routeTask) message.textContent = saved
|
||||||
|
? `${saved.count} Dateien dieser Strecke sind auf dem Gerät gespeichert.`
|
||||||
|
: 'Speichert nur diese Strecke mit GPX, Bildern und Audio für die App.';
|
||||||
|
const gpx = document.getElementById('route-gpx-download');
|
||||||
|
if (gpx) {
|
||||||
|
gpx.hidden = !route.gpxUrl;
|
||||||
|
if (route.gpxUrl) {
|
||||||
|
gpx.href = route.gpxUrl;
|
||||||
|
gpx.download = `strecke-${route.id}.gpx`;
|
||||||
|
} else gpx.removeAttribute('href');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRoute(route) {
|
||||||
|
if (routeTask) throw new Error('Eine Strecke wird bereits gespeichert.');
|
||||||
|
if (!supported()) throw new Error('Offline-Speicherung benötigt HTTPS und Browser-Unterstützung.');
|
||||||
|
if (!route || route.source === 'local') throw new Error('Bitte eine veröffentlichte Strecke auswählen.');
|
||||||
|
routeTask = (async () => {
|
||||||
|
let stage = null;
|
||||||
|
const message = document.getElementById('route-offline-status');
|
||||||
|
try {
|
||||||
|
await worker();
|
||||||
|
await navigator.storage?.persist?.().catch(() => false);
|
||||||
|
const url = `/api/routes/${encodeURIComponent(route.id)}`;
|
||||||
|
const options = { cache: 'no-store', headers: { 'X-Wegwichtel-Offline-Download': 'true' } };
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
if (!response.ok) throw new Error('Die Strecke konnte nicht geladen werden.');
|
||||||
|
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 (!fresh.gpxUrl) throw new Error('Für diese Strecke ist keine GPX-Datei vorhanden.');
|
||||||
|
const resources = [fresh.gpxUrl];
|
||||||
|
for (const poi of fresh.pois || []) {
|
||||||
|
if (poi.audioUrl) resources.push(poi.audioUrl);
|
||||||
|
for (const picture of poi.images || []) resources.push(picture.url);
|
||||||
|
}
|
||||||
|
const urls = [...new Set(resources.map(value => ns.OfflineStore.key(value)))];
|
||||||
|
stage = await ns.OfflineStore.create();
|
||||||
|
await ns.OfflineStore.put(stage, url, response);
|
||||||
|
for (const [index, resource] of urls.entries()) {
|
||||||
|
if (message && selectedRoute === route) message.textContent = `Strecke speichern: ${index + 1} von ${urls.length}`;
|
||||||
|
await ns.OfflineStore.put(stage, resource, await fetch(resource, options));
|
||||||
|
await ns.StorageUsage?.refresh();
|
||||||
|
}
|
||||||
|
return await ns.OfflineStore.commit(stage, fresh);
|
||||||
|
} catch (error) {
|
||||||
|
if (stage) await ns.OfflineStore.remove(stage.id);
|
||||||
|
ns.StorageUsage?.reportError(error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
routeTask = null;
|
||||||
|
await renderRoute(selectedRoute);
|
||||||
|
await ns.StorageUsage?.refresh();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const pending = routeTask;
|
||||||
|
await renderRoute(selectedRoute);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRoute(route) {
|
||||||
|
if (routeTask) throw new Error('Bitte den laufenden Download abwarten.');
|
||||||
|
await ns.OfflineStore.clearRoute(route.id);
|
||||||
|
await renderRoute(selectedRoute);
|
||||||
|
await ns.StorageUsage?.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindRouteControls() {
|
||||||
|
const run = action => {
|
||||||
|
const route = selectedRoute;
|
||||||
|
if (!route) return;
|
||||||
|
void action(route).catch(error => {
|
||||||
|
if (selectedRoute !== route) return;
|
||||||
|
const message = document.getElementById('route-offline-status');
|
||||||
|
if (message) message.textContent = `${error.message} Ein bisheriger Offline-Stand bleibt erhalten.`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
document.getElementById('route-offline-save')?.addEventListener('click', () => run(saveRoute));
|
||||||
|
document.getElementById('route-offline-remove')?.addEventListener('click', () => run(removeRoute));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
bindRouteControls();
|
||||||
|
ns.StorageUsage?.initialize();
|
||||||
|
const helpButton = document.getElementById('offline-cache-help-button');
|
||||||
|
const help = document.getElementById('offline-cache-help');
|
||||||
|
if (helpButton && help) {
|
||||||
|
helpButton.addEventListener('click', () => {
|
||||||
|
help.hidden = !help.hidden;
|
||||||
|
helpButton.setAttribute('aria-expanded', String(!help.hidden));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!supported()) {
|
||||||
|
if (toggle) toggle.disabled = true;
|
||||||
|
announce('Offline-Speicherung ist in diesem Browser oder ohne HTTPS nicht verfügbar.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Remove old v1 snapshots that also included all routes automatically.
|
||||||
|
const old = await ns.OfflineStore.state();
|
||||||
|
if (old && !old.appOnly) await ns.OfflineStore.clear();
|
||||||
|
const saved = await updateControls();
|
||||||
|
if ((await ns.OfflineStore.routes()).length) await worker();
|
||||||
|
if (saved) {
|
||||||
|
await worker();
|
||||||
|
announce(`${saved.count} Dateien sind offline verfügbar.`);
|
||||||
|
} else {
|
||||||
|
announce('Speichert nur die App-Dateien auf diesem Gerät. Strecken kannst du einzeln speichern.');
|
||||||
|
}
|
||||||
|
toggle?.addEventListener('change', () => {
|
||||||
|
toggle.setAttribute('aria-checked', String(toggle.checked));
|
||||||
|
void (toggle.checked ? download() : disable()).catch(() => {});
|
||||||
|
});
|
||||||
|
refresh?.addEventListener('click', () => { void download().catch(() => {}); });
|
||||||
|
}
|
||||||
|
|
||||||
|
ns.OfflineCache = Object.freeze({
|
||||||
|
initialize() {
|
||||||
|
initialized ??= setup().catch(error => { announce(error.message); });
|
||||||
|
return initialized;
|
||||||
|
},
|
||||||
|
download,
|
||||||
|
disable,
|
||||||
|
renderRoute,
|
||||||
|
saveRoute,
|
||||||
|
removeRoute,
|
||||||
|
get busy() { return Boolean(task); }
|
||||||
|
});
|
||||||
|
}(globalThis.Wegwichtel));
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
globalThis.Wegwichtel ??= {};
|
||||||
|
(function (ns) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const CONTROL = 'wegwichtel-offline-control-v1';
|
||||||
|
const PREFIX = 'wegwichtel-offline-data-';
|
||||||
|
const STATE_URL = '/__wegwichtel_offline_state__';
|
||||||
|
const ROUTES_URL = '/__wegwichtel_offline_routes__';
|
||||||
|
const DIRECTORY = 'wegwichtel-offline-v1';
|
||||||
|
|
||||||
|
function key(value) {
|
||||||
|
const url = new URL(value, globalThis.location.origin);
|
||||||
|
if (url.origin !== globalThis.location.origin) throw new TypeError('Nur Ressourcen dieser App sind zulässig.');
|
||||||
|
url.searchParams.delete('v');
|
||||||
|
if (url.pathname.startsWith('/api/')) url.searchParams.delete('_');
|
||||||
|
url.hash = '';
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function state() {
|
||||||
|
const cache = await caches.open(CONTROL);
|
||||||
|
const response = await cache.match(STATE_URL);
|
||||||
|
return response ? response.json() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function routes() {
|
||||||
|
const response = await (await caches.open(CONTROL)).match(ROUTES_URL);
|
||||||
|
return response ? response.json() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeRoutes(saved) {
|
||||||
|
await (await caches.open(CONTROL)).put(ROUTES_URL, new Response(JSON.stringify(saved), { headers: { 'Content-Type': 'application/json' } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function folder(id, create = false) {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
const directory = await root.getDirectoryHandle(DIRECTORY, { create });
|
||||||
|
return directory.getDirectoryHandle(id, { create });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeFile(directory, name, contents) {
|
||||||
|
const handle = await directory.getFileHandle(name, { create: true });
|
||||||
|
const stream = await handle.createWritable();
|
||||||
|
try {
|
||||||
|
await stream.write(contents);
|
||||||
|
await stream.close();
|
||||||
|
} catch (error) {
|
||||||
|
await stream.abort().catch(() => {});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
const id = `${PREFIX}${crypto.randomUUID()}`;
|
||||||
|
let directory = null;
|
||||||
|
if (typeof navigator.storage?.getDirectory === 'function') {
|
||||||
|
try { directory = await folder(id, true); } catch { /* Use CacheStorage when OPFS is unavailable. */ }
|
||||||
|
}
|
||||||
|
return { id, mode: directory ? 'filesystem' : 'cache', directory, resources: {}, count: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function put(stage, url, response) {
|
||||||
|
if (!response.ok || response.type === 'opaque') throw new Error(`Download fehlgeschlagen: ${url}`);
|
||||||
|
const normalized = key(url);
|
||||||
|
if (stage.mode === 'cache') {
|
||||||
|
const cache = await caches.open(stage.id);
|
||||||
|
await cache.put(normalized, response.clone());
|
||||||
|
} else {
|
||||||
|
const filename = `${stage.count}.bin`;
|
||||||
|
const headers = [...response.headers].filter(([name]) => !['content-encoding', 'content-length', 'transfer-encoding'].includes(name));
|
||||||
|
await writeFile(stage.directory, filename, await response.blob());
|
||||||
|
stage.resources[normalized] = { filename, headers, status: response.status, statusText: response.statusText };
|
||||||
|
}
|
||||||
|
stage.count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id) {
|
||||||
|
if (!id?.startsWith(PREFIX)) return;
|
||||||
|
await caches.delete(id);
|
||||||
|
if (typeof navigator.storage?.getDirectory === 'function') {
|
||||||
|
try {
|
||||||
|
const root = await navigator.storage.getDirectory();
|
||||||
|
const directory = await root.getDirectoryHandle(DIRECTORY);
|
||||||
|
await directory.removeEntry(id, { recursive: true });
|
||||||
|
} catch { /* Missing directory or disabled OPFS needs no cleanup. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commit(stage, route = null) {
|
||||||
|
if (stage.mode === 'filesystem') {
|
||||||
|
await writeFile(stage.directory, 'index.json', JSON.stringify(stage.resources));
|
||||||
|
}
|
||||||
|
const savedRoutes = route ? await routes() : [];
|
||||||
|
const previous = route ? savedRoutes.find(item => String(item.route.id) === String(route.id)) : await state();
|
||||||
|
const current = { id: stage.id, mode: stage.mode, count: stage.count, savedAt: new Date().toISOString(), appOnly: !route };
|
||||||
|
if (route) {
|
||||||
|
const summary = { ...route };
|
||||||
|
delete summary.pois;
|
||||||
|
delete summary.points;
|
||||||
|
current.route = summary;
|
||||||
|
await writeRoutes([...savedRoutes.filter(item => String(item.route.id) !== String(route.id)), current]);
|
||||||
|
} else {
|
||||||
|
const cache = await caches.open(CONTROL);
|
||||||
|
await cache.put(STATE_URL, new Response(JSON.stringify(current), { headers: { 'Content-Type': 'application/json' } }));
|
||||||
|
}
|
||||||
|
if (previous?.id !== current.id) await remove(previous?.id).catch(() => {});
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function matchSnapshot(current, normalized) {
|
||||||
|
if (!current) return null;
|
||||||
|
if (current.mode === 'cache') return (await caches.open(current.id)).match(normalized);
|
||||||
|
try {
|
||||||
|
const directory = await folder(current.id);
|
||||||
|
const index = JSON.parse(await (await (await directory.getFileHandle('index.json')).getFile()).text());
|
||||||
|
const entry = index[normalized];
|
||||||
|
if (!entry) return null;
|
||||||
|
const file = await (await directory.getFileHandle(entry.filename)).getFile();
|
||||||
|
return new Response(file, { status: entry.status, statusText: entry.statusText, headers: entry.headers });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function match(url) {
|
||||||
|
const normalized = key(url);
|
||||||
|
const snapshots = [await state(), ...await routes()];
|
||||||
|
for (const snapshot of snapshots) {
|
||||||
|
const response = await matchSnapshot(snapshot, normalized);
|
||||||
|
if (response) return response;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearRoute(routeId) {
|
||||||
|
const saved = await routes();
|
||||||
|
const previous = saved.find(item => String(item.route.id) === String(routeId));
|
||||||
|
await writeRoutes(saved.filter(item => String(item.route.id) !== String(routeId)));
|
||||||
|
await remove(previous?.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clear() {
|
||||||
|
// Disable serving first; remove only our offline resources, never route drafts/media.
|
||||||
|
const cache = await caches.open(CONTROL);
|
||||||
|
const previous = await state();
|
||||||
|
await cache.delete(STATE_URL);
|
||||||
|
await remove(previous?.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
ns.OfflineStore = Object.freeze({ key, state, routes, create, put, commit, match, remove, clear, clearRoute });
|
||||||
|
}(globalThis.Wegwichtel));
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
globalThis.Wegwichtel ??= {};
|
||||||
|
(function (ns) {
|
||||||
|
'use strict';
|
||||||
|
const LOCAL_REFERENCE = 5 * 1024 * 1024;
|
||||||
|
let initialized = false;
|
||||||
|
let lastError = '';
|
||||||
|
let refreshTask = null;
|
||||||
|
|
||||||
|
function format(bytes) {
|
||||||
|
return `${(bytes / 1024 / 1024).toLocaleString('de-DE', { maximumFractionDigits: 1 })} MiB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localBytes() {
|
||||||
|
const storage = globalThis.localStorage;
|
||||||
|
if (!storage) return null;
|
||||||
|
let bytes = 0;
|
||||||
|
for (let index = 0; index < storage.length; index += 1) {
|
||||||
|
const key = storage.key(index);
|
||||||
|
if (key != null) bytes += 2 * (key.length + (storage.getItem(key) || '').length);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(selector, usage, quota, text) {
|
||||||
|
for (const panel of document.querySelectorAll('[data-storage-usage]')) {
|
||||||
|
const meter = panel.querySelector(`[data-storage-${selector}-meter]`);
|
||||||
|
const label = panel.querySelector(`[data-storage-${selector}-text]`);
|
||||||
|
if (meter) {
|
||||||
|
meter.hidden = !quota;
|
||||||
|
if (quota) { meter.max = quota; meter.value = Math.min(usage, quota); }
|
||||||
|
}
|
||||||
|
if (label) label.textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (refreshTask) return refreshTask;
|
||||||
|
refreshTask = (async () => {
|
||||||
|
let estimate = null;
|
||||||
|
try { estimate = await navigator.storage?.estimate?.(); } catch { /* Display unknown rather than a guessed quota. */ }
|
||||||
|
const usage = estimate?.usage;
|
||||||
|
const quota = estimate?.quota;
|
||||||
|
const known = Number.isFinite(usage) && Number.isFinite(quota) && quota > 0;
|
||||||
|
show('site', usage, known ? quota : null, known
|
||||||
|
? `Website-Speicher: ${format(usage)} von ${format(quota)} belegt (${Math.round(usage / quota * 100)} %). Etwa ${format(Math.max(0, quota - usage))} verfügbar.${usage / quota >= .9 ? ' Speicher fast voll.' : ''}`
|
||||||
|
: 'Der Browser meldet keine Speicherkapazität.');
|
||||||
|
let local = null;
|
||||||
|
try { local = localBytes(); } catch { /* Storage can be blocked by browser settings. */ }
|
||||||
|
show('local', local, local == null ? null : LOCAL_REFERENCE, local == null
|
||||||
|
? 'Local Storage ist nicht verfügbar.'
|
||||||
|
: `Local Storage: ungefähr ${format(local)} belegt. Balken bezogen auf einen Richtwert von 5 MiB; die tatsächliche Grenze hängt vom Browser ab.${local >= LOCAL_REFERENCE * .9 ? ' Richtwert fast erreicht: Große Bilder können das Speichern verhindern.' : ''}`);
|
||||||
|
for (const panel of document.querySelectorAll('[data-storage-usage]')) {
|
||||||
|
const warning = panel.querySelector('[data-storage-warning]');
|
||||||
|
if (warning) { warning.hidden = !lastError; warning.textContent = lastError; }
|
||||||
|
}
|
||||||
|
})().finally(() => { refreshTask = null; });
|
||||||
|
return refreshTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportError(error) {
|
||||||
|
let current = error;
|
||||||
|
while (current) {
|
||||||
|
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.';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = current.cause;
|
||||||
|
}
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialize() {
|
||||||
|
if (initialized) return;
|
||||||
|
initialized = true;
|
||||||
|
document.addEventListener('wegwichtel:local-routes-changed', () => { lastError = ''; void refresh(); });
|
||||||
|
document.addEventListener('wegwichtel:storage-error', event => reportError(event.detail));
|
||||||
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') void refresh(); });
|
||||||
|
globalThis.addEventListener?.('storage', () => { void refresh(); });
|
||||||
|
globalThis.addEventListener?.('focus', () => { void refresh(); });
|
||||||
|
for (const button of document.querySelectorAll('[data-storage-refresh]')) button.addEventListener('click', () => { void refresh(); });
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
ns.StorageUsage = Object.freeze({ initialize, refresh, reportError });
|
||||||
|
}(globalThis.Wegwichtel));
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
'use strict';
|
||||||
|
// Serve app files and individually selected route snapshots independently (v2).
|
||||||
|
importScripts('/js/offline-storage.js');
|
||||||
|
|
||||||
|
self.addEventListener('install', event => event.waitUntil(self.skipWaiting()));
|
||||||
|
self.addEventListener('activate', event => event.waitUntil(self.clients.claim()));
|
||||||
|
|
||||||
|
/** Audio seeks need partial responses even when the entire file is stored locally. */
|
||||||
|
async function rangedResponse(response, range) {
|
||||||
|
const bytes = await response.arrayBuffer();
|
||||||
|
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
|
||||||
|
if (!match || (!match[1] && !match[2])) return new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${bytes.byteLength}` } });
|
||||||
|
const start = match[1] ? Number(match[1]) : Math.max(0, bytes.byteLength - Number(match[2]));
|
||||||
|
const end = match[1] ? (match[2] ? Math.min(Number(match[2]), bytes.byteLength - 1) : bytes.byteLength - 1) : bytes.byteLength - 1;
|
||||||
|
if (start > end || start >= bytes.byteLength) return new Response(null, { status: 416, headers: { 'Content-Range': `bytes */${bytes.byteLength}` } });
|
||||||
|
const headers = new Headers(response.headers);
|
||||||
|
headers.set('Content-Range', `bytes ${start}-${end}/${bytes.byteLength}`);
|
||||||
|
headers.set('Content-Length', String(end - start + 1));
|
||||||
|
headers.set('Accept-Ranges', 'bytes');
|
||||||
|
return new Response(bytes.slice(start, end + 1), { status: 206, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('fetch', event => {
|
||||||
|
const request = event.request;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (request.method !== 'GET' || url.origin !== self.location.origin
|
||||||
|
|| request.headers.has('X-Wegwichtel-Offline-Download')
|
||||||
|
|| url.pathname === '/api/offline-manifest') return;
|
||||||
|
event.respondWith((async () => {
|
||||||
|
// A complete snapshot is cache-first, avoiding any wait for a failing mobile network.
|
||||||
|
const cached = await self.Wegwichtel.OfflineStore.match(request.url).catch(() => null);
|
||||||
|
if (cached) {
|
||||||
|
const range = request.headers.get('Range');
|
||||||
|
return range ? rangedResponse(cached, range) : cached;
|
||||||
|
}
|
||||||
|
return fetch(request);
|
||||||
|
})());
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { offlineManifest } from './src/services/offline-manifest.js';
|
||||||
import { config, ensureRuntimeDirectories } from './src/config.js';
|
import { config, ensureRuntimeDirectories } from './src/config.js';
|
||||||
import { openDatabase } from './src/database.js';
|
import { openDatabase } from './src/database.js';
|
||||||
import { createApiRouter } from './src/routes/api.js';
|
import { createApiRouter } from './src/routes/api.js';
|
||||||
@@ -18,6 +19,10 @@ app.use(express.json({
|
|||||||
type: ['application/json', 'application/*+json', 'text/json']
|
type: ['application/json', 'application/*+json', 'text/json']
|
||||||
}));
|
}));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
|
app.use(express.urlencoded({ extended: true, limit: '2mb' }));
|
||||||
|
app.get('/api/offline-manifest', (req, res) => {
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.json(offlineManifest(publicDirectory));
|
||||||
|
});
|
||||||
app.use('/api', createApiRouter(db));
|
app.use('/api', createApiRouter(db));
|
||||||
app.use(express.static(publicDirectory, {
|
app.use(express.static(publicDirectory, {
|
||||||
extensions: ['html'],
|
extensions: ['html'],
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
/** Only the app shell; route data is downloaded separately on explicit request. */
|
||||||
|
export function offlineManifest(publicDirectory) {
|
||||||
|
const resources = new Set(['/']);
|
||||||
|
const appDirectories = new Set(['css', 'js', 'vendor', 'assets', 'images']);
|
||||||
|
function visit(directory, prefix = '') {
|
||||||
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
||||||
|
if (entry.name.startsWith('.')) continue;
|
||||||
|
if (!prefix && entry.isDirectory() && !appDirectories.has(entry.name)) continue;
|
||||||
|
if (prefix === '/images' && entry.isDirectory() && !['icons', 'group-symbols'].includes(entry.name)) continue;
|
||||||
|
const relative = `${prefix}/${encodeURIComponent(entry.name)}`;
|
||||||
|
if (entry.isDirectory()) visit(path.join(directory, entry.name), relative);
|
||||||
|
else if (entry.isFile() && /\.(?:html|css|js|svg|png|webp|jpg|jpeg|gif|ico|woff2?)$/i.test(entry.name)) resources.add(relative);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(publicDirectory);
|
||||||
|
return { resources: [...resources].sort() };
|
||||||
|
}
|
||||||
@@ -146,6 +146,17 @@ test('the npm-started local API is exercised through a write-auth proxy on loopb
|
|||||||
assert.match(indexResponse.headers.get('cache-control') || '', /no-store/);
|
assert.match(indexResponse.headers.get('cache-control') || '', /no-store/);
|
||||||
assert.match(indexHtml, /css\/app\.css\?v=0\.12\.14/);
|
assert.match(indexHtml, /css\/app\.css\?v=0\.12\.14/);
|
||||||
|
|
||||||
|
const { response: manifestResponse, body: manifest } = await requestJson(`${baseUrl}/api/offline-manifest`);
|
||||||
|
assert.match(manifestResponse.headers.get('cache-control') || '', /no-store/);
|
||||||
|
assert.ok(manifest.resources.includes('/offline-worker.js'));
|
||||||
|
assert.ok(manifest.resources.includes('/js/offline-storage.js'));
|
||||||
|
assert.ok(manifest.resources.includes('/js/offline-cache.js'));
|
||||||
|
assert.ok(manifest.resources.includes('/vendor/jquery/jquery-4.0.0.min.js'));
|
||||||
|
const workerResponse = await fetch(`${baseUrl}/offline-worker.js`);
|
||||||
|
assert.equal(workerResponse.status, 200);
|
||||||
|
assert.match(workerResponse.headers.get('content-type') || '', /javascript/);
|
||||||
|
await workerResponse.arrayBuffer();
|
||||||
|
|
||||||
const appStyleResponse = await fetch(`${baseUrl}/css/app.css?v=0.12.14`);
|
const appStyleResponse = await fetch(`${baseUrl}/css/app.css?v=0.12.14`);
|
||||||
assert.equal(appStyleResponse.status, 200);
|
assert.equal(appStyleResponse.status, 200);
|
||||||
assert.match(appStyleResponse.headers.get('cache-control') || '', /no-store/);
|
assert.match(appStyleResponse.headers.get('cache-control') || '', /no-store/);
|
||||||
|
|||||||
@@ -16,13 +16,16 @@ async function clientSources() {
|
|||||||
return Promise.all(names.filter(name => name.endsWith('.js')).map(name => read(`public/js/${name}`)));
|
return Promise.all(names.filter(name => name.endsWith('.js')).map(name => read(`public/js/${name}`)));
|
||||||
}
|
}
|
||||||
|
|
||||||
test('every static HTML id is referenced by authored client JavaScript', async () => {
|
test('every static HTML id is referenced by client JavaScript or accessible HTML relationships', async () => {
|
||||||
const html = await read('public/index.html');
|
const html = await read('public/index.html');
|
||||||
const scripts = (await clientSources()).join('\n');
|
const scripts = (await clientSources()).join('\n');
|
||||||
const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]);
|
const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]);
|
||||||
|
const htmlReferences = new Set([...html.matchAll(/\b(?:aria-labelledby|aria-describedby|aria-controls|for)="([^"]+)"/g)]
|
||||||
|
.flatMap(match => match[1].split(/\s+/)));
|
||||||
|
|
||||||
assert.ok(ids.length > 0);
|
assert.ok(ids.length > 0);
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
|
if (htmlReferences.has(id)) continue;
|
||||||
assert.match(scripts, new RegExp(id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), `unused id: ${id}`);
|
assert.match(scripts, new RegExp(id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), `unused id: ${id}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
import { offlineManifest } from '../src/services/offline-manifest.js';
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '..');
|
||||||
|
const origin = 'https://wegwichtel.test';
|
||||||
|
|
||||||
|
function memoryCaches() {
|
||||||
|
const stores = new Map();
|
||||||
|
const key = value => new URL(value.url ?? value, origin).href;
|
||||||
|
return {
|
||||||
|
stores,
|
||||||
|
async open(name) {
|
||||||
|
if (!stores.has(name)) stores.set(name, new Map());
|
||||||
|
const items = stores.get(name);
|
||||||
|
return {
|
||||||
|
async put(url, response) { items.set(key(url), response.clone()); },
|
||||||
|
async match(url) { return items.get(key(url))?.clone(); },
|
||||||
|
async delete(url) { return items.delete(key(url)); }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async delete(name) { return stores.delete(name); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function memoryDirectory() {
|
||||||
|
const files = new Map();
|
||||||
|
const directories = new Map();
|
||||||
|
return {
|
||||||
|
files, directories,
|
||||||
|
async getDirectoryHandle(name, options = {}) {
|
||||||
|
if (!directories.has(name)) {
|
||||||
|
if (!options.create) throw new Error('Missing directory');
|
||||||
|
directories.set(name, memoryDirectory());
|
||||||
|
}
|
||||||
|
return directories.get(name);
|
||||||
|
},
|
||||||
|
async getFileHandle(name, options = {}) {
|
||||||
|
if (!files.has(name) && !options.create) throw new Error('Missing file');
|
||||||
|
return {
|
||||||
|
async createWritable() {
|
||||||
|
let contents;
|
||||||
|
return {
|
||||||
|
async write(value) { contents = value; },
|
||||||
|
async close() { files.set(name, new Blob([contents])); },
|
||||||
|
async abort() {}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async getFile() { return files.get(name); }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async removeEntry(name) { directories.delete(name); files.delete(name); }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStore(filesystem = false) {
|
||||||
|
const directory = memoryDirectory();
|
||||||
|
const context = {
|
||||||
|
location: { origin }, navigator: { storage: filesystem ? { getDirectory: async () => directory } : {} },
|
||||||
|
caches: memoryCaches(), crypto, URL, Response, Headers, Blob, Date
|
||||||
|
};
|
||||||
|
context.globalThis = 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') });
|
||||||
|
return { context, directory, store: context.Wegwichtel.OfflineStore };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('app offline manifest includes app assets but no route APIs, GPX or route media', async () => {
|
||||||
|
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'wegwichtel-manifest-'));
|
||||||
|
const db = new DatabaseSync(':memory:');
|
||||||
|
try {
|
||||||
|
db.exec(await fs.readFile(path.join(root, 'database/schema.sql'), 'utf8'));
|
||||||
|
db.exec(`INSERT INTO routes(id,slug,name,gpx_path,activated_at,status) VALUES
|
||||||
|
(1,'released','Released','one.gpx','2026-09-30','active'),
|
||||||
|
(2,'draft','Draft','two.gpx',NULL,'active'),
|
||||||
|
(3,'deleted','Deleted','three.gpx','2026-09-30','deleted');
|
||||||
|
INSERT INTO pois(id,route_id,title,lat,lon,audio_path) VALUES (1,1,'POI',50,10,'audio.mp3');
|
||||||
|
INSERT INTO poi_images(id,poi_id,path) VALUES(1,1,'image.png');`);
|
||||||
|
await fs.mkdir(path.join(directory, 'images', 'group-symbols'), { recursive: true });
|
||||||
|
await fs.mkdir(path.join(directory, 'routes'));
|
||||||
|
await fs.writeFile(path.join(directory, 'routes', 'photo.png'), 'route media');
|
||||||
|
await fs.writeFile(path.join(directory, 'track.gpx'), 'track');
|
||||||
|
await fs.writeFile(path.join(directory, 'index.html'), 'App');
|
||||||
|
await fs.writeFile(path.join(directory, 'images', 'group-symbols', '🍓.png'), 'image');
|
||||||
|
await fs.writeFile(path.join(directory, '.private'), 'hidden');
|
||||||
|
const { resources } = offlineManifest(directory);
|
||||||
|
assert.ok(resources.includes('/'));
|
||||||
|
assert.ok(resources.includes('/index.html'));
|
||||||
|
assert.ok(resources.includes('/images/group-symbols/%F0%9F%8D%93.png'));
|
||||||
|
assert.ok(!resources.includes('/api/health'));
|
||||||
|
assert.ok(!resources.includes('/api/routes?activatedOnly=true'));
|
||||||
|
assert.ok(!resources.includes('/api/routes/1/gpx'));
|
||||||
|
assert.ok(!resources.includes('/api/routes/1/pois/1/audio'));
|
||||||
|
assert.ok(!resources.includes('/api/routes/1/pois/1/pictures/1'));
|
||||||
|
assert.ok(!resources.includes('/.private'));
|
||||||
|
assert.ok(!resources.includes('/track.gpx'));
|
||||||
|
assert.ok(!resources.includes('/routes/photo.png'));
|
||||||
|
assert.ok(!resources.some(url => url.startsWith('/api/')));
|
||||||
|
assert.ok(!resources.includes('/api/routes/2'));
|
||||||
|
assert.ok(!resources.includes('/api/routes/3'));
|
||||||
|
} finally { db.close(); await fs.rm(directory, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const filesystem of [false, true]) {
|
||||||
|
test(`offline ${filesystem ? 'filesystem' : 'cache'} saves bytes/headers, switches snapshots atomically and removes only its own data`, async () => {
|
||||||
|
const { context, directory, store } = await loadStore(filesystem);
|
||||||
|
const unrelated = await context.caches.open('other-cache');
|
||||||
|
await unrelated.put('/other', new Response('keep'));
|
||||||
|
assert.equal(await store.state(), null);
|
||||||
|
assert.equal(await store.match('/image.png'), null);
|
||||||
|
const first = await store.create();
|
||||||
|
assert.equal(first.mode, filesystem ? 'filesystem' : 'cache');
|
||||||
|
await store.put(first, '/image.png?v=1', new Response('first', { headers: { 'Content-Type': 'image/png' } }));
|
||||||
|
assert.equal(await store.match('/image.png'), null, 'partial downloads must not be visible');
|
||||||
|
await store.commit(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');
|
||||||
|
const failed = await store.create();
|
||||||
|
await assert.rejects(store.put(failed, '/image.png', new Response('missing', { status: 404 })), /Download fehlgeschlagen/);
|
||||||
|
await store.remove(failed.id);
|
||||||
|
assert.equal(await (await store.match('/image.png')).text(), 'first');
|
||||||
|
assert.throws(() => store.key('https://outside.test/image.png'), /dieser App/);
|
||||||
|
const second = await store.create();
|
||||||
|
await store.put(second, '/image.png', new Response('second'));
|
||||||
|
await store.commit(second);
|
||||||
|
assert.equal(await (await store.match('/image.png')).text(), 'second');
|
||||||
|
assert.equal(context.caches.stores.has(first.id), false);
|
||||||
|
if (filesystem) assert.equal(directory.directories.get('wegwichtel-offline-v1').directories.has(first.id), false);
|
||||||
|
await store.clear();
|
||||||
|
assert.equal(await store.match('/image.png'), null);
|
||||||
|
assert.equal(await (await unrelated.match('/other')).text(), 'keep');
|
||||||
|
await store.remove('other-cache');
|
||||||
|
assert.equal(context.caches.stores.has('other-cache'), true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function workerHarness() {
|
||||||
|
const listeners = new Map();
|
||||||
|
const calls = [];
|
||||||
|
const context = {
|
||||||
|
location: { origin }, URL, Headers, Response,
|
||||||
|
Wegwichtel: { OfflineStore: { async match() { return new Response('abcdefghij', { headers: { 'Content-Type': 'audio/mpeg' } }); } } },
|
||||||
|
importScripts() {},
|
||||||
|
fetch: async request => { calls.push(request); return new Response('network'); },
|
||||||
|
addEventListener(name, handler) { listeners.set(name, handler); },
|
||||||
|
skipWaiting: async () => {}, clients: { claim: async () => {} }
|
||||||
|
};
|
||||||
|
context.self = context;
|
||||||
|
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) {
|
||||||
|
let response;
|
||||||
|
listeners.get('fetch')({ request: new Request(new URL(url, origin), options), respondWith(value) { response = value; } });
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
return { context, listeners, calls, request };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('offline worker serves stored audio ranges including seeks, rejects invalid ranges and leaves writes/network downloads untouched', async () => {
|
||||||
|
const { context, listeners, calls, request } = await workerHarness();
|
||||||
|
for (const name of ['install', 'activate']) {
|
||||||
|
let pending;
|
||||||
|
listeners.get(name)({ waitUntil(value) { pending = value; } });
|
||||||
|
await pending;
|
||||||
|
}
|
||||||
|
assert.equal(await (await request('/audio')).text(), 'abcdefghij');
|
||||||
|
for (const [range, body, contentRange] of [
|
||||||
|
['bytes=2-5', 'cdef', 'bytes 2-5/10'], ['bytes=7-', 'hij', 'bytes 7-9/10'],
|
||||||
|
['bytes=-3', 'hij', 'bytes 7-9/10'], ['bytes=0-100', 'abcdefghij', 'bytes 0-9/10']
|
||||||
|
]) {
|
||||||
|
const response = await request('/audio', { headers: { Range: range } });
|
||||||
|
assert.equal(response.status, 206);
|
||||||
|
assert.equal(response.headers.get('Content-Range'), contentRange);
|
||||||
|
assert.equal(await response.text(), body);
|
||||||
|
}
|
||||||
|
for (const range of ['bytes=50-', 'bytes=8-2', 'bytes=-0', 'bytes=-', 'garbage']) {
|
||||||
|
assert.equal((await request('/audio', { headers: { Range: range } })).status, 416);
|
||||||
|
}
|
||||||
|
assert.equal(await request('/audio', { method: 'POST' }), undefined);
|
||||||
|
assert.equal(await request('https://outside.test/audio'), undefined);
|
||||||
|
assert.equal(await request('/audio', { headers: { 'X-Wegwichtel-Offline-Download': 'true' } }), undefined);
|
||||||
|
assert.equal(await request('/api/offline-manifest'), undefined);
|
||||||
|
context.Wegwichtel.OfflineStore.match = async () => null;
|
||||||
|
assert.equal(await (await request('/missing')).text(), 'network');
|
||||||
|
context.Wegwichtel.OfflineStore.match = async () => { throw new Error('Disk unavailable'); };
|
||||||
|
assert.equal(await (await request('/missing')).text(), 'network');
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function managerHarness({ supported = true, saved = false } = {}) {
|
||||||
|
const { context, store } = await loadStore(true);
|
||||||
|
const elements = new Map();
|
||||||
|
for (const id of ['offline-cache-toggle', 'offline-cache-status', 'offline-cache-refresh', 'bootstrap-enter']) {
|
||||||
|
const element = new EventTarget();
|
||||||
|
element.attributes = new Map();
|
||||||
|
element.setAttribute = (name, value) => element.attributes.set(name, value);
|
||||||
|
elements.set(id, element);
|
||||||
|
}
|
||||||
|
context.document = { getElementById: id => elements.get(id) };
|
||||||
|
context.isSecureContext = supported;
|
||||||
|
context.AbortController = AbortController;
|
||||||
|
context.setTimeout = setTimeout;
|
||||||
|
context.clearTimeout = clearTimeout;
|
||||||
|
context.navigator.serviceWorker = {
|
||||||
|
controller: {}, ready: Promise.resolve(),
|
||||||
|
async register(url, options) { assert.equal(url, '/offline-worker.js'); assert.equal(options.scope, '/'); }
|
||||||
|
};
|
||||||
|
context.navigator.storage.persist = async () => false;
|
||||||
|
const downloaded = [];
|
||||||
|
context.fetch = async (url, options) => {
|
||||||
|
assert.equal(options.cache, 'no-store');
|
||||||
|
assert.equal(options.headers['X-Wegwichtel-Offline-Download'], 'true');
|
||||||
|
if (url === '/api/offline-manifest') return Response.json({ resources: ['/', '/js/app.js', '/image.png', '/image.png'] });
|
||||||
|
downloaded.push(url);
|
||||||
|
return new Response(`content:${url}`);
|
||||||
|
};
|
||||||
|
if (saved) {
|
||||||
|
const stage = await store.create();
|
||||||
|
await store.put(stage, '/', new Response('old'));
|
||||||
|
await store.commit(stage);
|
||||||
|
}
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('offline switch prepares all resources once, persists opt-in and clears only offline copies', async () => {
|
||||||
|
const { manager, store, elements, downloaded } = await managerHarness();
|
||||||
|
await manager.initialize();
|
||||||
|
assert.equal(elements.get('offline-cache-toggle').checked, false);
|
||||||
|
elements.get('offline-cache-toggle').checked = true;
|
||||||
|
elements.get('offline-cache-toggle').dispatchEvent(new Event('change'));
|
||||||
|
const pending = manager.download();
|
||||||
|
assert.equal(manager.busy, true);
|
||||||
|
assert.equal(elements.get('bootstrap-enter').disabled, true);
|
||||||
|
await pending;
|
||||||
|
assert.equal(manager.busy, false);
|
||||||
|
assert.equal(elements.get('bootstrap-enter').disabled, false);
|
||||||
|
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-refresh').hidden, false);
|
||||||
|
assert.match(elements.get('offline-cache-status').textContent, /3 Dateien sind offline verfügbar/);
|
||||||
|
assert.ok(await store.match('/image.png'));
|
||||||
|
await manager.disable();
|
||||||
|
assert.equal(await store.state(), null);
|
||||||
|
assert.equal(elements.get('offline-cache-toggle').checked, false);
|
||||||
|
assert.equal(elements.get('offline-cache-refresh').hidden, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('failed refresh preserves the old snapshot and download can be cancelled with the switch', async () => {
|
||||||
|
const { context, manager, store, elements } = await managerHarness({ saved: true });
|
||||||
|
context.navigator.serviceWorker.getRegistration = async () => ({ active: { scriptURL: `${origin}/offline-worker.js` } });
|
||||||
|
context.navigator.serviceWorker.register = async () => { assert.fail('offline restart must reuse the existing worker'); };
|
||||||
|
await manager.initialize();
|
||||||
|
assert.equal(elements.get('offline-cache-toggle').checked, true);
|
||||||
|
const fetchResource = context.fetch;
|
||||||
|
context.fetch = async (url, options) => url.endsWith('/image.png') ? new Response('missing', { status: 404 }) : fetchResource(url, options);
|
||||||
|
await assert.rejects(manager.download(), /Download fehlgeschlagen/);
|
||||||
|
assert.equal(await (await store.match('/')).text(), 'old');
|
||||||
|
assert.match(elements.get('offline-cache-status').textContent, /bisherige Offline-Stand/);
|
||||||
|
let started;
|
||||||
|
const downloading = new Promise(resolve => { started = resolve; });
|
||||||
|
context.fetch = (url, options) => new Promise((resolve, reject) => {
|
||||||
|
options.signal.addEventListener('abort', () => reject(new DOMException('Cancelled', 'AbortError')), { once: true });
|
||||||
|
started();
|
||||||
|
});
|
||||||
|
const pending = manager.download();
|
||||||
|
const rejected = assert.rejects(pending, { name: 'AbortError' });
|
||||||
|
await downloading;
|
||||||
|
await manager.disable();
|
||||||
|
await rejected;
|
||||||
|
assert.equal(manager.busy, false);
|
||||||
|
assert.equal(await store.state(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offline UI handles unsupported browsers and malformed manifests without enabling an incomplete snapshot', async () => {
|
||||||
|
const unsupported = await managerHarness({ supported: false });
|
||||||
|
await unsupported.manager.initialize();
|
||||||
|
assert.equal(unsupported.elements.get('offline-cache-toggle').disabled, true);
|
||||||
|
assert.match(unsupported.elements.get('offline-cache-status').textContent, /nicht verfügbar/);
|
||||||
|
const setup = await managerHarness();
|
||||||
|
await setup.manager.initialize();
|
||||||
|
setup.context.fetch = async () => Response.json({ resources: [] });
|
||||||
|
await assert.rejects(setup.manager.download(), /Ungültige Offline/);
|
||||||
|
assert.equal(await setup.store.state(), null);
|
||||||
|
setup.context.fetch = async () => new Response('error', { status: 500 });
|
||||||
|
await assert.rejects(setup.manager.download(), /Liste der Offline-Dateien/);
|
||||||
|
assert.equal(setup.elements.get('offline-cache-toggle').checked, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const filesystem of [false, true]) {
|
||||||
|
test(`app and selected routes are independent in ${filesystem ? 'OPFS' : 'CacheStorage'}, including API cache busters`, async () => {
|
||||||
|
const { store } = await loadStore(filesystem);
|
||||||
|
const app = await store.create();
|
||||||
|
await store.put(app, '/', new Response('app'));
|
||||||
|
await store.commit(app);
|
||||||
|
for (const id of [1, 2]) {
|
||||||
|
const stage = await store.create();
|
||||||
|
await store.put(stage, `/api/routes/${id}`, Response.json({ id }));
|
||||||
|
await store.put(stage, `/api/routes/${id}/gpx`, new Response(`gpx${id}`));
|
||||||
|
await store.commit(stage, { id, name: `Route ${id}`, pois: [], points: [] });
|
||||||
|
}
|
||||||
|
assert.equal((await store.routes()).length, 2);
|
||||||
|
assert.deepEqual(await (await store.match('/api/routes/1?_=123')).json(), { id: 1 });
|
||||||
|
await store.clear();
|
||||||
|
assert.equal(await store.match('/'), null);
|
||||||
|
assert.equal(await (await store.match('/api/routes/2/gpx')).text(), 'gpx2');
|
||||||
|
await store.clearRoute(1);
|
||||||
|
assert.equal(await store.match('/api/routes/1'), null);
|
||||||
|
assert.equal((await store.routes()).length, 1);
|
||||||
|
assert.equal(await (await store.match('/api/routes/2/gpx')).text(), 'gpx2');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('explicit route download stores only selected GPX/media and failed refresh keeps previous route', async () => {
|
||||||
|
const { context, manager, store } = await managerHarness();
|
||||||
|
const route = { id: 7, name: 'Seven', activatedAt: '2026-09-30', gpxUrl: '/api/routes/7/gpx', pois: [{ audioUrl: '/audio7', images: [{ url: '/picture7' }] }] };
|
||||||
|
const requested = [];
|
||||||
|
context.fetch = async url => {
|
||||||
|
requested.push(url);
|
||||||
|
if (url === '/api/routes/7') return Response.json(route);
|
||||||
|
return new Response(`bytes:${url}`);
|
||||||
|
};
|
||||||
|
await manager.initialize();
|
||||||
|
assert.equal((await store.routes()).length, 0);
|
||||||
|
await manager.saveRoute(route);
|
||||||
|
assert.deepEqual(requested, ['/api/routes/7', origin + '/api/routes/7/gpx', origin + '/audio7', origin + '/picture7']);
|
||||||
|
assert.equal(await store.state(), null);
|
||||||
|
assert.equal((await store.routes()).length, 1);
|
||||||
|
assert.equal(await (await store.match('/api/routes/7/gpx')).text(), 'bytes:' + origin + '/api/routes/7/gpx');
|
||||||
|
const previous = (await store.routes())[0].id;
|
||||||
|
context.fetch = async url => url === '/api/routes/7' ? Response.json(route) : new Response('full', { status: 507 });
|
||||||
|
await assert.rejects(manager.saveRoute(route), /Download fehlgeschlagen/);
|
||||||
|
assert.equal((await store.routes())[0].id, previous);
|
||||||
|
await manager.disable();
|
||||||
|
assert.equal((await store.routes()).length, 1);
|
||||||
|
await manager.removeRoute(route);
|
||||||
|
assert.equal((await store.routes()).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('old automatic all-route snapshot is removed without enabling a new app download', async () => {
|
||||||
|
const { context, manager, store } = await managerHarness({ saved: true });
|
||||||
|
const control = await context.caches.open('wegwichtel-offline-control-v1');
|
||||||
|
const state = await store.state();
|
||||||
|
delete state.appOnly;
|
||||||
|
await control.put('/__wegwichtel_offline_state__', Response.json(state));
|
||||||
|
await manager.initialize();
|
||||||
|
assert.equal(await store.state(), null);
|
||||||
|
assert.equal(await store.match('/'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('API starts from selected offline routes and an empty list for app-only storage without network requests', async () => {
|
||||||
|
const { context, store } = await loadStore();
|
||||||
|
context.navigator.onLine = false;
|
||||||
|
context.URLSearchParams = URLSearchParams;
|
||||||
|
context.Wegwichtel.Config = { apiBase: '/api', routeRadiusKm: 5 };
|
||||||
|
context.jQuery = { ajax() { throw new Error('Network must not be used'); } };
|
||||||
|
const app = await store.create();
|
||||||
|
await store.put(app, '/', new Response('shell'));
|
||||||
|
await store.commit(app);
|
||||||
|
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/api-client.js'), 'utf8'), context);
|
||||||
|
const api = context.Wegwichtel.Api;
|
||||||
|
assert.equal((await api.health()).offline, true);
|
||||||
|
assert.equal((await api.routes(null)).routes.length, 0);
|
||||||
|
const route = { id: 7, name: 'Saved', pois: [] };
|
||||||
|
const stage = await store.create();
|
||||||
|
await store.put(stage, '/api/routes/7', Response.json(route));
|
||||||
|
await store.commit(stage, route);
|
||||||
|
assert.equal((await api.routes(null)).routes[0].name, 'Saved');
|
||||||
|
assert.deepEqual(await api.route(7), route);
|
||||||
|
context.navigator.onLine = true;
|
||||||
|
context.jQuery.ajax = () => Promise.reject({ status: 0 });
|
||||||
|
assert.equal((await api.routes(null)).offline, true);
|
||||||
|
context.jQuery.ajax = () => Promise.reject({ status: 403 });
|
||||||
|
await assert.rejects(api.routes(null), error => error.status === 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('storage gauge uses browser estimate, separates Local Storage and reports quota failures', async () => {
|
||||||
|
const elements = new Map();
|
||||||
|
const panel = { querySelector(selector) {
|
||||||
|
if (!elements.has(selector)) elements.set(selector, {});
|
||||||
|
return elements.get(selector);
|
||||||
|
} };
|
||||||
|
const context = {
|
||||||
|
document: { querySelectorAll: () => [panel] },
|
||||||
|
navigator: { storage: { estimate: async () => ({ usage: 95 * 1024 * 1024, quota: 100 * 1024 * 1024 }) } },
|
||||||
|
localStorage: { length: 1, key: () => 'image', getItem: () => 'x'.repeat(2400000) }
|
||||||
|
};
|
||||||
|
vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/storage-usage.js'), 'utf8'), context);
|
||||||
|
const usage = context.Wegwichtel.StorageUsage;
|
||||||
|
await usage.refresh();
|
||||||
|
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-local-text]').textContent, /Richtwert.*tatsächliche Grenze/);
|
||||||
|
usage.reportError({ cause: { name: 'QuotaExceededError' } });
|
||||||
|
await usage.refresh();
|
||||||
|
assert.match(elements.get('[data-storage-warning]').textContent, /Speicher voll/);
|
||||||
|
context.navigator.storage.estimate = async () => { throw new Error('Unavailable'); };
|
||||||
|
await usage.refresh();
|
||||||
|
assert.equal(elements.get('[data-storage-site-meter]').hidden, true);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user