Store staples seperately from recipes

This commit is contained in:
Jeremy Wall 2023-01-06 11:04:03 -05:00
parent 35968ac78d
commit c16b5bab7c
16 changed files with 318 additions and 16 deletions

View File

@ -0,0 +1,2 @@
-- Add down migration script here
drop table staples;

View File

@ -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)
);

View File

@ -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" "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": { "2582522f8ca9f12eccc70a3b339d9030aee0f52e62d6674cfd3862de2a68a177": {
"describe": { "describe": {
"columns": [], "columns": [],
@ -212,6 +222,24 @@
}, },
"query": "insert into users (id, password_hashed) values (?, ?)" "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": { "6e28698330e42fd6c87ba1e6f1deb664c0d3995caa2b937ceac8c908e98aded6": {
"describe": { "describe": {
"columns": [], "columns": [],

View File

@ -28,7 +28,7 @@ use recipes::{IngredientKey, RecipeEntry};
use rust_embed::RustEmbed; use rust_embed::RustEmbed;
use tower::ServiceBuilder; use tower::ServiceBuilder;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
use tracing::{debug, info, instrument}; use tracing::{debug, error, info, instrument};
use client_api as api; use client_api as api;
use storage::{APIStore, AuthStore}; 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<Arc<storage::SqliteStore>>,
session: storage::UserIdFromSession,
) -> api::Response<Option<String>> {
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<Arc<storage::SqliteStore>>,
session: storage::UserIdFromSession,
Json(content): Json<String>,
) -> 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 { fn mk_v1_routes() -> Router {
Router::new() Router::new()
.route("/recipes", get(api_recipes).post(api_save_recipes)) .route("/recipes", get(api_recipes).post(api_save_recipes))
@ -416,6 +459,7 @@ fn mk_v2_routes() -> Router {
"/category_map", "/category_map",
get(api_category_mappings).post(api_save_category_mappings), 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. // All the routes above require a UserId.
.route("/auth", get(auth::handler).post(auth::handler)) .route("/auth", get(auth::handler).post(auth::handler))
.route("/account", get(api_user_account)) .route("/account", get(api_user_account))

View File

@ -0,0 +1 @@
select content from staples where user_id = ?

View File

@ -148,6 +148,10 @@ pub trait APIStore {
modified_amts: BTreeMap<IngredientKey, String>, modified_amts: BTreeMap<IngredientKey, String>,
extra_items: Vec<(String, String)>, extra_items: Vec<(String, String)>,
) -> Result<()>; ) -> Result<()>;
async fn fetch_staples<S: AsRef<str> + Send>(&self, user_id: S) -> Result<Option<String>>;
async fn save_staples<S: AsRef<str> + Send>(&self, user_id: S, content: S) -> Result<()>;
} }
#[async_trait] #[async_trait]
@ -694,4 +698,24 @@ impl APIStore for SqliteStore {
transaction.commit().await?; transaction.commit().await?;
Ok(()) Ok(())
} }
async fn save_staples<S: AsRef<str> + 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<S: AsRef<str> + Send>(&self, user_id: S) -> Result<Option<String>> {
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)
}
} }

View File

@ -0,0 +1,2 @@
insert into staples (user_id, content) values (?, ?)
on conflict(user_id) do update set content = excluded.content

View File

