#![expect(clippy::unused_async)] // rocie - An enterprise grocery management system // // Copyright (C) 2026 Benedikt Peetz // SPDX-License-Identifier: GPL-3.0-or-later // // This file is part of Rocie. // // You should have received a copy of the License along with this program. // If not, see . use log::error; 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::{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)] 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)] 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 #[schema(nullable = false)] pub(crate) quantity: Option, } #[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, unit::from_name}; #[derive(thiserror::Error, Debug)] pub(crate) enum Error { #[error("Failed to get a product by id: `{0}`")] ProductAccess(#[from] from_id::Error), #[error("The quantity of a cookware has an unit: `{0}`")] CookwareQtyHasUnit(cooklang::Quantity), #[error("Failed to get a unit by name: `{0}`")] UnitLookup(#[from] from_name::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(ToOwned::to_owned), url: value.url().map(ToOwned::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(app, 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: { let unit_amount = quantity_to_unit_amount(app, value.quantity).await?; unit_amount.map(|ua| ProductAmount { product_id: product.id, amount: ua, }) }, }) } else { Ok(Self::NotRegisteredProduct { name: value.name, quantity: quantity_to_unit_amount(app, value.quantity).await?, }) } } } impl Cookware { async fn from(value: cooklang::Cookware) -> Result { Ok(Self { name: value.name, alias: value.alias, quantity: if let Some(qty) = value.quantity { if qty.unit().is_some() { return Err(conversion::Error::CookwareQtyHasUnit(qty)); } let var_name = match qty.value() { cooklang::Value::Number(number) => number.value() as usize, cooklang::Value::Range { .. } | cooklang::Value::Text(_) => todo!(), }; Some(var_name) } else { None }, note: value.note, }) } } impl Timer { async fn from(app: &App, value: cooklang::Timer) -> Result { Ok(Self { name: value.name, quantity: quantity_to_unit_amount(app, value.quantity).await?, }) } } async fn quantity_to_unit_amount( app: &App, qty: Option, ) -> Result, conversion::Error> { if let Some(qty) = qty { let amount: f64 = match qty.value() { cooklang::Value::Number(number) => number.value(), cooklang::Value::Range { start, end } => { // Let's just assume that more is better than less? // TODO: This should be mapped correctly <2026-03-15> end.value() } cooklang::Value::Text(_) => { // TODO: Is there maybe a better way to deal with, non-parsable quantities? <2026-03-15> return Ok(None); } }; let unit = if let Some(unit_name) = qty.unit() { let unit = Unit::from_name(app, unit_name).await?; if unit.is_none() { error!( "Failed to transfer the quantity for a recipe amount, as the unit is not yet known: {unit_name}" ); } unit } else { return Ok(None); }; Ok(unit.map(|unit| UnitAmount { // TODO: We need to convert the unit to the one that can fit the full f64 value as u32. <2026-03-15> value: amount as u32, unit: unit.id, })) } else { Ok(None) } }