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>`
|
||||
//! (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