@ -135,12 +135,17 @@ impl IngredientAccumulator {
} }
} }
pub fn accumulate_from(&mut self, r: &Recipe) { pub fn accumulate_ingredients_for<'a, Iter, S>(&'a mut self, recipe_title: S, ingredients: Iter)
for i in r.steps.iter().map(|s| s.ingredients.iter()).flatten() { where
Iter: Iterator<Item = &'a Ingredient>,
S: Into<String>,
{
let recipe_title = recipe_title.into();
for i in ingredients {
let key = i.key(); let key = i.key();
if !self.inner.contains_key(&key) { if !self.inner.contains_key(&key) {
let mut set = BTreeSet::new(); let mut set = BTreeSet::new();
set.insert(r.title.clone()); set.insert(recipe_title.clone());
self.inner.insert(key, (i.clone(), set)); self.inner.insert(key, (i.clone(), set));
} else { } else {
let amt = match (self.inner[&key].0.amt, i.amt) { let amt = match (self.inner[&key].0.amt, i.amt) {
@ -151,12 +156,19 @@ impl IngredientAccumulator {
}; };
self.inner.get_mut(&key).map(|(i, set)| { self.inner.get_mut(&key).map(|(i, set)| {
i.amt = amt; 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<IngredientKey, (Ingredient, BTreeSet<String>)> { pub fn ingredients(self) -> BTreeMap<IngredientKey, (Ingredient, BTreeSet<String>)> {
self.inner self.inner
} }

View File

@ -59,6 +59,14 @@ pub fn as_measure(i: &str) -> std::result::Result<Measure, String> {
} }
} }
pub fn as_ingredient_list(i: &str) -> std::result::Result<Vec<Ingredient>, 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!( make_fn!(
pub categories<StrIter, BTreeMap<String, String>>, pub categories<StrIter, BTreeMap<String, String>>,
do_each!( do_each!(

View File

@ -17,10 +17,10 @@ use base64;
use reqwasm; use reqwasm;
use serde_json::{from_str, to_string}; use serde_json::{from_str, to_string};
use sycamore::prelude::*; use sycamore::prelude::*;
use tracing::{debug, error, instrument, warn}; use tracing::{debug, error, instrument};
use client_api::*; use client_api::*;
use recipes::{parse, IngredientKey, Recipe, RecipeEntry}; use recipes::{IngredientKey, RecipeEntry};
use wasm_bindgen::JsValue; use wasm_bindgen::JsValue;
use web_sys::Storage; use web_sys::Storage;
@ -310,6 +310,18 @@ impl LocalStore {
) )
.expect("Failed to set inventory"); .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<String> {
self.store
.get("staples")
.expect("Failed to retreive staples from local store")
}
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -646,4 +658,41 @@ impl HttpStore {
Ok(()) Ok(())
} }
} }
pub async fn fetch_staples(&self) -> Result<Option<String>, 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::<Response<Option<String>>>()
.await
.expect("Failed to parse staples json")
.as_success()
.unwrap())
}
}
pub async fn store_staples<S: AsRef<str>>(&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(())
}
}
} }

View File

@ -17,7 +17,7 @@ use std::{
}; };
use client_api::UserData; 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::futures::spawn_local_scoped;
use sycamore::prelude::*; use sycamore::prelude::*;
use sycamore_state::{Handler, MessageMapper}; use sycamore_state::{Handler, MessageMapper};
@ -30,7 +30,7 @@ use crate::api::{HttpStore, LocalStore};
pub struct AppState { pub struct AppState {
pub recipe_counts: BTreeMap<String, usize>, pub recipe_counts: BTreeMap<String, usize>,
pub extras: Vec<(String, String)>, pub extras: Vec<(String, String)>,
pub staples: Option<Recipe>, pub staples: Option<BTreeSet<Ingredient>>,
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>,
@ -69,6 +69,7 @@ pub enum Message {
SetUserData(UserData), SetUserData(UserData),
SaveState(Option<Box<dyn FnOnce()>>), SaveState(Option<Box<dyn FnOnce()>>),
LoadState(Option<Box<dyn FnOnce()>>), LoadState(Option<Box<dyn FnOnce()>>),
UpdateStaples(String),
} }
impl Debug for Message { impl Debug for Message {
@ -108,6 +109,7 @@ impl Debug for Message {
Self::SetUserData(arg0) => f.debug_tuple("SetUserData").field(arg0).finish(), Self::SetUserData(arg0) => f.debug_tuple("SetUserData").field(arg0).finish(),
Self::SaveState(_) => write!(f, "SaveState"), Self::SaveState(_) => write!(f, "SaveState"),
Self::LoadState(_) => write!(f, "LoadState"), 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(); let mut state = original.get().as_ref().clone();
info!("Synchronizing Recipes"); info!("Synchronizing Recipes");
let recipe_entries = &store.fetch_recipes().await?; 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 { if let Some(recipes) = recipes {
state.staples = staples;
state.recipes = recipes; 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 { if let Some(recipe_entries) = recipe_entries {
local_store.set_all_recipes(recipe_entries); local_store.set_all_recipes(recipe_entries);
} }
@ -393,6 +409,18 @@ impl MessageMapper<Message, AppState> for StateMachine {
}); });
return; 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); original.set(original_copy);
} }

View File

@ -104,7 +104,7 @@ pub fn Categories<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> Vie
} }
} }
if let Some(staples) = &state.staples { if let Some(staples) = &state.staples {
for (_, i) in staples.get_ingredients().iter() { for i in staples.iter() {
let ingredient_name = i.name.clone(); let ingredient_name = i.name.clone();
ingredients ingredients
.entry(ingredient_name) .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 { if let Some(staples) = &state.staples {
for (_, i) in staples.get_ingredients().iter() { for i in staples.iter() {
ingredients.insert(i.name.clone()); ingredients.insert(i.name.clone());
} }
} }

View File

@ -20,6 +20,7 @@ pub mod recipe_list;
pub mod recipe_plan; pub mod recipe_plan;
pub mod recipe_selection; pub mod recipe_selection;
pub mod shopping_list; pub mod shopping_list;
pub mod staples;
pub mod tabs; pub mod tabs;
pub use add_recipe::*; pub use add_recipe::*;
@ -31,4 +32,5 @@ pub use recipe_list::*;
pub use recipe_plan::*; pub use recipe_plan::*;
pub use recipe_selection::*; pub use recipe_selection::*;
pub use shopping_list::*; pub use shopping_list::*;
pub use staples::*;
pub use tabs::*; pub use tabs::*;

View File

@ -43,7 +43,7 @@ fn make_ingredients_rows<'ctx, G: Html>(
} }
if *show_staples.get() { if *show_staples.get() {
if let Some(staples) = &state.staples { if let Some(staples) = &state.staples {
acc.accumulate_from(staples); acc.accumulate_ingredients_for("Staples", staples.iter());
} }
} }
let mut ingredients = acc let mut ingredients = acc

View File

@ -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<String>,
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<G> {
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" }
}
}

View File

@ -12,7 +12,7 @@
// 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 super::ManagePage; use super::ManagePage;
use crate::{app_state::StateHandler, components::recipe::Editor}; use crate::{app_state::StateHandler, components::staples::IngredientsEditor};
use sycamore::prelude::*; use sycamore::prelude::*;
use tracing::instrument; use tracing::instrument;
@ -23,6 +23,6 @@ pub fn StaplesPage<'ctx, G: Html>(cx: Scope<'ctx>, sh: StateHandler<'ctx>) -> Vi
view! {cx, view! {cx,
ManagePage( ManagePage(
selected=Some("Staples".to_owned()), selected=Some("Staples".to_owned()),
) { Editor(recipe_id="staples.txt".to_owned(), sh=sh) } ) { IngredientsEditor(sh=sh) }
} }
} }