331 lines
9.2 KiB
JavaScript
331 lines
9.2 KiB
JavaScript
globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
|
|
|
(function (ns) {
|
|
'use strict';
|
|
|
|
const PRIVACY_KEY = 'wegwichtel.privacy.v1';
|
|
const PROGRESS_KEY = 'wegwichtel.route-progress.v1';
|
|
const MAX_PROGRESS_AGE_MS = 24 * 60 * 60 * 1000;
|
|
|
|
function storage() {
|
|
try {
|
|
return globalThis.localStorage ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function now() {
|
|
return Date.now();
|
|
}
|
|
|
|
function parseTimestamp(value, fallback = 0) {
|
|
if (value === null || value === undefined || value === '') return fallback;
|
|
|
|
const numeric = Number(value);
|
|
if (Number.isFinite(numeric) && numeric > 0) return numeric;
|
|
|
|
const parsed = Date.parse(String(value));
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function readJson(key, fallback) {
|
|
const store = storage();
|
|
if (!store) return fallback;
|
|
|
|
try {
|
|
const raw = store.getItem(key);
|
|
if (!raw) return fallback;
|
|
const value = JSON.parse(raw);
|
|
return value && typeof value === 'object' ? value : fallback;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function writeJson(key, value) {
|
|
const store = storage();
|
|
if (!store) return false;
|
|
|
|
try {
|
|
store.setItem(key, JSON.stringify(value));
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function removeItem(key) {
|
|
const store = storage();
|
|
if (!store) return false;
|
|
|
|
try {
|
|
store.removeItem(key);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function normalizeSettings(value) {
|
|
return {
|
|
progressStorage: value?.progressStorage === true,
|
|
cookies: false,
|
|
updatedAt: parseTimestamp(value?.updatedAt, now())
|
|
};
|
|
}
|
|
|
|
function readSettings() {
|
|
return normalizeSettings(readJson(PRIVACY_KEY, {}));
|
|
}
|
|
|
|
function writeSettings(settings) {
|
|
const value = normalizeSettings({ ...settings, updatedAt: now() });
|
|
return writeJson(PRIVACY_KEY, value);
|
|
}
|
|
|
|
function isProgressStorageEnabled() {
|
|
return readSettings().progressStorage === true;
|
|
}
|
|
|
|
function setProgressStorageEnabled(enabled) {
|
|
const nextValue = Boolean(enabled);
|
|
writeSettings({ progressStorage: nextValue, cookies: false });
|
|
if (!nextValue) removeItem(PROGRESS_KEY);
|
|
return nextValue;
|
|
}
|
|
|
|
function routeId(route) {
|
|
const id = route?.id ?? route?.routeId ?? route;
|
|
if (id === null || id === undefined || id === '') return null;
|
|
return String(id);
|
|
}
|
|
|
|
function numericIdSet(values) {
|
|
if (!Array.isArray(values)) return [];
|
|
return [...new Set(values.map(Number).filter(Number.isFinite))];
|
|
}
|
|
|
|
function addNumericId(values, value) {
|
|
return numericIdSet([...values, value]);
|
|
}
|
|
|
|
function isRouteStateObject(value) {
|
|
return Boolean(value && typeof value === 'object' && (
|
|
Object.hasOwn(value, 'routeId')
|
|
|| Object.hasOwn(value, 'routeState')
|
|
|| Object.hasOwn(value, 'routeProgressIndex')
|
|
|| Object.hasOwn(value, 'visitedPoiIds')
|
|
|| Object.hasOwn(value, 'viewedPoiIds')
|
|
|| Object.hasOwn(value, 'playedAudioPoiIds')
|
|
));
|
|
}
|
|
|
|
function progressEnvelope(value) {
|
|
if (!value || typeof value !== 'object') return {};
|
|
if (value.routes && typeof value.routes === 'object') return value.routes;
|
|
if (!isRouteStateObject(value)) return value;
|
|
|
|
const id = routeId(value);
|
|
return id ? { [id]: value } : {};
|
|
}
|
|
|
|
function readProgressStore() {
|
|
return progressEnvelope(readJson(PROGRESS_KEY, {}));
|
|
}
|
|
|
|
function writeProgressStore(routes) {
|
|
return writeJson(PROGRESS_KEY, {
|
|
version: 1,
|
|
updatedAt: now(),
|
|
routes
|
|
});
|
|
}
|
|
|
|
function normalizeRouteTimestamp(value) {
|
|
return parseTimestamp(value?.updatedAt, parseTimestamp(value?.savedAt, 0));
|
|
}
|
|
|
|
function normalizeRouteState(value, fallbackRouteId = null) {
|
|
if (!value || typeof value !== 'object') return null;
|
|
|
|
const id = value.routeId === null || value.routeId === undefined
|
|
? fallbackRouteId
|
|
: value.routeId;
|
|
const timestamp = normalizeRouteTimestamp(value) || now();
|
|
|
|
return {
|
|
routeId: id === null || id === undefined ? null : String(id),
|
|
routeName: String(value.routeName ?? ''),
|
|
routeState: value.routeState === 'running' || value.routeState === 'paused' ? value.routeState : 'idle',
|
|
routeProgressIndex: Number.isInteger(value.routeProgressIndex) ? value.routeProgressIndex : null,
|
|
visitedPoiIds: numericIdSet(value.visitedPoiIds),
|
|
viewedPoiIds: numericIdSet(value.viewedPoiIds),
|
|
playedAudioPoiIds: numericIdSet(value.playedAudioPoiIds),
|
|
savedAt: parseTimestamp(value.savedAt, timestamp),
|
|
updatedAt: timestamp
|
|
};
|
|
}
|
|
|
|
function freshEmptyRouteState(id, route = null) {
|
|
const timestamp = now();
|
|
return {
|
|
routeId: id,
|
|
routeName: route?.name ?? '',
|
|
routeState: 'idle',
|
|
routeProgressIndex: null,
|
|
visitedPoiIds: [],
|
|
viewedPoiIds: [],
|
|
playedAudioPoiIds: [],
|
|
savedAt: timestamp,
|
|
updatedAt: timestamp
|
|
};
|
|
}
|
|
|
|
function currentRouteState(routes, id, route = null) {
|
|
const normalized = normalizeRouteState(routes[id], id);
|
|
return normalized ?? freshEmptyRouteState(id, route);
|
|
}
|
|
|
|
function isExpired(value, referenceTime = now()) {
|
|
const timestamp = normalizeRouteTimestamp(value);
|
|
return timestamp > 0 && referenceTime - timestamp > MAX_PROGRESS_AGE_MS;
|
|
}
|
|
|
|
function updateRouteState(route, updater) {
|
|
if (!isProgressStorageEnabled()) return false;
|
|
|
|
const id = routeId(route);
|
|
if (!id || typeof updater !== 'function') return false;
|
|
|
|
const routes = readProgressStore();
|
|
const entry = currentRouteState(routes, id, typeof route === 'object' ? route : null);
|
|
const updated = updater(entry) ?? entry;
|
|
updated.routeId = id;
|
|
updated.updatedAt = now();
|
|
if (!updated.savedAt) updated.savedAt = updated.updatedAt;
|
|
routes[id] = normalizeRouteState(updated, id);
|
|
return writeProgressStore(routes);
|
|
}
|
|
|
|
function markVisited(route, poiId) {
|
|
return updateRouteState(route, entry => ({
|
|
...entry,
|
|
visitedPoiIds: addNumericId(entry.visitedPoiIds, poiId)
|
|
}));
|
|
}
|
|
|
|
function markViewed(route, poiId) {
|
|
return updateRouteState(route, entry => ({
|
|
...entry,
|
|
viewedPoiIds: addNumericId(entry.viewedPoiIds, poiId)
|
|
}));
|
|
}
|
|
|
|
function markAudioPlayed(route, poiId) {
|
|
return updateRouteState(route, entry => ({
|
|
...entry,
|
|
playedAudioPoiIds: addNumericId(entry.playedAudioPoiIds, poiId)
|
|
}));
|
|
}
|
|
|
|
function saveRouteState(route, routeState, routeProgressIndex, visitedPoiIds = [], viewedPoiIds = [], playedAudioPoiIds = []) {
|
|
if (!isProgressStorageEnabled()) return false;
|
|
|
|
const id = routeId(route);
|
|
if (!id) return false;
|
|
|
|
const timestamp = now();
|
|
const routes = readProgressStore();
|
|
routes[id] = {
|
|
routeId: id,
|
|
routeName: route?.name ?? '',
|
|
routeState: routeState === 'running' || routeState === 'paused' ? routeState : 'idle',
|
|
routeProgressIndex: Number.isInteger(routeProgressIndex) ? routeProgressIndex : null,
|
|
visitedPoiIds: numericIdSet(visitedPoiIds),
|
|
viewedPoiIds: numericIdSet(viewedPoiIds),
|
|
playedAudioPoiIds: numericIdSet(playedAudioPoiIds),
|
|
savedAt: timestamp,
|
|
updatedAt: timestamp
|
|
};
|
|
return writeProgressStore(routes);
|
|
}
|
|
|
|
function normalizedEntries(routes) {
|
|
return Object.entries(routes)
|
|
.map(([id, value]) => [id, normalizeRouteState(value, id)])
|
|
.filter(([, value]) => value !== null);
|
|
}
|
|
|
|
function reportCleanupProgress(callback, removed, total) {
|
|
if (typeof callback !== 'function') return;
|
|
callback(removed, total);
|
|
}
|
|
|
|
function cleanupExpired(progressCallback) {
|
|
const referenceTime = now();
|
|
const entries = normalizedEntries(readProgressStore());
|
|
const total = entries.filter(([, value]) => isExpired(value, referenceTime)).length;
|
|
const kept = {};
|
|
let removed = 0;
|
|
|
|
entries.forEach(([id, value]) => {
|
|
reportCleanupProgress(progressCallback, removed, total);
|
|
if (isExpired(value, referenceTime)) {
|
|
removed += 1;
|
|
return;
|
|
}
|
|
kept[id] = value;
|
|
});
|
|
|
|
if (removed > 0) writeProgressStore(kept);
|
|
return { removed, total, routes: kept };
|
|
}
|
|
|
|
async function cleanupExpiredProgress(progressCallback) {
|
|
return cleanupExpired(progressCallback);
|
|
}
|
|
|
|
function loadRoute(route) {
|
|
if (!isProgressStorageEnabled()) return null;
|
|
|
|
const id = routeId(route);
|
|
if (!id) return null;
|
|
|
|
const saved = normalizeRouteState(readProgressStore()[id], id);
|
|
return saved && !isExpired(saved) ? saved : null;
|
|
}
|
|
|
|
function activateRoute(route) {
|
|
if (!isProgressStorageEnabled()) return null;
|
|
|
|
const id = routeId(route);
|
|
if (!id) return null;
|
|
|
|
return loadRoute(route);
|
|
}
|
|
|
|
ns.PrivacyStorage = Object.freeze({
|
|
PRIVACY_KEY,
|
|
PROGRESS_KEY,
|
|
MAX_PROGRESS_AGE_MS,
|
|
activateRoute,
|
|
cleanExpiredProgress: cleanupExpiredProgress,
|
|
cleanupExpired,
|
|
cleanupExpiredProgress,
|
|
cookiesEnabled: () => false,
|
|
getPrivacySettings: readSettings,
|
|
isProgressStorageEnabled,
|
|
loadRoute,
|
|
markAudioPlayed,
|
|
markPlayed: markAudioPlayed,
|
|
markPlayedAudio: markAudioPlayed,
|
|
markVisited,
|
|
markViewed,
|
|
removeExpiredProgress: cleanupExpiredProgress,
|
|
removeExpiredRouteProgress: cleanupExpiredProgress,
|
|
saveRouteState,
|
|
setProgressStorageEnabled
|
|
});
|
|
}(globalThis.Wegwichtel));
|