Files
Wegwichtel/public/js/offline-storage.js
T
Florian Zumpe aff88eba1b
Sonarqube Scanner / Prepare environment (push) Successful in 20s
Sonarqube Scanner / Build and test (push) Failing after 36s
Sonarqube Scanner / SonarQube analysis (push) Skipped
Sonarqube Scanner / Deploy to Server (push) Successful in 9s
Internationalization
2026-10-01 09:41:01 +02:00

152 lines
6.0 KiB
JavaScript

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(ns.I18n.t("offline-storage.inhalte.von.anderen.seiten.konnen.hier.nicht"));
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(ns.I18n.t("offline-storage.ein.teil.konnte.nicht.gespeichert.werden.prufe"));
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));