diff --git a/cooked/src/routes.rs b/cooked/src/routes.rs index 7fbc200..9a5c066 100644 --- a/cooked/src/routes.rs +++ b/cooked/src/routes.rs @@ -10,6 +10,7 @@ use actix_web::HttpMessage; use actix_web::{get, post, HttpRequest, HttpResponse, Responder}; use askama::Template; use askama_actix::TemplateToResponse; +use itertools::Itertools; use sea_orm::{ prelude::*, DatabaseTransaction, JoinType, QueryOrder, QuerySelect, TransactionTrait, }; @@ -22,14 +23,249 @@ use std::collections::{BTreeMap, BTreeSet}; #[template(path = "recipe_card.jinja")] struct RecipeCard(entities::recipies::Model); +#[derive(Debug, Template, derive_more::Deref)] +#[template(path = "ingredient_card.jinja")] +struct IngredientCard(crate::types::ingredient_with_recipe_count::Model); + +#[derive(Debug, Template, derive_more::Deref)] +#[template(path = "tag_card.jinja")] +struct TagCard(crate::types::tag_with_recipe_count::Model); + #[derive(Debug, Template)] -#[template(path = "index.jinja", ext = "html")] -struct IndexTemplate { +#[template(path = "ingredient.jinja", ext = "html")] +pub struct IngredientTemplate { recipies: Vec, count: u64, session: Option, page: Page, } +impl IngredientTemplate { + pub async fn load( + db: &DatabaseConnection, + ingredient_id: u32, + page: Option, + session: Option, + ) -> Self { + let count = (entities::prelude::Recipies::find() + .join( + JoinType::InnerJoin, + entities::recipies::Relation::RecipeIngredients.def(), + ) + .filter(entities::recipe_ingredients::Column::IngredientId.eq(ingredient_id)) + .count(&*db) + .await + .unwrap_or_default() as f64 + / 20.0) + .ceil() as u64; + let recipies = entities::prelude::Recipies::find() + .join( + JoinType::InnerJoin, + entities::recipies::Relation::RecipeIngredients.def(), + ) + .filter(entities::recipe_ingredients::Column::IngredientId.eq(ingredient_id)) + .order_by_desc(entities::recipies::Column::Title) + .limit(20) + .offset(page.unwrap_or_default() as u64) + .all(&*db) + .await + .unwrap_or_default(); + + Self { + recipies: recipies.into_iter().map(RecipeCard).collect(), + count, + session, + page: Page::Index, + } + } +} + +#[derive(Debug, Template)] +#[template(path = "ingredients.jinja", ext = "html")] +pub struct IngredientsTemplate { + session: Option, + page: Page, + ingredients: Vec, +} + +impl IngredientsTemplate { + pub async fn load(db: &DatabaseConnection, session: Option) -> Self { + let rel = || { + Ingredients::find() + .join( + JoinType::Join, + entities::ingredients::Relation::RecipeIngredients.def(), + ) + .join( + JoinType::Join, + entities::recipe_ingredients::Relation::Recipies.def(), + ) + }; + let ingredients = rel() + .select_only() + .column(entities::ingredients::Column::Id) + .column(entities::ingredients::Column::Name) + .column_as( + entities::recipe_ingredients::Column::RecipeId.count(), + "recipe_count", + ) + .order_by_asc(entities::ingredients::Column::Name) + .group_by(entities::ingredients::Column::Id) + .into_model::() + .all(&*db) + .await + .inspect_err(|e| tracing::error!("Failed to load ingredients: {e}")) + .unwrap_or_default() + .into_iter() + .map(IngredientCard) + .collect(); + Self { + ingredients, + session, + page: Page::Index, + } + } +} + +#[derive(Debug, Template)] +#[template(path = "tags.jinja", ext = "html")] +pub struct TagsTemplate { + session: Option, + page: Page, + tags: Vec, +} + +impl TagsTemplate { + pub async fn load(db: &DatabaseConnection, session: Option) -> Self { + let rel = || { + Tags::find() + .join(JoinType::Join, entities::tags::Relation::RecipeTags.def()) + .join( + JoinType::Join, + entities::recipe_tags::Relation::Recipies.def(), + ) + }; + let tags = rel() + .select_only() + .column(entities::tags::Column::Id) + .column(entities::tags::Column::Name) + .column_as( + entities::recipe_tags::Column::RecipeId.count(), + "recipe_count", + ) + .order_by_asc(entities::tags::Column::Name) + .group_by(entities::tags::Column::Id) + .into_model::() + .all(&*db) + .await + .inspect_err(|e| tracing::error!("Failed to load ingredients: {e}")) + .unwrap_or_default() + .into_iter() + .map(TagCard) + .collect(); + Self { + tags, + session, + page: Page::Index, + } + } +} +// +// +// +// + +#[derive(Debug, Template)] +#[template(path = "tag.jinja", ext = "html")] +pub struct TagTemplate { + recipies: Vec, + count: u64, + session: Option, + page: Page, +} +impl TagTemplate { + pub async fn load( + db: &DatabaseConnection, + ingredient_id: u32, + page: Option, + session: Option, + ) -> Self { + let count = (entities::prelude::Recipies::find() + .join( + JoinType::InnerJoin, + entities::recipies::Relation::RecipeTags.def(), + ) + .filter(entities::recipe_tags::Column::TagId.eq(ingredient_id)) + .count(&*db) + .await + .unwrap_or_default() as f64 + / 20.0) + .ceil() as u64; + let recipies = entities::prelude::Recipies::find() + .join( + JoinType::InnerJoin, + entities::recipies::Relation::RecipeTags.def(), + ) + .filter(entities::recipe_tags::Column::TagId.eq(ingredient_id)) + .order_by_desc(entities::recipies::Column::Title) + .limit(20) + .offset(page.unwrap_or_default() as u64) + .all(&*db) + .await + .unwrap_or_default(); + + Self { + recipies: recipies.into_iter().map(RecipeCard).collect(), + count, + session, + page: Page::Index, + } + } +} +// +// +// +// + +#[derive(Debug, Template)] +#[template(path = "index.jinja", ext = "html")] +pub struct IndexTemplate { + recipies: Vec, + count: u64, + session: Option, + page: Page, + error: Option, +} + +impl IndexTemplate { + pub async fn load(db: &DatabaseConnection, page: Option, session: Option) -> Self { + let count = (entities::prelude::Recipies::find() + .count(&*db) + .await + .unwrap_or_default() as f64 + / 20.0) + .ceil() as u64; + let recipies = entities::prelude::Recipies::find() + .order_by_desc(entities::recipies::Column::Title) + .limit(20) + .offset(page.unwrap_or_default() as u64) + .all(&*db) + .await + .unwrap_or_default(); + + Self { + recipies: recipies.into_iter().map(RecipeCard).collect(), + count, + session, + page: Page::Index, + error: None, + } + } + + pub fn with_error(mut self, error: String) -> Self { + self.error = Some(error); + self + } +} #[derive(Debug, Template)] #[template(path = "search.jinja", ext = "html")] @@ -72,10 +308,10 @@ struct ImageUpload { struct RecipeForm { title: String, summary: String, - ingeredients: String, + ingredients: String, steps: String, tags: String, - selected_tags: Vec, + selected_tags: Option>, image_url: String, time: Option, author: Option, @@ -99,8 +335,7 @@ struct EditRecipeForm { ingredients: String, steps: String, tags: String, - selected_tags: Vec, - image_url: String, + selected_tags: Option>, time: Option, author: Option, error: Option, @@ -161,10 +396,6 @@ async fn sign_in( .any(|admin| admin.email == payload.email && admin.pass == payload.password); if is_admin { tracing::info!("Valid credentials"); - // let res = session - // .insert(SESSION_KEY, payload.email.clone()) - // .inspect_err(|e| tracing::error!("Failed to save session: {e}")); - // tracing::debug!("Saving session res: {res:?}"); let _s = Identity::login(&req.extensions(), payload.email).expect("Failed to store session"); @@ -256,33 +487,55 @@ async fn index_html( q: actix_web::web::Query, admin: Option, ) -> HttpResponse { - let count = (entities::prelude::Recipies::find() - .count(&**db) + IndexTemplate::load(&**db, q.page, admin.and_then(|session| session.id().ok())) .await - .unwrap_or_default() as f64 - / 20.0) - .ceil() as u64; - let recipies = entities::prelude::Recipies::find() - .order_by_desc(entities::recipies::Column::Title) - .limit(20) - .offset(q.page.unwrap_or_default() as u64) - .all(&**db) + .to_response() +} + +#[get("/ingredients")] +async fn ingredients_list(db: Data, admin: Option) -> HttpResponse { + IngredientsTemplate::load(&**db, admin.and_then(|session| session.id().ok())) .await - .unwrap_or_default(); + .to_response() +} +#[get("/tags")] +async fn tags_list(db: Data, admin: Option) -> HttpResponse { + TagsTemplate::load(&**db, admin.and_then(|session| session.id().ok())) + .await + .to_response() +} +#[get("/tags/{ingredient}")] +async fn by_tag( + db: Data, + q: actix_web::web::Query, + admin: Option, + path: Path, +) -> HttpResponse { + TagTemplate::load( + &**db, + path.into_inner(), + q.page, + admin.and_then(|session| session.id().ok()), + ) + .await + .to_response() +} - let html = IndexTemplate { - recipies: recipies.into_iter().map(RecipeCard).collect(), - count, - session: admin.and_then(|s| s.id().ok()), - page: Page::Index, - } - .render() - .unwrap(); - - HttpResponse::Ok() - .append_header((CONTENT_TYPE, "text/html; charset=utf-8")) - .append_header((CONTENT_LENGTH, html.len())) - .body(html) +#[get("/ingredients/{ingredient}")] +async fn by_ingredient( + db: Data, + q: actix_web::web::Query, + admin: Option, + path: Path, +) -> HttpResponse { + IngredientTemplate::load( + &**db, + path.into_inner(), + q.page, + admin.and_then(|session| session.id().ok()), + ) + .await + .to_response() } #[derive(Debug, Template)] @@ -330,14 +583,11 @@ async fn show( .await .unwrap_or_default(); let ingredients = entities::prelude::Ingredients::find() - .filter( - entities::ingredients::Column::Id.is_in( - recipe_ingredients - .iter() - .map(|i| i.ingredient_id) - .collect::>(), - ), + .join( + JoinType::InnerJoin, + entities::ingredients::Relation::RecipeIngredients.def(), ) + .filter(entities::recipe_ingredients::Column::RecipeId.eq(recipe.id)) .all(db) .await .unwrap_or_default() @@ -348,6 +598,7 @@ async fn show( .into_iter() .filter_map(|ri| { Some(Ingredient { + id: ri.ingredient_id, name: ingredients.get(&ri.ingredient_id)?.to_owned(), qty: ri.qty, unit: ri.unit, @@ -378,7 +629,7 @@ async fn recipe_form(admin: Identity, db: Data) -> RecipeFor RecipeForm { title: "".into(), summary: "".into(), - ingeredients: "".into(), + ingredients: "".into(), steps: "".into(), tags: "".into(), selected_tags: Default::default(), @@ -413,6 +664,7 @@ async fn create_recipe( .map_err(|_| Error::DatabaseError)?; match save_recipe(form, &mut t).await { Err(e) => { + tracing::warn!("Failed to save changes: {e}"); failure.error = Some(e.to_string()); let _ = t.rollback().await; failure.known_tags = Tags::find().all(&**db).await.unwrap_or_default(); @@ -470,171 +722,229 @@ async fn save_recipe(form: RecipeForm, t: &mut DatabaseTransaction) -> Result>(); - - let text_tags = form - .tags - .split(",") - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(String::from) - .collect::>(); - tracing::debug!("Received tags: {text_tags:?}"); - let known = Tags::find() - .filter(entities::tags::Column::Name.is_in(&text_tags)) - .all(&mut *t) - .await - .unwrap_or_default(); - tracing::debug!("Known tags: {known:?}"); - let missing = { - let known = known - .iter() - .map(|model| &model.name) - .collect::>(); - tracing::debug!("Known tag names: {known:?}"); - - let mut missing = Vec::with_capacity(text_tags.len()); - for tag_name in text_tags - .into_iter() - .filter(|tag_name| !known.contains(&tag_name)) - { - tracing::debug!("Creating missing tag: {tag_name:?}"); - missing.push( - Tags::insert(entities::tags::ActiveModel { - name: Set(tag_name), - ..Default::default() - }) - .exec_with_returning(&mut *t) - .await - .inspect_err(|e| tracing::warn!("Failed to find tag: {e}")) - .map_err(|_| Error::SaveTag)?, - ); - } - tracing::debug!("Missing tags: {missing:?}"); - missing - }; - #[cfg(debug_assertions)] - tracing::debug!( - "Tags to attach: {:?}", - known - .clone() - .into_iter() - .map(|tag| tag.id) - .chain(missing.clone().into_iter().map(|tag| tag.id)) - .chain(selected_tags.clone().into_iter()) - .collect::>() - ); - for tag_id in known - .into_iter() - .map(|tag| tag.id) - .chain(missing.into_iter().map(|tag| tag.id)) - .chain(selected_tags.into_iter()) - .collect::>() - { - RecipeTags::insert(entities::recipe_tags::ActiveModel { - recipe_id: Set(recipe_id), - tag_id: Set(tag_id), - ..Default::default() - }) - .exec(&mut *t) - .await - .inspect_err(|e| tracing::error!("Failed to save RecipeTag: {e}")) - .map_err(|_| Error::SaveRecipeTag)?; - } - } + create_tags(recipe_id, form.selected_tags, form.tags, &mut *t).await?; // Steps - { - let steps = crate::utils::parse_steps(&form.steps)? - .into_iter() - .map(|(body, hint)| entities::recipe_steps::ActiveModel { - body: Set(body), - hint: Set(hint), - recipe_id: Set(recipe_id), + create_steps(recipe_id, form.steps, &mut *t).await?; + + // Ingredients + create_ingredients(recipe_id, form.ingredients, &mut *t).await?; + + Ok(recipe_id) +} + +async fn create_ingredients( + recipe_id: i32, + ingredients: String, + t: &mut DatabaseTransaction, +) -> Result<(), Error> { + use sea_orm::ActiveValue::*; + + let ingredients = crate::utils::parse_ingredients(&ingredients)?; + let known = Ingredients::find() + .filter( + entities::ingredients::Column::Name.is_in( + ingredients + .iter() + .map(|(name, ..)| name.to_string()) + .collect::>(), + ), + ) + .all(&mut *t) + .await + .inspect_err(|e| tracing::warn!("Failed to find ingredients: {e}")) + .map_err(|_| Error::SaveRecipeIngeredient)?; + let missing = { + let known = known + .iter() + .map(|model| &model.name) + .collect::>(); + let missing = ingredients + .iter() + .filter(|(name, ..)| !known.contains(name)) + .map(|(name, ..)| entities::ingredients::ActiveModel { + name: Set(name.to_owned()), ..Default::default() }) .collect::>(); - let _steps = RecipeSteps::insert_many(steps) - .exec(&mut *t) - .await - .inspect_err(|e| tracing::error!("Save steps: {e}")) - .map_err(|_| Error::SaveRecipeStep)?; - } - - // Ingredients - { - let ingredients = crate::utils::parse_ingredients(&form.ingeredients)?; - let known = Ingredients::find() - .filter( - entities::ingredients::Column::Name.is_in( - ingredients - .iter() - .map(|(name, ..)| name.to_string()) - .collect::>(), - ), + let mut v = Vec::with_capacity(missing.len()); + for missing in missing { + v.push( + Ingredients::insert(missing) + .exec_with_returning(&mut *t) + .await + .inspect_err(|e| tracing::error!("Failed to create ingredients: {e}")) + .map_err(|_| Error::DatabaseError)?, ) - .all(&mut *t) - .await - .inspect_err(|e| tracing::warn!("Failed to find ingredients: {e}")) - .map_err(|_| Error::SaveRecipeIngeredient)?; - let missing = { - let known = known - .iter() - .map(|model| &model.name) - .collect::>(); - let missing = ingredients - .iter() - .filter(|(name, ..)| !known.contains(name)) - .map(|(name, ..)| entities::ingredients::ActiveModel { - name: Set(name.to_owned()), + } + v + }; + let map = known + .into_iter() + .chain(missing.into_iter()) + .map(|row| (row.name, row.id)) + .collect::>(); + + RecipeIngredients::insert_many( + ingredients + .into_iter() + .filter_map(|(name, unit, qty)| { + Some(entities::recipe_ingredients::ActiveModel { + ingredient_id: Set(*map.get(&name)?), + qty: Set(qty), + unit: Set(unit.unwrap_or_default()), + recipe_id: Set(recipe_id), ..Default::default() }) - .collect::>(); - let mut v = Vec::with_capacity(missing.len()); - for missing in missing { - v.push( - Ingredients::insert(missing) - .exec_with_returning(&mut *t) - .await - .inspect_err(|e| tracing::error!("Failed to create ingredients: {e}")) - .map_err(|_| Error::DatabaseError)?, - ) - } - v - }; - let map = known - .into_iter() - .chain(missing.into_iter()) - .map(|row| (row.name, row.id)) - .collect::>(); + }) + .collect::>(), + ) + .exec(&mut *t) + .await + .inspect_err(|e| tracing::error!("Save ingeredients: {e}")) + .map_err(|_| Error::SaveRecipeIngeredient) + .map(|_| ()) +} - RecipeIngredients::insert_many( - ingredients - .into_iter() - .filter_map(|(name, unit, qty)| { - Some(entities::recipe_ingredients::ActiveModel { - ingredient_id: Set(*map.get(&name)?), - qty: Set(qty), - unit: Set(unit.unwrap_or_default()), - recipe_id: Set(recipe_id), - ..Default::default() - }) - }) - .collect::>(), - ) +async fn create_steps( + recipe_id: i32, + steps: String, + t: &mut DatabaseTransaction, +) -> Result<(), Error> { + use sea_orm::ActiveValue::*; + + let steps = crate::utils::parse_steps(&steps)? + .into_iter() + .map(|(body, hint)| entities::recipe_steps::ActiveModel { + body: Set(body), + hint: Set(hint), + recipe_id: Set(recipe_id), + ..Default::default() + }) + .collect::>(); + let _steps = RecipeSteps::insert_many(steps) .exec(&mut *t) .await - .inspect_err(|e| tracing::error!("Save ingeredients: {e}")) - .map_err(|_| Error::SaveRecipeIngeredient)?; - }; + .inspect_err(|e| tracing::error!("Save steps: {e}")) + .map_err(|_| Error::SaveRecipeStep)?; - Ok(recipe_id) + Ok(()) +} + +async fn delete_steps(recipe_id: i32, t: &mut DatabaseTransaction) -> Result<(), Error> { + RecipeSteps::delete_many() + .filter(entities::recipe_steps::Column::RecipeId.eq(recipe_id)) + .exec(&mut *t) + .await + .inspect_err(|e| tracing::error!("Delete steps: {e}")) + .map_err(|_| Error::SaveRecipeStep)?; + Ok(()) +} + +async fn delete_tags(recipe_id: i32, t: &mut DatabaseTransaction) -> Result<(), Error> { + RecipeTags::delete_many() + .filter(entities::recipe_tags::Column::RecipeId.eq(recipe_id)) + .exec(&mut *t) + .await + .inspect_err(|e| tracing::error!("Delete tags: {e}")) + .map_err(|_| Error::SaveRecipeTag)?; + Ok(()) +} + +async fn delete_ingredients(recipe_id: i32, t: &mut DatabaseTransaction) -> Result<(), Error> { + RecipeIngredients::delete_many() + .filter(entities::recipe_ingredients::Column::RecipeId.eq(recipe_id)) + .exec(&mut *t) + .await + .inspect_err(|e| tracing::error!("Delete ingeredients: {e}")) + .map_err(|_| Error::SaveRecipeIngeredient)?; + Ok(()) +} + +async fn create_tags( + recipe_id: i32, + selected_tags: Option>, + tags: String, + t: &mut DatabaseTransaction, +) -> Result<(), Error> { + use sea_orm::ActiveValue::*; + + tracing::debug!("Selected tags: {:?}", selected_tags); + let selected_tags = selected_tags.unwrap_or_default(); + let selected_tags = selected_tags + .into_iter() + .filter_map(|s| s.parse().ok()) + .collect::>(); + + let text_tags = tags + .split(",") + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect::>(); + tracing::debug!("Received tags: {text_tags:?}"); + let known = Tags::find() + .filter(entities::tags::Column::Name.is_in(&text_tags)) + .all(&mut *t) + .await + .unwrap_or_default(); + tracing::debug!("Known tags: {known:?}"); + let missing = { + let known = known + .iter() + .map(|model| &model.name) + .collect::>(); + tracing::debug!("Known tag names: {known:?}"); + + let mut missing = Vec::with_capacity(text_tags.len()); + for tag_name in text_tags + .into_iter() + .filter(|tag_name| !known.contains(&tag_name)) + { + tracing::debug!("Creating missing tag: {tag_name:?}"); + missing.push( + Tags::insert(entities::tags::ActiveModel { + name: Set(tag_name), + ..Default::default() + }) + .exec_with_returning(&mut *t) + .await + .inspect_err(|e| tracing::warn!("Failed to find tag: {e}")) + .map_err(|_| Error::SaveTag)?, + ); + } + tracing::debug!("Missing tags: {missing:?}"); + missing + }; + #[cfg(debug_assertions)] + tracing::debug!( + "Tags to attach: {:?}", + known + .clone() + .into_iter() + .map(|tag| tag.id) + .chain(missing.clone().into_iter().map(|tag| tag.id)) + .chain(selected_tags.clone().into_iter()) + .collect::>() + ); + for tag_id in known + .into_iter() + .map(|tag| tag.id) + .chain(missing.into_iter().map(|tag| tag.id)) + .chain(selected_tags.into_iter()) + .collect::>() + { + RecipeTags::insert(entities::recipe_tags::ActiveModel { + recipe_id: Set(recipe_id), + tag_id: Set(tag_id), + ..Default::default() + }) + .exec(&mut *t) + .await + .inspect_err(|e| tracing::error!("Failed to save RecipeTag: {e}")) + .map_err(|_| Error::SaveRecipeTag)?; + } + Ok(()) } #[get("/recipe/{id}/edit")] @@ -642,7 +952,7 @@ async fn edit_recipe( id: actix_web::web::Path, admin: Identity, db: Data, -) -> Result { +) -> Result { let id = id.into_inner(); let recipe = match Recipies::find() .filter(entities::recipies::Column::Id.eq(id)) @@ -652,13 +962,17 @@ async fn edit_recipe( Ok(Some(recipe)) => recipe, Ok(_) => todo!("index & not found"), Err(_err) => { - todo!() + return Ok(IndexTemplate::load(&**db, None, admin.id().ok()) + .await + .with_error(_err.to_string()) + .to_response()) } }; // steps let recipe_steps = RecipeSteps::find() .filter(entities::recipe_steps::Column::RecipeId.eq(id)) + .order_by_asc(entities::recipe_steps::Column::Id) .all(&**db) .await .inspect_err(|e| tracing::error!("Failed to load recipe steps for edit recipe: {e}")) @@ -667,6 +981,7 @@ async fn edit_recipe( // tags let recipe_tags = RecipeTags::find() .filter(entities::recipe_tags::Column::RecipeId.eq(id)) + .order_by_asc(entities::recipe_tags::Column::Id) .all(&**db) .await .inspect_err(|e| tracing::error!("Failed to load recipe tags for edit recipe: {e}")) @@ -680,6 +995,7 @@ async fn edit_recipe( // ingredients let recipe_ingredients = RecipeIngredients::find() .filter(entities::recipe_ingredients::Column::RecipeId.eq(id)) + .order_by_asc(entities::recipe_ingredients::Column::Id) .all(&**db) .await .inspect_err(|e| tracing::error!("Failed to load recipe ingredients for edit recipe: {e}")) @@ -690,6 +1006,7 @@ async fn edit_recipe( entities::ingredients::Relation::RecipeIngredients.def(), ) .filter(entities::recipe_ingredients::Column::RecipeId.eq(id)) + .order_by_asc(entities::ingredients::Column::Name) .all(&**db) .await .inspect_err(|e| tracing::error!("Failed to load ingredients for edit recipe: {e}")) @@ -705,9 +1022,12 @@ async fn edit_recipe( }); let steps = recipe_steps .iter() - .map(|recipe| match recipe.hint { - Some(ref hint) => format!("* {body}\n> {hint}", body = recipe.body, hint = hint), - None => format!("* {body}", body = recipe.body), + .map(|recipe| { + let body = recipe.body.replace("\n", " "); + match recipe.hint { + Some(ref hint) => format!("* {body}\n> {hint}", hint = hint), + None => format!("* {body}"), + } }) .collect::>() .join("\n"); @@ -734,27 +1054,33 @@ async fn edit_recipe( .filter_map(|recipe_tag| Some(tags_by_id.get(&recipe_tag.tag_id)?.name.clone())) .collect::>() .join(","), - selected_tags: recipe_tags - .iter() - .map(|rt| rt.recipe_id.to_string()) - .collect(), - image_url: recipe.image_url, + selected_tags: Some( + recipe_tags + .iter() + .map(|rt| rt.recipe_id.to_string()) + .collect(), + ), time: recipe.time.map(|i| i.to_string()), author: recipe.author, error: None, known_tags: tags.clone(), - known_ingredients: ingredients.values().cloned().collect(), + known_ingredients: ingredients + .values() + .cloned() + .sorted_by(|a, b| a.name.cmp(&b.name)) + .collect(), session: admin.id().ok(), page: Page::Index, - }) + } + .to_response()) } -#[post("/recipe/{id}/edit")] +#[post("/recipe/{id}/update")] async fn update_recipe( _id: actix_web::web::Path, admin: Identity, db: Data, - form: QsForm, + form: QsForm, ) -> Result { let mut form = form.into_inner(); form.session = admin.id().ok(); @@ -767,7 +1093,8 @@ async fn update_recipe( .await .inspect_err(|e| tracing::error!("Create recipe transaction: {e}")) .map_err(|_| Error::DatabaseError)?; - match save_recipe(form, &mut t).await { + + match save_recipe_changes(form, &mut t).await { Err(e) => { failure.error = Some(e.to_string()); let _ = t.rollback().await; @@ -777,15 +1104,6 @@ async fn update_recipe( } Ok(recipe_id) => { let _ = t.commit().await; - let Ok(_) = tokio::fs::copy( - format!("/tmp{}", failure.image_url), - format!(".{}", failure.image_url), - ) - .await - .inspect_err(|e| tracing::error!("Failed to copy {}: {e}", failure.image_url)) else { - tracing::error!("Failed to copy file: {}", failure.image_url); - return Ok(HttpResponse::InternalServerError().finish()); - }; Ok(HttpResponse::SeeOther() .append_header(("location", format!("/recipe/{recipe_id}").as_str())) .finish()) @@ -793,6 +1111,52 @@ async fn update_recipe( } } +async fn save_recipe_changes( + form: EditRecipeForm, + t: &mut DatabaseTransaction, +) -> Result { + use crate::entities::recipies::ActiveModel as RAM; + use sea_orm::ActiveValue::*; + + let mut summary = String::new(); + let parser = pulldown_cmark::Parser::new(&form.summary); + pulldown_cmark::html::push_html(&mut summary, parser); + + let time = match form.time { + Some(s) => Some( + humantime::parse_duration(&s) + .inspect_err(|e| tracing::warn!("Invalid duration format: {e}")) + .map_err(|_| Error::InvalidTime)? + .as_secs() as i32, + ), + None => None, + }; + + delete_steps(form.id, &mut *t).await?; + create_steps(form.id, form.steps, &mut *t).await?; + + delete_tags(form.id, &mut *t).await?; + create_tags(form.id, form.selected_tags, form.tags, &mut *t).await?; + + delete_ingredients(form.id, &mut *t).await?; + create_ingredients(form.id, form.ingredients, &mut *t).await?; + + Recipies::update(RAM { + id: Set(form.id), + title: Set(form.title), + author: Set(form.author), + time: Set(time), + summary: Set(Some(summary)), + ..Default::default() + }) + .exec(&mut *t) + .await + .inspect_err(|e| tracing::error!("Failed to save Recipe: {e}")) + .map_err(|_| Error::SaveRecipe)?; + + Ok(form.id) +} + #[get("/styles.css")] async fn styles_css() -> HttpResponse { HttpResponse::Ok() @@ -843,5 +1207,9 @@ pub fn configure(config: &mut actix_web::web::ServiceConfig) { .service(recipe_form) .service(create_recipe) .service(recipe_image_upload) + .service(by_ingredient) + .service(ingredients_list) + .service(by_tag) + .service(tags_list) .service(Files::new("/assets", "./assets")); } diff --git a/cooked/src/types.rs b/cooked/src/types.rs index 729c088..e92e4d5 100644 --- a/cooked/src/types.rs +++ b/cooked/src/types.rs @@ -7,6 +7,7 @@ pub type User = String; #[derive(Debug)] pub struct Ingredient { + pub id: i32, pub qty: i32, pub name: String, pub unit: String, @@ -89,3 +90,38 @@ impl FromStr for Admins { )) } } + +pub mod ingredient_with_recipe_count { + use sea_orm::entity::prelude::*; + use serde::{Deserialize, Serialize}; + + #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] + #[sea_orm(table_name = "ingredients")] + pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + pub recipe_count: Option, + } + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + + impl ActiveModelBehavior for ActiveModel {} +} +pub mod tag_with_recipe_count { + use sea_orm::entity::prelude::*; + use serde::{Deserialize, Serialize}; + + #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] + #[sea_orm(table_name = "tags")] + pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, + pub recipe_count: Option, + } + #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] + pub enum Relation {} + + impl ActiveModelBehavior for ActiveModel {} +} diff --git a/cooked/src/utils.rs b/cooked/src/utils.rs index f562df4..47f1a7d 100644 --- a/cooked/src/utils.rs +++ b/cooked/src/utils.rs @@ -12,10 +12,16 @@ pub fn parse_steps(s: &str) -> Result)>, Error> { v.push((line.replacen("*", "", 1).trim().to_string(), None)); } Some('>') => { - v.last_mut().ok_or(InvalidStepList)?.1 = - Some(line.replacen(">", "", 1).trim().to_string()); + v.last_mut() + .ok_or(InvalidStepList) + .inspect_err(|_| tracing::warn!("Hint must be part of step."))? + .1 = Some(line.replacen(">", "", 1).trim().to_string()); + } + None => return Ok(v), + char => { + tracing::warn!("Invalid step starting character: {char:?}"); + return Err(InvalidStepList); } - _ => return Err(InvalidStepList), }; Ok(v) diff --git a/cooked/templates/ingredient.jinja b/cooked/templates/ingredient.jinja new file mode 100644 index 0000000..9f31bbe --- /dev/null +++ b/cooked/templates/ingredient.jinja @@ -0,0 +1,28 @@ +{% extends "base.jinja" %} + +{% block content %} +
+ {{ TopBar::new(session)|safe }} + +
+

