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.'; updateGpxDownload(route); } function updateGpxDownload(route) { 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; return 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));