diff options
Diffstat (limited to 'crates/rocie-server/src/storage')
17 files changed, 818 insertions, 56 deletions
diff --git a/crates/rocie-server/src/storage/migrate/sql/0->1.sql b/crates/rocie-server/src/storage/migrate/sql/0->1.sql index e3dd879..ba44c68 100644 --- a/crates/rocie-server/src/storage/migrate/sql/0->1.sql +++ b/crates/rocie-server/src/storage/migrate/sql/0->1.sql @@ -14,17 +14,29 @@ CREATE TABLE version ( valid_to INTEGER UNIQUE CHECK (valid_to > valid_from) ) STRICT; --- Encodes the tree structure of the products. --- A parent cannot be a product, but can have parents on it's own. +-- Encodes the tree structure of the product parents. +-- A product parent cannot be a product, but can have parents on it's own. -- TODO: Fix the possibility for cyclic parent-ship entries <2025-09-05> -CREATE TABLE parents ( +CREATE TABLE product_parents ( id TEXT UNIQUE NOT NULL PRIMARY KEY, parent TEXT DEFAULT NULL CHECK ( id IS NOT parent ), name TEXT UNIQUE NOT NULL, description TEXT, - FOREIGN KEY(parent) REFERENCES parents(id) + FOREIGN KEY(parent) REFERENCES product_parents(id) +) STRICT; + +-- Encodes the tree structure of the recipe parents. +-- TODO: Fix the possibility for cyclic parent-ship entries <2025-09-05> +CREATE TABLE recipe_parents ( + id TEXT UNIQUE NOT NULL PRIMARY KEY, + parent TEXT DEFAULT NULL CHECK ( + id IS NOT parent + ), + name TEXT UNIQUE NOT NULL, + description TEXT, + FOREIGN KEY(parent) REFERENCES recipe_parents(id) ) STRICT; -- Stores the registered users. @@ -71,7 +83,7 @@ CREATE TABLE products ( description TEXT, parent TEXT DEFAULT NULL, unit_property TEXT NOT NULL, - FOREIGN KEY(parent) REFERENCES parents(id), + FOREIGN KEY(parent) REFERENCES product_parents(id), FOREIGN KEY(unit_property) REFERENCES unit_properties(id) ) STRICT; @@ -123,8 +135,10 @@ CREATE TABLE unit_properties ( CREATE TABLE recipies ( id TEXT UNIQUE NOT NULL PRIMARY KEY, - path TEXT UNIQUE NOT NULL, - content TEXT NOT NULL + name TEXT UNIQUE NOT NULL, + parent TEXT, + content TEXT NOT NULL, + FOREIGN KEY(parent) REFERENCES recipe_parents(id) ) STRICT; -- Encodes unit conversions: diff --git a/crates/rocie-server/src/storage/sql/get/mod.rs b/crates/rocie-server/src/storage/sql/get/mod.rs index 92b34aa..a6ee0e1 100644 --- a/crates/rocie-server/src/storage/sql/get/mod.rs +++ b/crates/rocie-server/src/storage/sql/get/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod barcode; pub(crate) mod product; pub(crate) mod product_amount; pub(crate) mod product_parent; +pub(crate) mod recipe_parent; pub(crate) mod recipe; pub(crate) mod unit; pub(crate) mod unit_property; diff --git a/crates/rocie-server/src/storage/sql/get/product/mod.rs b/crates/rocie-server/src/storage/sql/get/product/mod.rs index 915da81..3d8b6e6 100644 --- a/crates/rocie-server/src/storage/sql/get/product/mod.rs +++ b/crates/rocie-server/src/storage/sql/get/product/mod.rs @@ -66,7 +66,7 @@ impl Product { } } - pub(crate) async fn from_name(app: &App, name: String) -> Result<Option<Self>, from_id::Error> { + pub(crate) async fn from_name(app: &App, name: &str) -> Result<Option<Self>, from_id::Error> { let record = query!( " SELECT name, id, unit_property, description, parent diff --git a/crates/rocie-server/src/storage/sql/get/product_parent/mod.rs b/crates/rocie-server/src/storage/sql/get/product_parent/mod.rs index 5b85b62..243ae1e 100644 --- a/crates/rocie-server/src/storage/sql/get/product_parent/mod.rs +++ b/crates/rocie-server/src/storage/sql/get/product_parent/mod.rs @@ -10,7 +10,7 @@ impl ProductParent { let records = query!( " SELECT id, parent, name, description - FROM parents + FROM product_parents " ) .fetch_all(&app.db) @@ -40,7 +40,7 @@ impl ProductParent { let record = query!( " SELECT parent, name, description - FROM parents + FROM product_parents WHERE id = ? ", id diff --git a/crates/rocie-server/src/storage/sql/get/recipe/mod.rs b/crates/rocie-server/src/storage/sql/get/recipe/mod.rs index 9d6dc79..f433541 100644 --- a/crates/rocie-server/src/storage/sql/get/recipe/mod.rs +++ b/crates/rocie-server/src/storage/sql/get/recipe/mod.rs @@ -1,15 +1,38 @@ use crate::{ app::App, - storage::sql::recipe::{Recipe, RecipeId}, + storage::sql::{ + recipe::{CooklangRecipe, Recipe, RecipeId}, + recipe_parent::RecipeParentId, + }, }; use sqlx::query; +pub(crate) mod parse { + use crate::storage::sql::recipe::conversion; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to convert from cooklang recipe to our own struct")] + Conversion(#[from] conversion::Error), + } +} + +async fn recipe_from_content(app: &App, content: &str) -> Result<CooklangRecipe, parse::Error> { + // NOTE: We can ignore warnings here, as we should already have handled them at the recipe + // insert point. <2026-01-31> + let (output, _warnings) = cooklang::parse(content) + .into_result() + .expect("The values in the db should always be valid, as we checked before inserting them"); + + Ok(CooklangRecipe::from(app, output).await?) +} + impl Recipe { pub(crate) async fn from_id(app: &App, id: RecipeId) -> Result<Option<Self>, from_id::Error> { let record = query!( " - SELECT content, path + SELECT name, parent, content FROM recipies WHERE id = ? ", @@ -21,11 +44,33 @@ impl Recipe { if let Some(record) = record { Ok(Some(Self { id, - path: record - .path - .parse() - .expect("Was a path before, should still be one"), - content: record.content, + content: recipe_from_content(app, &record.content).await?, + name: record.name, + parent: record.parent.map(|id| RecipeParentId::from_db(&id)), + })) + } else { + Ok(None) + } + } + + pub(crate) async fn from_name(app: &App, name: String) -> Result<Option<Self>, from_id::Error> { + let record = query!( + " + SELECT id, parent, content + FROM recipies + WHERE name = ? +", + name + ) + .fetch_optional(&app.db) + .await?; + + if let Some(record) = record { + Ok(Some(Self { + id: RecipeId::from_db(&record.id), + content: recipe_from_content(app, &record.content).await?, + name, + parent: record.parent.map(|id| RecipeParentId::from_db(&id)), })) } else { Ok(None) @@ -35,31 +80,39 @@ impl Recipe { pub(crate) async fn get_all(app: &App) -> Result<Vec<Self>, get_all::Error> { let records = query!( " - SELECT id, content, path + SELECT id, name, parent, content FROM recipies ", ) .fetch_all(&app.db) .await?; - Ok(records - .into_iter() - .map(|record| Self { + let mut output = vec![]; + for record in records { + output.push(Self { id: RecipeId::from_db(&record.id), - path: record.path.parse().expect("Is still valid"), - content: record.content, - }) - .collect()) + content: recipe_from_content(app, &record.content).await?, + name: record.name, + parent: record.parent.map(|id| RecipeParentId::from_db(&id)), + }); + } + + Ok(output) } } pub(crate) mod from_id { use actix_web::ResponseError; + use crate::storage::sql::get::recipe::parse; + #[derive(thiserror::Error, Debug)] pub(crate) enum Error { - #[error("Failed to execute the sql query")] + #[error("Failed to execute the sql query: `{0}`")] SqlError(#[from] sqlx::Error), + + #[error("Failed to parse the recipe content as cooklang recipe: `{0}`")] + RecipeParse(#[from] parse::Error), } impl ResponseError for Error {} @@ -68,10 +121,15 @@ pub(crate) mod from_id { pub(crate) mod get_all { use actix_web::ResponseError; + use crate::storage::sql::get::recipe::parse; + #[derive(thiserror::Error, Debug)] pub(crate) enum Error { #[error("Failed to execute the sql query")] SqlError(#[from] sqlx::Error), + + #[error("Failed to parse the recipe content as cooklang recipe")] + RecipeParse(#[from] parse::Error), } impl ResponseError for Error {} diff --git a/crates/rocie-server/src/storage/sql/get/recipe_parent/mod.rs b/crates/rocie-server/src/storage/sql/get/recipe_parent/mod.rs new file mode 100644 index 0000000..d53e853 --- /dev/null +++ b/crates/rocie-server/src/storage/sql/get/recipe_parent/mod.rs @@ -0,0 +1,85 @@ +use crate::{ + app::App, + storage::sql::{ + recipe_parent::{RecipeParent, RecipeParentId}, + }, +}; + +use sqlx::query; + +impl RecipeParent { + pub(crate) async fn get_all(app: &App) -> Result<Vec<Self>, get_all::Error> { + let records = query!( + " + SELECT id, parent, name, description + FROM recipe_parents +" + ) + .fetch_all(&app.db) + .await?; + + let mut all = Vec::with_capacity(records.len()); + for record in records { + let parent = Self { + id: RecipeParentId::from_db(&record.id), + parent: record.parent.map(|parent| RecipeParentId::from_db(&parent)), + name: record.name, + description: record.description, + }; + + all.push(parent); + } + + Ok(all) + } + + pub(crate) async fn from_id( + app: &App, + id: RecipeParentId, + ) -> Result<Option<Self>, from_id::Error> { + let record = query!( + " + SELECT parent, name, description + FROM recipe_parents + WHERE id = ? +", + id + ) + .fetch_optional(&app.db) + .await?; + + match record { + Some(record) => Ok(Some(Self { + id, + parent: record.parent.map(|parent| RecipeParentId::from_db(&parent)), + name: record.name, + description: record.description, + })), + None => Ok(None), + } + } +} + +pub(crate) mod from_id { + use actix_web::ResponseError; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to execute the sql query")] + SqlError(#[from] sqlx::Error), + } + + impl ResponseError for Error {} +} + +pub(crate) mod get_all { + use actix_web::ResponseError; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to execute the sql query")] + SqlError(#[from] sqlx::Error), + } + + impl ResponseError for Error {} +} diff --git a/crates/rocie-server/src/storage/sql/get/user/mod.rs b/crates/rocie-server/src/storage/sql/get/user/mod.rs index e36c6cf..e09ef67 100644 --- a/crates/rocie-server/src/storage/sql/get/user/mod.rs +++ b/crates/rocie-server/src/storage/sql/get/user/mod.rs @@ -50,6 +50,33 @@ impl User { Ok(None) } } + + pub(crate) async fn from_name( + app: &App, + name: String, + ) -> Result<Option<Self>, from_name::Error> { + let record = query!( + " + SELECT id, name, password_hash, description + FROM users + WHERE name = ? +", + name + ) + .fetch_optional(&app.db) + .await?; + + if let Some(record) = record { + Ok(Some(Self { + name: record.name, + description: record.description, + id: UserId::from_db(&record.id), + password_hash: PasswordHash::from_db(record.password_hash), + })) + } else { + Ok(None) + } + } } pub(crate) mod get_all { @@ -75,3 +102,15 @@ pub(crate) mod from_id { impl ResponseError for Error {} } + +pub(crate) mod from_name { + use actix_web::ResponseError; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to execute the sql query")] + SqlError(#[from] sqlx::Error), + } + + impl ResponseError for Error {} +} diff --git a/crates/rocie-server/src/storage/sql/insert/mod.rs b/crates/rocie-server/src/storage/sql/insert/mod.rs index 673cbdd..b92a88c 100644 --- a/crates/rocie-server/src/storage/sql/insert/mod.rs +++ b/crates/rocie-server/src/storage/sql/insert/mod.rs @@ -11,6 +11,7 @@ pub(crate) mod barcode; pub(crate) mod product; pub(crate) mod product_parent; pub(crate) mod recipe; +pub(crate) mod recipe_parent; pub(crate) mod unit; pub(crate) mod unit_property; pub(crate) mod user; diff --git a/crates/rocie-server/src/storage/sql/insert/product_parent/mod.rs b/crates/rocie-server/src/storage/sql/insert/product_parent/mod.rs index 644778f..72fb564 100644 --- a/crates/rocie-server/src/storage/sql/insert/product_parent/mod.rs +++ b/crates/rocie-server/src/storage/sql/insert/product_parent/mod.rs @@ -31,7 +31,7 @@ impl Transactionable for Operation { } => { query!( " - INSERT INTO parents (id, name, description, parent) + INSERT INTO product_parents (id, name, description, parent) VALUES (?,?,?,?) ", id, @@ -56,7 +56,7 @@ impl Transactionable for Operation { } => { query!( " - DELETE FROM products + DELETE FROM product_parents WHERE id = ? AND name = ? AND description = ? AND parent = ?; ", id, diff --git a/crates/rocie-server/src/storage/sql/insert/recipe/mod.rs b/crates/rocie-server/src/storage/sql/insert/recipe/mod.rs index b223bfe..b60874f 100644 --- a/crates/rocie-server/src/storage/sql/insert/recipe/mod.rs +++ b/crates/rocie-server/src/storage/sql/insert/recipe/mod.rs @@ -1,19 +1,23 @@ -use std::path::PathBuf; - +use cooklang::{Converter, CooklangParser, Extensions}; use serde::{Deserialize, Serialize}; use sqlx::query; use uuid::Uuid; -use crate::storage::sql::{ - insert::{Operations, Transactionable}, - recipe::{Recipe, RecipeId}, +use crate::{ + app::App, + storage::sql::{ + insert::{Operations, Transactionable}, + recipe::{CooklangRecipe, Recipe, RecipeId}, + recipe_parent::RecipeParentId, + }, }; #[derive(Debug, Deserialize, Serialize)] pub(crate) enum Operation { New { id: RecipeId, - path: PathBuf, + name: String, + parent: Option<RecipeParentId>, content: String, }, } @@ -24,16 +28,20 @@ impl Transactionable for Operation { async fn apply(self, txn: &mut sqlx::SqliteConnection) -> Result<(), apply::Error> { match self { - Operation::New { id, path, content } => { - let path = path.display().to_string(); - + Operation::New { + id, + name, + parent, + content, + } => { query!( " - INSERT INTO recipies (id, path, content) - VALUES (?, ?, ?) + INSERT INTO recipies (id, name, parent, content) + VALUES (?, ?, ?, ?) ", id, - path, + name, + parent, content, ) .execute(txn) @@ -45,16 +53,20 @@ impl Transactionable for Operation { async fn undo(self, txn: &mut sqlx::SqliteConnection) -> Result<(), undo::Error> { match self { - Operation::New { id, path, content } => { - let path = path.display().to_string(); - + Operation::New { + id, + name, + parent, + content, + } => { query!( " DELETE FROM recipies - WHERE id = ? AND path = ? AND content = ? + WHERE id = ? AND name = ? AND parent = ? AND content = ? ", id, - path, + name, + parent, content ) .execute(txn) @@ -79,17 +91,50 @@ pub(crate) mod apply { SqlError(#[from] sqlx::Error), } } +pub(crate) mod new { + use actix_web::ResponseError; + + use crate::storage::sql::recipe::conversion; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to parse the recipe contents as cooklang: `{0}`")] + RecipeParse(#[from] cooklang::error::SourceReport), + + #[error("Failed to convert the cooklang recipe to our struct: `{0}`")] + RecipeConvert(#[from] conversion::Error), + } + + impl ResponseError for Error {} +} impl Recipe { - pub(crate) fn new(path: PathBuf, content: String, ops: &mut Operations<Operation>) -> Self { + pub(crate) async fn new( + app: &App, + name: String, + parent: Option<RecipeParentId>, + content: String, + ops: &mut Operations<Operation>, + ) -> Result<Self, new::Error> { let id = RecipeId::from(Uuid::new_v4()); + let parser = CooklangParser::new(Extensions::empty(), Converter::bundled()); + + // TODO: Somehow return the warnings <2026-01-31> + let (recipe, _warnings) = parser.parse(&content).into_result()?; + ops.push(Operation::New { id, - path: path.clone(), - content: content.clone(), + content, + name: name.clone(), + parent, }); - Self { id, path, content } + Ok(Self { + id, + name, + parent, + content: CooklangRecipe::from(app, recipe).await?, + }) } } diff --git a/crates/rocie-server/src/storage/sql/insert/recipe_parent/mod.rs b/crates/rocie-server/src/storage/sql/insert/recipe_parent/mod.rs new file mode 100644 index 0000000..95bc6f1 --- /dev/null +++ b/crates/rocie-server/src/storage/sql/insert/recipe_parent/mod.rs @@ -0,0 +1,113 @@ +use serde::{Deserialize, Serialize}; +use sqlx::query; +use uuid::Uuid; + +use crate::storage::sql::{ + insert::{Operations, Transactionable}, + recipe_parent::{RecipeParent, RecipeParentId}, +}; + +#[derive(Debug, Deserialize, Serialize)] +pub(crate) enum Operation { + RegisterRecipeParent { + id: RecipeParentId, + name: String, + description: Option<String>, + parent: Option<RecipeParentId>, + }, +} + +impl Transactionable for Operation { + type ApplyError = apply::Error; + type UndoError = undo::Error; + + async fn apply(self, txn: &mut sqlx::SqliteConnection) -> Result<(), apply::Error> { + match self { + Operation::RegisterRecipeParent { + id, + name, + description, + parent, + } => { + query!( + " + INSERT INTO recipe_parents (id, name, description, parent) + VALUES (?,?,?,?) +", + id, + name, + description, + parent + ) + .execute(txn) + .await?; + } + } + Ok(()) + } + + async fn undo(self, txn: &mut sqlx::SqliteConnection) -> Result<(), undo::Error> { + match self { + Operation::RegisterRecipeParent { + id, + name, + description, + parent, + } => { + query!( + " + DELETE FROM recipe_parents + WHERE id = ? AND name = ? AND description = ? AND parent = ?; +", + id, + name, + description, + parent, + ) + .execute(txn) + .await?; + } + } + Ok(()) + } +} + +pub(crate) mod undo { + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to execute undo sql statments: {0}")] + SqlError(#[from] sqlx::Error), + } +} +pub(crate) mod apply { + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to execute apply sql statments: {0}")] + SqlError(#[from] sqlx::Error), + } +} + +impl RecipeParent { + pub(crate) fn register( + name: String, + description: Option<String>, + parent: Option<RecipeParentId>, + ops: &mut Operations<Operation>, + ) -> Self { + let id = RecipeParentId::from(Uuid::new_v4()); + + ops.push(Operation::RegisterRecipeParent { + id, + name: name.clone(), + description: description.clone(), + parent, + }); + + Self { + id, + name, + description, + parent, + } + } +} diff --git a/crates/rocie-server/src/storage/sql/mod.rs b/crates/rocie-server/src/storage/sql/mod.rs index 315c251..c37e68f 100644 --- a/crates/rocie-server/src/storage/sql/mod.rs +++ b/crates/rocie-server/src/storage/sql/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod product; pub(crate) mod product_amount; pub(crate) mod product_parent; pub(crate) mod recipe; +pub(crate) mod recipe_parent; pub(crate) mod unit; pub(crate) mod unit_property; pub(crate) mod user; diff --git a/crates/rocie-server/src/storage/sql/product.rs b/crates/rocie-server/src/storage/sql/product.rs index 00c79d3..c2c32ec 100644 --- a/crates/rocie-server/src/storage/sql/product.rs +++ b/crates/rocie-server/src/storage/sql/product.rs @@ -17,6 +17,7 @@ pub(crate) struct Product { /// The parent this product has. /// /// This is effectively it's anchor in the product DAG. + /// None means, that it has no parents and as such is in the toplevel. #[schema(nullable = false)] pub(crate) parent: Option<ProductParentId>, diff --git a/crates/rocie-server/src/storage/sql/product_amount.rs b/crates/rocie-server/src/storage/sql/product_amount.rs index 0f19afc..dafe43a 100644 --- a/crates/rocie-server/src/storage/sql/product_amount.rs +++ b/crates/rocie-server/src/storage/sql/product_amount.rs @@ -3,7 +3,7 @@ use utoipa::ToSchema; use crate::storage::sql::{product::ProductId, unit::UnitAmount}; -#[derive(Clone, ToSchema, Serialize, Deserialize)] +#[derive(Debug, Clone, ToSchema, Serialize, Deserialize, PartialEq)] pub(crate) struct ProductAmount { pub(crate) product_id: ProductId, pub(crate) amount: UnitAmount, 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!(), + }) + } +} diff --git a/crates/rocie-server/src/storage/sql/recipe_parent.rs b/crates/rocie-server/src/storage/sql/recipe_parent.rs new file mode 100644 index 0000000..6225a4b --- /dev/null +++ b/crates/rocie-server/src/storage/sql/recipe_parent.rs @@ -0,0 +1,31 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::storage::sql::mk_id; + +/// The grouping system for recipes. +/// +/// Every recipe can have a related parent, and every parent can have a parent themselves. +/// As such, the recipe list constructs a DAG. +#[derive(Clone, ToSchema, Serialize, Deserialize)] +pub(crate) struct RecipeParent { + /// The id of the recipe parent. + pub(crate) id: RecipeParentId, + + /// The optional id of the parent of this recipe parent. + /// + /// This must not form a cycle. + #[schema(nullable = false)] + pub(crate) parent: Option<RecipeParentId>, + + /// The name of the recipe parent. + /// + /// This should be globally unique, to make searching easier for the user. + pub(crate) name: String, + + /// An optional description of this recipe parent. + #[schema(nullable = false)] + pub(super) description: Option<String>, +} + +mk_id!(RecipeParentId and RecipeParentIdStub); diff --git a/crates/rocie-server/src/storage/sql/unit.rs b/crates/rocie-server/src/storage/sql/unit.rs index 8bbfe60..dc16e4c 100644 --- a/crates/rocie-server/src/storage/sql/unit.rs +++ b/crates/rocie-server/src/storage/sql/unit.rs @@ -41,7 +41,7 @@ pub(crate) struct Unit { pub(crate) unit_property: UnitPropertyId, } -#[derive(ToSchema, Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(ToSchema, Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] pub(crate) struct UnitAmount { #[schema(minimum = 0)] pub(crate) value: u32, |
