This commit is contained in:
vcoppe committed 2026-10-08 18:34:19 +02:00
1 parent 606a0e974a
commit 67a4c090bd
107 files changed
+3474 -3948

No files matched your search

+245
View File
@@ -11,6 +11,15 @@ dependencies = [
"libc",
]
[[package]]
name = "atomic-polyfill"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
dependencies = [
"critical-section",
]
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -23,6 +32,12 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "cc"
version = "1.4.6"
@@ -52,6 +67,15 @@ dependencies = [
"windows-link",
]
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror",
]
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
@@ -68,6 +92,30 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "embedded-io"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
[[package]]
name = "embedded-io"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "find-msvc-tools"
version = "0.1.12"
@@ -114,7 +162,9 @@ name = "gpx-engine"
version = "0.1.0"
dependencies = [
"chrono",
"postcard",
"quick-xml",
"serde",
"uuid",
]
@@ -124,9 +174,40 @@ version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"gpx-engine",
"idb",
"js-sys",
"uuid",
"wasm-bindgen",
"wasm-bindgen-futures",
]
[[package]]
name = "hash32"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heapless"
version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
dependencies = [
"atomic-polyfill",
"hash32",
"rustc_version",
"serde",
"spin",
"stable_deref_trait",
]
[[package]]
@@ -153,6 +234,31 @@ dependencies = [
"cc",
]
[[package]]
name = "idb"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6554f394e990a1af530a528a7fdcad6e01b29cb1b990f89df3ffd62cf15f7828"
dependencies = [
"indexmap",
"js-sys",
"num-traits",
"thiserror",
"tokio",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "js-sys"
version = "0.3.106"
@@ -170,6 +276,15 @@ version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.34"
@@ -203,6 +318,19 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"heapless",
"serde",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -236,12 +364,63 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "shlex"
version = "2.0.1"
@@ -254,6 +433,21 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
dependencies = [
"lock_api",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "syn"
version = "2.0.118"
@@ -276,6 +470,35 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "tokio"
version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [
"pin-project-lite",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -290,6 +513,7 @@ checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
dependencies = [
"getrandom",
"js-sys",
"serde_core",
"wasm-bindgen",
]
@@ -306,6 +530,17 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
dependencies = [
"js-sys",
"tokio",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.129"
@@ -338,6 +573,16 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "windows-core"
version = "0.62.2"
+3 -1
View File
@@ -5,5 +5,7 @@ edition = "2024"
[dependencies]
chrono = "0.4.45"
postcard = { version = "1.1.3", features = ["use-std"] }
quick-xml = "0.42.0"
uuid = { version = "1.26.1", features = ["v4"] }
serde = { version = "1.0.229", features = ["derive"] }
uuid = { version = "1.26.1", features = ["serde", "v4"] }
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx xmlns="http://www.topografix.com/GPX/1/1" xmlns:gpxx="http://www.garmin.com/xmlschemas/GpxExtensions/v3" version="1.1" creator="test">
<rte>
<name>with extensions</name>
<rtept lat="1" lon="1">
<ele>10</ele>
<extensions>
<gpxx:RoutePointExtension>
<gpxx:rpt lat="1.1" lon="1.1"/>
<gpxx:rpt lat="1.2" lon="1.2"></gpxx:rpt>
</gpxx:RoutePointExtension>
</extensions>
</rtept>
<rtept lat="2" lon="2">
<ele>20</ele>
<time>2024-01-01T00:00:00Z</time>
</rtept>
<rtept lat="3" lon="3"/>
</rte>
<rte>
<name>empty</name>
</rte>
</gpx>
+2 -2
View File
@@ -3,7 +3,7 @@
///
/// The codes are the positions of the names in the order of first appearance. The table only
/// grows: a code never changes meaning, so it stays valid in every state of the history.
#[derive(Debug, Default, Clone, PartialEq)]
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Categories(Vec<String>);
impl Categories {
@@ -36,7 +36,7 @@ impl Categories {
/// The categories of the data of the trackpoints. There is one table per engine, shared by all
/// its files.
#[derive(Debug, Default, Clone, PartialEq)]
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct TrackpointCategories {
pub surface: Categories,
pub highway: Categories,
+8 -32
View File
@@ -5,8 +5,8 @@ use crate::{Trackpoint, Waypoint};
/// A run of items, shared between the successive versions of a [`crate::Chunked`] list: a chunk
/// that is not touched by an edit is not copied.
///
/// Chunks are equal when they have the same identity, which is cheap to compare and changes
/// whenever the content does (a modified chunk is a new one).
/// A chunk has an identity, which changes whenever the content does (a modified chunk is a new
/// one): what is derived from a chunk, or kept of it, is up to date as long as the identity is.
pub trait Chunk {
type Item: Clone;
@@ -27,8 +27,8 @@ pub trait Chunk {
const MAX_TRKPT_CHUNK_SIZE: usize = 4096;
#[derive(Debug, PartialEq, Eq)]
pub struct TrackpointChunkId(Uuid);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct TrackpointChunkId(pub Uuid);
impl Default for TrackpointChunkId {
fn default() -> Self {
@@ -42,12 +42,6 @@ pub struct TrackpointChunk {
pub trkpt: Vec<Trackpoint>,
}
impl PartialEq for TrackpointChunk {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Chunk for TrackpointChunk {
type Item = Trackpoint;
const MAX_SIZE: usize = MAX_TRKPT_CHUNK_SIZE;
@@ -68,8 +62,8 @@ impl Chunk for TrackpointChunk {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WaypointChunkId(Uuid);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct WaypointChunkId(pub Uuid);
impl Default for WaypointChunkId {
fn default() -> Self {
@@ -85,12 +79,6 @@ pub struct WaypointChunk {
pub wpt: Vec<Waypoint>,
}
impl PartialEq for WaypointChunk {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
impl Chunk for WaypointChunk {
type Item = Waypoint;
const MAX_SIZE: usize = MAX_WPT_CHUNK_SIZE;
@@ -141,20 +129,8 @@ mod tests {
fn test_new_chunks_have_their_own_identity() {
let a = TrackpointChunk::new(vec![Trackpoint::default()]);
let b = TrackpointChunk::new(vec![Trackpoint::default()]);
assert_ne!(a, b);
assert_ne!(a.id, b.id);
assert_eq!(a.items().len(), 1);
assert_ne!(WaypointChunk::new(vec![]), WaypointChunk::new(vec![]));
}
#[test]
fn test_chunk_equality_is_by_id() {
let a = TrackpointChunk::default();
let b = TrackpointChunk::default();
assert_ne!(a, b);
let c = TrackpointChunk {
id: TrackpointChunkId(a.id.0),
trkpt: vec![Trackpoint::default()],
};
assert_eq!(a, c);
assert_ne!(WaypointChunk::new(vec![]).id, WaypointChunk::new(vec![]).id);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ use crate::Chunk;
/// copies the chunks around the change, so that a version costs little more than the change.
///
/// There are no empty chunks.
#[derive(Debug, PartialEq)]
#[derive(Debug)]
pub struct Chunked<C: Chunk> {
chunks: Vec<Rc<C>>,
cumul_length: Vec<usize>,
+2 -2
View File
@@ -1,10 +1,10 @@
#[derive(Debug, Default, Clone, PartialEq, Eq)]
#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Link {
pub href: String,
pub text: Option<String>,
}
#[derive(Debug, Default, Clone, Copy)]
#[derive(Debug, Default, Clone, Copy, serde::Serialize, serde::Deserialize)]
pub struct LngLat {
pub lng: f64,
pub lat: f64,
+4 -5
View File
@@ -2,7 +2,7 @@ use uuid::Uuid;
use crate::{Link, Track, Waypoints};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct FileId(pub Uuid);
impl Default for FileId {
@@ -11,16 +11,15 @@ impl Default for FileId {
}
}
#[derive(Debug, Default, Clone, PartialEq)]
#[derive(Debug, Default, Clone)]
pub struct File {
pub id: FileId,
pub info: FileInfo,
pub trk: Vec<Track>,
pub wpt: Waypoints,
// TODO routes
}
#[derive(Debug, Default, Clone, PartialEq)]
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FileInfo {
pub name: String,
pub desc: Option<String>,
@@ -29,7 +28,7 @@ pub struct FileInfo {
pub time: Option<i64>,
}
#[derive(Debug, Default, Clone, PartialEq)]
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Author {
pub name: Option<String>,
pub email: Option<String>,
+2 -2
View File
@@ -4,7 +4,7 @@ use uuid::Uuid;
use crate::{ChunkIndex, Chunked, ChunkedIter, Trackpoint, TrackpointChunk, compute_anchors};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct TrackSegmentId(pub Uuid);
impl Default for TrackSegmentId {
@@ -22,7 +22,7 @@ impl Default for TrackSegmentRevisionId {
}
}
#[derive(Debug, Default, Clone, PartialEq)]
#[derive(Debug, Default, Clone)]
pub struct TrackSegment {
pub id: TrackSegmentId,
pub rev_id: TrackSegmentRevisionId,
+3 -3
View File
@@ -2,7 +2,7 @@ use uuid::Uuid;
use crate::{Link, TrackSegment};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct TrackId(pub Uuid);
impl Default for TrackId {
@@ -11,14 +11,14 @@ impl Default for TrackId {
}
}
#[derive(Debug, Default, Clone, PartialEq)]
#[derive(Debug, Default, Clone)]
pub struct Track {
pub id: TrackId,
pub info: TrackInfo,
pub trkseg: Vec<TrackSegment>,
}
#[derive(Debug, Default, Clone, PartialEq)]
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct TrackInfo {
pub name: Option<String>,
pub cmt: Option<String>,
+1 -1
View File
@@ -1,6 +1,6 @@
use crate::LngLat;
#[derive(Debug, Default, Clone)]
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
pub struct Trackpoint {
pub coordinates: LngLat,
pub ele: f64,
+2 -2
View File
@@ -2,7 +2,7 @@ use uuid::Uuid;
use crate::{Link, LngLat};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct WaypointId(pub Uuid);
impl Default for WaypointId {
@@ -11,7 +11,7 @@ impl Default for WaypointId {
}
}
#[derive(Debug, Default, Clone)]
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
pub struct Waypoint {
pub id: WaypointId,
pub coordinates: LngLat,
+7 -1
View File
@@ -19,7 +19,7 @@ impl Default for FileWaypointsRevisionId {
/// Everything that changes the waypoints changes the revision too, which is why there is no
/// mutable access to the list itself: the revision tells what is derived from the waypoints
/// (coordinates for the map...) that it is out of date.
#[derive(Debug, Default, Clone, PartialEq)]
#[derive(Debug, Default, Clone)]
pub struct Waypoints {
chunks: Chunked<WaypointChunk>,
/// Changes when the waypoints do.
@@ -50,6 +50,12 @@ impl Waypoints {
self.rev_id = Default::default();
}
/// Adds a chunk that is shared with something else after the last waypoint.
pub fn push_shared(&mut self, chunk: std::rc::Rc<WaypointChunk>) {
self.chunks.push_shared(chunk);
self.rev_id = Default::default();
}
/// Replaces the waypoints in `start..end` by `waypoints`, see [`Chunked::splice`].
pub fn splice(&mut self, start: usize, end: usize, waypoints: Vec<Waypoint>) {
self.chunks.splice(start, end, waypoints);
+116 -2
View File
@@ -21,6 +21,7 @@ enum GPXElement {
Track(Track),
Segment(TrackSegment),
Trackpoint(Trackpoint),
RoutePoint(LngLat),
Waypoint(Waypoint),
Elevation,
Time,
@@ -51,6 +52,11 @@ fn parse_coordinates(attributes: Attributes<'_>) -> LngLat {
coordinates
}
/// Whether the element is a point of the detailed path of a route point (`gpxx:rpt`).
fn is_route_point_extension(name: &str) -> bool {
name == "rpt" || name.ends_with(":rpt")
}
/// Whether the element holds a value, which is its text.
fn holds_text(element: Option<&GPXElement>) -> bool {
matches!(
@@ -262,6 +268,10 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
let mut stack: Vec<GPXElement> = vec![];
let mut trkpt_chunk = TrackpointChunk::default();
let mut wpt_chunk = WaypointChunk::default();
// Routes are read as tracks of a single segment, which is what `rte_segment` collects the points of. A route
// point that comes with the detailed path of the route to the next one (`gpxx:rpt`) is replaced by it.
let mut rte_segment = TrackSegment::default();
let mut rpt_points: Vec<Trackpoint> = vec![];
// the text of the element at the top of the stack, up to now
let mut text = String::new();
loop {
@@ -283,11 +293,11 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
stack.push(GPXElement::Link(link));
}
"text" => stack.push(GPXElement::Text),
"trk" => stack.push(GPXElement::Track(Track::default())),
"trk" | "rte" => stack.push(GPXElement::Track(Track::default())),
"trkseg" => {
stack.push(GPXElement::Segment(TrackSegment::default()));
}
"trkpt" => {
"trkpt" | "rtept" => {
stack.push(GPXElement::Trackpoint(Trackpoint {
coordinates: parse_coordinates(e.attributes()),
..Default::default()
@@ -299,6 +309,9 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
..Default::default()
}));
}
name if is_route_point_extension(name) => {
stack.push(GPXElement::RoutePoint(parse_coordinates(e.attributes())));
}
"ele" => stack.push(GPXElement::Elevation),
"time" => stack.push(GPXElement::Time),
e if e.ends_with("atemp") => stack.push(GPXElement::Temperature),
@@ -330,6 +343,25 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
}
}
}
"rtept" => {
if let Some(GPXElement::Track(_)) = stack.last() {
trkpt_chunk.trkpt.push(Trackpoint {
coordinates: parse_coordinates(e.attributes()),
..Default::default()
});
if trkpt_chunk.is_full() {
rte_segment.push(std::mem::take(&mut trkpt_chunk));
}
}
}
name if is_route_point_extension(name) => {
if let Some(GPXElement::Trackpoint(_)) = stack.last() {
rpt_points.push(Trackpoint {
coordinates: parse_coordinates(e.attributes()),
..Default::default()
});
}
}
"wpt" => {
wpt_chunk.wpt.push(Waypoint {
coordinates: parse_coordinates(e.attributes()),
@@ -385,6 +417,42 @@ pub fn parse(data: &[u8], categories: &mut TrackpointCategories) -> Result<File,
gpx.trk.push(trk);
}
}
"rte" => {
if let Some(GPXElement::Track(mut trk)) = stack.pop() {
rte_segment.push(std::mem::take(&mut trkpt_chunk));
if !rte_segment.is_empty() {
let mut trkseg = std::mem::take(&mut rte_segment);
trkseg.compute_anchors();
trk.trkseg.push(trkseg);
}
gpx.trk.push(trk);
}
}
"rtept" => {
if let Some(GPXElement::Trackpoint(rtept)) = stack.pop()
&& let Some(GPXElement::Track(_)) = stack.last()
{
let points = if rpt_points.is_empty() {
vec![rtept]
} else {
std::mem::take(&mut rpt_points)
};
for point in points {
trkpt_chunk.trkpt.push(point);
if trkpt_chunk.is_full() {
rte_segment.push(std::mem::take(&mut trkpt_chunk));
}
}
}
}
name if is_route_point_extension(name) => {
if let Some(GPXElement::RoutePoint(coordinates)) = stack.pop() {
rpt_points.push(Trackpoint {
coordinates,
..Default::default()
});
}
}
"trkseg" => {
if let Some(GPXElement::Segment(mut trkseg)) = stack.pop()
&& let Some(GPXElement::Track(trk)) = stack.last_mut()
@@ -571,6 +639,52 @@ mod tests {
assert_eq!(trkpt.ele, 115.5);
}
#[test]
fn test_parse_routes_as_tracks() {
let gpx = parse_data("with_routes");
assert_eq!(gpx.trk.len(), 2);
let trk = &gpx.trk[0];
assert_eq!(trk.info.name.as_deref(), Some("route 1"));
assert_eq!(trk.info.type_.as_deref(), Some("Cycling"));
assert_eq!(trk.trkseg.len(), 1);
let trkseg = &trk.trkseg[0];
assert_eq!(trkseg.len(), 49);
let trkpt = &trkseg[0];
assert_eq!(trkpt.coordinates.lat, 50.790867);
assert_eq!(trkpt.coordinates.lng, 4.404968);
assert_eq!(trkpt.ele, 109.0);
let trk = &gpx.trk[1];
assert_eq!(trk.info.name.as_deref(), Some("route 2"));
assert_eq!(trk.trkseg[0].len(), 28);
}
#[test]
fn test_parse_route_point_extensions() {
let gpx = parse_data("with_route_extensions");
// a route without points has no segment
assert_eq!(gpx.trk.len(), 2);
assert!(gpx.trk[1].trkseg.is_empty());
let trk = &gpx.trk[0];
assert_eq!(trk.info.name.as_deref(), Some("with extensions"));
assert_eq!(trk.trkseg.len(), 1);
let points: Vec<_> = trk.trkseg[0].iter().collect();
// the detailed path replaces the route point it comes with
let coordinates: Vec<_> = points
.iter()
.map(|p| (p.coordinates.lat, p.coordinates.lng))
.collect();
assert_eq!(
coordinates,
[(1.1, 1.1), (1.2, 1.2), (2.0, 2.0), (3.0, 3.0)]
);
assert_eq!(points[2].ele, 20.0);
assert!(points[2].time.is_some());
}
#[test]
fn test_parse_segments() {
let gpx = parse_data("with_segments");
@@ -329,7 +329,8 @@ mod tests {
let result =
clean(bounds(-180.0, -90.0, 180.0, 90.0), CleanType::Outside).apply(&mut fx.state());
assert!(matches!(result, Err(CommandError::NothingToDo)));
assert_eq!(fx.files[&id], before);
// the file was not even copied
assert!(std::rc::Rc::ptr_eq(&fx.files[&id], &before));
}
#[test]
@@ -1,6 +1,6 @@
use std::rc::Rc;
use crate::{Apply, CommandError, File, State, Trackpoint, reduce_indices, reduction_distances};
use crate::{Apply, CommandError, File, State, Trackpoint, reduce_indices};
/// Removes the trackpoints of the selected segments that are less than `tolerance` meters away
/// from the line of their neighbours (Ramer-Douglas-Peucker, see [`reduce_indices`]). The ends of the segments are
@@ -56,7 +56,7 @@ impl Apply for Reduce {
mod tests {
use std::collections::HashSet;
use crate::{FileId, Load, Selection, engine::command::fixture::Fixture};
use crate::{FileId, Load, Selection, engine::command::fixture::Fixture, reduction_distances};
use super::*;
@@ -245,7 +245,14 @@ mod tests {
// the segments before are in the first track, the ones after in the second one
assert_eq!(a.trkseg.len(), seg + 1);
assert_eq!(b.trkseg.len(), before.trk[trk].trkseg.len() - seg);
assert_eq!(a.trkseg[..seg], before.trk[trk].trkseg[..seg]);
// the segments before are the same ones, with the same trackpoints
for (kept, original) in a.trkseg[..seg].iter().zip(&before.trk[trk].trkseg[..seg]) {
assert_eq!((kept.id, kept.rev_id), (original.id, original.rev_id));
let chunks = |segment: &TrackSegment| -> Vec<_> {
segment.chunks().iter().map(|chunk| chunk.id).collect()
};
assert_eq!(chunks(kept), chunks(original));
}
// new ids for everything that moved, so that they are unique
let ids: HashSet<TrackSegmentId> = after
.trk
+76 -3
View File
@@ -2,9 +2,10 @@
use crate::{
Action, Apply, Clipboard, Command, CoordinatesCache, Diff, ExportOptions, FileId, FileOrder,
FileStructure, FileStructureCache, GlobalStatistics, RoutingBuffer, SelectMode, Selection,
Stack, State, StatisticsBuffer, StatisticsCache, TrackSegmentId, Trackpoint,
TrackpointCategories, Waypoint, WaypointId, reduction_distances, write,
FileStructure, FileStructureCache, GlobalStatistics, Restored, RoutingBuffer, SelectMode,
Selection, Snapshot, Stack, StackEntry, State, StatisticsBuffer, StatisticsCache,
TrackSegmentId, Trackpoint, TrackpointCategories, Waypoint, WaypointId, reduction_distances,
write,
};
#[derive(Debug, Default)]
@@ -118,6 +119,40 @@ impl Engine {
.collect()
}
/// What there is to keep of the engine, see [`crate::Persister`].
pub fn snapshot(&self) -> Snapshot {
Snapshot {
files: self.stack.current().cloned().unwrap_or_default(),
order: self.order.0.clone(),
categories: self.categories.clone(),
}
}
/// Puts files in an engine that has none, typically the ones read from the storage. They are
/// the state to start from: they cannot be undone. The files that were added are reported as
/// the last diff, and the order as changed.
pub fn restore(&mut self, restored: Restored) {
let files: StackEntry = restored
.files
.into_iter()
.map(|file| (file.id, file))
.collect();
self.categories = restored.categories;
self.order = FileOrder(restored.order);
self.selection = Selection::Empty;
self.clipboard = None;
self.diff = Some(Diff {
added: files.keys().copied().collect(),
..Default::default()
});
self.stack = Stack::restored(files);
self.refresh();
self.sort_diff();
self.order_changed = true;
self.selection_changed = true;
self.clipboard_changed = true;
}
/// A file as GPX (UTF-8), `None` if it does not exist.
pub fn export(&self, id: &FileId, options: ExportOptions) -> Option<Vec<u8>> {
let file = self.stack.current()?.get(id)?;
@@ -243,6 +278,7 @@ impl Engine {
|| self.clipboard != clipboard_before;
if changed {
self.refresh();
self.sort_diff();
}
// after the refresh, which also syncs the order and selection with the files (undo, redo)
self.order_changed = self.order.0 != order_before;
@@ -273,6 +309,16 @@ impl Engine {
})
}
/// Puts the files of the last diff in the order of the files (the stack does not know it), so
/// that whatever is done with them one after the other, like giving them colors, follows it.
fn sort_diff(&mut self) {
let position = |id: &FileId| self.order.0.iter().position(|other| other == id);
if let Some(diff) = &mut self.diff {
diff.added.sort_by_key(position);
diff.modified.sort_by_key(position);
}
}
/// Brings everything derived from the files back in line with the current stack entry.
fn refresh(&mut self) {
let current = self.stack.current();
@@ -1166,6 +1212,33 @@ mod tests {
assert_eq!(track_ids(&engine, a), tracks);
}
#[test]
fn test_added_files_are_reported_in_the_order_of_the_files() {
use crate::LoadFiles;
let mut engine = Engine::default();
let datas: Vec<Vec<u8>> = (0..12)
.map(|i| {
format!(r#"<gpx version="1.1"><metadata><name>{i}</name></metadata></gpx>"#)
.into_bytes()
})
.collect();
let loads = datas
.iter()
.map(|data| Load { data, name: "x" })
.collect::<Vec<_>>();
assert!(edit(
&mut engine,
Command::LoadFiles(LoadFiles { files: loads })
));
assert_eq!(engine.last_diff().unwrap().added, engine.order());
// files that come back with an undo too
assert!(engine.execute(Action::Undo));
assert!(engine.execute(Action::Redo));
assert_eq!(engine.last_diff().unwrap().added, engine.order());
}
#[test]
fn test_load_several_files_is_one_edit() {
use crate::LoadFiles;
+145 -3
View File
@@ -9,9 +9,21 @@ use crate::{File, FileId};
pub struct Stack {
entries: Vec<StackEntry>,
index: Option<usize>,
/// Whether the first entry is the state the stack started from (files restored from the
/// storage), which is not undone.
restored: bool,
}
impl Stack {
/// A stack whose first entry is `entry`, from which nothing can be undone.
pub fn restored(entry: StackEntry) -> Self {
Self {
entries: vec![entry],
index: Some(0),
restored: true,
}
}
pub fn current(&self) -> Option<&StackEntry> {
self.index.map_or_default(|i| self.get(i))
}
@@ -32,7 +44,9 @@ impl Stack {
self.record_diff(|stack| {
if let Some(i) = stack.index {
if i == 0 {
stack.index = None;
if !stack.restored {
stack.index = None;
}
} else {
stack.index = Some(i - 1);
}
@@ -56,7 +70,7 @@ impl Stack {
}
pub fn can_undo(&self) -> bool {
self.index.is_some()
self.index.is_some_and(|i| i > 0 || !self.restored)
}
pub fn can_redo(&self) -> bool {
@@ -97,7 +111,7 @@ impl Stack {
for id in prev_ids.intersection(&cur_ids) {
let before = prev.map_or_default(|e| e.get(id));
let after = cur.map_or_default(|e| e.get(id));
if !before.zip(after).is_some_and(|(b, a)| Rc::ptr_eq(b, a)) && before != after {
if !before.zip(after).is_some_and(|(b, a)| Rc::ptr_eq(b, a)) {
modified.push(*id);
}
}
@@ -231,6 +245,34 @@ mod tests {
assert_eq!(stack.current().unwrap()[&id].info.name, "renamed");
}
#[test]
fn test_a_file_is_modified_when_it_is_not_the_same_one_anymore() {
let mut stack = Stack::default();
let id = add_file(&mut stack);
// a copy, even if it has the same content, is another file for the stack: files are
// told apart by their identity, comparing them would read all of them
let diff = stack
.create_and_push_next(|e| {
let copy = (**e.get(&id).unwrap()).clone();
e.insert(id, Rc::new(copy));
Ok(())
})
.unwrap();
assert_eq!(diff.modified, vec![id]);
// and the files that were not replaced are not
let other = add_file(&mut stack);
let diff = stack
.create_and_push_next(|e| {
let copy = (**e.get(&other).unwrap()).clone();
e.insert(other, Rc::new(copy));
Ok(())
})
.unwrap();
assert_eq!(diff.modified, vec![other]);
}
#[test]
fn test_push_after_undo_drops_redo_branch() {
let mut stack = Stack::default();
@@ -249,4 +291,104 @@ mod tests {
assert!(current.contains_key(&third));
assert!(!current.contains_key(&second));
}
fn restored_entry() -> (StackEntry, FileId) {
let file = Rc::new(File::default());
let id = file.id;
(StackEntry::from([(id, file)]), id)
}
#[test]
fn test_restored_stack_starts_from_its_entry() {
let (entry, id) = restored_entry();
let mut stack = Stack::restored(entry);
assert!(stack.current().unwrap().contains_key(&id));
// there is nothing to go back to, nor forward to
assert!(!stack.can_undo());
assert!(!stack.can_redo());
assert!(stack.undo().is_none());
assert!(stack.redo().is_none());
assert!(stack.current().unwrap().contains_key(&id));
}
#[test]
fn test_restored_stack_cannot_undo_an_empty_entry_either() {
let mut stack = Stack::restored(StackEntry::new());
assert!(stack.current().is_some_and(|files| files.is_empty()));
assert!(!stack.can_undo());
assert!(stack.undo().is_none());
// the stack is not the one of an engine that has done nothing
assert!(stack.current().is_some());
}
#[test]
fn test_restored_stack_undoes_what_comes_after_it_and_no_further() {
let (entry, restored) = restored_entry();
let mut stack = Stack::restored(entry);
let added = add_file(&mut stack);
assert!(stack.can_undo());
assert!(!stack.can_redo());
assert_eq!(stack.current().unwrap().len(), 2);
// back to the restored files
let diff = stack.undo().unwrap();
assert_eq!(diff.removed, vec![added]);
assert!(diff.added.is_empty() && diff.modified.is_empty());
assert_eq!(stack.current().unwrap().len(), 1);
assert!(stack.current().unwrap().contains_key(&restored));
assert!(!stack.can_undo());
assert!(stack.can_redo());
assert!(stack.undo().is_none());
assert!(stack.current().unwrap().contains_key(&restored));
let diff = stack.redo().unwrap();
assert_eq!(diff.added, vec![added]);
assert!(stack.can_undo());
assert!(!stack.can_redo());
// and again
assert!(stack.undo().is_some());
assert!(stack.undo().is_none());
assert!(stack.current().unwrap().contains_key(&restored));
}
#[test]
fn test_restored_stack_removing_its_files_can_be_undone() {
let (entry, restored) = restored_entry();
let mut stack = Stack::restored(entry);
let diff = stack
.create_and_push_next(|e| {
e.remove(&restored);
Ok(())
})
.unwrap();
assert_eq!(diff.removed, vec![restored]);
assert!(stack.current().unwrap().is_empty());
let diff = stack.undo().unwrap();
assert_eq!(diff.added, vec![restored]);
assert!(stack.current().unwrap().contains_key(&restored));
assert!(!stack.can_undo());
}
#[test]
fn test_restored_stack_push_after_undo_drops_redo_branch() {
let (entry, restored) = restored_entry();
let mut stack = Stack::restored(entry);
add_file(&mut stack);
stack.undo().unwrap();
assert!(stack.can_redo());
let other = add_file(&mut stack);
assert!(!stack.can_redo());
assert!(stack.can_undo());
let diff = stack.undo().unwrap();
assert_eq!(diff.removed, vec![other]);
assert!(stack.current().unwrap().contains_key(&restored));
assert!(!stack.can_undo());
}
}
+2
View File
@@ -1,5 +1,7 @@
mod core;
mod engine;
mod storage;
pub use core::*;
pub use engine::*;
pub use storage::*;
+130
View File
@@ -0,0 +1,130 @@
//! 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)?,
})
}
+105
View File
@@ -0,0 +1,105 @@
use std::cell::RefCell;
use std::collections::HashMap;
use uuid::Uuid;
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,
/// and what the tests use.
#[derive(Debug, Default)]
pub struct MemoryStorage {
state: RefCell<State>,
}
#[derive(Debug, Default)]
struct State {
files: HashMap<FileId, Vec<u8>>,
trackpoint_chunks: HashMap<Uuid, Vec<u8>>,
waypoint_chunks: HashMap<Uuid, Vec<u8>>,
order: Option<Vec<u8>>,
categories: Option<Vec<u8>>,
settings: HashMap<String, String>,
}
impl MemoryStorage {
/// How many files and chunks (trackpoints, waypoints) are stored.
pub fn counts(&self) -> (usize, usize, usize) {
let state = self.state.borrow();
(
state.files.len(),
state.trackpoint_chunks.len(),
state.waypoint_chunks.len(),
)
}
}
impl Storage for MemoryStorage {
async fn load(&self) -> Result<StoredData, StorageError> {
let state = self.state.borrow();
Ok(StoredData {
files: state.files.iter().map(|(k, v)| (*k, v.clone())).collect(),
trackpoint_chunks: state
.trackpoint_chunks
.iter()
.map(|(k, v)| (*k, v.clone()))
.collect(),
waypoint_chunks: state
.waypoint_chunks
.iter()
.map(|(k, v)| (*k, v.clone()))
.collect(),
order: state.order.clone(),
categories: state.categories.clone(),
})
}
async fn commit(&self, batch: &Batch) -> Result<(), StorageError> {
let mut state = self.state.borrow_mut();
state.files.extend(batch.put_files.iter().cloned());
for id in &batch.delete_files {
state.files.remove(id);
}
state
.trackpoint_chunks
.extend(batch.put_trackpoint_chunks.iter().cloned());
for id in &batch.delete_trackpoint_chunks {
state.trackpoint_chunks.remove(id);
}
state
.waypoint_chunks
.extend(batch.put_waypoint_chunks.iter().cloned());
for id in &batch.delete_waypoint_chunks {
state.waypoint_chunks.remove(id);
}
if let Some(order) = &batch.order {
state.order = Some(order.clone());
}
if let Some(categories) = &batch.categories {
state.categories = Some(categories.clone());
}
Ok(())
}
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
let state = self.state.borrow();
Ok(state
.settings
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect())
}
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> {
self.state
.borrow_mut()
.settings
.insert(key.to_owned(), value.to_owned());
Ok(())
}
async fn delete_setting(&self, key: &str) -> Result<(), StorageError> {
self.state.borrow_mut().settings.remove(key);
Ok(())
}
}
+85
View File
@@ -0,0 +1,85 @@
//! Keeping the files and the settings between sessions.
//!
//! The engine does not know where the data goes: the host (the browser, a desktop app...)
//! implements [`Storage`], and a [`Persister`] works out what to write after each change.
//!
//! The files are not stored as a whole. Their trackpoints and waypoints are stored in chunks (see
//! [`crate::Chunked`]) that are written once, under their identity, and a file is a small record
//! that lists the chunks it is made of. An edit only writes the new chunks and the records of the
//! files that changed; the chunks that nothing refers to anymore are deleted at the same time.
mod codec;
mod memory;
mod persister;
pub use memory::*;
pub use persister::*;
use uuid::Uuid;
use crate::FileId;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StorageError(pub String);
impl std::fmt::Display for StorageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "storage error: {}", self.0)
}
}
impl std::error::Error for StorageError {}
/// Everything that is stored about the files, as opaque bytes (see the module documentation).
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct StoredData {
/// The record of each file.
pub files: Vec<(FileId, Vec<u8>)>,
pub trackpoint_chunks: Vec<(Uuid, Vec<u8>)>,
pub waypoint_chunks: Vec<(Uuid, Vec<u8>)>,
/// The order of the files.
pub order: Option<Vec<u8>>,
/// The categories of the trackpoints, which their chunks refer to.
pub categories: Option<Vec<u8>>,
}
/// A change of the stored data, to be applied all at once: a stored state is always one that the
/// engine had.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Batch {
pub put_files: Vec<(FileId, Vec<u8>)>,
pub delete_files: Vec<FileId>,
pub put_trackpoint_chunks: Vec<(Uuid, Vec<u8>)>,
pub delete_trackpoint_chunks: Vec<Uuid>,
pub put_waypoint_chunks: Vec<(Uuid, Vec<u8>)>,
pub delete_waypoint_chunks: Vec<Uuid>,
pub order: Option<Vec<u8>>,
pub categories: Option<Vec<u8>>,
}
impl Batch {
pub fn is_empty(&self) -> bool {
self == &Self::default()
}
}
/// Where the data is kept. The implementation belongs to the host; [`MemoryStorage`] is one that
/// keeps nothing after the session.
///
/// The methods are async because storage often is (IndexedDB), the engine itself is not: it
/// changes its files at once and the changes are written afterwards, see [`Persister`].
#[allow(async_fn_in_trait)]
pub trait Storage {
/// Reads the files.
async fn load(&self) -> Result<StoredData, StorageError>;
/// Applies a batch: either all of it is stored, or none.
async fn commit(&self, batch: &Batch) -> Result<(), StorageError>;
/// Reads the settings, as `(key, value)`. The engine does not look into them.
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError>;
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError>;
async fn delete_setting(&self, key: &str) -> Result<(), StorageError>;
}
+485
View File
@@ -0,0 +1,485 @@
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use uuid::Uuid;
use super::codec::{
FileRecord, decode, decode_trackpoints, decode_waypoints, encode, encode_trackpoints,
encode_waypoints,
};
use crate::{
Batch, File, FileId, StackEntry, Storage, StorageError, TrackpointCategories, TrackpointChunk,
WaypointChunk,
};
/// What there is to keep of the engine: the files of the current state, their order, and the
/// categories their trackpoints refer to. See [`crate::Engine::snapshot`].
#[derive(Debug, Default, Clone)]
pub struct Snapshot {
pub files: StackEntry,
pub order: Vec<FileId>,
pub categories: TrackpointCategories,
}
/// What was read from the storage, to start an engine with (see [`crate::Engine::restore`]).
#[derive(Debug, Default)]
pub struct Restored {
pub files: Vec<Rc<File>>,
pub order: Vec<FileId>,
pub categories: TrackpointCategories,
/// The files that could not be read: they are dropped at the next save.
pub unreadable: usize,
}
/// Works out what to write to a [`Storage`] so that it holds a [`Snapshot`], knowing what it
/// already holds.
///
/// A file whose `Rc` is the saved one has not changed, which is what makes a save cheap: only the
/// files that changed are looked at to find the chunks to write, and the others are only counted
/// to find the chunks that nothing uses anymore.
#[derive(Debug, Default)]
pub struct Persister {
/// The files as they are saved.
files: HashMap<FileId, Rc<File>>,
/// The files that have a record, including the ones that could not be read.
stored_files: HashSet<FileId>,
trackpoint_chunks: HashSet<Uuid>,
waypoint_chunks: HashSet<Uuid>,
order: Option<Vec<FileId>>,
categories: Option<TrackpointCategories>,
}
/// What a save writes, and what is stored once it is written.
struct Plan {
batch: Batch,
snapshot: Snapshot,
trackpoint_chunks: HashSet<Uuid>,
waypoint_chunks: HashSet<Uuid>,
}
impl Persister {
/// Reads the files of the storage. The persister then knows that they are saved.
pub async fn restore<S: Storage>(&mut self, storage: &S) -> Result<Restored, StorageError> {
let data = storage.load().await?;
*self = Self::default();
let categories: Option<TrackpointCategories> = data.categories.as_deref().and_then(decode);
let stored_order: Option<Vec<FileId>> = data.order.as_deref().and_then(decode);
let trackpoint_bytes: HashMap<Uuid, Vec<u8>> = data.trackpoint_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
let trackpoint_cache: RefCell<HashMap<Uuid, Option<Rc<TrackpointChunk>>>> =
Default::default();
let waypoint_cache: RefCell<HashMap<Uuid, Option<Rc<WaypointChunk>>>> = Default::default();
let trackpoint_chunk = |id: &crate::TrackpointChunkId| {
trackpoint_cache
.borrow_mut()
.entry(id.0)
.or_insert_with(|| {
let bytes = trackpoint_bytes.get(&id.0)?;
decode_trackpoints(id.0, bytes).map(Rc::new)
})
.clone()
};
let waypoint_chunk = |id: &crate::WaypointChunkId| {
waypoint_cache
.borrow_mut()
.entry(id.0)
.or_insert_with(|| {
let bytes = waypoint_bytes.get(&id.0)?;
decode_waypoints(id.0, bytes).map(Rc::new)
})
.clone()
};
let mut restored = Restored {
categories: categories.clone().unwrap_or_default(),
..Default::default()
};
let mut files: HashMap<FileId, Rc<File>> = HashMap::new();
for (id, bytes) in &data.files {
self.stored_files.insert(*id);
let file = decode::<FileRecord>(bytes)
.and_then(|record| record.build(*id, &trackpoint_chunk, &waypoint_chunk));
match file {
Some(file) => {
files.insert(*id, Rc::new(file));
}
None => restored.unreadable += 1,
}
}
restored.order = stored_order
.iter()
.flatten()
.filter(|id| files.contains_key(id))
.copied()
.collect();
let ordered: HashSet<FileId> = restored.order.iter().copied().collect();
restored
.order
.extend(files.keys().filter(|id| !ordered.contains(id)).copied());
restored.files = restored.order.iter().map(|id| files[id].clone()).collect();
self.files = files;
self.trackpoint_chunks = trackpoint_bytes.into_keys().collect();
self.waypoint_chunks = waypoint_bytes.into_keys().collect();
self.order = stored_order;
self.categories = categories;
Ok(restored)
}
/// Writes what changed since the last save. Nothing is written if nothing changed.
pub async fn save<S: Storage>(
&mut self,
storage: &S,
snapshot: Snapshot,
) -> Result<(), StorageError> {
let plan = self.plan(snapshot);
if plan.batch.is_empty() {
self.files = plan.snapshot.files;
return Ok(());
}
storage.commit(&plan.batch).await?;
self.stored_files = plan.snapshot.files.keys().copied().collect();
self.files = plan.snapshot.files;
self.trackpoint_chunks = plan.trackpoint_chunks;
self.waypoint_chunks = plan.waypoint_chunks;
self.order = Some(plan.snapshot.order);
self.categories = Some(plan.snapshot.categories);
Ok(())
}
fn plan(&self, snapshot: Snapshot) -> Plan {
let mut batch = Batch::default();
// the chunks that the files use, which the storage has afterwards
let mut trackpoint_chunks = HashSet::new();
let mut waypoint_chunks = HashSet::new();
for (id, file) in &snapshot.files {
let changed = self
.files
.get(id)
.is_none_or(|saved| !Rc::ptr_eq(saved, file));
let segments = file.trk.iter().flat_map(|track| &track.trkseg);
for chunk in segments.flat_map(|segment| segment.chunks()) {
let new = trackpoint_chunks.insert(chunk.id.0);
if changed && new && !self.trackpoint_chunks.contains(&chunk.id.0) {
batch
.put_trackpoint_chunks
.push((chunk.id.0, encode_trackpoints(chunk)));
}
}
for chunk in file.wpt.chunks() {
let new = waypoint_chunks.insert(chunk.id.0);
if changed && new && !self.waypoint_chunks.contains(&chunk.id.0) {
batch
.put_waypoint_chunks
.push((chunk.id.0, encode_waypoints(chunk)));
}
}
if changed || !self.stored_files.contains(id) {
batch.put_files.push((*id, encode(&FileRecord::new(file))));
}
}
batch.delete_files = self
.stored_files
.iter()
.filter(|id| !snapshot.files.contains_key(id))
.copied()
.collect();
batch.delete_trackpoint_chunks = self
.trackpoint_chunks
.difference(&trackpoint_chunks)
.copied()
.collect();
batch.delete_waypoint_chunks = self
.waypoint_chunks
.difference(&waypoint_chunks)
.copied()
.collect();
if self.order.as_ref() != Some(&snapshot.order) {
batch.order = Some(encode(&snapshot.order));
}
if self.categories.as_ref() != Some(&snapshot.categories) {
batch.categories = Some(encode(&snapshot.categories));
}
Plan {
batch,
snapshot,
trackpoint_chunks,
waypoint_chunks,
}
}
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::future::Future;
use std::task::{Context, Poll, Waker};
use crate::{
Action, Command, Engine, ExportOptions, Load, MemoryStorage, StoredData, parse, write,
};
use super::*;
fn block_on<F: Future>(future: F) -> F::Output {
let mut future = std::pin::pin!(future);
let mut context = Context::from_waker(Waker::noop());
loop {
if let Poll::Ready(value) = future.as_mut().poll(&mut context) {
return value;
}
}
}
/// A memory storage that remembers the batches it was given.
#[derive(Default)]
struct Recording {
inner: MemoryStorage,
batches: RefCell<Vec<Batch>>,
}
impl Storage for Recording {
async fn load(&self) -> Result<StoredData, StorageError> {
self.inner.load().await
}
async fn commit(&self, batch: &Batch) -> Result<(), StorageError> {
self.batches.borrow_mut().push(batch.clone());
self.inner.commit(batch).await
}
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
self.inner.load_settings().await
}
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> {
self.inner.put_setting(key, value).await
}
async fn delete_setting(&self, key: &str) -> Result<(), StorageError> {
self.inner.delete_setting(key).await
}
}
fn snapshot_of(paths: &[&str]) -> Snapshot {
let mut snapshot = Snapshot::default();
for path in paths {
let data = std::fs::read(path).unwrap();
let file = parse(&data, &mut snapshot.categories).unwrap();
snapshot.order.push(file.id);
snapshot.files.insert(file.id, Rc::new(file));
}
snapshot
}
fn gpx(file: &File, categories: &TrackpointCategories) -> Vec<u8> {
write(file, categories, ExportOptions::ALL)
}
const FILES: [&str; 3] = [
"data/with_tracks_and_segments.gpx",
"data/with_waypoint.gpx",
"data/with_surface.gpx",
];
#[test]
fn test_saved_files_are_restored() {
let storage = Recording::default();
let snapshot = snapshot_of(&FILES);
block_on(Persister::default().save(&storage, snapshot.clone())).unwrap();
let mut persister = Persister::default();
let restored = block_on(persister.restore(&storage)).unwrap();
assert_eq!(restored.unreadable, 0);
assert_eq!(restored.order, snapshot.order);
assert_eq!(restored.categories, snapshot.categories);
for file in &restored.files {
let original = &snapshot.files[&file.id];
assert_eq!(
gpx(file, &restored.categories),
gpx(original, &snapshot.categories)
);
// identities that the rest of the engine refers to are kept
assert_eq!(file.trk[0].id, original.trk[0].id);
assert_eq!(file.trk[0].trkseg[0].id, original.trk[0].trkseg[0].id);
assert_eq!(
file.trk[0].trkseg[0].chunks()[0].id,
original.trk[0].trkseg[0].chunks()[0].id
);
}
// what was just read is saved
let again = Snapshot {
files: restored.files.iter().map(|f| (f.id, f.clone())).collect(),
order: restored.order,
categories: restored.categories,
};
block_on(persister.save(&storage, again)).unwrap();
assert_eq!(storage.batches.borrow().len(), 1);
}
#[test]
fn test_nothing_is_written_when_nothing_changed() {
let storage = Recording::default();
let snapshot = snapshot_of(&FILES);
let mut persister = Persister::default();
block_on(persister.save(&storage, snapshot.clone())).unwrap();
block_on(persister.save(&storage, snapshot)).unwrap();
assert_eq!(storage.batches.borrow().len(), 1);
}
#[test]
fn test_an_edit_writes_what_it_changed_only() {
let storage = Recording::default();
let mut snapshot = snapshot_of(&FILES);
let mut persister = Persister::default();
block_on(persister.save(&storage, snapshot.clone())).unwrap();
let counts = storage.inner.counts();
// renaming a file writes its record, not its chunks
let id = snapshot.order[0];
let mut file = (*snapshot.files[&id]).clone();
file.info.name = "renamed".into();
snapshot.files.insert(id, Rc::new(file));
block_on(persister.save(&storage, snapshot.clone())).unwrap();
{
let batches = storage.batches.borrow();
let batch = batches.last().unwrap();
assert_eq!(batch.put_files.len(), 1);
assert!(batch.put_trackpoint_chunks.is_empty());
assert!(batch.put_waypoint_chunks.is_empty());
assert!(batch.delete_trackpoint_chunks.is_empty());
assert!(batch.order.is_none() && batch.categories.is_none());
}
assert_eq!(storage.inner.counts(), counts);
// changing a trackpoint replaces the chunk that holds it only
let mut file = (*snapshot.files[&id]).clone();
let chunks_before = file
.trk
.iter()
.flat_map(|t| &t.trkseg)
.map(|s| s.chunks().len())
.sum::<usize>();
file.trk[0].trkseg[0].update(0, |point| point.ele += 1.0);
snapshot.files.insert(id, Rc::new(file));
block_on(persister.save(&storage, snapshot.clone())).unwrap();
{
let batches = storage.batches.borrow();
let batch = batches.last().unwrap();
assert_eq!(batch.put_files.len(), 1);
assert_eq!(batch.put_trackpoint_chunks.len(), 1);
assert_eq!(batch.delete_trackpoint_chunks.len(), 1);
}
assert_eq!(storage.inner.counts(), counts);
assert!(chunks_before >= 1);
// a deleted file takes its chunks and its record away
snapshot.files.remove(&id);
snapshot.order.retain(|other| *other != id);
block_on(persister.save(&storage, snapshot)).unwrap();
let (files, trackpoint_chunks, _) = storage.inner.counts();
assert_eq!(files, counts.0 - 1);
assert!(trackpoint_chunks < counts.1);
}
#[test]
fn test_chunks_shared_by_files_are_stored_once_and_stay_shared() {
let storage = Recording::default();
let mut snapshot = snapshot_of(&["data/with_time.gpx"]);
let original = snapshot.files[&snapshot.order[0]].clone();
let mut copy = (*original).clone();
copy.id = FileId::default();
snapshot.order.push(copy.id);
snapshot.files.insert(copy.id, Rc::new(copy));
let mut persister = Persister::default();
block_on(persister.save(&storage, snapshot)).unwrap();
let chunks = original.trk[0].trkseg[0].chunks().len();
assert_eq!(storage.inner.counts().1, chunks);
let restored = block_on(Persister::default().restore(&storage)).unwrap();
assert_eq!(restored.files.len(), 2);
assert!(Rc::ptr_eq(
&restored.files[0].trk[0].trkseg[0].chunks()[0],
&restored.files[1].trk[0].trkseg[0].chunks()[0]
));
}
#[test]
fn test_unreadable_files_are_skipped_and_dropped_at_the_next_save() {
let storage = Recording::default();
let snapshot = snapshot_of(&FILES);
let mut persister = Persister::default();
block_on(persister.save(&storage, snapshot.clone())).unwrap();
// damage a record, and lose a chunk of another file
let broken = snapshot.order[0];
block_on(storage.inner.commit(&Batch {
put_files: vec![(broken, vec![99, 1, 2])],
..Default::default()
}))
.unwrap();
let mut persister = Persister::default();
let restored = block_on(persister.restore(&storage)).unwrap();
assert_eq!(restored.unreadable, 1);
assert_eq!(restored.files.len(), FILES.len() - 1);
assert!(restored.files.iter().all(|file| file.id != broken));
let kept = Snapshot {
files: restored.files.iter().map(|f| (f.id, f.clone())).collect(),
order: restored.order,
categories: restored.categories,
};
block_on(persister.save(&storage, kept)).unwrap();
assert_eq!(storage.inner.counts().0, FILES.len() - 1);
}
#[test]
fn test_an_engine_starts_from_what_is_restored() {
let storage = Recording::default();
let mut engine = Engine::default();
for path in FILES {
let data = std::fs::read(path).unwrap();
assert!(engine.execute(Action::Edit(Command::Load(Load {
data: &data,
name: "file",
}))));
}
let snapshot = engine.snapshot();
assert_eq!(snapshot.files.len(), FILES.len());
block_on(Persister::default().save(&storage, snapshot.clone())).unwrap();
let restored = block_on(Persister::default().restore(&storage)).unwrap();
let mut engine = Engine::default();
engine.restore(restored);
assert_eq!(engine.snapshot().order, snapshot.order);
let diff = engine.last_diff().unwrap();
assert_eq!(diff.added.len(), FILES.len());
assert!(engine.order_changed());
for id in &snapshot.order {
assert!(engine.file_structure(id).is_some());
}
// the files that were restored are not undone
assert!(!engine.can_undo());
assert!(!engine.execute(Action::Undo));
let data = std::fs::read(FILES[0]).unwrap();
assert!(engine.execute(Action::Edit(Command::Load(Load {
data: &data,
name: "file",
}))));
assert!(engine.can_undo());
assert!(engine.execute(Action::Undo));
assert_eq!(engine.snapshot().files.len(), FILES.len());
assert!(!engine.can_undo());
}
}
+2
View File
@@ -9,6 +9,8 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
console_error_panic_hook = "0.1.7"
gpx-engine = { path = "../engine" }
idb = "0.6.5"
js-sys = "0.3.106"
uuid = { version = "1.26.1", features = ["js"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4.79"
+148 -14
View File
@@ -9,11 +9,17 @@
//! - Ids read from the file tree are hyphenated UUID strings; the functions reading buffers take
//! them as such.
//! - Every function returns `false` when its arguments are invalid or the command did nothing.
use std::cell::RefCell;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use gpx_engine::{self as engine, Action, Command, Engine, FileId, LngLat, LngLatBounds};
mod storage;
use storage::IdbStorage;
use gpx_engine::{
self as engine, Action, Command, Engine, FileId, LngLat, LngLatBounds, Persister, Storage,
};
use js_sys::{Array, BigInt64Array, Float64Array, Object, Reflect, Uint8Array, Uint32Array};
#[wasm_bindgen]
@@ -92,12 +98,18 @@ thread_local! {
}
fn execute(action: Action) -> bool {
ENGINE.with(|engine| {
engine
.borrow_mut()
.as_mut()
.is_some_and(|engine| engine.execute(action))
})
let (changed, persist) = ENGINE.with(|engine| match engine.borrow_mut().as_mut() {
Some(engine) => {
let changed = engine.execute(action);
let persist = changed && (engine.last_diff().is_some() || engine.order_changed());
(changed, persist)
}
None => (false, false),
});
if persist {
schedule_save();
}
changed
}
fn edit(command: Command) -> bool {
@@ -131,6 +143,133 @@ pub fn start() {
});
}
// 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);
}
fn storage() -> Option<Rc<IdbStorage>> {
STORAGE.with(|storage| storage.borrow().clone())
}
fn schedule_save() {
if storage().is_none() {
return;
}
DIRTY.set(true);
if SAVING.replace(true) {
// the running loop will go around again
return;
}
wasm_bindgen_futures::spawn_local(async {
while DIRTY.replace(false) {
let Some(storage) = storage() else { break };
let Some(snapshot) = with_engine(Engine::snapshot) else {
break;
};
let mut persister = PERSISTER
.with(|persister| persister.borrow_mut().take())
.unwrap_or_default();
let result = persister.save(&*storage, snapshot).await;
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
if let Err(error) = result {
// what failed is written with the next change
console_error(&error.to_string());
}
}
SAVING.set(false);
for resolve in WAITERS.with(|waiters| std::mem::take(&mut *waiters.borrow_mut())) {
let _ = resolve.call0(&JsValue::UNDEFINED);
}
});
}
/// Opens the database `name` (it is created if it does not exist), puts the files that it holds
/// in the engine, which has to be empty, and returns the settings: an object of JSON strings by
/// key. The changes of the files that follow are saved. Call `last_update` afterwards to learn
/// about the files.
#[wasm_bindgen]
pub async fn open_storage(name: &str) -> Result<Object, 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 settings = storage.load_settings().await.map_err(to_js_error)?;
ENGINE.with(|engine| {
if let Some(engine) = engine.borrow_mut().as_mut() {
engine.restore(restored);
}
});
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
STORAGE.with(|slot| *slot.borrow_mut() = Some(Rc::new(storage)));
let object = Object::new();
for (key, value) in settings {
set(&object, &key, value);
}
Ok(object)
}
fn to_js_error(error: engine::StorageError) -> JsValue {
JsValue::from_str(&error.to_string())
}
/// Resolves when what was changed has been written to the storage.
#[wasm_bindgen]
pub async fn flush_storage() -> Result<(), JsValue> {
if !SAVING.get() {
return Ok(());
}
let promise = js_sys::Promise::new(&mut |resolve, _| {
WAITERS.with(|waiters| waiters.borrow_mut().push(resolve));
});
wasm_bindgen_futures::JsFuture::from(promise).await?;
Ok(())
}
/// Saves a setting, `value` being its JSON.
#[wasm_bindgen]
pub fn set_setting(key: String, value: String) {
if let Some(storage) = storage() {
wasm_bindgen_futures::spawn_local(async move {
if let Err(error) = storage.put_setting(&key, &value).await {
console_error(&error.to_string());
}
});
}
}
#[wasm_bindgen]
pub fn delete_setting(key: String) {
if let Some(storage) = storage() {
wasm_bindgen_futures::spawn_local(async move {
if let Err(error) = storage.delete_setting(&key).await {
console_error(&error.to_string());
}
});
}
}
// Statistics buffers
//
// Each getter returns a copy of the buffer, as a new JS typed array.
@@ -1309,12 +1448,7 @@ pub fn exportable_data(file_ids: &[u8]) -> u8 {
};
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,
data.time, data.hr, data.cad, data.atemp, data.power, data.osm,
]
.iter()
.enumerate()
+234
View File
@@ -0,0 +1,234 @@
//! The storage of the engine in the browser: IndexedDB.
//!
//! Values are stored as `Uint8Array`s under string keys (the hyphenated UUIDs), in one object
//! store per kind of data. A batch is a single transaction, so that the stored files always are
//! ones that the engine had.
use gpx_engine::{Batch, FileId, Storage, StorageError, StoredData};
use idb::TransactionMode;
use idb::{Database, DatabaseEvent, Factory, ObjectStore, ObjectStoreParams, Query, Transaction};
use js_sys::Uint8Array;
use uuid::Uuid;
use wasm_bindgen::JsValue;
const FILES: &str = "files";
const TRACKPOINT_CHUNKS: &str = "trackpoint_chunks";
const WAYPOINT_CHUNKS: &str = "waypoint_chunks";
/// The order of the files and the categories of the trackpoints, under their own names.
const META: &str = "meta";
const SETTINGS: &str = "settings";
const STORES: [&str; 5] = [FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META, SETTINGS];
const ORDER_KEY: &str = "order";
const CATEGORIES_KEY: &str = "categories";
pub struct IdbStorage {
database: Database,
}
fn error(error: impl std::fmt::Display) -> StorageError {
StorageError(error.to_string())
}
fn key(id: Uuid) -> JsValue {
JsValue::from_str(&id.to_string())
}
fn bytes(value: &[u8]) -> JsValue {
Uint8Array::from(value).into()
}
fn to_vec(value: &JsValue) -> Vec<u8> {
Uint8Array::new(value).to_vec()
}
fn to_uuid(key: &JsValue) -> Result<Uuid, StorageError> {
key.as_string()
.and_then(|key| Uuid::parse_str(&key).ok())
.ok_or_else(|| error("a key is not an id"))
}
impl IdbStorage {
/// Opens the database, which is created if it does not exist.
pub async fn open(name: &str) -> Result<Self, StorageError> {
let factory = Factory::new().map_err(error)?;
let mut request = factory.open(name, Some(1)).map_err(error)?;
request.on_upgrade_needed(|event| {
if let Ok(database) = event.database() {
for store in STORES {
let _ = database.create_object_store(store, ObjectStoreParams::new());
}
}
});
let database = request.await.map_err(error)?;
Ok(Self { database })
}
fn transaction(
&self,
stores: &[&str],
mode: TransactionMode,
) -> Result<Transaction, StorageError> {
self.database.transaction(stores, mode).map_err(error)
}
/// Everything stored in the object store, as `(key, bytes)`.
async fn read_all(store: &ObjectStore) -> Result<Vec<(JsValue, Vec<u8>)>, StorageError> {
let keys = store.get_all_keys(None, None).map_err(error)?;
let values = store.get_all(None, None).map_err(error)?;
let (keys, values) = (keys.await.map_err(error)?, values.await.map_err(error)?);
Ok(keys.into_iter().zip(values.iter().map(to_vec)).collect())
}
async fn write(&self, transaction: &Transaction, batch: &Batch) -> Result<(), StorageError> {
let files = transaction.object_store(FILES).map_err(error)?;
let trackpoints = transaction.object_store(TRACKPOINT_CHUNKS).map_err(error)?;
let waypoints = transaction.object_store(WAYPOINT_CHUNKS).map_err(error)?;
let meta = transaction.object_store(META).map_err(error)?;
// the requests are all made before waiting for them
let mut puts = vec![];
let mut deletes = vec![];
for (store, chunks) in [
(&trackpoints, &batch.put_trackpoint_chunks),
(&waypoints, &batch.put_waypoint_chunks),
] {
for (id, data) in chunks {
puts.push(store.put(&bytes(data), Some(&key(*id))).map_err(error)?);
}
}
for (FileId(id), data) in &batch.put_files {
puts.push(files.put(&bytes(data), Some(&key(*id))).map_err(error)?);
}
if let Some(order) = &batch.order {
let value = bytes(order);
puts.push(
meta.put(&value, Some(&JsValue::from_str(ORDER_KEY)))
.map_err(error)?,
);
}
if let Some(categories) = &batch.categories {
let value = bytes(categories);
puts.push(
meta.put(&value, Some(&JsValue::from_str(CATEGORIES_KEY)))
.map_err(error)?,
);
}
for FileId(id) in &batch.delete_files {
deletes.push(files.delete(Query::Key(key(*id))).map_err(error)?);
}
for (store, ids) in [
(&trackpoints, &batch.delete_trackpoint_chunks),
(&waypoints, &batch.delete_waypoint_chunks),
] {
for id in ids {
deletes.push(store.delete(Query::Key(key(*id))).map_err(error)?);
}
}
for put in puts {
put.await.map_err(error)?;
}
for delete in deletes {
delete.await.map_err(error)?;
}
Ok(())
}
}
impl Storage for IdbStorage {
async fn load(&self) -> Result<StoredData, StorageError> {
let transaction = self.transaction(
&[FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META],
TransactionMode::ReadOnly,
)?;
let store = |name| transaction.object_store(name).map_err(error);
let files = Self::read_all(&store(FILES)?).await?;
let trackpoint_chunks = Self::read_all(&store(TRACKPOINT_CHUNKS)?).await?;
let waypoint_chunks = Self::read_all(&store(WAYPOINT_CHUNKS)?).await?;
let meta = store(META)?;
let order = meta
.get(JsValue::from_str(ORDER_KEY))
.map_err(error)?
.await
.map_err(error)?;
let categories = meta
.get(JsValue::from_str(CATEGORIES_KEY))
.map_err(error)?
.await
.map_err(error)?;
transaction.await.map_err(error)?;
let ids = |records: Vec<(JsValue, Vec<u8>)>| -> Result<Vec<(Uuid, Vec<u8>)>, StorageError> {
records
.into_iter()
.map(|(k, v)| Ok((to_uuid(&k)?, v)))
.collect()
};
Ok(StoredData {
files: ids(files)?
.into_iter()
.map(|(id, data)| (FileId(id), data))
.collect(),
trackpoint_chunks: ids(trackpoint_chunks)?,
waypoint_chunks: ids(waypoint_chunks)?,
order: order.as_ref().map(to_vec),
categories: categories.as_ref().map(to_vec),
})
}
async fn commit(&self, batch: &Batch) -> Result<(), StorageError> {
let transaction = self.transaction(
&[FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META],
TransactionMode::ReadWrite,
)?;
match self.write(&transaction, batch).await {
Ok(()) => {
transaction.commit().map_err(error)?.await.map_err(error)?;
Ok(())
}
Err(err) => {
// nothing of the batch is kept
let _ = transaction.abort();
Err(err)
}
}
}
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
let transaction = self.transaction(&[SETTINGS], TransactionMode::ReadOnly)?;
let store = transaction.object_store(SETTINGS).map_err(error)?;
let keys = store.get_all_keys(None, None).map_err(error)?;
let values = store.get_all(None, None).map_err(error)?;
let (keys, values) = (keys.await.map_err(error)?, values.await.map_err(error)?);
transaction.await.map_err(error)?;
Ok(keys
.into_iter()
.zip(values)
.filter_map(|(key, value)| Some((key.as_string()?, value.as_string()?)))
.collect())
}
async fn put_setting(&self, key: &str, value: &str) -> Result<(), StorageError> {
let transaction = self.transaction(&[SETTINGS], TransactionMode::ReadWrite)?;
let store = transaction.object_store(SETTINGS).map_err(error)?;
store
.put(&JsValue::from_str(value), Some(&JsValue::from_str(key)))
.map_err(error)?
.await
.map_err(error)?;
transaction.commit().map_err(error)?.await.map_err(error)?;
Ok(())
}
async fn delete_setting(&self, key: &str) -> Result<(), StorageError> {
let transaction = self.transaction(&[SETTINGS], TransactionMode::ReadWrite)?;
let store = transaction.object_store(SETTINGS).map_err(error)?;
store
.delete(Query::Key(JsValue::from_str(key)))
.map_err(error)?
.await
.map_err(error)?;
transaction.commit().map_err(error)?.await.map_err(error)?;
Ok(())
}
}