// SPDX-FileCopyrightText: 2022 Matteo Settenvini // SPDX-License-Identifier: AGPL-3.0-or-later use { chrono::Duration, serde::{Deserialize, Deserializer}, }; #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Metadata { #[serde(rename = "recipe_id")] pub id: u32, pub name: String, pub keywords: Option, pub date_created: DateTime, pub date_modified: DateTime, } /// A recipe according to [schema.org](http://schema.org/Recipe) #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Recipe { pub id: isize, pub name: String, pub description: String, pub url: Option, pub keywords: Option, #[serde(rename = "dateCreated")] pub created: DateTime, #[serde(rename = "dateModified")] pub modified: Option, pub image_url: String, #[serde(deserialize_with = "deserialize_duration")] pub prep_time: Duration, #[serde(default)] #[serde(deserialize_with = "deserialize_maybe_duration")] pub cook_time: Option, #[serde(default)] #[serde(deserialize_with = "deserialize_maybe_duration")] pub total_time: Option, pub image: Option, pub recipe_yield: isize, #[serde(rename = "recipeCategory")] pub category: Option, pub tools: Option>, #[serde(rename = "recipeIngredient")] pub ingredients: Vec, #[serde(rename = "recipeInstructions")] pub instructions: Vec, //pub nutrition: Nutrition, } #[derive(Debug, PartialEq, PartialOrd)] pub struct Ingredient { pub name: String, pub amount: f64, pub unit: Unit, } #[derive(Debug, Eq, PartialEq, PartialOrd)] pub enum Unit { None, Kilogram, Liter, } #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Tool(pub String); #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Instruction(pub String); #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct Nutrition { pub calories: Option, pub carbohydrates_content: Option, pub cholesterol_content: Option, pub fat_content: Option, pub fiber_content: Option, pub protein_content: Option, pub saturated_fat_content: Option, pub serving_size: Option, pub sodium_content: Option, pub sugar_content: Option, pub trans_fat_content: Option, pub unsaturated_fat_content: Option, } type DateTime = chrono::DateTime; fn deserialize_maybe_duration<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { Ok(Some(deserialize_duration(deserializer)?)) } fn deserialize_duration<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { deserializer.deserialize_str(DurationVisitor) } struct DurationVisitor; impl<'de> serde::de::Visitor<'de> for DurationVisitor { type Value = Duration; fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("a duration in ISO 8601 format") } fn visit_str(self, value: &str) -> Result where E: serde::de::Error, { speedate::Duration::parse_str(value) .map(|dt| Duration::seconds(dt.signed_total_seconds())) .map_err(|e| E::custom(e.to_string())) } } impl Recipe { pub fn total_time(&self) -> Duration { self.total_time .unwrap_or_else(|| self.prep_time + self.cook_time.unwrap_or(Duration::zero())) } } impl<'de> Deserialize<'de> for Ingredient { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { deserializer.deserialize_str(IngredientVisitor) } } struct IngredientVisitor; impl<'de> serde::de::Visitor<'de> for IngredientVisitor { type Value = Ingredient; fn visit_str(self, value: &str) -> Result where E: serde::de::Error, { use std::cmp::min; let quantity_regex_start: regex::Regex = regex::Regex::new(r"^(?P[0-9,\.]+)\s*(?P\w+)?\s+(?P.*)").unwrap(); let quantity_regex_end: regex::Regex = regex::Regex::new(r"(?P.*?)\s+(?P[0-9,\.]+)\s*(?P\w+)?$").unwrap(); if let Some(captures) = quantity_regex_start .captures(value) .or_else(|| quantity_regex_end.captures(value)) { let v = captures.name("v").unwrap().as_str(); let um = captures.name("u"); let u = um.map(|u| u.as_str()).unwrap_or(""); let im = captures.name("i").unwrap(); let i = im.as_str(); let (amount, unit) = Self::parse_quantity(v, u)?; let name = match unit { Unit::None => { // pick the longest string including what was recognized by the regex as the possible unit let start = um .map(|um| min(um.start(), im.start())) .unwrap_or_else(|| im.start()); &value[start..im.end()] } _ => i, } .to_owned(); Ok(Ingredient { name, amount, unit }) } else { Ok(Ingredient { name: value.to_owned(), amount: 1.0, unit: Unit::None, }) } } fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { formatter.write_str("a parseable ingredient name with an optional mass or volume quantity at the beginning or end") } } impl IngredientVisitor { fn parse_quantity(value: &str, unit: &str) -> Result<(f64, Unit), E> where E: serde::de::Error, { let mut v = value .parse::() .map_err(|e| serde::de::Error::custom(e.to_string()))?; let u = match unit.to_lowercase().as_str() { "kg" => Unit::Kilogram, "g" | "gr" => { v = v / 1000.0; Unit::Kilogram } "hg" => { v = v / 10.0; Unit::Kilogram } "l" => Unit::Liter, "dl" => { v = v / 10.0; Unit::Liter } "cl" => { v = v / 100.0; Unit::Liter } "ml" => { v = v / 1000.0; Unit::Liter } "tl" => { v = v / 1000.0 * 5.0; Unit::Liter } "el" => { v = v / 1000.0 * 15.0; Unit::Liter } "tsp" => { v = v / 1000.0 * 4.93; Unit::Liter } "tblsp" => { v = v / 1000.0 * 14.79; Unit::Liter } _ => Unit::None, }; Ok((v, u)) } } impl core::fmt::Display for Ingredient { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "{amount:.3}{unit} {name}", name = self.name, amount = self.amount, unit = self.unit ) } } impl core::fmt::Display for Unit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let u: &dyn core::fmt::Display = match self { Unit::Kilogram => &"kg", Unit::Liter => &"L", Unit::None => &"", }; write!(f, "{}", u) } }