Refactor state management and http APIs

This commit is contained in:
Jeremy Wall 2022-10-21 14:57:50 -04:00
parent dd7ad63cfb
commit e77af193aa
23 changed files with 562 additions and 530 deletions

16
Cargo.lock generated
View File

@ -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]]

View File

@ -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.

View File

@ -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"

View File

@ -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": [],

View File

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

View File

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

View File

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

View File

@ -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"

View File

@ -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"

View File

@ -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 {

View File

@ -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
View 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(())
}
}
}

View File

@ -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
}
}

View File

@ -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
{ {

View File

@ -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 || {

View File

@ -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() {

View File

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

View File

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

View File

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

View File

@ -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::*;

View File

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

View File

@ -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(())
}
}
}

View File

@ -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(),
}) })