From 42a55e67597748f45631cb38efabc2e49a734c71 Mon Sep 17 00:00:00 2001 From: vcoppe Date: Thu, 8 Oct 2026 21:04:29 +0200 Subject: [PATCH] progress --- gpx-rs/Cargo.lock | 151 ++ gpx-rs/README.md | 11 + .../engine/src/core/algorithm/timestamps.rs | 89 +- gpx-rs/engine/src/core/gpx/categories.rs | 11 +- gpx-rs/engine/src/core/gpx/common.rs | 6 +- gpx-rs/engine/src/core/gpx/file.rs | 4 +- gpx-rs/engine/src/core/gpx/track.rs | 2 +- gpx-rs/engine/src/core/gpx/trackpoint.rs | 2 +- gpx-rs/engine/src/core/gpx/waypoint.rs | 2 +- .../statistics/{statistics.rs => compute.rs} | 35 +- gpx-rs/engine/src/core/statistics/mod.rs | 4 +- gpx-rs/engine/src/core/utils.rs | 17 +- gpx-rs/engine/src/engine/command/command.rs | 81 - gpx-rs/engine/src/engine/command/mod.rs | 78 +- .../engine/src/engine/command/tools/time.rs | 79 +- gpx-rs/engine/src/engine/engine.rs | 1557 --------------- gpx-rs/engine/src/engine/mod.rs | 365 +++- gpx-rs/engine/src/engine/outcome.rs | 21 + gpx-rs/engine/src/engine/state/selection.rs | 50 + gpx-rs/engine/src/engine/state/stack.rs | 89 +- gpx-rs/engine/src/engine/tests.rs | 1310 +++++++++++++ gpx-rs/engine/src/storage/codec.rs | 286 --- gpx-rs/engine/src/storage/codec/mod.rs | 579 ++++++ gpx-rs/engine/src/storage/codec/v1.rs | 318 ++++ gpx-rs/engine/src/storage/memory.rs | 93 +- gpx-rs/engine/src/storage/mod.rs | 59 +- gpx-rs/engine/src/storage/persister.rs | 218 ++- gpx-rs/wasm/Cargo.toml | 3 + gpx-rs/wasm/src/command/edit.rs | 34 + gpx-rs/wasm/src/command/files.rs | 120 ++ gpx-rs/wasm/src/command/history.rs | 30 + gpx-rs/wasm/src/command/mod.rs | 6 + gpx-rs/wasm/src/command/routing.rs | 144 ++ gpx-rs/wasm/src/command/tools.rs | 109 ++ gpx-rs/wasm/src/command/waypoints.rs | 90 + gpx-rs/wasm/src/convert.rs | 266 +++ gpx-rs/wasm/src/{storage.rs => idb.rs} | 47 +- gpx-rs/wasm/src/ids.rs | 25 + gpx-rs/wasm/src/lib.rs | 1670 +---------------- gpx-rs/wasm/src/persistence.rs | 143 ++ gpx-rs/wasm/src/queries/coordinates.rs | 37 + gpx-rs/wasm/src/queries/files.rs | 209 +++ gpx-rs/wasm/src/queries/mod.rs | 4 + gpx-rs/wasm/src/queries/routing.rs | 54 + gpx-rs/wasm/src/queries/statistics.rs | 142 ++ gpx-rs/wasm/src/selection.rs | 110 ++ gpx-rs/wasm/src/session.rs | 52 + gpx-rs/wasm/src/tests.rs | 482 +++++ gpx-rs/wasm/src/ts.rs | 191 ++ gpx-rs/wasm/src/types.rs | 75 + website/src/lib/engine.ts | 50 +- 51 files changed, 5756 insertions(+), 3854 deletions(-) rename gpx-rs/engine/src/core/statistics/{statistics.rs => compute.rs} (96%) delete mode 100644 gpx-rs/engine/src/engine/command/command.rs delete mode 100644 gpx-rs/engine/src/engine/engine.rs create mode 100644 gpx-rs/engine/src/engine/outcome.rs create mode 100644 gpx-rs/engine/src/engine/tests.rs delete mode 100644 gpx-rs/engine/src/storage/codec.rs create mode 100644 gpx-rs/engine/src/storage/codec/mod.rs create mode 100644 gpx-rs/engine/src/storage/codec/v1.rs create mode 100644 gpx-rs/wasm/src/command/edit.rs create mode 100644 gpx-rs/wasm/src/command/files.rs create mode 100644 gpx-rs/wasm/src/command/history.rs create mode 100644 gpx-rs/wasm/src/command/mod.rs create mode 100644 gpx-rs/wasm/src/command/routing.rs create mode 100644 gpx-rs/wasm/src/command/tools.rs create mode 100644 gpx-rs/wasm/src/command/waypoints.rs create mode 100644 gpx-rs/wasm/src/convert.rs rename gpx-rs/wasm/src/{storage.rs => idb.rs} (86%) create mode 100644 gpx-rs/wasm/src/ids.rs create mode 100644 gpx-rs/wasm/src/persistence.rs create mode 100644 gpx-rs/wasm/src/queries/coordinates.rs create mode 100644 gpx-rs/wasm/src/queries/files.rs create mode 100644 gpx-rs/wasm/src/queries/mod.rs create mode 100644 gpx-rs/wasm/src/queries/routing.rs create mode 100644 gpx-rs/wasm/src/queries/statistics.rs create mode 100644 gpx-rs/wasm/src/selection.rs create mode 100644 gpx-rs/wasm/src/session.rs create mode 100644 gpx-rs/wasm/src/tests.rs create mode 100644 gpx-rs/wasm/src/ts.rs create mode 100644 gpx-rs/wasm/src/types.rs diff --git a/gpx-rs/Cargo.lock b/gpx-rs/Cargo.lock index 9acce6566..62ebb9ec0 100644 --- a/gpx-rs/Cargo.lock +++ b/gpx-rs/Cargo.lock @@ -11,6 +11,17 @@ dependencies = [ "libc", ] +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "atomic-polyfill" version = "1.0.3" @@ -38,6 +49,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.4.6" @@ -179,6 +196,7 @@ dependencies = [ "uuid", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-bindgen-test", ] [[package]] @@ -259,6 +277,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "js-sys" version = "0.3.106" @@ -276,6 +300,12 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "lock_api" version = "0.4.14" @@ -297,6 +327,25 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -304,6 +353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -312,6 +362,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -379,6 +435,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -421,6 +486,19 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "shlex" version = "2.0.1" @@ -517,6 +595,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -573,6 +661,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe" + [[package]] name = "web-sys" version = "0.3.106" @@ -583,6 +710,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -641,3 +777,18 @@ checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ "windows-link", ] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/gpx-rs/README.md b/gpx-rs/README.md index fe79689d7..7645015bd 100644 --- a/gpx-rs/README.md +++ b/gpx-rs/README.md @@ -1,3 +1,14 @@ +# gpx-rs + +- `engine`: the GPX engine, plain Rust. Tested natively: `cargo test`. +- `wasm`: the interface to the web page, compiled to WebAssembly. + ``` wasm-pack build wasm --target bundler --out-dir ../pkg ``` + +The interface is tested in Node (it needs `wasm-pack`, which fetches the test runner the first time): + +``` +wasm-pack test --node wasm +``` diff --git a/gpx-rs/engine/src/core/algorithm/timestamps.rs b/gpx-rs/engine/src/core/algorithm/timestamps.rs index 946650a15..0ee42049f 100644 --- a/gpx-rs/engine/src/core/algorithm/timestamps.rs +++ b/gpx-rs/engine/src/core/algorithm/timestamps.rs @@ -1,4 +1,91 @@ -use crate::{Trackpoint, distance}; +use crate::{Statistics, TrackSegment, Trackpoint, distance}; + +/// The new timestamps of the trackpoints of `segments`, one after the other: each segment goes on +/// from the end of the previous one, the first one starts at `start_time` (in milliseconds). +/// +/// A segment that has timestamps keeps its durations multiplied by `ratio`; one that has none +/// (judged by its first trackpoint) follows the previous trackpoint at `speed` (km/h). See +/// [`shifted_and_compressed`] and [`with_timestamps`]. +pub fn changed_segment_times( + segments: &[&TrackSegment], + start_time: i64, + speed: f64, + ratio: f64, +) -> Vec>> { + let mut last: Option = None; + segments + .iter() + .map(|segment| { + let points: Vec = segment.iter().cloned().collect(); + let Some(first) = points.first() else { + return vec![]; + }; + let start = last.clone().unwrap_or_else(|| { + let mut start = first.clone(); + start.time = Some(start_time); + start + }); + let points = if first.time.is_none() { + with_timestamps(points, Some(speed), Some(&start), Some(start_time)) + } else { + shifted_and_compressed(points, Some(speed), ratio, &start) + }; + last = points.last().cloned(); + points.iter().map(|point| point.time).collect() + }) + .collect() +} + +/// Makes up timestamps for the trackpoints of `segments`, one after the other: they start at +/// `start_time` (in milliseconds) and last `total_time` seconds together, the longer and the +/// steeper an interval the more of it. Each segment goes on from the end of the previous one. +pub fn artificial_segment_times( + segments: &[&TrackSegment], + start_time: i64, + total_time: f64, +) -> Vec>> { + let mut weights: Vec> = segments + .iter() + .map(|segment| { + let points: Vec = segment.iter().cloned().collect(); + let slopes: Vec = Statistics::compute(segment) + .local + .iter() + .map(|l| l.slope) + .collect(); + artificial_weights(&points, &slopes) + }) + .collect(); + let mut total: f64 = weights.iter().flatten().sum(); + if total.is_nan() || total <= 0.0 { + // no distance to share the time by: every interval takes as long + for weights in &mut weights { + weights.iter_mut().for_each(|w| *w = 1.0); + } + total = weights.iter().map(Vec::len).sum::() as f64; + } + let ms_per_weight = if total > 0.0 { + total_time * 1000.0 / total + } else { + 0.0 + }; + + let mut start = start_time; + segments + .iter() + .zip(&weights) + .map(|(segment, weights)| { + let points: Vec = segment.iter().cloned().collect(); + if points.is_empty() { + return vec![]; + } + let points = with_artificial_timestamps(points, weights, ms_per_weight, start); + // the next segment goes on from the end of this one + start = points.last().and_then(|point| point.time).unwrap_or(start); + points.iter().map(|point| point.time).collect() + }) + .collect() +} /// Replaces the trackpoints `start..end` of `trkpt` by `points`, keeping the timestamps of the /// whole path consistent. It is the work of the old `replaceTrackPoints` of the TS library. diff --git a/gpx-rs/engine/src/core/gpx/categories.rs b/gpx-rs/engine/src/core/gpx/categories.rs index fc88378eb..5c8440ff4 100644 --- a/gpx-rs/engine/src/core/gpx/categories.rs +++ b/gpx-rs/engine/src/core/gpx/categories.rs @@ -3,10 +3,17 @@ /// /// The codes are the positions of the names in the order of first appearance. The table only /// grows: a code never changes meaning, so it stays valid in every state of the history. -#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Default, Clone, PartialEq)] pub struct Categories(Vec); impl Categories { + /// A table with these names, in the order of their codes (at most [`Self::CAPACITY`] of them + /// are kept). + pub fn from_names(mut names: Vec) -> Self { + names.truncate(Self::CAPACITY); + Self(names) + } + /// Maximum number of distinct values. pub const CAPACITY: usize = u8::MAX as usize; @@ -36,7 +43,7 @@ impl Categories { /// The categories of the data of the trackpoints. There is one table per engine, shared by all /// its files. -#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Default, Clone, PartialEq)] pub struct TrackpointCategories { pub surface: Categories, pub highway: Categories, diff --git a/gpx-rs/engine/src/core/gpx/common.rs b/gpx-rs/engine/src/core/gpx/common.rs index ef14fb5cf..017f5b66d 100644 --- a/gpx-rs/engine/src/core/gpx/common.rs +++ b/gpx-rs/engine/src/core/gpx/common.rs @@ -2,7 +2,7 @@ macro_rules! uuid_id { ($(#[$meta:meta])* $name:ident) => { $(#[$meta])* - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct $name(pub uuid::Uuid); impl Default for $name { @@ -15,13 +15,13 @@ macro_rules! uuid_id { pub(crate) use uuid_id; -#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct Link { pub href: String, pub text: Option, } -#[derive(Debug, Default, Clone, Copy, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Default, Clone, Copy)] pub struct LngLat { pub lng: f64, pub lat: f64, diff --git a/gpx-rs/engine/src/core/gpx/file.rs b/gpx-rs/engine/src/core/gpx/file.rs index b1d00c2ea..f6baad9d4 100644 --- a/gpx-rs/engine/src/core/gpx/file.rs +++ b/gpx-rs/engine/src/core/gpx/file.rs @@ -12,7 +12,7 @@ pub struct File { pub wpt: Waypoints, } -#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Default, Clone, PartialEq)] pub struct FileInfo { pub name: String, pub desc: Option, @@ -21,7 +21,7 @@ pub struct FileInfo { pub time: Option, } -#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Default, Clone, PartialEq)] pub struct Author { pub name: Option, pub email: Option, diff --git a/gpx-rs/engine/src/core/gpx/track.rs b/gpx-rs/engine/src/core/gpx/track.rs index 9a3c06358..fd3d4a4b0 100644 --- a/gpx-rs/engine/src/core/gpx/track.rs +++ b/gpx-rs/engine/src/core/gpx/track.rs @@ -11,7 +11,7 @@ pub struct Track { pub trkseg: Vec, } -#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Default, Clone, PartialEq)] pub struct TrackInfo { pub name: Option, pub cmt: Option, diff --git a/gpx-rs/engine/src/core/gpx/trackpoint.rs b/gpx-rs/engine/src/core/gpx/trackpoint.rs index 7b859d1f3..625acb652 100644 --- a/gpx-rs/engine/src/core/gpx/trackpoint.rs +++ b/gpx-rs/engine/src/core/gpx/trackpoint.rs @@ -1,6 +1,6 @@ use crate::LngLat; -#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Default, Clone)] pub struct Trackpoint { pub coordinates: LngLat, pub ele: f64, diff --git a/gpx-rs/engine/src/core/gpx/waypoint.rs b/gpx-rs/engine/src/core/gpx/waypoint.rs index b3cda01f9..ea7d67b32 100644 --- a/gpx-rs/engine/src/core/gpx/waypoint.rs +++ b/gpx-rs/engine/src/core/gpx/waypoint.rs @@ -4,7 +4,7 @@ use super::common::uuid_id; uuid_id!(WaypointId); -#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Default, Clone)] pub struct Waypoint { pub id: WaypointId, pub coordinates: LngLat, diff --git a/gpx-rs/engine/src/core/statistics/statistics.rs b/gpx-rs/engine/src/core/statistics/compute.rs similarity index 96% rename from gpx-rs/engine/src/core/statistics/statistics.rs rename to gpx-rs/engine/src/core/statistics/compute.rs index a426afead..f81dbf8c1 100644 --- a/gpx-rs/engine/src/core/statistics/statistics.rs +++ b/gpx-rs/engine/src/core/statistics/compute.rs @@ -69,18 +69,18 @@ impl Statistics { self.global.total_distance += dist; - if let Some(time) = time { - let speed = speed(dist, time); - if (0.5..=1500.0).contains(&speed) { - self.global.moving_distance = self - .global - .moving_distance - .map_or(Some(dist), |d| Some(d + dist)); - self.global.moving_time = self - .global - .moving_time - .map_or(Some(time), |t| Some(t + time)); - } + let moving = time + .zip(time.and_then(|time| speed(dist, time))) + .filter(|(_, speed)| (0.5..=1500.0).contains(speed)); + if let Some((time, _)) = moving { + self.global.moving_distance = self + .global + .moving_distance + .map_or(Some(dist), |d| Some(d + dist)); + self.global.moving_time = self + .global + .moving_time + .map_or(Some(time), |t| Some(t + time)); } } @@ -119,8 +119,7 @@ impl Statistics { }; let (left, right) = (timed(left), timed(right)); self.local[i.flat].speed = time_diff(trkseg[right].time, trkseg[left].time) - .filter(|time| *time > 0) - .map(|time| { + .and_then(|time| { speed( self.local[right.flat].total_distance - self.local[left.flat].total_distance, @@ -286,16 +285,14 @@ impl GlobalStatistics { /// Average speed over the total time, unknown if that is not a positive duration (for /// example a range with a single timestamp, or timestamps going backwards). pub fn total_speed(&self) -> Option { - self.total_time - .filter(|t| *t > 0) - .map(|t| speed(self.total_distance, t)) + self.total_time.and_then(|t| speed(self.total_distance, t)) } /// Average speed over the moving time, unknown if there is no moving time. pub fn moving_speed(&self) -> Option { self.moving_distance - .zip(self.moving_time.filter(|t| *t > 0)) - .map(|(d, t)| speed(d, t)) + .zip(self.moving_time) + .and_then(|(d, t)| speed(d, t)) } pub fn merge(&mut self, other: &GlobalStatistics) { diff --git a/gpx-rs/engine/src/core/statistics/mod.rs b/gpx-rs/engine/src/core/statistics/mod.rs index 41e187567..92903534c 100644 --- a/gpx-rs/engine/src/core/statistics/mod.rs +++ b/gpx-rs/engine/src/core/statistics/mod.rs @@ -1,5 +1,5 @@ -mod statistics; +mod compute; mod utils; -pub use statistics::*; +pub use compute::*; pub use utils::*; diff --git a/gpx-rs/engine/src/core/utils.rs b/gpx-rs/engine/src/core/utils.rs index 5ed9e3044..eae29cf0b 100644 --- a/gpx-rs/engine/src/core/utils.rs +++ b/gpx-rs/engine/src/core/utils.rs @@ -22,9 +22,10 @@ pub fn time_diff(a: Option, b: Option) -> Option { Some(a? - b?) } -/// Computes the speed for a given distance in kilometers and a time in milliseconds -pub fn speed(distance: f64, time: i64) -> f64 { - distance / (time as f64 / 3_600_000.0) +/// Computes the speed in km/h for a given distance in kilometers and a time in milliseconds. +/// Unknown if the time is not a positive duration. +pub fn speed(distance: f64, time: i64) -> Option { + (time > 0).then(|| distance / (time as f64 / 3_600_000.0)) } pub fn slope(ele: f64, distance: f64) -> f64 { @@ -125,7 +126,7 @@ mod tests { assert_eq!(time_diff(None, Some(1)), None); assert_eq!(time_diff(Some(1), None), None); // 1 km in one hour - assert!((speed(1.0, 3_600_000) - 1.0).abs() < 1e-12); + assert!((speed(1.0, 3_600_000).unwrap() - 1.0).abs() < 1e-12); } #[test] @@ -154,9 +155,13 @@ mod tests { #[test] fn test_speed() { // 1 km in 1 h - assert!((speed(1.0, 3_600_000) - 1.0).abs() < 1e-12); + assert!((speed(1.0, 3_600_000).unwrap() - 1.0).abs() < 1e-12); // 10 km in 30 min - assert!((speed(10.0, 1_800_000) - 20.0).abs() < 1e-12); + assert!((speed(10.0, 1_800_000).unwrap() - 20.0).abs() < 1e-12); + // no duration, no speed + assert_eq!(speed(1.0, 0), None); + assert_eq!(speed(0.0, 0), None); + assert_eq!(speed(1.0, -1000), None); } #[test] diff --git a/gpx-rs/engine/src/engine/command/command.rs b/gpx-rs/engine/src/engine/command/command.rs deleted file mode 100644 index c2ea204ac..000000000 --- a/gpx-rs/engine/src/engine/command/command.rs +++ /dev/null @@ -1,81 +0,0 @@ -use crate::{Apply, CommandError, State}; -use crate::{ - ChangeLoopStart, Clean, Crop, Delete, DeleteAll, DeleteWaypoint, Duplicate, EditWaypoint, - Elevation, Extract, InsertAnchor, Load, LoadFiles, Merge, Metadata, Move, MoveWaypoint, New, - NewTrack, NewTrackSegment, NewWaypoint, Paste, Reduce, Reverse, RoundTrip, Route, Split, Style, - Time, -}; - -/// A user action that edits the files. Each variant wraps the command's own struct, whose -/// effect is implemented in its own file (see `Apply`). -/// -/// Bulk data (coordinates, file bytes) is borrowed straight from the wasm-bindgen argument -/// buffers, so a command is built without copying; the engine only copies what it keeps. -#[derive(Debug)] -pub enum Command<'a> { - New(New<'a>), - Load(Load<'a>), - LoadFiles(LoadFiles<'a>), - Duplicate(Duplicate), - Delete(Delete), - Paste(Paste), - Move(Move), - DeleteAll(DeleteAll), - DeleteWaypoint(DeleteWaypoint), - Metadata(Metadata<'a>), - Style(Style<'a>), - NewTrack(NewTrack), - NewTrackSegment(NewTrackSegment), - Reverse(Reverse), - RoundTrip(RoundTrip), - Route(Route<'a>), - NewWaypoint(NewWaypoint<'a>), - EditWaypoint(EditWaypoint<'a>), - MoveWaypoint(MoveWaypoint), - Crop(Crop), - Reduce(Reduce), - Split(Split), - Time(Time), - Merge(Merge), - Extract(Extract), - InsertAnchor(InsertAnchor), - Elevation(Elevation<'a>), - Clean(Clean), - ChangeLoopStart(ChangeLoopStart), -} - -impl Apply for Command<'_> { - fn apply(self, state: &mut State) -> Result<(), CommandError> { - match self { - Command::New(c) => c.apply(state), - Command::Load(c) => c.apply(state), - Command::LoadFiles(c) => c.apply(state), - Command::Duplicate(c) => c.apply(state), - Command::Delete(c) => c.apply(state), - Command::Paste(c) => c.apply(state), - Command::Move(c) => c.apply(state), - Command::DeleteAll(c) => c.apply(state), - Command::DeleteWaypoint(c) => c.apply(state), - Command::Metadata(c) => c.apply(state), - Command::Style(c) => c.apply(state), - Command::NewTrack(c) => c.apply(state), - Command::NewTrackSegment(c) => c.apply(state), - Command::Reverse(c) => c.apply(state), - Command::RoundTrip(c) => c.apply(state), - Command::Route(c) => c.apply(state), - Command::NewWaypoint(c) => c.apply(state), - Command::EditWaypoint(c) => c.apply(state), - Command::MoveWaypoint(c) => c.apply(state), - Command::Crop(c) => c.apply(state), - Command::Reduce(c) => c.apply(state), - Command::Split(c) => c.apply(state), - Command::Time(c) => c.apply(state), - Command::Merge(c) => c.apply(state), - Command::Extract(c) => c.apply(state), - Command::InsertAnchor(c) => c.apply(state), - Command::Elevation(c) => c.apply(state), - Command::Clean(c) => c.apply(state), - Command::ChangeLoopStart(c) => c.apply(state), - } - } -} diff --git a/gpx-rs/engine/src/engine/command/mod.rs b/gpx-rs/engine/src/engine/command/mod.rs index 1356ca835..e71a95c45 100644 --- a/gpx-rs/engine/src/engine/command/mod.rs +++ b/gpx-rs/engine/src/engine/command/mod.rs @@ -1,5 +1,4 @@ mod apply; -mod command; mod edit; mod file; #[cfg(test)] @@ -8,8 +7,83 @@ mod pattern; mod tools; pub use apply::*; -pub use command::*; pub use edit::*; pub use file::*; pub use pattern::*; pub use tools::*; + +use crate::State; + +/// A user action that edits the files. Each variant wraps the command's own struct, whose +/// effect is implemented in its own file (see `Apply`). +/// +/// Bulk data (coordinates, file bytes) is borrowed straight from the wasm-bindgen argument +/// buffers, so a command is built without copying; the engine only copies what it keeps. +#[derive(Debug)] +pub enum Command<'a> { + New(New<'a>), + Load(Load<'a>), + LoadFiles(LoadFiles<'a>), + Duplicate(Duplicate), + Delete(Delete), + Paste(Paste), + Move(Move), + DeleteAll(DeleteAll), + DeleteWaypoint(DeleteWaypoint), + Metadata(Metadata<'a>), + Style(Style<'a>), + NewTrack(NewTrack), + NewTrackSegment(NewTrackSegment), + Reverse(Reverse), + RoundTrip(RoundTrip), + Route(Route<'a>), + NewWaypoint(NewWaypoint<'a>), + EditWaypoint(EditWaypoint<'a>), + MoveWaypoint(MoveWaypoint), + Crop(Crop), + Reduce(Reduce), + Split(Split), + Time(Time), + Merge(Merge), + Extract(Extract), + InsertAnchor(InsertAnchor), + Elevation(Elevation<'a>), + Clean(Clean), + ChangeLoopStart(ChangeLoopStart), +} + +impl Apply for Command<'_> { + fn apply(self, state: &mut State) -> Result<(), CommandError> { + match self { + Command::New(c) => c.apply(state), + Command::Load(c) => c.apply(state), + Command::LoadFiles(c) => c.apply(state), + Command::Duplicate(c) => c.apply(state), + Command::Delete(c) => c.apply(state), + Command::Paste(c) => c.apply(state), + Command::Move(c) => c.apply(state), + Command::DeleteAll(c) => c.apply(state), + Command::DeleteWaypoint(c) => c.apply(state), + Command::Metadata(c) => c.apply(state), + Command::Style(c) => c.apply(state), + Command::NewTrack(c) => c.apply(state), + Command::NewTrackSegment(c) => c.apply(state), + Command::Reverse(c) => c.apply(state), + Command::RoundTrip(c) => c.apply(state), + Command::Route(c) => c.apply(state), + Command::NewWaypoint(c) => c.apply(state), + Command::EditWaypoint(c) => c.apply(state), + Command::MoveWaypoint(c) => c.apply(state), + Command::Crop(c) => c.apply(state), + Command::Reduce(c) => c.apply(state), + Command::Split(c) => c.apply(state), + Command::Time(c) => c.apply(state), + Command::Merge(c) => c.apply(state), + Command::Extract(c) => c.apply(state), + Command::InsertAnchor(c) => c.apply(state), + Command::Elevation(c) => c.apply(state), + Command::Clean(c) => c.apply(state), + Command::ChangeLoopStart(c) => c.apply(state), + } + } +} diff --git a/gpx-rs/engine/src/engine/command/tools/time.rs b/gpx-rs/engine/src/engine/command/tools/time.rs index 1bf4602a0..4dd6340d7 100644 --- a/gpx-rs/engine/src/engine/command/tools/time.rs +++ b/gpx-rs/engine/src/engine/command/tools/time.rs @@ -1,7 +1,6 @@ use crate::{ - Apply, CommandError, SegmentLocation, State, Statistics, TrackSegment, Trackpoint, - artificial_weights, shifted_and_compressed, update_segments, with_artificial_timestamps, - with_timestamps, + Apply, CommandError, State, TrackSegment, artificial_segment_times, changed_segment_times, + update_segments, }; /// How the timestamps of the selection are set. @@ -46,80 +45,22 @@ impl Apply for Time { let locations = state .selection .segment_locations(state.files, &state.order.0); - let segment_at = |location: &SegmentLocation| -> &TrackSegment { - &state.files[&location.file_id].trk[location.trk].trkseg[location.seg] - }; - if locations + let segments: Vec<&TrackSegment> = locations .iter() - .all(|location| segment_at(location).is_empty()) - { + .map(|l| &state.files[&l.file_id].trk[l.trk].trkseg[l.seg]) + .collect(); + if segments.iter().all(|segment| segment.is_empty()) { return Err(CommandError::NothingToDo); } - // the new timestamps of each segment - let mut times: Vec>> = Vec::with_capacity(locations.len()); - match self.kind { + let times = match self.kind { TimeKind::Change { speed, ratio } => { - let mut last: Option = None; - for location in &locations { - let points: Vec = segment_at(location).iter().cloned().collect(); - let Some(first) = points.first() else { - times.push(vec![]); - continue; - }; - let start = last.clone().unwrap_or_else(|| { - let mut start = first.clone(); - start.time = Some(self.start_time); - start - }); - let points = if first.time.is_none() { - with_timestamps(points, Some(speed), Some(&start), Some(self.start_time)) - } else { - shifted_and_compressed(points, Some(speed), ratio, &start) - }; - last = points.last().cloned(); - times.push(points.iter().map(|point| point.time).collect()); - } + changed_segment_times(&segments, self.start_time, speed, ratio) } TimeKind::Artificial { total_time } => { - let mut weights: Vec> = locations - .iter() - .map(|location| { - let segment = segment_at(location); - let points: Vec = segment.iter().cloned().collect(); - let stats = Statistics::compute(segment); - let slopes: Vec = stats.local.iter().map(|l| l.slope).collect(); - artificial_weights(&points, &slopes) - }) - .collect(); - let mut total: f64 = weights.iter().flatten().sum(); - if total.is_nan() || total <= 0.0 { - // no distance to share the time by: every interval takes as long - for weights in &mut weights { - weights.iter_mut().for_each(|w| *w = 1.0); - } - total = weights.iter().map(Vec::len).sum::() as f64; - } - let ms_per_weight = if total > 0.0 { - total_time * 1000.0 / total - } else { - 0.0 - }; - - let mut start = self.start_time; - for (location, weights) in locations.iter().zip(&weights) { - let points: Vec = segment_at(location).iter().cloned().collect(); - if points.is_empty() { - times.push(vec![]); - continue; - } - let points = with_artificial_timestamps(points, weights, ms_per_weight, start); - // the next segment goes on from the end of this one - start = points.last().and_then(|point| point.time).unwrap_or(start); - times.push(points.iter().map(|point| point.time).collect()); - } + artificial_segment_times(&segments, self.start_time, total_time) } - } + }; update_segments(state, &locations, |i, segment| { let times = ×[i]; diff --git a/gpx-rs/engine/src/engine/engine.rs b/gpx-rs/engine/src/engine/engine.rs deleted file mode 100644 index 80661f4d1..000000000 --- a/gpx-rs/engine/src/engine/engine.rs +++ /dev/null @@ -1,1557 +0,0 @@ -use crate::{ - Action, Apply, Clipboard, Command, CoordinatesCache, Diff, ExportOptions, FileId, FileOrder, - FileStructure, FileStructureCache, GlobalStatistics, Restored, RoutingBuffer, SelectMode, - Selection, Snapshot, Stack, StackEntry, State, StatisticsBuffer, StatisticsCache, - TrackSegmentId, Trackpoint, TrackpointCategories, Waypoint, WaypointId, reduction_distances, - write, -}; - -#[derive(Debug, Default)] -pub struct Engine { - stack: Stack, - selection: Selection, - order: FileOrder, - statistics_cache: StatisticsCache, - coordinates_cache: CoordinatesCache, - structure_cache: FileStructureCache, - diff: Option, - order_changed: bool, - selection_changed: bool, - statistics_buffer: StatisticsBuffer, - routing_buffer: RoutingBuffer, - categories: TrackpointCategories, - clipboard: Option, - clipboard_changed: bool, -} - -impl Engine { - pub fn statistics(&self) -> &StatisticsBuffer { - &self.statistics_buffer - } - - /// The anchors of the routing tool among the trackpoints of the selection. - pub fn routing(&self) -> &RoutingBuffer { - &self.routing_buffer - } - - /// The names of the surfaces and highways that the trackpoints refer to by code. - pub fn categories(&self) -> &TrackpointCategories { - &self.categories - } - - /// Files in display order. - pub fn order(&self) -> &[FileId] { - &self.order.0 - } - - /// Name, tracks, segments and waypoints (with their ids) of a file. - pub fn file_structure(&self, id: &FileId) -> Option<&FileStructure> { - self.structure_cache.get(id) - } - - /// Global statistics (distance, time, elevation, bounds...) of a file. - pub fn file_statistics(&self, id: &FileId) -> Option { - let file = self.stack.current()?.get(id)?; - Some(self.statistics_cache.file_global(file)) - } - - /// What was copied or cut and is waiting to be pasted. - pub fn clipboard(&self) -> Option<&Clipboard> { - self.clipboard.as_ref() - } - - /// Whether the clipboard can be pasted with the current selection. - pub fn can_paste(&self) -> bool { - self.clipboard - .as_ref() - .is_some_and(|clipboard| clipboard.can_paste(&self.selection)) - } - - /// Whether the last action changed the clipboard. - pub fn clipboard_changed(&self) -> bool { - self.clipboard_changed - } - - /// A waypoint of a file, with all its data. `None` if it does not exist. - pub fn waypoint(&self, file_id: &FileId, id: &WaypointId) -> Option<&Waypoint> { - self.stack - .current()? - .get(file_id)? - .wpt - .iter() - .find(|wpt| wpt.id == *id) - } - - /// A trackpoint of a segment of a file. `None` if it does not exist. - pub fn trackpoint( - &self, - file_id: &FileId, - segment_id: &TrackSegmentId, - index: usize, - ) -> Option<&Trackpoint> { - let segment = self - .stack - .current()? - .get(file_id)? - .trk - .iter() - .flat_map(|trk| &trk.trkseg) - .find(|seg| seg.id == *segment_id)?; - (index < segment.len()).then(|| &segment[index]) - } - - /// For each trackpoint of the selection (numbered as in the statistics), the tolerance up to - /// which `Reduce` keeps it, see [`reduction_distances`]. - pub fn reduction_distances(&self) -> Vec { - let Some(files) = self.stack.current() else { - return vec![]; - }; - self.selection - .segment_locations(files, &self.order.0) - .iter() - .flat_map(|location| { - reduction_distances( - &files[&location.file_id].trk[location.trk].trkseg[location.seg], - ) - }) - .collect() - } - - /// What there is to keep of the engine, see [`crate::Persister`]. - pub fn snapshot(&self) -> Snapshot { - Snapshot { - files: self.stack.current().cloned().unwrap_or_default(), - order: self.order.0.clone(), - categories: self.categories.clone(), - } - } - - /// Puts files in an engine that has none, typically the ones read from the storage. They are - /// the state to start from: they cannot be undone. The files that were added are reported as - /// the last diff, and the order as changed. - pub fn restore(&mut self, restored: Restored) { - let files: StackEntry = restored - .files - .into_iter() - .map(|file| (file.id, file)) - .collect(); - self.categories = restored.categories; - self.order = FileOrder(restored.order); - self.selection = Selection::Empty; - self.clipboard = None; - self.diff = Some(Diff { - added: files.keys().copied().collect(), - ..Default::default() - }); - self.stack = Stack::restored(files); - self.refresh(); - self.sort_diff(); - self.order_changed = true; - self.selection_changed = true; - self.clipboard_changed = true; - } - - /// A file as GPX (UTF-8), `None` if it does not exist. - pub fn export(&self, id: &FileId, options: ExportOptions) -> Option> { - let file = self.stack.current()?.get(id)?; - Some(write(file, &self.categories, options)) - } - - /// The data that the given files have, which the options of `export` can leave out. - pub fn exportable_data(&self, ids: &[FileId]) -> ExportOptions { - let Some(files) = self.stack.current() else { - return ExportOptions::NONE; - }; - ids.iter() - .filter_map(|id| files.get(id)) - .fold(ExportOptions::NONE, |data, file| { - data.union(file.exportable_data()) - }) - } - - /// Whether there is something to undo. - pub fn can_undo(&self) -> bool { - self.stack.can_undo() - } - - /// Whether there is something to redo. - pub fn can_redo(&self) -> bool { - self.stack.can_redo() - } - - /// Which files the last action added, removed or modified. - pub fn last_diff(&self) -> Option<&Diff> { - self.diff.as_ref() - } - - /// Whether the last action changed the order of the files. - pub fn order_changed(&self) -> bool { - self.order_changed - } - - /// Currently selected elements. - pub fn selection(&self) -> &Selection { - &self.selection - } - - /// Whether the last action changed the selection. - pub fn selection_changed(&self) -> bool { - self.selection_changed - } - - /// Coordinates (`[lng, lat, ...]`) of the trackpoints of a segment. - pub fn segment_coordinates(&self, id: &TrackSegmentId) -> &[f64] { - self.coordinates_cache.segment(id) - } - - /// Coordinates (`[lng, lat, ...]`) of the waypoints of a file. - pub fn waypoint_coordinates(&self, id: &FileId) -> &[f64] { - self.coordinates_cache.waypoints(id) - } - - /// Single entry point for every action coming from the frontend. Returns whether anything - /// changed. - pub fn execute(&mut self, action: Action) -> bool { - let selection_before = self.selection.clone(); - let order_before = self.order.0.clone(); - let clipboard_before = self.clipboard.clone(); - // the files change iff there is a diff, but selection and order actions have none - self.diff = match action { - Action::Edit(command) => self.edit(command), - Action::Undo => self.stack.undo(), - Action::Redo => self.stack.redo(), - Action::Select { - mut selection, - mode, - } => { - match self.stack.current() { - Some(files) => selection.retain_existing(files), - None => selection = Selection::Empty, - } - match mode { - SelectMode::Replace => self.selection = selection, - // nothing to add or toggle: keep the selection - _ if selection == Selection::Empty => {} - SelectMode::Add => self.selection.extend(selection), - SelectMode::Toggle => self.selection.toggle(selection), - } - None - } - Action::SelectAll => { - if let Some(files) = self.stack.current() - && let Some(all) = self.selection.all_at_level(files, &self.order.0) - { - self.selection = all; - } - None - } - Action::ArrowSelect { down, add } => { - if let Some(files) = self.stack.current() - && let Some(next) = self.selection.neighbour(files, &self.order.0, down) - { - if add { - self.selection.extend(next); - } else { - self.selection = next; - } - } - None - } - Action::Copy => { - self.copy(false); - None - } - Action::Cut => { - self.copy(true); - None - } - Action::Reorder { file_ids, index } => { - self.order.move_files(&file_ids, index); - None - } - }; - let changed = self.diff.is_some() - || self.selection != selection_before - || self.order.0 != order_before - || self.clipboard != clipboard_before; - if changed { - self.refresh(); - self.sort_diff(); - } - // after the refresh, which also syncs the order and selection with the files (undo, redo) - self.order_changed = self.order.0 != order_before; - self.selection_changed = self.selection != selection_before; - self.clipboard_changed = self.clipboard != clipboard_before; - changed - } - - /// Puts the selection in the clipboard, if there is one. - fn copy(&mut self, cut: bool) { - if let Some(files) = self.stack.current() - && let Some(clipboard) = Clipboard::new(&self.selection, files, &self.order.0, cut) - { - self.clipboard = Some(clipboard); - } - } - - fn edit(&mut self, command: Command) -> Option { - self.stack.create_and_push_next(|files| { - let mut state = State { - categories: &mut self.categories, - files, - selection: &mut self.selection, - order: &mut self.order, - clipboard: &mut self.clipboard, - }; - command.apply(&mut state).map_err(|err| err.to_string()) - }) - } - - /// Puts the files of the last diff in the order of the files (the stack does not know it), so - /// that whatever is done with them one after the other, like giving them colors, follows it. - fn sort_diff(&mut self) { - let position = |id: &FileId| self.order.0.iter().position(|other| other == id); - if let Some(diff) = &mut self.diff { - diff.added.sort_by_key(position); - diff.modified.sort_by_key(position); - } - } - - /// Brings everything derived from the files back in line with the current stack entry. - fn refresh(&mut self) { - let current = self.stack.current(); - match current { - Some(files) => { - // files coming back after an undo/redo are added at the end of the order - self.order.sync(files); - self.selection.retain_existing(files); - } - None => { - self.order.0.clear(); - self.selection = Selection::Empty; - } - } - self.statistics_cache.update(current); - self.coordinates_cache.update(current); - self.structure_cache.update(current, self.diff.as_ref()); - self.routing_buffer - .update(current, &self.selection, &self.order.0); - self.statistics_buffer - .update( - &self - .statistics_cache - .selected(current, &self.selection, &self.order.0), - ); - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashSet; - - use crate::{ - ClipboardIds, Delete, DeleteAll, Load, Metadata, New, NewTrack, Paste, Style, TrackId, - TrackSegmentId, - }; - - use super::*; - - fn expand(intervals: &crate::Intervals, len: usize) -> Vec { - let mut values = Vec::new(); - for (i, value) in intervals.values.iter().enumerate() { - let end = intervals.starts.get(i + 1).map_or(len, |end| *end as usize); - values.resize(end, *value); - } - values - } - - fn edit(engine: &mut Engine, command: Command) -> bool { - engine.execute(Action::Edit(command)) - } - - fn new(engine: &mut Engine, name: &str) -> bool { - edit( - engine, - Command::New(New { - name, - trackpoint: None, - }), - ) - } - - fn load(engine: &mut Engine, path: &str) -> bool { - let data = std::fs::read(path).unwrap(); - edit( - engine, - Command::Load(Load { - data: &data, - name: "file", - }), - ) - } - - fn selected(engine: &Engine) -> Vec { - match &engine.selection { - Selection::File { file_ids } => file_ids.iter().copied().collect(), - _ => vec![], - } - } - - #[test] - fn test_load_file() { - let mut engine = Engine::default(); - assert!(load(&mut engine, "data/simple.gpx")); - assert_eq!(engine.statistics_buffer.total_distance.len(), 80); - } - - #[test] - fn test_new_file_has_no_statistics() { - let mut engine = Engine::default(); - assert!(new(&mut engine, "empty")); - assert_eq!(engine.stack.current().unwrap().len(), 1); - assert_eq!(engine.order.0.len(), 1); - assert!(engine.statistics().total_distance.is_empty()); - } - - #[test] - fn test_statistics_follow_selection() { - let mut engine = Engine::default(); - assert!(load(&mut engine, "data/simple.gpx")); - let n = engine.statistics().total_distance.len(); - assert!(n > 0); - - // a newly created (empty) file becomes the selection - assert!(new(&mut engine, "empty")); - assert!(engine.statistics().total_distance.is_empty()); - assert_eq!(engine.stack.current().unwrap().len(), 2); - - // loading a file selects it again - assert!(load(&mut engine, "data/simple.gpx")); - assert_eq!(engine.statistics().total_distance.len(), n); - } - - #[test] - fn test_invalid_load_is_rejected() { - let mut engine = Engine::default(); - assert!(!edit( - &mut engine, - Command::Load(Load { - data: b"", - name: "file", - }) - )); - assert!(engine.stack.current().is_none()); - assert!(!engine.stack.can_undo()); - assert!(engine.order.0.is_empty()); - } - - #[test] - fn test_command_with_nothing_to_do_is_rejected() { - let mut engine = Engine::default(); - new(&mut engine, "file"); - assert!(!edit(&mut engine, Command::Reverse(crate::Reverse))); - assert!(!engine.stack.can_redo()); - assert_eq!(engine.stack.current().unwrap().len(), 1); - } - - #[test] - fn test_metadata_renames_selected_file() { - let mut engine = Engine::default(); - new(&mut engine, "before"); - assert!(edit( - &mut engine, - Command::Metadata(Metadata { - name: "after", - desc: "about", - }) - )); - let file = engine.stack.current().unwrap().values().next().unwrap(); - assert_eq!(file.info.name, "after"); - assert_eq!(file.info.desc.as_deref(), Some("about")); - } - - #[test] - fn test_metadata_without_selection_does_not_modify_files() { - let mut engine = Engine::default(); - new(&mut engine, "file"); - engine.selection = Selection::Empty; - edit( - &mut engine, - Command::Metadata(Metadata { - name: "renamed", - desc: "", - }), - ); - let file = engine.stack.current().unwrap().values().next().unwrap(); - assert_eq!(file.info.name, "file"); - } - - #[test] - fn test_edit_changes_statistics_when_segments_change() { - let mut engine = Engine::default(); - load(&mut engine, "data/simple.gpx"); - let before = engine - .statistics_cache - .get(engine.stack.current(), &engine.selection)[0] as *const _; - edit(&mut engine, Command::NewTrack(NewTrack)); - edit( - &mut engine, - Command::Style(Style { - color: Some("ff0000"), - opacity: None, - width: None, - }), - ); - // styling does not touch the segments: their statistics are reused - let after = engine - .statistics_cache - .get(engine.stack.current(), &engine.selection)[0] as *const _; - assert!(std::ptr::eq(before, after)); - } - - #[test] - fn test_undo_redo_keep_selection_and_order_consistent() { - let mut engine = Engine::default(); - new(&mut engine, "a"); - let a = selected(&engine)[0]; - new(&mut engine, "b"); - let b = selected(&engine)[0]; - assert_eq!(engine.order.0, vec![a, b]); - - assert!(engine.execute(Action::Undo)); - assert_eq!(engine.order.0, vec![a]); - assert!(selected(&engine).is_empty()); - - assert!(engine.execute(Action::Redo)); - assert_eq!(engine.order.0, vec![a, b]); - - assert!(engine.execute(Action::Undo)); - assert!(engine.execute(Action::Undo)); - assert!(engine.order.0.is_empty()); - assert!(!engine.execute(Action::Undo)); - assert!(engine.stack.current().is_none()); - } - - #[test] - fn test_reorder_is_not_undoable_and_returning_files_go_last() { - let mut engine = Engine::default(); - new(&mut engine, "a"); - let a = engine.order.0[0]; - new(&mut engine, "b"); - let b = engine.order.0[1]; - new(&mut engine, "c"); - let c = engine.order.0[2]; - - assert!(engine.execute(Action::Reorder { - file_ids: vec![c], - index: 0 - })); - assert_eq!(engine.order.0, vec![c, a, b]); - assert!(!engine.execute(Action::Reorder { - file_ids: vec![c], - index: 0 - })); - - // undoing the creation of c does not undo the reorder - assert!(engine.execute(Action::Undo)); - assert_eq!(engine.order.0, vec![a, b]); - // c comes back at the end - assert!(engine.execute(Action::Redo)); - assert_eq!(engine.order.0, vec![a, b, c]); - } - - #[test] - fn test_structures_and_coordinates_follow_actions() { - let mut engine = Engine::default(); - assert!(engine.order().is_empty()); - load(&mut engine, "data/simple.gpx"); - let loaded_id = engine.order()[0]; - let diff = engine.last_diff().unwrap(); - assert_eq!(diff.added, vec![loaded_id]); - assert!(engine.order_changed()); - - new(&mut engine, "empty"); - let empty_id = engine.order()[1]; - // only the new file is reported, the other one is not recomputed - let diff = engine.last_diff().unwrap(); - assert_eq!(diff.added, vec![empty_id]); - assert!(diff.modified.is_empty() && diff.removed.is_empty()); - assert_eq!(engine.file_structure(&empty_id).unwrap().name, "empty"); - - let structure = engine.file_structure(&loaded_id).unwrap(); - let seg = &structure.tracks[0].segments[0]; - let (seg_id, len) = (seg.id, seg.len); - assert_eq!(engine.segment_coordinates(&seg_id).len(), len * 2); - assert!(engine.waypoint_coordinates(&loaded_id).is_empty()); - - // reordering only changes the order - assert!(engine.execute(Action::Reorder { - file_ids: vec![loaded_id], - index: 1 - })); - assert_eq!(engine.order(), [empty_id, loaded_id]); - assert!(engine.last_diff().is_none()); - assert!(engine.order_changed()); - - // an action that changes nothing reports nothing - assert!(!engine.execute(Action::Reorder { - file_ids: vec![loaded_id], - index: 1 - })); - assert!(engine.last_diff().is_none()); - assert!(!engine.order_changed()); - - // undo removes the empty file, redoing the load removes the other one too - assert!(engine.execute(Action::Undo)); - let diff = engine.last_diff().unwrap(); - assert_eq!(diff.removed, vec![empty_id]); - assert!(engine.order_changed()); - assert!(engine.file_structure(&empty_id).is_none()); - assert!(engine.execute(Action::Undo)); - assert!(engine.order().is_empty()); - assert!(engine.file_structure(&loaded_id).is_none()); - assert!(engine.segment_coordinates(&seg_id).is_empty()); - } - - #[test] - fn test_selection_changed_flag() { - let mut engine = Engine::default(); - new(&mut engine, "a"); - let a = engine.order()[0]; - // a new file is selected by the edit - assert!(engine.selection_changed()); - assert!(!select_files(&mut engine, &[a])); - assert!(!engine.selection_changed()); - assert!(select_files(&mut engine, &[])); - assert!(engine.selection_changed()); - // undoing drops the selected file from the selection - select_files(&mut engine, &[a]); - assert!(engine.execute(Action::Undo)); - assert!(engine.selection_changed()); - assert_eq!(engine.selection(), &Selection::Empty); - } - - #[test] - fn test_undo_updates_statistics() { - let mut engine = Engine::default(); - load(&mut engine, "data/simple.gpx"); - assert!(!engine.statistics().total_distance.is_empty()); - engine.execute(Action::Undo); - assert!(engine.statistics().total_distance.is_empty()); - engine.execute(Action::Redo); - select_all(&mut engine); - assert_eq!(engine.statistics().total_distance.len(), 80); - } - - #[test] - fn test_selection_actions() { - let mut engine = Engine::default(); - new(&mut engine, "a"); - let a = selected(&engine)[0]; - new(&mut engine, "b"); - let b = selected(&engine)[0]; - assert_eq!(selected(&engine), vec![b]); - - assert!(select_files(&mut engine, &[a])); - assert_eq!(selected(&engine), vec![a]); - - assert!(select_elements(&mut engine, files(&[b]), SelectMode::Add)); - assert_eq!(selected(&engine).len(), 2); - - select_files(&mut engine, &[]); - assert!(select_all(&mut engine)); - assert_eq!(selected(&engine).len(), 2); - } - - #[test] - fn test_selection_does_not_create_undo_steps() { - let mut engine = Engine::default(); - new(&mut engine, "a"); - select_all(&mut engine); - engine.execute(Action::Undo); - assert!(engine.stack.current().is_none()); - } - - fn select_elements(engine: &mut Engine, selection: Selection, mode: SelectMode) -> bool { - engine.execute(Action::Select { selection, mode }) - } - - fn files(ids: &[FileId]) -> Selection { - Selection::File { - file_ids: ids.iter().copied().collect(), - } - } - - fn select_files(engine: &mut Engine, ids: &[FileId]) -> bool { - select_elements(engine, files(ids), SelectMode::Replace) - } - - fn select_all(engine: &mut Engine) -> bool { - let ids = engine.order().to_vec(); - select_files(engine, &ids) - } - - #[test] - fn test_select_elements() { - let mut engine = Engine::default(); - load(&mut engine, "data/with_tracks_and_segments.gpx"); - let file = engine - .stack - .current() - .unwrap() - .values() - .next() - .unwrap() - .clone(); - assert!(file.trk.len() >= 2 && file.trk[0].trkseg.len() >= 2); - let (t0, t1) = (file.trk[0].id, file.trk[1].id); - let (s0, s1) = (file.trk[0].trkseg[0].id, file.trk[0].trkseg[1].id); - - // tracks, then more tracks - let tracks = |ids: &[TrackId]| Selection::Track { - file_id: file.id, - trk_ids: ids.iter().copied().collect(), - }; - assert!(select_elements( - &mut engine, - tracks(&[t0]), - SelectMode::Replace - )); - assert_eq!(engine.selection(), &tracks(&[t0])); - assert!(select_elements(&mut engine, tracks(&[t1]), SelectMode::Add)); - assert_eq!(engine.selection(), &tracks(&[t0, t1])); - - // segments of a track, added only within the same track - let segments = |trk_id, ids: &[TrackSegmentId]| Selection::TrackSegment { - file_id: file.id, - trk_id, - trkseg_ids: ids.iter().copied().collect(), - }; - assert!(select_elements( - &mut engine, - segments(t0, &[s0]), - SelectMode::Add - )); - assert_eq!(engine.selection(), &segments(t0, &[s0])); - assert!(select_elements( - &mut engine, - segments(t0, &[s1]), - SelectMode::Add - )); - assert_eq!(engine.selection(), &segments(t0, &[s0, s1])); - // the statistics follow the selected segments - assert_eq!( - engine.statistics().total_distance.len(), - file.trk[0].trkseg[0].len() + file.trk[0].trkseg[1].len() - ); - - // the waypoints node - let node = Selection::Waypoints { file_id: file.id }; - assert!(select_elements( - &mut engine, - node.clone(), - SelectMode::Replace - )); - assert!(!select_elements(&mut engine, node.clone(), SelectMode::Add)); - assert_eq!(engine.selection(), &node); - assert!(!engine.selection_changed()); - - // unknown elements and files are ignored when adding - let unknown = tracks(&[TrackId::default()]); - assert!(!select_elements( - &mut engine, - unknown.clone(), - SelectMode::Add - )); - let unknown_file = Selection::Waypoints { - file_id: FileId::default(), - }; - assert!(!select_elements( - &mut engine, - unknown_file.clone(), - SelectMode::Add - )); - assert!(!select_elements( - &mut engine, - Selection::Empty, - SelectMode::Add - )); - assert_eq!(engine.selection(), &node); - - // and select nothing otherwise - assert!(select_elements(&mut engine, unknown, SelectMode::Replace)); - assert_eq!(engine.selection(), &Selection::Empty); - select_elements(&mut engine, node, SelectMode::Replace); - assert!(select_elements( - &mut engine, - unknown_file, - SelectMode::Replace - )); - assert_eq!(engine.selection(), &Selection::Empty); - } - - #[test] - fn test_toggle_elements() { - let mut engine = Engine::default(); - load(&mut engine, "data/with_tracks_and_segments.gpx"); - let file = engine - .stack - .current() - .unwrap() - .values() - .next() - .unwrap() - .clone(); - let (t0, t1) = (file.trk[0].id, file.trk[1].id); - let tracks = |ids: &[TrackId]| Selection::Track { - file_id: file.id, - trk_ids: ids.iter().copied().collect(), - }; - let toggle = |engine: &mut Engine, ids: &[TrackId]| { - select_elements(engine, tracks(ids), SelectMode::Toggle) - }; - - select_elements(&mut engine, tracks(&[t0]), SelectMode::Replace); - assert!(toggle(&mut engine, &[t1])); - assert_eq!(engine.selection(), &tracks(&[t0, t1])); - assert!(toggle(&mut engine, &[t0])); - assert_eq!(engine.selection(), &tracks(&[t1])); - // toggling the last element deselects everything - assert!(toggle(&mut engine, &[t1])); - assert_eq!(engine.selection(), &Selection::Empty); - // unknown elements change nothing - assert!(!toggle(&mut engine, &[TrackId::default()])); - assert_eq!(engine.selection(), &Selection::Empty); - } - - #[test] - fn test_selected_elements_are_dropped_when_they_disappear() { - let mut engine = Engine::default(); - load(&mut engine, "data/with_tracks_and_segments.gpx"); - let file = engine - .stack - .current() - .unwrap() - .values() - .next() - .unwrap() - .clone(); - let trk_id = file.trk[0].id; - select_elements( - &mut engine, - Selection::Track { - file_id: file.id, - trk_ids: [trk_id].into(), - }, - SelectMode::Replace, - ); - engine.execute(Action::Undo); - assert_eq!(engine.selection(), &Selection::Empty); - } - - #[test] - fn test_can_undo_and_redo() { - let mut engine = Engine::default(); - assert!(!engine.can_undo() && !engine.can_redo()); - new(&mut engine, "a"); - assert!(engine.can_undo() && !engine.can_redo()); - assert!(engine.execute(Action::Undo)); - assert!(!engine.can_undo() && engine.can_redo()); - // selecting does not change the history - new(&mut engine, "b"); - let a = engine.order()[0]; - select_files(&mut engine, &[a]); - assert!(engine.can_undo() && !engine.can_redo()); - assert!(engine.execute(Action::Undo)); - assert!(engine.execute(Action::Redo)); - assert!(engine.can_undo() && !engine.can_redo()); - } - - #[test] - fn test_select_all_follows_the_selected_level() { - let mut engine = Engine::default(); - new(&mut engine, "a"); - new(&mut engine, "b"); - let order = engine.order().to_vec(); - select_files(&mut engine, &[order[0]]); - - // files - assert!(engine.execute(Action::SelectAll)); - assert_eq!(engine.selection(), &files(&order)); - // everything is selected already - assert!(!engine.execute(Action::SelectAll)); - - // the tracks of a file - let load = |engine: &mut Engine| load(engine, "data/with_tracks_and_segments.gpx"); - load(&mut engine); - let file = engine - .stack - .current() - .unwrap() - .values() - .find(|f| !f.trk.is_empty()) - .unwrap() - .clone(); - let tracks: HashSet<_> = file.trk.iter().map(|t| t.id).collect(); - assert!(tracks.len() >= 2); - select_elements( - &mut engine, - Selection::Track { - file_id: file.id, - trk_ids: [file.trk[0].id].into(), - }, - SelectMode::Replace, - ); - assert!(engine.execute(Action::SelectAll)); - assert_eq!( - engine.selection(), - &Selection::Track { - file_id: file.id, - trk_ids: tracks - } - ); - - // nothing selected: all the files - select_elements(&mut engine, Selection::Empty, SelectMode::Replace); - assert!(engine.execute(Action::SelectAll)); - assert!(matches!(engine.selection(), Selection::File { file_ids } if file_ids.len() == 3)); - // the waypoints node: nothing more to select - select_elements( - &mut engine, - Selection::Waypoints { file_id: file.id }, - SelectMode::Replace, - ); - assert!(!engine.execute(Action::SelectAll)); - assert_eq!( - engine.selection(), - &Selection::Waypoints { file_id: file.id } - ); - } - - #[test] - fn test_arrow_select() { - let mut engine = Engine::default(); - new(&mut engine, "file"); - for _ in 0..3 { - edit(&mut engine, Command::NewTrack(NewTrack)); - } - let file = engine - .stack - .current() - .unwrap() - .values() - .next() - .unwrap() - .clone(); - let ids: Vec = file.trk.iter().map(|t| t.id).collect(); - assert_eq!(ids.len(), 3); - let tracks = |ids: &[TrackId]| Selection::Track { - file_id: file.id, - trk_ids: ids.iter().copied().collect(), - }; - let arrow = - |engine: &mut Engine, down, add| engine.execute(Action::ArrowSelect { down, add }); - - select_elements(&mut engine, tracks(&[ids[0]]), SelectMode::Replace); - // without shift the next one replaces the selection - assert!(arrow(&mut engine, true, false)); - assert_eq!(engine.selection(), &tracks(&[ids[1]])); - assert!(engine.selection_changed()); - // with shift it is added - assert!(arrow(&mut engine, true, true)); - assert_eq!(engine.selection(), &tracks(&[ids[1], ids[2]])); - // the selection grows from its end, and the other way round from its start - assert!(arrow(&mut engine, false, true)); - assert_eq!(engine.selection(), &tracks(&[ids[0], ids[1], ids[2]])); - // at the ends nothing happens - assert!(!arrow(&mut engine, false, false)); - assert!(!arrow(&mut engine, true, true)); - assert_eq!(engine.selection(), &tracks(&[ids[0], ids[1], ids[2]])); - // and there is nothing to move without selection - select_elements(&mut engine, Selection::Empty, SelectMode::Replace); - assert!(!arrow(&mut engine, true, false)); - assert_eq!(engine.selection(), &Selection::Empty); - } - - #[test] - fn test_arrow_select_does_not_create_undo_steps() { - let mut engine = Engine::default(); - new(&mut engine, "a"); - new(&mut engine, "b"); - let a = engine.order()[0]; - select_files(&mut engine, &[a]); - let before = engine.can_redo(); - assert!(engine.execute(Action::ArrowSelect { - down: true, - add: false - })); - assert_eq!(engine.can_redo(), before); - assert_eq!(engine.selection(), &files(&[engine.order()[1]])); - assert!(engine.execute(Action::Undo)); - assert_eq!(engine.order().len(), 1); - } - - /// A file named `name` with `n` tracks, selected. - fn file_with_tracks(engine: &mut Engine, name: &str, n: usize) -> FileId { - new(engine, name); - for _ in 0..n { - edit(engine, Command::NewTrack(NewTrack)); - } - *engine.order().last().unwrap() - } - - fn track_ids(engine: &Engine, id: FileId) -> Vec { - engine - .file_structure(&id) - .unwrap() - .tracks - .iter() - .map(|t| t.id) - .collect() - } - - fn paste(engine: &mut Engine) -> bool { - edit(engine, Command::Paste(Paste)) - } - - #[test] - fn test_copy_and_paste() { - let mut engine = Engine::default(); - let a = file_with_tracks(&mut engine, "a", 2); - let b = file_with_tracks(&mut engine, "b", 0); - assert!(engine.clipboard().is_none() && !engine.can_paste()); - - // nothing selected, nothing copied - select_elements(&mut engine, Selection::Empty, SelectMode::Replace); - assert!(!engine.execute(Action::Copy)); - assert!(engine.clipboard().is_none()); - - let tracks = track_ids(&engine, a); - let selected_tracks = Selection::Track { - file_id: a, - trk_ids: tracks.iter().copied().collect(), - }; - select_elements(&mut engine, selected_tracks.clone(), SelectMode::Replace); - assert!(engine.execute(Action::Copy)); - assert!(engine.clipboard_changed() && !engine.selection_changed()); - assert_eq!( - engine.clipboard().map(|c| (c.content.ids(), c.cut)), - Some((ClipboardIds::Tracks(tracks.clone()), false)) - ); - // tracks cannot be pasted onto themselves... but into a track, a file or the list of files - assert!(engine.can_paste()); - select_elements(&mut engine, files(&[b]), SelectMode::Replace); - assert!(engine.can_paste()); - select_elements( - &mut engine, - Selection::Waypoints { file_id: b }, - SelectMode::Replace, - ); - assert!(!engine.can_paste()); - select_elements(&mut engine, files(&[b]), SelectMode::Replace); - - // pasting is an edit - assert!(paste(&mut engine)); - assert_eq!(track_ids(&engine, a), tracks); - assert_eq!(track_ids(&engine, b).len(), 2); - // a copy stays in the clipboard, to be pasted again - assert!(engine.clipboard().is_some() && !engine.clipboard_changed()); - assert!( - matches!(engine.selection(), Selection::Track { file_id, trk_ids } if *file_id == b && trk_ids.len() == 2) - ); - assert!(engine.can_paste()); - assert!(paste(&mut engine)); - assert_eq!(track_ids(&engine, b).len(), 4); - assert_eq!(track_ids(&engine, a), tracks); - - // and it can be undone - assert!(engine.execute(Action::Undo)); - assert_eq!(track_ids(&engine, b).len(), 2); - assert!(engine.execute(Action::Undo)); - assert!(track_ids(&engine, b).is_empty()); - } - - #[test] - fn test_cut_and_paste_moves_after_the_paste() { - let mut engine = Engine::default(); - let a = file_with_tracks(&mut engine, "a", 2); - let b = file_with_tracks(&mut engine, "b", 0); - let tracks = track_ids(&engine, a); - select_elements( - &mut engine, - Selection::Track { - file_id: a, - trk_ids: [tracks[0]].into(), - }, - SelectMode::Replace, - ); - assert!(engine.execute(Action::Cut)); - assert!(engine.clipboard().is_some_and(|c| c.cut)); - // nothing moves before the paste - assert_eq!(track_ids(&engine, a), tracks); - - select_elements(&mut engine, files(&[b]), SelectMode::Replace); - assert!(paste(&mut engine)); - assert_eq!(track_ids(&engine, a), vec![tracks[1]]); - assert_eq!(track_ids(&engine, b), vec![tracks[0]]); - assert!(engine.execute(Action::Undo)); - assert_eq!(track_ids(&engine, a), tracks); - assert!(track_ids(&engine, b).is_empty()); - } - - #[test] - fn test_clipboard_survives_what_happens_to_the_elements() { - let mut engine = Engine::default(); - let a = file_with_tracks(&mut engine, "a", 2); - let tracks = track_ids(&engine, a); - select_elements( - &mut engine, - Selection::Track { - file_id: a, - trk_ids: tracks.iter().copied().collect(), - }, - SelectMode::Replace, - ); - engine.execute(Action::Copy); - let clipboard = engine.clipboard().cloned(); - assert!(clipboard.is_some()); - - // one of the tracks is deleted, then the whole file: the clipboard is not affected - select_elements( - &mut engine, - Selection::Track { - file_id: a, - trk_ids: [tracks[0]].into(), - }, - SelectMode::Replace, - ); - assert!(edit( - &mut engine, - Command::Delete(Delete { whole_files: false }) - )); - select_elements(&mut engine, files(&[a]), SelectMode::Replace); - assert!(edit(&mut engine, Command::DeleteAll(DeleteAll))); - assert_eq!(engine.clipboard(), clipboard.as_ref()); - assert!(!engine.clipboard_changed()); - - // both tracks are pasted in a new file, as they were when they were copied - let b = file_with_tracks(&mut engine, "b", 0); - select_files(&mut engine, &[b]); - assert!(engine.can_paste()); - assert!(paste(&mut engine)); - let pasted = track_ids(&engine, b); - assert_eq!(pasted.len(), 2); - // copies, with new ids - assert!(pasted.iter().all(|id| !tracks.contains(id))); - // and again - assert!(paste(&mut engine)); - assert_eq!(track_ids(&engine, b).len(), 4); - } - - #[test] - fn test_copy_replaces_the_clipboard_and_does_not_change_the_history() { - let mut engine = Engine::default(); - let a = file_with_tracks(&mut engine, "a", 0); - let b = file_with_tracks(&mut engine, "b", 0); - select_files(&mut engine, &[a]); - engine.execute(Action::Copy); - select_files(&mut engine, &[b]); - assert!(engine.execute(Action::Cut)); - assert_eq!( - engine.clipboard().map(|c| (c.content.ids(), c.cut)), - Some((ClipboardIds::Files(vec![b]), true)) - ); - // the same elements again are a new copy - assert!(engine.execute(Action::Cut)); - assert!(engine.clipboard_changed()); - // only the creations of the files are in the history - assert!(engine.execute(Action::Undo)); - assert!(engine.execute(Action::Undo)); - assert!(!engine.can_undo()); - } - - #[test] - fn test_move_elements_is_an_edit_and_selects_what_moved() { - use crate::{Move, MoveTarget}; - - let mut engine = Engine::default(); - let a = file_with_tracks(&mut engine, "a", 3); - let tracks = track_ids(&engine, a); - let moved = Selection::Track { - file_id: a, - trk_ids: [tracks[2]].into(), - }; - assert!(edit( - &mut engine, - Command::Move(Move { - what: moved.clone(), - to: MoveTarget::Tracks { - file_id: a, - index: 0 - }, - }) - )); - assert_eq!(track_ids(&engine, a), vec![tracks[2], tracks[0], tracks[1]]); - assert_eq!(engine.selection(), &moved); - - // invalid moves change nothing - assert!(!edit( - &mut engine, - Command::Move(Move { - what: moved, - to: MoveTarget::Waypoints { - file_id: a, - index: 0 - }, - }) - )); - - assert!(engine.execute(Action::Undo)); - assert_eq!(track_ids(&engine, a), tracks); - } - - #[test] - fn test_added_files_are_reported_in_the_order_of_the_files() { - use crate::LoadFiles; - - let mut engine = Engine::default(); - let datas: Vec> = (0..12) - .map(|i| { - format!(r#"{i}"#) - .into_bytes() - }) - .collect(); - let loads = datas - .iter() - .map(|data| Load { data, name: "x" }) - .collect::>(); - assert!(edit( - &mut engine, - Command::LoadFiles(LoadFiles { files: loads }) - )); - assert_eq!(engine.last_diff().unwrap().added, engine.order()); - - // files that come back with an undo too - assert!(engine.execute(Action::Undo)); - assert!(engine.execute(Action::Redo)); - assert_eq!(engine.last_diff().unwrap().added, engine.order()); - } - - #[test] - fn test_load_several_files_is_one_edit() { - use crate::LoadFiles; - - let mut engine = Engine::default(); - new(&mut engine, "before"); - let gpx = |name: &str| { - format!(r#"{name}"#) - .into_bytes() - }; - let (a, b) = (gpx("a"), gpx("b")); - assert!(edit( - &mut engine, - Command::LoadFiles(LoadFiles { - files: vec![ - Load { - data: &a, - name: "x" - }, - Load { - data: &b, - name: "x" - }, - ], - }) - )); - assert_eq!(engine.order().len(), 3); - let diff = engine.last_diff().unwrap(); - assert_eq!(diff.added.len(), 2); - // the first one that was loaded is selected - assert_eq!(engine.selection(), &files(&[engine.order()[1]])); - - // a single undo removes both - assert!(engine.execute(Action::Undo)); - assert_eq!(engine.order().len(), 1); - assert!(engine.execute(Action::Redo)); - assert_eq!(engine.order().len(), 3); - } - - #[test] - fn test_create_edit_and_move_a_waypoint() { - use crate::{EditWaypoint, MoveWaypoint, NewWaypoint}; - - let mut engine = Engine::default(); - new(&mut engine, "file"); - let file = engine.order()[0]; - assert!(edit( - &mut engine, - Command::NewWaypoint(NewWaypoint { - lng: 4.0, - lat: 50.0, - ele: 10.0, - name: "created", - desc: "", - icon: "Flag", - link: "", - }) - )); - let id = engine.file_structure(&file).unwrap().waypoints[0].id; - let waypoint = engine.waypoint(&file, &id).unwrap(); - assert_eq!(waypoint.name.as_deref(), Some("created")); - assert_eq!(waypoint.sym.as_deref(), Some("Flag")); - assert_eq!(waypoint.ele, 10.0); - // unknown waypoint or file - assert!(engine.waypoint(&file, &Default::default()).is_none()); - assert!(engine.waypoint(&Default::default(), &id).is_none()); - - // no selection needed - assert!(edit( - &mut engine, - Command::MoveWaypoint(MoveWaypoint { - file_id: file, - waypoint_id: id, - lng: 5.0, - lat: 51.0, - ele: 20.0, - }) - )); - let moved = engine.waypoint(&file, &id).unwrap(); - assert_eq!( - (moved.coordinates.lng, moved.coordinates.lat, moved.ele), - (5.0, 51.0, 20.0) - ); - assert_eq!(moved.name.as_deref(), Some("created")); - // the coordinates buffer follows - assert_eq!(engine.waypoint_coordinates(&file), &[5.0, 51.0]); - - assert!(edit( - &mut engine, - Command::EditWaypoint(EditWaypoint { - file_id: file, - waypoint_id: id, - lng: 6.0, - lat: 52.0, - ele: 30.0, - name: "edited", - desc: "text", - icon: "", - link: "https://example.com", - }) - )); - let edited = engine.waypoint(&file, &id).unwrap(); - assert_eq!(edited.name.as_deref(), Some("edited")); - assert_eq!(edited.cmt.as_deref(), Some("text")); - assert!(edited.sym.is_none()); - assert_eq!(engine.waypoint_coordinates(&file), &[6.0, 52.0]); - - // each of them can be undone - assert!(engine.execute(Action::Undo)); - assert_eq!( - engine.waypoint(&file, &id).unwrap().name.as_deref(), - Some("created") - ); - assert!(engine.execute(Action::Undo)); - assert_eq!(engine.waypoint_coordinates(&file), &[4.0, 50.0]); - - // deleting does not need a selection either - assert!(edit( - &mut engine, - Command::DeleteWaypoint(crate::DeleteWaypoint { - file_id: file, - waypoint_id: id, - }) - )); - assert!(engine.waypoint(&file, &id).is_none()); - assert!(engine.waypoint_coordinates(&file).is_empty()); - assert!(engine.execute(Action::Undo)); - assert!(engine.waypoint(&file, &id).is_some()); - } - - #[test] - fn test_categories_are_shared_and_kept_by_the_history() { - let mut engine = Engine::default(); - assert!(engine.categories().surface.names().is_empty()); - - load(&mut engine, "data/with_highway.gpx"); - assert_eq!(engine.categories().surface.names(), ["asphalt", "gravel"]); - assert_eq!( - engine.categories().highway.names(), - ["residential", "track"] - ); - // the trackpoints of the selection refer to them - assert_eq!(expand(&engine.statistics().surface, 5), [1, 1, 0, 2, 2]); - assert_eq!(expand(&engine.statistics().highway, 5), [1, 1, 0, 2, 0]); - assert_eq!( - engine.categories().sac_scale.names(), - ["mountain_hiking", "hiking"] - ); - assert_eq!(expand(&engine.statistics().sac_scale, 5), [0, 0, 0, 1, 2]); - assert_eq!(expand(&engine.statistics().mtb_scale, 5), [0, 0, 0, 1, 1]); - - // a second file goes on with the same table - load(&mut engine, "data/with_surface.gpx"); - assert_eq!( - engine.categories().surface.names(), - ["asphalt", "gravel", "cobblestone"] - ); - assert_eq!(engine.statistics().len(), 80); - let surface = expand(&engine.statistics().surface, 80); - assert_eq!(surface[0], 1); - assert_eq!(surface.iter().filter(|c| **c == 3).count(), 1); - - // undoing does not forget what was learned: the codes stay valid in every state - assert!(engine.execute(Action::Undo)); - assert!(engine.execute(Action::Undo)); - assert_eq!( - engine.categories().surface.names(), - ["asphalt", "gravel", "cobblestone"] - ); - assert!(engine.execute(Action::Redo)); - let first = engine.order()[0]; - select_files(&mut engine, &[first]); - assert_eq!(expand(&engine.statistics().surface, 5), [1, 1, 0, 2, 2]); - } - - #[test] - fn test_trackpoint() { - let mut engine = Engine::default(); - load(&mut engine, "data/simple.gpx"); - let file = engine.order()[0]; - let structure = engine.file_structure(&file).unwrap(); - let seg = &structure.tracks[0].segments[0]; - let (seg_id, len) = (seg.id, seg.len); - let coordinates = engine.segment_coordinates(&seg_id).to_vec(); - let last = engine.trackpoint(&file, &seg_id, len - 1).unwrap(); - assert_eq!( - [last.coordinates.lng, last.coordinates.lat], - coordinates[(len - 1) * 2..] - ); - assert!(engine.trackpoint(&file, &seg_id, len).is_none()); - assert!(engine.trackpoint(&Default::default(), &seg_id, 0).is_none()); - } - - #[test] - fn test_export_writes_the_file_as_it_is() { - let mut engine = Engine::default(); - assert!( - engine - .export(&FileId::default(), ExportOptions::ALL) - .is_none() - ); - - load(&mut engine, "data/with_time.gpx"); - let id = engine.order()[0]; - let bytes = engine.export(&id, ExportOptions::ALL).unwrap(); - let again = crate::parse(&bytes, &mut engine.categories().clone()).unwrap(); - let file = engine.stack.current().unwrap()[&id].clone(); - assert_eq!(again.trk.len(), file.trk.len()); - assert_eq!(again.trk[0].trkseg[0].len(), file.trk[0].trkseg[0].len()); - assert!(again.trk[0].trkseg[0][0].time.is_some()); - - // the options leave data out - let bytes = engine.export(&id, ExportOptions::NONE).unwrap(); - let again = crate::parse(&bytes, &mut engine.categories().clone()).unwrap(); - assert!(again.trk[0].trkseg[0].iter().all(|p| p.time.is_none())); - - // a file that is gone cannot be exported - engine.execute(Action::Edit(Command::DeleteAll(DeleteAll))); - assert!(engine.export(&id, ExportOptions::ALL).is_none()); - } - - #[test] - fn test_exportable_data_is_the_union_over_the_files() { - let mut engine = Engine::default(); - assert_eq!(engine.exportable_data(&[]), ExportOptions::NONE); - - load(&mut engine, "data/simple.gpx"); - load(&mut engine, "data/with_time.gpx"); - let (plain, timed) = (engine.order()[0], engine.order()[1]); - assert!(!engine.exportable_data(&[plain]).time); - assert!(engine.exportable_data(&[timed]).time); - assert!(engine.exportable_data(&[plain, timed]).time); - // unknown files have nothing - assert_eq!( - engine.exportable_data(&[FileId::default()]), - ExportOptions::NONE - ); - assert!(!engine.exportable_data(&[plain, FileId::default()]).time); - } - - #[test] - fn test_file_statistics_are_the_ones_of_the_whole_file() { - let mut engine = Engine::default(); - assert!(engine.file_statistics(&FileId::default()).is_none()); - - load(&mut engine, "data/simple.gpx"); - let id = engine.order()[0]; - let stats = engine.file_statistics(&id).unwrap(); - assert!(stats.total_distance > 0.0); - let last = engine.statistics().total_distance.last().copied().unwrap(); - assert!((stats.total_distance - last).abs() < 1e-9); - assert!(engine.file_statistics(&FileId::default()).is_none()); - - // statistics of a file do not depend on the selection - engine.execute(Action::Select { - selection: Selection::Empty, - mode: SelectMode::Replace, - }); - assert_eq!( - engine.file_statistics(&id).unwrap().total_distance, - stats.total_distance - ); - - engine.execute(Action::Undo); - assert!(engine.file_statistics(&id).is_none()); - } - - #[test] - fn test_reduction_distances_follow_the_selection() { - let mut engine = Engine::default(); - assert!(engine.reduction_distances().is_empty()); - - load(&mut engine, "data/simple.gpx"); - let n = engine.statistics().total_distance.len(); - let distances = engine.reduction_distances(); - assert_eq!(distances.len(), n); - assert!(distances.iter().all(|d| !d.is_nan())); - - engine.execute(Action::Select { - selection: Selection::Empty, - mode: SelectMode::Replace, - }); - assert!(engine.reduction_distances().is_empty()); - } - - #[test] - fn test_routing_describes_the_anchors_of_the_selection() { - let mut engine = Engine::default(); - assert!(engine.routing().anchor_indices.is_empty()); - - load(&mut engine, "data/simple.gpx"); - let n = engine.statistics().total_distance.len() as u32; - let routing = engine.routing(); - assert_eq!(routing.segment_starts, vec![0]); - assert_eq!(routing.segment_ids.len(), 1); - // the ends of a segment are always anchors - assert_eq!(routing.anchor_indices.first(), Some(&0)); - assert_eq!(routing.anchor_indices.last(), Some(&(n - 1))); - assert_eq!(routing.anchor_indices.len(), routing.anchor_zooms.len()); - assert_eq!(routing.anchor_zooms[0], 0); - - // the revision follows what the indices refer to - let revision = routing.revision; - engine.execute(Action::Select { - selection: Selection::Empty, - mode: SelectMode::Replace, - }); - assert!(engine.routing().anchor_indices.is_empty()); - assert_ne!(engine.routing().revision, revision); - // nothing changed for the routing: same revision - let revision = engine.routing().revision; - engine.execute(Action::Select { - selection: Selection::Empty, - mode: SelectMode::Replace, - }); - assert_eq!(engine.routing().revision, revision); - } -} diff --git a/gpx-rs/engine/src/engine/mod.rs b/gpx-rs/engine/src/engine/mod.rs index dacbd43ba..8a766c707 100644 --- a/gpx-rs/engine/src/engine/mod.rs +++ b/gpx-rs/engine/src/engine/mod.rs @@ -1,11 +1,372 @@ mod action; mod command; mod derived; -mod engine; +mod outcome; mod state; pub use action::*; pub use command::*; pub use derived::*; -pub use engine::*; +pub use outcome::*; pub use state::*; + +use crate::{ + ExportOptions, FileId, GlobalStatistics, Restored, Snapshot, TrackSegmentId, Trackpoint, + TrackpointCategories, Waypoint, WaypointId, reduction_distances, write, +}; + +#[derive(Debug, Default)] +pub struct Engine { + stack: Stack, + selection: Selection, + order: FileOrder, + statistics_cache: StatisticsCache, + coordinates_cache: CoordinatesCache, + structure_cache: FileStructureCache, + statistics_buffer: StatisticsBuffer, + routing_buffer: RoutingBuffer, + categories: TrackpointCategories, + clipboard: Option, + /// What the tests of the last action read, see `Engine::run`. + #[cfg(test)] + last: Outcome, +} + +impl Engine { + pub fn statistics(&self) -> &StatisticsBuffer { + &self.statistics_buffer + } + + /// The anchors of the routing tool among the trackpoints of the selection. + pub fn routing(&self) -> &RoutingBuffer { + &self.routing_buffer + } + + /// The names of the surfaces and highways that the trackpoints refer to by code. + pub fn categories(&self) -> &TrackpointCategories { + &self.categories + } + + /// Files in display order. + pub fn order(&self) -> &[FileId] { + &self.order.0 + } + + /// Name, tracks, segments and waypoints (with their ids) of a file. + pub fn file_structure(&self, id: &FileId) -> Option<&FileStructure> { + self.structure_cache.get(id) + } + + /// Global statistics (distance, time, elevation, bounds...) of a file. + pub fn file_statistics(&self, id: &FileId) -> Option { + let file = self.stack.current()?.get(id)?; + Some(self.statistics_cache.file_global(file)) + } + + /// What was copied or cut and is waiting to be pasted. + pub fn clipboard(&self) -> Option<&Clipboard> { + self.clipboard.as_ref() + } + + /// Whether the clipboard can be pasted with the current selection. + pub fn can_paste(&self) -> bool { + self.clipboard + .as_ref() + .is_some_and(|clipboard| clipboard.can_paste(&self.selection)) + } + + /// A waypoint of a file, with all its data. `None` if it does not exist. + pub fn waypoint(&self, file_id: &FileId, id: &WaypointId) -> Option<&Waypoint> { + self.stack + .current()? + .get(file_id)? + .wpt + .iter() + .find(|wpt| wpt.id == *id) + } + + /// A trackpoint of a segment of a file. `None` if it does not exist. + pub fn trackpoint( + &self, + file_id: &FileId, + segment_id: &TrackSegmentId, + index: usize, + ) -> Option<&Trackpoint> { + let segment = self + .stack + .current()? + .get(file_id)? + .trk + .iter() + .flat_map(|trk| &trk.trkseg) + .find(|seg| seg.id == *segment_id)?; + (index < segment.len()).then(|| &segment[index]) + } + + /// For each trackpoint of the selection (numbered as in the statistics), the tolerance up to + /// which `Reduce` keeps it, see [`reduction_distances`]. + pub fn reduction_distances(&self) -> Vec { + let Some(files) = self.stack.current() else { + return vec![]; + }; + self.selection + .segment_locations(files, &self.order.0) + .iter() + .flat_map(|location| { + reduction_distances( + &files[&location.file_id].trk[location.trk].trkseg[location.seg], + ) + }) + .collect() + } + + /// What there is to keep of the engine, see [`crate::Persister`]. + pub fn snapshot(&self) -> Snapshot { + Snapshot { + files: self.stack.current().cloned().unwrap_or_default(), + order: self.order.0.clone(), + categories: self.categories.clone(), + } + } + + /// Puts files in an engine that has none, typically the ones read from the storage. They are + /// the state to start from: they cannot be undone. The files are reported as added, and the + /// order, the selection and the clipboard as changed. + pub fn restore(&mut self, restored: Restored) -> Outcome { + let files: StackEntry = restored + .files + .into_iter() + .map(|file| (file.id, file)) + .collect(); + self.categories = restored.categories; + self.order = FileOrder(restored.order); + self.selection = Selection::Empty; + self.clipboard = None; + let mut diff = Some(Diff { + added: files.keys().copied().collect(), + ..Default::default() + }); + self.stack = Stack::restored(files); + self.refresh(diff.as_ref()); + sort_diff(&mut diff, &self.order.0); + let outcome = Outcome { + changed: true, + diff, + order_changed: true, + selection_changed: true, + clipboard_changed: true, + error: None, + }; + #[cfg(test)] + { + self.last = outcome.clone(); + } + outcome + } + + /// A file as GPX (UTF-8), `None` if it does not exist. + pub fn export(&self, id: &FileId, options: ExportOptions) -> Option> { + let file = self.stack.current()?.get(id)?; + Some(write(file, &self.categories, options)) + } + + /// The data that the given files have, which the options of `export` can leave out. + pub fn exportable_data(&self, ids: &[FileId]) -> ExportOptions { + let Some(files) = self.stack.current() else { + return ExportOptions::NONE; + }; + ids.iter() + .filter_map(|id| files.get(id)) + .fold(ExportOptions::NONE, |data, file| { + data.union(file.exportable_data()) + }) + } + + /// Whether there is something to undo. + pub fn can_undo(&self) -> bool { + self.stack.can_undo() + } + + /// Whether there is something to redo. + pub fn can_redo(&self) -> bool { + self.stack.can_redo() + } + + /// Currently selected elements. + pub fn selection(&self) -> &Selection { + &self.selection + } + + /// Coordinates (`[lng, lat, ...]`) of the trackpoints of a segment. + pub fn segment_coordinates(&self, id: &TrackSegmentId) -> &[f64] { + self.coordinates_cache.segment(id) + } + + /// Coordinates (`[lng, lat, ...]`) of the waypoints of a file. + pub fn waypoint_coordinates(&self, id: &FileId) -> &[f64] { + self.coordinates_cache.waypoints(id) + } + + /// Single entry point for every action coming from the frontend. Says what it changed. + pub fn execute(&mut self, action: Action) -> Outcome { + let selection_before = self.selection.clone(); + let order_before = self.order.0.clone(); + let clipboard_before = self.clipboard.clone(); + let is_edit = matches!(action, Action::Edit(_)); + let mut error = None; + // the files change iff there is a diff, but selection and order actions have none + let mut diff = match action { + Action::Edit(command) => self.edit(command).unwrap_or_else(|err| { + error = Some(err); + None + }), + Action::Undo => self.stack.undo(), + Action::Redo => self.stack.redo(), + Action::Select { selection, mode } => { + self.selection.select(self.stack.current(), selection, mode); + None + } + Action::SelectAll => { + if let Some(files) = self.stack.current() { + self.selection.select_all(files, &self.order.0); + } + None + } + Action::ArrowSelect { down, add } => { + if let Some(files) = self.stack.current() { + self.selection + .select_neighbour(files, &self.order.0, down, add); + } + None + } + Action::Copy => { + self.copy(false); + None + } + Action::Cut => { + self.copy(true); + None + } + Action::Reorder { file_ids, index } => { + self.order.move_files(&file_ids, index); + None + } + }; + let changed = diff.is_some() + || self.selection != selection_before + || self.order.0 != order_before + || self.clipboard != clipboard_before; + if is_edit && !changed && error.is_none() { + error = Some(CommandError::NothingToDo); + } + if changed { + self.refresh(diff.as_ref()); + sort_diff(&mut diff, &self.order.0); + } + // after the refresh, which also syncs the order and selection with the files (undo, redo) + let outcome = Outcome { + changed, + diff, + order_changed: self.order.0 != order_before, + selection_changed: self.selection != selection_before, + clipboard_changed: self.clipboard != clipboard_before, + error, + }; + #[cfg(test)] + { + self.last = outcome.clone(); + } + outcome + } + + /// Puts the selection in the clipboard, if there is one. + fn copy(&mut self, cut: bool) { + if let Some(files) = self.stack.current() + && let Some(clipboard) = Clipboard::new(&self.selection, files, &self.order.0, cut) + { + self.clipboard = Some(clipboard); + } + } + + fn edit(&mut self, command: Command) -> Result, CommandError> { + self.stack.create_and_push_next(|files| { + let mut state = State { + categories: &mut self.categories, + files, + selection: &mut self.selection, + order: &mut self.order, + clipboard: &mut self.clipboard, + }; + command.apply(&mut state) + }) + } + + /// Brings everything derived from the files back in line with the current stack entry. + fn refresh(&mut self, diff: Option<&Diff>) { + let current = self.stack.current(); + match current { + Some(files) => { + // files coming back after an undo/redo are added at the end of the order + self.order.sync(files); + self.selection.retain_existing(files); + } + None => { + self.order.0.clear(); + self.selection = Selection::Empty; + } + } + self.statistics_cache.update(current); + self.coordinates_cache.update(current); + self.structure_cache.update(current, diff); + self.routing_buffer + .update(current, &self.selection, &self.order.0); + self.statistics_buffer + .update( + &self + .statistics_cache + .selected(current, &self.selection, &self.order.0), + ); + } +} + +/// Puts the files of a diff in the order of the files (the stack does not know it), so that +/// whatever is done with them one after the other, like giving them colors, follows it. +fn sort_diff(diff: &mut Option, order: &[FileId]) { + let position = |id: &FileId| order.iter().position(|other| other == id); + if let Some(diff) = diff { + diff.added.sort_by_key(position); + diff.modified.sort_by_key(position); + } +} + +#[cfg(test)] +impl Engine { + /// Executes an action and tells whether it changed anything. What it did is kept for the + /// accessors below, which the tests read right after. + pub(crate) fn run(&mut self, action: Action) -> bool { + self.execute(action).changed + } + + pub(crate) fn last_diff(&self) -> Option<&Diff> { + self.last.diff.as_ref() + } + + pub(crate) fn last_error(&self) -> Option<&CommandError> { + self.last.error.as_ref() + } + + pub(crate) fn order_changed(&self) -> bool { + self.last.order_changed + } + + pub(crate) fn selection_changed(&self) -> bool { + self.last.selection_changed + } + + pub(crate) fn clipboard_changed(&self) -> bool { + self.last.clipboard_changed + } +} + +#[cfg(test)] +mod tests; diff --git a/gpx-rs/engine/src/engine/outcome.rs b/gpx-rs/engine/src/engine/outcome.rs new file mode 100644 index 000000000..dc8ca59fc --- /dev/null +++ b/gpx-rs/engine/src/engine/outcome.rs @@ -0,0 +1,21 @@ +use crate::{CommandError, Diff}; + +/// What an action did, returned by [`crate::Engine::execute`] and [`crate::Engine::restore`]. +/// +/// It describes that action only: it is not kept by the engine, so reading it never depends on +/// what was done since. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Outcome { + /// Whether anything changed: the files, the selection, the order of the files or the + /// clipboard. + pub changed: bool, + /// Which files the action added, removed or modified, in the order of the files. `None` when + /// the files are as they were. + pub diff: Option, + pub order_changed: bool, + pub selection_changed: bool, + pub clipboard_changed: bool, + /// Why an edit did nothing: the command failed, or it had nothing to change. `None` if it + /// succeeded, and for the actions that do not edit the files. + pub error: Option, +} diff --git a/gpx-rs/engine/src/engine/state/selection.rs b/gpx-rs/engine/src/engine/state/selection.rs index e5019388a..a9185af1e 100644 --- a/gpx-rs/engine/src/engine/state/selection.rs +++ b/gpx-rs/engine/src/engine/state/selection.rs @@ -48,6 +48,56 @@ pub struct SegmentLocation { pub seg: usize, } +impl Selection { + /// Combines `selection` with this one according to `mode`, ignoring the elements of + /// `selection` that do not exist in `files`. Nothing to add or toggle leaves the selection + /// as it is, while replacing it with nothing deselects everything. + pub fn select( + &mut self, + files: Option<&StackEntry>, + mut selection: Selection, + mode: SelectMode, + ) { + match files { + Some(files) => selection.retain_existing(files), + None => selection = Selection::Empty, + } + match mode { + SelectMode::Replace => *self = selection, + _ if selection == Selection::Empty => {} + SelectMode::Add => self.extend(selection), + SelectMode::Toggle => self.toggle(selection), + } + } + + /// Selects all the elements of the same kind as the selected ones, see + /// [`Selection::all_at_level`]. Does nothing when there is nothing more to select. + pub fn select_all(&mut self, files: &StackEntry, order: &[FileId]) { + if let Some(all) = self.all_at_level(files, order) { + *self = all; + } + } + + /// Moves the selection to the next (`down`) or previous element, see + /// [`Selection::neighbour`]. With `add`, the element is added to the selection instead of + /// replacing it. + pub fn select_neighbour( + &mut self, + files: &StackEntry, + order: &[FileId], + down: bool, + add: bool, + ) { + if let Some(next) = self.neighbour(files, order, down) { + if add { + self.extend(next); + } else { + *self = next; + } + } + } +} + impl Selection { /// The track segments covered by the selection, in order: the segments of several selected /// files follow `order` (the files missing from it come last), then the tracks and segments diff --git a/gpx-rs/engine/src/engine/state/stack.rs b/gpx-rs/engine/src/engine/state/stack.rs index 354b7dc65..71bac7e18 100644 --- a/gpx-rs/engine/src/engine/state/stack.rs +++ b/gpx-rs/engine/src/engine/state/stack.rs @@ -3,15 +3,15 @@ use std::{ rc::Rc, }; -use crate::{File, FileId}; +use crate::{CommandError, File, FileId}; #[derive(Debug, Default)] pub struct Stack { entries: Vec, index: Option, - /// Whether the first entry is the state the stack started from (files restored from the - /// storage), which is not undone. - restored: bool, + /// Whether the first entry is the state the stack starts from, which is not undone: files + /// restored from the storage. + floor: bool, } impl Stack { @@ -20,7 +20,7 @@ impl Stack { Self { entries: vec![entry], index: Some(0), - restored: true, + floor: true, } } @@ -28,23 +28,40 @@ impl Stack { self.index.map_or_default(|i| self.get(i)) } - pub fn create_and_push_next(&mut self, f: F) -> Option + /// Applies `f` to a copy of the current entry and makes the copy the new current entry. + /// + /// Nothing is pushed, and `None` is returned, if `f` fails (the error is returned) or if it + /// leaves every file as it was: a step that undoes nothing is not worth a place in the + /// history. Files are told apart by their identity, not their content. + pub fn create_and_push_next(&mut self, f: F) -> Result, CommandError> where - F: FnOnce(&mut StackEntry) -> Result<(), String>, + F: FnOnce(&mut StackEntry) -> Result<(), CommandError>, { - self.record_diff(|stack| { - let mut next = stack.current().map_or_default(|c| c.clone()); - if f(&mut next).is_ok() { - stack.push(next); - } - }) + let mut next = self.current().map_or_default(|c| c.clone()); + f(&mut next)?; + if self.is_current(&next) { + return Ok(None); + } + Ok(self.record_diff(|stack| stack.push(next))) + } + + /// Whether `entry` holds exactly the files of the current entry. + fn is_current(&self, entry: &StackEntry) -> bool { + let current = self.current(); + let len = current.map_or(0, |current| current.len()); + entry.len() == len + && current.is_none_or(|current| { + entry + .iter() + .all(|(id, file)| current.get(id).is_some_and(|c| Rc::ptr_eq(c, file))) + }) } pub fn undo(&mut self) -> Option { self.record_diff(|stack| { if let Some(i) = stack.index { if i == 0 { - if !stack.restored { + if !stack.floor { stack.index = None; } } else { @@ -70,7 +87,7 @@ impl Stack { } pub fn can_undo(&self) -> bool { - self.index.is_some_and(|i| i > 0 || !self.restored) + self.index.is_some_and(|i| i > 0 || !self.floor) } pub fn can_redo(&self) -> bool { @@ -137,6 +154,16 @@ pub struct Diff { mod tests { use super::*; + impl Stack { + /// A step that is expected not to fail. + fn push_ok( + &mut self, + f: impl FnOnce(&mut StackEntry) -> Result<(), CommandError>, + ) -> Option { + self.create_and_push_next(f).unwrap() + } + } + #[test] fn test_new_file() { let mut stack = Stack::default(); @@ -145,7 +172,7 @@ mod tests { assert!(!stack.can_redo()); assert!(stack.current().is_none()); - let diff = stack.create_and_push_next(|e| { + let diff = stack.push_ok(|e| { let file = Rc::new(File::default()); e.insert(file.id, file); Ok(()) @@ -167,7 +194,7 @@ mod tests { let file = Rc::new(File::default()); let id = file.id; stack - .create_and_push_next(|e| { + .push_ok(|e| { e.insert(id, file); Ok(()) }) @@ -180,9 +207,9 @@ mod tests { let mut stack = Stack::default(); let diff = stack.create_and_push_next(|e| { e.insert(FileId::default(), Rc::new(File::default())); - Err("nope".to_string()) + Err(CommandError::InvalidData("nope".to_string())) }); - assert!(diff.is_none()); + assert_eq!(diff, Err(CommandError::InvalidData("nope".to_string()))); assert!(!stack.can_undo()); assert!(stack.current().is_none()); } @@ -217,7 +244,7 @@ mod tests { let id = add_file(&mut stack); let diff = stack - .create_and_push_next(|e| { + .push_ok(|e| { let mut file = (**e.get(&id).unwrap()).clone(); file.info.name = "renamed".to_string(); e.insert(id, Rc::new(file)); @@ -227,12 +254,20 @@ mod tests { assert_eq!(diff.modified, vec![id]); assert!(diff.added.is_empty() && diff.removed.is_empty()); - // an unchanged file is not reported as modified - let diff = stack.create_and_push_next(|_| Ok(())).unwrap(); - assert!(diff.modified.is_empty()); + // a step that changes nothing is not a step + assert!(stack.push_ok(|_| Ok(())).is_none()); + assert!( + stack + .push_ok(|e| { + let file = e[&id].clone(); + e.insert(id, file); + Ok(()) + }) + .is_none() + ); let diff = stack - .create_and_push_next(|e| { + .push_ok(|e| { e.remove(&id); Ok(()) }) @@ -253,7 +288,7 @@ mod tests { // a copy, even if it has the same content, is another file for the stack: files are // told apart by their identity, comparing them would read all of them let diff = stack - .create_and_push_next(|e| { + .push_ok(|e| { let copy = (**e.get(&id).unwrap()).clone(); e.insert(id, Rc::new(copy)); Ok(()) @@ -264,7 +299,7 @@ mod tests { // and the files that were not replaced are not let other = add_file(&mut stack); let diff = stack - .create_and_push_next(|e| { + .push_ok(|e| { let copy = (**e.get(&other).unwrap()).clone(); e.insert(other, Rc::new(copy)); Ok(()) @@ -361,7 +396,7 @@ mod tests { let mut stack = Stack::restored(entry); let diff = stack - .create_and_push_next(|e| { + .push_ok(|e| { e.remove(&restored); Ok(()) }) diff --git a/gpx-rs/engine/src/engine/tests.rs b/gpx-rs/engine/src/engine/tests.rs new file mode 100644 index 000000000..c243e06c0 --- /dev/null +++ b/gpx-rs/engine/src/engine/tests.rs @@ -0,0 +1,1310 @@ +use std::collections::HashSet; + +use crate::{ + ClipboardIds, Delete, DeleteAll, Load, Metadata, New, NewTrack, Paste, Style, TrackId, + TrackSegmentId, +}; + +use super::*; + +fn expand(intervals: &crate::Intervals, len: usize) -> Vec { + let mut values = Vec::new(); + for (i, value) in intervals.values.iter().enumerate() { + let end = intervals.starts.get(i + 1).map_or(len, |end| *end as usize); + values.resize(end, *value); + } + values +} + +fn edit(engine: &mut Engine, command: Command) -> bool { + engine.run(Action::Edit(command)) +} + +fn new(engine: &mut Engine, name: &str) -> bool { + edit( + engine, + Command::New(New { + name, + trackpoint: None, + }), + ) +} + +fn load(engine: &mut Engine, path: &str) -> bool { + let data = std::fs::read(path).unwrap(); + edit( + engine, + Command::Load(Load { + data: &data, + name: "file", + }), + ) +} + +fn selected(engine: &Engine) -> Vec { + match &engine.selection { + Selection::File { file_ids } => file_ids.iter().copied().collect(), + _ => vec![], + } +} + +#[test] +fn test_load_file() { + let mut engine = Engine::default(); + assert!(load(&mut engine, "data/simple.gpx")); + assert_eq!(engine.statistics_buffer.total_distance.len(), 80); +} + +#[test] +fn test_new_file_has_no_statistics() { + let mut engine = Engine::default(); + assert!(new(&mut engine, "empty")); + assert_eq!(engine.stack.current().unwrap().len(), 1); + assert_eq!(engine.order.0.len(), 1); + assert!(engine.statistics().total_distance.is_empty()); +} + +#[test] +fn test_statistics_follow_selection() { + let mut engine = Engine::default(); + assert!(load(&mut engine, "data/simple.gpx")); + let n = engine.statistics().total_distance.len(); + assert!(n > 0); + + // a newly created (empty) file becomes the selection + assert!(new(&mut engine, "empty")); + assert!(engine.statistics().total_distance.is_empty()); + assert_eq!(engine.stack.current().unwrap().len(), 2); + + // loading a file selects it again + assert!(load(&mut engine, "data/simple.gpx")); + assert_eq!(engine.statistics().total_distance.len(), n); +} + +#[test] +fn test_invalid_load_is_rejected() { + let mut engine = Engine::default(); + assert!(!edit( + &mut engine, + Command::Load(Load { + data: b"", + name: "file", + }) + )); + assert!(engine.stack.current().is_none()); + assert!(!engine.stack.can_undo()); + assert!(engine.order.0.is_empty()); +} + +#[test] +fn test_command_with_nothing_to_do_is_rejected() { + let mut engine = Engine::default(); + new(&mut engine, "file"); + assert!(!edit(&mut engine, Command::Reverse(crate::Reverse))); + assert!(!engine.stack.can_redo()); + assert_eq!(engine.stack.current().unwrap().len(), 1); +} + +#[test] +fn test_metadata_renames_selected_file() { + let mut engine = Engine::default(); + new(&mut engine, "before"); + assert!(edit( + &mut engine, + Command::Metadata(Metadata { + name: "after", + desc: "about", + }) + )); + let file = engine.stack.current().unwrap().values().next().unwrap(); + assert_eq!(file.info.name, "after"); + assert_eq!(file.info.desc.as_deref(), Some("about")); +} + +#[test] +fn test_metadata_without_selection_does_not_modify_files() { + let mut engine = Engine::default(); + new(&mut engine, "file"); + engine.selection = Selection::Empty; + edit( + &mut engine, + Command::Metadata(Metadata { + name: "renamed", + desc: "", + }), + ); + let file = engine.stack.current().unwrap().values().next().unwrap(); + assert_eq!(file.info.name, "file"); +} + +#[test] +fn test_edit_changes_statistics_when_segments_change() { + let mut engine = Engine::default(); + load(&mut engine, "data/simple.gpx"); + let before = engine + .statistics_cache + .get(engine.stack.current(), &engine.selection)[0] as *const _; + edit(&mut engine, Command::NewTrack(NewTrack)); + edit( + &mut engine, + Command::Style(Style { + color: Some("ff0000"), + opacity: None, + width: None, + }), + ); + // styling does not touch the segments: their statistics are reused + let after = engine + .statistics_cache + .get(engine.stack.current(), &engine.selection)[0] as *const _; + assert!(std::ptr::eq(before, after)); +} + +#[test] +fn test_undo_redo_keep_selection_and_order_consistent() { + let mut engine = Engine::default(); + new(&mut engine, "a"); + let a = selected(&engine)[0]; + new(&mut engine, "b"); + let b = selected(&engine)[0]; + assert_eq!(engine.order.0, vec![a, b]); + + assert!(engine.run(Action::Undo)); + assert_eq!(engine.order.0, vec![a]); + assert!(selected(&engine).is_empty()); + + assert!(engine.run(Action::Redo)); + assert_eq!(engine.order.0, vec![a, b]); + + assert!(engine.run(Action::Undo)); + assert!(engine.run(Action::Undo)); + assert!(engine.order.0.is_empty()); + assert!(!engine.run(Action::Undo)); + assert!(engine.stack.current().is_none()); +} + +#[test] +fn test_reorder_is_not_undoable_and_returning_files_go_last() { + let mut engine = Engine::default(); + new(&mut engine, "a"); + let a = engine.order.0[0]; + new(&mut engine, "b"); + let b = engine.order.0[1]; + new(&mut engine, "c"); + let c = engine.order.0[2]; + + assert!(engine.run(Action::Reorder { + file_ids: vec![c], + index: 0 + })); + assert_eq!(engine.order.0, vec![c, a, b]); + assert!(!engine.run(Action::Reorder { + file_ids: vec![c], + index: 0 + })); + + // undoing the creation of c does not undo the reorder + assert!(engine.run(Action::Undo)); + assert_eq!(engine.order.0, vec![a, b]); + // c comes back at the end + assert!(engine.run(Action::Redo)); + assert_eq!(engine.order.0, vec![a, b, c]); +} + +#[test] +fn test_structures_and_coordinates_follow_actions() { + let mut engine = Engine::default(); + assert!(engine.order().is_empty()); + load(&mut engine, "data/simple.gpx"); + let loaded_id = engine.order()[0]; + let diff = engine.last_diff().unwrap(); + assert_eq!(diff.added, vec![loaded_id]); + assert!(engine.order_changed()); + + new(&mut engine, "empty"); + let empty_id = engine.order()[1]; + // only the new file is reported, the other one is not recomputed + let diff = engine.last_diff().unwrap(); + assert_eq!(diff.added, vec![empty_id]); + assert!(diff.modified.is_empty() && diff.removed.is_empty()); + assert_eq!(engine.file_structure(&empty_id).unwrap().name, "empty"); + + let structure = engine.file_structure(&loaded_id).unwrap(); + let seg = &structure.tracks[0].segments[0]; + let (seg_id, len) = (seg.id, seg.len); + assert_eq!(engine.segment_coordinates(&seg_id).len(), len * 2); + assert!(engine.waypoint_coordinates(&loaded_id).is_empty()); + + // reordering only changes the order + assert!(engine.run(Action::Reorder { + file_ids: vec![loaded_id], + index: 1 + })); + assert_eq!(engine.order(), [empty_id, loaded_id]); + assert!(engine.last_diff().is_none()); + assert!(engine.order_changed()); + + // an action that changes nothing reports nothing + assert!(!engine.run(Action::Reorder { + file_ids: vec![loaded_id], + index: 1 + })); + assert!(engine.last_diff().is_none()); + assert!(!engine.order_changed()); + + // undo removes the empty file, redoing the load removes the other one too + assert!(engine.run(Action::Undo)); + let diff = engine.last_diff().unwrap(); + assert_eq!(diff.removed, vec![empty_id]); + assert!(engine.order_changed()); + assert!(engine.file_structure(&empty_id).is_none()); + assert!(engine.run(Action::Undo)); + assert!(engine.order().is_empty()); + assert!(engine.file_structure(&loaded_id).is_none()); + assert!(engine.segment_coordinates(&seg_id).is_empty()); +} + +#[test] +fn test_selection_changed_flag() { + let mut engine = Engine::default(); + new(&mut engine, "a"); + let a = engine.order()[0]; + // a new file is selected by the edit + assert!(engine.selection_changed()); + assert!(!select_files(&mut engine, &[a])); + assert!(!engine.selection_changed()); + assert!(select_files(&mut engine, &[])); + assert!(engine.selection_changed()); + // undoing drops the selected file from the selection + select_files(&mut engine, &[a]); + assert!(engine.run(Action::Undo)); + assert!(engine.selection_changed()); + assert_eq!(engine.selection(), &Selection::Empty); +} + +#[test] +fn test_undo_updates_statistics() { + let mut engine = Engine::default(); + load(&mut engine, "data/simple.gpx"); + assert!(!engine.statistics().total_distance.is_empty()); + engine.run(Action::Undo); + assert!(engine.statistics().total_distance.is_empty()); + engine.run(Action::Redo); + select_all(&mut engine); + assert_eq!(engine.statistics().total_distance.len(), 80); +} + +#[test] +fn test_selection_actions() { + let mut engine = Engine::default(); + new(&mut engine, "a"); + let a = selected(&engine)[0]; + new(&mut engine, "b"); + let b = selected(&engine)[0]; + assert_eq!(selected(&engine), vec![b]); + + assert!(select_files(&mut engine, &[a])); + assert_eq!(selected(&engine), vec![a]); + + assert!(select_elements(&mut engine, files(&[b]), SelectMode::Add)); + assert_eq!(selected(&engine).len(), 2); + + select_files(&mut engine, &[]); + assert!(select_all(&mut engine)); + assert_eq!(selected(&engine).len(), 2); +} + +#[test] +fn test_selection_does_not_create_undo_steps() { + let mut engine = Engine::default(); + new(&mut engine, "a"); + select_all(&mut engine); + engine.run(Action::Undo); + assert!(engine.stack.current().is_none()); +} + +fn select_elements(engine: &mut Engine, selection: Selection, mode: SelectMode) -> bool { + engine.run(Action::Select { selection, mode }) +} + +fn files(ids: &[FileId]) -> Selection { + Selection::File { + file_ids: ids.iter().copied().collect(), + } +} + +fn select_files(engine: &mut Engine, ids: &[FileId]) -> bool { + select_elements(engine, files(ids), SelectMode::Replace) +} + +fn select_all(engine: &mut Engine) -> bool { + let ids = engine.order().to_vec(); + select_files(engine, &ids) +} + +#[test] +fn test_select_elements() { + let mut engine = Engine::default(); + load(&mut engine, "data/with_tracks_and_segments.gpx"); + let file = engine + .stack + .current() + .unwrap() + .values() + .next() + .unwrap() + .clone(); + assert!(file.trk.len() >= 2 && file.trk[0].trkseg.len() >= 2); + let (t0, t1) = (file.trk[0].id, file.trk[1].id); + let (s0, s1) = (file.trk[0].trkseg[0].id, file.trk[0].trkseg[1].id); + + // tracks, then more tracks + let tracks = |ids: &[TrackId]| Selection::Track { + file_id: file.id, + trk_ids: ids.iter().copied().collect(), + }; + assert!(select_elements( + &mut engine, + tracks(&[t0]), + SelectMode::Replace + )); + assert_eq!(engine.selection(), &tracks(&[t0])); + assert!(select_elements(&mut engine, tracks(&[t1]), SelectMode::Add)); + assert_eq!(engine.selection(), &tracks(&[t0, t1])); + + // segments of a track, added only within the same track + let segments = |trk_id, ids: &[TrackSegmentId]| Selection::TrackSegment { + file_id: file.id, + trk_id, + trkseg_ids: ids.iter().copied().collect(), + }; + assert!(select_elements( + &mut engine, + segments(t0, &[s0]), + SelectMode::Add + )); + assert_eq!(engine.selection(), &segments(t0, &[s0])); + assert!(select_elements( + &mut engine, + segments(t0, &[s1]), + SelectMode::Add + )); + assert_eq!(engine.selection(), &segments(t0, &[s0, s1])); + // the statistics follow the selected segments + assert_eq!( + engine.statistics().total_distance.len(), + file.trk[0].trkseg[0].len() + file.trk[0].trkseg[1].len() + ); + + // the waypoints node + let node = Selection::Waypoints { file_id: file.id }; + assert!(select_elements( + &mut engine, + node.clone(), + SelectMode::Replace + )); + assert!(!select_elements(&mut engine, node.clone(), SelectMode::Add)); + assert_eq!(engine.selection(), &node); + assert!(!engine.selection_changed()); + + // unknown elements and files are ignored when adding + let unknown = tracks(&[TrackId::default()]); + assert!(!select_elements( + &mut engine, + unknown.clone(), + SelectMode::Add + )); + let unknown_file = Selection::Waypoints { + file_id: FileId::default(), + }; + assert!(!select_elements( + &mut engine, + unknown_file.clone(), + SelectMode::Add + )); + assert!(!select_elements( + &mut engine, + Selection::Empty, + SelectMode::Add + )); + assert_eq!(engine.selection(), &node); + + // and select nothing otherwise + assert!(select_elements(&mut engine, unknown, SelectMode::Replace)); + assert_eq!(engine.selection(), &Selection::Empty); + select_elements(&mut engine, node, SelectMode::Replace); + assert!(select_elements( + &mut engine, + unknown_file, + SelectMode::Replace + )); + assert_eq!(engine.selection(), &Selection::Empty); +} + +#[test] +fn test_toggle_elements() { + let mut engine = Engine::default(); + load(&mut engine, "data/with_tracks_and_segments.gpx"); + let file = engine + .stack + .current() + .unwrap() + .values() + .next() + .unwrap() + .clone(); + let (t0, t1) = (file.trk[0].id, file.trk[1].id); + let tracks = |ids: &[TrackId]| Selection::Track { + file_id: file.id, + trk_ids: ids.iter().copied().collect(), + }; + let toggle = |engine: &mut Engine, ids: &[TrackId]| { + select_elements(engine, tracks(ids), SelectMode::Toggle) + }; + + select_elements(&mut engine, tracks(&[t0]), SelectMode::Replace); + assert!(toggle(&mut engine, &[t1])); + assert_eq!(engine.selection(), &tracks(&[t0, t1])); + assert!(toggle(&mut engine, &[t0])); + assert_eq!(engine.selection(), &tracks(&[t1])); + // toggling the last element deselects everything + assert!(toggle(&mut engine, &[t1])); + assert_eq!(engine.selection(), &Selection::Empty); + // unknown elements change nothing + assert!(!toggle(&mut engine, &[TrackId::default()])); + assert_eq!(engine.selection(), &Selection::Empty); +} + +#[test] +fn test_selected_elements_are_dropped_when_they_disappear() { + let mut engine = Engine::default(); + load(&mut engine, "data/with_tracks_and_segments.gpx"); + let file = engine + .stack + .current() + .unwrap() + .values() + .next() + .unwrap() + .clone(); + let trk_id = file.trk[0].id; + select_elements( + &mut engine, + Selection::Track { + file_id: file.id, + trk_ids: [trk_id].into(), + }, + SelectMode::Replace, + ); + engine.run(Action::Undo); + assert_eq!(engine.selection(), &Selection::Empty); +} + +#[test] +fn test_can_undo_and_redo() { + let mut engine = Engine::default(); + assert!(!engine.can_undo() && !engine.can_redo()); + new(&mut engine, "a"); + assert!(engine.can_undo() && !engine.can_redo()); + assert!(engine.run(Action::Undo)); + assert!(!engine.can_undo() && engine.can_redo()); + // selecting does not change the history + new(&mut engine, "b"); + let a = engine.order()[0]; + select_files(&mut engine, &[a]); + assert!(engine.can_undo() && !engine.can_redo()); + assert!(engine.run(Action::Undo)); + assert!(engine.run(Action::Redo)); + assert!(engine.can_undo() && !engine.can_redo()); +} + +#[test] +fn test_select_all_follows_the_selected_level() { + let mut engine = Engine::default(); + new(&mut engine, "a"); + new(&mut engine, "b"); + let order = engine.order().to_vec(); + select_files(&mut engine, &[order[0]]); + + // files + assert!(engine.run(Action::SelectAll)); + assert_eq!(engine.selection(), &files(&order)); + // everything is selected already + assert!(!engine.run(Action::SelectAll)); + + // the tracks of a file + let load = |engine: &mut Engine| load(engine, "data/with_tracks_and_segments.gpx"); + load(&mut engine); + let file = engine + .stack + .current() + .unwrap() + .values() + .find(|f| !f.trk.is_empty()) + .unwrap() + .clone(); + let tracks: HashSet<_> = file.trk.iter().map(|t| t.id).collect(); + assert!(tracks.len() >= 2); + select_elements( + &mut engine, + Selection::Track { + file_id: file.id, + trk_ids: [file.trk[0].id].into(), + }, + SelectMode::Replace, + ); + assert!(engine.run(Action::SelectAll)); + assert_eq!( + engine.selection(), + &Selection::Track { + file_id: file.id, + trk_ids: tracks + } + ); + + // nothing selected: all the files + select_elements(&mut engine, Selection::Empty, SelectMode::Replace); + assert!(engine.run(Action::SelectAll)); + assert!(matches!(engine.selection(), Selection::File { file_ids } if file_ids.len() == 3)); + // the waypoints node: nothing more to select + select_elements( + &mut engine, + Selection::Waypoints { file_id: file.id }, + SelectMode::Replace, + ); + assert!(!engine.run(Action::SelectAll)); + assert_eq!( + engine.selection(), + &Selection::Waypoints { file_id: file.id } + ); +} + +#[test] +fn test_arrow_select() { + let mut engine = Engine::default(); + new(&mut engine, "file"); + for _ in 0..3 { + edit(&mut engine, Command::NewTrack(NewTrack)); + } + let file = engine + .stack + .current() + .unwrap() + .values() + .next() + .unwrap() + .clone(); + let ids: Vec = file.trk.iter().map(|t| t.id).collect(); + assert_eq!(ids.len(), 3); + let tracks = |ids: &[TrackId]| Selection::Track { + file_id: file.id, + trk_ids: ids.iter().copied().collect(), + }; + let arrow = |engine: &mut Engine, down, add| engine.run(Action::ArrowSelect { down, add }); + + select_elements(&mut engine, tracks(&[ids[0]]), SelectMode::Replace); + // without shift the next one replaces the selection + assert!(arrow(&mut engine, true, false)); + assert_eq!(engine.selection(), &tracks(&[ids[1]])); + assert!(engine.selection_changed()); + // with shift it is added + assert!(arrow(&mut engine, true, true)); + assert_eq!(engine.selection(), &tracks(&[ids[1], ids[2]])); + // the selection grows from its end, and the other way round from its start + assert!(arrow(&mut engine, false, true)); + assert_eq!(engine.selection(), &tracks(&[ids[0], ids[1], ids[2]])); + // at the ends nothing happens + assert!(!arrow(&mut engine, false, false)); + assert!(!arrow(&mut engine, true, true)); + assert_eq!(engine.selection(), &tracks(&[ids[0], ids[1], ids[2]])); + // and there is nothing to move without selection + select_elements(&mut engine, Selection::Empty, SelectMode::Replace); + assert!(!arrow(&mut engine, true, false)); + assert_eq!(engine.selection(), &Selection::Empty); +} + +#[test] +fn test_arrow_select_does_not_create_undo_steps() { + let mut engine = Engine::default(); + new(&mut engine, "a"); + new(&mut engine, "b"); + let a = engine.order()[0]; + select_files(&mut engine, &[a]); + let before = engine.can_redo(); + assert!(engine.run(Action::ArrowSelect { + down: true, + add: false + })); + assert_eq!(engine.can_redo(), before); + assert_eq!(engine.selection(), &files(&[engine.order()[1]])); + assert!(engine.run(Action::Undo)); + assert_eq!(engine.order().len(), 1); +} + +/// A file named `name` with `n` tracks, selected. +fn file_with_tracks(engine: &mut Engine, name: &str, n: usize) -> FileId { + new(engine, name); + for _ in 0..n { + edit(engine, Command::NewTrack(NewTrack)); + } + *engine.order().last().unwrap() +} + +fn track_ids(engine: &Engine, id: FileId) -> Vec { + engine + .file_structure(&id) + .unwrap() + .tracks + .iter() + .map(|t| t.id) + .collect() +} + +fn paste(engine: &mut Engine) -> bool { + edit(engine, Command::Paste(Paste)) +} + +#[test] +fn test_copy_and_paste() { + let mut engine = Engine::default(); + let a = file_with_tracks(&mut engine, "a", 2); + let b = file_with_tracks(&mut engine, "b", 0); + assert!(engine.clipboard().is_none() && !engine.can_paste()); + + // nothing selected, nothing copied + select_elements(&mut engine, Selection::Empty, SelectMode::Replace); + assert!(!engine.run(Action::Copy)); + assert!(engine.clipboard().is_none()); + + let tracks = track_ids(&engine, a); + let selected_tracks = Selection::Track { + file_id: a, + trk_ids: tracks.iter().copied().collect(), + }; + select_elements(&mut engine, selected_tracks.clone(), SelectMode::Replace); + assert!(engine.run(Action::Copy)); + assert!(engine.clipboard_changed() && !engine.selection_changed()); + assert_eq!( + engine.clipboard().map(|c| (c.content.ids(), c.cut)), + Some((ClipboardIds::Tracks(tracks.clone()), false)) + ); + // tracks cannot be pasted onto themselves... but into a track, a file or the list of files + assert!(engine.can_paste()); + select_elements(&mut engine, files(&[b]), SelectMode::Replace); + assert!(engine.can_paste()); + select_elements( + &mut engine, + Selection::Waypoints { file_id: b }, + SelectMode::Replace, + ); + assert!(!engine.can_paste()); + select_elements(&mut engine, files(&[b]), SelectMode::Replace); + + // pasting is an edit + assert!(paste(&mut engine)); + assert_eq!(track_ids(&engine, a), tracks); + assert_eq!(track_ids(&engine, b).len(), 2); + // a copy stays in the clipboard, to be pasted again + assert!(engine.clipboard().is_some() && !engine.clipboard_changed()); + assert!( + matches!(engine.selection(), Selection::Track { file_id, trk_ids } if *file_id == b && trk_ids.len() == 2) + ); + assert!(engine.can_paste()); + assert!(paste(&mut engine)); + assert_eq!(track_ids(&engine, b).len(), 4); + assert_eq!(track_ids(&engine, a), tracks); + + // and it can be undone + assert!(engine.run(Action::Undo)); + assert_eq!(track_ids(&engine, b).len(), 2); + assert!(engine.run(Action::Undo)); + assert!(track_ids(&engine, b).is_empty()); +} + +#[test] +fn test_cut_and_paste_moves_after_the_paste() { + let mut engine = Engine::default(); + let a = file_with_tracks(&mut engine, "a", 2); + let b = file_with_tracks(&mut engine, "b", 0); + let tracks = track_ids(&engine, a); + select_elements( + &mut engine, + Selection::Track { + file_id: a, + trk_ids: [tracks[0]].into(), + }, + SelectMode::Replace, + ); + assert!(engine.run(Action::Cut)); + assert!(engine.clipboard().is_some_and(|c| c.cut)); + // nothing moves before the paste + assert_eq!(track_ids(&engine, a), tracks); + + select_elements(&mut engine, files(&[b]), SelectMode::Replace); + assert!(paste(&mut engine)); + assert_eq!(track_ids(&engine, a), vec![tracks[1]]); + assert_eq!(track_ids(&engine, b), vec![tracks[0]]); + assert!(engine.run(Action::Undo)); + assert_eq!(track_ids(&engine, a), tracks); + assert!(track_ids(&engine, b).is_empty()); +} + +#[test] +fn test_clipboard_survives_what_happens_to_the_elements() { + let mut engine = Engine::default(); + let a = file_with_tracks(&mut engine, "a", 2); + let tracks = track_ids(&engine, a); + select_elements( + &mut engine, + Selection::Track { + file_id: a, + trk_ids: tracks.iter().copied().collect(), + }, + SelectMode::Replace, + ); + engine.run(Action::Copy); + let clipboard = engine.clipboard().cloned(); + assert!(clipboard.is_some()); + + // one of the tracks is deleted, then the whole file: the clipboard is not affected + select_elements( + &mut engine, + Selection::Track { + file_id: a, + trk_ids: [tracks[0]].into(), + }, + SelectMode::Replace, + ); + assert!(edit( + &mut engine, + Command::Delete(Delete { whole_files: false }) + )); + select_elements(&mut engine, files(&[a]), SelectMode::Replace); + assert!(edit(&mut engine, Command::DeleteAll(DeleteAll))); + assert_eq!(engine.clipboard(), clipboard.as_ref()); + assert!(!engine.clipboard_changed()); + + // both tracks are pasted in a new file, as they were when they were copied + let b = file_with_tracks(&mut engine, "b", 0); + select_files(&mut engine, &[b]); + assert!(engine.can_paste()); + assert!(paste(&mut engine)); + let pasted = track_ids(&engine, b); + assert_eq!(pasted.len(), 2); + // copies, with new ids + assert!(pasted.iter().all(|id| !tracks.contains(id))); + // and again + assert!(paste(&mut engine)); + assert_eq!(track_ids(&engine, b).len(), 4); +} + +#[test] +fn test_copy_replaces_the_clipboard_and_does_not_change_the_history() { + let mut engine = Engine::default(); + let a = file_with_tracks(&mut engine, "a", 0); + let b = file_with_tracks(&mut engine, "b", 0); + select_files(&mut engine, &[a]); + engine.run(Action::Copy); + select_files(&mut engine, &[b]); + assert!(engine.run(Action::Cut)); + assert_eq!( + engine.clipboard().map(|c| (c.content.ids(), c.cut)), + Some((ClipboardIds::Files(vec![b]), true)) + ); + // the same elements again are a new copy + assert!(engine.run(Action::Cut)); + assert!(engine.clipboard_changed()); + // only the creations of the files are in the history + assert!(engine.run(Action::Undo)); + assert!(engine.run(Action::Undo)); + assert!(!engine.can_undo()); +} + +#[test] +fn test_move_elements_is_an_edit_and_selects_what_moved() { + use crate::{Move, MoveTarget}; + + let mut engine = Engine::default(); + let a = file_with_tracks(&mut engine, "a", 3); + let tracks = track_ids(&engine, a); + let moved = Selection::Track { + file_id: a, + trk_ids: [tracks[2]].into(), + }; + assert!(edit( + &mut engine, + Command::Move(Move { + what: moved.clone(), + to: MoveTarget::Tracks { + file_id: a, + index: 0 + }, + }) + )); + assert_eq!(track_ids(&engine, a), vec![tracks[2], tracks[0], tracks[1]]); + assert_eq!(engine.selection(), &moved); + + // invalid moves change nothing + assert!(!edit( + &mut engine, + Command::Move(Move { + what: moved, + to: MoveTarget::Waypoints { + file_id: a, + index: 0 + }, + }) + )); + + assert!(engine.run(Action::Undo)); + assert_eq!(track_ids(&engine, a), tracks); +} + +#[test] +fn test_added_files_are_reported_in_the_order_of_the_files() { + use crate::LoadFiles; + + let mut engine = Engine::default(); + let datas: Vec> = (0..12) + .map(|i| { + format!(r#"{i}"#) + .into_bytes() + }) + .collect(); + let loads = datas + .iter() + .map(|data| Load { data, name: "x" }) + .collect::>(); + assert!(edit( + &mut engine, + Command::LoadFiles(LoadFiles { files: loads }) + )); + assert_eq!(engine.last_diff().unwrap().added, engine.order()); + + // files that come back with an undo too + assert!(engine.run(Action::Undo)); + assert!(engine.run(Action::Redo)); + assert_eq!(engine.last_diff().unwrap().added, engine.order()); +} + +#[test] +fn test_load_several_files_is_one_edit() { + use crate::LoadFiles; + + let mut engine = Engine::default(); + new(&mut engine, "before"); + let gpx = |name: &str| { + format!(r#"{name}"#).into_bytes() + }; + let (a, b) = (gpx("a"), gpx("b")); + assert!(edit( + &mut engine, + Command::LoadFiles(LoadFiles { + files: vec![ + Load { + data: &a, + name: "x" + }, + Load { + data: &b, + name: "x" + }, + ], + }) + )); + assert_eq!(engine.order().len(), 3); + let diff = engine.last_diff().unwrap(); + assert_eq!(diff.added.len(), 2); + // the first one that was loaded is selected + assert_eq!(engine.selection(), &files(&[engine.order()[1]])); + + // a single undo removes both + assert!(engine.run(Action::Undo)); + assert_eq!(engine.order().len(), 1); + assert!(engine.run(Action::Redo)); + assert_eq!(engine.order().len(), 3); +} + +#[test] +fn test_create_edit_and_move_a_waypoint() { + use crate::{EditWaypoint, MoveWaypoint, NewWaypoint}; + + let mut engine = Engine::default(); + new(&mut engine, "file"); + let file = engine.order()[0]; + assert!(edit( + &mut engine, + Command::NewWaypoint(NewWaypoint { + lng: 4.0, + lat: 50.0, + ele: 10.0, + name: "created", + desc: "", + icon: "Flag", + link: "", + }) + )); + let id = engine.file_structure(&file).unwrap().waypoints[0].id; + let waypoint = engine.waypoint(&file, &id).unwrap(); + assert_eq!(waypoint.name.as_deref(), Some("created")); + assert_eq!(waypoint.sym.as_deref(), Some("Flag")); + assert_eq!(waypoint.ele, 10.0); + // unknown waypoint or file + assert!(engine.waypoint(&file, &Default::default()).is_none()); + assert!(engine.waypoint(&Default::default(), &id).is_none()); + + // no selection needed + assert!(edit( + &mut engine, + Command::MoveWaypoint(MoveWaypoint { + file_id: file, + waypoint_id: id, + lng: 5.0, + lat: 51.0, + ele: 20.0, + }) + )); + let moved = engine.waypoint(&file, &id).unwrap(); + assert_eq!( + (moved.coordinates.lng, moved.coordinates.lat, moved.ele), + (5.0, 51.0, 20.0) + ); + assert_eq!(moved.name.as_deref(), Some("created")); + // the coordinates buffer follows + assert_eq!(engine.waypoint_coordinates(&file), &[5.0, 51.0]); + + assert!(edit( + &mut engine, + Command::EditWaypoint(EditWaypoint { + file_id: file, + waypoint_id: id, + lng: 6.0, + lat: 52.0, + ele: 30.0, + name: "edited", + desc: "text", + icon: "", + link: "https://example.com", + }) + )); + let edited = engine.waypoint(&file, &id).unwrap(); + assert_eq!(edited.name.as_deref(), Some("edited")); + assert_eq!(edited.cmt.as_deref(), Some("text")); + assert!(edited.sym.is_none()); + assert_eq!(engine.waypoint_coordinates(&file), &[6.0, 52.0]); + + // each of them can be undone + assert!(engine.run(Action::Undo)); + assert_eq!( + engine.waypoint(&file, &id).unwrap().name.as_deref(), + Some("created") + ); + assert!(engine.run(Action::Undo)); + assert_eq!(engine.waypoint_coordinates(&file), &[4.0, 50.0]); + + // deleting does not need a selection either + assert!(edit( + &mut engine, + Command::DeleteWaypoint(crate::DeleteWaypoint { + file_id: file, + waypoint_id: id, + }) + )); + assert!(engine.waypoint(&file, &id).is_none()); + assert!(engine.waypoint_coordinates(&file).is_empty()); + assert!(engine.run(Action::Undo)); + assert!(engine.waypoint(&file, &id).is_some()); +} + +#[test] +fn test_categories_are_shared_and_kept_by_the_history() { + let mut engine = Engine::default(); + assert!(engine.categories().surface.names().is_empty()); + + load(&mut engine, "data/with_highway.gpx"); + assert_eq!(engine.categories().surface.names(), ["asphalt", "gravel"]); + assert_eq!( + engine.categories().highway.names(), + ["residential", "track"] + ); + // the trackpoints of the selection refer to them + assert_eq!(expand(&engine.statistics().surface, 5), [1, 1, 0, 2, 2]); + assert_eq!(expand(&engine.statistics().highway, 5), [1, 1, 0, 2, 0]); + assert_eq!( + engine.categories().sac_scale.names(), + ["mountain_hiking", "hiking"] + ); + assert_eq!(expand(&engine.statistics().sac_scale, 5), [0, 0, 0, 1, 2]); + assert_eq!(expand(&engine.statistics().mtb_scale, 5), [0, 0, 0, 1, 1]); + + // a second file goes on with the same table + load(&mut engine, "data/with_surface.gpx"); + assert_eq!( + engine.categories().surface.names(), + ["asphalt", "gravel", "cobblestone"] + ); + assert_eq!(engine.statistics().len(), 80); + let surface = expand(&engine.statistics().surface, 80); + assert_eq!(surface[0], 1); + assert_eq!(surface.iter().filter(|c| **c == 3).count(), 1); + + // undoing does not forget what was learned: the codes stay valid in every state + assert!(engine.run(Action::Undo)); + assert!(engine.run(Action::Undo)); + assert_eq!( + engine.categories().surface.names(), + ["asphalt", "gravel", "cobblestone"] + ); + assert!(engine.run(Action::Redo)); + let first = engine.order()[0]; + select_files(&mut engine, &[first]); + assert_eq!(expand(&engine.statistics().surface, 5), [1, 1, 0, 2, 2]); +} + +#[test] +fn test_trackpoint() { + let mut engine = Engine::default(); + load(&mut engine, "data/simple.gpx"); + let file = engine.order()[0]; + let structure = engine.file_structure(&file).unwrap(); + let seg = &structure.tracks[0].segments[0]; + let (seg_id, len) = (seg.id, seg.len); + let coordinates = engine.segment_coordinates(&seg_id).to_vec(); + let last = engine.trackpoint(&file, &seg_id, len - 1).unwrap(); + assert_eq!( + [last.coordinates.lng, last.coordinates.lat], + coordinates[(len - 1) * 2..] + ); + assert!(engine.trackpoint(&file, &seg_id, len).is_none()); + assert!(engine.trackpoint(&Default::default(), &seg_id, 0).is_none()); +} + +#[test] +fn test_export_writes_the_file_as_it_is() { + let mut engine = Engine::default(); + assert!( + engine + .export(&FileId::default(), ExportOptions::ALL) + .is_none() + ); + + load(&mut engine, "data/with_time.gpx"); + let id = engine.order()[0]; + let bytes = engine.export(&id, ExportOptions::ALL).unwrap(); + let again = crate::parse(&bytes, &mut engine.categories().clone()).unwrap(); + let file = engine.stack.current().unwrap()[&id].clone(); + assert_eq!(again.trk.len(), file.trk.len()); + assert_eq!(again.trk[0].trkseg[0].len(), file.trk[0].trkseg[0].len()); + assert!(again.trk[0].trkseg[0][0].time.is_some()); + + // the options leave data out + let bytes = engine.export(&id, ExportOptions::NONE).unwrap(); + let again = crate::parse(&bytes, &mut engine.categories().clone()).unwrap(); + assert!(again.trk[0].trkseg[0].iter().all(|p| p.time.is_none())); + + // a file that is gone cannot be exported + engine.run(Action::Edit(Command::DeleteAll(DeleteAll))); + assert!(engine.export(&id, ExportOptions::ALL).is_none()); +} + +#[test] +fn test_exportable_data_is_the_union_over_the_files() { + let mut engine = Engine::default(); + assert_eq!(engine.exportable_data(&[]), ExportOptions::NONE); + + load(&mut engine, "data/simple.gpx"); + load(&mut engine, "data/with_time.gpx"); + let (plain, timed) = (engine.order()[0], engine.order()[1]); + assert!(!engine.exportable_data(&[plain]).time); + assert!(engine.exportable_data(&[timed]).time); + assert!(engine.exportable_data(&[plain, timed]).time); + // unknown files have nothing + assert_eq!( + engine.exportable_data(&[FileId::default()]), + ExportOptions::NONE + ); + assert!(!engine.exportable_data(&[plain, FileId::default()]).time); +} + +#[test] +fn test_file_statistics_are_the_ones_of_the_whole_file() { + let mut engine = Engine::default(); + assert!(engine.file_statistics(&FileId::default()).is_none()); + + load(&mut engine, "data/simple.gpx"); + let id = engine.order()[0]; + let stats = engine.file_statistics(&id).unwrap(); + assert!(stats.total_distance > 0.0); + let last = engine.statistics().total_distance.last().copied().unwrap(); + assert!((stats.total_distance - last).abs() < 1e-9); + assert!(engine.file_statistics(&FileId::default()).is_none()); + + // statistics of a file do not depend on the selection + engine.run(Action::Select { + selection: Selection::Empty, + mode: SelectMode::Replace, + }); + assert_eq!( + engine.file_statistics(&id).unwrap().total_distance, + stats.total_distance + ); + + engine.run(Action::Undo); + assert!(engine.file_statistics(&id).is_none()); +} + +#[test] +fn test_reduction_distances_follow_the_selection() { + let mut engine = Engine::default(); + assert!(engine.reduction_distances().is_empty()); + + load(&mut engine, "data/simple.gpx"); + let n = engine.statistics().total_distance.len(); + let distances = engine.reduction_distances(); + assert_eq!(distances.len(), n); + assert!(distances.iter().all(|d| !d.is_nan())); + + engine.run(Action::Select { + selection: Selection::Empty, + mode: SelectMode::Replace, + }); + assert!(engine.reduction_distances().is_empty()); +} + +#[test] +fn test_routing_describes_the_anchors_of_the_selection() { + let mut engine = Engine::default(); + assert!(engine.routing().anchor_indices.is_empty()); + + load(&mut engine, "data/simple.gpx"); + let n = engine.statistics().total_distance.len() as u32; + let routing = engine.routing(); + assert_eq!(routing.segment_starts, vec![0]); + assert_eq!(routing.segment_ids.len(), 1); + // the ends of a segment are always anchors + assert_eq!(routing.anchor_indices.first(), Some(&0)); + assert_eq!(routing.anchor_indices.last(), Some(&(n - 1))); + assert_eq!(routing.anchor_indices.len(), routing.anchor_zooms.len()); + assert_eq!(routing.anchor_zooms[0], 0); + + // the revision follows what the indices refer to + let revision = routing.revision; + engine.run(Action::Select { + selection: Selection::Empty, + mode: SelectMode::Replace, + }); + assert!(engine.routing().anchor_indices.is_empty()); + assert_ne!(engine.routing().revision, revision); + // nothing changed for the routing: same revision + let revision = engine.routing().revision; + engine.run(Action::Select { + selection: Selection::Empty, + mode: SelectMode::Replace, + }); + assert_eq!(engine.routing().revision, revision); +} + +#[test] +fn test_last_error_tells_why_an_edit_did_nothing() { + let mut engine = Engine::default(); + assert!(engine.last_error().is_none()); + + // invalid data + assert!(!edit( + &mut engine, + Command::Load(Load { + data: b"", + name: "file", + }) + )); + assert!(matches!( + engine.last_error(), + Some(CommandError::InvalidData(_)) + )); + + // nothing to change + new(&mut engine, "file"); + assert!(engine.last_error().is_none()); + assert!(!edit(&mut engine, Command::Reverse(crate::Reverse))); + assert_eq!(engine.last_error(), Some(&CommandError::NothingToDo)); + + // the next action starts afresh, whatever it is + engine.run(Action::Undo); + assert!(engine.last_error().is_none()); + assert!(!edit(&mut engine, Command::Reverse(crate::Reverse))); + assert!(engine.last_error().is_some()); + new(&mut engine, "other"); + assert!(engine.last_error().is_none()); +} + +#[test] +fn test_an_edit_that_changes_nothing_is_not_an_undo_step() { + let mut engine = Engine::default(); + new(&mut engine, "file"); + // "file" is selected: naming it the same still builds a new file, but selecting nothing + // leaves nothing to rename + engine.run(Action::Select { + selection: Selection::Empty, + mode: SelectMode::Replace, + }); + assert!(!edit( + &mut engine, + Command::Metadata(Metadata { + name: "renamed", + desc: "", + }) + )); + assert_eq!(engine.last_error(), Some(&CommandError::NothingToDo)); + assert!(engine.last_diff().is_none()); + + // one step to undo: the creation + assert!(engine.run(Action::Undo)); + assert!(!engine.can_undo()); + assert!(engine.order().is_empty()); +} + +#[test] +fn test_execute_returns_what_the_action_did() { + let mut engine = Engine::default(); + let data = std::fs::read("data/simple.gpx").unwrap(); + let outcome = engine.execute(Action::Edit(Command::Load(Load { + data: &data, + name: "file", + }))); + assert!(outcome.changed && outcome.error.is_none()); + assert_eq!(outcome.diff.as_ref().unwrap().added, engine.order()); + assert!(outcome.order_changed && outcome.selection_changed); + assert!(!outcome.clipboard_changed); + + // the outcome of an action does not depend on what is done after it + let kept = outcome.clone(); + engine.execute(Action::Undo); + assert_eq!(outcome, kept); + + // a selection only changes the selection + engine.execute(Action::Redo); + let outcome = engine.execute(Action::Select { + selection: files(engine.order()), + mode: SelectMode::Replace, + }); + assert!(outcome.changed && outcome.selection_changed); + assert!(outcome.diff.is_none() && !outcome.order_changed); + + // an edit with nothing to do says why + engine.execute(Action::Select { + selection: Selection::Empty, + mode: SelectMode::Replace, + }); + let outcome = engine.execute(Action::Edit(Command::Reverse(crate::Reverse))); + assert!(!outcome.changed); + assert_eq!(outcome.error, Some(CommandError::NothingToDo)); + assert_eq!(outcome.diff, None); + + // a failing one too + let outcome = engine.execute(Action::Edit(Command::Load(Load { + data: b"", + name: "file", + }))); + assert!(!outcome.changed); + assert!(matches!(outcome.error, Some(CommandError::InvalidData(_)))); +} diff --git a/gpx-rs/engine/src/storage/codec.rs b/gpx-rs/engine/src/storage/codec.rs deleted file mode 100644 index bd151a2c4..000000000 --- a/gpx-rs/engine/src/storage/codec.rs +++ /dev/null @@ -1,286 +0,0 @@ -//! The binary form of what is stored: a version byte, then the data (postcard). - -use std::rc::Rc; - -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::{ - File, FileInfo, Track, TrackId, TrackInfo, TrackSegment, TrackSegmentId, Trackpoint, - TrackpointChunk, TrackpointChunkId, Waypoint, WaypointChunk, WaypointChunkId, Waypoints, -}; - -/// Changes when the form of the records does. -const VERSION: u8 = 1; - -pub fn encode(value: &T) -> Vec { - postcard::to_extend(value, vec![VERSION]).expect("serializing to memory") -} - -pub fn decode<'a, T: Deserialize<'a>>(bytes: &'a [u8]) -> Option { - match bytes.split_first() { - Some((&VERSION, data)) => postcard::from_bytes(data).ok(), - _ => None, - } -} - -/// A file without its trackpoints and waypoints, which are in chunks. -#[derive(Serialize, Deserialize)] -pub struct FileRecord { - info: FileInfo, - tracks: Vec, - waypoints: Vec, -} - -#[derive(Serialize, Deserialize)] -struct TrackRecord { - id: TrackId, - info: TrackInfo, - segments: Vec, -} - -#[derive(Serialize, Deserialize)] -struct SegmentRecord { - id: TrackSegmentId, - chunks: Vec, -} - -impl FileRecord { - pub fn new(file: &File) -> Self { - Self { - info: file.info.clone(), - tracks: file - .trk - .iter() - .map(|track| TrackRecord { - id: track.id, - info: track.info.clone(), - segments: track - .trkseg - .iter() - .map(|segment| SegmentRecord { - id: segment.id, - chunks: segment.chunks().iter().map(|chunk| chunk.id).collect(), - }) - .collect(), - }) - .collect(), - waypoints: file.wpt.chunks().iter().map(|chunk| chunk.id).collect(), - } - } - - /// The file made of the record and its chunks, `None` if one is missing. Chunks that several - /// files use stay shared. - pub fn build( - self, - id: crate::FileId, - trackpoint_chunks: &impl Fn(&TrackpointChunkId) -> Option>, - waypoint_chunks: &impl Fn(&WaypointChunkId) -> Option>, - ) -> Option { - let mut file = File { - id, - info: self.info, - ..Default::default() - }; - for track in self.tracks { - let mut trkseg = Vec::with_capacity(track.segments.len()); - for segment in track.segments { - let mut built = TrackSegment::default(); - built.id = segment.id; - for chunk in &segment.chunks { - built.push_shared(trackpoint_chunks(chunk)?); - } - trkseg.push(built); - } - file.trk.push(Track { - id: track.id, - info: track.info, - trkseg, - }); - } - let mut wpt = Waypoints::default(); - for chunk in &self.waypoints { - wpt.push_shared(waypoint_chunks(chunk)?); - } - file.wpt = wpt; - Some(file) - } -} - -pub fn encode_trackpoints(chunk: &TrackpointChunk) -> Vec { - encode(&chunk.trkpt) -} - -pub fn decode_trackpoints(id: Uuid, bytes: &[u8]) -> Option { - Some(TrackpointChunk { - id: TrackpointChunkId(id), - trkpt: decode::>(bytes)?, - }) -} - -pub fn encode_waypoints(chunk: &WaypointChunk) -> Vec { - encode(&chunk.wpt) -} - -pub fn decode_waypoints(id: Uuid, bytes: &[u8]) -> Option { - Some(WaypointChunk { - id: WaypointChunkId(id), - wpt: decode::>(bytes)?, - }) -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use crate::Chunk; - - use super::*; - - fn trackpoints(n: usize) -> TrackpointChunk { - TrackpointChunk::new( - (0..n) - .map(|i| Trackpoint { - ele: i as f64, - ..Default::default() - }) - .collect(), - ) - } - - #[test] - fn test_encode_decode_round_trip() { - let value = vec![(1_u32, "a".to_string()), (2, "b".to_string())]; - let bytes = encode(&value); - assert_eq!(bytes[0], VERSION); - assert_eq!(decode::>(&bytes), Some(value)); - } - - #[test] - fn test_decode_rejects_other_versions_and_garbage() { - let mut bytes = encode(&42_u32); - assert_eq!(decode::(&bytes), Some(42)); - bytes[0] = VERSION + 1; - assert_eq!(decode::(&bytes), None); - assert_eq!(decode::(&[]), None); - // the right version, but not what was asked for - assert_eq!(decode::>(&[VERSION, 0xff, 0xff, 0xff]), None); - assert_eq!(decode::(&[VERSION]), None); - } - - #[test] - fn test_chunks_keep_their_identity_and_items() { - let chunk = trackpoints(3); - let decoded = decode_trackpoints(chunk.id.0, &encode_trackpoints(&chunk)).unwrap(); - assert_eq!(decoded.id, chunk.id); - let eles: Vec = decoded.trkpt.iter().map(|p| p.ele).collect(); - assert_eq!(eles, [0.0, 1.0, 2.0]); - assert!(decode_trackpoints(chunk.id.0, &[VERSION, 9, 9]).is_none()); - - let chunk = WaypointChunk::new(vec![Waypoint { - name: Some("summit".into()), - ..Default::default() - }]); - let decoded = decode_waypoints(chunk.id.0, &encode_waypoints(&chunk)).unwrap(); - assert_eq!(decoded.id, chunk.id); - assert_eq!(decoded.wpt[0].name.as_deref(), Some("summit")); - assert!(decode_waypoints(chunk.id.0, &[]).is_none()); - } - - fn file() -> File { - let mut file = File::default(); - file.info.name = "file".into(); - for name in ["a", "b"] { - let mut track = Track::default(); - track.info.name = Some(name.into()); - let mut segment = TrackSegment::default(); - segment.push(trackpoints(2)); - segment.push(trackpoints(1)); - track.trkseg.push(segment); - track.trkseg.push(TrackSegment::default()); - file.trk.push(track); - } - file.wpt.push(WaypointChunk::new(vec![Waypoint::default()])); - file - } - - /// The chunks of a file, by identity. - fn chunks_of( - file: &File, - ) -> ( - HashMap>, - HashMap>, - ) { - let trackpoints = file - .trk - .iter() - .flat_map(|track| &track.trkseg) - .flat_map(|segment| segment.chunks().iter().map(|c| (c.id, c.clone()))) - .collect(); - let waypoints = file - .wpt - .chunks() - .iter() - .map(|c| (c.id, c.clone())) - .collect(); - (trackpoints, waypoints) - } - - #[test] - fn test_file_record_rebuilds_the_file_with_the_same_chunks() { - let file = file(); - let (trackpoints, waypoints) = chunks_of(&file); - let record: FileRecord = decode(&encode(&FileRecord::new(&file))).unwrap(); - let built = record - .build(file.id, &|id| trackpoints.get(id).cloned(), &|id| { - waypoints.get(id).cloned() - }) - .unwrap(); - - assert_eq!(built.id, file.id); - assert_eq!(built.info, file.info); - assert_eq!(built.trk.len(), 2); - for (built, original) in built.trk.iter().zip(&file.trk) { - assert_eq!(built.id, original.id); - assert_eq!(built.info, original.info); - assert_eq!(built.trkseg.len(), 2); - for (built, original) in built.trkseg.iter().zip(&original.trkseg) { - assert_eq!(built.id, original.id); - assert_eq!(built.len(), original.len()); - for (a, b) in built.chunks().iter().zip(original.chunks()) { - // the chunks are the ones that were given, not copies - assert!(Rc::ptr_eq(a, b)); - } - } - } - assert_eq!(built.wpt.len(), 1); - assert!(Rc::ptr_eq(&built.wpt.chunks()[0], &file.wpt.chunks()[0])); - } - - #[test] - fn test_file_record_of_a_missing_chunk_builds_nothing() { - let file = file(); - let (trackpoints, waypoints) = chunks_of(&file); - - let record = || FileRecord::new(&file); - assert!( - record() - .build(file.id, &|_| None, &|id| waypoints.get(id).cloned()) - .is_none() - ); - assert!( - record() - .build(file.id, &|id| trackpoints.get(id).cloned(), &|_| None) - .is_none() - ); - } - - #[test] - fn test_file_record_without_data_builds_an_empty_file() { - let file = File::default(); - let built = FileRecord::new(&file) - .build(file.id, &|_| None, &|_| None) - .unwrap(); - assert!(built.trk.is_empty() && built.wpt.is_empty()); - } -} diff --git a/gpx-rs/engine/src/storage/codec/mod.rs b/gpx-rs/engine/src/storage/codec/mod.rs new file mode 100644 index 000000000..187c68cd0 --- /dev/null +++ b/gpx-rs/engine/src/storage/codec/mod.rs @@ -0,0 +1,579 @@ +//! The binary form of what is stored. +//! +//! Every blob starts with a version byte, then the data (postcard, which does not name the fields: +//! the form is exactly the types that wrote it). So a form never changes once it has been +//! written: the types of each version live in their own module (see [`v1`]) and are frozen, and +//! a change is a new version next to them. Blobs of any known version can be read; they are +//! written again in the latest version when they are written again. Chunks are never rewritten +//! unless they change, so blobs of several versions live together in a storage. +//! +//! To change the form: add `vN`, make `LATEST` its number, convert from it in the decoders, and +//! keep the golden bytes of the older versions in the tests. + +mod v1; + +use std::rc::Rc; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + File, FileId, FileInfo, Track, TrackId, TrackInfo, TrackSegment, TrackSegmentId, Trackpoint, + TrackpointCategories, TrackpointChunk, TrackpointChunkId, Waypoint, WaypointChunk, + WaypointChunkId, Waypoints, +}; + +/// The version that is written. +pub const LATEST: u8 = 1; + +/// Why a blob could not be read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DecodeError { + /// It was written by a version that this one does not know: a newer one, or not a blob. + UnknownVersion(u8), + /// It has a known version, but not what it should have. + Corrupt, +} + +fn encode(value: &T) -> Vec { + postcard::to_extend(value, vec![LATEST]).expect("serializing to memory") +} + +/// The version of a blob, and its data. +fn split(bytes: &[u8]) -> Result<(u8, &[u8]), DecodeError> { + bytes + .split_first() + .map(|(v, data)| (*v, data)) + .ok_or(DecodeError::Corrupt) +} + +fn parse<'a, T: Deserialize<'a>>(data: &'a [u8]) -> Result { + postcard::from_bytes(data).map_err(|_| DecodeError::Corrupt) +} + +/// A file without its trackpoints and waypoints, which are in chunks. +pub struct FileRecord { + info: FileInfo, + tracks: Vec, + waypoints: Vec, +} + +struct TrackRecord { + id: TrackId, + info: TrackInfo, + segments: Vec, +} + +struct SegmentRecord { + id: TrackSegmentId, + chunks: Vec, +} + +impl From for FileRecord { + fn from(record: v1::FileRecord1) -> Self { + Self { + info: record.info.into(), + tracks: record + .tracks + .into_iter() + .map(|track| TrackRecord { + id: TrackId(track.id), + info: track.info.into(), + segments: track + .segments + .into_iter() + .map(|segment| SegmentRecord { + id: TrackSegmentId(segment.id), + chunks: segment.chunks.into_iter().map(TrackpointChunkId).collect(), + }) + .collect(), + }) + .collect(), + waypoints: record.waypoints.into_iter().map(WaypointChunkId).collect(), + } + } +} + +impl FileRecord { + /// The file made of the record and its chunks, `None` if one is missing. Chunks that several + /// files use stay shared. + pub fn build( + self, + id: FileId, + trackpoint_chunks: &impl Fn(&TrackpointChunkId) -> Option>, + waypoint_chunks: &impl Fn(&WaypointChunkId) -> Option>, + ) -> Option { + let mut file = File { + id, + info: self.info, + ..Default::default() + }; + for track in self.tracks { + let mut trkseg = Vec::with_capacity(track.segments.len()); + for segment in track.segments { + let mut built = TrackSegment::default(); + built.id = segment.id; + for chunk in &segment.chunks { + built.push_shared(trackpoint_chunks(chunk)?); + } + trkseg.push(built); + } + file.trk.push(Track { + id: track.id, + info: track.info, + trkseg, + }); + } + let mut wpt = Waypoints::default(); + for chunk in &self.waypoints { + wpt.push_shared(waypoint_chunks(chunk)?); + } + file.wpt = wpt; + Some(file) + } +} + +pub fn encode_file(file: &File) -> Vec { + encode(&v1::FileRecord1::new(file)) +} + +pub fn decode_file(bytes: &[u8]) -> Result { + match split(bytes)? { + (1, data) => Ok(parse::(data)?.into()), + (version, _) => Err(DecodeError::UnknownVersion(version)), + } +} + +pub fn encode_trackpoints(chunk: &TrackpointChunk) -> Vec { + encode( + &chunk + .trkpt + .iter() + .map(v1::Trackpoint1::from) + .collect::>(), + ) +} + +pub fn decode_trackpoints(id: Uuid, bytes: &[u8]) -> Result { + let trkpt = match split(bytes)? { + (1, data) => parse::>(data)? + .into_iter() + .map(Trackpoint::from) + .collect(), + (version, _) => return Err(DecodeError::UnknownVersion(version)), + }; + Ok(TrackpointChunk { + id: TrackpointChunkId(id), + trkpt, + }) +} + +pub fn encode_waypoints(chunk: &WaypointChunk) -> Vec { + encode( + &chunk + .wpt + .iter() + .map(v1::Waypoint1::from) + .collect::>(), + ) +} + +pub fn decode_waypoints(id: Uuid, bytes: &[u8]) -> Result { + let wpt = match split(bytes)? { + (1, data) => parse::>(data)? + .into_iter() + .map(Waypoint::from) + .collect(), + (version, _) => return Err(DecodeError::UnknownVersion(version)), + }; + Ok(WaypointChunk { + id: WaypointChunkId(id), + wpt, + }) +} + +pub fn encode_order(order: &[FileId]) -> Vec { + encode(&order.iter().map(|id| id.0).collect::>()) +} + +pub fn decode_order(bytes: &[u8]) -> Result, DecodeError> { + match split(bytes)? { + (1, data) => Ok(parse::>(data)?.into_iter().map(FileId).collect()), + (version, _) => Err(DecodeError::UnknownVersion(version)), + } +} + +pub fn encode_categories(categories: &TrackpointCategories) -> Vec { + encode(&v1::Categories1::from(categories)) +} + +pub fn decode_categories(bytes: &[u8]) -> Result { + match split(bytes)? { + (1, data) => Ok(parse::(data)?.into()), + (version, _) => Err(DecodeError::UnknownVersion(version)), + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use crate::{Author, Link, LngLat}; + + use super::*; + + fn id(n: u128) -> Uuid { + Uuid::from_u128(n) + } + + fn link(href: &str) -> Link { + Link { + href: href.into(), + text: Some(format!("text of {href}")), + } + } + + fn trackpoint(n: u8) -> Trackpoint { + let f = f64::from(n); + Trackpoint { + coordinates: LngLat { + lng: 4.0 + f, + lat: 50.0 + f, + }, + ele: 100.5 + f, + time: Some(1_700_000_000_000 + i64::from(n)), + atemp: Some(-3), + hr: Some(140 + u16::from(n)), + cad: Some(80), + power: Some(250), + surface: Some(1), + highway: Some(2), + sac_scale: Some(3), + mtb_scale: Some(4), + anchor: Some(n), + } + } + + fn waypoint() -> Waypoint { + Waypoint { + id: crate::WaypointId(id(7)), + coordinates: LngLat { + lng: 5.5, + lat: 51.5, + }, + ele: 321.0, + time: Some(1_700_000_000_000), + name: Some("summit".into()), + cmt: Some("comment".into()), + desc: Some("description".into()), + link: Some(link("https://example.com/summit")), + sym: Some("Summit".into()), + type_: Some("peak".into()), + } + } + + /// A file that has a value for every field, with fixed ids. + fn sample() -> File { + let mut file = File { + id: FileId(id(1)), + info: FileInfo { + name: "sample".into(), + desc: Some("about".into()), + author: Some(Author { + name: Some("someone".into()), + email: Some("someone@example.com".into()), + link: Some(link("https://example.com/someone")), + }), + link: Some(link("https://example.com/file")), + time: Some(1_700_000_000_000), + }, + ..Default::default() + }; + let mut segment = TrackSegment::default(); + segment.id = TrackSegmentId(id(3)); + segment.push(TrackpointChunk { + id: TrackpointChunkId(id(4)), + trkpt: vec![trackpoint(0), trackpoint(1), Trackpoint::default()], + }); + let mut empty = TrackSegment::default(); + empty.id = TrackSegmentId(id(6)); + file.trk.push(Track { + id: TrackId(id(2)), + info: TrackInfo { + name: Some("track".into()), + cmt: Some("cmt".into()), + desc: Some("desc".into()), + src: Some("src".into()), + link: Some(link("https://example.com/track")), + type_: Some("Cycling".into()), + color: Some("ff0000".into()), + opacity: Some(0.5), + width: Some(6.0), + }, + trkseg: vec![segment, empty], + }); + file.wpt.push(WaypointChunk { + id: WaypointChunkId(id(5)), + wpt: vec![ + waypoint(), + Waypoint { + id: crate::WaypointId(id(8)), + ..Default::default() + }, + ], + }); + file + } + + fn categories() -> TrackpointCategories { + let mut categories = TrackpointCategories::default(); + categories.surface.code("asphalt"); + categories.surface.code("gravel"); + categories.highway.code("path"); + categories.sac_scale.code("hiking"); + categories.mtb_scale.code("mtb0"); + categories + } + + fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() + } + + fn unhex(text: &str) -> Vec { + (0..text.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&text[i..i + 2], 16).unwrap()) + .collect() + } + + /// The chunks of a file, by identity. + fn chunks_of( + file: &File, + ) -> ( + HashMap>, + HashMap>, + ) { + let trackpoints = file + .trk + .iter() + .flat_map(|track| &track.trkseg) + .flat_map(|segment| segment.chunks().iter().map(|c| (c.id, c.clone()))) + .collect(); + let waypoints = file + .wpt + .chunks() + .iter() + .map(|c| (c.id, c.clone())) + .collect(); + (trackpoints, waypoints) + } + + fn rebuilt(file: &File, record: FileRecord) -> Option { + let (trackpoints, waypoints) = chunks_of(file); + record.build(file.id, &|id| trackpoints.get(id).cloned(), &|id| { + waypoints.get(id).cloned() + }) + } + + fn assert_same_file(a: &File, b: &File) { + assert_eq!(a.id, b.id); + assert_eq!(a.info, b.info); + assert_eq!(a.trk.len(), b.trk.len()); + for (a, b) in a.trk.iter().zip(&b.trk) { + assert_eq!((a.id, &a.info), (b.id, &b.info)); + assert_eq!(a.trkseg.len(), b.trkseg.len()); + for (a, b) in a.trkseg.iter().zip(&b.trkseg) { + assert_eq!(a.id, b.id); + assert_eq!(a.len(), b.len()); + for (a, b) in a.chunks().iter().zip(b.chunks()) { + assert!(Rc::ptr_eq(a, b), "the chunks are the ones that were given"); + } + } + } + assert_eq!(a.wpt.len(), b.wpt.len()); + for (a, b) in a.wpt.chunks().iter().zip(b.wpt.chunks()) { + assert!(Rc::ptr_eq(a, b)); + } + } + + #[test] + fn test_a_file_record_rebuilds_the_file_with_the_same_chunks() { + let file = sample(); + let record = decode_file(&encode_file(&file)).unwrap(); + assert_same_file(&rebuilt(&file, record).unwrap(), &file); + } + + #[test] + fn test_a_file_record_of_a_missing_chunk_builds_nothing() { + let file = sample(); + let (trackpoints, waypoints) = chunks_of(&file); + let record = || decode_file(&encode_file(&file)).unwrap(); + assert!( + record() + .build(file.id, &|_| None, &|id| waypoints.get(id).cloned()) + .is_none() + ); + assert!( + record() + .build(file.id, &|id| trackpoints.get(id).cloned(), &|_| None) + .is_none() + ); + } + + #[test] + fn test_a_file_without_data_builds_an_empty_file() { + let file = File::default(); + let built = decode_file(&encode_file(&file)) + .unwrap() + .build(file.id, &|_| None, &|_| None) + .unwrap(); + assert!(built.trk.is_empty() && built.wpt.is_empty()); + } + + #[test] + fn test_chunks_keep_their_identity_and_items() { + let chunk = &sample().trk[0].trkseg[0].chunks()[0].clone(); + let decoded = decode_trackpoints(chunk.id.0, &encode_trackpoints(chunk)).unwrap(); + assert_eq!(decoded.id, chunk.id); + assert_eq!(decoded.trkpt.len(), 3); + for (a, b) in decoded.trkpt.iter().zip(&chunk.trkpt) { + assert_eq!(format!("{a:?}"), format!("{b:?}")); + } + + let chunk = &sample().wpt.chunks()[0].clone(); + let decoded = decode_waypoints(chunk.id.0, &encode_waypoints(chunk)).unwrap(); + assert_eq!(decoded.id, chunk.id); + for (a, b) in decoded.wpt.iter().zip(&chunk.wpt) { + assert_eq!(a.id, b.id); + assert!(a.same_content(b)); + } + } + + #[test] + fn test_order_and_categories_round_trip() { + let order = vec![FileId(id(9)), FileId(id(8))]; + assert_eq!(decode_order(&encode_order(&order)).unwrap(), order); + let categories = categories(); + assert_eq!( + decode_categories(&encode_categories(&categories)).unwrap(), + categories + ); + } + + #[test] + fn test_blobs_say_their_version_and_unknown_ones_are_told_apart_from_corrupt_ones() { + assert_eq!(encode_file(&sample())[0], LATEST); + assert_eq!(encode_order(&[])[0], LATEST); + + let mut bytes = encode_order(&[FileId(id(1))]); + bytes[0] = LATEST + 1; + assert_eq!( + decode_order(&bytes), + Err(DecodeError::UnknownVersion(LATEST + 1)) + ); + assert_eq!(decode_order(&[0]), Err(DecodeError::UnknownVersion(0))); + assert!(matches!( + decode_file(&bytes), + Err(DecodeError::UnknownVersion(_)) + )); + assert!(matches!( + decode_trackpoints(id(1), &bytes), + Err(DecodeError::UnknownVersion(_)) + )); + assert!(matches!( + decode_waypoints(id(1), &bytes), + Err(DecodeError::UnknownVersion(_)) + )); + assert!(matches!( + decode_categories(&bytes), + Err(DecodeError::UnknownVersion(_)) + )); + + // nothing at all, or a known version with something else + assert_eq!(decode_order(&[]), Err(DecodeError::Corrupt)); + assert_eq!( + decode_file(&[LATEST, 0xff, 0xff]).err(), + Some(DecodeError::Corrupt) + ); + assert_eq!( + decode_trackpoints(id(1), &[LATEST, 9, 9, 9]).err(), + Some(DecodeError::Corrupt) + ); + assert_eq!(decode_categories(&[LATEST]), Err(DecodeError::Corrupt)); + } + + // The stored forms of version 1, as they were written when the version was made. They must + // stay readable, whatever happens to the engine types: if a test below fails, the form was + // changed by mistake, or a version was not added when it should have been. + const FILE_V1: &str = concat!( + "010673616d706c65010561626f7574010107736f6d656f6e650113736f6d656f6e65406578616d706c652e63", + "6f6d011b68747470733a2f2f6578616d706c652e636f6d2f736f6d656f6e65012374657874206f6620687474", + "70733a2f2f6578616d706c652e636f6d2f736f6d656f6e65011868747470733a2f2f6578616d706c652e636f", + "6d2f66696c65012074657874206f662068747470733a2f2f6578616d706c652e636f6d2f66696c650180a0ab", + "fef9620110000000000000000000000000000000020105747261636b0103636d740104646573630103737263", + "011968747470733a2f2f6578616d706c652e636f6d2f747261636b012174657874206f662068747470733a2f", + "2f6578616d706c652e636f6d2f747261636b01074379636c696e67010666663030303001000000000000e03f", + "0100000000000018400210000000000000000000000000000000030110000000000000000000000000000000", + "04100000000000000000000000000000000600011000000000000000000000000000000005", + ); + const TRACKPOINTS_V1: &str = concat!( + "01030000000000001040000000000000494000000000002059400180a0abfef9620105018c01015001fa0101", + "0101020103010401000000000000001440000000000080494000000000006059400182a0abfef9620105018d", + "01015001fa010101010201030104010100000000000000000000000000000000000000000000000000000000", + "000000000000", + ); + const WAYPOINTS_V1: &str = concat!( + "0102100000000000000000000000000000000700000000000016400000000000c04940000000000010744001", + "80a0abfef962010673756d6d69740107636f6d6d656e74010b6465736372697074696f6e011a68747470733a", + "2f2f6578616d706c652e636f6d2f73756d6d6974012274657874206f662068747470733a2f2f6578616d706c", + "652e636f6d2f73756d6d6974010653756d6d697401047065616b100000000000000000000000000000000800", + "000000000000000000000000000000000000000000000000000000000000", + ); + const ORDER_V1: &str = + "010210000000000000000000000000000000091000000000000000000000000000000008"; + const CATEGORIES_V1: &str = + "01020761737068616c740667726176656c010470617468010668696b696e6701046d746230"; + + #[test] + fn test_version_1_is_still_written_as_it_was() { + let file = sample(); + assert_eq!(hex(&encode_file(&file)), FILE_V1); + assert_eq!( + hex(&encode_trackpoints(&file.trk[0].trkseg[0].chunks()[0])), + TRACKPOINTS_V1 + ); + assert_eq!(hex(&encode_waypoints(&file.wpt.chunks()[0])), WAYPOINTS_V1); + assert_eq!( + hex(&encode_order(&[FileId(id(9)), FileId(id(8))])), + ORDER_V1 + ); + assert_eq!(hex(&encode_categories(&categories())), CATEGORIES_V1); + } + + #[test] + fn test_version_1_is_still_read_as_it_was() { + let file = sample(); + assert_same_file( + &rebuilt(&file, decode_file(&unhex(FILE_V1)).unwrap()).unwrap(), + &file, + ); + let chunk = decode_trackpoints(id(4), &unhex(TRACKPOINTS_V1)).unwrap(); + let expected = &file.trk[0].trkseg[0].chunks()[0]; + assert_eq!( + format!("{:?}", chunk.trkpt), + format!("{:?}", expected.trkpt) + ); + let chunk = decode_waypoints(id(5), &unhex(WAYPOINTS_V1)).unwrap(); + assert_eq!(chunk.wpt.len(), 2); + assert!(chunk.wpt[0].same_content(&waypoint())); + assert_eq!(chunk.wpt[0].id, waypoint().id); + assert_eq!( + decode_order(&unhex(ORDER_V1)).unwrap(), + vec![FileId(id(9)), FileId(id(8))] + ); + assert_eq!( + decode_categories(&unhex(CATEGORIES_V1)).unwrap(), + categories() + ); + } +} diff --git a/gpx-rs/engine/src/storage/codec/v1.rs b/gpx-rs/engine/src/storage/codec/v1.rs new file mode 100644 index 000000000..31a1541b4 --- /dev/null +++ b/gpx-rs/engine/src/storage/codec/v1.rs @@ -0,0 +1,318 @@ +//! Version 1 of the stored form. It is frozen: the engine types may change, these may not (a test +//! compares the bytes they give with the ones of a fixture). A change of the form is a new +//! version, next to this one, that the decoders convert from. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + Author, Categories, File, FileInfo, Link, LngLat, TrackInfo, Trackpoint, TrackpointCategories, + Waypoint, WaypointId, +}; + +#[derive(Serialize, Deserialize)] +pub struct Link1 { + href: String, + text: Option, +} + +impl From<&Link> for Link1 { + fn from(link: &Link) -> Self { + Self { + href: link.href.clone(), + text: link.text.clone(), + } + } +} + +impl From for Link { + fn from(link: Link1) -> Self { + Self { + href: link.href, + text: link.text, + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct Author1 { + name: Option, + email: Option, + link: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct FileInfo1 { + name: String, + desc: Option, + author: Option, + link: Option, + time: Option, +} + +impl From<&FileInfo> for FileInfo1 { + fn from(info: &FileInfo) -> Self { + Self { + name: info.name.clone(), + desc: info.desc.clone(), + author: info.author.as_ref().map(|author| Author1 { + name: author.name.clone(), + email: author.email.clone(), + link: author.link.as_ref().map(Into::into), + }), + link: info.link.as_ref().map(Into::into), + time: info.time, + } + } +} + +impl From for FileInfo { + fn from(info: FileInfo1) -> Self { + Self { + name: info.name, + desc: info.desc, + author: info.author.map(|author| Author { + name: author.name, + email: author.email, + link: author.link.map(Into::into), + }), + link: info.link.map(Into::into), + time: info.time, + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct TrackInfo1 { + name: Option, + cmt: Option, + desc: Option, + src: Option, + link: Option, + type_: Option, + color: Option, + opacity: Option, + width: Option, +} + +impl From<&TrackInfo> for TrackInfo1 { + fn from(info: &TrackInfo) -> Self { + Self { + name: info.name.clone(), + cmt: info.cmt.clone(), + desc: info.desc.clone(), + src: info.src.clone(), + link: info.link.as_ref().map(Into::into), + type_: info.type_.clone(), + color: info.color.clone(), + opacity: info.opacity, + width: info.width, + } + } +} + +impl From for TrackInfo { + fn from(info: TrackInfo1) -> Self { + Self { + name: info.name, + cmt: info.cmt, + desc: info.desc, + src: info.src, + link: info.link.map(Into::into), + type_: info.type_, + color: info.color, + opacity: info.opacity, + width: info.width, + } + } +} + +/// A file without its trackpoints and waypoints, which are in chunks. +#[derive(Serialize, Deserialize)] +pub struct FileRecord1 { + pub info: FileInfo1, + pub tracks: Vec, + pub waypoints: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct TrackRecord1 { + pub id: Uuid, + pub info: TrackInfo1, + pub segments: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct SegmentRecord1 { + pub id: Uuid, + pub chunks: Vec, +} + +impl FileRecord1 { + pub fn new(file: &File) -> Self { + Self { + info: (&file.info).into(), + tracks: file + .trk + .iter() + .map(|track| TrackRecord1 { + id: track.id.0, + info: (&track.info).into(), + segments: track + .trkseg + .iter() + .map(|segment| SegmentRecord1 { + id: segment.id.0, + chunks: segment.chunks().iter().map(|chunk| chunk.id.0).collect(), + }) + .collect(), + }) + .collect(), + waypoints: file.wpt.chunks().iter().map(|chunk| chunk.id.0).collect(), + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct Trackpoint1 { + lng: f64, + lat: f64, + ele: f64, + time: Option, + atemp: Option, + hr: Option, + cad: Option, + power: Option, + surface: Option, + highway: Option, + sac_scale: Option, + mtb_scale: Option, + anchor: Option, +} + +impl From<&Trackpoint> for Trackpoint1 { + fn from(point: &Trackpoint) -> Self { + Self { + lng: point.coordinates.lng, + lat: point.coordinates.lat, + ele: point.ele, + time: point.time, + atemp: point.atemp, + hr: point.hr, + cad: point.cad, + power: point.power, + surface: point.surface, + highway: point.highway, + sac_scale: point.sac_scale, + mtb_scale: point.mtb_scale, + anchor: point.anchor, + } + } +} + +impl From for Trackpoint { + fn from(point: Trackpoint1) -> Self { + Self { + coordinates: LngLat { + lng: point.lng, + lat: point.lat, + }, + ele: point.ele, + time: point.time, + atemp: point.atemp, + hr: point.hr, + cad: point.cad, + power: point.power, + surface: point.surface, + highway: point.highway, + sac_scale: point.sac_scale, + mtb_scale: point.mtb_scale, + anchor: point.anchor, + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct Waypoint1 { + id: Uuid, + lng: f64, + lat: f64, + ele: f64, + time: Option, + name: Option, + cmt: Option, + desc: Option, + link: Option, + sym: Option, + type_: Option, +} + +impl From<&Waypoint> for Waypoint1 { + fn from(waypoint: &Waypoint) -> Self { + Self { + id: waypoint.id.0, + lng: waypoint.coordinates.lng, + lat: waypoint.coordinates.lat, + ele: waypoint.ele, + time: waypoint.time, + name: waypoint.name.clone(), + cmt: waypoint.cmt.clone(), + desc: waypoint.desc.clone(), + link: waypoint.link.as_ref().map(Into::into), + sym: waypoint.sym.clone(), + type_: waypoint.type_.clone(), + } + } +} + +impl From for Waypoint { + fn from(waypoint: Waypoint1) -> Self { + Self { + id: WaypointId(waypoint.id), + coordinates: LngLat { + lng: waypoint.lng, + lat: waypoint.lat, + }, + ele: waypoint.ele, + time: waypoint.time, + name: waypoint.name, + cmt: waypoint.cmt, + desc: waypoint.desc, + link: waypoint.link.map(Into::into), + sym: waypoint.sym, + type_: waypoint.type_, + } + } +} + +/// The names of each table of categories, in the order of their codes. +#[derive(Serialize, Deserialize)] +pub struct Categories1 { + surface: Vec, + highway: Vec, + sac_scale: Vec, + mtb_scale: Vec, +} + +impl From<&TrackpointCategories> for Categories1 { + fn from(categories: &TrackpointCategories) -> Self { + let names = |table: &Categories| table.names().to_vec(); + Self { + surface: names(&categories.surface), + highway: names(&categories.highway), + sac_scale: names(&categories.sac_scale), + mtb_scale: names(&categories.mtb_scale), + } + } +} + +impl From for TrackpointCategories { + fn from(categories: Categories1) -> Self { + Self { + surface: Categories::from_names(categories.surface), + highway: Categories::from_names(categories.highway), + sac_scale: Categories::from_names(categories.sac_scale), + mtb_scale: Categories::from_names(categories.mtb_scale), + } + } +} diff --git a/gpx-rs/engine/src/storage/memory.rs b/gpx-rs/engine/src/storage/memory.rs index a1cc20c6d..ad784d756 100644 --- a/gpx-rs/engine/src/storage/memory.rs +++ b/gpx-rs/engine/src/storage/memory.rs @@ -1,9 +1,7 @@ use std::cell::RefCell; use std::collections::HashMap; -use uuid::Uuid; - -use crate::{Batch, FileId, Storage, StorageError, StoredData}; +use crate::{Batch, ChunkKey, ChunkKind, FileId, Storage, StorageError, StoredData}; /// A storage that lives as long as the session. It is what an engine without a host storage uses, /// and what the tests use. @@ -15,22 +13,30 @@ pub struct MemoryStorage { #[derive(Debug, Default)] struct State { files: HashMap>, - trackpoint_chunks: HashMap>, - waypoint_chunks: HashMap>, + chunks: HashMap>, order: Option>, categories: Option>, settings: HashMap, } +/// How much there is in a storage. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StorageCounts { + pub files: usize, + pub trackpoint_chunks: usize, + pub waypoint_chunks: usize, +} + impl MemoryStorage { - /// How many files and chunks (trackpoints, waypoints) are stored. - pub fn counts(&self) -> (usize, usize, usize) { + /// How many files and chunks are stored. + pub fn counts(&self) -> StorageCounts { let state = self.state.borrow(); - ( - state.files.len(), - state.trackpoint_chunks.len(), - state.waypoint_chunks.len(), - ) + let chunks = |kind| state.chunks.keys().filter(|key| key.kind == kind).count(); + StorageCounts { + files: state.files.len(), + trackpoint_chunks: chunks(ChunkKind::Trackpoints), + waypoint_chunks: chunks(ChunkKind::Waypoints), + } } } @@ -39,16 +45,7 @@ impl Storage for MemoryStorage { let state = self.state.borrow(); Ok(StoredData { files: state.files.iter().map(|(k, v)| (*k, v.clone())).collect(), - trackpoint_chunks: state - .trackpoint_chunks - .iter() - .map(|(k, v)| (*k, v.clone())) - .collect(), - waypoint_chunks: state - .waypoint_chunks - .iter() - .map(|(k, v)| (*k, v.clone())) - .collect(), + chunks: state.chunks.iter().map(|(k, v)| (*k, v.clone())).collect(), order: state.order.clone(), categories: state.categories.clone(), }) @@ -60,17 +57,9 @@ impl Storage for MemoryStorage { for id in &batch.delete_files { state.files.remove(id); } - state - .trackpoint_chunks - .extend(batch.put_trackpoint_chunks.iter().cloned()); - for id in &batch.delete_trackpoint_chunks { - state.trackpoint_chunks.remove(id); - } - state - .waypoint_chunks - .extend(batch.put_waypoint_chunks.iter().cloned()); - for id in &batch.delete_waypoint_chunks { - state.waypoint_chunks.remove(id); + state.chunks.extend(batch.put_chunks.iter().cloned()); + for key in &batch.delete_chunks { + state.chunks.remove(key); } if let Some(order) = &batch.order { state.order = Some(order.clone()); @@ -109,6 +98,8 @@ mod tests { use std::future::Future; use std::task::{Context, Poll, Waker}; + use uuid::Uuid; + use super::*; fn block_on(future: F) -> F::Output { @@ -120,12 +111,20 @@ mod tests { } } + fn counts(files: usize, trackpoint_chunks: usize, waypoint_chunks: usize) -> StorageCounts { + StorageCounts { + files, + trackpoint_chunks, + waypoint_chunks, + } + } + #[test] fn test_empty_storage_has_nothing() { let storage = MemoryStorage::default(); let data = block_on(storage.load()).unwrap(); assert_eq!(data, StoredData::default()); - assert_eq!(storage.counts(), (0, 0, 0)); + assert_eq!(storage.counts(), counts(0, 0, 0)); assert!(block_on(storage.load_settings()).unwrap().is_empty()); } @@ -136,20 +135,28 @@ mod tests { let (t, w) = (Uuid::new_v4(), Uuid::new_v4()); block_on(storage.commit(&Batch { put_files: vec![(a, vec![1]), (b, vec![2])], - put_trackpoint_chunks: vec![(t, vec![3])], - put_waypoint_chunks: vec![(w, vec![4])], + put_chunks: vec![ + (ChunkKey::trackpoints(t), vec![3]), + (ChunkKey::waypoints(w), vec![4]), + ], order: Some(vec![5]), categories: Some(vec![6]), ..Default::default() })) .unwrap(); - assert_eq!(storage.counts(), (2, 1, 1)); + assert_eq!(storage.counts(), counts(2, 1, 1)); let mut data = block_on(storage.load()).unwrap(); data.files.sort_by_key(|(_, bytes)| bytes.clone()); assert_eq!(data.files, vec![(a, vec![1]), (b, vec![2])]); - assert_eq!(data.trackpoint_chunks, vec![(t, vec![3])]); - assert_eq!(data.waypoint_chunks, vec![(w, vec![4])]); + data.chunks.sort_by_key(|(_, bytes)| bytes.clone()); + assert_eq!( + data.chunks, + vec![ + (ChunkKey::trackpoints(t), vec![3]), + (ChunkKey::waypoints(w), vec![4]) + ] + ); assert_eq!( (data.order, data.categories), (Some(vec![5]), Some(vec![6])) @@ -159,11 +166,11 @@ mod tests { block_on(storage.commit(&Batch { put_files: vec![(a, vec![10])], delete_files: vec![b], - delete_trackpoint_chunks: vec![t], + delete_chunks: vec![ChunkKey::trackpoints(t)], ..Default::default() })) .unwrap(); - assert_eq!(storage.counts(), (1, 0, 1)); + assert_eq!(storage.counts(), counts(1, 0, 1)); let data = block_on(storage.load()).unwrap(); assert_eq!(data.files, vec![(a, vec![10])]); assert_eq!( @@ -172,12 +179,12 @@ mod tests { ); block_on(storage.commit(&Batch { - delete_waypoint_chunks: vec![w], + delete_chunks: vec![ChunkKey::waypoints(w)], order: Some(vec![]), ..Default::default() })) .unwrap(); - assert_eq!(storage.counts(), (1, 0, 0)); + assert_eq!(storage.counts(), counts(1, 0, 0)); assert_eq!(block_on(storage.load()).unwrap().order, Some(vec![])); } diff --git a/gpx-rs/engine/src/storage/mod.rs b/gpx-rs/engine/src/storage/mod.rs index 102156a59..408fb39af 100644 --- a/gpx-rs/engine/src/storage/mod.rs +++ b/gpx-rs/engine/src/storage/mod.rs @@ -30,13 +30,42 @@ impl std::fmt::Display for StorageError { impl std::error::Error for StorageError {} +/// What a chunk holds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ChunkKind { + Trackpoints, + Waypoints, +} + +/// Where a chunk is stored: chunks are told apart by their identity within their kind. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ChunkKey { + pub kind: ChunkKind, + pub id: Uuid, +} + +impl ChunkKey { + pub fn trackpoints(id: Uuid) -> Self { + Self { + kind: ChunkKind::Trackpoints, + id, + } + } + + pub fn waypoints(id: Uuid) -> Self { + Self { + kind: ChunkKind::Waypoints, + id, + } + } +} + /// Everything that is stored about the files, as opaque bytes (see the module documentation). #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct StoredData { /// The record of each file. pub files: Vec<(FileId, Vec)>, - pub trackpoint_chunks: Vec<(Uuid, Vec)>, - pub waypoint_chunks: Vec<(Uuid, Vec)>, + pub chunks: Vec<(ChunkKey, Vec)>, /// The order of the files. pub order: Option>, /// The categories of the trackpoints, which their chunks refer to. @@ -49,10 +78,8 @@ pub struct StoredData { pub struct Batch { pub put_files: Vec<(FileId, Vec)>, pub delete_files: Vec, - pub put_trackpoint_chunks: Vec<(Uuid, Vec)>, - pub delete_trackpoint_chunks: Vec, - pub put_waypoint_chunks: Vec<(Uuid, Vec)>, - pub delete_waypoint_chunks: Vec, + pub put_chunks: Vec<(ChunkKey, Vec)>, + pub delete_chunks: Vec, pub order: Option>, pub categories: Option>, } @@ -61,6 +88,22 @@ impl Batch { pub fn is_empty(&self) -> bool { self == &Self::default() } + + /// How many chunks of the kind the batch writes. + pub fn puts(&self, kind: ChunkKind) -> usize { + self.put_chunks + .iter() + .filter(|(key, _)| key.kind == kind) + .count() + } + + /// How many chunks of the kind the batch deletes. + pub fn deletes(&self, kind: ChunkKind) -> usize { + self.delete_chunks + .iter() + .filter(|key| key.kind == kind) + .count() + } } /// Where the data is kept. The implementation belongs to the host; [`MemoryStorage`] is one that @@ -101,11 +144,11 @@ mod tests { ..Default::default() }, Batch { - delete_trackpoint_chunks: vec![Uuid::new_v4()], + put_chunks: vec![(ChunkKey::waypoints(Uuid::new_v4()), vec![])], ..Default::default() }, Batch { - delete_waypoint_chunks: vec![Uuid::new_v4()], + delete_chunks: vec![ChunkKey::trackpoints(Uuid::new_v4())], ..Default::default() }, Batch { diff --git a/gpx-rs/engine/src/storage/persister.rs b/gpx-rs/engine/src/storage/persister.rs index 89a89f9ab..69c135457 100644 --- a/gpx-rs/engine/src/storage/persister.rs +++ b/gpx-rs/engine/src/storage/persister.rs @@ -1,16 +1,17 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::{HashMap, HashSet}; use std::rc::Rc; use uuid::Uuid; use super::codec::{ - FileRecord, decode, decode_trackpoints, decode_waypoints, encode, encode_trackpoints, + DecodeError, decode_categories, decode_file, decode_order, decode_trackpoints, + decode_waypoints, encode_categories, encode_file, encode_order, encode_trackpoints, encode_waypoints, }; use crate::{ - Batch, File, FileId, StackEntry, Storage, StorageError, TrackpointCategories, TrackpointChunk, - WaypointChunk, + Batch, ChunkKey, File, FileId, StackEntry, Storage, StorageError, TrackpointCategories, + TrackpointChunk, WaypointChunk, }; /// What there is to keep of the engine: the files of the current state, their order, and the @@ -30,6 +31,10 @@ pub struct Restored { pub categories: TrackpointCategories, /// The files that could not be read: they are dropped at the next save. pub unreadable: usize, + /// Whether some of what is stored was written by a newer version, which this one cannot read. + /// Nothing is written then, so that the newer version finds its data again: the files that + /// could be read are only kept in memory, and the persister does not save. + pub read_only: bool, } /// Works out what to write to a [`Storage`] so that it holds a [`Snapshot`], knowing what it @@ -44,18 +49,35 @@ pub struct Persister { files: HashMap>, /// The files that have a record, including the ones that could not be read. stored_files: HashSet, - trackpoint_chunks: HashSet, - waypoint_chunks: HashSet, + chunks: HashSet, order: Option>, categories: Option, + /// Set by `restore`, see [`Restored::read_only`]. + read_only: bool, +} + +/// Remembers whether something that was read comes from a newer version. +#[derive(Default)] +struct Newer(Cell); + +impl Newer { + fn read(&self, result: Result) -> Option { + match result { + Ok(value) => Some(value), + Err(error) => { + self.0 + .set(self.0.get() || matches!(error, DecodeError::UnknownVersion(_))); + None + } + } + } } /// What a save writes, and what is stored once it is written. struct Plan { batch: Batch, snapshot: Snapshot, - trackpoint_chunks: HashSet, - waypoint_chunks: HashSet, + chunks: HashSet, } impl Persister { @@ -64,11 +86,18 @@ impl Persister { let data = storage.load().await?; *self = Self::default(); - let categories: Option = data.categories.as_deref().and_then(decode); - let stored_order: Option> = data.order.as_deref().and_then(decode); + // what comes from a newer version is not corrupt: it must not be overwritten + let newer = Newer::default(); + let categories: Option = data + .categories + .as_deref() + .and_then(|bytes| newer.read(decode_categories(bytes))); + let stored_order: Option> = data + .order + .as_deref() + .and_then(|bytes| newer.read(decode_order(bytes))); - let trackpoint_bytes: HashMap> = data.trackpoint_chunks.into_iter().collect(); - let waypoint_bytes: HashMap> = data.waypoint_chunks.into_iter().collect(); + let chunk_bytes: HashMap> = data.chunks.into_iter().collect(); // chunks are decoded once, whatever the number of files that use them let trackpoint_cache: RefCell>>> = Default::default(); @@ -78,8 +107,8 @@ impl Persister { .borrow_mut() .entry(id.0) .or_insert_with(|| { - let bytes = trackpoint_bytes.get(&id.0)?; - decode_trackpoints(id.0, bytes).map(Rc::new) + let bytes = chunk_bytes.get(&ChunkKey::trackpoints(id.0))?; + newer.read(decode_trackpoints(id.0, bytes)).map(Rc::new) }) .clone() }; @@ -88,8 +117,8 @@ impl Persister { .borrow_mut() .entry(id.0) .or_insert_with(|| { - let bytes = waypoint_bytes.get(&id.0)?; - decode_waypoints(id.0, bytes).map(Rc::new) + let bytes = chunk_bytes.get(&ChunkKey::waypoints(id.0))?; + newer.read(decode_waypoints(id.0, bytes)).map(Rc::new) }) .clone() }; @@ -101,7 +130,8 @@ impl Persister { let mut files: HashMap> = HashMap::new(); for (id, bytes) in &data.files { self.stored_files.insert(*id); - let file = decode::(bytes) + let file = newer + .read(decode_file(bytes)) .and_then(|record| record.build(*id, &trackpoint_chunk, &waypoint_chunk)); match file { Some(file) => { @@ -124,10 +154,11 @@ impl Persister { restored.files = restored.order.iter().map(|id| files[id].clone()).collect(); self.files = files; - self.trackpoint_chunks = trackpoint_bytes.into_keys().collect(); - self.waypoint_chunks = waypoint_bytes.into_keys().collect(); + self.chunks = chunk_bytes.into_keys().collect(); self.order = stored_order; self.categories = categories; + self.read_only = newer.0.get(); + restored.read_only = self.read_only; Ok(restored) } @@ -137,6 +168,9 @@ impl Persister { storage: &S, snapshot: Snapshot, ) -> Result<(), StorageError> { + if self.read_only { + return Ok(()); + } let plan = self.plan(snapshot); if plan.batch.is_empty() { self.files = plan.snapshot.files; @@ -146,8 +180,7 @@ impl Persister { self.stored_files = plan.snapshot.files.keys().copied().collect(); self.files = plan.snapshot.files; - self.trackpoint_chunks = plan.trackpoint_chunks; - self.waypoint_chunks = plan.waypoint_chunks; + self.chunks = plan.chunks; self.order = Some(plan.snapshot.order); self.categories = Some(plan.snapshot.categories); Ok(()) @@ -156,8 +189,7 @@ impl Persister { fn plan(&self, snapshot: Snapshot) -> Plan { let mut batch = Batch::default(); // the chunks that the files use, which the storage has afterwards - let mut trackpoint_chunks = HashSet::new(); - let mut waypoint_chunks = HashSet::new(); + let mut chunks = HashSet::new(); for (id, file) in &snapshot.files { let changed = self @@ -166,23 +198,19 @@ impl Persister { .is_none_or(|saved| !Rc::ptr_eq(saved, file)); let segments = file.trk.iter().flat_map(|track| &track.trkseg); for chunk in segments.flat_map(|segment| segment.chunks()) { - let new = trackpoint_chunks.insert(chunk.id.0); - if changed && new && !self.trackpoint_chunks.contains(&chunk.id.0) { - batch - .put_trackpoint_chunks - .push((chunk.id.0, encode_trackpoints(chunk))); + let key = ChunkKey::trackpoints(chunk.id.0); + if chunks.insert(key) && changed && !self.chunks.contains(&key) { + batch.put_chunks.push((key, encode_trackpoints(chunk))); } } for chunk in file.wpt.chunks() { - let new = waypoint_chunks.insert(chunk.id.0); - if changed && new && !self.waypoint_chunks.contains(&chunk.id.0) { - batch - .put_waypoint_chunks - .push((chunk.id.0, encode_waypoints(chunk))); + let key = ChunkKey::waypoints(chunk.id.0); + if chunks.insert(key) && changed && !self.chunks.contains(&key) { + batch.put_chunks.push((key, encode_waypoints(chunk))); } } if changed || !self.stored_files.contains(id) { - batch.put_files.push((*id, encode(&FileRecord::new(file)))); + batch.put_files.push((*id, encode_file(file))); } } @@ -192,28 +220,18 @@ impl Persister { .filter(|id| !snapshot.files.contains_key(id)) .copied() .collect(); - batch.delete_trackpoint_chunks = self - .trackpoint_chunks - .difference(&trackpoint_chunks) - .copied() - .collect(); - batch.delete_waypoint_chunks = self - .waypoint_chunks - .difference(&waypoint_chunks) - .copied() - .collect(); + batch.delete_chunks = self.chunks.difference(&chunks).copied().collect(); if self.order.as_ref() != Some(&snapshot.order) { - batch.order = Some(encode(&snapshot.order)); + batch.order = Some(encode_order(&snapshot.order)); } if self.categories.as_ref() != Some(&snapshot.categories) { - batch.categories = Some(encode(&snapshot.categories)); + batch.categories = Some(encode_categories(&snapshot.categories)); } Plan { batch, snapshot, - trackpoint_chunks, - waypoint_chunks, + chunks, } } } @@ -225,7 +243,8 @@ mod tests { use std::task::{Context, Poll, Waker}; use crate::{ - Action, Command, Engine, ExportOptions, Load, MemoryStorage, StoredData, parse, write, + Action, ChunkKind, Command, Engine, ExportOptions, Load, MemoryStorage, StoredData, parse, + write, }; use super::*; @@ -354,9 +373,8 @@ mod tests { let batches = storage.batches.borrow(); let batch = batches.last().unwrap(); assert_eq!(batch.put_files.len(), 1); - assert!(batch.put_trackpoint_chunks.is_empty()); - assert!(batch.put_waypoint_chunks.is_empty()); - assert!(batch.delete_trackpoint_chunks.is_empty()); + assert!(batch.put_chunks.is_empty()); + assert!(batch.delete_chunks.is_empty()); assert!(batch.order.is_none() && batch.categories.is_none()); } assert_eq!(storage.inner.counts(), counts); @@ -376,8 +394,9 @@ mod tests { let batches = storage.batches.borrow(); let batch = batches.last().unwrap(); assert_eq!(batch.put_files.len(), 1); - assert_eq!(batch.put_trackpoint_chunks.len(), 1); - assert_eq!(batch.delete_trackpoint_chunks.len(), 1); + assert_eq!(batch.puts(ChunkKind::Trackpoints), 1); + assert_eq!(batch.deletes(ChunkKind::Trackpoints), 1); + assert_eq!(batch.puts(ChunkKind::Waypoints), 0); } assert_eq!(storage.inner.counts(), counts); assert!(chunks_before >= 1); @@ -386,9 +405,9 @@ mod tests { snapshot.files.remove(&id); snapshot.order.retain(|other| *other != id); block_on(persister.save(&storage, snapshot)).unwrap(); - let (files, trackpoint_chunks, _) = storage.inner.counts(); - assert_eq!(files, counts.0 - 1); - assert!(trackpoint_chunks < counts.1); + let after = storage.inner.counts(); + assert_eq!(after.files, counts.files - 1); + assert!(after.trackpoint_chunks < counts.trackpoint_chunks); } #[test] @@ -404,7 +423,7 @@ mod tests { let mut persister = Persister::default(); block_on(persister.save(&storage, snapshot)).unwrap(); let chunks = original.trk[0].trkseg[0].chunks().len(); - assert_eq!(storage.inner.counts().1, chunks); + assert_eq!(storage.inner.counts().trackpoint_chunks, chunks); let restored = block_on(Persister::default().restore(&storage)).unwrap(); assert_eq!(restored.files.len(), 2); @@ -424,13 +443,14 @@ mod tests { // damage a record, and lose a chunk of another file let broken = snapshot.order[0]; block_on(storage.inner.commit(&Batch { - put_files: vec![(broken, vec![99, 1, 2])], + put_files: vec![(broken, vec![super::super::codec::LATEST, 0xff, 0xff])], ..Default::default() })) .unwrap(); let mut persister = Persister::default(); let restored = block_on(persister.restore(&storage)).unwrap(); assert_eq!(restored.unreadable, 1); + assert!(!restored.read_only); assert_eq!(restored.files.len(), FILES.len() - 1); assert!(restored.files.iter().all(|file| file.id != broken)); @@ -440,7 +460,77 @@ mod tests { categories: restored.categories, }; block_on(persister.save(&storage, kept)).unwrap(); - assert_eq!(storage.inner.counts().0, FILES.len() - 1); + assert_eq!(storage.inner.counts().files, FILES.len() - 1); + } + + /// A storage holding the files, with one blob replaced by `damage`. + fn stored_with(damage: impl FnOnce(&Snapshot, &mut Batch)) -> (Recording, Snapshot) { + let storage = Recording::default(); + let snapshot = snapshot_of(&FILES); + block_on(Persister::default().save(&storage, snapshot.clone())).unwrap(); + let mut batch = Batch::default(); + damage(&snapshot, &mut batch); + block_on(storage.inner.commit(&batch)).unwrap(); + storage.batches.borrow_mut().clear(); + (storage, snapshot) + } + + fn from_the_future() -> Vec { + vec![super::super::codec::LATEST + 1, 1, 2, 3] + } + + #[test] + fn test_what_a_newer_version_wrote_is_left_alone() { + type Damage = (&'static str, fn(&Snapshot, &mut Batch)); + let damages: [Damage; 4] = [ + ("a file", |snapshot, batch| { + batch.put_files.push((snapshot.order[0], from_the_future())); + }), + ("a chunk", |snapshot, batch| { + let file = &snapshot.files[&snapshot.order[0]]; + let chunk = &file.trk[0].trkseg[0].chunks()[0]; + batch + .put_chunks + .push((ChunkKey::trackpoints(chunk.id.0), from_the_future())); + }), + ("the order", |_, batch| { + batch.order = Some(from_the_future()) + }), + ("the categories", |_, batch| { + batch.categories = Some(from_the_future()) + }), + ]; + for (what, damage) in damages { + let (storage, snapshot) = stored_with(damage); + let before = storage.inner.counts(); + let mut persister = Persister::default(); + let restored = block_on(persister.restore(&storage)).unwrap(); + assert!(restored.read_only, "{what}"); + + // whatever is saved afterwards, nothing is written: the newer version needs its data + let mut changed = snapshot.clone(); + changed.order.clear(); + changed.files.clear(); + block_on(persister.save(&storage, changed)).unwrap(); + assert!(storage.batches.borrow().is_empty(), "{what}"); + assert_eq!(storage.inner.counts(), before, "{what}"); + } + } + + #[test] + fn test_restoring_again_forgets_that_the_storage_was_newer() { + let (storage, _) = stored_with(|snapshot, batch| { + batch.put_files.push((snapshot.order[0], from_the_future())); + }); + let mut persister = Persister::default(); + assert!(block_on(persister.restore(&storage)).unwrap().read_only); + + // the same persister, on a storage that is not newer + let clean = Recording::default(); + let restored = block_on(persister.restore(&clean)).unwrap(); + assert!(!restored.read_only); + block_on(persister.save(&clean, snapshot_of(&FILES))).unwrap(); + assert_eq!(clean.inner.counts().files, FILES.len()); } #[test] @@ -449,7 +539,7 @@ mod tests { let mut engine = Engine::default(); for path in FILES { let data = std::fs::read(path).unwrap(); - assert!(engine.execute(Action::Edit(Command::Load(Load { + assert!(engine.run(Action::Edit(Command::Load(Load { data: &data, name: "file", })))); @@ -471,14 +561,14 @@ mod tests { // the files that were restored are not undone assert!(!engine.can_undo()); - assert!(!engine.execute(Action::Undo)); + assert!(!engine.run(Action::Undo)); let data = std::fs::read(FILES[0]).unwrap(); - assert!(engine.execute(Action::Edit(Command::Load(Load { + assert!(engine.run(Action::Edit(Command::Load(Load { data: &data, name: "file", })))); assert!(engine.can_undo()); - assert!(engine.execute(Action::Undo)); + assert!(engine.run(Action::Undo)); assert_eq!(engine.snapshot().files.len(), FILES.len()); assert!(!engine.can_undo()); } diff --git a/gpx-rs/wasm/Cargo.toml b/gpx-rs/wasm/Cargo.toml index 337301908..d7dcd03ef 100644 --- a/gpx-rs/wasm/Cargo.toml +++ b/gpx-rs/wasm/Cargo.toml @@ -14,3 +14,6 @@ js-sys = "0.3.106" uuid = { version = "1.26.1", features = ["js"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4.79" + +[dev-dependencies] +wasm-bindgen-test = "0.3" diff --git a/gpx-rs/wasm/src/command/edit.rs b/gpx-rs/wasm/src/command/edit.rs new file mode 100644 index 000000000..1e2201ed1 --- /dev/null +++ b/gpx-rs/wasm/src/command/edit.rs @@ -0,0 +1,34 @@ +//! Editing the metadata, the style and the structure of the files. + +use gpx_engine::{self as engine, Command}; +use wasm_bindgen::prelude::*; + +use crate::ts::Outcome; + +use crate::session::*; + +// Edit commands + +#[wasm_bindgen] +pub fn metadata(name: &str, desc: &str) -> Outcome { + edit(Command::Metadata(engine::Metadata { name, desc })) +} + +#[wasm_bindgen] +pub fn style(color: Option, opacity: Option, width: Option) -> Outcome { + edit(Command::Style(engine::Style { + color: color.as_deref(), + opacity, + width, + })) +} + +#[wasm_bindgen] +pub fn new_track() -> Outcome { + edit(Command::NewTrack(engine::NewTrack)) +} + +#[wasm_bindgen] +pub fn new_track_segment() -> Outcome { + edit(Command::NewTrackSegment(engine::NewTrackSegment)) +} diff --git a/gpx-rs/wasm/src/command/files.rs b/gpx-rs/wasm/src/command/files.rs new file mode 100644 index 000000000..3d9361efa --- /dev/null +++ b/gpx-rs/wasm/src/command/files.rs @@ -0,0 +1,120 @@ +//! Creating, loading, deleting, moving, copying and pasting files and their elements. + +use gpx_engine::{self as engine, Action, Command}; +use wasm_bindgen::prelude::*; + +use crate::convert::*; +use crate::ids::*; +use crate::session::*; +use crate::ts::*; + +// File commands + +/// Creates a file and selects it. With `lng` and `lat`, it starts with a track and a segment that +/// hold that trackpoint (at `ele`, 0 by default). +#[wasm_bindgen] +pub fn new_file(name: &str, lng: Option, lat: Option, ele: Option) -> Outcome { + let trackpoint = lng.zip(lat).map(|(lng, lat)| engine::NewTrackpoint { + lng, + lat, + ele: ele.unwrap_or_default(), + }); + edit(Command::New(engine::New { name, trackpoint })) +} + +/// Loads files as a single command (one undo step). The files are in `data`, one after the +/// other, `lengths` being the number of bytes of each one. `names`: the name of each file when +/// its data has none (the name on disk, without extension). The files that cannot be read are +/// skipped, the first one that was read is selected. +#[wasm_bindgen] +pub fn load_files(data: &[u8], lengths: &[u32], names: Vec) -> Outcome { + if lengths.len() != names.len() { + return fail(format!( + "{} lengths for {} names", + lengths.len(), + names.len() + )); + } + let mut files = Vec::with_capacity(names.len()); + let mut offset = 0; + for (length, name) in lengths.iter().zip(&names) { + let end = offset + *length as usize; + let Some(data) = data.get(offset..end) else { + return fail(format!("the data of {name} is shorter than its length")); + }; + files.push(engine::Load { data, name }); + offset = end; + } + edit(Command::LoadFiles(engine::LoadFiles { files })) +} + +#[wasm_bindgen] +pub fn duplicate() -> Outcome { + edit(Command::Duplicate(engine::Duplicate)) +} + +/// Deletes the selected elements. With `whole_files`, the files holding the selected elements are +/// deleted instead, even if only a track or a waypoint is selected. +#[wasm_bindgen] +pub fn delete(whole_files: bool) -> Outcome { + edit(Command::Delete(engine::Delete { whole_files })) +} + +#[wasm_bindgen] +pub fn delete_all() -> Outcome { + edit(Command::DeleteAll(engine::DeleteAll)) +} + +/// Moves the files to `index` among the other files, in the given order (not undoable). +/// `file_ids_bytes`: concatenated 16-byte UUIDs. +#[wasm_bindgen] +pub fn reorder(file_ids_bytes: &[u8], index: usize) -> Outcome { + match file_ids(file_ids_bytes) { + Some(file_ids) => execute(Action::Reorder { file_ids, index }), + None => fail("the file ids are not a list of UUIDs"), + } +} + +// Clipboard + +/// Puts the selected elements in the clipboard. Nothing happens when nothing is selected. +#[wasm_bindgen] +pub fn copy() -> Outcome { + execute(Action::Copy) +} + +/// Like `copy`, but the elements are moved when they are pasted. +#[wasm_bindgen] +pub fn cut() -> Outcome { + execute(Action::Cut) +} + +/// Pastes the clipboard according to the selection (see `can_paste`). What is pasted is always +/// the elements as they were when they were copied or cut, even if they were changed or deleted +/// since. It can be pasted several times: what was copied is copied again, what was cut is moved +/// the first time (and the clipboard is not cut after that), then it is copied. +#[wasm_bindgen] +pub fn paste() -> Outcome { + edit(Command::Paste(engine::Paste)) +} + +/// Whether the clipboard can be pasted with the current selection. +#[wasm_bindgen] +pub fn can_paste() -> bool { + with_engine(|e| e.can_paste()).unwrap_or(false) +} + +/// Moves elements (given as a `Selection`) to a place of the file tree, keeping their ids, like +/// dragging and dropping them does. The moved elements are selected. +/// +/// Files can only go among the files; tracks among the files (each becomes a file) or the tracks +/// of a file; segments among the files (each becomes a file), the tracks of a file (each becomes +/// a track) or the segments of a track; waypoints, or all the waypoints of a file, among the +/// waypoints of a file. +#[wasm_bindgen] +pub fn move_elements(what: Selection, to: MoveTarget) -> Outcome { + match parse_selection(&what).zip(parse_move_target(&to)) { + Some((what, to)) => edit(Command::Move(engine::Move { what, to })), + None => fail("the elements or the target are not valid"), + } +} diff --git a/gpx-rs/wasm/src/command/history.rs b/gpx-rs/wasm/src/command/history.rs new file mode 100644 index 000000000..3dcfaf15e --- /dev/null +++ b/gpx-rs/wasm/src/command/history.rs @@ -0,0 +1,30 @@ +//! Undo and redo. + +use gpx_engine::Action; +use wasm_bindgen::prelude::*; + +use crate::ts::Outcome; + +use crate::session::*; + +// Undo-redo + +#[wasm_bindgen] +pub fn undo() -> Outcome { + execute(Action::Undo) +} + +#[wasm_bindgen] +pub fn redo() -> Outcome { + execute(Action::Redo) +} + +#[wasm_bindgen] +pub fn can_undo() -> bool { + with_engine(|e| e.can_undo()).unwrap_or(false) +} + +#[wasm_bindgen] +pub fn can_redo() -> bool { + with_engine(|e| e.can_redo()).unwrap_or(false) +} diff --git a/gpx-rs/wasm/src/command/mod.rs b/gpx-rs/wasm/src/command/mod.rs new file mode 100644 index 000000000..bdd4589d9 --- /dev/null +++ b/gpx-rs/wasm/src/command/mod.rs @@ -0,0 +1,6 @@ +pub(crate) mod edit; +pub(crate) mod files; +pub(crate) mod history; +pub(crate) mod routing; +pub(crate) mod tools; +pub(crate) mod waypoints; diff --git a/gpx-rs/wasm/src/command/routing.rs b/gpx-rs/wasm/src/command/routing.rs new file mode 100644 index 000000000..6fa5e1d76 --- /dev/null +++ b/gpx-rs/wasm/src/command/routing.rs @@ -0,0 +1,144 @@ +//! The commands that refer to trackpoints by their index in the routing buffers. + +use gpx_engine::{self as engine, Command}; +use js_sys::{Array, Uint8Array, Uint32Array}; +use wasm_bindgen::prelude::*; + +use crate::convert::*; +use crate::ids::*; +use crate::queries::routing::with_routing; +use crate::session::*; +use crate::ts::*; +use crate::types::*; + +// Routing +// +// The indices of the trackpoints and of the anchors are the ones of the routing buffers: they only +// mean something for the `revision` they were read with (`routing_revision`), and the commands +// that use them do nothing if the selection is not the one of that revision anymore. + +/// Makes the call `f`, unless the selection is not the one of the revision anymore. +pub(crate) fn at_revision(revision: u32, f: impl FnOnce() -> Outcome) -> Outcome { + if with_routing(|r| r.revision == revision).unwrap_or(false) { + f() + } else { + fail("the selection changed since the routing buffers were read") + } +} + +/// A category of the trackpoints of a route: see `RouteCategory` in `engine`. +pub(crate) struct ParsedCategory { + starts: Vec, + values: Vec, + names: Vec, +} + +impl ParsedCategory { + fn parse(attributes: &JsValue, key: &str) -> Option { + let Some(category) = property(attributes, key) else { + return Some(Self { + starts: vec![], + values: vec![], + names: vec![], + }); + }; + Some(Self { + starts: property(&category, "starts")? + .unchecked_into::() + .to_vec(), + values: property(&category, "values")? + .unchecked_into::() + .to_vec(), + names: Array::from(&property(&category, "names")?) + .iter() + .map(|name| name.as_string()) + .collect::>()?, + }) + } + + fn view(&self) -> engine::RouteCategory<'_> { + engine::RouteCategory { + starts: &self.starts, + values: &self.values, + names: &self.names, + } + } +} + +/// Replaces the trackpoints `start..end` of the selection by the given ones, in a single segment +/// (see `RoutingBuffer`). A pure insertion if `start == end`, a pure removal without new points. +/// `attributes` holds the surface, highway, SAC scale and MTB scale of the new points, as +/// intervals, and `anchors` the indices among the new points of the ones that become anchors. +/// Does nothing, and says why, if the revision of the routing buffers is not the given one. +#[wasm_bindgen] +#[allow(clippy::too_many_arguments)] +pub fn route( + revision: u32, + start: u32, + end: u32, + lng: &[f64], + lat: &[f64], + ele: &[f64], + attributes: RouteAttributes, + anchors: &[u32], +) -> Outcome { + let parse = |key| ParsedCategory::parse(&attributes, key); + let (Some(surface), Some(highway), Some(sac_scale), Some(mtb_scale)) = ( + parse("surface"), + parse("highway"), + parse("sacScale"), + parse("mtbScale"), + ) else { + return fail("the attributes of the route are not valid"); + }; + if !same_len(lng, lat, ele) { + return fail("lng, lat and ele differ in length"); + } + at_revision(revision, || { + edit(Command::Route(engine::Route { + start, + end, + lng, + lat, + ele, + surface: surface.view(), + highway: highway.view(), + sac_scale: sac_scale.view(), + mtb_scale: mtb_scale.view(), + anchors, + })) + }) +} + +/// Makes an anchor of the point of the selected segments that is the closest to the coordinates, +/// inserting a trackpoint on the path if none is there. Does nothing, and says why, if the +/// revision of the routing buffers is not the given one. +#[wasm_bindgen] +pub fn insert_anchor(revision: u32, lng: f64, lat: f64) -> Outcome { + at_revision(revision, || { + edit(Command::InsertAnchor(engine::InsertAnchor { lng, lat })) + }) +} + +/// Makes the trackpoint `index` of the selection the start of its segment, which is a loop (the +/// trackpoints before it go to the end, and the loop closes on it). Does nothing, and says why, if the revision of the +/// routing buffers is not the given one. +#[wasm_bindgen] +pub fn change_loop_start(revision: u32, index: u32) -> Outcome { + at_revision(revision, || { + edit(Command::ChangeLoopStart(engine::ChangeLoopStart { index })) + }) +} + +/// Cuts the file, the track or the segment (see `SplitType`) of the trackpoint `at` of the +/// selection in two, there: the trackpoint ends the first part and starts the second one. Does +/// nothing, and returns false, if the revision of the routing buffers is not the given one. +#[wasm_bindgen] +pub fn split(revision: u32, at: u32, split_type: SplitType) -> Outcome { + at_revision(revision, || { + edit(Command::Split(engine::Split { + at, + split_type: split_type.into(), + })) + }) +} diff --git a/gpx-rs/wasm/src/command/tools.rs b/gpx-rs/wasm/src/command/tools.rs new file mode 100644 index 000000000..b4914ca30 --- /dev/null +++ b/gpx-rs/wasm/src/command/tools.rs @@ -0,0 +1,109 @@ +//! The tools: reversing, cropping, reducing, timestamps, merging, cleaning... + +use gpx_engine::{self as engine, Command, LngLat, LngLatBounds}; +use wasm_bindgen::prelude::*; + +use crate::ts::Outcome; + +use crate::session::*; +use crate::types::*; + +// Tools + +#[wasm_bindgen] +pub fn reverse() -> Outcome { + edit(Command::Reverse(engine::Reverse)) +} + +/// Adds to each selected segment a reversed copy of itself (without repeating its last point), so that it goes back to where it +/// started. +#[wasm_bindgen] +pub fn round_trip() -> Outcome { + edit(Command::RoundTrip(engine::RoundTrip)) +} + +/// Keeps the trackpoints `start` to `end` (both included) of the selection, counted over its +/// segments one after the other, and removes the others (and what is left empty). +#[wasm_bindgen] +pub fn crop(start: u32, end: u32) -> Outcome { + if start > end { + return fail(format!("the range {start}..{end} is reversed")); + } + edit(Command::Crop(engine::Crop { start, end })) +} + +/// Removes the trackpoints of the selection that are less than `tolerance` meters away from the +/// line of their neighbours, except the ends of the segments. +#[wasm_bindgen] +pub fn reduce(tolerance: f64) -> Outcome { + edit(Command::Reduce(engine::Reduce { tolerance })) +} + +/// Sets the timestamps of the selection, which starts at `start_time` (ms since the epoch): the +/// trackpoints that have some keep their durations multiplied by `ratio`, the others follow the +/// previous one at `speed` (km/h). +#[wasm_bindgen(js_name = changeTimestamps)] +pub fn change_timestamps(start_time: f64, speed: f64, ratio: f64) -> Outcome { + edit(Command::Time(engine::Time { + start_time: start_time as i64, + kind: engine::TimeKind::Change { speed, ratio }, + })) +} + +/// Makes up timestamps for the selection, which starts at `start_time` (ms since the epoch) and +/// lasts `total_time` seconds. +#[wasm_bindgen(js_name = createArtificialTimestamps)] +pub fn create_artificial_timestamps(start_time: f64, total_time: f64) -> Outcome { + edit(Command::Time(engine::Time { + start_time: start_time as i64, + kind: engine::TimeKind::Artificial { total_time }, + })) +} + +/// Merges the selection into its first element (see `MergeType`). `remove_gaps` brings the parts +/// that are connected closer in time, if they have timestamps. +#[wasm_bindgen] +pub fn merge(type_: MergeType, remove_gaps: bool) -> Outcome { + edit(Command::Merge(engine::Merge { + type_: type_.into(), + remove_gaps, + })) +} + +#[wasm_bindgen] +pub fn extract() -> Outcome { + edit(Command::Extract(engine::Extract)) +} + +/// Sets the elevation of the trackpoints of the selection, one elevation per trackpoint. +#[wasm_bindgen] +pub fn elevation(ele: &[f64]) -> Outcome { + edit(Command::Elevation(engine::Elevation { ele })) +} + +#[wasm_bindgen] +pub fn clean( + west: f64, + south: f64, + east: f64, + north: f64, + type_: CleanType, + trkpt: bool, + wpt: bool, +) -> Outcome { + edit(Command::Clean(engine::Clean { + bounds: LngLatBounds { + sw: LngLat { + lng: west, + lat: south, + }, + ne: LngLat { + lng: east, + lat: north, + }, + }, + type_: type_.into(), + trkpt, + wpt, + })) +} diff --git a/gpx-rs/wasm/src/command/waypoints.rs b/gpx-rs/wasm/src/command/waypoints.rs new file mode 100644 index 000000000..b6058d0bc --- /dev/null +++ b/gpx-rs/wasm/src/command/waypoints.rs @@ -0,0 +1,90 @@ +//! Creating, editing, moving and deleting waypoints. + +use gpx_engine::{self as engine, Command}; +use wasm_bindgen::prelude::*; + +use crate::ts::Outcome; + +use crate::ids::*; +use crate::session::*; + +#[wasm_bindgen] +pub fn new_waypoint( + lng: f64, + lat: f64, + ele: f64, + name: &str, + desc: &str, + icon: &str, + link: &str, +) -> Outcome { + edit(Command::NewWaypoint(engine::NewWaypoint { + lng, + lat, + ele, + name, + desc, + icon, + link, + })) +} + +/// Moves a waypoint of a file, whatever is selected. +#[wasm_bindgen] +pub fn move_waypoint(file_id: &str, waypoint_id: &str, lng: f64, lat: f64, ele: f64) -> Outcome { + match (parse_file_id(file_id), parse_waypoint_id(waypoint_id)) { + (Some(file_id), Some(waypoint_id)) => edit(Command::MoveWaypoint(engine::MoveWaypoint { + file_id, + waypoint_id, + lng, + lat, + ele, + })), + _ => fail("the file or waypoint id is not a UUID"), + } +} + +/// Deletes a waypoint of a file, whatever is selected. +#[wasm_bindgen] +pub fn delete_waypoint(file_id: &str, waypoint_id: &str) -> Outcome { + match (parse_file_id(file_id), parse_waypoint_id(waypoint_id)) { + (Some(file_id), Some(waypoint_id)) => { + edit(Command::DeleteWaypoint(engine::DeleteWaypoint { + file_id, + waypoint_id, + })) + } + _ => fail("the file or waypoint id is not a UUID"), + } +} + +/// Changes a waypoint of a file, whatever is selected: its name, description, icon, link, position +/// and elevation. The strings that are empty remove the field. +#[wasm_bindgen] +#[allow(clippy::too_many_arguments)] +pub fn update_waypoint( + file_id: &str, + waypoint_id: &str, + lng: f64, + lat: f64, + ele: f64, + name: &str, + desc: &str, + icon: &str, + link: &str, +) -> Outcome { + match (parse_file_id(file_id), parse_waypoint_id(waypoint_id)) { + (Some(file_id), Some(waypoint_id)) => edit(Command::EditWaypoint(engine::EditWaypoint { + file_id, + waypoint_id, + lng, + lat, + ele, + name, + desc, + icon, + link, + })), + _ => fail("the file or waypoint id is not a UUID"), + } +} diff --git a/gpx-rs/wasm/src/convert.rs b/gpx-rs/wasm/src/convert.rs new file mode 100644 index 000000000..a5ecdfb37 --- /dev/null +++ b/gpx-rs/wasm/src/convert.rs @@ -0,0 +1,266 @@ +//! Turning engine types into JS objects and back. + +use gpx_engine::{self as engine, FileId}; +use js_sys::{Array, Object, Reflect}; +use wasm_bindgen::prelude::*; + +use crate::ts::*; + +pub(crate) fn property(object: &JsValue, key: &str) -> Option { + Reflect::get(object, &key.into()) + .ok() + .filter(|value| !value.is_undefined()) +} + +pub(crate) fn uuid_property(object: &JsValue, key: &str) -> Option { + uuid::Uuid::parse_str(&property(object, key)?.as_string()?).ok() +} + +pub(crate) fn uuids_property(object: &JsValue, key: &str) -> Option> { + Array::from(&property(object, key)?) + .iter() + .map(|id| uuid::Uuid::parse_str(&id.as_string()?).ok()) + .collect() +} + +/// The inverse of `selection_object`. +pub(crate) fn parse_selection(value: &JsValue) -> Option { + use engine::Selection as S; + let file_id = || uuid_property(value, "fileId").map(FileId); + Some(match property(value, "type")?.as_string()?.as_str() { + "empty" => S::Empty, + "file" => S::File { + file_ids: uuids_property(value, "fileIds")? + .into_iter() + .map(FileId) + .collect(), + }, + "track" => S::Track { + file_id: file_id()?, + trk_ids: uuids_property(value, "trackIds")? + .into_iter() + .map(engine::TrackId) + .collect(), + }, + "segment" => S::TrackSegment { + file_id: file_id()?, + trk_id: engine::TrackId(uuid_property(value, "trackId")?), + trkseg_ids: uuids_property(value, "segmentIds")? + .into_iter() + .map(engine::TrackSegmentId) + .collect(), + }, + "waypoints" => S::Waypoints { + file_id: file_id()?, + }, + "waypoint" => S::Waypoint { + file_id: file_id()?, + wpt_ids: uuids_property(value, "waypointIds")? + .into_iter() + .map(engine::WaypointId) + .collect(), + }, + _ => return None, + }) +} + +pub(crate) fn parse_move_target(value: &JsValue) -> Option { + use engine::MoveTarget as T; + // a negative index is the start, an infinite one is the end + let index = property(value, "index")?.as_f64()?.max(0.0) as usize; + let file_id = || uuid_property(value, "fileId").map(FileId); + Some(match property(value, "type")?.as_string()?.as_str() { + "files" => T::Files { index }, + "tracks" => T::Tracks { + file_id: file_id()?, + index, + }, + "segments" => T::Segments { + file_id: file_id()?, + trk_id: engine::TrackId(uuid_property(value, "trackId")?), + index, + }, + "waypoints" => T::Waypoints { + file_id: file_id()?, + index, + }, + _ => return None, + }) +} + +pub(crate) fn set(object: &Object, key: &str, value: impl Into) { + Reflect::set(object, &key.into(), &value.into()).unwrap(); +} + +pub(crate) fn array(items: &[T], f: impl Fn(&T) -> JsValue) -> Array { + items.iter().map(f).collect() +} + +pub(crate) fn ids(items: &[FileId]) -> Array { + array(items, |id| id.0.to_string().into()) +} + +pub(crate) fn named_node(id: uuid::Uuid, name: Option<&str>) -> Object { + let node = Object::new(); + set(&node, "id", id.to_string()); + if let Some(name) = name { + set(&node, "name", name); + } + node +} + +pub(crate) fn structure_object(file: &engine::FileStructure) -> Object { + let node = named_node(file.id.0, Some(&file.name)); + if let Some(desc) = &file.desc { + set(&node, "desc", desc.as_str()); + } + set( + &node, + "tracks", + array(&file.tracks, |trk| { + let node = named_node(trk.id.0, trk.name.as_deref()); + if let Some(desc) = &trk.desc { + set(&node, "desc", desc.as_str()); + } + if let Some(color) = &trk.color { + set(&node, "color", color.as_str()); + } + if let Some(opacity) = trk.opacity { + set(&node, "opacity", opacity); + } + if let Some(width) = trk.width { + set(&node, "width", width); + } + set( + &node, + "segments", + array(&trk.segments, |seg| { + let node = named_node(seg.id.0, None); + set(&node, "rev", seg.rev_id.0.to_string()); + set(&node, "length", seg.len as f64); + node.into() + }), + ); + node.into() + }), + ); + set( + &node, + "waypoints", + array(&file.waypoints, |wpt| { + let node = named_node(wpt.id.0, wpt.name.as_deref()); + if let Some(sym) = &wpt.sym { + set(&node, "sym", sym.as_str()); + } + node.into() + }), + ); + set(&node, "waypointsRev", file.wpt_rev_id.0.to_string()); + node +} + +pub(crate) fn statistics_object(stats: &engine::GlobalStatistics) -> GlobalStatistics { + let object = Object::new(); + let optional = |key: &str, value: Option| { + if let Some(value) = value { + set(&object, key, value); + } + }; + let seconds = |ms: Option| ms.map(|ms| ms as f64 / 1000.0); + let average = |key: &str, average: &engine::Average| { + if let Some(avg) = average.avg() { + let value = Object::new(); + set(&value, "avg", avg); + set(&value, "count", f64::from(average.count)); + set(&object, key, value); + } + }; + set(&object, "totalDistance", stats.total_distance); + optional("movingDistance", stats.moving_distance); + optional("totalTime", seconds(stats.total_time)); + optional("movingTime", seconds(stats.moving_time)); + set(&object, "elevationGain", stats.elevation_gain); + set(&object, "elevationLoss", stats.elevation_loss); + optional("startTime", stats.start_time.map(|t| t as f64)); + optional("endTime", stats.end_time.map(|t| t as f64)); + optional("totalSpeed", stats.total_speed()); + optional("movingSpeed", stats.moving_speed()); + average("hr", &stats.hr); + average("cad", &stats.cad); + average("atemp", &stats.atemp); + average("power", &stats.power); + let (sw, ne) = (&stats.bounds.sw, &stats.bounds.ne); + if sw.lng <= ne.lng && sw.lat <= ne.lat { + let bounds = Object::new(); + set(&bounds, "west", sw.lng); + set(&bounds, "south", sw.lat); + set(&bounds, "east", ne.lng); + set(&bounds, "north", ne.lat); + set(&object, "bounds", bounds); + } + object.unchecked_into() +} + +pub(crate) fn uuids( + items: impl IntoIterator, + uuid: impl Fn(T) -> uuid::Uuid, +) -> Array { + items + .into_iter() + .map(|item| JsValue::from(uuid(item).to_string())) + .collect() +} + +pub(crate) fn selection_object(selection: &engine::Selection) -> Object { + use engine::Selection as S; + let object = Object::new(); + match selection { + S::Empty => set(&object, "type", "empty"), + S::File { file_ids } => { + set(&object, "type", "file"); + set(&object, "fileIds", uuids(file_ids, |id| id.0)); + } + S::Track { file_id, trk_ids } => { + set(&object, "type", "track"); + set(&object, "fileId", file_id.0.to_string()); + set(&object, "trackIds", uuids(trk_ids, |id| id.0)); + } + S::TrackSegment { + file_id, + trk_id, + trkseg_ids, + } => { + set(&object, "type", "segment"); + set(&object, "fileId", file_id.0.to_string()); + set(&object, "trackId", trk_id.0.to_string()); + set(&object, "segmentIds", uuids(trkseg_ids, |id| id.0)); + } + S::Waypoints { file_id } => { + set(&object, "type", "waypoints"); + set(&object, "fileId", file_id.0.to_string()); + } + S::Waypoint { file_id, wpt_ids } => { + set(&object, "type", "waypoint"); + set(&object, "fileId", file_id.0.to_string()); + set(&object, "waypointIds", uuids(wpt_ids, |id| id.0)); + } + } + object +} + +/// The outcome of a call. `error` is what it says about why nothing was done, if that is so. +pub(crate) fn outcome_object(outcome: &engine::Outcome, error: Option<&str>) -> Outcome { + let object = Object::new(); + let diff = outcome.diff.clone().unwrap_or_default(); + set(&object, "changed", outcome.changed); + set(&object, "orderChanged", outcome.order_changed); + set(&object, "selectionChanged", outcome.selection_changed); + set(&object, "clipboardChanged", outcome.clipboard_changed); + set(&object, "added", ids(&diff.added)); + set(&object, "modified", ids(&diff.modified)); + set(&object, "removed", ids(&diff.removed)); + if let Some(error) = error { + set(&object, "error", error); + } + object.unchecked_into() +} diff --git a/gpx-rs/wasm/src/storage.rs b/gpx-rs/wasm/src/idb.rs similarity index 86% rename from gpx-rs/wasm/src/storage.rs rename to gpx-rs/wasm/src/idb.rs index 0845b5442..baf901638 100644 --- a/gpx-rs/wasm/src/storage.rs +++ b/gpx-rs/wasm/src/idb.rs @@ -4,7 +4,7 @@ //! store per kind of data. A batch is a single transaction, so that the stored files always are //! ones that the engine had. -use gpx_engine::{Batch, FileId, Storage, StorageError, StoredData}; +use gpx_engine::{Batch, ChunkKey, ChunkKind, FileId, Storage, StorageError, StoredData}; use idb::TransactionMode; use idb::{Database, DatabaseEvent, Factory, ObjectStore, ObjectStoreParams, Query, Transaction}; use js_sys::Uint8Array; @@ -30,6 +30,14 @@ fn error(error: impl std::fmt::Display) -> StorageError { StorageError(error.to_string()) } +/// The object store of a kind of chunks. +fn chunk_store(kind: ChunkKind) -> &'static str { + match kind { + ChunkKind::Trackpoints => TRACKPOINT_CHUNKS, + ChunkKind::Waypoints => WAYPOINT_CHUNKS, + } +} + fn key(id: Uuid) -> JsValue { JsValue::from_str(&id.to_string()) } @@ -82,20 +90,16 @@ impl IdbStorage { async fn write(&self, transaction: &Transaction, batch: &Batch) -> Result<(), StorageError> { let files = transaction.object_store(FILES).map_err(error)?; - let trackpoints = transaction.object_store(TRACKPOINT_CHUNKS).map_err(error)?; - let waypoints = transaction.object_store(WAYPOINT_CHUNKS).map_err(error)?; let meta = transaction.object_store(META).map_err(error)?; // the requests are all made before waiting for them let mut puts = vec![]; let mut deletes = vec![]; - for (store, chunks) in [ - (&trackpoints, &batch.put_trackpoint_chunks), - (&waypoints, &batch.put_waypoint_chunks), - ] { - for (id, data) in chunks { - puts.push(store.put(&bytes(data), Some(&key(*id))).map_err(error)?); - } + for (ChunkKey { kind, id }, data) in &batch.put_chunks { + let store = transaction + .object_store(chunk_store(*kind)) + .map_err(error)?; + puts.push(store.put(&bytes(data), Some(&key(*id))).map_err(error)?); } for (FileId(id), data) in &batch.put_files { puts.push(files.put(&bytes(data), Some(&key(*id))).map_err(error)?); @@ -117,13 +121,11 @@ impl IdbStorage { for FileId(id) in &batch.delete_files { deletes.push(files.delete(Query::Key(key(*id))).map_err(error)?); } - for (store, ids) in [ - (&trackpoints, &batch.delete_trackpoint_chunks), - (&waypoints, &batch.delete_waypoint_chunks), - ] { - for id in ids { - deletes.push(store.delete(Query::Key(key(*id))).map_err(error)?); - } + for ChunkKey { kind, id } in &batch.delete_chunks { + let store = transaction + .object_store(chunk_store(*kind)) + .map_err(error)?; + deletes.push(store.delete(Query::Key(key(*id))).map_err(error)?); } for put in puts { put.await.map_err(error)?; @@ -169,8 +171,15 @@ impl Storage for IdbStorage { .into_iter() .map(|(id, data)| (FileId(id), data)) .collect(), - trackpoint_chunks: ids(trackpoint_chunks)?, - waypoint_chunks: ids(waypoint_chunks)?, + chunks: ids(trackpoint_chunks)? + .into_iter() + .map(|(id, data)| (ChunkKey::trackpoints(id), data)) + .chain( + ids(waypoint_chunks)? + .into_iter() + .map(|(id, data)| (ChunkKey::waypoints(id), data)), + ) + .collect(), order: order.as_ref().map(to_vec), categories: categories.as_ref().map(to_vec), }) diff --git a/gpx-rs/wasm/src/ids.rs b/gpx-rs/wasm/src/ids.rs new file mode 100644 index 000000000..2f48e8d01 --- /dev/null +++ b/gpx-rs/wasm/src/ids.rs @@ -0,0 +1,25 @@ +//! Ids, which cross as strings (one id) or as concatenated 16-byte UUIDs (several). + +use gpx_engine::{self as engine, FileId}; + +pub(crate) fn uuid_list(bytes: &[u8]) -> Option> { + let (chunks, rest) = bytes.as_chunks::<16>(); + rest.is_empty() + .then(|| chunks.iter().map(|c| uuid::Uuid::from_bytes(*c)).collect()) +} + +pub(crate) fn file_ids(bytes: &[u8]) -> Option> { + uuid_list(bytes).map(|ids| ids.into_iter().map(FileId).collect()) +} + +pub(crate) fn parse_file_id(id: &str) -> Option { + uuid::Uuid::parse_str(id).ok().map(FileId) +} + +pub(crate) fn same_len(a: &[f64], b: &[f64], c: &[f64]) -> bool { + a.len() == b.len() && b.len() == c.len() +} + +pub(crate) fn parse_waypoint_id(id: &str) -> Option { + uuid::Uuid::parse_str(id).ok().map(engine::WaypointId) +} diff --git a/gpx-rs/wasm/src/lib.rs b/gpx-rs/wasm/src/lib.rs index 4ebeedc4b..78ff7208e 100644 --- a/gpx-rs/wasm/src/lib.rs +++ b/gpx-rs/wasm/src/lib.rs @@ -8,1651 +8,25 @@ //! - Rectangles cross as four numbers instead of an object. //! - Ids read from the file tree are hyphenated UUID strings; the functions reading buffers take //! them as such. -//! - Every function returns `false` when its arguments are invalid or the command did nothing. -use std::cell::{Cell, RefCell}; -use std::rc::Rc; - -use wasm_bindgen::prelude::*; - -mod storage; -use storage::IdbStorage; - -use gpx_engine::{ - self as engine, Action, Command, Engine, FileId, LngLat, LngLatBounds, Persister, Storage, -}; -use js_sys::{Array, BigInt64Array, Float64Array, Object, Reflect, Uint8Array, Uint32Array}; - -#[wasm_bindgen] -#[derive(Clone, Copy)] -pub enum MergeType { - Connect, - Group, -} - -/// What a split cuts in two. -#[wasm_bindgen] -#[derive(Clone, Copy)] -pub enum SplitType { - Files, - Tracks, - Segments, -} - -impl From for engine::SplitType { - fn from(t: SplitType) -> Self { - match t { - SplitType::Files => Self::Files, - SplitType::Tracks => Self::Tracks, - SplitType::Segments => Self::Segments, - } - } -} - -#[wasm_bindgen] -#[derive(Clone, Copy)] -pub enum CleanType { - Inside, - Outside, -} - -/// How a selection combines with the current one: `Replace` it, `Add` to it, or `Toggle` the -/// elements (the ones already selected are removed). -#[wasm_bindgen] -#[derive(Clone, Copy)] -pub enum SelectMode { - Replace, - Add, - Toggle, -} - -impl From for engine::SelectMode { - fn from(m: SelectMode) -> Self { - match m { - SelectMode::Replace => Self::Replace, - SelectMode::Add => Self::Add, - SelectMode::Toggle => Self::Toggle, - } - } -} - -impl From for engine::MergeType { - fn from(t: MergeType) -> Self { - match t { - MergeType::Connect => Self::Connect, - MergeType::Group => Self::Group, - } - } -} - -impl From for engine::CleanType { - fn from(t: CleanType) -> Self { - match t { - CleanType::Inside => Self::Inside, - CleanType::Outside => Self::Outside, - } - } -} - -thread_local! { - static ENGINE: RefCell> = const { RefCell::new(None) }; -} - -fn execute(action: Action) -> bool { - let (changed, persist) = ENGINE.with(|engine| match engine.borrow_mut().as_mut() { - Some(engine) => { - let changed = engine.execute(action); - let persist = changed && (engine.last_diff().is_some() || engine.order_changed()); - (changed, persist) - } - None => (false, false), - }); - if persist { - schedule_save(); - } - changed -} - -fn edit(command: Command) -> bool { - execute(Action::Edit(command)) -} - -/// Decodes concatenated 16-byte UUIDs. -fn uuid_list(bytes: &[u8]) -> Option> { - let (chunks, rest) = bytes.as_chunks::<16>(); - rest.is_empty() - .then(|| chunks.iter().map(|c| uuid::Uuid::from_bytes(*c)).collect()) -} - -fn file_ids(bytes: &[u8]) -> Option> { - uuid_list(bytes).map(|ids| ids.into_iter().map(FileId).collect()) -} - -fn parse_file_id(id: &str) -> Option { - uuid::Uuid::parse_str(id).ok().map(FileId) -} - -fn same_len(a: &[f64], b: &[f64], c: &[f64]) -> bool { - a.len() == b.len() && b.len() == c.len() -} - -#[wasm_bindgen] -pub fn start() { - console_error_panic_hook::set_once(); - ENGINE.with(|engine| { - *engine.borrow_mut() = Some(Engine::default()); - }); -} - -// Storage -// -// The files are saved in IndexedDB after each change that touches them, without making the change -// wait: `schedule_save` starts a loop that writes what changed since the last write, until -// nothing has. The settings are opaque to the engine, the frontend keeps its own copy of them. - -thread_local! { - static STORAGE: RefCell>> = const { RefCell::new(None) }; - static PERSISTER: RefCell> = const { RefCell::new(None) }; - static SAVING: Cell = const { Cell::new(false) }; - static DIRTY: Cell = const { Cell::new(false) }; - /// Resolve functions of the promises of `flush_storage`. - static WAITERS: RefCell> = const { RefCell::new(Vec::new()) }; -} - -#[wasm_bindgen] -extern "C" { - #[wasm_bindgen(js_namespace = console, js_name = error)] - fn console_error(message: &str); -} - -fn storage() -> Option> { - STORAGE.with(|storage| storage.borrow().clone()) -} - -fn schedule_save() { - if storage().is_none() { - return; - } - DIRTY.set(true); - if SAVING.replace(true) { - // the running loop will go around again - return; - } - wasm_bindgen_futures::spawn_local(async { - while DIRTY.replace(false) { - let Some(storage) = storage() else { break }; - let Some(snapshot) = with_engine(Engine::snapshot) else { - break; - }; - let mut persister = PERSISTER - .with(|persister| persister.borrow_mut().take()) - .unwrap_or_default(); - let result = persister.save(&*storage, snapshot).await; - PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister)); - if let Err(error) = result { - // what failed is written with the next change - console_error(&error.to_string()); - } - } - SAVING.set(false); - for resolve in WAITERS.with(|waiters| std::mem::take(&mut *waiters.borrow_mut())) { - let _ = resolve.call0(&JsValue::UNDEFINED); - } - }); -} - -/// Opens the database `name` (it is created if it does not exist), puts the files that it holds -/// in the engine, which has to be empty, and returns the settings: an object of JSON strings by -/// key. The changes of the files that follow are saved. Call `last_update` afterwards to learn -/// about the files. -#[wasm_bindgen] -pub async fn open_storage(name: &str) -> Result { - let empty = with_engine(|e| e.snapshot().files.is_empty()).unwrap_or(false); - if !empty { - return Err(JsValue::from_str("the engine already has files")); - } - let storage = IdbStorage::open(name).await.map_err(to_js_error)?; - let mut persister = Persister::default(); - let restored = persister.restore(&storage).await.map_err(to_js_error)?; - let settings = storage.load_settings().await.map_err(to_js_error)?; - - ENGINE.with(|engine| { - if let Some(engine) = engine.borrow_mut().as_mut() { - engine.restore(restored); - } - }); - PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister)); - STORAGE.with(|slot| *slot.borrow_mut() = Some(Rc::new(storage))); - - let object = Object::new(); - for (key, value) in settings { - set(&object, &key, value); - } - Ok(object) -} - -fn to_js_error(error: engine::StorageError) -> JsValue { - JsValue::from_str(&error.to_string()) -} - -/// Resolves when what was changed has been written to the storage. -#[wasm_bindgen] -pub async fn flush_storage() -> Result<(), JsValue> { - if !SAVING.get() { - return Ok(()); - } - let promise = js_sys::Promise::new(&mut |resolve, _| { - WAITERS.with(|waiters| waiters.borrow_mut().push(resolve)); - }); - wasm_bindgen_futures::JsFuture::from(promise).await?; - Ok(()) -} - -/// Saves a setting, `value` being its JSON. -#[wasm_bindgen] -pub fn set_setting(key: String, value: String) { - if let Some(storage) = storage() { - wasm_bindgen_futures::spawn_local(async move { - if let Err(error) = storage.put_setting(&key, &value).await { - console_error(&error.to_string()); - } - }); - } -} - -#[wasm_bindgen] -pub fn delete_setting(key: String) { - if let Some(storage) = storage() { - wasm_bindgen_futures::spawn_local(async move { - if let Err(error) = storage.delete_setting(&key).await { - console_error(&error.to_string()); - } - }); - } -} - -// Statistics buffers -// -// Each getter returns a copy of the buffer, as a new JS typed array. - -fn with_stats(f: impl FnOnce(&engine::StatisticsBuffer) -> T) -> Option { - ENGINE.with(|engine| engine.borrow().as_ref().map(|e| f(e.statistics()))) -} - -macro_rules! stats_getter { - ($name:ident, $array:ident) => { - #[wasm_bindgen] - pub fn $name() -> $array { - with_stats(|s| $array::from(&s.$name[..])).unwrap_or_else(|| $array::new_with_length(0)) - } - }; -} - -stats_getter!(total_distance, Float64Array); -stats_getter!(elevation_gain, Float64Array); -stats_getter!(elevation_loss, Float64Array); -stats_getter!(slope, Float64Array); -stats_getter!(slope_segment_slope, Float64Array); -stats_getter!(slope_segment_distance, Float64Array); -stats_getter!(lng, Float64Array); -stats_getter!(lat, Float64Array); -stats_getter!(ele, Float64Array); - -// The optional values are `undefined` when no trackpoint of the selection has one, else they have -// an entry per trackpoint, the missing ones being NaN (`NO_TIME` for the timestamps, 0 before the -// first trackpoint that has them for the cumulative distances and durations). -macro_rules! optional_stats_getter { - ($name:ident, $field:ident, $array:ident) => { - #[wasm_bindgen] - pub fn $name() -> Option<$array> { - with_stats(|s| s.$field.as_deref().map($array::from)).flatten() - } - }; -} - -optional_stats_getter!(moving_distance, moving_distance, Float64Array); -// Durations since the start of the selection, in milliseconds. -optional_stats_getter!(total_time, total_time, BigInt64Array); -optional_stats_getter!(moving_time, moving_time, BigInt64Array); -optional_stats_getter!(speed, speed, Float64Array); -// Timestamps in milliseconds since the epoch (`NO_TIME`, the smallest i64, when missing). -optional_stats_getter!(timestamps, time, BigInt64Array); - -/// Value of the timestamps of the trackpoints that have none. -#[wasm_bindgen] -pub fn no_time() -> i64 { - engine::NO_TIME -} - -optional_stats_getter!(hr, hr, Float64Array); -optional_stats_getter!(cad, cad, Float64Array); -optional_stats_getter!(atemp, atemp, Float64Array); -optional_stats_getter!(power, power, Float64Array); - -// Surface, highway, SAC scale and MTB scale of the trackpoints, as intervals of trackpoints that -// have the same value: `_starts` holds the index of the first trackpoint of each interval, -// and `_values` its value (0 when unknown, else 1 + the index in `surfaces()`, -// `highways()`, `sac_scales()` or `mtb_scales()`). A value that is unknown for every trackpoint is -// a single interval. -macro_rules! intervals_getters { - ($starts:ident, $values:ident, $field:ident) => { - #[wasm_bindgen] - pub fn $starts() -> Uint32Array { - with_stats(|s| Uint32Array::from(&s.$field.starts[..])) - .unwrap_or_else(|| Uint32Array::new_with_length(0)) - } - - #[wasm_bindgen] - pub fn $values() -> Uint8Array { - with_stats(|s| Uint8Array::from(&s.$field.values[..])) - .unwrap_or_else(|| Uint8Array::new_with_length(0)) - } - }; -} - -intervals_getters!(surface_starts, surface_values, surface); -intervals_getters!(highway_starts, highway_values, highway); -intervals_getters!(sac_scale_starts, sac_scale_values, sac_scale); -intervals_getters!(mtb_scale_starts, mtb_scale_values, mtb_scale); - -/// The names of the surfaces, in the order of their codes (see `surface_values`). The list only grows: -/// a name keeps its index. -#[wasm_bindgen] -pub fn surfaces() -> StringList { - with_engine(|e| array(e.categories().surface.names(), |name| name.as_str().into())) - .unwrap_or_default() - .unchecked_into() -} - -/// The names of the highways, in the order of their codes (see `highway_values`). -#[wasm_bindgen] -pub fn highways() -> StringList { - with_engine(|e| array(e.categories().highway.names(), |name| name.as_str().into())) - .unwrap_or_default() - .unchecked_into() -} - -/// The names of the SAC scales, in the order of their codes (see `sac_scale_values`). -#[wasm_bindgen] -pub fn sac_scales() -> StringList { - with_engine(|e| { - array(e.categories().sac_scale.names(), |name| { - name.as_str().into() - }) - }) - .unwrap_or_default() - .unchecked_into() -} - -/// The names of the MTB scales, in the order of their codes (see `mtb_scale_values`). -#[wasm_bindgen] -pub fn mtb_scales() -> StringList { - with_engine(|e| { - array(e.categories().mtb_scale.names(), |name| { - name.as_str().into() - }) - }) - .unwrap_or_default() - .unchecked_into() -} - -// Routing buffers -// -// The anchors of the routing tool among the trackpoints of the selection (the numbering is the one -// of the statistics buffers). The ends of the segments are always anchors. - -fn with_routing(f: impl FnOnce(&engine::RoutingBuffer) -> T) -> Option { - ENGINE.with(|engine| engine.borrow().as_ref().map(|e| f(e.routing()))) -} - -/// The index in the selection of each anchor. -#[wasm_bindgen] -pub fn anchor_indices() -> Uint32Array { - with_routing(|r| Uint32Array::from(&r.anchor_indices[..])) - .unwrap_or_else(|| Uint32Array::new_with_length(0)) -} - -/// The lowest map zoom level at which each anchor is shown. -#[wasm_bindgen] -pub fn anchor_zooms() -> Uint8Array { - with_routing(|r| Uint8Array::from(&r.anchor_zooms[..])) - .unwrap_or_else(|| Uint8Array::new_with_length(0)) -} - -/// Index in the selection of the first trackpoint of each selected segment that has some. -#[wasm_bindgen] -pub fn segment_starts() -> Uint32Array { - with_routing(|r| Uint32Array::from(&r.segment_starts[..])) - .unwrap_or_else(|| Uint32Array::new_with_length(0)) -} - -/// The id of each selected segment that has trackpoints, as `segment_starts`. -#[wasm_bindgen] -pub fn segment_ids() -> StringList { - with_routing(|r| array(&r.segment_ids, |id| id.0.to_string().into())) - .unwrap_or_default() - .unchecked_into() -} - -/// Changes when the selected segments or their trackpoints change: the indices in the selection -/// are only valid while it stays the same. -#[wasm_bindgen] -pub fn routing_revision() -> u32 { - with_routing(|r| r.revision).unwrap_or_default() -} - -// File commands - -/// Creates a file and selects it. With `lng` and `lat`, it starts with a track and a segment that -/// hold that trackpoint (at `ele`, 0 by default). -#[wasm_bindgen] -pub fn new_file(name: &str, lng: Option, lat: Option, ele: Option) -> bool { - let trackpoint = lng.zip(lat).map(|(lng, lat)| engine::NewTrackpoint { - lng, - lat, - ele: ele.unwrap_or_default(), - }); - edit(Command::New(engine::New { name, trackpoint })) -} - -/// Loads files as a single command (one undo step). The files are in `data`, one after the -/// other, `lengths` being the number of bytes of each one. `names`: the name of each file when -/// its data has none (the name on disk, without extension). The files that cannot be read are -/// skipped, the first one that was read is selected. -#[wasm_bindgen] -pub fn load_files(data: &[u8], lengths: &[u32], names: Vec) -> bool { - if lengths.len() != names.len() { - return false; - } - let mut files = Vec::with_capacity(names.len()); - let mut offset = 0; - for (length, name) in lengths.iter().zip(&names) { - let end = offset + *length as usize; - let Some(data) = data.get(offset..end) else { - return false; - }; - files.push(engine::Load { data, name }); - offset = end; - } - edit(Command::LoadFiles(engine::LoadFiles { files })) -} - -#[wasm_bindgen] -pub fn duplicate() -> bool { - edit(Command::Duplicate(engine::Duplicate)) -} - -/// Deletes the selected elements. With `whole_files`, the files holding the selected elements are -/// deleted instead, even if only a track or a waypoint is selected. -#[wasm_bindgen] -pub fn delete(whole_files: bool) -> bool { - edit(Command::Delete(engine::Delete { whole_files })) -} - -#[wasm_bindgen] -pub fn delete_all() -> bool { - edit(Command::DeleteAll(engine::DeleteAll)) -} - -/// Moves the files to `index` among the other files, in the given order (not undoable). -/// `file_ids_bytes`: concatenated 16-byte UUIDs. -#[wasm_bindgen] -pub fn reorder(file_ids_bytes: &[u8], index: usize) -> bool { - file_ids(file_ids_bytes).is_some_and(|file_ids| execute(Action::Reorder { file_ids, index })) -} - -// Edit commands - -#[wasm_bindgen] -pub fn metadata(name: &str, desc: &str) -> bool { - edit(Command::Metadata(engine::Metadata { name, desc })) -} - -#[wasm_bindgen] -pub fn style(color: Option, opacity: Option, width: Option) -> bool { - edit(Command::Style(engine::Style { - color: color.as_deref(), - opacity, - width, - })) -} - -#[wasm_bindgen] -pub fn new_track() -> bool { - edit(Command::NewTrack(engine::NewTrack)) -} - -#[wasm_bindgen] -pub fn new_track_segment() -> bool { - edit(Command::NewTrackSegment(engine::NewTrackSegment)) -} - -// Tools - -#[wasm_bindgen] -pub fn reverse() -> bool { - edit(Command::Reverse(engine::Reverse)) -} - -/// Adds to each selected segment a reversed copy of itself (without repeating its last point), so that it goes back to where it -/// started. -#[wasm_bindgen] -pub fn round_trip() -> bool { - edit(Command::RoundTrip(engine::RoundTrip)) -} - -// Routing -// -// The indices of the trackpoints and of the anchors are the ones of the routing buffers: they only -// mean something for the `revision` they were read with (`routing_revision`), and the commands -// that use them do nothing if the selection is not the one of that revision anymore. - -fn routing_revision_is(revision: u32) -> bool { - with_routing(|r| r.revision == revision).unwrap_or(false) -} - -/// A category of the trackpoints of a route: see `RouteCategory` in `engine`. -struct ParsedCategory { - starts: Vec, - values: Vec, - names: Vec, -} - -impl ParsedCategory { - fn parse(attributes: &JsValue, key: &str) -> Option { - let Some(category) = property(attributes, key) else { - return Some(Self { - starts: vec![], - values: vec![], - names: vec![], - }); - }; - Some(Self { - starts: property(&category, "starts")? - .unchecked_into::() - .to_vec(), - values: property(&category, "values")? - .unchecked_into::() - .to_vec(), - names: Array::from(&property(&category, "names")?) - .iter() - .map(|name| name.as_string()) - .collect::>()?, - }) - } - - fn view(&self) -> engine::RouteCategory<'_> { - engine::RouteCategory { - starts: &self.starts, - values: &self.values, - names: &self.names, - } - } -} - -/// Replaces the trackpoints `start..end` of the selection by the given ones, in a single segment -/// (see `RoutingBuffer`). A pure insertion if `start == end`, a pure removal without new points. -/// `attributes` holds the surface, highway, SAC scale and MTB scale of the new points, as -/// intervals, and `anchors` the indices among the new points of the ones that become anchors. -/// Does nothing, and returns false, if the revision of the routing buffers is not the given one. -#[wasm_bindgen] -#[allow(clippy::too_many_arguments)] -pub fn route( - revision: u32, - start: u32, - end: u32, - lng: &[f64], - lat: &[f64], - ele: &[f64], - attributes: RouteAttributes, - anchors: &[u32], -) -> bool { - let parse = |key| ParsedCategory::parse(&attributes, key); - let (Some(surface), Some(highway), Some(sac_scale), Some(mtb_scale)) = ( - parse("surface"), - parse("highway"), - parse("sacScale"), - parse("mtbScale"), - ) else { - return false; - }; - routing_revision_is(revision) - && same_len(lng, lat, ele) - && edit(Command::Route(engine::Route { - start, - end, - lng, - lat, - ele, - surface: surface.view(), - highway: highway.view(), - sac_scale: sac_scale.view(), - mtb_scale: mtb_scale.view(), - anchors, - })) -} - -/// Makes an anchor of the point of the selected segments that is the closest to the coordinates, -/// inserting a trackpoint on the path if none is there. Does nothing, and returns false, if the -/// revision of the routing buffers is not the given one. -#[wasm_bindgen] -pub fn insert_anchor(revision: u32, lng: f64, lat: f64) -> bool { - routing_revision_is(revision) && edit(Command::InsertAnchor(engine::InsertAnchor { lng, lat })) -} - -/// Makes the trackpoint `index` of the selection the start of its segment, which is a loop (the -/// trackpoints before it go to the end, and the loop closes on it). Does nothing, and returns false, if the revision of the -/// routing buffers is not the given one. -#[wasm_bindgen] -pub fn change_loop_start(revision: u32, index: u32) -> bool { - routing_revision_is(revision) - && edit(Command::ChangeLoopStart(engine::ChangeLoopStart { index })) -} - -#[wasm_bindgen] -pub fn new_waypoint( - lng: f64, - lat: f64, - ele: f64, - name: &str, - desc: &str, - icon: &str, - link: &str, -) -> bool { - edit(Command::NewWaypoint(engine::NewWaypoint { - lng, - lat, - ele, - name, - desc, - icon, - link, - })) -} - -/// Moves a waypoint of a file, whatever is selected. -#[wasm_bindgen] -pub fn move_waypoint(file_id: &str, waypoint_id: &str, lng: f64, lat: f64, ele: f64) -> bool { - match (parse_file_id(file_id), parse_waypoint_id(waypoint_id)) { - (Some(file_id), Some(waypoint_id)) => edit(Command::MoveWaypoint(engine::MoveWaypoint { - file_id, - waypoint_id, - lng, - lat, - ele, - })), - _ => false, - } -} - -/// Deletes a waypoint of a file, whatever is selected. -#[wasm_bindgen] -pub fn delete_waypoint(file_id: &str, waypoint_id: &str) -> bool { - match (parse_file_id(file_id), parse_waypoint_id(waypoint_id)) { - (Some(file_id), Some(waypoint_id)) => { - edit(Command::DeleteWaypoint(engine::DeleteWaypoint { - file_id, - waypoint_id, - })) - } - _ => false, - } -} - -fn parse_waypoint_id(id: &str) -> Option { - uuid::Uuid::parse_str(id).ok().map(engine::WaypointId) -} - -/// Changes a waypoint of a file, whatever is selected: its name, description, icon, link, position -/// and elevation. The strings that are empty remove the field. -#[wasm_bindgen] -#[allow(clippy::too_many_arguments)] -pub fn update_waypoint( - file_id: &str, - waypoint_id: &str, - lng: f64, - lat: f64, - ele: f64, - name: &str, - desc: &str, - icon: &str, - link: &str, -) -> bool { - match (parse_file_id(file_id), parse_waypoint_id(waypoint_id)) { - (Some(file_id), Some(waypoint_id)) => edit(Command::EditWaypoint(engine::EditWaypoint { - file_id, - waypoint_id, - lng, - lat, - ele, - name, - desc, - icon, - link, - })), - _ => false, - } -} - -/// Keeps the trackpoints `start` to `end` (both included) of the selection, counted over its -/// segments one after the other, and removes the others (and what is left empty). -#[wasm_bindgen] -pub fn crop(start: u32, end: u32) -> bool { - start <= end && edit(Command::Crop(engine::Crop { start, end })) -} - -/// Removes the trackpoints of the selection that are less than `tolerance` meters away from the -/// line of their neighbours, except the ends of the segments. -#[wasm_bindgen] -pub fn reduce(tolerance: f64) -> bool { - edit(Command::Reduce(engine::Reduce { tolerance })) -} - -/// For each trackpoint of the selection (numbered as in the statistics), the highest tolerance -/// (m) of `reduce` that keeps it: infinite for the ends of the segments. -#[wasm_bindgen] -pub fn reduction_distances() -> Float64Array { - with_engine(|e| Float64Array::from(&e.reduction_distances()[..])) - .unwrap_or_else(|| Float64Array::new_with_length(0)) -} - -/// Cuts the file, the track or the segment (see `SplitType`) of the trackpoint `at` of the -/// selection in two, there: the trackpoint ends the first part and starts the second one. Does -/// nothing, and returns false, if the revision of the routing buffers is not the given one. -#[wasm_bindgen] -pub fn split(revision: u32, at: u32, split_type: SplitType) -> bool { - routing_revision_is(revision) - && edit(Command::Split(engine::Split { - at, - split_type: split_type.into(), - })) -} - -/// Sets the timestamps of the selection, which starts at `start_time` (ms since the epoch): the -/// trackpoints that have some keep their durations multiplied by `ratio`, the others follow the -/// previous one at `speed` (km/h). -#[wasm_bindgen(js_name = changeTimestamps)] -pub fn change_timestamps(start_time: f64, speed: f64, ratio: f64) -> bool { - edit(Command::Time(engine::Time { - start_time: start_time as i64, - kind: engine::TimeKind::Change { speed, ratio }, - })) -} - -/// Makes up timestamps for the selection, which starts at `start_time` (ms since the epoch) and -/// lasts `total_time` seconds. -#[wasm_bindgen(js_name = createArtificialTimestamps)] -pub fn create_artificial_timestamps(start_time: f64, total_time: f64) -> bool { - edit(Command::Time(engine::Time { - start_time: start_time as i64, - kind: engine::TimeKind::Artificial { total_time }, - })) -} - -/// Merges the selection into its first element (see `MergeType`). `remove_gaps` brings the parts -/// that are connected closer in time, if they have timestamps. -#[wasm_bindgen] -pub fn merge(type_: MergeType, remove_gaps: bool) -> bool { - edit(Command::Merge(engine::Merge { - type_: type_.into(), - remove_gaps, - })) -} - -#[wasm_bindgen] -pub fn extract() -> bool { - edit(Command::Extract(engine::Extract)) -} - -/// Sets the elevation of the trackpoints of the selection, one elevation per trackpoint. -#[wasm_bindgen] -pub fn elevation(ele: &[f64]) -> bool { - edit(Command::Elevation(engine::Elevation { ele })) -} - -#[wasm_bindgen] -pub fn clean( - west: f64, - south: f64, - east: f64, - north: f64, - type_: CleanType, - trkpt: bool, - wpt: bool, -) -> bool { - edit(Command::Clean(engine::Clean { - bounds: LngLatBounds { - sw: LngLat { - lng: west, - lat: south, - }, - ne: LngLat { - lng: east, - lat: north, - }, - }, - type_: type_.into(), - trkpt, - wpt, - })) -} - -// Undo-redo - -#[wasm_bindgen] -pub fn undo() -> bool { - execute(Action::Undo) -} - -#[wasm_bindgen] -pub fn redo() -> bool { - execute(Action::Redo) -} - -#[wasm_bindgen] -pub fn can_undo() -> bool { - with_engine(|e| e.can_undo()).unwrap_or(false) -} - -#[wasm_bindgen] -pub fn can_redo() -> bool { - with_engine(|e| e.can_redo()).unwrap_or(false) -} - -// Clipboard - -/// Puts the selected elements in the clipboard. Nothing happens when nothing is selected. -#[wasm_bindgen] -pub fn copy() -> bool { - execute(Action::Copy) -} - -/// Like `copy`, but the elements are moved when they are pasted. -#[wasm_bindgen] -pub fn cut() -> bool { - execute(Action::Cut) -} - -/// Pastes the clipboard according to the selection (see `can_paste`). What is pasted is always -/// the elements as they were when they were copied or cut, even if they were changed or deleted -/// since. It can be pasted several times: what was copied is copied again, what was cut is moved -/// the first time (and the clipboard is not cut after that), then it is copied. -#[wasm_bindgen] -pub fn paste() -> bool { - edit(Command::Paste(engine::Paste)) -} - -/// Whether the clipboard can be pasted with the current selection. -#[wasm_bindgen] -pub fn can_paste() -> bool { - with_engine(|e| e.can_paste()).unwrap_or(false) -} - -// Moving elements (drag and drop) -// -// Unlike the other functions, the arguments are objects, as they are in the `Selection` and -// `MoveTarget` types: it happens once per drop, and they are much clearer than a list of ids. - -fn property(object: &JsValue, key: &str) -> Option { - Reflect::get(object, &key.into()) - .ok() - .filter(|value| !value.is_undefined()) -} - -fn uuid_property(object: &JsValue, key: &str) -> Option { - uuid::Uuid::parse_str(&property(object, key)?.as_string()?).ok() -} - -fn uuids_property(object: &JsValue, key: &str) -> Option> { - Array::from(&property(object, key)?) - .iter() - .map(|id| uuid::Uuid::parse_str(&id.as_string()?).ok()) - .collect() -} - -/// The inverse of `selection_object`. -fn parse_selection(value: &JsValue) -> Option { - use engine::Selection as S; - let file_id = || uuid_property(value, "fileId").map(FileId); - Some(match property(value, "type")?.as_string()?.as_str() { - "empty" => S::Empty, - "file" => S::File { - file_ids: uuids_property(value, "fileIds")? - .into_iter() - .map(FileId) - .collect(), - }, - "track" => S::Track { - file_id: file_id()?, - trk_ids: uuids_property(value, "trackIds")? - .into_iter() - .map(engine::TrackId) - .collect(), - }, - "segment" => S::TrackSegment { - file_id: file_id()?, - trk_id: engine::TrackId(uuid_property(value, "trackId")?), - trkseg_ids: uuids_property(value, "segmentIds")? - .into_iter() - .map(engine::TrackSegmentId) - .collect(), - }, - "waypoints" => S::Waypoints { - file_id: file_id()?, - }, - "waypoint" => S::Waypoint { - file_id: file_id()?, - wpt_ids: uuids_property(value, "waypointIds")? - .into_iter() - .map(engine::WaypointId) - .collect(), - }, - _ => return None, - }) -} - -fn parse_move_target(value: &JsValue) -> Option { - use engine::MoveTarget as T; - // a negative index is the start, an infinite one is the end - let index = property(value, "index")?.as_f64()?.max(0.0) as usize; - let file_id = || uuid_property(value, "fileId").map(FileId); - Some(match property(value, "type")?.as_string()?.as_str() { - "files" => T::Files { index }, - "tracks" => T::Tracks { - file_id: file_id()?, - index, - }, - "segments" => T::Segments { - file_id: file_id()?, - trk_id: engine::TrackId(uuid_property(value, "trackId")?), - index, - }, - "waypoints" => T::Waypoints { - file_id: file_id()?, - index, - }, - _ => return None, - }) -} - -/// Moves elements (given as a `Selection`) to a place of the file tree, keeping their ids, like -/// dragging and dropping them does. The moved elements are selected. -/// -/// Files can only go among the files; tracks among the files (each becomes a file) or the tracks -/// of a file; segments among the files (each becomes a file), the tracks of a file (each becomes -/// a track) or the segments of a track; waypoints, or all the waypoints of a file, among the -/// waypoints of a file. -#[wasm_bindgen] -pub fn move_elements(what: Selection, to: MoveTarget) -> bool { - parse_selection(&what) - .zip(parse_move_target(&to)) - .is_some_and(|(what, to)| edit(Command::Move(engine::Move { what, to }))) -} - -// Selection - -/// Selects files. `file_ids_bytes`: concatenated 16-byte UUIDs. Unknown files are ignored, and -/// selecting nothing deselects everything. See `SelectMode`. -#[wasm_bindgen] -pub fn select(file_ids_bytes: &[u8], mode: SelectMode) -> bool { - select_elements( - file_ids(file_ids_bytes).map(|ids| engine::Selection::File { - file_ids: ids.into_iter().collect(), - }), - mode, - ) -} - -/// Selects all the elements of the same kind as the selected ones, in the same place: all the -/// files, the tracks of the file, the segments of the track, the waypoints of the file. All the -/// files when nothing is selected. -#[wasm_bindgen] -pub fn select_all() -> bool { - execute(Action::SelectAll) -} - -/// Moves the selection to the next (`down`) or previous element of the same kind, like the arrow -/// keys do in the file list. With `add` (shift + arrow), the element is added to the selection, -/// otherwise it replaces it. -#[wasm_bindgen] -pub fn arrow_select(down: bool, add: bool) -> bool { - execute(Action::ArrowSelect { down, add }) -} - -// The elements below cross as the id of their file (UUID string) and, like the files, as -// concatenated 16-byte UUIDs. What does not exist is ignored. See `SelectMode`: elements are merged -// with the selection when it holds elements of the same kind in the same place (same file, same -// track for segments), otherwise they replace it. - -fn select_elements(selection: Option, mode: SelectMode) -> bool { - selection.is_some_and(|selection| { - execute(Action::Select { - selection, - mode: mode.into(), - }) - }) -} - -#[wasm_bindgen] -pub fn select_tracks(file_id: &str, track_ids_bytes: &[u8], mode: SelectMode) -> bool { - select_elements( - parse_file_id(file_id) - .zip(uuid_list(track_ids_bytes)) - .map(|(file_id, ids)| engine::Selection::Track { - file_id, - trk_ids: ids.into_iter().map(engine::TrackId).collect(), - }), - mode, - ) -} - -#[wasm_bindgen] -pub fn select_segments( - file_id: &str, - track_id: &str, - segment_ids_bytes: &[u8], - mode: SelectMode, -) -> bool { - select_elements( - parse_file_id(file_id) - .zip(uuid::Uuid::parse_str(track_id).ok()) - .zip(uuid_list(segment_ids_bytes)) - .map(|((file_id, trk_id), ids)| engine::Selection::TrackSegment { - file_id, - trk_id: engine::TrackId(trk_id), - trkseg_ids: ids.into_iter().map(engine::TrackSegmentId).collect(), - }), - mode, - ) -} - -/// Selects the node standing for all the waypoints of a file. -#[wasm_bindgen] -pub fn select_waypoint_group(file_id: &str, mode: SelectMode) -> bool { - select_elements( - parse_file_id(file_id).map(|file_id| engine::Selection::Waypoints { file_id }), - mode, - ) -} - -#[wasm_bindgen] -pub fn select_waypoints(file_id: &str, waypoint_ids_bytes: &[u8], mode: SelectMode) -> bool { - select_elements( - parse_file_id(file_id) - .zip(uuid_list(waypoint_ids_bytes)) - .map(|(file_id, ids)| engine::Selection::Waypoint { - file_id, - wpt_ids: ids.into_iter().map(engine::WaypointId).collect(), - }), - mode, - ) -} - -// File order and structures -// -// The files are described by one ordered list of ids and one structure object per file, so that -// the UI can react to what changed only: after each action, `last_update` tells which -// structures to (re)read or drop and whether the order changed. Ids are UUID strings. The -// trackpoints of a segment and the waypoints of a file are in the same order as the coordinates -// of their buffers (see below). - -#[wasm_bindgen(typescript_custom_section)] -const ROUTE_TS: &str = r#" -/** - * An OSM attribute of the new trackpoints of a route, as the intervals of trackpoints that share - * a value: `starts[i]` is the index of the first trackpoint of the interval `i`, and `values[i]` - * its value, 0 when unknown, else 1 + the index of its name in `names`. - */ -export interface RouteCategory { - starts: Uint32Array; - values: Uint8Array; - names: string[]; -} -/** The attributes of the new trackpoints of a route. The ones that are missing are unknown. */ -export interface RouteAttributes { - surface?: RouteCategory; - highway?: RouteCategory; - sacScale?: RouteCategory; - mtbScale?: RouteCategory; -} -"#; - -#[wasm_bindgen(typescript_custom_section)] -const FILE_STRUCTURE_TS: &str = r#" -export type Selection = - | { type: 'empty' } - | { type: 'file'; fileIds: string[] } - | { type: 'track'; fileId: string; trackIds: string[] } - | { type: 'segment'; fileId: string; trackId: string; segmentIds: string[] } - | { type: 'waypoints'; fileId: string } - | { type: 'waypoint'; fileId: string; waypointIds: string[] }; -/** - * Where moved elements go: a list of the file tree and the position in it, counted among the - * elements of the list that are not moved. - */ -export type MoveTarget = - | { type: 'files'; index: number } - | { type: 'tracks'; fileId: string; index: number } - | { type: 'segments'; fileId: string; trackId: string; index: number } - | { type: 'waypoints'; fileId: string; index: number }; -/** All the data of a waypoint. The fields it does not have are absent. */ -export interface WaypointDetails { - id: string; - lng: number; - lat: number; - ele: number; - /** ms since epoch */ - time?: number; - name?: string; - desc?: string; - cmt?: string; - link?: { href: string; text?: string }; - sym?: string; -} -/** The position, elevation and time of a trackpoint. */ -export interface TrackpointDetails { - lng: number; - lat: number; - ele: number; - /** ms since epoch */ - time?: number; -} -/** What was copied or cut, to be pasted: the kind of the elements and their ids. */ -export interface Clipboard { - type: 'files' | 'tracks' | 'segments' | 'waypoints'; - ids: string[]; - cut: boolean; -} -export interface FilesUpdate { - orderChanged: boolean; - selectionChanged: boolean; - clipboardChanged: boolean; - /** Files to read the structure of. */ - added: string[]; - /** Files whose structure changed: reread it. */ - modified: string[]; - /** Files that do not exist anymore: drop their structure. */ - removed: string[]; -} -export interface FileStructure { - id: string; - name: string; - desc?: string; - tracks: TrackNode[]; - waypoints: WaypointNode[]; - /** Changes when the waypoints of the file change: refetch their coordinates. */ - waypointsRev: string; -} -/** - * Global statistics of a file, of the selection, or of a part of it. The optional fields are - * absent when there is no data for them (no timestamps, no heart rate...). - */ -export interface GlobalStatistics { - /** km */ - totalDistance: number; - movingDistance?: number; - /** seconds */ - totalTime?: number; - movingTime?: number; - elevationGain: number; - elevationLoss: number; - /** ms since epoch */ - startTime?: number; - endTime?: number; - /** km/h */ - totalSpeed?: number; - movingSpeed?: number; - /** Average and number of trackpoints having the measure. */ - hr?: { avg: number; count: number }; - cad?: { avg: number; count: number }; - atemp?: { avg: number; count: number }; - power?: { avg: number; count: number }; - /** Absent when there are no trackpoints. */ - bounds?: { west: number; south: number; east: number; north: number }; -} -export interface TrackNode { - id: string; - name?: string; - desc?: string; - /** Style of the track, only present when the file defines it. */ - color?: string; - opacity?: number; - width?: number; - segments: SegmentNode[]; -} -export interface SegmentNode { - id: string; - /** Changes when the trackpoints of the segment change: refetch their coordinates. */ - rev: string; - /** Number of trackpoints. */ - length: number; -} -export interface WaypointNode { - id: string; - name?: string; - sym?: string; -} -"#; - -#[wasm_bindgen] -extern "C" { - #[wasm_bindgen(typescript_type = "Selection")] - pub type Selection; - #[wasm_bindgen(typescript_type = "WaypointDetails | undefined")] - pub type WaypointDetails; - #[wasm_bindgen(typescript_type = "TrackpointDetails | undefined")] - pub type TrackpointDetails; - #[wasm_bindgen(typescript_type = "MoveTarget")] - pub type MoveTarget; - - #[wasm_bindgen(typescript_type = "RouteAttributes")] - pub type RouteAttributes; - #[wasm_bindgen(typescript_type = "Clipboard | undefined")] - pub type Clipboard; - #[wasm_bindgen(typescript_type = "FilesUpdate")] - pub type FilesUpdate; - #[wasm_bindgen(typescript_type = "FileStructure | undefined")] - pub type FileStructure; - #[wasm_bindgen(typescript_type = "GlobalStatistics | undefined")] - pub type GlobalStatistics; - #[wasm_bindgen(typescript_type = "string[]")] - pub type FileOrder; - #[wasm_bindgen(typescript_type = "string[]")] - pub type StringList; -} - -fn set(object: &Object, key: &str, value: impl Into) { - Reflect::set(object, &key.into(), &value.into()).unwrap(); -} - -fn array(items: &[T], f: impl Fn(&T) -> JsValue) -> Array { - items.iter().map(f).collect() -} - -fn ids(items: &[FileId]) -> Array { - array(items, |id| id.0.to_string().into()) -} - -fn named_node(id: uuid::Uuid, name: Option<&str>) -> Object { - let node = Object::new(); - set(&node, "id", id.to_string()); - if let Some(name) = name { - set(&node, "name", name); - } - node -} - -fn structure_object(file: &engine::FileStructure) -> Object { - let node = named_node(file.id.0, Some(&file.name)); - if let Some(desc) = &file.desc { - set(&node, "desc", desc.as_str()); - } - set( - &node, - "tracks", - array(&file.tracks, |trk| { - let node = named_node(trk.id.0, trk.name.as_deref()); - if let Some(desc) = &trk.desc { - set(&node, "desc", desc.as_str()); - } - if let Some(color) = &trk.color { - set(&node, "color", color.as_str()); - } - if let Some(opacity) = trk.opacity { - set(&node, "opacity", opacity); - } - if let Some(width) = trk.width { - set(&node, "width", width); - } - set( - &node, - "segments", - array(&trk.segments, |seg| { - let node = named_node(seg.id.0, None); - set(&node, "rev", seg.rev_id.0.to_string()); - set(&node, "length", seg.len as f64); - node.into() - }), - ); - node.into() - }), - ); - set( - &node, - "waypoints", - array(&file.waypoints, |wpt| { - let node = named_node(wpt.id.0, wpt.name.as_deref()); - if let Some(sym) = &wpt.sym { - set(&node, "sym", sym.as_str()); - } - node.into() - }), - ); - set(&node, "waypointsRev", file.wpt_rev_id.0.to_string()); - node -} - -fn with_engine(f: impl FnOnce(&Engine) -> T) -> Option { - ENGINE.with(|engine| engine.borrow().as_ref().map(f)) -} - -/// Ids of the files in display order. -#[wasm_bindgen] -pub fn file_order() -> FileOrder { - let order = with_engine(|e| ids(e.order())).unwrap_or_default(); - order.unchecked_into() -} - -/// Structure of a file, `undefined` if the id is unknown. -#[wasm_bindgen] -pub fn file_structure(file_id: &str) -> FileStructure { - let structure = uuid::Uuid::parse_str(file_id) - .ok() - .and_then(|id| { - with_engine(|e| e.file_structure(&FileId(id)).map(structure_object)).flatten() - }) - .map_or(JsValue::UNDEFINED, JsValue::from); - structure.unchecked_into() -} - -fn statistics_object(stats: &engine::GlobalStatistics) -> GlobalStatistics { - let object = Object::new(); - let optional = |key: &str, value: Option| { - if let Some(value) = value { - set(&object, key, value); - } - }; - let seconds = |ms: Option| ms.map(|ms| ms as f64 / 1000.0); - let average = |key: &str, average: &engine::Average| { - if let Some(avg) = average.avg() { - let value = Object::new(); - set(&value, "avg", avg); - set(&value, "count", f64::from(average.count)); - set(&object, key, value); - } - }; - set(&object, "totalDistance", stats.total_distance); - optional("movingDistance", stats.moving_distance); - optional("totalTime", seconds(stats.total_time)); - optional("movingTime", seconds(stats.moving_time)); - set(&object, "elevationGain", stats.elevation_gain); - set(&object, "elevationLoss", stats.elevation_loss); - optional("startTime", stats.start_time.map(|t| t as f64)); - optional("endTime", stats.end_time.map(|t| t as f64)); - optional("totalSpeed", stats.total_speed()); - optional("movingSpeed", stats.moving_speed()); - average("hr", &stats.hr); - average("cad", &stats.cad); - average("atemp", &stats.atemp); - average("power", &stats.power); - let (sw, ne) = (&stats.bounds.sw, &stats.bounds.ne); - if sw.lng <= ne.lng && sw.lat <= ne.lat { - let bounds = Object::new(); - set(&bounds, "west", sw.lng); - set(&bounds, "south", sw.lat); - set(&bounds, "east", ne.lng); - set(&bounds, "north", ne.lat); - set(&object, "bounds", bounds); - } - object.unchecked_into() -} - -/// Global statistics of a file, `undefined` if the id is unknown. -#[wasm_bindgen] -pub fn file_statistics(file_id: &str) -> GlobalStatistics { - uuid::Uuid::parse_str(file_id) - .ok() - .and_then(|id| with_engine(|e| e.file_statistics(&FileId(id))).flatten()) - .map_or(JsValue::UNDEFINED.unchecked_into(), |stats| { - statistics_object(&stats) - }) -} - -/// Global statistics of the whole selection (the arrays of the statistics buffers cover it). -#[wasm_bindgen] -pub fn selection_statistics() -> GlobalStatistics { - with_stats(|buffer| statistics_object(&buffer.global)) - .unwrap_or_else(|| JsValue::UNDEFINED.unchecked_into()) -} - -/// A file as GPX (UTF-8 bytes), `undefined` if it does not exist. The options say which data of the trackpoints -/// to keep. -#[wasm_bindgen] -pub fn export_file( - file_id: &str, - time: bool, - hr: bool, - cad: bool, - atemp: bool, - power: bool, - osm: bool, -) -> Option> { - let id = parse_file_id(file_id)?; - let options = engine::ExportOptions { - time, - hr, - cad, - atemp, - power, - osm, - }; - with_engine(|e| e.export(&id, options)).flatten() -} - -/// The data that some files have, so that the ones that none has are not offered when exporting -/// them: a bit for the time (1), heart rate (2), cadence (4), temperature (8), power (16) and the -/// OpenStreetMap data (32). `file_ids` are concatenated UUIDs. -#[wasm_bindgen] -pub fn exportable_data(file_ids: &[u8]) -> u8 { - let Some(ids) = self::file_ids(file_ids) else { - return 0; - }; - let data = with_engine(|e| e.exportable_data(&ids)).unwrap_or(engine::ExportOptions::NONE); - [ - data.time, data.hr, data.cad, data.atemp, data.power, data.osm, - ] - .iter() - .enumerate() - .fold(0, |bits, (i, on)| bits | (u8::from(*on) << i)) -} - -/// Global statistics of the trackpoints from `start` to `end` (both included) of the selection, -/// as indexed by the statistics buffers. `undefined` when the range is not in the selection. -#[wasm_bindgen] -pub fn slice_statistics(start: usize, end: usize) -> GlobalStatistics { - with_stats(|buffer| buffer.slice(start, end)) - .flatten() - .map_or(JsValue::UNDEFINED.unchecked_into(), |stats| { - statistics_object(&stats) - }) -} - -fn uuids(items: impl IntoIterator, uuid: impl Fn(T) -> uuid::Uuid) -> Array { - items - .into_iter() - .map(|item| JsValue::from(uuid(item).to_string())) - .collect() -} - -fn selection_object(selection: &engine::Selection) -> Object { - use engine::Selection as S; - let object = Object::new(); - match selection { - S::Empty => set(&object, "type", "empty"), - S::File { file_ids } => { - set(&object, "type", "file"); - set(&object, "fileIds", uuids(file_ids, |id| id.0)); - } - S::Track { file_id, trk_ids } => { - set(&object, "type", "track"); - set(&object, "fileId", file_id.0.to_string()); - set(&object, "trackIds", uuids(trk_ids, |id| id.0)); - } - S::TrackSegment { - file_id, - trk_id, - trkseg_ids, - } => { - set(&object, "type", "segment"); - set(&object, "fileId", file_id.0.to_string()); - set(&object, "trackId", trk_id.0.to_string()); - set(&object, "segmentIds", uuids(trkseg_ids, |id| id.0)); - } - S::Waypoints { file_id } => { - set(&object, "type", "waypoints"); - set(&object, "fileId", file_id.0.to_string()); - } - S::Waypoint { file_id, wpt_ids } => { - set(&object, "type", "waypoint"); - set(&object, "fileId", file_id.0.to_string()); - set(&object, "waypointIds", uuids(wpt_ids, |id| id.0)); - } - } - object -} - -/// The current selection (ids are UUID strings). -#[wasm_bindgen] -pub fn selection() -> Selection { - with_engine(|e| selection_object(e.selection())) - .unwrap_or_default() - .unchecked_into() -} - -/// All the data of a waypoint of a file, `undefined` if it does not exist. -#[wasm_bindgen] -pub fn waypoint(file_id: &str, waypoint_id: &str) -> WaypointDetails { - uuid::Uuid::parse_str(file_id) - .ok() - .zip(uuid::Uuid::parse_str(waypoint_id).ok()) - .and_then(|(file_id, id)| { - with_engine(|e| { - e.waypoint(&FileId(file_id), &engine::WaypointId(id)) - .map(|wpt| { - let object = Object::new(); - set(&object, "id", wpt.id.0.to_string()); - set(&object, "lng", wpt.coordinates.lng); - set(&object, "lat", wpt.coordinates.lat); - set(&object, "ele", wpt.ele); - if let Some(time) = wpt.time { - set(&object, "time", time as f64); - } - for (key, value) in [ - ("name", &wpt.name), - ("desc", &wpt.desc), - ("cmt", &wpt.cmt), - ("sym", &wpt.sym), - ] { - if let Some(value) = value { - set(&object, key, value.as_str()); - } - } - if let Some(link) = &wpt.link { - let link_object = Object::new(); - set(&link_object, "href", link.href.as_str()); - if let Some(text) = &link.text { - set(&link_object, "text", text.as_str()); - } - set(&object, "link", link_object); - } - JsValue::from(object) - }) - }) - .flatten() - }) - .unwrap_or(JsValue::UNDEFINED) - .unchecked_into() -} - -/// A trackpoint of a segment of a file, `undefined` if it does not exist. -#[wasm_bindgen] -pub fn trackpoint(file_id: &str, segment_id: &str, index: usize) -> TrackpointDetails { - parse_file_id(file_id) - .zip(uuid::Uuid::parse_str(segment_id).ok()) - .and_then(|(file_id, segment_id)| { - with_engine(|e| { - e.trackpoint(&file_id, &engine::TrackSegmentId(segment_id), index) - .map(|pt| { - let object = Object::new(); - set(&object, "lng", pt.coordinates.lng); - set(&object, "lat", pt.coordinates.lat); - set(&object, "ele", pt.ele); - if let Some(time) = pt.time { - set(&object, "time", time as f64); - } - JsValue::from(object) - }) - }) - .flatten() - }) - .unwrap_or(JsValue::UNDEFINED) - .unchecked_into() -} - -/// What was copied or cut and is waiting to be pasted, `undefined` if nothing: the kind of the -/// elements and their ids (UUID strings). -#[wasm_bindgen] -pub fn clipboard() -> Clipboard { - with_engine(|e| { - e.clipboard().map_or(JsValue::UNDEFINED, |clipboard| { - let object = Object::new(); - let (kind, ids) = match clipboard.content.ids() { - engine::ClipboardIds::Files(ids) => ("files", uuids(ids, |id| id.0)), - engine::ClipboardIds::Tracks(ids) => ("tracks", uuids(ids, |id| id.0)), - engine::ClipboardIds::Segments(ids) => ("segments", uuids(ids, |id| id.0)), - engine::ClipboardIds::Waypoints(ids) => ("waypoints", uuids(ids, |id| id.0)), - }; - set(&object, "type", kind); - set(&object, "ids", ids); - set(&object, "cut", clipboard.cut); - object.into() - }) - }) - .unwrap_or(JsValue::UNDEFINED) - .unchecked_into() -} - -/// What the last action changed. Read it right after each action. -#[wasm_bindgen] -pub fn last_update() -> FilesUpdate { - let update = Object::new(); - with_engine(|e| { - let diff = e.last_diff().cloned().unwrap_or_default(); - set(&update, "orderChanged", e.order_changed()); - set(&update, "selectionChanged", e.selection_changed()); - set(&update, "clipboardChanged", e.clipboard_changed()); - set(&update, "added", ids(&diff.added)); - set(&update, "modified", ids(&diff.modified)); - set(&update, "removed", ids(&diff.removed)); - }); - update.unchecked_into() -} - -// Coordinates buffers -// -// Like the statistics buffers, these are copies. They are flat `[lng, lat, ...]` arrays. Compare `rev` / `waypointsRev` of the file structures with the previous ones to know which -// buffers actually changed. - -fn coordinates_array(f: impl FnOnce(&Engine) -> Option<&[f64]>) -> Float64Array { - ENGINE.with(|engine| match engine.borrow().as_ref().and_then(f) { - Some(coordinates) => Float64Array::from(coordinates), - None => Float64Array::new_with_length(0), - }) -} - -/// Coordinates of the trackpoints of a segment (empty if the id is unknown). -#[wasm_bindgen] -pub fn segment_coordinates(segment_id: &str) -> Float64Array { - match uuid::Uuid::parse_str(segment_id) { - Ok(id) => coordinates_array(|e| Some(e.segment_coordinates(&engine::TrackSegmentId(id)))), - Err(_) => Float64Array::new_with_length(0), - } -} - -/// Coordinates of the waypoints of a file (empty if the id is unknown). -#[wasm_bindgen] -pub fn waypoint_coordinates(file_id: &str) -> Float64Array { - match uuid::Uuid::parse_str(file_id) { - Ok(id) => coordinates_array(|e| Some(e.waypoint_coordinates(&FileId(id)))), - Err(_) => Float64Array::new_with_length(0), - } -} +//! - The functions that edit the files or change the selection return an `Outcome`: whether they +//! changed anything, what changed (to update what the page keeps), and why nothing was done when +//! that is so (the command had nothing to do, the data is invalid, an argument is wrong). +//! +//! The functions are grouped by what they do: `commands` edit the files, `queries` read the state +//! of the engine, `selection` changes what is selected, and `persistence` keeps the files and the +//! settings in the browser. `session` holds the engine they all talk to, `convert` turns engine +//! types into JS objects and back, and `ts` declares the TypeScript types of those objects. + +mod command; +mod convert; +mod idb; +mod ids; +mod persistence; +mod queries; +mod selection; +mod session; +mod ts; +mod types; + +#[cfg(all(test, target_arch = "wasm32"))] +mod tests; diff --git a/gpx-rs/wasm/src/persistence.rs b/gpx-rs/wasm/src/persistence.rs new file mode 100644 index 000000000..d68e0f27c --- /dev/null +++ b/gpx-rs/wasm/src/persistence.rs @@ -0,0 +1,143 @@ +//! Keeping the files and the settings in the browser. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use gpx_engine::{self as engine, Engine, Persister, Storage}; +use js_sys::Object; +use wasm_bindgen::prelude::*; + +use crate::convert::{outcome_object, set}; +use crate::idb::IdbStorage; +use crate::session::*; +use crate::ts::StorageOpened; + +// Storage +// +// The files are saved in IndexedDB after each change that touches them, without making the change +// wait: `schedule_save` starts a loop that writes what changed since the last write, until +// nothing has. The settings are opaque to the engine, the frontend keeps its own copy of them. + +thread_local! { + static STORAGE: RefCell>> = const { RefCell::new(None) }; + static PERSISTER: RefCell> = const { RefCell::new(None) }; + static SAVING: Cell = const { Cell::new(false) }; + static DIRTY: Cell = const { Cell::new(false) }; + /// Resolve functions of the promises of `flush_storage`. + static WAITERS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_namespace = console, js_name = error)] + fn console_error(message: &str); +} + +pub(crate) fn storage() -> Option> { + STORAGE.with(|storage| storage.borrow().clone()) +} + +pub(crate) fn schedule_save() { + if storage().is_none() { + return; + } + DIRTY.set(true); + if SAVING.replace(true) { + // the running loop will go around again + return; + } + wasm_bindgen_futures::spawn_local(async { + while DIRTY.replace(false) { + let Some(storage) = storage() else { break }; + let Some(snapshot) = with_engine(Engine::snapshot) else { + break; + }; + let mut persister = PERSISTER + .with(|persister| persister.borrow_mut().take()) + .unwrap_or_default(); + let result = persister.save(&*storage, snapshot).await; + PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister)); + if let Err(error) = result { + // what failed is written with the next change + console_error(&error.to_string()); + } + } + SAVING.set(false); + for resolve in WAITERS.with(|waiters| std::mem::take(&mut *waiters.borrow_mut())) { + let _ = resolve.call0(&JsValue::UNDEFINED); + } + }); +} + +/// Opens the database `name` (it is created if it does not exist), puts the files that it holds +/// in the engine, which has to be empty, and returns the settings (JSON strings by key) and the +/// outcome of putting the files in. The changes of the files that follow are saved, unless some of +/// what is stored was written by a newer version of the app (`readOnly`): then nothing is, so that +/// the newer version finds its data again. +#[wasm_bindgen] +pub async fn open_storage(name: &str) -> Result { + let empty = with_engine(|e| e.snapshot().files.is_empty()).unwrap_or(false); + if !empty { + return Err(JsValue::from_str("the engine already has files")); + } + let storage = IdbStorage::open(name).await.map_err(to_js_error)?; + let mut persister = Persister::default(); + let restored = persister.restore(&storage).await.map_err(to_js_error)?; + let (read_only, unreadable) = (restored.read_only, restored.unreadable); + let settings = storage.load_settings().await.map_err(to_js_error)?; + + let outcome = with_engine_mut(|engine| engine.restore(restored)).unwrap_or_default(); + PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister)); + STORAGE.with(|slot| *slot.borrow_mut() = Some(Rc::new(storage))); + + let settings_object = Object::new(); + for (key, value) in settings { + set(&settings_object, &key, value); + } + let opened = Object::new(); + set(&opened, "settings", settings_object); + set(&opened, "outcome", outcome_object(&outcome, None)); + set(&opened, "readOnly", read_only); + set(&opened, "unreadable", unreadable as f64); + Ok(opened.unchecked_into()) +} + +pub(crate) fn to_js_error(error: engine::StorageError) -> JsValue { + JsValue::from_str(&error.to_string()) +} + +/// Resolves when what was changed has been written to the storage. +#[wasm_bindgen] +pub async fn flush_storage() -> Result<(), JsValue> { + if !SAVING.get() { + return Ok(()); + } + let promise = js_sys::Promise::new(&mut |resolve, _| { + WAITERS.with(|waiters| waiters.borrow_mut().push(resolve)); + }); + wasm_bindgen_futures::JsFuture::from(promise).await?; + Ok(()) +} + +/// Saves a setting, `value` being its JSON. +#[wasm_bindgen] +pub fn set_setting(key: String, value: String) { + if let Some(storage) = storage() { + wasm_bindgen_futures::spawn_local(async move { + if let Err(error) = storage.put_setting(&key, &value).await { + console_error(&error.to_string()); + } + }); + } +} + +#[wasm_bindgen] +pub fn delete_setting(key: String) { + if let Some(storage) = storage() { + wasm_bindgen_futures::spawn_local(async move { + if let Err(error) = storage.delete_setting(&key).await { + console_error(&error.to_string()); + } + }); + } +} diff --git a/gpx-rs/wasm/src/queries/coordinates.rs b/gpx-rs/wasm/src/queries/coordinates.rs new file mode 100644 index 000000000..5448b7122 --- /dev/null +++ b/gpx-rs/wasm/src/queries/coordinates.rs @@ -0,0 +1,37 @@ +//! The coordinates of the trackpoints and waypoints, as flat typed arrays. + +use gpx_engine::{self as engine, Engine, FileId}; +use js_sys::Float64Array; +use wasm_bindgen::prelude::*; + +use crate::session::*; + +// Coordinates buffers +// +// Like the statistics buffers, these are copies. They are flat `[lng, lat, ...]` arrays. Compare `rev` / `waypointsRev` of the file structures with the previous ones to know which +// buffers actually changed. + +pub(crate) fn coordinates_array(f: impl FnOnce(&Engine) -> Option<&[f64]>) -> Float64Array { + ENGINE.with(|engine| match engine.borrow().as_ref().and_then(f) { + Some(coordinates) => Float64Array::from(coordinates), + None => Float64Array::new_with_length(0), + }) +} + +/// Coordinates of the trackpoints of a segment (empty if the id is unknown). +#[wasm_bindgen] +pub fn segment_coordinates(segment_id: &str) -> Float64Array { + match uuid::Uuid::parse_str(segment_id) { + Ok(id) => coordinates_array(|e| Some(e.segment_coordinates(&engine::TrackSegmentId(id)))), + Err(_) => Float64Array::new_with_length(0), + } +} + +/// Coordinates of the waypoints of a file (empty if the id is unknown). +#[wasm_bindgen] +pub fn waypoint_coordinates(file_id: &str) -> Float64Array { + match uuid::Uuid::parse_str(file_id) { + Ok(id) => coordinates_array(|e| Some(e.waypoint_coordinates(&FileId(id)))), + Err(_) => Float64Array::new_with_length(0), + } +} diff --git a/gpx-rs/wasm/src/queries/files.rs b/gpx-rs/wasm/src/queries/files.rs new file mode 100644 index 000000000..bc26113ff --- /dev/null +++ b/gpx-rs/wasm/src/queries/files.rs @@ -0,0 +1,209 @@ +//! The files, their structure and statistics, the selection and the clipboard. + +// File order and structures +// +// The files are described by one ordered list of ids and one structure object per file, so that +// the UI can react to what changed only: after each action, `last_update` tells which +// structures to (re)read or drop and whether the order changed. Ids are UUID strings. The +// trackpoints of a segment and the waypoints of a file are in the same order as the coordinates +// of their buffers (see below). + +use gpx_engine::{self as engine, FileId}; +use js_sys::Object; +use wasm_bindgen::prelude::*; + +use crate::convert::*; +use crate::ids::*; +use crate::queries::statistics::with_stats; +use crate::session::*; +use crate::ts::*; + +/// Ids of the files in display order. +#[wasm_bindgen] +pub fn file_order() -> FileOrder { + let order = with_engine(|e| ids(e.order())).unwrap_or_default(); + order.unchecked_into() +} + +/// Structure of a file, `undefined` if the id is unknown. +#[wasm_bindgen] +pub fn file_structure(file_id: &str) -> FileStructure { + let structure = uuid::Uuid::parse_str(file_id) + .ok() + .and_then(|id| { + with_engine(|e| e.file_structure(&FileId(id)).map(structure_object)).flatten() + }) + .map_or(JsValue::UNDEFINED, JsValue::from); + structure.unchecked_into() +} + +/// Global statistics of a file, `undefined` if the id is unknown. +#[wasm_bindgen] +pub fn file_statistics(file_id: &str) -> GlobalStatistics { + uuid::Uuid::parse_str(file_id) + .ok() + .and_then(|id| with_engine(|e| e.file_statistics(&FileId(id))).flatten()) + .map_or(JsValue::UNDEFINED.unchecked_into(), |stats| { + statistics_object(&stats) + }) +} + +/// Global statistics of the whole selection (the arrays of the statistics buffers cover it). +#[wasm_bindgen] +pub fn selection_statistics() -> GlobalStatistics { + with_stats(|buffer| statistics_object(&buffer.global)) + .unwrap_or_else(|| JsValue::UNDEFINED.unchecked_into()) +} + +/// A file as GPX (UTF-8 bytes), `undefined` if it does not exist. The options say which data of the trackpoints +/// to keep. +#[wasm_bindgen] +pub fn export_file( + file_id: &str, + time: bool, + hr: bool, + cad: bool, + atemp: bool, + power: bool, + osm: bool, +) -> Option> { + let id = parse_file_id(file_id)?; + let options = engine::ExportOptions { + time, + hr, + cad, + atemp, + power, + osm, + }; + with_engine(|e| e.export(&id, options)).flatten() +} + +/// The data that some files have, so that the ones that none has are not offered when exporting +/// them: a bit for the time (1), heart rate (2), cadence (4), temperature (8), power (16) and the +/// OpenStreetMap data (32). `file_ids` are concatenated UUIDs. +#[wasm_bindgen] +pub fn exportable_data(file_ids: &[u8]) -> u8 { + let Some(ids) = self::file_ids(file_ids) else { + return 0; + }; + let data = with_engine(|e| e.exportable_data(&ids)).unwrap_or(engine::ExportOptions::NONE); + [ + data.time, data.hr, data.cad, data.atemp, data.power, data.osm, + ] + .iter() + .enumerate() + .fold(0, |bits, (i, on)| bits | (u8::from(*on) << i)) +} + +/// Global statistics of the trackpoints from `start` to `end` (both included) of the selection, +/// as indexed by the statistics buffers. `undefined` when the range is not in the selection. +#[wasm_bindgen] +pub fn slice_statistics(start: usize, end: usize) -> GlobalStatistics { + with_stats(|buffer| buffer.slice(start, end)) + .flatten() + .map_or(JsValue::UNDEFINED.unchecked_into(), |stats| { + statistics_object(&stats) + }) +} + +/// The current selection (ids are UUID strings). +#[wasm_bindgen] +pub fn selection() -> Selection { + with_engine(|e| selection_object(e.selection())) + .unwrap_or_default() + .unchecked_into() +} + +/// All the data of a waypoint of a file, `undefined` if it does not exist. +#[wasm_bindgen] +pub fn waypoint(file_id: &str, waypoint_id: &str) -> WaypointDetails { + uuid::Uuid::parse_str(file_id) + .ok() + .zip(uuid::Uuid::parse_str(waypoint_id).ok()) + .and_then(|(file_id, id)| { + with_engine(|e| { + e.waypoint(&FileId(file_id), &engine::WaypointId(id)) + .map(|wpt| { + let object = Object::new(); + set(&object, "id", wpt.id.0.to_string()); + set(&object, "lng", wpt.coordinates.lng); + set(&object, "lat", wpt.coordinates.lat); + set(&object, "ele", wpt.ele); + if let Some(time) = wpt.time { + set(&object, "time", time as f64); + } + for (key, value) in [ + ("name", &wpt.name), + ("desc", &wpt.desc), + ("cmt", &wpt.cmt), + ("sym", &wpt.sym), + ] { + if let Some(value) = value { + set(&object, key, value.as_str()); + } + } + if let Some(link) = &wpt.link { + let link_object = Object::new(); + set(&link_object, "href", link.href.as_str()); + if let Some(text) = &link.text { + set(&link_object, "text", text.as_str()); + } + set(&object, "link", link_object); + } + JsValue::from(object) + }) + }) + .flatten() + }) + .unwrap_or(JsValue::UNDEFINED) + .unchecked_into() +} + +/// A trackpoint of a segment of a file, `undefined` if it does not exist. +#[wasm_bindgen] +pub fn trackpoint(file_id: &str, segment_id: &str, index: usize) -> TrackpointDetails { + parse_file_id(file_id) + .zip(uuid::Uuid::parse_str(segment_id).ok()) + .and_then(|(file_id, segment_id)| { + with_engine(|e| { + e.trackpoint(&file_id, &engine::TrackSegmentId(segment_id), index) + .map(|pt| { + let object = Object::new(); + set(&object, "lng", pt.coordinates.lng); + set(&object, "lat", pt.coordinates.lat); + set(&object, "ele", pt.ele); + if let Some(time) = pt.time { + set(&object, "time", time as f64); + } + JsValue::from(object) + }) + }) + .flatten() + }) + .unwrap_or(JsValue::UNDEFINED) + .unchecked_into() +} + +/// What was copied or cut and is waiting to be pasted, `undefined` if nothing: the kind of the +/// elements and their ids (UUID strings). +#[wasm_bindgen] +pub fn clipboard() -> Clipboard { + with_engine(|e| { + e.clipboard().map_or(JsValue::UNDEFINED, |clipboard| { + let object = Object::new(); + let (kind, ids) = match clipboard.content.ids() { + engine::ClipboardIds::Files(ids) => ("files", uuids(ids, |id| id.0)), + engine::ClipboardIds::Tracks(ids) => ("tracks", uuids(ids, |id| id.0)), + engine::ClipboardIds::Segments(ids) => ("segments", uuids(ids, |id| id.0)), + engine::ClipboardIds::Waypoints(ids) => ("waypoints", uuids(ids, |id| id.0)), + }; + set(&object, "type", kind); + set(&object, "ids", ids); + set(&object, "cut", clipboard.cut); + object.into() + }) + }) + .unwrap_or(JsValue::UNDEFINED) + .unchecked_into() +} diff --git a/gpx-rs/wasm/src/queries/mod.rs b/gpx-rs/wasm/src/queries/mod.rs new file mode 100644 index 000000000..130fff1ba --- /dev/null +++ b/gpx-rs/wasm/src/queries/mod.rs @@ -0,0 +1,4 @@ +pub(crate) mod coordinates; +pub(crate) mod files; +pub(crate) mod routing; +pub(crate) mod statistics; diff --git a/gpx-rs/wasm/src/queries/routing.rs b/gpx-rs/wasm/src/queries/routing.rs new file mode 100644 index 000000000..d9a98afa7 --- /dev/null +++ b/gpx-rs/wasm/src/queries/routing.rs @@ -0,0 +1,54 @@ +//! The anchors of the routing tool among the trackpoints of the selection. + +use gpx_engine as engine; +use js_sys::{Uint8Array, Uint32Array}; +use wasm_bindgen::prelude::*; + +use crate::convert::*; +use crate::session::*; +use crate::ts::*; + +// Routing buffers +// +// The anchors of the routing tool among the trackpoints of the selection (the numbering is the one +// of the statistics buffers). The ends of the segments are always anchors. + +pub(crate) fn with_routing(f: impl FnOnce(&engine::RoutingBuffer) -> T) -> Option { + ENGINE.with(|engine| engine.borrow().as_ref().map(|e| f(e.routing()))) +} + +/// The index in the selection of each anchor. +#[wasm_bindgen] +pub fn anchor_indices() -> Uint32Array { + with_routing(|r| Uint32Array::from(&r.anchor_indices[..])) + .unwrap_or_else(|| Uint32Array::new_with_length(0)) +} + +/// The lowest map zoom level at which each anchor is shown. +#[wasm_bindgen] +pub fn anchor_zooms() -> Uint8Array { + with_routing(|r| Uint8Array::from(&r.anchor_zooms[..])) + .unwrap_or_else(|| Uint8Array::new_with_length(0)) +} + +/// Index in the selection of the first trackpoint of each selected segment that has some. +#[wasm_bindgen] +pub fn segment_starts() -> Uint32Array { + with_routing(|r| Uint32Array::from(&r.segment_starts[..])) + .unwrap_or_else(|| Uint32Array::new_with_length(0)) +} + +/// The id of each selected segment that has trackpoints, as `segment_starts`. +#[wasm_bindgen] +pub fn segment_ids() -> StringList { + with_routing(|r| array(&r.segment_ids, |id| id.0.to_string().into())) + .unwrap_or_default() + .unchecked_into() +} + +/// Changes when the selected segments or their trackpoints change: the indices in the selection +/// are only valid while it stays the same. +#[wasm_bindgen] +pub fn routing_revision() -> u32 { + with_routing(|r| r.revision).unwrap_or_default() +} diff --git a/gpx-rs/wasm/src/queries/statistics.rs b/gpx-rs/wasm/src/queries/statistics.rs new file mode 100644 index 000000000..76a645dba --- /dev/null +++ b/gpx-rs/wasm/src/queries/statistics.rs @@ -0,0 +1,142 @@ +//! The statistics of the selection, as typed arrays, and the categories they refer to. + +use gpx_engine as engine; +use js_sys::{BigInt64Array, Float64Array, Uint8Array, Uint32Array}; +use wasm_bindgen::prelude::*; + +use crate::convert::*; +use crate::session::*; +use crate::ts::*; + +// Statistics buffers +// +// Each getter returns a copy of the buffer, as a new JS typed array. + +pub(crate) fn with_stats(f: impl FnOnce(&engine::StatisticsBuffer) -> T) -> Option { + ENGINE.with(|engine| engine.borrow().as_ref().map(|e| f(e.statistics()))) +} + +macro_rules! stats_getter { + ($name:ident, $array:ident) => { + #[wasm_bindgen] + pub fn $name() -> $array { + with_stats(|s| $array::from(&s.$name[..])).unwrap_or_else(|| $array::new_with_length(0)) + } + }; +} + +stats_getter!(total_distance, Float64Array); +stats_getter!(elevation_gain, Float64Array); +stats_getter!(elevation_loss, Float64Array); +stats_getter!(slope, Float64Array); +stats_getter!(slope_segment_slope, Float64Array); +stats_getter!(slope_segment_distance, Float64Array); +stats_getter!(lng, Float64Array); +stats_getter!(lat, Float64Array); +stats_getter!(ele, Float64Array); + +// The optional values are `undefined` when no trackpoint of the selection has one, else they have +// an entry per trackpoint, the missing ones being NaN (`NO_TIME` for the timestamps, 0 before the +// first trackpoint that has them for the cumulative distances and durations). +macro_rules! optional_stats_getter { + ($name:ident, $field:ident, $array:ident) => { + #[wasm_bindgen] + pub fn $name() -> Option<$array> { + with_stats(|s| s.$field.as_deref().map($array::from)).flatten() + } + }; +} + +optional_stats_getter!(moving_distance, moving_distance, Float64Array); +// Durations since the start of the selection, in milliseconds. +optional_stats_getter!(total_time, total_time, BigInt64Array); +optional_stats_getter!(moving_time, moving_time, BigInt64Array); +optional_stats_getter!(speed, speed, Float64Array); +// Timestamps in milliseconds since the epoch (`NO_TIME`, the smallest i64, when missing). +optional_stats_getter!(timestamps, time, BigInt64Array); + +/// Value of the timestamps of the trackpoints that have none. +#[wasm_bindgen] +pub fn no_time() -> i64 { + engine::NO_TIME +} + +optional_stats_getter!(hr, hr, Float64Array); +optional_stats_getter!(cad, cad, Float64Array); +optional_stats_getter!(atemp, atemp, Float64Array); +optional_stats_getter!(power, power, Float64Array); + +// Surface, highway, SAC scale and MTB scale of the trackpoints, as intervals of trackpoints that +// have the same value: `_starts` holds the index of the first trackpoint of each interval, +// and `_values` its value (0 when unknown, else 1 + the index in `surfaces()`, +// `highways()`, `sac_scales()` or `mtb_scales()`). A value that is unknown for every trackpoint is +// a single interval. +macro_rules! intervals_getters { + ($starts:ident, $values:ident, $field:ident) => { + #[wasm_bindgen] + pub fn $starts() -> Uint32Array { + with_stats(|s| Uint32Array::from(&s.$field.starts[..])) + .unwrap_or_else(|| Uint32Array::new_with_length(0)) + } + + #[wasm_bindgen] + pub fn $values() -> Uint8Array { + with_stats(|s| Uint8Array::from(&s.$field.values[..])) + .unwrap_or_else(|| Uint8Array::new_with_length(0)) + } + }; +} + +intervals_getters!(surface_starts, surface_values, surface); +intervals_getters!(highway_starts, highway_values, highway); +intervals_getters!(sac_scale_starts, sac_scale_values, sac_scale); +intervals_getters!(mtb_scale_starts, mtb_scale_values, mtb_scale); + +/// The names of the surfaces, in the order of their codes (see `surface_values`). The list only grows: +/// a name keeps its index. +#[wasm_bindgen] +pub fn surfaces() -> StringList { + with_engine(|e| array(e.categories().surface.names(), |name| name.as_str().into())) + .unwrap_or_default() + .unchecked_into() +} + +/// The names of the highways, in the order of their codes (see `highway_values`). +#[wasm_bindgen] +pub fn highways() -> StringList { + with_engine(|e| array(e.categories().highway.names(), |name| name.as_str().into())) + .unwrap_or_default() + .unchecked_into() +} + +/// The names of the SAC scales, in the order of their codes (see `sac_scale_values`). +#[wasm_bindgen] +pub fn sac_scales() -> StringList { + with_engine(|e| { + array(e.categories().sac_scale.names(), |name| { + name.as_str().into() + }) + }) + .unwrap_or_default() + .unchecked_into() +} + +/// The names of the MTB scales, in the order of their codes (see `mtb_scale_values`). +#[wasm_bindgen] +pub fn mtb_scales() -> StringList { + with_engine(|e| { + array(e.categories().mtb_scale.names(), |name| { + name.as_str().into() + }) + }) + .unwrap_or_default() + .unchecked_into() +} + +/// For each trackpoint of the selection (numbered as in the statistics), the highest tolerance +/// (m) of `reduce` that keeps it: infinite for the ends of the segments. +#[wasm_bindgen] +pub fn reduction_distances() -> Float64Array { + with_engine(|e| Float64Array::from(&e.reduction_distances()[..])) + .unwrap_or_else(|| Float64Array::new_with_length(0)) +} diff --git a/gpx-rs/wasm/src/selection.rs b/gpx-rs/wasm/src/selection.rs new file mode 100644 index 000000000..89f428b10 --- /dev/null +++ b/gpx-rs/wasm/src/selection.rs @@ -0,0 +1,110 @@ +//! Changing what is selected. + +use gpx_engine::{self as engine, Action}; +use wasm_bindgen::prelude::*; + +use crate::ts::Outcome; + +use crate::ids::*; +use crate::session::*; +use crate::types::SelectMode; + +// Selection + +/// Selects files. `file_ids_bytes`: concatenated 16-byte UUIDs. Unknown files are ignored, and +/// selecting nothing deselects everything. See `SelectMode`. +#[wasm_bindgen] +pub fn select(file_ids_bytes: &[u8], mode: SelectMode) -> Outcome { + select_elements( + file_ids(file_ids_bytes).map(|ids| engine::Selection::File { + file_ids: ids.into_iter().collect(), + }), + mode, + ) +} + +/// Selects all the elements of the same kind as the selected ones, in the same place: all the +/// files, the tracks of the file, the segments of the track, the waypoints of the file. All the +/// files when nothing is selected. +#[wasm_bindgen] +pub fn select_all() -> Outcome { + execute(Action::SelectAll) +} + +/// Moves the selection to the next (`down`) or previous element of the same kind, like the arrow +/// keys do in the file list. With `add` (shift + arrow), the element is added to the selection, +/// otherwise it replaces it. +#[wasm_bindgen] +pub fn arrow_select(down: bool, add: bool) -> Outcome { + execute(Action::ArrowSelect { down, add }) +} + +// The elements below cross as the id of their file (UUID string) and, like the files, as +// concatenated 16-byte UUIDs. What does not exist is ignored. See `SelectMode`: elements are merged +// with the selection when it holds elements of the same kind in the same place (same file, same +// track for segments), otherwise they replace it. + +pub(crate) fn select_elements(selection: Option, mode: SelectMode) -> Outcome { + match selection { + Some(selection) => execute(Action::Select { + selection, + mode: mode.into(), + }), + None => fail("the ids are not UUIDs"), + } +} + +#[wasm_bindgen] +pub fn select_tracks(file_id: &str, track_ids_bytes: &[u8], mode: SelectMode) -> Outcome { + select_elements( + parse_file_id(file_id) + .zip(uuid_list(track_ids_bytes)) + .map(|(file_id, ids)| engine::Selection::Track { + file_id, + trk_ids: ids.into_iter().map(engine::TrackId).collect(), + }), + mode, + ) +} + +#[wasm_bindgen] +pub fn select_segments( + file_id: &str, + track_id: &str, + segment_ids_bytes: &[u8], + mode: SelectMode, +) -> Outcome { + select_elements( + parse_file_id(file_id) + .zip(uuid::Uuid::parse_str(track_id).ok()) + .zip(uuid_list(segment_ids_bytes)) + .map(|((file_id, trk_id), ids)| engine::Selection::TrackSegment { + file_id, + trk_id: engine::TrackId(trk_id), + trkseg_ids: ids.into_iter().map(engine::TrackSegmentId).collect(), + }), + mode, + ) +} + +/// Selects the node standing for all the waypoints of a file. +#[wasm_bindgen] +pub fn select_waypoint_group(file_id: &str, mode: SelectMode) -> Outcome { + select_elements( + parse_file_id(file_id).map(|file_id| engine::Selection::Waypoints { file_id }), + mode, + ) +} + +#[wasm_bindgen] +pub fn select_waypoints(file_id: &str, waypoint_ids_bytes: &[u8], mode: SelectMode) -> Outcome { + select_elements( + parse_file_id(file_id) + .zip(uuid_list(waypoint_ids_bytes)) + .map(|(file_id, ids)| engine::Selection::Waypoint { + file_id, + wpt_ids: ids.into_iter().map(engine::WaypointId).collect(), + }), + mode, + ) +} diff --git a/gpx-rs/wasm/src/session.rs b/gpx-rs/wasm/src/session.rs new file mode 100644 index 000000000..01e20c5bf --- /dev/null +++ b/gpx-rs/wasm/src/session.rs @@ -0,0 +1,52 @@ +//! The engine that every function talks to, and the way actions reach it. + +use std::cell::RefCell; + +use gpx_engine::{self as engine, Action, Command, Engine}; +use wasm_bindgen::prelude::*; + +use crate::convert::outcome_object; +use crate::persistence::schedule_save; +use crate::ts::Outcome; + +thread_local! { + pub(crate) static ENGINE: RefCell> = const { RefCell::new(None) }; +} + +/// What a call that does nothing returns, when the reason is in its arguments rather than in the +/// engine: the call is not made. +pub(crate) fn fail(message: impl AsRef) -> Outcome { + outcome_object(&engine::Outcome::default(), Some(message.as_ref())) +} + +/// Runs an action and tells what it did, see `Outcome` in `ts`. +pub(crate) fn execute(action: Action) -> Outcome { + let Some(outcome) = with_engine_mut(|engine| engine.execute(action)) else { + return fail("the engine is not started"); + }; + if outcome.changed && (outcome.diff.is_some() || outcome.order_changed) { + schedule_save(); + } + let error = outcome.error.as_ref().map(ToString::to_string); + outcome_object(&outcome, error.as_deref()) +} + +pub(crate) fn edit(command: Command) -> Outcome { + execute(Action::Edit(command)) +} + +#[wasm_bindgen] +pub fn start() { + console_error_panic_hook::set_once(); + ENGINE.with(|engine| { + *engine.borrow_mut() = Some(Engine::default()); + }); +} + +pub(crate) fn with_engine(f: impl FnOnce(&Engine) -> T) -> Option { + ENGINE.with(|engine| engine.borrow().as_ref().map(f)) +} + +pub(crate) fn with_engine_mut(f: impl FnOnce(&mut Engine) -> T) -> Option { + ENGINE.with(|engine| engine.borrow_mut().as_mut().map(f)) +} diff --git a/gpx-rs/wasm/src/tests.rs b/gpx-rs/wasm/src/tests.rs new file mode 100644 index 000000000..2c5ae277a --- /dev/null +++ b/gpx-rs/wasm/src/tests.rs @@ -0,0 +1,482 @@ +//! Tests of the JS interface, run with `wasm-pack test --node wasm` (they need a JS environment). +//! +//! Every test starts a new engine, which is what the page does once. + +#![allow(clippy::needless_borrows_for_generic_args)] + +use js_sys::{Array, BigInt64Array, Float64Array, Object, Reflect}; +use wasm_bindgen::prelude::*; +use wasm_bindgen_test::*; + +use crate::command::history::*; +use crate::session::*; +use crate::ts::Outcome; +use crate::types::*; + +const SIMPLE: &[u8] = include_bytes!("../../engine/data/simple.gpx"); +const WITH_TIME: &[u8] = include_bytes!("../../engine/data/with_time.gpx"); +const WITH_SURFACE: &[u8] = include_bytes!("../../engine/data/with_surface.gpx"); + +thread_local! { + static LAST: std::cell::RefCell = const { std::cell::RefCell::new(JsValue::UNDEFINED) }; +} + +/// Keeps the outcome of a call for the checks that follow it, and tells whether it changed anything. +fn run(outcome: Outcome) -> bool { + let outcome = JsValue::from(outcome); + let changed = get(&outcome, "changed").as_bool().unwrap(); + LAST.with(|last| *last.borrow_mut() = outcome); + changed +} + +fn last_outcome() -> JsValue { + LAST.with(|last| last.borrow().clone()) +} + +fn last_error() -> Option { + get(&last_outcome(), "error").as_string() +} + +fn get(object: impl AsRef, key: &str) -> JsValue { + Reflect::get(object.as_ref(), &key.into()).unwrap() +} + +fn array(value: impl Into) -> Array { + Array::from(&value.into()) +} + +fn strings(value: impl Into) -> Vec { + array(value) + .iter() + .map(|v| v.as_string().unwrap()) + .collect() +} + +/// Starts a new engine and loads the given files (one command). +fn started_with(files: &[(&[u8], &str)]) { + start(); + if files.is_empty() { + return; + } + let data: Vec = files + .iter() + .flat_map(|(data, _)| data.iter().copied()) + .collect(); + let lengths: Vec = files.iter().map(|(data, _)| data.len() as u32).collect(); + let names = files.iter().map(|(_, name)| name.to_string()).collect(); + assert!(run(crate::command::files::load_files( + &data, &lengths, names + ))); +} + +fn order() -> Vec { + strings(crate::queries::files::file_order()) +} + +fn uuid_bytes(ids: &[String]) -> Vec { + ids.iter() + .flat_map(|id| *uuid::Uuid::parse_str(id).unwrap().as_bytes()) + .collect() +} + +#[wasm_bindgen_test] +fn test_new_file_is_listed_and_selected() { + start(); + assert!(run(crate::command::files::new_file( + "route", None, None, None + ))); + assert_eq!(last_error(), None); + + let ids = order(); + assert_eq!(ids.len(), 1); + let structure = crate::queries::files::file_structure(&ids[0]); + assert_eq!( + get(&structure, "name").as_string().as_deref(), + Some("route") + ); + assert_eq!(array(get(&structure, "tracks")).length(), 0); + + let selection = crate::queries::files::selection(); + assert_eq!(get(&selection, "type").as_string().as_deref(), Some("file")); + assert_eq!(strings(get(&selection, "fileIds")), ids); + + let update = last_outcome(); + assert_eq!(strings(get(&update, "added")), ids); + assert_eq!(get(&update, "orderChanged").as_bool(), Some(true)); +} + +#[wasm_bindgen_test] +fn test_unknown_file_has_no_structure() { + started_with(&[]); + let id = uuid::Uuid::new_v4().to_string(); + assert!(crate::queries::files::file_structure(&id).is_undefined()); + assert!(crate::queries::files::file_structure("not an id").is_undefined()); + assert!(crate::queries::files::file_statistics(&id).is_undefined()); + assert!(crate::queries::files::export_file(&id, true, true, true, true, true, true).is_none()); + assert_eq!( + crate::queries::coordinates::segment_coordinates(&id).length(), + 0 + ); + assert_eq!( + crate::queries::coordinates::waypoint_coordinates("nope").length(), + 0 + ); +} + +#[wasm_bindgen_test] +fn test_loaded_file_has_statistics_and_coordinates() { + started_with(&[(SIMPLE, "simple")]); + let id = order().remove(0); + + let stats = crate::queries::files::file_statistics(&id); + assert!(get(&stats, "totalDistance").as_f64().unwrap() > 0.0); + // the file is selected: the buffers describe it + let selection_stats = crate::queries::files::selection_statistics(); + assert_eq!( + get(&selection_stats, "totalDistance").as_f64(), + get(&stats, "totalDistance").as_f64() + ); + let n = crate::queries::statistics::total_distance().length(); + assert!(n > 0); + assert_eq!(crate::queries::statistics::lng().length(), n); + assert_eq!(crate::queries::statistics::ele().length(), n); + // no timestamps in this file + assert!(crate::queries::statistics::timestamps().is_none()); + + let structure = crate::queries::files::file_structure(&id); + let segment = get( + &array(get(&array(get(&structure, "tracks")).get(0), "segments")).get(0), + "id", + ); + let coordinates = + crate::queries::coordinates::segment_coordinates(&segment.as_string().unwrap()); + assert_eq!(coordinates.length(), 2 * n); + + // the routing buffers: one segment, anchored at both ends + assert_eq!(crate::queries::routing::segment_starts().to_vec(), vec![0]); + let anchors = crate::queries::routing::anchor_indices().to_vec(); + assert_eq!(anchors.first(), Some(&0)); + assert_eq!(anchors.last(), Some(&(n - 1))); +} + +#[wasm_bindgen_test] +fn test_timestamps_and_categories() { + started_with(&[(WITH_TIME, "time")]); + let times: BigInt64Array = crate::queries::statistics::timestamps().unwrap(); + assert!(times.length() > 0); + assert!(crate::queries::statistics::total_time().is_some()); + + started_with(&[(WITH_SURFACE, "surface")]); + let surfaces = strings(crate::queries::statistics::surfaces()); + assert!(!surfaces.is_empty()); + let values = crate::queries::statistics::surface_values().to_vec(); + assert!(values.iter().any(|v| *v > 0)); + assert!(values.iter().all(|v| (*v as usize) <= surfaces.len())); + assert_eq!( + crate::queries::statistics::surface_starts().length(), + values.len() as u32 + ); +} + +#[wasm_bindgen_test] +fn test_the_reason_of_a_failure_can_be_read() { + started_with(&[]); + + // nothing to do + assert!(!run(crate::command::tools::reverse())); + assert_eq!(last_error().as_deref(), Some("nothing to do")); + // the next call starts afresh + assert!(run(crate::command::files::new_file("a", None, None, None))); + assert_eq!(last_error(), None); + + // invalid data + assert!(!run(crate::command::files::load_files( + b"", + &[16], + vec!["bad".into()] + ))); + assert!(last_error().unwrap().starts_with("invalid data")); + assert_eq!(order().len(), 1); + + // arguments that do not make sense + assert!(!run(crate::command::files::load_files( + b"", + &[1, 2], + vec!["a".into()] + ))); + assert!(last_error().unwrap().contains("lengths")); + assert!(!run(crate::command::files::load_files( + b"abc", + &[10], + vec!["a".into()] + ))); + assert!(last_error().unwrap().contains("shorter")); + assert!(!run(crate::command::files::reorder(&[1, 2, 3], 0))); + assert!(last_error().unwrap().contains("UUIDs")); + assert!(!run(crate::command::tools::crop(5, 1))); + assert!(last_error().unwrap().contains("reversed")); + assert!(!run(crate::command::waypoints::delete_waypoint("x", "y"))); + assert!(last_error().unwrap().contains("UUID")); + assert!(!run(crate::selection::select_tracks( + "x", + &[], + SelectMode::Replace + ))); + assert!(last_error().unwrap().contains("UUIDs")); + + // a stale revision + let stale = crate::queries::routing::routing_revision().wrapping_add(1); + assert!(!run(crate::command::routing::insert_anchor( + stale, 0.0, 0.0 + ))); + assert!(last_error().unwrap().contains("routing buffers")); + assert!(!run(crate::command::routing::change_loop_start(stale, 0))); + assert!(!run(crate::command::routing::split( + stale, + 0, + SplitType::Files + ))); + assert!(last_error().is_some()); + + // answers are not failures + assert!(run(crate::command::files::new_file("b", None, None, None))); + assert!(!crate::command::files::can_paste()); + assert_eq!(last_error(), None); +} + +#[wasm_bindgen_test] +fn test_undo_and_redo() { + started_with(&[]); + assert!(!can_undo() && !can_redo()); + assert!(run(crate::command::files::new_file("a", None, None, None))); + assert!(can_undo() && !can_redo()); + assert!(run(undo())); + assert!(order().is_empty()); + assert!(can_redo()); + assert!(run(redo())); + assert_eq!(order().len(), 1); + // nothing to redo is not an error of the engine + assert!(!run(redo())); +} + +#[wasm_bindgen_test] +fn test_export_and_exportable_data() { + started_with(&[(SIMPLE, "plain"), (WITH_TIME, "timed")]); + let ids = order(); + + let bytes = + crate::queries::files::export_file(&ids[0], true, true, true, true, true, true).unwrap(); + assert!(String::from_utf8(bytes).unwrap().contains(" = ids.iter().rev().cloned().collect(); + assert!(run(crate::command::files::reorder( + &uuid_bytes(&reversed[..1]), + 0 + ))); + assert_eq!(order(), reversed); + assert_eq!(get(&last_outcome(), "orderChanged").as_bool(), Some(true)); +} + +#[wasm_bindgen_test] +fn test_move_elements_takes_js_objects() { + started_with(&[(SIMPLE, "a"), (WITH_TIME, "b")]); + let ids = order(); + assert!(run(crate::selection::select( + &uuid_bytes(&ids[1..]), + SelectMode::Replace + ))); + + let target = Object::new(); + Reflect::set(&target, &"type".into(), &"files".into()).unwrap(); + Reflect::set(&target, &"index".into(), &0.into()).unwrap(); + let what = JsValue::from(crate::queries::files::selection()); + assert!(run(crate::command::files::move_elements( + what.clone().unchecked_into(), + JsValue::from(target).unchecked_into() + ))); + assert_eq!(order(), vec![ids[1].clone(), ids[0].clone()]); + + // an unknown target + let bad = Object::new(); + Reflect::set(&bad, &"type".into(), &"nowhere".into()).unwrap(); + Reflect::set(&bad, &"index".into(), &0.into()).unwrap(); + assert!(!run(crate::command::files::move_elements( + what.unchecked_into(), + JsValue::from(bad).unchecked_into() + ))); + assert!(last_error().unwrap().contains("not valid")); +} + +#[wasm_bindgen_test] +fn test_waypoints() { + started_with(&[]); + assert!(run(crate::command::files::new_file( + "file", None, None, None + ))); + let file = order().remove(0); + assert!(run(crate::command::waypoints::new_waypoint( + 4.0, 50.0, 100.0, "summit", "", "", "" + ))); + + let structure = crate::queries::files::file_structure(&file); + let waypoints = array(get(&structure, "waypoints")); + assert_eq!(waypoints.length(), 1); + let id = get(&waypoints.get(0), "id").as_string().unwrap(); + let details = crate::queries::files::waypoint(&file, &id); + assert_eq!(get(&details, "name").as_string().as_deref(), Some("summit")); + assert_eq!(get(&details, "ele").as_f64(), Some(100.0)); + assert_eq!( + crate::queries::coordinates::waypoint_coordinates(&file).to_vec(), + vec![4.0, 50.0] + ); + + assert!(run(crate::command::waypoints::update_waypoint( + &file, &id, 5.0, 51.0, 120.0, "peak", "", "", "" + ))); + let details = crate::queries::files::waypoint(&file, &id); + assert_eq!(get(&details, "name").as_string().as_deref(), Some("peak")); + assert!(run(crate::command::waypoints::move_waypoint( + &file, &id, 6.0, 52.0, 130.0 + ))); + assert_eq!( + crate::queries::coordinates::waypoint_coordinates(&file).to_vec(), + vec![6.0, 52.0] + ); + + // the structure tells the coordinates changed + let rev = get( + &crate::queries::files::file_structure(&file), + "waypointsRev", + ); + assert!(run(crate::command::waypoints::move_waypoint( + &file, &id, 7.0, 52.0, 130.0 + ))); + assert_ne!( + get( + &crate::queries::files::file_structure(&file), + "waypointsRev" + ), + rev + ); + + assert!(run(crate::command::waypoints::delete_waypoint(&file, &id))); + assert!(crate::queries::files::waypoint(&file, &id).is_undefined()); + // already gone + assert!(!run(crate::command::waypoints::delete_waypoint(&file, &id))); + assert!(last_error().is_some()); +} + +#[wasm_bindgen_test] +fn test_clipboard() { + started_with(&[(SIMPLE, "a")]); + assert!(crate::queries::files::clipboard().is_undefined()); + assert!(run(crate::command::files::copy())); + let clipboard = crate::queries::files::clipboard(); + assert_eq!( + get(&clipboard, "type").as_string().as_deref(), + Some("files") + ); + assert_eq!(get(&clipboard, "cut").as_bool(), Some(false)); + assert!(crate::command::files::can_paste()); + assert!(run(crate::command::files::paste())); + assert_eq!(order().len(), 2); +} + +#[wasm_bindgen_test] +fn test_editing_tools_change_the_statistics() { + started_with(&[(SIMPLE, "a")]); + let before = crate::queries::statistics::total_distance().length(); + assert!(run(crate::command::tools::crop(0, 9))); + assert_eq!(crate::queries::statistics::total_distance().length(), 10); + assert!(run(undo())); + assert_eq!( + crate::queries::statistics::total_distance().length(), + before + ); + + // one elevation per trackpoint + let ele = Float64Array::new_with_length(before); + assert!(run(crate::command::tools::elevation(&ele.to_vec()))); + assert!( + crate::queries::statistics::ele() + .to_vec() + .iter() + .all(|e| *e == 0.0) + ); + assert!(!run(crate::command::tools::elevation(&[1.0]))); + assert!(last_error().unwrap().contains("elevations")); + + assert!( + run(crate::command::tools::reduce(5.0)) || last_error().as_deref() == Some("nothing to do") + ); + assert!(crate::queries::statistics::total_distance().length() <= before); +} + +#[wasm_bindgen_test] +fn test_last_update_describes_the_last_call_only() { + started_with(&[]); + assert!(run(crate::command::files::new_file("a", None, None, None))); + let update = last_outcome(); + assert_eq!(array(get(&update, "added")).length(), 1); + assert_eq!(get(&update, "orderChanged").as_bool(), Some(true)); + + // a call that does nothing replaces it, whether the engine refuses or the arguments are wrong + assert!(!run(crate::command::tools::reverse())); + let update = last_outcome(); + assert_eq!(array(get(&update, "added")).length(), 0); + assert_eq!(get(&update, "orderChanged").as_bool(), Some(false)); + assert_eq!(get(&update, "selectionChanged").as_bool(), Some(false)); + + assert!(run(crate::command::files::new_file("b", None, None, None))); + assert!(!run(crate::command::tools::crop(3, 1))); + let update = last_outcome(); + assert_eq!(array(get(&update, "added")).length(), 0); + assert_eq!(get(&update, "orderChanged").as_bool(), Some(false)); +} diff --git a/gpx-rs/wasm/src/ts.rs b/gpx-rs/wasm/src/ts.rs new file mode 100644 index 000000000..aa00552ea --- /dev/null +++ b/gpx-rs/wasm/src/ts.rs @@ -0,0 +1,191 @@ +//! The TypeScript types of the JS objects, which the functions declare in their signatures. + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(typescript_custom_section)] +const ROUTE_TS: &str = r#" +/** + * An OSM attribute of the new trackpoints of a route, as the intervals of trackpoints that share + * a value: `starts[i]` is the index of the first trackpoint of the interval `i`, and `values[i]` + * its value, 0 when unknown, else 1 + the index of its name in `names`. + */ +export interface RouteCategory { + starts: Uint32Array; + values: Uint8Array; + names: string[]; +} +/** The attributes of the new trackpoints of a route. The ones that are missing are unknown. */ +export interface RouteAttributes { + surface?: RouteCategory; + highway?: RouteCategory; + sacScale?: RouteCategory; + mtbScale?: RouteCategory; +} +"#; + +#[wasm_bindgen(typescript_custom_section)] +const FILE_STRUCTURE_TS: &str = r#" +export type Selection = + | { type: 'empty' } + | { type: 'file'; fileIds: string[] } + | { type: 'track'; fileId: string; trackIds: string[] } + | { type: 'segment'; fileId: string; trackId: string; segmentIds: string[] } + | { type: 'waypoints'; fileId: string } + | { type: 'waypoint'; fileId: string; waypointIds: string[] }; +/** + * Where moved elements go: a list of the file tree and the position in it, counted among the + * elements of the list that are not moved. + */ +export type MoveTarget = + | { type: 'files'; index: number } + | { type: 'tracks'; fileId: string; index: number } + | { type: 'segments'; fileId: string; trackId: string; index: number } + | { type: 'waypoints'; fileId: string; index: number }; +/** All the data of a waypoint. The fields it does not have are absent. */ +export interface WaypointDetails { + id: string; + lng: number; + lat: number; + ele: number; + /** ms since epoch */ + time?: number; + name?: string; + desc?: string; + cmt?: string; + link?: { href: string; text?: string }; + sym?: string; +} +/** The position, elevation and time of a trackpoint. */ +export interface TrackpointDetails { + lng: number; + lat: number; + ele: number; + /** ms since epoch */ + time?: number; +} +/** What was copied or cut, to be pasted: the kind of the elements and their ids. */ +export interface Clipboard { + type: 'files' | 'tracks' | 'segments' | 'waypoints'; + ids: string[]; + cut: boolean; +} +/** What changed in the files, the selection and the clipboard. */ +export interface FilesUpdate { + orderChanged: boolean; + selectionChanged: boolean; + clipboardChanged: boolean; + /** Files to read the structure of. */ + added: string[]; + /** Files whose structure changed: reread it. */ + modified: string[]; + /** Files that do not exist anymore: drop their structure. */ + removed: string[]; +} +/** + * What a call that edits the files or changes the selection did. `changed` is whether anything + * did; `error` tells why not (nothing to do, invalid data, or what is wrong with the arguments). + * Apply the update to what is kept of the files before the next call. + */ +export interface Outcome extends FilesUpdate { + changed: boolean; + error?: string; +} +/** What opening the storage found. */ +export interface StorageOpened { + /** The settings, as JSON strings by key. */ + settings: Record; + /** The files that were restored are the added ones. */ + outcome: Outcome; + /** Some of what is stored was written by a newer version: nothing is saved. */ + readOnly: boolean; + /** How many stored files could not be read, and are dropped at the next save. */ + unreadable: number; +} +export interface FileStructure { + id: string; + name: string; + desc?: string; + tracks: TrackNode[]; + waypoints: WaypointNode[]; + /** Changes when the waypoints of the file change: refetch their coordinates. */ + waypointsRev: string; +} +/** + * Global statistics of a file, of the selection, or of a part of it. The optional fields are + * absent when there is no data for them (no timestamps, no heart rate...). + */ +export interface GlobalStatistics { + /** km */ + totalDistance: number; + movingDistance?: number; + /** seconds */ + totalTime?: number; + movingTime?: number; + elevationGain: number; + elevationLoss: number; + /** ms since epoch */ + startTime?: number; + endTime?: number; + /** km/h */ + totalSpeed?: number; + movingSpeed?: number; + /** Average and number of trackpoints having the measure. */ + hr?: { avg: number; count: number }; + cad?: { avg: number; count: number }; + atemp?: { avg: number; count: number }; + power?: { avg: number; count: number }; + /** Absent when there are no trackpoints. */ + bounds?: { west: number; south: number; east: number; north: number }; +} +export interface TrackNode { + id: string; + name?: string; + desc?: string; + /** Style of the track, only present when the file defines it. */ + color?: string; + opacity?: number; + width?: number; + segments: SegmentNode[]; +} +export interface SegmentNode { + id: string; + /** Changes when the trackpoints of the segment change: refetch their coordinates. */ + rev: string; + /** Number of trackpoints. */ + length: number; +} +export interface WaypointNode { + id: string; + name?: string; + sym?: string; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "Selection")] + pub type Selection; + #[wasm_bindgen(typescript_type = "WaypointDetails | undefined")] + pub type WaypointDetails; + #[wasm_bindgen(typescript_type = "TrackpointDetails | undefined")] + pub type TrackpointDetails; + #[wasm_bindgen(typescript_type = "MoveTarget")] + pub type MoveTarget; + + #[wasm_bindgen(typescript_type = "RouteAttributes")] + pub type RouteAttributes; + #[wasm_bindgen(typescript_type = "Clipboard | undefined")] + pub type Clipboard; + #[wasm_bindgen(typescript_type = "Outcome")] + pub type Outcome; + #[wasm_bindgen(typescript_type = "StorageOpened")] + pub type StorageOpened; + #[wasm_bindgen(typescript_type = "FileStructure | undefined")] + pub type FileStructure; + #[wasm_bindgen(typescript_type = "GlobalStatistics | undefined")] + pub type GlobalStatistics; + #[wasm_bindgen(typescript_type = "string[]")] + pub type FileOrder; + #[wasm_bindgen(typescript_type = "string[]")] + pub type StringList; +} diff --git a/gpx-rs/wasm/src/types.rs b/gpx-rs/wasm/src/types.rs new file mode 100644 index 000000000..0ffd68af7 --- /dev/null +++ b/gpx-rs/wasm/src/types.rs @@ -0,0 +1,75 @@ +//! The enumerations that cross to JS, and their engine counterparts. + +use gpx_engine as engine; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +#[derive(Clone, Copy)] +pub enum MergeType { + Connect, + Group, +} + +/// What a split cuts in two. +#[wasm_bindgen] +#[derive(Clone, Copy)] +pub enum SplitType { + Files, + Tracks, + Segments, +} + +impl From for engine::SplitType { + fn from(t: SplitType) -> Self { + match t { + SplitType::Files => Self::Files, + SplitType::Tracks => Self::Tracks, + SplitType::Segments => Self::Segments, + } + } +} + +#[wasm_bindgen] +#[derive(Clone, Copy)] +pub enum CleanType { + Inside, + Outside, +} + +/// How a selection combines with the current one: `Replace` it, `Add` to it, or `Toggle` the +/// elements (the ones already selected are removed). +#[wasm_bindgen] +#[derive(Clone, Copy)] +pub enum SelectMode { + Replace, + Add, + Toggle, +} + +impl From for engine::SelectMode { + fn from(m: SelectMode) -> Self { + match m { + SelectMode::Replace => Self::Replace, + SelectMode::Add => Self::Add, + SelectMode::Toggle => Self::Toggle, + } + } +} + +impl From for engine::MergeType { + fn from(t: MergeType) -> Self { + match t { + MergeType::Connect => Self::Connect, + MergeType::Group => Self::Group, + } + } +} + +impl From for engine::CleanType { + fn from(t: CleanType) -> Self { + match t { + CleanType::Inside => Self::Inside, + CleanType::Outside => Self::Outside, + } + } +} diff --git a/website/src/lib/engine.ts b/website/src/lib/engine.ts index 795e90d58..43a4556b6 100644 --- a/website/src/lib/engine.ts +++ b/website/src/lib/engine.ts @@ -21,6 +21,9 @@ import type { WaypointDetails, TrackpointDetails, RouteAttributes, + Outcome, + FilesUpdate, + StorageOpened, } from 'gpx-rs'; export type { @@ -31,6 +34,7 @@ export type { GlobalStatistics, FileStructure, FilesUpdate, + Outcome, Selection, TrackNode, SegmentNode, @@ -314,6 +318,7 @@ class Engine { private _visibility = new Map(); private _files = writable>>(new Map()); private _selection = writable({ type: 'empty' }); + private _lastError = writable(undefined); private _canUndo = writable(false); private _canRedo = writable(false); private _canPaste = writable(false); @@ -332,6 +337,8 @@ class Engine { readonly statistics: Readable = { subscribe: this._statistics.subscribe }; readonly canUndo: Readable = { subscribe: this._canUndo.subscribe }; readonly canRedo: Readable = { subscribe: this._canRedo.subscribe }; + /** Why the last call to the engine did nothing, `undefined` if it did something. */ + readonly lastError: Readable = { subscribe: this._lastError.subscribe }; /** What was copied or cut, waiting to be pasted. */ readonly clipboard: Readable = { subscribe: this._clipboard.subscribe }; /** Whether the clipboard can be pasted with the current selection. */ @@ -374,18 +381,27 @@ class Engine { private async openStorageOnce(): Promise> { await this.ready; const wasm = this.wasm!; - let settings: Record; + let opened: StorageOpened; try { - settings = (await wasm.open_storage(STORAGE_NAME)) as Record; + opened = await wasm.open_storage(STORAGE_NAME); } catch (error) { console.error('The files cannot be kept in this browser', error); return {}; } + if (opened.readOnly) { + console.warn( + 'The files were saved by a newer version of the app: they are not saved again by this one' + ); + } + if (opened.unreadable > 0) { + console.warn(`${opened.unreadable} stored file(s) could not be read`); + } + let settings = opened.settings; // what was hidden, which the files that were restored need when they are read parseVisibility(settings[VISIBILITY_KEY]).forEach((visibility, fileId) => this._visibility.set(fileId, visibility) ); - this.sync(wasm); + this.sync(wasm, opened.outcome); if (settings[LEGACY_IMPORTED_KEY] === undefined && get(this._order).length === 0) { settings = { ...settings, ...(await this.importLegacyData(wasm)) }; @@ -959,17 +975,31 @@ class Engine { return this.wasm?.waypoint_coordinates(fileId) ?? new Float64Array(); } - private async run(action: (wasm: Wasm) => boolean): Promise { + /** + * Makes a call to the engine and applies its outcome to what is kept of the files. Resolves to + * whether anything changed. When nothing did, `lastError` tells why. + */ + private async run(action: (wasm: Wasm) => Outcome): Promise { await this.ready; const wasm = this.wasm!; - const changed = action(wasm); - this.sync(wasm); - return changed; + const outcome = action(wasm); + this.sync(wasm, outcome); + this.report(outcome); + return outcome.changed; } - /** Reads what the last action changed. */ - private sync(wasm: Wasm) { - const update = wasm.last_update(); + /** Logs why a call did nothing, and keeps it in `lastError`. */ + private report(outcome: Outcome) { + this._lastError.set(outcome.error); + if (outcome.error === 'nothing to do') { + console.debug('engine:', outcome.error); + } else if (outcome.error !== undefined) { + console.warn('engine:', outcome.error); + } + } + + /** Updates what is kept of the engine with what a call changed. */ + private sync(wasm: Wasm, update: FilesUpdate) { this._canUndo.set(wasm.can_undo()); this._canRedo.set(wasm.can_redo()); this._canPaste.set(wasm.can_paste());