about summary refs log tree commit diff stats
path: root/crates/rocie-server/src/storage/sql/recipe.rs
diff options
context:
space:
mode:
authorBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-02-15 22:24:32 +0100
committerBenedikt Peetz <benedikt.peetz@b-peetz.de>2026-02-15 22:25:06 +0100
commite5f90f4474cb96a78080395980283e4b2ce40214 (patch)
treecaac3300795eae8e4cb1ee3c1c4bf85cd5950402 /crates/rocie-server/src/storage/sql/recipe.rs
parentchore(treewide): Update (diff)
downloadserver-e5f90f4474cb96a78080395980283e4b2ce40214.zip
feat(treewide): Add recipes and user handling
Diffstat (limited to 'crates/rocie-server/src/storage/sql/recipe.rs')
-rw-r--r--crates/rocie-server/src/storage/sql/recipe.rs383
1 files changed, 378 insertions, 5 deletions
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<RecipeParentId>,
 
-    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<Section>,
+
+    /// All the ingredients
+    pub(crate) ingredients: Vec<Ingredient>,
+
+    /// All the cookware
+    pub(crate) cookware: Vec<Cookware>,
+
+    /// All the timers
+    pub(crate) timers: Vec<Timer>,
+}
+
+/// 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<String>,
+
+    /// Content inside
+    pub(crate) content: Vec<Content>,
+}
+
+/// 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<Item>,
+
+    /// 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<String>,
+
+        /// Quantity
+        #[schema(nullable = false)]
+        quantity: Option<ProductAmount>,
+    },
+
+    /// This ingredient is a not yet registered product.
+    NotRegisteredProduct {
+        name: String,
+
+        /// Quantity
+        #[schema(nullable = false)]
+        quantity: Option<UnitAmount>,
+    },
+
+    /// 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<String>,
+
+    /// Amount needed
+    ///
+    /// Note that this is a value, not a quantity, so it doesn't have units.
+    #[schema(nullable = false)]
+    pub(crate) quantity: Option<usize>,
+
+    /// Note
+    #[schema(nullable = false)]
+    pub(crate) note: Option<String>,
+}
+
+/// 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<String>,
+
+    /// Time quantity
+    pub(crate) quantity: UnitAmount,
+}
+
+#[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)]
+pub(crate) struct Metadata {
+    #[schema(nullable = false)]
+    title: Option<String>,
+
+    #[schema(nullable = false)]
+    description: Option<String>,
+
+    #[schema(nullable = false)]
+    tags: Option<Vec<String>>,
+
+    #[schema(nullable = false)]
+    author: Option<NameAndUrl>,
+
+    #[schema(nullable = false)]
+    source: Option<NameAndUrl>,
+    // time: Option<serde_yaml::Value>,
+    // prep_time: Option<serde_yaml::Value>,
+    // cook_time: Option<serde_yaml::Value>,
+    // servings: Option<serde_yaml::Value>,
+    // difficulty: Option<serde_yaml::Value>,
+    // cuisine: Option<serde_yaml::Value>,
+    // diet: Option<serde_yaml::Value>,
+    // images: Option<serde_yaml::Value>,
+    // locale: Option<serde_yaml::Value>,
+
+    // 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<String>,
+
+    #[schema(nullable = false)]
+    url: Option<String>,
+}
+impl NameAndUrl {
+    async fn from(value: cooklang::metadata::NameAndUrl) -> Result<Self, conversion::Error> {
+        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<Self, conversion::Error> {
+        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<Self, conversion::Error> {
+        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<Self, conversion::Error> {
+        Ok(Self {
+            name: value.name,
+            content: for_in!(value.content, |c| Content::from(c).await),
+        })
+    }
+}
+impl Content {
+    async fn from(value: cooklang::Content) -> Result<Self, conversion::Error> {
+        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<Self, conversion::Error> {
+        Ok(Self {
+            items: for_in!(value.items, |item| Item::from(item).await),
+            number: value.number,
+        })
+    }
+}
+impl Item {
+    async fn from(value: cooklang::Item) -> Result<Self, conversion::Error> {
+        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<Self, conversion::Error> {
+        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<Self, conversion::Error> {
+        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<Self, conversion::Error> {
+        Ok(Self {
+            name: value.name,
+            quantity: todo!(),
+        })
+    }
+}