mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-27 02:27:18 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce8398f749 | ||
|
|
3c2055b50e | ||
|
|
1d13972014 | ||
|
|
890bcd765a | ||
|
|
4795513344 | ||
|
|
d84ca47745 | ||
|
|
9adb8e7221 | ||
|
|
a2b4d89150 | ||
|
|
77929cfa04 |
No files matched your search
@@ -7,13 +7,6 @@
|
|||||||
pub struct Categories(Vec<String>);
|
pub struct Categories(Vec<String>);
|
||||||
|
|
||||||
impl Categories {
|
impl Categories {
|
||||||
/// A table with these names, in the order of their codes (at most [`Self::CAPACITY`] of them
|
|
||||||
/// are kept).
|
|
||||||
pub fn from_names(mut names: Vec<String>) -> Self {
|
|
||||||
names.truncate(Self::CAPACITY);
|
|
||||||
Self(names)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Maximum number of distinct values.
|
/// Maximum number of distinct values.
|
||||||
pub const CAPACITY: usize = u8::MAX as usize;
|
pub const CAPACITY: usize = u8::MAX as usize;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use crate::{Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint};
|
use crate::{Apply, CommandError, FileId, Files, Selection, State, Waypoint};
|
||||||
|
|
||||||
/// Deletes the selected elements. With `whole_files`, the files holding the selected elements
|
/// Deletes the selected elements. With `whole_files`, the files holding the selected elements
|
||||||
/// are deleted instead, even if only a track or a waypoint is selected.
|
/// are deleted instead, even if only a track or a waypoint is selected.
|
||||||
@@ -75,7 +75,7 @@ impl Apply for Delete {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn delete_waypoints(
|
pub(crate) fn delete_waypoints(
|
||||||
files: &mut StackEntry,
|
files: &mut Files,
|
||||||
file_id: FileId,
|
file_id: FileId,
|
||||||
filter: impl Fn(&Waypoint) -> bool,
|
filter: impl Fn(&Waypoint) -> bool,
|
||||||
) -> Result<Selection, CommandError> {
|
) -> Result<Selection, CommandError> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::{collections::HashSet, rc::Rc};
|
use std::{collections::HashSet, rc::Rc};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Apply, CommandError, FileId, Selection, StackEntry, State, Waypoint, copy_file, copy_segment,
|
Apply, CommandError, FileId, Files, Selection, State, Waypoint, copy_file, copy_segment,
|
||||||
copy_track, copy_waypoint,
|
copy_track, copy_waypoint,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ impl Apply for Duplicate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn duplicate_waypoints(
|
fn duplicate_waypoints(
|
||||||
files: &mut StackEntry,
|
files: &mut Files,
|
||||||
file_id: FileId,
|
file_id: FileId,
|
||||||
filter: impl Fn(&Waypoint) -> bool,
|
filter: impl Fn(&Waypoint) -> bool,
|
||||||
) -> Result<Selection, CommandError> {
|
) -> Result<Selection, CommandError> {
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use crate::{Clipboard, FileId, FileOrder, Selection, StackEntry, State, TrackpointCategories};
|
use crate::{Clipboard, FileId, FileOrder, Files, Selection, State, TrackpointCategories};
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct Fixture {
|
pub struct Fixture {
|
||||||
pub files: StackEntry,
|
pub files: Files,
|
||||||
pub selection: Selection,
|
pub selection: Selection,
|
||||||
pub order: FileOrder,
|
pub order: FileOrder,
|
||||||
pub clipboard: Option<Clipboard>,
|
pub clipboard: Option<Clipboard>,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use crate::{File, FileId, Selection, StackEntry, State, Track, TrackSegment, Waypoint};
|
use crate::{File, FileId, Files, Selection, State, Track, TrackSegment, Waypoint};
|
||||||
|
|
||||||
/// What an [`Editor`] hook did to the element it was given.
|
/// What an [`Editor`] hook did to the element it was given.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -70,7 +70,7 @@ fn edit_where<T>(
|
|||||||
|
|
||||||
/// Edits a copy of the file (cheap, track points are shared chunks) and stores it back as a
|
/// Edits a copy of the file (cheap, track points are shared chunks) and stores it back as a
|
||||||
/// new snapshot only if something changed.
|
/// new snapshot only if something changed.
|
||||||
fn update_file(files: &mut StackEntry, id: FileId, f: impl FnOnce(&mut File) -> Edit) {
|
fn update_file(files: &mut Files, id: FileId, f: impl FnOnce(&mut File) -> Edit) {
|
||||||
let Some(current) = files.get(&id) else {
|
let Some(current) = files.get(&id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use crate::{
|
use crate::{FileId, FileWaypointsRevisionId, Files, TrackSegmentId, TrackSegmentRevisionId};
|
||||||
FileId, FileWaypointsRevisionId, StackEntry as Files, TrackSegmentId, TrackSegmentRevisionId,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Coordinates buffers, as flat `[lng, lat, lng, lat, ...]` arrays so that they can be handed
|
/// Coordinates buffers, as flat `[lng, lat, lng, lat, ...]` arrays so that they can be handed
|
||||||
/// to the UI without per-point calls: one per track segment (its trackpoints) and one per file
|
/// to the UI without per-point calls: one per track segment (its trackpoints) and one per file
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::{Diff, FileId, FileStructure, StackEntry as Files};
|
use crate::{Diff, FileId, FileStructure, Files};
|
||||||
|
|
||||||
/// The structure of each file, only recomputed for the files that changed according to the
|
/// The structure of each file, only recomputed for the files that changed according to the
|
||||||
/// [`Diff`] of the stack.
|
/// [`Diff`] of the stack.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::{FileId, Selection, StackEntry, TrackSegmentId, TrackSegmentRevisionId};
|
use crate::{FileId, Files, Selection, TrackSegmentId, TrackSegmentRevisionId};
|
||||||
|
|
||||||
/// What the routing tool needs about the selected segments (see
|
/// What the routing tool needs about the selected segments (see
|
||||||
/// [`Selection::segment_locations`]): where their anchors are among their trackpoints.
|
/// [`Selection::segment_locations`]): where their anchors are among their trackpoints.
|
||||||
@@ -27,7 +27,7 @@ pub struct RoutingBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RoutingBuffer {
|
impl RoutingBuffer {
|
||||||
pub fn update(&mut self, files: Option<&StackEntry>, selection: &Selection, order: &[FileId]) {
|
pub fn update(&mut self, files: Option<&Files>, selection: &Selection, order: &[FileId]) {
|
||||||
self.anchor_indices.clear();
|
self.anchor_indices.clear();
|
||||||
self.anchor_zooms.clear();
|
self.anchor_zooms.clear();
|
||||||
self.segment_starts.clear();
|
self.segment_starts.clear();
|
||||||
@@ -70,11 +70,11 @@ mod tests {
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn loaded() -> (StackEntry, FileId) {
|
fn loaded() -> (Files, FileId) {
|
||||||
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
|
let data = std::fs::read("data/with_tracks_and_segments.gpx").unwrap();
|
||||||
let file = parse(&data, &mut Default::default()).unwrap();
|
let file = parse(&data, &mut Default::default()).unwrap();
|
||||||
let id = file.id;
|
let id = file.id;
|
||||||
let mut files = StackEntry::default();
|
let mut files = Files::default();
|
||||||
files.insert(id, Rc::new(file));
|
files.insert(id, Rc::new(file));
|
||||||
(files, id)
|
(files, id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::collections::{HashMap, HashSet, hash_map::Entry};
|
use std::collections::{HashMap, HashSet, hash_map::Entry};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
File, FileId, GlobalStatistics, Selection, StackEntry, Statistics, Track, TrackSegment,
|
File, FileId, Files, GlobalStatistics, Selection, Statistics, Track, TrackSegment,
|
||||||
TrackSegmentRevisionId,
|
TrackSegmentRevisionId,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ pub struct StatisticsCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl StatisticsCache {
|
impl StatisticsCache {
|
||||||
pub fn update(&mut self, state: Option<&StackEntry>) {
|
pub fn update(&mut self, state: Option<&Files>) {
|
||||||
let mut stale: HashSet<TrackSegmentRevisionId> = self.map.keys().copied().collect();
|
let mut stale: HashSet<TrackSegmentRevisionId> = self.map.keys().copied().collect();
|
||||||
if let Some(state) = state {
|
if let Some(state) = state {
|
||||||
for file in state.values() {
|
for file in state.values() {
|
||||||
@@ -40,7 +40,7 @@ impl StatisticsCache {
|
|||||||
/// Statistics of the segments of the selection.
|
/// Statistics of the segments of the selection.
|
||||||
pub fn get<'a>(
|
pub fn get<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
state: Option<&'a StackEntry>,
|
state: Option<&'a Files>,
|
||||||
selection: &Selection,
|
selection: &Selection,
|
||||||
) -> Vec<&'a Statistics> {
|
) -> Vec<&'a Statistics> {
|
||||||
self.selected(state, selection, &[])
|
self.selected(state, selection, &[])
|
||||||
@@ -53,7 +53,7 @@ impl StatisticsCache {
|
|||||||
/// files follow `order` (the files missing from it come last).
|
/// files follow `order` (the files missing from it come last).
|
||||||
pub fn selected<'a>(
|
pub fn selected<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
state: Option<&'a StackEntry>,
|
state: Option<&'a Files>,
|
||||||
selection: &Selection,
|
selection: &Selection,
|
||||||
order: &[FileId],
|
order: &[FileId],
|
||||||
) -> Vec<SelectedSegment<'a>> {
|
) -> Vec<SelectedSegment<'a>> {
|
||||||
@@ -159,10 +159,10 @@ mod tests {
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn state(path: &str) -> (StackEntry, Rc<File>) {
|
fn state(path: &str) -> (Files, Rc<File>) {
|
||||||
let data = std::fs::read(path).unwrap();
|
let data = std::fs::read(path).unwrap();
|
||||||
let file = Rc::new(parse(&data, &mut Default::default()).unwrap());
|
let file = Rc::new(parse(&data, &mut Default::default()).unwrap());
|
||||||
let mut state = StackEntry::default();
|
let mut state = Files::default();
|
||||||
state.insert(file.id, file.clone());
|
state.insert(file.id, file.clone());
|
||||||
(state, file)
|
(state, file)
|
||||||
}
|
}
|
||||||
@@ -235,7 +235,7 @@ mod tests {
|
|||||||
let before: *const Statistics = cache.get(Some(&state), &file_selection(file.id))[0];
|
let before: *const Statistics = cache.get(Some(&state), &file_selection(file.id))[0];
|
||||||
|
|
||||||
// a new snapshot of the file that shares its segments (same revision ids)
|
// a new snapshot of the file that shares its segments (same revision ids)
|
||||||
let mut next = StackEntry::default();
|
let mut next = Files::default();
|
||||||
let renamed = {
|
let renamed = {
|
||||||
let mut f = (*file).clone();
|
let mut f = (*file).clone();
|
||||||
f.info.name = "other".to_string();
|
f.info.name = "other".to_string();
|
||||||
@@ -258,7 +258,7 @@ mod tests {
|
|||||||
let mut f = (*file).clone();
|
let mut f = (*file).clone();
|
||||||
f.trk[0].trkseg[0].rev_id = Default::default();
|
f.trk[0].trkseg[0].rev_id = Default::default();
|
||||||
let new_rev = f.trk[0].trkseg[0].rev_id;
|
let new_rev = f.trk[0].trkseg[0].rev_id;
|
||||||
let mut next = StackEntry::default();
|
let mut next = Files::default();
|
||||||
next.insert(f.id, Rc::new(f));
|
next.insert(f.id, Rc::new(f));
|
||||||
cache.update(Some(&next));
|
cache.update(Some(&next));
|
||||||
assert!(cache.map.contains_key(&new_rev));
|
assert!(cache.map.contains_key(&new_rev));
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use crate::{
|
|||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct Engine {
|
pub struct Engine {
|
||||||
stack: Stack,
|
history: History,
|
||||||
selection: Selection,
|
selection: Selection,
|
||||||
order: FileOrder,
|
order: FileOrder,
|
||||||
statistics_cache: StatisticsCache,
|
statistics_cache: StatisticsCache,
|
||||||
@@ -27,9 +27,6 @@ pub struct Engine {
|
|||||||
routing_buffer: RoutingBuffer,
|
routing_buffer: RoutingBuffer,
|
||||||
categories: TrackpointCategories,
|
categories: TrackpointCategories,
|
||||||
clipboard: Option<Clipboard>,
|
clipboard: Option<Clipboard>,
|
||||||
/// What the tests of the last action read, see `Engine::run`.
|
|
||||||
#[cfg(test)]
|
|
||||||
last: Outcome,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Engine {
|
impl Engine {
|
||||||
@@ -59,7 +56,7 @@ impl Engine {
|
|||||||
|
|
||||||
/// Global statistics (distance, time, elevation, bounds...) of a file.
|
/// Global statistics (distance, time, elevation, bounds...) of a file.
|
||||||
pub fn file_statistics(&self, id: &FileId) -> Option<GlobalStatistics> {
|
pub fn file_statistics(&self, id: &FileId) -> Option<GlobalStatistics> {
|
||||||
let file = self.stack.current()?.get(id)?;
|
let file = self.history.current()?.get(id)?;
|
||||||
Some(self.statistics_cache.file_global(file))
|
Some(self.statistics_cache.file_global(file))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +74,7 @@ impl Engine {
|
|||||||
|
|
||||||
/// A waypoint of a file, with all its data. `None` if it does not exist.
|
/// 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> {
|
pub fn waypoint(&self, file_id: &FileId, id: &WaypointId) -> Option<&Waypoint> {
|
||||||
self.stack
|
self.history
|
||||||
.current()?
|
.current()?
|
||||||
.get(file_id)?
|
.get(file_id)?
|
||||||
.wpt
|
.wpt
|
||||||
@@ -93,7 +90,7 @@ impl Engine {
|
|||||||
index: usize,
|
index: usize,
|
||||||
) -> Option<&Trackpoint> {
|
) -> Option<&Trackpoint> {
|
||||||
let segment = self
|
let segment = self
|
||||||
.stack
|
.history
|
||||||
.current()?
|
.current()?
|
||||||
.get(file_id)?
|
.get(file_id)?
|
||||||
.trk
|
.trk
|
||||||
@@ -106,7 +103,7 @@ impl Engine {
|
|||||||
/// For each trackpoint of the selection (numbered as in the statistics), the tolerance up to
|
/// For each trackpoint of the selection (numbered as in the statistics), the tolerance up to
|
||||||
/// which `Reduce` keeps it, see [`reduction_distances`].
|
/// which `Reduce` keeps it, see [`reduction_distances`].
|
||||||
pub fn reduction_distances(&self) -> Vec<f64> {
|
pub fn reduction_distances(&self) -> Vec<f64> {
|
||||||
let Some(files) = self.stack.current() else {
|
let Some(files) = self.history.current() else {
|
||||||
return vec![];
|
return vec![];
|
||||||
};
|
};
|
||||||
self.selection
|
self.selection
|
||||||
@@ -123,56 +120,58 @@ impl Engine {
|
|||||||
/// What there is to keep of the engine, see [`crate::Persister`].
|
/// What there is to keep of the engine, see [`crate::Persister`].
|
||||||
pub fn snapshot(&self) -> Snapshot {
|
pub fn snapshot(&self) -> Snapshot {
|
||||||
Snapshot {
|
Snapshot {
|
||||||
files: self.stack.current().cloned().unwrap_or_default(),
|
files: self.history.current().cloned().unwrap_or_default(),
|
||||||
order: self.order.0.clone(),
|
order: self.order.0.clone(),
|
||||||
categories: self.categories.clone(),
|
categories: self.categories.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Puts files in an engine that has none, typically the ones read from the storage. They are
|
/// Replaces what the engine holds by the files read from the storage: at the start, or when
|
||||||
/// the state to start from: they cannot be undone. The files are reported as added, and the
|
/// another writer (a tab) committed since, which makes what was held here out of date. What
|
||||||
/// order, the selection and the clipboard as changed.
|
/// was here is gone, edits that were not saved included, and the files are the state to start
|
||||||
|
/// from: they cannot be undone, and nothing from before can be.
|
||||||
|
///
|
||||||
|
/// The selection keeps what still exists, and so does the clipboard. The files that differ
|
||||||
|
/// from the ones that were here are reported as added, removed or modified (all the files
|
||||||
|
/// that were here, as they are not the same objects), and the order as changed if it is.
|
||||||
pub fn restore(&mut self, restored: Restored) -> Outcome {
|
pub fn restore(&mut self, restored: Restored) -> Outcome {
|
||||||
let files: StackEntry = restored
|
let selection_before = self.selection.clone();
|
||||||
|
let order_before = self.order.0.clone();
|
||||||
|
let before = self.history.current().cloned();
|
||||||
|
let files: Files = restored
|
||||||
.files
|
.files
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|file| (file.id, file))
|
.map(|file| (file.id, file))
|
||||||
.collect();
|
.collect();
|
||||||
|
let diff = History::diff_between(before.as_ref(), Some(&files));
|
||||||
|
let mut diff =
|
||||||
|
(!(diff.added.is_empty() && diff.removed.is_empty() && diff.modified.is_empty()))
|
||||||
|
.then_some(diff);
|
||||||
self.categories = restored.categories;
|
self.categories = restored.categories;
|
||||||
self.order = FileOrder(restored.order);
|
self.order = FileOrder(restored.order);
|
||||||
self.selection = Selection::Empty;
|
self.history = History::restored(files);
|
||||||
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());
|
self.refresh(diff.as_ref());
|
||||||
sort_diff(&mut diff, &self.order.0);
|
sort_diff(&mut diff, &self.order.0);
|
||||||
let outcome = Outcome {
|
Outcome {
|
||||||
changed: true,
|
changed: diff.is_some()
|
||||||
|
|| self.order.0 != order_before
|
||||||
|
|| self.selection != selection_before,
|
||||||
diff,
|
diff,
|
||||||
order_changed: true,
|
order_changed: self.order.0 != order_before,
|
||||||
selection_changed: true,
|
selection_changed: self.selection != selection_before,
|
||||||
clipboard_changed: true,
|
clipboard_changed: false,
|
||||||
error: None,
|
|
||||||
};
|
|
||||||
#[cfg(test)]
|
|
||||||
{
|
|
||||||
self.last = outcome.clone();
|
|
||||||
}
|
}
|
||||||
outcome
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A file as GPX (UTF-8), `None` if it does not exist.
|
/// A file as GPX (UTF-8), `None` if it does not exist.
|
||||||
pub fn export(&self, id: &FileId, options: ExportOptions) -> Option<Vec<u8>> {
|
pub fn export(&self, id: &FileId, options: ExportOptions) -> Option<Vec<u8>> {
|
||||||
let file = self.stack.current()?.get(id)?;
|
let file = self.history.current()?.get(id)?;
|
||||||
Some(write(file, &self.categories, options))
|
Some(write(file, &self.categories, options))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The data that the given files have, which the options of `export` can leave out.
|
/// The data that the given files have, which the options of `export` can leave out.
|
||||||
pub fn exportable_data(&self, ids: &[FileId]) -> ExportOptions {
|
pub fn exportable_data(&self, ids: &[FileId]) -> ExportOptions {
|
||||||
let Some(files) = self.stack.current() else {
|
let Some(files) = self.history.current() else {
|
||||||
return ExportOptions::NONE;
|
return ExportOptions::NONE;
|
||||||
};
|
};
|
||||||
ids.iter()
|
ids.iter()
|
||||||
@@ -184,12 +183,12 @@ impl Engine {
|
|||||||
|
|
||||||
/// Whether there is something to undo.
|
/// Whether there is something to undo.
|
||||||
pub fn can_undo(&self) -> bool {
|
pub fn can_undo(&self) -> bool {
|
||||||
self.stack.can_undo()
|
self.history.can_undo()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether there is something to redo.
|
/// Whether there is something to redo.
|
||||||
pub fn can_redo(&self) -> bool {
|
pub fn can_redo(&self) -> bool {
|
||||||
self.stack.can_redo()
|
self.history.can_redo()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Currently selected elements.
|
/// Currently selected elements.
|
||||||
@@ -207,33 +206,31 @@ impl Engine {
|
|||||||
self.coordinates_cache.waypoints(id)
|
self.coordinates_cache.waypoints(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Single entry point for every action coming from the frontend. Says what it changed.
|
/// Single entry point for every action coming from the frontend. Says what it changed, or why
|
||||||
pub fn execute(&mut self, action: Action) -> Outcome {
|
/// it did nothing: an edit that fails or has nothing to do.
|
||||||
|
pub fn execute(&mut self, action: Action) -> Result<Outcome, CommandError> {
|
||||||
let selection_before = self.selection.clone();
|
let selection_before = self.selection.clone();
|
||||||
let order_before = self.order.0.clone();
|
let order_before = self.order.0.clone();
|
||||||
let clipboard_before = self.clipboard.clone();
|
let clipboard_before = self.clipboard.clone();
|
||||||
let is_edit = matches!(action, Action::Edit(_));
|
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
|
// the files change iff there is a diff, but selection and order actions have none
|
||||||
let mut diff = match action {
|
let mut diff = match action {
|
||||||
Action::Edit(command) => self.edit(command).unwrap_or_else(|err| {
|
Action::Edit(command) => self.edit(command)?,
|
||||||
error = Some(err);
|
Action::Undo => self.history.undo(),
|
||||||
None
|
Action::Redo => self.history.redo(),
|
||||||
}),
|
|
||||||
Action::Undo => self.stack.undo(),
|
|
||||||
Action::Redo => self.stack.redo(),
|
|
||||||
Action::Select { selection, mode } => {
|
Action::Select { selection, mode } => {
|
||||||
self.selection.select(self.stack.current(), selection, mode);
|
self.selection
|
||||||
|
.select(self.history.current(), selection, mode);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
Action::SelectAll => {
|
Action::SelectAll => {
|
||||||
if let Some(files) = self.stack.current() {
|
if let Some(files) = self.history.current() {
|
||||||
self.selection.select_all(files, &self.order.0);
|
self.selection.select_all(files, &self.order.0);
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
Action::ArrowSelect { down, add } => {
|
Action::ArrowSelect { down, add } => {
|
||||||
if let Some(files) = self.stack.current() {
|
if let Some(files) = self.history.current() {
|
||||||
self.selection
|
self.selection
|
||||||
.select_neighbour(files, &self.order.0, down, add);
|
.select_neighbour(files, &self.order.0, down, add);
|
||||||
}
|
}
|
||||||
@@ -256,32 +253,26 @@ impl Engine {
|
|||||||
|| self.selection != selection_before
|
|| self.selection != selection_before
|
||||||
|| self.order.0 != order_before
|
|| self.order.0 != order_before
|
||||||
|| self.clipboard != clipboard_before;
|
|| self.clipboard != clipboard_before;
|
||||||
if is_edit && !changed && error.is_none() {
|
if is_edit && !changed {
|
||||||
error = Some(CommandError::NothingToDo);
|
return Err(CommandError::NothingToDo);
|
||||||
}
|
}
|
||||||
if changed {
|
if changed {
|
||||||
self.refresh(diff.as_ref());
|
self.refresh(diff.as_ref());
|
||||||
sort_diff(&mut diff, &self.order.0);
|
sort_diff(&mut diff, &self.order.0);
|
||||||
}
|
}
|
||||||
// after the refresh, which also syncs the order and selection with the files (undo, redo)
|
// after the refresh, which also syncs the order and selection with the files (undo, redo)
|
||||||
let outcome = Outcome {
|
Ok(Outcome {
|
||||||
changed,
|
changed,
|
||||||
diff,
|
diff,
|
||||||
order_changed: self.order.0 != order_before,
|
order_changed: self.order.0 != order_before,
|
||||||
selection_changed: self.selection != selection_before,
|
selection_changed: self.selection != selection_before,
|
||||||
clipboard_changed: self.clipboard != clipboard_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.
|
/// Puts the selection in the clipboard, if there is one.
|
||||||
fn copy(&mut self, cut: bool) {
|
fn copy(&mut self, cut: bool) {
|
||||||
if let Some(files) = self.stack.current()
|
if let Some(files) = self.history.current()
|
||||||
&& let Some(clipboard) = Clipboard::new(&self.selection, files, &self.order.0, cut)
|
&& let Some(clipboard) = Clipboard::new(&self.selection, files, &self.order.0, cut)
|
||||||
{
|
{
|
||||||
self.clipboard = Some(clipboard);
|
self.clipboard = Some(clipboard);
|
||||||
@@ -289,7 +280,7 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn edit(&mut self, command: Command) -> Result<Option<Diff>, CommandError> {
|
fn edit(&mut self, command: Command) -> Result<Option<Diff>, CommandError> {
|
||||||
self.stack.create_and_push_next(|files| {
|
self.history.create_and_push_next(|files| {
|
||||||
let mut state = State {
|
let mut state = State {
|
||||||
categories: &mut self.categories,
|
categories: &mut self.categories,
|
||||||
files,
|
files,
|
||||||
@@ -301,9 +292,9 @@ impl Engine {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Brings everything derived from the files back in line with the current stack entry.
|
/// Brings everything derived from the files back in line with the current history entry.
|
||||||
fn refresh(&mut self, diff: Option<&Diff>) {
|
fn refresh(&mut self, diff: Option<&Diff>) {
|
||||||
let current = self.stack.current();
|
let current = self.history.current();
|
||||||
match current {
|
match current {
|
||||||
Some(files) => {
|
Some(files) => {
|
||||||
// files coming back after an undo/redo are added at the end of the order
|
// files coming back after an undo/redo are added at the end of the order
|
||||||
@@ -329,7 +320,7 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Puts the files of a diff in the order of the files (the stack does not know it), so that
|
/// Puts the files of a diff in the order of the files (the history does not know it), so that
|
||||||
/// whatever is done with them one after the other, like giving them colors, follows it.
|
/// whatever is done with them one after the other, like giving them colors, follows it.
|
||||||
fn sort_diff(diff: &mut Option<Diff>, order: &[FileId]) {
|
fn sort_diff(diff: &mut Option<Diff>, order: &[FileId]) {
|
||||||
let position = |id: &FileId| order.iter().position(|other| other == id);
|
let position = |id: &FileId| order.iter().position(|other| other == id);
|
||||||
@@ -339,34 +330,5 @@ fn sort_diff(diff: &mut Option<Diff>, order: &[FileId]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::{CommandError, Diff};
|
use crate::Diff;
|
||||||
|
|
||||||
/// What an action did, returned by [`crate::Engine::execute`] and [`crate::Engine::restore`].
|
/// What an action did, returned by [`crate::Engine::execute`] (unless it was refused) and
|
||||||
|
/// [`crate::Engine::restore`].
|
||||||
///
|
///
|
||||||
/// It describes that action only: it is not kept by the engine, so reading it never depends on
|
/// It describes that action only: it is not kept by the engine, so reading it never depends on
|
||||||
/// what was done since.
|
/// what was done since.
|
||||||
@@ -15,7 +16,4 @@ pub struct Outcome {
|
|||||||
pub order_changed: bool,
|
pub order_changed: bool,
|
||||||
pub selection_changed: bool,
|
pub selection_changed: bool,
|
||||||
pub clipboard_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>,
|
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ use std::rc::Rc;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
File, FileId, Selection, StackEntry, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint,
|
File, FileId, Files, Selection, Track, TrackId, TrackSegment, TrackSegmentId, Waypoint,
|
||||||
WaypointId,
|
WaypointId,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ impl Clipboard {
|
|||||||
/// selected does not exist).
|
/// selected does not exist).
|
||||||
pub fn new(
|
pub fn new(
|
||||||
selection: &Selection,
|
selection: &Selection,
|
||||||
files: &StackEntry,
|
files: &Files,
|
||||||
order: &[FileId],
|
order: &[FileId],
|
||||||
cut: bool,
|
cut: bool,
|
||||||
) -> Option<Clipboard> {
|
) -> Option<Clipboard> {
|
||||||
@@ -238,14 +238,14 @@ mod tests {
|
|||||||
file
|
file
|
||||||
}
|
}
|
||||||
|
|
||||||
fn files_of(files: Vec<File>) -> StackEntry {
|
fn files_of(files: Vec<File>) -> Files {
|
||||||
files
|
files
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|file| (file.id, Rc::new(file)))
|
.map(|file| (file.id, Rc::new(file)))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn order_of(files: &StackEntry) -> Vec<FileId> {
|
fn order_of(files: &Files) -> Vec<FileId> {
|
||||||
files.keys().copied().collect()
|
files.keys().copied().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+143
-137
@@ -6,17 +6,17 @@ use std::{
|
|||||||
use crate::{CommandError, File, FileId};
|
use crate::{CommandError, File, FileId};
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct Stack {
|
pub struct History {
|
||||||
entries: Vec<StackEntry>,
|
entries: Vec<Files>,
|
||||||
index: Option<usize>,
|
index: Option<usize>,
|
||||||
/// Whether the first entry is the state the stack starts from, which is not undone: files
|
/// Whether the first entry is the state the history starts from, which is not undone: files
|
||||||
/// restored from the storage.
|
/// restored from the storage.
|
||||||
floor: bool,
|
floor: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Stack {
|
impl History {
|
||||||
/// A stack whose first entry is `entry`, from which nothing can be undone.
|
/// A history whose first entry is `entry`, from which nothing can be undone.
|
||||||
pub fn restored(entry: StackEntry) -> Self {
|
pub fn restored(entry: Files) -> Self {
|
||||||
Self {
|
Self {
|
||||||
entries: vec![entry],
|
entries: vec![entry],
|
||||||
index: Some(0),
|
index: Some(0),
|
||||||
@@ -24,7 +24,7 @@ impl Stack {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current(&self) -> Option<&StackEntry> {
|
pub fn current(&self) -> Option<&Files> {
|
||||||
self.index.map_or_default(|i| self.get(i))
|
self.index.map_or_default(|i| self.get(i))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,18 +35,18 @@ impl Stack {
|
|||||||
/// history. Files are told apart by their identity, not their content.
|
/// 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>
|
pub fn create_and_push_next<F>(&mut self, f: F) -> Result<Option<Diff>, CommandError>
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut StackEntry) -> Result<(), CommandError>,
|
F: FnOnce(&mut Files) -> Result<(), CommandError>,
|
||||||
{
|
{
|
||||||
let mut next = self.current().map_or_default(|c| c.clone());
|
let mut next = self.current().map_or_default(|c| c.clone());
|
||||||
f(&mut next)?;
|
f(&mut next)?;
|
||||||
if self.is_current(&next) {
|
if self.is_current(&next) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
Ok(self.record_diff(|stack| stack.push(next)))
|
Ok(self.record_diff(|history| history.push(next)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `entry` holds exactly the files of the current entry.
|
/// Whether `entry` holds exactly the files of the current entry.
|
||||||
fn is_current(&self, entry: &StackEntry) -> bool {
|
fn is_current(&self, entry: &Files) -> bool {
|
||||||
let current = self.current();
|
let current = self.current();
|
||||||
let len = current.map_or(0, |current| current.len());
|
let len = current.map_or(0, |current| current.len());
|
||||||
entry.len() == len
|
entry.len() == len
|
||||||
@@ -58,29 +58,29 @@ impl Stack {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn undo(&mut self) -> Option<Diff> {
|
pub fn undo(&mut self) -> Option<Diff> {
|
||||||
self.record_diff(|stack| {
|
self.record_diff(|history| {
|
||||||
if let Some(i) = stack.index {
|
if let Some(i) = history.index {
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
if !stack.floor {
|
if !history.floor {
|
||||||
stack.index = None;
|
history.index = None;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
stack.index = Some(i - 1);
|
history.index = Some(i - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn redo(&mut self) -> Option<Diff> {
|
pub fn redo(&mut self) -> Option<Diff> {
|
||||||
self.record_diff(|stack| match stack.index {
|
self.record_diff(|history| match history.index {
|
||||||
Some(i) => {
|
Some(i) => {
|
||||||
if i + 1 < stack.entries.len() {
|
if i + 1 < history.entries.len() {
|
||||||
stack.index = Some(i + 1);
|
history.index = Some(i + 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
if !stack.entries.is_empty() {
|
if !history.entries.is_empty() {
|
||||||
stack.index = Some(0);
|
history.index = Some(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -97,11 +97,11 @@ impl Stack {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get(&self, index: usize) -> Option<&StackEntry> {
|
fn get(&self, index: usize) -> Option<&Files> {
|
||||||
self.entries.get(index)
|
self.entries.get(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn push(&mut self, entry: StackEntry) {
|
fn push(&mut self, entry: Files) {
|
||||||
if let Some(i) = self.index {
|
if let Some(i) = self.index {
|
||||||
self.entries.truncate(i + 1);
|
self.entries.truncate(i + 1);
|
||||||
}
|
}
|
||||||
@@ -120,8 +120,14 @@ impl Stack {
|
|||||||
if prev == cur {
|
if prev == cur {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let prev = prev.map_or_default(|i| self.get(i));
|
Some(Self::diff_between(
|
||||||
let cur = cur.map_or_default(|i| self.get(i));
|
prev.map_or_default(|i| self.get(i)),
|
||||||
|
cur.map_or_default(|i| self.get(i)),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which files differ between two states.
|
||||||
|
pub fn diff_between(prev: Option<&Files>, cur: Option<&Files>) -> Diff {
|
||||||
let prev_ids: HashSet<FileId> = prev.map_or_default(|e| e.keys().copied().collect());
|
let prev_ids: HashSet<FileId> = prev.map_or_default(|e| e.keys().copied().collect());
|
||||||
let cur_ids: HashSet<FileId> = cur.map_or_default(|e| e.keys().copied().collect());
|
let cur_ids: HashSet<FileId> = cur.map_or_default(|e| e.keys().copied().collect());
|
||||||
let mut modified = vec![];
|
let mut modified = vec![];
|
||||||
@@ -132,17 +138,17 @@ impl Stack {
|
|||||||
modified.push(*id);
|
modified.push(*id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(Diff {
|
Diff {
|
||||||
added: cur_ids.difference(&prev_ids).copied().collect(),
|
added: cur_ids.difference(&prev_ids).copied().collect(),
|
||||||
removed: prev_ids.difference(&cur_ids).copied().collect(),
|
removed: prev_ids.difference(&cur_ids).copied().collect(),
|
||||||
modified,
|
modified,
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type StackEntry = HashMap<FileId, Rc<File>>;
|
pub type Files = HashMap<FileId, Rc<File>>;
|
||||||
|
|
||||||
/// Which files were added, removed or modified by a change of the current stack entry.
|
/// Which files were added, removed or modified by a change of the current history entry.
|
||||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||||
pub struct Diff {
|
pub struct Diff {
|
||||||
pub added: Vec<FileId>,
|
pub added: Vec<FileId>,
|
||||||
@@ -154,11 +160,11 @@ pub struct Diff {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
impl Stack {
|
impl History {
|
||||||
/// A step that is expected not to fail.
|
/// A step that is expected not to fail.
|
||||||
fn push_ok(
|
fn push_ok(
|
||||||
&mut self,
|
&mut self,
|
||||||
f: impl FnOnce(&mut StackEntry) -> Result<(), CommandError>,
|
f: impl FnOnce(&mut Files) -> Result<(), CommandError>,
|
||||||
) -> Option<Diff> {
|
) -> Option<Diff> {
|
||||||
self.create_and_push_next(f).unwrap()
|
self.create_and_push_next(f).unwrap()
|
||||||
}
|
}
|
||||||
@@ -166,21 +172,21 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_new_file() {
|
fn test_new_file() {
|
||||||
let mut stack = Stack::default();
|
let mut history = History::default();
|
||||||
|
|
||||||
assert!(!stack.can_undo());
|
assert!(!history.can_undo());
|
||||||
assert!(!stack.can_redo());
|
assert!(!history.can_redo());
|
||||||
assert!(stack.current().is_none());
|
assert!(history.current().is_none());
|
||||||
|
|
||||||
let diff = stack.push_ok(|e| {
|
let diff = history.push_ok(|e| {
|
||||||
let file = Rc::new(File::default());
|
let file = Rc::new(File::default());
|
||||||
e.insert(file.id, file);
|
e.insert(file.id, file);
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
assert!(stack.can_undo());
|
assert!(history.can_undo());
|
||||||
assert!(!stack.can_redo());
|
assert!(!history.can_redo());
|
||||||
assert!(stack.current().is_some());
|
assert!(history.current().is_some());
|
||||||
|
|
||||||
assert!(diff.is_some());
|
assert!(diff.is_some());
|
||||||
|
|
||||||
@@ -190,10 +196,10 @@ mod tests {
|
|||||||
assert!(diff.modified.is_empty());
|
assert!(diff.modified.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_file(stack: &mut Stack) -> FileId {
|
fn add_file(history: &mut History) -> FileId {
|
||||||
let file = Rc::new(File::default());
|
let file = Rc::new(File::default());
|
||||||
let id = file.id;
|
let id = file.id;
|
||||||
stack
|
history
|
||||||
.push_ok(|e| {
|
.push_ok(|e| {
|
||||||
e.insert(id, file);
|
e.insert(id, file);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -204,46 +210,46 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_failed_command_pushes_nothing() {
|
fn test_failed_command_pushes_nothing() {
|
||||||
let mut stack = Stack::default();
|
let mut history = History::default();
|
||||||
let diff = stack.create_and_push_next(|e| {
|
let diff = history.create_and_push_next(|e| {
|
||||||
e.insert(FileId::default(), Rc::new(File::default()));
|
e.insert(FileId::default(), Rc::new(File::default()));
|
||||||
Err(CommandError::InvalidData("nope".to_string()))
|
Err(CommandError::InvalidData("nope".to_string()))
|
||||||
});
|
});
|
||||||
assert_eq!(diff, Err(CommandError::InvalidData("nope".to_string())));
|
assert_eq!(diff, Err(CommandError::InvalidData("nope".to_string())));
|
||||||
assert!(!stack.can_undo());
|
assert!(!history.can_undo());
|
||||||
assert!(stack.current().is_none());
|
assert!(history.current().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_undo_redo() {
|
fn test_undo_redo() {
|
||||||
let mut stack = Stack::default();
|
let mut history = History::default();
|
||||||
assert!(stack.undo().is_none());
|
assert!(history.undo().is_none());
|
||||||
assert!(stack.redo().is_none());
|
assert!(history.redo().is_none());
|
||||||
|
|
||||||
let id = add_file(&mut stack);
|
let id = add_file(&mut history);
|
||||||
|
|
||||||
let diff = stack.undo().unwrap();
|
let diff = history.undo().unwrap();
|
||||||
assert_eq!(diff.removed, vec![id]);
|
assert_eq!(diff.removed, vec![id]);
|
||||||
assert!(diff.added.is_empty());
|
assert!(diff.added.is_empty());
|
||||||
assert!(stack.current().is_none());
|
assert!(history.current().is_none());
|
||||||
assert!(!stack.can_undo());
|
assert!(!history.can_undo());
|
||||||
assert!(stack.can_redo());
|
assert!(history.can_redo());
|
||||||
assert!(stack.undo().is_none());
|
assert!(history.undo().is_none());
|
||||||
|
|
||||||
let diff = stack.redo().unwrap();
|
let diff = history.redo().unwrap();
|
||||||
assert_eq!(diff.added, vec![id]);
|
assert_eq!(diff.added, vec![id]);
|
||||||
assert!(stack.current().unwrap().contains_key(&id));
|
assert!(history.current().unwrap().contains_key(&id));
|
||||||
assert!(stack.can_undo());
|
assert!(history.can_undo());
|
||||||
assert!(!stack.can_redo());
|
assert!(!history.can_redo());
|
||||||
assert!(stack.redo().is_none());
|
assert!(history.redo().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_modified_and_removed() {
|
fn test_modified_and_removed() {
|
||||||
let mut stack = Stack::default();
|
let mut history = History::default();
|
||||||
let id = add_file(&mut stack);
|
let id = add_file(&mut history);
|
||||||
|
|
||||||
let diff = stack
|
let diff = history
|
||||||
.push_ok(|e| {
|
.push_ok(|e| {
|
||||||
let mut file = (**e.get(&id).unwrap()).clone();
|
let mut file = (**e.get(&id).unwrap()).clone();
|
||||||
file.info.name = "renamed".to_string();
|
file.info.name = "renamed".to_string();
|
||||||
@@ -255,9 +261,9 @@ mod tests {
|
|||||||
assert!(diff.added.is_empty() && diff.removed.is_empty());
|
assert!(diff.added.is_empty() && diff.removed.is_empty());
|
||||||
|
|
||||||
// a step that changes nothing is not a step
|
// a step that changes nothing is not a step
|
||||||
assert!(stack.push_ok(|_| Ok(())).is_none());
|
assert!(history.push_ok(|_| Ok(())).is_none());
|
||||||
assert!(
|
assert!(
|
||||||
stack
|
history
|
||||||
.push_ok(|e| {
|
.push_ok(|e| {
|
||||||
let file = e[&id].clone();
|
let file = e[&id].clone();
|
||||||
e.insert(id, file);
|
e.insert(id, file);
|
||||||
@@ -266,28 +272,28 @@ mod tests {
|
|||||||
.is_none()
|
.is_none()
|
||||||
);
|
);
|
||||||
|
|
||||||
let diff = stack
|
let diff = history
|
||||||
.push_ok(|e| {
|
.push_ok(|e| {
|
||||||
e.remove(&id);
|
e.remove(&id);
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(diff.removed, vec![id]);
|
assert_eq!(diff.removed, vec![id]);
|
||||||
assert!(stack.current().unwrap().is_empty());
|
assert!(history.current().unwrap().is_empty());
|
||||||
|
|
||||||
// going back restores the renamed file
|
// going back restores the renamed file
|
||||||
stack.undo();
|
history.undo();
|
||||||
assert_eq!(stack.current().unwrap()[&id].info.name, "renamed");
|
assert_eq!(history.current().unwrap()[&id].info.name, "renamed");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_a_file_is_modified_when_it_is_not_the_same_one_anymore() {
|
fn test_a_file_is_modified_when_it_is_not_the_same_one_anymore() {
|
||||||
let mut stack = Stack::default();
|
let mut history = History::default();
|
||||||
let id = add_file(&mut stack);
|
let id = add_file(&mut history);
|
||||||
|
|
||||||
// a copy, even if it has the same content, is another file for the stack: files are
|
// a copy, even if it has the same content, is another file for the history: files are
|
||||||
// told apart by their identity, comparing them would read all of them
|
// told apart by their identity, comparing them would read all of them
|
||||||
let diff = stack
|
let diff = history
|
||||||
.push_ok(|e| {
|
.push_ok(|e| {
|
||||||
let copy = (**e.get(&id).unwrap()).clone();
|
let copy = (**e.get(&id).unwrap()).clone();
|
||||||
e.insert(id, Rc::new(copy));
|
e.insert(id, Rc::new(copy));
|
||||||
@@ -297,8 +303,8 @@ mod tests {
|
|||||||
assert_eq!(diff.modified, vec![id]);
|
assert_eq!(diff.modified, vec![id]);
|
||||||
|
|
||||||
// and the files that were not replaced are not
|
// and the files that were not replaced are not
|
||||||
let other = add_file(&mut stack);
|
let other = add_file(&mut history);
|
||||||
let diff = stack
|
let diff = history
|
||||||
.push_ok(|e| {
|
.push_ok(|e| {
|
||||||
let copy = (**e.get(&other).unwrap()).clone();
|
let copy = (**e.get(&other).unwrap()).clone();
|
||||||
e.insert(other, Rc::new(copy));
|
e.insert(other, Rc::new(copy));
|
||||||
@@ -310,120 +316,120 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_push_after_undo_drops_redo_branch() {
|
fn test_push_after_undo_drops_redo_branch() {
|
||||||
let mut stack = Stack::default();
|
let mut history = History::default();
|
||||||
let first = add_file(&mut stack);
|
let first = add_file(&mut history);
|
||||||
let second = add_file(&mut stack);
|
let second = add_file(&mut history);
|
||||||
|
|
||||||
stack.undo();
|
history.undo();
|
||||||
assert!(stack.can_redo());
|
assert!(history.can_redo());
|
||||||
assert!(!stack.current().unwrap().contains_key(&second));
|
assert!(!history.current().unwrap().contains_key(&second));
|
||||||
|
|
||||||
let third = add_file(&mut stack);
|
let third = add_file(&mut history);
|
||||||
assert!(!stack.can_redo());
|
assert!(!history.can_redo());
|
||||||
assert!(stack.redo().is_none());
|
assert!(history.redo().is_none());
|
||||||
let current = stack.current().unwrap();
|
let current = history.current().unwrap();
|
||||||
assert!(current.contains_key(&first));
|
assert!(current.contains_key(&first));
|
||||||
assert!(current.contains_key(&third));
|
assert!(current.contains_key(&third));
|
||||||
assert!(!current.contains_key(&second));
|
assert!(!current.contains_key(&second));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn restored_entry() -> (StackEntry, FileId) {
|
fn restored_entry() -> (Files, FileId) {
|
||||||
let file = Rc::new(File::default());
|
let file = Rc::new(File::default());
|
||||||
let id = file.id;
|
let id = file.id;
|
||||||
(StackEntry::from([(id, file)]), id)
|
(Files::from([(id, file)]), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_restored_stack_starts_from_its_entry() {
|
fn test_restored_history_starts_from_its_entry() {
|
||||||
let (entry, id) = restored_entry();
|
let (entry, id) = restored_entry();
|
||||||
let mut stack = Stack::restored(entry);
|
let mut history = History::restored(entry);
|
||||||
|
|
||||||
assert!(stack.current().unwrap().contains_key(&id));
|
assert!(history.current().unwrap().contains_key(&id));
|
||||||
// there is nothing to go back to, nor forward to
|
// there is nothing to go back to, nor forward to
|
||||||
assert!(!stack.can_undo());
|
assert!(!history.can_undo());
|
||||||
assert!(!stack.can_redo());
|
assert!(!history.can_redo());
|
||||||
assert!(stack.undo().is_none());
|
assert!(history.undo().is_none());
|
||||||
assert!(stack.redo().is_none());
|
assert!(history.redo().is_none());
|
||||||
assert!(stack.current().unwrap().contains_key(&id));
|
assert!(history.current().unwrap().contains_key(&id));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_restored_stack_cannot_undo_an_empty_entry_either() {
|
fn test_restored_history_cannot_undo_an_empty_entry_either() {
|
||||||
let mut stack = Stack::restored(StackEntry::new());
|
let mut history = History::restored(Files::new());
|
||||||
|
|
||||||
assert!(stack.current().is_some_and(|files| files.is_empty()));
|
assert!(history.current().is_some_and(|files| files.is_empty()));
|
||||||
assert!(!stack.can_undo());
|
assert!(!history.can_undo());
|
||||||
assert!(stack.undo().is_none());
|
assert!(history.undo().is_none());
|
||||||
// the stack is not the one of an engine that has done nothing
|
// the history is not the one of an engine that has done nothing
|
||||||
assert!(stack.current().is_some());
|
assert!(history.current().is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_restored_stack_undoes_what_comes_after_it_and_no_further() {
|
fn test_restored_history_undoes_what_comes_after_it_and_no_further() {
|
||||||
let (entry, restored) = restored_entry();
|
let (entry, restored) = restored_entry();
|
||||||
let mut stack = Stack::restored(entry);
|
let mut history = History::restored(entry);
|
||||||
|
|
||||||
let added = add_file(&mut stack);
|
let added = add_file(&mut history);
|
||||||
assert!(stack.can_undo());
|
assert!(history.can_undo());
|
||||||
assert!(!stack.can_redo());
|
assert!(!history.can_redo());
|
||||||
assert_eq!(stack.current().unwrap().len(), 2);
|
assert_eq!(history.current().unwrap().len(), 2);
|
||||||
|
|
||||||
// back to the restored files
|
// back to the restored files
|
||||||
let diff = stack.undo().unwrap();
|
let diff = history.undo().unwrap();
|
||||||
assert_eq!(diff.removed, vec![added]);
|
assert_eq!(diff.removed, vec![added]);
|
||||||
assert!(diff.added.is_empty() && diff.modified.is_empty());
|
assert!(diff.added.is_empty() && diff.modified.is_empty());
|
||||||
assert_eq!(stack.current().unwrap().len(), 1);
|
assert_eq!(history.current().unwrap().len(), 1);
|
||||||
assert!(stack.current().unwrap().contains_key(&restored));
|
assert!(history.current().unwrap().contains_key(&restored));
|
||||||
assert!(!stack.can_undo());
|
assert!(!history.can_undo());
|
||||||
assert!(stack.can_redo());
|
assert!(history.can_redo());
|
||||||
assert!(stack.undo().is_none());
|
assert!(history.undo().is_none());
|
||||||
assert!(stack.current().unwrap().contains_key(&restored));
|
assert!(history.current().unwrap().contains_key(&restored));
|
||||||
|
|
||||||
let diff = stack.redo().unwrap();
|
let diff = history.redo().unwrap();
|
||||||
assert_eq!(diff.added, vec![added]);
|
assert_eq!(diff.added, vec![added]);
|
||||||
assert!(stack.can_undo());
|
assert!(history.can_undo());
|
||||||
assert!(!stack.can_redo());
|
assert!(!history.can_redo());
|
||||||
|
|
||||||
// and again
|
// and again
|
||||||
assert!(stack.undo().is_some());
|
assert!(history.undo().is_some());
|
||||||
assert!(stack.undo().is_none());
|
assert!(history.undo().is_none());
|
||||||
assert!(stack.current().unwrap().contains_key(&restored));
|
assert!(history.current().unwrap().contains_key(&restored));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_restored_stack_removing_its_files_can_be_undone() {
|
fn test_restored_history_removing_its_files_can_be_undone() {
|
||||||
let (entry, restored) = restored_entry();
|
let (entry, restored) = restored_entry();
|
||||||
let mut stack = Stack::restored(entry);
|
let mut history = History::restored(entry);
|
||||||
|
|
||||||
let diff = stack
|
let diff = history
|
||||||
.push_ok(|e| {
|
.push_ok(|e| {
|
||||||
e.remove(&restored);
|
e.remove(&restored);
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(diff.removed, vec![restored]);
|
assert_eq!(diff.removed, vec![restored]);
|
||||||
assert!(stack.current().unwrap().is_empty());
|
assert!(history.current().unwrap().is_empty());
|
||||||
|
|
||||||
let diff = stack.undo().unwrap();
|
let diff = history.undo().unwrap();
|
||||||
assert_eq!(diff.added, vec![restored]);
|
assert_eq!(diff.added, vec![restored]);
|
||||||
assert!(stack.current().unwrap().contains_key(&restored));
|
assert!(history.current().unwrap().contains_key(&restored));
|
||||||
assert!(!stack.can_undo());
|
assert!(!history.can_undo());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_restored_stack_push_after_undo_drops_redo_branch() {
|
fn test_restored_history_push_after_undo_drops_redo_branch() {
|
||||||
let (entry, restored) = restored_entry();
|
let (entry, restored) = restored_entry();
|
||||||
let mut stack = Stack::restored(entry);
|
let mut history = History::restored(entry);
|
||||||
add_file(&mut stack);
|
add_file(&mut history);
|
||||||
stack.undo().unwrap();
|
history.undo().unwrap();
|
||||||
assert!(stack.can_redo());
|
assert!(history.can_redo());
|
||||||
|
|
||||||
let other = add_file(&mut stack);
|
let other = add_file(&mut history);
|
||||||
assert!(!stack.can_redo());
|
assert!(!history.can_redo());
|
||||||
assert!(stack.can_undo());
|
assert!(history.can_undo());
|
||||||
let diff = stack.undo().unwrap();
|
let diff = history.undo().unwrap();
|
||||||
assert_eq!(diff.removed, vec![other]);
|
assert_eq!(diff.removed, vec![other]);
|
||||||
assert!(stack.current().unwrap().contains_key(&restored));
|
assert!(history.current().unwrap().contains_key(&restored));
|
||||||
assert!(!stack.can_undo());
|
assert!(!history.can_undo());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
mod clipboard;
|
mod clipboard;
|
||||||
|
mod history;
|
||||||
mod order;
|
mod order;
|
||||||
mod selection;
|
mod selection;
|
||||||
mod stack;
|
|
||||||
|
|
||||||
pub use clipboard::*;
|
pub use clipboard::*;
|
||||||
|
pub use history::*;
|
||||||
pub use order::*;
|
pub use order::*;
|
||||||
pub use selection::*;
|
pub use selection::*;
|
||||||
pub use stack::*;
|
|
||||||
|
|
||||||
pub struct State<'a> {
|
pub struct State<'a> {
|
||||||
pub categories: &'a mut crate::TrackpointCategories,
|
pub categories: &'a mut crate::TrackpointCategories,
|
||||||
pub files: &'a mut StackEntry,
|
pub files: &'a mut Files,
|
||||||
pub selection: &'a mut Selection,
|
pub selection: &'a mut Selection,
|
||||||
pub order: &'a mut FileOrder,
|
pub order: &'a mut FileOrder,
|
||||||
pub clipboard: &'a mut Option<Clipboard>,
|
pub clipboard: &'a mut Option<Clipboard>,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use crate::{FileId, StackEntry as Files};
|
use crate::{FileId, Files};
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||||
pub struct FileOrder(pub Vec<FileId>);
|
pub struct FileOrder(pub Vec<FileId>);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use crate::{FileId, StackEntry, TrackId, TrackSegmentId, WaypointId};
|
use crate::{FileId, Files, TrackId, TrackSegmentId, WaypointId};
|
||||||
|
|
||||||
/// How a new selection combines with the current one.
|
/// How a new selection combines with the current one.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -52,12 +52,7 @@ impl Selection {
|
|||||||
/// Combines `selection` with this one according to `mode`, ignoring the elements of
|
/// 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
|
/// `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.
|
/// as it is, while replacing it with nothing deselects everything.
|
||||||
pub fn select(
|
pub fn select(&mut self, files: Option<&Files>, mut selection: Selection, mode: SelectMode) {
|
||||||
&mut self,
|
|
||||||
files: Option<&StackEntry>,
|
|
||||||
mut selection: Selection,
|
|
||||||
mode: SelectMode,
|
|
||||||
) {
|
|
||||||
match files {
|
match files {
|
||||||
Some(files) => selection.retain_existing(files),
|
Some(files) => selection.retain_existing(files),
|
||||||
None => selection = Selection::Empty,
|
None => selection = Selection::Empty,
|
||||||
@@ -72,7 +67,7 @@ impl Selection {
|
|||||||
|
|
||||||
/// Selects all the elements of the same kind as the selected ones, see
|
/// 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.
|
/// [`Selection::all_at_level`]. Does nothing when there is nothing more to select.
|
||||||
pub fn select_all(&mut self, files: &StackEntry, order: &[FileId]) {
|
pub fn select_all(&mut self, files: &Files, order: &[FileId]) {
|
||||||
if let Some(all) = self.all_at_level(files, order) {
|
if let Some(all) = self.all_at_level(files, order) {
|
||||||
*self = all;
|
*self = all;
|
||||||
}
|
}
|
||||||
@@ -81,13 +76,7 @@ impl Selection {
|
|||||||
/// Moves the selection to the next (`down`) or previous element, see
|
/// Moves the selection to the next (`down`) or previous element, see
|
||||||
/// [`Selection::neighbour`]. With `add`, the element is added to the selection instead of
|
/// [`Selection::neighbour`]. With `add`, the element is added to the selection instead of
|
||||||
/// replacing it.
|
/// replacing it.
|
||||||
pub fn select_neighbour(
|
pub fn select_neighbour(&mut self, files: &Files, order: &[FileId], down: bool, add: bool) {
|
||||||
&mut self,
|
|
||||||
files: &StackEntry,
|
|
||||||
order: &[FileId],
|
|
||||||
down: bool,
|
|
||||||
add: bool,
|
|
||||||
) {
|
|
||||||
if let Some(next) = self.neighbour(files, order, down) {
|
if let Some(next) = self.neighbour(files, order, down) {
|
||||||
if add {
|
if add {
|
||||||
self.extend(next);
|
self.extend(next);
|
||||||
@@ -102,7 +91,7 @@ impl Selection {
|
|||||||
/// The track segments covered by the selection, in order: the segments of several selected
|
/// The track segments covered by the selection, in order: the segments of several selected
|
||||||
/// files follow `order` (the files missing from it come last), then the tracks and segments
|
/// files follow `order` (the files missing from it come last), then the tracks and segments
|
||||||
/// follow the file. Waypoints are not segments: selecting only waypoints covers none.
|
/// follow the file. Waypoints are not segments: selecting only waypoints covers none.
|
||||||
pub fn segment_locations(&self, files: &StackEntry, order: &[FileId]) -> Vec<SegmentLocation> {
|
pub fn segment_locations(&self, files: &Files, order: &[FileId]) -> Vec<SegmentLocation> {
|
||||||
let mut locations = vec![];
|
let mut locations = vec![];
|
||||||
let mut add = |file_id: FileId,
|
let mut add = |file_id: FileId,
|
||||||
trk_filter: &dyn Fn(&crate::Track) -> bool,
|
trk_filter: &dyn Fn(&crate::Track) -> bool,
|
||||||
@@ -145,7 +134,7 @@ impl Selection {
|
|||||||
|
|
||||||
/// Drops what does not exist anymore (e.g. after an undo): files, and the tracks, segments
|
/// Drops what does not exist anymore (e.g. after an undo): files, and the tracks, segments
|
||||||
/// and waypoints of the files that remain. A selection left with nothing becomes empty.
|
/// and waypoints of the files that remain. A selection left with nothing becomes empty.
|
||||||
pub fn retain_existing(&mut self, files: &StackEntry) {
|
pub fn retain_existing(&mut self, files: &Files) {
|
||||||
let empty = match self {
|
let empty = match self {
|
||||||
Selection::Empty => false,
|
Selection::Empty => false,
|
||||||
Selection::File { file_ids } => {
|
Selection::File { file_ids } => {
|
||||||
@@ -340,7 +329,7 @@ impl Selection {
|
|||||||
/// the files, all the tracks of the file, all the segments of the track, all the waypoints
|
/// the files, all the tracks of the file, all the segments of the track, all the waypoints
|
||||||
/// of the file. All the files when nothing is selected. `None` when there is nothing to
|
/// of the file. All the files when nothing is selected. `None` when there is nothing to
|
||||||
/// select (the waypoints node, or a file that does not exist).
|
/// select (the waypoints node, or a file that does not exist).
|
||||||
pub fn all_at_level(&self, files: &StackEntry, order: &[FileId]) -> Option<Selection> {
|
pub fn all_at_level(&self, files: &Files, order: &[FileId]) -> Option<Selection> {
|
||||||
match self {
|
match self {
|
||||||
Selection::Empty | Selection::File { .. } => Some(Selection::File {
|
Selection::Empty | Selection::File { .. } => Some(Selection::File {
|
||||||
file_ids: order.iter().copied().collect(),
|
file_ids: order.iter().copied().collect(),
|
||||||
@@ -375,7 +364,7 @@ impl Selection {
|
|||||||
/// (`down`) or the previous one among the elements of the same kind, in the same place.
|
/// (`down`) or the previous one among the elements of the same kind, in the same place.
|
||||||
/// The tracks, segments and waypoints stop at their ends, the files wrap around and skip
|
/// The tracks, segments and waypoints stop at their ends, the files wrap around and skip
|
||||||
/// the ones that are already selected. `None` when there is no such element.
|
/// the ones that are already selected. `None` when there is no such element.
|
||||||
pub fn neighbour(&self, files: &StackEntry, order: &[FileId], down: bool) -> Option<Selection> {
|
pub fn neighbour(&self, files: &Files, order: &[FileId], down: bool) -> Option<Selection> {
|
||||||
match self {
|
match self {
|
||||||
Selection::Empty | Selection::Waypoints { .. } => None,
|
Selection::Empty | Selection::Waypoints { .. } => None,
|
||||||
Selection::File { file_ids } => {
|
Selection::File { file_ids } => {
|
||||||
@@ -443,7 +432,7 @@ mod tests {
|
|||||||
fn test_retain_existing() {
|
fn test_retain_existing() {
|
||||||
let file = File::default();
|
let file = File::default();
|
||||||
let (kept, gone) = (file.id, FileId::default());
|
let (kept, gone) = (file.id, FileId::default());
|
||||||
let mut files = StackEntry::default();
|
let mut files = Files::default();
|
||||||
files.insert(kept, Rc::new(file));
|
files.insert(kept, Rc::new(file));
|
||||||
|
|
||||||
let mut selection = Selection::File {
|
let mut selection = Selection::File {
|
||||||
@@ -481,7 +470,7 @@ mod tests {
|
|||||||
});
|
});
|
||||||
let (file_id, trk_id) = (file.id, file.trk[0].id);
|
let (file_id, trk_id) = (file.id, file.trk[0].id);
|
||||||
let (seg_id, wpt_id) = (file.trk[0].trkseg[0].id, file.wpt[0].id);
|
let (seg_id, wpt_id) = (file.trk[0].trkseg[0].id, file.wpt[0].id);
|
||||||
let mut files = StackEntry::default();
|
let mut files = Files::default();
|
||||||
files.insert(file_id, Rc::new(file));
|
files.insert(file_id, Rc::new(file));
|
||||||
|
|
||||||
let mut selection = Selection::Track {
|
let mut selection = Selection::Track {
|
||||||
@@ -610,7 +599,7 @@ mod tests {
|
|||||||
|
|
||||||
/// A file with 3 tracks of 2 segments each and 3 waypoints, with the ids of everything.
|
/// A file with 3 tracks of 2 segments each and 3 waypoints, with the ids of everything.
|
||||||
struct Tree {
|
struct Tree {
|
||||||
files: StackEntry,
|
files: Files,
|
||||||
order: Vec<FileId>,
|
order: Vec<FileId>,
|
||||||
file: FileId,
|
file: FileId,
|
||||||
tracks: Vec<TrackId>,
|
tracks: Vec<TrackId>,
|
||||||
@@ -644,7 +633,7 @@ mod tests {
|
|||||||
.collect(),
|
.collect(),
|
||||||
waypoints: file.wpt.iter().map(|w| w.id).collect(),
|
waypoints: file.wpt.iter().map(|w| w.id).collect(),
|
||||||
order: vec![file.id],
|
order: vec![file.id],
|
||||||
files: StackEntry::from([(file.id, Rc::new(file))]),
|
files: Files::from([(file.id, Rc::new(file))]),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -772,7 +761,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_neighbour_of_files_wraps_around_and_skips_the_selected() {
|
fn test_neighbour_of_files_wraps_around_and_skips_the_selected() {
|
||||||
let files = StackEntry::default();
|
let files = Files::default();
|
||||||
let [a, b, c] = [FileId::default(), FileId::default(), FileId::default()];
|
let [a, b, c] = [FileId::default(), FileId::default(), FileId::default()];
|
||||||
let order = [a, b, c];
|
let order = [a, b, c];
|
||||||
let select = |ids: &[FileId]| Selection::File {
|
let select = |ids: &[FileId]| Selection::File {
|
||||||
|
|||||||
@@ -1,12 +1,73 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
ClipboardIds, Delete, DeleteAll, Load, Metadata, New, NewTrack, Paste, Style, TrackId,
|
ClipboardIds, Delete, DeleteAll, File, Load, Metadata, New, NewTrack, Paste, Style, TrackId,
|
||||||
TrackSegmentId,
|
TrackSegmentId,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// An engine that remembers what the last action it was given did, for the tests to read right
|
||||||
|
/// after (see `run`).
|
||||||
|
#[derive(Default)]
|
||||||
|
struct TestEngine {
|
||||||
|
engine: Engine,
|
||||||
|
last: Outcome,
|
||||||
|
last_error: Option<CommandError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Deref for TestEngine {
|
||||||
|
type Target = Engine;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Engine {
|
||||||
|
&self.engine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::DerefMut for TestEngine {
|
||||||
|
fn deref_mut(&mut self) -> &mut Engine {
|
||||||
|
&mut self.engine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestEngine {
|
||||||
|
/// Executes an action and tells whether it changed anything.
|
||||||
|
fn run(&mut self, action: Action) -> bool {
|
||||||
|
let result = self.engine.execute(action);
|
||||||
|
self.last = result.clone().unwrap_or_default();
|
||||||
|
self.last_error = result.as_ref().err().cloned();
|
||||||
|
result.is_ok_and(|outcome| outcome.changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore(&mut self, restored: Restored) -> Outcome {
|
||||||
|
let outcome = self.engine.restore(restored);
|
||||||
|
self.last = outcome.clone();
|
||||||
|
self.last_error = None;
|
||||||
|
outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_diff(&self) -> Option<&Diff> {
|
||||||
|
self.last.diff.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_error(&self) -> Option<&CommandError> {
|
||||||
|
self.last_error.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn order_changed(&self) -> bool {
|
||||||
|
self.last.order_changed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selection_changed(&self) -> bool {
|
||||||
|
self.last.selection_changed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clipboard_changed(&self) -> bool {
|
||||||
|
self.last.clipboard_changed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn expand(intervals: &crate::Intervals, len: usize) -> Vec<u8> {
|
fn expand(intervals: &crate::Intervals, len: usize) -> Vec<u8> {
|
||||||
let mut values = Vec::new();
|
let mut values = Vec::new();
|
||||||
for (i, value) in intervals.values.iter().enumerate() {
|
for (i, value) in intervals.values.iter().enumerate() {
|
||||||
@@ -16,11 +77,11 @@ fn expand(intervals: &crate::Intervals, len: usize) -> Vec<u8> {
|
|||||||
values
|
values
|
||||||
}
|
}
|
||||||
|
|
||||||
fn edit(engine: &mut Engine, command: Command) -> bool {
|
fn edit(engine: &mut TestEngine, command: Command) -> bool {
|
||||||
engine.run(Action::Edit(command))
|
engine.run(Action::Edit(command))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new(engine: &mut Engine, name: &str) -> bool {
|
fn new(engine: &mut TestEngine, name: &str) -> bool {
|
||||||
edit(
|
edit(
|
||||||
engine,
|
engine,
|
||||||
Command::New(New {
|
Command::New(New {
|
||||||
@@ -30,7 +91,7 @@ fn new(engine: &mut Engine, name: &str) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load(engine: &mut Engine, path: &str) -> bool {
|
fn load(engine: &mut TestEngine, path: &str) -> bool {
|
||||||
let data = std::fs::read(path).unwrap();
|
let data = std::fs::read(path).unwrap();
|
||||||
edit(
|
edit(
|
||||||
engine,
|
engine,
|
||||||
@@ -50,23 +111,23 @@ fn selected(engine: &Engine) -> Vec<FileId> {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_load_file() {
|
fn test_load_file() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
assert!(load(&mut engine, "data/simple.gpx"));
|
assert!(load(&mut engine, "data/simple.gpx"));
|
||||||
assert_eq!(engine.statistics_buffer.total_distance.len(), 80);
|
assert_eq!(engine.statistics_buffer.total_distance.len(), 80);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_new_file_has_no_statistics() {
|
fn test_new_file_has_no_statistics() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
assert!(new(&mut engine, "empty"));
|
assert!(new(&mut engine, "empty"));
|
||||||
assert_eq!(engine.stack.current().unwrap().len(), 1);
|
assert_eq!(engine.history.current().unwrap().len(), 1);
|
||||||
assert_eq!(engine.order.0.len(), 1);
|
assert_eq!(engine.order.0.len(), 1);
|
||||||
assert!(engine.statistics().total_distance.is_empty());
|
assert!(engine.statistics().total_distance.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_statistics_follow_selection() {
|
fn test_statistics_follow_selection() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
assert!(load(&mut engine, "data/simple.gpx"));
|
assert!(load(&mut engine, "data/simple.gpx"));
|
||||||
let n = engine.statistics().total_distance.len();
|
let n = engine.statistics().total_distance.len();
|
||||||
assert!(n > 0);
|
assert!(n > 0);
|
||||||
@@ -74,7 +135,7 @@ fn test_statistics_follow_selection() {
|
|||||||
// a newly created (empty) file becomes the selection
|
// a newly created (empty) file becomes the selection
|
||||||
assert!(new(&mut engine, "empty"));
|
assert!(new(&mut engine, "empty"));
|
||||||
assert!(engine.statistics().total_distance.is_empty());
|
assert!(engine.statistics().total_distance.is_empty());
|
||||||
assert_eq!(engine.stack.current().unwrap().len(), 2);
|
assert_eq!(engine.history.current().unwrap().len(), 2);
|
||||||
|
|
||||||
// loading a file selects it again
|
// loading a file selects it again
|
||||||
assert!(load(&mut engine, "data/simple.gpx"));
|
assert!(load(&mut engine, "data/simple.gpx"));
|
||||||
@@ -83,7 +144,7 @@ fn test_statistics_follow_selection() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_invalid_load_is_rejected() {
|
fn test_invalid_load_is_rejected() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
assert!(!edit(
|
assert!(!edit(
|
||||||
&mut engine,
|
&mut engine,
|
||||||
Command::Load(Load {
|
Command::Load(Load {
|
||||||
@@ -91,23 +152,23 @@ fn test_invalid_load_is_rejected() {
|
|||||||
name: "file",
|
name: "file",
|
||||||
})
|
})
|
||||||
));
|
));
|
||||||
assert!(engine.stack.current().is_none());
|
assert!(engine.history.current().is_none());
|
||||||
assert!(!engine.stack.can_undo());
|
assert!(!engine.history.can_undo());
|
||||||
assert!(engine.order.0.is_empty());
|
assert!(engine.order.0.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_command_with_nothing_to_do_is_rejected() {
|
fn test_command_with_nothing_to_do_is_rejected() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
new(&mut engine, "file");
|
new(&mut engine, "file");
|
||||||
assert!(!edit(&mut engine, Command::Reverse(crate::Reverse)));
|
assert!(!edit(&mut engine, Command::Reverse(crate::Reverse)));
|
||||||
assert!(!engine.stack.can_redo());
|
assert!(!engine.history.can_redo());
|
||||||
assert_eq!(engine.stack.current().unwrap().len(), 1);
|
assert_eq!(engine.history.current().unwrap().len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_metadata_renames_selected_file() {
|
fn test_metadata_renames_selected_file() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
new(&mut engine, "before");
|
new(&mut engine, "before");
|
||||||
assert!(edit(
|
assert!(edit(
|
||||||
&mut engine,
|
&mut engine,
|
||||||
@@ -116,14 +177,14 @@ fn test_metadata_renames_selected_file() {
|
|||||||
desc: "about",
|
desc: "about",
|
||||||
})
|
})
|
||||||
));
|
));
|
||||||
let file = engine.stack.current().unwrap().values().next().unwrap();
|
let file = engine.history.current().unwrap().values().next().unwrap();
|
||||||
assert_eq!(file.info.name, "after");
|
assert_eq!(file.info.name, "after");
|
||||||
assert_eq!(file.info.desc.as_deref(), Some("about"));
|
assert_eq!(file.info.desc.as_deref(), Some("about"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_metadata_without_selection_does_not_modify_files() {
|
fn test_metadata_without_selection_does_not_modify_files() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
new(&mut engine, "file");
|
new(&mut engine, "file");
|
||||||
engine.selection = Selection::Empty;
|
engine.selection = Selection::Empty;
|
||||||
edit(
|
edit(
|
||||||
@@ -133,17 +194,17 @@ fn test_metadata_without_selection_does_not_modify_files() {
|
|||||||
desc: "",
|
desc: "",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
let file = engine.stack.current().unwrap().values().next().unwrap();
|
let file = engine.history.current().unwrap().values().next().unwrap();
|
||||||
assert_eq!(file.info.name, "file");
|
assert_eq!(file.info.name, "file");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_edit_changes_statistics_when_segments_change() {
|
fn test_edit_changes_statistics_when_segments_change() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
load(&mut engine, "data/simple.gpx");
|
load(&mut engine, "data/simple.gpx");
|
||||||
let before = engine
|
let before = engine
|
||||||
.statistics_cache
|
.statistics_cache
|
||||||
.get(engine.stack.current(), &engine.selection)[0] as *const _;
|
.get(engine.history.current(), &engine.selection)[0] as *const _;
|
||||||
edit(&mut engine, Command::NewTrack(NewTrack));
|
edit(&mut engine, Command::NewTrack(NewTrack));
|
||||||
edit(
|
edit(
|
||||||
&mut engine,
|
&mut engine,
|
||||||
@@ -156,13 +217,13 @@ fn test_edit_changes_statistics_when_segments_change() {
|
|||||||
// styling does not touch the segments: their statistics are reused
|
// styling does not touch the segments: their statistics are reused
|
||||||
let after = engine
|
let after = engine
|
||||||
.statistics_cache
|
.statistics_cache
|
||||||
.get(engine.stack.current(), &engine.selection)[0] as *const _;
|
.get(engine.history.current(), &engine.selection)[0] as *const _;
|
||||||
assert!(std::ptr::eq(before, after));
|
assert!(std::ptr::eq(before, after));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_undo_redo_keep_selection_and_order_consistent() {
|
fn test_undo_redo_keep_selection_and_order_consistent() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
new(&mut engine, "a");
|
new(&mut engine, "a");
|
||||||
let a = selected(&engine)[0];
|
let a = selected(&engine)[0];
|
||||||
new(&mut engine, "b");
|
new(&mut engine, "b");
|
||||||
@@ -180,12 +241,12 @@ fn test_undo_redo_keep_selection_and_order_consistent() {
|
|||||||
assert!(engine.run(Action::Undo));
|
assert!(engine.run(Action::Undo));
|
||||||
assert!(engine.order.0.is_empty());
|
assert!(engine.order.0.is_empty());
|
||||||
assert!(!engine.run(Action::Undo));
|
assert!(!engine.run(Action::Undo));
|
||||||
assert!(engine.stack.current().is_none());
|
assert!(engine.history.current().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_reorder_is_not_undoable_and_returning_files_go_last() {
|
fn test_reorder_is_not_undoable_and_returning_files_go_last() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
new(&mut engine, "a");
|
new(&mut engine, "a");
|
||||||
let a = engine.order.0[0];
|
let a = engine.order.0[0];
|
||||||
new(&mut engine, "b");
|
new(&mut engine, "b");
|
||||||
@@ -213,7 +274,7 @@ fn test_reorder_is_not_undoable_and_returning_files_go_last() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_structures_and_coordinates_follow_actions() {
|
fn test_structures_and_coordinates_follow_actions() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
assert!(engine.order().is_empty());
|
assert!(engine.order().is_empty());
|
||||||
load(&mut engine, "data/simple.gpx");
|
load(&mut engine, "data/simple.gpx");
|
||||||
let loaded_id = engine.order()[0];
|
let loaded_id = engine.order()[0];
|
||||||
@@ -266,7 +327,7 @@ fn test_structures_and_coordinates_follow_actions() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_selection_changed_flag() {
|
fn test_selection_changed_flag() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
new(&mut engine, "a");
|
new(&mut engine, "a");
|
||||||
let a = engine.order()[0];
|
let a = engine.order()[0];
|
||||||
// a new file is selected by the edit
|
// a new file is selected by the edit
|
||||||
@@ -284,7 +345,7 @@ fn test_selection_changed_flag() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_undo_updates_statistics() {
|
fn test_undo_updates_statistics() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
load(&mut engine, "data/simple.gpx");
|
load(&mut engine, "data/simple.gpx");
|
||||||
assert!(!engine.statistics().total_distance.is_empty());
|
assert!(!engine.statistics().total_distance.is_empty());
|
||||||
engine.run(Action::Undo);
|
engine.run(Action::Undo);
|
||||||
@@ -296,7 +357,7 @@ fn test_undo_updates_statistics() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_selection_actions() {
|
fn test_selection_actions() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
new(&mut engine, "a");
|
new(&mut engine, "a");
|
||||||
let a = selected(&engine)[0];
|
let a = selected(&engine)[0];
|
||||||
new(&mut engine, "b");
|
new(&mut engine, "b");
|
||||||
@@ -316,14 +377,14 @@ fn test_selection_actions() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_selection_does_not_create_undo_steps() {
|
fn test_selection_does_not_create_undo_steps() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
new(&mut engine, "a");
|
new(&mut engine, "a");
|
||||||
select_all(&mut engine);
|
select_all(&mut engine);
|
||||||
engine.run(Action::Undo);
|
engine.run(Action::Undo);
|
||||||
assert!(engine.stack.current().is_none());
|
assert!(engine.history.current().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn select_elements(engine: &mut Engine, selection: Selection, mode: SelectMode) -> bool {
|
fn select_elements(engine: &mut TestEngine, selection: Selection, mode: SelectMode) -> bool {
|
||||||
engine.run(Action::Select { selection, mode })
|
engine.run(Action::Select { selection, mode })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,21 +394,21 @@ fn files(ids: &[FileId]) -> Selection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn select_files(engine: &mut Engine, ids: &[FileId]) -> bool {
|
fn select_files(engine: &mut TestEngine, ids: &[FileId]) -> bool {
|
||||||
select_elements(engine, files(ids), SelectMode::Replace)
|
select_elements(engine, files(ids), SelectMode::Replace)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn select_all(engine: &mut Engine) -> bool {
|
fn select_all(engine: &mut TestEngine) -> bool {
|
||||||
let ids = engine.order().to_vec();
|
let ids = engine.order().to_vec();
|
||||||
select_files(engine, &ids)
|
select_files(engine, &ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_select_elements() {
|
fn test_select_elements() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
load(&mut engine, "data/with_tracks_and_segments.gpx");
|
load(&mut engine, "data/with_tracks_and_segments.gpx");
|
||||||
let file = engine
|
let file = engine
|
||||||
.stack
|
.history
|
||||||
.current()
|
.current()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.values()
|
.values()
|
||||||
@@ -443,10 +504,10 @@ fn test_select_elements() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_toggle_elements() {
|
fn test_toggle_elements() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
load(&mut engine, "data/with_tracks_and_segments.gpx");
|
load(&mut engine, "data/with_tracks_and_segments.gpx");
|
||||||
let file = engine
|
let file = engine
|
||||||
.stack
|
.history
|
||||||
.current()
|
.current()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.values()
|
.values()
|
||||||
@@ -458,7 +519,7 @@ fn test_toggle_elements() {
|
|||||||
file_id: file.id,
|
file_id: file.id,
|
||||||
trk_ids: ids.iter().copied().collect(),
|
trk_ids: ids.iter().copied().collect(),
|
||||||
};
|
};
|
||||||
let toggle = |engine: &mut Engine, ids: &[TrackId]| {
|
let toggle = |engine: &mut TestEngine, ids: &[TrackId]| {
|
||||||
select_elements(engine, tracks(ids), SelectMode::Toggle)
|
select_elements(engine, tracks(ids), SelectMode::Toggle)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -477,10 +538,10 @@ fn test_toggle_elements() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_selected_elements_are_dropped_when_they_disappear() {
|
fn test_selected_elements_are_dropped_when_they_disappear() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
load(&mut engine, "data/with_tracks_and_segments.gpx");
|
load(&mut engine, "data/with_tracks_and_segments.gpx");
|
||||||
let file = engine
|
let file = engine
|
||||||
.stack
|
.history
|
||||||
.current()
|
.current()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.values()
|
.values()
|
||||||
@@ -502,7 +563,7 @@ fn test_selected_elements_are_dropped_when_they_disappear() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_can_undo_and_redo() {
|
fn test_can_undo_and_redo() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
assert!(!engine.can_undo() && !engine.can_redo());
|
assert!(!engine.can_undo() && !engine.can_redo());
|
||||||
new(&mut engine, "a");
|
new(&mut engine, "a");
|
||||||
assert!(engine.can_undo() && !engine.can_redo());
|
assert!(engine.can_undo() && !engine.can_redo());
|
||||||
@@ -520,7 +581,7 @@ fn test_can_undo_and_redo() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_select_all_follows_the_selected_level() {
|
fn test_select_all_follows_the_selected_level() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
new(&mut engine, "a");
|
new(&mut engine, "a");
|
||||||
new(&mut engine, "b");
|
new(&mut engine, "b");
|
||||||
let order = engine.order().to_vec();
|
let order = engine.order().to_vec();
|
||||||
@@ -533,10 +594,10 @@ fn test_select_all_follows_the_selected_level() {
|
|||||||
assert!(!engine.run(Action::SelectAll));
|
assert!(!engine.run(Action::SelectAll));
|
||||||
|
|
||||||
// the tracks of a file
|
// the tracks of a file
|
||||||
let load = |engine: &mut Engine| load(engine, "data/with_tracks_and_segments.gpx");
|
let load = |engine: &mut TestEngine| load(engine, "data/with_tracks_and_segments.gpx");
|
||||||
load(&mut engine);
|
load(&mut engine);
|
||||||
let file = engine
|
let file = engine
|
||||||
.stack
|
.history
|
||||||
.current()
|
.current()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.values()
|
.values()
|
||||||
@@ -581,13 +642,13 @@ fn test_select_all_follows_the_selected_level() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_arrow_select() {
|
fn test_arrow_select() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
new(&mut engine, "file");
|
new(&mut engine, "file");
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
edit(&mut engine, Command::NewTrack(NewTrack));
|
edit(&mut engine, Command::NewTrack(NewTrack));
|
||||||
}
|
}
|
||||||
let file = engine
|
let file = engine
|
||||||
.stack
|
.history
|
||||||
.current()
|
.current()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.values()
|
.values()
|
||||||
@@ -600,7 +661,7 @@ fn test_arrow_select() {
|
|||||||
file_id: file.id,
|
file_id: file.id,
|
||||||
trk_ids: ids.iter().copied().collect(),
|
trk_ids: ids.iter().copied().collect(),
|
||||||
};
|
};
|
||||||
let arrow = |engine: &mut Engine, down, add| engine.run(Action::ArrowSelect { down, add });
|
let arrow = |engine: &mut TestEngine, down, add| engine.run(Action::ArrowSelect { down, add });
|
||||||
|
|
||||||
select_elements(&mut engine, tracks(&[ids[0]]), SelectMode::Replace);
|
select_elements(&mut engine, tracks(&[ids[0]]), SelectMode::Replace);
|
||||||
// without shift the next one replaces the selection
|
// without shift the next one replaces the selection
|
||||||
@@ -625,7 +686,7 @@ fn test_arrow_select() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_arrow_select_does_not_create_undo_steps() {
|
fn test_arrow_select_does_not_create_undo_steps() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
new(&mut engine, "a");
|
new(&mut engine, "a");
|
||||||
new(&mut engine, "b");
|
new(&mut engine, "b");
|
||||||
let a = engine.order()[0];
|
let a = engine.order()[0];
|
||||||
@@ -642,7 +703,7 @@ fn test_arrow_select_does_not_create_undo_steps() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A file named `name` with `n` tracks, selected.
|
/// A file named `name` with `n` tracks, selected.
|
||||||
fn file_with_tracks(engine: &mut Engine, name: &str, n: usize) -> FileId {
|
fn file_with_tracks(engine: &mut TestEngine, name: &str, n: usize) -> FileId {
|
||||||
new(engine, name);
|
new(engine, name);
|
||||||
for _ in 0..n {
|
for _ in 0..n {
|
||||||
edit(engine, Command::NewTrack(NewTrack));
|
edit(engine, Command::NewTrack(NewTrack));
|
||||||
@@ -660,13 +721,13 @@ fn track_ids(engine: &Engine, id: FileId) -> Vec<TrackId> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn paste(engine: &mut Engine) -> bool {
|
fn paste(engine: &mut TestEngine) -> bool {
|
||||||
edit(engine, Command::Paste(Paste))
|
edit(engine, Command::Paste(Paste))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_copy_and_paste() {
|
fn test_copy_and_paste() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
let a = file_with_tracks(&mut engine, "a", 2);
|
let a = file_with_tracks(&mut engine, "a", 2);
|
||||||
let b = file_with_tracks(&mut engine, "b", 0);
|
let b = file_with_tracks(&mut engine, "b", 0);
|
||||||
assert!(engine.clipboard().is_none() && !engine.can_paste());
|
assert!(engine.clipboard().is_none() && !engine.can_paste());
|
||||||
@@ -723,7 +784,7 @@ fn test_copy_and_paste() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cut_and_paste_moves_after_the_paste() {
|
fn test_cut_and_paste_moves_after_the_paste() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
let a = file_with_tracks(&mut engine, "a", 2);
|
let a = file_with_tracks(&mut engine, "a", 2);
|
||||||
let b = file_with_tracks(&mut engine, "b", 0);
|
let b = file_with_tracks(&mut engine, "b", 0);
|
||||||
let tracks = track_ids(&engine, a);
|
let tracks = track_ids(&engine, a);
|
||||||
@@ -751,7 +812,7 @@ fn test_cut_and_paste_moves_after_the_paste() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_clipboard_survives_what_happens_to_the_elements() {
|
fn test_clipboard_survives_what_happens_to_the_elements() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
let a = file_with_tracks(&mut engine, "a", 2);
|
let a = file_with_tracks(&mut engine, "a", 2);
|
||||||
let tracks = track_ids(&engine, a);
|
let tracks = track_ids(&engine, a);
|
||||||
select_elements(
|
select_elements(
|
||||||
@@ -800,7 +861,7 @@ fn test_clipboard_survives_what_happens_to_the_elements() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_copy_replaces_the_clipboard_and_does_not_change_the_history() {
|
fn test_copy_replaces_the_clipboard_and_does_not_change_the_history() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
let a = file_with_tracks(&mut engine, "a", 0);
|
let a = file_with_tracks(&mut engine, "a", 0);
|
||||||
let b = file_with_tracks(&mut engine, "b", 0);
|
let b = file_with_tracks(&mut engine, "b", 0);
|
||||||
select_files(&mut engine, &[a]);
|
select_files(&mut engine, &[a]);
|
||||||
@@ -824,7 +885,7 @@ fn test_copy_replaces_the_clipboard_and_does_not_change_the_history() {
|
|||||||
fn test_move_elements_is_an_edit_and_selects_what_moved() {
|
fn test_move_elements_is_an_edit_and_selects_what_moved() {
|
||||||
use crate::{Move, MoveTarget};
|
use crate::{Move, MoveTarget};
|
||||||
|
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
let a = file_with_tracks(&mut engine, "a", 3);
|
let a = file_with_tracks(&mut engine, "a", 3);
|
||||||
let tracks = track_ids(&engine, a);
|
let tracks = track_ids(&engine, a);
|
||||||
let moved = Selection::Track {
|
let moved = Selection::Track {
|
||||||
@@ -864,7 +925,7 @@ fn test_move_elements_is_an_edit_and_selects_what_moved() {
|
|||||||
fn test_added_files_are_reported_in_the_order_of_the_files() {
|
fn test_added_files_are_reported_in_the_order_of_the_files() {
|
||||||
use crate::LoadFiles;
|
use crate::LoadFiles;
|
||||||
|
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
let datas: Vec<Vec<u8>> = (0..12)
|
let datas: Vec<Vec<u8>> = (0..12)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
format!(r#"<gpx version="1.1"><metadata><name>{i}</name></metadata></gpx>"#)
|
format!(r#"<gpx version="1.1"><metadata><name>{i}</name></metadata></gpx>"#)
|
||||||
@@ -891,7 +952,7 @@ fn test_added_files_are_reported_in_the_order_of_the_files() {
|
|||||||
fn test_load_several_files_is_one_edit() {
|
fn test_load_several_files_is_one_edit() {
|
||||||
use crate::LoadFiles;
|
use crate::LoadFiles;
|
||||||
|
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
new(&mut engine, "before");
|
new(&mut engine, "before");
|
||||||
let gpx = |name: &str| {
|
let gpx = |name: &str| {
|
||||||
format!(r#"<gpx version="1.1"><metadata><name>{name}</name></metadata></gpx>"#).into_bytes()
|
format!(r#"<gpx version="1.1"><metadata><name>{name}</name></metadata></gpx>"#).into_bytes()
|
||||||
@@ -929,7 +990,7 @@ fn test_load_several_files_is_one_edit() {
|
|||||||
fn test_create_edit_and_move_a_waypoint() {
|
fn test_create_edit_and_move_a_waypoint() {
|
||||||
use crate::{EditWaypoint, MoveWaypoint, NewWaypoint};
|
use crate::{EditWaypoint, MoveWaypoint, NewWaypoint};
|
||||||
|
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
new(&mut engine, "file");
|
new(&mut engine, "file");
|
||||||
let file = engine.order()[0];
|
let file = engine.order()[0];
|
||||||
assert!(edit(
|
assert!(edit(
|
||||||
@@ -1018,7 +1079,7 @@ fn test_create_edit_and_move_a_waypoint() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_categories_are_shared_and_kept_by_the_history() {
|
fn test_categories_are_shared_and_kept_by_the_history() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
assert!(engine.categories().surface.names().is_empty());
|
assert!(engine.categories().surface.names().is_empty());
|
||||||
|
|
||||||
load(&mut engine, "data/with_highway.gpx");
|
load(&mut engine, "data/with_highway.gpx");
|
||||||
@@ -1063,7 +1124,7 @@ fn test_categories_are_shared_and_kept_by_the_history() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_trackpoint() {
|
fn test_trackpoint() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
load(&mut engine, "data/simple.gpx");
|
load(&mut engine, "data/simple.gpx");
|
||||||
let file = engine.order()[0];
|
let file = engine.order()[0];
|
||||||
let structure = engine.file_structure(&file).unwrap();
|
let structure = engine.file_structure(&file).unwrap();
|
||||||
@@ -1081,7 +1142,7 @@ fn test_trackpoint() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_export_writes_the_file_as_it_is() {
|
fn test_export_writes_the_file_as_it_is() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
assert!(
|
assert!(
|
||||||
engine
|
engine
|
||||||
.export(&FileId::default(), ExportOptions::ALL)
|
.export(&FileId::default(), ExportOptions::ALL)
|
||||||
@@ -1092,7 +1153,7 @@ fn test_export_writes_the_file_as_it_is() {
|
|||||||
let id = engine.order()[0];
|
let id = engine.order()[0];
|
||||||
let bytes = engine.export(&id, ExportOptions::ALL).unwrap();
|
let bytes = engine.export(&id, ExportOptions::ALL).unwrap();
|
||||||
let again = crate::parse(&bytes, &mut engine.categories().clone()).unwrap();
|
let again = crate::parse(&bytes, &mut engine.categories().clone()).unwrap();
|
||||||
let file = engine.stack.current().unwrap()[&id].clone();
|
let file = engine.history.current().unwrap()[&id].clone();
|
||||||
assert_eq!(again.trk.len(), file.trk.len());
|
assert_eq!(again.trk.len(), file.trk.len());
|
||||||
assert_eq!(again.trk[0].trkseg[0].len(), file.trk[0].trkseg[0].len());
|
assert_eq!(again.trk[0].trkseg[0].len(), file.trk[0].trkseg[0].len());
|
||||||
assert!(again.trk[0].trkseg[0][0].time.is_some());
|
assert!(again.trk[0].trkseg[0][0].time.is_some());
|
||||||
@@ -1109,7 +1170,7 @@ fn test_export_writes_the_file_as_it_is() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_exportable_data_is_the_union_over_the_files() {
|
fn test_exportable_data_is_the_union_over_the_files() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
assert_eq!(engine.exportable_data(&[]), ExportOptions::NONE);
|
assert_eq!(engine.exportable_data(&[]), ExportOptions::NONE);
|
||||||
|
|
||||||
load(&mut engine, "data/simple.gpx");
|
load(&mut engine, "data/simple.gpx");
|
||||||
@@ -1128,7 +1189,7 @@ fn test_exportable_data_is_the_union_over_the_files() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_file_statistics_are_the_ones_of_the_whole_file() {
|
fn test_file_statistics_are_the_ones_of_the_whole_file() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
assert!(engine.file_statistics(&FileId::default()).is_none());
|
assert!(engine.file_statistics(&FileId::default()).is_none());
|
||||||
|
|
||||||
load(&mut engine, "data/simple.gpx");
|
load(&mut engine, "data/simple.gpx");
|
||||||
@@ -1155,7 +1216,7 @@ fn test_file_statistics_are_the_ones_of_the_whole_file() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_reduction_distances_follow_the_selection() {
|
fn test_reduction_distances_follow_the_selection() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
assert!(engine.reduction_distances().is_empty());
|
assert!(engine.reduction_distances().is_empty());
|
||||||
|
|
||||||
load(&mut engine, "data/simple.gpx");
|
load(&mut engine, "data/simple.gpx");
|
||||||
@@ -1173,7 +1234,7 @@ fn test_reduction_distances_follow_the_selection() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_routing_describes_the_anchors_of_the_selection() {
|
fn test_routing_describes_the_anchors_of_the_selection() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
assert!(engine.routing().anchor_indices.is_empty());
|
assert!(engine.routing().anchor_indices.is_empty());
|
||||||
|
|
||||||
load(&mut engine, "data/simple.gpx");
|
load(&mut engine, "data/simple.gpx");
|
||||||
@@ -1206,7 +1267,7 @@ fn test_routing_describes_the_anchors_of_the_selection() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_last_error_tells_why_an_edit_did_nothing() {
|
fn test_last_error_tells_why_an_edit_did_nothing() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
assert!(engine.last_error().is_none());
|
assert!(engine.last_error().is_none());
|
||||||
|
|
||||||
// invalid data
|
// invalid data
|
||||||
@@ -1239,7 +1300,7 @@ fn test_last_error_tells_why_an_edit_did_nothing() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_an_edit_that_changes_nothing_is_not_an_undo_step() {
|
fn test_an_edit_that_changes_nothing_is_not_an_undo_step() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
new(&mut engine, "file");
|
new(&mut engine, "file");
|
||||||
// "file" is selected: naming it the same still builds a new file, but selecting nothing
|
// "file" is selected: naming it the same still builds a new file, but selecting nothing
|
||||||
// leaves nothing to rename
|
// leaves nothing to rename
|
||||||
@@ -1265,46 +1326,111 @@ fn test_an_edit_that_changes_nothing_is_not_an_undo_step() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_execute_returns_what_the_action_did() {
|
fn test_execute_returns_what_the_action_did() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = TestEngine::default();
|
||||||
let data = std::fs::read("data/simple.gpx").unwrap();
|
let data = std::fs::read("data/simple.gpx").unwrap();
|
||||||
let outcome = engine.execute(Action::Edit(Command::Load(Load {
|
let outcome = engine
|
||||||
|
.execute(Action::Edit(Command::Load(Load {
|
||||||
data: &data,
|
data: &data,
|
||||||
name: "file",
|
name: "file",
|
||||||
})));
|
})))
|
||||||
assert!(outcome.changed && outcome.error.is_none());
|
.unwrap();
|
||||||
|
assert!(outcome.changed);
|
||||||
assert_eq!(outcome.diff.as_ref().unwrap().added, engine.order());
|
assert_eq!(outcome.diff.as_ref().unwrap().added, engine.order());
|
||||||
assert!(outcome.order_changed && outcome.selection_changed);
|
assert!(outcome.order_changed && outcome.selection_changed);
|
||||||
assert!(!outcome.clipboard_changed);
|
assert!(!outcome.clipboard_changed);
|
||||||
|
|
||||||
// the outcome of an action does not depend on what is done after it
|
// the outcome of an action does not depend on what is done after it
|
||||||
let kept = outcome.clone();
|
let kept = outcome.clone();
|
||||||
engine.execute(Action::Undo);
|
engine.execute(Action::Undo).unwrap();
|
||||||
assert_eq!(outcome, kept);
|
assert_eq!(outcome, kept);
|
||||||
|
|
||||||
// a selection only changes the selection
|
// a selection only changes the selection
|
||||||
engine.execute(Action::Redo);
|
engine.execute(Action::Redo).unwrap();
|
||||||
let outcome = engine.execute(Action::Select {
|
let selection = files(engine.order());
|
||||||
selection: files(engine.order()),
|
let outcome = engine
|
||||||
|
.execute(Action::Select {
|
||||||
|
selection,
|
||||||
mode: SelectMode::Replace,
|
mode: SelectMode::Replace,
|
||||||
});
|
})
|
||||||
|
.unwrap();
|
||||||
assert!(outcome.changed && outcome.selection_changed);
|
assert!(outcome.changed && outcome.selection_changed);
|
||||||
assert!(outcome.diff.is_none() && !outcome.order_changed);
|
assert!(outcome.diff.is_none() && !outcome.order_changed);
|
||||||
|
|
||||||
// an edit with nothing to do says why
|
// an edit with nothing to do says why
|
||||||
engine.execute(Action::Select {
|
engine
|
||||||
|
.execute(Action::Select {
|
||||||
selection: Selection::Empty,
|
selection: Selection::Empty,
|
||||||
mode: SelectMode::Replace,
|
mode: SelectMode::Replace,
|
||||||
});
|
})
|
||||||
let outcome = engine.execute(Action::Edit(Command::Reverse(crate::Reverse)));
|
.unwrap();
|
||||||
assert!(!outcome.changed);
|
let result = engine.execute(Action::Edit(Command::Reverse(crate::Reverse)));
|
||||||
assert_eq!(outcome.error, Some(CommandError::NothingToDo));
|
assert_eq!(result, Err(CommandError::NothingToDo));
|
||||||
assert_eq!(outcome.diff, None);
|
|
||||||
|
|
||||||
// a failing one too
|
// a failing one too
|
||||||
let outcome = engine.execute(Action::Edit(Command::Load(Load {
|
let result = engine.execute(Action::Edit(Command::Load(Load {
|
||||||
data: b"<gpx><trk></gpx>",
|
data: b"<gpx><trk></gpx>",
|
||||||
name: "file",
|
name: "file",
|
||||||
})));
|
})));
|
||||||
|
assert!(matches!(result, Err(CommandError::InvalidData(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the engine does when it is overwritten with what is stored
|
||||||
|
|
||||||
|
fn restored_from(engine: &Engine, keep: &[FileId], extra: Vec<Rc<File>>) -> crate::Restored {
|
||||||
|
let mut files: Vec<Rc<File>> = keep
|
||||||
|
.iter()
|
||||||
|
.map(|id| engine.snapshot().files[id].clone())
|
||||||
|
.collect();
|
||||||
|
files.extend(extra);
|
||||||
|
crate::Restored {
|
||||||
|
order: files.iter().map(|file| file.id).collect(),
|
||||||
|
files,
|
||||||
|
categories: engine.categories().clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_overwriting_reports_what_was_added_removed_and_modified() {
|
||||||
|
let mut engine = TestEngine::default();
|
||||||
|
new(&mut engine, "kept");
|
||||||
|
new(&mut engine, "modified");
|
||||||
|
new(&mut engine, "removed");
|
||||||
|
let [kept, modified, removed] = engine.order()[..] else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
let mut changed = (*engine.snapshot().files[&modified]).clone();
|
||||||
|
changed.info.name = "other name".into();
|
||||||
|
let added = File::default();
|
||||||
|
let added_id = added.id;
|
||||||
|
|
||||||
|
let restored = restored_from(&engine, &[kept], vec![Rc::new(changed), Rc::new(added)]);
|
||||||
|
let outcome = engine.restore(restored);
|
||||||
|
assert!(outcome.changed);
|
||||||
|
let diff = outcome.diff.unwrap();
|
||||||
|
assert_eq!(diff.added, vec![added_id]);
|
||||||
|
assert_eq!(diff.removed, vec![removed]);
|
||||||
|
assert_eq!(diff.modified, vec![modified]);
|
||||||
|
assert!(outcome.order_changed);
|
||||||
|
assert_eq!(engine.order(), &[kept, modified, added_id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_overwriting_with_the_same_files_changes_nothing() {
|
||||||
|
let mut engine = TestEngine::default();
|
||||||
|
new(&mut engine, "a");
|
||||||
|
let ids = engine.order().to_vec();
|
||||||
|
let outcome = engine.restore(restored_from(&engine, &ids, vec![]));
|
||||||
assert!(!outcome.changed);
|
assert!(!outcome.changed);
|
||||||
assert!(matches!(outcome.error, Some(CommandError::InvalidData(_))));
|
assert!(outcome.diff.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_overwriting_forgets_the_history() {
|
||||||
|
let mut engine = TestEngine::default();
|
||||||
|
new(&mut engine, "a");
|
||||||
|
new(&mut engine, "b");
|
||||||
|
assert!(engine.can_undo());
|
||||||
|
let ids = engine.order().to_vec();
|
||||||
|
engine.restore(restored_from(&engine, &ids, vec![]));
|
||||||
|
assert!(!engine.can_undo());
|
||||||
}
|
}
|
||||||
@@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
File, FileId, FileInfo, Track, TrackId, TrackInfo, TrackSegment, TrackSegmentId, Trackpoint,
|
File, FileId, FileInfo, Track, TrackId, TrackInfo, TrackSegment, TrackSegmentId,
|
||||||
TrackpointCategories, TrackpointChunk, TrackpointChunkId, Waypoint, WaypointChunk,
|
TrackpointCategories, TrackpointChunk, TrackpointChunkId, Waypoint, WaypointChunk,
|
||||||
WaypointChunkId, Waypoints,
|
WaypointChunkId, Waypoints,
|
||||||
};
|
};
|
||||||
@@ -144,22 +144,21 @@ pub fn decode_file(bytes: &[u8]) -> Result<FileRecord, DecodeError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn encode_trackpoints(chunk: &TrackpointChunk) -> Vec<u8> {
|
/// The bytes of a chunk of trackpoints. Its surfaces, highways and scales are written as names,
|
||||||
encode(
|
/// the codes of `categories` being the ones of the engine that has the chunk.
|
||||||
&chunk
|
pub fn encode_trackpoints(chunk: &TrackpointChunk, categories: &TrackpointCategories) -> Vec<u8> {
|
||||||
.trkpt
|
encode(&v1::TrackpointChunk1::new(&chunk.trkpt, categories))
|
||||||
.iter()
|
|
||||||
.map(v1::Trackpoint1::from)
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decode_trackpoints(id: Uuid, bytes: &[u8]) -> Result<TrackpointChunk, DecodeError> {
|
/// A chunk of trackpoints from its bytes. The names it holds are added to `categories` if it does
|
||||||
|
/// not know them yet, and the trackpoints refer to them by the codes of `categories`.
|
||||||
|
pub fn decode_trackpoints(
|
||||||
|
id: Uuid,
|
||||||
|
bytes: &[u8],
|
||||||
|
categories: &mut TrackpointCategories,
|
||||||
|
) -> Result<TrackpointChunk, DecodeError> {
|
||||||
let trkpt = match split(bytes)? {
|
let trkpt = match split(bytes)? {
|
||||||
(1, data) => parse::<Vec<v1::Trackpoint1>>(data)?
|
(1, data) => parse::<v1::TrackpointChunk1>(data)?.into_points(categories),
|
||||||
.into_iter()
|
|
||||||
.map(Trackpoint::from)
|
|
||||||
.collect(),
|
|
||||||
(version, _) => return Err(DecodeError::UnknownVersion(version)),
|
(version, _) => return Err(DecodeError::UnknownVersion(version)),
|
||||||
};
|
};
|
||||||
Ok(TrackpointChunk {
|
Ok(TrackpointChunk {
|
||||||
@@ -203,22 +202,11 @@ pub fn decode_order(bytes: &[u8]) -> Result<Vec<FileId>, DecodeError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::{Author, Link, LngLat};
|
use crate::{Author, Chunk, Link, LngLat, Trackpoint};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -246,10 +234,11 @@ mod tests {
|
|||||||
hr: Some(140 + u16::from(n)),
|
hr: Some(140 + u16::from(n)),
|
||||||
cad: Some(80),
|
cad: Some(80),
|
||||||
power: Some(250),
|
power: Some(250),
|
||||||
surface: Some(1),
|
// the first has the second name of the table, which comes first in the chunk
|
||||||
highway: Some(2),
|
surface: Some(if n == 0 { 1 } else { 0 }),
|
||||||
sac_scale: Some(3),
|
highway: Some(0),
|
||||||
mtb_scale: Some(4),
|
sac_scale: Some(0),
|
||||||
|
mtb_scale: Some(0),
|
||||||
anchor: Some(n),
|
anchor: Some(n),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -439,12 +428,34 @@ mod tests {
|
|||||||
assert!(built.trk.is_empty() && built.wpt.is_empty());
|
assert!(built.trk.is_empty() && built.wpt.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The names of the categories of a trackpoint.
|
||||||
|
fn names(point: &Trackpoint, categories: &TrackpointCategories) -> [Option<String>; 4] {
|
||||||
|
let name = |table: &crate::Categories, code: Option<u8>| {
|
||||||
|
code.and_then(|code| table.name(code)).map(str::to_owned)
|
||||||
|
};
|
||||||
|
[
|
||||||
|
name(&categories.surface, point.surface),
|
||||||
|
name(&categories.highway, point.highway),
|
||||||
|
name(&categories.sac_scale, point.sac_scale),
|
||||||
|
name(&categories.mtb_scale, point.mtb_scale),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_chunks_keep_their_identity_and_items() {
|
fn test_chunks_keep_their_identity_and_items() {
|
||||||
let chunk = &sample().trk[0].trkseg[0].chunks()[0].clone();
|
let chunk = &sample().trk[0].trkseg[0].chunks()[0].clone();
|
||||||
let decoded = decode_trackpoints(chunk.id.0, &encode_trackpoints(chunk)).unwrap();
|
let categories = categories();
|
||||||
|
let mut reader = categories.clone();
|
||||||
|
let decoded = decode_trackpoints(
|
||||||
|
chunk.id.0,
|
||||||
|
&encode_trackpoints(chunk, &categories),
|
||||||
|
&mut reader,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(decoded.id, chunk.id);
|
assert_eq!(decoded.id, chunk.id);
|
||||||
assert_eq!(decoded.trkpt.len(), 3);
|
assert_eq!(decoded.trkpt.len(), 3);
|
||||||
|
// read by an engine that has the same table, the trackpoints are the same
|
||||||
|
assert_eq!(reader, categories);
|
||||||
for (a, b) in decoded.trkpt.iter().zip(&chunk.trkpt) {
|
for (a, b) in decoded.trkpt.iter().zip(&chunk.trkpt) {
|
||||||
assert_eq!(format!("{a:?}"), format!("{b:?}"));
|
assert_eq!(format!("{a:?}"), format!("{b:?}"));
|
||||||
}
|
}
|
||||||
@@ -459,14 +470,99 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_order_and_categories_round_trip() {
|
fn test_a_chunk_means_the_same_to_an_engine_with_another_table() {
|
||||||
|
let chunk = &sample().trk[0].trkseg[0].chunks()[0].clone();
|
||||||
|
let writer = categories();
|
||||||
|
let bytes = encode_trackpoints(chunk, &writer);
|
||||||
|
|
||||||
|
// an engine that learned other names first, and the same ones in another order
|
||||||
|
let mut reader = TrackpointCategories::default();
|
||||||
|
reader.surface.code("sand");
|
||||||
|
reader.surface.code("gravel");
|
||||||
|
reader.surface.code("asphalt");
|
||||||
|
reader.highway.code("track");
|
||||||
|
let decoded = decode_trackpoints(chunk.id.0, &bytes, &mut reader).unwrap();
|
||||||
|
|
||||||
|
for (read, written) in decoded.trkpt.iter().zip(&chunk.trkpt) {
|
||||||
|
assert_eq!(names(read, &reader), names(written, &writer));
|
||||||
|
}
|
||||||
|
// the codes are the ones of the reader, which learned what it did not know
|
||||||
|
assert_eq!(decoded.trkpt[0].surface, Some(1));
|
||||||
|
assert_eq!(decoded.trkpt[1].surface, Some(2));
|
||||||
|
assert_eq!(reader.highway.names(), ["track", "path"]);
|
||||||
|
assert_eq!(decoded.trkpt[0].highway, Some(1));
|
||||||
|
assert_eq!(reader.sac_scale.names(), ["hiking"]);
|
||||||
|
// and an empty engine learns them in the order they come
|
||||||
|
let mut empty = TrackpointCategories::default();
|
||||||
|
decode_trackpoints(chunk.id.0, &bytes, &mut empty).unwrap();
|
||||||
|
assert_eq!(empty.surface.names(), ["gravel", "asphalt"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_a_chunk_only_carries_the_names_it_uses() {
|
||||||
|
let mut writer = TrackpointCategories::default();
|
||||||
|
for i in 0..10 {
|
||||||
|
writer.surface.code(&format!("surface-{i}"));
|
||||||
|
}
|
||||||
|
let mut chunk = sample().trk[0].trkseg[0].chunks()[0].trkpt.clone();
|
||||||
|
chunk.iter_mut().for_each(|p| p.surface = Some(7));
|
||||||
|
let chunk = TrackpointChunk::new(chunk);
|
||||||
|
let bytes = encode_trackpoints(&chunk, &writer);
|
||||||
|
|
||||||
|
let mut reader = TrackpointCategories::default();
|
||||||
|
let decoded = decode_trackpoints(chunk.id.0, &bytes, &mut reader).unwrap();
|
||||||
|
assert_eq!(reader.surface.names(), ["surface-7"]);
|
||||||
|
assert!(decoded.trkpt.iter().all(|p| p.surface == Some(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_codes_without_a_name_are_not_stored_and_names_without_room_are_lost() {
|
||||||
|
let mut writer = categories();
|
||||||
|
let mut chunk = sample().trk[0].trkseg[0].chunks()[0].trkpt.clone();
|
||||||
|
// a code that the table does not know
|
||||||
|
chunk[1].surface = Some(200);
|
||||||
|
let chunk = TrackpointChunk::new(chunk);
|
||||||
|
let bytes = encode_trackpoints(&chunk, &writer);
|
||||||
|
let decoded =
|
||||||
|
decode_trackpoints(chunk.id.0, &bytes, &mut TrackpointCategories::default()).unwrap();
|
||||||
|
assert_eq!(decoded.trkpt[1].surface, None);
|
||||||
|
assert_eq!(decoded.trkpt[0].surface, Some(0));
|
||||||
|
|
||||||
|
// an engine whose table is full keeps the names it has and loses the other ones
|
||||||
|
let mut full = TrackpointCategories::default();
|
||||||
|
for i in 0..crate::Categories::CAPACITY {
|
||||||
|
full.surface.code(&format!("other-{i}"));
|
||||||
|
}
|
||||||
|
writer.surface.code("other-3");
|
||||||
|
let mut chunk = chunk.trkpt.clone();
|
||||||
|
chunk[0].surface = writer.surface.code("other-3");
|
||||||
|
chunk[1].surface = writer.surface.code("gravel");
|
||||||
|
let chunk = TrackpointChunk::new(chunk);
|
||||||
|
let decoded =
|
||||||
|
decode_trackpoints(chunk.id.0, &encode_trackpoints(&chunk, &writer), &mut full)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(decoded.trkpt[0].surface, Some(3));
|
||||||
|
assert_eq!(decoded.trkpt[1].surface, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_chunks_read_one_after_the_other_share_the_table() {
|
||||||
|
let writer = categories();
|
||||||
|
let chunk = sample().trk[0].trkseg[0].chunks()[0].clone();
|
||||||
|
let bytes = encode_trackpoints(&chunk, &writer);
|
||||||
|
let mut reader = TrackpointCategories::default();
|
||||||
|
let a = decode_trackpoints(id(1), &bytes, &mut reader).unwrap();
|
||||||
|
let b = decode_trackpoints(id(2), &bytes, &mut reader).unwrap();
|
||||||
|
for (a, b) in a.trkpt.iter().zip(&b.trkpt) {
|
||||||
|
assert_eq!((a.surface, a.highway), (b.surface, b.highway));
|
||||||
|
}
|
||||||
|
assert_eq!(reader.surface.names().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_order_round_trips() {
|
||||||
let order = vec![FileId(id(9)), FileId(id(8))];
|
let order = vec![FileId(id(9)), FileId(id(8))];
|
||||||
assert_eq!(decode_order(&encode_order(&order)).unwrap(), order);
|
assert_eq!(decode_order(&encode_order(&order)).unwrap(), order);
|
||||||
let categories = categories();
|
|
||||||
assert_eq!(
|
|
||||||
decode_categories(&encode_categories(&categories)).unwrap(),
|
|
||||||
categories
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -486,17 +582,13 @@ mod tests {
|
|||||||
Err(DecodeError::UnknownVersion(_))
|
Err(DecodeError::UnknownVersion(_))
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
decode_trackpoints(id(1), &bytes),
|
decode_trackpoints(id(1), &bytes, &mut Default::default()),
|
||||||
Err(DecodeError::UnknownVersion(_))
|
Err(DecodeError::UnknownVersion(_))
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
decode_waypoints(id(1), &bytes),
|
decode_waypoints(id(1), &bytes),
|
||||||
Err(DecodeError::UnknownVersion(_))
|
Err(DecodeError::UnknownVersion(_))
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
|
||||||
decode_categories(&bytes),
|
|
||||||
Err(DecodeError::UnknownVersion(_))
|
|
||||||
));
|
|
||||||
|
|
||||||
// nothing at all, or a known version with something else
|
// nothing at all, or a known version with something else
|
||||||
assert_eq!(decode_order(&[]), Err(DecodeError::Corrupt));
|
assert_eq!(decode_order(&[]), Err(DecodeError::Corrupt));
|
||||||
@@ -505,10 +597,9 @@ mod tests {
|
|||||||
Some(DecodeError::Corrupt)
|
Some(DecodeError::Corrupt)
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
decode_trackpoints(id(1), &[LATEST, 9, 9, 9]).err(),
|
decode_trackpoints(id(1), &[LATEST, 9, 9, 9], &mut Default::default()).err(),
|
||||||
Some(DecodeError::Corrupt)
|
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
|
// The stored forms of version 1, as they were written when the version was made. They must
|
||||||
@@ -529,10 +620,10 @@ mod tests {
|
|||||||
"000000000000000000000000000600011000000000000000000000000000000005",
|
"000000000000000000000000000600011000000000000000000000000000000005",
|
||||||
);
|
);
|
||||||
const TRACKPOINTS_V1: &str = concat!(
|
const TRACKPOINTS_V1: &str = concat!(
|
||||||
"01030000000000001040000000000000494000000000002059400180a0abfef9620105018c01015001fa0101",
|
"01020667726176656c0761737068616c74010470617468010668696b696e6701046d74623003000000000000",
|
||||||
"0101020103010401000000000000001440000000000080494000000000006059400182a0abfef9620105018d",
|
"1040000000000000494000000000002059400180a0abfef9620105018c01015001fa01010001000100010001",
|
||||||
"01015001fa010101010201030104010100000000000000000000000000000000000000000000000000000000",
|
"000000000000001440000000000080494000000000006059400182a0abfef9620105018d01015001fa010101",
|
||||||
"000000000000",
|
"010001000100010100000000000000000000000000000000000000000000000000000000000000000000",
|
||||||
);
|
);
|
||||||
const WAYPOINTS_V1: &str = concat!(
|
const WAYPOINTS_V1: &str = concat!(
|
||||||
"0102100000000000000000000000000000000700000000000016400000000000c04940000000000010744001",
|
"0102100000000000000000000000000000000700000000000016400000000000c04940000000000010744001",
|
||||||
@@ -545,15 +636,16 @@ mod tests {
|
|||||||
);
|
);
|
||||||
const ORDER_V1: &str =
|
const ORDER_V1: &str =
|
||||||
"010210000000000000000000000000000000091000000000000000000000000000000008";
|
"010210000000000000000000000000000000091000000000000000000000000000000008";
|
||||||
const CATEGORIES_V1: &str =
|
|
||||||
"01020761737068616c740667726176656c010470617468010668696b696e6701046d746230";
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_version_1_is_still_written_as_it_was() {
|
fn test_version_1_is_still_written_as_it_was() {
|
||||||
let file = sample();
|
let file = sample();
|
||||||
assert_eq!(hex(&encode_file(&file)), FILE_V1);
|
assert_eq!(hex(&encode_file(&file)), FILE_V1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
hex(&encode_trackpoints(&file.trk[0].trkseg[0].chunks()[0])),
|
hex(&encode_trackpoints(
|
||||||
|
&file.trk[0].trkseg[0].chunks()[0],
|
||||||
|
&categories()
|
||||||
|
)),
|
||||||
TRACKPOINTS_V1
|
TRACKPOINTS_V1
|
||||||
);
|
);
|
||||||
assert_eq!(hex(&encode_waypoints(&file.wpt.chunks()[0])), WAYPOINTS_V1);
|
assert_eq!(hex(&encode_waypoints(&file.wpt.chunks()[0])), WAYPOINTS_V1);
|
||||||
@@ -561,7 +653,6 @@ mod tests {
|
|||||||
hex(&encode_order(&[FileId(id(9)), FileId(id(8))])),
|
hex(&encode_order(&[FileId(id(9)), FileId(id(8))])),
|
||||||
ORDER_V1
|
ORDER_V1
|
||||||
);
|
);
|
||||||
assert_eq!(hex(&encode_categories(&categories())), CATEGORIES_V1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -571,12 +662,15 @@ mod tests {
|
|||||||
&rebuilt(&file, decode_file(&unhex(FILE_V1)).unwrap()).unwrap(),
|
&rebuilt(&file, decode_file(&unhex(FILE_V1)).unwrap()).unwrap(),
|
||||||
&file,
|
&file,
|
||||||
);
|
);
|
||||||
let chunk = decode_trackpoints(id(4), &unhex(TRACKPOINTS_V1)).unwrap();
|
let mut reader = TrackpointCategories::default();
|
||||||
|
let chunk = decode_trackpoints(id(4), &unhex(TRACKPOINTS_V1), &mut reader).unwrap();
|
||||||
let expected = &file.trk[0].trkseg[0].chunks()[0];
|
let expected = &file.trk[0].trkseg[0].chunks()[0];
|
||||||
assert_eq!(
|
assert_eq!(chunk.trkpt.len(), expected.trkpt.len());
|
||||||
format!("{:?}", chunk.trkpt),
|
for (read, written) in chunk.trkpt.iter().zip(&expected.trkpt) {
|
||||||
format!("{:?}", expected.trkpt)
|
assert_eq!(names(read, &reader), names(written, &categories()));
|
||||||
);
|
assert_eq!(read.hr, written.hr);
|
||||||
|
assert_eq!(read.anchor, written.anchor);
|
||||||
|
}
|
||||||
let chunk = decode_waypoints(id(5), &unhex(WAYPOINTS_V1)).unwrap();
|
let chunk = decode_waypoints(id(5), &unhex(WAYPOINTS_V1)).unwrap();
|
||||||
assert_eq!(chunk.wpt.len(), 2);
|
assert_eq!(chunk.wpt.len(), 2);
|
||||||
assert!(chunk.wpt[0].same_content(&waypoint()));
|
assert!(chunk.wpt[0].same_content(&waypoint()));
|
||||||
@@ -585,9 +679,5 @@ mod tests {
|
|||||||
decode_order(&unhex(ORDER_V1)).unwrap(),
|
decode_order(&unhex(ORDER_V1)).unwrap(),
|
||||||
vec![FileId(id(9)), FileId(id(8))]
|
vec![FileId(id(9)), FileId(id(8))]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
decode_categories(&unhex(CATEGORIES_V1)).unwrap(),
|
|
||||||
categories()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
//! compares the bytes they give with the ones of a fixture). A change of the form is a new
|
//! 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.
|
//! version, next to this one, that the decoders convert from.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -183,6 +185,7 @@ pub struct Trackpoint1 {
|
|||||||
hr: Option<u16>,
|
hr: Option<u16>,
|
||||||
cad: Option<u16>,
|
cad: Option<u16>,
|
||||||
power: Option<u16>,
|
power: Option<u16>,
|
||||||
|
/// The position of the name in the list of the chunk (see [`TrackpointChunk1`]).
|
||||||
surface: Option<u8>,
|
surface: Option<u8>,
|
||||||
highway: Option<u8>,
|
highway: Option<u8>,
|
||||||
sac_scale: Option<u8>,
|
sac_scale: Option<u8>,
|
||||||
@@ -285,34 +288,107 @@ impl From<Waypoint1> for Waypoint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The names of each table of categories, in the order of their codes.
|
/// A chunk of trackpoints. It carries the names its categories refer to: the surface, highway,
|
||||||
|
/// SAC scale and MTB scale of its trackpoints are the positions of the names in its own lists,
|
||||||
|
/// which only hold the names it uses. So it means the same whatever the table of categories of
|
||||||
|
/// the engine that reads it is, and it is not changed by what other chunks learn.
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct Categories1 {
|
pub struct TrackpointChunk1 {
|
||||||
surface: Vec<String>,
|
surface: Vec<String>,
|
||||||
highway: Vec<String>,
|
highway: Vec<String>,
|
||||||
sac_scale: Vec<String>,
|
sac_scale: Vec<String>,
|
||||||
mtb_scale: Vec<String>,
|
mtb_scale: Vec<String>,
|
||||||
|
points: Vec<Trackpoint1>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&TrackpointCategories> for Categories1 {
|
/// The names of a category used by a chunk, with their position in the list of the chunk.
|
||||||
fn from(categories: &TrackpointCategories) -> Self {
|
struct LocalNames<'a> {
|
||||||
let names = |table: &Categories| table.names().to_vec();
|
table: &'a Categories,
|
||||||
|
names: Vec<String>,
|
||||||
|
local: HashMap<u8, u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> LocalNames<'a> {
|
||||||
|
fn new(table: &'a Categories) -> Self {
|
||||||
Self {
|
Self {
|
||||||
surface: names(&categories.surface),
|
table,
|
||||||
highway: names(&categories.highway),
|
names: vec![],
|
||||||
sac_scale: names(&categories.sac_scale),
|
local: HashMap::new(),
|
||||||
mtb_scale: names(&categories.mtb_scale),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The position in the list of the chunk of the name of a code of the table. A code that
|
||||||
|
/// has no name has no position.
|
||||||
|
fn position(&mut self, code: Option<u8>) -> Option<u8> {
|
||||||
|
let code = code?;
|
||||||
|
if let Some(position) = self.local.get(&code) {
|
||||||
|
return Some(*position);
|
||||||
|
}
|
||||||
|
let name = self.table.name(code)?;
|
||||||
|
let position = u8::try_from(self.names.len()).ok()?;
|
||||||
|
self.names.push(name.to_owned());
|
||||||
|
self.local.insert(code, position);
|
||||||
|
Some(position)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<Categories1> for TrackpointCategories {
|
/// The codes of the table for the names of a chunk, which are added to it if it does not know
|
||||||
fn from(categories: Categories1) -> Self {
|
/// them. A name that does not fit in a full table has no code.
|
||||||
|
fn codes_of(names: &[String], table: &mut Categories) -> Vec<Option<u8>> {
|
||||||
|
names.iter().map(|name| table.code(name)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrackpointChunk1 {
|
||||||
|
pub fn new(points: &[Trackpoint], categories: &TrackpointCategories) -> Self {
|
||||||
|
let mut surface = LocalNames::new(&categories.surface);
|
||||||
|
let mut highway = LocalNames::new(&categories.highway);
|
||||||
|
let mut sac_scale = LocalNames::new(&categories.sac_scale);
|
||||||
|
let mut mtb_scale = LocalNames::new(&categories.mtb_scale);
|
||||||
|
let points = points
|
||||||
|
.iter()
|
||||||
|
.map(|point| {
|
||||||
|
let mut stored = Trackpoint1::from(point);
|
||||||
|
stored.surface = surface.position(point.surface);
|
||||||
|
stored.highway = highway.position(point.highway);
|
||||||
|
stored.sac_scale = sac_scale.position(point.sac_scale);
|
||||||
|
stored.mtb_scale = mtb_scale.position(point.mtb_scale);
|
||||||
|
stored
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
Self {
|
Self {
|
||||||
surface: Categories::from_names(categories.surface),
|
surface: surface.names,
|
||||||
highway: Categories::from_names(categories.highway),
|
highway: highway.names,
|
||||||
sac_scale: Categories::from_names(categories.sac_scale),
|
sac_scale: sac_scale.names,
|
||||||
mtb_scale: Categories::from_names(categories.mtb_scale),
|
mtb_scale: mtb_scale.names,
|
||||||
|
points,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The trackpoints, with the codes of `categories` for the names of the chunk.
|
||||||
|
pub fn into_points(self, categories: &mut TrackpointCategories) -> Vec<Trackpoint> {
|
||||||
|
let surface = codes_of(&self.surface, &mut categories.surface);
|
||||||
|
let highway = codes_of(&self.highway, &mut categories.highway);
|
||||||
|
let sac_scale = codes_of(&self.sac_scale, &mut categories.sac_scale);
|
||||||
|
let mtb_scale = codes_of(&self.mtb_scale, &mut categories.mtb_scale);
|
||||||
|
let global = |codes: &[Option<u8>], local: Option<u8>| {
|
||||||
|
local.and_then(|local| codes.get(usize::from(local)).copied().flatten())
|
||||||
|
};
|
||||||
|
self.points
|
||||||
|
.into_iter()
|
||||||
|
.map(|stored| {
|
||||||
|
let (s, h, sac, mtb) = (
|
||||||
|
global(&surface, stored.surface),
|
||||||
|
global(&highway, stored.highway),
|
||||||
|
global(&sac_scale, stored.sac_scale),
|
||||||
|
global(&mtb_scale, stored.mtb_scale),
|
||||||
|
);
|
||||||
|
let mut point = Trackpoint::from(stored);
|
||||||
|
point.surface = s;
|
||||||
|
point.highway = h;
|
||||||
|
point.sac_scale = sac;
|
||||||
|
point.mtb_scale = mtb;
|
||||||
|
point
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::{Batch, ChunkKey, ChunkKind, FileId, Storage, StorageError, StoredData};
|
use crate::{
|
||||||
|
Batch, ChunkKey, ChunkKind, CommitError, FileId, Revision, Storage, StorageError, StoredData,
|
||||||
|
};
|
||||||
|
|
||||||
/// A storage that lives as long as the session. It is what an engine without a host storage uses,
|
/// A storage that lives as long as the session. It is what an engine without a host storage uses,
|
||||||
/// and what the tests use.
|
/// and what the tests use.
|
||||||
@@ -12,10 +14,10 @@ pub struct MemoryStorage {
|
|||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct State {
|
struct State {
|
||||||
|
head: Revision,
|
||||||
files: HashMap<FileId, Vec<u8>>,
|
files: HashMap<FileId, Vec<u8>>,
|
||||||
chunks: HashMap<ChunkKey, Vec<u8>>,
|
chunks: HashMap<ChunkKey, Vec<u8>>,
|
||||||
order: Option<Vec<u8>>,
|
order: Option<Vec<u8>>,
|
||||||
categories: Option<Vec<u8>>,
|
|
||||||
settings: HashMap<String, String>,
|
settings: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,12 +49,19 @@ impl Storage for MemoryStorage {
|
|||||||
files: state.files.iter().map(|(k, v)| (*k, v.clone())).collect(),
|
files: state.files.iter().map(|(k, v)| (*k, v.clone())).collect(),
|
||||||
chunks: state.chunks.iter().map(|(k, v)| (*k, v.clone())).collect(),
|
chunks: state.chunks.iter().map(|(k, v)| (*k, v.clone())).collect(),
|
||||||
order: state.order.clone(),
|
order: state.order.clone(),
|
||||||
categories: state.categories.clone(),
|
head: state.head,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn commit(&self, batch: &Batch) -> Result<(), StorageError> {
|
async fn head(&self) -> Result<Revision, StorageError> {
|
||||||
|
Ok(self.state.borrow().head)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError> {
|
||||||
let mut state = self.state.borrow_mut();
|
let mut state = self.state.borrow_mut();
|
||||||
|
if state.head != batch.expect_head {
|
||||||
|
return Err(CommitError::Stale { head: state.head });
|
||||||
|
}
|
||||||
state.files.extend(batch.put_files.iter().cloned());
|
state.files.extend(batch.put_files.iter().cloned());
|
||||||
for id in &batch.delete_files {
|
for id in &batch.delete_files {
|
||||||
state.files.remove(id);
|
state.files.remove(id);
|
||||||
@@ -64,10 +73,8 @@ impl Storage for MemoryStorage {
|
|||||||
if let Some(order) = &batch.order {
|
if let Some(order) = &batch.order {
|
||||||
state.order = Some(order.clone());
|
state.order = Some(order.clone());
|
||||||
}
|
}
|
||||||
if let Some(categories) = &batch.categories {
|
state.head = state.head.next();
|
||||||
state.categories = Some(categories.clone());
|
Ok(state.head)
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
|
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
|
||||||
@@ -140,7 +147,7 @@ mod tests {
|
|||||||
(ChunkKey::waypoints(w), vec![4]),
|
(ChunkKey::waypoints(w), vec![4]),
|
||||||
],
|
],
|
||||||
order: Some(vec![5]),
|
order: Some(vec![5]),
|
||||||
categories: Some(vec![6]),
|
expect_head: Revision(0),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}))
|
}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -157,30 +164,26 @@ mod tests {
|
|||||||
(ChunkKey::waypoints(w), vec![4])
|
(ChunkKey::waypoints(w), vec![4])
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(data.order, Some(vec![5]));
|
||||||
(data.order, data.categories),
|
|
||||||
(Some(vec![5]), Some(vec![6]))
|
|
||||||
);
|
|
||||||
|
|
||||||
// a put replaces, a delete removes, what is not mentioned stays
|
// a put replaces, a delete removes, what is not mentioned stays
|
||||||
block_on(storage.commit(&Batch {
|
block_on(storage.commit(&Batch {
|
||||||
put_files: vec![(a, vec![10])],
|
put_files: vec![(a, vec![10])],
|
||||||
delete_files: vec![b],
|
delete_files: vec![b],
|
||||||
delete_chunks: vec![ChunkKey::trackpoints(t)],
|
delete_chunks: vec![ChunkKey::trackpoints(t)],
|
||||||
|
expect_head: Revision(1),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}))
|
}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(storage.counts(), counts(1, 0, 1));
|
assert_eq!(storage.counts(), counts(1, 0, 1));
|
||||||
let data = block_on(storage.load()).unwrap();
|
let data = block_on(storage.load()).unwrap();
|
||||||
assert_eq!(data.files, vec![(a, vec![10])]);
|
assert_eq!(data.files, vec![(a, vec![10])]);
|
||||||
assert_eq!(
|
assert_eq!(data.order, Some(vec![5]));
|
||||||
(data.order, data.categories),
|
|
||||||
(Some(vec![5]), Some(vec![6]))
|
|
||||||
);
|
|
||||||
|
|
||||||
block_on(storage.commit(&Batch {
|
block_on(storage.commit(&Batch {
|
||||||
delete_chunks: vec![ChunkKey::waypoints(w)],
|
delete_chunks: vec![ChunkKey::waypoints(w)],
|
||||||
order: Some(vec![]),
|
order: Some(vec![]),
|
||||||
|
expect_head: Revision(2),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}))
|
}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -216,4 +219,50 @@ mod tests {
|
|||||||
block_on(storage.put_setting("a", "1")).unwrap();
|
block_on(storage.put_setting("a", "1")).unwrap();
|
||||||
assert_eq!(block_on(storage.load()).unwrap(), StoredData::default());
|
assert_eq!(block_on(storage.load()).unwrap(), StoredData::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn batch(expect_head: u64, entry: u8) -> Batch {
|
||||||
|
Batch {
|
||||||
|
put_files: vec![(FileId::default(), vec![entry])],
|
||||||
|
expect_head: Revision(expect_head),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_every_commit_advances_the_head() {
|
||||||
|
let storage = MemoryStorage::default();
|
||||||
|
assert_eq!(block_on(storage.head()).unwrap(), Revision(0));
|
||||||
|
assert_eq!(block_on(storage.load()).unwrap().head, Revision(0));
|
||||||
|
assert_eq!(block_on(storage.commit(&batch(0, 1))), Ok(Revision(1)));
|
||||||
|
assert_eq!(block_on(storage.commit(&batch(1, 2))), Ok(Revision(2)));
|
||||||
|
assert_eq!(block_on(storage.head()).unwrap(), Revision(2));
|
||||||
|
assert_eq!(block_on(storage.load()).unwrap().head, Revision(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_a_commit_based_on_another_head_changes_nothing() {
|
||||||
|
let storage = MemoryStorage::default();
|
||||||
|
block_on(storage.commit(&batch(0, 1))).unwrap();
|
||||||
|
block_on(storage.commit(&batch(1, 2))).unwrap();
|
||||||
|
let before = block_on(storage.load()).unwrap();
|
||||||
|
|
||||||
|
// behind, or ahead: the head is not the one it is based on
|
||||||
|
for expect in [0, 1, 3] {
|
||||||
|
let stale = Batch {
|
||||||
|
delete_files: vec![before.files[0].0],
|
||||||
|
order: Some(vec![9]),
|
||||||
|
..batch(expect, 3)
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
block_on(storage.commit(&stale)),
|
||||||
|
Err(CommitError::Stale { head: Revision(2) })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let after = block_on(storage.load()).unwrap();
|
||||||
|
assert_eq!(after.head, Revision(2));
|
||||||
|
assert_eq!(after.files.len(), before.files.len());
|
||||||
|
assert_eq!(after.order, before.order);
|
||||||
|
// and it can be done again from the right head
|
||||||
|
assert_eq!(block_on(storage.commit(&batch(2, 3))), Ok(Revision(3)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,17 @@
|
|||||||
//! [`crate::Chunked`]) that are written once, under their identity, and a file is a small record
|
//! [`crate::Chunked`]) that are written once, under their identity, and a file is a small record
|
||||||
//! that lists the chunks it is made of. An edit only writes the new chunks and the records of the
|
//! that lists the chunks it is made of. An edit only writes the new chunks and the records of the
|
||||||
//! files that changed; the chunks that nothing refers to anymore are deleted at the same time.
|
//! files that changed; the chunks that nothing refers to anymore are deleted at the same time.
|
||||||
|
//!
|
||||||
|
//! Several engines can write to a storage (tabs, a server). It has a head, a revision that each
|
||||||
|
//! commit advances, and a commit only applies if it is based on the current head: an engine that
|
||||||
|
//! is behind is refused, and has to read the storage again, which replaces what it holds, before
|
||||||
|
//! it writes. So what an engine knows of the storage is exact whenever it writes, which is what
|
||||||
|
//! lets it write only what changed and delete what it does not use anymore.
|
||||||
|
//!
|
||||||
|
//! The surface, highway and scales of the trackpoints are stored as names, in the chunk that has
|
||||||
|
//! them: the codes that the engine gives them (see [`crate::TrackpointCategories`]) depend on the
|
||||||
|
//! order in which it learned the names, so they mean nothing outside of it. A storage that two
|
||||||
|
//! engines (tabs, a server) write to therefore has nothing to agree on but the chunks.
|
||||||
|
|
||||||
mod codec;
|
mod codec;
|
||||||
mod memory;
|
mod memory;
|
||||||
@@ -20,16 +31,63 @@ use uuid::Uuid;
|
|||||||
use crate::FileId;
|
use crate::FileId;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct StorageError(pub String);
|
pub enum StorageError {
|
||||||
|
/// The storage could not be read or written.
|
||||||
|
Failed(String),
|
||||||
|
/// Some of what is stored was written by a newer version of the app, which this one cannot
|
||||||
|
/// read. Nothing is written then, so that the newer version finds its data again.
|
||||||
|
Newer,
|
||||||
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for StorageError {
|
impl std::fmt::Display for StorageError {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
write!(f, "storage error: {}", self.0)
|
match self {
|
||||||
|
Self::Failed(message) => write!(f, "storage error: {message}"),
|
||||||
|
Self::Newer => write!(f, "stored by a newer version"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for StorageError {}
|
impl std::error::Error for StorageError {}
|
||||||
|
|
||||||
|
/// A state of the storage: the head is the revision of the last commit, 0 for a storage that was
|
||||||
|
/// never written to.
|
||||||
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct Revision(pub u64);
|
||||||
|
|
||||||
|
impl Revision {
|
||||||
|
pub fn next(self) -> Self {
|
||||||
|
Self(self.0 + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a commit did not happen.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum CommitError {
|
||||||
|
/// The head is not the one the commit is based on: another engine wrote since.
|
||||||
|
Stale {
|
||||||
|
head: Revision,
|
||||||
|
},
|
||||||
|
Failed(StorageError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for CommitError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Stale { head } => write!(f, "the storage changed (now at revision {})", head.0),
|
||||||
|
Self::Failed(error) => error.fmt(f),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for CommitError {}
|
||||||
|
|
||||||
|
impl From<StorageError> for CommitError {
|
||||||
|
fn from(error: StorageError) -> Self {
|
||||||
|
Self::Failed(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What a chunk holds.
|
/// What a chunk holds.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum ChunkKind {
|
pub enum ChunkKind {
|
||||||
@@ -68,8 +126,8 @@ pub struct StoredData {
|
|||||||
pub chunks: Vec<(ChunkKey, Vec<u8>)>,
|
pub chunks: Vec<(ChunkKey, Vec<u8>)>,
|
||||||
/// The order of the files.
|
/// The order of the files.
|
||||||
pub order: Option<Vec<u8>>,
|
pub order: Option<Vec<u8>>,
|
||||||
/// The categories of the trackpoints, which their chunks refer to.
|
/// The revision of the storage that this is the state of.
|
||||||
pub categories: Option<Vec<u8>>,
|
pub head: Revision,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A change of the stored data, to be applied all at once: a stored state is always one that the
|
/// A change of the stored data, to be applied all at once: a stored state is always one that the
|
||||||
@@ -81,7 +139,8 @@ pub struct Batch {
|
|||||||
pub put_chunks: Vec<(ChunkKey, Vec<u8>)>,
|
pub put_chunks: Vec<(ChunkKey, Vec<u8>)>,
|
||||||
pub delete_chunks: Vec<ChunkKey>,
|
pub delete_chunks: Vec<ChunkKey>,
|
||||||
pub order: Option<Vec<u8>>,
|
pub order: Option<Vec<u8>>,
|
||||||
pub categories: Option<Vec<u8>>,
|
/// The head that the batch is based on: it is only applied if the head still is that one.
|
||||||
|
pub expect_head: Revision,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Batch {
|
impl Batch {
|
||||||
@@ -116,8 +175,12 @@ pub trait Storage {
|
|||||||
/// Reads the files.
|
/// Reads the files.
|
||||||
async fn load(&self) -> Result<StoredData, StorageError>;
|
async fn load(&self) -> Result<StoredData, StorageError>;
|
||||||
|
|
||||||
/// Applies a batch: either all of it is stored, or none.
|
/// The head of the storage.
|
||||||
async fn commit(&self, batch: &Batch) -> Result<(), StorageError>;
|
async fn head(&self) -> Result<Revision, StorageError>;
|
||||||
|
|
||||||
|
/// Applies a batch if the head is the one it is based on (`Stale` otherwise): either all of it
|
||||||
|
/// is stored, or none. The head advances. Gives the new head.
|
||||||
|
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError>;
|
||||||
|
|
||||||
/// Reads the settings, as `(key, value)`. The engine does not look into them.
|
/// Reads the settings, as `(key, value)`. The engine does not look into them.
|
||||||
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError>;
|
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError>;
|
||||||
@@ -155,17 +218,13 @@ mod tests {
|
|||||||
order: Some(vec![]),
|
order: Some(vec![]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
Batch {
|
|
||||||
categories: Some(vec![]),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
assert!(holds.iter().all(|batch| !batch.is_empty()));
|
assert!(holds.iter().all(|batch| !batch.is_empty()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_storage_error_is_readable() {
|
fn test_storage_error_is_readable() {
|
||||||
let error = StorageError("disk full".into());
|
let error = StorageError::Failed("disk full".into());
|
||||||
assert_eq!(error.to_string(), "storage error: disk full");
|
assert_eq!(error.to_string(), "storage error: disk full");
|
||||||
let _: &dyn std::error::Error = &error;
|
let _: &dyn std::error::Error = &error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,19 @@ use std::rc::Rc;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::codec::{
|
use super::codec::{
|
||||||
DecodeError, decode_categories, decode_file, decode_order, decode_trackpoints,
|
DecodeError, decode_file, decode_order, decode_trackpoints, decode_waypoints, encode_file,
|
||||||
decode_waypoints, encode_categories, encode_file, encode_order, encode_trackpoints,
|
encode_order, encode_trackpoints, encode_waypoints,
|
||||||
encode_waypoints,
|
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
Batch, ChunkKey, File, FileId, StackEntry, Storage, StorageError, TrackpointCategories,
|
Batch, ChunkKey, CommitError, File, FileId, Files, Revision, Storage, StorageError,
|
||||||
TrackpointChunk, WaypointChunk,
|
TrackpointCategories, TrackpointChunk, WaypointChunk,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// What there is to keep of the engine: the files of the current state, their order, and the
|
/// What there is to keep of the engine: the files of the current state, their order, and the
|
||||||
/// categories their trackpoints refer to. See [`crate::Engine::snapshot`].
|
/// categories their trackpoints refer to. See [`crate::Engine::snapshot`].
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct Snapshot {
|
pub struct Snapshot {
|
||||||
pub files: StackEntry,
|
pub files: Files,
|
||||||
pub order: Vec<FileId>,
|
pub order: Vec<FileId>,
|
||||||
pub categories: TrackpointCategories,
|
pub categories: TrackpointCategories,
|
||||||
}
|
}
|
||||||
@@ -29,12 +28,6 @@ pub struct Restored {
|
|||||||
pub files: Vec<Rc<File>>,
|
pub files: Vec<Rc<File>>,
|
||||||
pub order: Vec<FileId>,
|
pub order: Vec<FileId>,
|
||||||
pub categories: TrackpointCategories,
|
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
|
/// Works out what to write to a [`Storage`] so that it holds a [`Snapshot`], knowing what it
|
||||||
@@ -51,7 +44,8 @@ pub struct Persister {
|
|||||||
stored_files: HashSet<FileId>,
|
stored_files: HashSet<FileId>,
|
||||||
chunks: HashSet<ChunkKey>,
|
chunks: HashSet<ChunkKey>,
|
||||||
order: Option<Vec<FileId>>,
|
order: Option<Vec<FileId>>,
|
||||||
categories: Option<TrackpointCategories>,
|
/// The head of the storage that all of this is the state of.
|
||||||
|
head: Revision,
|
||||||
/// Set by `restore`, see [`Restored::read_only`].
|
/// Set by `restore`, see [`Restored::read_only`].
|
||||||
read_only: bool,
|
read_only: bool,
|
||||||
}
|
}
|
||||||
@@ -82,16 +76,18 @@ struct Plan {
|
|||||||
|
|
||||||
impl Persister {
|
impl Persister {
|
||||||
/// Reads the files of the storage. The persister then knows that they are saved.
|
/// Reads the files of the storage. The persister then knows that they are saved.
|
||||||
|
///
|
||||||
|
/// If some of what is stored was written by a newer version, nothing is read (`Newer`), and the
|
||||||
|
/// persister does not save until it reads a storage that is not newer: the newer version
|
||||||
|
/// needs its data.
|
||||||
pub async fn restore<S: Storage>(&mut self, storage: &S) -> Result<Restored, StorageError> {
|
pub async fn restore<S: Storage>(&mut self, storage: &S) -> Result<Restored, StorageError> {
|
||||||
let data = storage.load().await?;
|
let data = storage.load().await?;
|
||||||
*self = Self::default();
|
*self = Self::default();
|
||||||
|
|
||||||
// what comes from a newer version is not corrupt: it must not be overwritten
|
// what comes from a newer version is not corrupt: it must not be overwritten
|
||||||
let newer = Newer::default();
|
let newer = Newer::default();
|
||||||
let categories: Option<TrackpointCategories> = data
|
// the names of the chunks are added to the categories of the engine as they are read
|
||||||
.categories
|
let categories: RefCell<TrackpointCategories> = Default::default();
|
||||||
.as_deref()
|
|
||||||
.and_then(|bytes| newer.read(decode_categories(bytes)));
|
|
||||||
let stored_order: Option<Vec<FileId>> = data
|
let stored_order: Option<Vec<FileId>> = data
|
||||||
.order
|
.order
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -108,7 +104,13 @@ impl Persister {
|
|||||||
.entry(id.0)
|
.entry(id.0)
|
||||||
.or_insert_with(|| {
|
.or_insert_with(|| {
|
||||||
let bytes = chunk_bytes.get(&ChunkKey::trackpoints(id.0))?;
|
let bytes = chunk_bytes.get(&ChunkKey::trackpoints(id.0))?;
|
||||||
newer.read(decode_trackpoints(id.0, bytes)).map(Rc::new)
|
newer
|
||||||
|
.read(decode_trackpoints(
|
||||||
|
id.0,
|
||||||
|
bytes,
|
||||||
|
&mut categories.borrow_mut(),
|
||||||
|
))
|
||||||
|
.map(Rc::new)
|
||||||
})
|
})
|
||||||
.clone()
|
.clone()
|
||||||
};
|
};
|
||||||
@@ -123,69 +125,88 @@ impl Persister {
|
|||||||
.clone()
|
.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut restored = Restored {
|
|
||||||
categories: categories.clone().unwrap_or_default(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let mut files: HashMap<FileId, Rc<File>> = HashMap::new();
|
let mut files: HashMap<FileId, Rc<File>> = HashMap::new();
|
||||||
for (id, bytes) in &data.files {
|
for (id, bytes) in &data.files {
|
||||||
self.stored_files.insert(*id);
|
self.stored_files.insert(*id);
|
||||||
let file = newer
|
let file = newer
|
||||||
.read(decode_file(bytes))
|
.read(decode_file(bytes))
|
||||||
.and_then(|record| record.build(*id, &trackpoint_chunk, &waypoint_chunk));
|
.and_then(|record| record.build(*id, &trackpoint_chunk, &waypoint_chunk));
|
||||||
match file {
|
// a file that cannot be read is left out, and dropped at the next save
|
||||||
Some(file) => {
|
if let Some(file) = file {
|
||||||
files.insert(*id, Rc::new(file));
|
files.insert(*id, Rc::new(file));
|
||||||
}
|
}
|
||||||
None => restored.unreadable += 1,
|
|
||||||
}
|
}
|
||||||
|
if newer.0.get() {
|
||||||
|
// `*self` was reset: only what is needed to not write, and to know the storage
|
||||||
|
self.read_only = true;
|
||||||
|
self.head = data.head;
|
||||||
|
return Err(StorageError::Newer);
|
||||||
}
|
}
|
||||||
|
|
||||||
restored.order = stored_order
|
let mut order: Vec<FileId> = stored_order
|
||||||
.iter()
|
.iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
.filter(|id| files.contains_key(id))
|
.filter(|id| files.contains_key(id))
|
||||||
.copied()
|
.copied()
|
||||||
.collect();
|
.collect();
|
||||||
let ordered: HashSet<FileId> = restored.order.iter().copied().collect();
|
let ordered: HashSet<FileId> = order.iter().copied().collect();
|
||||||
restored
|
order.extend(files.keys().filter(|id| !ordered.contains(id)).copied());
|
||||||
.order
|
let restored = Restored {
|
||||||
.extend(files.keys().filter(|id| !ordered.contains(id)).copied());
|
files: order.iter().map(|id| files[id].clone()).collect(),
|
||||||
restored.files = restored.order.iter().map(|id| files[id].clone()).collect();
|
order,
|
||||||
|
categories: categories.into_inner(),
|
||||||
|
};
|
||||||
|
|
||||||
self.files = files;
|
self.files = files;
|
||||||
self.chunks = chunk_bytes.into_keys().collect();
|
self.chunks = chunk_bytes.into_keys().collect();
|
||||||
self.order = stored_order;
|
self.order = stored_order;
|
||||||
self.categories = categories;
|
self.head = data.head;
|
||||||
self.read_only = newer.0.get();
|
|
||||||
restored.read_only = self.read_only;
|
|
||||||
Ok(restored)
|
Ok(restored)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The revision of the storage that the persister knows.
|
||||||
|
pub fn head(&self) -> Revision {
|
||||||
|
self.head
|
||||||
|
}
|
||||||
|
|
||||||
/// Writes what changed since the last save. Nothing is written if nothing changed.
|
/// Writes what changed since the last save. Nothing is written if nothing changed.
|
||||||
|
///
|
||||||
|
/// Nothing is written while what is stored comes from a newer version (`Failed(Newer)`, see
|
||||||
|
/// [`Persister::restore`]).
|
||||||
|
///
|
||||||
|
/// The write only happens if nothing else was committed since the persister read or wrote
|
||||||
|
/// last: otherwise it is refused (`Stale`), nothing is written, and the persister has to read
|
||||||
|
/// the storage again ([`Persister::restore`]) before it can write.
|
||||||
pub async fn save<S: Storage>(
|
pub async fn save<S: Storage>(
|
||||||
&mut self,
|
&mut self,
|
||||||
storage: &S,
|
storage: &S,
|
||||||
snapshot: Snapshot,
|
snapshot: Snapshot,
|
||||||
) -> Result<(), StorageError> {
|
) -> Result<(), CommitError> {
|
||||||
if self.read_only {
|
if self.read_only {
|
||||||
return Ok(());
|
return Err(StorageError::Newer.into());
|
||||||
}
|
}
|
||||||
let plan = self.plan(snapshot);
|
let mut plan = self.plan(snapshot);
|
||||||
if plan.batch.is_empty() {
|
if plan.batch.is_empty() {
|
||||||
self.files = plan.snapshot.files;
|
self.files = plan.snapshot.files;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
storage.commit(&plan.batch).await?;
|
plan.batch.expect_head = self.head;
|
||||||
|
self.head = storage.commit(&plan.batch).await?;
|
||||||
|
|
||||||
self.stored_files = plan.snapshot.files.keys().copied().collect();
|
self.stored_files = plan.snapshot.files.keys().copied().collect();
|
||||||
self.files = plan.snapshot.files;
|
self.files = plan.snapshot.files;
|
||||||
self.chunks = plan.chunks;
|
self.chunks = plan.chunks;
|
||||||
self.order = Some(plan.snapshot.order);
|
self.order = Some(plan.snapshot.order);
|
||||||
self.categories = Some(plan.snapshot.categories);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether something else was committed to the storage since the persister read or wrote it
|
||||||
|
/// last: what it knows of the storage is out of date, and it cannot write until it reads
|
||||||
|
/// again ([`Persister::restore`]).
|
||||||
|
pub async fn is_behind<S: Storage>(&self, storage: &S) -> Result<bool, StorageError> {
|
||||||
|
Ok(storage.head().await? != self.head)
|
||||||
|
}
|
||||||
|
|
||||||
fn plan(&self, snapshot: Snapshot) -> Plan {
|
fn plan(&self, snapshot: Snapshot) -> Plan {
|
||||||
let mut batch = Batch::default();
|
let mut batch = Batch::default();
|
||||||
// the chunks that the files use, which the storage has afterwards
|
// the chunks that the files use, which the storage has afterwards
|
||||||
@@ -200,7 +221,9 @@ impl Persister {
|
|||||||
for chunk in segments.flat_map(|segment| segment.chunks()) {
|
for chunk in segments.flat_map(|segment| segment.chunks()) {
|
||||||
let key = ChunkKey::trackpoints(chunk.id.0);
|
let key = ChunkKey::trackpoints(chunk.id.0);
|
||||||
if chunks.insert(key) && changed && !self.chunks.contains(&key) {
|
if chunks.insert(key) && changed && !self.chunks.contains(&key) {
|
||||||
batch.put_chunks.push((key, encode_trackpoints(chunk)));
|
batch
|
||||||
|
.put_chunks
|
||||||
|
.push((key, encode_trackpoints(chunk, &snapshot.categories)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for chunk in file.wpt.chunks() {
|
for chunk in file.wpt.chunks() {
|
||||||
@@ -224,9 +247,6 @@ impl Persister {
|
|||||||
if self.order.as_ref() != Some(&snapshot.order) {
|
if self.order.as_ref() != Some(&snapshot.order) {
|
||||||
batch.order = Some(encode_order(&snapshot.order));
|
batch.order = Some(encode_order(&snapshot.order));
|
||||||
}
|
}
|
||||||
if self.categories.as_ref() != Some(&snapshot.categories) {
|
|
||||||
batch.categories = Some(encode_categories(&snapshot.categories));
|
|
||||||
}
|
|
||||||
|
|
||||||
Plan {
|
Plan {
|
||||||
batch,
|
batch,
|
||||||
@@ -271,9 +291,14 @@ mod tests {
|
|||||||
self.inner.load().await
|
self.inner.load().await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn commit(&self, batch: &Batch) -> Result<(), StorageError> {
|
async fn head(&self) -> Result<Revision, StorageError> {
|
||||||
|
self.inner.head().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError> {
|
||||||
|
let revision = self.inner.commit(batch).await?;
|
||||||
self.batches.borrow_mut().push(batch.clone());
|
self.batches.borrow_mut().push(batch.clone());
|
||||||
self.inner.commit(batch).await
|
Ok(revision)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
|
async fn load_settings(&self) -> Result<Vec<(String, String)>, StorageError> {
|
||||||
@@ -318,7 +343,6 @@ mod tests {
|
|||||||
|
|
||||||
let mut persister = Persister::default();
|
let mut persister = Persister::default();
|
||||||
let restored = block_on(persister.restore(&storage)).unwrap();
|
let restored = block_on(persister.restore(&storage)).unwrap();
|
||||||
assert_eq!(restored.unreadable, 0);
|
|
||||||
assert_eq!(restored.order, snapshot.order);
|
assert_eq!(restored.order, snapshot.order);
|
||||||
assert_eq!(restored.categories, snapshot.categories);
|
assert_eq!(restored.categories, snapshot.categories);
|
||||||
for file in &restored.files {
|
for file in &restored.files {
|
||||||
@@ -375,7 +399,7 @@ mod tests {
|
|||||||
assert_eq!(batch.put_files.len(), 1);
|
assert_eq!(batch.put_files.len(), 1);
|
||||||
assert!(batch.put_chunks.is_empty());
|
assert!(batch.put_chunks.is_empty());
|
||||||
assert!(batch.delete_chunks.is_empty());
|
assert!(batch.delete_chunks.is_empty());
|
||||||
assert!(batch.order.is_none() && batch.categories.is_none());
|
assert!(batch.order.is_none());
|
||||||
}
|
}
|
||||||
assert_eq!(storage.inner.counts(), counts);
|
assert_eq!(storage.inner.counts(), counts);
|
||||||
|
|
||||||
@@ -444,13 +468,12 @@ mod tests {
|
|||||||
let broken = snapshot.order[0];
|
let broken = snapshot.order[0];
|
||||||
block_on(storage.inner.commit(&Batch {
|
block_on(storage.inner.commit(&Batch {
|
||||||
put_files: vec![(broken, vec![super::super::codec::LATEST, 0xff, 0xff])],
|
put_files: vec![(broken, vec![super::super::codec::LATEST, 0xff, 0xff])],
|
||||||
|
expect_head: persister.head(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}))
|
}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut persister = Persister::default();
|
let mut persister = Persister::default();
|
||||||
let restored = block_on(persister.restore(&storage)).unwrap();
|
let restored = block_on(persister.restore(&storage)).unwrap();
|
||||||
assert_eq!(restored.unreadable, 1);
|
|
||||||
assert!(!restored.read_only);
|
|
||||||
assert_eq!(restored.files.len(), FILES.len() - 1);
|
assert_eq!(restored.files.len(), FILES.len() - 1);
|
||||||
assert!(restored.files.iter().all(|file| file.id != broken));
|
assert!(restored.files.iter().all(|file| file.id != broken));
|
||||||
|
|
||||||
@@ -470,6 +493,7 @@ mod tests {
|
|||||||
block_on(Persister::default().save(&storage, snapshot.clone())).unwrap();
|
block_on(Persister::default().save(&storage, snapshot.clone())).unwrap();
|
||||||
let mut batch = Batch::default();
|
let mut batch = Batch::default();
|
||||||
damage(&snapshot, &mut batch);
|
damage(&snapshot, &mut batch);
|
||||||
|
batch.expect_head = block_on(storage.inner.head()).unwrap();
|
||||||
block_on(storage.inner.commit(&batch)).unwrap();
|
block_on(storage.inner.commit(&batch)).unwrap();
|
||||||
storage.batches.borrow_mut().clear();
|
storage.batches.borrow_mut().clear();
|
||||||
(storage, snapshot)
|
(storage, snapshot)
|
||||||
@@ -482,7 +506,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_what_a_newer_version_wrote_is_left_alone() {
|
fn test_what_a_newer_version_wrote_is_left_alone() {
|
||||||
type Damage = (&'static str, fn(&Snapshot, &mut Batch));
|
type Damage = (&'static str, fn(&Snapshot, &mut Batch));
|
||||||
let damages: [Damage; 4] = [
|
let damages: [Damage; 3] = [
|
||||||
("a file", |snapshot, batch| {
|
("a file", |snapshot, batch| {
|
||||||
batch.put_files.push((snapshot.order[0], from_the_future()));
|
batch.put_files.push((snapshot.order[0], from_the_future()));
|
||||||
}),
|
}),
|
||||||
@@ -496,22 +520,24 @@ mod tests {
|
|||||||
("the order", |_, batch| {
|
("the order", |_, batch| {
|
||||||
batch.order = Some(from_the_future())
|
batch.order = Some(from_the_future())
|
||||||
}),
|
}),
|
||||||
("the categories", |_, batch| {
|
|
||||||
batch.categories = Some(from_the_future())
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
for (what, damage) in damages {
|
for (what, damage) in damages {
|
||||||
let (storage, snapshot) = stored_with(damage);
|
let (storage, snapshot) = stored_with(damage);
|
||||||
let before = storage.inner.counts();
|
let before = storage.inner.counts();
|
||||||
let mut persister = Persister::default();
|
let mut persister = Persister::default();
|
||||||
let restored = block_on(persister.restore(&storage)).unwrap();
|
let result = block_on(persister.restore(&storage));
|
||||||
assert!(restored.read_only, "{what}");
|
assert_eq!(result.unwrap_err(), StorageError::Newer, "{what}");
|
||||||
|
|
||||||
// whatever is saved afterwards, nothing is written: the newer version needs its data
|
// whatever is saved afterwards is refused: the newer version needs its data
|
||||||
let mut changed = snapshot.clone();
|
let mut changed = snapshot.clone();
|
||||||
changed.order.clear();
|
changed.order.clear();
|
||||||
changed.files.clear();
|
changed.files.clear();
|
||||||
block_on(persister.save(&storage, changed)).unwrap();
|
let result = block_on(persister.save(&storage, changed));
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
Err(CommitError::Failed(StorageError::Newer)),
|
||||||
|
"{what}"
|
||||||
|
);
|
||||||
assert!(storage.batches.borrow().is_empty(), "{what}");
|
assert!(storage.batches.borrow().is_empty(), "{what}");
|
||||||
assert_eq!(storage.inner.counts(), before, "{what}");
|
assert_eq!(storage.inner.counts(), before, "{what}");
|
||||||
}
|
}
|
||||||
@@ -523,26 +549,187 @@ mod tests {
|
|||||||
batch.put_files.push((snapshot.order[0], from_the_future()));
|
batch.put_files.push((snapshot.order[0], from_the_future()));
|
||||||
});
|
});
|
||||||
let mut persister = Persister::default();
|
let mut persister = Persister::default();
|
||||||
assert!(block_on(persister.restore(&storage)).unwrap().read_only);
|
assert!(block_on(persister.restore(&storage)).is_err());
|
||||||
|
|
||||||
// the same persister, on a storage that is not newer
|
// the same persister, on a storage that is not newer
|
||||||
let clean = Recording::default();
|
let clean = Recording::default();
|
||||||
let restored = block_on(persister.restore(&clean)).unwrap();
|
block_on(persister.restore(&clean)).unwrap();
|
||||||
assert!(!restored.read_only);
|
|
||||||
block_on(persister.save(&clean, snapshot_of(&FILES))).unwrap();
|
block_on(persister.save(&clean, snapshot_of(&FILES))).unwrap();
|
||||||
assert_eq!(clean.inner.counts().files, FILES.len());
|
assert_eq!(clean.inner.counts().files, FILES.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opens a storage as a tab does: the persister, and the state of the engine it gives.
|
||||||
|
fn open(storage: &Recording) -> (Persister, Snapshot) {
|
||||||
|
let mut persister = Persister::default();
|
||||||
|
let restored = block_on(persister.restore(storage)).unwrap();
|
||||||
|
let snapshot = Snapshot {
|
||||||
|
files: restored.files.iter().map(|f| (f.id, f.clone())).collect(),
|
||||||
|
order: restored.order,
|
||||||
|
categories: restored.categories,
|
||||||
|
};
|
||||||
|
(persister, snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a file that has just been read to the state of an engine.
|
||||||
|
fn add_file(snapshot: &mut Snapshot, path: &str) -> FileId {
|
||||||
|
let data = std::fs::read(path).unwrap();
|
||||||
|
let file = parse(&data, &mut snapshot.categories).unwrap();
|
||||||
|
let id = file.id;
|
||||||
|
snapshot.order.push(id);
|
||||||
|
snapshot.files.insert(id, Rc::new(file));
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_files_saved_by_engines_with_other_categories_read_the_same() {
|
||||||
|
// a second engine opens the storage, so its categories come from what it read, in the
|
||||||
|
// order it read them; it adds a file that has names of its own
|
||||||
|
let storage = Recording::default();
|
||||||
|
let first = snapshot_of(&["data/with_surface.gpx", "data/with_highway.gpx"]);
|
||||||
|
block_on(Persister::default().save(&storage, first.clone())).unwrap();
|
||||||
|
|
||||||
|
let (mut persister, mut second) = open(&storage);
|
||||||
|
let added = add_file(&mut second, "data/with_surface.gpx");
|
||||||
|
block_on(persister.save(&storage, second.clone())).unwrap();
|
||||||
|
|
||||||
|
// the first engine reads it all again, and every file means what it did for its engine
|
||||||
|
let (_, reader) = open(&storage);
|
||||||
|
assert_eq!(reader.files.len(), 3);
|
||||||
|
for (id, file) in &reader.files {
|
||||||
|
let (original, categories) = match first.files.get(id) {
|
||||||
|
Some(original) => (original, &first.categories),
|
||||||
|
None => (&second.files[id], &second.categories),
|
||||||
|
};
|
||||||
|
assert_eq!(gpx(file, &reader.categories), gpx(original, categories));
|
||||||
|
}
|
||||||
|
assert!(reader.files.contains_key(&added));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_the_head_follows_the_commits_of_the_persister() {
|
||||||
|
let storage = Recording::default();
|
||||||
|
let mut persister = Persister::default();
|
||||||
|
assert_eq!(persister.head(), Revision(0));
|
||||||
|
|
||||||
|
let mut snapshot = snapshot_of(&FILES);
|
||||||
|
block_on(persister.save(&storage, snapshot.clone())).unwrap();
|
||||||
|
assert_eq!(persister.head(), Revision(1));
|
||||||
|
assert_eq!(block_on(storage.head()).unwrap(), Revision(1));
|
||||||
|
|
||||||
|
// nothing to write is no commit
|
||||||
|
block_on(persister.save(&storage, snapshot.clone())).unwrap();
|
||||||
|
assert_eq!(persister.head(), Revision(1));
|
||||||
|
assert_eq!(storage.batches.borrow().len(), 1);
|
||||||
|
|
||||||
|
let id = snapshot.order[0];
|
||||||
|
let mut file = (*snapshot.files[&id]).clone();
|
||||||
|
file.info.name = "renamed".into();
|
||||||
|
snapshot.files.insert(id, Rc::new(file));
|
||||||
|
block_on(persister.save(&storage, snapshot)).unwrap();
|
||||||
|
assert_eq!(persister.head(), Revision(2));
|
||||||
|
// a persister that opens the storage starts from its head
|
||||||
|
assert_eq!(open(&storage).0.head(), Revision(2));
|
||||||
|
assert_eq!(storage.batches.borrow()[1].expect_head, Revision(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_a_tab_that_is_behind_cannot_write() {
|
||||||
|
// two tabs open the same file; one edits a point, the other renames the file
|
||||||
|
let storage = Recording::default();
|
||||||
|
block_on(Persister::default().save(&storage, snapshot_of(&["data/simple.gpx"]))).unwrap();
|
||||||
|
let ((mut a, mut snap_a), (mut b, mut snap_b)) = (open(&storage), open(&storage));
|
||||||
|
let id = snap_a.order[0];
|
||||||
|
|
||||||
|
let mut file = (*snap_a.files[&id]).clone();
|
||||||
|
file.trk[0].trkseg[0].update(0, |p| p.ele += 100.0);
|
||||||
|
snap_a.files.insert(id, Rc::new(file));
|
||||||
|
block_on(a.save(&storage, snap_a.clone())).unwrap();
|
||||||
|
let stored = block_on(storage.load()).unwrap();
|
||||||
|
|
||||||
|
let mut file = (*snap_b.files[&id]).clone();
|
||||||
|
file.info.name = "renamed by B".into();
|
||||||
|
snap_b.files.insert(id, Rc::new(file));
|
||||||
|
assert_eq!(
|
||||||
|
block_on(b.save(&storage, snap_b)),
|
||||||
|
Err(CommitError::Stale { head: Revision(2) })
|
||||||
|
);
|
||||||
|
// nothing was written, the file is still readable, and the persister did not move
|
||||||
|
assert_eq!(block_on(storage.load()).unwrap(), stored);
|
||||||
|
assert_eq!(b.head(), Revision(1));
|
||||||
|
let (_, reader) = open(&storage);
|
||||||
|
assert_eq!(reader.files.len(), 1);
|
||||||
|
assert_eq!(reader.files[&id].info.name, snap_a.files[&id].info.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_a_tab_that_is_behind_reads_the_storage_again_and_writes_again() {
|
||||||
|
let storage = Recording::default();
|
||||||
|
block_on(Persister::default().save(&storage, snapshot_of(&["data/simple.gpx"]))).unwrap();
|
||||||
|
let ((mut a, mut snap_a), (mut b, snap_b)) = (open(&storage), open(&storage));
|
||||||
|
let id = snap_a.order[0];
|
||||||
|
assert!(!block_on(b.is_behind(&storage)).unwrap());
|
||||||
|
|
||||||
|
let mut file = (*snap_a.files[&id]).clone();
|
||||||
|
file.info.name = "renamed by A".into();
|
||||||
|
snap_a.files.insert(id, Rc::new(file));
|
||||||
|
block_on(a.save(&storage, snap_a)).unwrap();
|
||||||
|
assert!(block_on(b.is_behind(&storage)).unwrap());
|
||||||
|
assert!(!block_on(a.is_behind(&storage)).unwrap());
|
||||||
|
|
||||||
|
let restored = block_on(b.restore(&storage)).unwrap();
|
||||||
|
assert_eq!(restored.files[0].info.name, "renamed by A");
|
||||||
|
assert!(!block_on(b.is_behind(&storage)).unwrap());
|
||||||
|
let mut snapshot = snap_b;
|
||||||
|
snapshot.files.insert(id, restored.files[0].clone());
|
||||||
|
assert!(block_on(b.save(&storage, snapshot)).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_chunks_shared_by_files_still_are_after_the_names_are_read() {
|
||||||
|
let storage = Recording::default();
|
||||||
|
let mut snapshot = snapshot_of(&["data/with_surface.gpx"]);
|
||||||
|
let original = snapshot.files[&snapshot.order[0]].clone();
|
||||||
|
let mut copy = (*original).clone();
|
||||||
|
copy.id = FileId::default();
|
||||||
|
snapshot.order.push(copy.id);
|
||||||
|
snapshot.files.insert(copy.id, Rc::new(copy));
|
||||||
|
block_on(Persister::default().save(&storage, snapshot)).unwrap();
|
||||||
|
|
||||||
|
let restored = block_on(Persister::default().restore(&storage)).unwrap();
|
||||||
|
assert_eq!(restored.files.len(), 2);
|
||||||
|
assert!(Rc::ptr_eq(
|
||||||
|
&restored.files[0].trk[0].trkseg[0].chunks()[0],
|
||||||
|
&restored.files[1].trk[0].trkseg[0].chunks()[0]
|
||||||
|
));
|
||||||
|
assert!(!restored.categories.surface.names().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_nothing_but_the_files_and_the_order_is_stored() {
|
||||||
|
let storage = Recording::default();
|
||||||
|
block_on(Persister::default().save(&storage, snapshot_of(&FILES))).unwrap();
|
||||||
|
// the categories are in the chunks: a storage with no more than these is complete
|
||||||
|
let data = block_on(storage.load()).unwrap();
|
||||||
|
assert_eq!(data.files.len(), FILES.len());
|
||||||
|
assert!(data.order.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_an_engine_starts_from_what_is_restored() {
|
fn test_an_engine_starts_from_what_is_restored() {
|
||||||
let storage = Recording::default();
|
let storage = Recording::default();
|
||||||
let mut engine = Engine::default();
|
let mut engine = Engine::default();
|
||||||
for path in FILES {
|
let load = |engine: &mut Engine, path: &str| {
|
||||||
let data = std::fs::read(path).unwrap();
|
let data = std::fs::read(path).unwrap();
|
||||||
assert!(engine.run(Action::Edit(Command::Load(Load {
|
engine
|
||||||
|
.execute(Action::Edit(Command::Load(Load {
|
||||||
data: &data,
|
data: &data,
|
||||||
name: "file",
|
name: "file",
|
||||||
}))));
|
})))
|
||||||
|
.unwrap()
|
||||||
|
.changed
|
||||||
|
};
|
||||||
|
for path in FILES {
|
||||||
|
assert!(load(&mut engine, path));
|
||||||
}
|
}
|
||||||
let snapshot = engine.snapshot();
|
let snapshot = engine.snapshot();
|
||||||
assert_eq!(snapshot.files.len(), FILES.len());
|
assert_eq!(snapshot.files.len(), FILES.len());
|
||||||
@@ -550,25 +737,20 @@ mod tests {
|
|||||||
|
|
||||||
let restored = block_on(Persister::default().restore(&storage)).unwrap();
|
let restored = block_on(Persister::default().restore(&storage)).unwrap();
|
||||||
let mut engine = Engine::default();
|
let mut engine = Engine::default();
|
||||||
engine.restore(restored);
|
let outcome = engine.restore(restored);
|
||||||
assert_eq!(engine.snapshot().order, snapshot.order);
|
assert_eq!(engine.snapshot().order, snapshot.order);
|
||||||
let diff = engine.last_diff().unwrap();
|
assert_eq!(outcome.diff.unwrap().added.len(), FILES.len());
|
||||||
assert_eq!(diff.added.len(), FILES.len());
|
assert!(outcome.order_changed);
|
||||||
assert!(engine.order_changed());
|
|
||||||
for id in &snapshot.order {
|
for id in &snapshot.order {
|
||||||
assert!(engine.file_structure(id).is_some());
|
assert!(engine.file_structure(id).is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
// the files that were restored are not undone
|
// the files that were restored are not undone
|
||||||
assert!(!engine.can_undo());
|
assert!(!engine.can_undo());
|
||||||
assert!(!engine.run(Action::Undo));
|
assert!(!engine.execute(Action::Undo).unwrap().changed);
|
||||||
let data = std::fs::read(FILES[0]).unwrap();
|
assert!(load(&mut engine, FILES[0]));
|
||||||
assert!(engine.run(Action::Edit(Command::Load(Load {
|
|
||||||
data: &data,
|
|
||||||
name: "file",
|
|
||||||
}))));
|
|
||||||
assert!(engine.can_undo());
|
assert!(engine.can_undo());
|
||||||
assert!(engine.run(Action::Undo));
|
assert!(engine.execute(Action::Undo).unwrap().changed);
|
||||||
assert_eq!(engine.snapshot().files.len(), FILES.len());
|
assert_eq!(engine.snapshot().files.len(), FILES.len());
|
||||||
assert!(!engine.can_undo());
|
assert!(!engine.can_undo());
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-26
@@ -2,9 +2,13 @@
|
|||||||
//!
|
//!
|
||||||
//! Values are stored as `Uint8Array`s under string keys (the hyphenated UUIDs), in one object
|
//! Values are stored as `Uint8Array`s under string keys (the hyphenated UUIDs), in one object
|
||||||
//! store per kind of data. A batch is a single transaction, so that the stored files always are
|
//! store per kind of data. A batch is a single transaction, so that the stored files always are
|
||||||
//! ones that the engine had.
|
//! ones that the engine had. The head is a number in the meta store, which the transaction of a
|
||||||
|
//! batch checks before it writes anything (so that a tab that is behind cannot write over what
|
||||||
|
//! another tab saved).
|
||||||
|
|
||||||
use gpx_engine::{Batch, ChunkKey, ChunkKind, FileId, Storage, StorageError, StoredData};
|
use gpx_engine::{
|
||||||
|
Batch, ChunkKey, ChunkKind, CommitError, FileId, Revision, Storage, StorageError, StoredData,
|
||||||
|
};
|
||||||
use idb::TransactionMode;
|
use idb::TransactionMode;
|
||||||
use idb::{Database, DatabaseEvent, Factory, ObjectStore, ObjectStoreParams, Query, Transaction};
|
use idb::{Database, DatabaseEvent, Factory, ObjectStore, ObjectStoreParams, Query, Transaction};
|
||||||
use js_sys::Uint8Array;
|
use js_sys::Uint8Array;
|
||||||
@@ -14,20 +18,24 @@ use wasm_bindgen::JsValue;
|
|||||||
const FILES: &str = "files";
|
const FILES: &str = "files";
|
||||||
const TRACKPOINT_CHUNKS: &str = "trackpoint_chunks";
|
const TRACKPOINT_CHUNKS: &str = "trackpoint_chunks";
|
||||||
const WAYPOINT_CHUNKS: &str = "waypoint_chunks";
|
const WAYPOINT_CHUNKS: &str = "waypoint_chunks";
|
||||||
/// The order of the files and the categories of the trackpoints, under their own names.
|
/// The order of the files and the head, under their own names.
|
||||||
const META: &str = "meta";
|
const META: &str = "meta";
|
||||||
const SETTINGS: &str = "settings";
|
const SETTINGS: &str = "settings";
|
||||||
const STORES: [&str; 5] = [FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META, SETTINGS];
|
const STORES: [&str; 5] = [FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META, SETTINGS];
|
||||||
|
/// The stores of the files, which a commit writes to together with the head.
|
||||||
|
const COMMIT_STORES: [&str; 4] = [FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META];
|
||||||
|
|
||||||
const ORDER_KEY: &str = "order";
|
const ORDER_KEY: &str = "order";
|
||||||
const CATEGORIES_KEY: &str = "categories";
|
const HEAD_KEY: &str = "head";
|
||||||
|
/// Version of the database.
|
||||||
|
const VERSION: u32 = 1;
|
||||||
|
|
||||||
pub struct IdbStorage {
|
pub struct IdbStorage {
|
||||||
database: Database,
|
database: Database,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn error(error: impl std::fmt::Display) -> StorageError {
|
fn error(error: impl std::fmt::Display) -> StorageError {
|
||||||
StorageError(error.to_string())
|
StorageError::Failed(error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The object store of a kind of chunks.
|
/// The object store of a kind of chunks.
|
||||||
@@ -60,7 +68,7 @@ impl IdbStorage {
|
|||||||
/// Opens the database, which is created if it does not exist.
|
/// Opens the database, which is created if it does not exist.
|
||||||
pub async fn open(name: &str) -> Result<Self, StorageError> {
|
pub async fn open(name: &str) -> Result<Self, StorageError> {
|
||||||
let factory = Factory::new().map_err(error)?;
|
let factory = Factory::new().map_err(error)?;
|
||||||
let mut request = factory.open(name, Some(1)).map_err(error)?;
|
let mut request = factory.open(name, Some(VERSION)).map_err(error)?;
|
||||||
request.on_upgrade_needed(|event| {
|
request.on_upgrade_needed(|event| {
|
||||||
if let Ok(database) = event.database() {
|
if let Ok(database) = event.database() {
|
||||||
for store in STORES {
|
for store in STORES {
|
||||||
@@ -72,6 +80,18 @@ impl IdbStorage {
|
|||||||
Ok(Self { database })
|
Ok(Self { database })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The head, 0 for a database that was never written to.
|
||||||
|
async fn read_head(meta: &ObjectStore) -> Result<Revision, StorageError> {
|
||||||
|
let head = meta
|
||||||
|
.get(JsValue::from_str(HEAD_KEY))
|
||||||
|
.map_err(error)?
|
||||||
|
.await
|
||||||
|
.map_err(error)?;
|
||||||
|
Ok(Revision(
|
||||||
|
head.and_then(|head| head.as_f64()).unwrap_or_default() as u64,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn transaction(
|
fn transaction(
|
||||||
&self,
|
&self,
|
||||||
stores: &[&str],
|
stores: &[&str],
|
||||||
@@ -88,9 +108,19 @@ impl IdbStorage {
|
|||||||
Ok(keys.into_iter().zip(values.iter().map(to_vec)).collect())
|
Ok(keys.into_iter().zip(values.iter().map(to_vec)).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write(&self, transaction: &Transaction, batch: &Batch) -> Result<(), StorageError> {
|
/// Writes a batch in a transaction, if the head is the one it is based on. Gives the new head.
|
||||||
let files = transaction.object_store(FILES).map_err(error)?;
|
async fn write(
|
||||||
|
&self,
|
||||||
|
transaction: &Transaction,
|
||||||
|
batch: &Batch,
|
||||||
|
) -> Result<Revision, CommitError> {
|
||||||
let meta = transaction.object_store(META).map_err(error)?;
|
let meta = transaction.object_store(META).map_err(error)?;
|
||||||
|
let head = Self::read_head(&meta).await?;
|
||||||
|
if head != batch.expect_head {
|
||||||
|
return Err(CommitError::Stale { head });
|
||||||
|
}
|
||||||
|
let new_head = head.next();
|
||||||
|
let files = transaction.object_store(FILES).map_err(error)?;
|
||||||
|
|
||||||
// the requests are all made before waiting for them
|
// the requests are all made before waiting for them
|
||||||
let mut puts = vec![];
|
let mut puts = vec![];
|
||||||
@@ -111,13 +141,13 @@ impl IdbStorage {
|
|||||||
.map_err(error)?,
|
.map_err(error)?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(categories) = &batch.categories {
|
|
||||||
let value = bytes(categories);
|
|
||||||
puts.push(
|
puts.push(
|
||||||
meta.put(&value, Some(&JsValue::from_str(CATEGORIES_KEY)))
|
meta.put(
|
||||||
|
&JsValue::from_f64(new_head.0 as f64),
|
||||||
|
Some(&JsValue::from_str(HEAD_KEY)),
|
||||||
|
)
|
||||||
.map_err(error)?,
|
.map_err(error)?,
|
||||||
);
|
);
|
||||||
}
|
|
||||||
for FileId(id) in &batch.delete_files {
|
for FileId(id) in &batch.delete_files {
|
||||||
deletes.push(files.delete(Query::Key(key(*id))).map_err(error)?);
|
deletes.push(files.delete(Query::Key(key(*id))).map_err(error)?);
|
||||||
}
|
}
|
||||||
@@ -133,7 +163,7 @@ impl IdbStorage {
|
|||||||
for delete in deletes {
|
for delete in deletes {
|
||||||
delete.await.map_err(error)?;
|
delete.await.map_err(error)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(new_head)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,11 +183,7 @@ impl Storage for IdbStorage {
|
|||||||
.map_err(error)?
|
.map_err(error)?
|
||||||
.await
|
.await
|
||||||
.map_err(error)?;
|
.map_err(error)?;
|
||||||
let categories = meta
|
let head = Self::read_head(&meta).await?;
|
||||||
.get(JsValue::from_str(CATEGORIES_KEY))
|
|
||||||
.map_err(error)?
|
|
||||||
.await
|
|
||||||
.map_err(error)?;
|
|
||||||
transaction.await.map_err(error)?;
|
transaction.await.map_err(error)?;
|
||||||
|
|
||||||
let ids = |records: Vec<(JsValue, Vec<u8>)>| -> Result<Vec<(Uuid, Vec<u8>)>, StorageError> {
|
let ids = |records: Vec<(JsValue, Vec<u8>)>| -> Result<Vec<(Uuid, Vec<u8>)>, StorageError> {
|
||||||
@@ -181,19 +207,24 @@ impl Storage for IdbStorage {
|
|||||||
)
|
)
|
||||||
.collect(),
|
.collect(),
|
||||||
order: order.as_ref().map(to_vec),
|
order: order.as_ref().map(to_vec),
|
||||||
categories: categories.as_ref().map(to_vec),
|
head,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn commit(&self, batch: &Batch) -> Result<(), StorageError> {
|
async fn head(&self) -> Result<Revision, StorageError> {
|
||||||
let transaction = self.transaction(
|
let transaction = self.transaction(&[META], TransactionMode::ReadOnly)?;
|
||||||
&[FILES, TRACKPOINT_CHUNKS, WAYPOINT_CHUNKS, META],
|
let meta = transaction.object_store(META).map_err(error)?;
|
||||||
TransactionMode::ReadWrite,
|
let head = Self::read_head(&meta).await?;
|
||||||
)?;
|
transaction.await.map_err(error)?;
|
||||||
|
Ok(head)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn commit(&self, batch: &Batch) -> Result<Revision, CommitError> {
|
||||||
|
let transaction = self.transaction(&COMMIT_STORES, TransactionMode::ReadWrite)?;
|
||||||
match self.write(&transaction, batch).await {
|
match self.write(&transaction, batch).await {
|
||||||
Ok(()) => {
|
Ok(head) => {
|
||||||
transaction.commit().map_err(error)?.await.map_err(error)?;
|
transaction.commit().map_err(error)?.await.map_err(error)?;
|
||||||
Ok(())
|
Ok(head)
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
// nothing of the batch is kept
|
// nothing of the batch is kept
|
||||||
|
|||||||
+132
-13
@@ -10,13 +10,16 @@ use wasm_bindgen::prelude::*;
|
|||||||
use crate::convert::{outcome_object, set};
|
use crate::convert::{outcome_object, set};
|
||||||
use crate::idb::IdbStorage;
|
use crate::idb::IdbStorage;
|
||||||
use crate::session::*;
|
use crate::session::*;
|
||||||
use crate::ts::StorageOpened;
|
use crate::ts::{Outcome, StorageOpened};
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
//
|
//
|
||||||
// The files are saved in IndexedDB after each change that touches them, without making the change
|
// The files are saved in IndexedDB after each change that touches them, without making the change
|
||||||
// wait: `schedule_save` starts a loop that writes what changed since the last write, until
|
// wait: `schedule_save` starts a loop that writes what changed since the last write, until
|
||||||
// nothing has. The settings are opaque to the engine, the frontend keeps its own copy of them.
|
// nothing has. When another tab wrote in between, the write is refused (the storage has a newer
|
||||||
|
// head than this tab last saw): the tab reads the storage again and overwrites what it holds, see
|
||||||
|
// `sync_storage`. When what is stored was written by a newer version of the app, the write is
|
||||||
|
// refused too, and the frontend is told, so that it makes the user reload the page. The settings are opaque to the engine, the frontend keeps its own copy of them.
|
||||||
|
|
||||||
thread_local! {
|
thread_local! {
|
||||||
static STORAGE: RefCell<Option<Rc<IdbStorage>>> = const { RefCell::new(None) };
|
static STORAGE: RefCell<Option<Rc<IdbStorage>>> = const { RefCell::new(None) };
|
||||||
@@ -24,9 +27,20 @@ thread_local! {
|
|||||||
static SAVING: Cell<bool> = const { Cell::new(false) };
|
static SAVING: Cell<bool> = const { Cell::new(false) };
|
||||||
static DIRTY: Cell<bool> = const { Cell::new(false) };
|
static DIRTY: Cell<bool> = const { Cell::new(false) };
|
||||||
/// Resolve functions of the promises of `flush_storage`.
|
/// Resolve functions of the promises of `flush_storage`.
|
||||||
|
/// Called after the files were written, and when a write was refused because they were
|
||||||
|
/// written by someone else since: see `open_storage`.
|
||||||
|
static LISTENERS: RefCell<Option<Listeners>> = const { RefCell::new(None) };
|
||||||
static WAITERS: RefCell<Vec<js_sys::Function>> = const { RefCell::new(Vec::new()) };
|
static WAITERS: RefCell<Vec<js_sys::Function>> = const { RefCell::new(Vec::new()) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the frontend is told: the files were written, a write was refused because another tab
|
||||||
|
/// wrote since, a write was refused because what is stored comes from a newer version.
|
||||||
|
struct Listeners {
|
||||||
|
saved: js_sys::Function,
|
||||||
|
behind: js_sys::Function,
|
||||||
|
newer: js_sys::Function,
|
||||||
|
}
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
extern "C" {
|
extern "C" {
|
||||||
#[wasm_bindgen(js_namespace = console, js_name = error)]
|
#[wasm_bindgen(js_namespace = console, js_name = error)]
|
||||||
@@ -47,6 +61,7 @@ pub(crate) fn schedule_save() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
wasm_bindgen_futures::spawn_local(async {
|
wasm_bindgen_futures::spawn_local(async {
|
||||||
|
let (mut saved, mut behind, mut newer) = (false, false, false);
|
||||||
while DIRTY.replace(false) {
|
while DIRTY.replace(false) {
|
||||||
let Some(storage) = storage() else { break };
|
let Some(storage) = storage() else { break };
|
||||||
let Some(snapshot) = with_engine(Engine::snapshot) else {
|
let Some(snapshot) = with_engine(Engine::snapshot) else {
|
||||||
@@ -57,38 +72,86 @@ pub(crate) fn schedule_save() {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let result = persister.save(&*storage, snapshot).await;
|
let result = persister.save(&*storage, snapshot).await;
|
||||||
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
|
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
|
||||||
if let Err(error) = result {
|
match result {
|
||||||
|
Ok(()) => saved = true,
|
||||||
|
// nothing was written, so that the files of the other tab are not damaged: this
|
||||||
|
// tab has to read them, which replaces what it did
|
||||||
|
Err(engine::CommitError::Stale { .. }) => {
|
||||||
|
DIRTY.set(false);
|
||||||
|
behind = true;
|
||||||
|
}
|
||||||
|
// nothing is written, so that the newer version finds its data again
|
||||||
|
Err(engine::CommitError::Failed(engine::StorageError::Newer)) => {
|
||||||
|
DIRTY.set(false);
|
||||||
|
newer = true;
|
||||||
|
}
|
||||||
// what failed is written with the next change
|
// what failed is written with the next change
|
||||||
console_error(&error.to_string());
|
Err(error) => console_error(&error.to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SAVING.set(false);
|
SAVING.set(false);
|
||||||
|
if saved {
|
||||||
|
notify(|listeners| &listeners.saved);
|
||||||
|
}
|
||||||
|
if behind {
|
||||||
|
notify(|listeners| &listeners.behind);
|
||||||
|
}
|
||||||
|
if newer {
|
||||||
|
notify(|listeners| &listeners.newer);
|
||||||
|
}
|
||||||
for resolve in WAITERS.with(|waiters| std::mem::take(&mut *waiters.borrow_mut())) {
|
for resolve in WAITERS.with(|waiters| std::mem::take(&mut *waiters.borrow_mut())) {
|
||||||
let _ = resolve.call0(&JsValue::UNDEFINED);
|
let _ = resolve.call0(&JsValue::UNDEFINED);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn notify(pick: impl FnOnce(&Listeners) -> &js_sys::Function) {
|
||||||
|
let callback = LISTENERS.with(|l| l.borrow().as_ref().map(|l| pick(l).clone()));
|
||||||
|
if let Some(callback) = callback {
|
||||||
|
let _ = callback.call0(&JsValue::UNDEFINED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Opens the database `name` (it is created if it does not exist), puts the files that it holds
|
/// Opens the database `name` (it is created if it does not exist), puts the files that it holds
|
||||||
/// in the engine, which has to be empty, and returns the settings (JSON strings by key) and the
|
/// in the engine, which has to be empty, and returns the settings (JSON strings by key) and the
|
||||||
/// outcome of putting the files in. The changes of the files that follow are saved, unless some of
|
/// outcome of putting the files in. The changes of the files that follow are saved, unless some of
|
||||||
/// what is stored was written by a newer version of the app (`readOnly`): then nothing is, so that
|
/// what is stored was written by a newer version of the app: then nothing is read and nothing is
|
||||||
/// the newer version finds its data again.
|
/// ever written, so that the newer version finds its data again.
|
||||||
|
///
|
||||||
|
/// `on_saved` is called after the files were written (to tell the other tabs), `on_behind` when
|
||||||
|
/// a write was refused because another tab wrote since (call `sync_storage` then), and `on_newer`
|
||||||
|
/// when what is stored comes from a newer version (now, or when it is read again): the page has to
|
||||||
|
/// be reloaded, the changes are not kept.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub async fn open_storage(name: &str) -> Result<StorageOpened, JsValue> {
|
pub async fn open_storage(
|
||||||
|
name: &str,
|
||||||
|
on_saved: js_sys::Function,
|
||||||
|
on_behind: js_sys::Function,
|
||||||
|
on_newer: js_sys::Function,
|
||||||
|
) -> Result<StorageOpened, JsValue> {
|
||||||
let empty = with_engine(|e| e.snapshot().files.is_empty()).unwrap_or(false);
|
let empty = with_engine(|e| e.snapshot().files.is_empty()).unwrap_or(false);
|
||||||
if !empty {
|
if !empty {
|
||||||
return Err(JsValue::from_str("the engine already has files"));
|
return Err(JsValue::from_str("the engine already has files"));
|
||||||
}
|
}
|
||||||
let storage = IdbStorage::open(name).await.map_err(to_js_error)?;
|
let storage = IdbStorage::open(name).await.map_err(to_js_error)?;
|
||||||
let mut persister = Persister::default();
|
let mut persister = Persister::default();
|
||||||
let restored = persister.restore(&storage).await.map_err(to_js_error)?;
|
let outcome = restore_engine(persister.restore(&storage).await).map_err(to_js_error)?;
|
||||||
let (read_only, unreadable) = (restored.read_only, restored.unreadable);
|
let newer = outcome.is_none();
|
||||||
|
let outcome = outcome.unwrap_or_else(|| outcome_object(&engine::Outcome::default(), None));
|
||||||
let settings = storage.load_settings().await.map_err(to_js_error)?;
|
let settings = storage.load_settings().await.map_err(to_js_error)?;
|
||||||
|
|
||||||
let outcome = with_engine_mut(|engine| engine.restore(restored)).unwrap_or_default();
|
|
||||||
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
|
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
|
||||||
STORAGE.with(|slot| *slot.borrow_mut() = Some(Rc::new(storage)));
|
STORAGE.with(|slot| *slot.borrow_mut() = Some(Rc::new(storage)));
|
||||||
|
LISTENERS.with(|slot| {
|
||||||
|
*slot.borrow_mut() = Some(Listeners {
|
||||||
|
saved: on_saved,
|
||||||
|
behind: on_behind,
|
||||||
|
newer: on_newer,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if newer {
|
||||||
|
notify(|listeners| &listeners.newer);
|
||||||
|
}
|
||||||
|
|
||||||
let settings_object = Object::new();
|
let settings_object = Object::new();
|
||||||
for (key, value) in settings {
|
for (key, value) in settings {
|
||||||
@@ -96,12 +159,28 @@ pub async fn open_storage(name: &str) -> Result<StorageOpened, JsValue> {
|
|||||||
}
|
}
|
||||||
let opened = Object::new();
|
let opened = Object::new();
|
||||||
set(&opened, "settings", settings_object);
|
set(&opened, "settings", settings_object);
|
||||||
set(&opened, "outcome", outcome_object(&outcome, None));
|
set(&opened, "outcome", outcome);
|
||||||
set(&opened, "readOnly", read_only);
|
|
||||||
set(&opened, "unreadable", unreadable as f64);
|
|
||||||
Ok(opened.unchecked_into())
|
Ok(opened.unchecked_into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Overwrites the files of the engine with what was read, and tells what that did. If what is
|
||||||
|
/// stored comes from a newer version of the app, the files are left as they are and `None` is
|
||||||
|
/// returned: the persister does not write anymore (see `Persister::save`).
|
||||||
|
fn restore_engine(
|
||||||
|
read: Result<engine::Restored, engine::StorageError>,
|
||||||
|
) -> Result<Option<Outcome>, engine::StorageError> {
|
||||||
|
match read {
|
||||||
|
Ok(restored) => Ok(Some(
|
||||||
|
match with_engine_mut(|engine| engine.restore(restored)) {
|
||||||
|
Some(outcome) => outcome_object(&outcome, None),
|
||||||
|
None => outcome_object(&engine::Outcome::default(), None),
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
Err(engine::StorageError::Newer) => Ok(None),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn to_js_error(error: engine::StorageError) -> JsValue {
|
pub(crate) fn to_js_error(error: engine::StorageError) -> JsValue {
|
||||||
JsValue::from_str(&error.to_string())
|
JsValue::from_str(&error.to_string())
|
||||||
}
|
}
|
||||||
@@ -119,6 +198,46 @@ pub async fn flush_storage() -> Result<(), JsValue> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// If another tab wrote the files since this one last did, reads them again and overwrites the
|
||||||
|
/// files of the engine with them (what was changed here and not saved is lost, and the history
|
||||||
|
/// starts over). Returns what that changed, to apply like the outcome of any other call.
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub async fn sync_storage() -> Result<Outcome, JsValue> {
|
||||||
|
// the writes are done first, and none starts before this is done
|
||||||
|
while SAVING.get() {
|
||||||
|
flush_storage().await?;
|
||||||
|
}
|
||||||
|
let (Some(storage), Some(mut persister)) = (
|
||||||
|
storage(),
|
||||||
|
PERSISTER.with(|persister| persister.borrow_mut().take()),
|
||||||
|
) else {
|
||||||
|
return Ok(outcome_object(&engine::Outcome::default(), None));
|
||||||
|
};
|
||||||
|
SAVING.set(true);
|
||||||
|
let result = async {
|
||||||
|
if !persister.is_behind(&*storage).await? {
|
||||||
|
return Ok(outcome_object(&engine::Outcome::default(), None));
|
||||||
|
}
|
||||||
|
let outcome = restore_engine(persister.restore(&*storage).await)?;
|
||||||
|
DIRTY.set(false);
|
||||||
|
if outcome.is_none() {
|
||||||
|
notify(|listeners| &listeners.newer);
|
||||||
|
}
|
||||||
|
Ok(outcome.unwrap_or_else(|| outcome_object(&engine::Outcome::default(), None)))
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
|
||||||
|
SAVING.set(false);
|
||||||
|
for resolve in WAITERS.with(|waiters| std::mem::take(&mut *waiters.borrow_mut())) {
|
||||||
|
let _ = resolve.call0(&JsValue::UNDEFINED);
|
||||||
|
}
|
||||||
|
if DIRTY.get() {
|
||||||
|
// edited while reading, and nothing was overwritten
|
||||||
|
schedule_save();
|
||||||
|
}
|
||||||
|
result.map_err(to_js_error)
|
||||||
|
}
|
||||||
|
|
||||||
/// Saves a setting, `value` being its JSON.
|
/// Saves a setting, `value` being its JSON.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub fn set_setting(key: String, value: String) {
|
pub fn set_setting(key: String, value: String) {
|
||||||
|
|||||||
@@ -21,14 +21,15 @@ pub(crate) fn fail(message: impl AsRef<str>) -> Outcome {
|
|||||||
|
|
||||||
/// Runs an action and tells what it did, see `Outcome` in `ts`.
|
/// Runs an action and tells what it did, see `Outcome` in `ts`.
|
||||||
pub(crate) fn execute(action: Action) -> Outcome {
|
pub(crate) fn execute(action: Action) -> Outcome {
|
||||||
let Some(outcome) = with_engine_mut(|engine| engine.execute(action)) else {
|
let outcome = match with_engine_mut(|engine| engine.execute(action)) {
|
||||||
return fail("the engine is not started");
|
Some(Ok(outcome)) => outcome,
|
||||||
|
Some(Err(error)) => return fail(error.to_string()),
|
||||||
|
None => return fail("the engine is not started"),
|
||||||
};
|
};
|
||||||
if outcome.changed && (outcome.diff.is_some() || outcome.order_changed) {
|
if outcome.changed && (outcome.diff.is_some() || outcome.order_changed) {
|
||||||
schedule_save();
|
schedule_save();
|
||||||
}
|
}
|
||||||
let error = outcome.error.as_ref().map(ToString::to_string);
|
outcome_object(&outcome, None)
|
||||||
outcome_object(&outcome, error.as_deref())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn edit(command: Command) -> Outcome {
|
pub(crate) fn edit(command: Command) -> Outcome {
|
||||||
|
|||||||
@@ -97,10 +97,6 @@ export interface StorageOpened {
|
|||||||
settings: Record<string, string>;
|
settings: Record<string, string>;
|
||||||
/** The files that were restored are the added ones. */
|
/** The files that were restored are the added ones. */
|
||||||
outcome: Outcome;
|
outcome: Outcome;
|
||||||
/** Some of what is stored was written by a newer version: nothing is saved. */
|
|
||||||
readOnly: boolean;
|
|
||||||
/** How many stored files could not be read, and are dropped at the next save. */
|
|
||||||
unreadable: number;
|
|
||||||
}
|
}
|
||||||
export interface FileStructure {
|
export interface FileStructure {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import { languages } from '$lib/languages';
|
import { languages } from '$lib/i18n/languages';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
|
|
||||||
export async function handle({ event, resolve }) {
|
export async function handle({ event, resolve }) {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { defaultFileName } from './default-file-name';
|
import { defaultFileName } from '$lib/actions/default-file-name';
|
||||||
|
|
||||||
describe('default file name', () => {
|
describe('default file name', () => {
|
||||||
it('is the default name when there is no other file with it', () => {
|
it('is the default name when there is no other file with it', () => {
|
||||||
File renamed without changes.
@@ -1,9 +1,9 @@
|
|||||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { boundsManager } from './bounds';
|
import { boundsManager } from '$lib/state/bounds';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
import { defaultFileName } from '$lib/default-file-name';
|
import { defaultFileName } from '$lib/actions/default-file-name';
|
||||||
|
|
||||||
/** The name of a new file: the translated default name, numbered if other files have it. */
|
/** The name of a new file: the translated default name, numbered if other files have it. */
|
||||||
export function newFileName() {
|
export function newFileName() {
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import docsearch from '@docsearch/js';
|
import docsearch from '@docsearch/js';
|
||||||
import '@docsearch/css';
|
import '@docsearch/css';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
|
|
||||||
let props: {
|
let props: {
|
||||||
class?: string;
|
class?: string;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
|
import ModeSwitch from '$lib/components/ModeSwitch.svelte';
|
||||||
import Logo from '$lib/components/Logo.svelte';
|
import Logo from '$lib/components/Logo.svelte';
|
||||||
import { AtSign, BookOpenText, Heart, House, Map } from '@lucide/svelte';
|
import { AtSign, BookOpenText, Heart, House, Map } from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
|
|
||||||
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte';
|
import { MoveDownRight, MoveUpRight, Ruler, Timer, Zap } from '@lucide/svelte';
|
||||||
|
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import type { Readable } from 'svelte/store';
|
import type { Readable } from 'svelte/store';
|
||||||
import type { SelectionStatistics } from '$lib/engine';
|
import type { SelectionStatistics } from '$lib/engine';
|
||||||
import type { SlicedStatistics } from '$lib/logic/selection-statistics';
|
import type { SlicedStatistics } from '$lib/state/selection-statistics';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
|
|
||||||
const { velocityUnits } = settings;
|
const { velocityUnits } = settings;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CircleQuestionMark } from '@lucide/svelte';
|
import { CircleQuestionMark } from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import { languages } from '$lib/languages';
|
import { languages } from '$lib/i18n/languages';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { Languages } from '@lucide/svelte';
|
import { Languages } from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Select.Root type="single" value={i18n.lang}>
|
<Select.Root type="single" value={i18n.lang}>
|
||||||
|
|||||||
@@ -51,19 +51,19 @@
|
|||||||
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||||
import { exportState, ExportState } from '$lib/components/export/utils.svelte';
|
import { exportState, ExportState } from '$lib/components/export/utils.svelte';
|
||||||
import { anySelectedLayer } from '$lib/components/map/layer-control/utils';
|
import { anySelectedLayer } from '$lib/components/map/layer-control/utils';
|
||||||
import { defaultOverlays } from '$lib/assets/layers';
|
import { defaultOverlays } from '$lib/data/layers';
|
||||||
import LayerControlSettings from '$lib/components/map/layer-control/LayerControlSettings.svelte';
|
import LayerControlSettings from '$lib/components/map/layer-control/LayerControlSettings.svelte';
|
||||||
import Export from '$lib/components/export/Export.svelte';
|
import Export from '$lib/components/export/Export.svelte';
|
||||||
import { mode, setMode } from 'mode-watcher';
|
import { mode, setMode } from 'mode-watcher';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { languages } from '$lib/languages';
|
import { languages } from '$lib/i18n/languages';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { createFile, loadFiles, triggerFileInput } from '$lib/logic/file-actions';
|
import { createFile, loadFiles, triggerFileInput } from '$lib/actions/file-actions';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
import { allHidden } from '$lib/all-hidden';
|
import { allHidden } from '$lib/engine/all-hidden';
|
||||||
import { selectionSize } from '$lib/selection-helpers';
|
import { selectionSize } from '$lib/engine/selection-helpers';
|
||||||
import { boundsManager } from '$lib/logic/bounds';
|
import { boundsManager } from '$lib/state/bounds';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Moon, Sun } from '@lucide/svelte';
|
import { Moon, Sun } from '@lucide/svelte';
|
||||||
import { mode, setMode } from 'mode-watcher';
|
import { mode, setMode } from 'mode-watcher';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
class: className = '',
|
class: className = '',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import Logo from '$lib/components/Logo.svelte';
|
import Logo from '$lib/components/Logo.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { BookOpenText, House, Map } from '@lucide/svelte';
|
import { BookOpenText, House, Map } from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { isMac, isSafari } from '$lib/utils';
|
import { isMac, isSafari } from '$lib/utils';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import * as Kbd from '$lib/components/ui/kbd/index.js';
|
import * as Kbd from '$lib/components/ui/kbd/index.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
getElevationUnits,
|
getElevationUnits,
|
||||||
getVelocityUnits,
|
getVelocityUnits,
|
||||||
secondsToHHMMSS,
|
secondsToHHMMSS,
|
||||||
} from '$lib/units';
|
} from '$lib/units/units';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
value,
|
value,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import { languages } from '$lib/languages';
|
import { languages } from '$lib/i18n/languages';
|
||||||
import { getNextGuide, getPreviousGuide, guides } from './guides';
|
import { getNextGuide, getPreviousGuide, guides } from './guides';
|
||||||
|
|
||||||
/** Every page of the help in reading order, as the links at the bottom of the pages follow them. */
|
/** Every page of the help in reading order, as the links at the bottom of the pages follow them. */
|
||||||
|
|||||||
@@ -18,11 +18,11 @@
|
|||||||
Construction,
|
Construction,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import type { Readable, Writable } from 'svelte/store';
|
import type { Readable, Writable } from 'svelte/store';
|
||||||
import type { Coordinates } from '$lib/geo';
|
import type { Coordinates } from '$lib/geo/coordinates';
|
||||||
import type { SelectionStatistics } from '$lib/engine';
|
import type { SelectionStatistics } from '$lib/engine';
|
||||||
import type { SlicedStatistics } from '$lib/logic/selection-statistics';
|
import type { SlicedStatistics } from '$lib/state/selection-statistics';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
|
import { ElevationProfile } from '$lib/components/elevation-profile/elevation-profile';
|
||||||
|
|
||||||
const { velocityUnits } = settings;
|
const { velocityUnits } = settings;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import {
|
import {
|
||||||
getCadenceWithUnits,
|
getCadenceWithUnits,
|
||||||
getConvertedDistance,
|
getConvertedDistance,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
getPowerWithUnits,
|
getPowerWithUnits,
|
||||||
getTemperatureWithUnits,
|
getTemperatureWithUnits,
|
||||||
getVelocityWithUnits,
|
getVelocityWithUnits,
|
||||||
} from '$lib/units';
|
} from '$lib/units/units';
|
||||||
import Chart, {
|
import Chart, {
|
||||||
type ChartEvent,
|
type ChartEvent,
|
||||||
type ChartOptions,
|
type ChartOptions,
|
||||||
@@ -21,7 +21,7 @@ import Chart, {
|
|||||||
type TooltipItem,
|
type TooltipItem,
|
||||||
} from 'chart.js/auto';
|
} from 'chart.js/auto';
|
||||||
import { get, type Readable, type Writable } from 'svelte/store';
|
import { get, type Readable, type Writable } from 'svelte/store';
|
||||||
import type { Coordinates } from '$lib/geo';
|
import type { Coordinates } from '$lib/geo/coordinates';
|
||||||
import {
|
import {
|
||||||
engine,
|
engine,
|
||||||
NO_TIME,
|
NO_TIME,
|
||||||
@@ -29,10 +29,10 @@ import {
|
|||||||
type StatisticsMetric,
|
type StatisticsMetric,
|
||||||
type StatisticsRequest,
|
type StatisticsRequest,
|
||||||
} from '$lib/engine';
|
} from '$lib/engine';
|
||||||
import type { SlicedStatistics } from '$lib/logic/selection-statistics';
|
import type { SlicedStatistics } from '$lib/state/selection-statistics';
|
||||||
import { mode } from 'mode-watcher';
|
import { mode } from 'mode-watcher';
|
||||||
import { categoryValues } from '$lib/trackpoint-categories';
|
import { categoryValues } from '$lib/engine/trackpoint-categories';
|
||||||
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/assets/colors';
|
import { getHighwayColor, getSlopeColor, getSurfaceColor } from '$lib/data/colors';
|
||||||
|
|
||||||
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
const { distanceUnits, velocityUnits, temperatureUnits } = settings;
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,12 @@
|
|||||||
type EmbeddingOptions,
|
type EmbeddingOptions,
|
||||||
} from './embedding';
|
} from './embedding';
|
||||||
import { setMode } from 'mode-watcher';
|
import { setMode } from 'mode-watcher';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { loadFiles } from '$lib/logic/file-actions';
|
import { loadFiles } from '$lib/actions/file-actions';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
import { isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
import { hoveredPoint, slicedStatistics } from '$lib/logic/selection-statistics';
|
import { hoveredPoint, slicedStatistics } from '$lib/state/selection-statistics';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
useHash = true,
|
useHash = true,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
Milestone,
|
Milestone,
|
||||||
Video,
|
Video,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import {
|
import {
|
||||||
allowedEmbeddingBasemaps,
|
allowedEmbeddingBasemaps,
|
||||||
defaultEmbeddingOptions,
|
defaultEmbeddingOptions,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import Logo from '$lib/components/Logo.svelte';
|
import Logo from '$lib/components/Logo.svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
files,
|
files,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
|
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
|
||||||
import { basemaps } from '$lib/assets/layers';
|
import { basemaps } from '$lib/data/layers';
|
||||||
|
|
||||||
export type EmbeddingOptions = {
|
export type EmbeddingOptions = {
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
SquareActivity,
|
SquareActivity,
|
||||||
Route,
|
Route,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { engine, type ExportOptions } from '$lib/engine';
|
import { engine, type ExportOptions } from '$lib/engine';
|
||||||
|
|
||||||
const selection = engine.selection;
|
const selection = engine.selection;
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
import { onMount, setContext } from 'svelte';
|
import { onMount, setContext } from 'svelte';
|
||||||
import { ClipboardPaste, FileStack, Plus } from '@lucide/svelte';
|
import { ClipboardPaste, FileStack, Plus } from '@lucide/svelte';
|
||||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
import { selectedFileIds } from '$lib/selection-helpers';
|
import { selectedFileIds } from '$lib/engine/selection-helpers';
|
||||||
import { createFile } from '$lib/logic/file-actions';
|
import { createFile } from '$lib/actions/file-actions';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
orientation,
|
orientation,
|
||||||
|
|||||||
@@ -3,15 +3,15 @@
|
|||||||
import FileListNodeContent from './FileListNodeContent.svelte';
|
import FileListNodeContent from './FileListNodeContent.svelte';
|
||||||
import FileListNodeLabel from './FileListNodeLabel.svelte';
|
import FileListNodeLabel from './FileListNodeLabel.svelte';
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { engine, type FileState } from '$lib/engine';
|
import { engine, type FileState } from '$lib/engine';
|
||||||
import {
|
import {
|
||||||
elementId,
|
elementId,
|
||||||
hasSelectionWithin,
|
hasSelectionWithin,
|
||||||
isSelected,
|
isSelected,
|
||||||
type FileTreeNode,
|
type FileTreeNode,
|
||||||
} from '$lib/selection-helpers';
|
} from '$lib/engine/selection-helpers';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
fileState,
|
fileState,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import FileListNodeContent from './FileListNodeContent.svelte';
|
import FileListNodeContent from './FileListNodeContent.svelte';
|
||||||
import { allowedMoves, dragging, SortableFileList, type ListLevel } from './sortable-file-list';
|
import { allowedMoves, dragging, SortableFileList, type ListLevel } from './sortable-file-list';
|
||||||
import { engine, type FileState } from '$lib/engine';
|
import { engine, type FileState } from '$lib/engine';
|
||||||
import type { FileTreeNode } from '$lib/selection-helpers';
|
import type { FileTreeNode } from '$lib/engine/selection-helpers';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
fileState,
|
fileState,
|
||||||
|
|||||||
@@ -19,16 +19,16 @@
|
|||||||
FileStack,
|
FileStack,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { getContext } from 'svelte';
|
import { getContext } from 'svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte';
|
import MetadataDialog from '$lib/components/file-list/metadata/MetadataDialog.svelte';
|
||||||
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||||
import StyleDialog from '$lib/components/file-list/style/StyleDialog.svelte';
|
import StyleDialog from '$lib/components/file-list/style/StyleDialog.svelte';
|
||||||
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
import { getSymbolKey, symbols } from '$lib/data/symbols';
|
||||||
import { engine, type FileState } from '$lib/engine';
|
import { engine, type FileState } from '$lib/engine';
|
||||||
import { allHidden } from '$lib/all-hidden';
|
import { allHidden } from '$lib/engine/all-hidden';
|
||||||
import { normalizeColor } from '$lib/file-colors';
|
import { normalizeColor } from '$lib/engine/file-colors';
|
||||||
import { isHidden } from '$lib/file-visibility';
|
import { isHidden } from '$lib/engine/file-visibility';
|
||||||
import {
|
import {
|
||||||
elementId,
|
elementId,
|
||||||
isInClipboard,
|
isInClipboard,
|
||||||
@@ -36,8 +36,8 @@
|
|||||||
selectedElementIds,
|
selectedElementIds,
|
||||||
selectionSize,
|
selectionSize,
|
||||||
type FileTreeNode,
|
type FileTreeNode,
|
||||||
} from '$lib/selection-helpers';
|
} from '$lib/engine/selection-helpers';
|
||||||
import { boundsManager } from '$lib/logic/bounds';
|
import { boundsManager } from '$lib/state/bounds';
|
||||||
import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
|
import { waypointPopup } from '$lib/components/map/gpx-layer/gpx-layer-popup';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
import { Label } from '$lib/components/ui/label/index.js';
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
import * as Popover from '$lib/components/ui/popover';
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
import { Save } from '@lucide/svelte';
|
import { Save } from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
import { editMetadata } from '$lib/components/file-list/metadata/utils.svelte';
|
||||||
import { engine, type FileState } from '$lib/engine';
|
import { engine, type FileState } from '$lib/engine';
|
||||||
import type { FileTreeNode } from '$lib/selection-helpers';
|
import type { FileTreeNode } from '$lib/engine/selection-helpers';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
fileState,
|
fileState,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Sortable, { type Direction } from 'sortablejs/Sortable';
|
|||||||
import { get, writable } from 'svelte/store';
|
import { get, writable } from 'svelte/store';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import { engine, type MoveTarget, type Selection } from '$lib/engine';
|
import { engine, type MoveTarget, type Selection } from '$lib/engine';
|
||||||
import { isSelected, type FileTreeNode } from '$lib/selection-helpers';
|
import { isSelected, type FileTreeNode } from '$lib/engine/selection-helpers';
|
||||||
|
|
||||||
/** What a list holds: the files, the tracks of a file, the segments of a track, or the waypoints. */
|
/** What a list holds: the files, the tracks of a file, the segments of a track, or the waypoints. */
|
||||||
export type ListLevel = 'file' | 'track' | 'segment' | 'waypoints' | 'waypoint';
|
export type ListLevel = 'file' | 'track' | 'segment' | 'waypoints' | 'waypoint';
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
import * as Popover from '$lib/components/ui/popover';
|
import * as Popover from '$lib/components/ui/popover';
|
||||||
import { Save } from '@lucide/svelte';
|
import { Save } from '@lucide/svelte';
|
||||||
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
import { editStyle } from '$lib/components/file-list/style/utils.svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { engine, type FileState } from '$lib/engine';
|
import { engine, type FileState } from '$lib/engine';
|
||||||
import { normalizeColor } from '$lib/file-colors';
|
import { normalizeColor } from '$lib/engine/file-colors';
|
||||||
import { selectedFileIds, type FileTreeNode } from '$lib/selection-helpers';
|
import { selectedFileIds, type FileTreeNode } from '$lib/engine/selection-helpers';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
|
import { PUBLIC_MAPTILER_KEY } from '$env/static/public';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { ClipboardCopy } from '@lucide/svelte';
|
import { ClipboardCopy } from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
coordinates,
|
coordinates,
|
||||||
|
|||||||
@@ -4,12 +4,16 @@
|
|||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||||
import { Compass, Earth, Mountain, Timer } from '@lucide/svelte';
|
import { Compass, Earth, Mountain, Timer } from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import type { PopupItem } from '$lib/components/map/map-popup';
|
import type { PopupItem } from '$lib/components/map/map-popup';
|
||||||
import type { TrackpointDetails } from '$lib/engine';
|
import type { TrackpointDetails } from '$lib/engine';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
|
|
||||||
let { trackpoint }: { trackpoint: PopupItem<Pick<TrackpointDetails, 'lng' | 'lat'> & Partial<TrackpointDetails>> } = $props();
|
let {
|
||||||
|
trackpoint,
|
||||||
|
}: {
|
||||||
|
trackpoint: PopupItem<Pick<TrackpointDetails, 'lng' | 'lat'> & Partial<TrackpointDetails>>;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card.Root class="border-none shadow-md text-base p-2">
|
<Card.Root class="border-none shadow-md text-base p-2">
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||||
import { Dot, ExternalLink, Trash2 } from '@lucide/svelte';
|
import { Dot, ExternalLink, Trash2 } from '@lucide/svelte';
|
||||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
import { getSymbolKey, symbols } from '$lib/data/symbols';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import sanitizeHtml from 'sanitize-html';
|
import sanitizeHtml from 'sanitize-html';
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||||
import type { PopupItem } from '$lib/components/map/map-popup';
|
import type { PopupItem } from '$lib/components/map/map-popup';
|
||||||
import { engine, type WaypointDetails } from '$lib/engine';
|
import { engine, type WaypointDetails } from '$lib/engine';
|
||||||
import { hasSelectionWithin } from '$lib/selection-helpers';
|
import { hasSelectionWithin } from '$lib/engine/selection-helpers';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
waypoint,
|
waypoint,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
import { getConvertedDistanceToKilometers } from '$lib/units';
|
import { getConvertedDistanceToKilometers } from '$lib/units/units';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import { allHidden } from '$lib/all-hidden';
|
import { allHidden } from '$lib/engine/all-hidden';
|
||||||
import type { GeoJSONSource } from 'maplibre-gl';
|
import type { GeoJSONSource } from 'maplibre-gl';
|
||||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||||
|
|
||||||
|
|||||||
@@ -7,18 +7,18 @@ import maplibregl, {
|
|||||||
} from 'maplibre-gl';
|
} from 'maplibre-gl';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
|
import { waypointPopup, trackpointPopup } from './gpx-layer-popup';
|
||||||
import { closestPointIndex } from '$lib/closest-point';
|
import { closestPointIndex } from '$lib/geo/closest-point';
|
||||||
import { getElevation, loadSVGIcon } from '$lib/utils';
|
import { getElevation, loadSVGIcon } from '$lib/utils';
|
||||||
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
|
import { selectedWaypoint } from '$lib/components/toolbar/tools/waypoint/waypoint';
|
||||||
import { MapPin, Square } from 'lucide-static';
|
import { MapPin, Square } from 'lucide-static';
|
||||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
import { getSymbolKey, symbols } from '$lib/data/symbols';
|
||||||
import { engine, type FileState, type Selection } from '$lib/engine';
|
import { engine, type FileState, type Selection } from '$lib/engine';
|
||||||
import { isCovered, hasSelectionWithin, type FileTreeNode } from '$lib/selection-helpers';
|
import { isCovered, hasSelectionWithin, type FileTreeNode } from '$lib/engine/selection-helpers';
|
||||||
import { isSegmentHidden, isWaypointHidden } from '$lib/file-visibility';
|
import { isSegmentHidden, isWaypointHidden } from '$lib/engine/file-visibility';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||||
import { splitAtPoint } from '$lib/components/toolbar/tools/scissors/scissors';
|
import { splitAtPoint } from '$lib/components/toolbar/tools/scissors/scissors';
|
||||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
||||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||||
|
|
||||||
export function getSvgForSymbol(symbol?: string | undefined, layerColor?: string | undefined) {
|
export function getSvgForSymbol(symbol?: string | undefined, layerColor?: string | undefined) {
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
|||||||
import type { GeoJSONSource } from 'maplibre-gl';
|
import type { GeoJSONSource } from 'maplibre-gl';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import { allHidden } from '$lib/all-hidden';
|
import { allHidden } from '$lib/engine/all-hidden';
|
||||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||||
import { loadSVGIcon } from '$lib/utils';
|
import { loadSVGIcon } from '$lib/utils';
|
||||||
import { hoveredPoint, slicedStatistics } from '$lib/logic/selection-statistics';
|
import { hoveredPoint, slicedStatistics } from '$lib/state/selection-statistics';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
|
|
||||||
const startMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
const startMarkerSVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|||||||
@@ -17,11 +17,11 @@
|
|||||||
Map,
|
Map,
|
||||||
Layers2,
|
Layers2,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { defaultBasemap, type CustomLayer } from '$lib/assets/layers';
|
import { defaultBasemap, type CustomLayer } from '$lib/data/layers';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { remove } from './utils';
|
import { remove } from './utils';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { dndzone } from 'svelte-dnd-action';
|
import { dndzone } from 'svelte-dnd-action';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||||
import { Layers } from '@lucide/svelte';
|
import { Layers } from '@lucide/svelte';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
|
|||||||
@@ -14,12 +14,12 @@
|
|||||||
overlayTree,
|
overlayTree,
|
||||||
overpassTree,
|
overpassTree,
|
||||||
terrainSources,
|
terrainSources,
|
||||||
} from '$lib/assets/layers';
|
} from '$lib/data/layers';
|
||||||
import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
import { getLayers, isSelected, toggle } from '$lib/components/map/layer-control/utils';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import CustomLayers from './CustomLayers.svelte';
|
import CustomLayers from './CustomLayers.svelte';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { extensionAPI } from '$lib/components/map/layer-control/extension-api';
|
import { extensionAPI } from '$lib/components/map/layer-control/extension-api';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import LayerTreeNode from './LayerTreeNode.svelte';
|
import LayerTreeNode from './LayerTreeNode.svelte';
|
||||||
import { type LayerTreeType } from '$lib/assets/layers';
|
import { type LayerTreeType } from '$lib/data/layers';
|
||||||
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
|
import CollapsibleTree from '$lib/components/collapsible-tree/CollapsibleTree.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import CollapsibleTreeNode from '$lib/components/collapsible-tree/CollapsibleTreeNode.svelte';
|
import CollapsibleTreeNode from '$lib/components/collapsible-tree/CollapsibleTreeNode.svelte';
|
||||||
import { type LayerTreeType } from '$lib/assets/layers';
|
import { type LayerTreeType } from '$lib/data/layers';
|
||||||
import { anySelectedLayer } from './utils';
|
import { anySelectedLayer } from './utils';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { extensionAPI } from '$lib/components/map/layer-control/extension-api';
|
import { extensionAPI } from '$lib/components/map/layer-control/extension-api';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { PencilLine, MapPin } from '@lucide/svelte';
|
import { PencilLine, MapPin } from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||||
import type { PopupItem } from '$lib/components/map/map-popup';
|
import type { PopupItem } from '$lib/components/map/map-popup';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
import { selectionSize } from '$lib/selection-helpers';
|
import { selectionSize } from '$lib/engine/selection-helpers';
|
||||||
import { getElevation } from '$lib/utils';
|
import { getElevation } from '$lib/utils';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { afterEach, beforeEach, describe, it } from 'node:test';
|
import { afterEach, beforeEach, describe, it } from 'node:test';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { overlays, overlayTree, type LayerTreeType } from '$lib/assets/layers';
|
import { overlays, overlayTree, type LayerTreeType } from '$lib/data/layers';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { ExtensionAPI, type CustomOverlay } from './extension-api';
|
import { ExtensionAPI, type CustomOverlay } from './extension-api';
|
||||||
import { isSelected } from './utils';
|
import { isSelected } from './utils';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { derived, get, writable, type Writable } from 'svelte/store';
|
import { derived, get, writable, type Writable } from 'svelte/store';
|
||||||
import { isSelected, remove, removeAll } from './utils';
|
import { isSelected, remove, removeAll } from './utils';
|
||||||
import { overlays, overlayTree } from '$lib/assets/layers';
|
import { overlays, overlayTree } from '$lib/data/layers';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { SphericalMercator } from '@mapbox/sphericalmercator';
|
import { SphericalMercator } from '@mapbox/sphericalmercator';
|
||||||
import { getLayers } from './utils';
|
import { getLayers } from './utils';
|
||||||
import { get, writable } from 'svelte/store';
|
import { get, writable } from 'svelte/store';
|
||||||
import { overpassQueryData } from '$lib/assets/layers';
|
import { overpassQueryData } from '$lib/data/layers';
|
||||||
import { MapPopup } from '$lib/components/map/map-popup';
|
import { MapPopup } from '$lib/components/map/map-popup';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import type { GeoJSONSource } from 'maplibre-gl';
|
import type { GeoJSONSource } from 'maplibre-gl';
|
||||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||||
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||||
import { loadSVGIcon } from '$lib/utils';
|
import { loadSVGIcon } from '$lib/utils';
|
||||||
import { cachedFetch } from '$lib/cached-fetch';
|
import { cachedFetch } from '$lib/storage/cached-fetch';
|
||||||
|
|
||||||
const { currentOverpassQueries } = settings;
|
const { currentOverpassQueries } = settings;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import type { LayerTreeType } from '$lib/assets/layers';
|
import type { LayerTreeType } from '$lib/data/layers';
|
||||||
import { anySelectedLayer, getLayers, isSelected, remove, removeAll, toggle } from './utils';
|
import { anySelectedLayer, getLayers, isSelected, remove, removeAll, toggle } from './utils';
|
||||||
|
|
||||||
// roads: bike (selected), foot
|
// roads: bike (selected), foot
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { LayerTreeType } from '$lib/assets/layers';
|
import type { LayerTreeType } from '$lib/data/layers';
|
||||||
|
|
||||||
export function anySelectedLayer(node: LayerTreeType) {
|
export function anySelectedLayer(node: LayerTreeType) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import MaplibreGeocoder, {
|
|||||||
} from '@maplibre/maplibre-gl-geocoder';
|
} from '@maplibre/maplibre-gl-geocoder';
|
||||||
import '@maplibre/maplibre-gl-geocoder/dist/maplibre-gl-geocoder.css';
|
import '@maplibre/maplibre-gl-geocoder/dist/maplibre-gl-geocoder.css';
|
||||||
import { get, writable, type Writable } from 'svelte/store';
|
import { get, writable, type Writable } from 'svelte/store';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import { ANCHOR_LAYER_KEY, StyleManager } from '$lib/components/map/style';
|
import { ANCHOR_LAYER_KEY, StyleManager } from '$lib/components/map/style';
|
||||||
import { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
import { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
import { PersonStanding, X } from '@lucide/svelte';
|
import { PersonStanding, X } from '@lucide/svelte';
|
||||||
import { MapillaryLayer } from './mapillary';
|
import { MapillaryLayer } from './mapillary';
|
||||||
import { GoogleRedirect } from './google';
|
import { GoogleRedirect } from './google';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
|
import ButtonWithTooltip from '$lib/components/ButtonWithTooltip.svelte';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
||||||
|
|
||||||
export class GoogleRedirect {
|
export class GoogleRedirect {
|
||||||
map: maplibregl.Map;
|
map: maplibregl.Map;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import maplibregl, { type LayerSpecification, type VectorSourceSpecification } from 'maplibre-gl';
|
import maplibregl, { type LayerSpecification, type VectorSourceSpecification } from 'maplibre-gl';
|
||||||
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
|
import { Viewer, type ViewerBearingEvent } from 'mapillary-js/dist/mapillary.module';
|
||||||
import 'mapillary-js/dist/mapillary.css';
|
import 'mapillary-js/dist/mapillary.css';
|
||||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
||||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||||
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { get, type Writable } from 'svelte/store';
|
import { get, type Writable } from 'svelte/store';
|
||||||
import {
|
import {
|
||||||
basemaps,
|
basemaps,
|
||||||
@@ -6,9 +6,9 @@ import {
|
|||||||
maptilerKeyPlaceHolder,
|
maptilerKeyPlaceHolder,
|
||||||
overlays,
|
overlays,
|
||||||
terrainSources,
|
terrainSources,
|
||||||
} from '$lib/assets/layers';
|
} from '$lib/data/layers';
|
||||||
import { getLayers } from '$lib/components/map/layer-control/utils';
|
import { getLayers } from '$lib/components/map/layer-control/utils';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
|
|
||||||
const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource, distanceUnits } =
|
const { currentBasemap, currentOverlays, customLayers, opacities, terrainSource, distanceUnits } =
|
||||||
settings;
|
settings;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
Scissors,
|
Scissors,
|
||||||
MountainSnow,
|
MountainSnow,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
|
|
||||||
let props: {
|
let props: {
|
||||||
class?: string;
|
class?: string;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
import Reduce from '$lib/components/toolbar/tools/reduce/Reduce.svelte';
|
import Reduce from '$lib/components/toolbar/tools/reduce/Reduce.svelte';
|
||||||
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
|
import RoutingControlPopup from '$lib/components/toolbar/tools/routing/RoutingControlPopup.svelte';
|
||||||
import maplibregl from 'maplibre-gl';
|
import maplibregl from 'maplibre-gl';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
class: className = '',
|
class: className = '',
|
||||||
|
|||||||
@@ -11,14 +11,14 @@
|
|||||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import Help from '$lib/components/Help.svelte';
|
import Help from '$lib/components/Help.svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { Trash2 } from '@lucide/svelte';
|
import { Trash2 } from '@lucide/svelte';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import type { GeoJSONSource } from 'maplibre-gl';
|
import type { GeoJSONSource } from 'maplibre-gl';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
||||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||||
|
|
||||||
let props: {
|
let props: {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import Help from '$lib/components/Help.svelte';
|
import Help from '$lib/components/Help.svelte';
|
||||||
import { MountainSnow } from '@lucide/svelte';
|
import { MountainSnow } from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { getElevation, getURLForLanguage } from '$lib/utils';
|
import { getElevation, getURLForLanguage } from '$lib/utils';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Ungroup } from '@lucide/svelte';
|
import { Ungroup } from '@lucide/svelte';
|
||||||
import Help from '$lib/components/Help.svelte';
|
import Help from '$lib/components/Help.svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import { Label } from '$lib/components/ui/label/index.js';
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { Group } from '@lucide/svelte';
|
import { Group } from '@lucide/svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import Shortcut from '$lib/components/Shortcut.svelte';
|
import Shortcut from '$lib/components/Shortcut.svelte';
|
||||||
|
|||||||
@@ -10,14 +10,14 @@
|
|||||||
getConvertedVelocity,
|
getConvertedVelocity,
|
||||||
milesToKilometers,
|
milesToKilometers,
|
||||||
nauticalMilesToKilometers,
|
nauticalMilesToKilometers,
|
||||||
} from '$lib/units';
|
} from '$lib/units/units';
|
||||||
import { CalendarDate, type DateValue } from '@internationalized/date';
|
import { CalendarDate, type DateValue } from '@internationalized/date';
|
||||||
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from '@lucide/svelte';
|
import { CalendarClock, CirclePlay, CircleStop, CircleX, Timer, Zap } from '@lucide/svelte';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import Help from '$lib/components/Help.svelte';
|
import Help from '$lib/components/Help.svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
|
|
||||||
let props: {
|
let props: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { Slider } from '$lib/components/ui/slider';
|
import { Slider } from '$lib/components/ui/slider';
|
||||||
import Help from '$lib/components/Help.svelte';
|
import Help from '$lib/components/Help.svelte';
|
||||||
import { Funnel } from '@lucide/svelte';
|
import { Funnel } from '@lucide/svelte';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import WithUnits from '$lib/components/WithUnits.svelte';
|
import WithUnits from '$lib/components/WithUnits.svelte';
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
|
|||||||
@@ -22,16 +22,16 @@
|
|||||||
SquareArrowOutDownRight,
|
SquareArrowOutDownRight,
|
||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { routingProfiles } from '$lib/components/toolbar/tools/routing/routing';
|
import { routingProfiles } from '$lib/components/toolbar/tools/routing/routing';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
import { newFileName } from '$lib/logic/file-actions';
|
import { newFileName } from '$lib/actions/file-actions';
|
||||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
||||||
import { RoutingControls } from './routing-controls';
|
import { RoutingControls } from './routing-controls';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { canChangeStart } from './routing-controls';
|
import { canChangeStart } from './routing-controls';
|
||||||
import { CirclePlay, Trash2 } from '@lucide/svelte';
|
import { CirclePlay, Trash2 } from '@lucide/svelte';
|
||||||
|
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
element = $bindable(),
|
element = $bindable(),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { distance, type Coordinates } from '$lib/geo';
|
import { distance, type Coordinates } from '$lib/geo/coordinates';
|
||||||
import { get, writable } from 'svelte/store';
|
import { get, writable } from 'svelte/store';
|
||||||
import maplibregl, {
|
import maplibregl, {
|
||||||
type MapMouseEvent,
|
type MapMouseEvent,
|
||||||
@@ -9,14 +9,14 @@ import maplibregl, {
|
|||||||
import { route } from './routing';
|
import { route } from './routing';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { loadSVGIcon } from '$lib/utils';
|
import { loadSVGIcon } from '$lib/utils';
|
||||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
||||||
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
||||||
import { streetViewEnabled } from '$lib/components/map/street-view-control/utils';
|
import { streetViewEnabled } from '$lib/components/map/street-view-control/utils';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { closestPointIndexIn } from '$lib/closest-point';
|
import { closestPointIndexIn } from '$lib/geo/closest-point';
|
||||||
import {
|
import {
|
||||||
engine,
|
engine,
|
||||||
type Selection,
|
type Selection,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import { distance } from '$lib/geo';
|
import { distance } from '$lib/geo/coordinates';
|
||||||
import {
|
import {
|
||||||
brouterError,
|
brouterError,
|
||||||
getTags,
|
getTags,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { distance, type Coordinates } from '$lib/geo';
|
import { distance, type Coordinates } from '$lib/geo/coordinates';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
import { getElevation } from '$lib/utils';
|
import { getElevation } from '$lib/utils';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,12 @@
|
|||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import { Separator } from '$lib/components/ui/separator';
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import { onDestroy, onMount, untrack } from 'svelte';
|
import { onDestroy, onMount, untrack } from 'svelte';
|
||||||
import { Crop } from '@lucide/svelte';
|
import { Crop } from '@lucide/svelte';
|
||||||
import { SplitControls } from './split-controls';
|
import { SplitControls } from './split-controls';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { slicedStatistics } from '$lib/logic/selection-statistics';
|
import { slicedStatistics } from '$lib/state/selection-statistics';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
|
|
||||||
let props: {
|
let props: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { get, writable, type Writable } from 'svelte/store';
|
import { get, writable, type Writable } from 'svelte/store';
|
||||||
import { engine, type SplitType as EngineSplitType } from '$lib/engine';
|
import { engine, type SplitType as EngineSplitType } from '$lib/engine';
|
||||||
import { closestPointIndexIn } from '$lib/closest-point';
|
import { closestPointIndexIn } from '$lib/geo/closest-point';
|
||||||
|
|
||||||
/** What a split cuts in two, the values being the ones of the engine. */
|
/** What a split cuts in two, the values being the ones of the engine. */
|
||||||
export const SplitType = {
|
export const SplitType = {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { currentTool, Tool } from '$lib/components/toolbar/tools';
|
|||||||
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
|
import { splitAs } from '$lib/components/toolbar/tools/scissors/scissors';
|
||||||
import { Scissors } from 'lucide-static';
|
import { Scissors } from 'lucide-static';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
||||||
import type { GeoJSONSource } from 'maplibre-gl';
|
import type { GeoJSONSource } from 'maplibre-gl';
|
||||||
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
import { ANCHOR_LAYER_KEY } from '$lib/components/map/style';
|
||||||
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
import type { MapLayerEventManager } from '$lib/components/map/map-layer-event-manager';
|
||||||
|
|||||||
@@ -4,18 +4,18 @@
|
|||||||
import { Label } from '$lib/components/ui/label/index.js';
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n/i18n.svelte';
|
||||||
import Help from '$lib/components/Help.svelte';
|
import Help from '$lib/components/Help.svelte';
|
||||||
import { onDestroy, onMount, untrack } from 'svelte';
|
import { onDestroy, onMount, untrack } from 'svelte';
|
||||||
import { getURLForLanguage } from '$lib/utils';
|
import { getURLForLanguage } from '$lib/utils';
|
||||||
import { MapPin, CircleX, Save } from '@lucide/svelte';
|
import { MapPin, CircleX, Save } from '@lucide/svelte';
|
||||||
import { getSymbolKey, symbols } from '$lib/assets/symbols';
|
import { getSymbolKey, symbols } from '$lib/data/symbols';
|
||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
import { selectionSize } from '$lib/selection-helpers';
|
import { selectionSize } from '$lib/engine/selection-helpers';
|
||||||
import { getElevation } from '$lib/utils';
|
import { getElevation } from '$lib/utils';
|
||||||
import { selectedWaypoint } from './waypoint';
|
import { selectedWaypoint } from './waypoint';
|
||||||
import { map } from '$lib/components/map/map';
|
import { map } from '$lib/components/map/map';
|
||||||
import { mapCursor, MapCursorState } from '$lib/logic/map-cursor';
|
import { mapCursor, MapCursorState } from '$lib/state/map-cursor';
|
||||||
import maplibregl from 'maplibre-gl';
|
import maplibregl from 'maplibre-gl';
|
||||||
import { getSvgForSymbol } from '$lib/components/map/gpx-layer/gpx-layer';
|
import { getSvgForSymbol } from '$lib/components/map/gpx-layer/gpx-layer';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { get, writable } from 'svelte/store';
|
import { get, writable } from 'svelte/store';
|
||||||
import { engine, type WaypointDetails } from '$lib/engine';
|
import { engine, type WaypointDetails } from '$lib/engine';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/state/settings';
|
||||||
|
|
||||||
export type SelectedWaypoint = {
|
export type SelectedWaypoint = {
|
||||||
fileId: string;
|
fileId: string;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
getSurfaceColor,
|
getSurfaceColor,
|
||||||
highwayColors,
|
highwayColors,
|
||||||
surfaceColors,
|
surfaceColors,
|
||||||
} from './colors';
|
} from '$lib/data/colors';
|
||||||
|
|
||||||
describe('colors of the OSM data', () => {
|
describe('colors of the OSM data', () => {
|
||||||
it('has the color of a surface, or the one of the missing ones', () => {
|
it('has the color of a surface, or the one of the missing ones', () => {
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user