add offline support
This commit is contained in:
@@ -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));
|
||||
Reference in New Issue
Block a user