diff --git a/kitchen/migrations/20230106195850_staples.down.sql b/kitchen/migrations/20230106195850_staples.down.sql new file mode 100644 index 0000000..b3615e1 --- /dev/null +++ b/kitchen/migrations/20230106195850_staples.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +drop table staples; \ No newline at end of file diff --git a/kitchen/migrations/20230106195850_staples.up.sql b/kitchen/migrations/20230106195850_staples.up.sql new file mode 100644 index 0000000..c38c602 --- /dev/null +++ b/kitchen/migrations/20230106195850_staples.up.sql @@ -0,0 +1,6 @@ +-- Add up migration script here +create table staples ( + user_id TEXT NOT NULL, + content TEXT NOT NULL, + primary key(user_id) +); \ No newline at end of file diff --git a/kitchen/sqlx-data.json b/kitchen/sqlx-data.json index 4cc79fd..f0e9513 100644 --- a/kitchen/sqlx-data.json +++ b/kitchen/sqlx-data.json @@ -112,6 +112,16 @@ }, "query": "select plan_date as \"plan_date: NaiveDate\", recipe_id, count\nfrom plan_recipes\nwhere\n user_id = ?\n and date(plan_date) > ?\norder by user_id, plan_date" }, + "1b4a7250e451991ee7e642c6389656814e0dd00c94e59383c02af6313bc76213": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Right": 2 + } + }, + "query": "insert into staples (user_id, content) values (?, ?)\n on conflict(user_id) do update set content = excluded.content" + }, "2582522f8ca9f12eccc70a3b339d9030aee0f52e62d6674cfd3862de2a68a177": { "describe": { "columns": [], @@ -212,6 +222,24 @@ }, "query": "insert into users (id, password_hashed) values (?, ?)" }, + "64af3f713eb4c61ac02cab2dfea83d0ed197e602e99079d4d32cb38d677edf2e": { + "describe": { + "columns": [ + { + "name": "content", + "ordinal": 0, + "type_info": "Text" + } + ], + "nullable": [ + false + ], + "parameters": { + "Right": 1 + } + }, + "query": "select content from staples where user_id = ?" + }, "6e28698330e42fd6c87ba1e6f1deb664c0d3995caa2b937ceac8c908e98aded6": { "describe": { "columns": [], diff --git a/kitchen/src/web/mod.rs b/kitchen/src/web/mod.rs index 1ea349f..60df730 100644 --- a/kitchen/src/web/mod.rs +++ b/kitchen/src/web/mod.rs @@ -28,7 +28,7 @@ use recipes::{IngredientKey, RecipeEntry}; use rust_embed::RustEmbed; use tower::ServiceBuilder; use tower_http::trace::TraceLayer; -use tracing::{debug, info, instrument}; +use tracing::{debug, error, info, instrument}; use client_api as api; use storage::{APIStore, AuthStore}; @@ -383,6 +383,49 @@ async fn api_user_account(session: storage::UserIdFromSession) -> api::AccountRe } } +async fn api_staples( + Extension(app_store): Extension>, + session: storage::UserIdFromSession, +) -> api::Response> { + use storage::{UserId, UserIdFromSession::FoundUserId}; + if let FoundUserId(UserId(user_id)) = session { + match app_store.fetch_staples(user_id).await { + Ok(staples) => api::Response::success(staples), + Err(err) => { + error!(?err); + api::Response::error( + StatusCode::INTERNAL_SERVER_ERROR.as_u16(), + format!("{:?}", err), + ) + } + } + } else { + api::Response::Unauthorized + } +} + +async fn api_save_staples( + Extension(app_store): Extension>, + session: storage::UserIdFromSession, + Json(content): Json, +) -> api::Response<()> { + use storage::{UserId, UserIdFromSession::FoundUserId}; + if let FoundUserId(UserId(user_id)) = session { + match app_store.save_staples(user_id, content).await { + Ok(_) => api::EmptyResponse::success(()), + Err(err) => { + error!(?err); + api::EmptyResponse::error( + StatusCode::INTERNAL_SERVER_ERROR.as_u16(), + format!("{:?}", err), + ) + } + } + } else { + api::EmptyResponse::Unauthorized + } +} + fn mk_v1_routes() -> Router { Router::new() .route("/recipes", get(api_recipes).post(api_save_recipes)) @@ -416,6 +459,7 @@ fn mk_v2_routes() -> Router { "/category_map", get(api_category_mappings).post(api_save_category_mappings), ) + .route("/staples", get(api_staples).post(api_save_staples)) // All the routes above require a UserId. .route("/auth", get(auth::handler).post(auth::handler)) .route("/account", get(api_user_account)) diff --git a/kitchen/src/web/storage/fetch_staples.sql b/kitchen/src/web/storage/fetch_staples.sql new file mode 100644 index 0000000..576cddb --- /dev/null +++ b/kitchen/src/web/storage/fetch_staples.sql @@ -0,0 +1 @@ +select content from staples where user_id = ? \ No newline at end of file diff --git a/kitchen/src/web/storage/mod.rs b/kitchen/src/web/storage/mod.rs index b1d0bce..331dc14 100644 --- a/kitchen/src/web/storage/mod.rs +++ b/kitchen/src/web/storage/mod.rs @@ -148,6 +148,10 @@ pub trait APIStore { modified_amts: BTreeMap, extra_items: Vec<(String, String)>, ) -> Result<()>; + + async fn fetch_staples + Send>(&self, user_id: S) -> Result>; + + async fn save_staples + Send>(&self, user_id: S, content: S) -> Result<()>; } #[async_trait] @@ -694,4 +698,24 @@ impl APIStore for SqliteStore { transaction.commit().await?; Ok(()) } + + async fn save_staples + Send>(&self, user_id: S, content: S) -> Result<()> { + let (user_id, content) = (user_id.as_ref(), content.as_ref()); + sqlx::query_file!("src/web/storage/save_staples.sql", user_id, content) + .execute(self.pool.as_ref()) + .await?; + Ok(()) + } + + async fn fetch_staples + Send>(&self, user_id: S) -> Result> { + let user_id = user_id.as_ref(); + if let Some(content) = + sqlx::query_file_scalar!("src/web/storage/fetch_staples.sql", user_id) + .fetch_optional(self.pool.as_ref()) + .await? + { + return Ok(Some(content)); + } + Ok(None) + } } diff --git a/kitchen/src/web/storage/save_staples.sql b/kitchen/src/web/storage/save_staples.sql new file mode 100644 index 0000000..69f32eb --- /dev/null +++ b/kitchen/src/web/storage/save_staples.sql @@ -0,0 +1,2 @@ +insert into staples (user_id, content) values (?, ?) + on conflict(user_id) do update set content = excluded.content \ No newline at end of file diff --git a/recipes/src/lib.rs b/recipes/src/lib.rs index 4d5cdcf..d674476 100644 --- a/recipes/src/lib.rs +++ b/recipes/src/lib.rs @@ -135,12 +135,17 @@ impl IngredientAccumulator { } } - pub fn accumulate_from(&mut self, r: &Recipe) { - for i in r.steps.iter().map(|s| s.ingredients.iter()).flatten() { + pub fn accumulate_ingredients_for<'a, Iter, S>(&'a mut self, recipe_title: S, ingredients: Iter) + where + Iter: Iterator, + S: Into, + { + let recipe_title = recipe_title.into(); + for i in ingredients { let key = i.key(); if !self.inner.contains_key(&key) { let mut set = BTreeSet::new(); - set.insert(r.title.clone()); + set.insert(recipe_title.clone()); self.inner.insert(key, (i.clone(), set)); } else { let amt = match (self.inner[&key].0.amt, i.amt) { @@ -151,12 +156,19 @@ impl IngredientAccumulator { }; self.inner.get_mut(&key).map(|(i, set)| { i.amt = amt; - set.insert(r.title.clone()); + set.insert(recipe_title.clone()); }); } } } + pub fn accumulate_from(&mut self, r: &Recipe) { + self.accumulate_ingredients_for( + &r.title, + r.steps.iter().map(|s| s.ingredients.iter()).flatten(), + ); + } + pub fn ingredients(self) -> BTreeMap)> { self.inner } diff --git a/recipes/src/parse.rs b/recipes/src/parse.rs index a14103c..bf2e30e 100644 --- a/recipes/src/parse.rs +++ b/recipes/src/parse.rs @@ -59,6 +59,14 @@ pub fn as_measure(i: &str) -> std::result::Result { } } +pub fn as_ingredient_list(i: &str) -> std::result::Result, String> { + match ingredient_list(StrIter::new(i)) { + Result::Abort(e) | Result::Fail(e) => Err(format_err(e)), + Result::Incomplete(_) => Err(format!("Incomplete categories list can not parse")), + Result::Complete(_, m) => Ok(m), + } +} + make_fn!( pub categories>, do_each!( diff --git a/web/src/api.rs b/web/src/api.rs index 7728354..726666d 100644 --- a/web/src/api.rs +++ b/web/src/api.rs @@ -17,10 +17,10 @@ use base64; use reqwasm; use serde_json::{from_str, to_string}; use sycamore::prelude::*; -use tracing::{debug, error, instrument, warn}; +use tracing::{debug, error, instrument}; use client_api::*; -use recipes::{parse, IngredientKey, Recipe, RecipeEntry}; +use recipes::{IngredientKey, RecipeEntry}; use wasm_bindgen::JsValue; use web_sys::Storage; @@ -310,6 +310,18 @@ impl LocalStore { ) .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)] @@ -646,4 +658,41 @@ impl HttpStore { Ok(()) } } + + pub async fn fetch_staples(&self) -> Result, Error> { + let mut path = self.v2_path(); + path.push_str("/staples"); + let resp = reqwasm::http::Request::get(&path).send().await?; + if resp.status() != 200 { + debug!("Invalid response back"); + Err(format!("Status: {}", resp.status()).into()) + } else { + Ok(resp + .json::>>() + .await + .expect("Failed to parse staples json") + .as_success() + .unwrap()) + } + } + + pub async fn store_staples>(&self, content: S) -> Result<(), Error> { + let mut path = self.v2_path(); + path.push_str("/staples"); + let serialized_staples: String = + to_string(content.as_ref()).expect("Failed to serialize staples to json"); + + let resp = reqwasm::http::Request::post(&path) + .body(&serialized_staples) + .header("content-type", "application/json") + .send() + .await?; + if resp.status() != 200 { + debug!("Invalid response back"); + Err(format!("Status: {}", resp.status()).into()) + } else { + debug!("We got a valid response back!"); + Ok(()) + } + } } diff --git a/web/src/app_state.rs b/web/src/app_state.rs index 8a3a7dc..03647d5 100644 --- a/web/src/app_state.rs +++ b/web/src/app_state.rs @@ -17,7 +17,7 @@ use std::{ }; use client_api::UserData; -use recipes::{parse, IngredientKey, Recipe, RecipeEntry}; +use recipes::{parse, Ingredient, IngredientKey, Recipe, RecipeEntry}; use sycamore::futures::spawn_local_scoped; use sycamore::prelude::*; use sycamore_state::{Handler, MessageMapper}; @@ -30,7 +30,7 @@ use crate::api::{HttpStore, LocalStore}; pub struct AppState { pub recipe_counts: BTreeMap, pub extras: Vec<(String, String)>, - pub staples: Option, + pub staples: Option>, pub recipes: BTreeMap, pub category_map: BTreeMap, pub filtered_ingredients: BTreeSet, @@ -69,6 +69,7 @@ pub enum Message { SetUserData(UserData), SaveState(Option>), LoadState(Option>), + UpdateStaples(String), } impl Debug for Message { @@ -108,6 +109,7 @@ impl Debug for Message { Self::SetUserData(arg0) => f.debug_tuple("SetUserData").field(arg0).finish(), Self::SaveState(_) => write!(f, "SaveState"), Self::LoadState(_) => write!(f, "LoadState"), + Self::UpdateStaples(arg) => f.debug_tuple("UpdateStaples").field(arg).finish(), } } } @@ -158,11 +160,25 @@ impl StateMachine { let mut state = original.get().as_ref().clone(); info!("Synchronizing Recipes"); let recipe_entries = &store.fetch_recipes().await?; - let (staples, recipes) = filter_recipes(&recipe_entries)?; + let (_old_staples, recipes) = filter_recipes(&recipe_entries)?; if let Some(recipes) = recipes { - state.staples = staples; state.recipes = recipes; }; + + 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 + } + }; + if let Some(recipe_entries) = recipe_entries { local_store.set_all_recipes(recipe_entries); } @@ -393,6 +409,18 @@ impl MessageMapper for StateMachine { }); return; } + Message::UpdateStaples(content) => { + let store = self.store.clone(); + let local_store = self.local_store.clone(); + spawn_local_scoped(cx, async move { + local_store.set_staples(&content); + store + .store_staples(content) + .await + .expect("Failed to store staples"); + }); + return; + } } original.set(original_copy); } diff --git a/web/src/components/categories.rs b/web/src/components/categories.rs index 1905e14..f464752 100644 --- a/web/src/components/categories.rs +++ b/web/src/components/categories.rs @@ -104,7 +104,7 @@ pub fn Categories<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> Vie } } if let Some(staples) = &state.staples { - for (_, i) in staples.get_ingredients().iter() { + for i in staples.iter() { let ingredient_name = i.name.clone(); ingredients .entry(ingredient_name) @@ -125,7 +125,7 @@ pub fn Categories<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> Vie } } if let Some(staples) = &state.staples { - for (_, i) in staples.get_ingredients().iter() { + for i in staples.iter() { ingredients.insert(i.name.clone()); } } diff --git a/web/src/components/mod.rs b/web/src/components/mod.rs index c800294..5b83ad9 100644 --- a/web/src/components/mod.rs +++ b/web/src/components/mod.rs @@ -20,6 +20,7 @@ pub mod recipe_list; pub mod recipe_plan; pub mod recipe_selection; pub mod shopping_list; +pub mod staples; pub mod tabs; pub use add_recipe::*; @@ -31,4 +32,5 @@ pub use recipe_list::*; pub use recipe_plan::*; pub use recipe_selection::*; pub use shopping_list::*; +pub use staples::*; pub use tabs::*; diff --git a/web/src/components/shopping_list.rs b/web/src/components/shopping_list.rs index 5e01a45..41bd1a2 100644 --- a/web/src/components/shopping_list.rs +++ b/web/src/components/shopping_list.rs @@ -43,7 +43,7 @@ fn make_ingredients_rows<'ctx, G: Html>( } if *show_staples.get() { if let Some(staples) = &state.staples { - acc.accumulate_from(staples); + acc.accumulate_ingredients_for("Staples", staples.iter()); } } let mut ingredients = acc diff --git a/web/src/components/staples.rs b/web/src/components/staples.rs new file mode 100644 index 0000000..ef2100f --- /dev/null +++ b/web/src/components/staples.rs @@ -0,0 +1,96 @@ +// Copyright 2022 Jeremy Wall +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +use sycamore::{futures::spawn_local_scoped, prelude::*}; +use tracing::{debug, error}; + +use crate::app_state::{Message, StateHandler}; +use recipes::{self, parse}; + +fn check_ingredients_parses( + text: &str, + error_text: &Signal, + aria_hint: &Signal<&'static str>, +) -> bool { + if let Err(e) = parse::as_ingredient_list(text) { + error!(?e, "Error parsing recipe"); + error_text.set(e); + aria_hint.set("true"); + false + } else { + error_text.set(String::from("No parse errors...")); + aria_hint.set("false"); + true + } +} + +#[derive(Props)] +pub struct IngredientComponentProps<'ctx> { + sh: StateHandler<'ctx>, +} + +#[component] +pub fn IngredientsEditor<'ctx, G: Html>( + cx: Scope<'ctx>, + props: IngredientComponentProps<'ctx>, +) -> View { + let IngredientComponentProps { sh } = props; + let store = crate::api::HttpStore::get_from_context(cx); + let text = create_signal(cx, String::new()); + let error_text = create_signal(cx, String::from("Parse results...")); + let aria_hint = create_signal(cx, "false"); + + spawn_local_scoped(cx, { + let store = store.clone(); + async move { + let entry = store + .fetch_staples() + .await + .expect("Failure getting staples"); + if let Some(entry) = entry { + check_ingredients_parses(entry.as_str(), error_text, aria_hint); + text.set(entry); + } else { + error_text.set("Unable to find staples".to_owned()); + } + } + }); + + let dirty = create_signal(cx, false); + + debug!("creating editor view"); + view! {cx, + div(class="grid") { + textarea(bind:value=text, aria-invalid=aria_hint.get(), rows=20, on:change=move |_| { + dirty.set(true); + }) + div(class="parse") { (error_text.get()) } + } + span(role="button", on:click=move |_| { + let unparsed = text.get(); + check_ingredients_parses(unparsed.as_str(), error_text, aria_hint); + }) { "Check" } " " + span(role="button", on:click=move |_| { + let unparsed = text.get(); + if !*dirty.get_untracked() { + debug!("Staples text is unchanged"); + return; + } + debug!("triggering a save"); + if check_ingredients_parses(unparsed.as_str(), error_text, aria_hint) { + debug!("Staples text is changed"); + sh.dispatch(cx, Message::UpdateStaples(unparsed.as_ref().clone())); + } + }) { "Save" } + } +} diff --git a/web/src/pages/manage/staples.rs b/web/src/pages/manage/staples.rs index 318b4a2..a97a0d1 100644 --- a/web/src/pages/manage/staples.rs +++ b/web/src/pages/manage/staples.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. use super::ManagePage; -use crate::{app_state::StateHandler, components::recipe::Editor}; +use crate::{app_state::StateHandler, components::staples::IngredientsEditor}; use sycamore::prelude::*; use tracing::instrument; @@ -23,6 +23,6 @@ pub fn StaplesPage<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> Vi view! {cx, ManagePage( selected=Some("Staples".to_owned()), - ) { Editor(recipe_id="staples.txt".to_owned(), sh=sh) } + ) { IngredientsEditor(sh=sh) } } }