Compare commits

..

No commits in common. "aba1e114cf56fac216959ed84daf603ee2a0cce0" and "1c55a315b00d185f2c556966b4edb4f63fcc9e11" have entirely different histories.

15 changed files with 978 additions and 1301 deletions

1652
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -6,12 +6,10 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
serde = "<=1.0.171"
recipes = { path = "../recipes" } recipes = { path = "../recipes" }
chrono = "0.4.22" chrono = "0.4.22"
[dependencies.serde]
version = "1.0.204"
features = ["derive"]
[dependencies.axum] [dependencies.axum]
version = "0.5.16" version = "0.5.16"

View File

@ -18,19 +18,14 @@ async-trait = "0.1.57"
async-session = "3.0.0" async-session = "3.0.0"
ciborium = "0.2.0" ciborium = "0.2.0"
tower = "0.4.13" tower = "0.4.13"
serde = "<=1.0.171"
cookie = "0.17.0" cookie = "0.17.0"
chrono = "0.4.22"
metrics = "0.20.1" metrics = "0.20.1"
metrics-exporter-prometheus = "0.11.0" metrics-exporter-prometheus = "0.11.0"
futures = "0.3" futures = "0.3"
metrics-process = "1.0.8" metrics-process = "1.0.8"
[dependencies.chrono]
version = "0.4.22"
features = ["serde"]
[dependencies.serde]
version = "1.0.204"
[dependencies.argon2] [dependencies.argon2]
version = "0.5.0" version = "0.5.0"

View File