Recipes

+
+ +
+
+ {% for recipe in recipies %} + {{ recipe|safe }} + {% endfor %} +
+
+ {% match count %} + {% when 0 %} + {% when _ %} + {% for page in 0..count %} + {{page}} + {% endfor %} + {% endmatch %} +
+
+
+{% endblock %} diff --git a/cooked/templates/ingredient_card.jinja b/cooked/templates/ingredient_card.jinja new file mode 100644 index 0000000..a72faee --- /dev/null +++ b/cooked/templates/ingredient_card.jinja @@ -0,0 +1,12 @@ + +
+ {{name}} +
+ {% match recipe_count %} + {% when Some with (n) %} + {{n}} recipies + {% else %} + {% endmatch %} +
+
+
diff --git a/cooked/templates/ingredients.jinja b/cooked/templates/ingredients.jinja new file mode 100644 index 0000000..b7dfb93 --- /dev/null +++ b/cooked/templates/ingredients.jinja @@ -0,0 +1,19 @@ +{% extends "base.jinja" %} + +{% block content %} +
+ {{ TopBar::new(session)|safe }} + +
+

Ingredients

+
+ +
+
+ {% for ingredient in ingredients %} + {{ ingredient|safe }} + {% endfor %} +
+
+
+{% endblock %} diff --git a/cooked/templates/nav.jinja b/cooked/templates/nav.jinja index cb7207d..0cc1a5d 100644 --- a/cooked/templates/nav.jinja +++ b/cooked/templates/nav.jinja @@ -48,7 +48,7 @@
  • - + Ingeredients Summary supports Markdown

    -
    Ingeredients: +
    @@ -84,11 +84,17 @@ {% for tag in known_tags %} {% endfor %} diff --git a/cooked/templates/recipies/show.jinja b/cooked/templates/recipies/show.jinja index 8687939..bfb9180 100644 --- a/cooked/templates/recipies/show.jinja +++ b/cooked/templates/recipies/show.jinja @@ -69,7 +69,7 @@

    {% for tag in tags %} - + {{ tag.name }} {% endfor %} @@ -82,7 +82,7 @@

    {% for ingredient in ingredients %} - + {{ ingredient.name }} {% endfor %} diff --git a/cooked/templates/recipies/update_form.jinja b/cooked/templates/recipies/update_form.jinja index cea73b8..3ae3178 100644 --- a/cooked/templates/recipies/update_form.jinja +++ b/cooked/templates/recipies/update_form.jinja @@ -11,12 +11,19 @@

    Create recipe

    +
    + {% match error %} + {% when Some with (text) %} +
    {{text}}
    + {% else %} + {% endmatch %} +
    +
    diff --git a/cooked/templates/tag.jinja b/cooked/templates/tag.jinja new file mode 100644 index 0000000..9f31bbe --- /dev/null +++ b/cooked/templates/tag.jinja @@ -0,0 +1,28 @@ +{% extends "base.jinja" %} + +{% block content %} +
    + {{ TopBar::new(session)|safe }} + +
    +

    Recipes

    +
    + +
    +
    + {% for recipe in recipies %} + {{ recipe|safe }} + {% endfor %} +
    +
    + {% match count %} + {% when 0 %} + {% when _ %} + {% for page in 0..count %} + {{page}} + {% endfor %} + {% endmatch %} +
    +
    +
    +{% endblock %} diff --git a/cooked/templates/tag_card.jinja b/cooked/templates/tag_card.jinja new file mode 100644 index 0000000..89777ea --- /dev/null +++ b/cooked/templates/tag_card.jinja @@ -0,0 +1,12 @@ + +
    + {{name}} +
    + {% match recipe_count %} + {% when Some with (n) %} + {{n}} recipies + {% else %} + {% endmatch %} +
    +
    +
    diff --git a/cooked/templates/tags.jinja b/cooked/templates/tags.jinja new file mode 100644 index 0000000..b12f5e6 --- /dev/null +++ b/cooked/templates/tags.jinja @@ -0,0 +1,19 @@ +{% extends "base.jinja" %} + +{% block content %} +
    + {{ TopBar::new(session)|safe }} + +
    +

    Tags

    +
    + +
    +
    + {% for tag in tags %} + {{ tag|safe }} + {% endfor %} +
    +
    +
    +{% endblock %}