From 9adb8e7221ab08db1d584669bdb7473a4b2883f9 Mon Sep 17 00:00:00 2001 From: vcoppe Date: Sun, 11 Oct 2026 10:48:43 +0200 Subject: [PATCH] outdated state --- gpx-rs/engine/src/engine/command/apply.rs | 3 + gpx-rs/engine/src/engine/mod.rs | 58 ++++++++----- gpx-rs/engine/src/engine/outcome.rs | 8 +- gpx-rs/engine/src/engine/tests.rs | 84 +++++++++++++------ gpx-rs/engine/src/storage/mod.rs | 15 +++- gpx-rs/engine/src/storage/persister.rs | 54 ++++++------ gpx-rs/wasm/src/idb.rs | 2 +- gpx-rs/wasm/src/persistence.rs | 42 ++++++---- gpx-rs/wasm/src/session.rs | 9 +- gpx-rs/wasm/src/ts.rs | 4 - website/src/lib/engine/engine.ts | 14 +++- website/src/lib/engine/storage.ts | 12 +-- website/src/locales/en.json | 7 +- .../src/routes/[[language]]/app/+page.svelte | 17 ++++ 14 files changed, 211 insertions(+), 118 deletions(-) diff --git a/gpx-rs/engine/src/engine/command/apply.rs b/gpx-rs/engine/src/engine/command/apply.rs index 108a0b9e9..60381092b 100644 --- a/gpx-rs/engine/src/engine/command/apply.rs +++ b/gpx-rs/engine/src/engine/command/apply.rs @@ -16,6 +16,8 @@ pub enum CommandError { NothingToDo, /// The input data could not be parsed. InvalidData(String), + /// The engine is in the outdated state. + Outdated, } impl fmt::Display for CommandError { @@ -23,6 +25,7 @@ impl fmt::Display for CommandError { match self { Self::NothingToDo => write!(f, "nothing to do"), Self::InvalidData(err) => write!(f, "invalid data: {err}"), + Self::Outdated => write!(f, "saved by a newer version"), } } } diff --git a/gpx-rs/engine/src/engine/mod.rs b/gpx-rs/engine/src/engine/mod.rs index 12f722673..cd293a962 100644 --- a/gpx-rs/engine/src/engine/mod.rs +++ b/gpx-rs/engine/src/engine/mod.rs @@ -27,9 +27,13 @@ pub struct Engine { routing_buffer: RoutingBuffer, categories: TrackpointCategories, clipboard: Option, + /// 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`. #[cfg(test)] last: Outcome, + #[cfg(test)] + last_error: Option, } impl Engine { @@ -138,6 +142,7 @@ impl Engine { /// 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 { + self.outdated = false; let selection_before = self.selection.clone(); let order_before = self.order.0.clone(); let before = self.stack.current().cloned(); @@ -163,15 +168,23 @@ impl Engine { order_changed: self.order.0 != order_before, selection_changed: self.selection != selection_before, clipboard_changed: false, - error: None, }; #[cfg(test)] { self.last = outcome.clone(); + self.last_error = None; } 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. pub fn export(&self, id: &FileId, options: ExportOptions) -> Option> { let file = self.stack.current()?.get(id)?; @@ -215,19 +228,30 @@ impl Engine { self.coordinates_cache.waypoints(id) } - /// Single entry point for every action coming from the frontend. Says what it changed. - pub fn execute(&mut self, action: Action) -> Outcome { + /// Single entry point for every action coming from the frontend. Says what it changed, or why + /// 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 { + 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 { + if self.outdated { + return Err(CommandError::Outdated); + } let selection_before = self.selection.clone(); let order_before = self.order.0.clone(); let clipboard_before = self.clipboard.clone(); let is_edit = matches!(action, Action::Edit(_)); - let mut error = None; // the files change iff there is a diff, but selection and order actions have none let mut diff = match action { - Action::Edit(command) => self.edit(command).unwrap_or_else(|err| { - error = Some(err); - None - }), + Action::Edit(command) => self.edit(command)?, Action::Undo => self.stack.undo(), Action::Redo => self.stack.redo(), Action::Select { selection, mode } => { @@ -264,27 +288,21 @@ impl Engine { || self.selection != selection_before || self.order.0 != order_before || self.clipboard != clipboard_before; - if is_edit && !changed && error.is_none() { - error = Some(CommandError::NothingToDo); + if is_edit && !changed { + return Err(CommandError::NothingToDo); } if changed { self.refresh(diff.as_ref()); sort_diff(&mut diff, &self.order.0); } // after the refresh, which also syncs the order and selection with the files (undo, redo) - let outcome = Outcome { + Ok(Outcome { changed, diff, order_changed: self.order.0 != order_before, selection_changed: self.selection != selection_before, clipboard_changed: self.clipboard != clipboard_before, - error, - }; - #[cfg(test)] - { - self.last = outcome.clone(); - } - outcome + }) } /// Puts the selection in the clipboard, if there is one. @@ -352,7 +370,7 @@ 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 + self.execute(action).is_ok_and(|outcome| outcome.changed) } pub(crate) fn last_diff(&self) -> Option<&Diff> { @@ -360,7 +378,7 @@ impl Engine { } 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 { diff --git a/gpx-rs/engine/src/engine/outcome.rs b/gpx-rs/engine/src/engine/outcome.rs index dc8ca59fc..896b21aaa 100644 --- a/gpx-rs/engine/src/engine/outcome.rs +++ b/gpx-rs/engine/src/engine/outcome.rs @@ -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 /// what was done since. @@ -15,7 +16,4 @@ pub struct Outcome { pub order_changed: bool, pub selection_changed: bool, pub clipboard_changed: bool, - /// Why an edit did nothing: the command failed, or it had nothing to change. `None` if it - /// succeeded, and for the actions that do not edit the files. - pub error: Option, } diff --git a/gpx-rs/engine/src/engine/tests.rs b/gpx-rs/engine/src/engine/tests.rs index a93d7f1e8..3765ebcf6 100644 --- a/gpx-rs/engine/src/engine/tests.rs +++ b/gpx-rs/engine/src/engine/tests.rs @@ -1268,46 +1268,49 @@ fn test_an_edit_that_changes_nothing_is_not_an_undo_step() { fn test_execute_returns_what_the_action_did() { let mut engine = Engine::default(); let data = std::fs::read("data/simple.gpx").unwrap(); - let outcome = engine.execute(Action::Edit(Command::Load(Load { - data: &data, - name: "file", - }))); - assert!(outcome.changed && outcome.error.is_none()); + let outcome = engine + .execute(Action::Edit(Command::Load(Load { + data: &data, + name: "file", + }))) + .unwrap(); + assert!(outcome.changed); assert_eq!(outcome.diff.as_ref().unwrap().added, engine.order()); assert!(outcome.order_changed && outcome.selection_changed); assert!(!outcome.clipboard_changed); // the outcome of an action does not depend on what is done after it let kept = outcome.clone(); - engine.execute(Action::Undo); + engine.execute(Action::Undo).unwrap(); assert_eq!(outcome, kept); // a selection only changes the selection - engine.execute(Action::Redo); - let outcome = engine.execute(Action::Select { - selection: files(engine.order()), - mode: SelectMode::Replace, - }); + engine.execute(Action::Redo).unwrap(); + let outcome = engine + .execute(Action::Select { + selection: files(engine.order()), + mode: SelectMode::Replace, + }) + .unwrap(); assert!(outcome.changed && outcome.selection_changed); assert!(outcome.diff.is_none() && !outcome.order_changed); // an edit with nothing to do says why - engine.execute(Action::Select { - selection: Selection::Empty, - mode: SelectMode::Replace, - }); - let outcome = engine.execute(Action::Edit(Command::Reverse(crate::Reverse))); - assert!(!outcome.changed); - assert_eq!(outcome.error, Some(CommandError::NothingToDo)); - assert_eq!(outcome.diff, None); + engine + .execute(Action::Select { + selection: Selection::Empty, + mode: SelectMode::Replace, + }) + .unwrap(); + let result = engine.execute(Action::Edit(Command::Reverse(crate::Reverse))); + assert_eq!(result, Err(CommandError::NothingToDo)); // a failing one too - let outcome = engine.execute(Action::Edit(Command::Load(Load { + let result = engine.execute(Action::Edit(Command::Load(Load { data: b"", name: "file", }))); - assert!(!outcome.changed); - assert!(matches!(outcome.error, Some(CommandError::InvalidData(_)))); + assert!(matches!(result, Err(CommandError::InvalidData(_)))); } // 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>) -> crat order: files.iter().map(|file| file.id).collect(), files, 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![])); 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)); +} diff --git a/gpx-rs/engine/src/storage/mod.rs b/gpx-rs/engine/src/storage/mod.rs index 6af66e978..c8b2700b6 100644 --- a/gpx-rs/engine/src/storage/mod.rs +++ b/gpx-rs/engine/src/storage/mod.rs @@ -31,11 +31,20 @@ use uuid::Uuid; use crate::FileId; #[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 { 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] 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"); let _: &dyn std::error::Error = &error; } diff --git a/gpx-rs/engine/src/storage/persister.rs b/gpx-rs/engine/src/storage/persister.rs index 51bff3dd0..a8fa558a8 100644 --- a/gpx-rs/engine/src/storage/persister.rs +++ b/gpx-rs/engine/src/storage/persister.rs @@ -28,12 +28,6 @@ pub struct Restored { pub files: Vec>, pub order: Vec, 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 @@ -82,6 +76,10 @@ struct Plan { impl Persister { /// 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(&mut self, storage: &S) -> Result { let data = storage.load().await?; *self = Self::default(); @@ -127,40 +125,42 @@ impl Persister { .clone() }; - let mut restored = Restored::default(); let mut files: HashMap> = HashMap::new(); for (id, bytes) in &data.files { self.stored_files.insert(*id); let file = newer .read(decode_file(bytes)) .and_then(|record| record.build(*id, &trackpoint_chunk, &waypoint_chunk)); - match file { - Some(file) => { - files.insert(*id, Rc::new(file)); - } - None => restored.unreadable += 1, + // a file that cannot be read is left out, and dropped at the next save + if let Some(file) = file { + files.insert(*id, Rc::new(file)); } } + 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 = stored_order .iter() .flatten() .filter(|id| files.contains_key(id)) .copied() .collect(); - let ordered: HashSet = restored.order.iter().copied().collect(); - restored - .order - .extend(files.keys().filter(|id| !ordered.contains(id)).copied()); - restored.files = restored.order.iter().map(|id| files[id].clone()).collect(); + let ordered: HashSet = order.iter().copied().collect(); + order.extend(files.keys().filter(|id| !ordered.contains(id)).copied()); + let restored = Restored { + files: order.iter().map(|id| files[id].clone()).collect(), + order, + categories: categories.into_inner(), + }; self.files = files; self.chunks = chunk_bytes.into_keys().collect(); self.order = stored_order; - restored.categories = categories.into_inner(); self.head = data.head; - self.read_only = newer.0.get(); - restored.read_only = self.read_only; Ok(restored) } @@ -340,7 +340,6 @@ mod tests { let mut persister = Persister::default(); let restored = block_on(persister.restore(&storage)).unwrap(); - assert_eq!(restored.unreadable, 0); assert_eq!(restored.order, snapshot.order); assert_eq!(restored.categories, snapshot.categories); for file in &restored.files { @@ -472,8 +471,6 @@ mod tests { .unwrap(); let mut persister = Persister::default(); let restored = block_on(persister.restore(&storage)).unwrap(); - assert_eq!(restored.unreadable, 1); - assert!(!restored.read_only); assert_eq!(restored.files.len(), FILES.len() - 1); assert!(restored.files.iter().all(|file| file.id != broken)); @@ -525,8 +522,8 @@ mod tests { let (storage, snapshot) = stored_with(damage); let before = storage.inner.counts(); let mut persister = Persister::default(); - let restored = block_on(persister.restore(&storage)).unwrap(); - assert!(restored.read_only, "{what}"); + let result = block_on(persister.restore(&storage)); + assert_eq!(result.unwrap_err(), StorageError::Newer, "{what}"); // whatever is saved afterwards, nothing is written: the newer version needs its data let mut changed = snapshot.clone(); @@ -544,12 +541,11 @@ mod tests { batch.put_files.push((snapshot.order[0], from_the_future())); }); 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 let clean = Recording::default(); - let restored = block_on(persister.restore(&clean)).unwrap(); - assert!(!restored.read_only); + block_on(persister.restore(&clean)).unwrap(); block_on(persister.save(&clean, snapshot_of(&FILES))).unwrap(); assert_eq!(clean.inner.counts().files, FILES.len()); } diff --git a/gpx-rs/wasm/src/idb.rs b/gpx-rs/wasm/src/idb.rs index 888d328ff..3c83b8e87 100644 --- a/gpx-rs/wasm/src/idb.rs +++ b/gpx-rs/wasm/src/idb.rs @@ -35,7 +35,7 @@ pub struct IdbStorage { } 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. diff --git a/gpx-rs/wasm/src/persistence.rs b/gpx-rs/wasm/src/persistence.rs index 9f9fce712..53f996a33 100644 --- a/gpx-rs/wasm/src/persistence.rs +++ b/gpx-rs/wasm/src/persistence.rs @@ -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 /// in the engine, which has to be empty, and returns the settings (JSON strings by key) and the /// outcome of putting the files in. The changes of the files that follow are saved, unless some of -/// what is stored was written by a newer version of the app (`readOnly`): then nothing is, so that -/// the newer version finds its data again. +/// what is stored was written by a newer version of the app: then nothing is read, and the files +/// 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 /// 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 mut persister = Persister::default(); - let restored = persister.restore(&storage).await.map_err(to_js_error)?; - let (read_only, unreadable) = (restored.read_only, restored.unreadable); + let outcome = restore_engine(persister.restore(&storage).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)); STORAGE.with(|slot| *slot.borrow_mut() = Some(Rc::new(storage))); LISTENERS.with(|slot| *slot.borrow_mut() = Some((on_saved, on_behind))); @@ -130,12 +129,29 @@ pub async fn open_storage( } let opened = Object::new(); set(&opened, "settings", settings_object); - set(&opened, "outcome", outcome_object(&outcome, None)); - set(&opened, "readOnly", read_only); - set(&opened, "unreadable", unreadable as f64); + set(&opened, "outcome", outcome); 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, +) -> Result { + 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 { JsValue::from_str(&error.to_string()) } @@ -171,11 +187,11 @@ pub async fn sync_storage() -> Result { SAVING.set(true); let result = async { 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); - Ok(with_engine_mut(|engine| engine.restore(restored)).unwrap_or_default()) + Ok(outcome) } .await; PERSISTER.with(|slot| *slot.borrow_mut() = Some(persister)); @@ -187,9 +203,7 @@ pub async fn sync_storage() -> Result { // edited while reading, and nothing was overwritten schedule_save(); } - result - .map(|outcome| outcome_object(&outcome, None)) - .map_err(to_js_error) + result.map_err(to_js_error) } /// Saves a setting, `value` being its JSON. diff --git a/gpx-rs/wasm/src/session.rs b/gpx-rs/wasm/src/session.rs index 01e20c5bf..24b4b326e 100644 --- a/gpx-rs/wasm/src/session.rs +++ b/gpx-rs/wasm/src/session.rs @@ -21,14 +21,15 @@ pub(crate) fn fail(message: impl AsRef) -> Outcome { /// Runs an action and tells what it did, see `Outcome` in `ts`. pub(crate) fn execute(action: Action) -> Outcome { - let Some(outcome) = with_engine_mut(|engine| engine.execute(action)) else { - return fail("the engine is not started"); + let outcome = match with_engine_mut(|engine| engine.execute(action)) { + 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) { schedule_save(); } - let error = outcome.error.as_ref().map(ToString::to_string); - outcome_object(&outcome, error.as_deref()) + outcome_object(&outcome, None) } pub(crate) fn edit(command: Command) -> Outcome { diff --git a/gpx-rs/wasm/src/ts.rs b/gpx-rs/wasm/src/ts.rs index d6bf626fa..bc70eb5b4 100644 --- a/gpx-rs/wasm/src/ts.rs +++ b/gpx-rs/wasm/src/ts.rs @@ -97,10 +97,6 @@ export interface StorageOpened { settings: Record; /** The files that were restored are the added ones. */ outcome: Outcome; - /** Some of what is stored was written by a newer version: nothing is saved. */ - readOnly: boolean; - /** How many stored files could not be read, and are dropped at the next save. */ - unreadable: number; } export interface FileStructure { id: string; diff --git a/website/src/lib/engine/engine.ts b/website/src/lib/engine/engine.ts index 03412fcc9..10effb38d 100644 --- a/website/src/lib/engine/engine.ts +++ b/website/src/lib/engine/engine.ts @@ -19,7 +19,6 @@ import { type Clipboard, type ExportOptions, type FileState, - type FilesUpdate, type MergeType, type MoveTarget, type NewWaypoint, @@ -34,6 +33,9 @@ import { type WaypointDetails, } 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. * @@ -57,6 +59,7 @@ class Engine { private _files = writable>>(new Map()); private _selection = writable({ type: 'empty' }); private _lastError = writable(undefined); + private _outdated = writable(false); private _canUndo = writable(false); private _canRedo = writable(false); private _canPaste = writable(false); @@ -67,6 +70,8 @@ class Engine { readonly order: Readable = { subscribe: this._order.subscribe }; /** Statistics of what is currently selected. */ readonly statistics: Readable = this._statistics.store; + /** Whether the stored files are from a newer version of the app: they cannot be changed. */ + readonly outdated: Readable = { subscribe: this._outdated.subscribe }; readonly canUndo: Readable = { subscribe: this._canUndo.subscribe }; readonly canRedo: Readable = { subscribe: this._canRedo.subscribe }; /** 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); if (outcome.error === 'nothing to do') { console.debug('engine:', outcome.error); - } else if (outcome.error !== undefined) { + } else if (outcome.error !== undefined && outcome.error !== OUTDATED) { console.warn('engine:', outcome.error); } } @@ -691,7 +696,10 @@ class Engine { } /** 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._canRedo.set(wasm.can_redo()); this._canPaste.set(wasm.can_paste()); diff --git a/website/src/lib/engine/storage.ts b/website/src/lib/engine/storage.ts index 8316757e5..c8e739a51 100644 --- a/website/src/lib/engine/storage.ts +++ b/website/src/lib/engine/storage.ts @@ -1,6 +1,6 @@ import { LEGACY_IMPORTED_KEY, readLegacyData } from '$lib/legacy-import'; 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'; /** 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. */ visibility: Map; /** Applies what a call changed to what the facade keeps of the files. */ - sync(update: FilesUpdate): void; + sync(update: Outcome): void; hasFiles(): boolean; loadFiles(files: { data: Uint8Array; name: string }[]): Promise; deselect(): Promise; @@ -66,14 +66,6 @@ export async function openStoredFiles(host: StorageHost): Promise 0) { - console.warn(`${opened.unreadable} stored file(s) could not be read`); - } let settings = opened.settings; // what was hidden, which the files that were restored need when they are read parseVisibility(settings[VISIBILITY_KEY]).forEach((visibility, fileId) => diff --git a/website/src/locales/en.json b/website/src/locales/en.json index 14fe8edc1..bf3fdc1b9 100644 --- a/website/src/locales/en.json +++ b/website/src/locales/en.json @@ -557,5 +557,10 @@ }, "webgl2_required": "WebGL 2 is required to display the map.", "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" + } } diff --git a/website/src/routes/[[language]]/app/+page.svelte b/website/src/routes/[[language]]/app/+page.svelte index 3a75940f5..c8d897db7 100644 --- a/website/src/routes/[[language]]/app/+page.svelte +++ b/website/src/routes/[[language]]/app/+page.svelte @@ -11,6 +11,7 @@ import CoordinatesPopup from '$lib/components/map/CoordinatesPopup.svelte'; import Resizer from '$lib/components/Resizer.svelte'; import { Toaster } from '$lib/components/ui/sonner'; + import * as AlertDialog from '$lib/components/ui/alert-dialog'; import { i18n } from '$lib/i18n.svelte'; import { settings } from '$lib/logic/settings'; import { loadFiles } from '$lib/logic/file-actions'; @@ -20,6 +21,8 @@ import { engine } from '$lib/engine'; import { getURLForGoogleDriveFile } from '$lib/components/embedding/embedding'; + const outdated = engine.outdated; + const { treeFileView, elevationProfile, @@ -153,6 +156,20 @@ {/if} + + + + {i18n._('outdated.title')} + {i18n._('outdated.description')} + + + location.reload()}> + {i18n._('outdated.reload')} + + + + +