Files
Wegwichtel/public/js/offline-cache.js
T
Florian Zumpe 8f4664282b
Sonarqube Scanner / Prepare environment (push) Successful in 14s
Sonarqube Scanner / Build and test (push) Successful in 22s
Sonarqube Scanner / SonarQube analysis (push) Failing after 1m14s
Sonarqube Scanner / Deploy to Server (push) Successful in 11s
add offline support
2026-09-30 23:27:42 +02:00

259 lines
11 KiB
JavaScript

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));