image handling update for route creation
Sonarqube Scanner / Build and analyze (push) Failing after 20s
Sonarqube Scanner / Build and analyze (push) Failing after 20s
This commit is contained in:
+221
-24
@@ -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
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user