252 lines
7.1 KiB
JavaScript
252 lines
7.1 KiB
JavaScript
(function (ns) {
|
|
'use strict';
|
|
|
|
const SETTINGS_KEY = 'wegwichtel.privacy.v1';
|
|
const PROGRESS_KEY = 'wegwichtel.route-progress.v1';
|
|
const COOKIE_PREFIX = 'wegwichtel_';
|
|
const MAX_PROGRESS_AGE_MS = 24 * 60 * 60 * 1000;
|
|
const STORE_VERSION = 1;
|
|
|
|
function storage() {
|
|
try {
|
|
return globalThis.localStorage ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function nowIso() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function readJson(key, fallback) {
|
|
const backend = storage();
|
|
if (!backend) return fallback;
|
|
|
|
try {
|
|
const raw = backend.getItem(key);
|
|
return raw ? JSON.parse(raw) : fallback;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function writeJson(key, value) {
|
|
const backend = storage();
|
|
if (!backend) return false;
|
|
|
|
try {
|
|
backend.setItem(key, JSON.stringify(value));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function removeItem(key) {
|
|
const backend = storage();
|
|
if (!backend) return;
|
|
|
|
try {
|
|
backend.removeItem(key);
|
|
} catch {
|
|
// Private browsing modes may reject storage access. The app remains usable without persistence.
|
|
}
|
|
}
|
|
|
|
function defaultSettings() {
|
|
return { progressStorage: false, updatedAt: null };
|
|
}
|
|
|
|
function settings() {
|
|
const value = readJson(SETTINGS_KEY, defaultSettings());
|
|
return {
|
|
progressStorage: value?.progressStorage === true,
|
|
updatedAt: typeof value?.updatedAt === 'string' ? value.updatedAt : null
|
|
};
|
|
}
|
|
|
|
function saveSettings(progressStorage) {
|
|
return writeJson(SETTINGS_KEY, {
|
|
progressStorage: progressStorage === true,
|
|
updatedAt: nowIso()
|
|
});
|
|
}
|
|
|
|
function defaultProgressStore() {
|
|
return { version: STORE_VERSION, currentRouteId: null, routes: {} };
|
|
}
|
|
|
|
function progressStore() {
|
|
const value = readJson(PROGRESS_KEY, defaultProgressStore());
|
|
if (value?.version !== STORE_VERSION || typeof value?.routes !== 'object' || value.routes === null) {
|
|
return defaultProgressStore();
|
|
}
|
|
|
|
return {
|
|
version: STORE_VERSION,
|
|
currentRouteId: value.currentRouteId ?? null,
|
|
routes: value.routes
|
|
};
|
|
}
|
|
|
|
function persistProgressStore(value) {
|
|
if (!settings().progressStorage) return false;
|
|
return writeJson(PROGRESS_KEY, value);
|
|
}
|
|
|
|
function uniqueIds(values) {
|
|
return [...new Set((Array.isArray(values) ? values : [])
|
|
.map(value => Number(value))
|
|
.filter(Number.isFinite))];
|
|
}
|
|
|
|
function routeRecord(route, previous = {}) {
|
|
const timestamp = nowIso();
|
|
return {
|
|
routeId: route.id,
|
|
routeName: String(route.name ?? previous.routeName ?? ''),
|
|
routeState: previous.routeState ?? 'idle',
|
|
routeProgressIndex: Number.isInteger(previous.routeProgressIndex)
|
|
? previous.routeProgressIndex
|
|
: null,
|
|
visitedPoiIds: uniqueIds(previous.visitedPoiIds),
|
|
viewedPoiIds: uniqueIds(previous.viewedPoiIds),
|
|
playedAudioPoiIds: uniqueIds(previous.playedAudioPoiIds),
|
|
startedAt: previous.startedAt ?? timestamp,
|
|
updatedAt: timestamp
|
|
};
|
|
}
|
|
|
|
function updateRoute(routeId, updater) {
|
|
if (!settings().progressStorage || routeId == null) return null;
|
|
|
|
const store = progressStore();
|
|
const key = String(routeId);
|
|
const current = store.routes[key];
|
|
if (!current) return null;
|
|
|
|
const updated = updater({ ...current });
|
|
updated.updatedAt = nowIso();
|
|
store.currentRouteId = routeId;
|
|
store.routes[key] = updated;
|
|
persistProgressStore(store);
|
|
return updated;
|
|
}
|
|
|
|
function markPoi(routeId, field, poiId) {
|
|
const normalizedId = Number(poiId);
|
|
if (!Number.isFinite(normalizedId)) return null;
|
|
|
|
return updateRoute(routeId, record => {
|
|
record[field] = uniqueIds([...(record[field] ?? []), normalizedId]);
|
|
return record;
|
|
});
|
|
}
|
|
|
|
function clearApplicationCookies() {
|
|
const cookieText = globalThis.document?.cookie;
|
|
if (!cookieText) return;
|
|
|
|
cookieText.split(';').forEach(item => {
|
|
const name = item.split('=', 1)[0].trim();
|
|
if (!name.startsWith(COOKIE_PREFIX)) return;
|
|
globalThis.document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`;
|
|
});
|
|
}
|
|
|
|
function setProgressStorageEnabled(enabled) {
|
|
const active = enabled === true;
|
|
const saved = saveSettings(active);
|
|
if (!active) removeItem(PROGRESS_KEY);
|
|
return saved && settings().progressStorage;
|
|
}
|
|
|
|
function activateRoute(route) {
|
|
if (!settings().progressStorage || route?.id == null) return null;
|
|
|
|
const store = progressStore();
|
|
const key = String(route.id);
|
|
const record = routeRecord(route, store.routes[key]);
|
|
store.currentRouteId = route.id;
|
|
store.routes[key] = record;
|
|
persistProgressStore(store);
|
|
return record;
|
|
}
|
|
|
|
function saveRouteState(route, routeState, routeProgressIndex, visitedPoiIds, viewedPoiIds, playedAudioPoiIds) {
|
|
if (route?.id == null) return null;
|
|
|
|
return updateRoute(route.id, record => ({
|
|
...record,
|
|
routeName: String(route.name ?? record.routeName ?? ''),
|
|
routeState: String(routeState ?? 'idle'),
|
|
routeProgressIndex: Number.isInteger(routeProgressIndex) ? routeProgressIndex : null,
|
|
visitedPoiIds: uniqueIds(visitedPoiIds),
|
|
viewedPoiIds: uniqueIds(viewedPoiIds),
|
|
playedAudioPoiIds: uniqueIds(playedAudioPoiIds)
|
|
}));
|
|
}
|
|
|
|
function loadRoute(routeId) {
|
|
if (!settings().progressStorage || routeId == null) return null;
|
|
return progressStore().routes[String(routeId)] ?? null;
|
|
}
|
|
|
|
function nextTurn() {
|
|
return new Promise(resolve => globalThis.setTimeout(resolve, 0));
|
|
}
|
|
|
|
async function cleanupExpired(onProgress = () => {}) {
|
|
if (!settings().progressStorage) {
|
|
removeItem(PROGRESS_KEY);
|
|
return { removed: 0, total: 0 };
|
|
}
|
|
|
|
const store = progressStore();
|
|
const cutoff = Date.now() - MAX_PROGRESS_AGE_MS;
|
|
const expiredKeys = Object.entries(store.routes)
|
|
.filter(([, route]) => {
|
|
const timestamp = Date.parse(route?.updatedAt ?? '');
|
|
return !Number.isFinite(timestamp) || timestamp <= cutoff;
|
|
})
|
|
.map(([key]) => key);
|
|
|
|
if (!expiredKeys.length) return { removed: 0, total: 0 };
|
|
|
|
let removed = 0;
|
|
onProgress(removed, expiredKeys.length);
|
|
|
|
for (const key of expiredKeys) {
|
|
delete store.routes[key];
|
|
if (String(store.currentRouteId) === key) store.currentRouteId = null;
|
|
removed += 1;
|
|
persistProgressStore(store);
|
|
onProgress(removed, expiredKeys.length);
|
|
await nextTurn();
|
|
}
|
|
|
|
return { removed, total: expiredKeys.length };
|
|
}
|
|
|
|
clearApplicationCookies();
|
|
|
|
ns.PrivacyStorage = Object.freeze({
|
|
SETTINGS_KEY,
|
|
PROGRESS_KEY,
|
|
MAX_PROGRESS_AGE_MS,
|
|
cookiesEnabled: () => false,
|
|
clearApplicationCookies,
|
|
isProgressStorageEnabled: () => settings().progressStorage,
|
|
setProgressStorageEnabled,
|
|
cleanupExpired,
|
|
activateRoute,
|
|
loadRoute,
|
|
saveRouteState,
|
|
markVisited: (routeId, poiId) => markPoi(routeId, 'visitedPoiIds', poiId),
|
|
markViewed: (routeId, poiId) => markPoi(routeId, 'viewedPoiIds', poiId),
|
|
markAudioPlayed: (routeId, poiId) => markPoi(routeId, 'playedAudioPoiIds', poiId),
|
|
clearProgress: () => removeItem(PROGRESS_KEY)
|
|
});
|
|
}(globalThis.Wegwichtel ??= {}));
|