change trackpoint format to match server expectation
Sonarqube Scanner / Build and analyze (push) Successful in 1m9s

This commit is contained in:
Florian Zumpe
2026-09-27 19:25:04 +02:00
parent b032940681
commit 3cdad9caa5
6 changed files with 258 additions and 58 deletions
+13
View File
@@ -523,6 +523,19 @@
min-width: 2.4rem; min-width: 2.4rem;
} }
.editor-icon-danger {
background: #b42318;
border-color: #b42318;
color: #fff;
font-size: 1.3rem;
font-weight: 900;
}
.editor-icon-danger:hover {
background: #8f1c13;
border-color: #8f1c13;
}
.editor-icon-button:disabled, .editor-icon-button:disabled,
.editor-icon-button[aria-disabled="true"] { .editor-icon-button[aria-disabled="true"] {
cursor: not-allowed; cursor: not-allowed;
+6 -6
View File
@@ -373,8 +373,8 @@
</div> </div>
</form> </form>
<div class="editor-toolbar editor-poi-toolbar"> <div class="editor-toolbar editor-poi-toolbar">
<h3>Stationen</h3> <h3>Trackpunkte</h3>
<button id="editor-add-poi" class="editor-round-button" type="button" aria-label="GPX-POI hinzufügen" title="GPX-POI hinzufügen"> <button id="editor-add-poi" class="editor-round-button" type="button" aria-label="Trackpunkt hinzufügen" title="Trackpunkt hinzufügen">
<span aria-hidden="true">+</span> <span aria-hidden="true">+</span>
</button> </button>
</div> </div>
@@ -383,7 +383,7 @@
<section id="editor-poi-form-view" class="editor-view" aria-labelledby="editor-poi-form-title" hidden> <section id="editor-poi-form-view" class="editor-view" aria-labelledby="editor-poi-form-title" hidden>
<div class="editor-toolbar editor-toolbar-back"> <div class="editor-toolbar editor-toolbar-back">
<button id="editor-poi-cancel" class="editor-secondary-button" type="button">Zurück zur Strecke</button> <button id="editor-poi-cancel" class="editor-secondary-button" type="button">Zurück zur Strecke</button>
<h3 id="editor-poi-form-title">Station bearbeiten</h3> <h3 id="editor-poi-form-title">Trackpunkt bearbeiten</h3>
</div> </div>
<form id="editor-poi-form" class="editor-form"> <form id="editor-poi-form" class="editor-form">
@@ -398,7 +398,7 @@
<input id="editor-poi-lon" type="hidden"> <input id="editor-poi-lon" type="hidden">
<input id="editor-poi-radius" type="hidden" value="25"> <input id="editor-poi-radius" type="hidden" value="25">
<div class="editor-media-actions" aria-label="Medien zur Station hinzufügen"> <div class="editor-media-actions" aria-label="Medien zum Trackpunkt hinzufügen">
<button id="editor-poi-audio-add" class="editor-media-button" type="button" title="Audio hinzufügen"> <button id="editor-poi-audio-add" class="editor-media-button" type="button" title="Audio hinzufügen">
<svg class="editor-media-icon" viewBox="0 0 32 32" aria-hidden="true" focusable="false"> <svg class="editor-media-icon" viewBox="0 0 32 32" aria-hidden="true" focusable="false">
<path d="M18 5v15.2a5 5 0 1 1-2-4V9l10-2v9.2a5 5 0 1 1-2-4V4.6z"/> <path d="M18 5v15.2a5 5 0 1 1-2-4V9l10-2v9.2a5 5 0 1 1-2-4V4.6z"/>
@@ -421,10 +421,10 @@
<p id="editor-poi-media-status" class="editor-muted" aria-live="polite"></p> <p id="editor-poi-media-status" class="editor-muted" aria-live="polite"></p>
<div class="editor-form-actions"> <div class="editor-form-actions">
<button id="editor-poi-save" class="editor-primary-button" type="submit">Station speichern</button> <button id="editor-poi-save" class="editor-primary-button" type="submit">Trackpunkt speichern</button>
<button id="editor-poi-delete" class="editor-danger-button" type="button" hidden> <button id="editor-poi-delete" class="editor-danger-button" type="button" hidden>
<span class="editor-danger-icon" aria-hidden="true">−</span> <span class="editor-danger-icon" aria-hidden="true">−</span>
<span>POI entfernen</span> <span>Trackpunkt entfernen</span>
</button> </button>
</div> </div>
</form> </form>
+68 -25
View File
@@ -56,6 +56,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
formActions: 'editor-form-actions', formActions: 'editor-form-actions',
form: 'editor-form', form: 'editor-form',
iconButton: 'editor-icon-button', iconButton: 'editor-icon-button',
iconDanger: 'editor-icon-danger',
iconRow: 'editor-icon-row', iconRow: 'editor-icon-row',
mediaActions: 'editor-media-actions', mediaActions: 'editor-media-actions',
mediaButton: 'editor-media-button', mediaButton: 'editor-media-button',
@@ -127,7 +128,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
function poiTitle(poi) { function poiTitle(poi) {
return text(poi?.title || poi?.name || 'Unbenannte Station'); return text(poi?.title || poi?.name || 'Unbenannter Trackpunkt');
} }
function apiBase() { function apiBase() {
@@ -192,7 +193,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
} }
function showPoiView() { function showPoiView() {
setDialogTitle(state.currentPoi ? 'POI bearbeiten' : 'POI hinzufügen'); setDialogTitle(state.currentPoi ? 'Trackpunkt bearbeiten' : 'Trackpunkt hinzufügen');
setHidden(byId(ids.routeListView), true); setHidden(byId(ids.routeListView), true);
setHidden(byId(ids.routeDetailView), true); setHidden(byId(ids.routeDetailView), true);
setHidden(byId(ids.poiFormView), false); setHidden(byId(ids.poiFormView), false);
@@ -306,9 +307,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
return child; return child;
} }
function iconButton(label, symbol, action, disabled = false) { function iconButton(label, symbol, action, disabled = false, danger = false) {
const button = document.createElement('button'); const button = document.createElement('button');
button.className = classes.iconButton; button.className = danger ? `${classes.iconButton} ${classes.iconDanger}` : classes.iconButton;
button.dataset.editorAction = action; button.dataset.editorAction = action;
button.textContent = symbol; button.textContent = symbol;
button.title = label; button.title = label;
@@ -335,6 +336,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = classes.iconRow; actions.className = classes.iconRow;
actions.appendChild(iconButton('Strecke bearbeiten', '✎', 'edit-route')); actions.appendChild(iconButton('Strecke bearbeiten', '✎', 'edit-route'));
actions.appendChild(iconButton('Strecke löschen', '×', 'delete-route', false, true));
item.append(label, actions); item.append(label, actions);
return item; return item;
} }
@@ -353,12 +355,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const title = poiTitle(poi); const title = poiTitle(poi);
const label = document.createElement('div'); const label = document.createElement('div');
appendText(label, 'strong', title); appendText(label, 'strong', title);
appendText(label, 'small', `Position ${index + 1}`); appendText(label, 'small', `Trackpunkt ${index + 1}`);
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = classes.iconRow; actions.className = classes.iconRow;
actions.appendChild(iconButton(`${title} nach oben schieben`, '↑', 'move-poi-up', index === 0)); actions.appendChild(iconButton(`${title} nach oben schieben`, '↑', 'move-poi-up', index === 0));
actions.appendChild(iconButton(`${title} nach unten schieben`, '↓', 'move-poi-down', index === state.pois.length - 1)); actions.appendChild(iconButton(`${title} nach unten schieben`, '↓', 'move-poi-down', index === state.pois.length - 1));
actions.appendChild(iconButton(`${title} bearbeiten`, '✎', 'edit-poi')); actions.appendChild(iconButton(`${title} bearbeiten`, '✎', 'edit-poi'));
actions.appendChild(iconButton(`${title} löschen`, '×', 'delete-poi', false, true));
item.append(label, actions); item.append(label, actions);
return item; return item;
} }
@@ -392,7 +395,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
if (!state.pois.length) { if (!state.pois.length) {
const item = document.createElement('li'); const item = document.createElement('li');
item.className = classes.poiItem; item.className = classes.poiItem;
appendText(item, 'span', 'Noch keine Stationen angelegt.'); appendText(item, 'span', 'Noch keine Trackpunkte angelegt.');
list.appendChild(item); list.appendChild(item);
return; return;
} }
@@ -625,7 +628,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function fillPoiForm(poi) { function fillPoiForm(poi) {
resetPendingMedia(); resetPendingMedia();
byId(ids.poiId).value = poiId(poi); byId(ids.poiId).value = poiId(poi);
byId(ids.poiTitle).value = poiTitle(poi) === 'Unbenannte Station' ? '' : poiTitle(poi); byId(ids.poiTitle).value = poiTitle(poi) === 'Unbenannter Trackpunkt' ? '' : poiTitle(poi);
byId(ids.poiDescription).value = text(poi?.description || poi?.text); byId(ids.poiDescription).value = text(poi?.description || poi?.text);
byId(ids.poiLat).value = text(poi?.lat ?? poi?.latitude); byId(ids.poiLat).value = text(poi?.lat ?? poi?.latitude);
byId(ids.poiLon).value = text(poi?.lon ?? poi?.longitude); byId(ids.poiLon).value = text(poi?.lon ?? poi?.longitude);
@@ -781,7 +784,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const data = new FormData(); const data = new FormData();
data.append('name', routePackage.route?.name || 'Unbenannte Strecke'); data.append('name', routePackage.route?.name || 'Unbenannte Strecke');
data.append('description', routePackage.route?.description || ''); data.append('description', routePackage.route?.description || '');
const gpx = new Blob([routePackage.gpx], { type: 'application/gpx+xml' }); const uploadGpx = ns.LocalRoutes.gpxForUpload(routePackage);
const gpx = new Blob([uploadGpx], { type: 'application/gpx+xml' });
data.append('gpx', gpx, safeGpxFilename(routePackage.route?.name)); data.append('gpx', gpx, safeGpxFilename(routePackage.route?.name));
return data; return data;
} }
@@ -899,7 +903,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const lat = Number(position?.lat); const lat = Number(position?.lat);
const lon = Number(position?.lon); const lon = Number(position?.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) { if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
setStatus('Für einen neuen POI muss zuerst eine aktuelle GPS-Position verfügbar sein.', true); setStatus('Für einen neuen Trackpunkt muss zuerst eine aktuelle GPS-Position verfügbar sein.', true);
return; return;
} }
@@ -910,14 +914,14 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
lon, lon,
triggerRadiusM: Number.isFinite(accuracy) && accuracy > 0 ? accuracy : 25 triggerRadiusM: Number.isFinite(accuracy) && accuracy > 0 ? accuracy : 25
}); });
byId(ids.poiFormTitle).textContent = state.currentSource === 'local' ? 'GPX-POI hinzufügen' : 'Station hinzufügen'; byId(ids.poiFormTitle).textContent = 'Trackpunkt hinzufügen';
showPoiView(); showPoiView();
} }
function editPoi(poi) { function editPoi(poi) {
state.currentPoi = poi; state.currentPoi = poi;
fillPoiForm(poi); fillPoiForm(poi);
byId(ids.poiFormTitle).textContent = 'Station bearbeiten'; byId(ids.poiFormTitle).textContent = 'Trackpunkt bearbeiten';
showPoiView(); showPoiView();
} }
@@ -957,7 +961,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function saveLocalPoi(values) { async function saveLocalPoi(values) {
if (!Number.isFinite(values.lat) || !Number.isFinite(values.lon)) { if (!Number.isFinite(values.lat) || !Number.isFinite(values.lon)) {
throw new TypeError('Für einen GPX-POI sind gültige Breiten- und Längengrade erforderlich.'); throw new TypeError('Für einen GPX-Trackpunkt sind gültige Breiten- und Längengrade erforderlich.');
} }
const currentId = Number(byId(ids.poiId).value); const currentId = Number(byId(ids.poiId).value);
const identifier = Number.isSafeInteger(currentId) ? currentId : ns.LocalRoutes.createPoiId(); const identifier = Number.isSafeInteger(currentId) ? currentId : ns.LocalRoutes.createPoiId();
@@ -974,7 +978,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
if (index >= 0) state.pois[index] = savedPoi; if (index >= 0) state.pois[index] = savedPoi;
else state.pois.push(savedPoi); else state.pois.push(savedPoi);
await persistLocalCurrentRoute(); await persistLocalCurrentRoute();
setStatus('GPX-POI und zugehörige JSON-Mediendaten lokal gespeichert.'); setStatus('Trackpunkt und zugehörige JSON-Mediendaten lokal gespeichert.');
await loadRoute({ id: routeId(state.currentRoute), _source: 'local' }); await loadRoute({ id: routeId(state.currentRoute), _source: 'local' });
} }
@@ -1007,13 +1011,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const saved = payload.poi || payload; const saved = payload.poi || payload;
const savedPoiId = poiId(saved) || currentPoiId; const savedPoiId = poiId(saved) || currentPoiId;
if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId); if (savedPoiId) await uploadPoiMedia(currentRouteId, savedPoiId);
setStatus('Station gespeichert.'); setStatus('Trackpunkt gespeichert.');
await loadRoute({ id: currentRouteId, _source: 'server' }); await loadRoute({ id: currentRouteId, _source: 'server' });
} }
async function savePoi() { async function savePoi() {
const values = poiFromForm(); const values = poiFromForm();
if (!values.title) throw new Error('Der Titel der Station ist erforderlich.'); if (!values.title) throw new Error('Der Titel des Trackpunkts ist erforderlich.');
if (state.currentSource === 'local') await saveLocalPoi(values); if (state.currentSource === 'local') await saveLocalPoi(values);
else await saveServerPoi(values); else await saveServerPoi(values);
} }
@@ -1058,7 +1062,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function removeExistingServerMedia(kind, mediaId) { async function removeExistingServerMedia(kind, mediaId) {
const currentRouteId = routeId(state.currentRoute); const currentRouteId = routeId(state.currentRoute);
const currentPoiId = poiId(state.currentPoi); const currentPoiId = poiId(state.currentPoi);
if (!currentRouteId || !currentPoiId) throw new Error('Der POI ist noch nicht gespeichert.'); if (!currentRouteId || !currentPoiId) throw new Error('Der Trackpunkt ist noch nicht gespeichert.');
const basePath = `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`; const basePath = `/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`;
const updated = { ...state.currentPoi }; const updated = { ...state.currentPoi };
@@ -1119,13 +1123,13 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
await persistLocalCurrentRoute(); await persistLocalCurrentRoute();
renderPoiList(); renderPoiList();
showRouteView(); showRouteView();
setStatus('POI entfernt und lokale Strecke aktualisiert.'); setStatus('Trackpunkt entfernt und lokale Strecke aktualisiert.');
return; return;
} }
await sendDelete(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`); await sendDelete(`/routes/${encodeURIComponent(currentRouteId)}/pois/${encodeURIComponent(currentPoiId)}`);
await loadRoute({ id: currentRouteId, _source: 'server' }); await loadRoute({ id: currentRouteId, _source: 'server' });
setStatus('POI entfernt.'); setStatus('Trackpunkt entfernt.');
} }
function reorderPois(index, offset) { function reorderPois(index, offset) {
@@ -1148,7 +1152,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
if (!currentRouteId) return; if (!currentRouteId) return;
if (state.currentSource === 'local') { if (state.currentSource === 'local') {
await persistLocalCurrentRoute(); await persistLocalCurrentRoute();
setStatus('Lokale POI-Reihenfolge gespeichert.'); setStatus('Lokale Trackpunkt-Reihenfolge gespeichert.');
return; return;
} }
const jobs = state.pois.map((poi, index) => { const jobs = state.pois.map((poi, index) => {
@@ -1178,18 +1182,57 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
return state.pois.findIndex(poi => poiId(poi) === item?.dataset.poiId); return state.pois.findIndex(poi => poiId(poi) === item?.dataset.poiId);
} }
async function routeListAction(event) { async function deleteRouteFromList(route) {
const action = event.target.closest('[data-editor-action]')?.dataset.editorAction; const id = routeId(route);
if (action !== 'edit-route') return; if (!id) return;
await guarded(() => loadRoute(routeForItem(event.target)));
if (routeSource(route) === 'local') {
ns.LocalRoutes.remove(id);
} else {
await sendDelete(`/routes/${encodeURIComponent(id)}`);
} }
function poiListAction(event) { if (routeId(state.currentRoute) === id && state.currentSource === routeSource(route)) {
state.currentRoute = null;
state.currentSource = null;
state.currentPoi = null;
state.pois = [];
}
await loadRoutes();
showListView();
setStatus('Strecke gelöscht.');
}
async function routeListAction(event) {
const control = event.target.closest('[data-editor-action]');
const action = control?.dataset.editorAction;
const route = routeForItem(event.target);
if (!route) return;
if (action === 'edit-route') {
await loadRoute(route);
return;
}
if (action === 'delete-route') await deleteRouteFromList(route);
}
async function deletePoiFromList(poi) {
if (!poi) return;
state.currentPoi = poi;
await deleteCurrentPoi();
}
async function poiListAction(event) {
const action = event.target.closest('[data-editor-action]')?.dataset.editorAction; const action = event.target.closest('[data-editor-action]')?.dataset.editorAction;
if (action === 'edit-poi') { if (action === 'edit-poi') {
editPoi(poiForItem(event.target)); editPoi(poiForItem(event.target));
return; return;
} }
if (action === 'delete-poi') {
await deletePoiFromList(poiForItem(event.target));
return;
}
if (action === 'move-poi-up') { if (action === 'move-poi-up') {
swapPois(poiIndexForItem(event.target), -1); swapPois(poiIndexForItem(event.target), -1);
return; return;
@@ -1220,7 +1263,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
activate(byId(ids.poiDelete), () => guarded(deleteCurrentPoi)); activate(byId(ids.poiDelete), () => guarded(deleteCurrentPoi));
activate(byId(ids.poiCancel), showRouteView); activate(byId(ids.poiCancel), showRouteView);
activate(byId(ids.routeList), event => guarded(() => routeListAction(event))); activate(byId(ids.routeList), event => guarded(() => routeListAction(event)));
activate(byId(ids.poiList), poiListAction); activate(byId(ids.poiList), event => guarded(() => poiListAction(event)));
submit(byId(ids.routeForm), () => guarded(saveRoute)); submit(byId(ids.routeForm), () => guarded(saveRoute));
submit(byId(ids.poiForm), () => guarded(savePoi)); submit(byId(ids.poiForm), () => guarded(savePoi));
} }
+56 -16
View File
@@ -153,33 +153,64 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
nameElement.textContent = name; nameElement.textContent = name;
} }
function syncGpx(gpx, route, pois) { function ensureTrackSegment(document, routeName) {
const document = parseXml(gpx || emptyGpx(route?.name));
const root = document.documentElement; const root = document.documentElement;
updateMetadataName(document, route?.name || 'Neue Strecke'); let track = directChildrenByLocalName(root, 'trk')[0];
Array.from(document.getElementsByTagNameNS('*', 'wpt')).forEach(node => node.remove()); if (!track) {
track = createElement(document, 'trk');
root.appendChild(track);
}
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 isManagedTrackPoint(node) {
return Boolean(firstDescendantByLocalName(node, 'poiId'));
}
function syncGpx(gpx, route, pois) {
const routeName = route?.name || 'Neue Strecke';
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.
Array.from(document.getElementsByTagNameNS('*', 'wpt')).forEach(node => node.remove());
Array.from(document.getElementsByTagNameNS('*', 'trkpt'))
.filter(isManagedTrackPoint)
.forEach(node => node.remove());
const segment = ensureTrackSegment(document, routeName);
const orderedPois = [...(pois || [])].sort((left, right) => Number(left.sequence) - Number(right.sequence)); const orderedPois = [...(pois || [])].sort((left, right) => Number(left.sequence) - Number(right.sequence));
orderedPois.forEach((poi, index) => { orderedPois.forEach((poi, index) => {
const title = poi.title || `Station ${index + 1}`; const title = poi.title || `Trackpunkt ${index + 1}`;
if (!Number.isFinite(Number(poi.lat)) || !Number.isFinite(Number(poi.lon))) { if (!Number.isFinite(Number(poi.lat)) || !Number.isFinite(Number(poi.lon))) {
throw new TypeError(`Für ${title} fehlen gültige Koordinaten.`); throw new TypeError(`Für ${title} fehlen gültige Koordinaten.`);
} }
const waypoint = createElement(document, 'wpt');
waypoint.setAttribute('lat', String(Number(poi.lat))); const trackPoint = createElement(document, 'trkpt');
waypoint.setAttribute('lon', String(Number(poi.lon))); trackPoint.setAttribute('lat', String(Number(poi.lat)));
waypoint.appendChild(createElement(document, 'name', title)); trackPoint.setAttribute('lon', String(Number(poi.lon)));
if (poi.description) waypoint.appendChild(createElement(document, 'desc', poi.description)); trackPoint.appendChild(createElement(document, 'name', title));
if (poi.description) trackPoint.appendChild(createElement(document, 'desc', poi.description));
const extensions = createElement(document, 'extensions'); const extensions = createElement(document, 'extensions');
const poiId = document.createElementNS(POI_NAMESPACE, 'wegwichtel:poiId'); const poiId = document.createElementNS(POI_NAMESPACE, 'wegwichtel:poiId');
poiId.textContent = String(poi.id); poiId.textContent = String(poi.id);
extensions.appendChild(poiId); extensions.appendChild(poiId);
waypoint.appendChild(extensions); trackPoint.appendChild(extensions);
const routeOrTrack = directChildrenByLocalName(root, 'rte')[0] segment.appendChild(trackPoint);
|| directChildrenByLocalName(root, 'trk')[0]
|| null;
if (routeOrTrack) routeOrTrack.before(waypoint);
else root.appendChild(waypoint);
}); });
return new XMLSerializer().serializeToString(document); return new XMLSerializer().serializeToString(document);
@@ -292,6 +323,14 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}).filter(point => point.lat != null && point.lon != null); }).filter(point => point.lat != null && point.lon != null);
} }
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.');
}
return routePackage.gpx;
}
function radians(value) { function radians(value) {
return value * (Math.PI / 180); return value * (Math.PI / 180);
} }
@@ -437,6 +476,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
fileToDataUrl, fileToDataUrl,
get: readPackage, get: readPackage,
getAppRoute, getAppRoute,
gpxForUpload,
list: listPackages, list: listPackages,
listAppRoutes, listAppRoutes,
parseWaypoints, parseWaypoints,
+68 -4
View File
@@ -34,6 +34,8 @@ test('editor markup and source describe local draft creation instead of immediat
assert.doesNotMatch(html, /id="editor-poi-(?:lat|lon)"[^>]+step=/); assert.doesNotMatch(html, /id="editor-poi-(?:lat|lon)"[^>]+step=/);
assert.match(html, /id="editor-poi-media-preview"/); assert.match(html, /id="editor-poi-media-preview"/);
assert.match(html, /id="editor-poi-delete"[^>]+editor-danger-button/); assert.match(html, /id="editor-poi-delete"[^>]+editor-danger-button/);
assert.match(html, />Trackpunkte<\/h3>/);
assert.match(html, /aria-label="Trackpunkt hinzufügen"/);
assert.match(html, /id="editor-route-upload"[^>]+type="button"[^>]+hidden/); assert.match(html, /id="editor-route-upload"[^>]+type="button"[^>]+hidden/);
assert.doesNotMatch(html, /accept="video\//); assert.doesNotMatch(html, /accept="video\//);
assert.match(html, /ausschließlich in diesem Browser/i); assert.match(html, /ausschließlich in diesem Browser/i);
@@ -45,13 +47,17 @@ test('editor markup and source describe local draft creation instead of immediat
assert.match(css, /\.editor-media-preview/); assert.match(css, /\.editor-media-preview/);
assert.match(css, /\.editor-media-remove/); assert.match(css, /\.editor-media-remove/);
assert.match(css, /\.editor-danger-button/); assert.match(css, /\.editor-danger-button/);
assert.match(css, /\.editor-icon-danger/);
assert.match(ui, /readJson\(['"]\/routes\?activatedOnly=true['"]\)/); assert.match(ui, /readJson\(['"]\/routes\?activatedOnly=true['"]\)/);
assert.match(ui, /ns\.LocalRoutes\.save\(/); assert.match(ui, /ns\.LocalRoutes\.save\(/);
assert.match(ui, /ns\.LocalRoutes\.parseWaypoints\(/); assert.match(ui, /ns\.LocalRoutes\.parseWaypoints\(/);
assert.match(ui, /ns\.LocalRoutes\.fileToDataUrl\(/); assert.match(ui, /ns\.LocalRoutes\.fileToDataUrl\(/);
assert.match(ui, /ns\.LocalRoutes\.gpxForUpload\(/);
assert.match(ui, /move-poi-up/); assert.match(ui, /move-poi-up/);
assert.match(ui, /move-poi-down/); assert.match(ui, /move-poi-down/);
assert.match(ui, /delete-route/);
assert.match(ui, /delete-poi/);
assert.match(ui, /sortOrder:\s*index/); assert.match(ui, /sortOrder:\s*index/);
assert.match(ui, /pointerup/); assert.match(ui, /pointerup/);
assert.doesNotMatch(ui, /sendJson\(['"]\/routes['"],\s*['"]POST['"]/); assert.doesNotMatch(ui, /sendJson\(['"]\/routes['"],\s*['"]POST['"]/);
@@ -246,6 +252,7 @@ function buildEditorContext() {
if (/^\/api\/routes\/22\/pois\/23\/(audio|pictures)$/.test(url) && method === 'POST') return jsonResponse({ ok: true }, true, 201); if (/^\/api\/routes\/22\/pois\/23\/(audio|pictures)$/.test(url) && method === 'POST') return jsonResponse({ ok: true }, true, 201);
if (url === '/api/routes/22' && method === 'DELETE') return jsonResponse({ deleted: true }); if (url === '/api/routes/22' && method === 'DELETE') return jsonResponse({ deleted: true });
if (url === '/api/routes/1' && method === 'GET') return jsonResponse({ route: serverRoute }); if (url === '/api/routes/1' && method === 'GET') return jsonResponse({ route: serverRoute });
if (url === '/api/routes/1' && method === 'DELETE') return jsonResponse({ deleted: true });
if (url === '/api/routes/1' && method === 'PUT') { if (url === '/api/routes/1' && method === 'PUT') {
const body = JSON.parse(options.body); const body = JSON.parse(options.body);
return jsonResponse({ route: { ...serverRoute, ...body } }); return jsonResponse({ route: { ...serverRoute, ...body } });
@@ -262,7 +269,7 @@ function buildEditorContext() {
const LocalRoutes = { const LocalRoutes = {
createPoiId: () => 9001, createPoiId: () => 9001,
createRouteId: () => 'local-new', createRouteId: () => 'local-new',
emptyGpx: name => `<gpx><metadata><name>${name}</name></metadata></gpx>`, emptyGpx: name => `<gpx><metadata><name>${name}</name></metadata><trk><trkseg></trkseg></trk></gpx>`,
fileToDataUrl: async file => ({ fileToDataUrl: async file => ({
dataUrl: `data:${file.type || 'application/octet-stream'};base64,TEST`, dataUrl: `data:${file.type || 'application/octet-stream'};base64,TEST`,
name: file.name, name: file.name,
@@ -270,17 +277,23 @@ function buildEditorContext() {
type: file.type || 'application/octet-stream' type: file.type || 'application/octet-stream'
}), }),
get: id => packages.get(String(id)) || null, get: id => packages.get(String(id)) || null,
gpxForUpload: routePackage => routePackage.gpx,
list: () => [...packages.values()], list: () => [...packages.values()],
parseWaypoints: () => [], parseWaypoints: () => [],
readFileAsText: file => file.text(), readFileAsText: file => file.text(),
remove: id => packages.delete(String(id)),
save: input => { save: input => {
const id = String(input.id); const id = String(input.id);
const now = '2026-09-19T14:00:00.000Z'; const now = '2026-09-19T14:00:00.000Z';
const pois = input.pois.map((poi, sequence) => ({ ...poi, sequence }));
const trackPoints = pois
.map(poi => `<trkpt lat="${poi.lat}" lon="${poi.lon}"><name>${poi.title || ''}</name><extensions><wegwichtel:poiId>${poi.id}</wegwichtel:poiId></extensions></trkpt>`)
.join('');
const saved = { const saved = {
id, id,
gpx: input.gpx, gpx: `<gpx><trk><trkseg>${trackPoints}</trkseg></trk></gpx>`,
route: { ...input.route, id, updatedAt: now }, route: { ...input.route, id, updatedAt: now },
pois: input.pois.map((poi, sequence) => ({ ...poi, sequence })) pois
}; };
packages.set(id, saved); packages.set(id, saved);
saves.push(structuredClone(saved)); saves.push(structuredClone(saved));
@@ -307,7 +320,7 @@ function buildEditorContext() {
return { calls, context, elements, packages, saves }; return { calls, context, elements, packages, saves };
} }
test('public editor workflow saves new route and GPX POI locally without POSTing a route', async () => { test('public editor workflow saves new route and GPX track point locally without POSTing a route', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js'); const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8'); const source = await fs.readFile(sourcePath, 'utf8');
const { calls, context, elements, packages, saves } = buildEditorContext(); const { calls, context, elements, packages, saves } = buildEditorContext();
@@ -364,6 +377,12 @@ test('public editor workflow saves new route and GPX POI locally without POSTing
assert.match(local.pois[0].audio.dataUrl, /^data:audio\/mpeg/); assert.match(local.pois[0].audio.dataUrl, /^data:audio\/mpeg/);
assert.equal(local.pois[0].images.length, 1); assert.equal(local.pois[0].images.length, 1);
assert.match(local.pois[0].images[0].dataUrl, /^data:image\/jpeg/); assert.match(local.pois[0].images[0].dataUrl, /^data:image\/jpeg/);
assert.match(local.gpx, /<trkpt lat="50\.123456789" lon="10\.987654321">/);
const pointListBeforeUpload = elements.get('editor-poi-list');
const pointItemBeforeUpload = pointListBeforeUpload.children.find(child => child.dataset.poiId);
assert.equal(pointItemBeforeUpload.children[1].children[3].textContent, '×');
assert.match(pointItemBeforeUpload.children[1].children[3].className, /editor-icon-danger/);
elements.get('editor-route-name').value = '--- Neue lokale Strecke ---'; elements.get('editor-route-name').value = '--- Neue lokale Strecke ---';
elements.get('editor-route-upload').dispatchEvent(pointerEvent()); elements.get('editor-route-upload').dispatchEvent(pointerEvent());
@@ -377,6 +396,7 @@ test('public editor workflow saves new route and GPX POI locally without POSTing
const gpxUpload = routeUpload.body.entries.find(([name]) => name === 'gpx'); const gpxUpload = routeUpload.body.entries.find(([name]) => name === 'gpx');
assert.ok(gpxUpload); assert.ok(gpxUpload);
assert.equal(gpxUpload[2], 'Neue-lokale-Strecke.gpx'); assert.equal(gpxUpload[2], 'Neue-lokale-Strecke.gpx');
assert.match(await gpxUpload[1].text(), /<trkpt lat=\"50\.123456789\" lon=\"10\.987654321\">/);
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/22/pois')); assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/22/pois'));
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/22/pois/23/audio')); assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/22/pois/23/audio'));
assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/22/pois/23/pictures')); assert.ok(calls.some(call => call.method === 'POST' && call.url === '/api/routes/22/pois/23/pictures'));
@@ -425,6 +445,50 @@ test('public editor workflow saves new route and GPX POI locally without POSTing
assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false'); assert.equal(elements.get('editor-button').attributes.get('aria-expanded'), 'false');
}); });
test('route and track point list expose red delete actions and remove local data', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8');
const { context, elements, packages } = buildEditorContext();
vm.runInNewContext(source, context, { filename: sourcePath });
await context.Wegwichtel.EditorUi.openNewLocalRoute();
elements.get('editor-route-name').value = 'Löschtest';
elements.get('editor-route-form').dispatchEvent(submitEvent());
await tick();
await tick();
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
elements.get('editor-poi-title').value = 'Trackpunkt zum Löschen';
elements.get('editor-poi-form').dispatchEvent(submitEvent());
await tick();
await tick();
let pointList = elements.get('editor-poi-list');
let pointItem = pointList.children.find(child => child.dataset.poiId);
const pointDelete = pointItem.children[1].children[3];
assert.equal(pointDelete.textContent, '×');
assert.match(pointDelete.className, /editor-icon-danger/);
pointList.dispatchEvent(pointerEvent(pointDelete));
await tick();
await tick();
assert.equal(packages.get('local-new').pois.length, 0);
elements.get('editor-route-back').dispatchEvent(pointerEvent());
await tick();
await tick();
const routeList = elements.get('editor-route-list');
const localItem = routeList.children.find(child => child.dataset.routeId === 'local-new');
const routeDelete = localItem.children[1].children[1];
assert.equal(routeDelete.textContent, '×');
assert.match(routeDelete.className, /editor-icon-danger/);
routeList.dispatchEvent(pointerEvent(routeDelete));
await tick();
await tick();
assert.equal(packages.has('local-new'), false);
assert.equal(elements.get('editor-status').textContent, 'Strecke gelöscht.');
});
test('editing an activated server route uses PUT and never writes activation state', async () => { test('editing an activated server route uses PUT and never writes activation state', async () => {
const sourcePath = path.join(root, 'public/js/editor-ui.js'); const sourcePath = path.join(root, 'public/js/editor-ui.js');
const source = await fs.readFile(sourcePath, 'utf8'); const source = await fs.readFile(sourcePath, 'utf8');
+47 -7
View File
@@ -324,7 +324,7 @@ test('local route package stores GPX and JSON separately and reconstructs an app
const saved = api.save({ const saved = api.save({
id: routeId, id: routeId,
gpx: sampleGpx, gpx: api.emptyGpx('Neue Route'),
route: { route: {
id: routeId, id: routeId,
name: 'Neue Route', name: 'Neue Route',
@@ -365,9 +365,10 @@ test('local route package stores GPX and JSON separately and reconstructs an app
const gpx = localStorage.getItem(`${prefix}.${encodedId}.gpx`); const gpx = localStorage.getItem(`${prefix}.${encodedId}.gpx`);
const metadata = JSON.parse(localStorage.getItem(`${prefix}.${encodedId}.json`)); const metadata = JSON.parse(localStorage.getItem(`${prefix}.${encodedId}.json`));
assert.match(gpx, /<wpt lat="50\.1" lon="10\.1">/); assert.match(gpx, /<trkpt lat="50\.1" lon="10\.1">/);
assert.match(gpx, /<wegwichtel:poiId>101<\/wegwichtel:poiId>/); assert.match(gpx, /<wegwichtel:poiId>101<\/wegwichtel:poiId>/);
assert.ok(gpx.indexOf('<wpt') < gpx.indexOf('<trk>'), 'GPX waypoints must be serialized before the track'); assert.doesNotMatch(gpx, /<wpt/);
assert.equal((gpx.match(/<trkpt /g) || []).length, 2);
assert.equal(metadata.route.name, 'Neue Route'); assert.equal(metadata.route.name, 'Neue Route');
assert.equal(metadata.route.serverRouteId, 42); assert.equal(metadata.route.serverRouteId, 42);
assert.equal(metadata.route.uploadedAt, '2026-09-27T18:00:00.000Z'); assert.equal(metadata.route.uploadedAt, '2026-09-27T18:00:00.000Z');
@@ -383,7 +384,7 @@ test('local route package stores GPX and JSON separately and reconstructs an app
assert.equal(appRoute.pois[0].images[0].url, 'data:image/jpeg;base64,Qg=='); assert.equal(appRoute.pois[0].images[0].url, 'data:image/jpeg;base64,Qg==');
assert.equal(appRoute.points.length, 2); assert.equal(appRoute.points.length, 2);
assert.ok(appRoute.distanceM > 0); assert.ok(appRoute.distanceM > 0);
assert.equal(Math.round(appRoute.elevationGainM), 12); assert.equal(Math.round(appRoute.elevationGainM), 0);
const listed = api.listAppRoutes(); const listed = api.listAppRoutes();
assert.equal(listed.length, 1); assert.equal(listed.length, 1);
assert.equal(listed[0].id, routeId); assert.equal(listed[0].id, routeId);
@@ -405,10 +406,49 @@ test('GPX waypoint import reads stable POI ids and validates coordinates', async
const waypointOnly = api.syncGpx( const waypointOnly = api.syncGpx(
`<gpx version="1.1" xmlns="${GPX_NAMESPACE}"></gpx>`, `<gpx version="1.1" xmlns="${GPX_NAMESPACE}"></gpx>`,
{ name: 'Nur POI' }, { name: 'Nur Trackpunkt' },
[{ id: 2, title: 'Einzelner POI', lat: 50.2, lon: 10.2, sequence: 0 }] [{ id: 2, title: 'Einzelner Trackpunkt', lat: 50.2, lon: 10.2, sequence: 0 }]
); );
assert.match(waypointOnly, /<wpt lat="50.2" lon="10.2">/); assert.match(waypointOnly, /<trkpt lat="50.2" lon="10.2">/);
assert.doesNotMatch(waypointOnly, /<wpt\b/);
});
test('local points are stored directly as managed GPX track points and uploaded unchanged', async () => {
const { api } = await loadLocalRoutes();
const saved = api.save({
id: 'local-upload',
gpx: api.emptyGpx('Lokale Strecke'),
route: { name: 'Lokale Strecke' },
pois: [
{ id: 1, title: 'Start', lat: 50.123456789, lon: 10.987654321, sequence: 0 },
{ id: 2, title: 'Ziel', lat: 50.223456789, lon: 11.087654321, sequence: 1 }
]
});
assert.match(saved.gpx, /<trkpt lat="50\.123456789" lon="10\.987654321">/);
assert.match(saved.gpx, /<trkpt lat="50\.223456789" lon="11\.087654321">/);
assert.match(saved.gpx, /<wegwichtel:poiId>1<\/wegwichtel:poiId>/);
assert.equal((saved.gpx.match(/<trkpt /g) || []).length, 2);
assert.doesNotMatch(saved.gpx, /<wpt\b/);
assert.equal(api.gpxForUpload(saved), saved.gpx, 'upload must use the already stored track without conversion');
});
test('sync keeps imported foreign track points and upload rejects a route without track points', async () => {
const { api } = await loadLocalRoutes();
const synced = api.syncGpx(sampleGpx, { name: 'Import' }, [
{ id: 200, title: 'Neuer Trackpunkt', lat: 50.2, lon: 10.2, sequence: 0 }
]);
assert.match(synced, /<ele>100<\/ele>/);
assert.match(synced, /<ele>112<\/ele>/);
assert.match(synced, /<wegwichtel:poiId>200<\/wegwichtel:poiId>/);
assert.equal((synced.match(/<trkpt /g) || []).length, 3);
assert.equal(api.gpxForUpload({ gpx: sampleGpx, route: { name: 'Import' }, pois: [] }), sampleGpx);
assert.throws(() => api.gpxForUpload({
gpx: api.emptyGpx('Leer'),
route: { name: 'Leer' },
pois: []
}), /noch keine GPX-Trackpunkte/);
}); });
test('remove updates index and dispatches a local-route change event', async () => { test('remove updates index and dispatches a local-route change event', async () => {