image handling update for route creation
Sonarqube Scanner / Build and analyze (push) Failing after 20s

This commit is contained in:
Florian Zumpe
2026-09-27 21:50:52 +02:00
parent 4b8105188f
commit 7f6d0425d5
16 changed files with 831 additions and 182 deletions
+221 -24
View File
@@ -7,6 +7,10 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
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() {
@@ -19,6 +23,181 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
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 [];
@@ -121,7 +300,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const document = parseXml(gpx);
return Array.from(document.getElementsByTagNameNS('*', 'wpt')).map((node, index) => ({
id: waypointId(node),
title: textOfChild(node, 'name') || `Station ${index + 1}`,
title: textOfChild(node, 'name') || `Streckenpunkt ${index + 1}`,
description: textOfChild(node, 'desc'),
lat: numberAttribute(node, 'lat'),
lon: numberAttribute(node, 'lon'),
@@ -176,7 +355,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
return segment;
}
function isManagedTrackPoint(node) {
function isManagedGpxPoint(node) {
return Boolean(firstDescendantByLocalName(node, 'poiId'));
}
@@ -185,32 +364,32 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const document = parseXml(gpx || emptyGpx(routeName));
updateMetadataName(document, routeName);
// Wegwichtel verwaltet neue Streckenpunkte direkt als GPX-Trackpunkte.
// Fremde/importierte Trackpunkte ohne Wegwichtel-ID bleiben erhalten.
// 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(isManagedTrackPoint)
.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 || `Trackpunkt ${index + 1}`;
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 trackPoint = createElement(document, 'trkpt');
trackPoint.setAttribute('lat', String(Number(poi.lat)));
trackPoint.setAttribute('lon', String(Number(poi.lon)));
trackPoint.appendChild(createElement(document, 'name', title));
if (poi.description) trackPoint.appendChild(createElement(document, 'desc', poi.description));
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);
trackPoint.appendChild(extensions);
segment.appendChild(trackPoint);
gpxPoint.appendChild(extensions);
segment.appendChild(gpxPoint);
});
return new XMLSerializer().serializeToString(document);
@@ -268,6 +447,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
};
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
}));
@@ -286,20 +467,28 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} catch {
// Die ursprüngliche Speicherfehlermeldung ist aussagekräftiger.
}
const name = error?.name === 'QuotaExceededError' ? 'Der lokale Browser-Speicher ist voll.' : 'Die lokale Strecke konnte nicht gespeichert werden.';
throw new Error(`${name} Bilder und Audiodateien können das Local-Storage-Limit schnell erreichen.`, { cause: error });
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);
}
function remove(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');
}
@@ -309,9 +498,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function routePoints(gpx) {
const document = parseXml(gpx);
const trackPoints = Array.from(document.getElementsByTagNameNS('*', 'trkpt'));
const gpxPoints = Array.from(document.getElementsByTagNameNS('*', 'trkpt'));
const routePoints = Array.from(document.getElementsByTagNameNS('*', 'rtept'));
const selected = trackPoints.length ? trackPoints : routePoints;
const selected = gpxPoints.length ? gpxPoints : routePoints;
return selected.map(node => {
const elevation = Number.parseFloat(textOfChild(node, 'ele'));
return {
@@ -326,7 +515,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
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 GPX-Trackpunkte.');
throw new Error('Die lokale Strecke enthält noch keine Streckenpunkte im GPX-Track.');
}
return routePackage.gpx;
}
@@ -387,18 +576,18 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function appPoi(poi) {
return {
id: Number(poi.id),
title: poi.title || 'Unbenannte Station',
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?.dataUrl || null,
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.dataUrl
url: image.url || image.dataUrl
})).filter(image => image.url)
};
}
@@ -433,12 +622,16 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
return listPackages().map(toAppRoute);
}
function getAppRoute(id) {
const routePackage = readPackage(id);
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') {
@@ -476,13 +669,17 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
fileToDataUrl,
get: readPackage,
getAppRoute,
getWithMedia,
gpxForUpload,
list: listPackages,
listAppRoutes,
mediaBlob,
parseWaypoints,
readFileAsText,
remove,
removeMedia,
save: savePackage,
storeMediaFile,
syncGpx,
toAppRoute
});