From e5f90f4474cb96a78080395980283e4b2ce40214 Mon Sep 17 00:00:00 2001 From: Benedikt Peetz Date: Sun, 15 Feb 2026 22:24:32 +0100 Subject: feat(treewide): Add recipes and user handling --- crates/rocie-server/src/storage/sql/recipe.rs | 383 +++++++++++++++++++++++++- 1 file changed, 378 insertions(+), 5 deletions(-) (limited to 'crates/rocie-server/src/storage/sql/recipe.rs') diff --git a/crates/rocie-server/src/storage/sql/recipe.rs b/crates/rocie-server/src/storage/sql/recipe.rs index 835d98b..1fc3b56 100644 --- a/crates/rocie-server/src/storage/sql/recipe.rs +++ b/crates/rocie-server/src/storage/sql/recipe.rs @@ -1,18 +1,391 @@ -use std::path::PathBuf; +#![expect(clippy::unused_async)] use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use crate::storage::sql::mk_id; +use crate::{ + app::App, + storage::sql::{ + mk_id, + product::{Product, ProductId}, + product_amount::ProductAmount, + recipe_parent::RecipeParentId, + unit::UnitAmount, + }, +}; +macro_rules! for_in { + ($value:expr, |$name:ident| $closoure:expr) => {{ + let fun = async |$name| $closoure; + + let mut output = Vec::with_capacity($value.len()); + for $name in $value { + output.push(fun($name).await?); + } + output + }}; +} + +/// An recipe. +/// +/// These are transparently expressed in cooklang. #[derive(ToSchema, Debug, Clone, Serialize, Deserialize)] pub(crate) struct Recipe { + /// The unique id of this recipe. pub(crate) id: RecipeId, - #[schema(value_type = String)] - pub(crate) path: PathBuf, + /// The name of the recipe. + /// + /// This should be globally unique, to make searching easier for the user. + pub(crate) name: String, + + /// The parent this recipe has. + /// + /// This is effectively it's anchor in the recipe DAG. + /// None means, that it has no parents and as such is in the toplevel. + #[schema(nullable = false)] + pub(crate) parent: Option, - pub(crate) content: String, + /// The actual content of this recipe. + pub(crate) content: CooklangRecipe, } mk_id!(RecipeId and RecipeIdStub); + +/// A complete recipe +/// +/// The recipes do not have a name. You give it externally or maybe use +/// some metadata key. +/// +/// The recipe returned from parsing is a [`ScalableRecipe`]. +/// +/// The difference between [`ScalableRecipe`] and [`ScaledRecipe`] is in the +/// values of the quantities of ingredients, cookware and timers. The parser +/// returns [`ScalableValue`]s and after scaling, these are converted to regular +/// [`Value`]s. +#[derive(ToSchema, Debug, Serialize, Deserialize, PartialEq, Clone)] +pub(crate) struct CooklangRecipe { + /// Metadata as read from preamble + pub(crate) metadata: Metadata, + + /// Each of the sections + /// + /// If no sections declared, a section without name + /// is the default. + pub(crate) sections: Vec
, + + /// All the ingredients + pub(crate) ingredients: Vec, + + /// All the cookware + pub(crate) cookware: Vec, + + /// All the timers + pub(crate) timers: Vec, +} + +/// A section holding steps +#[derive(Debug, ToSchema, Default, Serialize, Deserialize, PartialEq, Clone)] +pub(crate) struct Section { + /// Name of the section + #[schema(nullable = false)] + pub(crate) name: Option, + + /// Content inside + pub(crate) content: Vec, +} + +/// Each type of content inside a section +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)] +#[serde(tag = "type", content = "value", rename_all = "camelCase")] +pub(crate) enum Content { + /// A step + Step(Step), + + /// A paragraph of just text, no instructions + Text(String), +} + +/// A step holding step [`Item`]s +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)] +pub(crate) struct Step { + /// [`Item`]s inside + pub(crate) items: Vec, + + /// Step number + /// + /// The step numbers start at 1 in each section and increase with non + /// text step. + pub(crate) number: u32, +} + +/// A step item +/// +/// Except for [`Item::Text`], the value is the index where the item is located +/// in it's corresponding [`Vec`] in the [`Recipe`]. +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)] +#[serde(tag = "type", rename_all = "camelCase")] +pub(crate) enum Item { + /// Just plain text + Text { + value: String, + }, + Ingredient { + index: usize, + }, + Cookware { + index: usize, + }, + Timer { + index: usize, + }, + InlineQuantity { + index: usize, + }, +} + +/// A recipe ingredient +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)] +pub(crate) enum Ingredient { + /// This ingredient is a registered product. + RegisteredProduct { + id: ProductId, + + /// Alias + #[schema(nullable = false)] + alias: Option, + + /// Quantity + #[schema(nullable = false)] + quantity: Option, + }, + + /// This ingredient is a not yet registered product. + NotRegisteredProduct { + name: String, + + /// Quantity + #[schema(nullable = false)] + quantity: Option, + }, + + /// This ingredient is a reference to another recipe. + RecipeReference { id: RecipeId }, +} + +/// A recipe cookware item +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)] +pub(crate) struct Cookware { + /// Name + pub(crate) name: String, + + /// Alias + #[schema(nullable = false)] + pub(crate) alias: Option, + + /// Amount needed + /// + /// Note that this is a value, not a quantity, so it doesn't have units. + #[schema(nullable = false)] + pub(crate) quantity: Option, + + /// Note + #[schema(nullable = false)] + pub(crate) note: Option, +} + +/// A recipe timer +/// +/// If created from parsing, at least one of the fields is guaranteed to be +/// [`Some`]. +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)] +pub(crate) struct Timer { + /// Name + #[schema(nullable = false)] + pub(crate) name: Option, + + /// Time quantity + pub(crate) quantity: UnitAmount, +} + +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)] +pub(crate) struct Metadata { + #[schema(nullable = false)] + title: Option, + + #[schema(nullable = false)] + description: Option, + + #[schema(nullable = false)] + tags: Option>, + + #[schema(nullable = false)] + author: Option, + + #[schema(nullable = false)] + source: Option, + // time: Option, + // prep_time: Option, + // cook_time: Option, + // servings: Option, + // difficulty: Option, + // cuisine: Option, + // diet: Option, + // images: Option, + // locale: Option, + + // other: serde_yaml::Mapping, +} + +pub(crate) mod conversion { + use crate::storage::sql::get::product::from_id; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to get a product by id: `{0}`")] + ProductAccess(#[from] from_id::Error), + } +} + +#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)] +pub(crate) struct NameAndUrl { + #[schema(nullable = false)] + name: Option, + + #[schema(nullable = false)] + url: Option, +} +impl NameAndUrl { + async fn from(value: cooklang::metadata::NameAndUrl) -> Result { + Ok(Self { + name: value.name().map(|v| v.to_owned()), + url: value.url().map(|v| v.to_owned()), + }) + } +} + +impl CooklangRecipe { + pub(crate) async fn from( + app: &App, + value: cooklang::Recipe, + ) -> Result { + Ok(Self { + metadata: Metadata::from(value.metadata).await?, + sections: for_in!(value.sections, |s| Section::from(s).await), + ingredients: for_in!(value.ingredients, |i| Ingredient::from(app, i).await), + cookware: for_in!(value.cookware, |c| Cookware::from(c).await), + timers: for_in!(value.timers, |t| Timer::from(t).await), + }) + } +} + +impl Metadata { + async fn from(value: cooklang::Metadata) -> Result { + let author = if let Some(author) = value.author() { + Some(NameAndUrl::from(author).await?) + } else { + None + }; + let source = if let Some(source) = value.source() { + Some(NameAndUrl::from(source).await?) + } else { + None + }; + + Ok(Self { + title: value.title().map(str::to_owned), + description: value.description().map(str::to_owned), + tags: value + .tags() + .map(|vec| vec.into_iter().map(|c| c.to_string()).collect()), + author, + source, + // time: value.time(&Converter::bundled()).map(|t| t.total()), + // prep_time: todo!(), + // cook_time: todo!(), + // servings: todo!(), + // difficulty: todo!(), + // cuisine: todo!(), + // diet: todo!(), + // images: todo!(), + // locale: todo!(), + // other: value.map_filtered()., + }) + } +} + +impl Section { + async fn from(value: cooklang::Section) -> Result { + Ok(Self { + name: value.name, + content: for_in!(value.content, |c| Content::from(c).await), + }) + } +} +impl Content { + async fn from(value: cooklang::Content) -> Result { + match value { + cooklang::Content::Step(step) => Ok(Self::Step(Step::from(step).await?)), + cooklang::Content::Text(text) => Ok(Self::Text(text)), + } + } +} +impl Step { + async fn from(value: cooklang::Step) -> Result { + Ok(Self { + items: for_in!(value.items, |item| Item::from(item).await), + number: value.number, + }) + } +} +impl Item { + async fn from(value: cooklang::Item) -> Result { + match value { + cooklang::Item::Text { value } => Ok(Self::Text { value }), + cooklang::Item::Ingredient { index } => Ok(Self::Ingredient { index }), + cooklang::Item::Cookware { index } => Ok(Self::Cookware { index }), + cooklang::Item::Timer { index } => Ok(Self::Timer { index }), + cooklang::Item::InlineQuantity { index } => Ok(Self::InlineQuantity { index }), + } + } +} +impl Ingredient { + async fn from(app: &App, value: cooklang::Ingredient) -> Result { + if value.name.starts_with('/') { + Ok(Self::RecipeReference { id: todo!() }) + } else if let Some(product) = Product::from_name(&app, value.name.as_str()).await? { + Ok(Self::RegisteredProduct { + id: product.id, + alias: value.alias, + quantity: None, + }) + } else { + Ok(Self::NotRegisteredProduct { + name: value.name, + quantity: None, + }) + } + } +} +impl Cookware { + async fn from(value: cooklang::Cookware) -> Result { + Ok(Self { + name: value.name, + alias: value.alias, + quantity: value.quantity.map(|q| match q.value() { + cooklang::Value::Number(number) => number.value() as usize, + cooklang::Value::Range { start, end } => todo!(), + cooklang::Value::Text(_) => todo!(), + }), + note: value.note, + }) + } +} +impl Timer { + async fn from(value: cooklang::Timer) -> Result { + Ok(Self { + name: value.name, + quantity: todo!(), + }) + } +} -- cgit 1.4.1