diff --git a/src/state/mod.rs b/src/state/mod.rs index be0470e..ab56ae0 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -2,4 +2,8 @@ //! //! The whole dashboard lives in one `reactive_stores::Store` //! (alias `Context`), created in `App` and shared through Leptos context. +//! Persistence (localStorage autosave, JSON export/import) lives in +//! [`persist`]. + +pub (crate) mod persist; diff --git a/src/state/persist.rs b/src/state/persist.rs new file mode 100644 index 0000000..51caad1 --- /dev/null +++ b/src/state/persist.rs @@ -0,0 +1,271 @@ +//! Persistence: localStorage autosave + JSON export/import. +//! +//! Everything here is client-side. The server build (`ssr`) never touches +//! storage: it renders mock data, and the hydrated client swaps in the +//! saved state a moment after mount ([`init_hydrated`]). +//! +//! Flow: +//! - on mount, [`init_hydrated`] loads the saved state (mock stays as the +//! first-visit fallback) and starts the debounced autosave effect, +//! - the header's Save / Load / Clear buttons call [`export_download`], +//! [`apply_import`] and [`clear_all`], +//! - the saved format is the whole `Dashboard` JSON, version-checked by +//! [`parse_dashboard`] (the migration hook). + +#[cfg(feature = "hydrate")] +use std::{ cell::Cell, rc::Rc, time::Duration }; + +use leptos::prelude::*; + +use crate::prelude::*; + +/// The localStorage key under which the whole dashboard is stored. +#[cfg(feature = "hydrate")] +pub const STORAGE_KEY: &str = "aex.dashboard"; + +/// Autosave debounce: changes are written this long after the last change. +#[cfg(feature = "hydrate")] +const SAVE_DEBOUNCE: Duration = Duration::from_millis(500); + +/// Failure modes when parsing an imported or stored file. +#[derive(Debug, thiserror::Error)] +pub enum ImportError { + /// The file is not valid JSON or not a dashboard. + #[error("Файл повреждён или не является файлом кампании.")] + Parse, + /// The file comes from a newer app version. + #[error( + "Версия файла ({0}) новее, чем поддерживает приложение (текущая: {current}).", + current = Settings::VERSION + )] + NewerVersion(u32), + /// The file comes from an older app version; no migration exists yet. + #[error( + "Версия файла ({0}) устарела; перенос данных пока не реализован (текущая: {current}).", + current = Settings::VERSION + )] + OldVersion(u32), +} + +/// Parses and version-checks a dashboard JSON document. +/// +/// This is the migration hook: a future format bump adds an arm here that +/// converts the old version into the new one and recurses. +pub fn parse_dashboard(text: &str) -> std::result::Result { + let dashboard: Dashboard = serde_json::from_str(text).map_err(|_| ImportError::Parse)?; + + match dashboard.settings.version { + version if version == Settings::VERSION => Ok(dashboard), + version if version > Settings::VERSION => Err(ImportError::NewerVersion(version)), + version => Err(ImportError::OldVersion(version)), + } +} + +/// Serializes the current dashboard to pretty JSON. +#[cfg(feature = "hydrate")] +pub fn serialize_dashboard(store: &Context) -> Option { + let guard = store.try_read_untracked()?; + serde_json::to_string_pretty(&*guard).ok() +} + +/// Replaces the whole dashboard inside the store. +pub fn replace_state(store: &Context, dashboard: Dashboard) { + if let Some(mut guard) = store.try_write() { + *guard = dashboard; + } +} + +/// Parses `text` and applies it: replaces the state and saves it back. +/// +/// Returns the error for the UI to show; nothing is applied on error. +pub fn apply_import(store: &Context, text: &str) -> std::result::Result<(), ImportError> { + let dashboard = parse_dashboard(text)?; + replace_state(store, dashboard); + #[cfg(feature = "hydrate")] + save_to_storage(store); + Ok(()) +} + +/// Client-only startup: load the saved state after mount, start autosave. +/// +/// Called from `App`. The load is deferred out of the hydration render so +/// the SSR markup stays authoritative; the swap happens before the browser +/// draws the first frame. +#[cfg(feature = "hydrate")] +pub fn init_hydrated(store: &Context) { + let store = store.clone(); + let pending: Rc>> = Rc::new(Cell::new(None)); + + // Load the saved state (or keep mock) once hydration has settled. + let store_load = store.clone(); + let _ = set_timeout_with_handle( + move || { + load_from_storage(&store_load); + }, + Duration::ZERO, + ); + + // Debounced autosave: any store change restarts the timer. + let store_save = store.clone(); + Effect::new(move |_| { + store_save.track(); + if let Some(handle) = pending.take() { + handle.clear(); + } + let handle = set_timeout_with_handle( + { + let store = store_save.clone(); + move || { + save_to_storage(&store); + } + }, + SAVE_DEBOUNCE, + ); + if let Ok(handle) = handle { + pending.set(Some(handle)); + } + }); +} + +/// Writes the current dashboard to localStorage. +#[cfg(feature = "hydrate")] +pub fn save_to_storage(store: &Context) { + let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) else { + return; + }; + let Some(json) = serialize_dashboard(store) else { + return; + }; + if let Err(err) = storage.set_item(STORAGE_KEY, &json) { + e!("не удалось сохранить состояние: {err:?}"); + } +} + +/// Loads the saved dashboard from localStorage and replaces the store. +/// +/// Returns `true` when a valid saved state was applied. +#[cfg(feature = "hydrate")] +pub fn load_from_storage(store: &Context) -> bool { + let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) else { + return false; + }; + let Some(text) = storage.get_item(STORAGE_KEY).ok().flatten() else { + return false; + }; + match parse_dashboard(&text) { + Ok(dashboard) => { + replace_state(store, dashboard); + true + } + Err(err) => { + e!("сохранённое состояние повреждено: {err}"); + false + } + } +} + +/// Wipes the saved state and replaces the dashboard with the blank slate. +/// +/// The blank dashboard is written back to storage so it survives a reload; +/// mock data stays the fallback for the very first visit only. +#[cfg(feature = "hydrate")] +pub fn clear_all (store: &Context) { + replace_state(store, Dashboard::blank()); + save_to_storage(store); +} + +/// SSR stub: there is no browser, nothing to wipe. +#[cfg(not(feature = "hydrate"))] +pub fn clear_all (_store: &Context) {} + +/// SSR stub: no browser, nothing to download. +#[cfg(not(feature = "hydrate"))] +pub fn export_download (_store: &Context) {} + +/// Downloads the current dashboard as `dashboard.json`. +#[cfg(feature = "hydrate")] +pub fn export_download (store: &Context) { + use wasm_bindgen::JsCast; + + let Some(json) = serialize_dashboard(store) else { + return; + }; + let Some(doc) = web_sys::window().and_then(|w| w.document()) else { + return; + }; + + let blob = match web_sys::Blob::new_with_str_sequence(&js_sys::Array::of1( + &wasm_bindgen::JsValue::from_str(&json), + )) { + Ok(blob) => blob, + Err(_) => return, + }; + let Ok(url) = web_sys::Url::create_object_url_with_blob(&blob) else { + return; + }; + + let Ok(anchor) = doc.create_element("a") else { + return; + }; + let anchor = anchor.unchecked_into::(); + let _ = anchor.set_attribute("href", &url); + let _ = anchor.set_attribute("download", "dashboard.json"); + anchor.click(); + + // Release the object URL after the download has started. + queue_microtask(move || { + let _ = web_sys::Url::revoke_object_url(&url); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The whole dashboard survives a JSON round trip with the version intact. + #[test] + fn round_trip_keeps_state() { + let dashboard = Dashboard::mock(); + let json = serde_json::to_string(&dashboard).unwrap(); + let loaded = parse_dashboard(&json).unwrap(); + assert_eq!(loaded.campaign, dashboard.campaign); + assert_eq!(loaded.player.name.first, dashboard.player.name.first); + assert_eq!(loaded.player.inventory.rows.len(), dashboard.player.inventory.rows.len()); + assert_eq!(loaded.settings.version, Settings::VERSION); + } + + /// Files from a newer app version are rejected with a clear error. + #[test] + fn rejects_newer_version() { + let mut dashboard = Dashboard::mock(); + dashboard.settings.version = Settings::VERSION + 1; + let json = serde_json::to_string(&dashboard).unwrap(); + assert!(matches!(parse_dashboard(&json), Err(ImportError::NewerVersion(_)))); + } + + /// Files from an older version are rejected until a migration exists. + #[test] + fn rejects_older_version() { + let mut dashboard = Dashboard::mock(); + dashboard.settings.version = Settings::VERSION - 1; + let json = serde_json::to_string(&dashboard).unwrap(); + assert!(matches!(parse_dashboard(&json), Err(ImportError::OldVersion(_)))); + } + + /// Garbage input fails with the parse error. + #[test] + fn rejects_garbage() { + assert!(matches!(parse_dashboard("not json"), Err(ImportError::Parse))); + assert!(matches!(parse_dashboard("[]"), Err(ImportError::Parse))); + } + + /// An empty dashboard round-trips too (Clear produces valid files). + #[test] + fn blank_round_trips() { + let json = serde_json::to_string(&Dashboard::blank()).unwrap(); + let loaded = parse_dashboard(&json).unwrap(); + assert!(loaded.campaign.is_empty()); + assert!(loaded.player.inventory.rows.is_empty()); + assert_eq!(loaded.settings.version, Settings::VERSION); + } +}