284 lines
7.7 KiB
Rust
284 lines
7.7 KiB
Rust
// SPDX-FileCopyrightText: 2022 Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
|
|
// 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<String>,
|
|
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<String>,
|
|
pub keywords: Option<String>,
|
|
|
|
#[serde(rename = "dateCreated")]
|
|
pub created: DateTime,
|
|
|
|
#[serde(rename = "dateModified")]
|
|
pub modified: Option<DateTime>,
|
|
|
|
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<Duration>,
|
|
|
|
#[serde(default)]
|
|
#[serde(deserialize_with = "deserialize_maybe_duration")]
|
|
pub total_time: Option<Duration>,
|
|
|
|
pub image: Option<String>,
|
|
pub recipe_yield: isize,
|
|
|
|
#[serde(rename = "recipeCategory")]
|
|
pub category: Option<String>,
|
|
|
|
pub tools: Option<Vec<Tool>>,
|
|
#[serde(rename = "recipeIngredient")]
|
|
pub ingredients: Vec<Ingredient>,
|
|
#[serde(rename = "recipeInstructions")]
|
|
pub instructions: Vec<Instruction>,
|
|
//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<String>,
|
|
pub carbohydrates_content: Option<String>,
|
|
pub cholesterol_content: Option<String>,
|
|
pub fat_content: Option<String>,
|
|
pub fiber_content: Option<String>,
|
|
pub protein_content: Option<String>,
|
|
pub saturated_fat_content: Option<String>,
|
|
pub serving_size: Option<String>,
|
|
pub sodium_content: Option<String>,
|
|
pub sugar_content: Option<String>,
|
|
pub trans_fat_content: Option<String>,
|
|
pub unsaturated_fat_content: Option<String>,
|
|
}
|
|
|
|
type DateTime = chrono::DateTime<chrono::Utc>;
|
|
|
|
fn deserialize_maybe_duration<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
Ok(Some(deserialize_duration(deserializer)?))
|
|
}
|
|
|
|
fn deserialize_duration<'de, D>(deserializer: D) -> Result<Duration, D::Error>
|
|
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<E>(self, value: &str) -> Result<Self::Value, E>
|
|
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<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
deserializer.deserialize_str(IngredientVisitor)
|
|
}
|
|
}
|
|
|
|
struct IngredientVisitor;
|
|
|
|
impl<'de> serde::de::Visitor<'de> for IngredientVisitor {
|
|
type Value = Ingredient;
|
|
|
|
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
|
|
where
|
|
E: serde::de::Error,
|
|
{
|
|
use std::cmp::min;
|
|
|
|
let quantity_regex_start: regex::Regex =
|
|
regex::Regex::new(r"^(?P<v>[0-9,\.]+)\s*(?P<u>\w+)?\s+(?P<i>.*)").unwrap();
|
|
let quantity_regex_end: regex::Regex =
|
|
regex::Regex::new(r"(?P<i>.*?)\s+(?P<v>[0-9,\.]+)\s*(?P<u>\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<E>(value: &str, unit: &str) -> Result<(f64, Unit), E>
|
|
where
|
|
E: serde::de::Error,
|
|
{
|
|
let mut v = value
|
|
.parse::<f64>()
|
|
.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)
|
|
}
|
|
}
|