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",
|
||||
"csv",
|
||||
"mime_guess",
|
||||
"recipe-store",
|
||||
"recipes",
|
||||
"rust-embed",
|
||||
"secrecy",
|
||||
@ -1207,7 +1206,6 @@ dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"console_error_panic_hook",
|
||||
"recipe-store",
|
||||
"recipes",
|
||||
"reqwasm",
|
||||
"serde_json",
|
||||
@ -1570,19 +1568,6 @@ dependencies = [
|
||||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "recipe-store"
|
||||
version = "0.2.10"
|
||||
dependencies = [
|
||||
"async-std",
|
||||
"async-trait",
|
||||
"recipes",
|
||||
"reqwasm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "recipes"
|
||||
version = "0.2.10"
|
||||
@ -1591,6 +1576,7 @@ dependencies = [
|
||||
"abortable_parser",
|
||||
"chrono",
|
||||
"num-rational",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = [ "recipes", "kitchen", "web", "recipe-store"]
|
||||
members = [ "recipes", "kitchen", "web" ]
|
||||
|
||||
[patch.crates-io]
|
||||
# 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-subscriber = "0.3.14"
|
||||
recipes = { path = "../recipes" }
|
||||
recipe-store = {path = "../recipe-store" }
|
||||
csv = "1.1.1"
|
||||
rust-embed="6.4.0"
|
||||
mime_guess = "2.0.4"
|
||||
|
@ -18,6 +18,30 @@
|
||||
},
|
||||
"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": {
|
||||
"describe": {
|
||||
"columns": [],
|
||||
|
@ -23,7 +23,7 @@ use axum::{
|
||||
routing::{get, Router},
|
||||
};
|
||||
use mime_guess;
|
||||
use recipe_store::{self, RecipeEntry, RecipeStore};
|
||||
use recipes::RecipeEntry;
|
||||
use rust_embed::RustEmbed;
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::trace::TraceLayer;
|
||||
@ -83,9 +83,34 @@ async fn ui_static_assets(Path(path): Path<String>) -> impl IntoResponse {
|
||||
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]
|
||||
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>>,
|
||||
session: storage::UserIdFromSession,
|
||||
) -> impl IntoResponse {
|
||||
@ -110,7 +135,7 @@ async fn api_recipes(
|
||||
|
||||
#[instrument]
|
||||
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>>,
|
||||
session: storage::UserIdFromSession,
|
||||
) -> 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>>,
|
||||
session: storage::UserIdFromSession,
|
||||
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)]
|
||||
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 app_store = Arc::new(
|
||||
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("/ui/*path", get(ui_static_assets))
|
||||
// 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
|
||||
.route(
|
||||
"/api/v1/categories",
|
||||
@ -237,7 +266,7 @@ pub async fn add_user(
|
||||
.await
|
||||
.expect("Failed to store user creds");
|
||||
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
|
||||
.get_recipes()
|
||||
.await
|
||||
|
@ -17,11 +17,11 @@ use async_std::{
|
||||
path::PathBuf,
|
||||
stream::StreamExt,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::warn;
|
||||
use tracing::{debug, instrument};
|
||||
|
||||
use super::RecipeEntry;
|
||||
|
||||
#[derive(Debug)]
|
||||
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)]
|
||||
pub struct AsyncFileStore {
|
||||
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.
|
||||
impl RecipeStore for AsyncFileStore {
|
||||
impl AsyncFileStore {
|
||||
#[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();
|
||||
category_path.push(&self.path);
|
||||
category_path.push("categories.txt");
|
||||
@ -99,7 +78,7 @@ impl RecipeStore for AsyncFileStore {
|
||||
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();
|
||||
recipe_path.push(&self.path);
|
||||
recipe_path.push("recipes");
|
||||
@ -129,4 +108,19 @@ impl RecipeStore for AsyncFileStore {
|
||||
}
|
||||
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,
|
||||
};
|
||||
use ciborium;
|
||||
use recipes::RecipeEntry;
|
||||
use secrecy::{ExposeSecret, Secret};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{
|
||||
@ -36,14 +37,14 @@ use sqlx::{
|
||||
};
|
||||
use tracing::{debug, error, info, instrument};
|
||||
|
||||
use recipe_store::RecipeEntry;
|
||||
|
||||
mod error;
|
||||
pub mod file_store;
|
||||
|
||||
pub use error::*;
|
||||
|
||||
pub const AXUM_SESSION_COOKIE_NAME: &'static str = "kitchen-session-cookie";
|
||||
|
||||
// TODO(jwall): Should this move to the recipe crate?
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UserId(pub String);
|
||||
|
||||
@ -93,6 +94,12 @@ pub trait APIStore {
|
||||
-> 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]
|
||||
@ -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>>> {
|
||||
// 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
|
||||
|
@ -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]
|
||||
abortable_parser = "~0.2.6"
|
||||
chrono = "~0.4"
|
||||
serde = "1.0.144"
|
||||
|
||||
[dependencies.num-rational]
|
||||
version = "~0.4.0"
|
||||
|
@ -17,6 +17,7 @@ pub mod unit;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use unit::*;
|
||||
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.
|
||||
#[derive(Debug, Clone, PartialEq, PartialOrd, Ord, Eq)]
|
||||
pub struct Recipe {
|
||||
|
@ -14,7 +14,6 @@ crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
recipes = { path = "../recipes" }
|
||||
recipe-store = { path = "../recipe-store" }
|
||||
# This makes debugging panics more tractable.
|
||||
console_error_panic_hook = "0.1.7"
|
||||
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.
|
||||
// See the License for the specific language governing permissions and
|
||||
// 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)]
|
||||
pub enum AppRoutes {
|
||||
pub enum Routes {
|
||||
Plan,
|
||||
Inventory,
|
||||
Cook,
|
||||
@ -23,8 +29,96 @@ pub enum AppRoutes {
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl Default for AppRoutes {
|
||||
impl Default for Routes {
|
||||
fn default() -> Self {
|
||||
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.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
use serde_json::from_str;
|
||||
use sycamore::{futures::spawn_local_scoped, prelude::*};
|
||||
use tracing::{debug, error, instrument};
|
||||
use web_sys::HtmlDialogElement;
|
||||
|
||||
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 {
|
||||
get_element_by_id::<HtmlDialogElement>("error-dialog")
|
||||
@ -42,29 +41,30 @@ fn check_category_text_parses(unparsed: &str, error_text: &Signal<String>) -> bo
|
||||
#[instrument]
|
||||
#[component]
|
||||
pub fn Categories<G: Html>(cx: Scope) -> View<G> {
|
||||
let app_service = use_context::<AppService>(cx);
|
||||
let save_signal = create_signal(cx, ());
|
||||
let error_text = create_signal(cx, String::new());
|
||||
let category_text = create_signal(
|
||||
cx,
|
||||
match app_service
|
||||
.get_category_text()
|
||||
.expect("Failed to get categories.")
|
||||
{
|
||||
Some(js) => from_str::<String>(&js)
|
||||
.map_err(|e| format!("{}", e))
|
||||
.expect("Failed to parse categories as json"),
|
||||
None => String::new(),
|
||||
},
|
||||
);
|
||||
let category_text: &Signal<String> = create_signal(cx, String::new());
|
||||
spawn_local_scoped(cx, {
|
||||
let store = crate::api::HttpStore::get_from_context(cx);
|
||||
async move {
|
||||
if let Some(js) = store
|
||||
.get_categories()
|
||||
.await
|
||||
.expect("Failed to get categories.")
|
||||
{
|
||||
category_text.set(js);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
create_effect(cx, move || {
|
||||
// TODO(jwall): This is triggering on load which is not desired.
|
||||
save_signal.track();
|
||||
spawn_local_scoped(cx, {
|
||||
let store = crate::api::HttpStore::get_from_context(cx);
|
||||
async move {
|
||||
// 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())
|
||||
.await
|
||||
{
|
||||
|
@ -15,9 +15,8 @@ use sycamore::{futures::spawn_local_scoped, prelude::*};
|
||||
use tracing::{debug, error};
|
||||
use web_sys::HtmlDialogElement;
|
||||
|
||||
use crate::{js_lib::get_element_by_id, service::AppService};
|
||||
use recipe_store::RecipeEntry;
|
||||
use recipes;
|
||||
use crate::{app_state, js_lib::get_element_by_id};
|
||||
use recipes::{self, RecipeEntry};
|
||||
|
||||
fn get_error_dialog() -> HtmlDialogElement {
|
||||
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 text = create_signal(cx, recipe.recipe_text().to_owned());
|
||||
let error_text = create_signal(cx, String::new());
|
||||
let app_service = use_context::<AppService>(cx);
|
||||
let save_signal = create_signal(cx, ());
|
||||
|
||||
create_effect(cx, move || {
|
||||
// TODO(jwall): This is triggering on load which is not desired.
|
||||
save_signal.track();
|
||||
spawn_local_scoped(cx, {
|
||||
let store = crate::api::HttpStore::get_from_context(cx);
|
||||
async move {
|
||||
if let Err(e) = app_service
|
||||
if let Err(e) = store
|
||||
.save_recipes(vec![RecipeEntry(
|
||||
id.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]
|
||||
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 show_edit = create_signal(cx, false);
|
||||
// FIXME(jwall): This has too many unwrap() calls
|
||||
if let Some(recipe) = app_service
|
||||
.fetch_recipes_from_storage()
|
||||
.expect("Failed to fetch recipes from storage")
|
||||
.1
|
||||
.expect(&format!("No recipe counts for recipe id: {}", recipe_id))
|
||||
.get(&recipe_id)
|
||||
{
|
||||
let recipe_text = create_signal(
|
||||
cx,
|
||||
app_service
|
||||
.fetch_recipe_text(recipe_id.as_str())
|
||||
.expect("No such recipe"),
|
||||
);
|
||||
if let Some(recipe) = state.recipes.get_untracked().get(&recipe_id) {
|
||||
// FIXME(jwall): This should be create_effect rather than create_signal
|
||||
let recipe_text: &Signal<Option<RecipeEntry>> = create_signal(cx, None);
|
||||
spawn_local_scoped(cx, {
|
||||
let store = store.clone();
|
||||
async move {
|
||||
let entry = store
|
||||
.get_recipe_text(recipe_id.as_str())
|
||||
.await
|
||||
.expect("Failure getting recipe");
|
||||
recipe_text.set(entry);
|
||||
}
|
||||
});
|
||||
let recipe = create_signal(cx, recipe.clone());
|
||||
let title = create_memo(cx, move || recipe.get().title.clone());
|
||||
let desc = create_memo(cx, move || {
|
||||
|
@ -11,7 +11,7 @@
|
||||
// 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 crate::{components::Recipe, service::AppService};
|
||||
use crate::{app_state, components::Recipe};
|
||||
|
||||
use sycamore::prelude::*;
|
||||
use tracing::{debug, instrument};
|
||||
@ -19,8 +19,8 @@ use tracing::{debug, instrument};
|
||||
#[instrument]
|
||||
#[component]
|
||||
pub fn RecipeList<G: Html>(cx: Scope) -> View<G> {
|
||||
let app_service = use_context::<AppService>(cx);
|
||||
let menu_list = create_memo(cx, || app_service.get_menu_list());
|
||||
let state = app_state::State::get_from_context(cx);
|
||||
let menu_list = create_memo(cx, move || state.get_menu_list());
|
||||
view! {cx,
|
||||
h1 { "Recipe List" }
|
||||
div() {
|
||||
|
@ -16,7 +16,7 @@ use std::rc::Rc;
|
||||
use sycamore::prelude::*;
|
||||
use tracing::{debug, instrument};
|
||||
|
||||
use crate::service::get_appservice_from_context;
|
||||
use crate::app_state;
|
||||
|
||||
#[derive(Prop)]
|
||||
pub struct RecipeCheckBoxProps<'ctx> {
|
||||
@ -30,7 +30,7 @@ pub struct RecipeCheckBoxProps<'ctx> {
|
||||
))]
|
||||
#[component]
|
||||
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
|
||||
// the `view!` macro.
|
||||
let id = Rc::new(props.i);
|
||||
@ -38,9 +38,9 @@ pub fn RecipeSelection<G: Html>(cx: Scope, props: RecipeCheckBoxProps) -> View<G
|
||||
cx,
|
||||
format!(
|
||||
"{}",
|
||||
app_service
|
||||
state
|
||||
.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();
|
||||
@ -51,9 +51,8 @@ pub fn RecipeSelection<G: Html>(cx: Scope, props: RecipeCheckBoxProps) -> View<G
|
||||
div() {
|
||||
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 |_| {
|
||||
let mut app_service = app_service.clone();
|
||||
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 crate::components::recipe_selection::*;
|
||||
use crate::service::*;
|
||||
use crate::{api::*, app_state};
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
#[instrument]
|
||||
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 state = app_state::State::get_from_context(cx);
|
||||
let mut rows = Vec::new();
|
||||
if let (_, Some(bt)) = app_service
|
||||
.fetch_recipes_from_storage()
|
||||
.expect("Unable to fetch recipes from storage")
|
||||
for row in state
|
||||
.recipes
|
||||
.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
|
||||
.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.push(create_signal(cx, Vec::from(row)));
|
||||
}
|
||||
rows
|
||||
});
|
||||
let app_service = get_appservice_from_context(cx).clone();
|
||||
let clicked = create_signal(cx, false);
|
||||
create_effect(cx, move || {
|
||||
clicked.track();
|
||||
let store = HttpStore::get_from_context(cx);
|
||||
let state = app_state::State::get_from_context(cx);
|
||||
spawn_local_scoped(cx, {
|
||||
let mut app_service = app_service.clone();
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
@ -17,8 +17,6 @@ use recipes::{Ingredient, IngredientKey};
|
||||
use sycamore::prelude::*;
|
||||
use tracing::{debug, instrument};
|
||||
|
||||
use crate::service::get_appservice_from_context;
|
||||
|
||||
fn make_ingredients_rows<'ctx, G: Html>(
|
||||
cx: Scope<'ctx>,
|
||||
ingredients: &'ctx ReadSignal<Vec<(IngredientKey, (Ingredient, BTreeSet<String>))>>,
|
||||
@ -133,7 +131,6 @@ fn make_shopping_table<'ctx, G: Html>(
|
||||
#[instrument]
|
||||
#[component]
|
||||
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 ingredients_map = create_rc_signal(BTreeMap::new());
|
||||
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 show_staples = create_signal(cx, true);
|
||||
create_effect(cx, {
|
||||
let state = crate::app_state::State::get_from_context(cx);
|
||||
let ingredients_map = ingredients_map.clone();
|
||||
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());
|
||||
@ -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())));
|
||||
extras.set(cloned_extras.drain(0..).enumerate().collect());
|
||||
})
|
||||
input(type="button", value="Reset", class="no-print", on:click=move |_| {
|
||||
// TODO(jwall): We should actually pop up a modal here or use a different set of items.
|
||||
ingredients_map.set(app_service.get_shopping_list(*show_staples.get()));
|
||||
// clear the filter_signal
|
||||
filtered_keys.set(BTreeSet::new());
|
||||
modified_amts.set(BTreeMap::new());
|
||||
extras.set(Vec::new());
|
||||
input(type="button", value="Reset", class="no-print", on:click={
|
||||
let state = crate::app_state::State::get_from_context(cx);
|
||||
move |_| {
|
||||
// TODO(jwall): We should actually pop up a modal here or use a different set of items.
|
||||
ingredients_map.set(state.get_shopping_list(*show_staples.get()));
|
||||
// clear the filter_signal
|
||||
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.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
mod api;
|
||||
mod app_state;
|
||||
mod components;
|
||||
mod js_lib;
|
||||
mod pages;
|
||||
mod router_integration;
|
||||
mod service;
|
||||
mod web;
|
||||
|
||||
use sycamore::prelude::*;
|
||||
|
@ -21,7 +21,7 @@ use wasm_bindgen::JsCast;
|
||||
use web_sys::Event;
|
||||
use web_sys::{Element, HtmlAnchorElement};
|
||||
|
||||
use crate::app_state::AppRoutes;
|
||||
use crate::app_state::Routes;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BrowserIntegration(RcSignal<(String, String, String)>);
|
||||
@ -182,9 +182,9 @@ pub trait NotFound {
|
||||
fn not_found() -> Self;
|
||||
}
|
||||
|
||||
impl NotFound for AppRoutes {
|
||||
impl NotFound for Routes {
|
||||
fn not_found() -> Self {
|
||||
AppRoutes::NotFound
|
||||
Routes::NotFound
|
||||
}
|
||||
}
|
||||
|
||||
@ -192,30 +192,30 @@ pub trait DeriveRoute {
|
||||
fn from(input: &(String, String, String)) -> Self;
|
||||
}
|
||||
|
||||
impl DeriveRoute for AppRoutes {
|
||||
impl DeriveRoute for Routes {
|
||||
#[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");
|
||||
let (_origin, path, _hash) = input;
|
||||
let route = match path.as_str() {
|
||||
"" | "/" | "/ui/" => AppRoutes::default(),
|
||||
"/ui/login" => AppRoutes::Login,
|
||||
"/ui/plan" => AppRoutes::Plan,
|
||||
"/ui/cook" => AppRoutes::Cook,
|
||||
"/ui/inventory" => AppRoutes::Inventory,
|
||||
"/ui/categories" => AppRoutes::Categories,
|
||||
"" | "/" | "/ui/" => Routes::default(),
|
||||
"/ui/login" => Routes::Login,
|
||||
"/ui/plan" => Routes::Plan,
|
||||
"/ui/cook" => Routes::Cook,
|
||||
"/ui/inventory" => Routes::Inventory,
|
||||
"/ui/categories" => Routes::Categories,
|
||||
h => {
|
||||
if h.starts_with("/ui/recipe/") {
|
||||
let parts: Vec<&str> = h.split("/").collect();
|
||||
debug!(?parts, "found recipe path");
|
||||
if let Some(&"recipe") = parts.get(2) {
|
||||
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");
|
||||
AppRoutes::NotFound
|
||||
Routes::NotFound
|
||||
}
|
||||
};
|
||||
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
|
||||
// limitations under the License.
|
||||
use crate::pages::*;
|
||||
use crate::{
|
||||
app_state::*,
|
||||
components::*,
|
||||
router_integration::*,
|
||||
service::{self, AppService},
|
||||
};
|
||||
use crate::{api, app_state::*, components::*, router_integration::*};
|
||||
use tracing::{error, info, instrument};
|
||||
|
||||
use sycamore::{futures::spawn_local_scoped, prelude::*};
|
||||
|
||||
#[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
|
||||
// this are somewhat unclear and underdocumented for Sycamore. But basically
|
||||
// avoid conditionals in the `view!` macro calls here.
|
||||
match route.get().as_ref() {
|
||||
AppRoutes::Plan => view! {cx,
|
||||
Routes::Plan => view! {cx,
|
||||
PlanPage()
|
||||
},
|
||||
AppRoutes::Inventory => view! {cx,
|
||||
Routes::Inventory => view! {cx,
|
||||
InventoryPage()
|
||||
},
|
||||
AppRoutes::Login => view! {cx,
|
||||
Routes::Login => view! {cx,
|
||||
LoginPage()
|
||||
},
|
||||
AppRoutes::Cook => view! {cx,
|
||||
Routes::Cook => view! {cx,
|
||||
CookPage()
|
||||
},
|
||||
AppRoutes::Recipe(idx) => view! {cx,
|
||||
Routes::Recipe(idx) => view! {cx,
|
||||
RecipePage(recipe=idx.clone())
|
||||
},
|
||||
AppRoutes::Categories => view! {cx,
|
||||
Routes::Categories => view! {cx,
|
||||
CategoryPage()
|
||||
},
|
||||
AppRoutes::NotFound => view! {cx,
|
||||
Routes::NotFound => view! {cx,
|
||||
// TODO(Create a real one)
|
||||
PlanPage()
|
||||
},
|
||||
@ -56,24 +51,25 @@ fn route_switch<G: Html>(cx: Scope, route: &ReadSignal<AppRoutes>) -> View<G> {
|
||||
#[instrument]
|
||||
#[component]
|
||||
pub fn UI<G: Html>(cx: Scope) -> View<G> {
|
||||
let app_service = AppService::new(service::HttpStore::new("/api/v1".to_owned()));
|
||||
provide_context(cx, app_service.clone());
|
||||
crate::app_state::State::provide_context(cx);
|
||||
api::HttpStore::provide_context(cx, "/api/v1".to_owned());
|
||||
info!("Starting UI");
|
||||
|
||||
let view = create_signal(cx, View::empty());
|
||||
// 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.
|
||||
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 {
|
||||
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);
|
||||
};
|
||||
view.set(view! { cx,
|
||||
div(class="app") {
|
||||
Header()
|
||||
Router(RouterProps {
|
||||
route: AppRoutes::Plan,
|
||||
route: Routes::Plan,
|
||||
route_select: route_switch,
|
||||
browser_integration: BrowserIntegration::new(),
|
||||
})
|
||||
|
Loading…
x
Reference in New Issue
Block a user