Fehlende Orte kannst du später eintragen. Leere Felder bleiben gespeichert.
diff --git a/public/js/app.js b/public/js/app.js
index b5de889..e23c46a 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -22,6 +22,19 @@
persistTimer: null
};
+ let poiMediaGeneration = 0;
+ let localPoiMedia = null;
+
+ function releaseLocalPoiMedia() {
+ poiMediaGeneration += 1;
+ if (localPoiMedia) {
+ ns.AudioPlayer.unload();
+ ns.Slideshow.show([]);
+ ns.LocalRoutes.releasePoiMedia(localPoiMedia);
+ localPoiMedia = null;
+ }
+ }
+
function escapeHtml(value) {
return $('').text(value ?? '').html();
}
@@ -88,6 +101,7 @@
const target = $(pageSelector);
if (!target.length) throw new Error(ns.I18n.t("app.page.missing"));
+ if (pageSelector !== '#poi-page') releaseLocalPoiMedia();
const previous = state.activePage;
$('.app-page').attr('hidden', true);
target.removeAttr('hidden');
@@ -287,6 +301,7 @@
}
function endRoute({ announce = true, returnToRoutes = false } = {}) {
+ releaseLocalPoiMedia();
const wasActive = state.routeState !== 'idle';
state.routeState = 'idle';
persistRouteProgress();
@@ -330,6 +345,7 @@
}
function suspendCurrentRoute() {
+ releaseLocalPoiMedia();
if (!state.route) return;
if (state.routeState === 'running') state.routeState = 'paused';
persistRouteProgress();
@@ -383,6 +399,7 @@
}
function showPoi(poi) {
+ releaseLocalPoiMedia();
state.activePoiId = Number(poi.id);
state.viewed.add(state.activePoiId);
persistRouteProgress();
@@ -392,6 +409,27 @@
ns.Slideshow.show(poi.images);
ns.AudioPlayer.load(poi.audioUrl);
navigate('#poi-page');
+ if (state.route?.source === 'local') void loadLocalPoiMedia(poi);
+ }
+
+ async function loadLocalPoiMedia(poi) {
+ const route = state.route;
+ const requestGeneration = poiMediaGeneration;
+ try {
+ const loaded = await ns.LocalRoutes.getAppPoi(route.id, poi.id, { requestPermission: true });
+ if (requestGeneration !== poiMediaGeneration || state.route !== route) {
+ ns.LocalRoutes.releasePoiMedia(loaded);
+ return;
+ }
+ if (!loaded) return;
+ localPoiMedia = loaded;
+ ns.Slideshow.show(loaded.images);
+ ns.AudioPlayer.load(loaded.audioUrl);
+ } catch (error) {
+ if (requestGeneration === poiMediaGeneration) {
+ $('#tracking-status').prop('hidden', false).text(ns.UserMessages?.error(error) || ns.I18n.t('app.error.open-draft'));
+ }
+ }
}
function nearestRoutePointIndex(position) {
@@ -680,7 +718,7 @@
async function selectRoute(source, id) {
setRouteControlOpen(false);
const route = source === 'local'
- ? await ns.LocalRoutes.getAppRoute(id, { requestPermission: true })
+ ? await ns.LocalRoutes.getAppRoute(id)
: { ...(await ns.Api.route(id)), source: 'server', local: false };
renderRoute(route);
navigate('#route-page');
diff --git a/public/js/editor-ui.js b/public/js/editor-ui.js
index a606fb1..4995a10 100644
--- a/public/js/editor-ui.js
+++ b/public/js/editor-ui.js
@@ -202,7 +202,17 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
return ns.I18n.t("editor.view-route");
}
+ let poiMediaGeneration = 0;
+ let previewPoi = null;
+
+ function releasePoiPreview() {
+ poiMediaGeneration += 1;
+ ns.LocalRoutes?.releasePoiMedia?.(previewPoi);
+ previewPoi = null;
+ }
+
function showListView() {
+ releasePoiPreview();
ns.RouteRecorder?.endEditing();
setDialogTitle('Streckenverwaltung');
setHidden(byId(ids.routeListView), false);
@@ -211,6 +221,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
function showRouteView() {
+ releasePoiPreview();
setDialogTitle(routeDialogTitle());
setHidden(byId(ids.routeListView), true);
setHidden(byId(ids.routeDetailView), false);
@@ -411,8 +422,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
list.textContent = '';
const serverRoutes = state.routes.filter(route => routeSource(route) === 'server');
const localRoutes = state.routes.filter(route => routeSource(route) === 'local');
- renderRouteGroup(list, ns.I18n.t("editor.public-routes"), serverRoutes, ns.I18n.t("app.no-public-route"));
renderRouteGroup(list, ns.I18n.t("editor.drafts"), localRoutes, ns.I18n.t("editor.no-draft"));
+ renderRouteGroup(list, ns.I18n.t("editor.public-routes"), serverRoutes, ns.I18n.t("app.no-public-route"));
}
function renderPoiList() {
@@ -438,19 +449,30 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}));
}
+ let routeListGeneration = 0;
+
async function loadRoutes() {
- setStatus(ns.I18n.t("editor.loading-routes"));
- const payload = await readJson('/routes?activatedOnly=true');
- const serverRoutes = routeArray(payload).map(route => ({ ...route, _source: 'server' }));
- let localRoutes = [];
- try {
- localRoutes = localRouteSummaries();
- } catch (error) {
- setStatus(error.message, true);
- }
- state.routes = [...serverRoutes, ...localRoutes];
+ const requestGeneration = ++routeListGeneration;
+ state.routes = [...localRouteSummaries(), ...state.routes.filter(route => routeSource(route) === 'server')];
renderRouteList();
- if (!byId(ids.status)?.dataset.kind || byId(ids.status).dataset.kind !== 'error') setStatus('');
+ setStatus('');
+ // Local drafts are usable even while this request is waiting for a connection.
+ void refreshPublicRoutes(requestGeneration);
+ }
+
+ async function refreshPublicRoutes(requestGeneration) {
+ const listIsVisible = () => byId(ids.dialog)?.open && !byId(ids.routeListView)?.hidden;
+ try {
+ const payload = await readJson('/routes?activatedOnly=true');
+ if (requestGeneration !== routeListGeneration) return;
+ const serverRoutes = routeArray(payload).map(route => ({ ...route, _source: 'server' }));
+ state.routes = [...localRouteSummaries(), ...serverRoutes];
+ renderRouteList();
+ } catch {
+ if (requestGeneration === routeListGeneration && listIsVisible()) {
+ setStatus(ns.I18n.t('editor.public-unavailable'));
+ }
+ }
}
async function openEditor() {
@@ -471,6 +493,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
function closeEditor() {
+ releasePoiPreview();
ns.RouteRecorder?.endEditing();
const dialog = byId(ids.dialog);
if (dialog?.open && typeof dialog.close === 'function') dialog.close();
@@ -499,6 +522,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function fillRouteForm(route) {
setHidden(byId('editor-path-positions'), state.currentSource !== 'local');
+ setHidden(byId('editor-recording-options'), state.currentSource !== 'local');
if (state.currentSource === 'local') ns.RouteRecorder?.renderPoints(route.gpx || ns.LocalRoutes.emptyGpx(route.name));
byId(ids.routeId).value = routeId(route);
byId(ids.routeName).value = routeName(route) === ns.I18n.t("editor.untitled-route") ? '' : routeName(route);
@@ -764,14 +788,14 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function loadRoute(route) {
const id = routeId(route);
if (!id) return;
- if (ns.RouteRecorder?.routeId && (routeSource(route) !== 'local' || ns.RouteRecorder.routeId !== id)) {
- ns.RouteRecorder.endEditing();
+ if (state.currentRoute && (routeSource(route) !== state.currentSource || routeId(state.currentRoute) !== id)) {
+ ns.RouteRecorder?.endEditing();
}
setStatus(ns.I18n.t("editor.route.loading"));
state.currentSource = routeSource(route);
if (state.currentSource === 'local') {
- const routePackage = await ns.LocalRoutes.getWithMedia(id, { requestPermission: true });
+ const routePackage = ns.LocalRoutes.get(id);
if (!routePackage) throw new Error(ns.I18n.t("editor.error.draft-missing"));
state.currentRoute = {
...routePackage.route,
@@ -1048,6 +1072,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
function newPoi() {
+ releasePoiPreview();
const position = currentPosition();
const lat = position?.lat ?? null;
const lon = position?.lon ?? null;
@@ -1065,11 +1090,26 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
showPoiView();
}
- function editPoi(poi) {
+ async function editPoi(poi) {
+ if (!poi) return;
+ releasePoiPreview();
state.currentPoi = poi;
fillPoiForm(poi);
byId(ids.poiFormTitle).textContent = ns.I18n.t("editor.edit-station");
showPoiView();
+ if (state.currentSource !== 'local') return;
+ const requestGeneration = poiMediaGeneration;
+ const hydrated = await ns.LocalRoutes.getPoiWithMedia(routeId(state.currentRoute), poi.id, { requestPermission: true });
+ if (requestGeneration !== poiMediaGeneration || state.currentPoi !== poi) {
+ ns.LocalRoutes.releasePoiMedia(hydrated);
+ return;
+ }
+ if (!hydrated) return;
+ previewPoi = hydrated;
+ state.currentPoi = hydrated;
+ // Preserve any text edits and newly selected files while existing previews load.
+ renderPoiMediaPreview();
+ updatePoiMediaStatus();
}
async function mediaFromLocalForm(existingPoi, poiIdentifier) {
@@ -1122,13 +1162,16 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
route: state.currentRoute,
pois: state.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;
+ const previousPoi = state.currentPoi;
+ state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
+ state.pois = saved.pois;
state.currentPoi = currentPoiId
? state.pois.find(poi => poiId(poi) === currentPoiId) || null
: null;
- return hydrated;
+ if (state.currentPoi && previousPoi) {
+ state.currentPoi = { ...state.currentPoi, audio: previousPoi.audio, images: previousPoi.images };
+ }
+ return saved;
}
async function saveLocalPoi(values) {
@@ -1241,7 +1284,10 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
updateCurrentPoi(updated);
await persistLocalCurrentRoute();
- if (removedMedia) await ns.LocalRoutes.removeMedia(removedMedia);
+ if (removedMedia) {
+ ns.LocalRoutes.releasePoiMedia({ audio: removedMedia });
+ await ns.LocalRoutes.removeMedia(removedMedia);
+ }
state.selectedMediaKey = null;
renderPoiMediaPreview();
updatePoiMediaStatus();
@@ -1416,7 +1462,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function poiListAction(event) {
const action = event.target.closest('[data-editor-action]')?.dataset.editorAction;
if (action === 'edit-poi') {
- editPoi(poiForItem(event.target));
+ await editPoi(poiForItem(event.target));
return;
}
if (action === 'delete-poi') {
@@ -1442,12 +1488,15 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
function bind() {
for (const eventName of ['cancel', 'close']) {
- byId(ids.dialog)?.addEventListener(eventName, () => ns.RouteRecorder?.endEditing());
+ byId(ids.dialog)?.addEventListener(eventName, () => {
+ releasePoiPreview();
+ ns.RouteRecorder?.endEditing();
+ });
}
activate(byId(ids.button), () => guarded(openEditor));
activate(byId(ids.close), closeEditor);
activate(byId(ids.newRoute), newRoute);
- activate(byId(ids.routeBack), () => guarded(loadRoutes).then(showListView));
+ activate(byId(ids.routeBack), () => { showListView(); void guarded(loadRoutes); });
activate(byId(ids.routeUpload), () => guarded(uploadLocalRoute));
activate(byId(ids.rightsOpen), () => ns.LegalUi?.open('terms'));
activate(byId(ids.addPoi), newPoi);
diff --git a/public/js/local-routes.js b/public/js/local-routes.js
index 369cfd3..5a04d37 100644
--- a/public/js/local-routes.js
+++ b/public/js/local-routes.js
@@ -12,6 +12,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const MEDIA_STORE_NAME = 'media';
let mediaDatabasePromise = null;
let poiCounter = 0;
+ const objectUrls = new Set();
function storage() {
const target = globalThis.localStorage;
@@ -195,7 +196,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
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 url = globalThis.URL.createObjectURL(blob);
+ objectUrls.add(url);
+ return { ...media, url, unavailable: false };
}
const fallback = await fileToDataUrl(blob);
return { ...media, dataUrl: fallback.dataUrl, unavailable: false };
@@ -204,16 +207,39 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
}
}
- async function withMedia(routePackage, options = {}) {
- if (!routePackage) return null;
- const pois = await Promise.all((routePackage.pois || []).map(async poi => ({
+ async function poiWithMedia(poi, options = {}) {
+ if (!poi) return null;
+ return {
...poi,
audio: await mediaForUse(poi.audio, options),
images: await Promise.all((poi.images || []).map(media => mediaForUse(media, options)))
- })));
+ };
+ }
+
+ function releasePoiMedia(poi) {
+ const urls = [poi?.audio?.url, poi?.audioUrl, ...(poi?.images || []).map(image => image.url)];
+ for (const url of urls) {
+ if (!objectUrls.delete(url)) continue;
+ globalThis.URL?.revokeObjectURL?.(url);
+ }
+ }
+
+ async function withMedia(routePackage, options = {}) {
+ if (!routePackage) return null;
+ const pois = await Promise.all((routePackage.pois || []).map(poi => poiWithMedia(poi, options)));
return { ...routePackage, pois };
}
+ async function getPoiWithMedia(id, poiId, options = {}) {
+ const poi = readPackage(id)?.pois.find(item => String(item.id) === String(poiId));
+ return poiWithMedia(poi, options);
+ }
+
+ async function getAppPoi(id, poiId, options = {}) {
+ const poi = await getPoiWithMedia(id, poiId, options);
+ return poi ? appPoi(poi) : null;
+ }
+
function mediaForStorage(media) {
if (!media) return null;
const stored = { ...media };
@@ -709,8 +735,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
return listPackages().map(toAppRoute);
}
- async function getAppRoute(id, options = {}) {
- const routePackage = await withMedia(readPackage(id), options);
+ async function getAppRoute(id) {
+ const routePackage = readPackage(id);
if (!routePackage) throw new Error(ns.I18n.t("local.error.route-missing"));
return toAppRoute(routePackage);
}
@@ -762,6 +788,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
get: readPackage,
getAppRoute,
getWithMedia,
+ getPoiWithMedia,
+ getAppPoi,
+ releasePoiMedia,
gpxForUpload,
list: listPackages,
listAppRoutes,
diff --git a/public/js/route-recorder.js b/public/js/route-recorder.js
index c8d08be..b6e8287 100644
--- a/public/js/route-recorder.js
+++ b/public/js/route-recorder.js
@@ -1,18 +1,22 @@
globalThis.Wegwichtel ??= {};
(function (ns) {
'use strict';
- const INTERVAL_MS = 30000;
+ const INTERVAL_MS = 120000;
let id = null;
let editingId = null;
let status = 'idle';
let timer = null;
let generation = 0;
- let pending = false;
+ let pendingGeneration = null;
+ let enabled = false;
+ let manualGeneration = 0;
let message = '';
let manualFields = [];
let lastPosition = null;
function render() {
+ const toggle = document.getElementById('route-recording-toggle');
+ if (toggle) { toggle.checked = enabled; toggle.setAttribute('aria-checked', String(enabled)); }
const control = document.getElementById('route-recording-control');
if (!control) return;
control.hidden = status === 'idle';
@@ -21,9 +25,9 @@ globalThis.Wegwichtel ??= {};
}
async function sample() {
- if (pending || status !== 'recording') return;
- pending = true;
+ if (pendingGeneration === generation || status !== 'recording') return;
const currentGeneration = generation;
+ pendingGeneration = currentGeneration;
try {
let position;
try { position = await ns.Geo.current({ maximumAge: 0 }); }
@@ -40,7 +44,7 @@ globalThis.Wegwichtel ??= {};
ns.StorageUsage?.reportError(error);
message = ns.I18n.t('recorder.save-error');
} finally {
- pending = false;
+ if (pendingGeneration === currentGeneration) pendingGeneration = null;
render();
}
}
@@ -69,6 +73,7 @@ globalThis.Wegwichtel ??= {};
}
function stop() {
+ enabled = false;
clearTimer();
id = null;
lastPosition = null;
@@ -78,6 +83,7 @@ globalThis.Wegwichtel ??= {};
}
function start(routeId) {
+ enabled = true;
lastPosition = null;
id = String(routeId);
editingId = id;
@@ -85,16 +91,46 @@ globalThis.Wegwichtel ??= {};
}
function beginEditing(routeId) {
- if (editingId === String(routeId)) return;
- stop();
- start(routeId);
+ const nextId = String(routeId);
+ if (editingId === nextId) return;
+ if (editingId !== null) endEditing();
+ editingId = nextId;
+ if (enabled) { id = editingId; resume(); }
+ render();
+ }
+
+ function setEnabled(value) {
+ if (!value) { stop(); return; }
+ enabled = true;
+ if (editingId) { id = editingId; resume(); }
+ render();
}
function endEditing() {
editingId = null;
+ manualGeneration += 1;
stop();
}
+ async function fillCurrentLocation(field, button, notice) {
+ if (button.disabled) return;
+ const currentGeneration = manualGeneration;
+ button.disabled = true;
+ notice.textContent = ns.I18n.t('recorder.locating');
+ try {
+ const position = await ns.Geo.current({ maximumAge: 0 });
+ if (currentGeneration !== manualGeneration || !manualFields.includes(field)) return;
+ if (!Number.isFinite(position.lat) || !Number.isFinite(position.lon) || Math.abs(position.lat) > 90 || Math.abs(position.lon) > 180) throw new Error('Invalid location');
+ field.lat.value = String(position.lat);
+ field.lon.value = String(position.lon);
+ notice.textContent = ns.I18n.t('recorder.location-set');
+ } catch {
+ if (currentGeneration === manualGeneration && manualFields.includes(field)) notice.textContent = ns.I18n.t('recorder.location-error');
+ } finally {
+ button.disabled = false;
+ }
+ }
+
function addPositionField(position = {}, index = null) {
const container = document.getElementById('editor-recorded-positions');
if (!container) return;
@@ -114,13 +150,30 @@ globalThis.Wegwichtel ??= {};
row.appendChild(label); row.appendChild(input);
fields[key] = input;
}
- manualFields.push({ index, original: position, ...fields });
+ const field = { index, original: position, ...fields };
+ manualFields.push(field);
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'current-location-button';
+ button.setAttribute('aria-label', ns.I18n.t('recorder.use-location'));
+ button.title = ns.I18n.t('recorder.use-location');
+ const icon = document.createElement('img');
+ icon.src = '/images/icons/current-location.svg';
+ icon.alt = '';
+ button.appendChild(icon);
+ const notice = document.createElement('p');
+ notice.className = 'editor-muted';
+ notice.setAttribute('aria-live', 'polite');
+ button.addEventListener('click', () => { void fillCurrentLocation(field, button, notice); });
+ row.appendChild(button);
+ row.appendChild(notice);
container.appendChild(row);
}
function renderPoints(gpx) {
const container = document.getElementById('editor-recorded-positions');
if (!container) return;
+ manualGeneration += 1;
container.replaceChildren();
manualFields = [];
ns.LocalRoutes.recordedPositions(gpx).forEach(addPositionField);
@@ -146,6 +199,10 @@ globalThis.Wegwichtel ??= {};
function bind() {
globalThis.addEventListener?.('pagehide', endEditing);
+ document.getElementById('route-recording-toggle')?.addEventListener('change', event => setEnabled(event.target.checked));
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible' && status === 'recording') void sample();
+ });
const activate = (elementId, action) => {
const element = document.getElementById(elementId);
element?.addEventListener('pointerup', event => {
diff --git a/public/js/texts-de.js b/public/js/texts-de.js
index f492586..4e8566a 100644
--- a/public/js/texts-de.js
+++ b/public/js/texts-de.js
@@ -129,6 +129,7 @@ globalThis.Wegwichtel ??= {};
"drafts": "Meine Entwürfe",
"no-draft": "Noch kein eigener Entwurf vorhanden.",
"no-stations": "Noch keine Stationen angelegt.",
+ "public-unavailable": "Die veröffentlichten Strecken sind gerade nicht erreichbar. Deine Entwürfe auf diesem Gerät kannst du weiter bearbeiten.",
"loading-routes": "Strecken werden geladen …",
"submitted": "Erneut zur Prüfung senden",
"rights-hint": "Wenn du Bilder oder Tonaufnahmen sendest, erlaubst du Wegwichtel, sie nach der Nutzungsvereinbarung zu verwenden. Sende nur Aufnahmen, für die du diese Erlaubnis geben darfst. Kinder brauchen die Zustimmung ihrer Sorgeberechtigten.",
@@ -452,6 +453,13 @@ globalThis.Wegwichtel ??= {};
}
},
"recorder": {
+ "toggle": "Meinen Weg aufnehmen",
+ "toggle-help": "Alle zwei Minuten wird dein Standort auf diesem Gerät gespeichert. Die Aufnahme endet, wenn du die Bearbeitung verlässt.",
+ "use-location": "Meinen Standort einsetzen",
+ "locating": "Dein Standort wird gesucht …",
+ "location-set": "Dein Standort ist eingetragen. Speichere jetzt deine Änderungen.",
+ "location-error": "Dein Standort konnte nicht gefunden werden. Erlaube den Zugriff auf deinen Standort und versuche es noch einmal.",
+
"recording": "Dein Weg wird aufgenommen.",
"paused": "Die Aufnahme macht eine Pause.",
"pause": "Aufnahme pausieren",
diff --git a/public/js/texts-en.js b/public/js/texts-en.js
index 3e102b7..c92603d 100644
--- a/public/js/texts-en.js
+++ b/public/js/texts-en.js
@@ -129,6 +129,7 @@ globalThis.Wegwichtel ??= {};
"drafts": "My drafts",
"no-draft": "There are no drafts yet.",
"no-stations": "There are no stops yet.",
+ "public-unavailable": "Published trails are currently unavailable. You can still edit your drafts on this device.",
"loading-routes": "Loading trails …",
"submitted": "Send changes for review",
"rights-hint": "By sending pictures or recordings, you allow Wegwichtel to use them under its terms of use. Only send recordings you are allowed to share this way. Children need their parent or guardian’s permission.",
@@ -452,6 +453,13 @@ globalThis.Wegwichtel ??= {};
}
},
"recorder": {
+ "toggle": "Record my path",
+ "toggle-help": "Your location is saved on this device every two minutes. Recording stops when you leave the editor.",
+ "use-location": "Use my current location",
+ "locating": "Finding your location …",
+ "location-set": "Your location has been entered. Save your changes now.",
+ "location-error": "Your location could not be found. Allow access to your location and try again.",
+
"recording": "Your trail is being recorded.",
"paused": "Recording is paused.",
"pause": "Pause recording",
diff --git a/src/services/gpx.js b/src/services/gpx.js
index 90d5cbf..71e0190 100644
--- a/src/services/gpx.js
+++ b/src/services/gpx.js
@@ -3,6 +3,8 @@ import { HttpError } from '../middleware/errors.js';
import { distanceMeters, routeMetrics } from './geo.js';
import { config } from '../config.js';
+const WAYPOINT_DERIVED = Symbol('waypointDerived');
+
const xmlOptions = { ignoreAttributes: false, attributeNamePrefix: '@_', trimValues: true };
const parser = new XMLParser(xmlOptions);
const builder = new XMLBuilder({ ...xmlOptions, format: true });
@@ -20,6 +22,7 @@ function parseXml(xml) {
function toRoutePoint(point, sequence) {
return {
sequence,
+ ...(point.extensions?.['wegwichtel:waypointRoutePoint'] === true ? { [WAYPOINT_DERIVED]: true } : {}),
lat: Number.parseFloat(point['@_lat']),
lon: Number.parseFloat(point['@_lon']),
elevation: point.ele == null ? null : Number.parseFloat(point.ele),
@@ -42,6 +45,49 @@ export function simplifyTrack(points, radiusM = config.gpxClusterMeters) {
return result.map((point, sequence) => ({ ...point, sequence }));
}
+function sameLocation(left, right) {
+ return left.lat === right.lat && left.lon === right.lon;
+}
+
+function insertionDistance(path, slot, waypoint) {
+ const before = path[slot - 1];
+ const after = path[slot];
+ return (before ? distanceMeters(before, waypoint) : 0)
+ + (after ? distanceMeters(waypoint, after) : 0)
+ - (before && after ? distanceMeters(before, after) : 0);
+}
+
+function waypointSlot(path, waypoint, firstSlot) {
+ let slot = firstSlot;
+ let bestDistance = Number.POSITIVE_INFINITY;
+ for (let index = firstSlot; index <= path.length; index += 1) {
+ const addedDistance = insertionDistance(path, index, waypoint);
+ if (addedDistance < bestDistance) {
+ bestDistance = addedDistance;
+ slot = index;
+ }
+ }
+ return slot;
+}
+
+// Keep the recorded path and station order. Among remaining gaps choose the
+// insertion with the smallest extra walking distance; no road routing is used.
+function mergeWaypoints(path, waypoints) {
+ const result = [...path];
+ let firstSlot = 0;
+ for (const waypoint of waypoints) {
+ const existing = result.findIndex((point, index) => index >= firstSlot && sameLocation(point, waypoint));
+ if (existing !== -1) {
+ firstSlot = existing;
+ continue;
+ }
+ const slot = waypointSlot(result, waypoint, firstSlot);
+ result.splice(slot, 0, { ...waypoint, [WAYPOINT_DERIVED]: true });
+ firstSlot = slot + 1;
+ }
+ return result.map((point, sequence) => ({ ...point, sequence }));
+}
+
export function parseGpx(xml, replacementPoints = null, { allowEmpty = false } = {}) {
const gpx = parseXml(xml)?.gpx;
if (!gpx) throw new HttpError(400, 'GPX-Wurzelelement fehlt.');
@@ -49,16 +95,16 @@ export function parseGpx(xml, replacementPoints = null, { allowEmpty = false } =
const routes = asList(gpx.rte);
const routed = validPoints(routes.flatMap(route => asList(route.rtept)));
const recorded = validPoints(tracks.flatMap(track => asList(track.trkseg)).flatMap(segment => asList(segment.trkpt)));
- const points = replacementPoints ?? (routed.length ? routed : simplifyTrack(recorded));
const waypoints = validPoints(asList(gpx.wpt));
+ const path = (replacementPoints ?? (routed.length ? routed : simplifyTrack(recorded)))
+ .filter(point => !point[WAYPOINT_DERIVED]);
+ const points = mergeWaypoints(path, waypoints);
if (!allowEmpty && !points.length && !waypoints.length && !gpx.extensions?.['wegwichtel:gpsGap'] && !gpx.extensions?.['wegwichtel:pendingWaypoint']) throw new HttpError(400, 'Die GPX-Datei enthält keine verwertbaren Punkte.');
- const metricPoints = points.length ? points : waypoints;
- const metrics = metricPoints.length ? routeMetrics(metricPoints) : {
+ const metrics = points.length ? routeMetrics(points) : {
startLat: null, startLon: null, centerLat: null, centerLon: null,
minLat: null, minLon: null, maxLat: null, maxLon: null,
distanceM: 0, elevationGainM: 0, pointCount: 0
};
- if (!points.length) { metrics.distanceM = 0; metrics.elevationGainM = 0; metrics.pointCount = 0; }
const name = routes[0]?.name || tracks[0]?.name || gpx.metadata?.name || null;
delete gpx.trk;
delete gpx.rte;
@@ -66,11 +112,12 @@ export function parseGpx(xml, replacementPoints = null, { allowEmpty = false } =
const { extensions, ...base } = gpx;
// GPX 1.1 namespace identifier: this URI is written into XML, never fetched.
// Its exact spelling is part of the GPX standard; HTTPS would be another namespace.
- const normalized = { ...base, '@_version': '1.1', '@_creator': 'Wegwichtel', '@_xmlns': 'http://www.topografix.com/GPX/1/1' };
+ const normalized = { ...base, '@_xmlns:wegwichtel': 'urn:wegwichtel:poi', '@_version': '1.1', '@_creator': 'Wegwichtel', '@_xmlns': 'http://www.topografix.com/GPX/1/1' };
if (points.length) normalized.rte = { ...(name ? { name } : {}), rtept: points.map(point => ({
'@_lat': point.lat, '@_lon': point.lon,
...(Number.isFinite(point.elevation) ? { ele: point.elevation } : {}),
- ...(point.recordedAt ? { time: point.recordedAt } : {})
+ ...(point.recordedAt ? { time: point.recordedAt } : {}),
+ ...(point[WAYPOINT_DERIVED] ? { extensions: { 'wegwichtel:waypointRoutePoint': true } } : {})
})) };
if (extensions) normalized.extensions = extensions;
return { name, points, metrics, normalizedXml: '\n' + builder.build({ gpx: normalized }) };
diff --git a/src/services/routes-service.js b/src/services/routes-service.js
index 70b74d7..2333021 100644
--- a/src/services/routes-service.js
+++ b/src/services/routes-service.js
@@ -246,7 +246,10 @@ export function appendRoute(db, id, gpxFile) {
const parsed = parseGpx(fs.readFileSync(gpxFile.path, 'utf8'));
const current = readRouteGpx(existing).points;
- const allPoints = current.concat(parsed.points).map((point, sequence) => ({ ...point, sequence }));
+ // An appended route contributes its complete path, including its stations.
+ // Those points are now part of the path independently of this route's POIs.
+ const appendedPoints = parsed.points.map(({ lat, lon, elevation, recordedAt }) => ({ lat, lon, elevation, recordedAt }));
+ const allPoints = current.concat(appendedPoints).map((point, sequence) => ({ ...point, sequence }));
const target = resolveStoredFile(existing.gpx_path);
const original = fs.readFileSync(target, 'utf8');
const normalized = parseGpx(original, allPoints);
@@ -290,8 +293,9 @@ function syncRouteWaypoints(db, routeId) {
const row = db.prepare('SELECT gpx_path FROM routes WHERE id = ?').get(routeId);
const filename = resolveStoredFile(row.gpx_path);
const xml = fs.readFileSync(filename, 'utf8');
- fs.writeFileSync(filename, gpxWithWaypoints(xml, listPois(db, routeId)));
- updateBounds(db, routeId, parseGpx(fs.readFileSync(filename, 'utf8'), null, { allowEmpty: true }).metrics);
+ const normalized = parseGpx(gpxWithWaypoints(xml, listPois(db, routeId)), null, { allowEmpty: true });
+ fs.writeFileSync(filename, normalized.normalizedXml);
+ updateBounds(db, routeId, normalized.metrics);
}
function poiCoordinates(fields, existing = {}) {
diff --git a/test/app-route-control-runtime.test.js b/test/app-route-control-runtime.test.js
index 1e8aee8..7b51197 100644
--- a/test/app-route-control-runtime.test.js
+++ b/test/app-route-control-runtime.test.js
@@ -277,6 +277,8 @@ async function loadApp({ language = 'de-DE' } = {}) {
stop() {}
},
LocalRoutes: {
+ async getAppPoi(_id, poiId) { return structuredClone(localRoute?.pois.find(poi => Number(poi.id) === Number(poiId)) || null); },
+ releasePoiMedia() {},
getAppRoute(id) {
if (String(id) !== 'local-1' || !localRoute) throw new Error('lokal nicht gefunden');
return structuredClone(localRoute);
@@ -640,3 +642,43 @@ test('invalid API responses reject initialization and route selection errors res
assert.deepEqual(errors, ['Strecke entfernt']);
assert.equal(button.properties.get('disabled'), false);
});
+
+
+test('local station playback loads only the selected station and ignores media arriving after leaving', async () => {
+ const setup = await loadApp();
+ const { context, harness } = setup;
+ const ns = context.Wegwichtel;
+ await ns.App.initialize({ markStep() {} });
+ const button = new FakeNode('local-select');
+ button.dataValues.set('route-source', 'local');
+ button.dataValues.set('route-id', 'local-1');
+ const requests = [];
+ const released = [];
+ ns.LocalRoutes.getAppPoi = (routeId, poiId) => new Promise(resolve => requests.push({ routeId, poiId, resolve }));
+ ns.LocalRoutes.releasePoiMedia = poi => released.push(poi);
+ const calls = observeSensors(setup);
+ await harness.activate('[data-route-select]', button);
+ assert.equal(requests.length, 0);
+ const poiButton = new FakeNode('station');
+ poiButton.dataValues.set('poi-id', 10);
+ // Use the first actual local station ID rather than assuming server IDs.
+ const local = ns.LocalRoutes.getAppRoute('local-1');
+ poiButton.dataValues.set('poi-id', local.pois[0].id);
+ await harness.activate('[data-poi-select]', poiButton);
+ assert.equal(requests.length, 1);
+ assert.equal(requests[0].routeId, 'local-1');
+ assert.equal(requests[0].poiId, local.pois[0].id);
+ const firstLoaded = { images: [{ url: 'blob:shown' }], audioUrl: 'blob:shown-audio' };
+ requests[0].resolve(firstLoaded);
+ await new Promise(resolve => setImmediate(resolve));
+ assert.equal(calls.audio.at(-1), 'blob:shown-audio');
+ assert.equal(calls.slides.at(-1)[0].url, 'blob:shown');
+ await harness.activate('[data-poi-select]', poiButton);
+ assert.equal(released[0], firstLoaded);
+ await harness.activate('#stop-route', new FakeNode('stop'));
+ const loaded = { images: [{ url: 'blob:late' }], audioUrl: 'blob:late-audio' };
+ requests[1].resolve(loaded);
+ await new Promise(resolve => setImmediate(resolve));
+ assert.equal(released[1], loaded);
+ assert.equal(calls.audio.includes('blob:late-audio'), false);
+});
diff --git a/test/backend-services.test.js b/test/backend-services.test.js
index d7a30a9..47e3be5 100644
--- a/test/backend-services.test.js
+++ b/test/backend-services.test.js
@@ -149,7 +149,13 @@ test('route, POI, media and storage services cover successful and rejected lifec
const indoorStation = routes.createPoi(db, indoor.id, { title: 'Ohne GPS', lat: null, lon: null });
assert.equal(routes.getRoute(db, indoor.id).pois[0].lat, null);
routes.updatePoi(db, indoor.id, indoorStation.id, { lat: 50, lon: 10 });
+ assert.equal(routes.getRoute(db, indoor.id).pointCount, 1);
assert.equal(db.prepare('SELECT min_lat FROM routes WHERE id=?').get(indoor.id).min_lat, 50);
+ routes.updatePoi(db, indoor.id, indoorStation.id, { lat: 51, lon: 11 });
+ const movedStationRoute = routes.getRoute(db, indoor.id);
+ assert.equal(movedStationRoute.pointCount, 1);
+ assert.equal(movedStationRoute.points[0].lat, 51);
+ assert.equal(db.prepare('SELECT min_lat FROM routes WHERE id=?').get(indoor.id).min_lat, 51);
routes.deletePoi(db, indoor.id, indoorStation.id);
assert.equal(routes.getRoute(db, indoor.id).pointCount, 0);
assert.equal(db.prepare('SELECT min_lat FROM routes WHERE id=?').get(indoor.id).min_lat, null);
diff --git a/test/editor-ui.test.js b/test/editor-ui.test.js
index f02d83f..49b2e8a 100644
--- a/test/editor-ui.test.js
+++ b/test/editor-ui.test.js
@@ -302,6 +302,13 @@ function buildEditorContext() {
type: file.type || 'application/octet-stream'
}),
get: id => packages.get(String(id)) || null,
+ getPoiWithMedia: async (id, poiId) => {
+ const value = packages.get(String(id))?.pois.find(poi => String(poi.id) === String(poiId));
+ if (!value) return null;
+ return { ...value, audio: value.audio ? { ...value.audio, url: 'blob:test-audio' } : null,
+ images: (value.images || []).map(image => ({ ...image, url: 'blob:test-image' })) };
+ },
+ releasePoiMedia() {},
getWithMedia: async id => {
const value = packages.get(String(id));
if (!value) return null;
@@ -659,6 +666,7 @@ test('editing an activated server route uses PUT and never writes activation sta
await context.Wegwichtel.EditorUi.open();
const routeList = elements.get('editor-route-list');
+ await tick();
const serverItem = routeList.children.find(child => child.dataset.routeSource === 'server');
const serverActions = serverItem.children[1];
assert.equal(serverActions.children.length, 1);
@@ -737,3 +745,94 @@ test('local media picker keeps File System Access handles for later upload', asy
assert.equal(imageStore.handle, handle);
assert.equal(imageStore.file, imageFile);
});
+
+
+test('local drafts can be opened and saved while the server request is still pending', async () => {
+ const { context, elements, packages } = buildEditorContext();
+ context.fetch = () => new Promise(() => {});
+ await loadI18n(context);
+ vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/editor-ui.js'), 'utf8'), context);
+ await context.Wegwichtel.EditorUi.open();
+ const list = elements.get('editor-route-list');
+ const item = list.children.find(child => child.dataset.routeId === 'local-existing');
+ assert.ok(item);
+ list.dispatchEvent(pointerEvent(item.children[1].children[0]));
+ await tick();
+ assert.equal(elements.get('editor-route-id').value, 'local-existing');
+ elements.get('editor-route-name').value = 'Offline geändert';
+ elements.get('editor-route-form').dispatchEvent(submitEvent());
+ await tick(); await tick();
+ assert.equal(packages.get('local-existing').route.name, 'Offline geändert');
+ assert.equal(elements.get('editor-status').textContent, 'Gespeichert.');
+});
+
+test('a failed public route request preserves local drafts and late replies use the latest local data', async () => {
+ const { context, elements, packages } = buildEditorContext();
+ const requests = [];
+ context.fetch = () => new Promise((resolve, reject) => requests.push({ resolve, reject }));
+ await loadI18n(context);
+ vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/editor-ui.js'), 'utf8'), context);
+ await context.Wegwichtel.EditorUi.open();
+ requests[0].reject(new Error('offline')); await tick();
+ assert.match(elements.get('editor-status').textContent, /nicht erreichbar/);
+ assert.ok(elements.get('editor-route-list').children.some(child => child.dataset.routeId === 'local-existing'));
+ await context.Wegwichtel.EditorUi.refresh();
+ packages.delete('local-existing');
+ requests[1].resolve(jsonResponse({ routes: [{ id: 7, name: 'Veröffentlicht' }] }));
+ await tick(); await tick();
+ assert.equal(elements.get('editor-route-list').children.some(child => child.dataset.routeId === 'local-existing'), false);
+ assert.ok(elements.get('editor-route-list').children.some(child => child.dataset.routeSource === 'server'));
+});
+
+
+test('opening and saving a draft does not load files; editing a station loads only that station', async () => {
+ const { context, elements, packages } = buildEditorContext();
+ const local = packages.get('local-existing');
+ local.pois = [0, 1].map(id => ({ id, title: `Station ${id}`, sequence: id, images: [{ id: `image-${id}`, mediaKey: `key-${id}` }] }));
+ const loads = [];
+ context.Wegwichtel.LocalRoutes.getWithMedia = () => { throw new Error('Full route hydration must not run'); };
+ const original = context.Wegwichtel.LocalRoutes.getPoiWithMedia;
+ context.Wegwichtel.LocalRoutes.getPoiWithMedia = (...args) => { loads.push(args); return original(...args); };
+ await loadI18n(context);
+ vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/editor-ui.js'), 'utf8'), context);
+ await context.Wegwichtel.EditorUi.open();
+ const list = elements.get('editor-route-list');
+ const item = list.children.find(child => child.dataset.routeId === 'local-existing');
+ list.dispatchEvent(pointerEvent(item.children[1].children[0])); await tick();
+ assert.equal(loads.length, 0);
+ elements.get('editor-route-name').value = 'Nur Name geändert';
+ elements.get('editor-route-form').dispatchEvent(submitEvent()); await tick(); await tick();
+ assert.equal(loads.length, 0);
+ const poiList = elements.get('editor-poi-list');
+ const poiItem = poiList.children.find(child => child.dataset.poiId === '1');
+ poiList.dispatchEvent(pointerEvent(poiItem.children[1].children[2])); await tick();
+ assert.equal(loads.length, 1);
+ assert.equal(loads[0][1], 1);
+ assert.equal(elements.get('editor-poi-media-preview').children.length, 1);
+ elements.get('editor-poi-title').value = 'Titel geändert';
+ elements.get('editor-poi-form').dispatchEvent(submitEvent()); await tick(); await tick();
+ assert.equal(loads.length, 1);
+ assert.equal(packages.get('local-existing').pois[1].title, 'Titel geändert');
+});
+
+test('late station previews are released after the editor closes without replacing unsaved input', async () => {
+ const { context, elements, packages } = buildEditorContext();
+ packages.get('local-existing').pois = [{ id: 0, title: 'Station', images: [] }];
+ let finish;
+ const released = [];
+ context.Wegwichtel.LocalRoutes.getPoiWithMedia = () => new Promise(resolve => { finish = resolve; });
+ context.Wegwichtel.LocalRoutes.releasePoiMedia = poi => { if(poi) released.push(poi); };
+ await loadI18n(context);
+ vm.runInNewContext(await fs.readFile(path.join(root, 'public/js/editor-ui.js'), 'utf8'), context);
+ await context.Wegwichtel.EditorUi.open();
+ const list = elements.get('editor-route-list');
+ const item = list.children.find(child => child.dataset.routeId === 'local-existing');
+ list.dispatchEvent(pointerEvent(item.children[1].children[0])); await tick();
+ const poiList = elements.get('editor-poi-list');
+ poiList.dispatchEvent(pointerEvent(poiList.children[0].children[1].children[2])); await tick();
+ elements.get('editor-poi-title').value = 'Noch nicht gespeichert';
+ context.Wegwichtel.EditorUi.close();
+ finish({ id: 0, title: 'Alter Titel', images: [{ url: 'blob:late' }] }); await tick();
+ assert.equal(released.length, 1);
+ assert.equal(elements.get('editor-poi-title').value, 'Noch nicht gespeichert');
+});
diff --git a/test/gpx.test.js b/test/gpx.test.js
index dc6df72..b601045 100644
--- a/test/gpx.test.js
+++ b/test/gpx.test.js
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { parseGpx } from '../src/services/gpx.js';
+import { parseGpx, gpxWithWaypoints } from '../src/services/gpx.js';
import { SAMPLE_GPX } from '../test-support/gpx-fixtures.js';
test('parseGpx reads synthetic track data without repository example files', () => {
@@ -11,3 +11,73 @@ test('parseGpx reads synthetic track data without repository example files', ()
assert.equal(parsed.points[0].lat, 52.5200);
assert.equal(parsed.points[2].lon, 13.4090);
});
+
+const point = (tag, lat, lon, content = '') => `<${tag} lat="${lat}" lon="${lon}">${content}${tag}>`;
+const gpx = (waypoints, path = '', extra = '') => `
${waypoints}${path}${extra}`;
+const track = points => `
${points}`;
+const locations = points => points.map(({ lat, lon }) => [lat, lon]);
+
+test('waypoints alone become route points in station order and contribute to distance and bounds', () => {
+ const parsed = parseGpx(gpx(point('wpt', 50, 10) + point('wpt', 50.01, 10.02)));
+ assert.deepEqual(locations(parsed.points), [[50, 10], [50.01, 10.02]]);
+ assert.equal(parsed.metrics.pointCount, 2);
+ assert.ok(parsed.metrics.distanceM > 0);
+ assert.equal(parsed.metrics.maxLon, 10.02);
+ assert.match(parsed.normalizedXml, /
{
+ const wp = point('wpt', 50, 10.00001) + point('wpt', 50.005, 10.015);
+ const recorded = point('trkpt', 50, 10) + point('trkpt', 50, 10.00002)
+ + point('trkpt', 50, 10.01) + point('trkpt', 50, 10.02);
+ const parsed = parseGpx(gpx(wp, track(recorded)));
+ assert.deepEqual(locations(parsed.points), [[50, 10], [50, 10.00001], [50, 10.01], [50.005, 10.015], [50, 10.02]]);
+ assert.equal(parsed.metrics.maxLat, 50.005);
+ assert.equal(parsed.points.length, 5);
+ assert.deepEqual(parsed.points.map(p => p.sequence), [0, 1, 2, 3, 4]);
+ assert.deepEqual(locations(parseGpx(parsed.normalizedXml).points), locations(parsed.points));
+});
+
+test('duplicate station locations do not multiply route points and station order is retained on loops', () => {
+ const stations = point('wpt', 50, 10.02) + point('wpt', 50, 10.02) + point('wpt', 50, 10.01);
+ const recorded = [10, 10.01, 10.02, 10.01, 10].map(lon => point('trkpt', 50, lon)).join('');
+ const parsed = parseGpx(gpx(stations, track(recorded)));
+ assert.deepEqual(locations(parsed.points), [[50, 10], [50, 10.01], [50, 10.02], [50, 10.01], [50, 10]]);
+ assert.deepEqual(locations(parseGpx(parsed.normalizedXml).points), locations(parsed.points));
+});
+
+test('existing routes and replacement points incorporate stations, including stations before and after the path', () => {
+ const stations = [9.99, 10.005, 10.02].map(lon => point('wpt', 50, lon)).join('');
+ const route = `${point('rtept', 50, 10)}${point('rtept', 50, 10.01)}`;
+ const xml = gpx(stations, route);
+ const parsed = parseGpx(xml);
+ assert.deepEqual(locations(parsed.points), [[50, 9.99], [50, 10], [50, 10.005], [50, 10.01], [50, 10.02]]);
+ assert.deepEqual(locations(parseGpx(xml, [{ lat: 50, lon: 10 }, { lat: 50, lon: 10.01 }]).points), locations(parsed.points));
+});
+
+test('missing and invalid station coordinates are excluded while GPS gaps remain available for editing', () => {
+ const stations = 'Ohne Ort' + point('wpt', 91, 10) + point('wpt', 50, 181) + point('wpt', 50, 10);
+ const extra = 'Später';
+ const parsed = parseGpx(gpx(stations, '', extra));
+ assert.deepEqual(locations(parsed.points), [[50, 10]]);
+ assert.match(parsed.normalizedXml, /wegwichtel:gpsGap/);
+ assert.match(parsed.normalizedXml, /wegwichtel:pendingWaypoint/);
+ const empty = parseGpx(gpx('', '', extra));
+ assert.equal(empty.metrics.pointCount, 0);
+ assert.equal(empty.metrics.minLat, null);
+});
+
+
+test('moving or deleting a station replaces its generated route anchor without removing recorded points', () => {
+ const original = parseGpx(gpx(point('wpt', 50.01, 10.005), track(point('trkpt', 50, 10) + point('trkpt', 50, 10.01))));
+ assert.match(original.normalizedXml, /wegwichtel:waypointRoutePoint/);
+ const updated = parseGpx(gpxWithWaypoints(original.normalizedXml, [{ id: 1, title: 'Neu', lat: 50.02, lon: 10.006 }]));
+ assert.equal(updated.points.length, 3);
+ assert.equal(updated.metrics.maxLat, 50.02);
+ assert.equal(updated.points.some(p => p.lat === 50.01), false);
+ const removed = parseGpx(gpxWithWaypoints(updated.normalizedXml, []));
+ assert.deepEqual(locations(removed.points), [[50, 10], [50, 10.01]]);
+ assert.deepEqual(locations(parseGpx(updated.normalizedXml).points), locations(updated.points));
+});
diff --git a/test/legal-ui.test.js b/test/legal-ui.test.js
index ba46023..0c5bca1 100644
--- a/test/legal-ui.test.js
+++ b/test/legal-ui.test.js
@@ -45,7 +45,8 @@ test('information button opens scrollable overlay pages for privacy, imprint, te
assert.match(html, /data-info-target="technology"/);
assert.match(html, /Technisch verantwortlich<\/h3>\s*
Florian Zumpe
/);
assert.match(html, /\[Anschrift von Florian Zumpe ergänzen\]/);
- assert.match(html, /\[E-Mail-Adresse von Florian Zumpe ergänzen\]/);
+ assert.match(html, /E-Mail: info@wegwichtel\.de<\/a>/);
+ assert.doesNotMatch(html, /\[E-Mail-Adresse von Florian Zumpe ergänzen\]|\[Kontakt für Rechtefragen ergänzen\]/);
const storage = await read('public/js/privacy-storage.js');
assert.match(storage, /wegwichtel\.privacy\.v1/);
assert.match(storage, /wegwichtel\.route-progress\.v1/);
diff --git a/test/local-routes.test.js b/test/local-routes.test.js
index b85b0a5..edf9aa1 100644
--- a/test/local-routes.test.js
+++ b/test/local-routes.test.js
@@ -730,3 +730,35 @@ test('creator identity never falls back to insecure randomness or replaces corru
assert.throws(() => api.creatorId(), /Gerätekennung/);
assert.equal(localStorage.getItem('wegwichtel.creator-id.v1'), 'broken');
});
+
+
+test('route selection reads metadata only and station media is loaded and released separately', async () => {
+ const { api, context } = await loadLocalRoutes();
+ const reads = [0, 0];
+ const revoked = [];
+ let urlNumber = 0;
+ context.URL.createObjectURL = () => `blob:lazy-${++urlNumber}`;
+ context.URL.revokeObjectURL = url => revoked.push(url);
+ const files = [];
+ for (let id = 0; id < 2; id++) {
+ const file = Object.assign(new Blob(['image']), { name: `${id}.jpg` });
+ const handle = { async getFile() { reads[id]++; return file; } };
+ files.push(await api.storeMediaFile('lazy', id, 'image', id, file, handle));
+ }
+ api.save({ id: 'lazy', route: { name: 'Lazy' }, pois: files.map((image, id) => ({ id, images: [image] })) });
+ await api.getAppRoute('lazy');
+ api.get('lazy');
+ assert.deepEqual(reads, [0, 0]);
+ const first = await api.getPoiWithMedia('lazy', 0, { requestPermission: true });
+ assert.deepEqual(reads, [1, 0]);
+ assert.equal(first.images[0].url, 'blob:lazy-1');
+ const second = await api.getAppPoi('lazy', 1);
+ assert.deepEqual(reads, [1, 1]);
+ assert.equal(second.images[0].url, 'blob:lazy-2');
+ api.releasePoiMedia(first); api.releasePoiMedia(first); api.releasePoiMedia(second);
+ api.releasePoiMedia({ images: [{ url: 'https://example.org/image.jpg' }] });
+ assert.deepEqual(revoked, ['blob:lazy-1', 'blob:lazy-2']);
+ assert.equal(await api.getPoiWithMedia('lazy', 9), null);
+ assert.equal(await api.getAppPoi('missing', 0), null);
+ assert.equal(api.get('lazy').pois[0].images[0].url, undefined);
+});
diff --git a/test/route-recorder.test.js b/test/route-recorder.test.js
index 332212d..45968ad 100644
--- a/test/route-recorder.test.js
+++ b/test/route-recorder.test.js
@@ -14,7 +14,7 @@ async function setup() {
appendChild(child) { this.children.push(child); }
replaceChildren() { this.children = []; }
}
- const ids = ['route-recording-control', 'route-recording-status', 'route-recording-pause', 'route-recording-stop', 'editor-recorded-positions', 'editor-add-position'];
+ const ids = ['route-recording-control', 'route-recording-status', 'route-recording-pause', 'route-recording-stop', 'editor-recorded-positions', 'editor-add-position', 'route-recording-toggle'];
const nodes = new Map(ids.map(id => [id, new Element()]));
const document = new EventTarget();
document.readyState = 'complete';
@@ -23,7 +23,7 @@ async function setup() {
const intervals = new Map();
const packages = new Map([['draft', { id: 'draft', route: { name: 'Entwurf' }, gpx: '[]', pois: [] }]]);
const context = { document, Date, console,
- setInterval(callback, delay) { assert.equal(delay, 30000); intervals.set(1, callback); return 1; },
+ setInterval(callback, delay) { assert.equal(delay, 120000); intervals.set(1, callback); return 1; },
clearInterval(id) { intervals.delete(id); },
Wegwichtel: { Geo: { current: async () => ({ lat: 50, lon: 10, timestamp: Date.now() }) },
LocalRoutes: {
@@ -47,7 +47,7 @@ function activate(node, type = 'pointerup', key) {
node.dispatchEvent(event);
}
-test('recording persists every 30 seconds during an editing session, pauses and finishes', async () => {
+test('recording persists every two minutes during an editing session, pauses and finishes', async () => {
const { recorder, packages, intervals, nodes } = await setup();
recorder.start('draft'); await tick();
assert.equal(recorder.status, 'recording');
@@ -106,10 +106,14 @@ test('missing recorded locations can be filled manually while newer samples rema
test('ending editing stops sampling, reopening permits recording and a fresh app remains inactive', async () => {
- const { recorder, intervals, packages } = await setup();
+ const { recorder, intervals, packages, nodes } = await setup();
assert.equal(recorder.status, 'idle');
recorder.beginEditing('draft');
await tick();
+ assert.equal(intervals.size, 0);
+ nodes.get('route-recording-toggle').checked = true;
+ nodes.get('route-recording-toggle').dispatchEvent(new Event('change'));
+ await tick();
assert.equal(intervals.size, 1);
recorder.pause();
recorder.beginEditing('draft');
@@ -121,8 +125,68 @@ test('ending editing stops sampling, reopening permits recording and a fresh app
assert.equal(JSON.parse(packages.get('draft').gpx).length, 1);
recorder.beginEditing('draft');
await tick();
- assert.equal(recorder.status, 'recording');
+ assert.equal(recorder.status, 'idle');
+ assert.equal(nodes.get('route-recording-toggle').checked, false);
const fresh = await setup();
assert.equal(fresh.recorder.status, 'idle');
assert.equal(fresh.intervals.size, 0);
});
+
+
+test('the switch can prepare a new draft and late GPS from an older session is ignored', async () => {
+ const { context, recorder, nodes, packages, intervals } = await setup();
+ const answers = [];
+ context.Wegwichtel.Geo.current = () => new Promise(resolve => answers.push(resolve));
+ const toggle = nodes.get('route-recording-toggle');
+ toggle.checked = true; toggle.dispatchEvent(new Event('change'));
+ assert.equal(answers.length, 0);
+ recorder.beginEditing('draft');
+ assert.equal(answers.length, 1);
+ toggle.checked = false; toggle.dispatchEvent(new Event('change'));
+ toggle.checked = true; toggle.dispatchEvent(new Event('change'));
+ assert.equal(answers.length, 2);
+ answers[1]({ lat: 52, lon: 12 }); await tick();
+ answers[0]({ lat: 50, lon: 10 }); await tick();
+ assert.equal(JSON.parse(packages.get('draft').gpx).length, 1);
+ assert.equal(JSON.parse(packages.get('draft').gpx)[0].lat, 52);
+ context.Wegwichtel.Geo.current = async () => ({ lat: 53, lon: 13 });
+ context.document.visibilityState = 'visible';
+ context.document.dispatchEvent(new Event('visibilitychange')); await tick();
+ assert.equal(JSON.parse(packages.get('draft').gpx).length, 2);
+ recorder.endEditing();
+ assert.equal(intervals.size, 0);
+ assert.equal(toggle.checked, false);
+});
+
+test('each crosshair fills only its own point without starting recording or saving', async () => {
+ const { recorder, nodes, packages } = await setup();
+ recorder.renderPoints('[{"lat":1,"lon":2},{"lat":3,"lon":4}]');
+ const [first, second] = nodes.get('editor-recorded-positions').children;
+ second.children[5].dispatchEvent(new Event('click')); await tick();
+ assert.equal(first.children[2].value, '1');
+ assert.equal(second.children[2].value, '50');
+ assert.equal(second.children[4].value, '10');
+ assert.equal(recorder.status, 'idle');
+ assert.equal(packages.get('draft').gpx, '[]');
+ assert.match(second.children[6].textContent, /Speichere/);
+});
+
+test('manual location errors preserve coordinates and stale or duplicate requests cannot overwrite points', async () => {
+ const { context, recorder, nodes } = await setup();
+ recorder.renderPoints('[{"lat":1,"lon":2}]');
+ const row = nodes.get('editor-recorded-positions').children[0];
+ context.Wegwichtel.Geo.current = async () => { throw new Error('denied'); };
+ row.children[5].dispatchEvent(new Event('click')); await tick();
+ assert.equal(row.children[2].value, '1');
+ assert.match(row.children[6].textContent, /nicht gefunden/);
+ let finish;
+ let requests = 0;
+ context.Wegwichtel.Geo.current = () => { requests++; return new Promise(resolve => { finish = resolve; }); };
+ row.children[5].dispatchEvent(new Event('click'));
+ row.children[5].dispatchEvent(new Event('click'));
+ assert.equal(requests, 1);
+ recorder.renderPoints('[]');
+ finish({ lat: 51, lon: 11 }); await tick();
+ assert.equal(row.children[2].value, '1');
+ assert.equal(row.children[5].disabled, false);
+});