mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-09 23:26:31 +00:00
progress
This commit is contained in:
1 parent
b085a1d476
commit
42a55e6759
51 files changed
+5743
-3841
No files matched your search
Generated
+151
@@ -11,6 +11,17 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "atomic-polyfill"
|
name = "atomic-polyfill"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -38,6 +49,12 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cast"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.4.6"
|
version = "1.4.6"
|
||||||
@@ -179,6 +196,7 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
|
"wasm-bindgen-test",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -259,6 +277,12 @@ dependencies = [
|
|||||||
"hashbrown",
|
"hashbrown",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.106"
|
version = "0.3.106"
|
||||||
@@ -276,6 +300,12 @@ version = "0.2.189"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libm"
|
||||||
|
version = "0.2.16"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
version = "0.4.14"
|
version = "0.4.14"
|
||||||
@@ -297,6 +327,25 @@ version = "2.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
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]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -304,6 +353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"autocfg",
|
"autocfg",
|
||||||
|
"libm",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -312,6 +362,12 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oorandom"
|
||||||
|
version = "11.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -379,6 +435,15 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
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]]
|
[[package]]
|
||||||
name = "scopeguard"
|
name = "scopeguard"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -421,6 +486,19 @@ dependencies = [
|
|||||||
"syn 3.0.6",
|
"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]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -517,6 +595,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.129"
|
version = "0.2.129"
|
||||||
@@ -573,6 +661,45 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.106"
|
version = "0.3.106"
|
||||||
@@ -583,6 +710,15 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
@@ -641,3 +777,18 @@ checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"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"
|
||||||
@@ -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
|
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
|
||||||
|
```
|
||||||
@@ -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<Vec<Option<i64>>> {
|
||||||
|
let mut last: Option<Trackpoint> = None;
|
||||||
|
segments
|
||||||
|
.iter()
|
||||||
|
.map(|segment| {
|
||||||
|
let points: Vec<Trackpoint> = 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<Vec<Option<i64>>> {
|
||||||
|
let mut weights: Vec<Vec<f64>> = segments
|
||||||
|
.iter()
|
||||||
|
.map(|segment| {
|
||||||
|
let points: Vec<Trackpoint> = segment.iter().cloned().collect();
|
||||||
|
let slopes: Vec<f64> = 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::<usize>() 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<Trackpoint> = 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
|
/// 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.
|
/// whole path consistent. It is the work of the old `replaceTrackPoints` of the TS library.
|
||||||
|
|||||||
@@ -3,10 +3,17 @@
|
|||||||
///
|
///
|
||||||
/// The codes are the positions of the names in the order of first appearance. The table only
|
/// 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.
|
/// 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<String>);
|
pub struct Categories(Vec<String>);
|
||||||
|
|
||||||
impl Categories {
|
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<String>) -> Self {
|
||||||
|
names.truncate(Self::CAPACITY);
|
||||||
|
Self(names)
|
||||||
|
}
|
||||||
|
|
||||||
/// Maximum number of distinct values.
|
/// Maximum number of distinct values.
|
||||||
pub const CAPACITY: usize = u8::MAX as usize;
|
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
|
/// The categories of the data of the trackpoints. There is one table per engine, shared by all
|
||||||
/// its files.
|
/// its files.
|
||||||
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
pub struct TrackpointCategories {
|
pub struct TrackpointCategories {
|
||||||
pub surface: Categories,
|
pub surface: Categories,
|
||||||
pub highway: Categories,
|
pub highway: Categories,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
macro_rules! uuid_id {
|
macro_rules! uuid_id {
|
||||||
($(#[$meta:meta])* $name:ident) => {
|
($(#[$meta:meta])* $name:ident) => {
|
||||||
$(#[$meta])*
|
$(#[$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);
|
pub struct $name(pub uuid::Uuid);
|
||||||
|
|
||||||
impl Default for $name {
|
impl Default for $name {
|
||||||
@@ -15,13 +15,13 @@ macro_rules! uuid_id {
|
|||||||
|
|
||||||
pub(crate) use 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 struct Link {
|
||||||
pub href: String,
|
pub href: String,
|
||||||
pub text: Option<String>,
|
pub text: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, Copy, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
pub struct LngLat {
|
pub struct LngLat {
|
||||||
pub lng: f64,
|
pub lng: f64,
|
||||||
pub lat: f64,
|
pub lat: f64,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ pub struct File {
|
|||||||
pub wpt: Waypoints,
|
pub wpt: Waypoints,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
pub struct FileInfo {
|
pub struct FileInfo {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub desc: Option<String>,
|
pub desc: Option<String>,
|
||||||
@@ -21,7 +21,7 @@ pub struct FileInfo {
|
|||||||
pub time: Option<i64>,
|
pub time: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
pub struct Author {
|
pub struct Author {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub email: Option<String>,
|
pub email: Option<String>,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ pub struct Track {
|
|||||||
pub trkseg: Vec<TrackSegment>,
|
pub trkseg: Vec<TrackSegment>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
pub struct TrackInfo {
|
pub struct TrackInfo {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub cmt: Option<String>,
|
pub cmt: Option<String>,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::LngLat;
|
use crate::LngLat;
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct Trackpoint {
|
pub struct Trackpoint {
|
||||||
pub coordinates: LngLat,
|
pub coordinates: LngLat,
|
||||||
pub ele: f64,
|
pub ele: f64,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use super::common::uuid_id;
|
|||||||
|
|
||||||
uuid_id!(WaypointId);
|
uuid_id!(WaypointId);
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct Waypoint {
|
pub struct Waypoint {
|
||||||
pub id: WaypointId,
|
pub id: WaypointId,
|
||||||
pub coordinates: LngLat,
|
pub coordinates: LngLat,
|
||||||
|
|||||||
+8
-11
@@ -69,9 +69,10 @@ impl Statistics {
|
|||||||
|
|
||||||
self.global.total_distance += dist;
|
self.global.total_distance += dist;
|
||||||
|
|
||||||
if let Some(time) = time {
|
let moving = time
|
||||||
let speed = speed(dist, time);
|
.zip(time.and_then(|time| speed(dist, time)))
|
||||||
if (0.5..=1500.0).contains(&speed) {
|
.filter(|(_, speed)| (0.5..=1500.0).contains(speed));
|
||||||
|
if let Some((time, _)) = moving {
|
||||||
self.global.moving_distance = self
|
self.global.moving_distance = self
|
||||||
.global
|
.global
|
||||||
.moving_distance
|
.moving_distance
|
||||||
@@ -82,7 +83,6 @@ impl Statistics {
|
|||||||
.map_or(Some(time), |t| Some(t + time));
|
.map_or(Some(time), |t| Some(t + time));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn update_time_bounds(&mut self, time: Option<i64>) {
|
fn update_time_bounds(&mut self, time: Option<i64>) {
|
||||||
if let Some(time) = time {
|
if let Some(time) = time {
|
||||||
@@ -119,8 +119,7 @@ impl Statistics {
|
|||||||
};
|
};
|
||||||
let (left, right) = (timed(left), timed(right));
|
let (left, right) = (timed(left), timed(right));
|
||||||
self.local[i.flat].speed = time_diff(trkseg[right].time, trkseg[left].time)
|
self.local[i.flat].speed = time_diff(trkseg[right].time, trkseg[left].time)
|
||||||
.filter(|time| *time > 0)
|
.and_then(|time| {
|
||||||
.map(|time| {
|
|
||||||
speed(
|
speed(
|
||||||
self.local[right.flat].total_distance
|
self.local[right.flat].total_distance
|
||||||
- self.local[left.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
|
/// 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).
|
/// example a range with a single timestamp, or timestamps going backwards).
|
||||||
pub fn total_speed(&self) -> Option<f64> {
|
pub fn total_speed(&self) -> Option<f64> {
|
||||||
self.total_time
|
self.total_time.and_then(|t| speed(self.total_distance, t))
|
||||||
.filter(|t| *t > 0)
|
|
||||||
.map(|t| speed(self.total_distance, t))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Average speed over the moving time, unknown if there is no moving time.
|
/// Average speed over the moving time, unknown if there is no moving time.
|
||||||
pub fn moving_speed(&self) -> Option<f64> {
|
pub fn moving_speed(&self) -> Option<f64> {
|
||||||
self.moving_distance
|
self.moving_distance
|
||||||
.zip(self.moving_time.filter(|t| *t > 0))
|
.zip(self.moving_time)
|
||||||
.map(|(d, t)| speed(d, t))
|
.and_then(|(d, t)| speed(d, t))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn merge(&mut self, other: &GlobalStatistics) {
|
pub fn merge(&mut self, other: &GlobalStatistics) {
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
mod statistics;
|
mod compute;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
pub use statistics::*;
|
pub use compute::*;
|
||||||
pub use utils::*;
|
pub use utils::*;
|
||||||
@@ -22,9 +22,10 @@ pub fn time_diff(a: Option<i64>, b: Option<i64>) -> Option<i64> {
|
|||||||
Some(a? - b?)
|
Some(a? - b?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Computes the speed for a given distance in kilometers and a time in milliseconds
|
/// Computes the speed in km/h for a given distance in kilometers and a time in milliseconds.
|
||||||
pub fn speed(distance: f64, time: i64) -> f64 {
|
/// Unknown if the time is not a positive duration.
|
||||||
distance / (time as f64 / 3_600_000.0)
|
pub fn speed(distance: f64, time: i64) -> Option<f64> {
|
||||||
|
(time > 0).then(|| distance / (time as f64 / 3_600_000.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn slope(ele: f64, distance: f64) -> f64 {
|
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(None, Some(1)), None);
|
||||||
assert_eq!(time_diff(Some(1), None), None);
|
assert_eq!(time_diff(Some(1), None), None);
|
||||||
// 1 km in one hour
|
// 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]
|
#[test]
|
||||||
@@ -154,9 +155,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_speed() {
|
fn test_speed() {
|
||||||
// 1 km in 1 h
|
// 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
|
// 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]
|
#[test]
|
||||||
|
|||||||
@@ -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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
mod apply;
|
mod apply;
|
||||||
mod command;
|
|
||||||
mod edit;
|
mod edit;
|
||||||
mod file;
|
mod file;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -8,8 +7,83 @@ mod pattern;
|
|||||||
mod tools;
|
mod tools;
|
||||||
|
|
||||||
pub use apply::*;
|
pub use apply::*;
|
||||||
pub use command::*;
|
|
||||||
pub use edit::*;
|
pub use edit::*;
|
||||||
pub use file::*;
|
pub use file::*;
|
||||||
pub use pattern::*;
|
pub use pattern::*;
|
||||||
pub use tools::*;
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
Apply, CommandError, SegmentLocation, State, Statistics, TrackSegment, Trackpoint,
|
Apply, CommandError, State, TrackSegment, artificial_segment_times, changed_segment_times,
|
||||||
artificial_weights, shifted_and_compressed, update_segments, with_artificial_timestamps,
|
update_segments,
|
||||||
with_timestamps,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// How the timestamps of the selection are set.
|
/// How the timestamps of the selection are set.
|
||||||
@@ -46,81 +45,23 @@ impl Apply for Time {
|
|||||||
let locations = state
|
let locations = state
|
||||||
.selection
|
.selection
|
||||||
.segment_locations(state.files, &state.order.0);
|
.segment_locations(state.files, &state.order.0);
|
||||||
let segment_at = |location: &SegmentLocation| -> &TrackSegment {
|
let segments: Vec<&TrackSegment> = locations
|
||||||
&state.files[&location.file_id].trk[location.trk].trkseg[location.seg]
|
|
||||||
};
|
|
||||||
if locations
|
|
||||||
.iter()
|
.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);
|
return Err(CommandError::NothingToDo);
|
||||||
}
|
}
|
||||||
|
|
||||||
// the new timestamps of each segment
|
let times = match self.kind {
|
||||||
let mut times: Vec<Vec<Option<i64>>> = Vec::with_capacity(locations.len());
|
|
||||||
match self.kind {
|
|
||||||
TimeKind::Change { speed, ratio } => {
|
TimeKind::Change { speed, ratio } => {
|
||||||
let mut last: Option<Trackpoint> = None;
|
changed_segment_times(&segments, self.start_time, speed, ratio)
|
||||||
for location in &locations {
|
|
||||||
let points: Vec<Trackpoint> = 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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
TimeKind::Artificial { total_time } => {
|
TimeKind::Artificial { total_time } => {
|
||||||
let mut weights: Vec<Vec<f64>> = locations
|
artificial_segment_times(&segments, self.start_time, total_time)
|
||||||
.iter()
|
|
||||||
.map(|location| {
|
|
||||||
let segment = segment_at(location);
|
|
||||||
let points: Vec<Trackpoint> = segment.iter().cloned().collect();
|
|
||||||
let stats = Statistics::compute(segment);
|
|
||||||
let slopes: Vec<f64> = 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::<usize>() 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<Trackpoint> = 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
update_segments(state, &locations, |i, segment| {
|
update_segments(state, &locations, |i, segment| {
|
||||||
let times = ×[i];
|
let times = ×[i];
|
||||||
segment.update_all(|j, pt| pt.time = times.get(j).copied().flatten());
|
segment.update_all(|j, pt| pt.time = times.get(j).copied().flatten());
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,11 +1,372 @@
|
|||||||
mod action;
|
mod action;
|
||||||
mod command;
|
mod command;
|
||||||
mod derived;
|
mod derived;
|
||||||
mod engine;
|
mod outcome;
|
||||||
mod state;
|
mod state;
|
||||||
|
|
||||||
pub use action::*;
|
pub use action::*;
|
||||||
pub use command::*;
|
pub use command::*;
|
||||||
pub use derived::*;
|
pub use derived::*;
|
||||||
pub use engine::*;
|
pub use outcome::*;
|
||||||
pub use state::*;
|
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<Clipboard>,
|
||||||
|
/// 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<GlobalStatistics> {
|
||||||
|
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<f64> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Option<Diff>, 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<Diff>, 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;
|
||||||
@@ -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<Diff>,
|
||||||
|
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<CommandError>,
|
||||||
|
}
|
||||||
@@ -48,6 +48,56 @@ pub struct SegmentLocation {
|
|||||||
pub seg: usize,
|
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 {
|
impl Selection {
|
||||||
/// The track segments covered by the selection, in order: the segments of several selected
|
/// 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
|
/// files follow `order` (the files missing from it come last), then the tracks and segments
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ use std::{
|
|||||||
rc::Rc,
|
rc::Rc,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{File, FileId};
|
use crate::{CommandError, File, FileId};
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct Stack {
|
pub struct Stack {
|
||||||
entries: Vec<StackEntry>,
|
entries: Vec<StackEntry>,
|
||||||
index: Option<usize>,
|
index: Option<usize>,
|
||||||
/// Whether the first entry is the state the stack started from (files restored from the
|
/// Whether the first entry is the state the stack starts from, which is not undone: files
|
||||||
/// storage), which is not undone.
|
/// restored from the storage.
|
||||||
restored: bool,
|
floor: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Stack {
|
impl Stack {
|
||||||
@@ -20,7 +20,7 @@ impl Stack {
|
|||||||
Self {
|
Self {
|
||||||
entries: vec![entry],
|
entries: vec![entry],
|
||||||
index: Some(0),
|
index: Some(0),
|
||||||
restored: true,
|
floor: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,15 +28,32 @@ impl Stack {
|
|||||||
self.index.map_or_default(|i| self.get(i))
|
self.index.map_or_default(|i| self.get(i))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_and_push_next<F>(&mut self, f: F) -> Option<Diff>
|
/// 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<F>(&mut self, f: F) -> Result<Option<Diff>, CommandError>
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut StackEntry) -> Result<(), String>,
|
F: FnOnce(&mut StackEntry) -> Result<(), CommandError>,
|
||||||
{
|
{
|
||||||
self.record_diff(|stack| {
|
let mut next = self.current().map_or_default(|c| c.clone());
|
||||||
let mut next = stack.current().map_or_default(|c| c.clone());
|
f(&mut next)?;
|
||||||
if f(&mut next).is_ok() {
|
if self.is_current(&next) {
|
||||||
stack.push(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)))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +61,7 @@ impl Stack {
|
|||||||
self.record_diff(|stack| {
|
self.record_diff(|stack| {
|
||||||
if let Some(i) = stack.index {
|
if let Some(i) = stack.index {
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
if !stack.restored {
|
if !stack.floor {
|
||||||
stack.index = None;
|
stack.index = None;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -70,7 +87,7 @@ impl Stack {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn can_undo(&self) -> bool {
|
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 {
|
pub fn can_redo(&self) -> bool {
|
||||||
@@ -137,6 +154,16 @@ pub struct Diff {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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<Diff> {
|
||||||
|
self.create_and_push_next(f).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_new_file() {
|
fn test_new_file() {
|
||||||
let mut stack = Stack::default();
|
let mut stack = Stack::default();
|
||||||
@@ -145,7 +172,7 @@ mod tests {
|
|||||||
assert!(!stack.can_redo());
|
assert!(!stack.can_redo());
|
||||||
assert!(stack.current().is_none());
|
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());
|
let file = Rc::new(File::default());
|
||||||
e.insert(file.id, file);
|
e.insert(file.id, file);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -167,7 +194,7 @@ mod tests {
|
|||||||
let file = Rc::new(File::default());
|
let file = Rc::new(File::default());
|
||||||
let id = file.id;
|
let id = file.id;
|
||||||
stack
|
stack
|
||||||
.create_and_push_next(|e| {
|
.push_ok(|e| {
|
||||||
e.insert(id, file);
|
e.insert(id, file);
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -180,9 +207,9 @@ mod tests {
|
|||||||
let mut stack = Stack::default();
|
let mut stack = Stack::default();
|
||||||
let diff = stack.create_and_push_next(|e| {
|
let diff = stack.create_and_push_next(|e| {
|
||||||
e.insert(FileId::default(), Rc::new(File::default()));
|
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.can_undo());
|
||||||
assert!(stack.current().is_none());
|
assert!(stack.current().is_none());
|
||||||
}
|
}
|
||||||
@@ -217,7 +244,7 @@ mod tests {
|
|||||||
let id = add_file(&mut stack);
|
let id = add_file(&mut stack);
|
||||||
|
|
||||||
let diff = stack
|
let diff = stack
|
||||||
.create_and_push_next(|e| {
|
.push_ok(|e| {
|
||||||
let mut file = (**e.get(&id).unwrap()).clone();
|
let mut file = (**e.get(&id).unwrap()).clone();
|
||||||
file.info.name = "renamed".to_string();
|
file.info.name = "renamed".to_string();
|
||||||
e.insert(id, Rc::new(file));
|
e.insert(id, Rc::new(file));
|
||||||
@@ -227,12 +254,20 @@ mod tests {
|
|||||||
assert_eq!(diff.modified, vec![id]);
|
assert_eq!(diff.modified, vec![id]);
|
||||||
assert!(diff.added.is_empty() && diff.removed.is_empty());
|
assert!(diff.added.is_empty() && diff.removed.is_empty());
|
||||||
|
|
||||||
// an unchanged file is not reported as modified
|
// a step that changes nothing is not a step
|
||||||
let diff = stack.create_and_push_next(|_| Ok(())).unwrap();
|
assert!(stack.push_ok(|_| Ok(())).is_none());
|
||||||
assert!(diff.modified.is_empty());
|
assert!(
|
||||||
|
stack
|
||||||
|
.push_ok(|e| {
|
||||||
|
let file = e[&id].clone();
|
||||||
|
e.insert(id, file);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
let diff = stack
|
let diff = stack
|
||||||
.create_and_push_next(|e| {
|
.push_ok(|e| {
|
||||||
e.remove(&id);
|
e.remove(&id);
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
@@ -253,7 +288,7 @@ mod tests {
|
|||||||
// a copy, even if it has the same content, is another file for the stack: files are
|
// 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
|
// told apart by their identity, comparing them would read all of them
|
||||||
let diff = stack
|
let diff = stack
|
||||||
.create_and_push_next(|e| {
|
.push_ok(|e| {
|
||||||
let copy = (**e.get(&id).unwrap()).clone();
|
let copy = (**e.get(&id).unwrap()).clone();
|
||||||
e.insert(id, Rc::new(copy));
|
e.insert(id, Rc::new(copy));
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -264,7 +299,7 @@ mod tests {
|
|||||||
// and the files that were not replaced are not
|
// and the files that were not replaced are not
|
||||||
let other = add_file(&mut stack);
|
let other = add_file(&mut stack);
|
||||||
let diff = stack
|
let diff = stack
|
||||||
.create_and_push_next(|e| {
|
.push_ok(|e| {
|
||||||
let copy = (**e.get(&other).unwrap()).clone();
|
let copy = (**e.get(&other).unwrap()).clone();
|
||||||
e.insert(other, Rc::new(copy));
|
e.insert(other, Rc::new(copy));
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -361,7 +396,7 @@ mod tests {
|
|||||||
let mut stack = Stack::restored(entry);
|
let mut stack = Stack::restored(entry);
|
||||||
|
|
||||||
let diff = stack
|
let diff = stack
|
||||||
.create_and_push_next(|e| {
|
.push_ok(|e| {
|
||||||
e.remove(&restored);
|
e.remove(&restored);
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -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<T: Serialize + ?Sized>(value: &T) -> Vec<u8> {
|
|
||||||
postcard::to_extend(value, vec![VERSION]).expect("serializing to memory")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode<'a, T: Deserialize<'a>>(bytes: &'a [u8]) -> Option<T> {
|
|
||||||
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<TrackRecord>,
|
|
||||||
waypoints: Vec<WaypointChunkId>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
struct TrackRecord {
|
|
||||||
id: TrackId,
|
|
||||||
info: TrackInfo,
|
|
||||||
segments: Vec<SegmentRecord>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
|
||||||
struct SegmentRecord {
|
|
||||||
id: TrackSegmentId,
|
|
||||||
chunks: Vec<TrackpointChunkId>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<Rc<TrackpointChunk>>,
|
|
||||||
waypoint_chunks: &impl Fn(&WaypointChunkId) -> Option<Rc<WaypointChunk>>,
|
|
||||||
) -> Option<File> {
|
|
||||||
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<u8> {
|
|
||||||
encode(&chunk.trkpt)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode_trackpoints(id: Uuid, bytes: &[u8]) -> Option<TrackpointChunk> {
|
|
||||||
Some(TrackpointChunk {
|
|
||||||
id: TrackpointChunkId(id),
|
|
||||||
trkpt: decode::<Vec<Trackpoint>>(bytes)?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn encode_waypoints(chunk: &WaypointChunk) -> Vec<u8> {
|
|
||||||
encode(&chunk.wpt)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn decode_waypoints(id: Uuid, bytes: &[u8]) -> Option<WaypointChunk> {
|
|
||||||
Some(WaypointChunk {
|
|
||||||
id: WaypointChunkId(id),
|
|
||||||
wpt: decode::<Vec<Waypoint>>(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::<Vec<(u32, String)>>(&bytes), Some(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_decode_rejects_other_versions_and_garbage() {
|
|
||||||
let mut bytes = encode(&42_u32);
|
|
||||||
assert_eq!(decode::<u32>(&bytes), Some(42));
|
|
||||||
bytes[0] = VERSION + 1;
|
|
||||||
assert_eq!(decode::<u32>(&bytes), None);
|
|
||||||
assert_eq!(decode::<u32>(&[]), None);
|
|
||||||
// the right version, but not what was asked for
|
|
||||||
assert_eq!(decode::<Vec<String>>(&[VERSION, 0xff, 0xff, 0xff]), None);
|
|
||||||
assert_eq!(decode::<u32>(&[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<f64> = 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<TrackpointChunkId, Rc<TrackpointChunk>>,
|
|
||||||
HashMap<WaypointChunkId, Rc<WaypointChunk>>,
|
|
||||||
) {
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<T: Serialize + ?Sized>(value: &T) -> Vec<u8> {
|
||||||
|
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<T, DecodeError> {
|
||||||
|
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<TrackRecord>,
|
||||||
|
waypoints: Vec<WaypointChunkId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TrackRecord {
|
||||||
|
id: TrackId,
|
||||||
|
info: TrackInfo,
|
||||||
|
segments: Vec<SegmentRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SegmentRecord {
|
||||||
|
id: TrackSegmentId,
|
||||||
|
chunks: Vec<TrackpointChunkId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<v1::FileRecord1> 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<Rc<TrackpointChunk>>,
|
||||||
|
waypoint_chunks: &impl Fn(&WaypointChunkId) -> Option<Rc<WaypointChunk>>,
|
||||||
|
) -> Option<File> {
|
||||||
|
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<u8> {
|
||||||
|
encode(&v1::FileRecord1::new(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_file(bytes: &[u8]) -> Result<FileRecord, DecodeError> {
|
||||||
|
match split(bytes)? {
|
||||||
|
(1, data) => Ok(parse::<v1::FileRecord1>(data)?.into()),
|
||||||
|
(version, _) => Err(DecodeError::UnknownVersion(version)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_trackpoints(chunk: &TrackpointChunk) -> Vec<u8> {
|
||||||
|
encode(
|
||||||
|
&chunk
|
||||||
|
.trkpt
|
||||||
|
.iter()
|
||||||
|
.map(v1::Trackpoint1::from)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_trackpoints(id: Uuid, bytes: &[u8]) -> Result<TrackpointChunk, DecodeError> {
|
||||||
|
let trkpt = match split(bytes)? {
|
||||||
|
(1, data) => parse::<Vec<v1::Trackpoint1>>(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<u8> {
|
||||||
|
encode(
|
||||||
|
&chunk
|
||||||
|
.wpt
|
||||||
|
.iter()
|
||||||
|
.map(v1::Waypoint1::from)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_waypoints(id: Uuid, bytes: &[u8]) -> Result<WaypointChunk, DecodeError> {
|
||||||
|
let wpt = match split(bytes)? {
|
||||||
|
(1, data) => parse::<Vec<v1::Waypoint1>>(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<u8> {
|
||||||
|
encode(&order.iter().map(|id| id.0).collect::<Vec<_>>())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_order(bytes: &[u8]) -> Result<Vec<FileId>, DecodeError> {
|
||||||
|
match split(bytes)? {
|
||||||
|
(1, data) => Ok(parse::<Vec<Uuid>>(data)?.into_iter().map(FileId).collect()),
|
||||||
|
(version, _) => Err(DecodeError::UnknownVersion(version)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_categories(categories: &TrackpointCategories) -> Vec<u8> {
|
||||||
|
encode(&v1::Categories1::from(categories))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_categories(bytes: &[u8]) -> Result<TrackpointCategories, DecodeError> {
|
||||||
|
match split(bytes)? {
|
||||||
|
(1, data) => Ok(parse::<v1::Categories1>(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<u8> {
|
||||||
|
(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<TrackpointChunkId, Rc<TrackpointChunk>>,
|
||||||
|
HashMap<WaypointChunkId, Rc<WaypointChunk>>,
|
||||||
|
) {
|
||||||
|
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<File> {
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Link> for Link1 {
|
||||||
|
fn from(link: &Link) -> Self {
|
||||||
|
Self {
|
||||||
|
href: link.href.clone(),
|
||||||
|
text: link.text.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Link1> for Link {
|
||||||
|
fn from(link: Link1) -> Self {
|
||||||
|
Self {
|
||||||
|
href: link.href,
|
||||||
|
text: link.text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct Author1 {
|
||||||
|
name: Option<String>,
|
||||||
|
email: Option<String>,
|
||||||
|
link: Option<Link1>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct FileInfo1 {
|
||||||
|
name: String,
|
||||||
|
desc: Option<String>,
|
||||||
|
author: Option<Author1>,
|
||||||
|
link: Option<Link1>,
|
||||||
|
time: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<FileInfo1> 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<String>,
|
||||||
|
cmt: Option<String>,
|
||||||
|
desc: Option<String>,
|
||||||
|
src: Option<String>,
|
||||||
|
link: Option<Link1>,
|
||||||
|
type_: Option<String>,
|
||||||
|
color: Option<String>,
|
||||||
|
opacity: Option<f64>,
|
||||||
|
width: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<TrackInfo1> 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<TrackRecord1>,
|
||||||
|
pub waypoints: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct TrackRecord1 {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub info: TrackInfo1,
|
||||||
|
pub segments: Vec<SegmentRecord1>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct SegmentRecord1 {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub chunks: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<i64>,
|
||||||
|
atemp: Option<i16>,
|
||||||
|
hr: Option<u16>,
|
||||||
|
cad: Option<u16>,
|
||||||
|
power: Option<u16>,
|
||||||
|
surface: Option<u8>,
|
||||||
|
highway: Option<u8>,
|
||||||
|
sac_scale: Option<u8>,
|
||||||
|
mtb_scale: Option<u8>,
|
||||||
|
anchor: Option<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Trackpoint1> 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<i64>,
|
||||||
|
name: Option<String>,
|
||||||
|
cmt: Option<String>,
|
||||||
|
desc: Option<String>,
|
||||||
|
link: Option<Link1>,
|
||||||
|
sym: Option<String>,
|
||||||
|
type_: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Waypoint1> 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<String>,
|
||||||
|
highway: Vec<String>,
|
||||||
|
sac_scale: Vec<String>,
|
||||||
|
mtb_scale: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Categories1> 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use uuid::Uuid;
|
use crate::{Batch, ChunkKey, ChunkKind, FileId, Storage, StorageError, StoredData};
|
||||||
|
|
||||||
use crate::{Batch, FileId, Storage, StorageError, StoredData};
|
|
||||||
|
|
||||||
/// A storage that lives as long as the session. It is what an engine without a host storage uses,
|
/// A storage that lives as long as the session. It is what an engine without a host storage uses,
|
||||||
/// and what the tests use.
|
/// and what the tests use.
|
||||||
@@ -15,22 +13,30 @@ pub struct MemoryStorage {
|
|||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct State {
|
struct State {
|
||||||
files: HashMap<FileId, Vec<u8>>,
|
files: HashMap<FileId, Vec<u8>>,
|
||||||
trackpoint_chunks: HashMap<Uuid, Vec<u8>>,
|
chunks: HashMap<ChunkKey, Vec<u8>>,
|
||||||
waypoint_chunks: HashMap<Uuid, Vec<u8>>,
|
|
||||||
order: Option<Vec<u8>>,
|
order: Option<Vec<u8>>,
|
||||||
categories: Option<Vec<u8>>,
|
categories: Option<Vec<u8>>,
|
||||||
settings: HashMap<String, String>,
|
settings: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
impl MemoryStorage {
|
||||||
/// How many files and chunks (trackpoints, waypoints) are stored.
|
/// How many files and chunks are stored.
|
||||||
pub fn counts(&self) -> (usize, usize, usize) {
|
pub fn counts(&self) -> StorageCounts {
|
||||||
let state = self.state.borrow();
|
let state = self.state.borrow();
|
||||||
(
|
let chunks = |kind| state.chunks.keys().filter(|key| key.kind == kind).count();
|
||||||
state.files.len(),
|
StorageCounts {
|
||||||
state.trackpoint_chunks.len(),
|
files: state.files.len(),
|
||||||
state.waypoint_chunks.len(),
|
trackpoint_chunks: chunks(ChunkKind::Trackpoints),
|
||||||
)
|
waypoint_chunks: chunks(ChunkKind::Waypoints),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,16 +45,7 @@ impl Storage for MemoryStorage {
|
|||||||
let state = self.state.borrow();
|
let state = self.state.borrow();
|
||||||
Ok(StoredData {
|
Ok(StoredData {
|
||||||
files: state.files.iter().map(|(k, v)| (*k, v.clone())).collect(),
|
files: state.files.iter().map(|(k, v)| (*k, v.clone())).collect(),
|
||||||
trackpoint_chunks: state
|
chunks: state.chunks.iter().map(|(k, v)| (*k, v.clone())).collect(),
|
||||||
.trackpoint_chunks
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (*k, v.clone()))
|
|
||||||
.collect(),
|
|
||||||
waypoint_chunks: state
|
|
||||||
.waypoint_chunks
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (*k, v.clone()))
|
|
||||||
.collect(),
|
|
||||||
order: state.order.clone(),
|
order: state.order.clone(),
|
||||||
categories: state.categories.clone(),
|
categories: state.categories.clone(),
|
||||||
})
|
})
|
||||||
@@ -60,17 +57,9 @@ impl Storage for MemoryStorage {
|
|||||||
for id in &batch.delete_files {
|
for id in &batch.delete_files {
|
||||||
state.files.remove(id);
|
state.files.remove(id);
|
||||||
}
|
}
|
||||||
state
|
state.chunks.extend(batch.put_chunks.iter().cloned());
|
||||||
.trackpoint_chunks
|
for key in &batch.delete_chunks {
|
||||||
.extend(batch.put_trackpoint_chunks.iter().cloned());
|
state.chunks.remove(key);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
if let Some(order) = &batch.order {
|
if let Some(order) = &batch.order {
|
||||||
state.order = Some(order.clone());
|
state.order = Some(order.clone());
|
||||||
@@ -109,6 +98,8 @@ mod tests {
|
|||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::task::{Context, Poll, Waker};
|
use std::task::{Context, Poll, Waker};
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn block_on<F: Future>(future: F) -> F::Output {
|
fn block_on<F: Future>(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]
|
#[test]
|
||||||
fn test_empty_storage_has_nothing() {
|
fn test_empty_storage_has_nothing() {
|
||||||
let storage = MemoryStorage::default();
|
let storage = MemoryStorage::default();
|
||||||
let data = block_on(storage.load()).unwrap();
|
let data = block_on(storage.load()).unwrap();
|
||||||
assert_eq!(data, StoredData::default());
|
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());
|
assert!(block_on(storage.load_settings()).unwrap().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,20 +135,28 @@ mod tests {
|
|||||||
let (t, w) = (Uuid::new_v4(), Uuid::new_v4());
|
let (t, w) = (Uuid::new_v4(), Uuid::new_v4());
|
||||||
block_on(storage.commit(&Batch {
|
block_on(storage.commit(&Batch {
|
||||||
put_files: vec![(a, vec![1]), (b, vec![2])],
|
put_files: vec![(a, vec![1]), (b, vec![2])],
|
||||||
put_trackpoint_chunks: vec![(t, vec![3])],
|
put_chunks: vec![
|
||||||
put_waypoint_chunks: vec![(w, vec![4])],
|
(ChunkKey::trackpoints(t), vec![3]),
|
||||||
|
(ChunkKey::waypoints(w), vec![4]),
|
||||||
|
],
|
||||||
order: Some(vec![5]),
|
order: Some(vec![5]),
|
||||||
categories: Some(vec![6]),
|
categories: Some(vec![6]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}))
|
}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(storage.counts(), (2, 1, 1));
|
assert_eq!(storage.counts(), counts(2, 1, 1));
|
||||||
|
|
||||||
let mut data = block_on(storage.load()).unwrap();
|
let mut data = block_on(storage.load()).unwrap();
|
||||||
data.files.sort_by_key(|(_, bytes)| bytes.clone());
|
data.files.sort_by_key(|(_, bytes)| bytes.clone());
|
||||||
assert_eq!(data.files, vec![(a, vec![1]), (b, vec![2])]);
|
assert_eq!(data.files, vec![(a, vec![1]), (b, vec![2])]);
|
||||||
assert_eq!(data.trackpoint_chunks, vec![(t, vec![3])]);
|
data.chunks.sort_by_key(|(_, bytes)| bytes.clone());
|
||||||
assert_eq!(data.waypoint_chunks, vec![(w, vec![4])]);
|
assert_eq!(
|
||||||
|
data.chunks,
|
||||||
|
vec![
|
||||||
|
(ChunkKey::trackpoints(t), vec![3]),
|
||||||
|
(ChunkKey::waypoints(w), vec![4])
|
||||||
|
]
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(data.order, data.categories),
|
(data.order, data.categories),
|
||||||
(Some(vec![5]), Some(vec![6]))
|
(Some(vec![5]), Some(vec![6]))
|
||||||
@@ -159,11 +166,11 @@ mod tests {
|
|||||||
block_on(storage.commit(&Batch {
|
block_on(storage.commit(&Batch {
|
||||||
put_files: vec![(a, vec![10])],
|
put_files: vec![(a, vec![10])],
|
||||||
delete_files: vec![b],
|
delete_files: vec![b],
|
||||||
delete_trackpoint_chunks: vec![t],
|
delete_chunks: vec![ChunkKey::trackpoints(t)],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}))
|
}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(storage.counts(), (1, 0, 1));
|
assert_eq!(storage.counts(), counts(1, 0, 1));
|
||||||
let data = block_on(storage.load()).unwrap();
|
let data = block_on(storage.load()).unwrap();
|
||||||
assert_eq!(data.files, vec![(a, vec![10])]);
|
assert_eq!(data.files, vec![(a, vec![10])]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -172,12 +179,12 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
block_on(storage.commit(&Batch {
|
block_on(storage.commit(&Batch {
|
||||||
delete_waypoint_chunks: vec![w],
|
delete_chunks: vec![ChunkKey::waypoints(w)],
|
||||||
order: Some(vec![]),
|
order: Some(vec![]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}))
|
}))
|
||||||
.unwrap();
|
.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![]));
|
assert_eq!(block_on(storage.load()).unwrap().order, Some(vec![]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,13 +30,42 @@ impl std::fmt::Display for StorageError {
|
|||||||
|
|
||||||
impl std::error::Error 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).
|
/// Everything that is stored about the files, as opaque bytes (see the module documentation).
|
||||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||||
pub struct StoredData {
|
pub struct StoredData {
|
||||||
/// The record of each file.
|
/// The record of each file.
|
||||||
pub files: Vec<(FileId, Vec<u8>)>,
|
pub files: Vec<(FileId, Vec<u8>)>,
|
||||||
pub trackpoint_chunks: Vec<(Uuid, Vec<u8>)>,
|
pub chunks: Vec<(ChunkKey, Vec<u8>)>,
|
||||||
pub waypoint_chunks: Vec<(Uuid, Vec<u8>)>,
|
|
||||||
/// The order of the files.
|
/// The order of the files.
|
||||||
pub order: Option<Vec<u8>>,
|
pub order: Option<Vec<u8>>,
|
||||||
/// The categories of the trackpoints, which their chunks refer to.
|
/// The categories of the trackpoints, which their chunks refer to.
|
||||||
@@ -49,10 +78,8 @@ pub struct StoredData {
|
|||||||
pub struct Batch {
|
pub struct Batch {
|
||||||
pub put_files: Vec<(FileId, Vec<u8>)>,
|
pub put_files: Vec<(FileId, Vec<u8>)>,
|
||||||
pub delete_files: Vec<FileId>,
|
pub delete_files: Vec<FileId>,
|
||||||
pub put_trackpoint_chunks: Vec<(Uuid, Vec<u8>)>,
|
pub put_chunks: Vec<(ChunkKey, Vec<u8>)>,
|
||||||
pub delete_trackpoint_chunks: Vec<Uuid>,
|
pub delete_chunks: Vec<ChunkKey>,
|
||||||
pub put_waypoint_chunks: Vec<(Uuid, Vec<u8>)>,
|
|
||||||
pub delete_waypoint_chunks: Vec<Uuid>,
|
|
||||||
pub order: Option<Vec<u8>>,
|
pub order: Option<Vec<u8>>,
|
||||||
pub categories: Option<Vec<u8>>,
|
pub categories: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
@@ -61,6 +88,22 @@ impl Batch {
|
|||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self == &Self::default()
|
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
|
/// Where the data is kept. The implementation belongs to the host; [`MemoryStorage`] is one that
|
||||||
@@ -101,11 +144,11 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
Batch {
|
Batch {
|
||||||
delete_trackpoint_chunks: vec![Uuid::new_v4()],
|
put_chunks: vec![(ChunkKey::waypoints(Uuid::new_v4()), vec![])],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
Batch {
|
Batch {
|
||||||
delete_waypoint_chunks: vec![Uuid::new_v4()],
|
delete_chunks: vec![ChunkKey::trackpoints(Uuid::new_v4())],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
Batch {
|
Batch {
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
use std::cell::RefCell;
|
use std::cell::{Cell, RefCell};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::codec::{
|
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,
|
encode_waypoints,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
Batch, File, FileId, StackEntry, Storage, StorageError, TrackpointCategories, TrackpointChunk,
|
Batch, ChunkKey, File, FileId, StackEntry, Storage, StorageError, TrackpointCategories,
|
||||||
WaypointChunk,
|
TrackpointChunk, WaypointChunk,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// What there is to keep of the engine: the files of the current state, their order, and the
|
/// 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,
|
pub categories: TrackpointCategories,
|
||||||
/// The files that could not be read: they are dropped at the next save.
|
/// The files that could not be read: they are dropped at the next save.
|
||||||
pub unreadable: usize,
|
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
|
/// 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<FileId, Rc<File>>,
|
files: HashMap<FileId, Rc<File>>,
|
||||||
/// The files that have a record, including the ones that could not be read.
|
/// The files that have a record, including the ones that could not be read.
|
||||||
stored_files: HashSet<FileId>,
|
stored_files: HashSet<FileId>,
|
||||||
trackpoint_chunks: HashSet<Uuid>,
|
chunks: HashSet<ChunkKey>,
|
||||||
waypoint_chunks: HashSet<Uuid>,
|
|
||||||
order: Option<Vec<FileId>>,
|
order: Option<Vec<FileId>>,
|
||||||
categories: Option<TrackpointCategories>,
|
categories: Option<TrackpointCategories>,
|
||||||
|
/// 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<bool>);
|
||||||
|
|
||||||
|
impl Newer {
|
||||||
|
fn read<T>(&self, result: Result<T, DecodeError>) -> Option<T> {
|
||||||
|
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.
|
/// What a save writes, and what is stored once it is written.
|
||||||
struct Plan {
|
struct Plan {
|
||||||
batch: Batch,
|
batch: Batch,
|
||||||
snapshot: Snapshot,
|
snapshot: Snapshot,
|
||||||
trackpoint_chunks: HashSet<Uuid>,
|
chunks: HashSet<ChunkKey>,
|
||||||
waypoint_chunks: HashSet<Uuid>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Persister {
|
impl Persister {
|
||||||
@@ -64,11 +86,18 @@ impl Persister {
|
|||||||
let data = storage.load().await?;
|
let data = storage.load().await?;
|
||||||
*self = Self::default();
|
*self = Self::default();
|
||||||
|
|
||||||
let categories: Option<TrackpointCategories> = data.categories.as_deref().and_then(decode);
|
// what comes from a newer version is not corrupt: it must not be overwritten
|
||||||
let stored_order: Option<Vec<FileId>> = data.order.as_deref().and_then(decode);
|
let newer = Newer::default();
|
||||||
|
let categories: Option<TrackpointCategories> = data
|
||||||
|
.categories
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|bytes| newer.read(decode_categories(bytes)));
|
||||||
|
let stored_order: Option<Vec<FileId>> = data
|
||||||
|
.order
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|bytes| newer.read(decode_order(bytes)));
|
||||||
|
|
||||||
let trackpoint_bytes: HashMap<Uuid, Vec<u8>> = data.trackpoint_chunks.into_iter().collect();
|
let chunk_bytes: HashMap<ChunkKey, Vec<u8>> = data.chunks.into_iter().collect();
|
||||||
let waypoint_bytes: HashMap<Uuid, Vec<u8>> = data.waypoint_chunks.into_iter().collect();
|
|
||||||
// chunks are decoded once, whatever the number of files that use them
|
// chunks are decoded once, whatever the number of files that use them
|
||||||
let trackpoint_cache: RefCell<HashMap<Uuid, Option<Rc<TrackpointChunk>>>> =
|
let trackpoint_cache: RefCell<HashMap<Uuid, Option<Rc<TrackpointChunk>>>> =
|
||||||
Default::default();
|
Default::default();
|
||||||
@@ -78,8 +107,8 @@ impl Persister {
|
|||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.entry(id.0)
|
.entry(id.0)
|
||||||
.or_insert_with(|| {
|
.or_insert_with(|| {
|
||||||
let bytes = trackpoint_bytes.get(&id.0)?;
|
let bytes = chunk_bytes.get(&ChunkKey::trackpoints(id.0))?;
|
||||||
decode_trackpoints(id.0, bytes).map(Rc::new)
|
newer.read(decode_trackpoints(id.0, bytes)).map(Rc::new)
|
||||||
})
|
})
|
||||||
.clone()
|
.clone()
|
||||||
};
|
};
|
||||||
@@ -88,8 +117,8 @@ impl Persister {
|
|||||||
.borrow_mut()
|
.borrow_mut()
|
||||||
.entry(id.0)
|
.entry(id.0)
|
||||||
.or_insert_with(|| {
|
.or_insert_with(|| {
|
||||||
let bytes = waypoint_bytes.get(&id.0)?;
|
let bytes = chunk_bytes.get(&ChunkKey::waypoints(id.0))?;
|
||||||
decode_waypoints(id.0, bytes).map(Rc::new)
|
newer.read(decode_waypoints(id.0, bytes)).map(Rc::new)
|
||||||
})
|
})
|
||||||
.clone()
|
.clone()
|
||||||
};
|
};
|
||||||
@@ -101,7 +130,8 @@ impl Persister {
|
|||||||
let mut files: HashMap<FileId, Rc<File>> = HashMap::new();
|
let mut files: HashMap<FileId, Rc<File>> = HashMap::new();
|
||||||
for (id, bytes) in &data.files {
|
for (id, bytes) in &data.files {
|
||||||
self.stored_files.insert(*id);
|
self.stored_files.insert(*id);
|
||||||
let file = decode::<FileRecord>(bytes)
|
let file = newer
|
||||||
|
.read(decode_file(bytes))
|
||||||
.and_then(|record| record.build(*id, &trackpoint_chunk, &waypoint_chunk));
|
.and_then(|record| record.build(*id, &trackpoint_chunk, &waypoint_chunk));
|
||||||
match file {
|
match file {
|
||||||
Some(file) => {
|
Some(file) => {
|
||||||
@@ -124,10 +154,11 @@ impl Persister {
|
|||||||
restored.files = restored.order.iter().map(|id| files[id].clone()).collect();
|
restored.files = restored.order.iter().map(|id| files[id].clone()).collect();
|
||||||
|
|
||||||
self.files = files;
|
self.files = files;
|
||||||
self.trackpoint_chunks = trackpoint_bytes.into_keys().collect();
|
self.chunks = chunk_bytes.into_keys().collect();
|
||||||
self.waypoint_chunks = waypoint_bytes.into_keys().collect();
|
|
||||||
self.order = stored_order;
|
self.order = stored_order;
|
||||||
self.categories = categories;
|
self.categories = categories;
|
||||||
|
self.read_only = newer.0.get();
|
||||||
|
restored.read_only = self.read_only;
|
||||||
Ok(restored)
|
Ok(restored)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +168,9 @@ impl Persister {
|
|||||||
storage: &S,
|
storage: &S,
|
||||||
snapshot: Snapshot,
|
snapshot: Snapshot,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), StorageError> {
|
||||||
|
if self.read_only {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let plan = self.plan(snapshot);
|
let plan = self.plan(snapshot);
|
||||||
if plan.batch.is_empty() {
|
if plan.batch.is_empty() {
|
||||||
self.files = plan.snapshot.files;
|
self.files = plan.snapshot.files;
|
||||||
@@ -146,8 +180,7 @@ impl Persister {
|
|||||||
|
|
||||||
self.stored_files = plan.snapshot.files.keys().copied().collect();
|
self.stored_files = plan.snapshot.files.keys().copied().collect();
|
||||||
self.files = plan.snapshot.files;
|
self.files = plan.snapshot.files;
|
||||||
self.trackpoint_chunks = plan.trackpoint_chunks;
|
self.chunks = plan.chunks;
|
||||||
self.waypoint_chunks = plan.waypoint_chunks;
|
|
||||||
self.order = Some(plan.snapshot.order);
|
self.order = Some(plan.snapshot.order);
|
||||||
self.categories = Some(plan.snapshot.categories);
|
self.categories = Some(plan.snapshot.categories);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -156,8 +189,7 @@ impl Persister {
|
|||||||
fn plan(&self, snapshot: Snapshot) -> Plan {
|
fn plan(&self, snapshot: Snapshot) -> Plan {
|
||||||
let mut batch = Batch::default();
|
let mut batch = Batch::default();
|
||||||
// the chunks that the files use, which the storage has afterwards
|
// the chunks that the files use, which the storage has afterwards
|
||||||
let mut trackpoint_chunks = HashSet::new();
|
let mut chunks = HashSet::new();
|
||||||
let mut waypoint_chunks = HashSet::new();
|
|
||||||
|
|
||||||
for (id, file) in &snapshot.files {
|
for (id, file) in &snapshot.files {
|
||||||
let changed = self
|
let changed = self
|
||||||
@@ -166,23 +198,19 @@ impl Persister {
|
|||||||
.is_none_or(|saved| !Rc::ptr_eq(saved, file));
|
.is_none_or(|saved| !Rc::ptr_eq(saved, file));
|
||||||
let segments = file.trk.iter().flat_map(|track| &track.trkseg);
|
let segments = file.trk.iter().flat_map(|track| &track.trkseg);
|
||||||
for chunk in segments.flat_map(|segment| segment.chunks()) {
|
for chunk in segments.flat_map(|segment| segment.chunks()) {
|
||||||
let new = trackpoint_chunks.insert(chunk.id.0);
|
let key = ChunkKey::trackpoints(chunk.id.0);
|
||||||
if changed && new && !self.trackpoint_chunks.contains(&chunk.id.0) {
|
if chunks.insert(key) && changed && !self.chunks.contains(&key) {
|
||||||
batch
|
batch.put_chunks.push((key, encode_trackpoints(chunk)));
|
||||||
.put_trackpoint_chunks
|
|
||||||
.push((chunk.id.0, encode_trackpoints(chunk)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for chunk in file.wpt.chunks() {
|
for chunk in file.wpt.chunks() {
|
||||||
let new = waypoint_chunks.insert(chunk.id.0);
|
let key = ChunkKey::waypoints(chunk.id.0);
|
||||||
if changed && new && !self.waypoint_chunks.contains(&chunk.id.0) {
|
if chunks.insert(key) && changed && !self.chunks.contains(&key) {
|
||||||
batch
|
batch.put_chunks.push((key, encode_waypoints(chunk)));
|
||||||
.put_waypoint_chunks
|
|
||||||
.push((chunk.id.0, encode_waypoints(chunk)));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if changed || !self.stored_files.contains(id) {
|
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))
|
.filter(|id| !snapshot.files.contains_key(id))
|
||||||
.copied()
|
.copied()
|
||||||
.collect();
|
.collect();
|
||||||
batch.delete_trackpoint_chunks = self
|
batch.delete_chunks = self.chunks.difference(&chunks).copied().collect();
|
||||||
.trackpoint_chunks
|
|
||||||
.difference(&trackpoint_chunks)
|
|
||||||
.copied()
|
|
||||||
.collect();
|
|
||||||
batch.delete_waypoint_chunks = self
|
|
||||||
.waypoint_chunks
|
|
||||||
.difference(&waypoint_chunks)
|
|
||||||
.copied()
|
|
||||||
.collect();
|
|
||||||
if self.order.as_ref() != Some(&snapshot.order) {
|
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) {
|
if self.categories.as_ref() != Some(&snapshot.categories) {
|
||||||
batch.categories = Some(encode(&snapshot.categories));
|
batch.categories = Some(encode_categories(&snapshot.categories));
|
||||||
}
|
}
|
||||||
|
|
||||||
Plan {
|
Plan {
|
||||||
batch,
|
batch,
|
||||||
snapshot,
|
snapshot,
|
||||||
trackpoint_chunks,
|
chunks,
|
||||||
waypoint_chunks,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,7 +243,8 @@ mod tests {
|
|||||||
use std::task::{Context, Poll, Waker};
|
use std::task::{Context, Poll, Waker};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Action, Command, Engine, ExportOptions, Load, MemoryStorage, StoredData, parse, write,
|
Action, ChunkKind, Command, Engine, ExportOptions, Load, MemoryStorage, StoredData, parse,
|
||||||
|
write,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -354,9 +373,8 @@ mod tests {
|
|||||||
let batches = storage.batches.borrow();
|
let batches = storage.batches.borrow();
|
||||||
let batch = batches.last().unwrap();
|
let batch = batches.last().unwrap();
|
||||||
assert_eq!(batch.put_files.len(), 1);
|
assert_eq!(batch.put_files.len(), 1);
|
||||||
assert!(batch.put_trackpoint_chunks.is_empty());
|
assert!(batch.put_chunks.is_empty());
|
||||||
assert!(batch.put_waypoint_chunks.is_empty());
|
assert!(batch.delete_chunks.is_empty());
|
||||||
assert!(batch.delete_trackpoint_chunks.is_empty());
|
|
||||||
assert!(batch.order.is_none() && batch.categories.is_none());
|
assert!(batch.order.is_none() && batch.categories.is_none());
|
||||||
}
|
}
|
||||||
assert_eq!(storage.inner.counts(), counts);
|
assert_eq!(storage.inner.counts(), counts);
|
||||||
@@ -376,8 +394,9 @@ mod tests {
|
|||||||
let batches = storage.batches.borrow();
|
let batches = storage.batches.borrow();
|
||||||
let batch = batches.last().unwrap();
|
let batch = batches.last().unwrap();
|
||||||
assert_eq!(batch.put_files.len(), 1);
|
assert_eq!(batch.put_files.len(), 1);
|
||||||
assert_eq!(batch.put_trackpoint_chunks.len(), 1);
|
assert_eq!(batch.puts(ChunkKind::Trackpoints), 1);
|
||||||
assert_eq!(batch.delete_trackpoint_chunks.len(), 1);
|
assert_eq!(batch.deletes(ChunkKind::Trackpoints), 1);
|
||||||
|
assert_eq!(batch.puts(ChunkKind::Waypoints), 0);
|
||||||
}
|
}
|
||||||
assert_eq!(storage.inner.counts(), counts);
|
assert_eq!(storage.inner.counts(), counts);
|
||||||
assert!(chunks_before >= 1);
|
assert!(chunks_before >= 1);
|
||||||
@@ -386,9 +405,9 @@ mod tests {
|
|||||||
snapshot.files.remove(&id);
|
snapshot.files.remove(&id);
|
||||||
snapshot.order.retain(|other| *other != id);
|
snapshot.order.retain(|other| *other != id);
|
||||||
block_on(persister.save(&storage, snapshot)).unwrap();
|
block_on(persister.save(&storage, snapshot)).unwrap();
|
||||||
let (files, trackpoint_chunks, _) = storage.inner.counts();
|
let after = storage.inner.counts();
|
||||||
assert_eq!(files, counts.0 - 1);
|
assert_eq!(after.files, counts.files - 1);
|
||||||
assert!(trackpoint_chunks < counts.1);
|
assert!(after.trackpoint_chunks < counts.trackpoint_chunks);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -404,7 +423,7 @@ mod tests {
|
|||||||
let mut persister = Persister::default();
|
let mut persister = Persister::default();
|
||||||
block_on(persister.save(&storage, snapshot)).unwrap();
|
block_on(persister.save(&storage, snapshot)).unwrap();
|
||||||
let chunks = original.trk[0].trkseg[0].chunks().len();
|
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();
|
let restored = block_on(Persister::default().restore(&storage)).unwrap();
|
||||||
assert_eq!(restored.files.len(), 2);
|
assert_eq!(restored.files.len(), 2);
|
||||||
@@ -424,13 +443,14 @@ mod tests {
|
|||||||
// damage a record, and lose a chunk of another file
|
// damage a record, and lose a chunk of another file
|
||||||
let broken = snapshot.order[0];
|
let broken = snapshot.order[0];
|
||||||
block_on(storage.inner.commit(&Batch {
|
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()
|
..Default::default()
|
||||||
}))
|
}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut persister = Persister::default();
|
let mut persister = Persister::default();
|
||||||
let restored = block_on(persister.restore(&storage)).unwrap();
|
let restored = block_on(persister.restore(&storage)).unwrap();
|
||||||
assert_eq!(restored.unreadable, 1);
|
assert_eq!(restored.unreadable, 1);
|
||||||
|
assert!(!restored.read_only);
|
||||||
assert_eq!(restored.files.len(), FILES.len() - 1);
|
assert_eq!(restored.files.len(), FILES.len() - 1);
|
||||||
assert!(restored.files.iter().all(|file| file.id != broken));
|
assert!(restored.files.iter().all(|file| file.id != broken));
|
||||||
|
|
||||||
@@ -440,7 +460,77 @@ mod tests {
|
|||||||
categories: restored.categories,
|
categories: restored.categories,
|
||||||
};
|
};
|
||||||
block_on(persister.save(&storage, kept)).unwrap();
|
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<u8> {
|
||||||
|
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]
|
#[test]
|
||||||
@@ -449,7 +539,7 @@ mod tests {
|
|||||||
let mut engine = Engine::default();
|
let mut engine = Engine::default();
|
||||||
for path in FILES {
|
for path in FILES {
|
||||||
let data = std::fs::read(path).unwrap();
|
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,
|
data: &data,
|
||||||
name: "file",
|
name: "file",
|
||||||
}))));
|
}))));
|
||||||
@@ -471,14 +561,14 @@ mod tests {
|
|||||||
|
|
||||||
// the files that were restored are not undone
|
// the files that were restored are not undone
|
||||||
assert!(!engine.can_undo());
|
assert!(!engine.can_undo());
|
||||||
assert!(!engine.execute(Action::Undo));
|
assert!(!engine.run(Action::Undo));
|
||||||
let data = std::fs::read(FILES[0]).unwrap();
|
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,
|
data: &data,
|
||||||
name: "file",
|
name: "file",
|
||||||
}))));
|
}))));
|
||||||
assert!(engine.can_undo());
|
assert!(engine.can_undo());
|
||||||
assert!(engine.execute(Action::Undo));
|
assert!(engine.run(Action::Undo));
|
||||||
assert_eq!(engine.snapshot().files.len(), FILES.len());
|
assert_eq!(engine.snapshot().files.len(), FILES.len());
|
||||||
assert!(!engine.can_undo());
|
assert!(!engine.can_undo());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,3 +14,6 @@ js-sys = "0.3.106"
|
|||||||
uuid = { version = "1.26.1", features = ["js"] }
|
uuid = { version = "1.26.1", features = ["js"] }
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
wasm-bindgen-futures = "0.4.79"
|
wasm-bindgen-futures = "0.4.79"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
wasm-bindgen-test = "0.3"
|
||||||
@@ -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<String>, opacity: Option<f64>, width: Option<f64>) -> 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))
|
||||||
|
}
|
||||||
@@ -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<f64>, lat: Option<f64>, ele: Option<f64>) -> 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<String>) -> 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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<u32>,
|
||||||
|
values: Vec<u8>,
|
||||||
|
names: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParsedCategory {
|
||||||
|
fn parse(attributes: &JsValue, key: &str) -> Option<Self> {
|
||||||
|
let Some(category) = property(attributes, key) else {
|
||||||
|
return Some(Self {
|
||||||
|
starts: vec![],
|
||||||
|
values: vec![],
|
||||||
|
names: vec![],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
Some(Self {
|
||||||
|
starts: property(&category, "starts")?
|
||||||
|
.unchecked_into::<Uint32Array>()
|
||||||
|
.to_vec(),
|
||||||
|
values: property(&category, "values")?
|
||||||
|
.unchecked_into::<Uint8Array>()
|
||||||
|
.to_vec(),
|
||||||
|
names: Array::from(&property(&category, "names")?)
|
||||||
|
.iter()
|
||||||
|
.map(|name| name.as_string())
|
||||||
|
.collect::<Option<_>>()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<JsValue> {
|
||||||
|
Reflect::get(object, &key.into())
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.is_undefined())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn uuid_property(object: &JsValue, key: &str) -> Option<uuid::Uuid> {
|
||||||
|
uuid::Uuid::parse_str(&property(object, key)?.as_string()?).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn uuids_property(object: &JsValue, key: &str) -> Option<Vec<uuid::Uuid>> {
|
||||||
|
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<engine::Selection> {
|
||||||
|
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<engine::MoveTarget> {
|
||||||
|
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<JsValue>) {
|
||||||
|
Reflect::set(object, &key.into(), &value.into()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn array<T>(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<f64>| {
|
||||||
|
if let Some(value) = value {
|
||||||
|
set(&object, key, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let seconds = |ms: Option<i64>| 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<T>(
|
||||||
|
items: impl IntoIterator<Item = T>,
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
//! store per kind of data. A batch is a single transaction, so that the stored files always are
|
//! store per kind of data. A batch is a single transaction, so that the stored files always are
|
||||||
//! ones that the engine had.
|
//! 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::TransactionMode;
|
||||||
use idb::{Database, DatabaseEvent, Factory, ObjectStore, ObjectStoreParams, Query, Transaction};
|
use idb::{Database, DatabaseEvent, Factory, ObjectStore, ObjectStoreParams, Query, Transaction};
|
||||||
use js_sys::Uint8Array;
|
use js_sys::Uint8Array;
|
||||||
@@ -30,6 +30,14 @@ fn error(error: impl std::fmt::Display) -> StorageError {
|
|||||||
StorageError(error.to_string())
|
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 {
|
fn key(id: Uuid) -> JsValue {
|
||||||
JsValue::from_str(&id.to_string())
|
JsValue::from_str(&id.to_string())
|
||||||
}
|
}
|
||||||
@@ -82,21 +90,17 @@ impl IdbStorage {
|
|||||||
|
|
||||||
async fn write(&self, transaction: &Transaction, batch: &Batch) -> Result<(), StorageError> {
|
async fn write(&self, transaction: &Transaction, batch: &Batch) -> Result<(), StorageError> {
|
||||||
let files = transaction.object_store(FILES).map_err(error)?;
|
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)?;
|
let meta = transaction.object_store(META).map_err(error)?;
|
||||||
|
|
||||||
// the requests are all made before waiting for them
|
// the requests are all made before waiting for them
|
||||||
let mut puts = vec![];
|
let mut puts = vec![];
|
||||||
let mut deletes = vec![];
|
let mut deletes = vec![];
|
||||||
for (store, chunks) in [
|
for (ChunkKey { kind, id }, data) in &batch.put_chunks {
|
||||||
(&trackpoints, &batch.put_trackpoint_chunks),
|
let store = transaction
|
||||||
(&waypoints, &batch.put_waypoint_chunks),
|
.object_store(chunk_store(*kind))
|
||||||
] {
|
.map_err(error)?;
|
||||||
for (id, data) in chunks {
|
|
||||||
puts.push(store.put(&bytes(data), Some(&key(*id))).map_err(error)?);
|
puts.push(store.put(&bytes(data), Some(&key(*id))).map_err(error)?);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for (FileId(id), data) in &batch.put_files {
|
for (FileId(id), data) in &batch.put_files {
|
||||||
puts.push(files.put(&bytes(data), Some(&key(*id))).map_err(error)?);
|
puts.push(files.put(&bytes(data), Some(&key(*id))).map_err(error)?);
|
||||||
}
|
}
|
||||||
@@ -117,14 +121,12 @@ impl IdbStorage {
|
|||||||
for FileId(id) in &batch.delete_files {
|
for FileId(id) in &batch.delete_files {
|
||||||
deletes.push(files.delete(Query::Key(key(*id))).map_err(error)?);
|
deletes.push(files.delete(Query::Key(key(*id))).map_err(error)?);
|
||||||
}
|
}
|
||||||
for (store, ids) in [
|
for ChunkKey { kind, id } in &batch.delete_chunks {
|
||||||
(&trackpoints, &batch.delete_trackpoint_chunks),
|
let store = transaction
|
||||||
(&waypoints, &batch.delete_waypoint_chunks),
|
.object_store(chunk_store(*kind))
|
||||||
] {
|
.map_err(error)?;
|
||||||
for id in ids {
|
|
||||||
deletes.push(store.delete(Query::Key(key(*id))).map_err(error)?);
|
deletes.push(store.delete(Query::Key(key(*id))).map_err(error)?);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
for put in puts {
|
for put in puts {
|
||||||
put.await.map_err(error)?;
|
put.await.map_err(error)?;
|
||||||
}
|
}
|
||||||
@@ -169,8 +171,15 @@ impl Storage for IdbStorage {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(id, data)| (FileId(id), data))
|
.map(|(id, data)| (FileId(id), data))
|
||||||
.collect(),
|
.collect(),
|
||||||
trackpoint_chunks: ids(trackpoint_chunks)?,
|
chunks: ids(trackpoint_chunks)?
|
||||||
waypoint_chunks: ids(waypoint_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),
|
order: order.as_ref().map(to_vec),
|
||||||
categories: categories.as_ref().map(to_vec),
|
categories: categories.as_ref().map(to_vec),
|
||||||
})
|
})
|
||||||
@@ -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<Vec<uuid::Uuid>> {
|
||||||
|
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<Vec<FileId>> {
|
||||||
|
uuid_list(bytes).map(|ids| ids.into_iter().map(FileId).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_file_id(id: &str) -> Option<FileId> {
|
||||||
|
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<engine::WaypointId> {
|
||||||
|
uuid::Uuid::parse_str(id).ok().map(engine::WaypointId)
|
||||||
|
}
|
||||||
+22
-1648
File diff suppressed because it is too large.
Load diff
@@ -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<Option<Rc<IdbStorage>>> = const { RefCell::new(None) };
|
||||||
|
static PERSISTER: RefCell<Option<Persister>> = const { RefCell::new(None) };
|
||||||
|
static SAVING: Cell<bool> = const { Cell::new(false) };
|
||||||
|
static DIRTY: Cell<bool> = const { Cell::new(false) };
|
||||||
|
/// Resolve functions of the promises of `flush_storage`.
|
||||||
|
static WAITERS: RefCell<Vec<js_sys::Function>> = 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<Rc<IdbStorage>> {
|
||||||
|
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<StorageOpened, JsValue> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Vec<u8>> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
pub(crate) mod coordinates;
|
||||||
|
pub(crate) mod files;
|
||||||
|
pub(crate) mod routing;
|
||||||
|
pub(crate) mod statistics;
|
||||||
@@ -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<T>(f: impl FnOnce(&engine::RoutingBuffer) -> T) -> Option<T> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -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<T>(f: impl FnOnce(&engine::StatisticsBuffer) -> T) -> Option<T> {
|
||||||
|
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: `<name>_starts` holds the index of the first trackpoint of each interval,
|
||||||
|
// and `<name>_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))
|
||||||
|
}
|
||||||
@@ -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<engine::Selection>, 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<Option<Engine>> = 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<str>) -> 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<T>(f: impl FnOnce(&Engine) -> T) -> Option<T> {
|
||||||
|
ENGINE.with(|engine| engine.borrow().as_ref().map(f))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_engine_mut<T>(f: impl FnOnce(&mut Engine) -> T) -> Option<T> {
|
||||||
|
ENGINE.with(|engine| engine.borrow_mut().as_mut().map(f))
|
||||||
|
}
|
||||||
@@ -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<JsValue> = 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<String> {
|
||||||
|
get(&last_outcome(), "error").as_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(object: impl AsRef<JsValue>, key: &str) -> JsValue {
|
||||||
|
Reflect::get(object.as_ref(), &key.into()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn array(value: impl Into<JsValue>) -> Array {
|
||||||
|
Array::from(&value.into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strings(value: impl Into<JsValue>) -> Vec<String> {
|
||||||
|
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<u8> = files
|
||||||
|
.iter()
|
||||||
|
.flat_map(|(data, _)| data.iter().copied())
|
||||||
|
.collect();
|
||||||
|
let lengths: Vec<u32> = 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<String> {
|
||||||
|
strings(crate::queries::files::file_order())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uuid_bytes(ids: &[String]) -> Vec<u8> {
|
||||||
|
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"<gpx><trk></gpx>",
|
||||||
|
&[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("<gpx"));
|
||||||
|
|
||||||
|
// the time is the first bit
|
||||||
|
assert_eq!(
|
||||||
|
crate::queries::files::exportable_data(&uuid_bytes(&ids[..1])) & 1,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
crate::queries::files::exportable_data(&uuid_bytes(&ids[1..])) & 1,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
crate::queries::files::exportable_data(&uuid_bytes(&ids)) & 1,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(crate::queries::files::exportable_data(&[1, 2, 3]), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen_test]
|
||||||
|
fn test_select_and_reorder_files() {
|
||||||
|
started_with(&[(SIMPLE, "a"), (WITH_TIME, "b")]);
|
||||||
|
let ids = order();
|
||||||
|
|
||||||
|
assert!(run(crate::selection::select(
|
||||||
|
&uuid_bytes(&ids[1..]),
|
||||||
|
SelectMode::Replace
|
||||||
|
)));
|
||||||
|
assert_eq!(
|
||||||
|
strings(get(&crate::queries::files::selection(), "fileIds")),
|
||||||
|
ids[1..]
|
||||||
|
);
|
||||||
|
assert!(run(crate::selection::select(
|
||||||
|
&uuid_bytes(&ids[..1]),
|
||||||
|
SelectMode::Add
|
||||||
|
)));
|
||||||
|
assert_eq!(
|
||||||
|
array(get(&crate::queries::files::selection(), "fileIds")).length(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert!(run(crate::selection::select(
|
||||||
|
&uuid_bytes(&ids[..1]),
|
||||||
|
SelectMode::Toggle
|
||||||
|
)));
|
||||||
|
assert_eq!(
|
||||||
|
strings(get(&crate::queries::files::selection(), "fileIds")),
|
||||||
|
ids[1..]
|
||||||
|
);
|
||||||
|
|
||||||
|
// reordering is a change of the order, nothing else
|
||||||
|
let reversed: Vec<String> = 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));
|
||||||
|
}
|
||||||
@@ -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<string, string>;
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
@@ -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<SplitType> 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<SelectMode> for engine::SelectMode {
|
||||||
|
fn from(m: SelectMode) -> Self {
|
||||||
|
match m {
|
||||||
|
SelectMode::Replace => Self::Replace,
|
||||||
|
SelectMode::Add => Self::Add,
|
||||||
|
SelectMode::Toggle => Self::Toggle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<MergeType> for engine::MergeType {
|
||||||
|
fn from(t: MergeType) -> Self {
|
||||||
|
match t {
|
||||||
|
MergeType::Connect => Self::Connect,
|
||||||
|
MergeType::Group => Self::Group,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CleanType> for engine::CleanType {
|
||||||
|
fn from(t: CleanType) -> Self {
|
||||||
|
match t {
|
||||||
|
CleanType::Inside => Self::Inside,
|
||||||
|
CleanType::Outside => Self::Outside,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
-10
@@ -21,6 +21,9 @@ import type {
|
|||||||
WaypointDetails,
|
WaypointDetails,
|
||||||
TrackpointDetails,
|
TrackpointDetails,
|
||||||
RouteAttributes,
|
RouteAttributes,
|
||||||
|
Outcome,
|
||||||
|
FilesUpdate,
|
||||||
|
StorageOpened,
|
||||||
} from 'gpx-rs';
|
} from 'gpx-rs';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -31,6 +34,7 @@ export type {
|
|||||||
GlobalStatistics,
|
GlobalStatistics,
|
||||||
FileStructure,
|
FileStructure,
|
||||||
FilesUpdate,
|
FilesUpdate,
|
||||||
|
Outcome,
|
||||||
Selection,
|
Selection,
|
||||||
TrackNode,
|
TrackNode,
|
||||||
SegmentNode,
|
SegmentNode,
|
||||||
@@ -314,6 +318,7 @@ class Engine {
|
|||||||
private _visibility = new Map<string, Visibility>();
|
private _visibility = new Map<string, Visibility>();
|
||||||
private _files = writable<Map<string, Writable<FileState>>>(new Map());
|
private _files = writable<Map<string, Writable<FileState>>>(new Map());
|
||||||
private _selection = writable<Selection>({ type: 'empty' });
|
private _selection = writable<Selection>({ type: 'empty' });
|
||||||
|
private _lastError = writable<string | undefined>(undefined);
|
||||||
private _canUndo = writable(false);
|
private _canUndo = writable(false);
|
||||||
private _canRedo = writable(false);
|
private _canRedo = writable(false);
|
||||||
private _canPaste = writable(false);
|
private _canPaste = writable(false);
|
||||||
@@ -332,6 +337,8 @@ class Engine {
|
|||||||
readonly statistics: Readable<SelectionStatistics> = { subscribe: this._statistics.subscribe };
|
readonly statistics: Readable<SelectionStatistics> = { subscribe: this._statistics.subscribe };
|
||||||
readonly canUndo: Readable<boolean> = { subscribe: this._canUndo.subscribe };
|
readonly canUndo: Readable<boolean> = { subscribe: this._canUndo.subscribe };
|
||||||
readonly canRedo: Readable<boolean> = { subscribe: this._canRedo.subscribe };
|
readonly canRedo: Readable<boolean> = { subscribe: this._canRedo.subscribe };
|
||||||
|
/** Why the last call to the engine did nothing, `undefined` if it did something. */
|
||||||
|
readonly lastError: Readable<string | undefined> = { subscribe: this._lastError.subscribe };
|
||||||
/** What was copied or cut, waiting to be pasted. */
|
/** What was copied or cut, waiting to be pasted. */
|
||||||
readonly clipboard: Readable<Clipboard | undefined> = { subscribe: this._clipboard.subscribe };
|
readonly clipboard: Readable<Clipboard | undefined> = { subscribe: this._clipboard.subscribe };
|
||||||
/** Whether the clipboard can be pasted with the current selection. */
|
/** Whether the clipboard can be pasted with the current selection. */
|
||||||
@@ -374,18 +381,27 @@ class Engine {
|
|||||||
private async openStorageOnce(): Promise<Record<string, string>> {
|
private async openStorageOnce(): Promise<Record<string, string>> {
|
||||||
await this.ready;
|
await this.ready;
|
||||||
const wasm = this.wasm!;
|
const wasm = this.wasm!;
|
||||||
let settings: Record<string, string>;
|
let opened: StorageOpened;
|
||||||
try {
|
try {
|
||||||
settings = (await wasm.open_storage(STORAGE_NAME)) as Record<string, string>;
|
opened = await wasm.open_storage(STORAGE_NAME);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('The files cannot be kept in this browser', error);
|
console.error('The files cannot be kept in this browser', error);
|
||||||
return {};
|
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
|
// what was hidden, which the files that were restored need when they are read
|
||||||
parseVisibility(settings[VISIBILITY_KEY]).forEach((visibility, fileId) =>
|
parseVisibility(settings[VISIBILITY_KEY]).forEach((visibility, fileId) =>
|
||||||
this._visibility.set(fileId, visibility)
|
this._visibility.set(fileId, visibility)
|
||||||
);
|
);
|
||||||
this.sync(wasm);
|
this.sync(wasm, opened.outcome);
|
||||||
|
|
||||||
if (settings[LEGACY_IMPORTED_KEY] === undefined && get(this._order).length === 0) {
|
if (settings[LEGACY_IMPORTED_KEY] === undefined && get(this._order).length === 0) {
|
||||||
settings = { ...settings, ...(await this.importLegacyData(wasm)) };
|
settings = { ...settings, ...(await this.importLegacyData(wasm)) };
|
||||||
@@ -959,17 +975,31 @@ class Engine {
|
|||||||
return this.wasm?.waypoint_coordinates(fileId) ?? new Float64Array();
|
return this.wasm?.waypoint_coordinates(fileId) ?? new Float64Array();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async run(action: (wasm: Wasm) => boolean): Promise<boolean> {
|
/**
|
||||||
|
* 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<boolean> {
|
||||||
await this.ready;
|
await this.ready;
|
||||||
const wasm = this.wasm!;
|
const wasm = this.wasm!;
|
||||||
const changed = action(wasm);
|
const outcome = action(wasm);
|
||||||
this.sync(wasm);
|
this.sync(wasm, outcome);
|
||||||
return changed;
|
this.report(outcome);
|
||||||
|
return outcome.changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reads what the last action changed. */
|
/** Logs why a call did nothing, and keeps it in `lastError`. */
|
||||||
private sync(wasm: Wasm) {
|
private report(outcome: Outcome) {
|
||||||
const update = wasm.last_update();
|
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._canUndo.set(wasm.can_undo());
|
||||||
this._canRedo.set(wasm.can_redo());
|
this._canRedo.set(wasm.can_redo());
|
||||||
this._canPaste.set(wasm.can_paste());
|
this._canPaste.set(wasm.can_paste());
|
||||||
|
|||||||
Reference in new issue
Block a user