mirror of
https://github.com/zaphar/kitchen.git
synced 2025-07-21 19:29:49 -04:00
Refactor state management and http APIs
This commit is contained in:
parent
dd7ad63cfb
commit
e77af193aa
16
Cargo.lock
generated
16
Cargo.lock
generated
@ -1187,7 +1187,6 @@ dependencies = [
|
|||||||
"cookie",
|
"cookie",
|
||||||
"csv",
|
"csv",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"recipe-store",
|
|
||||||
"recipes",
|
"recipes",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
"secrecy",
|
"secrecy",
|
||||||
@ -1207,7 +1206,6 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
"console_error_panic_hook",
|
"console_error_panic_hook",
|
||||||
"recipe-store",
|
|
||||||
"recipes",
|
"recipes",
|
||||||
"reqwasm",
|
"reqwasm",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@ -1570,19 +1568,6 @@ dependencies = [
|
|||||||
"getrandom",
|
"getrandom",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "recipe-store"
|
|
||||||
version = "0.2.10"
|
|
||||||
dependencies = [
|
|
||||||
"async-std",
|
|
||||||
"async-trait",
|
|
||||||
"recipes",
|
|
||||||
"reqwasm",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"tracing",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "recipes"
|
name = "recipes"
|
||||||
version = "0.2.10"
|
version = "0.2.10"
|
||||||
@ -1591,6 +1576,7 @@ dependencies = [
|
|||||||
"abortable_parser",
|
"abortable_parser",
|
||||||
"chrono",
|
"chrono",
|
||||||
"num-rational",
|
"num-rational",
|
||||||
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [ "recipes", "kitchen", "web", "recipe-store"]
|
members = [ "recipes", "kitchen", "web" ]
|
||||||
|
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
# TODO(jwall): When the fix for RcSignal Binding is released we can drop this patch.
|
# TODO(jwall): When the fix for RcSignal Binding is released we can drop this patch.
|
||||||
|
@ -10,7 +10,6 @@ edition = "2021"
|
|||||||
tracing = "0.1.35"
|
tracing = "0.1.35"
|
||||||
tracing-subscriber = "0.3.14"
|
tracing-subscriber = "0.3.14"
|
||||||
recipes = { path = "../recipes" }
|
recipes = { path = "../recipes" }
|
||||||
recipe-store = {path = "../recipe-store" }
|
|
||||||
csv = "1.1.1"
|
csv = "1.1.1"
|
||||||
rust-embed="6.4.0"
|
rust-embed="6.4.0"
|
||||||
mime_guess = "2.0.4"
|
mime_guess = "2.0.4"
|
||||||
|
@ -18,6 +18,30 @@
|
|||||||
},
|
},
|
||||||
"query": "select password_hashed from users where id = ?"
|
"query": "select password_hashed from users where id = ?"
|
||||||
},
|
},
|
||||||
|
"196e289cbd65224293c4213552160a0cdf82f924ac597810fe05102e247b809d": {
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "recipe_id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "recipe_text",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"query": "select recipe_id, recipe_text from recipes where user_id = ? and recipe_id = ?"
|
||||||
|
},
|
||||||
"3fd4017569dca4fe73e97e0e2bd612027a8c1b17b0b10faabd6459f56ca1c0bb": {
|
"3fd4017569dca4fe73e97e0e2bd612027a8c1b17b0b10faabd6459f56ca1c0bb": {
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [],
|
"columns": [],
|
||||||
|
@ -23,7 +23,7 @@ use axum::{
|
|||||||
routing::{get, Router},
|
routing::{get, Router},
|
||||||
};
|
};
|
||||||
use mime_guess;
|
use mime_guess;
|
||||||
use recipe_store::{self, RecipeEntry, RecipeStore};
|
use recipes::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;
|
||||||
@ -83,9 +83,34 @@ async fn ui_static_assets(Path(path): Path<String>) -> impl IntoResponse {
|
|||||||
StaticFile(path.to_owned())
|
StaticFile(path.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[instrument]
|
||||||
|
async fn api_recipe_entry(
|
||||||
|
Extension(store): Extension<Arc<storage::file_store::AsyncFileStore>>,
|
||||||
|
Extension(app_store): Extension<Arc<storage::SqliteStore>>,
|
||||||
|
session: storage::UserIdFromSession,
|
||||||
|
Path(recipe_id): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
use storage::{UserId, UserIdFromSession::*};
|
||||||
|
let result = match session {
|
||||||
|
NoUserId => store
|
||||||
|
.get_recipe_entry(recipe_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Error: {:?}", e)),
|
||||||
|
FoundUserId(UserId(id)) => app_store
|
||||||
|
.get_recipe_entry_for_user(id, recipe_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Error: {:?}", e)),
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(Some(recipes)) => (StatusCode::OK, axum::Json::from(recipes)).into_response(),
|
||||||
|
Ok(None) => (StatusCode::NOT_FOUND, axum::Json::from("")).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, axum::Json::from(e)).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[instrument]
|
#[instrument]
|
||||||
async fn api_recipes(
|
async fn api_recipes(
|
||||||
Extension(store): Extension<Arc<recipe_store::AsyncFileStore>>,
|
Extension(store): Extension<Arc<storage::file_store::AsyncFileStore>>,
|
||||||
Extension(app_store): Extension<Arc<storage::SqliteStore>>,
|
Extension(app_store): Extension<Arc<storage::SqliteStore>>,
|
||||||
session: storage::UserIdFromSession,
|
session: storage::UserIdFromSession,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
@ -110,7 +135,7 @@ async fn api_recipes(
|
|||||||
|
|
||||||
#[instrument]
|
#[instrument]
|
||||||
async fn api_categories(
|
async fn api_categories(
|
||||||
Extension(store): Extension<Arc<recipe_store::AsyncFileStore>>,
|
Extension(store): Extension<Arc<storage::file_store::AsyncFileStore>>,
|
||||||
Extension(app_store): Extension<Arc<storage::SqliteStore>>,
|
Extension(app_store): Extension<Arc<storage::SqliteStore>>,
|
||||||
session: storage::UserIdFromSession,
|
session: storage::UserIdFromSession,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
@ -156,7 +181,7 @@ async fn api_save_categories(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn api_save_recipe(
|
async fn api_save_recipes(
|
||||||
Extension(app_store): Extension<Arc<storage::SqliteStore>>,
|
Extension(app_store): Extension<Arc<storage::SqliteStore>>,
|
||||||
session: storage::UserIdFromSession,
|
session: storage::UserIdFromSession,
|
||||||
Json(recipes): Json<Vec<RecipeEntry>>,
|
Json(recipes): Json<Vec<RecipeEntry>>,
|
||||||
@ -180,7 +205,9 @@ async fn api_save_recipe(
|
|||||||
|
|
||||||
#[instrument(fields(recipe_dir=?recipe_dir_path,listen=?listen_socket), skip_all)]
|
#[instrument(fields(recipe_dir=?recipe_dir_path,listen=?listen_socket), skip_all)]
|
||||||
pub async fn ui_main(recipe_dir_path: PathBuf, store_path: PathBuf, listen_socket: SocketAddr) {
|
pub async fn ui_main(recipe_dir_path: PathBuf, store_path: PathBuf, listen_socket: SocketAddr) {
|
||||||
let store = Arc::new(recipe_store::AsyncFileStore::new(recipe_dir_path.clone()));
|
let store = Arc::new(storage::file_store::AsyncFileStore::new(
|
||||||
|
recipe_dir_path.clone(),
|
||||||
|
));
|
||||||
//let dir_path = (&dir_path).clone();
|
//let dir_path = (&dir_path).clone();
|
||||||
let app_store = Arc::new(
|
let app_store = Arc::new(
|
||||||
storage::SqliteStore::new(store_path)
|
storage::SqliteStore::new(store_path)
|
||||||
@ -191,7 +218,9 @@ pub async fn ui_main(recipe_dir_path: PathBuf, store_path: PathBuf, listen_socke
|
|||||||
.route("/", get(|| async { Redirect::temporary("/ui/plan") }))
|
.route("/", get(|| async { Redirect::temporary("/ui/plan") }))
|
||||||
.route("/ui/*path", get(ui_static_assets))
|
.route("/ui/*path", get(ui_static_assets))
|
||||||
// recipes api path route
|
// recipes api path route
|
||||||
.route("/api/v1/recipes", get(api_recipes).post(api_save_recipe))
|
.route("/api/v1/recipes", get(api_recipes).post(api_save_recipes))
|
||||||
|
// recipe entry api path route
|
||||||
|
.route("/api/v1/recipe/:recipe_id", get(api_recipe_entry))
|
||||||
// categories api path route
|
// categories api path route
|
||||||
.route(
|
.route(
|
||||||
"/api/v1/categories",
|
"/api/v1/categories",
|
||||||
@ -237,7 +266,7 @@ pub async fn add_user(
|
|||||||
.await
|
.await
|
||||||
.expect("Failed to store user creds");
|
.expect("Failed to store user creds");
|
||||||
if let Some(path) = recipe_dir_path {
|
if let Some(path) = recipe_dir_path {
|
||||||
let store = recipe_store::AsyncFileStore::new(path);
|
let store = storage::file_store::AsyncFileStore::new(path);
|
||||||
if let Some(recipes) = store
|
if let Some(recipes) = store
|
||||||
.get_recipes()
|
.get_recipes()
|
||||||
.await
|
.await
|
||||||
|
@ -17,11 +17,11 @@ use async_std::{
|
|||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
stream::StreamExt,
|
stream::StreamExt,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
use tracing::{debug, instrument};
|
use tracing::{debug, instrument};
|
||||||
|
|
||||||
|
use super::RecipeEntry;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Error(String);
|
pub struct Error(String);
|
||||||
|
|
||||||
@ -43,35 +43,6 @@ impl From<std::string::FromUtf8Error> for Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait TenantStoreFactory<S>
|
|
||||||
where
|
|
||||||
S: RecipeStore,
|
|
||||||
{
|
|
||||||
fn get_user_store(&self, user: String) -> S;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
|
||||||
pub struct RecipeEntry(pub String, pub String);
|
|
||||||
|
|
||||||
impl RecipeEntry {
|
|
||||||
pub fn recipe_id(&self) -> &str {
|
|
||||||
self.0.as_str()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn recipe_text(&self) -> &str {
|
|
||||||
self.1.as_str()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
/// Define the shared interface to use for interacting with a store of recipes.
|
|
||||||
pub trait RecipeStore: Clone + Sized {
|
|
||||||
/// Get categories text unparsed.
|
|
||||||
async fn get_categories(&self) -> Result<Option<String>, Error>;
|
|
||||||
/// Get list of recipe text unparsed.
|
|
||||||
async fn get_recipes(&self) -> Result<Option<Vec<RecipeEntry>>, Error>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct AsyncFileStore {
|
pub struct AsyncFileStore {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
@ -83,11 +54,19 @@ impl AsyncFileStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
impl AsyncFileStore {
|
||||||
|
fn get_recipe_path_root(&self) -> PathBuf {
|
||||||
|
let mut recipe_path = PathBuf::new();
|
||||||
|
recipe_path.push(&self.path);
|
||||||
|
recipe_path.push("recipes");
|
||||||
|
recipe_path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO(jwall): We need to model our own set of errors for this.
|
// TODO(jwall): We need to model our own set of errors for this.
|
||||||
impl RecipeStore for AsyncFileStore {
|
impl AsyncFileStore {
|
||||||
#[instrument(skip_all)]
|
#[instrument(skip_all)]
|
||||||
async fn get_categories(&self) -> Result<Option<String>, Error> {
|
pub async fn get_categories(&self) -> Result<Option<String>, Error> {
|
||||||
let mut category_path = PathBuf::new();
|
let mut category_path = PathBuf::new();
|
||||||
category_path.push(&self.path);
|
category_path.push(&self.path);
|
||||||
category_path.push("categories.txt");
|
category_path.push("categories.txt");
|
||||||
@ -99,7 +78,7 @@ impl RecipeStore for AsyncFileStore {
|
|||||||
Ok(Some(String::from_utf8(contents)?))
|
Ok(Some(String::from_utf8(contents)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_recipes(&self) -> Result<Option<Vec<RecipeEntry>>, Error> {
|
pub async fn get_recipes(&self) -> Result<Option<Vec<RecipeEntry>>, Error> {
|
||||||
let mut recipe_path = PathBuf::new();
|
let mut recipe_path = PathBuf::new();
|
||||||
recipe_path.push(&self.path);
|
recipe_path.push(&self.path);
|
||||||
recipe_path.push("recipes");
|
recipe_path.push("recipes");
|
||||||
@ -129,4 +108,19 @@ impl RecipeStore for AsyncFileStore {
|
|||||||
}
|
}
|
||||||
Ok(Some(entry_vec))
|
Ok(Some(entry_vec))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_recipe_entry<S: AsRef<str> + Send>(
|
||||||
|
&self,
|
||||||
|
id: S,
|
||||||
|
) -> Result<Option<RecipeEntry>, Error> {
|
||||||
|
let mut recipe_path = self.get_recipe_path_root();
|
||||||
|
recipe_path.push(id.as_ref());
|
||||||
|
if recipe_path.exists().await && recipe_path.is_file().await {
|
||||||
|
debug!("Found recipe file {}", recipe_path.to_string_lossy());
|
||||||
|
let recipe_contents = read_to_string(recipe_path).await?;
|
||||||
|
return Ok(Some(RecipeEntry(id.as_ref().to_owned(), recipe_contents)));
|
||||||
|
} else {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
@ -27,6 +27,7 @@ use axum::{
|
|||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
use ciborium;
|
use ciborium;
|
||||||
|
use recipes::RecipeEntry;
|
||||||
use secrecy::{ExposeSecret, Secret};
|
use secrecy::{ExposeSecret, Secret};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{
|
use sqlx::{
|
||||||
@ -36,14 +37,14 @@ use sqlx::{
|
|||||||
};
|
};
|
||||||
use tracing::{debug, error, info, instrument};
|
use tracing::{debug, error, info, instrument};
|
||||||
|
|
||||||
use recipe_store::RecipeEntry;
|
|
||||||
|
|
||||||
mod error;
|
mod error;
|
||||||
|
pub mod file_store;
|
||||||
|
|
||||||
pub use error::*;
|
pub use error::*;
|
||||||
|
|
||||||
pub const AXUM_SESSION_COOKIE_NAME: &'static str = "kitchen-session-cookie";
|
pub const AXUM_SESSION_COOKIE_NAME: &'static str = "kitchen-session-cookie";
|
||||||
|
|
||||||
|
// TODO(jwall): Should this move to the recipe crate?
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct UserId(pub String);
|
pub struct UserId(pub String);
|
||||||
|
|
||||||
@ -93,6 +94,12 @@ pub trait APIStore {
|
|||||||
-> Result<()>;
|
-> Result<()>;
|
||||||
|
|
||||||
async fn store_categories_for_user(&self, user_id: &str, categories: &str) -> Result<()>;
|
async fn store_categories_for_user(&self, user_id: &str, categories: &str) -> Result<()>;
|
||||||
|
|
||||||
|
async fn get_recipe_entry_for_user<S: AsRef<str> + Send>(
|
||||||
|
&self,
|
||||||
|
user_id: S,
|
||||||
|
id: S,
|
||||||
|
) -> Result<Option<RecipeEntry>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@ -271,6 +278,40 @@ impl APIStore for SqliteStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_recipe_entry_for_user<S: AsRef<str> + Send>(
|
||||||
|
&self,
|
||||||
|
user_id: S,
|
||||||
|
id: S,
|
||||||
|
) -> Result<Option<RecipeEntry>> {
|
||||||
|
// NOTE(jwall): We allow dead code becaue Rust can't figure out that
|
||||||
|
// this code is actually constructed but it's done via the query_as
|
||||||
|
// macro.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct RecipeRow {
|
||||||
|
pub recipe_id: String,
|
||||||
|
pub recipe_text: Option<String>,
|
||||||
|
}
|
||||||
|
let id = id.as_ref();
|
||||||
|
let user_id = user_id.as_ref();
|
||||||
|
let entry = sqlx::query_as!(
|
||||||
|
RecipeRow,
|
||||||
|
"select recipe_id, recipe_text from recipes where user_id = ? and recipe_id = ?",
|
||||||
|
user_id,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
.fetch_all(self.pool.as_ref())
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
RecipeEntry(
|
||||||
|
row.recipe_id.clone(),
|
||||||
|
row.recipe_text.clone().unwrap_or_else(|| String::new()),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.nth(0);
|
||||||
|
Ok(entry)
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_recipes_for_user(&self, user_id: &str) -> Result<Option<Vec<RecipeEntry>>> {
|
async fn get_recipes_for_user(&self, user_id: &str) -> Result<Option<Vec<RecipeEntry>>> {
|
||||||
// NOTE(jwall): We allow dead code becaue Rust can't figure out that
|
// NOTE(jwall): We allow dead code becaue Rust can't figure out that
|
||||||
// this code is actually constructed but it's done via the query_as
|
// this code is actually constructed but it's done via the query_as
|
||||||
|
@ -1,15 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "recipe-store"
|
|
||||||
version = "0.2.10"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
recipes = {path = "../recipes" }
|
|
||||||
async-trait = "0.1.57"
|
|
||||||
async-std = "1.10.0"
|
|
||||||
tracing = "0.1.35"
|
|
||||||
reqwasm = "0.5.0"
|
|
||||||
serde_json = "1.0.79"
|
|
||||||
serde = "1.0.143"
|
|
@ -9,6 +9,7 @@ edition = "2021"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
abortable_parser = "~0.2.6"
|
abortable_parser = "~0.2.6"
|
||||||
chrono = "~0.4"
|
chrono = "~0.4"
|
||||||
|
serde = "1.0.144"
|
||||||
|
|
||||||
[dependencies.num-rational]
|
[dependencies.num-rational]
|
||||||
version = "~0.4.0"
|
version = "~0.4.0"
|
||||||
|
@ -17,6 +17,7 @@ pub mod unit;
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use unit::*;
|
use unit::*;
|
||||||
use Measure::*;
|
use Measure::*;
|
||||||
@ -48,6 +49,19 @@ impl Mealplan {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
pub struct RecipeEntry(pub String, pub String);
|
||||||
|
|
||||||
|
impl RecipeEntry {
|
||||||
|
pub fn recipe_id(&self) -> &str {
|
||||||
|
self.0.as_str()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn recipe_text(&self) -> &str {
|
||||||
|
self.1.as_str()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A Recipe with a title, description, and a series of steps.
|
/// A Recipe with a title, description, and a series of steps.
|
||||||
#[derive(Debug, Clone, PartialEq, PartialOrd, Ord, Eq)]
|
#[derive(Debug, Clone, PartialEq, PartialOrd, Ord, Eq)]
|
||||||
pub struct Recipe {
|
pub struct Recipe {
|
||||||
|
@ -14,7 +14,6 @@ crate-type = ["cdylib", "rlib"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
recipes = { path = "../recipes" }
|
recipes = { path = "../recipes" }
|
||||||
recipe-store = { path = "../recipe-store" }
|
|
||||||
# This makes debugging panics more tractable.
|
# This makes debugging panics more tractable.
|
||||||
console_error_panic_hook = "0.1.7"
|
console_error_panic_hook = "0.1.7"
|
||||||
serde_json = "1.0.79"
|
serde_json = "1.0.79"
|
||||||
|
219
web/src/api.rs
Normal file
219
web/src/api.rs
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
// 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 std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use reqwasm;
|
||||||
|
//use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::to_string;
|
||||||
|
use sycamore::prelude::*;
|
||||||
|
use tracing::{debug, error, info, instrument, warn};
|
||||||
|
|
||||||
|
use recipes::{parse, Recipe, RecipeEntry};
|
||||||
|
|
||||||
|
use crate::{app_state, js_lib};
|
||||||
|
|
||||||
|
#[instrument]
|
||||||
|
fn filter_recipes(
|
||||||
|
recipe_entries: &Option<Vec<RecipeEntry>>,
|
||||||
|
) -> Result<(Option<Recipe>, Option<BTreeMap<String, Recipe>>), String> {
|
||||||
|
match recipe_entries {
|
||||||
|
Some(parsed) => {
|
||||||
|
let mut staples = None;
|
||||||
|
let mut parsed_map = BTreeMap::new();
|
||||||
|
for r in parsed {
|
||||||
|
let recipe = match parse::as_recipe(&r.recipe_text()) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Error parsing recipe {}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if recipe.title == "Staples" {
|
||||||
|
staples = Some(recipe);
|
||||||
|
} else {
|
||||||
|
parsed_map.insert(r.recipe_id().to_owned(), recipe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((staples, Some(parsed_map)))
|
||||||
|
}
|
||||||
|
None => Ok((None, None)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(state))]
|
||||||
|
pub async fn init_page_state(store: &HttpStore, state: &app_state::State) -> Result<(), String> {
|
||||||
|
info!("Synchronizing Recipes");
|
||||||
|
// TODO(jwall): Make our caching logic using storage more robust.
|
||||||
|
let recipes = store.get_recipes().await.map_err(|e| format!("{:?}", e))?;
|
||||||
|
if let Ok((staples, recipes)) = filter_recipes(&recipes) {
|
||||||
|
state.staples.set(staples);
|
||||||
|
if let Some(recipes) = recipes {
|
||||||
|
state.recipes.set(recipes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(rs) = recipes {
|
||||||
|
for r in rs {
|
||||||
|
if !state.recipe_counts.get().contains_key(r.recipe_id()) {
|
||||||
|
state.set_recipe_count_by_index(&r.recipe_id().to_owned(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info!("Synchronizing categories");
|
||||||
|
match store.get_categories().await {
|
||||||
|
Ok(Some(categories_content)) => {
|
||||||
|
debug!(categories=?categories_content);
|
||||||
|
let category_map = recipes::parse::as_categories(&categories_content)?;
|
||||||
|
state.category_map.set(category_map);
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
warn!("There is no category file");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("{:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Error(String);
|
||||||
|
|
||||||
|
impl From<std::io::Error> for Error {
|
||||||
|
fn from(item: std::io::Error) -> Self {
|
||||||
|
Error(format!("{:?}", item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Error> for String {
|
||||||
|
fn from(item: Error) -> Self {
|
||||||
|
format!("{:?}", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for Error {
|
||||||
|
fn from(item: String) -> Self {
|
||||||
|
Error(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::string::FromUtf8Error> for Error {
|
||||||
|
fn from(item: std::string::FromUtf8Error) -> Self {
|
||||||
|
Error(format!("{:?}", item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<reqwasm::Error> for Error {
|
||||||
|
fn from(item: reqwasm::Error) -> Self {
|
||||||
|
Error(format!("{:?}", item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct HttpStore {
|
||||||
|
root: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpStore {
|
||||||
|
pub fn new(root: String) -> Self {
|
||||||
|
Self { root }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn provide_context<S: Into<String>>(cx: Scope, root: S) {
|
||||||
|
provide_context(cx, std::rc::Rc::new(Self::new(root.into())));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_from_context(cx: Scope) -> std::rc::Rc<Self> {
|
||||||
|
use_context::<std::rc::Rc<Self>>(cx).clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument]
|
||||||
|
pub async fn get_categories(&self) -> Result<Option<String>, Error> {
|
||||||
|
let mut path = self.root.clone();
|
||||||
|
path.push_str("/categories");
|
||||||
|
let resp = reqwasm::http::Request::get(&path).send().await?;
|
||||||
|
if resp.status() == 404 {
|
||||||
|
debug!("Categories returned 404");
|
||||||
|
Ok(None)
|
||||||
|
} else if resp.status() != 200 {
|
||||||
|
Err(format!("Status: {}", resp.status()).into())
|
||||||
|
} else {
|
||||||
|
debug!("We got a valid response back!");
|
||||||
|
let resp = resp.text().await;
|
||||||
|
Ok(Some(resp.map_err(|e| format!("{}", e))?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument]
|
||||||
|
pub async fn get_recipes(&self) -> Result<Option<Vec<RecipeEntry>>, Error> {
|
||||||
|
let mut path = self.root.clone();
|
||||||
|
path.push_str("/recipes");
|
||||||
|
let resp = reqwasm::http::Request::get(&path).send().await?;
|
||||||
|
if resp.status() != 200 {
|
||||||
|
Err(format!("Status: {}", resp.status()).into())
|
||||||
|
} else {
|
||||||
|
debug!("We got a valid response back!");
|
||||||
|
Ok(resp.json().await.map_err(|e| format!("{}", e))?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_recipe_text<S: AsRef<str>>(
|
||||||
|
&self,
|
||||||
|
id: S,
|
||||||
|
) -> Result<Option<RecipeEntry>, Error> {
|
||||||
|
let mut path = self.root.clone();
|
||||||
|
path.push_str("/recipe");
|
||||||
|
path.push_str(id.as_ref());
|
||||||
|
let resp = reqwasm::http::Request::get(&path).send().await?;
|
||||||
|
if resp.status() != 200 {
|
||||||
|
Err(format!("Status: {}", resp.status()).into())
|
||||||
|
} else {
|
||||||
|
debug!("We got a valid response back!");
|
||||||
|
Ok(resp.json().await.map_err(|e| format!("{}", e))?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(recipes), fields(count=recipes.len()))]
|
||||||
|
pub async fn save_recipes(&self, recipes: Vec<RecipeEntry>) -> Result<(), Error> {
|
||||||
|
let mut path = self.root.clone();
|
||||||
|
path.push_str("/recipes");
|
||||||
|
let resp = reqwasm::http::Request::post(&path)
|
||||||
|
.body(to_string(&recipes).expect("Unable to serialize recipe entries"))
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
if resp.status() != 200 {
|
||||||
|
Err(format!("Status: {}", resp.status()).into())
|
||||||
|
} else {
|
||||||
|
debug!("We got a valid response back!");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(categories))]
|
||||||
|
pub async fn save_categories(&self, categories: String) -> Result<(), Error> {
|
||||||
|
let mut path = self.root.clone();
|
||||||
|
path.push_str("/categories");
|
||||||
|
let resp = reqwasm::http::Request::post(&path)
|
||||||
|
.body(to_string(&categories).expect("Unable to encode categories as json"))
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
if resp.status() != 200 {
|
||||||
|
Err(format!("Status: {}", resp.status()).into())
|
||||||
|
} else {
|
||||||
|
debug!("We got a valid response back!");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -11,9 +11,15 @@
|
|||||||
// 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 std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
use sycamore::prelude::*;
|
||||||
|
use tracing::{debug, instrument, warn};
|
||||||
|
|
||||||
|
use recipes::{Ingredient, IngredientAccumulator, Recipe};
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum AppRoutes {
|
pub enum Routes {
|
||||||
Plan,
|
Plan,
|
||||||
Inventory,
|
Inventory,
|
||||||
Cook,
|
Cook,
|
||||||
@ -23,8 +29,96 @@ pub enum AppRoutes {
|
|||||||
NotFound,
|
NotFound,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppRoutes {
|
impl Default for Routes {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::Plan
|
Self::Plan
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct State {
|
||||||
|
pub recipe_counts: RcSignal<BTreeMap<String, usize>>,
|
||||||
|
pub staples: RcSignal<Option<Recipe>>,
|
||||||
|
pub recipes: RcSignal<BTreeMap<String, Recipe>>,
|
||||||
|
pub category_map: RcSignal<BTreeMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
recipe_counts: create_rc_signal(BTreeMap::new()),
|
||||||
|
staples: create_rc_signal(None),
|
||||||
|
recipes: create_rc_signal(BTreeMap::new()),
|
||||||
|
category_map: create_rc_signal(BTreeMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn provide_context(cx: Scope) {
|
||||||
|
provide_context(cx, std::rc::Rc::new(Self::new()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_from_context(cx: Scope) -> std::rc::Rc<Self> {
|
||||||
|
use_context::<std::rc::Rc<Self>>(cx).clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_menu_list(&self) -> Vec<(String, usize)> {
|
||||||
|
self.recipe_counts
|
||||||
|
.get()
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), *v))
|
||||||
|
.filter(|(_, v)| *v != 0)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(self))]
|
||||||
|
pub fn get_shopping_list(
|
||||||
|
&self,
|
||||||
|
show_staples: bool,
|
||||||
|
) -> BTreeMap<String, Vec<(Ingredient, BTreeSet<String>)>> {
|
||||||
|
let mut acc = IngredientAccumulator::new();
|
||||||
|
let recipe_counts = self.get_menu_list();
|
||||||
|
for (idx, count) in recipe_counts.iter() {
|
||||||
|
for _ in 0..*count {
|
||||||
|
acc.accumulate_from(
|
||||||
|
self.recipes
|
||||||
|
.get()
|
||||||
|
.get(idx)
|
||||||
|
.expect(&format!("No such recipe id exists: {}", idx)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if show_staples {
|
||||||
|
if let Some(staples) = self.staples.get().as_ref() {
|
||||||
|
acc.accumulate_from(staples);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut ingredients = acc.ingredients();
|
||||||
|
let mut groups = BTreeMap::new();
|
||||||
|
let cat_map = self.category_map.get().clone();
|
||||||
|
for (_, (i, recipes)) in ingredients.iter_mut() {
|
||||||
|
let category = if let Some(cat) = cat_map.get(&i.name) {
|
||||||
|
cat.clone()
|
||||||
|
} else {
|
||||||
|
"other".to_owned()
|
||||||
|
};
|
||||||
|
i.category = category.clone();
|
||||||
|
groups
|
||||||
|
.entry(category)
|
||||||
|
.or_insert(vec![])
|
||||||
|
.push((i.clone(), recipes.clone()));
|
||||||
|
}
|
||||||
|
debug!(?self.category_map);
|
||||||
|
// FIXME(jwall): Sort by categories and names.
|
||||||
|
groups
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_recipe_count_by_index(&self, key: &String) -> Option<usize> {
|
||||||
|
self.recipe_counts.get().get(key).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_recipe_count_by_index(&self, key: &String, count: usize) -> usize {
|
||||||
|
let mut counts = self.recipe_counts.get().as_ref().clone();
|
||||||
|
counts.insert(key.clone(), count);
|
||||||
|
self.recipe_counts.set(counts);
|
||||||
|
count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -11,14 +11,13 @@
|
|||||||
// 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 serde_json::from_str;
|
|
||||||
use sycamore::{futures::spawn_local_scoped, prelude::*};
|
use sycamore::{futures::spawn_local_scoped, prelude::*};
|
||||||
use tracing::{debug, error, instrument};
|
use tracing::{debug, error, instrument};
|
||||||
use web_sys::HtmlDialogElement;
|
use web_sys::HtmlDialogElement;
|
||||||
|
|
||||||
use recipes::parse;
|
use recipes::parse;
|
||||||
|
|
||||||
use crate::{js_lib::get_element_by_id, service::AppService};
|
use crate::js_lib::get_element_by_id;
|
||||||
|
|
||||||
fn get_error_dialog() -> HtmlDialogElement {
|
fn get_error_dialog() -> HtmlDialogElement {
|
||||||
get_element_by_id::<HtmlDialogElement>("error-dialog")
|
get_element_by_id::<HtmlDialogElement>("error-dialog")
|
||||||
@ -42,29 +41,30 @@ fn check_category_text_parses(unparsed: &str, error_text: &Signal<String>) -> bo
|
|||||||
#[instrument]
|
#[instrument]
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Categories<G: Html>(cx: Scope) -> View<G> {
|
pub fn Categories<G: Html>(cx: Scope) -> View<G> {
|
||||||
let app_service = use_context::<AppService>(cx);
|
|
||||||
let save_signal = create_signal(cx, ());
|
let save_signal = create_signal(cx, ());
|
||||||
let error_text = create_signal(cx, String::new());
|
let error_text = create_signal(cx, String::new());
|
||||||
let category_text = create_signal(
|
let category_text: &Signal<String> = create_signal(cx, String::new());
|
||||||
cx,
|
spawn_local_scoped(cx, {
|
||||||
match app_service
|
let store = crate::api::HttpStore::get_from_context(cx);
|
||||||
.get_category_text()
|
async move {
|
||||||
.expect("Failed to get categories.")
|
if let Some(js) = store
|
||||||
{
|
.get_categories()
|
||||||
Some(js) => from_str::<String>(&js)
|
.await
|
||||||
.map_err(|e| format!("{}", e))
|
.expect("Failed to get categories.")
|
||||||
.expect("Failed to parse categories as json"),
|
{
|
||||||
None => String::new(),
|
category_text.set(js);
|
||||||
},
|
};
|
||||||
);
|
}
|
||||||
|
});
|
||||||
|
|
||||||
create_effect(cx, move || {
|
create_effect(cx, move || {
|
||||||
// TODO(jwall): This is triggering on load which is not desired.
|
// TODO(jwall): This is triggering on load which is not desired.
|
||||||
save_signal.track();
|
save_signal.track();
|
||||||
spawn_local_scoped(cx, {
|
spawn_local_scoped(cx, {
|
||||||
|
let store = crate::api::HttpStore::get_from_context(cx);
|
||||||
async move {
|
async move {
|
||||||
// TODO(jwall): Save the categories.
|
// TODO(jwall): Save the categories.
|
||||||
if let Err(e) = app_service
|
if let Err(e) = store
|
||||||
.save_categories(category_text.get_untracked().as_ref().clone())
|
.save_categories(category_text.get_untracked().as_ref().clone())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
@ -15,9 +15,8 @@ use sycamore::{futures::spawn_local_scoped, prelude::*};
|
|||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
use web_sys::HtmlDialogElement;
|
use web_sys::HtmlDialogElement;
|
||||||
|
|
||||||
use crate::{js_lib::get_element_by_id, service::AppService};
|
use crate::{app_state, js_lib::get_element_by_id};
|
||||||
use recipe_store::RecipeEntry;
|
use recipes::{self, RecipeEntry};
|
||||||
use recipes;
|
|
||||||
|
|
||||||
fn get_error_dialog() -> HtmlDialogElement {
|
fn get_error_dialog() -> HtmlDialogElement {
|
||||||
get_element_by_id::<HtmlDialogElement>("error-dialog")
|
get_element_by_id::<HtmlDialogElement>("error-dialog")
|
||||||
@ -45,15 +44,15 @@ fn Editor<G: Html>(cx: Scope, recipe: RecipeEntry) -> View<G> {
|
|||||||
let id = create_signal(cx, recipe.recipe_id().to_owned());
|
let id = create_signal(cx, recipe.recipe_id().to_owned());
|
||||||
let text = create_signal(cx, recipe.recipe_text().to_owned());
|
let text = create_signal(cx, recipe.recipe_text().to_owned());
|
||||||
let error_text = create_signal(cx, String::new());
|
let error_text = create_signal(cx, String::new());
|
||||||
let app_service = use_context::<AppService>(cx);
|
|
||||||
let save_signal = create_signal(cx, ());
|
let save_signal = create_signal(cx, ());
|
||||||
|
|
||||||
create_effect(cx, move || {
|
create_effect(cx, move || {
|
||||||
// TODO(jwall): This is triggering on load which is not desired.
|
// TODO(jwall): This is triggering on load which is not desired.
|
||||||
save_signal.track();
|
save_signal.track();
|
||||||
spawn_local_scoped(cx, {
|
spawn_local_scoped(cx, {
|
||||||
|
let store = crate::api::HttpStore::get_from_context(cx);
|
||||||
async move {
|
async move {
|
||||||
if let Err(e) = app_service
|
if let Err(e) = store
|
||||||
.save_recipes(vec![RecipeEntry(
|
.save_recipes(vec![RecipeEntry(
|
||||||
id.get_untracked().as_ref().clone(),
|
id.get_untracked().as_ref().clone(),
|
||||||
text.get_untracked().as_ref().clone(),
|
text.get_untracked().as_ref().clone(),
|
||||||
@ -133,23 +132,23 @@ fn Steps<'ctx, G: Html>(cx: Scope<'ctx>, steps: &'ctx ReadSignal<Vec<recipes::St
|
|||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Recipe<'ctx, G: Html>(cx: Scope<'ctx>, recipe_id: String) -> View<G> {
|
pub fn Recipe<'ctx, G: Html>(cx: Scope<'ctx>, recipe_id: String) -> View<G> {
|
||||||
let app_service = use_context::<AppService>(cx).clone();
|
let state = app_state::State::get_from_context(cx);
|
||||||
|
let store = crate::api::HttpStore::get_from_context(cx);
|
||||||
let view = create_signal(cx, View::empty());
|
let view = create_signal(cx, View::empty());
|
||||||
let show_edit = create_signal(cx, false);
|
let show_edit = create_signal(cx, false);
|
||||||
// FIXME(jwall): This has too many unwrap() calls
|
if let Some(recipe) = state.recipes.get_untracked().get(&recipe_id) {
|
||||||
if let Some(recipe) = app_service
|
// FIXME(jwall): This should be create_effect rather than create_signal
|
||||||
.fetch_recipes_from_storage()
|
let recipe_text: &Signal<Option<RecipeEntry>> = create_signal(cx, None);
|
||||||
.expect("Failed to fetch recipes from storage")
|
spawn_local_scoped(cx, {
|
||||||
.1
|
let store = store.clone();
|
||||||
.expect(&format!("No recipe counts for recipe id: {}", recipe_id))
|
async move {
|
||||||
.get(&recipe_id)
|
let entry = store
|
||||||
{
|
.get_recipe_text(recipe_id.as_str())
|
||||||
let recipe_text = create_signal(
|
.await
|
||||||
cx,
|
.expect("Failure getting recipe");
|
||||||
app_service
|
recipe_text.set(entry);
|
||||||
.fetch_recipe_text(recipe_id.as_str())
|
}
|
||||||
.expect("No such recipe"),
|
});
|
||||||
);
|
|
||||||
let recipe = create_signal(cx, recipe.clone());
|
let recipe = create_signal(cx, recipe.clone());
|
||||||
let title = create_memo(cx, move || recipe.get().title.clone());
|
let title = create_memo(cx, move || recipe.get().title.clone());
|
||||||
let desc = create_memo(cx, move || {
|
let desc = create_memo(cx, move || {
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
// 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 crate::{components::Recipe, service::AppService};
|
use crate::{app_state, components::Recipe};
|
||||||
|
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
use tracing::{debug, instrument};
|
use tracing::{debug, instrument};
|
||||||
@ -19,8 +19,8 @@ use tracing::{debug, instrument};
|
|||||||
#[instrument]
|
#[instrument]
|
||||||
#[component]
|
#[component]
|
||||||
pub fn RecipeList<G: Html>(cx: Scope) -> View<G> {
|
pub fn RecipeList<G: Html>(cx: Scope) -> View<G> {
|
||||||
let app_service = use_context::<AppService>(cx);
|
let state = app_state::State::get_from_context(cx);
|
||||||
let menu_list = create_memo(cx, || app_service.get_menu_list());
|
let menu_list = create_memo(cx, move || state.get_menu_list());
|
||||||
view! {cx,
|
view! {cx,
|
||||||
h1 { "Recipe List" }
|
h1 { "Recipe List" }
|
||||||
div() {
|
div() {
|
||||||
|
@ -16,7 +16,7 @@ use std::rc::Rc;
|
|||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
use tracing::{debug, instrument};
|
use tracing::{debug, instrument};
|
||||||
|
|
||||||
use crate::service::get_appservice_from_context;
|
use crate::app_state;
|
||||||
|
|
||||||
#[derive(Prop)]
|
#[derive(Prop)]
|
||||||
pub struct RecipeCheckBoxProps<'ctx> {
|
pub struct RecipeCheckBoxProps<'ctx> {
|
||||||
@ -30,7 +30,7 @@ pub struct RecipeCheckBoxProps<'ctx> {
|
|||||||
))]
|
))]
|
||||||
#[component]
|
#[component]
|
||||||
pub fn RecipeSelection<G: Html>(cx: Scope, props: RecipeCheckBoxProps) -> View<G> {
|
pub fn RecipeSelection<G: Html>(cx: Scope, props: RecipeCheckBoxProps) -> View<G> {
|
||||||
let mut app_service = get_appservice_from_context(cx).clone();
|
let state = app_state::State::get_from_context(cx);
|
||||||
// This is total hack but it works around the borrow issues with
|
// This is total hack but it works around the borrow issues with
|
||||||
// the `view!` macro.
|
// the `view!` macro.
|
||||||
let id = Rc::new(props.i);
|
let id = Rc::new(props.i);
|
||||||
@ -38,9 +38,9 @@ pub fn RecipeSelection<G: Html>(cx: Scope, props: RecipeCheckBoxProps) -> View<G
|
|||||||
cx,
|
cx,
|
||||||
format!(
|
format!(
|
||||||
"{}",
|
"{}",
|
||||||
app_service
|
state
|
||||||
.get_recipe_count_by_index(id.as_ref())
|
.get_recipe_count_by_index(id.as_ref())
|
||||||
.unwrap_or_else(|| app_service.set_recipe_count_by_index(id.as_ref(), 0))
|
.unwrap_or_else(|| state.set_recipe_count_by_index(id.as_ref(), 0))
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
let title = props.title.get().clone();
|
let title = props.title.get().clone();
|
||||||
@ -51,9 +51,8 @@ pub fn RecipeSelection<G: Html>(cx: Scope, props: RecipeCheckBoxProps) -> View<G
|
|||||||
div() {
|
div() {
|
||||||
label(for=for_id) { a(href=href) { (*title) } }
|
label(for=for_id) { a(href=href) { (*title) } }
|
||||||
input(type="number", class="item-count-sel", min="0", bind:value=count, name=name, on:change=move |_| {
|
input(type="number", class="item-count-sel", min="0", bind:value=count, name=name, on:change=move |_| {
|
||||||
let mut app_service = app_service.clone();
|
|
||||||
debug!(idx=%id, count=%(*count.get()), "setting recipe count");
|
debug!(idx=%id, count=%(*count.get()), "setting recipe count");
|
||||||
app_service.set_recipe_count_by_index(id.as_ref(), count.get().parse().expect("recipe count isn't a valid usize number"));
|
state.set_recipe_count_by_index(id.as_ref(), count.get().parse().expect("recipe count isn't a valid usize number"));
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,37 +16,35 @@ use sycamore::{futures::spawn_local_scoped, prelude::*};
|
|||||||
use tracing::{error, instrument};
|
use tracing::{error, instrument};
|
||||||
|
|
||||||
use crate::components::recipe_selection::*;
|
use crate::components::recipe_selection::*;
|
||||||
use crate::service::*;
|
use crate::{api::*, app_state};
|
||||||
|
|
||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
#[instrument]
|
#[instrument]
|
||||||
pub fn RecipeSelector<G: Html>(cx: Scope) -> View<G> {
|
pub fn RecipeSelector<G: Html>(cx: Scope) -> View<G> {
|
||||||
let app_service = get_appservice_from_context(cx).clone();
|
|
||||||
let rows = create_memo(cx, move || {
|
let rows = create_memo(cx, move || {
|
||||||
|
let state = app_state::State::get_from_context(cx);
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
if let (_, Some(bt)) = app_service
|
for row in state
|
||||||
.fetch_recipes_from_storage()
|
.recipes
|
||||||
.expect("Unable to fetch recipes from storage")
|
.get()
|
||||||
|
.as_ref()
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| create_signal(cx, (k.clone(), v.clone())))
|
||||||
|
.collect::<Vec<&Signal<(String, Recipe)>>>()
|
||||||
|
.chunks(4)
|
||||||
{
|
{
|
||||||
for row in bt
|
rows.push(create_signal(cx, Vec::from(row)));
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| create_signal(cx, (k.clone(), v.clone())))
|
|
||||||
.collect::<Vec<&Signal<(String, Recipe)>>>()
|
|
||||||
.chunks(4)
|
|
||||||
{
|
|
||||||
rows.push(create_signal(cx, Vec::from(row)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
rows
|
rows
|
||||||
});
|
});
|
||||||
let app_service = get_appservice_from_context(cx).clone();
|
|
||||||
let clicked = create_signal(cx, false);
|
let clicked = create_signal(cx, false);
|
||||||
create_effect(cx, move || {
|
create_effect(cx, move || {
|
||||||
clicked.track();
|
clicked.track();
|
||||||
|
let store = HttpStore::get_from_context(cx);
|
||||||
|
let state = app_state::State::get_from_context(cx);
|
||||||
spawn_local_scoped(cx, {
|
spawn_local_scoped(cx, {
|
||||||
let mut app_service = app_service.clone();
|
|
||||||
async move {
|
async move {
|
||||||
if let Err(err) = app_service.synchronize().await {
|
if let Err(err) = init_page_state(store.as_ref(), state.as_ref()).await {
|
||||||
error!(?err);
|
error!(?err);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -17,8 +17,6 @@ use recipes::{Ingredient, IngredientKey};
|
|||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
use tracing::{debug, instrument};
|
use tracing::{debug, instrument};
|
||||||
|
|
||||||
use crate::service::get_appservice_from_context;
|
|
||||||
|
|
||||||
fn make_ingredients_rows<'ctx, G: Html>(
|
fn make_ingredients_rows<'ctx, G: Html>(
|
||||||
cx: Scope<'ctx>,
|
cx: Scope<'ctx>,
|
||||||
ingredients: &'ctx ReadSignal<Vec<(IngredientKey, (Ingredient, BTreeSet<String>))>>,
|
ingredients: &'ctx ReadSignal<Vec<(IngredientKey, (Ingredient, BTreeSet<String>))>>,
|
||||||
@ -133,7 +131,6 @@ fn make_shopping_table<'ctx, G: Html>(
|
|||||||
#[instrument]
|
#[instrument]
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ShoppingList<G: Html>(cx: Scope) -> View<G> {
|
pub fn ShoppingList<G: Html>(cx: Scope) -> View<G> {
|
||||||
let app_service = get_appservice_from_context(cx);
|
|
||||||
let filtered_keys: RcSignal<BTreeSet<IngredientKey>> = create_rc_signal(BTreeSet::new());
|
let filtered_keys: RcSignal<BTreeSet<IngredientKey>> = create_rc_signal(BTreeSet::new());
|
||||||
let ingredients_map = create_rc_signal(BTreeMap::new());
|
let ingredients_map = create_rc_signal(BTreeMap::new());
|
||||||
let extras = create_signal(
|
let extras = create_signal(
|
||||||
@ -143,9 +140,10 @@ pub fn ShoppingList<G: Html>(cx: Scope) -> View<G> {
|
|||||||
let modified_amts = create_signal(cx, BTreeMap::new());
|
let modified_amts = create_signal(cx, BTreeMap::new());
|
||||||
let show_staples = create_signal(cx, true);
|
let show_staples = create_signal(cx, true);
|
||||||
create_effect(cx, {
|
create_effect(cx, {
|
||||||
|
let state = crate::app_state::State::get_from_context(cx);
|
||||||
let ingredients_map = ingredients_map.clone();
|
let ingredients_map = ingredients_map.clone();
|
||||||
move || {
|
move || {
|
||||||
ingredients_map.set(app_service.get_shopping_list(*show_staples.get()));
|
ingredients_map.set(state.get_shopping_list(*show_staples.get()));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
debug!(ingredients_map=?ingredients_map.get_untracked());
|
debug!(ingredients_map=?ingredients_map.get_untracked());
|
||||||
@ -192,13 +190,16 @@ pub fn ShoppingList<G: Html>(cx: Scope) -> View<G> {
|
|||||||
cloned_extras.push((create_signal(cx, "".to_owned()), create_signal(cx, "".to_owned())));
|
cloned_extras.push((create_signal(cx, "".to_owned()), create_signal(cx, "".to_owned())));
|
||||||
extras.set(cloned_extras.drain(0..).enumerate().collect());
|
extras.set(cloned_extras.drain(0..).enumerate().collect());
|
||||||
})
|
})
|
||||||
input(type="button", value="Reset", class="no-print", on:click=move |_| {
|
input(type="button", value="Reset", class="no-print", on:click={
|
||||||
// TODO(jwall): We should actually pop up a modal here or use a different set of items.
|
let state = crate::app_state::State::get_from_context(cx);
|
||||||
ingredients_map.set(app_service.get_shopping_list(*show_staples.get()));
|
move |_| {
|
||||||
// clear the filter_signal
|
// TODO(jwall): We should actually pop up a modal here or use a different set of items.
|
||||||
filtered_keys.set(BTreeSet::new());
|
ingredients_map.set(state.get_shopping_list(*show_staples.get()));
|
||||||
modified_amts.set(BTreeMap::new());
|
// clear the filter_signal
|
||||||
extras.set(Vec::new());
|
filtered_keys.set(BTreeSet::new());
|
||||||
|
modified_amts.set(BTreeMap::new());
|
||||||
|
extras.set(Vec::new());
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,12 +11,12 @@
|
|||||||
// 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.
|
||||||
|
mod api;
|
||||||
mod app_state;
|
mod app_state;
|
||||||
mod components;
|
mod components;
|
||||||
mod js_lib;
|
mod js_lib;
|
||||||
mod pages;
|
mod pages;
|
||||||
mod router_integration;
|
mod router_integration;
|
||||||
mod service;
|
|
||||||
mod web;
|
mod web;
|
||||||
|
|
||||||
use sycamore::prelude::*;
|
use sycamore::prelude::*;
|
||||||
|
@ -21,7 +21,7 @@ use wasm_bindgen::JsCast;
|
|||||||
use web_sys::Event;
|
use web_sys::Event;
|
||||||
use web_sys::{Element, HtmlAnchorElement};
|
use web_sys::{Element, HtmlAnchorElement};
|
||||||
|
|
||||||
use crate::app_state::AppRoutes;
|
use crate::app_state::Routes;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct BrowserIntegration(RcSignal<(String, String, String)>);
|
pub struct BrowserIntegration(RcSignal<(String, String, String)>);
|
||||||
@ -182,9 +182,9 @@ pub trait NotFound {
|
|||||||
fn not_found() -> Self;
|
fn not_found() -> Self;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NotFound for AppRoutes {
|
impl NotFound for Routes {
|
||||||
fn not_found() -> Self {
|
fn not_found() -> Self {
|
||||||
AppRoutes::NotFound
|
Routes::NotFound
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,30 +192,30 @@ pub trait DeriveRoute {
|
|||||||
fn from(input: &(String, String, String)) -> Self;
|
fn from(input: &(String, String, String)) -> Self;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeriveRoute for AppRoutes {
|
impl DeriveRoute for Routes {
|
||||||
#[instrument]
|
#[instrument]
|
||||||
fn from(input: &(String, String, String)) -> AppRoutes {
|
fn from(input: &(String, String, String)) -> Routes {
|
||||||
debug!(origin=%input.0, path=%input.1, hash=%input.2, "routing");
|
debug!(origin=%input.0, path=%input.1, hash=%input.2, "routing");
|
||||||
let (_origin, path, _hash) = input;
|
let (_origin, path, _hash) = input;
|
||||||
let route = match path.as_str() {
|
let route = match path.as_str() {
|
||||||
"" | "/" | "/ui/" => AppRoutes::default(),
|
"" | "/" | "/ui/" => Routes::default(),
|
||||||
"/ui/login" => AppRoutes::Login,
|
"/ui/login" => Routes::Login,
|
||||||
"/ui/plan" => AppRoutes::Plan,
|
"/ui/plan" => Routes::Plan,
|
||||||
"/ui/cook" => AppRoutes::Cook,
|
"/ui/cook" => Routes::Cook,
|
||||||
"/ui/inventory" => AppRoutes::Inventory,
|
"/ui/inventory" => Routes::Inventory,
|
||||||
"/ui/categories" => AppRoutes::Categories,
|
"/ui/categories" => Routes::Categories,
|
||||||
h => {
|
h => {
|
||||||
if h.starts_with("/ui/recipe/") {
|
if h.starts_with("/ui/recipe/") {
|
||||||
let parts: Vec<&str> = h.split("/").collect();
|
let parts: Vec<&str> = h.split("/").collect();
|
||||||
debug!(?parts, "found recipe path");
|
debug!(?parts, "found recipe path");
|
||||||
if let Some(&"recipe") = parts.get(2) {
|
if let Some(&"recipe") = parts.get(2) {
|
||||||
if let Some(&idx) = parts.get(3) {
|
if let Some(&idx) = parts.get(3) {
|
||||||
return AppRoutes::Recipe(idx.to_owned());
|
return Routes::Recipe(idx.to_owned());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
error!(origin=%input.0, path=%input.1, hash=%input.2, "Path not found");
|
error!(origin=%input.0, path=%input.1, hash=%input.2, "Path not found");
|
||||||
AppRoutes::NotFound
|
Routes::NotFound
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
info!(route=?route, "Route identified");
|
info!(route=?route, "Route identified");
|
||||||
|
@ -1,346 +0,0 @@
|
|||||||
// 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 std::collections::{BTreeMap, BTreeSet};
|
|
||||||
|
|
||||||
use reqwasm;
|
|
||||||
//use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::{from_str, to_string};
|
|
||||||
use sycamore::prelude::*;
|
|
||||||
use tracing::{debug, error, info, instrument, warn};
|
|
||||||
use web_sys::Storage;
|
|
||||||
|
|
||||||
use recipe_store::*;
|
|
||||||
use recipes::{parse, Ingredient, IngredientAccumulator, Recipe};
|
|
||||||
|
|
||||||
use crate::js_lib;
|
|
||||||
|
|
||||||
pub fn get_appservice_from_context(cx: Scope) -> &AppService {
|
|
||||||
use_context::<AppService>(cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO(jwall): We should not be cloning this.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct AppService {
|
|
||||||
recipe_counts: RcSignal<BTreeMap<String, usize>>,
|
|
||||||
staples: RcSignal<Option<Recipe>>,
|
|
||||||
recipes: RcSignal<BTreeMap<String, Recipe>>,
|
|
||||||
category_map: RcSignal<BTreeMap<String, String>>,
|
|
||||||
store: HttpStore,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppService {
|
|
||||||
pub fn new(store: HttpStore) -> Self {
|
|
||||||
Self {
|
|
||||||
recipe_counts: create_rc_signal(BTreeMap::new()),
|
|
||||||
staples: create_rc_signal(None),
|
|
||||||
recipes: create_rc_signal(BTreeMap::new()),
|
|
||||||
category_map: create_rc_signal(BTreeMap::new()),
|
|
||||||
store: store,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_storage(&self) -> Result<Option<Storage>, String> {
|
|
||||||
js_lib::get_storage().map_err(|e| format!("{:?}", e))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_menu_list(&self) -> Vec<(String, usize)> {
|
|
||||||
self.recipe_counts
|
|
||||||
.get()
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), *v))
|
|
||||||
.filter(|(_, v)| *v != 0)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
pub async fn synchronize(&mut self) -> Result<(), String> {
|
|
||||||
info!("Synchronizing Recipes");
|
|
||||||
// TODO(jwall): Make our caching logic using storage more robust.
|
|
||||||
let storage = self
|
|
||||||
.get_storage()?
|
|
||||||
.expect("Unable to get storage for browser session");
|
|
||||||
let recipes = self
|
|
||||||
.store
|
|
||||||
.get_recipes()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("{:?}", e))?;
|
|
||||||
storage
|
|
||||||
.set_item(
|
|
||||||
"recipes",
|
|
||||||
&(to_string(&recipes).map_err(|e| format!("{:?}", e))?),
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("{:?}", e))?;
|
|
||||||
if let Ok((staples, recipes)) = self.fetch_recipes_from_storage() {
|
|
||||||
self.staples.set(staples);
|
|
||||||
if let Some(recipes) = recipes {
|
|
||||||
self.recipes.set(recipes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(rs) = recipes {
|
|
||||||
for r in rs {
|
|
||||||
if !self.recipe_counts.get().contains_key(r.recipe_id()) {
|
|
||||||
self.set_recipe_count_by_index(&r.recipe_id().to_owned(), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
info!("Synchronizing categories");
|
|
||||||
match self.store.get_categories().await {
|
|
||||||
Ok(Some(categories_content)) => {
|
|
||||||
debug!(categories=?categories_content);
|
|
||||||
storage
|
|
||||||
.set_item("categories", &categories_content)
|
|
||||||
.map_err(|e| format!("{:?}", e))?;
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
warn!("There is no category file");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!("{:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_recipe_count_by_index(&self, key: &String) -> Option<usize> {
|
|
||||||
self.recipe_counts.get().get(key).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_recipe_count_by_index(&mut self, key: &String, count: usize) -> usize {
|
|
||||||
let mut counts = self.recipe_counts.get().as_ref().clone();
|
|
||||||
counts.insert(key.clone(), count);
|
|
||||||
self.recipe_counts.set(counts);
|
|
||||||
count
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
pub fn get_shopping_list(
|
|
||||||
&self,
|
|
||||||
show_staples: bool,
|
|
||||||
) -> BTreeMap<String, Vec<(Ingredient, BTreeSet<String>)>> {
|
|
||||||
let mut acc = IngredientAccumulator::new();
|
|
||||||
let recipe_counts = self.get_menu_list();
|
|
||||||
for (idx, count) in recipe_counts.iter() {
|
|
||||||
for _ in 0..*count {
|
|
||||||
acc.accumulate_from(
|
|
||||||
self.recipes
|
|
||||||
.get()
|
|
||||||
.get(idx)
|
|
||||||
.expect(&format!("No such recipe id exists: {}", idx)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if show_staples {
|
|
||||||
if let Some(staples) = self.staples.get().as_ref() {
|
|
||||||
acc.accumulate_from(staples);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut ingredients = acc.ingredients();
|
|
||||||
let mut groups = BTreeMap::new();
|
|
||||||
let cat_map = self.category_map.get().clone();
|
|
||||||
for (_, (i, recipes)) in ingredients.iter_mut() {
|
|
||||||
let category = if let Some(cat) = cat_map.get(&i.name) {
|
|
||||||
cat.clone()
|
|
||||||
} else {
|
|
||||||
"other".to_owned()
|
|
||||||
};
|
|
||||||
i.category = category.clone();
|
|
||||||
groups
|
|
||||||
.entry(category)
|
|
||||||
.or_insert(vec![])
|
|
||||||
.push((i.clone(), recipes.clone()));
|
|
||||||
}
|
|
||||||
debug!(?self.category_map);
|
|
||||||
// FIXME(jwall): Sort by categories and names.
|
|
||||||
groups
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_category_text(&self) -> Result<Option<String>, String> {
|
|
||||||
let storage = self
|
|
||||||
.get_storage()?
|
|
||||||
.expect("Unable to get storage for browser session");
|
|
||||||
storage
|
|
||||||
.get_item("categories")
|
|
||||||
.map_err(|e| format!("{:?}", e))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(self))]
|
|
||||||
pub fn fetch_recipes_from_storage(
|
|
||||||
&self,
|
|
||||||
) -> Result<(Option<Recipe>, Option<BTreeMap<String, Recipe>>), String> {
|
|
||||||
let storage = self.get_storage()?.unwrap();
|
|
||||||
let mut staples = None;
|
|
||||||
match storage
|
|
||||||
.get_item("recipes")
|
|
||||||
.map_err(|e| format!("{:?}", e))?
|
|
||||||
{
|
|
||||||
Some(s) => {
|
|
||||||
let parsed = from_str::<Vec<RecipeEntry>>(&s).map_err(|e| format!("{}", e))?;
|
|
||||||
let mut parsed_map = BTreeMap::new();
|
|
||||||
// TODO(jwall): Utilize the id instead of the index from now on.
|
|
||||||
for r in parsed {
|
|
||||||
let recipe = match parse::as_recipe(&r.recipe_text()) {
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(e) => {
|
|
||||||
error!("Error parsing recipe {}", e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if recipe.title == "Staples" {
|
|
||||||
staples = Some(recipe);
|
|
||||||
} else {
|
|
||||||
parsed_map.insert(r.recipe_id().to_owned(), recipe);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok((staples, Some(parsed_map)))
|
|
||||||
}
|
|
||||||
None => Ok((None, None)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn fetch_recipe_text(&self, id: &str) -> Result<Option<RecipeEntry>, String> {
|
|
||||||
let storage = self
|
|
||||||
.get_storage()?
|
|
||||||
.expect("Unable to get storage for browser session");
|
|
||||||
if let Some(s) = storage
|
|
||||||
.get_item("recipes")
|
|
||||||
.map_err(|e| format!("{:?}", e))?
|
|
||||||
{
|
|
||||||
let parsed = from_str::<Vec<RecipeEntry>>(&s).map_err(|e| format!("{}", e))?;
|
|
||||||
for r in parsed {
|
|
||||||
if r.recipe_id() == id {
|
|
||||||
return Ok(Some(r));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn save_recipes(&self, recipes: Vec<RecipeEntry>) -> Result<(), String> {
|
|
||||||
self.store.save_recipes(recipes).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn save_categories(&self, categories: String) -> Result<(), String> {
|
|
||||||
self.store.save_categories(categories).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Error(String);
|
|
||||||
|
|
||||||
impl From<std::io::Error> for Error {
|
|
||||||
fn from(item: std::io::Error) -> Self {
|
|
||||||
Error(format!("{:?}", item))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Error> for String {
|
|
||||||
fn from(item: Error) -> Self {
|
|
||||||
format!("{:?}", item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<String> for Error {
|
|
||||||
fn from(item: String) -> Self {
|
|
||||||
Error(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<std::string::FromUtf8Error> for Error {
|
|
||||||
fn from(item: std::string::FromUtf8Error) -> Self {
|
|
||||||
Error(format!("{:?}", item))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<reqwasm::Error> for Error {
|
|
||||||
fn from(item: reqwasm::Error) -> Self {
|
|
||||||
Error(format!("{:?}", item))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct HttpStore {
|
|
||||||
root: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl HttpStore {
|
|
||||||
pub fn new(root: String) -> Self {
|
|
||||||
Self { root }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument]
|
|
||||||
async fn get_categories(&self) -> Result<Option<String>, Error> {
|
|
||||||
let mut path = self.root.clone();
|
|
||||||
path.push_str("/categories");
|
|
||||||
let resp = reqwasm::http::Request::get(&path).send().await?;
|
|
||||||
if resp.status() == 404 {
|
|
||||||
debug!("Categories returned 404");
|
|
||||||
Ok(None)
|
|
||||||
} else if resp.status() != 200 {
|
|
||||||
Err(format!("Status: {}", resp.status()).into())
|
|
||||||
} else {
|
|
||||||
debug!("We got a valid response back!");
|
|
||||||
let resp = resp.text().await;
|
|
||||||
Ok(Some(resp.map_err(|e| format!("{}", e))?))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument]
|
|
||||||
async fn get_recipes(&self) -> Result<Option<Vec<RecipeEntry>>, Error> {
|
|
||||||
let mut path = self.root.clone();
|
|
||||||
path.push_str("/recipes");
|
|
||||||
let resp = reqwasm::http::Request::get(&path).send().await?;
|
|
||||||
if resp.status() != 200 {
|
|
||||||
Err(format!("Status: {}", resp.status()).into())
|
|
||||||
} else {
|
|
||||||
debug!("We got a valid response back!");
|
|
||||||
Ok(resp.json().await.map_err(|e| format!("{}", e))?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(recipes), fields(count=recipes.len()))]
|
|
||||||
async fn save_recipes(&self, recipes: Vec<RecipeEntry>) -> Result<(), Error> {
|
|
||||||
let mut path = self.root.clone();
|
|
||||||
path.push_str("/recipes");
|
|
||||||
let resp = reqwasm::http::Request::post(&path)
|
|
||||||
.body(to_string(&recipes).expect("Unable to serialize recipe entries"))
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
if resp.status() != 200 {
|
|
||||||
Err(format!("Status: {}", resp.status()).into())
|
|
||||||
} else {
|
|
||||||
debug!("We got a valid response back!");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[instrument(skip(categories))]
|
|
||||||
async fn save_categories(&self, categories: String) -> Result<(), Error> {
|
|
||||||
let mut path = self.root.clone();
|
|
||||||
path.push_str("/categories");
|
|
||||||
let resp = reqwasm::http::Request::post(&path)
|
|
||||||
.body(to_string(&categories).expect("Unable to encode categories as json"))
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
if resp.status() != 200 {
|
|
||||||
Err(format!("Status: {}", resp.status()).into())
|
|
||||||
} else {
|
|
||||||
debug!("We got a valid response back!");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -12,41 +12,36 @@
|
|||||||
// 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 crate::pages::*;
|
use crate::pages::*;
|
||||||
use crate::{
|
use crate::{api, app_state::*, components::*, router_integration::*};
|
||||||
app_state::*,
|
|
||||||
components::*,
|
|
||||||
router_integration::*,
|
|
||||||
service::{self, AppService},
|
|
||||||
};
|
|
||||||
use tracing::{error, info, instrument};
|
use tracing::{error, info, instrument};
|
||||||
|
|
||||||
use sycamore::{futures::spawn_local_scoped, prelude::*};
|
use sycamore::{futures::spawn_local_scoped, prelude::*};
|
||||||
|
|
||||||
#[instrument]
|
#[instrument]
|
||||||
fn route_switch<G: Html>(cx: Scope, route: &ReadSignal<AppRoutes>) -> View<G> {
|
fn route_switch<G: Html>(cx: Scope, route: &ReadSignal<Routes>) -> View<G> {
|
||||||
// NOTE(jwall): This needs to not be a dynamic node. The rules around
|
// NOTE(jwall): This needs to not be a dynamic node. The rules around
|
||||||
// this are somewhat unclear and underdocumented for Sycamore. But basically
|
// this are somewhat unclear and underdocumented for Sycamore. But basically
|
||||||
// avoid conditionals in the `view!` macro calls here.
|
// avoid conditionals in the `view!` macro calls here.
|
||||||
match route.get().as_ref() {
|
match route.get().as_ref() {
|
||||||
AppRoutes::Plan => view! {cx,
|
Routes::Plan => view! {cx,
|
||||||
PlanPage()
|
PlanPage()
|
||||||
},
|
},
|
||||||
AppRoutes::Inventory => view! {cx,
|
Routes::Inventory => view! {cx,
|
||||||
InventoryPage()
|
InventoryPage()
|
||||||
},
|
},
|
||||||
AppRoutes::Login => view! {cx,
|
Routes::Login => view! {cx,
|
||||||
LoginPage()
|
LoginPage()
|
||||||
},
|
},
|
||||||
AppRoutes::Cook => view! {cx,
|
Routes::Cook => view! {cx,
|
||||||
CookPage()
|
CookPage()
|
||||||
},
|
},
|
||||||
AppRoutes::Recipe(idx) => view! {cx,
|
Routes::Recipe(idx) => view! {cx,
|
||||||
RecipePage(recipe=idx.clone())
|
RecipePage(recipe=idx.clone())
|
||||||
},
|
},
|
||||||
AppRoutes::Categories => view! {cx,
|
Routes::Categories => view! {cx,
|
||||||
CategoryPage()
|
CategoryPage()
|
||||||
},
|
},
|
||||||
AppRoutes::NotFound => view! {cx,
|
Routes::NotFound => view! {cx,
|
||||||
// TODO(Create a real one)
|
// TODO(Create a real one)
|
||||||
PlanPage()
|
PlanPage()
|
||||||
},
|
},
|
||||||
@ -56,24 +51,25 @@ fn route_switch<G: Html>(cx: Scope, route: &ReadSignal<AppRoutes>) -> View<G> {
|
|||||||
#[instrument]
|
#[instrument]
|
||||||
#[component]
|
#[component]
|
||||||
pub fn UI<G: Html>(cx: Scope) -> View<G> {
|
pub fn UI<G: Html>(cx: Scope) -> View<G> {
|
||||||
let app_service = AppService::new(service::HttpStore::new("/api/v1".to_owned()));
|
crate::app_state::State::provide_context(cx);
|
||||||
provide_context(cx, app_service.clone());
|
api::HttpStore::provide_context(cx, "/api/v1".to_owned());
|
||||||
info!("Starting UI");
|
info!("Starting UI");
|
||||||
|
|
||||||
let view = create_signal(cx, View::empty());
|
let view = create_signal(cx, View::empty());
|
||||||
// FIXME(jwall): We need a way to trigger refreshes when required. Turn this
|
// FIXME(jwall): We need a way to trigger refreshes when required. Turn this
|
||||||
// into a create_effect with a refresh signal stored as a context.
|
// into a create_effect with a refresh signal stored as a context.
|
||||||
spawn_local_scoped(cx, {
|
spawn_local_scoped(cx, {
|
||||||
let mut app_service = crate::service::get_appservice_from_context(cx).clone();
|
let store = api::HttpStore::get_from_context(cx);
|
||||||
|
let state = crate::app_state::State::get_from_context(cx);
|
||||||
async move {
|
async move {
|
||||||
if let Err(err) = app_service.synchronize().await {
|
if let Err(err) = api::init_page_state(store.as_ref(), state.as_ref()).await {
|
||||||
error!(?err);
|
error!(?err);
|
||||||
};
|
};
|
||||||
view.set(view! { cx,
|
view.set(view! { cx,
|
||||||
div(class="app") {
|
div(class="app") {
|
||||||
Header()
|
Header()
|
||||||
Router(RouterProps {
|
Router(RouterProps {
|
||||||
route: AppRoutes::Plan,
|
route: Routes::Plan,
|
||||||
route_select: route_switch,
|
route_select: route_switch,
|
||||||
browser_integration: BrowserIntegration::new(),
|
browser_integration: BrowserIntegration::new(),
|
||||||
})
|
})
|
||||||
|
Loading…
x
Reference in New Issue
Block a user