@ -99,7 +99,7 @@ impl AsyncFileStore {
let file_name = entry.file_name().to_string_lossy().to_string(); let file_name = entry.file_name().to_string_lossy().to_string();
debug!("adding recipe file {}", file_name); debug!("adding recipe file {}", file_name);
let recipe_contents = read_to_string(entry.path()).await?; let recipe_contents = read_to_string(entry.path()).await?;
entry_vec.push(RecipeEntry::new(file_name, recipe_contents)); entry_vec.push(RecipeEntry(file_name, recipe_contents, None, None));
} else { } else {
warn!( warn!(
file = %entry.path().to_string_lossy(), file = %entry.path().to_string_lossy(),
@ -119,12 +119,12 @@ impl AsyncFileStore {
if recipe_path.exists().await && recipe_path.is_file().await { if recipe_path.exists().await && recipe_path.is_file().await {
debug!("Found recipe file {}", recipe_path.to_string_lossy()); debug!("Found recipe file {}", recipe_path.to_string_lossy());
let recipe_contents = read_to_string(recipe_path).await?; let recipe_contents = read_to_string(recipe_path).await?;
return Ok(Some(RecipeEntry { return Ok(Some(RecipeEntry(
id: id.as_ref().to_owned(), id.as_ref().to_owned(),
text: recipe_contents, recipe_contents,
category: None, None,
serving_count: None, None,
})); )));
} else { } else {
return Ok(None); return Ok(None);
} }

View File

@ -440,12 +440,12 @@ impl APIStore for SqliteStore {
.await? .await?
.iter() .iter()
.map(|row| { .map(|row| {
RecipeEntry { RecipeEntry(
id: row.recipe_id.clone(), row.recipe_id.clone(),
text: row.recipe_text.clone().unwrap_or_else(|| String::new()), row.recipe_text.clone().unwrap_or_else(|| String::new()),
category: row.category.clone(), row.category.clone(),
serving_count: row.serving_count.clone(), row.serving_count.clone(),
} )
}) })
.nth(0); .nth(0);
Ok(entry) Ok(entry)
@ -460,12 +460,12 @@ impl APIStore for SqliteStore {
.await? .await?
.iter() .iter()
.map(|row| { .map(|row| {
RecipeEntry { RecipeEntry(
id: row.recipe_id.clone(), row.recipe_id.clone(),
text: row.recipe_text.clone().unwrap_or_else(|| String::new()), row.recipe_text.clone().unwrap_or_else(|| String::new()),
category: row.category.clone(), row.category.clone(),
serving_count: row.serving_count.clone(), row.serving_count.clone(),
} )
}) })
.collect(); .collect();
Ok(Some(rows)) Ok(Some(rows))

View File

@ -8,14 +8,8 @@ edition = "2021"
[dependencies] [dependencies]
abortable_parser = "~0.2.6" abortable_parser = "~0.2.6"
chrono = "~0.4"
[dependencies.chrono] serde = "1.0.144"
version = "0.4.22"
features = ["serde"]
[dependencies.serde]
version = "1.0.204"
features = ["derive"]
[dependencies.num-rational] [dependencies.num-rational]
version = "~0.4.0" version = "~0.4.0"

View File

@ -50,49 +50,39 @@ impl Mealplan {
} }
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct RecipeEntry { pub struct RecipeEntry(pub String, pub String, pub Option<String>, pub Option<i64>);
pub id: String,
pub text: String,
pub category: Option<String>,
pub serving_count: Option<i64>,
}
impl RecipeEntry { impl RecipeEntry {
pub fn new<IS: Into<String>, TS: Into<String>>(recipe_id: IS, text: TS) -> Self { pub fn new<IS: Into<String>, TS: Into<String>>(recipe_id: IS, text: TS) -> Self {
Self { Self(recipe_id.into(), text.into(), None, None)
id: recipe_id.into(),
text: text.into(),
category: None,
serving_count: None,
}
} }
pub fn set_recipe_id<S: Into<String>>(&mut self, id: S) { pub fn set_recipe_id<S: Into<String>>(&mut self, id: S) {
self.id = id.into(); self.0 = id.into();
} }
pub fn recipe_id(&self) -> &str { pub fn recipe_id(&self) -> &str {
self.id.as_str() self.0.as_str()
} }
pub fn set_recipe_text<S: Into<String>>(&mut self, text: S) { pub fn set_recipe_text<S: Into<String>>(&mut self, text: S) {
self.text = text.into(); self.1 = text.into();
} }
pub fn recipe_text(&self) -> &str { pub fn recipe_text(&self) -> &str {
self.text.as_str() self.1.as_str()
} }
pub fn set_category<S: Into<String>>(&mut self, cat: S) { pub fn set_category<S: Into<String>>(&mut self, cat: S) {
self.category = Some(cat.into()); self.2 = Some(cat.into());
} }
pub fn category(&self) -> Option<&String> { pub fn category(&self) -> Option<&String> {
self.category.as_ref() self.2.as_ref()
} }
pub fn serving_count(&self) -> Option<i64> { pub fn serving_count(&self) -> Option<i64> {
self.serving_count.clone() self.3.clone()
} }
} }
@ -220,7 +210,7 @@ pub struct Step {
impl Step { impl Step {
pub fn new<S: Into<String>>(prep_time: Option<std::time::Duration>, instructions: S) -> Self { pub fn new<S: Into<String>>(prep_time: Option<std::time::Duration>, instructions: S) -> Self {
Self { Self {
prep_time, prep_time: prep_time,
instructions: instructions.into(), instructions: instructions.into(),
ingredients: Vec::new(), ingredients: Vec::new(),
} }

View File

@ -27,12 +27,9 @@ sycamore-router = "0.8"
js-sys = "0.3.60" js-sys = "0.3.60"
wasm-web-component = { git = "https://github.com/zaphar/wasm-web-components.git", rev = "v0.3.0" } wasm-web-component = { git = "https://github.com/zaphar/wasm-web-components.git", rev = "v0.3.0" }
maud = "*" maud = "*"
indexed-db = "0.4.1"
anyhow = "1.0.86"
serde-wasm-bindgen = "0.6.5"
[dependencies.serde] [dependencies.serde]
version = "1.0.204" version = "<=1.0.171"
features = ["derive"] features = ["derive"]
[dependencies.tracing-subscriber] [dependencies.tracing-subscriber]

View File

@ -16,27 +16,18 @@ use std::collections::{BTreeMap, BTreeSet};
use base64::{self, Engine}; use base64::{self, Engine};
use chrono::NaiveDate; use chrono::NaiveDate;
use gloo_net; use gloo_net;
// TODO(jwall): Remove this when we have gone a few migrations past. use serde_json::{from_str, to_string};
use serde_json::from_str;
use sycamore::prelude::*; use sycamore::prelude::*;
use tracing::{debug, error, instrument}; use tracing::{debug, error, instrument};
use anyhow::Result;
use client_api::*; use client_api::*;
use recipes::{IngredientKey, RecipeEntry}; use recipes::{IngredientKey, RecipeEntry};
use serde_wasm_bindgen::{from_value, Serializer};
use wasm_bindgen::JsValue; use wasm_bindgen::JsValue;
// TODO(jwall): Remove this when we have gone a few migrations past.
use web_sys::Storage; use web_sys::Storage;
fn to_js<T: serde::ser::Serialize>(value: T) -> Result<JsValue, serde_wasm_bindgen::Error> {
let s = Serializer::new().serialize_maps_as_objects(true);
value.serialize(&s)
}
use crate::{ use crate::{
app_state::{parse_recipes, AppState}, app_state::{parse_recipes, AppState},
js_lib::{self, DBFactory}, js_lib,
}; };
#[allow(dead_code)] #[allow(dead_code)]
@ -85,110 +76,48 @@ impl From<gloo_net::Error> for Error {
} }
} }
fn recipe_key<S: std::fmt::Display>(id: S) -> String {
format!("recipe:{}", id)
}
fn token68(user: String, pass: String) -> String { fn token68(user: String, pass: String) -> String {
base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", user, pass)) base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", user, pass))
} }
fn convert_to_io_error<V, E>(res: Result<V, E>) -> Result<V, std::io::Error>
where
E: Into<Box<dyn std::error::Error>> + std::fmt::Debug,
{
match res {
Ok(v) => Ok(v),
Err(e) => Err(std::io::Error::new(
std::io::ErrorKind::Other,
format!("{:?}", e),
)),
}
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct LocalStore { pub struct LocalStore {
// TODO(zaphar): Remove this when it's safe to delete the migration store: Storage,
old_store: Storage,
store: DBFactory<'static>,
} }
const APP_STATE_KEY: &'static str = "app-state";
const USER_DATA_KEY: &'static str = "user_data";
impl LocalStore { impl LocalStore {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
store: DBFactory::default(), store: js_lib::get_storage(),
old_store: js_lib::get_storage(),
} }
} }
pub async fn migrate(&self) { pub fn store_app_state(&self, state: &AppState) {
// 1. migrate app-state from localstore to indexeddb self.migrate_local_store();
debug!("Peforming localstorage migration"); let state = match to_string(state) {
if let Ok(Some(v)) = self.old_store.get("app_state") {
if let Ok(Some(local_state)) = from_str::<Option<AppState>>(&v) {
self.store_app_state(&local_state).await;
}
}
let _ = self.old_store.remove_item("app_state");
// 2. migrate user-state from localstore to indexeddb
if let Ok(Some(v)) = self.old_store.get(USER_DATA_KEY) {
if let Ok(local_user_data) = from_str::<Option<UserData>>(&v) {
self.set_user_data(local_user_data.as_ref()).await;
}
}
let _ = self.old_store.remove_item(USER_DATA_KEY);
// 3. Recipes
let store_len = self.old_store.length().unwrap();
let mut key_list = Vec::new();
for i in 0..store_len {
let key = self.old_store.key(i).unwrap().unwrap();
if key.starts_with("recipe:") {
key_list.push(key);
}
}
for k in key_list {
if let Ok(Some(recipe)) = self.old_store.get(&k) {
if let Ok(recipe) = from_str::<RecipeEntry>(&recipe) {
self.set_recipe_entry(&recipe).await;
}
}
let _ = self.old_store.delete(&k);
}
}
#[instrument(skip_all)]
pub async fn store_app_state(&self, state: &AppState) {
let state = match to_js(state) {
Ok(state) => state, Ok(state) => state,
Err(err) => { Err(err) => {
error!(?err, ?state, "Error deserializing app_state"); error!(?err, ?state, "Error deserializing app_state");
return; return;
} }
}; };
web_sys::console::log_1(&state);
let key = to_js(APP_STATE_KEY).expect("Failed to serialize key");
self.store self.store
.rw_transaction(&[js_lib::STATE_STORE_NAME], |trx| async move { .set("app_state", &state)
let object_store = trx.object_store(js_lib::STATE_STORE_NAME)?; .expect("Failed to set our app state");
object_store.put_kv(&key, &state).await?;
Ok(())
})
.await
.expect("Failed to store app-state");
} }
#[instrument] pub fn fetch_app_state(&self) -> Option<AppState> {
pub async fn fetch_app_state(&self) -> Option<AppState> {
debug!("Loading state from local store"); debug!("Loading state from local store");
let recipes = parse_recipes(&self.get_recipes().await).expect("Failed to parse recipes"); self.store.get("app_state").map_or(None, |val| {
self.store val.map(|s| {
.ro_transaction(&[js_lib::STATE_STORE_NAME], |trx| async move { debug!("Found an app_state object");
let key = convert_to_io_error(to_js(APP_STATE_KEY))?; let mut app_state: AppState =
let object_store = trx.object_store(js_lib::STATE_STORE_NAME)?; from_str(&s).expect("Failed to deserialize app state");
let mut app_state: AppState = match object_store.get(&key).await? { let recipes = parse_recipes(&self.get_recipes()).expect("Failed to parse recipes");
Some(s) => convert_to_io_error(from_value(s))?,
None => return Ok(None),
};
if let Some(recipes) = recipes { if let Some(recipes) = recipes {
debug!("Populating recipes"); debug!("Populating recipes");
for (id, recipe) in recipes { for (id, recipe) in recipes {
@ -196,173 +125,122 @@ impl LocalStore {
app_state.recipes.insert(id, recipe); app_state.recipes.insert(id, recipe);
} }
} }
Ok(Some(app_state)) app_state
})
}) })
.await
.expect("Failed to fetch app-state")
} }
#[instrument]
/// Gets user data from local storage. /// Gets user data from local storage.
pub async fn get_user_data(&self) -> Option<UserData> { pub fn get_user_data(&self) -> Option<UserData> {
self.store self.store
.ro_transaction(&[js_lib::STATE_STORE_NAME], |trx| async move { .get("user_data")
let key = to_js(USER_DATA_KEY).expect("Failed to serialize key"); .map_or(None, |val| val.map(|val| from_str(&val).unwrap_or(None)))
let object_store = trx.object_store(js_lib::STATE_STORE_NAME)?; .flatten()
let user_data: UserData = match object_store.get(&key).await? {
Some(s) => convert_to_io_error(from_value(s))?,
None => return Ok(None),
};
Ok(Some(user_data))
})
.await
.expect("Failed to fetch user_data")
} }
#[instrument]
// Set's user data to local storage. // Set's user data to local storage.
pub async fn set_user_data(&self, data: Option<&UserData>) { pub fn set_user_data(&self, data: Option<&UserData>) {
let key = to_js(USER_DATA_KEY).expect("Failed to serialize key");
if let Some(data) = data { if let Some(data) = data {
let data = data.clone();
self.store self.store
.rw_transaction(&[js_lib::STATE_STORE_NAME], |trx| async move { .set(
let object_store = trx.object_store(js_lib::STATE_STORE_NAME)?; "user_data",
object_store &to_string(data).expect("Failed to desrialize user_data"),
.put_kv(&key, &convert_to_io_error(to_js(&data))?) )
.await?;
Ok(())
})
.await
.expect("Failed to set user_data"); .expect("Failed to set user_data");
} else { } else {
self.store self.store
.rw_transaction(&[js_lib::STATE_STORE_NAME], |trx| async move { .delete("user_data")
let object_store = trx.object_store(js_lib::STATE_STORE_NAME)?;
object_store.delete(&key).await?;
Ok(())
})
.await
.expect("Failed to delete user_data"); .expect("Failed to delete user_data");
} }
} }
#[instrument] fn get_storage_keys(&self) -> Vec<String> {
async fn get_recipe_keys(&self) -> impl Iterator<Item = String> {
self.store
.ro_transaction(&[js_lib::RECIPE_STORE_NAME], |trx| async move {
let mut keys = Vec::new(); let mut keys = Vec::new();
let object_store = trx.object_store(js_lib::RECIPE_STORE_NAME)?; for idx in 0..self.store.length().unwrap() {
let key_vec = object_store.get_all_keys(None).await?; if let Some(k) = self.store.key(idx).expect("Failed to get storage key") {
for k in key_vec { keys.push(k)
if let Ok(v) = from_value(k) {
keys.push(v);
} }
} }
Ok(keys) keys
}) }
.await
.expect("Failed to get storage keys") fn migrate_local_store(&self) {
.into_iter() for k in self.get_storage_keys().into_iter().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<Item = String> {
self.get_storage_keys()
.into_iter()
.filter(|k| k.starts_with("recipe:"))
} }
#[instrument]
/// Gets all the recipes from local storage. /// Gets all the recipes from local storage.
pub async fn get_recipes(&self) -> Option<Vec<RecipeEntry>> { pub fn get_recipes(&self) -> Option<Vec<RecipeEntry>> {
self.store
.ro_transaction(&[js_lib::RECIPE_STORE_NAME], |trx| async move {
let mut recipe_list = Vec::new(); let mut recipe_list = Vec::new();
let object_store = trx.object_store(js_lib::RECIPE_STORE_NAME)?; for recipe_key in self.get_recipe_keys() {
let mut c = object_store.cursor().open().await?; if let Some(entry) = self
while let Some(value) = c.value() { .store
recipe_list.push(convert_to_io_error(from_value(value))?); .get(&recipe_key)
c.advance(1).await?; .expect(&format!("Failed to get recipe: {}", recipe_key))
{
match from_str(&entry) {
Ok(entry) => {
recipe_list.push(entry);
}
Err(e) => {
error!(recipe_key, err = ?e, "Failed to parse recipe entry");
}
}
}
} }
if recipe_list.is_empty() { if recipe_list.is_empty() {
return Ok(None); return None;
} }
Ok(Some(recipe_list)) Some(recipe_list)
})
.await
.expect("Failed to get recipes")
} }
#[instrument] pub fn get_recipe_entry(&self, id: &str) -> Option<RecipeEntry> {
pub async fn get_recipe_entry(&self, id: &str) -> Option<RecipeEntry> { let key = recipe_key(id);
let key = to_js(id).expect("Failed to serialize key");
self.store self.store
.ro_transaction(&[js_lib::RECIPE_STORE_NAME], |trx| async move { .get(&key)
let object_store = trx.object_store(js_lib::RECIPE_STORE_NAME)?; .expect(&format!("Failed to get recipe {}", key))
let entry: Option<RecipeEntry> = match object_store.get(&key).await? { .map(|entry| from_str(&entry).expect(&format!("Failed to get recipe {}", key)))
Some(v) => convert_to_io_error(from_value(v))?,
None => None,
};
Ok(entry)
})
.await
.expect("Failed to get recipes")
} }
#[instrument]
/// Sets the set of recipes to the entries passed in. Deletes any recipes not /// Sets the set of recipes to the entries passed in. Deletes any recipes not
/// in the list. /// in the list.
pub async fn set_all_recipes(&self, entries: &Vec<RecipeEntry>) { pub fn set_all_recipes(&self, entries: &Vec<RecipeEntry>) {
for recipe_key in self.get_recipe_keys().await { for recipe_key in self.get_recipe_keys() {
let key = to_js(&recipe_key).expect("Failed to serialize key");
self.store self.store
.rw_transaction(&[js_lib::STATE_STORE_NAME], |trx| async move { .delete(&recipe_key)
let object_store = trx.object_store(js_lib::STATE_STORE_NAME)?; .expect(&format!("Failed to get recipe {}", recipe_key));
object_store.delete(&key).await?;
Ok(())
})
.await
.expect("Failed to delete user_data");
} }
for entry in entries { for entry in entries {
let entry = entry.clone(); self.set_recipe_entry(entry);
let key = to_js(entry.recipe_id()).expect("Failed to serialize recipe key");
self.store
.rw_transaction(&[js_lib::RECIPE_STORE_NAME], |trx| async move {
let object_store = trx.object_store(js_lib::RECIPE_STORE_NAME)?;
object_store
.put_kv(&key, &convert_to_io_error(to_js(&entry))?)
.await?;
Ok(())
})
.await
.expect("Failed to store recipe entry");
} }
} }
#[instrument]
/// Set recipe entry in local storage. /// Set recipe entry in local storage.
pub async fn set_recipe_entry(&self, entry: &RecipeEntry) { pub fn set_recipe_entry(&self, entry: &RecipeEntry) {
let entry = entry.clone();
let key = to_js(entry.recipe_id()).expect("Failed to serialize recipe key");
self.store self.store
.rw_transaction(&[js_lib::RECIPE_STORE_NAME], |trx| async move { .set(
let object_store = trx.object_store(js_lib::RECIPE_STORE_NAME)?; &recipe_key(entry.recipe_id()),
object_store &to_string(&entry).expect(&format!("Failed to get recipe {}", entry.recipe_id())),
.put_kv(&key, &convert_to_io_error(to_js(&entry))?) )
.await?; .expect(&format!("Failed to store recipe {}", entry.recipe_id()))
Ok(())
})
.await
.expect("Failed to store recipe entry");
} }
#[instrument]
/// Delete recipe entry from local storage. /// Delete recipe entry from local storage.
pub async fn delete_recipe_entry(&self, recipe_id: &str) { pub fn delete_recipe_entry(&self, recipe_id: &str) {
let key = to_js(recipe_id).expect("Failed to serialize key");
self.store self.store
.rw_transaction(&[js_lib::RECIPE_STORE_NAME], |trx| async move { .delete(&recipe_key(recipe_id))
let object_store = trx.object_store(js_lib::RECIPE_STORE_NAME)?; .expect(&format!("Failed to delete recipe {}", recipe_id))
object_store.delete(&key).await?;
Ok(())
})
.await
.expect("Failed to delete user_data");
} }
} }
@ -487,7 +365,7 @@ impl HttpStore {
Ok(resp) => resp, Ok(resp) => resp,
Err(gloo_net::Error::JsError(err)) => { Err(gloo_net::Error::JsError(err)) => {
error!(path, ?err, "Error hitting api"); error!(path, ?err, "Error hitting api");
return Ok(self.local_store.get_recipes().await); return Ok(self.local_store.get_recipes());
} }
Err(err) => { Err(err) => {
return Err(err)?; return Err(err)?;
@ -517,7 +395,7 @@ impl HttpStore {
Ok(resp) => resp, Ok(resp) => resp,
Err(gloo_net::Error::JsError(err)) => { Err(gloo_net::Error::JsError(err)) => {
error!(path, ?err, "Error hitting api"); error!(path, ?err, "Error hitting api");
return Ok(self.local_store.get_recipe_entry(id.as_ref()).await); return Ok(self.local_store.get_recipe_entry(id.as_ref()));
} }
Err(err) => { Err(err) => {
return Err(err)?; return Err(err)?;
@ -537,7 +415,7 @@ impl HttpStore {
.as_success() .as_success()
.unwrap(); .unwrap();
if let Some(ref entry) = entry { if let Some(ref entry) = entry {
self.local_store.set_recipe_entry(entry).await; self.local_store.set_recipe_entry(entry);
} }
Ok(entry) Ok(entry)
} }

View File

@ -37,14 +37,12 @@ fn bool_true() -> bool {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AppState { pub struct AppState {
pub recipe_counts: BTreeMap<String, u32>, pub recipe_counts: BTreeMap<String, usize>,
pub recipe_categories: BTreeMap<String, String>, pub recipe_categories: BTreeMap<String, String>,
pub extras: Vec<(String, String)>, pub extras: Vec<(String, String)>,
// FIXME(jwall): This should really be storable I think? #[serde(skip)] // FIXME(jwall): This should really be storable I think?
#[serde(skip_deserializing,skip_serializing)]
pub staples: Option<BTreeSet<Ingredient>>, pub staples: Option<BTreeSet<Ingredient>>,
// FIXME(jwall): This should really be storable I think? #[serde(skip)] // FIXME(jwall): This should really be storable I think?
#[serde(skip_deserializing,skip_serializing)]
pub recipes: BTreeMap<String, Recipe>, pub recipes: BTreeMap<String, Recipe>,
pub category_map: BTreeMap<String, String>, pub category_map: BTreeMap<String, String>,
pub filtered_ingredients: BTreeSet<IngredientKey>, pub filtered_ingredients: BTreeSet<IngredientKey>,
@ -77,7 +75,7 @@ impl AppState {
pub enum Message { pub enum Message {
ResetRecipeCounts, ResetRecipeCounts,
UpdateRecipeCount(String, u32), UpdateRecipeCount(String, usize),
AddExtra(String, String), AddExtra(String, String),
RemoveExtra(usize), RemoveExtra(usize),
UpdateExtra(usize, String, String), UpdateExtra(usize, String, String),
@ -171,7 +169,6 @@ impl StateMachine {
Self { store, local_store } Self { store, local_store }
} }
#[instrument(skip_all)]
async fn load_state( async fn load_state(
store: &HttpStore, store: &HttpStore,
local_store: &LocalStore, local_store: &LocalStore,
@ -181,7 +178,7 @@ impl StateMachine {
// call set on the signal once. When the LinearSignal get's dropped it // call set on the signal once. When the LinearSignal get's dropped it
// will call set on the contained Signal. // will call set on the contained Signal.
let mut original: LinearSignal<AppState> = original.into(); let mut original: LinearSignal<AppState> = original.into();
if let Some(state) = local_store.fetch_app_state().await { if let Some(state) = local_store.fetch_app_state() {
original = original.update(state); original = original.update(state);
} }
let mut state = original.get().as_ref().clone(); let mut state = original.get().as_ref().clone();
@ -204,7 +201,7 @@ impl StateMachine {
info!("Synchronizing recipe"); info!("Synchronizing recipe");
if let Some(recipe_entries) = recipe_entries { if let Some(recipe_entries) = recipe_entries {
local_store.set_all_recipes(recipe_entries).await; local_store.set_all_recipes(recipe_entries);
state.recipe_categories = recipe_entries state.recipe_categories = recipe_entries
.iter() .iter()
.map(|entry| { .map(|entry| {
@ -239,7 +236,7 @@ impl StateMachine {
// set the counts. // set the counts.
let mut plan_map = BTreeMap::new(); let mut plan_map = BTreeMap::new();
for (id, count) in plan { for (id, count) in plan {
plan_map.insert(id, count as u32); plan_map.insert(id, count as usize);
} }
state.recipe_counts = plan_map; state.recipe_counts = plan_map;
for (id, _) in state.recipes.iter() { for (id, _) in state.recipes.iter() {
@ -258,11 +255,11 @@ impl StateMachine {
info!("Checking for user account data"); info!("Checking for user account data");
if let Some(user_data) = store.fetch_user_data().await { if let Some(user_data) = store.fetch_user_data().await {
debug!("Successfully got account data from server"); debug!("Successfully got account data from server");
local_store.set_user_data(Some(&user_data)).await; local_store.set_user_data(Some(&user_data));
state.auth = Some(user_data); state.auth = Some(user_data);
} else { } else {
debug!("Using account data from local store"); debug!("Using account data from local store");
let user_data = local_store.get_user_data().await; let user_data = local_store.get_user_data();
state.auth = user_data; state.auth = user_data;
} }
info!("Synchronizing categories"); info!("Synchronizing categories");
@ -296,7 +293,7 @@ impl StateMachine {
} }
} }
// Finally we store all of this app state back to our localstore // Finally we store all of this app state back to our localstore
local_store.store_app_state(&state).await; local_store.store_app_state(&state);
original.update(state); original.update(state);
Ok(()) Ok(())
} }
@ -352,9 +349,8 @@ impl MessageMapper<Message, AppState> for StateMachine {
.or_insert(cat); .or_insert(cat);
} }
let store = self.store.clone(); let store = self.store.clone();
let local_store = self.local_store.clone(); self.local_store.set_recipe_entry(&entry);
spawn_local_scoped(cx, async move { spawn_local_scoped(cx, async move {
local_store.set_recipe_entry(&entry).await;
if let Err(e) = store.store_recipes(vec![entry]).await { if let Err(e) = store.store_recipes(vec![entry]).await {
// FIXME(jwall): We should have a global way to trigger error messages // FIXME(jwall): We should have a global way to trigger error messages
error!(err=?e, "Unable to save Recipe"); error!(err=?e, "Unable to save Recipe");
@ -367,10 +363,9 @@ impl MessageMapper<Message, AppState> for StateMachine {
Message::RemoveRecipe(recipe, callback) => { Message::RemoveRecipe(recipe, callback) => {
original_copy.recipe_counts.remove(&recipe); original_copy.recipe_counts.remove(&recipe);
original_copy.recipes.remove(&recipe); original_copy.recipes.remove(&recipe);
self.local_store.delete_recipe_entry(&recipe);
let store = self.store.clone(); let store = self.store.clone();
let local_store = self.local_store.clone();
spawn_local_scoped(cx, async move { spawn_local_scoped(cx, async move {
local_store.delete_recipe_entry(&recipe).await;
if let Err(err) = store.delete_recipe(&recipe).await { if let Err(err) = store.delete_recipe(&recipe).await {
error!(?err, "Failed to delete recipe"); error!(?err, "Failed to delete recipe");
} }
@ -401,11 +396,8 @@ impl MessageMapper<Message, AppState> for StateMachine {
original_copy.modified_amts.insert(key, amt); original_copy.modified_amts.insert(key, amt);
} }
Message::SetUserData(user_data) => { Message::SetUserData(user_data) => {
let local_store = self.local_store.clone(); self.local_store.set_user_data(Some(&user_data));
original_copy.auth = Some(user_data.clone()); original_copy.auth = Some(user_data);
spawn_local_scoped(cx, async move {
local_store.set_user_data(Some(&user_data)).await;
});
} }
Message::SaveState(f) => { Message::SaveState(f) => {
let mut original_copy = original_copy.clone(); let mut original_copy = original_copy.clone();
@ -425,7 +417,7 @@ impl MessageMapper<Message, AppState> for StateMachine {
if let Err(e) = store.store_app_state(&original_copy).await { if let Err(e) = store.store_app_state(&original_copy).await {
error!(err=?e, "Error saving app state"); error!(err=?e, "Error saving app state");
}; };
local_store.store_app_state(&original_copy).await; local_store.store_app_state(&original_copy);
original.set(original_copy); original.set(original_copy);
f.map(|f| f()); f.map(|f| f());
}); });
@ -471,7 +463,7 @@ impl MessageMapper<Message, AppState> for StateMachine {
// Note(jwall): This is a little unusual but because this // Note(jwall): This is a little unusual but because this
// is async code we can't rely on the set below. // is async code we can't rely on the set below.
original_copy.recipe_counts = original_copy.recipe_counts =
BTreeMap::from_iter(plan.drain(0..).map(|(k, v)| (k, v as u32))); BTreeMap::from_iter(plan.drain(0..).map(|(k, v)| (k, v as usize)));
} }
let (filtered, modified, extras) = store let (filtered, modified, extras) = store
.fetch_inventory_for_date(&date) .fetch_inventory_for_date(&date)
@ -486,7 +478,7 @@ impl MessageMapper<Message, AppState> for StateMachine {
.store_plan_for_date(vec![], &date) .store_plan_for_date(vec![], &date)
.await .await
.expect("Failed to init meal plan for date"); .expect("Failed to init meal plan for date");
local_store.store_app_state(&original_copy).await; local_store.store_app_state(&original_copy);
original.set(original_copy); original.set(original_copy);
callback.map(|f| f()); callback.map(|f| f());
@ -509,7 +501,7 @@ impl MessageMapper<Message, AppState> for StateMachine {
original_copy.filtered_ingredients = BTreeSet::new(); original_copy.filtered_ingredients = BTreeSet::new();
original_copy.modified_amts = BTreeMap::new(); original_copy.modified_amts = BTreeMap::new();
original_copy.extras = Vec::new(); original_copy.extras = Vec::new();
local_store.store_app_state(&original_copy).await; local_store.store_app_state(&original_copy);
original.set(original_copy); original.set(original_copy);
callback.map(|f| f()); callback.map(|f| f());
@ -521,14 +513,9 @@ impl MessageMapper<Message, AppState> for StateMachine {
return; return;
} }
} }
spawn_local_scoped(cx, { self.local_store.store_app_state(&original_copy);
let local_store = self.local_store.clone();
async move {
local_store.store_app_state(&original_copy).await;
original.set(original_copy); original.set(original_copy);
} }
});
}
} }
pub type StateHandler<'ctx> = &'ctx Handler<'ctx, StateMachine, AppState, Message>; pub type StateHandler<'ctx> = &'ctx Handler<'ctx, StateMachine, AppState, Message>;

View File

@ -42,19 +42,19 @@ pub fn AddRecipe<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> View
} else { } else {
Some(category) Some(category)
}; };
RecipeEntry { RecipeEntry(
id: recipe_title recipe_title
.get() .get()
.as_ref() .as_ref()
.to_lowercase() .to_lowercase()
.replace(" ", "_") .replace(" ", "_")
.replace("\n", ""), .replace("\n", ""),
text: STARTER_RECIPE STARTER_RECIPE
.replace("TITLE_PLACEHOLDER", recipe_title.get().as_str()) .replace("TITLE_PLACEHOLDER", recipe_title.get().as_str())
.replace("\r", ""), .replace("\r", ""),
category, category,
serving_count: None, None,
} )
}); });
view! {cx, view! {cx,

View File

@ -115,12 +115,12 @@ pub fn Editor<'ctx, G: Html>(cx: Scope<'ctx>, props: RecipeComponentProps<'ctx>)
} else { } else {
Some(category.as_ref().clone()) Some(category.as_ref().clone())
}; };
let recipe_entry = RecipeEntry { let recipe_entry = RecipeEntry(
id: id.get_untracked().as_ref().clone(), id.get_untracked().as_ref().clone(),
text: text.get_untracked().as_ref().clone(), text.get_untracked().as_ref().clone(),
category, category,
serving_count: None, None,
}; );
sh.dispatch(cx, Message::SaveRecipe(recipe_entry, None)); sh.dispatch(cx, Message::SaveRecipe(recipe_entry, None));
dirty.set(false); dirty.set(false);
} }

View File

@ -68,7 +68,7 @@ pub fn RecipeSelection<'ctx, G: Html>(
label(for=for_id, class="flex-item-grow") { a(href=href) { (*title) } } label(for=for_id, class="flex-item-grow") { a(href=href) { (*title) } }
NumberField(name=name, class="flex-item-shrink".to_string(), counter=count, min=0.0, on_change=Some(move |_| { NumberField(name=name, class="flex-item-shrink".to_string(), counter=count, min=0.0, on_change=Some(move |_| {
debug!(idx=%id, count=%(*count.get_untracked()), "setting recipe count"); debug!(idx=%id, count=%(*count.get_untracked()), "setting recipe count");
sh.dispatch(cx, Message::UpdateRecipeCount(id.as_ref().clone(), *count.get_untracked() as u32)); sh.dispatch(cx, Message::UpdateRecipeCount(id.as_ref().clone(), *count.get_untracked() as usize));
})) }))
} }
} }

View File

@ -11,123 +11,17 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use anyhow::{Context, Result};
use indexed_db::{self, Database, Factory, Transaction};
use js_sys::Date; use js_sys::Date;
use std::collections::HashSet;
use std::future::Future;
use tracing::error; use tracing::error;
use web_sys::{window, Window}; use web_sys::{window, Storage, Window};
pub fn get_storage() -> web_sys::Storage { pub fn get_storage() -> Storage {
get_window() get_window()
.local_storage() .local_storage()
.expect("Failed to get storage") .expect("Failed to get storage")
.expect("No storage available") .expect("No storage available")
} }
pub const STATE_STORE_NAME: &'static str = "state-store";
pub const RECIPE_STORE_NAME: &'static str = "recipe-store";
pub const SERVING_COUNT_IDX: &'static str = "recipe-serving-count";
pub const CATEGORY_IDX: &'static str = "recipe-category";
pub const DB_VERSION: u32 = 1;
#[derive(Clone, Debug)]
pub struct DBFactory<'name> {
name: &'name str,
version: Option<u32>,
}
impl Default for DBFactory<'static> {
fn default() -> Self {
DBFactory {
name: STATE_STORE_NAME,
version: Some(DB_VERSION),
}
}
}
async fn version1_setup<'db>(
stores: &HashSet<String>,
db: &'db Database<std::io::Error>,
) -> Result<(), indexed_db::Error<std::io::Error>> {
// We use out of line keys for this object store
if !stores.contains(STATE_STORE_NAME) {
db.build_object_store(STATE_STORE_NAME).create()?;
}
if !stores.contains(RECIPE_STORE_NAME) {
let recipe_store = db.build_object_store(RECIPE_STORE_NAME).create()?;
recipe_store
.build_index(CATEGORY_IDX, "category")
.create()?;
recipe_store
.build_index(SERVING_COUNT_IDX, "serving_count")
.create()?;
}
Ok(())
}
impl<'name> DBFactory<'name> {
pub async fn get_indexed_db(&self) -> Result<Database<std::io::Error>> {
let factory = Factory::<std::io::Error>::get().context("opening IndexedDB")?;
let db = factory
.open(self.name, self.version.unwrap_or(0), |evt| async move {
// NOTE(zaphar): This is the on upgradeneeded handler. It get's called on new databases or
// databases with an older version than the one we requested to build.
let db = evt.database();
let stores = db
.object_store_names()
.into_iter()
.collect::<HashSet<String>>();
// NOTE(jwall): This needs to be somewhat clever in handling version upgrades.
if db.version() > 0 {
version1_setup(&stores, db).await?;
}
Ok(())
})
.await
.context(format!("Opening or creating the database {}", self.name))?;
Ok(db)
}
pub async fn rw_transaction<Fun, RetFut, Ret>(
&self,
stores: &[&str],
transaction: Fun,
) -> indexed_db::Result<Ret, std::io::Error>
where
Fun: 'static + FnOnce(Transaction<std::io::Error>) -> RetFut,
RetFut: 'static + Future<Output = indexed_db::Result<Ret, std::io::Error>>,
Ret: 'static,
{
self.get_indexed_db()
.await
.expect("Failed to open database")
.transaction(stores)
.rw()
.run(transaction)
.await
}
pub async fn ro_transaction<Fun, RetFut, Ret>(
&self,
stores: &[&str],
transaction: Fun,
) -> indexed_db::Result<Ret, std::io::Error>
where
Fun: 'static + FnOnce(Transaction<std::io::Error>) -> RetFut,
RetFut: 'static + Future<Output = indexed_db::Result<Ret, std::io::Error>>,
Ret: 'static,
{
self.get_indexed_db()
.await
.expect("Failed to open database")
.transaction(stores)
.run(transaction)
.await
}
}
pub fn get_ms_timestamp() -> u32 { pub fn get_ms_timestamp() -> u32 {
Date::new_0().get_milliseconds() Date::new_0().get_milliseconds()
} }

View File

@ -20,22 +20,20 @@ use crate::{api, routing::Handler as RouteHandler};
#[instrument] #[instrument]
#[component] #[component]
pub fn UI<G: Html>(cx: Scope) -> View<G> { pub fn UI<G: Html>(cx: Scope) -> View<G> {
let view = create_signal(cx, View::empty());
api::HttpStore::provide_context(cx, "/api".to_owned()); api::HttpStore::provide_context(cx, "/api".to_owned());
let store = api::HttpStore::get_from_context(cx).as_ref().clone(); let store = api::HttpStore::get_from_context(cx).as_ref().clone();
info!("Starting UI"); info!("Starting UI");
spawn_local_scoped(cx, {
async move {
let local_store = api::LocalStore::new(); let local_store = api::LocalStore::new();
// TODO(jwall): At some point we can drop this potentially? let app_state = if let Some(app_state) = local_store.fetch_app_state() {
local_store.migrate().await;
let app_state = if let Some(app_state) = local_store.fetch_app_state().await {
app_state app_state
} else { } else {
crate::app_state::AppState::new() crate::app_state::AppState::new()
}; };
debug!(?app_state, "Loaded app state from local storage"); debug!(?app_state, "Loaded app state from local storage");
let sh = crate::app_state::get_state_handler(cx, app_state, store); let sh = crate::app_state::get_state_handler(cx, app_state, store);
let view = create_signal(cx, View::empty());
spawn_local_scoped(cx, {
async move {
sh.dispatch(cx, Message::LoadState(None)); sh.dispatch(cx, Message::LoadState(None));
view.set(view! { cx, view.set(view! { cx,
RouteHandler(sh=sh) RouteHandler(sh=sh)