allow update for uploaded routes and implement creator id for routes
This commit is contained in:
@@ -799,6 +799,7 @@
|
||||
},
|
||||
|
||||
async initialize(context) {
|
||||
ns.LocalRoutes?.creatorId?.();
|
||||
bindEvents();
|
||||
await ns.Api.health();
|
||||
context.markStep('server', 'done');
|
||||
|
||||
+85
-50
@@ -202,6 +202,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function showListView() {
|
||||
ns.RouteRecorder?.endEditing();
|
||||
setDialogTitle('Streckenverwaltung');
|
||||
setHidden(byId(ids.routeListView), false);
|
||||
setHidden(byId(ids.routeDetailView), true);
|
||||
@@ -469,6 +470,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
ns.RouteRecorder?.endEditing();
|
||||
const dialog = byId(ids.dialog);
|
||||
if (dialog?.open && typeof dialog.close === 'function') dialog.close();
|
||||
else if (dialog?.open) dialog.removeAttribute('open');
|
||||
@@ -507,7 +509,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
if (state.currentSource === 'local') {
|
||||
const uploaded = Boolean(route?.serverRouteId);
|
||||
setHidden(uploadButton, false);
|
||||
setDisabled(uploadButton, uploaded);
|
||||
setDisabled(uploadButton, false);
|
||||
if (uploadButton) uploadButton.textContent = uploaded ? ns.I18n.t("editor.submitted") : ns.I18n.t("editor.submit");
|
||||
byId(ids.routeActivated).textContent = uploaded
|
||||
? ns.I18n.t("editor.submitted-hint")
|
||||
@@ -761,6 +763,9 @@ 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();
|
||||
}
|
||||
setStatus(ns.I18n.t("editor.route.loading"));
|
||||
state.currentSource = routeSource(route);
|
||||
|
||||
@@ -773,6 +778,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
gpx: routePackage.gpx,
|
||||
_source: 'local'
|
||||
};
|
||||
if (byId(ids.dialog)?.open) ns.RouteRecorder?.beginEditing(id);
|
||||
state.pois = [...routePackage.pois].sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
||||
} else {
|
||||
const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
|
||||
@@ -789,6 +795,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function newRoute() {
|
||||
ns.RouteRecorder?.endEditing();
|
||||
state.currentSource = 'local';
|
||||
state.currentRoute = {
|
||||
id: '',
|
||||
@@ -839,7 +846,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
});
|
||||
state.currentRoute = { ...saved.route, id: saved.id, gpx: saved.gpx, _source: 'local' };
|
||||
state.currentSource = 'local';
|
||||
if (isNew) ns.RouteRecorder?.start(saved.id);
|
||||
if (isNew && byId(ids.dialog)?.open) ns.RouteRecorder?.beginEditing(saved.id);
|
||||
state.pois = saved.pois;
|
||||
await loadRoutes();
|
||||
await loadRoute({ id: saved.id, _source: 'local' });
|
||||
@@ -884,6 +891,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const data = new FormData();
|
||||
data.append('name', routePackage.route?.name || ns.I18n.t("editor.untitled-route"));
|
||||
data.append('description', routePackage.route?.description || '');
|
||||
data.append('creator_id', routePackage.route?.creator_id || ns.LocalRoutes.creatorId());
|
||||
const uploadGpx = ns.LocalRoutes.gpxForUpload(routePackage);
|
||||
const gpx = new Blob([uploadGpx], { type: 'application/gpx+xml' });
|
||||
data.append('gpx', gpx, safeGpxFilename(routePackage.route?.name));
|
||||
@@ -898,72 +906,89 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
return blob;
|
||||
}
|
||||
|
||||
async function uploadStoredMedia(path, fieldName, media, fallbackName) {
|
||||
async function uploadStoredMedia(path, fieldName, media, fallbackName, method = 'POST') {
|
||||
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) {
|
||||
await uploadStoredMedia(`${basePath}/audio`, 'audio', poi.audio, 'audio.bin');
|
||||
}
|
||||
for (const [index, image] of (poi.images || []).entries()) {
|
||||
if (!image) continue;
|
||||
await uploadStoredMedia(`${basePath}/pictures`, 'picture', image, `bild-${index + 1}.bin`);
|
||||
}
|
||||
if (fieldName === 'picture') data.append('caption', media.caption || '');
|
||||
return sendForm(path, method, data);
|
||||
}
|
||||
|
||||
async function createServerRouteFromLocal(routePackage) {
|
||||
let createdRouteId = null;
|
||||
try {
|
||||
const checkpoint = () => {
|
||||
const latest = ns.LocalRoutes.get(routePackage.id);
|
||||
// GPS samples may arrive while media requests are pending.
|
||||
if (latest) routePackage.gpx = latest.gpx;
|
||||
return ns.LocalRoutes.save(routePackage);
|
||||
};
|
||||
let id = routePackage.route.serverRouteId;
|
||||
let previousPois = [];
|
||||
if (id) {
|
||||
const payload = await readJson(`/routes/${encodeURIComponent(id)}`);
|
||||
const existing = payload.route || payload;
|
||||
if (existing.activatedAt || existing.activated_at) {
|
||||
throw new Error(ns.I18n.t("editor.read-only-hint"));
|
||||
}
|
||||
previousPois = existing.pois || [];
|
||||
await sendForm(`/routes/${encodeURIComponent(id)}`, 'PUT', localRouteUploadForm(routePackage));
|
||||
} else {
|
||||
const payload = await sendForm('/routes', 'POST', localRouteUploadForm(routePackage));
|
||||
const serverRoute = payload.route || payload;
|
||||
createdRouteId = routeId(serverRoute);
|
||||
if (!createdRouteId) throw new Error(ns.I18n.t("editor.error.send-route"));
|
||||
|
||||
const orderedPois = [...(routePackage.pois || [])]
|
||||
.sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
||||
for (const [index, poi] of orderedPois.entries()) {
|
||||
const poiTitle = poi.title || ns.I18n.t("editor.station-number", { value1: index + 1 });
|
||||
const poiPayload = await sendJson(`/routes/${encodeURIComponent(createdRouteId)}/pois`, 'POST', {
|
||||
description: poi.description || '',
|
||||
lat: poi.lat,
|
||||
lon: poi.lon,
|
||||
sequence: index,
|
||||
title: poiTitle,
|
||||
id = routeId(payload.route || payload);
|
||||
if (!id) throw new Error(ns.I18n.t("editor.error.send-route"));
|
||||
routePackage.route.serverRouteId = id;
|
||||
checkpoint();
|
||||
}
|
||||
const orderedPois = [...(routePackage.pois || [])]
|
||||
.sort((left, right) => Number(left.sequence) - Number(right.sequence));
|
||||
for (const [index, poi] of orderedPois.entries()) {
|
||||
const base = `/routes/${encodeURIComponent(id)}/pois`;
|
||||
const payload = await sendJson(poi.serverPoiId ? `${base}/${poi.serverPoiId}` : base,
|
||||
poi.serverPoiId ? 'PUT' : 'POST', {
|
||||
description: poi.description || '', lat: poi.lat, lon: poi.lon, sequence: index,
|
||||
title: poi.title || ns.I18n.t("editor.station-number", { value1: index + 1 }),
|
||||
triggerRadiusM: poi.triggerRadiusM || 25
|
||||
});
|
||||
const serverPoi = poiPayload.poi || poiPayload;
|
||||
const serverPoiId = poiId(serverPoi);
|
||||
if (!serverPoiId) throw new Error(ns.I18n.t("editor.error.send-station", { value1: poiTitle }));
|
||||
await uploadLocalPoiMedia(createdRouteId, serverPoiId, poi);
|
||||
poi.serverPoiId = poiId(payload.poi || payload);
|
||||
if (!poi.serverPoiId) throw new Error(ns.I18n.t("editor.error.send-station", { value1: poi.title || '' }));
|
||||
checkpoint();
|
||||
const mediaBase = `${base}/${poi.serverPoiId}`;
|
||||
const oldPoi = previousPois.find(old => String(old.id) === String(poi.serverPoiId));
|
||||
if (poi.audio) {
|
||||
await uploadStoredMedia(`${mediaBase}/audio`, 'audio', poi.audio, 'audio.bin',
|
||||
oldPoi?.audioUrl || poi.serverAudio ? 'PUT' : 'POST');
|
||||
poi.serverAudio = true;
|
||||
checkpoint();
|
||||
} else if (oldPoi?.audioUrl) {
|
||||
await sendDelete(`${mediaBase}/audio`);
|
||||
poi.serverAudio = false;
|
||||
checkpoint();
|
||||
}
|
||||
return createdRouteId;
|
||||
} catch (error) {
|
||||
if (createdRouteId) {
|
||||
try {
|
||||
await sendDelete(`/routes/${encodeURIComponent(createdRouteId)}`);
|
||||
} catch {
|
||||
// Der ursprüngliche Uploadfehler ist wichtiger als ein fehlgeschlagener Rollback.
|
||||
for (const [imageIndex, image] of (poi.images || []).entries()) {
|
||||
const path = image.serverPictureId ? `${mediaBase}/pictures/${image.serverPictureId}` : `${mediaBase}/pictures`;
|
||||
const picture = await uploadStoredMedia(path, 'picture', image, `bild-${imageIndex + 1}.bin`,
|
||||
image.serverPictureId ? 'PUT' : 'POST');
|
||||
image.serverPictureId = (picture.picture || picture).id;
|
||||
if (!image.serverPictureId) throw new Error(ns.I18n.t("editor.error.send-file", { value1: image.name || '' }));
|
||||
checkpoint();
|
||||
}
|
||||
for (const oldImage of oldPoi?.images || []) {
|
||||
if (!(poi.images || []).some(image => String(image.serverPictureId) === String(oldImage.id))) {
|
||||
await sendDelete(`${mediaBase}/pictures/${oldImage.id}`);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
for (const oldPoi of previousPois) {
|
||||
if (!orderedPois.some(poi => String(poi.serverPoiId) === String(oldPoi.id))) {
|
||||
await sendDelete(`/routes/${encodeURIComponent(id)}/pois/${oldPoi.id}`);
|
||||
}
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async function uploadLocalRoute() {
|
||||
if (state.currentSource !== 'local') throw new Error(ns.I18n.t("editor.error.submit-own-draft"));
|
||||
const values = routeFromForm();
|
||||
if (!values.name) throw new Error(ns.I18n.t("editor.error.route-name"));
|
||||
if (state.currentRoute?.serverRouteId) {
|
||||
setStatus(ns.I18n.t("editor.error.already-submitted"));
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadButton = byId(ids.routeUpload);
|
||||
setDisabled(uploadButton, true);
|
||||
try {
|
||||
@@ -989,7 +1014,14 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
await loadRoutes();
|
||||
setStatus(ns.I18n.t("editor.submit-success"));
|
||||
} finally {
|
||||
if (!state.currentRoute?.serverRouteId) setDisabled(uploadButton, false);
|
||||
// Keep acknowledged server IDs even if a later media request failed.
|
||||
const latest = ns.LocalRoutes.get(routeId(state.currentRoute));
|
||||
if (latest) {
|
||||
state.currentRoute = { ...latest.route, id: latest.id, gpx: latest.gpx, _source: 'local' };
|
||||
state.pois = latest.pois;
|
||||
fillRouteForm(state.currentRoute);
|
||||
}
|
||||
setDisabled(uploadButton, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1392,6 +1424,9 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
}
|
||||
|
||||
function bind() {
|
||||
for (const eventName of ['cancel', 'close']) {
|
||||
byId(ids.dialog)?.addEventListener(eventName, () => ns.RouteRecorder?.endEditing());
|
||||
}
|
||||
activate(byId(ids.button), () => guarded(openEditor));
|
||||
activate(byId(ids.close), closeEditor);
|
||||
activate(byId(ids.newRoute), newRoute);
|
||||
|
||||
@@ -19,6 +19,31 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
return target;
|
||||
}
|
||||
|
||||
// Separate from app/route storage: ordinary cleanup must preserve this identity.
|
||||
const CREATOR_KEY = 'wegwichtel.creator-id.v1';
|
||||
|
||||
function creatorId() {
|
||||
const target = storage();
|
||||
const existing = target.getItem(CREATOR_KEY);
|
||||
if (existing) {
|
||||
if (!/^[a-f0-9]{64}$/.test(existing)) throw new Error(ns.I18n.t("local.error.identity"));
|
||||
return existing;
|
||||
}
|
||||
if (typeof globalThis.crypto?.getRandomValues !== 'function') {
|
||||
throw new Error(ns.I18n.t("local.error.identity"));
|
||||
}
|
||||
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(32));
|
||||
const value = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
target.setItem(CREATOR_KEY, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function forgetCreatorId() {
|
||||
if (globalThis.confirm?.(ns.I18n.t("local.confirm-forget-identity")) !== true) return false;
|
||||
storage().removeItem(CREATOR_KEY);
|
||||
return true;
|
||||
}
|
||||
|
||||
function routeKey(id, suffix) {
|
||||
return `${STORAGE_PREFIX}.${encodeURIComponent(String(id))}.${suffix}`;
|
||||
}
|
||||
@@ -499,6 +524,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
const inputRoute = routePackage.route || {};
|
||||
const route = {
|
||||
id,
|
||||
creator_id: inputRoute.creator_id || creatorId(),
|
||||
name: String(inputRoute.name || ''),
|
||||
description: String(inputRoute.description || ''),
|
||||
createdAt: inputRoute.createdAt || now,
|
||||
@@ -724,6 +750,8 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {};
|
||||
|
||||
ns.LocalRoutes = Object.freeze({
|
||||
STORAGE_PREFIX,
|
||||
creatorId,
|
||||
forgetCreatorId,
|
||||
appendTrackPoint,
|
||||
recordedPositions,
|
||||
replaceRecordedPositions,
|
||||
|
||||
@@ -3,6 +3,7 @@ globalThis.Wegwichtel ??= {};
|
||||
'use strict';
|
||||
const INTERVAL_MS = 30000;
|
||||
let id = null;
|
||||
let editingId = null;
|
||||
let status = 'idle';
|
||||
let timer = null;
|
||||
let generation = 0;
|
||||
@@ -79,9 +80,21 @@ globalThis.Wegwichtel ??= {};
|
||||
function start(routeId) {
|
||||
lastPosition = null;
|
||||
id = String(routeId);
|
||||
editingId = id;
|
||||
resume();
|
||||
}
|
||||
|
||||
function beginEditing(routeId) {
|
||||
if (editingId === String(routeId)) return;
|
||||
stop();
|
||||
start(routeId);
|
||||
}
|
||||
|
||||
function endEditing() {
|
||||
editingId = null;
|
||||
stop();
|
||||
}
|
||||
|
||||
function addPositionField(position = {}, index = null) {
|
||||
const container = document.getElementById('editor-recorded-positions');
|
||||
if (!container) return;
|
||||
@@ -132,6 +145,7 @@ globalThis.Wegwichtel ??= {};
|
||||
}
|
||||
|
||||
function bind() {
|
||||
globalThis.addEventListener?.('pagehide', endEditing);
|
||||
const activate = (elementId, action) => {
|
||||
const element = document.getElementById(elementId);
|
||||
element?.addEventListener('pointerup', event => {
|
||||
@@ -153,7 +167,7 @@ globalThis.Wegwichtel ??= {};
|
||||
});
|
||||
}
|
||||
|
||||
ns.RouteRecorder = Object.freeze({ start, pause, resume, stop, renderPoints, editGpx, get status() { return status; }, get routeId() { return id; }, get position() { return lastPosition; } });
|
||||
ns.RouteRecorder = Object.freeze({ start, pause, resume, stop, beginEditing, endEditing, renderPoints, editGpx, get status() { return status; }, get routeId() { return id; }, get position() { return lastPosition; } });
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bind, { once: true });
|
||||
else bind();
|
||||
}(globalThis.Wegwichtel));
|
||||
|
||||
@@ -105,7 +105,6 @@ globalThis.Wegwichtel ??= {};
|
||||
"send-route": "Die Strecke konnte nicht gesendet werden. Bitte versuche es noch einmal.",
|
||||
"send-station": "Die Station {value1} konnte nicht gesendet werden. Bitte versuche es noch einmal.",
|
||||
"submit-own-draft": "Du kannst nur deine eigenen Entwürfe zur Prüfung senden.",
|
||||
"already-submitted": "Diese Strecke wurde schon zur Prüfung gesendet.",
|
||||
"location-required": "Für eine neue Station brauchen wir deinen Standort. Erlaube den Standort und versuche es noch einmal.",
|
||||
"save-route-first": "Speichere zuerst die Strecke.",
|
||||
"station-location": "Der Ort dieser Station fehlt. Erlaube deinen Standort und versuche es noch einmal.",
|
||||
@@ -131,9 +130,9 @@ globalThis.Wegwichtel ??= {};
|
||||
"no-draft": "Noch kein eigener Entwurf vorhanden.",
|
||||
"no-stations": "Noch keine Stationen angelegt.",
|
||||
"loading-routes": "Strecken werden geladen …",
|
||||
"submitted": "Schon zur Prüfung gesendet",
|
||||
"submitted": "Erneut zur Prüfung senden",
|
||||
"submit": "Zur Prüfung senden",
|
||||
"submitted-hint": "Diese Strecke wurde zur Prüfung gesendet. Andere können sie erst nach der Freigabe sehen.",
|
||||
"submitted-hint": "Du kannst Änderungen erneut zur Prüfung senden, solange die Strecke noch nicht freigegeben wurde.",
|
||||
"draft-hint": "Dieser Entwurf bleibt auf deinem Gerät. Mit „Zur Prüfung senden“ reichst du ihn zur Freigabe ein.",
|
||||
"read-only-hint": "Diese Strecke ist für alle freigegeben und kann hier nicht mehr verändert werden.",
|
||||
"unpublished-hint": "Diese Strecke wurde noch nicht für alle freigegeben.",
|
||||
@@ -220,7 +219,9 @@ globalThis.Wegwichtel ??= {};
|
||||
"missing": "{value1}: Diese Information fehlt noch."
|
||||
},
|
||||
"local": {
|
||||
"confirm-forget-identity": "Gerätekennung wirklich löschen? Dieses Gerät bekommt eine neue Kennung. Die Zuordnung zu bisherigen Strecken bleibt bestehen.",
|
||||
"error": {
|
||||
"identity": "Die Gerätekennung konnte nicht gespeichert werden. Erlaube das Speichern für diese Seite und versuche es erneut.",
|
||||
"route-storage": "Deine Strecke kann auf diesem Gerät gerade nicht gespeichert werden.",
|
||||
"media-storage": "Bilder und Ton können auf diesem Gerät gerade nicht gespeichert werden.",
|
||||
"media-read": "Gespeicherte Bilder und Ton konnten nicht geöffnet werden.",
|
||||
|
||||
@@ -105,7 +105,6 @@ globalThis.Wegwichtel ??= {};
|
||||
"send-route": "The trail could not be sent. Please try again.",
|
||||
"send-station": "The stop {value1} could not be sent. Please try again.",
|
||||
"submit-own-draft": "You can only send your own drafts for review.",
|
||||
"already-submitted": "This trail has already been sent for review.",
|
||||
"location-required": "We need your location to add a new stop. Allow location access and try again.",
|
||||
"save-route-first": "Save the trail first.",
|
||||
"station-location": "This stop has no location. Allow location access and try again.",
|
||||
@@ -131,9 +130,9 @@ globalThis.Wegwichtel ??= {};
|
||||
"no-draft": "There are no drafts yet.",
|
||||
"no-stations": "There are no stops yet.",
|
||||
"loading-routes": "Loading trails …",
|
||||
"submitted": "Already sent for review",
|
||||
"submitted": "Send changes for review",
|
||||
"submit": "Send for review",
|
||||
"submitted-hint": "This trail has been sent for review. Others can see it once it has been approved.",
|
||||
"submitted-hint": "You can send changes for review until the route has been approved.",
|
||||
"draft-hint": "This draft stays on your device. Choose “Send for review” to submit it for approval.",
|
||||
"read-only-hint": "This trail is available to everyone and can no longer be changed here.",
|
||||
"unpublished-hint": "This trail has not been made available to everyone yet.",
|
||||
@@ -220,7 +219,9 @@ globalThis.Wegwichtel ??= {};
|
||||
"missing": "{value1}: This information still needs to be added."
|
||||
},
|
||||
"local": {
|
||||
"confirm-forget-identity": "Delete the device ID? This device will get a new ID. Existing routes will keep their original creator ID.",
|
||||
"error": {
|
||||
"identity": "The device ID could not be saved. Allow this site to store data and try again.",
|
||||
"route-storage": "Your trail cannot be saved on this device right now.",
|
||||
"media-storage": "Pictures and recordings cannot be saved on this device right now.",
|
||||
"media-read": "Saved pictures and recordings could not be opened.",
|
||||
|
||||
Reference in New Issue
Block a user