Streckenfortschritt lokal speichern
@@ -365,9 +365,12 @@
GPX-Datei importieren oder ersetzen
- Neue Strecken werden als lokaler GPX-/JSON-Entwurf gespeichert und nicht an den Server übertragen.
+ Neue Strecken werden zunächst lokal gespeichert. Der Upload-Button überträgt den Entwurf als unveröffentlichte Strecke zur Prüfung an den Server.
- Strecke speichern
+
+ Strecke speichern
+ An Server übertragen
+
Stationen
diff --git a/public/js/editor-ui.js b/public/js/editor-ui.js
index c6d4cfd..731bd88 100644
--- a/public/js/editor-ui.js
+++ b/public/js/editor-ui.js
@@ -42,6 +42,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
routeListView: 'editor-route-list-view',
routeName: 'editor-route-name',
routeSave: 'editor-route-save',
+ routeUpload: 'editor-route-upload',
status: 'editor-status'
});
@@ -240,7 +241,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
async function handleResponse(response) {
if (response.ok) return parseJson(response);
const message = await response.text();
- throw new Error(message || `HTTP ${response.status}`);
+ const error = new Error(message || `HTTP ${response.status}`);
+ error.status = response.status;
+ throw error;
}
async function readJson(path) {
@@ -468,9 +471,19 @@ 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 = '';
+
+ const uploadButton = byId(ids.routeUpload);
if (state.currentSource === 'local') {
- byId(ids.routeActivated).textContent = 'Lokaler Entwurf: GPX, Texte und Medien bleiben ausschließlich im Browser. Eine Server-Freigabe ist noch nicht implementiert.';
+ const uploaded = Boolean(route?.serverRouteId);
+ setHidden(uploadButton, false);
+ setDisabled(uploadButton, uploaded);
+ if (uploadButton) uploadButton.textContent = uploaded ? 'Bereits übertragen' : 'An Server übertragen';
+ byId(ids.routeActivated).textContent = uploaded
+ ? `Lokaler Entwurf. Bereits als unveröffentlichte Server-Strecke ${route.serverRouteId} zur Prüfung übertragen.`
+ : 'Lokaler Entwurf: GPX, Texte und Medien bleiben im Browser, bis „An Server übertragen“ gewählt wird.';
} else {
+ setHidden(uploadButton, true);
+ setDisabled(uploadButton, true);
byId(ids.routeActivated).textContent = route?.activatedAt || route?.activated_at
? `Veröffentlicht am ${route.activatedAt || route.activated_at}. Der Editor verändert die Freigabe nicht.`
: 'Server-Strecke ohne Freigabedatum. Der Editor verändert activated_at nicht.';
@@ -697,7 +710,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
const file = byId(ids.routeGpx)?.files?.[0];
data.append('name', values.name);
data.append('description', values.description);
- if (file) data.append('file', file);
+ if (file) data.append('gpx', file);
return data;
}
@@ -727,7 +740,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
state.pois = saved.pois;
await loadRoutes();
await loadRoute({ id: saved.id, _source: 'local' });
- setStatus('Strecke erfolgreich lokal gespeichert. Es wurden keine Daten an den Server übertragen.');
+ setStatus('Speichern erfolgreich.');
}
async function saveServerRoute(values) {
@@ -742,7 +755,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
state.currentRoute = { ...saved, _source: 'server' };
await loadRoutes();
await loadRoute({ id, _source: 'server' });
- setStatus('Strecke erfolgreich gespeichert. activated_at wurde nicht verändert.');
+ setStatus('Speichern erfolgreich.');
}
async function saveRoute() {
@@ -752,6 +765,127 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
else await saveLocalRoute(values);
}
+ function safeGpxFilename(name) {
+ const base = text(name || 'wegwichtel-route')
+ .normalize('NFKD')
+ .replaceAll(/[^a-zA-Z0-9_-]+/g, '-')
+ .replaceAll(/^-+|-+$/g, '')
+ .slice(0, 80);
+ return `${base || 'wegwichtel-route'}.gpx`;
+ }
+
+ function localRouteUploadForm(routePackage) {
+ const data = new FormData();
+ data.append('name', routePackage.route?.name || 'Unbenannte Strecke');
+ data.append('description', routePackage.route?.description || '');
+ const gpx = new Blob([routePackage.gpx], { type: 'application/gpx+xml' });
+ data.append('gpx', gpx, safeGpxFilename(routePackage.route?.name));
+ return data;
+ }
+
+ 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());
+ }
+ return response.blob();
+ }
+
+ async function uploadStoredMedia(path, fieldName, media, fallbackName) {
+ const blob = await storedMediaBlob(media);
+ if (!blob) return;
+ const data = new FormData();
+ data.append(fieldName, blob, media.name || fallbackName);
+ await sendForm(path, 'POST', data);
+ }
+
+ async function uploadLocalPoiMedia(serverRouteId, serverPoiId, poi) {
+ const basePath = `/routes/${encodeURIComponent(serverRouteId)}/pois/${encodeURIComponent(serverPoiId)}`;
+ if (poi.audio?.dataUrl) {
+ await uploadStoredMedia(`${basePath}/audio`, 'audio', poi.audio, 'audio.bin');
+ }
+ for (const [index, image] of (poi.images || []).entries()) {
+ if (!image?.dataUrl) continue;
+ await uploadStoredMedia(`${basePath}/pictures`, 'picture', image, `bild-${index + 1}.bin`);
+ }
+ }
+
+ async function createServerRouteFromLocal(routePackage) {
+ let createdRouteId = null;
+ try {
+ const payload = await sendForm('/routes', 'POST', localRouteUploadForm(routePackage));
+ const serverRoute = payload.route || payload;
+ createdRouteId = routeId(serverRoute);
+ if (!createdRouteId) throw new Error('Der Server hat keine Strecken-ID für den Upload zurückgegeben.');
+
+ const orderedPois = [...(routePackage.pois || [])]
+ .sort((left, right) => Number(left.sequence) - Number(right.sequence));
+ for (const [index, poi] of orderedPois.entries()) {
+ const poiPayload = await sendJson(`/routes/${encodeURIComponent(createdRouteId)}/pois`, 'POST', {
+ description: poi.description || '',
+ lat: poi.lat,
+ lon: poi.lon,
+ sequence: index,
+ title: poi.title || `Station ${index + 1}`,
+ triggerRadiusM: poi.triggerRadiusM || 25
+ });
+ const serverPoi = poiPayload.poi || poiPayload;
+ const serverPoiId = poiId(serverPoi);
+ if (!serverPoiId) throw new Error(`Für ${poi.title || `Station ${index + 1}`} wurde keine Server-POI-ID zurückgegeben.`);
+ await uploadLocalPoiMedia(createdRouteId, serverPoiId, poi);
+ }
+ return createdRouteId;
+ } catch (error) {
+ if (createdRouteId) {
+ try {
+ await sendDelete(`/routes/${encodeURIComponent(createdRouteId)}`);
+ } catch {
+ // Der ursprüngliche Uploadfehler ist wichtiger als ein fehlgeschlagener Rollback.
+ }
+ }
+ throw error;
+ }
+ }
+
+ async function uploadLocalRoute() {
+ if (state.currentSource !== 'local') throw new Error('Nur lokale Strecken können hochgeladen werden.');
+ const values = routeFromForm();
+ if (!values.name) throw new Error('Der Name der Strecke ist erforderlich.');
+ if (state.currentRoute?.serverRouteId) {
+ setStatus('Diese Strecke wurde bereits an den Server übertragen.');
+ return;
+ }
+
+ const uploadButton = byId(ids.routeUpload);
+ setDisabled(uploadButton, true);
+ try {
+ await saveLocalRoute(values);
+ setStatus('Übertragung läuft …');
+ const localId = routeId(state.currentRoute);
+ const routePackage = ns.LocalRoutes.get(localId);
+ if (!routePackage) throw new Error('Der lokale Streckenentwurf wurde nicht gefunden.');
+
+ const serverRouteId = await createServerRouteFromLocal(routePackage);
+ const uploadedAt = new Date().toISOString();
+ const saved = ns.LocalRoutes.save({
+ ...routePackage,
+ route: {
+ ...routePackage.route,
+ serverRouteId,
+ uploadedAt
+ }
+ });
+ state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
+ state.pois = saved.pois;
+ fillRouteForm(state.currentRoute);
+ await loadRoutes();
+ setStatus('Upload erfolgreich.');
+ } finally {
+ if (!state.currentRoute?.serverRouteId) setDisabled(uploadButton, false);
+ }
+ }
+
function currentPosition() {
return typeof ns.App?.currentPosition === 'function' ? ns.App.currentPosition() : null;
}
@@ -1072,6 +1206,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
activate(byId(ids.close), closeEditor);
activate(byId(ids.newRoute), newRoute);
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));
diff --git a/public/js/local-routes.js b/public/js/local-routes.js
index 03fe719..178724c 100644
--- a/public/js/local-routes.js
+++ b/public/js/local-routes.js
@@ -231,7 +231,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
name: String(inputRoute.name || ''),
description: String(inputRoute.description || ''),
createdAt: inputRoute.createdAt || now,
- updatedAt: now
+ updatedAt: now,
+ serverRouteId: inputRoute.serverRouteId ?? null,
+ uploadedAt: inputRoute.uploadedAt || null
};
const pois = (routePackage.pois || []).map((poi, index) => ({
...poi,
diff --git a/test/editor-ui.test.js b/test/editor-ui.test.js
index cbdfdc9..6aacab4 100644
--- a/test/editor-ui.test.js
+++ b/test/editor-ui.test.js
@@ -34,6 +34,7 @@ test('editor markup and source describe local draft creation instead of immediat
assert.doesNotMatch(html, /id="editor-poi-(?:lat|lon)"[^>]+step=/);
assert.match(html, /id="editor-poi-media-preview"/);
assert.match(html, /id="editor-poi-delete"[^>]+editor-danger-button/);
+ assert.match(html, /id="editor-route-upload"[^>]+type="button"[^>]+hidden/);
assert.doesNotMatch(html, /accept="video\//);
assert.match(html, /ausschließlich in diesem Browser/i);
@@ -61,6 +62,8 @@ test('editor markup and source describe local draft creation instead of immediat
assert.match(ui, /'picture'/);
assert.match(ui, /'audio'/);
assert.match(ui, /method:\s*'DELETE'/);
+ assert.match(ui, /sendForm\('\/routes',\s*'POST'/);
+ assert.match(ui, /serverRouteId/);
assert.match(ui, /position\?\.accuracy/);
});
@@ -140,8 +143,8 @@ class FakeFormData {
this.entries = [];
}
- append(name, value) {
- this.entries.push([name, value]);
+ append(name, value, filename) {
+ this.entries.push([name, value, filename]);
}
}
@@ -203,6 +206,7 @@ function buildEditorContext() {
'editor-route-list',
'editor-route-list-view',
'editor-route-name',
+ 'editor-route-upload',
'editor-status'
];
const elements = new Map(ids.map(id => [id, new FakeElement(id)]));
@@ -233,7 +237,14 @@ function buildEditorContext() {
async function fetch(url, options = {}) {
const method = options.method || 'GET';
calls.push({ body: options.body, method, url });
+ if (String(url).startsWith('data:')) {
+ return { ok: true, status: 200, blob: async () => new Blob(['media'], { type: 'application/octet-stream' }) };
+ }
if (url === '/api/routes?activatedOnly=true' && method === 'GET') return jsonResponse({ routes: [serverRoute] });
+ if (url === '/api/routes' && method === 'POST') return jsonResponse({ id: 22, name: 'Neue lokale Strecke', pois: [] }, true, 201);
+ if (url === '/api/routes/22/pois' && method === 'POST') return jsonResponse({ id: 23, title: 'Aussicht' }, 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/1' && method === 'GET') return jsonResponse({ route: serverRoute });
if (url === '/api/routes/1' && method === 'PUT') {
const body = JSON.parse(options.body);
@@ -278,6 +289,7 @@ function buildEditorContext() {
};
const context = {
+ Blob,
document,
Event,
EventTarget,
@@ -318,7 +330,9 @@ test('public editor workflow saves new route and GPX POI locally without POSTing
assert.equal(saves.at(-1).route.name, 'Neue lokale Strecke');
assert.equal(calls.some(call => call.method === 'POST' && call.url === '/api/routes'), false);
assert.match(elements.get('editor-route-activated').textContent, /Lokaler Entwurf/);
- assert.match(elements.get('editor-status').textContent, /erfolgreich lokal gespeichert/i);
+ assert.equal(elements.get('editor-status').textContent, 'Speichern erfolgreich.');
+ assert.equal(elements.get('editor-route-upload').hidden, false);
+ assert.equal(elements.get('editor-route-upload').disabled, false);
elements.get('editor-add-poi').dispatchEvent(pointerEvent());
assert.equal(elements.get('editor-poi-audio').clickCount, 0);
@@ -351,6 +365,25 @@ test('public editor workflow saves new route and GPX POI locally without POSTing
assert.equal(local.pois[0].images.length, 1);
assert.match(local.pois[0].images[0].dataUrl, /^data:image\/jpeg/);
+ elements.get('editor-route-upload').dispatchEvent(pointerEvent());
+ await tick();
+ await tick();
+ await tick();
+ await tick();
+
+ const routeUpload = calls.find(call => call.method === 'POST' && call.url === '/api/routes');
+ assert.ok(routeUpload);
+ assert.ok(routeUpload.body.entries.some(([name]) => name === 'gpx'));
+ 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/pictures'));
+ local = packages.get('local-new');
+ assert.equal(local.route.serverRouteId, '22');
+ assert.ok(local.route.uploadedAt);
+ assert.equal(elements.get('editor-route-upload').disabled, true);
+ assert.equal(elements.get('editor-route-upload').textContent, 'Bereits übertragen');
+ assert.equal(elements.get('editor-status').textContent, 'Upload erfolgreich.');
+
const poiList = elements.get('editor-poi-list');
const poiItem = poiList.children.find(child => child.dataset.poiId);
const poiEditButton = poiItem.children[1].children[2];
@@ -406,6 +439,7 @@ test('editing an activated server route uses PUT and never writes activation sta
assert.equal(elements.get('editor-route-id').value, '1');
assert.match(elements.get('editor-route-activated').textContent, /Veröffentlicht am/);
+ assert.equal(elements.get('editor-route-upload').hidden, true);
elements.get('editor-route-name').value = 'Freigegebener Waldweg geändert';
elements.get('editor-route-description').value = 'Bearbeitet';
@@ -419,4 +453,5 @@ test('editing an activated server route uses PUT and never writes activation sta
assert.deepEqual(Object.keys(body).sort(), ['description', 'name']);
assert.equal('activated_at' in body, false);
assert.equal('activatedAt' in body, false);
+ assert.equal(elements.get('editor-status').textContent, 'Speichern erfolgreich.');
});
diff --git a/test/local-routes.test.js b/test/local-routes.test.js
index 4f23c10..4f673b0 100644
--- a/test/local-routes.test.js
+++ b/test/local-routes.test.js
@@ -325,7 +325,13 @@ test('local route package stores GPX and JSON separately and reconstructs an app
const saved = api.save({
id: routeId,
gpx: sampleGpx,
- route: { id: routeId, name: 'Neue Route', description: 'Lokal gespeichert' },
+ route: {
+ id: routeId,
+ name: 'Neue Route',
+ description: 'Lokal gespeichert',
+ serverRouteId: 42,
+ uploadedAt: '2026-09-27T18:00:00.000Z'
+ },
pois: [
{
id: 101,
@@ -363,6 +369,8 @@ test('local route package stores GPX and JSON separately and reconstructs an app
assert.match(gpx, /101<\/wegwichtel:poiId>/);
assert.ok(gpx.indexOf(''), 'GPX waypoints must be serialized before the track');
assert.equal(metadata.route.name, 'Neue Route');
+ assert.equal(metadata.route.serverRouteId, 42);
+ assert.equal(metadata.route.uploadedAt, '2026-09-27T18:00:00.000Z');
assert.equal(metadata.pois[0].audio.name, 'start.mp3');
assert.equal('gpx' in metadata, false);
assert.equal('_source' in metadata.route, false);