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(ns.I18n.t("local.error.route-storage")); 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(ns.I18n.t("local.error.media-storage"))); } 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(ns.I18n.t("local.error.media-read"))); }; request.onblocked = () => { mediaDatabasePromise = null; reject(new Error(ns.I18n.t("local.error.other-tabs"))); }; }); 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(ns.I18n.t("local.error.media-access"))); transaction.onabort = () => reject(transaction.error || new Error(ns.I18n.t("local.error.media-cancelled"))); } 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(ns.I18n.t("local.error.media-quota-hint"), { 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' ? ns.I18n.t("local.error.media-quota") : ns.I18n.t("local.error.media-save"); throw new Error(ns.I18n.t("local.error.media-cleanup-hint", { value1: prefix }), { 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(ns.I18n.t("local.error.file-permission")); } try { return await handle.getFile(); } catch (error) { throw new Error(ns.I18n.t("local.error.file-missing"), { 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(ns.I18n.t("local.error.create-route")); } function createPoiId() { poiCounter = (poiCounter + 1) % 1000; return (Date.now() * 1000) + poiCounter; } function escapeXml(value) { return String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function emptyGpx(name = ns.I18n.t("editor.new-route")) { const safeName = escapeXml(name); return `\n` + `\n` + ` ${safeName}\n` + ` ${safeName}\n` + ''; } 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(ns.I18n.t("local.error.invalid-track")); } 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')), ...Array.from(document.getElementsByTagNameNS('*', 'pendingWaypoint'))].map((node, index) => ({ id: waypointId(node), title: textOfChild(node, 'name') || ns.I18n.t("editor.station-number", { value1: index + 1 }), description: textOfChild(node, 'desc'), lat: numberAttribute(node, 'lat'), lon: numberAttribute(node, 'lon'), triggerRadiusM: 25, sequence: index, audio: null, images: [] })); } 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.insertBefore(track, directChildrenByLocalName(root, 'extensions')[0] || null); } 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 || ns.I18n.t("editor.new-route"); const document = parseXml(gpx || emptyGpx(routeName)); updateMetadataName(document, routeName); // Stationen sind Wegpunkte; aufgezeichnete Trackpunkte bleiben getrennt. // Fremde/importierte GPX-Punkte ohne Wegwichtel-ID bleiben erhalten. Array.from(document.getElementsByTagNameNS('*', 'wpt')).forEach(node => node.remove()); Array.from(document.getElementsByTagNameNS('*', 'pendingWaypoint')).forEach(node => node.remove()); Array.from(document.getElementsByTagNameNS('*', 'trkpt')) .filter(isManagedGpxPoint) .forEach(node => node.remove()); const root = document.documentElement; const following = Array.from(root.children).find(node => ['rte', 'trk', 'extensions'].includes(node.localName)); const orderedPois = [...(pois || [])].sort((left, right) => Number(left.sequence) - Number(right.sequence)); orderedPois.forEach((poi, index) => { const title = poi.title || ns.I18n.t("editor.station-number", { value1: index + 1 }); const located = poi.lat != null && poi.lon != null && Number.isFinite(Number(poi.lat)) && Number.isFinite(Number(poi.lon)); const gpxPoint = located ? createElement(document, 'wpt') : document.createElementNS(POI_NAMESPACE, 'wegwichtel:pendingWaypoint'); if (located) { 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); if (located) root.insertBefore(gpxPoint, following || null); else { let pending = directChildrenByLocalName(root, 'extensions')[0]; if (!pending) { pending = createElement(document, 'extensions'); root.appendChild(pending); } pending.appendChild(gpxPoint); } }); return new XMLSerializer().serializeToString(document); } function appendTrackPoint(gpx, position, routeName) { const document = parseXml(gpx); appendPosition(document, position, routeName); return new XMLSerializer().serializeToString(document); } function appendPosition(document, position, routeName) { const lat = position.lat == null ? Number.NaN : Number(position.lat); const lon = position.lon == null ? Number.NaN : Number(position.lon); const timestamp = new Date(position.timestamp ?? Date.now()).toISOString(); if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) { const root = document.documentElement; let extensions = directChildrenByLocalName(root, 'extensions')[0]; if (!extensions) { extensions = createElement(document, 'extensions'); root.appendChild(extensions); } const gap = document.createElementNS(POI_NAMESPACE, 'wegwichtel:gpsGap'); gap.setAttribute('time', timestamp); extensions.appendChild(gap); return; } const point = createElement(document, 'trkpt'); point.setAttribute('lat', String(lat)); point.setAttribute('lon', String(lon)); if (Number.isFinite(position.altitude)) point.appendChild(createElement(document, 'ele', position.altitude)); point.appendChild(createElement(document, 'time', timestamp)); ensureTrackSegment(document, routeName).appendChild(point); } function recordedPositions(gpx) { const document = parseXml(gpx); const routes = Array.from(document.getElementsByTagNameNS('*', 'rtept')); const tracks = Array.from(document.getElementsByTagNameNS('*', 'trkpt')).filter(node => !isManagedGpxPoint(node)); const nodes = routes.length ? routes : tracks; const positions = nodes.map(node => ({ lat: numberAttribute(node, 'lat'), lon: numberAttribute(node, 'lon'), altitude: Number.parseFloat(textOfChild(node, 'ele')), timestamp: textOfChild(node, 'time') || null })); for (const node of Array.from(document.getElementsByTagNameNS('*', 'gpsGap'))) { positions.push({ lat: null, lon: null, altitude: null, timestamp: node.getAttribute('time') }); } return positions.sort((left, right) => left.timestamp && right.timestamp ? Date.parse(left.timestamp) - Date.parse(right.timestamp) : 0); } function replaceRecordedPositions(gpx, positions, routeName) { const document = parseXml(gpx); for (const name of ['trkpt', 'rtept', 'gpsGap']) { Array.from(document.getElementsByTagNameNS('*', name)).forEach(node => node.remove()); } for (const position of positions) appendPosition(document, { ...position, timestamp: position.timestamp ? Date.parse(position.timestamp) : Date.now() }, routeName); 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. } ns.StorageUsage?.reportError(error); const name = error?.name === 'QuotaExceededError' ? ns.I18n.t("local.error.route-quota") : ns.I18n.t("local.error.save-route"); throw new Error(ns.I18n.t("local.error.route-cleanup-hint", { value1: name }), { 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 = routePoints.length ? routePoints : gpxPoints.filter(node => !isManagedGpxPoint(node)); 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(ns.I18n.t("local.error.path-missing")); if (!routePoints(routePackage.gpx).length && !parseWaypoints(routePackage.gpx).length && !recordedPositions(routePackage.gpx).length) { throw new Error(ns.I18n.t("local.error.path-empty")); } 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 || ns.I18n.t("editor.untitled-station"), description: poi.description || '', lat: poi.lat == null ? null : Number(poi.lat), lon: poi.lon == null ? null : 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; const routeMetrics = metrics(points.length ? points : pois.filter(poi => poi.lat != null && poi.lon != null)); if (!points.length) { routeMetrics.distanceM = 0; routeMetrics.elevationGainM = 0; } return { id: String(routePackage.id), source: 'local', local: true, name: routePackage.route?.name || ns.I18n.t("editor.untitled-route"), description: routePackage.route?.description || '', schoolName: ns.I18n.t("local.device"), 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(ns.I18n.t("local.error.route-missing")); 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(ns.I18n.t("local.error.file-unsupported"))); } 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(ns.I18n.t("local.error.file-read"))); 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(ns.I18n.t("file.read.failed"))), { once: true }); reader.readAsDataURL(file); }); } ns.LocalRoutes = Object.freeze({ STORAGE_PREFIX, appendTrackPoint, recordedPositions, replaceRecordedPositions, createPoiId, createRouteId, emptyGpx, fileToDataUrl, get: readPackage, getAppRoute, getWithMedia, gpxForUpload, list: listPackages, listAppRoutes, mediaBlob, parseWaypoints, readFileAsText, remove, removeMedia, save: savePackage, storeMediaFile, syncGpx, toAppRoute }); }(globalThis.Wegwichtel));