#![expect(clippy::unused_async)] use serde::{Deserialize, Serialize}; use utoipa::ToSchema; 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, /// 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, /// 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!(), }) } }