diff options
Diffstat (limited to '')
19 files changed, 728 insertions, 199 deletions
diff --git a/crates/rocie-server/src/api/set/no_auth/user.rs b/crates/rocie-server/src/api/set/no_auth/user.rs index 7ca865c..69758a7 100644 --- a/crates/rocie-server/src/api/set/no_auth/user.rs +++ b/crates/rocie-server/src/api/set/no_auth/user.rs @@ -6,9 +6,13 @@ use utoipa::ToSchema; use crate::{ api::set::auth::user::UserStub, app::App, - storage::sql::{ - insert::Operations, - user::{PasswordHash, User, UserId}, + storage::{ + migrate::migrate_db, + sql::{ + config::Config, + insert::Operations, + user::{PasswordHash, User, UserId}, + }, }, }; @@ -84,6 +88,14 @@ async fn logout(user: Identity) -> impl Responder { HttpResponse::Ok() } +#[derive(ToSchema, Deserialize)] +struct ProvisionInfo { + user: UserStub, + + /// Whether we should apply the default configuration. + use_defaults: bool, +} + /// Provision this instance. /// /// This only works, if no users exist yet. @@ -104,24 +116,39 @@ async fn logout(user: Identity) -> impl Responder { body = String ) ), - request_body = UserStub, + request_body = ProvisionInfo, )] #[post("/provision")] async fn provision( request: HttpRequest, app: web::Data<App>, - new_user: web::Json<UserStub>, + info: web::Json<ProvisionInfo>, ) -> Result<impl Responder> { if User::get_all(&app).await?.is_empty() { - let user = new_user.into_inner(); + let info = info.into_inner(); let mut ops = Operations::new("register user (during provisioning)"); - let password_hash = PasswordHash::from_password(&user.password); - let user = User::register(user.name, password_hash, user.description, &mut ops); + let password_hash = PasswordHash::from_password(&info.user.password); + let user = User::register( + info.user.name, + password_hash, + info.user.description, + &mut ops, + ); ops.apply(&app).await?; + if info.use_defaults { + let mut ops = + Operations::new("Set should use defaults on config (during provisioning)"); + let mut config = Config::get(&app).await?; + config.set_use_default(true, &mut ops); + ops.apply(&app).await?; + + migrate_db(&app).await?; + } + Identity::login(&request.extensions(), user.id.to_string())?; Ok(HttpResponse::Ok().json(user.id)) diff --git a/crates/rocie-server/src/app.rs b/crates/rocie-server/src/app.rs index bb27470..59eed28 100644 --- a/crates/rocie-server/src/app.rs +++ b/crates/rocie-server/src/app.rs @@ -1,12 +1,13 @@ -use std::path::PathBuf; +use std::{cell::OnceCell, path::PathBuf, sync::OnceLock}; use sqlx::{SqlitePool, sqlite::SqliteConnectOptions}; -use crate::storage::migrate::migrate_db; +use crate::storage::migrate::{DbVersion, migrate_db}; #[derive(Clone)] pub(crate) struct App { pub(crate) db: SqlitePool, + pub(crate) db_version_at_start: OnceLock<DbVersion>, } impl App { @@ -25,7 +26,10 @@ impl App { })? }; - let me = Self { db }; + let me = Self { + db, + db_version_at_start: OnceLock::new(), + }; migrate_db(&me).await?; diff --git a/crates/rocie-server/src/storage/migrate/defaults.rs b/crates/rocie-server/src/storage/migrate/defaults.rs new file mode 100644 index 0000000..3a2019c --- /dev/null +++ b/crates/rocie-server/src/storage/migrate/defaults.rs @@ -0,0 +1,99 @@ +use crate::{ + app::App, + storage::sql::{insert::Operations, unit::Unit, unit_property::UnitProperty}, +}; + +#[expect(clippy::unnecessary_wraps, reason = "The API expects an Option<_>")] +fn so(input: &'static str) -> Option<String> { + Some(s(input)) +} +fn s(input: &'static str) -> String { + String::from(input) +} + +macro_rules! register { + ( + $app:ident # $unit_prop_description:literal, $unit_prop_name:literal + $( + -> $unit_full_name_singular:ident, $unit_full_name_plural:ident, $unit_short_name:ident + )* + ) => { + let mut ops = Operations::new(concat!( + "create", + $unit_prop_name, + "unit property (during provisioning)" + )); + let unit_property = UnitProperty::register( + s($unit_prop_name), + so($unit_prop_description), + &mut ops, + ) + .id; + ops.apply(&$app).await?; + + let mut ops = Operations::new(concat!( + "create default units for", + $unit_prop_name, + "property (during provisioning)" + )); + + $( + Unit::register( + s(stringify!($unit_full_name_singular)), + s(stringify!($unit_full_name_plural)), + s(stringify!($unit_short_name)), + None, + unit_property, + &mut ops, + ); + )* + + ops.apply(&$app).await?; + }; +} + +pub(super) async fn add_defaults_0_to_1(app: &App) -> Result<(), apply_defaults::Error> { + register!( + app # "Time mesurement units", "Time" + -> second, seconds, s + -> minute, minutes, min + -> hour, hours, h + ); + + register!( + app # "Mass (weight) mesurement units", "Mass" + -> milligram, milligrams, mg + -> gram, grams, g + -> kilogram, kilograms, kg + ); + + register!( + app # "Volume mesurement units", "Volume" + -> milliliter, millilters, ml + -> deciliter, deciliters, dl + -> liter, liters, l + + // English + -> tablespoon, tablespoons, tbsp + -> teaspoon, teaspoons, tsp + + // Swedish + -> tesked, teskedar, tsk + -> matsked, matskedar, msk + ); + + Ok(()) +} + +pub(crate) mod apply_defaults { + use crate::storage::sql::insert::{self, apply}; + + #[derive(thiserror::Error, Debug)] + pub(crate) enum Error { + #[error("Failed to add new default unit-property the database: {0}")] + AddUnitProperty(#[from] apply::Error<insert::unit_property::Operation>), + + #[error("Failed to add new default unit the database: {0}")] + AddUnit(#[from] apply::Error<insert::unit::Operation>), + } +} diff --git a/crates/rocie-server/src/storage/migrate/mod.rs b/crates/rocie-server/src/storage/migrate/mod.rs index ae0732b..5c81580 100644 --- a/crates/rocie-server/src/storage/migrate/mod.rs +++ b/crates/rocie-server/src/storage/migrate/mod.rs @@ -7,10 +7,15 @@ use chrono::TimeDelta; use log::{debug, info}; use sqlx::{Sqlite, SqlitePool, Transaction, query}; -use crate::app::App; +use crate::{ + app::App, + storage::sql::{config::Config, get::config}, +}; + +mod defaults; macro_rules! make_upgrade { - ($app:expr, $old_version:expr, $new_version:expr, $sql_name:expr) => { + ($app:expr, $old_version:expr, $new_version:expr, $sql_name:expr) => {{ let mut tx = $app .db .begin() @@ -57,8 +62,8 @@ macro_rules! make_upgrade { new_version: $new_version, })?; - Ok(()) - }; + Ok::<_, update::Error>(()) + }}; } #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] @@ -135,11 +140,12 @@ impl DbVersion { /// /// Each update is atomic, so if this function fails you are still guaranteed to have a /// database at version `get_version`. - #[allow(clippy::too_many_lines)] async fn update(self, app: &App) -> Result<(), update::Error> { match self { Self::Empty => { - make_upgrade! {app, Self::Empty, Self::One, "./sql/0->1.sql"} + make_upgrade! {app, Self::Empty, Self::One, "./sql/0->1.sql"}?; + + Ok(()) } // This is the current_version @@ -158,7 +164,10 @@ impl Display for DbVersion { } } pub(crate) mod update { - use crate::storage::migrate::{DbVersion, db_version_set, get_db_version}; + use crate::storage::{ + migrate::{DbVersion, db_version_set, defaults, get_db_version}, + sql::get::config, + }; #[derive(thiserror::Error, Debug)] pub(crate) enum Error { @@ -182,6 +191,11 @@ pub(crate) mod update { #[error("Failed to commit the update transaction: {0}")] TxnCommit(sqlx::Error), + #[error("Failed to access the rocie config: {0}")] + ConfigGet(#[from] config::get::Error), + #[error("Failed to add defaults to the database: {0}")] + AddDefaults(#[from] defaults::apply_defaults::Error), + #[error("Failed to perform the next chained update (to ver {new_version}): {err}")] NextUpdate { err: Box<Self>, @@ -197,6 +211,12 @@ pub(crate) mod db_version_parse { } } +async fn should_use_defaults(app: &App) -> Result<bool, config::get::Error> { + let config = Config::get(app).await?; + + Ok(config.should_use_defaults) +} + /// Returns the current data as UNIX time stamp. pub(crate) fn get_current_date() -> i64 { let start = SystemTime::now(); @@ -238,7 +258,7 @@ pub(crate) async fn get_version_db(pool: &SqlitePool) -> Result<DbVersion, get_d ) .fetch_optional(pool) .await - .map_err(|err| get_db_version::Error::VersionTableExistance(err))?; + .map_err(get_db_version::Error::VersionTableExistance)?; if let Some(output) = query { assert_eq!(output.result, 1); @@ -261,7 +281,7 @@ pub(crate) async fn get_version_db(pool: &SqlitePool) -> Result<DbVersion, get_d ) .fetch_one(pool) .await - .map_err(|err| get_db_version::Error::VersionNumberFetch(err))?; + .map_err(get_db_version::Error::VersionNumberFetch)?; Ok(DbVersion::from_db( current_version.number, @@ -288,6 +308,26 @@ pub(crate) mod get_db_version { pub(crate) async fn migrate_db(app: &App) -> Result<(), migrate_db::Error> { let current_version = get_version(app).await?; + if app.db_version_at_start.get().is_none() { + app.db_version_at_start + .set(current_version) + .expect("the cell to be unititialized, we checked"); + } + + if app.db_version_at_start.get() == Some(&DbVersion::Empty) { + // We cannot run this code in the normal update function, because for the empty db, there + // is no way to know if we want defaults. + // + // So we need to add the defaults later, which is achieved by another call to `migrate_db` + // in provision. + // + // That is kinda hacky, but I don't see a way around it. + // For defaults added in version Two and onward, the default mechanism should just work. + if should_use_defaults(app).await? { + defaults::add_defaults_0_to_1(app).await?; + } + } + if current_version == CURRENT_VERSION { return Ok(()); } @@ -300,7 +340,12 @@ pub(crate) async fn migrate_db(app: &App) -> Result<(), migrate_db::Error> { } pub(crate) mod migrate_db { - use crate::storage::migrate::{get_db_version, update}; + use actix_web::ResponseError; + + use crate::storage::{ + migrate::{defaults, get_db_version, update}, + sql::get::config, + }; #[derive(thiserror::Error, Debug)] pub(crate) enum Error { @@ -309,5 +354,12 @@ pub(crate) mod migrate_db { #[error("Failed to update the database: {0}")] Upadate(#[from] update::Error), + + #[error("Failed to access the rocie config: {0}")] + ConfigGet(#[from] config::get::Error), + #[error("Failed to add defaults to the database: {0}")] + AddDefaults(#[from] defaults::apply_defaults::Error), } + + impl ResponseError for Error {} } 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 ba44c68..8f99322 100644 --- a/crates/rocie-server/src/storage/migrate/sql/0->1.sql +++ b/crates/rocie-server/src/storage/migrate/sql/0->1.sql @@ -178,3 +178,10 @@ CREATE TABLE txn_log ( timestamp INTEGER NOT NULL, operation TEXT NOT NULL ) STRICT; + +CREATE TABLE config ( + -- Make it impossible to insert more than one value here. + id INTEGER PRIMARY KEY NOT NULL CHECK (id = 0), + use_defaults INTEGER NOT NULL CHECK (use_defaults = 1 OR use_defaults = 0) +) STRICT; +INSERT INTO config (id, use_defaults) VALUES (0, 0); diff --git a/crates/rocie-server/src/storage/sql/config.rs b/crates/rocie-server/src/storage/sql/config.rs new file mode 100644 index 0000000..d62859c --- /dev/null +++ b/crates/rocie-server/src/storage/sql/config.rs @@ -0,0 +1,7 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(ToSchema, Debug, Clone, Serialize, Deserialize)] +pub(crate) struct Config { + pub(crate) should_use_defaults: bool, +} diff --git a/crates/rocie-server/src/storage/sql/get/config/mod.rs b/crates/rocie-server/src/storage/sql/get/config/mod.rs new file mode 100644 index 0000000..eb8be86 --- /dev/null +++ b/crates/rocie-server/src/storage/sql/get/config/mod.rs @@ -0,0 +1,41 @@ +use crate::{app::App, storage::sql::config::Config}; + +use sqlx::query; + +impl Config { + pub(crate) async fn get(app: &App) -> Result<Self, get::Error> { + let record = query!( + " + SELECT use_defaults + FROM config + WHERE id = 0 +" + ) + .fetch_one(&app.db) + .await?; + + let should_use_defaults = if record.use_defaults == 1 { + true + } else if record.use_defaults == 0 { + false + } else { + unreachable!("Should not be possible, sqlite's CHECK prevents it") + }; + + Ok(Self { + should_use_defaults, + }) + } +} + +pub(crate) mod get { + 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/mod.rs b/crates/rocie-server/src/storage/sql/get/mod.rs index a6ee0e1..e3520da 100644 --- a/crates/rocie-server/src/storage/sql/get/mod.rs +++ b/crates/rocie-server/src/storage/sql/get/mod.rs @@ -1,9 +1,10 @@ pub(crate) mod barcode; +pub(crate) mod config; 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 recipe_parent; pub(crate) mod unit; pub(crate) mod unit_property; pub(crate) mod user; diff --git a/crates/rocie-server/src/storage/sql/get/unit/mod.rs b/crates/rocie-server/src/storage/sql/get/unit/mod.rs index 6f5d297..2c85970 100644 --- a/crates/rocie-server/src/storage/sql/get/unit/mod.rs +++ b/crates/rocie-server/src/storage/sql/get/unit/mod.rs @@ -57,6 +57,34 @@ impl Unit { Ok(None) } } + + pub(crate) async fn from_name(app: &App, name: &str) -> Result<Option<Self>, from_name::Error> { + let record = query!( + " + SELECT id, full_name_singular, unit_property, full_name_plural, short_name, description + FROM units + WHERE full_name_singular = ? OR full_name_plural = ? OR short_name = ? +", + name, + name, + name + ) + .fetch_optional(&app.db) + .await?; + + if let Some(record) = record { + Ok(Some(Self { + id: UnitId::from_db(&record.id), + unit_property: UnitPropertyId::from_db(&record.unit_property), + full_name_singular: record.full_name_singular, + full_name_plural: record.full_name_plural, + short_name: record.short_name, + description: record.description, + })) + } else { + Ok(None) + } + } } pub(crate) mod get_all { @@ -82,3 +110,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/config/mod.rs b/crates/rocie-server/src/storage/sql/insert/config/mod.rs new file mode 100644 index 0000000..8c81feb --- /dev/null +++ b/crates/rocie-server/src/storage/sql/insert/config/mod.rs @@ -0,0 +1,80 @@ +use serde::{Deserialize, Serialize}; +use sqlx::query; + +use crate::storage::sql::{ + config::Config, + insert::{Operations, Transactionable}, +}; + +#[derive(Debug, Deserialize, Serialize)] +pub(crate) enum Operation { + UseDefault { value: bool, old_value: bool }, +} + +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::UseDefault { value, .. } => { + query!( + " + UPDATE config + SET use_defaults = ? + WHERE id = 0 +", + value, + ) + .execute(txn) + .await?; + } + } + Ok(()) + } + + async fn undo(self, txn: &mut sqlx::SqliteConnection) -> Result<(), undo::Error> { + match self { + Operation::UseDefault { old_value, .. } => { + query!( + " + UPDATE config + SET use_defaults = ? + WHERE id = 0 +", + old_value, + ) + .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 Config { + pub(crate) fn set_use_default(&mut self, new_value: bool, ops: &mut Operations<Operation>) { + if self.should_use_defaults != new_value { + ops.push(Operation::UseDefault { + value: new_value, + old_value: self.should_use_defaults, + }); + self.should_use_defaults = new_value; + } + } +} diff --git a/crates/rocie-server/src/storage/sql/insert/mod.rs b/crates/rocie-server/src/storage/sql/insert/mod.rs index b92a88c..c106b2b 100644 --- a/crates/rocie-server/src/storage/sql/insert/mod.rs +++ b/crates/rocie-server/src/storage/sql/insert/mod.rs @@ -8,6 +8,7 @@ use serde::{Serialize, de::DeserializeOwned}; use sqlx::{SqliteConnection, query}; pub(crate) mod barcode; +pub(crate) mod config; pub(crate) mod product; pub(crate) mod product_parent; pub(crate) mod recipe; diff --git a/crates/rocie-server/src/storage/sql/mod.rs b/crates/rocie-server/src/storage/sql/mod.rs index c37e68f..f1d7cb1 100644 --- a/crates/rocie-server/src/storage/sql/mod.rs +++ b/crates/rocie-server/src/storage/sql/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod insert; // Types pub(crate) mod barcode; +pub(crate) mod config; pub(crate) mod product; pub(crate) mod product_amount; pub(crate) mod product_parent; diff --git a/crates/rocie-server/src/storage/sql/recipe.rs b/crates/rocie-server/src/storage/sql/recipe.rs index 1fc3b56..7347b4b 100644 --- a/crates/rocie-server/src/storage/sql/recipe.rs +++ b/crates/rocie-server/src/storage/sql/recipe.rs @@ -1,5 +1,6 @@ #![expect(clippy::unused_async)] +use log::error; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -10,7 +11,7 @@ use crate::{ product::{Product, ProductId}, product_amount::ProductAmount, recipe_parent::RecipeParentId, - unit::UnitAmount, + unit::{Unit, UnitAmount}, }, }; @@ -97,7 +98,6 @@ pub(crate) struct Section { /// 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), @@ -124,7 +124,6 @@ pub(crate) struct Step { /// 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 { @@ -205,7 +204,8 @@ pub(crate) struct Timer { pub(crate) name: Option<String>, /// Time quantity - pub(crate) quantity: UnitAmount, + #[schema(nullable = false)] + pub(crate) quantity: Option<UnitAmount>, } #[derive(Debug, ToSchema, Serialize, Deserialize, PartialEq, Clone)] @@ -238,12 +238,18 @@ pub(crate) struct Metadata { } pub(crate) mod conversion { - use crate::storage::sql::get::product::from_id; + 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), } } @@ -258,8 +264,8 @@ pub(crate) struct NameAndUrl { 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()), + name: value.name().map(ToOwned::to_owned), + url: value.url().map(ToOwned::to_owned), }) } } @@ -274,7 +280,7 @@ impl CooklangRecipe { 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), + timers: for_in!(value.timers, |t| Timer::from(app, t).await), }) } } @@ -353,16 +359,22 @@ 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? { + } 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, + 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: None, + quantity: quantity_to_unit_amount(app, value.quantity).await?, }) } } @@ -372,20 +384,72 @@ impl Cookware { 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!(), - }), + 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(value: cooklang::Timer) -> Result<Self, conversion::Error> { + async fn from(app: &App, value: cooklang::Timer) -> Result<Self, conversion::Error> { Ok(Self { name: value.name, - quantity: todo!(), + quantity: quantity_to_unit_amount(app, value.quantity).await?, }) } } + +async fn quantity_to_unit_amount( + app: &App, + qty: Option<cooklang::Quantity>, +) -> Result<Option<UnitAmount>, 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) + } +} diff --git a/crates/rocie-server/tests/_testenv/init.rs b/crates/rocie-server/tests/_testenv/init.rs index 4a169fe..758ca4e 100644 --- a/crates/rocie-server/tests/_testenv/init.rs +++ b/crates/rocie-server/tests/_testenv/init.rs @@ -21,7 +21,7 @@ use std::{ use rocie_client::{ apis::{api_set_no_auth_user_api::provision, configuration::Configuration}, - models::UserStub, + models::{ProvisionInfo, UserStub}, }; use crate::{ @@ -116,10 +116,15 @@ impl TestEnv { request!( env, - provision(UserStub { - description: Some("Test user, used during test runs".to_string()), - name: "rocie".to_string(), - password: "server".to_string() + provision(ProvisionInfo { + user: UserStub { + description: Some("Test user, used during test runs".to_string()), + name: "rocie".to_string(), + password: "server".to_string() + }, + // Don't use any default units. + // Otherwise we would need to update the tests every time we add new ones. + use_defaults: false, }) ); @@ -150,7 +155,11 @@ impl TestEnv { let mut stdout = BufReader::new(child.stdout.as_mut().expect("Was captured")); let mut port = String::new(); - assert_ne!(stdout.read_line(&mut port).expect("Works"), 0); + assert_ne!( + stdout.read_line(&mut port).expect("Works"), + 0, + "We should have been able to read the one line, the server printed" + ); port.trim_end() .parse() diff --git a/crates/rocie-server/tests/defaults/mod.rs b/crates/rocie-server/tests/defaults/mod.rs new file mode 100644 index 0000000..6a334b0 --- /dev/null +++ b/crates/rocie-server/tests/defaults/mod.rs @@ -0,0 +1,48 @@ +use rocie_client::{ + apis::{api_get_auth_unit_api::units, api_set_no_auth_user_api::provision}, + models::{ProvisionInfo, UserStub}, +}; + +use crate::testenv::{TestEnv, init::function_name, log::request}; + +#[tokio::test] +async fn test_defaults_disabled() { + let env = TestEnv::new_no_login(function_name!()); + + request!( + env, + provision(ProvisionInfo { + user: UserStub { + description: None, + name: "James Richard Haynes".to_string(), + password: "hunter14".to_string() + }, + use_defaults: false, + }) + ); + + let default_units = request!(env, units()); + + assert_eq!(default_units, vec![]); +} + +#[tokio::test] +async fn test_defaults_all() { + let env = TestEnv::new_no_login(function_name!()); + + request!( + env, + provision(ProvisionInfo { + user: UserStub { + description: None, + name: "James Richard Haynes".to_string(), + password: "hunter14".to_string() + }, + use_defaults: true, + }) + ); + + let default_units = request!(env, units()); + + assert!(!default_units.is_empty()); +} diff --git a/crates/rocie-server/tests/recipies/mod.rs b/crates/rocie-server/tests/recipies/mod.rs index e59c3f6..15680f1 100644 --- a/crates/rocie-server/tests/recipies/mod.rs +++ b/crates/rocie-server/tests/recipies/mod.rs @@ -1,13 +1,14 @@ use rocie_client::{ apis::{ - api_get_auth_recipe_api::recipe_by_id, api_set_auth_product_api::register_product, + api_get_auth_recipe_api::{recipe_by_id, recipe_by_name}, + api_set_auth_product_api::register_product, api_set_auth_recipe_api::add_recipe, api_set_auth_unit_property_api::register_unit_property, }, models::{ Content, ContentOneOf, CooklangRecipe, Ingredient, IngredientOneOf1NotRegisteredProduct, - Item, ItemOneOf, ItemOneOf1, Metadata, NameAndUrl, ProductStub, RecipeStub, Section, Step, - UnitPropertyStub, + Item, ItemOneOf, ItemOneOf1, ItemOneOf1Ingredient, ItemOneOf3, ItemOneOfText, Metadata, + NameAndUrl, ProductStub, RecipeStub, Section, Step, UnitPropertyStub, }, }; @@ -74,7 +75,28 @@ title: Curry } #[tokio::test] -async fn test_recipe_ingredients() { +async fn test_recipe_whitespace() { + let env = TestEnv::new(function_name!()).await; + + let name = " Curry ".to_owned(); + + request!( + env, + add_recipe(RecipeStub { + content: " nothing really ".to_owned(), + name: name.clone(), + parent: None, + }) + ); + + let output = request!(env, recipe_by_name(&name)); + + assert_eq!(output.name, name); +} + +#[tokio::test] +#[expect(clippy::too_many_lines)] +async fn test_recipe_contents() { let env = TestEnv::new(function_name!()).await; let up = request!( @@ -104,6 +126,8 @@ author: James Connor title: Curry --- Add @rice{} and @water{200%ml} to a pot. + +Now add @curry-spice{200%g} let rest for ~{20%min}. " .to_owned(), name: "Curry".to_owned(), @@ -130,41 +154,131 @@ Add @rice{} and @water{200%ml} to a pot. quantity: None, } }), + Ingredient::IngredientOneOf1(rocie_client::models::IngredientOneOf1 { + not_registered_product: IngredientOneOf1NotRegisteredProduct { + name: "curry-spice".to_owned(), + quantity: None, + } + }), ] ); assert_eq!( output.content.sections, vec![Section { - content: vec![Content::ContentOneOf(ContentOneOf { - value: Step { - items: vec![ - Item::ItemOneOf(ItemOneOf { - r#type: rocie_client::models::item_one_of::Type::Text, - value: "Add ".to_owned() - }), - Item::ItemOneOf1(ItemOneOf1 { - r#type: rocie_client::models::item_one_of_1::Type::Ingredient, - index: 0 - }), - Item::ItemOneOf(ItemOneOf { - r#type: rocie_client::models::item_one_of::Type::Text, - value: " and ".to_owned() - }), - Item::ItemOneOf1(ItemOneOf1 { - r#type: rocie_client::models::item_one_of_1::Type::Ingredient, - index: 1 - }), - Item::ItemOneOf(ItemOneOf { - r#type: rocie_client::models::item_one_of::Type::Text, - value: " to a pot.".to_owned() - }) - ], - number: 1 - }, - r#type: rocie_client::models::content_one_of::Type::Step - })], + content: vec![ + Content::ContentOneOf(ContentOneOf { + step: Step { + items: vec![ + Item::ItemOneOf(ItemOneOf { + text: ItemOneOfText { + value: "Add ".to_owned() + } + }), + Item::ItemOneOf1(ItemOneOf1 { + ingredient: ItemOneOf1Ingredient { index: 0 } + }), + Item::ItemOneOf(ItemOneOf { + text: ItemOneOfText { + value: " and ".to_owned() + } + }), + Item::ItemOneOf1(ItemOneOf1 { + ingredient: ItemOneOf1Ingredient { index: 1 } + }), + Item::ItemOneOf(ItemOneOf { + text: ItemOneOfText { + value: " to a pot.".to_owned() + } + }) + ], + number: 1 + } + }), + Content::ContentOneOf(ContentOneOf { + step: Step { + items: vec![ + Item::ItemOneOf(ItemOneOf { + text: ItemOneOfText { + value: "Now add ".to_owned() + } + }), + Item::ItemOneOf1(ItemOneOf1 { + ingredient: ItemOneOf1Ingredient { index: 2 } + }), + Item::ItemOneOf(ItemOneOf { + text: ItemOneOfText { + value: " let rest for ".to_owned() + } + }), + Item::ItemOneOf3(ItemOneOf3 { + timer: ItemOneOf1Ingredient { index: 0 } + }), + Item::ItemOneOf(ItemOneOf { + text: ItemOneOfText { + value: ".".to_owned() + } + }) + ], + number: 2 + } + }) + ], name: None }] ); } + +#[tokio::test] +async fn test_recipe_full_parse() { + let env = TestEnv::new(function_name!()).await; + + // Recipe source: https://cook.md/https://bbcgoodfood.com/recipes/easy-pancakes + let recipe_id = request!( + env, + add_recipe(RecipeStub { + content: " +--- +title: Easy pancakes +description: Learn how to make the perfect pancakes every time with our foolproof easy crêpe recipe – elaborate flip optional +image: https://images.immediate.co.uk/production/volatile/sites/30/2020/08/recipe-image-legacy-id-1273477_8-ad36e3b.jpg?resize=440,400 +nutrition: + calories: 61 calories + fat: 2 grams fat + saturated fat: 1 grams saturated fat + carbohydrates: 7 grams carbohydrates + sugar: 1 grams sugar + protein: 3 grams protein + sodium: 0.1 milligram of sodium +tags: Cassie Best, Cook school, Crepe, Crêpes, easy pancakes, Flip, Flipping, Good for you, healthy pancakes, How to make pancakes, Make ahead, Pancake day, Pancake filling, Shrove Tuesday, Skills, thin pancakes +source: https://bbcgoodfood.com/recipes/easy-pancakes +author: Cassie Best +prep time: 10 minutes +course: Breakfast, Brunch, Main course +time required: 30 minutes +cook time: 20 minutes +servings: Makes 12 +cuisine: British +diet: Vegetarian +--- + +Put @plain flour{100%g}, @eggs{2}(large), @milk{300%ml}, @sunflower oil{1%tbsp} and a pinch of @salt{} into a #bowl{} or large jug, then whisk to a smooth batter. This should be similar in consistency to single cream. + +Set aside for ~{30%minutes} to rest if you have time, or start cooking straight away. + +Set a #medium frying pan{} or #crêpe pan{} over a medium heat and carefully wipe it with some oiled kitchen paper. + +When hot, cook your pancakes for ~{1%minute} on each side until golden, using around half a ladleful of batter per pancake. Keep them warm in a low oven as you make the rest. + +Serve with @?lemon wedges{} (optional) and @?caster sugar{} (optional), or your favourite filling. Once cold, you can layer the pancakes between baking parchment, then wrap in cling film and freeze for up to two months. +" + .to_owned(), + name: "Easy pancakes".to_owned(), + parent: None, + }) + ); + + let output = request!(env, recipe_by_id(recipe_id)); + + assert_eq!(output.name, "Easy pancakes".to_owned()); +} diff --git a/crates/rocie-server/tests/tests.rs b/crates/rocie-server/tests/tests.rs index e788712..6222017 100644 --- a/crates/rocie-server/tests/tests.rs +++ b/crates/rocie-server/tests/tests.rs @@ -9,3 +9,4 @@ mod recipe_parents; mod recipies; mod units; mod users; +mod defaults; diff --git a/crates/rocie-server/tests/users/mod.rs b/crates/rocie-server/tests/users/mod.rs index d381e8f..c7ba6f8 100644 --- a/crates/rocie-server/tests/users/mod.rs +++ b/crates/rocie-server/tests/users/mod.rs @@ -4,7 +4,7 @@ use rocie_client::{ api_set_auth_user_api::register_user, api_set_no_auth_user_api::{login, logout, provision}, }, - models::{LoginInfo, UserStub}, + models::{LoginInfo, ProvisionInfo, UserStub}, }; use crate::testenv::{TestEnv, init::function_name, log::request}; @@ -15,10 +15,13 @@ async fn test_provisioning() { let user_id = request!( env, - provision(UserStub { - description: None, - name: "James Richard Haynes".to_string(), - password: "hunter14".to_string() + provision(ProvisionInfo { + user: UserStub { + description: None, + name: "James Richard Haynes".to_string(), + password: "hunter14".to_string() + }, + use_defaults: false, }) ); diff --git a/nix/package.nix b/nix/package.nix index f3d16bf..d0efb16 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -11,129 +11,59 @@ { lib, rustPlatform, - installShellFiles, - # buildInputs - mpv-unwrapped, - ffmpeg, - openssl, - libffi, - zlib, - curl, - # NativeBuildInputs - makeWrapper, - llvmPackages_latest, - glibc, - sqlite, - fd, + # nativeBuildInputs pkg-config, - SDL2, - python3, - # Passthru - tree-sitter-yts, -}: let - python = python3.withPackages (ps: [ps.yt-dlp]); -in - rustPlatform.buildRustPackage (finalAttrs: { - pname = "yt"; - inherit - ((builtins.fromTOML (builtins.readFile - ../Cargo.toml)).workspace.package) - version - ; - - src = lib.cleanSourceWith { - src = lib.cleanSource ./..; - filter = name: type: - (type == "directory") - || (builtins.elem (builtins.baseNameOf name) [ - "Cargo.toml" - "Cargo.lock" - "mkdb.sh" - "help.str" - "raw_error_warning.txt" - "golden.txt" - ]) - || (lib.strings.hasSuffix ".rs" (builtins.baseNameOf name)) - || (lib.strings.hasSuffix ".h" (builtins.baseNameOf name)) - || (lib.strings.hasSuffix ".sql" (builtins.baseNameOf name)); - }; - - nativeBuildInputs = [ - installShellFiles - makeWrapper - sqlite - fd - pkg-config - ]; - - buildInputs = [ - mpv-unwrapped.dev - ffmpeg - openssl - libffi - zlib - curl.dev - python - ]; - - checkInputs = [ - # Needed for the tests in `libmpv2` - SDL2 - ]; - - env = let - clang_version = - lib.versions.major - llvmPackages_latest.clang-unwrapped.version; - in { - # Needed for the compile time sqlite checks. - DATABASE_URL = "sqlite://database.sqlx"; + sqlite, +}: +rustPlatform.buildRustPackage (finalAttrs: { + pname = "rocie-server"; + inherit + ((builtins.fromTOML (builtins.readFile + ../Cargo.toml)).workspace.package) + version + ; - # Required by yt_dlp - FFMPEG_LOCATION = "${lib.getExe ffmpeg}"; + src = lib.cleanSourceWith { + src = lib.cleanSource ./..; + filter = name: type: + (type == "directory") + || (builtins.elem (builtins.baseNameOf name) [ + "Cargo.toml" + "Cargo.lock" + "mkdb.sh" + ]) + || (lib.strings.hasSuffix ".rs" (builtins.baseNameOf name)) + || (lib.strings.hasSuffix ".sql" (builtins.baseNameOf name)); + }; - # Tell pyo3 which python to use. - PYO3_PYTHON = lib.getExe python; + nativeBuildInputs = [ + pkg-config + sqlite + ]; - # Needed for the libmpv2. - C_INCLUDE_PATH = "${glibc.dev}/include"; - LIBCLANG_INCLUDE_PATH = "${llvmPackages_latest.clang-unwrapped.lib}/lib/clang/${clang_version}/include"; - LIBCLANG_PATH = "${llvmPackages_latest.clang-unwrapped.lib}/lib/libclang.so"; - }; + buildInputs = [ + ]; - doCheck = true; - checkFlags = [ - # All of these tests try to connect to the internet to download test data. - "--skip=select::base::test_base" - "--skip=select::file::test_file" - "--skip=select::options::test_options" - "--skip=subscriptions::import_export::test_import_export" - "--skip=subscriptions::naming_subscriptions::test_naming_subscriptions" - "--skip=videos::downloading::test_downloading" - ]; + checkInputs = [ + ]; - prePatch = '' - # Generate the sqlite db, so that we can run the comp-time sqlite checks. - bash ./scripts/mkdb.sh - ''; + env = { + # Needed for the compile time sqlite checks. + DATABASE_URL = "sqlite://database.sqlx"; + }; - passthru = { - inherit tree-sitter-yts; - }; + doCheck = true; - cargoLock = { - lockFile = ../Cargo.lock; - }; + prePatch = '' + # Generate the sqlite db, so that we can run the comp-time sqlite checks. + bash ./scripts/mkdb.sh + ''; - postInstall = '' - installShellCompletion --cmd yt \ - --bash <(COMPLETE=bash $out/bin/yt) \ - --fish <(COMPLETE=fish $out/bin/yt) \ - --zsh <(COMPLETE=zsh $out/bin/yt) + cargoLock = { + lockFile = ../Cargo.lock; + }; - # NOTE: We cannot clear the path, because we need access to the $EDITOR. <2025-04-04> - wrapProgram $out/bin/yt \ - --prefix PATH : ${lib.makeBinPath finalAttrs.buildInputs} \ - --set YTDLP_NO_PLUGINS 1 - ''; - }) + meta = { + mainProgram = "rocie-server"; + }; +}) |
