263 lines
11 KiB
JavaScript
263 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(ns.I18n.t("offline.error.download")));
|
|
}, 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(ns.I18n.t("offline.preparing"));
|
|
// 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(ns.I18n.t("offline.error.connection"));
|
|
const manifest = await manifestResponse.json();
|
|
if (!Array.isArray(manifest.resources) || !manifest.resources.length) throw new TypeError(ns.I18n.t("offline.error.prepare"));
|
|
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(ns.I18n.t("offline.download-progress", { value1: index + 1, value2: 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(ns.I18n.t("offline.ready"));
|
|
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(ns.I18n.t("offline.cancelled"));
|
|
else announce(`${ns.UserMessages?.error(error) || ns.I18n.t("offline.error.save")} ${saved ? ns.I18n.t("offline.previous.kept") : ns.I18n.t("offline.not-ready")}`);
|
|
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(ns.I18n.t("offline.app-removed"));
|
|
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 ? ns.I18n.t("offline.renew-route") : ns.I18n.t("offline.save-route");
|
|
}
|
|
if (remove) { remove.hidden = !saved; remove.disabled = Boolean(routeTask); }
|
|
if (message && !routeTask) message.textContent = saved
|
|
? ns.I18n.t("offline.route-ready")
|
|
: ns.I18n.t("offline.route-hint");
|
|
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(ns.I18n.t("offline.error.busy"));
|
|
if (!supported()) throw new Error(ns.I18n.t("offline.error.route-unsupported"));
|
|
if (!route || route.source === 'local') throw new Error(ns.I18n.t("offline.error.select-route"));
|
|
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(ns.I18n.t("offline.error.load-route"));
|
|
const fresh = await response.clone().json();
|
|
if (String(fresh.id) !== String(route.id) || !fresh.activatedAt) throw new Error(ns.I18n.t("offline.error.published-only"));
|
|
if (!fresh.gpxUrl) throw new Error(ns.I18n.t("offline.error.path-missing"));
|
|
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 = ns.I18n.t("offline.route-progress", { value1: index + 1, value2: 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(ns.I18n.t("offline.error.wait"));
|
|
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 = ns.I18n.t("offline.error.previous.kept", { value1: ns.UserMessages?.error(error) || ns.I18n.t("offline.error.save") });
|
|
});
|
|
};
|
|
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(ns.I18n.t("offline.error.unsupported"));
|
|
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(ns.I18n.t("offline.ready"));
|
|
} else {
|
|
announce(ns.I18n.t("offline.error.save-app-first"));
|
|
}
|
|
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(ns.UserMessages?.error(error) || ns.I18n.t("offline.error.download")); });
|
|
return initialized;
|
|
},
|
|
download,
|
|
disable,
|
|
renderRoute,
|
|
saveRoute,
|
|
removeRoute,
|
|
get busy() { return Boolean(task); }
|
|
});
|
|
}(globalThis.Wegwichtel));
|