persist: localStorage autosave + JSON export/import engine
- STORAGE_KEY aex.dashboard, 500 ms debounced autosave via store.track() - deferred load after hydration keeps SSR markup authoritative - parse_dashboard version-checks the file (the migration hook), errors are RU messages via ImportError (thiserror) - clear writes the blank dashboard back so a reload stays blank - export_download builds a Blob URL and revokes it after the click - 5 unit tests: round trip, blank, garbage, newer and older versions
This commit is contained in:
parent
0409d8edad
commit
043977cc1f
@ -2,4 +2,8 @@
|
|||||||
//!
|
//!
|
||||||
//! The whole dashboard lives in one `reactive_stores::Store<Dashboard>`
|
//! The whole dashboard lives in one `reactive_stores::Store<Dashboard>`
|
||||||
//! (alias `Context`), created in `App` and shared through Leptos context.
|
//! (alias `Context`), created in `App` and shared through Leptos context.
|
||||||
|
//! Persistence (localStorage autosave, JSON export/import) lives in
|
||||||
|
//! [`persist`].
|
||||||
|
|
||||||
|
pub (crate) mod persist;
|
||||||
|
|
||||||
|
|||||||
271
src/state/persist.rs
Normal file
271
src/state/persist.rs
Normal file
@ -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<Dashboard, ImportError> {
|
||||||
|
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<String> {
|
||||||
|
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<Cell<Option<TimeoutHandle>>> = 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::<web_sys::HtmlAnchorElement>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user