809 lines
29 KiB
JavaScript
809 lines
29 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;
|
|
const objectUrls = new Set();
|
|
|
|
function storage() {
|
|
const target = globalThis.localStorage;
|
|
if (!target) throw new Error(ns.I18n.t("local.error.route-storage"));
|
|
return target;
|
|
}
|
|
|
|
// Separate from app/route storage: ordinary cleanup must preserve this identity.
|
|
const CREATOR_KEY = 'wegwichtel.creator-id.v1';
|
|
|
|
function creatorId() {
|
|
const target = storage();
|
|
const existing = target.getItem(CREATOR_KEY);
|
|
if (existing) {
|
|
if (!/^[a-f0-9]{64}$/.test(existing)) throw new Error(ns.I18n.t("local.error.identity"));
|
|
return existing;
|
|
}
|
|
if (typeof globalThis.crypto?.getRandomValues !== 'function') {
|
|
throw new TypeError(ns.I18n.t("local.error.identity"));
|
|
}
|
|
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(32));
|
|
const value = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
|
target.setItem(CREATOR_KEY, value);
|
|
return value;
|
|
}
|
|
|
|
function forgetCreatorId() {
|
|
if (globalThis.confirm?.(ns.I18n.t("local.confirm-forget-identity")) !== true) return false;
|
|
storage().removeItem(CREATOR_KEY);
|
|
return true;
|
|
}
|
|
|
|
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') {
|
|
const url = globalThis.URL.createObjectURL(blob);
|
|
objectUrls.add(url);
|
|
return { ...media, url, unavailable: false };
|
|
}
|
|
const fallback = await fileToDataUrl(blob);
|
|
return { ...media, dataUrl: fallback.dataUrl, unavailable: false };
|
|
} catch {
|
|
return { ...media, unavailable: true };
|
|
}
|
|
}
|
|
|
|
async function poiWithMedia(poi, options = {}) {
|
|
if (!poi) return null;
|
|
return {
|
|
...poi,
|
|
audio: await mediaForUse(poi.audio, options),
|
|
images: await Promise.all((poi.images || []).map(media => mediaForUse(media, options)))
|
|
};
|
|
}
|
|
|
|
function releasePoiMedia(poi) {
|
|
const urls = [poi?.audio?.url, poi?.audioUrl, ...(poi?.images || []).map(image => image.url)];
|
|
for (const url of urls) {
|
|
if (!objectUrls.delete(url)) continue;
|
|
globalThis.URL?.revokeObjectURL?.(url);
|
|
}
|
|
}
|
|
|
|
async function withMedia(routePackage, options = {}) {
|
|
if (!routePackage) return null;
|
|
const pois = await Promise.all((routePackage.pois || []).map(poi => poiWithMedia(poi, options)));
|
|
return { ...routePackage, pois };
|
|
}
|
|
|
|
async function getPoiWithMedia(id, poiId, options = {}) {
|
|
const poi = readPackage(id)?.pois.find(item => String(item.id) === String(poiId));
|
|
return poiWithMedia(poi, options);
|
|
}
|
|
|
|
async function getAppPoi(id, poiId, options = {}) {
|
|
const poi = await getPoiWithMedia(id, poiId, options);
|
|
return poi ? appPoi(poi) : null;
|
|
}
|
|
|
|
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 `<?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(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,
|
|
creator_id: inputRoute.creator_id || creatorId(),
|
|
name: String(inputRoute.name || ''),
|
|
description: String(inputRoute.description || ''),
|
|
createdAt: inputRoute.createdAt || now,
|
|
updatedAt: now,
|
|
serverRouteId: inputRoute.serverRouteId ?? null,
|
|
submissionStatus: ['rejected', 'removed'].includes(inputRoute.submissionStatus) ? inputRoute.submissionStatus : 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) {
|
|
const routePackage = readPackage(id);
|
|
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,
|
|
creatorId,
|
|
forgetCreatorId,
|
|
appendTrackPoint,
|
|
recordedPositions,
|
|
replaceRecordedPositions,
|
|
createPoiId,
|
|
createRouteId,
|
|
emptyGpx,
|
|
fileToDataUrl,
|
|
get: readPackage,
|
|
getAppRoute,
|
|
getWithMedia,
|
|
getPoiWithMedia,
|
|
getAppPoi,
|
|
releasePoiMedia,
|
|
gpxForUpload,
|
|
list: listPackages,
|
|
listAppRoutes,
|
|
mediaBlob,
|
|
parseWaypoints,
|
|
readFileAsText,
|
|
remove,
|
|
removeMedia,
|
|
save: savePackage,
|
|
storeMediaFile,
|
|
syncGpx,
|
|
toAppRoute
|
|
});
|
|
}(globalThis.Wegwichtel));
|