mirror of
https://github.com/zaphar/kitchen.git
synced 2025-07-22 19:40:14 -04:00
Store staples seperately from recipes
This commit is contained in:
parent
35968ac78d
commit
c16b5bab7c
2
kitchen/migrations/20230106195850_staples.down.sql
Normal file
2
kitchen/migrations/20230106195850_staples.down.sql
Normal file
@ -0,0 +1,2 @@
|
||||
-- Add down migration script here
|
||||
drop table staples;
|
6
kitchen/migrations/20230106195850_staples.up.sql
Normal file
6
kitchen/migrations/20230106195850_staples.up.sql
Normal 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)
|
||||
);
|
@ -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": [],
|
||||
|
@ -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<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 {
|
||||
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))
|
||||
|
1
kitchen/src/web/storage/fetch_staples.sql
Normal file
1
kitchen/src/web/storage/fetch_staples.sql
Normal file
@ -0,0 +1 @@
|
||||
select content from staples where user_id = ?
|
@ -148,6 +148,10 @@ pub trait APIStore {
|
||||
modified_amts: BTreeMap<IngredientKey, String>,
|
||||
extra_items: Vec<(String, String)>,
|
||||
) -> 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]
|
||||
@ -694,4 +698,24 @@ impl APIStore for SqliteStore {
|
||||
transaction.commit().await?;
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
2
kitchen/src/web/storage/save_staples.sql
Normal file
2
kitchen/src/web/storage/save_staples.sql
Normal file
@ -0,0 +1,2 @@
|
||||
insert into staples (user_id, content) values (?, ?)
|
||||
on conflict(user_id) do update set content = excluded.content
|
@ -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<Item = &'a Ingredient>,
|
||||
S: Into<String>,
|
||||
{
|
||||
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<IngredientKey, (Ingredient, BTreeSet<String>)> {
|
||||
self.inner
|
||||
}
|
||||
|
@ -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!(
|
||||
pub categories<StrIter, BTreeMap<String, String>>,
|
||||
do_each!(
|
||||
|
@ -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<String> {
|
||||
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<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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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<String, usize>,
|
||||
pub extras: Vec<(String, String)>,
|
||||
pub staples: Option<Recipe>,
|
||||
pub staples: Option<BTreeSet<Ingredient>>,
|
||||
pub recipes: BTreeMap<String, Recipe>,
|
||||
pub category_map: BTreeMap<String, String>,
|
||||
pub filtered_ingredients: BTreeSet<IngredientKey>,
|
||||
@ -69,6 +69,7 @@ pub enum Message {
|
||||
SetUserData(UserData),
|
||||
SaveState(Option<Box<dyn FnOnce()>>),
|
||||
LoadState(Option<Box<dyn FnOnce()>>),
|
||||
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<Message, AppState> 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);
|
||||
}
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
@ -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::*;
|
||||
|
@ -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
|
||||
|
96
web/src/components/staples.rs
Normal file
96
web/src/components/staples.rs
Normal 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" }
|
||||
}
|
||||
}
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user