Files
Wegwichtel/public/js/local-routes.js
T
Florian Zumpe 7f6d0425d5
Sonarqube Scanner / Build and analyze (push) Failing after 20s
image handling update for route creation
2026-09-27 21:50:52 +02:00

687 lines
24 KiB
JavaScript

globalThis.Wegwichtel = globalThis.Wegwichtel || {};
(function (ns) {
'use strict';
const STORAGE_PREFIX = 'wegwichtel.local-routes.v1';
const INDEX_KEY = `${STORAGE_PREFIX}.index`;
const GPX_NAMESPACE = 'http://www.topografix.com/GPX/1/1';
const POI_NAMESPACE = 'urn:wegwichtel:poi';
const MEDIA_DB_NAME = 'wegwichtel.local-routes.media.v1';
const MEDIA_DB_VERSION = 1;
const MEDIA_STORE_NAME = 'media';
let mediaDatabasePromise = null;
let poiCounter = 0;
function storage() {
const target = globalThis.localStorage;
if (!target) throw new Error('Lokaler Browser-Speicher ist auf diesem Gerät nicht verfügbar.');
return target;
}
function routeKey(id, suffix) {
return `${STORAGE_PREFIX}.${encodeURIComponent(String(id))}.${suffix}`;
}
function openMediaDatabase() {
if (mediaDatabasePromise) return mediaDatabasePromise;
const indexedDb = globalThis.indexedDB;
if (!indexedDb || typeof indexedDb.open !== 'function') {
return Promise.reject(new Error('Der lokale Medienspeicher (IndexedDB) ist in diesem Browser nicht verfügbar.'));
}
mediaDatabasePromise = new Promise((resolve, reject) => {
const request = indexedDb.open(MEDIA_DB_NAME, MEDIA_DB_VERSION);
request.onupgradeneeded = () => {
const database = request.result;
if (!database.objectStoreNames.contains(MEDIA_STORE_NAME)) {
database.createObjectStore(MEDIA_STORE_NAME, { keyPath: 'key' });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => {
mediaDatabasePromise = null;
reject(request.error || new Error('Der lokale Medienspeicher konnte nicht geöffnet werden.'));
};
request.onblocked = () => {
mediaDatabasePromise = null;
reject(new Error('Der lokale Medienspeicher ist durch eine andere Browser-Sitzung blockiert.'));
};
});
return mediaDatabasePromise;
}
async function mediaStoreRequest(mode, operation) {
const database = await openMediaDatabase();
return new Promise((resolve, reject) => {
let request;
try {
const transaction = database.transaction(MEDIA_STORE_NAME, mode);
request = operation(transaction.objectStore(MEDIA_STORE_NAME));
request.onsuccess = () => resolve(request.result ?? null);
request.onerror = () => reject(request.error || new Error('Der lokale Medienspeicher konnte nicht gelesen oder geschrieben werden.'));
transaction.onabort = () => reject(transaction.error || new Error('Die Medien-Speicheroperation wurde abgebrochen.'));
} catch (error) {
reject(error);
}
});
}
function mediaKey(routeId, poiId, kind, mediaId) {
return [routeId, poiId, kind, mediaId]
.map(value => encodeURIComponent(String(value)))
.join(':');
}
async function putMediaRecord(record) {
await mediaStoreRequest('readwrite', store => store.put(record));
}
async function storeMediaFile(routeId, poiId, kind, mediaId, file, fileHandle = null) {
if (!file) return null;
const key = mediaKey(routeId, poiId, kind, mediaId);
const metadata = {
key,
name: file.name || 'Datei',
size: Number(file.size) || 0,
type: file.type || 'application/octet-stream'
};
if (fileHandle && typeof fileHandle.getFile === 'function') {
try {
await putMediaRecord({ ...metadata, storage: 'file-handle', handle: fileHandle });
return {
id: String(mediaId),
mediaKey: key,
storage: 'file-handle',
name: metadata.name,
size: metadata.size,
type: metadata.type
};
} catch (error) {
if (error?.name === 'QuotaExceededError') {
throw new Error('Der lokale Medienspeicher ist voll. Entferne nicht mehr benötigte lokale Medien oder Strecken.', { cause: error });
}
// Manche Browser können FileSystemFileHandle noch nicht in IndexedDB serialisieren.
// In diesem Fall wird die Datei als Blob in IndexedDB gespeichert.
}
}
try {
await putMediaRecord({ ...metadata, storage: 'indexeddb', blob: file });
} catch (error) {
const prefix = error?.name === 'QuotaExceededError'
? 'Der lokale Medienspeicher ist voll.'
: 'Die Mediendatei konnte nicht lokal gespeichert werden.';
throw new Error(`${prefix} Entferne nicht mehr benötigte lokale Medien oder Strecken.`, { cause: error });
}
return {
id: String(mediaId),
mediaKey: key,
storage: 'indexeddb',
name: metadata.name,
size: metadata.size,
type: metadata.type
};
}
async function fileFromHandle(handle, requestPermission = false) {
if (!handle || typeof handle.getFile !== 'function') return null;
let permission = 'granted';
if (typeof handle.queryPermission === 'function') {
permission = await handle.queryPermission({ mode: 'read' });
}
if (permission !== 'granted' && requestPermission && typeof handle.requestPermission === 'function') {
permission = await handle.requestPermission({ mode: 'read' });
}
if (permission !== 'granted') {
throw new Error('Der Zugriff auf eine lokale Mediendatei muss erneut erlaubt werden.');
}
try {
return await handle.getFile();
} catch (error) {
throw new Error('Eine referenzierte Mediendatei ist auf dem Gerät nicht mehr verfügbar.', { cause: error });
}
}
async function mediaBlob(media, options = {}) {
if (!media) return null;
if (media.mediaKey) {
const record = await mediaStoreRequest('readonly', store => store.get(media.mediaKey));
if (record?.handle) return fileFromHandle(record.handle, options.requestPermission === true);
if (record?.blob) return record.blob;
return null;
}
if (media.dataUrl && typeof globalThis.fetch === 'function') {
const response = await globalThis.fetch(media.dataUrl);
if (response.ok && typeof response.blob === 'function') return response.blob();
}
return null;
}
async function removeMedia(media) {
if (!media?.mediaKey) return;
await mediaStoreRequest('readwrite', store => store.delete(media.mediaKey));
}
async function mediaForUse(media, options = {}) {
if (!media || media.dataUrl || media.url) return media;
try {
const blob = await mediaBlob(media, options);
if (!blob) return { ...media, unavailable: true };
if (typeof globalThis.URL?.createObjectURL === 'function') {
return { ...media, url: globalThis.URL.createObjectURL(blob), unavailable: false };
}
const fallback = await fileToDataUrl(blob);
return { ...media, dataUrl: fallback.dataUrl, unavailable: false };
} catch {
return { ...media, unavailable: true };
}
}
async function withMedia(routePackage, options = {}) {
if (!routePackage) return null;
const pois = await Promise.all((routePackage.pois || []).map(async poi => ({
...poi,
audio: await mediaForUse(poi.audio, options),
images: await Promise.all((poi.images || []).map(media => mediaForUse(media, options)))
})));
return { ...routePackage, pois };
}
function mediaForStorage(media) {
if (!media) return null;
const stored = { ...media };
delete stored.url;
delete stored.blob;
if (stored.storage === 'indexeddb' || stored.storage === 'file-handle') delete stored.dataUrl;
return stored;
}
function readIndex() {
const raw = storage().getItem(INDEX_KEY);
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.map(String) : [];
} catch {
return [];
}
}
function writeIndex(ids) {
storage().setItem(INDEX_KEY, JSON.stringify([...new Set(ids.map(String))]));
}
function uuidFromRandomValues(cryptoApi) {
const bytes = new Uint8Array(16);
cryptoApi.getRandomValues(bytes);
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0'));
return [
hex.slice(0, 4).join(''),
hex.slice(4, 6).join(''),
hex.slice(6, 8).join(''),
hex.slice(8, 10).join(''),
hex.slice(10, 16).join('')
].join('-');
}
function createRouteId() {
const cryptoApi = globalThis.crypto;
if (typeof cryptoApi?.randomUUID === 'function') {
return `local-${cryptoApi.randomUUID()}`;
}
if (typeof cryptoApi?.getRandomValues === 'function') {
return `local-${uuidFromRandomValues(cryptoApi)}`;
}
throw new Error('Keine sichere Zufallsquelle für eine lokale Strecken-ID verfügbar.');
}
function createPoiId() {
poiCounter = (poiCounter + 1) % 1000;
return (Date.now() * 1000) + poiCounter;
}
function escapeXml(value) {
return String(value ?? '')
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
function emptyGpx(name = 'Neue Strecke') {
const safeName = escapeXml(name);
return `<?xml version="1.0" encoding="UTF-8"?>\n`
+ `<gpx version="1.1" creator="Wegwichtel" xmlns="${GPX_NAMESPACE}" xmlns:wegwichtel="${POI_NAMESPACE}">\n`
+ ` <metadata><name>${safeName}</name></metadata>\n`
+ ` <trk><name>${safeName}</name><trkseg></trkseg></trk>\n`
+ '</gpx>';
}
function parseXml(gpx) {
const parser = new DOMParser();
const document = parser.parseFromString(String(gpx || ''), 'application/xml');
if (document.querySelector('parsererror') || document.documentElement?.localName !== 'gpx') {
throw new Error('Die GPX-Datei ist nicht gültig.');
}
return document;
}
function directChildrenByLocalName(parent, localName) {
return Array.from(parent?.children || []).filter(node => node.localName === localName);
}
function firstDescendantByLocalName(parent, localName) {
return Array.from(parent?.getElementsByTagName('*') || []).find(node => node.localName === localName) || null;
}
function textOfChild(parent, localName) {
return directChildrenByLocalName(parent, localName)[0]?.textContent?.trim() || '';
}
function numberAttribute(node, name) {
const value = Number.parseFloat(node?.getAttribute(name));
return Number.isFinite(value) ? value : null;
}
function waypointId(node) {
const extension = firstDescendantByLocalName(node, 'poiId');
const parsed = Number(extension?.textContent);
return Number.isSafeInteger(parsed) ? parsed : createPoiId();
}
function parseWaypoints(gpx) {
const document = parseXml(gpx);
return Array.from(document.getElementsByTagNameNS('*', 'wpt')).map((node, index) => ({
id: waypointId(node),
title: textOfChild(node, 'name') || `Streckenpunkt ${index + 1}`,
description: textOfChild(node, 'desc'),
lat: numberAttribute(node, 'lat'),
lon: numberAttribute(node, 'lon'),
triggerRadiusM: 25,
sequence: index,
audio: null,
images: []
})).filter(poi => poi.lat != null && poi.lon != null);
}
function createElement(document, localName, text = null) {
const element = document.createElementNS(GPX_NAMESPACE, localName);
if (text != null) element.textContent = String(text);
return element;
}
function updateMetadataName(document, name) {
const root = document.documentElement;
let metadata = directChildrenByLocalName(root, 'metadata')[0];
if (!metadata) {
metadata = createElement(document, 'metadata');
root.insertBefore(metadata, root.firstChild);
}
let nameElement = directChildrenByLocalName(metadata, 'name')[0];
if (!nameElement) {
nameElement = createElement(document, 'name');
metadata.appendChild(nameElement);
}
nameElement.textContent = name;
}
function ensureTrackSegment(document, routeName) {
const root = document.documentElement;
let track = directChildrenByLocalName(root, 'trk')[0];
if (!track) {
track = createElement(document, 'trk');
root.appendChild(track);
}
let trackName = directChildrenByLocalName(track, 'name')[0];
if (!trackName) {
trackName = createElement(document, 'name');
track.insertBefore(trackName, track.firstChild);
}
trackName.textContent = routeName;
let segment = directChildrenByLocalName(track, 'trkseg')[0];
if (!segment) {
segment = createElement(document, 'trkseg');
track.appendChild(segment);
}
return segment;
}
function isManagedGpxPoint(node) {
return Boolean(firstDescendantByLocalName(node, 'poiId'));
}
function syncGpx(gpx, route, pois) {
const routeName = route?.name || 'Neue Strecke';
const document = parseXml(gpx || emptyGpx(routeName));
updateMetadataName(document, routeName);
// Wegwichtel verwaltet neue Streckenpunkte direkt als Punkte im GPX-Track.
// Fremde/importierte GPX-Punkte ohne Wegwichtel-ID bleiben erhalten.
Array.from(document.getElementsByTagNameNS('*', 'wpt')).forEach(node => node.remove());
Array.from(document.getElementsByTagNameNS('*', 'trkpt'))
.filter(isManagedGpxPoint)
.forEach(node => node.remove());
const segment = ensureTrackSegment(document, routeName);
const orderedPois = [...(pois || [])].sort((left, right) => Number(left.sequence) - Number(right.sequence));
orderedPois.forEach((poi, index) => {
const title = poi.title || `Streckenpunkt ${index + 1}`;
if (!Number.isFinite(Number(poi.lat)) || !Number.isFinite(Number(poi.lon))) {
throw new TypeError(`Für ${title} fehlen gültige Koordinaten.`);
}
const gpxPoint = createElement(document, 'trkpt');
gpxPoint.setAttribute('lat', String(Number(poi.lat)));
gpxPoint.setAttribute('lon', String(Number(poi.lon)));
gpxPoint.appendChild(createElement(document, 'name', title));
if (poi.description) gpxPoint.appendChild(createElement(document, 'desc', poi.description));
const extensions = createElement(document, 'extensions');
const poiId = document.createElementNS(POI_NAMESPACE, 'wegwichtel:poiId');
poiId.textContent = String(poi.id);
extensions.appendChild(poiId);
gpxPoint.appendChild(extensions);
segment.appendChild(gpxPoint);
});
return new XMLSerializer().serializeToString(document);
}
function readPackage(id) {
const target = storage();
const gpx = target.getItem(routeKey(id, 'gpx'));
const rawJson = target.getItem(routeKey(id, 'json'));
if (!gpx || !rawJson) return null;
try {
const metadata = JSON.parse(rawJson);
return {
id: String(id),
gpx,
route: metadata.route || {},
pois: Array.isArray(metadata.pois) ? metadata.pois : [],
schemaVersion: metadata.schemaVersion || 1
};
} catch {
return null;
}
}
function restoreValue(target, key, value) {
if (value == null) target.removeItem(key);
else target.setItem(key, value);
}
function notify(id, action) {
if (typeof document === 'undefined' || typeof CustomEvent === 'undefined') return;
document.dispatchEvent(new CustomEvent('wegwichtel:local-routes-changed', {
detail: { action, id: String(id) }
}));
}
function savePackage(routePackage) {
const target = storage();
const id = String(routePackage.id || routePackage.route?.id || createRouteId());
const previousIndex = target.getItem(INDEX_KEY);
const gpxKey = routeKey(id, 'gpx');
const jsonKey = routeKey(id, 'json');
const previousGpx = target.getItem(gpxKey);
const previousJson = target.getItem(jsonKey);
const now = new Date().toISOString();
const inputRoute = routePackage.route || {};
const route = {
id,
name: String(inputRoute.name || ''),
description: String(inputRoute.description || ''),
createdAt: inputRoute.createdAt || now,
updatedAt: now,
serverRouteId: inputRoute.serverRouteId ?? null,
uploadedAt: inputRoute.uploadedAt || null
};
const pois = (routePackage.pois || []).map((poi, index) => ({
...poi,
audio: mediaForStorage(poi.audio),
images: (poi.images || []).map(mediaForStorage),
id: Number.isSafeInteger(Number(poi.id)) ? Number(poi.id) : createPoiId(),
sequence: index
}));
const gpx = syncGpx(routePackage.gpx || emptyGpx(route.name), route, pois);
const metadata = JSON.stringify({ schemaVersion: 1, route, pois });
try {
target.setItem(gpxKey, gpx);
target.setItem(jsonKey, metadata);
writeIndex([...readIndex(), id]);
} catch (error) {
try {
restoreValue(target, gpxKey, previousGpx);
restoreValue(target, jsonKey, previousJson);
restoreValue(target, INDEX_KEY, previousIndex);
} catch {
// Die ursprüngliche Speicherfehlermeldung ist aussagekräftiger.
}
const name = error?.name === 'QuotaExceededError'
? 'Der lokale Speicher für Streckendaten ist voll.'
: 'Die lokale Strecke konnte nicht gespeichert werden.';
throw new Error(`${name} Entferne nicht mehr benötigte lokale Strecken.`, { cause: error });
}
notify(id, 'saved');
return readPackage(id);
}
async function remove(id) {
const target = storage();
const routeId = String(id);
const routePackage = readPackage(routeId);
target.removeItem(routeKey(routeId, 'gpx'));
target.removeItem(routeKey(routeId, 'json'));
writeIndex(readIndex().filter(item => item !== routeId));
if (routePackage) {
const media = routePackage.pois.flatMap(poi => [poi.audio, ...(poi.images || [])]).filter(Boolean);
await Promise.allSettled(media.map(removeMedia));
}
notify(routeId, 'removed');
}
function listPackages() {
return readIndex().map(readPackage).filter(Boolean);
}
function routePoints(gpx) {
const document = parseXml(gpx);
const gpxPoints = Array.from(document.getElementsByTagNameNS('*', 'trkpt'));
const routePoints = Array.from(document.getElementsByTagNameNS('*', 'rtept'));
const selected = gpxPoints.length ? gpxPoints : routePoints;
return selected.map(node => {
const elevation = Number.parseFloat(textOfChild(node, 'ele'));
return {
lat: numberAttribute(node, 'lat'),
lon: numberAttribute(node, 'lon'),
elevation: Number.isFinite(elevation) ? elevation : null,
recordedAt: textOfChild(node, 'time') || null
};
}).filter(point => point.lat != null && point.lon != null);
}
function gpxForUpload(routePackage) {
if (!routePackage?.gpx) throw new Error('Für die lokale Strecke ist keine GPX-Datei vorhanden.');
if (!routePoints(routePackage.gpx).length) {
throw new Error('Die lokale Strecke enthält noch keine Streckenpunkte im GPX-Track.');
}
return routePackage.gpx;
}
function radians(value) {
return value * (Math.PI / 180);
}
function meters(left, right) {
const earthRadius = 6371008.8;
const latDelta = radians(right.lat - left.lat);
const lonDelta = radians(right.lon - left.lon);
const leftLat = radians(left.lat);
const rightLat = radians(right.lat);
const value = Math.sin(latDelta / 2) ** 2
+ Math.cos(leftLat) * Math.cos(rightLat) * (Math.sin(lonDelta / 2) ** 2);
return earthRadius * 2 * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value));
}
function metrics(points) {
if (!points.length) {
return {
bounds: null,
center: null,
distanceM: 0,
elevationGainM: 0,
start: null
};
}
let distanceM = 0;
let elevationGainM = 0;
for (let index = 1; index < points.length; index += 1) {
distanceM += meters(points[index - 1], points[index]);
const previous = points[index - 1].elevation;
const current = points[index].elevation;
if (Number.isFinite(previous) && Number.isFinite(current) && current > previous) elevationGainM += current - previous;
}
const lats = points.map(point => point.lat);
const lons = points.map(point => point.lon);
const bounds = {
minLat: Math.min(...lats),
minLon: Math.min(...lons),
maxLat: Math.max(...lats),
maxLon: Math.max(...lons)
};
return {
bounds,
center: {
lat: (bounds.minLat + bounds.maxLat) / 2,
lon: (bounds.minLon + bounds.maxLon) / 2
},
distanceM,
elevationGainM,
start: { lat: points[0].lat, lon: points[0].lon }
};
}
function appPoi(poi) {
return {
id: Number(poi.id),
title: poi.title || 'Unbenannter Streckenpunkt',
description: poi.description || '',
lat: Number(poi.lat),
lon: Number(poi.lon),
triggerRadiusM: Number(poi.triggerRadiusM) || 25,
sequence: Number(poi.sequence) || 0,
audioUrl: poi.audio?.url || poi.audio?.dataUrl || null,
images: (poi.images || []).map((image, index) => ({
id: image.id || `${poi.id}-${index}`,
caption: image.caption || '',
sequence: index,
url: image.url || image.dataUrl
})).filter(image => image.url)
};
}
function toAppRoute(routePackage) {
const pois = (routePackage.pois || []).map(appPoi).sort((left, right) => left.sequence - right.sequence);
const parsedPoints = routePoints(routePackage.gpx);
const points = parsedPoints.length
? parsedPoints
: pois.map(poi => ({ lat: poi.lat, lon: poi.lon, elevation: null, recordedAt: null }));
const routeMetrics = metrics(points);
return {
id: String(routePackage.id),
source: 'local',
local: true,
name: routePackage.route?.name || 'Unbenannte Strecke',
description: routePackage.route?.description || '',
schoolName: 'Lokal gespeichert',
status: 'local',
activatedAt: null,
points,
pois,
pointCount: points.length,
gpxUrl: null,
createdAt: routePackage.route?.createdAt || null,
updatedAt: routePackage.route?.updatedAt || null,
...routeMetrics
};
}
function listAppRoutes() {
return listPackages().map(toAppRoute);
}
async function getAppRoute(id, options = {}) {
const routePackage = await withMedia(readPackage(id), options);
if (!routePackage) throw new Error('Die lokale Strecke wurde nicht gefunden.');
return toAppRoute(routePackage);
}
async function getWithMedia(id, options = {}) {
return withMedia(readPackage(id), options);
}
function readFileAsText(file) {
if (!file) return Promise.resolve('');
if (typeof file.text !== 'function') {
return Promise.reject(new TypeError('Die ausgewählte Datei unterstützt Blob.text() nicht.'));
}
return file.text();
}
function fileToDataUrl(file) {
if (!file) return Promise.resolve(null);
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener('load', () => {
if (typeof reader.result !== 'string') {
reject(new TypeError('Die Datei konnte nicht als Data-URL gelesen werden.'));
return;
}
resolve({
dataUrl: reader.result,
name: file.name || 'Datei',
size: Number(file.size) || 0,
type: file.type || 'application/octet-stream'
});
}, { once: true });
reader.addEventListener('error', () => reject(reader.error || new Error('Datei konnte nicht gelesen werden.')), { once: true });
reader.readAsDataURL(file);
});
}
ns.LocalRoutes = Object.freeze({
STORAGE_PREFIX,
createPoiId,
createRouteId,
emptyGpx,
fileToDataUrl,
get: readPackage,
getAppRoute,
getWithMedia,
gpxForUpload,
list: listPackages,
listAppRoutes,
mediaBlob,
parseWaypoints,
readFileAsText,
remove,
removeMedia,
save: savePackage,
storeMediaFile,
syncGpx,
toAppRoute
});
}(globalThis.Wegwichtel));