diff --git a/Cargo.lock b/Cargo.lock index fe31ae2..ca4e2cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index 0385c23..add1489 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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. diff --git a/kitchen/Cargo.toml b/kitchen/Cargo.toml index 02c27c2..f640b71 100644 --- a/kitchen/Cargo.toml +++ b/kitchen/Cargo.toml @@ -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" diff --git a/kitchen/sqlx-data.json b/kitchen/sqlx-data.json index 4e621a1..4cfe822 100644 --- a/kitchen/sqlx-data.json +++ b/kitchen/sqlx-data.json @@ -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": [], diff --git a/kitchen/src/web/mod.rs b/kitchen/src/web/mod.rs index 0c1392c..5cd3aab 100644 --- a/kitchen/src/web/mod.rs +++ b/kitchen/src/web/mod.rs @@ -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) -> impl IntoResponse { StaticFile(path.to_owned()) } +#[instrument] +async fn api_recipe_entry( + Extension(store): Extension>, + Extension(app_store): Extension>, + session: storage::UserIdFromSession, + Path(recipe_id): Path, +) -> 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>, + Extension(store): Extension>, Extension(app_store): Extension>, session: storage::UserIdFromSession, ) -> impl IntoResponse { @@ -110,7 +135,7 @@ async fn api_recipes( #[instrument] async fn api_categories( - Extension(store): Extension>, + Extension(store): Extension>, Extension(app_store): Extension>, 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>, session: storage::UserIdFromSession, Json(recipes): Json>, @@ -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 diff --git a/recipe-store/src/lib.rs b/kitchen/src/web/storage/file_store.rs similarity index 76% rename from recipe-store/src/lib.rs rename to kitchen/src/web/storage/file_store.rs index b196212..fc19df6 100644 --- a/recipe-store/src/lib.rs +++ b/kitchen/src/web/storage/file_store.rs @@ -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 for Error { } } -pub trait TenantStoreFactory -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, Error>; - /// Get list of recipe text unparsed. - async fn get_recipes(&self) -> Result>, 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, Error> { + pub async fn get_categories(&self) -> Result, 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>, Error> { + pub async fn get_recipes(&self) -> Result>, 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 + Send>( + &self, + id: S, + ) -> Result, 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); + } + } } diff --git a/kitchen/src/web/storage/mod.rs b/kitchen/src/web/storage/mod.rs index 5d57145..e6c634b 100644 --- a/kitchen/src/web/storage/mod.rs +++ b/kitchen/src/web/storage/mod.rs @@ -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 + Send>( + &self, + user_id: S, + id: S, + ) -> Result>; } #[async_trait] @@ -271,6 +278,40 @@ impl APIStore for SqliteStore { } } + async fn get_recipe_entry_for_user + Send>( + &self, + user_id: S, + id: S, + ) -> Result> { + // 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, + } + 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>> { // 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 diff --git a/recipe-store/Cargo.toml b/recipe-store/Cargo.toml deleted file mode 100644 index 1e19618..0000000 --- a/recipe-store/Cargo.toml +++ /dev/null @@ -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" \ No newline at end of file diff --git a/recipes/Cargo.toml b/recipes/Cargo.toml index a638508..6a75453 100644 --- a/recipes/Cargo.toml +++ b/recipes/Cargo.toml @@ -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" diff --git a/recipes/src/lib.rs b/recipes/src/lib.rs index ce36e96..60feb5c 100644 --- a/recipes/src/lib.rs +++ b/recipes/src/lib.rs @@ -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 { diff --git a/web/Cargo.toml b/web/Cargo.toml index 7d04dd6..8636866 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -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" diff --git a/web/src/api.rs b/web/src/api.rs new file mode 100644 index 0000000..17826b6 --- /dev/null +++ b/web/src/api.rs @@ -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>, +) -> Result<(Option, Option>), 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 for Error { + fn from(item: std::io::Error) -> Self { + Error(format!("{:?}", item)) + } +} + +impl From for String { + fn from(item: Error) -> Self { + format!("{:?}", item) + } +} + +impl From for Error { + fn from(item: String) -> Self { + Error(item) + } +} + +impl From for Error { + fn from(item: std::string::FromUtf8Error) -> Self { + Error(format!("{:?}", item)) + } +} + +impl From 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>(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 { + use_context::>(cx).clone() + } + + #[instrument] + pub async fn get_categories(&self) -> Result, 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>, 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>( + &self, + id: S, + ) -> Result, 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) -> 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(()) + } + } +} diff --git a/web/src/app_state.rs b/web/src/app_state.rs index e6ae7f0..8240c72 100644 --- a/web/src/app_state.rs +++ b/web/src/app_state.rs @@ -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>, + pub staples: RcSignal>, + pub recipes: RcSignal>, + pub category_map: RcSignal>, +} + +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 { + use_context::>(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)>> { + 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 { + 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 + } +} diff --git a/web/src/components/categories.rs b/web/src/components/categories.rs index 2419244..1eeccec 100644 --- a/web/src/components/categories.rs +++ b/web/src/components/categories.rs @@ -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::("error-dialog") @@ -42,29 +41,30 @@ fn check_category_text_parses(unparsed: &str, error_text: &Signal) -> bo #[instrument] #[component] pub fn Categories(cx: Scope) -> View { - let app_service = use_context::(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::(&js) - .map_err(|e| format!("{}", e)) - .expect("Failed to parse categories as json"), - None => String::new(), - }, - ); + let category_text: &Signal = 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 { diff --git a/web/src/components/recipe.rs b/web/src/components/recipe.rs index 1b2a9fe..95b64c3 100644 --- a/web/src/components/recipe.rs +++ b/web/src/components/recipe.rs @@ -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::("error-dialog") @@ -45,15 +44,15 @@ fn Editor(cx: Scope, recipe: RecipeEntry) -> View { 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::(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(cx: Scope<'ctx>, recipe_id: String) -> View { - let app_service = use_context::(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> = 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 || { diff --git a/web/src/components/recipe_list.rs b/web/src/components/recipe_list.rs index c2d236b..26b54f0 100644 --- a/web/src/components/recipe_list.rs +++ b/web/src/components/recipe_list.rs @@ -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(cx: Scope) -> View { - let app_service = use_context::(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() { diff --git a/web/src/components/recipe_selection.rs b/web/src/components/recipe_selection.rs index 2a6cd6b..b8bed49 100644 --- a/web/src/components/recipe_selection.rs +++ b/web/src/components/recipe_selection.rs @@ -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(cx: Scope, props: RecipeCheckBoxProps) -> View { - 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(cx: Scope, props: RecipeCheckBoxProps) -> View(cx: Scope, props: RecipeCheckBoxProps) -> View(cx: Scope) -> View { - 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::>>() + .chunks(4) { - for row in bt - .iter() - .map(|(k, v)| create_signal(cx, (k.clone(), v.clone()))) - .collect::>>() - .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); }; } diff --git a/web/src/components/shopping_list.rs b/web/src/components/shopping_list.rs index 65b2756..507cb08 100644 --- a/web/src/components/shopping_list.rs +++ b/web/src/components/shopping_list.rs @@ -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))>>, @@ -133,7 +131,6 @@ fn make_shopping_table<'ctx, G: Html>( #[instrument] #[component] pub fn ShoppingList(cx: Scope) -> View { - let app_service = get_appservice_from_context(cx); let filtered_keys: RcSignal> = create_rc_signal(BTreeSet::new()); let ingredients_map = create_rc_signal(BTreeMap::new()); let extras = create_signal( @@ -143,9 +140,10 @@ pub fn ShoppingList(cx: Scope) -> View { 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(cx: Scope) -> View { 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()); + } }) } } diff --git a/web/src/lib.rs b/web/src/lib.rs index 35f9eaa..f86cd7b 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -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::*; diff --git a/web/src/router_integration.rs b/web/src/router_integration.rs index c2dd368..1d82af9 100644 --- a/web/src/router_integration.rs +++ b/web/src/router_integration.rs @@ -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"); diff --git a/web/src/service.rs b/web/src/service.rs deleted file mode 100644 index a74fef6..0000000 --- a/web/src/service.rs +++ /dev/null @@ -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::(cx) -} - -// TODO(jwall): We should not be cloning this. -#[derive(Clone, Debug)] -pub struct AppService { - recipe_counts: RcSignal>, - staples: RcSignal>, - recipes: RcSignal>, - category_map: RcSignal>, - 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, 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 { - 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)>> { - 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, 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, Option>), 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::>(&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, 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::>(&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) -> 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 for Error { - fn from(item: std::io::Error) -> Self { - Error(format!("{:?}", item)) - } -} - -impl From for String { - fn from(item: Error) -> Self { - format!("{:?}", item) - } -} - -impl From for Error { - fn from(item: String) -> Self { - Error(item) - } -} - -impl From for Error { - fn from(item: std::string::FromUtf8Error) -> Self { - Error(format!("{:?}", item)) - } -} - -impl From 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, 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>, 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) -> 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(()) - } - } -} diff --git a/web/src/web.rs b/web/src/web.rs index 9456a23..1751d8d 100644 --- a/web/src/web.rs +++ b/web/src/web.rs @@ -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(cx: Scope, route: &ReadSignal) -> View { +fn route_switch(cx: Scope, route: &ReadSignal) -> View { // 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(cx: Scope, route: &ReadSignal) -> View { #[instrument] #[component] pub fn UI(cx: Scope) -> View { - 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(), })