From b8ee4b4cc0d369047372f3987f013686601c909a Mon Sep 17 00:00:00 2001 From: TheFozid Date: Mon, 3 Aug 2026 14:21:40 +0100 Subject: [PATCH] add gpx editing --- Cargo.lock | 2 +- Cargo.toml | 2 +- frontend/app.js | 66 ++++++++++++++++++++++++++++++++++--- src/gpx.rs | 33 +++++++++++++++++++ src/routes.rs | 86 +++++++++++++++++++++++++++++++++++++++++++------ 5 files changed, 173 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fe1781e..cb812e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1590,7 +1590,7 @@ dependencies = [ [[package]] name = "rs_maps" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "argon2", diff --git a/Cargo.toml b/Cargo.toml index 6b344e9..d91ae4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ # Cargo.toml [package] name = "rs_maps" -version = "0.2.0" +version = "0.3.0" edition = "2021" [dependencies] diff --git a/frontend/app.js b/frontend/app.js index 1ee4de4..680ae77 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -51,7 +51,7 @@ const state = { colour: DEFAULT_COLOUR, searchPin: null, // transient pin for the current search hit draw: { active: false, points: [], markers: [], line: null, guide: null, - timer: null, seq: 0, snapped: null }, + timer: null, seq: 0, snapped: null, editing: null }, }; /* ───────────────────────────── api ───────────────────────────── */ @@ -479,7 +479,7 @@ function drawReset() { if (state.draw.line) state.map.removeLayer(state.draw.line); if (state.draw.guide) state.map.removeLayer(state.draw.guide); state.draw = { active: false, points: [], markers: [], line: null, guide: null, - timer: null, seq: 0, snapped: null }; + timer: null, seq: 0, snapped: null, editing: null }; } function drawSetActive(on) { @@ -489,6 +489,7 @@ function drawSetActive(on) { $('#draw-panel').hidden = !on; $('#draw-start').textContent = on ? 'Drawing…' : 'New route'; $('#draw-start').disabled = on; + if (!on) $('#draw-hint').textContent = 'Snapped to paths for walking'; state.map.getContainer().style.cursor = on ? 'crosshair' : ''; if (on) drawUpdate(); } @@ -624,7 +625,10 @@ $('#draw-start').addEventListener('click', () => { toast('Click the map to add waypoints'); }); -$('#draw-cancel').addEventListener('click', () => drawSetActive(false)); +$('#draw-cancel').addEventListener('click', () => { + if (state.draw.editing && !confirm('Discard changes to this route?')) return; + drawSetActive(false); +}); $('#draw-undo').addEventListener('click', undoWaypoint); $('#draw-save').addEventListener('click', async () => { @@ -642,10 +646,18 @@ $('#draw-save').addEventListener('click', async () => { const body = { name, waypoints: points.map((p) => ({ lat: p.lat, lon: p.lng })), + // The backend replaces in place only when this matches the new name. + // Rename the file and the original is left alone, as a copy. + replace: state.draw.editing || null, }; const result = await api('api/routes/calculate', { method: 'POST', ...json(body) }); - toast(`Saved as ${result.filename}`); + const renamed = state.draw.editing && result.filename !== state.draw.editing; + toast( + !state.draw.editing ? `Saved as ${result.filename}` + : renamed ? `Saved as ${result.filename} — ${state.draw.editing} kept` + : `${result.filename} updated` + ); drawSetActive(false); await loadGpxList(); } catch (err) { @@ -1052,10 +1064,56 @@ async function loadGpxList() { } }); li.appendChild(show); + + const edit = document.createElement('button'); + edit.className = 'link'; + edit.textContent = 'Edit'; + edit.addEventListener('click', () => editRoute(f.name)); + li.appendChild(edit); + list.appendChild(li); }); } +/* Reopens a saved route for editing. Only routes created here can be edited: + they carry their original waypoints alongside the dense snapped track. + A GPX recorded on a device has no such waypoints, and a few thousand track + points can't be reduced back to the handful someone actually clicked. */ +async function editRoute(filename) { + try { + const res = await fetch('api/gpx/file/' + encodeURIComponent(filename), { + credentials: 'same-origin', + }); + if (!res.ok) throw new Error('Could not read that file.'); + + const doc = new DOMParser().parseFromString(await res.text(), 'application/xml'); + const wpts = [...doc.getElementsByTagName('wpt')] + .map((w) => ({ + lat: parseFloat(w.getAttribute('lat')), + lng: parseFloat(w.getAttribute('lon')), + })) + .filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lng)); + + if (wpts.length < 2) { + toast('That file has no editable waypoints — only routes drawn here can be edited', true); + return; + } + + drawSetActive(true); + state.draw.editing = filename; + // Strip the extension: it's re-added on save, and showing it invites + // someone to delete it by accident. + $('#draw-name').value = filename.replace(/\.gpx$/i, ''); + $('#draw-hint').textContent = `Editing ${filename}`; + + wpts.forEach((p) => addWaypoint(L.latLng(p.lat, p.lng))); + state.map.fitBounds(L.latLngBounds(wpts.map((p) => [p.lat, p.lng])), { padding: [40, 40] }); + toast('Drag points to adjust, then save'); + } catch (err) { + toast(err.message || 'Could not open that route', true); + } +} + $('#gpx-local').addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; diff --git a/src/gpx.rs b/src/gpx.rs index 4a18741..5404fce 100644 --- a/src/gpx.rs +++ b/src/gpx.rs @@ -107,6 +107,39 @@ pub fn reserve(dir: &StdPath, name: &str) -> AppResult<(std::fs::File, String)> )) } +/// Replaces an existing file's contents atomically: write a sibling temp file, +/// then rename over the target. A reader that opens the file mid-save sees +/// either the old contents or the new, never a partial write. +pub async fn overwrite(dir: &StdPath, name: &str, bytes: &[u8]) -> AppResult<()> { + validate_filename(name)?; + + // Must exist already — this path is for editing, not creating. + let target = resolve_existing(dir, name)?; + + // Same directory, so the rename stays on one filesystem and is atomic. + let temp = dir.join(format!(".{name}.tmp")); + + let write = async { + let mut file = tokio::fs::File::create(&temp).await?; + file.write_all(bytes).await?; + file.flush().await?; + // Durable before the rename, so a crash can't leave an empty file in place. + file.sync_all().await?; + Ok::<_, std::io::Error>(()) + }; + + if let Err(e) = write.await { + let _ = tokio::fs::remove_file(&temp).await; + return Err(e.into()); + } + + if let Err(e) = tokio::fs::rename(&temp, &target).await { + let _ = tokio::fs::remove_file(&temp).await; + return Err(e.into()); + } + Ok(()) +} + // ---------------------------------------------------------------- handlers async fn list( diff --git a/src/routes.rs b/src/routes.rs index a35212d..d0d110d 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -52,6 +52,11 @@ pub struct CalculateRequest { pub waypoints: Vec, /// Desired filename. ".gpx" is appended if absent. pub name: String, + /// When editing: the file this route came from. If it matches the target + /// name the file is replaced in place; if the user renamed it, a new file is + /// written and the original left alone. + #[serde(default)] + pub replace: Option, } #[derive(Serialize)] @@ -94,6 +99,38 @@ fn lonlats(waypoints: &[Waypoint]) -> String { .join("|") } +/// The stored GPX carries the clicked waypoints as `` elements alongside +/// the dense snapped ``. Without them a saved route can't be reopened for +/// editing — several thousand track points can't be reduced back to the handful +/// of points the user actually placed. `` is standard GPX, so other tools +/// simply show them as markers. +fn embed_waypoints(gpx: &str, waypoints: &[Waypoint]) -> String { + let block: String = waypoints + .iter() + .map(|p| { + format!( + " rs_maps:via\n", + p.lat, p.lon + ) + }) + .collect(); + + // Insert immediately after the opening tag; the schema requires + // wpt elements to precede trk. + match gpx.find("').map(|o| start + o + 1)) { + Some(after_open) => { + let mut out = String::with_capacity(gpx.len() + block.len() + 1); + out.push_str(&gpx[..after_open]); + out.push('\n'); + out.push_str(&block); + out.push_str(&gpx[after_open..]); + out + } + // Shouldn't happen — looks_like_gpx already ran — but never lose the route. + None => gpx.to_string(), + } +} + /// Cheap sanity check on the response body. BRouter reports routing failures /// ("operation not supported", "position not mapped in existing datafile") as /// 200 with a plain-text body, so status alone isn't enough. @@ -239,17 +276,35 @@ async fn calculate( return Err(routing_failure(&body)); } - // Same atomic create_new reservation the upload path uses, so a name clash - // becomes route-1.gpx rather than silently overwriting. - let (file, written_name) = gpx::reserve(&state.config.gpx_dir, &filename)?; - let mut file = tokio::fs::File::from_std(file); + let body = embed_waypoints(&body, &request.waypoints); - if let Err(e) = file.write_all(body.as_bytes()).await { - // Don't leave a zero-byte stub behind on a failed write. - let _ = tokio::fs::remove_file(state.config.gpx_dir.join(&written_name)).await; - return Err(e.into()); - } - file.flush().await?; + // Replacing in place only when the user kept the name. A rename leaves the + // original untouched and takes the normal collision-avoiding path. + let replacing = request + .replace + .as_deref() + .map(str::trim) + .filter(|original| !original.is_empty()) + .filter(|original| original.eq_ignore_ascii_case(&filename)); + + let written_name = if let Some(original) = replacing { + gpx::validate_filename(original)?; + gpx::overwrite(&state.config.gpx_dir, original, body.as_bytes()).await?; + original.to_string() + } else { + // Same atomic create_new reservation the upload path uses, so a name + // clash becomes route-1.gpx rather than silently overwriting. + let (file, written_name) = gpx::reserve(&state.config.gpx_dir, &filename)?; + let mut file = tokio::fs::File::from_std(file); + + if let Err(e) = file.write_all(body.as_bytes()).await { + // Don't leave a zero-byte stub behind on a failed write. + let _ = tokio::fs::remove_file(state.config.gpx_dir.join(&written_name)).await; + return Err(e.into()); + } + file.flush().await?; + written_name + }; tracing::info!(file = %written_name, points = request.waypoints.len(), "route saved"); @@ -298,6 +353,17 @@ mod tests { assert!(validate(&request).is_err()); } + #[test] + fn waypoints_are_embedded_before_the_track() { + let gpx = r#"x"#; + let out = embed_waypoints(gpx, &[wp(53.5, -2.2), wp(53.6, -2.3)]); + + assert_eq!(out.matches("