add offline support
This commit is contained in:
Vendored
+73
@@ -358,3 +358,76 @@
|
||||
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">
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<progress id="bootstrap-progress" class="bootstrap-state-model" max="7" value="0" hidden></progress>
|
||||
@@ -198,6 +229,13 @@
|
||||
</header>
|
||||
<div class="content">
|
||||
<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>
|
||||
<div><dt>Länge</dt><dd id="route-distance">–</dd></div>
|
||||
<div><dt>Höhenmeter</dt><dd id="route-elevation">–</dd></div>
|
||||
@@ -369,6 +407,7 @@
|
||||
</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">
|
||||
<span>
|
||||
@@ -415,6 +454,16 @@
|
||||
<span aria-hidden="true">+</span>
|
||||
</button>
|
||||
</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>
|
||||
<ul id="editor-route-list" class="editor-route-list" aria-live="polite"></ul>
|
||||
</section>
|
||||
@@ -505,6 +554,9 @@
|
||||
<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/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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+27
-2
@@ -1,8 +1,33 @@
|
||||
(function (ns, $) {
|
||||
'use strict';
|
||||
|
||||
function request(path) {
|
||||
return $.ajax({ url: ns.Config.apiBase + path, method: 'GET', dataType: 'json', cache: false });
|
||||
async function offlineResponse(path) {
|
||||
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) {
|
||||
|
||||
@@ -375,6 +375,7 @@
|
||||
}
|
||||
|
||||
function renderRouteView(route) {
|
||||
ns.OfflineCache?.renderRoute?.(route)?.catch?.(() => {});
|
||||
$('#route-title').text(route.name);
|
||||
$('#route-description').text(route.description || '');
|
||||
$('#route-distance').text(ns.Distance.format(route.distanceM));
|
||||
|
||||
Vendored
+3
-1
@@ -144,8 +144,9 @@
|
||||
}
|
||||
|
||||
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.closest('.offline-help, .storage-usage')) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation?.();
|
||||
@@ -192,6 +193,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
await globalThis.Wegwichtel.OfflineCache?.initialize();
|
||||
setStatusText(stepLabels.storage, true);
|
||||
await cleanupOldProgress();
|
||||
mark('storage', 'done');
|
||||
|
||||
@@ -1384,7 +1384,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
async function guarded(action) {
|
||||
try {
|
||||
await action();
|
||||
await ns.StorageUsage?.refresh();
|
||||
} catch (error) {
|
||||
ns.StorageUsage?.reportError(error);
|
||||
setStatus(error.message || 'Die Aktion konnte nicht ausgeführt werden.', true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,6 +467,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
} catch {
|
||||
// Die ursprüngliche Speicherfehlermeldung ist aussagekräftiger.
|
||||
}
|
||||
ns.StorageUsage?.reportError(error);
|
||||
const name = error?.name === 'QuotaExceededError'
|
||||
? 'Der lokale Speicher für Streckendaten ist voll.'
|
||||
: '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);
|
||||
})());
|
||||
});
|
||||
Reference in New Issue
Block a user