From b496cf956850a6f58e6dcf20b1aa78c83ce143b0 Mon Sep 17 00:00:00 2001 From: Jeremy Wall Date: Wed, 31 May 2023 15:56:39 -0400 Subject: [PATCH] Store app state atomically as one json blob --- Cargo.lock | 1 + web/Cargo.toml | 4 + web/src/api.rs | 214 ++++++------------------------ web/src/app_state.rs | 137 ++++++------------- web/src/components/recipe_plan.rs | 2 +- 5 files changed, 88 insertions(+), 270 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6afc4f7..ee685d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1429,6 +1429,7 @@ dependencies = [ "js-sys", "recipes", "reqwasm", + "serde", "serde_json", "sycamore", "sycamore-router", diff --git a/web/Cargo.toml b/web/Cargo.toml index 98db311..2c5fabb 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -26,6 +26,10 @@ base64 = "0.21.0" sycamore-router = "0.8" js-sys = "0.3.60" +[dependencies.serde] +version = "1.0" +features = ["derive"] + [dependencies.tracing-subscriber] version = "0.3.16" features = ["fmt", "time"] diff --git a/web/src/api.rs b/web/src/api.rs index ea99a94..48bf81e 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -76,10 +76,6 @@ fn recipe_key(id: S) -> String { format!("recipe:{}", id) } -fn category_key(id: S) -> String { - format!("category:{}", id) -} - fn token68(user: String, pass: String) -> String { base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", user, pass)) } @@ -96,6 +92,19 @@ impl LocalStore { } } + pub fn store_app_state(&self, state: &AppState) { + self.migrate_local_store(); + self.store + .set("app_state", &to_string(state).unwrap()) + .expect("Failed to set our app state"); + } + + pub fn fetch_app_state(&self) -> Option { + self.store.get("app_state").map_or(None, |val| { + val.map(|s| from_str(&s).expect("Failed to deserialize app state")) + }) + } + /// Gets user data from local storage. pub fn get_user_data(&self) -> Option { self.store @@ -120,43 +129,6 @@ impl LocalStore { } } - /// Gets categories from local storage. - pub fn get_categories(&self) -> Option> { - let mut mappings = Vec::new(); - for k in self.get_category_keys() { - if let Some(mut cat_map) = self - .store - .get(&k) - .expect(&format!("Failed to get category key {}", k)) - .map(|v| { - from_str::>(&v) - .expect(&format!("Failed to parse category key {}", k)) - }) - { - mappings.extend(cat_map.drain(0..)); - } - } - if mappings.is_empty() { - None - } else { - Some(mappings) - } - } - - /// Set the categories to the given string. - pub fn set_categories(&self, mappings: Option<&Vec<(String, String)>>) { - if let Some(mappings) = mappings { - for (i, cat) in mappings.iter() { - self.store - .set( - &category_key(i), - &to_string(&(i, cat)).expect("Failed to serialize category mapping"), - ) - .expect("Failed to store category mapping"); - } - } - } - fn get_storage_keys(&self) -> Vec { let mut keys = Vec::new(); for idx in 0..self.store.length().unwrap() { @@ -167,10 +139,14 @@ impl LocalStore { keys } - fn get_category_keys(&self) -> impl Iterator { - self.get_storage_keys() + fn migrate_local_store(&self) { + for k in self.get_storage_keys() .into_iter() - .filter(|k| k.starts_with("category:")) + .filter(|k| k.starts_with("categor") || k == "inventory" || k.starts_with("plan") || k == "staples") { + // Deleting old local store key + debug!("Deleting old local store key {}", k); + self.store.delete(&k).expect("Failed to delete storage key"); + } } fn get_recipe_keys(&self) -> impl Iterator { @@ -241,110 +217,6 @@ impl LocalStore { .delete(&recipe_key(recipe_id)) .expect(&format!("Failed to delete recipe {}", recipe_id)) } - - /// Save working plan to local storage. - pub fn store_plan(&self, plan: &Vec<(String, i32)>) { - self.store - .set("plan", &to_string(&plan).expect("Failed to serialize plan")) - .expect("Failed to store plan'"); - } - - pub fn get_plan(&self) -> Option> { - if let Some(plan) = self.store.get("plan").expect("Failed to store plan") { - Some(from_str(&plan).expect("Failed to deserialize plan")) - } else { - None - } - } - - pub fn delete_plan(&self) { - self.store.delete("plan").expect("Failed to delete plan"); - self.store - .delete("inventory") - .expect("Failed to delete inventory data"); - } - - pub fn set_plan_date(&self, date: &NaiveDate) { - self.store - .set( - "plan:date", - &to_string(&date).expect("Failed to serialize plan:date"), - ) - .expect("Failed to store plan:date"); - } - - pub fn get_plan_date(&self) -> Option { - if let Some(date) = self - .store - .get("plan:date") - .expect("Failed to get plan date") - { - Some(from_str(&date).expect("Failed to deserialize plan_date")) - } else { - None - } - } - - pub fn get_inventory_data( - &self, - ) -> Option<( - BTreeSet, - BTreeMap, - Vec<(String, String)>, - )> { - if let Some(inventory) = self - .store - .get("inventory") - .expect("Failed to retrieve inventory data") - { - let (filtered, modified, extras): ( - BTreeSet, - Vec<(IngredientKey, String)>, - Vec<(String, String)>, - ) = from_str(&inventory).expect("Failed to deserialize inventory"); - return Some((filtered, BTreeMap::from_iter(modified), extras)); - } - return None; - } - - pub fn set_inventory_data( - &self, - inventory: ( - &BTreeSet, - &BTreeMap, - &Vec<(String, String)>, - ), - ) { - let filtered = inventory.0; - let modified_amts = inventory - .1 - .iter() - .map(|(k, amt)| (k.clone(), amt.clone())) - .collect::>(); - let extras = inventory.2; - let inventory_data = (filtered, &modified_amts, extras); - self.store - .set( - "inventory", - &to_string(&inventory_data).expect(&format!( - "Failed to serialize inventory {:?}", - inventory_data - )), - ) - .expect("Failed to set inventory"); - } - - pub fn set_staples(&self, content: &String) { - self.store - .set("staples", content) - .expect("Failed to set staples in local store"); - } - - pub fn get_staples(&self) -> Option { - self.store - .get("staples") - .expect("Failed to retreive staples from local store") - } } #[derive(Clone, Debug)] @@ -434,7 +306,7 @@ impl HttpStore { Ok(resp) => resp, Err(reqwasm::Error::JsError(err)) => { error!(path, ?err, "Error hitting api"); - return Ok(self.local_store.get_categories()); + return Ok(None); } Err(err) => { return Err(err)?; @@ -706,22 +578,22 @@ impl HttpStore { } } - pub async fn fetch_plan(&self) -> Result>, Error> { - let mut path = self.v2_path(); - path.push_str("/plan"); - let resp = reqwasm::http::Request::get(&path).send().await?; - if resp.status() != 200 { - Err(format!("Status: {}", resp.status()).into()) - } else { - debug!("We got a valid response back"); - let plan = resp - .json::() - .await - .map_err(|e| format!("{}", e))? - .as_success(); - Ok(plan) - } - } + //pub async fn fetch_plan(&self) -> Result>, Error> { + // let mut path = self.v2_path(); + // path.push_str("/plan"); + // let resp = reqwasm::http::Request::get(&path).send().await?; + // if resp.status() != 200 { + // Err(format!("Status: {}", resp.status()).into()) + // } else { + // debug!("We got a valid response back"); + // let plan = resp + // .json::() + // .await + // .map_err(|e| format!("{}", e))? + // .as_success(); + // Ok(plan) + // } + //} pub async fn fetch_inventory_for_date( &self, @@ -740,11 +612,7 @@ impl HttpStore { path.push_str(&format!("/{}", date)); let resp = reqwasm::http::Request::get(&path).send().await?; if resp.status() != 200 { - let err = Err(format!("Status: {}", resp.status()).into()); - Ok(match self.local_store.get_inventory_data() { - Some(val) => val, - None => return err, - }) + Err(format!("Status: {}", resp.status()).into()) } else { debug!("We got a valid response back"); let InventoryData { @@ -779,11 +647,7 @@ impl HttpStore { path.push_str("/inventory"); let resp = reqwasm::http::Request::get(&path).send().await?; if resp.status() != 200 { - let err = Err(format!("Status: {}", resp.status()).into()); - Ok(match self.local_store.get_inventory_data() { - Some(val) => val, - None => return err, - }) + Err(format!("Status: {}", resp.status()).into()) } else { debug!("We got a valid response back"); let InventoryData { diff --git a/web/src/app_state.rs b/web/src/app_state.rs index 5abb88d..4ed728b 100644 --- a/web/src/app_state.rs +++ b/web/src/app_state.rs @@ -19,6 +19,7 @@ use std::{ use chrono::NaiveDate; use client_api::UserData; use recipes::{parse, Ingredient, IngredientKey, Recipe, RecipeEntry}; +use serde::{Deserialize, Serialize}; use sycamore::futures::spawn_local_scoped; use sycamore::prelude::*; use sycamore_state::{Handler, MessageMapper}; @@ -30,12 +31,14 @@ use crate::{ components, }; -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct AppState { pub recipe_counts: BTreeMap, pub recipe_categories: BTreeMap, pub extras: Vec<(String, String)>, + #[serde(skip)] // FIXME(jwall): This should really be storable I think? pub staples: Option>, + #[serde(skip)] // FIXME(jwall): This should really be storable I think? pub recipes: BTreeMap, pub category_map: BTreeMap, pub filtered_ingredients: BTreeSet, @@ -162,35 +165,35 @@ impl StateMachine { local_store: &LocalStore, original: &Signal, ) -> Result<(), crate::api::Error> { + // TODO(jwall): Load plan state from local_store first. + if let Some(app_state) = local_store.fetch_app_state() { + original.set_silent(app_state); + } let mut state = original.get().as_ref().clone(); info!("Synchronizing Recipes"); let recipe_entries = &store.fetch_recipes().await?; let recipes = parse_recipes(&recipe_entries)?; - + debug!(?recipes, "Parsed Recipes"); if let Some(recipes) = recipes { state.recipes = recipes; }; info!("Synchronizing staples"); state.staples = if let Some(content) = store.fetch_staples().await? { - local_store.set_staples(&content); // now we need to parse staples as ingredients let mut staples = parse::as_ingredient_list(&content)?; Some(staples.drain(0..).collect()) } else { - if let Some(content) = local_store.get_staples() { - let mut staples = parse::as_ingredient_list(&content)?; - Some(staples.drain(0..).collect()) - } else { - None - } + Some(BTreeSet::new()) }; + info!("Synchronizing recipe"); if let Some(recipe_entries) = recipe_entries { local_store.set_all_recipes(recipe_entries); state.recipe_categories = recipe_entries .iter() .map(|entry| { + debug!(recipe_entry=?entry, "Getting recipe category"); ( entry.recipe_id().to_owned(), entry @@ -203,19 +206,19 @@ impl StateMachine { } info!("Fetching meal plan list"); - let plan_dates = store.fetch_plan_dates().await?; - if let Some(mut plan_dates) = plan_dates { + if let Some(mut plan_dates) = store.fetch_plan_dates().await? { debug!(?plan_dates, "meal plan list"); state.plan_dates = BTreeSet::from_iter(plan_dates.drain(0..)); } info!("Synchronizing meal plan"); - let plan = if let Some(cached_plan_date) = local_store.get_plan_date() { - let plan = store.fetch_plan_for_date(&cached_plan_date).await?; - state.selected_plan_date = Some(cached_plan_date); - plan + let plan = if let Some(ref cached_plan_date) = state.selected_plan_date { + store + .fetch_plan_for_date(cached_plan_date) + .await? + .or_else(|| Some(Vec::new())) } else { - store.fetch_plan().await? + None }; if let Some(plan) = plan { // set the counts. @@ -230,23 +233,13 @@ impl StateMachine { } } } else { - if let Some(plan) = local_store.get_plan() { - state.recipe_counts = plan.iter().map(|(k, v)| (k.clone(), *v as usize)).collect(); - } else { - // Initialize things to zero. - if let Some(rs) = recipe_entries { - for r in rs { - state.recipe_counts.insert(r.recipe_id().to_owned(), 0); - } + // Initialize things to zero. + if let Some(rs) = recipe_entries { + for r in rs { + state.recipe_counts.insert(r.recipe_id().to_owned(), 0); } } } - let plan = state - .recipe_counts - .iter() - .map(|(k, v)| (k.clone(), *v as i32)) - .collect::>(); - local_store.store_plan(&plan); info!("Checking for user account data"); if let Some(user_data) = store.fetch_user_data().await { debug!("Successfully got account data from server"); @@ -261,13 +254,11 @@ impl StateMachine { match store.fetch_categories().await { Ok(Some(mut categories_content)) => { debug!(categories=?categories_content); - local_store.set_categories(Some(&categories_content)); let category_map = BTreeMap::from_iter(categories_content.drain(0..)); state.category_map = category_map; } Ok(None) => { warn!("There is no category file"); - local_store.set_categories(None); } Err(e) => { error!("{:?}", e); @@ -281,11 +272,6 @@ impl StateMachine { info!("Synchronizing inventory data"); match inventory_data { Ok((filtered_ingredients, modified_amts, extra_items)) => { - local_store.set_inventory_data(( - &filtered_ingredients, - &modified_amts, - &extra_items, - )); state.modified_amts = modified_amts; state.filtered_ingredients = filtered_ingredients; state.extras = extra_items; @@ -294,6 +280,8 @@ impl StateMachine { error!("{:?}", e); } } + // Finally we store all of this app state back to our localstore + local_store.store_app_state(&state); original.set(state); Ok(()) } @@ -310,35 +298,16 @@ impl MessageMapper for StateMachine { for (id, _) in original_copy.recipes.iter() { map.insert(id.clone(), 0); } - let plan: Vec<(String, i32)> = - map.iter().map(|(s, i)| (s.clone(), *i as i32)).collect(); - self.local_store.store_plan(&plan); original_copy.recipe_counts = map; } Message::UpdateRecipeCount(id, count) => { original_copy.recipe_counts.insert(id, count); - let plan: Vec<(String, i32)> = original_copy - .recipe_counts - .iter() - .map(|(s, i)| (s.clone(), *i as i32)) - .collect(); - self.local_store.store_plan(&plan); } Message::AddExtra(amt, name) => { original_copy.extras.push((amt, name)); - self.local_store.set_inventory_data(( - &original_copy.filtered_ingredients, - &original_copy.modified_amts, - &original_copy.extras, - )) } Message::RemoveExtra(idx) => { original_copy.extras.remove(idx); - self.local_store.set_inventory_data(( - &original_copy.filtered_ingredients, - &original_copy.modified_amts, - &original_copy.extras, - )) } Message::UpdateExtra(idx, amt, name) => { match original_copy.extras.get_mut(idx) { @@ -350,11 +319,6 @@ impl MessageMapper for StateMachine { throw_str("Attempted to remove extra that didn't exist"); } } - self.local_store.set_inventory_data(( - &original_copy.filtered_ingredients, - &original_copy.modified_amts, - &original_copy.extras, - )) } Message::SaveRecipe(entry, callback) => { let recipe = @@ -404,8 +368,6 @@ impl MessageMapper for StateMachine { }); } Message::UpdateCategory(ingredient, category, callback) => { - self.local_store - .set_categories(Some(&vec![(ingredient.clone(), category.clone())])); original_copy .category_map .insert(ingredient.clone(), category.clone()); @@ -421,28 +383,13 @@ impl MessageMapper for StateMachine { original_copy.filtered_ingredients = BTreeSet::new(); original_copy.modified_amts = BTreeMap::new(); original_copy.extras = Vec::new(); - self.local_store.set_inventory_data(( - &original_copy.filtered_ingredients, - &original_copy.modified_amts, - &original_copy.extras, - )); components::toast::message(cx, "Reset Inventory", None); } Message::AddFilteredIngredient(key) => { original_copy.filtered_ingredients.insert(key); - self.local_store.set_inventory_data(( - &original_copy.filtered_ingredients, - &original_copy.modified_amts, - &original_copy.extras, - )); } Message::UpdateAmt(key, amt) => { original_copy.modified_amts.insert(key, amt); - self.local_store.set_inventory_data(( - &original_copy.filtered_ingredients, - &original_copy.modified_amts, - &original_copy.extras, - )); } Message::SetUserData(user_data) => { self.local_store.set_user_data(Some(&user_data)); @@ -451,19 +398,25 @@ impl MessageMapper for StateMachine { Message::SaveState(f) => { let mut original_copy = original_copy.clone(); let store = self.store.clone(); + let local_store = self.local_store.clone(); spawn_local_scoped(cx, async move { if original_copy.selected_plan_date.is_none() { original_copy.selected_plan_date = Some(chrono::Local::now().date_naive()); } - original_copy - .plan_dates - .insert(original_copy.selected_plan_date.map(|d| d.clone()).unwrap()); + original_copy.plan_dates.insert( + original_copy + .selected_plan_date + .as_ref() + .map(|d| d.clone()) + .unwrap(), + ); if let Err(e) = store.store_app_state(&original_copy).await { error!(err=?e, "Error saving app state"); components::toast::error_message(cx, "Failed to save user state", None); } else { components::toast::message(cx, "Saved user state", None); }; + local_store.store_app_state(&original_copy); original.set(original_copy); f.map(|f| f()); }); @@ -474,17 +427,13 @@ impl MessageMapper for StateMachine { Message::LoadState(f) => { let store = self.store.clone(); let local_store = self.local_store.clone(); + debug!("Loading user state."); spawn_local_scoped(cx, async move { if let Err(err) = Self::load_state(&store, &local_store, original).await { error!(?err, "Failed to load user state"); components::toast::error_message(cx, "Failed to load_state.", None); } else { components::toast::message(cx, "Loaded user state", None); - local_store.set_inventory_data(( - &original.get().filtered_ingredients, - &original.get().modified_amts, - &original.get().extras, - )); } f.map(|f| f()); }); @@ -492,9 +441,7 @@ impl MessageMapper for StateMachine { } Message::UpdateStaples(content, callback) => { let store = self.store.clone(); - let local_store = self.local_store.clone(); spawn_local_scoped(cx, async move { - local_store.set_staples(&content); if let Err(err) = store.store_staples(content).await { error!(?err, "Failed to store staples"); components::toast::error_message(cx, "Failed to store staples", None); @@ -507,7 +454,7 @@ impl MessageMapper for StateMachine { } Message::SelectPlanDate(date, callback) => { let store = self.store.clone(); - let local_store = self.local_store.clone(); + let local_store = self.local_store.clone(); spawn_local_scoped(cx, async move { if let Some(mut plan) = store .fetch_plan_for_date(&date) @@ -528,12 +475,11 @@ impl MessageMapper for StateMachine { original_copy.filtered_ingredients = filtered; original_copy.extras = extras; original_copy.selected_plan_date = Some(date.clone()); - local_store.set_plan_date(&date); store .store_plan_for_date(vec![], &date) .await .expect("Failed to init meal plan for date"); - + local_store.store_app_state(&original_copy); original.set(original_copy); callback.map(|f| f()); @@ -545,7 +491,7 @@ impl MessageMapper for StateMachine { } Message::DeletePlan(date, callback) => { let store = self.store.clone(); - let local_store = self.local_store.clone(); + let local_store = self.local_store.clone(); spawn_local_scoped(cx, async move { if let Err(err) = store.delete_plan_for_date(&date).await { components::toast::error_message( @@ -555,23 +501,26 @@ impl MessageMapper for StateMachine { ); error!(?err, "Error deleting plan"); } else { - local_store.delete_plan(); - original_copy.plan_dates.remove(&date); // Reset all meal planning state; let _ = original_copy.recipe_counts.iter_mut().map(|(_, v)| *v = 0); original_copy.filtered_ingredients = BTreeSet::new(); original_copy.modified_amts = BTreeMap::new(); original_copy.extras = Vec::new(); + local_store.store_app_state(&original_copy); original.set(original_copy); components::toast::message(cx, "Deleted Plan", None); callback.map(|f| f()); } }); + // NOTE(jwall): Because we do our signal set above in the async block + // we have to return here to avoid lifetime issues and double setting + // the original signal. return; } } + self.local_store.store_app_state(&original_copy); original.set(original_copy); } } diff --git a/web/src/components/recipe_plan.rs b/web/src/components/recipe_plan.rs index 7e03ce1..607119b 100644 --- a/web/src/components/recipe_plan.rs +++ b/web/src/components/recipe_plan.rs @@ -89,7 +89,7 @@ pub fn RecipePlan<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> Vie .get() .recipes .get(r) - .expect("Failed to find recipe") + .expect(&format!("Failed to find recipe {}", r)) .clone(), )); map