diff --git a/public/js/editor-ui.js b/public/js/editor-ui.js index d293624..f447ee1 100644 --- a/public/js/editor-ui.js +++ b/public/js/editor-ui.js @@ -914,74 +914,90 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; return sendForm(path, method, data); } - async function createServerRouteFromLocal(routePackage) { - 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 = []; + function checkpointLocalUpload(routePackage) { + 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); + } + + async function prepareServerRoute(routePackage) { + const id = routePackage.route.serverRouteId; 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)); - id = routeId(payload.route || payload); - if (!id) throw new Error(ns.I18n.t("editor.error.send-route")); - routePackage.route.serverRouteId = id; - checkpoint(); + return { id, previousPois: existing.pois || [] }; } + const payload = await sendForm('/routes', 'POST', localRouteUploadForm(routePackage)); + const createdId = routeId(payload.route || payload); + if (!createdId) throw new Error(ns.I18n.t("editor.error.send-route")); + routePackage.route.serverRouteId = createdId; + checkpointLocalUpload(routePackage); + return { id: createdId, previousPois: [] }; + } + + async function syncLocalPoi(base, poi, index, routePackage) { + 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 + }); + poi.serverPoiId = poiId(payload.poi || payload); + if (!poi.serverPoiId) throw new Error(ns.I18n.t("editor.error.send-station", { value1: poi.title || '' })); + checkpointLocalUpload(routePackage); + } + + async function syncLocalAudio(mediaBase, poi, oldPoi, routePackage) { + if (poi.audio) { + await uploadStoredMedia(`${mediaBase}/audio`, 'audio', poi.audio, 'audio.bin', + oldPoi?.audioUrl || poi.serverAudio ? 'PUT' : 'POST'); + poi.serverAudio = true; + checkpointLocalUpload(routePackage); + } else if (oldPoi?.audioUrl) { + await sendDelete(`${mediaBase}/audio`); + poi.serverAudio = false; + checkpointLocalUpload(routePackage); + } + } + + async function removeMissingServerItems(previous, current, serverIdKey, base) { + const retained = new Set(current.map(item => String(item[serverIdKey]))); + for (const item of previous) { + if (!retained.has(String(item.id))) await sendDelete(`${base}/${item.id}`); + } + } + + async function syncLocalPictures(mediaBase, poi, oldPoi, routePackage) { + const images = poi.images || []; + for (const [index, image] of images.entries()) { + const path = image.serverPictureId ? `${mediaBase}/pictures/${image.serverPictureId}` : `${mediaBase}/pictures`; + const picture = await uploadStoredMedia(path, 'picture', image, `bild-${index + 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 || '' })); + checkpointLocalUpload(routePackage); + } + await removeMissingServerItems(oldPoi?.images || [], images, 'serverPictureId', `${mediaBase}/pictures`); + } + + async function createServerRouteFromLocal(routePackage) { + const { id, previousPois } = await prepareServerRoute(routePackage); + const base = `/routes/${encodeURIComponent(id)}/pois`; 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 - }); - poi.serverPoiId = poiId(payload.poi || payload); - if (!poi.serverPoiId) throw new Error(ns.I18n.t("editor.error.send-station", { value1: poi.title || '' })); - checkpoint(); + await syncLocalPoi(base, poi, index, routePackage); 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(); - } - 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}`); - } - } - } - for (const oldPoi of previousPois) { - if (!orderedPois.some(poi => String(poi.serverPoiId) === String(oldPoi.id))) { - await sendDelete(`/routes/${encodeURIComponent(id)}/pois/${oldPoi.id}`); - } + await syncLocalAudio(mediaBase, poi, oldPoi, routePackage); + await syncLocalPictures(mediaBase, poi, oldPoi, routePackage); } + await removeMissingServerItems(previousPois, orderedPois, 'serverPoiId', base); return id; } diff --git a/public/js/local-routes.js b/public/js/local-routes.js index 3933935..369cfd3 100644 --- a/public/js/local-routes.js +++ b/public/js/local-routes.js @@ -30,7 +30,7 @@ globalThis.Wegwichtel = globalThis.Wegwichtel || {}; return existing; } if (typeof globalThis.crypto?.getRandomValues !== 'function') { - throw new Error(ns.I18n.t("local.error.identity")); + throw new TypeError(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(''); diff --git a/test/local-routes.test.js b/test/local-routes.test.js index c82b079..b85b0a5 100644 --- a/test/local-routes.test.js +++ b/test/local-routes.test.js @@ -725,7 +725,7 @@ test('256-bit creator identity survives reload, route deletion and requires expl test('creator identity never falls back to insecure randomness or replaces corrupt stored identity', async () => { const { api, localStorage } = await loadLocalRoutes({ crypto: {} }); - assert.throws(() => api.creatorId(), /Gerätekennung/); + assert.throws(() => api.creatorId(), error => error.name === 'TypeError' && /Gerätekennung/.test(error.message)); localStorage.setItem('wegwichtel.creator-id.v1', 'broken'); assert.throws(() => api.creatorId(), /Gerätekennung/); assert.equal(localStorage.getItem('wegwichtel.creator-id.v1'), 'broken');