mirror of
https://github.com/gpxstudio/gpx.studio.git
synced 2026-10-16 08:19:59 +00:00
outdated state
This commit is contained in:
1 parent
a2b4d89150
commit
9adb8e7221
14 files changed
+203
-110
No files matched your search
@@ -16,6 +16,8 @@ pub enum CommandError {
|
|||||||
NothingToDo,
|
NothingToDo,
|
||||||
/// The input data could not be parsed.
|
/// The input data could not be parsed.
|
||||||
InvalidData(String),
|
InvalidData(String),
|
||||||
|
/// The engine is in the outdated state.
|
||||||
|
Outdated,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for CommandError {
|
impl fmt::Display for CommandError {
|
||||||
@@ -23,6 +25,7 @@ impl fmt::Display for CommandError {
|
|||||||
match self {
|
match self {
|
||||||
Self::NothingToDo => write!(f, "nothing to do"),
|
Self::NothingToDo => write!(f, "nothing to do"),
|
||||||
Self::InvalidData(err) => write!(f, "invalid data: {err}"),
|
Self::InvalidData(err) => write!(f, "invalid data: {err}"),
|
||||||
|
Self::Outdated => write!(f, "saved by a newer version"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,13 @@ pub struct Engine {
|
|||||||
routing_buffer: RoutingBuffer,
|
routing_buffer: RoutingBuffer,
|
||||||
categories: TrackpointCategories,
|
categories: TrackpointCategories,
|
||||||
clipboard: Option<Clipboard>,
|
clipboard: Option<Clipboard>,
|
||||||
|
/// Whether what is stored was written by a newer version (see [`Engine::mark_outdated`]).
|
||||||
|
outdated: bool,
|
||||||
/// What the tests of the last action read, see `Engine::run`.
|
/// What the tests of the last action read, see `Engine::run`.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
last: Outcome,
|
last: Outcome,
|
||||||
|
#[cfg(test)]
|
||||||
|
last_error: Option<CommandError>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Engine {
|
impl Engine {
|
||||||
@@ -138,6 +142,7 @@ impl Engine {
|
|||||||
/// from the ones that were here are reported as added, removed or modified (all the files
|
/// 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.
|
/// 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 {
|
||||||
|
self.outdated = false;
|
||||||
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 before = self.stack.current().cloned();
|
let before = self.stack.current().cloned();
|
||||||
@@ -163,15 +168,23 @@ impl Engine {
|
|||||||
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: false,
|
clipboard_changed: false,
|
||||||
error: None,
|
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
{
|
{
|
||||||
self.last = outcome.clone();
|
self.last = outcome.clone();
|
||||||
|
self.last_error = None;
|
||||||
}
|
}
|
||||||
outcome
|
outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Tells that what is stored was written by a newer version of the app, which cannot be read
|
||||||
|
/// (see [`crate::StorageError::Newer`]). The files are left as they are, but cannot be changed
|
||||||
|
/// from now on, as the changes could not be kept: [`Engine::execute`] refuses with
|
||||||
|
/// [`CommandError::Outdated`], until something that is not newer is restored.
|
||||||
|
pub fn mark_outdated(&mut self) {
|
||||||
|
self.outdated = true;
|
||||||
|
}
|
||||||
|
|
||||||
/// 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.stack.current()?.get(id)?;
|
||||||
@@ -215,19 +228,30 @@ 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, or any change of the files while
|
||||||
|
/// they are outdated (see [`Engine::is_outdated`]).
|
||||||
|
pub fn execute(&mut self, action: Action) -> Result<Outcome, CommandError> {
|
||||||
|
let result = self.apply(action);
|
||||||
|
#[cfg(test)]
|
||||||
|
{
|
||||||
|
self.last = result.clone().unwrap_or_default();
|
||||||
|
self.last_error = result.as_ref().err().cloned();
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(&mut self, action: Action) -> Result<Outcome, CommandError> {
|
||||||
|
if self.outdated {
|
||||||
|
return Err(CommandError::Outdated);
|
||||||
|
}
|
||||||
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);
|
|
||||||
None
|
|
||||||
}),
|
|
||||||
Action::Undo => self.stack.undo(),
|
Action::Undo => self.stack.undo(),
|
||||||
Action::Redo => self.stack.redo(),
|
Action::Redo => self.stack.redo(),
|
||||||
Action::Select { selection, mode } => {
|
Action::Select { selection, mode } => {
|
||||||
@@ -264,27 +288,21 @@ 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.
|
||||||
@@ -352,7 +370,7 @@ impl Engine {
|
|||||||
/// Executes an action and tells whether it changed anything. What it did is kept for the
|
/// Executes an action and tells whether it changed anything. What it did is kept for the
|
||||||
/// accessors below, which the tests read right after.
|
/// accessors below, which the tests read right after.
|
||||||
pub(crate) fn run(&mut self, action: Action) -> bool {
|
pub(crate) fn run(&mut self, action: Action) -> bool {
|
||||||
self.execute(action).changed
|
self.execute(action).is_ok_and(|outcome| outcome.changed)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn last_diff(&self) -> Option<&Diff> {
|
pub(crate) fn last_diff(&self) -> Option<&Diff> {
|
||||||
@@ -360,7 +378,7 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn last_error(&self) -> Option<&CommandError> {
|
pub(crate) fn last_error(&self) -> Option<&CommandError> {
|
||||||
self.last.error.as_ref()
|
self.last_error.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn order_changed(&self) -> bool {
|
pub(crate) fn order_changed(&self) -> bool {
|
||||||
|
|||||||
@@ -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>,
|
|
||||||
}
|
}
|
||||||
@@ -1268,46 +1268,49 @@ fn test_an_edit_that_changes_nothing_is_not_an_undo_step() {
|
|||||||
fn test_execute_returns_what_the_action_did() {
|
fn test_execute_returns_what_the_action_did() {
|
||||||
let mut engine = Engine::default();
|
let mut engine = Engine::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 outcome = engine
|
||||||
|
.execute(Action::Select {
|
||||||
selection: files(engine.order()),
|
selection: files(engine.order()),
|
||||||
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!(!outcome.changed);
|
assert!(matches!(result, Err(CommandError::InvalidData(_))));
|
||||||
assert!(matches!(outcome.error, Some(CommandError::InvalidData(_))));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the engine does when it is overwritten with what is stored
|
// What the engine does when it is overwritten with what is stored
|
||||||
@@ -1322,8 +1325,6 @@ fn restored_from(engine: &Engine, keep: &[FileId], extra: Vec<Rc<File>>) -> crat
|
|||||||
order: files.iter().map(|file| file.id).collect(),
|
order: files.iter().map(|file| file.id).collect(),
|
||||||
files,
|
files,
|
||||||
categories: engine.categories().clone(),
|
categories: engine.categories().clone(),
|
||||||
unreadable: 0,
|
|
||||||
read_only: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1372,3 +1373,38 @@ fn test_overwriting_forgets_the_history() {
|
|||||||
engine.restore(restored_from(&engine, &ids, vec![]));
|
engine.restore(restored_from(&engine, &ids, vec![]));
|
||||||
assert!(!engine.can_undo());
|
assert!(!engine.can_undo());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_files_cannot_be_changed_when_what_is_stored_is_from_a_newer_version() {
|
||||||
|
let mut engine = Engine::default();
|
||||||
|
new(&mut engine, "file");
|
||||||
|
let id = engine.order()[0];
|
||||||
|
|
||||||
|
engine.mark_outdated();
|
||||||
|
|
||||||
|
for action in [
|
||||||
|
Action::Edit(Command::Reverse(crate::Reverse)),
|
||||||
|
Action::Undo,
|
||||||
|
Action::Redo,
|
||||||
|
Action::Reorder {
|
||||||
|
file_ids: vec![id],
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
assert_eq!(engine.execute(action), Err(CommandError::Outdated));
|
||||||
|
}
|
||||||
|
assert_eq!(engine.order(), [id]);
|
||||||
|
|
||||||
|
// looking at the files still works
|
||||||
|
let outcome = engine
|
||||||
|
.execute(Action::Select {
|
||||||
|
selection: Selection::Empty,
|
||||||
|
mode: SelectMode::Replace,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(outcome.selection_changed);
|
||||||
|
|
||||||
|
// reading a storage that is not newer lifts it
|
||||||
|
engine.restore(restored_from(&engine, &[id], vec![]));
|
||||||
|
assert_ne!(engine.execute(Action::Undo), Err(CommandError::Outdated));
|
||||||
|
}
|
||||||
@@ -31,11 +31,20 @@ 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"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +224,7 @@ mod tests {
|
|||||||
|
|
||||||
#[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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,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
|
||||||
@@ -82,6 +76,10 @@ 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();
|
||||||
@@ -127,40 +125,42 @@ impl Persister {
|
|||||||
.clone()
|
.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut restored = Restored::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;
|
||||||
restored.categories = categories.into_inner();
|
|
||||||
self.head = data.head;
|
self.head = data.head;
|
||||||
self.read_only = newer.0.get();
|
|
||||||
restored.read_only = self.read_only;
|
|
||||||
Ok(restored)
|
Ok(restored)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +340,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 {
|
||||||
@@ -472,8 +471,6 @@ mod tests {
|
|||||||
.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));
|
||||||
|
|
||||||
@@ -525,8 +522,8 @@ mod tests {
|
|||||||
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, nothing is written: the newer version needs its data
|
||||||
let mut changed = snapshot.clone();
|
let mut changed = snapshot.clone();
|
||||||
@@ -544,12 +541,11 @@ 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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ pub struct IdbStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -98,8 +98,9 @@ fn notify(pick: impl FnOnce(&(js_sys::Function, js_sys::Function)) -> &js_sys::F
|
|||||||
/// 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 the files
|
||||||
/// the newer version finds its data again.
|
/// cannot be changed (the outcome has the `Outdated` error), so that the newer version finds its
|
||||||
|
/// data again.
|
||||||
///
|
///
|
||||||
/// `on_saved` is called after the files were written (to tell the other tabs), and `on_behind` when
|
/// `on_saved` is called after the files were written (to tell the other tabs), and `on_behind` when
|
||||||
/// a write was refused because another tab wrote since (call `sync_storage` then).
|
/// a write was refused because another tab wrote since (call `sync_storage` then).
|
||||||
@@ -115,11 +116,9 @@ pub async fn open_storage(
|
|||||||
}
|
}
|
||||||
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 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((on_saved, on_behind)));
|
LISTENERS.with(|slot| *slot.borrow_mut() = Some((on_saved, on_behind)));
|
||||||
@@ -130,12 +129,29 @@ pub async fn open_storage(
|
|||||||
}
|
}
|
||||||
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 cannot be
|
||||||
|
/// changed anymore: the error of the outcome says so, as for the edits that are refused afterwards.
|
||||||
|
fn restore_engine(
|
||||||
|
read: Result<engine::Restored, engine::StorageError>,
|
||||||
|
) -> Result<Outcome, engine::StorageError> {
|
||||||
|
match read {
|
||||||
|
Ok(restored) => Ok(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) => {
|
||||||
|
with_engine_mut(Engine::mark_outdated);
|
||||||
|
Ok(fail(engine::CommandError::Outdated.to_string()))
|
||||||
|
}
|
||||||
|
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())
|
||||||
}
|
}
|
||||||
@@ -171,11 +187,11 @@ pub async fn sync_storage() -> Result<Outcome, JsValue> {
|
|||||||
SAVING.set(true);
|
SAVING.set(true);
|
||||||
let result = async {
|
let result = async {
|
||||||
if !persister.is_behind(&*storage).await? {
|
if !persister.is_behind(&*storage).await? {
|
||||||
return Ok(engine::Outcome::default());
|
return Ok(outcome_object(&engine::Outcome::default(), None));
|
||||||
}
|
}
|
||||||
let restored = persister.restore(&*storage).await?;
|
let outcome = restore_engine(persister.restore(&*storage).await)?;
|
||||||
DIRTY.set(false);
|
DIRTY.set(false);
|
||||||
Ok(with_engine_mut(|engine| engine.restore(restored)).unwrap_or_default())
|
Ok(outcome)
|
||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
|
PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister));
|
||||||
@@ -187,9 +203,7 @@ pub async fn sync_storage() -> Result<Outcome, JsValue> {
|
|||||||
// edited while reading, and nothing was overwritten
|
// edited while reading, and nothing was overwritten
|
||||||
schedule_save();
|
schedule_save();
|
||||||
}
|
}
|
||||||
result
|
result.map_err(to_js_error)
|
||||||
.map(|outcome| outcome_object(&outcome, None))
|
|
||||||
.map_err(to_js_error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Saves a setting, `value` being its JSON.
|
/// Saves a setting, `value` being its JSON.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
type Clipboard,
|
type Clipboard,
|
||||||
type ExportOptions,
|
type ExportOptions,
|
||||||
type FileState,
|
type FileState,
|
||||||
type FilesUpdate,
|
|
||||||
type MergeType,
|
type MergeType,
|
||||||
type MoveTarget,
|
type MoveTarget,
|
||||||
type NewWaypoint,
|
type NewWaypoint,
|
||||||
@@ -34,6 +33,9 @@ import {
|
|||||||
type WaypointDetails,
|
type WaypointDetails,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
/** The error of the outcomes while the stored files come from a newer version of the app. */
|
||||||
|
const OUTDATED = 'saved by a newer version';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin wrapper around the WASM engine.
|
* Thin wrapper around the WASM engine.
|
||||||
*
|
*
|
||||||
@@ -57,6 +59,7 @@ class Engine {
|
|||||||
private _files = writable<Map<string, Writable<FileState>>>(new Map());
|
private _files = writable<Map<string, Writable<FileState>>>(new Map());
|
||||||
private _selection = writable<Selection>({ type: 'empty' });
|
private _selection = writable<Selection>({ type: 'empty' });
|
||||||
private _lastError = writable<string | undefined>(undefined);
|
private _lastError = writable<string | undefined>(undefined);
|
||||||
|
private _outdated = writable(false);
|
||||||
private _canUndo = writable(false);
|
private _canUndo = writable(false);
|
||||||
private _canRedo = writable(false);
|
private _canRedo = writable(false);
|
||||||
private _canPaste = writable(false);
|
private _canPaste = writable(false);
|
||||||
@@ -67,6 +70,8 @@ class Engine {
|
|||||||
readonly order: Readable<string[]> = { subscribe: this._order.subscribe };
|
readonly order: Readable<string[]> = { subscribe: this._order.subscribe };
|
||||||
/** Statistics of what is currently selected. */
|
/** Statistics of what is currently selected. */
|
||||||
readonly statistics: Readable<SelectionStatistics> = this._statistics.store;
|
readonly statistics: Readable<SelectionStatistics> = this._statistics.store;
|
||||||
|
/** Whether the stored files are from a newer version of the app: they cannot be changed. */
|
||||||
|
readonly outdated: Readable<boolean> = { subscribe: this._outdated.subscribe };
|
||||||
readonly canUndo: Readable<boolean> = { subscribe: this._canUndo.subscribe };
|
readonly canUndo: Readable<boolean> = { subscribe: this._canUndo.subscribe };
|
||||||
readonly canRedo: Readable<boolean> = { subscribe: this._canRedo.subscribe };
|
readonly canRedo: Readable<boolean> = { subscribe: this._canRedo.subscribe };
|
||||||
/** Why the last call to the engine did nothing, `undefined` if it did something. */
|
/** Why the last call to the engine did nothing, `undefined` if it did something. */
|
||||||
@@ -674,7 +679,7 @@ class Engine {
|
|||||||
this._lastError.set(outcome.error);
|
this._lastError.set(outcome.error);
|
||||||
if (outcome.error === 'nothing to do') {
|
if (outcome.error === 'nothing to do') {
|
||||||
console.debug('engine:', outcome.error);
|
console.debug('engine:', outcome.error);
|
||||||
} else if (outcome.error !== undefined) {
|
} else if (outcome.error !== undefined && outcome.error !== OUTDATED) {
|
||||||
console.warn('engine:', outcome.error);
|
console.warn('engine:', outcome.error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -691,7 +696,10 @@ class Engine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Updates what is kept of the engine with what a call changed. */
|
/** Updates what is kept of the engine with what a call changed. */
|
||||||
private sync(wasm: Wasm, update: FilesUpdate) {
|
private sync(wasm: Wasm, update: Outcome) {
|
||||||
|
if (update.error === OUTDATED) {
|
||||||
|
this._outdated.set(true);
|
||||||
|
}
|
||||||
this._canUndo.set(wasm.can_undo());
|
this._canUndo.set(wasm.can_undo());
|
||||||
this._canRedo.set(wasm.can_redo());
|
this._canRedo.set(wasm.can_redo());
|
||||||
this._canPaste.set(wasm.can_paste());
|
this._canPaste.set(wasm.can_paste());
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { LEGACY_IMPORTED_KEY, readLegacyData } from '$lib/legacy-import';
|
import { LEGACY_IMPORTED_KEY, readLegacyData } from '$lib/legacy-import';
|
||||||
import { parseVisibility, VISIBILITY_KEY, type Visibility } from '$lib/file-visibility';
|
import { parseVisibility, VISIBILITY_KEY, type Visibility } from '$lib/file-visibility';
|
||||||
import type { FilesUpdate, StorageOpened } from 'gpx-rs';
|
import type { Outcome, StorageOpened } from 'gpx-rs';
|
||||||
import type { Wasm } from './convert';
|
import type { Wasm } from './convert';
|
||||||
|
|
||||||
/** Name of the IndexedDB database that keeps the files and the settings. */
|
/** Name of the IndexedDB database that keeps the files and the settings. */
|
||||||
@@ -15,7 +15,7 @@ export type StorageHost = {
|
|||||||
/** The explicit visibility per file, to fill with the one that was saved. */
|
/** The explicit visibility per file, to fill with the one that was saved. */
|
||||||
visibility: Map<string, Visibility>;
|
visibility: Map<string, Visibility>;
|
||||||
/** Applies what a call changed to what the facade keeps of the files. */
|
/** Applies what a call changed to what the facade keeps of the files. */
|
||||||
sync(update: FilesUpdate): void;
|
sync(update: Outcome): void;
|
||||||
hasFiles(): boolean;
|
hasFiles(): boolean;
|
||||||
loadFiles(files: { data: Uint8Array; name: string }[]): Promise<boolean>;
|
loadFiles(files: { data: Uint8Array; name: string }[]): Promise<boolean>;
|
||||||
deselect(): Promise<boolean>;
|
deselect(): Promise<boolean>;
|
||||||
@@ -66,14 +66,6 @@ export async function openStoredFiles(host: StorageHost): Promise<Record<string,
|
|||||||
console.error('The files cannot be kept in this browser', error);
|
console.error('The files cannot be kept in this browser', error);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
if (opened.readOnly) {
|
|
||||||
console.warn(
|
|
||||||
'The files were saved by a newer version of the app: they are not saved again by this one'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (opened.unreadable > 0) {
|
|
||||||
console.warn(`${opened.unreadable} stored file(s) could not be read`);
|
|
||||||
}
|
|
||||||
let settings = opened.settings;
|
let settings = opened.settings;
|
||||||
// what was hidden, which the files that were restored need when they are read
|
// what was hidden, which the files that were restored need when they are read
|
||||||
parseVisibility(settings[VISIBILITY_KEY]).forEach((visibility, fileId) =>
|
parseVisibility(settings[VISIBILITY_KEY]).forEach((visibility, fileId) =>
|
||||||
|
|||||||
@@ -557,5 +557,10 @@
|
|||||||
},
|
},
|
||||||
"webgl2_required": "WebGL 2 is required to display the map.",
|
"webgl2_required": "WebGL 2 is required to display the map.",
|
||||||
"enable_webgl2": "Learn how to enable WebGL 2 in your browser",
|
"enable_webgl2": "Learn how to enable WebGL 2 in your browser",
|
||||||
"page_not_found": "page not found"
|
"page_not_found": "page not found",
|
||||||
|
"outdated": {
|
||||||
|
"title": "This page is out of date",
|
||||||
|
"description": "Your files were saved by a newer version of gpx.studio, which this page cannot read. Reload the page to get the latest version.",
|
||||||
|
"reload": "Reload"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
import CoordinatesPopup from '$lib/components/map/CoordinatesPopup.svelte';
|
import CoordinatesPopup from '$lib/components/map/CoordinatesPopup.svelte';
|
||||||
import Resizer from '$lib/components/Resizer.svelte';
|
import Resizer from '$lib/components/Resizer.svelte';
|
||||||
import { Toaster } from '$lib/components/ui/sonner';
|
import { Toaster } from '$lib/components/ui/sonner';
|
||||||
|
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||||
import { i18n } from '$lib/i18n.svelte';
|
import { i18n } from '$lib/i18n.svelte';
|
||||||
import { settings } from '$lib/logic/settings';
|
import { settings } from '$lib/logic/settings';
|
||||||
import { loadFiles } from '$lib/logic/file-actions';
|
import { loadFiles } from '$lib/logic/file-actions';
|
||||||
@@ -20,6 +21,8 @@
|
|||||||
import { engine } from '$lib/engine';
|
import { engine } from '$lib/engine';
|
||||||
import { getURLForGoogleDriveFile } from '$lib/components/embedding/embedding';
|
import { getURLForGoogleDriveFile } from '$lib/components/embedding/embedding';
|
||||||
|
|
||||||
|
const outdated = engine.outdated;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
treeFileView,
|
treeFileView,
|
||||||
elevationProfile,
|
elevationProfile,
|
||||||
@@ -153,6 +156,20 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog.Root open={$outdated}>
|
||||||
|
<AlertDialog.Content escapeKeydownBehavior="ignore" interactOutsideBehavior="ignore">
|
||||||
|
<AlertDialog.Header>
|
||||||
|
<AlertDialog.Title>{i18n._('outdated.title')}</AlertDialog.Title>
|
||||||
|
<AlertDialog.Description>{i18n._('outdated.description')}</AlertDialog.Description>
|
||||||
|
</AlertDialog.Header>
|
||||||
|
<AlertDialog.Footer>
|
||||||
|
<AlertDialog.Action onclick={() => location.reload()}>
|
||||||
|
{i18n._('outdated.reload')}
|
||||||
|
</AlertDialog.Action>
|
||||||
|
</AlertDialog.Footer>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Root>
|
||||||
|
|
||||||
<style lang="postcss">
|
<style lang="postcss">
|
||||||
@reference "tailwindcss";
|
@reference "tailwindcss";
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user