mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-10 07:36:29 +00:00
progress
This commit is contained in:
1 parent
b085a1d476
commit
42a55e6759
51 files changed
+5756
-3854
No files matched your search
@@ -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
|
||||
/// 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
|
||||
/// 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>);
|
||||
|
||||
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.
|
||||
pub const CAPACITY: usize = u8::MAX as usize;
|
||||
|
||||
@@ -36,7 +43,7 @@ impl Categories {
|
||||
|
||||
/// The categories of the data of the trackpoints. There is one table per engine, shared by all
|
||||
/// its files.
|
||||
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct TrackpointCategories {
|
||||
pub surface: Categories,
|
||||
pub highway: Categories,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
macro_rules! uuid_id {
|
||||
($(#[$meta:meta])* $name:ident) => {
|
||||
$(#[$meta])*
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct $name(pub uuid::Uuid);
|
||||
|
||||
impl Default for $name {
|
||||
@@ -15,13 +15,13 @@ macro_rules! uuid_id {
|
||||
|
||||
pub(crate) use uuid_id;
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct Link {
|
||||
pub href: String,
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct LngLat {
|
||||
pub lng: f64,
|
||||
pub lat: f64,
|
||||
|
||||
@@ -12,7 +12,7 @@ pub struct File {
|
||||
pub wpt: Waypoints,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct FileInfo {
|
||||
pub name: String,
|
||||
pub desc: Option<String>,
|
||||
@@ -21,7 +21,7 @@ pub struct FileInfo {
|
||||
pub time: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct Author {
|
||||
pub name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
|
||||
@@ -11,7 +11,7 @@ pub struct Track {
|
||||
pub trkseg: Vec<TrackSegment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct TrackInfo {
|
||||
pub name: Option<String>,
|
||||
pub cmt: Option<String>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::LngLat;
|
||||
|
||||
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Trackpoint {
|
||||
pub coordinates: LngLat,
|
||||
pub ele: f64,
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::common::uuid_id;
|
||||
|
||||
uuid_id!(WaypointId);
|
||||
|
||||
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Waypoint {
|
||||
pub id: WaypointId,
|
||||
pub coordinates: LngLat,
|
||||
|
||||
+16
-19
@@ -69,18 +69,18 @@ impl Statistics {
|
||||
|
||||
self.global.total_distance += dist;
|
||||
|
||||
if let Some(time) = time {
|
||||
let speed = speed(dist, time);
|
||||
if (0.5..=1500.0).contains(&speed) {
|
||||
self.global.moving_distance = self
|
||||
.global
|
||||
.moving_distance
|
||||
.map_or(Some(dist), |d| Some(d + dist));
|
||||
self.global.moving_time = self
|
||||
.global
|
||||
.moving_time
|
||||
.map_or(Some(time), |t| Some(t + time));
|
||||
}
|
||||
let moving = time
|
||||
.zip(time.and_then(|time| speed(dist, time)))
|
||||
.filter(|(_, speed)| (0.5..=1500.0).contains(speed));
|
||||
if let Some((time, _)) = moving {
|
||||
self.global.moving_distance = self
|
||||
.global
|
||||
.moving_distance
|
||||
.map_or(Some(dist), |d| Some(d + dist));
|
||||
self.global.moving_time = self
|
||||
.global
|
||||
.moving_time
|
||||
.map_or(Some(time), |t| Some(t + time));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,8 +119,7 @@ impl Statistics {
|
||||
};
|
||||
let (left, right) = (timed(left), timed(right));
|
||||
self.local[i.flat].speed = time_diff(trkseg[right].time, trkseg[left].time)
|
||||
.filter(|time| *time > 0)
|
||||
.map(|time| {
|
||||
.and_then(|time| {
|
||||
speed(
|
||||
self.local[right.flat].total_distance
|
||||
- self.local[left.flat].total_distance,
|
||||
@@ -286,16 +285,14 @@ impl GlobalStatistics {
|
||||
/// Average speed over the total time, unknown if that is not a positive duration (for
|
||||
/// example a range with a single timestamp, or timestamps going backwards).
|
||||
pub fn total_speed(&self) -> Option<f64> {
|
||||
self.total_time
|
||||
.filter(|t| *t > 0)
|
||||
.map(|t| speed(self.total_distance, t))
|
||||
self.total_time.and_then(|t| speed(self.total_distance, t))
|
||||
}
|
||||
|
||||
/// Average speed over the moving time, unknown if there is no moving time.
|
||||
pub fn moving_speed(&self) -> Option<f64> {
|
||||
self.moving_distance
|
||||
.zip(self.moving_time.filter(|t| *t > 0))
|
||||
.map(|(d, t)| speed(d, t))
|
||||
.zip(self.moving_time)
|
||||
.and_then(|(d, t)| speed(d, t))
|
||||
}
|
||||
|
||||
pub fn merge(&mut self, other: &GlobalStatistics) {
|
||||
@@ -1,5 +1,5 @@
|
||||
mod statistics;
|
||||
mod compute;
|
||||
mod utils;
|
||||
|
||||
pub use statistics::*;
|
||||
pub use compute::*;
|
||||
pub use utils::*;
|
||||
@@ -22,9 +22,10 @@ pub fn time_diff(a: Option<i64>, b: Option<i64>) -> Option<i64> {
|
||||
Some(a? - b?)
|
||||
}
|
||||
|
||||
/// Computes the speed for a given distance in kilometers and a time in milliseconds
|
||||
pub fn speed(distance: f64, time: i64) -> f64 {
|
||||
distance / (time as f64 / 3_600_000.0)
|
||||
/// Computes the speed in km/h for a given distance in kilometers and a time in milliseconds.
|
||||
/// Unknown if the time is not a positive duration.
|
||||
pub fn speed(distance: f64, time: i64) -> Option<f64> {
|
||||
(time > 0).then(|| distance / (time as f64 / 3_600_000.0))
|
||||
}
|
||||
|
||||
pub fn slope(ele: f64, distance: f64) -> f64 {
|
||||
@@ -125,7 +126,7 @@ mod tests {
|
||||
assert_eq!(time_diff(None, Some(1)), None);
|
||||
assert_eq!(time_diff(Some(1), None), None);
|
||||
// 1 km in one hour
|
||||
assert!((speed(1.0, 3_600_000) - 1.0).abs() < 1e-12);
|
||||
assert!((speed(1.0, 3_600_000).unwrap() - 1.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -154,9 +155,13 @@ mod tests {
|
||||
#[test]
|
||||
fn test_speed() {
|
||||
// 1 km in 1 h
|
||||
assert!((speed(1.0, 3_600_000) - 1.0).abs() < 1e-12);
|
||||
assert!((speed(1.0, 3_600_000).unwrap() - 1.0).abs() < 1e-12);
|
||||
// 10 km in 30 min
|
||||
assert!((speed(10.0, 1_800_000) - 20.0).abs() < 1e-12);
|
||||
assert!((speed(10.0, 1_800_000).unwrap() - 20.0).abs() < 1e-12);
|
||||
// no duration, no speed
|
||||
assert_eq!(speed(1.0, 0), None);
|
||||
assert_eq!(speed(0.0, 0), None);
|
||||
assert_eq!(speed(1.0, -1000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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 command;
|
||||
mod edit;
|
||||
mod file;
|
||||
#[cfg(test)]
|
||||
@@ -8,8 +7,83 @@ mod pattern;
|
||||
mod tools;
|
||||
|
||||
pub use apply::*;
|
||||
pub use command::*;
|
||||
pub use edit::*;
|
||||
pub use file::*;
|
||||
pub use pattern::*;
|
||||
pub use tools::*;
|
||||
|
||||
use crate::State;
|
||||
|
||||
/// A user action that edits the files. Each variant wraps the command's own struct, whose
|
||||
/// effect is implemented in its own file (see `Apply`).
|
||||
///
|
||||
/// Bulk data (coordinates, file bytes) is borrowed straight from the wasm-bindgen argument
|
||||
/// buffers, so a command is built without copying; the engine only copies what it keeps.
|
||||
#[derive(Debug)]
|
||||
pub enum Command<'a> {
|
||||
New(New<'a>),
|
||||
Load(Load<'a>),
|
||||
LoadFiles(LoadFiles<'a>),
|
||||
Duplicate(Duplicate),
|
||||
Delete(Delete),
|
||||
Paste(Paste),
|
||||
Move(Move),
|
||||
DeleteAll(DeleteAll),
|
||||
DeleteWaypoint(DeleteWaypoint),
|
||||
Metadata(Metadata<'a>),
|
||||
Style(Style<'a>),
|
||||
NewTrack(NewTrack),
|
||||
NewTrackSegment(NewTrackSegment),
|
||||
Reverse(Reverse),
|
||||
RoundTrip(RoundTrip),
|
||||
Route(Route<'a>),
|
||||
NewWaypoint(NewWaypoint<'a>),
|
||||
EditWaypoint(EditWaypoint<'a>),
|
||||
MoveWaypoint(MoveWaypoint),
|
||||
Crop(Crop),
|
||||
Reduce(Reduce),
|
||||
Split(Split),
|
||||
Time(Time),
|
||||
Merge(Merge),
|
||||
Extract(Extract),
|
||||
InsertAnchor(InsertAnchor),
|
||||
Elevation(Elevation<'a>),
|
||||
Clean(Clean),
|
||||
ChangeLoopStart(ChangeLoopStart),
|
||||
}
|
||||
|
||||
impl Apply for Command<'_> {
|
||||
fn apply(self, state: &mut State) -> Result<(), CommandError> {
|
||||
match self {
|
||||
Command::New(c) => c.apply(state),
|
||||
Command::Load(c) => c.apply(state),
|
||||
Command::LoadFiles(c) => c.apply(state),
|
||||
Command::Duplicate(c) => c.apply(state),
|
||||
Command::Delete(c) => c.apply(state),
|
||||
Command::Paste(c) => c.apply(state),
|
||||
Command::Move(c) => c.apply(state),
|
||||
Command::DeleteAll(c) => c.apply(state),
|
||||
Command::DeleteWaypoint(c) => c.apply(state),
|
||||
Command::Metadata(c) => c.apply(state),
|
||||
Command::Style(c) => c.apply(state),
|
||||
Command::NewTrack(c) => c.apply(state),
|
||||
Command::NewTrackSegment(c) => c.apply(state),
|
||||
Command::Reverse(c) => c.apply(state),
|
||||
Command::RoundTrip(c) => c.apply(state),
|
||||
Command::Route(c) => c.apply(state),
|
||||
Command::NewWaypoint(c) => c.apply(state),
|
||||
Command::EditWaypoint(c) => c.apply(state),
|
||||
Command::MoveWaypoint(c) => c.apply(state),
|
||||
Command::Crop(c) => c.apply(state),
|
||||
Command::Reduce(c) => c.apply(state),
|
||||
Command::Split(c) => c.apply(state),
|
||||
Command::Time(c) => c.apply(state),
|
||||
Command::Merge(c) => c.apply(state),
|
||||
Command::Extract(c) => c.apply(state),
|
||||
Command::InsertAnchor(c) => c.apply(state),
|
||||
Command::Elevation(c) => c.apply(state),
|
||||
Command::Clean(c) => c.apply(state),
|
||||
Command::ChangeLoopStart(c) => c.apply(state),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::{
|
||||
Apply, CommandError, SegmentLocation, State, Statistics, TrackSegment, Trackpoint,
|
||||
artificial_weights, shifted_and_compressed, update_segments, with_artificial_timestamps,
|
||||
with_timestamps,
|
||||
Apply, CommandError, State, TrackSegment, artificial_segment_times, changed_segment_times,
|
||||
update_segments,
|
||||
};
|
||||
|
||||
/// How the timestamps of the selection are set.
|
||||
@@ -46,80 +45,22 @@ impl Apply for Time {
|
||||
let locations = state
|
||||
.selection
|
||||
.segment_locations(state.files, &state.order.0);
|
||||
let segment_at = |location: &SegmentLocation| -> &TrackSegment {
|
||||
&state.files[&location.file_id].trk[location.trk].trkseg[location.seg]
|
||||
};
|
||||
if locations
|
||||
let segments: Vec<&TrackSegment> = locations
|
||||
.iter()
|
||||
.all(|location| segment_at(location).is_empty())
|
||||
{
|
||||
.map(|l| &state.files[&l.file_id].trk[l.trk].trkseg[l.seg])
|
||||
.collect();
|
||||
if segments.iter().all(|segment| segment.is_empty()) {
|
||||
return Err(CommandError::NothingToDo);
|
||||
}
|
||||
|
||||
// the new timestamps of each segment
|
||||
let mut times: Vec<Vec<Option<i64>>> = Vec::with_capacity(locations.len());
|
||||
match self.kind {
|
||||
let times = match self.kind {
|
||||
TimeKind::Change { speed, ratio } => {
|
||||
let mut last: Option<Trackpoint> = None;
|
||||
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());
|
||||
}
|
||||
changed_segment_times(&segments, self.start_time, speed, ratio)
|
||||
}
|
||||
TimeKind::Artificial { total_time } => {
|
||||
let mut weights: Vec<Vec<f64>> = locations
|
||||
.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());
|
||||
}
|
||||
artificial_segment_times(&segments, self.start_time, total_time)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
update_segments(state, &locations, |i, segment| {
|
||||
let times = ×[i];
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,11 +1,372 @@
|
||||
mod action;
|
||||
mod command;
|
||||
mod derived;
|
||||
mod engine;
|
||||
mod outcome;
|
||||
mod state;
|
||||
|
||||
pub use action::*;
|
||||
pub use command::*;
|
||||
pub use derived::*;
|
||||
pub use engine::*;
|
||||
pub use outcome::*;
|
||||
pub use state::*;
|
||||
|
||||
use crate::{
|
||||
ExportOptions, FileId, GlobalStatistics, Restored, Snapshot, TrackSegmentId, Trackpoint,
|
||||
TrackpointCategories, Waypoint, WaypointId, reduction_distances, write,
|
||||
};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Engine {
|
||||
stack: Stack,
|
||||
selection: Selection,
|
||||
order: FileOrder,
|
||||
statistics_cache: StatisticsCache,
|
||||
coordinates_cache: CoordinatesCache,
|
||||
structure_cache: FileStructureCache,
|
||||
statistics_buffer: StatisticsBuffer,
|
||||
routing_buffer: RoutingBuffer,
|
||||
categories: TrackpointCategories,
|
||||
clipboard: Option<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,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
/// Combines `selection` with this one according to `mode`, ignoring the elements of
|
||||
/// `selection` that do not exist in `files`. Nothing to add or toggle leaves the selection
|
||||
/// as it is, while replacing it with nothing deselects everything.
|
||||
pub fn select(
|
||||
&mut self,
|
||||
files: Option<&StackEntry>,
|
||||
mut selection: Selection,
|
||||
mode: SelectMode,
|
||||
) {
|
||||
match files {
|
||||
Some(files) => selection.retain_existing(files),
|
||||
None => selection = Selection::Empty,
|
||||
}
|
||||
match mode {
|
||||
SelectMode::Replace => *self = selection,
|
||||
_ if selection == Selection::Empty => {}
|
||||
SelectMode::Add => self.extend(selection),
|
||||
SelectMode::Toggle => self.toggle(selection),
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects all the elements of the same kind as the selected ones, see
|
||||
/// [`Selection::all_at_level`]. Does nothing when there is nothing more to select.
|
||||
pub fn select_all(&mut self, files: &StackEntry, order: &[FileId]) {
|
||||
if let Some(all) = self.all_at_level(files, order) {
|
||||
*self = all;
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the selection to the next (`down`) or previous element, see
|
||||
/// [`Selection::neighbour`]. With `add`, the element is added to the selection instead of
|
||||
/// replacing it.
|
||||
pub fn select_neighbour(
|
||||
&mut self,
|
||||
files: &StackEntry,
|
||||
order: &[FileId],
|
||||
down: bool,
|
||||
add: bool,
|
||||
) {
|
||||
if let Some(next) = self.neighbour(files, order, down) {
|
||||
if add {
|
||||
self.extend(next);
|
||||
} else {
|
||||
*self = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
/// The track segments covered by the selection, in order: the segments of several selected
|
||||
/// files follow `order` (the files missing from it come last), then the tracks and segments
|
||||
|
||||
@@ -3,15 +3,15 @@ use std::{
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
use crate::{File, FileId};
|
||||
use crate::{CommandError, File, FileId};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Stack {
|
||||
entries: Vec<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,
|
||||
/// Whether the first entry is the state the stack starts from, which is not undone: files
|
||||
/// restored from the storage.
|
||||
floor: bool,
|
||||
}
|
||||
|
||||
impl Stack {
|
||||
@@ -20,7 +20,7 @@ impl Stack {
|
||||
Self {
|
||||
entries: vec![entry],
|
||||
index: Some(0),
|
||||
restored: true,
|
||||
floor: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,23 +28,40 @@ impl Stack {
|
||||
self.index.map_or_default(|i| self.get(i))
|
||||
}
|
||||
|
||||
pub fn create_and_push_next<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
|
||||
F: FnOnce(&mut StackEntry) -> Result<(), String>,
|
||||
F: FnOnce(&mut StackEntry) -> Result<(), CommandError>,
|
||||
{
|
||||
self.record_diff(|stack| {
|
||||
let mut next = stack.current().map_or_default(|c| c.clone());
|
||||
if f(&mut next).is_ok() {
|
||||
stack.push(next);
|
||||
}
|
||||
})
|
||||
let mut next = self.current().map_or_default(|c| c.clone());
|
||||
f(&mut next)?;
|
||||
if self.is_current(&next) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(self.record_diff(|stack| stack.push(next)))
|
||||
}
|
||||
|
||||
/// Whether `entry` holds exactly the files of the current entry.
|
||||
fn is_current(&self, entry: &StackEntry) -> bool {
|
||||
let current = self.current();
|
||||
let len = current.map_or(0, |current| current.len());
|
||||
entry.len() == len
|
||||
&& current.is_none_or(|current| {
|
||||
entry
|
||||
.iter()
|
||||
.all(|(id, file)| current.get(id).is_some_and(|c| Rc::ptr_eq(c, file)))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) -> Option<Diff> {
|
||||
self.record_diff(|stack| {
|
||||
if let Some(i) = stack.index {
|
||||
if i == 0 {
|
||||
if !stack.restored {
|
||||
if !stack.floor {
|
||||
stack.index = None;
|
||||
}
|
||||
} else {
|
||||
@@ -70,7 +87,7 @@ impl Stack {
|
||||
}
|
||||
|
||||
pub fn can_undo(&self) -> bool {
|
||||
self.index.is_some_and(|i| i > 0 || !self.restored)
|
||||
self.index.is_some_and(|i| i > 0 || !self.floor)
|
||||
}
|
||||
|
||||
pub fn can_redo(&self) -> bool {
|
||||
@@ -137,6 +154,16 @@ pub struct Diff {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
impl Stack {
|
||||
/// A step that is expected not to fail.
|
||||
fn push_ok(
|
||||
&mut self,
|
||||
f: impl FnOnce(&mut StackEntry) -> Result<(), CommandError>,
|
||||
) -> Option<Diff> {
|
||||
self.create_and_push_next(f).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_file() {
|
||||
let mut stack = Stack::default();
|
||||
@@ -145,7 +172,7 @@ mod tests {
|
||||
assert!(!stack.can_redo());
|
||||
assert!(stack.current().is_none());
|
||||
|
||||
let diff = stack.create_and_push_next(|e| {
|
||||
let diff = stack.push_ok(|e| {
|
||||
let file = Rc::new(File::default());
|
||||
e.insert(file.id, file);
|
||||
Ok(())
|
||||
@@ -167,7 +194,7 @@ mod tests {
|
||||
let file = Rc::new(File::default());
|
||||
let id = file.id;
|
||||
stack
|
||||
.create_and_push_next(|e| {
|
||||
.push_ok(|e| {
|
||||
e.insert(id, file);
|
||||
Ok(())
|
||||
})
|
||||
@@ -180,9 +207,9 @@ mod tests {
|
||||
let mut stack = Stack::default();
|
||||
let diff = stack.create_and_push_next(|e| {
|
||||
e.insert(FileId::default(), Rc::new(File::default()));
|
||||
Err("nope".to_string())
|
||||
Err(CommandError::InvalidData("nope".to_string()))
|
||||
});
|
||||
assert!(diff.is_none());
|
||||
assert_eq!(diff, Err(CommandError::InvalidData("nope".to_string())));
|
||||
assert!(!stack.can_undo());
|
||||
assert!(stack.current().is_none());
|
||||
}
|
||||
@@ -217,7 +244,7 @@ mod tests {
|
||||
let id = add_file(&mut stack);
|
||||
|
||||
let diff = stack
|
||||
.create_and_push_next(|e| {
|
||||
.push_ok(|e| {
|
||||
let mut file = (**e.get(&id).unwrap()).clone();
|
||||
file.info.name = "renamed".to_string();
|
||||
e.insert(id, Rc::new(file));
|
||||
@@ -227,12 +254,20 @@ mod tests {
|
||||
assert_eq!(diff.modified, vec![id]);
|
||||
assert!(diff.added.is_empty() && diff.removed.is_empty());
|
||||
|
||||
// an unchanged file is not reported as modified
|
||||
let diff = stack.create_and_push_next(|_| Ok(())).unwrap();
|
||||
assert!(diff.modified.is_empty());
|
||||
// a step that changes nothing is not a step
|
||||
assert!(stack.push_ok(|_| Ok(())).is_none());
|
||||
assert!(
|
||||
stack
|
||||
.push_ok(|e| {
|
||||
let file = e[&id].clone();
|
||||
e.insert(id, file);
|
||||
Ok(())
|
||||
})
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let diff = stack
|
||||
.create_and_push_next(|e| {
|
||||
.push_ok(|e| {
|
||||
e.remove(&id);
|
||||
Ok(())
|
||||
})
|
||||
@@ -253,7 +288,7 @@ mod tests {
|
||||
// a copy, even if it has the same content, is another file for the stack: files are
|
||||
// told apart by their identity, comparing them would read all of them
|
||||
let diff = stack
|
||||
.create_and_push_next(|e| {
|
||||
.push_ok(|e| {
|
||||
let copy = (**e.get(&id).unwrap()).clone();
|
||||
e.insert(id, Rc::new(copy));
|
||||
Ok(())
|
||||
@@ -264,7 +299,7 @@ mod tests {
|
||||
// and the files that were not replaced are not
|
||||
let other = add_file(&mut stack);
|
||||
let diff = stack
|
||||
.create_and_push_next(|e| {
|
||||
.push_ok(|e| {
|
||||
let copy = (**e.get(&other).unwrap()).clone();
|
||||
e.insert(other, Rc::new(copy));
|
||||
Ok(())
|
||||
@@ -361,7 +396,7 @@ mod tests {
|
||||
let mut stack = Stack::restored(entry);
|
||||
|
||||
let diff = stack
|
||||
.create_and_push_next(|e| {
|
||||
.push_ok(|e| {
|
||||
e.remove(&restored);
|
||||
Ok(())
|
||||
})
|
||||
|
||||
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::collections::HashMap;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{Batch, FileId, Storage, StorageError, StoredData};
|
||||
use crate::{Batch, ChunkKey, ChunkKind, FileId, Storage, StorageError, StoredData};
|
||||
|
||||
/// A storage that lives as long as the session. It is what an engine without a host storage uses,
|
||||
/// and what the tests use.
|
||||
@@ -15,22 +13,30 @@ pub struct MemoryStorage {
|
||||
#[derive(Debug, Default)]
|
||||
struct State {
|
||||
files: HashMap<FileId, Vec<u8>>,
|
||||
trackpoint_chunks: HashMap<Uuid, Vec<u8>>,
|
||||
waypoint_chunks: HashMap<Uuid, Vec<u8>>,
|
||||
chunks: HashMap<ChunkKey, Vec<u8>>,
|
||||
order: Option<Vec<u8>>,
|
||||
categories: Option<Vec<u8>>,
|
||||
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 {
|
||||
/// How many files and chunks (trackpoints, waypoints) are stored.
|
||||
pub fn counts(&self) -> (usize, usize, usize) {
|
||||
/// How many files and chunks are stored.
|
||||
pub fn counts(&self) -> StorageCounts {
|
||||
let state = self.state.borrow();
|
||||
(
|
||||
state.files.len(),
|
||||
state.trackpoint_chunks.len(),
|
||||
state.waypoint_chunks.len(),
|
||||
)
|
||||
let chunks = |kind| state.chunks.keys().filter(|key| key.kind == kind).count();
|
||||
StorageCounts {
|
||||
files: state.files.len(),
|
||||
trackpoint_chunks: chunks(ChunkKind::Trackpoints),
|
||||
waypoint_chunks: chunks(ChunkKind::Waypoints),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,16 +45,7 @@ impl Storage for MemoryStorage {
|
||||
let state = self.state.borrow();
|
||||
Ok(StoredData {
|
||||
files: state.files.iter().map(|(k, v)| (*k, v.clone())).collect(),
|
||||
trackpoint_chunks: state
|
||||
.trackpoint_chunks
|
||||
.iter()
|
||||
.map(|(k, v)| (*k, v.clone()))
|
||||
.collect(),
|
||||
waypoint_chunks: state
|
||||
.waypoint_chunks
|
||||
.iter()
|
||||
.map(|(k, v)| (*k, v.clone()))
|
||||
.collect(),
|
||||
chunks: state.chunks.iter().map(|(k, v)| (*k, v.clone())).collect(),
|
||||
order: state.order.clone(),
|
||||
categories: state.categories.clone(),
|
||||
})
|
||||
@@ -60,17 +57,9 @@ impl Storage for MemoryStorage {
|
||||
for id in &batch.delete_files {
|
||||
state.files.remove(id);
|
||||
}
|
||||
state
|
||||
.trackpoint_chunks
|
||||
.extend(batch.put_trackpoint_chunks.iter().cloned());
|
||||
for id in &batch.delete_trackpoint_chunks {
|
||||
state.trackpoint_chunks.remove(id);
|
||||
}
|
||||
state
|
||||
.waypoint_chunks
|
||||
.extend(batch.put_waypoint_chunks.iter().cloned());
|
||||
for id in &batch.delete_waypoint_chunks {
|
||||
state.waypoint_chunks.remove(id);
|
||||
state.chunks.extend(batch.put_chunks.iter().cloned());
|
||||
for key in &batch.delete_chunks {
|
||||
state.chunks.remove(key);
|
||||
}
|
||||
if let Some(order) = &batch.order {
|
||||
state.order = Some(order.clone());
|
||||
@@ -109,6 +98,8 @@ mod tests {
|
||||
use std::future::Future;
|
||||
use std::task::{Context, Poll, Waker};
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn block_on<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]
|
||||
fn test_empty_storage_has_nothing() {
|
||||
let storage = MemoryStorage::default();
|
||||
let data = block_on(storage.load()).unwrap();
|
||||
assert_eq!(data, StoredData::default());
|
||||
assert_eq!(storage.counts(), (0, 0, 0));
|
||||
assert_eq!(storage.counts(), counts(0, 0, 0));
|
||||
assert!(block_on(storage.load_settings()).unwrap().is_empty());
|
||||
}
|
||||
|
||||
@@ -136,20 +135,28 @@ mod tests {
|
||||
let (t, w) = (Uuid::new_v4(), Uuid::new_v4());
|
||||
block_on(storage.commit(&Batch {
|
||||
put_files: vec![(a, vec![1]), (b, vec![2])],
|
||||
put_trackpoint_chunks: vec![(t, vec![3])],
|
||||
put_waypoint_chunks: vec![(w, vec![4])],
|
||||
put_chunks: vec![
|
||||
(ChunkKey::trackpoints(t), vec![3]),
|
||||
(ChunkKey::waypoints(w), vec![4]),
|
||||
],
|
||||
order: Some(vec![5]),
|
||||
categories: Some(vec![6]),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(storage.counts(), (2, 1, 1));
|
||||
assert_eq!(storage.counts(), counts(2, 1, 1));
|
||||
|
||||
let mut data = block_on(storage.load()).unwrap();
|
||||
data.files.sort_by_key(|(_, bytes)| bytes.clone());
|
||||
assert_eq!(data.files, vec![(a, vec![1]), (b, vec![2])]);
|
||||
assert_eq!(data.trackpoint_chunks, vec![(t, vec![3])]);
|
||||
assert_eq!(data.waypoint_chunks, vec![(w, vec![4])]);
|
||||
data.chunks.sort_by_key(|(_, bytes)| bytes.clone());
|
||||
assert_eq!(
|
||||
data.chunks,
|
||||
vec![
|
||||
(ChunkKey::trackpoints(t), vec![3]),
|
||||
(ChunkKey::waypoints(w), vec![4])
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
(data.order, data.categories),
|
||||
(Some(vec![5]), Some(vec![6]))
|
||||
@@ -159,11 +166,11 @@ mod tests {
|
||||
block_on(storage.commit(&Batch {
|
||||
put_files: vec![(a, vec![10])],
|
||||
delete_files: vec![b],
|
||||
delete_trackpoint_chunks: vec![t],
|
||||
delete_chunks: vec![ChunkKey::trackpoints(t)],
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(storage.counts(), (1, 0, 1));
|
||||
assert_eq!(storage.counts(), counts(1, 0, 1));
|
||||
let data = block_on(storage.load()).unwrap();
|
||||
assert_eq!(data.files, vec![(a, vec![10])]);
|
||||
assert_eq!(
|
||||
@@ -172,12 +179,12 @@ mod tests {
|
||||
);
|
||||
|
||||
block_on(storage.commit(&Batch {
|
||||
delete_waypoint_chunks: vec![w],
|
||||
delete_chunks: vec![ChunkKey::waypoints(w)],
|
||||
order: Some(vec![]),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(storage.counts(), (1, 0, 0));
|
||||
assert_eq!(storage.counts(), counts(1, 0, 0));
|
||||
assert_eq!(block_on(storage.load()).unwrap().order, Some(vec![]));
|
||||
}
|
||||
|
||||
|
||||
@@ -30,13 +30,42 @@ impl std::fmt::Display for StorageError {
|
||||
|
||||
impl std::error::Error for StorageError {}
|
||||
|
||||
/// What a chunk holds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ChunkKind {
|
||||
Trackpoints,
|
||||
Waypoints,
|
||||
}
|
||||
|
||||
/// Where a chunk is stored: chunks are told apart by their identity within their kind.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct ChunkKey {
|
||||
pub kind: ChunkKind,
|
||||
pub id: Uuid,
|
||||
}
|
||||
|
||||
impl ChunkKey {
|
||||
pub fn trackpoints(id: Uuid) -> Self {
|
||||
Self {
|
||||
kind: ChunkKind::Trackpoints,
|
||||
id,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn waypoints(id: Uuid) -> Self {
|
||||
Self {
|
||||
kind: ChunkKind::Waypoints,
|
||||
id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything that is stored about the files, as opaque bytes (see the module documentation).
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct StoredData {
|
||||
/// The record of each file.
|
||||
pub files: Vec<(FileId, Vec<u8>)>,
|
||||
pub trackpoint_chunks: Vec<(Uuid, Vec<u8>)>,
|
||||
pub waypoint_chunks: Vec<(Uuid, Vec<u8>)>,
|
||||
pub chunks: Vec<(ChunkKey, Vec<u8>)>,
|
||||
/// The order of the files.
|
||||
pub order: Option<Vec<u8>>,
|
||||
/// The categories of the trackpoints, which their chunks refer to.
|
||||
@@ -49,10 +78,8 @@ pub struct StoredData {
|
||||
pub struct Batch {
|
||||
pub put_files: Vec<(FileId, Vec<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 put_chunks: Vec<(ChunkKey, Vec<u8>)>,
|
||||
pub delete_chunks: Vec<ChunkKey>,
|
||||
pub order: Option<Vec<u8>>,
|
||||
pub categories: Option<Vec<u8>>,
|
||||
}
|
||||
@@ -61,6 +88,22 @@ impl Batch {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self == &Self::default()
|
||||
}
|
||||
|
||||
/// How many chunks of the kind the batch writes.
|
||||
pub fn puts(&self, kind: ChunkKind) -> usize {
|
||||
self.put_chunks
|
||||
.iter()
|
||||
.filter(|(key, _)| key.kind == kind)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// How many chunks of the kind the batch deletes.
|
||||
pub fn deletes(&self, kind: ChunkKind) -> usize {
|
||||
self.delete_chunks
|
||||
.iter()
|
||||
.filter(|key| key.kind == kind)
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the data is kept. The implementation belongs to the host; [`MemoryStorage`] is one that
|
||||
@@ -101,11 +144,11 @@ mod tests {
|
||||
..Default::default()
|
||||
},
|
||||
Batch {
|
||||
delete_trackpoint_chunks: vec![Uuid::new_v4()],
|
||||
put_chunks: vec![(ChunkKey::waypoints(Uuid::new_v4()), vec![])],
|
||||
..Default::default()
|
||||
},
|
||||
Batch {
|
||||
delete_waypoint_chunks: vec![Uuid::new_v4()],
|
||||
delete_chunks: vec![ChunkKey::trackpoints(Uuid::new_v4())],
|
||||
..Default::default()
|
||||
},
|
||||
Batch {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
use std::cell::RefCell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::codec::{
|
||||
FileRecord, decode, decode_trackpoints, decode_waypoints, encode, encode_trackpoints,
|
||||
DecodeError, decode_categories, decode_file, decode_order, decode_trackpoints,
|
||||
decode_waypoints, encode_categories, encode_file, encode_order, encode_trackpoints,
|
||||
encode_waypoints,
|
||||
};
|
||||
use crate::{
|
||||
Batch, File, FileId, StackEntry, Storage, StorageError, TrackpointCategories, TrackpointChunk,
|
||||
WaypointChunk,
|
||||
Batch, ChunkKey, File, FileId, StackEntry, Storage, StorageError, TrackpointCategories,
|
||||
TrackpointChunk, WaypointChunk,
|
||||
};
|
||||
|
||||
/// What there is to keep of the engine: the files of the current state, their order, and the
|
||||
@@ -30,6 +31,10 @@ pub struct Restored {
|
||||
pub categories: TrackpointCategories,
|
||||
/// The files that could not be read: they are dropped at the next save.
|
||||
pub unreadable: usize,
|
||||
/// Whether some of what is stored was written by a newer version, which this one cannot read.
|
||||
/// Nothing is written then, so that the newer version finds its data again: the files that
|
||||
/// could be read are only kept in memory, and the persister does not save.
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
/// Works out what to write to a [`Storage`] so that it holds a [`Snapshot`], knowing what it
|
||||
@@ -44,18 +49,35 @@ pub struct Persister {
|
||||
files: HashMap<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>,
|
||||
chunks: HashSet<ChunkKey>,
|
||||
order: Option<Vec<FileId>>,
|
||||
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.
|
||||
struct Plan {
|
||||
batch: Batch,
|
||||
snapshot: Snapshot,
|
||||
trackpoint_chunks: HashSet<Uuid>,
|
||||
waypoint_chunks: HashSet<Uuid>,
|
||||
chunks: HashSet<ChunkKey>,
|
||||
}
|
||||
|
||||
impl Persister {
|
||||
@@ -64,11 +86,18 @@ impl Persister {
|
||||
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);
|
||||
// what comes from a newer version is not corrupt: it must not be overwritten
|
||||
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 waypoint_bytes: HashMap<Uuid, Vec<u8>> = data.waypoint_chunks.into_iter().collect();
|
||||
let chunk_bytes: HashMap<ChunkKey, Vec<u8>> = data.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();
|
||||
@@ -78,8 +107,8 @@ impl Persister {
|
||||
.borrow_mut()
|
||||
.entry(id.0)
|
||||
.or_insert_with(|| {
|
||||
let bytes = trackpoint_bytes.get(&id.0)?;
|
||||
decode_trackpoints(id.0, bytes).map(Rc::new)
|
||||
let bytes = chunk_bytes.get(&ChunkKey::trackpoints(id.0))?;
|
||||
newer.read(decode_trackpoints(id.0, bytes)).map(Rc::new)
|
||||
})
|
||||
.clone()
|
||||
};
|
||||
@@ -88,8 +117,8 @@ impl Persister {
|
||||
.borrow_mut()
|
||||
.entry(id.0)
|
||||
.or_insert_with(|| {
|
||||
let bytes = waypoint_bytes.get(&id.0)?;
|
||||
decode_waypoints(id.0, bytes).map(Rc::new)
|
||||
let bytes = chunk_bytes.get(&ChunkKey::waypoints(id.0))?;
|
||||
newer.read(decode_waypoints(id.0, bytes)).map(Rc::new)
|
||||
})
|
||||
.clone()
|
||||
};
|
||||
@@ -101,7 +130,8 @@ impl Persister {
|
||||
let mut files: HashMap<FileId, Rc<File>> = HashMap::new();
|
||||
for (id, bytes) in &data.files {
|
||||
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));
|
||||
match file {
|
||||
Some(file) => {
|
||||
@@ -124,10 +154,11 @@ impl Persister {
|
||||
restored.files = restored.order.iter().map(|id| files[id].clone()).collect();
|
||||
|
||||
self.files = files;
|
||||
self.trackpoint_chunks = trackpoint_bytes.into_keys().collect();
|
||||
self.waypoint_chunks = waypoint_bytes.into_keys().collect();
|
||||
self.chunks = chunk_bytes.into_keys().collect();
|
||||
self.order = stored_order;
|
||||
self.categories = categories;
|
||||
self.read_only = newer.0.get();
|
||||
restored.read_only = self.read_only;
|
||||
Ok(restored)
|
||||
}
|
||||
|
||||
@@ -137,6 +168,9 @@ impl Persister {
|
||||
storage: &S,
|
||||
snapshot: Snapshot,
|
||||
) -> Result<(), StorageError> {
|
||||
if self.read_only {
|
||||
return Ok(());
|
||||
}
|
||||
let plan = self.plan(snapshot);
|
||||
if plan.batch.is_empty() {
|
||||
self.files = plan.snapshot.files;
|
||||
@@ -146,8 +180,7 @@ impl Persister {
|
||||
|
||||
self.stored_files = plan.snapshot.files.keys().copied().collect();
|
||||
self.files = plan.snapshot.files;
|
||||
self.trackpoint_chunks = plan.trackpoint_chunks;
|
||||
self.waypoint_chunks = plan.waypoint_chunks;
|
||||
self.chunks = plan.chunks;
|
||||
self.order = Some(plan.snapshot.order);
|
||||
self.categories = Some(plan.snapshot.categories);
|
||||
Ok(())
|
||||
@@ -156,8 +189,7 @@ impl Persister {
|
||||
fn plan(&self, snapshot: Snapshot) -> Plan {
|
||||
let mut batch = Batch::default();
|
||||
// the chunks that the files use, which the storage has afterwards
|
||||
let mut trackpoint_chunks = HashSet::new();
|
||||
let mut waypoint_chunks = HashSet::new();
|
||||
let mut chunks = HashSet::new();
|
||||
|
||||
for (id, file) in &snapshot.files {
|
||||
let changed = self
|
||||
@@ -166,23 +198,19 @@ impl Persister {
|
||||
.is_none_or(|saved| !Rc::ptr_eq(saved, file));
|
||||
let segments = file.trk.iter().flat_map(|track| &track.trkseg);
|
||||
for chunk in segments.flat_map(|segment| segment.chunks()) {
|
||||
let new = trackpoint_chunks.insert(chunk.id.0);
|
||||
if changed && new && !self.trackpoint_chunks.contains(&chunk.id.0) {
|
||||
batch
|
||||
.put_trackpoint_chunks
|
||||
.push((chunk.id.0, encode_trackpoints(chunk)));
|
||||
let key = ChunkKey::trackpoints(chunk.id.0);
|
||||
if chunks.insert(key) && changed && !self.chunks.contains(&key) {
|
||||
batch.put_chunks.push((key, encode_trackpoints(chunk)));
|
||||
}
|
||||
}
|
||||
for chunk in file.wpt.chunks() {
|
||||
let new = waypoint_chunks.insert(chunk.id.0);
|
||||
if changed && new && !self.waypoint_chunks.contains(&chunk.id.0) {
|
||||
batch
|
||||
.put_waypoint_chunks
|
||||
.push((chunk.id.0, encode_waypoints(chunk)));
|
||||
let key = ChunkKey::waypoints(chunk.id.0);
|
||||
if chunks.insert(key) && changed && !self.chunks.contains(&key) {
|
||||
batch.put_chunks.push((key, encode_waypoints(chunk)));
|
||||
}
|
||||
}
|
||||
if changed || !self.stored_files.contains(id) {
|
||||
batch.put_files.push((*id, encode(&FileRecord::new(file))));
|
||||
batch.put_files.push((*id, encode_file(file)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,28 +220,18 @@ impl Persister {
|
||||
.filter(|id| !snapshot.files.contains_key(id))
|
||||
.copied()
|
||||
.collect();
|
||||
batch.delete_trackpoint_chunks = self
|
||||
.trackpoint_chunks
|
||||
.difference(&trackpoint_chunks)
|
||||
.copied()
|
||||
.collect();
|
||||
batch.delete_waypoint_chunks = self
|
||||
.waypoint_chunks
|
||||
.difference(&waypoint_chunks)
|
||||
.copied()
|
||||
.collect();
|
||||
batch.delete_chunks = self.chunks.difference(&chunks).copied().collect();
|
||||
if self.order.as_ref() != Some(&snapshot.order) {
|
||||
batch.order = Some(encode(&snapshot.order));
|
||||
batch.order = Some(encode_order(&snapshot.order));
|
||||
}
|
||||
if self.categories.as_ref() != Some(&snapshot.categories) {
|
||||
batch.categories = Some(encode(&snapshot.categories));
|
||||
batch.categories = Some(encode_categories(&snapshot.categories));
|
||||
}
|
||||
|
||||
Plan {
|
||||
batch,
|
||||
snapshot,
|
||||
trackpoint_chunks,
|
||||
waypoint_chunks,
|
||||
chunks,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,7 +243,8 @@ mod tests {
|
||||
use std::task::{Context, Poll, Waker};
|
||||
|
||||
use crate::{
|
||||
Action, Command, Engine, ExportOptions, Load, MemoryStorage, StoredData, parse, write,
|
||||
Action, ChunkKind, Command, Engine, ExportOptions, Load, MemoryStorage, StoredData, parse,
|
||||
write,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
@@ -354,9 +373,8 @@ mod tests {
|
||||
let batches = storage.batches.borrow();
|
||||
let batch = batches.last().unwrap();
|
||||
assert_eq!(batch.put_files.len(), 1);
|
||||
assert!(batch.put_trackpoint_chunks.is_empty());
|
||||
assert!(batch.put_waypoint_chunks.is_empty());
|
||||
assert!(batch.delete_trackpoint_chunks.is_empty());
|
||||
assert!(batch.put_chunks.is_empty());
|
||||
assert!(batch.delete_chunks.is_empty());
|
||||
assert!(batch.order.is_none() && batch.categories.is_none());
|
||||
}
|
||||
assert_eq!(storage.inner.counts(), counts);
|
||||
@@ -376,8 +394,9 @@ mod tests {
|
||||
let batches = storage.batches.borrow();
|
||||
let batch = batches.last().unwrap();
|
||||
assert_eq!(batch.put_files.len(), 1);
|
||||
assert_eq!(batch.put_trackpoint_chunks.len(), 1);
|
||||
assert_eq!(batch.delete_trackpoint_chunks.len(), 1);
|
||||
assert_eq!(batch.puts(ChunkKind::Trackpoints), 1);
|
||||
assert_eq!(batch.deletes(ChunkKind::Trackpoints), 1);
|
||||
assert_eq!(batch.puts(ChunkKind::Waypoints), 0);
|
||||
}
|
||||
assert_eq!(storage.inner.counts(), counts);
|
||||
assert!(chunks_before >= 1);
|
||||
@@ -386,9 +405,9 @@ mod tests {
|
||||
snapshot.files.remove(&id);
|
||||
snapshot.order.retain(|other| *other != id);
|
||||
block_on(persister.save(&storage, snapshot)).unwrap();
|
||||
let (files, trackpoint_chunks, _) = storage.inner.counts();
|
||||
assert_eq!(files, counts.0 - 1);
|
||||
assert!(trackpoint_chunks < counts.1);
|
||||
let after = storage.inner.counts();
|
||||
assert_eq!(after.files, counts.files - 1);
|
||||
assert!(after.trackpoint_chunks < counts.trackpoint_chunks);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -404,7 +423,7 @@ mod tests {
|
||||
let mut persister = Persister::default();
|
||||
block_on(persister.save(&storage, snapshot)).unwrap();
|
||||
let chunks = original.trk[0].trkseg[0].chunks().len();
|
||||
assert_eq!(storage.inner.counts().1, chunks);
|
||||
assert_eq!(storage.inner.counts().trackpoint_chunks, chunks);
|
||||
|
||||
let restored = block_on(Persister::default().restore(&storage)).unwrap();
|
||||
assert_eq!(restored.files.len(), 2);
|
||||
@@ -424,13 +443,14 @@ mod tests {
|
||||
// damage a record, and lose a chunk of another file
|
||||
let broken = snapshot.order[0];
|
||||
block_on(storage.inner.commit(&Batch {
|
||||
put_files: vec![(broken, vec![99, 1, 2])],
|
||||
put_files: vec![(broken, vec![super::super::codec::LATEST, 0xff, 0xff])],
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
let mut persister = Persister::default();
|
||||
let restored = block_on(persister.restore(&storage)).unwrap();
|
||||
assert_eq!(restored.unreadable, 1);
|
||||
assert!(!restored.read_only);
|
||||
assert_eq!(restored.files.len(), FILES.len() - 1);
|
||||
assert!(restored.files.iter().all(|file| file.id != broken));
|
||||
|
||||
@@ -440,7 +460,77 @@ mod tests {
|
||||
categories: restored.categories,
|
||||
};
|
||||
block_on(persister.save(&storage, kept)).unwrap();
|
||||
assert_eq!(storage.inner.counts().0, FILES.len() - 1);
|
||||
assert_eq!(storage.inner.counts().files, FILES.len() - 1);
|
||||
}
|
||||
|
||||
/// A storage holding the files, with one blob replaced by `damage`.
|
||||
fn stored_with(damage: impl FnOnce(&Snapshot, &mut Batch)) -> (Recording, Snapshot) {
|
||||
let storage = Recording::default();
|
||||
let snapshot = snapshot_of(&FILES);
|
||||
block_on(Persister::default().save(&storage, snapshot.clone())).unwrap();
|
||||
let mut batch = Batch::default();
|
||||
damage(&snapshot, &mut batch);
|
||||
block_on(storage.inner.commit(&batch)).unwrap();
|
||||
storage.batches.borrow_mut().clear();
|
||||
(storage, snapshot)
|
||||
}
|
||||
|
||||
fn from_the_future() -> Vec<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]
|
||||
@@ -449,7 +539,7 @@ mod tests {
|
||||
let mut engine = Engine::default();
|
||||
for path in FILES {
|
||||
let data = std::fs::read(path).unwrap();
|
||||
assert!(engine.execute(Action::Edit(Command::Load(Load {
|
||||
assert!(engine.run(Action::Edit(Command::Load(Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}))));
|
||||
@@ -471,14 +561,14 @@ mod tests {
|
||||
|
||||
// the files that were restored are not undone
|
||||
assert!(!engine.can_undo());
|
||||
assert!(!engine.execute(Action::Undo));
|
||||
assert!(!engine.run(Action::Undo));
|
||||
let data = std::fs::read(FILES[0]).unwrap();
|
||||
assert!(engine.execute(Action::Edit(Command::Load(Load {
|
||||
assert!(engine.run(Action::Edit(Command::Load(Load {
|
||||
data: &data,
|
||||
name: "file",
|
||||
}))));
|
||||
assert!(engine.can_undo());
|
||||
assert!(engine.execute(Action::Undo));
|
||||
assert!(engine.run(Action::Undo));
|
||||
assert_eq!(engine.snapshot().files.len(), FILES.len());
|
||||
assert!(!engine.can_undo());
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user