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:
+13
-13
@@ -161,11 +161,11 @@
|
||||
title.text(state.route.name || 'Route');
|
||||
const metric = nextPoiMetric();
|
||||
if (!metric) {
|
||||
next.text((state.route.pois || []).length ? 'Alle POIs erreicht' : 'Keine POIs vorhanden');
|
||||
next.text((state.route.pois || []).length ? 'Alle Streckenpunkte erreicht' : 'Keine Streckenpunkte vorhanden');
|
||||
return;
|
||||
}
|
||||
const distance = metric.distance == null ? '' : ` · ${Math.round(metric.distance)} m`;
|
||||
next.text(`Nächster POI: ${metric.poi.title}${distance}`);
|
||||
next.text(`Nächster Streckenpunkt: ${metric.poi.title}${distance}`);
|
||||
}
|
||||
|
||||
function renderRouteControlList(selector, routes, source, emptyText) {
|
||||
@@ -377,7 +377,7 @@
|
||||
updateRouteControls();
|
||||
const list = $('#poi-list').empty();
|
||||
if (!route.pois.length) {
|
||||
list.append('<li><p>Diese Route enthält noch keine Stationen.</p></li>');
|
||||
list.append('<li><p>Diese Route enthält noch keine Streckenpunkte.</p></li>');
|
||||
updateRouteControlSummary();
|
||||
return;
|
||||
}
|
||||
@@ -530,13 +530,13 @@
|
||||
$('#navigation-arrow').css('transform', `rotate(${metric.rotation.toFixed(1)}deg)`);
|
||||
showNavigation(
|
||||
heading == null
|
||||
? `Norden oben · nächster POI: ${metric.poi.title}`
|
||||
: `Nächster POI: ${metric.poi.title}`,
|
||||
? `Norden oben · nächster Streckenpunkt: ${metric.poi.title}`
|
||||
: `Nächster Streckenpunkt: ${metric.poi.title}`,
|
||||
`${distance} m`
|
||||
);
|
||||
$('#navigation-footer').attr(
|
||||
'aria-label',
|
||||
`Nächster POI ${metric.poi.title} in ${distance} Metern`
|
||||
`Nächster Streckenpunkt ${metric.poi.title} in ${distance} Metern`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -549,8 +549,8 @@
|
||||
|
||||
if (progress.reached) {
|
||||
$('#navigation-arrow').css('transform', 'rotate(0deg)');
|
||||
showNavigation('Alle Stationen und das Routenziel erreicht', '0 m');
|
||||
$('#navigation-footer').attr('aria-label', 'Alle Stationen und das Routenziel erreicht');
|
||||
showNavigation('Alle Streckenpunkte und das Routenziel erreicht', '0 m');
|
||||
$('#navigation-footer').attr('aria-label', 'Alle Streckenpunkte und das Routenziel erreicht');
|
||||
return;
|
||||
}
|
||||
const bearing = ns.Distance.bearing(position, progress.target);
|
||||
@@ -602,7 +602,7 @@
|
||||
updateNavigation(position);
|
||||
$('#tracking-status').text(
|
||||
`GPS aktiv · Genauigkeit ${Math.round(position.accuracy)} m`
|
||||
+ (nearest ? ` · nächster POI ${Math.round(nearest.distance)} m` : ' · alle POIs erreicht')
|
||||
+ (nearest ? ` · nächster Streckenpunkt ${Math.round(nearest.distance)} m` : ' · alle Streckenpunkte erreicht')
|
||||
);
|
||||
if (nearest && nearest.distance <= nearest.poi.triggerRadiusM) {
|
||||
state.triggered.add(nearest.poi.id);
|
||||
@@ -705,17 +705,17 @@
|
||||
async function selectRoute(source, id) {
|
||||
setRouteControlOpen(false);
|
||||
const route = source === 'local'
|
||||
? ns.LocalRoutes.getAppRoute(id)
|
||||
? await ns.LocalRoutes.getAppRoute(id, { requestPermission: true })
|
||||
: { ...(await ns.Api.route(id)), source: 'server', local: false };
|
||||
renderRoute(route);
|
||||
navigate('#route-page');
|
||||
}
|
||||
|
||||
function refreshCurrentLocalRoute(id) {
|
||||
async function refreshCurrentLocalRoute(id) {
|
||||
if (state.route?.source !== 'local' || String(state.route.id) !== String(id)) return;
|
||||
persistRouteProgress();
|
||||
try {
|
||||
state.route = ns.LocalRoutes.getAppRoute(id);
|
||||
state.route = await ns.LocalRoutes.getAppRoute(id);
|
||||
renderRouteView(state.route);
|
||||
renderRouteControl();
|
||||
} catch {
|
||||
@@ -800,7 +800,7 @@
|
||||
document.addEventListener('wegwichtel:local-routes-changed', event => {
|
||||
loadLocalRoutes();
|
||||
if (state.position) updateRouteProximity(state.position);
|
||||
refreshCurrentLocalRoute(event.detail?.id);
|
||||
void refreshCurrentLocalRoute(event.detail?.id);
|
||||
renderRoutes();
|
||||
ns.EditorUi?.refresh?.();
|
||||
});
|
||||
|
||||
Vendored
+21
@@ -21,12 +21,32 @@
|
||||
const cleanupStatus = document.getElementById('storage-cleanup-status');
|
||||
const actionStatus = document.getElementById('bootstrap-action-status');
|
||||
const statusText = document.getElementById('bootstrap-status-text');
|
||||
const androidBrowserHint = document.getElementById('android-browser-hint');
|
||||
let ready = false;
|
||||
let statusTransition = 0;
|
||||
const completedSteps = new Set();
|
||||
|
||||
const versioned = path => `${path}${path.includes('?') ? '&' : '?'}v=${encodeURIComponent(versions.app)}`;
|
||||
|
||||
function updateAndroidBrowserHint() {
|
||||
if (!androidBrowserHint) return;
|
||||
const userAgent = globalThis.navigator?.userAgent || '';
|
||||
const platform = globalThis.navigator?.userAgentData?.platform || '';
|
||||
const isAndroid = /Android/i.test(`${userAgent} ${platform}`);
|
||||
if (!isAndroid) {
|
||||
androidBrowserHint.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const isChrome = /Chrome\//i.test(userAgent)
|
||||
&& !/(?:EdgA|OPR|SamsungBrowser)\//i.test(userAgent);
|
||||
androidBrowserHint.hidden = false;
|
||||
androidBrowserHint.dataset.browser = isChrome ? 'chrome' : 'other';
|
||||
androidBrowserHint.textContent = isChrome
|
||||
? 'Android mit Chrome erkannt. Lokale Bilder und Audiodateien können als Dateireferenzen verwendet werden.'
|
||||
: 'Android erkannt. Für lokale Bilder und Audiodateien bitte Chrome verwenden; dort können Dateireferenzen ohne Kopie in den Local Storage genutzt werden.';
|
||||
}
|
||||
|
||||
function schedule(callback, delay = 0) {
|
||||
if (typeof globalThis.setTimeout === 'function') {
|
||||
return globalThis.setTimeout(callback, delay);
|
||||
@@ -218,5 +238,6 @@
|
||||
enter.addEventListener('pointerup', openApplication);
|
||||
enter.addEventListener('keydown', openApplication);
|
||||
screen.addEventListener('pointerup', openApplication);
|
||||
updateAndroidBrowserHint();
|
||||
boot();
|
||||
}());
|
||||
|
||||
+174
-56
@@ -36,6 +36,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
routeDetailView: 'editor-route-detail-view',
|
||||
routeForm: 'editor-route-form',
|
||||
routeGpx: 'editor-route-gpx',
|
||||
routeGpxGroup: 'editor-route-gpx-group',
|
||||
routeId: 'editor-route-id',
|
||||
routeList: 'editor-route-list',
|
||||
routeListTitle: 'editor-route-list-title',
|
||||
@@ -88,6 +89,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
currentRoute: null,
|
||||
currentSource: null,
|
||||
pendingAudioFile: null,
|
||||
pendingAudioHandle: null,
|
||||
pendingImages: [],
|
||||
pois: [],
|
||||
routes: [],
|
||||
@@ -128,7 +130,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function poiTitle(poi) {
|
||||
return text(poi?.title || poi?.name || 'Unbenannter Trackpunkt');
|
||||
return text(poi?.title || poi?.name || 'Unbenannter Streckenpunkt');
|
||||
}
|
||||
|
||||
function apiBase() {
|
||||
@@ -193,7 +195,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function showPoiView() {
|
||||
setDialogTitle(state.currentPoi ? 'Trackpunkt bearbeiten' : 'Trackpunkt hinzufügen');
|
||||
setDialogTitle(state.currentPoi ? 'Streckenpunkt bearbeiten' : 'Streckenpunkt hinzufügen');
|
||||
setHidden(byId(ids.routeListView), true);
|
||||
setHidden(byId(ids.routeDetailView), true);
|
||||
setHidden(byId(ids.poiFormView), false);
|
||||
@@ -357,7 +359,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const title = poiTitle(poi);
|
||||
const label = document.createElement('div');
|
||||
appendText(label, 'strong', title);
|
||||
appendText(label, 'small', `Trackpunkt ${index + 1}`);
|
||||
appendText(label, 'small', `Streckenpunkt ${index + 1}`);
|
||||
const actions = document.createElement('div');
|
||||
actions.className = classes.iconRow;
|
||||
actions.appendChild(iconButton(`${title} nach oben schieben`, '↑', 'move-poi-up', index === 0));
|
||||
@@ -397,7 +399,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
if (!state.pois.length) {
|
||||
const item = document.createElement('li');
|
||||
item.className = classes.poiItem;
|
||||
appendText(item, 'span', 'Noch keine Trackpunkte angelegt.');
|
||||
appendText(item, 'span', 'Noch keine Streckenpunkte angelegt.');
|
||||
list.appendChild(item);
|
||||
return;
|
||||
}
|
||||
@@ -476,6 +478,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
byId(ids.routeName).value = routeName(route) === 'Unbenannte Strecke' ? '' : routeName(route);
|
||||
byId(ids.routeDescription).value = text(route?.description);
|
||||
byId(ids.routeGpx).value = '';
|
||||
setHidden(byId(ids.routeGpxGroup), !routeId(route));
|
||||
|
||||
const uploadButton = byId(ids.routeUpload);
|
||||
if (state.currentSource === 'local') {
|
||||
@@ -495,8 +498,21 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
}
|
||||
|
||||
function objectUrlFor(file) {
|
||||
if (!file || typeof globalThis.URL?.createObjectURL !== 'function') return null;
|
||||
return globalThis.URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
function releasePendingPreview(pending) {
|
||||
const url = pending?.previewUrl;
|
||||
if (!url || !url.startsWith('blob:') || typeof globalThis.URL?.revokeObjectURL !== 'function') return;
|
||||
globalThis.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function resetPendingMedia() {
|
||||
state.pendingAudioFile = null;
|
||||
state.pendingAudioHandle = null;
|
||||
state.pendingImages.forEach(releasePendingPreview);
|
||||
state.pendingImages = [];
|
||||
state.selectedMediaKey = null;
|
||||
if (byId(ids.poiAudio)) byId(ids.poiAudio).value = '';
|
||||
@@ -504,7 +520,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function existingAudioUrl(poi) {
|
||||
return poi?.audio?.dataUrl || poi?.audioUrl || poi?.audio_path || null;
|
||||
return poi?.audio?.url || poi?.audio?.dataUrl || poi?.audioUrl || poi?.audio_path || null;
|
||||
}
|
||||
|
||||
function imagePreviewUrl(image) {
|
||||
@@ -531,11 +547,11 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
image.alt = '';
|
||||
select.appendChild(image);
|
||||
} else {
|
||||
const audio = document.createElement('span');
|
||||
audio.className = classes.mediaPreviewAudio;
|
||||
audio.setAttribute('aria-hidden', 'true');
|
||||
audio.textContent = '♪';
|
||||
select.appendChild(audio);
|
||||
const mediaSymbol = document.createElement('span');
|
||||
mediaSymbol.className = classes.mediaPreviewAudio;
|
||||
mediaSymbol.setAttribute('aria-hidden', 'true');
|
||||
mediaSymbol.textContent = kind === 'image' ? '▧' : '♪';
|
||||
select.appendChild(mediaSymbol);
|
||||
}
|
||||
appendText(select, 'small', label);
|
||||
|
||||
@@ -562,7 +578,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
if (!preview) return;
|
||||
preview.textContent = '';
|
||||
|
||||
if (existingAudioUrl(poi)) {
|
||||
if (poi?.audio || existingAudioUrl(poi)) {
|
||||
preview.appendChild(mediaPreviewCard({
|
||||
key: 'existing-audio',
|
||||
kind: 'audio',
|
||||
@@ -599,7 +615,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
kind: 'image',
|
||||
label: pending.file?.name || `Neues Bild ${index + 1}`,
|
||||
mediaIndex: index,
|
||||
previewUrl: pending.preview?.dataUrl || null,
|
||||
previewUrl: pending.previewUrl || null,
|
||||
source: 'pending'
|
||||
}));
|
||||
});
|
||||
@@ -608,7 +624,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
function updatePoiMediaStatus(poi = state.currentPoi) {
|
||||
const status = byId(ids.poiMediaStatus);
|
||||
if (!status) return;
|
||||
const existingAudio = existingAudioUrl(poi) ? 1 : 0;
|
||||
const existingAudio = poi?.audio || existingAudioUrl(poi) ? 1 : 0;
|
||||
const existingImages = Array.isArray(poi?.images) ? poi.images.length : 0;
|
||||
const audioCount = existingAudio + (state.pendingAudioFile ? 1 : 0);
|
||||
const imageCount = existingImages + state.pendingImages.length;
|
||||
@@ -627,10 +643,65 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
if (input && typeof input.click === 'function') input.click();
|
||||
}
|
||||
|
||||
function pickerOptions(kind) {
|
||||
if (kind === 'audio') {
|
||||
return {
|
||||
multiple: false,
|
||||
types: [{ description: 'Audio', accept: { 'audio/*': ['.mp3', '.m4a', '.wav', '.ogg', '.webm'] } }]
|
||||
};
|
||||
}
|
||||
return {
|
||||
multiple: true,
|
||||
types: [{ description: 'Bilder', accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.webp'] } }]
|
||||
};
|
||||
}
|
||||
|
||||
async function pickWithFileSystemAccess(kind) {
|
||||
if (state.currentSource !== 'local' || typeof globalThis.showOpenFilePicker !== 'function') return false;
|
||||
let handles;
|
||||
try {
|
||||
handles = await globalThis.showOpenFilePicker(pickerOptions(kind));
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (kind === 'audio') {
|
||||
const handle = handles?.[0];
|
||||
if (!handle) return true;
|
||||
state.pendingAudioHandle = handle;
|
||||
state.pendingAudioFile = await handle.getFile();
|
||||
} else {
|
||||
for (const handle of handles || []) {
|
||||
const file = await handle.getFile();
|
||||
state.pendingImages.push({
|
||||
file,
|
||||
handle,
|
||||
key: `pending-image-${ns.LocalRoutes.createPoiId()}`,
|
||||
previewUrl: objectUrlFor(file)
|
||||
});
|
||||
}
|
||||
}
|
||||
state.selectedMediaKey = null;
|
||||
renderPoiMediaPreview();
|
||||
updatePoiMediaStatus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function chooseMedia(kind, inputId) {
|
||||
if (state.currentSource !== 'local' || typeof globalThis.showOpenFilePicker !== 'function') {
|
||||
openFilePicker(inputId);
|
||||
return null;
|
||||
}
|
||||
return pickWithFileSystemAccess(kind).then(handled => {
|
||||
if (!handled) openFilePicker(inputId);
|
||||
});
|
||||
}
|
||||
|
||||
function fillPoiForm(poi) {
|
||||
resetPendingMedia();
|
||||
byId(ids.poiId).value = poiId(poi);
|
||||
byId(ids.poiTitle).value = poiTitle(poi) === 'Unbenannter Trackpunkt' ? '' : poiTitle(poi);
|
||||
byId(ids.poiTitle).value = poiTitle(poi) === 'Unbenannter Streckenpunkt' ? '' : poiTitle(poi);
|
||||
byId(ids.poiDescription).value = text(poi?.description || poi?.text);
|
||||
byId(ids.poiLat).value = text(poi?.lat ?? poi?.latitude);
|
||||
byId(ids.poiLon).value = text(poi?.lon ?? poi?.longitude);
|
||||
@@ -641,6 +712,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function handleAudioSelection() {
|
||||
state.pendingAudioHandle = null;
|
||||
state.pendingAudioFile = byId(ids.poiAudio)?.files?.[0] || null;
|
||||
state.selectedMediaKey = null;
|
||||
if (byId(ids.poiAudio)) byId(ids.poiAudio).value = '';
|
||||
@@ -653,8 +725,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
for (const file of files) {
|
||||
state.pendingImages.push({
|
||||
file,
|
||||
handle: null,
|
||||
key: `pending-image-${ns.LocalRoutes.createPoiId()}`,
|
||||
preview: await ns.LocalRoutes.fileToDataUrl(file)
|
||||
previewUrl: objectUrlFor(file)
|
||||
});
|
||||
}
|
||||
state.selectedMediaKey = null;
|
||||
@@ -670,7 +743,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
state.currentSource = routeSource(route);
|
||||
|
||||
if (state.currentSource === 'local') {
|
||||
const routePackage = ns.LocalRoutes.get(id);
|
||||
const routePackage = await ns.LocalRoutes.getWithMedia(id, { requestPermission: true });
|
||||
if (!routePackage) throw new Error('Der lokale Entwurf wurde nicht gefunden.');
|
||||
state.currentRoute = {
|
||||
...routePackage.route,
|
||||
@@ -793,12 +866,11 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
async function storedMediaBlob(media) {
|
||||
if (!media?.dataUrl) return null;
|
||||
const response = await fetch(media.dataUrl);
|
||||
if (!response.ok || typeof response.blob !== 'function') {
|
||||
throw new Error(`Die Mediendatei ${media.name || ''} konnte nicht für den Upload vorbereitet werden.`.trim());
|
||||
const blob = await ns.LocalRoutes.mediaBlob(media, { requestPermission: true });
|
||||
if (!blob) {
|
||||
throw new Error(`Die Mediendatei ${media?.name || ''} konnte nicht für den Upload vorbereitet werden.`.trim());
|
||||
}
|
||||
return response.blob();
|
||||
return blob;
|
||||
}
|
||||
|
||||
async function uploadStoredMedia(path, fieldName, media, fallbackName) {
|
||||
@@ -811,11 +883,11 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
|
||||
async function uploadLocalPoiMedia(serverRouteId, serverPoiId, poi) {
|
||||
const basePath = `/routes/${encodeURIComponent(serverRouteId)}/pois/${encodeURIComponent(serverPoiId)}`;
|
||||
if (poi.audio?.dataUrl) {
|
||||
if (poi.audio) {
|
||||
await uploadStoredMedia(`${basePath}/audio`, 'audio', poi.audio, 'audio.bin');
|
||||
}
|
||||
for (const [index, image] of (poi.images || []).entries()) {
|
||||
if (!image?.dataUrl) continue;
|
||||
if (!image) continue;
|
||||
await uploadStoredMedia(`${basePath}/pictures`, 'picture', image, `bild-${index + 1}.bin`);
|
||||
}
|
||||
}
|
||||
@@ -831,7 +903,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const orderedPois = [...(routePackage.pois || [])]
|
||||
.sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
||||
for (const [index, poi] of orderedPois.entries()) {
|
||||
const poiTitle = poi.title || `Station ${index + 1}`;
|
||||
const poiTitle = poi.title || `Streckenpunkt ${index + 1}`;
|
||||
const poiPayload = await sendJson(`/routes/${encodeURIComponent(createdRouteId)}/pois`, 'POST', {
|
||||
description: poi.description || '',
|
||||
lat: poi.lat,
|
||||
@@ -842,7 +914,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
});
|
||||
const serverPoi = poiPayload.poi || poiPayload;
|
||||
const serverPoiId = poiId(serverPoi);
|
||||
if (!serverPoiId) throw new Error(`Für ${poiTitle} wurde keine Server-POI-ID zurückgegeben.`);
|
||||
if (!serverPoiId) throw new Error(`Für ${poiTitle} wurde keine Server-ID zurückgegeben.`);
|
||||
await uploadLocalPoiMedia(createdRouteId, serverPoiId, poi);
|
||||
}
|
||||
return createdRouteId;
|
||||
@@ -905,7 +977,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const lat = Number(position?.lat);
|
||||
const lon = Number(position?.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
setStatus('Für einen neuen Trackpunkt muss zuerst eine aktuelle GPS-Position verfügbar sein.', true);
|
||||
setStatus('Für einen neuen Streckenpunkt muss zuerst eine aktuelle GPS-Position verfügbar sein.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -916,31 +988,55 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
lon,
|
||||
triggerRadiusM: Number.isFinite(accuracy) && accuracy > 0 ? accuracy : 25
|
||||
});
|
||||
byId(ids.poiFormTitle).textContent = 'Trackpunkt hinzufügen';
|
||||
byId(ids.poiFormTitle).textContent = 'Streckenpunkt hinzufügen';
|
||||
showPoiView();
|
||||
}
|
||||
|
||||
function editPoi(poi) {
|
||||
state.currentPoi = poi;
|
||||
fillPoiForm(poi);
|
||||
byId(ids.poiFormTitle).textContent = 'Trackpunkt bearbeiten';
|
||||
byId(ids.poiFormTitle).textContent = 'Streckenpunkt bearbeiten';
|
||||
showPoiView();
|
||||
}
|
||||
|
||||
async function mediaFromLocalForm(existingPoi, poiIdentifier) {
|
||||
const audio = state.pendingAudioFile
|
||||
? await ns.LocalRoutes.fileToDataUrl(state.pendingAudioFile)
|
||||
: existingPoi?.audio || null;
|
||||
const routeIdentifier = routeId(state.currentRoute);
|
||||
if (!routeIdentifier) throw new Error('Speichere zuerst die Strecke.');
|
||||
|
||||
const storedNow = [];
|
||||
const replacedAfterSave = [];
|
||||
let audio = existingPoi?.audio || null;
|
||||
|
||||
if (state.pendingAudioFile) {
|
||||
const mediaId = `${poiIdentifier}-audio`;
|
||||
const storedAudio = await ns.LocalRoutes.storeMediaFile(
|
||||
routeIdentifier,
|
||||
poiIdentifier,
|
||||
'audio',
|
||||
mediaId,
|
||||
state.pendingAudioFile,
|
||||
state.pendingAudioHandle
|
||||
);
|
||||
storedNow.push(storedAudio);
|
||||
if (audio) replacedAfterSave.push(audio);
|
||||
audio = storedAudio;
|
||||
}
|
||||
|
||||
const images = [...(existingPoi?.images || [])];
|
||||
for (const pending of state.pendingImages) {
|
||||
const image = pending.preview || await ns.LocalRoutes.fileToDataUrl(pending.file);
|
||||
images.push({
|
||||
...image,
|
||||
id: `${poiIdentifier}-image-${ns.LocalRoutes.createPoiId()}`,
|
||||
caption: ''
|
||||
});
|
||||
const mediaId = `${poiIdentifier}-image-${ns.LocalRoutes.createPoiId()}`;
|
||||
const image = await ns.LocalRoutes.storeMediaFile(
|
||||
routeIdentifier,
|
||||
poiIdentifier,
|
||||
'image',
|
||||
mediaId,
|
||||
pending.file,
|
||||
pending.handle
|
||||
);
|
||||
images.push({ ...image, caption: '' });
|
||||
storedNow.push(image);
|
||||
}
|
||||
return { audio, images };
|
||||
return { audio, images, storedNow, replacedAfterSave };
|
||||
}
|
||||
|
||||
async function persistLocalCurrentRoute() {
|
||||
@@ -953,17 +1049,18 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
route: state.currentRoute,
|
||||
pois: state.pois
|
||||
});
|
||||
state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
|
||||
state.pois = saved.pois;
|
||||
const hydrated = await ns.LocalRoutes.getWithMedia(id) || saved;
|
||||
state.currentRoute = { ...hydrated.route, id: hydrated.id, gpx: hydrated.gpx, _source: 'local' };
|
||||
state.pois = hydrated.pois;
|
||||
state.currentPoi = currentPoiId
|
||||
? state.pois.find(poi => poiId(poi) === currentPoiId) || null
|
||||
: null;
|
||||
return saved;
|
||||
return hydrated;
|
||||
}
|
||||
|
||||
async function saveLocalPoi(values) {
|
||||
if (!Number.isFinite(values.lat) || !Number.isFinite(values.lon)) {
|
||||
throw new TypeError('Für einen GPX-Trackpunkt sind gültige Breiten- und Längengrade erforderlich.');
|
||||
throw new TypeError('Für einen Streckenpunkt sind gültige Breiten- und Längengrade erforderlich.');
|
||||
}
|
||||
const currentId = Number(byId(ids.poiId).value);
|
||||
const identifier = Number.isSafeInteger(currentId) ? currentId : ns.LocalRoutes.createPoiId();
|
||||
@@ -972,15 +1069,24 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const savedPoi = {
|
||||
...existing,
|
||||
...values,
|
||||
...media,
|
||||
audio: media.audio,
|
||||
images: media.images,
|
||||
id: identifier,
|
||||
sequence: existing?.sequence ?? state.pois.length
|
||||
};
|
||||
const index = state.pois.findIndex(poi => Number(poi.id) === identifier);
|
||||
if (index >= 0) state.pois[index] = savedPoi;
|
||||
else state.pois.push(savedPoi);
|
||||
await persistLocalCurrentRoute();
|
||||
setStatus('Trackpunkt und zugehörige JSON-Mediendaten lokal gespeichert.');
|
||||
|
||||
try {
|
||||
await persistLocalCurrentRoute();
|
||||
} catch (error) {
|
||||
await Promise.allSettled(media.storedNow.map(ns.LocalRoutes.removeMedia));
|
||||
throw error;
|
||||
}
|
||||
await Promise.allSettled(media.replacedAfterSave.map(ns.LocalRoutes.removeMedia));
|
||||
resetPendingMedia();
|
||||
setStatus('Streckenpunkt und zugehörige Mediendaten lokal gespeichert.');
|
||||
await loadRoute({ id: routeId(state.currentRoute), _source: 'local' });
|
||||
}
|
||||
|
||||
@@ -1013,13 +1119,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const saved = payload.poi || payload;
|
||||
const savedPoiId = poiId(saved) || currentPoiId;
|
||||
if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId);
|
||||
setStatus('Trackpunkt gespeichert.');
|
||||
setStatus('Streckenpunkt gespeichert.');
|
||||
await loadRoute({ id: currentRouteId, _source: 'server' });
|
||||
}
|
||||
|
||||
async function savePoi() {
|
||||
const values = poiFromForm();
|
||||
if (!values.title) throw new Error('Der Titel des Trackpunkts ist erforderlich.');
|
||||
if (!values.title) throw new Error('Der Titel des Streckenpunkts ist erforderlich.');
|
||||
if (state.currentSource === 'local') await saveLocalPoi(values);
|
||||
else await saveServerPoi(values);
|
||||
}
|
||||
@@ -1034,9 +1140,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
async function removePendingMedia(kind, mediaIndex) {
|
||||
if (kind === 'audio') {
|
||||
state.pendingAudioFile = null;
|
||||
state.pendingAudioHandle = null;
|
||||
} else {
|
||||
const index = Number.parseInt(mediaIndex, 10);
|
||||
if (Number.isInteger(index)) state.pendingImages.splice(index, 1);
|
||||
if (Number.isInteger(index)) {
|
||||
const [removed] = state.pendingImages.splice(index, 1);
|
||||
releasePendingPreview(removed);
|
||||
}
|
||||
}
|
||||
state.selectedMediaKey = null;
|
||||
renderPoiMediaPreview();
|
||||
@@ -1046,15 +1156,20 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
async function removeExistingLocalMedia(kind, mediaIndex) {
|
||||
if (!state.currentPoi) return;
|
||||
const updated = { ...state.currentPoi };
|
||||
let removedMedia = null;
|
||||
if (kind === 'audio') {
|
||||
removedMedia = updated.audio;
|
||||
updated.audio = null;
|
||||
} else {
|
||||
const index = Number.parseInt(mediaIndex, 10);
|
||||
updated.images = [...(state.currentPoi.images || [])];
|
||||
if (Number.isInteger(index)) updated.images.splice(index, 1);
|
||||
if (Number.isInteger(index)) {
|
||||
[removedMedia] = updated.images.splice(index, 1);
|
||||
}
|
||||
}
|
||||
updateCurrentPoi(updated);
|
||||
await persistLocalCurrentRoute();
|
||||
if (removedMedia) await ns.LocalRoutes.removeMedia(removedMedia);
|
||||
state.selectedMediaKey = null;
|
||||
renderPoiMediaPreview();
|
||||
updatePoiMediaStatus();
|
||||
@@ -1064,7 +1179,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
async function removeExistingServerMedia(kind, mediaId) {
|
||||
const currentRouteId = routeId(state.currentRoute);
|
||||
const currentPoiId = poiId(state.currentPoi);
|
||||
if (!currentRouteId || !currentPoiId) throw new Error('Der Trackpunkt ist noch nicht gespeichert.');
|
||||
if (!currentRouteId || !currentPoiId) throw new Error('Der Streckenpunkt ist noch nicht gespeichert.');
|
||||
const basePath = `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`;
|
||||
const updated = { ...state.currentPoi };
|
||||
|
||||
@@ -1118,20 +1233,23 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
if (!currentRouteId || !currentPoiId) return;
|
||||
|
||||
if (state.currentSource === 'local') {
|
||||
const removedPoi = state.currentPoi;
|
||||
state.pois = state.pois
|
||||
.filter(poi => poiId(poi) !== currentPoiId)
|
||||
.map((poi, sequence) => ({ ...poi, sequence }));
|
||||
state.currentPoi = null;
|
||||
await persistLocalCurrentRoute();
|
||||
const removedMedia = [removedPoi?.audio, ...(removedPoi?.images || [])].filter(Boolean);
|
||||
await Promise.allSettled(removedMedia.map(ns.LocalRoutes.removeMedia));
|
||||
renderPoiList();
|
||||
showRouteView();
|
||||
setStatus('Trackpunkt entfernt und lokale Strecke aktualisiert.');
|
||||
setStatus('Streckenpunkt entfernt und lokale Strecke aktualisiert.');
|
||||
return;
|
||||
}
|
||||
|
||||
await sendDelete(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`);
|
||||
await loadRoute({ id: currentRouteId, _source: 'server' });
|
||||
setStatus('Trackpunkt entfernt.');
|
||||
setStatus('Streckenpunkt entfernt.');
|
||||
}
|
||||
|
||||
function reorderPois(index, offset) {
|
||||
@@ -1154,7 +1272,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
if (!currentRouteId) return;
|
||||
if (state.currentSource === 'local') {
|
||||
await persistLocalCurrentRoute();
|
||||
setStatus('Lokale Trackpunkt-Reihenfolge gespeichert.');
|
||||
setStatus('Lokale Streckenpunkt-Reihenfolge gespeichert.');
|
||||
return;
|
||||
}
|
||||
const jobs = state.pois.map((poi, index) => {
|
||||
@@ -1188,7 +1306,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const id = routeId(route);
|
||||
if (!id || routeSource(route) !== 'local') return;
|
||||
|
||||
ns.LocalRoutes.remove(id);
|
||||
await ns.LocalRoutes.remove(id);
|
||||
|
||||
if (routeId(state.currentRoute) === id && state.currentSource === routeSource(route)) {
|
||||
state.currentRoute = null;
|
||||
@@ -1255,8 +1373,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
activate(byId(ids.routeBack), () => guarded(loadRoutes).then(showListView));
|
||||
activate(byId(ids.routeUpload), () => guarded(uploadLocalRoute));
|
||||
activate(byId(ids.addPoi), newPoi);
|
||||
activate(byId(ids.poiAudioAdd), () => openFilePicker(ids.poiAudio));
|
||||
activate(byId(ids.poiImagesAdd), () => openFilePicker(ids.poiImages));
|
||||
activate(byId(ids.poiAudioAdd), () => guarded(() => chooseMedia('audio', ids.poiAudio)));
|
||||
activate(byId(ids.poiImagesAdd), () => guarded(() => chooseMedia('image', ids.poiImages)));
|
||||
byId(ids.poiAudio)?.addEventListener('change', handleAudioSelection);
|
||||
byId(ids.poiImages)?.addEventListener('change', () => guarded(handleImageSelection));
|
||||
activate(byId(ids.poiMediaPreview), event => guarded(() => mediaPreviewAction(event)));
|
||||
|
||||
+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
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
const image = images[index];
|
||||
$('#slide-image')
|
||||
.attr('src', mediaUrl(image.url))
|
||||
.attr('alt', image.caption || 'Bild zur Station')
|
||||
.attr('alt', image.caption || 'Bild zum Streckenpunkt')
|
||||
.off('error.wegwichtel')
|
||||
.on('error.wegwichtel', function () {
|
||||
$(this).removeAttr('src').attr('alt', 'Bild konnte nicht geladen werden');
|
||||
|
||||
Reference in New Issue
Block a user