add offline support
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

This commit is contained in:
Florian Zumpe
2026-09-30 23:27:42 +02:00
parent 6913024af1
commit 8f4664282b
16 changed files with 1134 additions and 4 deletions
+27 -2
View File
@@ -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) {
+1
View File
@@ -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));
+3 -1
View File
@@ -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');
+2
View File
@@ -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);
}
}
+1
View File
@@ -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.';
+258
View File
@@ -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));
+151
View File
@@ -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));
+85
View File
@@ -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));