From dce761b0f9b678fc48539ce06808db85f3b7cf02 Mon Sep 17 00:00:00 2001 From: Matteo Settenvini Date: Thu, 28 Jul 2022 16:38:12 +0200 Subject: [PATCH] Create (but not publish) events from recipes --- .gitignore | 1 + .vscode/launch.json | 44 +++++++++ Cargo.lock | 98 +++++++++++++++++-- Cargo.toml | 12 ++- src/api_client.rs | 8 +- src/commands/import.rs | 30 ++++++ src/commands/init.rs | 14 +++ src/commands/mod.rs | 7 ++ src/commands/schedule.rs | 14 +++ src/commands/schedule_csv.rs | 181 +++++++++++++++++++++++++++++++++++ src/constants.rs | 1 + src/main.rs | 139 +++++++++------------------ src/recipe.rs | 130 +++++++++++++++++++++++-- 13 files changed, 560 insertions(+), 119 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 src/commands/import.rs create mode 100644 src/commands/init.rs create mode 100644 src/commands/mod.rs create mode 100644 src/commands/schedule.rs create mode 100644 src/commands/schedule_csv.rs diff --git a/.gitignore b/.gitignore index 03a17e2..803c8e0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ # SPDX-License-Identifier: CC0-1.0 /target +/*.csv \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..1ae0955 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,44 @@ +{ + // SPDX-FileCopyrightText: 2022 Matteo Settenvini + // SPDX-License-Identifier: CC0-1.0 + "version": "0.2.0", + "configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "Debug executable 'cook'", + "cargo": { + "args": [ + "build", + "--bin=cook", + "--package=cooking-schedule" + ], + "filter": { + "name": "cook", + "kind": "bin" + } + }, + "args": ["schedule-csv", "Cucina", "example-schedule.csv"], + "cwd": "${workspaceFolder}" + }, + { + "type": "lldb", + "request": "launch", + "name": "Debug unit tests in executable 'cook'", + "cargo": { + "args": [ + "test", + "--no-run", + "--bin=cook", + "--package=cooking-schedule" + ], + "filter": { + "name": "cook", + "kind": "bin" + } + }, + "args": [], + "cwd": "${workspaceFolder}" + } + ] +} \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index c1e26dc..5844dba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -147,12 +147,14 @@ dependencies = [ "clap", "csv", "directories", + "futures", "ics", "reqwest", "rusty-hook", - "rustydav", "serde", "serde_json", + "speedate", + "strum_macros", "tokio", "toml", "webbrowser", @@ -316,6 +318,21 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1fd087255f739f4f1aeea69f11b72f8080e9c2e7645cd06955dad4a178a49e3" +[[package]] +name = "futures" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f73fe65f54d1e12b726f517d3e2135ca3125a437b6d998caf1962961f7172d9e" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.21" @@ -323,6 +340,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -331,12 +349,34 @@ version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3" +[[package]] +name = "futures-executor" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b" +[[package]] +name = "futures-macro" +version = "0.3.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33c1e13800337f4d4d7a316bf45a567dbcb6ffe087f16424852d97e97a91f512" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.21" @@ -355,8 +395,11 @@ version = "0.3.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", + "futures-sink", "futures-task", "memchr", "pin-project-lite", @@ -409,6 +452,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db0d4cf898abf0081f964436dc980e96670a0f36863e4b83aaacdb65c9d7ccc3" +[[package]] +name = "heck" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" + [[package]] name = "hermit-abi" version = "0.1.19" @@ -940,6 +989,12 @@ dependencies = [ "winreg", ] +[[package]] +name = "rustversion" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24c8ad4f0c00e1eb5bc7614d236a7f1300e3dbd76b68cac8e06fb00b015ad8d8" + [[package]] name = "rusty-hook" version = "0.11.2" @@ -952,15 +1007,6 @@ dependencies = [ "toml", ] -[[package]] -name = "rustydav" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc4c86c47126ac8bfc573084610e93f4ca8726f3ae7bf6c64bd60476731b6e42" -dependencies = [ - "reqwest", -] - [[package]] name = "ryu" version = "1.0.10" @@ -1068,12 +1114,44 @@ dependencies = [ "winapi", ] +[[package]] +name = "speedate" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519ab0d5d5dc6d050a2327ea508d20b460dba1e3a76f1933c56b7c4da2b5c620" +dependencies = [ + "strum", + "strum_macros", +] + [[package]] name = "strsim" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strum" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4faebde00e8ff94316c01800f9054fd2ba77d30d9e922541913051d1d978918b" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "syn" version = "1.0.98" diff --git a/Cargo.toml b/Cargo.toml index 8dd4aeb..761f927 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,9 @@ features = ["cargo"] [dependencies.directories] version = "4.0" +[dependencies.futures] +version = "0.3" + [dependencies.ics] version = "0.5" @@ -43,9 +46,6 @@ version = "0.5" version = "0.11" features = ["json", "blocking"] -[dependencies.rustydav] -version = "0.1" - [dependencies.serde] version = "1.0" features = ["derive"] @@ -53,6 +53,12 @@ features = ["derive"] [dependencies.serde_json] version = "1.0" +[dependencies.speedate] +version = "0.6" + +[dependencies.strum_macros] +version = "0.24" + [dependencies.toml] version = "0.5" diff --git a/src/api_client.rs b/src/api_client.rs index 2a1be9f..6acb3e4 100644 --- a/src/api_client.rs +++ b/src/api_client.rs @@ -8,7 +8,8 @@ use { pub struct ApiClient { pub base_url: Url, - pub client: reqwest::Client, + pub username: String, + pub rest: reqwest::Client, } impl ApiClient { @@ -37,14 +38,15 @@ impl ApiClient { auth_header.set_sensitive(true); default_headers.insert(header::AUTHORIZATION, auth_header); - let client = reqwest::Client::builder() + let rest_client = reqwest::Client::builder() .user_agent(constants::USER_AGENT) .default_headers(default_headers) .build()?; Ok(ApiClient { base_url: Url::parse(&server.url)?, - client: client, + username: server.login_name.clone(), + rest: rest_client, }) } } diff --git a/src/commands/import.rs b/src/commands/import.rs new file mode 100644 index 0000000..3ac8331 --- /dev/null +++ b/src/commands/import.rs @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2022 Matteo Settenvini +// SPDX-License-Identifier: AGPL-3.0-or-later + +use {crate::api_client::ApiClient, anyhow::Result, reqwest::StatusCode}; + +pub async fn with(api_client: &ApiClient, urls: UrlsIter) -> Result<()> +where + UrlsIter: std::iter::Iterator, + UrlsIter::Item: AsRef, +{ + for url in urls { + let response = api_client + .rest + .post(api_client.base_url.join("apps/cookbook/import")?) + .json(&serde_json::json!({ + "url": url.as_ref(), + })) + .send() + .await?; + if ![StatusCode::OK, StatusCode::CONFLICT].contains(&response.status()) { + anyhow::bail!( + "Unable to import recipe {}, received status code {}", + url.as_ref(), + response.status() + ); + } + } + + Ok(()) +} diff --git a/src/commands/init.rs b/src/commands/init.rs new file mode 100644 index 0000000..5c4add4 --- /dev/null +++ b/src/commands/init.rs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2022 Matteo Settenvini +// SPDX-License-Identifier: AGPL-3.0-or-later + +use {crate::config::Config, anyhow::Result, reqwest::Url}; + +pub async fn with(configuration: &mut Config, server: &str) -> Result<()> { + tokio::task::block_in_place(move || -> anyhow::Result<()> { + configuration + .credentials + .add(Url::parse(server)?) + .expect("Unable to authenticate to NextCloud instance"); + Ok(()) + }) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..32611c5 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: 2022 Matteo Settenvini +// SPDX-License-Identifier: AGPL-3.0-or-later + +pub mod import; +pub mod init; +pub mod schedule; +pub mod schedule_csv; diff --git a/src/commands/schedule.rs b/src/commands/schedule.rs new file mode 100644 index 0000000..f92cab2 --- /dev/null +++ b/src/commands/schedule.rs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2022 Matteo Settenvini +// SPDX-License-Identifier: AGPL-3.0-or-later + +use {crate::api_client::ApiClient, crate::recipe, anyhow::Result}; + +pub async fn with(api_client: &ApiClient) -> Result<()> { + let recipes = api_client + .rest + .get(api_client.base_url.join("apps/cookbook/api/recipes")?) + .send() + .await?; + println!("{:#?}", recipes.json::>().await?); + todo!(); +} diff --git a/src/commands/schedule_csv.rs b/src/commands/schedule_csv.rs new file mode 100644 index 0000000..51e7ab9 --- /dev/null +++ b/src/commands/schedule_csv.rs @@ -0,0 +1,181 @@ +// SPDX-FileCopyrightText: 2022 Matteo Settenvini +// SPDX-License-Identifier: AGPL-3.0-or-later + +use { + crate::api_client::ApiClient, + crate::commands::import, + crate::constants, + crate::recipe, + anyhow::Result, + chrono::naive::{NaiveDate, NaiveDateTime, NaiveTime}, + chrono::{DateTime, Local, TimeZone}, + futures::future::try_join_all, + ics::properties as calprop, + ics::Event, + std::collections::{HashMap, HashSet}, + std::iter::Iterator, + std::path::Path, +}; + +#[derive(serde::Deserialize)] +struct CsvRecord { + day: NaiveDate, + lunch: String, + dinner: String, +} + +#[derive(strum_macros::Display)] +enum Meal { + Lunch, + Dinner, +} + +pub async fn with(api_client: &ApiClient, calendar: &str, csv_file: &Path) -> Result<()> { + let mut csv = csv::Reader::from_path(csv_file)?; + let records = csv.deserialize::().flatten().collect::>(); + + let recipe_urls = urls_from_csv(records.iter())?; + import::with(&api_client, recipe_urls.into_iter()).await?; + let recipes = get_all_recipes(&api_client).await?; + + let events = records + .iter() + .flat_map(|r| { + let lunch = recipes.get(&r.lunch); + let dinner = recipes.get(&r.dinner); + + let events = [ + lunch.map(|recipe| to_event(r.day, Meal::Lunch, recipe)), + dinner.map(|recipe| to_event(r.day, Meal::Dinner, recipe)), + ]; + + events + }) + .flatten(); + + publish_events(&api_client, calendar, events)?; + Ok(()) +} + +fn to_event(date: NaiveDate, meal: Meal, recipe: &recipe::Recipe) -> Event { + // TODO: this is momentarily hardcoded, should be an option + let meal_time = match meal { + Meal::Lunch => NaiveTime::from_hms(12, 00, 00), + Meal::Dinner => NaiveTime::from_hms(19, 00, 00), + }; + + let uid = format!( + "{}-{}@{}.montecristosoftware.eu", + date, + meal, + env!("CARGO_PKG_NAME") + ); + + let end_time = NaiveDateTime::new(date, meal_time); + let start_time = end_time - recipe.total_time(); + + let mut event = Event::new(uid, dt_fmt(&Local::now())); + event.push(calprop::Summary::new(&recipe.name)); + event.push(calprop::Description::new(format!("cookbook@{}", recipe.id))); + event.push(calprop::Location::new(&recipe.url)); + event.push(calprop::DtStart::new(dt_fmt( + &Local.from_local_datetime(&start_time).unwrap(), + ))); + event.push(calprop::DtEnd::new(dt_fmt( + &Local.from_local_datetime(&end_time).unwrap(), + ))); + + let alarm = ics::Alarm::display( + calprop::Trigger::new("-P15M"), + calprop::Description::new(&recipe.name), + ); + event.add_alarm(alarm); + + event +} + +fn urls_from_csv<'a, RecordsIter>(records: RecordsIter) -> Result> +where + RecordsIter: Iterator, +{ + Ok( + records.fold(std::collections::HashSet::new(), |mut set, r| { + if !r.lunch.is_empty() { + set.insert(r.lunch.clone()); + } + if !r.dinner.is_empty() { + set.insert(r.dinner.clone()); + } + set + }), + ) +} + +fn dt_fmt(datetime: &DateTime) -> String { + datetime.format("%Y%m%dT%H%M%SZ").to_string() +} + +async fn get_all_recipes(api_client: &ApiClient) -> Result> { + let metadata = api_client + .rest + .get(api_client.base_url.join("apps/cookbook/api/recipes")?) + .send() + .await? + .json::>() + .await?; + + let recipes = metadata.iter().map(|rm| async { + let response = api_client + .rest + .get( + api_client + .base_url + .join(&format!("apps/cookbook/api/recipes/{id}", id = rm.id)) + .unwrap(), + ) + .send() + .await + .expect(&format!( + "Cannot fetch recipe {} with id {}", + rm.name, rm.id + )); + response.json::().await + }); + + let recipes = try_join_all(recipes).await?; + Ok(HashMap::from_iter( + recipes.into_iter().map(|r| (r.url.clone(), r)), + )) +} + +fn publish_events<'a, EventsIter>( + api_client: &ApiClient, + calendar: &str, + events: EventsIter, +) -> Result<()> +where + EventsIter: Iterator>, +{ + let calendar_url = api_client.base_url.join(&format!( + "remote.php/dav/calendars/{}/{}/", + &api_client.username, + calendar.to_lowercase().as_str().replace(" ", "-") + )); + + let cal = ics::ICalendar::new( + "2.0", + format!( + "-//{}//NONSGML {}//EN", + constants::VENDOR, + env!("CARGO_PKG_NAME") + ), + ); + let cal = events.fold(cal, |mut cal, e| { + cal.add_event(e); + cal + }); + + println!("CALENDAR: \n {:?}", cal); + + Ok(()) +} diff --git a/src/constants.rs b/src/constants.rs index 8fb4a1c..4c99f75 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -2,3 +2,4 @@ // SPDX-License-Identifier: AGPL-3.0-or-later pub const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); +pub const VENDOR: &str = "montecristosoftware.eu"; diff --git a/src/main.rs b/src/main.rs index 097f3aa..3fb9c3d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later mod api_client; +mod commands; mod config; mod constants; mod recipe; @@ -9,106 +10,18 @@ mod recipe; use { crate::api_client::ApiClient, crate::config::Config, - crate::recipe::Recipe, + anyhow::Result, clap::{arg, command, ArgMatches, Command}, - reqwest::{StatusCode, Url}, std::path::PathBuf, }; #[tokio::main(flavor = "multi_thread")] -async fn main() -> anyhow::Result<()> { - let args = parse_args(); - let mut configuration = Config::new(); - - match args.subcommand() { - Some(("init", sub_matches)) => { - let server = sub_matches - .get_one::("server") - .expect("Mandatory parameter "); - tokio::task::block_in_place(move || -> anyhow::Result<()> { - configuration - .credentials - .add(Url::parse(server)?) - .expect("Unable to authenticate to NextCloud instance"); - Ok(()) - })?; - } - Some(("import", sub_matches)) => { - let api_client = get_api_client(&sub_matches, &configuration)?; - for url in sub_matches - .get_many::("url") - .expect("At least one url is required") - { - let response = api_client - .client - .post(api_client.base_url.join("apps/cookbook/import")?) - .json(&serde_json::json!({ - "url": url, - })) - .send() - .await?; - println!("{:#?}", response); // TODO - assert!([StatusCode::OK, StatusCode::CONFLICT].contains(&response.status())); - } - } - Some(("schedule", sub_matches)) => { - let api_client = get_api_client(&sub_matches, &configuration)?; - let recipes = api_client - .client - .get(api_client.base_url.join("apps/cookbook/api/recipes")?) - .send() - .await?; - println!("{:#?}", recipes.json::>().await?); // TODO - } - Some(("schedule-csv", sub_matches)) => { - let csv_file = sub_matches - .get_one::("csv_file") - .expect(" is a mandatory parameter, it cannot be missing"); - let calendar_name = sub_matches - .get_one::("calendar_name") - .expect(" is a mandatory parameter, it cannot be missing"); - - let mut csv = csv::Reader::from_path(csv_file)?; - - #[derive(serde::Deserialize)] - struct CsvRecord { - day: chrono::naive::NaiveDate, - lunch: String, - dinner: String, - } - - let recipe_urls = csv.deserialize::().fold( - std::collections::HashSet::new(), - |mut set, r| { - if let Ok(r) = r { - set.insert(r.lunch); - set.insert(r.dinner); - } - set - }, - ); - - let api_client = get_api_client(&sub_matches, &configuration)?; - for url in recipe_urls { - let response = api_client - .client - .post(api_client.base_url.join("apps/cookbook/import")?) - .json(&serde_json::json!({ - "url": url, - })) - .send() - .await?; - println!("{:#?}", response); // TODO - assert!([StatusCode::OK, StatusCode::CONFLICT].contains(&response.status())); - } - } - _ => unreachable!("Exhausted list of subcommands and subcommand_required prevents `None`"), - }; - - Ok(()) +async fn main() -> Result<()> { + let args = setup_args(); + parse_args(&args).await } -fn parse_args() -> ArgMatches { +fn setup_args() -> ArgMatches { let server_arg = arg!(-s --server "NextCloud server to connect to").required(false); command!() @@ -137,7 +50,7 @@ fn parse_args() -> ArgMatches { ) .subcommand( Command::new("schedule-csv") - .about("TEMPORARY WIP FUNCTION, UNSTABLE") + .about("TEMPORARY WIP FUNCTION USED FOR INTERNAL TESTING") .arg(server_arg.clone()) .arg(arg!( "")) .arg(arg!( "").value_parser(clap::value_parser!(PathBuf))) @@ -145,7 +58,43 @@ fn parse_args() -> ArgMatches { .get_matches() } -fn get_api_client(sub_matches: &ArgMatches, configuration: &Config) -> anyhow::Result { +async fn parse_args(args: &ArgMatches) -> Result<()> { + let mut configuration = Config::new(); + + match args.subcommand() { + Some(("init", sub_matches)) => { + let server = sub_matches + .get_one::("server") + .expect("Mandatory parameter "); + commands::init::with(&mut configuration, server).await + } + Some(("import", sub_matches)) => { + let api_client = get_api_client(&sub_matches, &configuration)?; + let urls = sub_matches + .get_many::("url") + .expect("At least one url is required") + .map(|s| s.as_str()); + commands::import::with(&api_client, urls).await + } + Some(("schedule", sub_matches)) => { + let api_client = get_api_client(&sub_matches, &configuration)?; + commands::schedule::with(&api_client).await + } + Some(("schedule-csv", sub_matches)) => { + let api_client = get_api_client(&sub_matches, &configuration)?; + let csv_file = sub_matches + .get_one::("csv_file") + .expect(" is a mandatory parameter, it cannot be missing"); + let calendar_name = sub_matches + .get_one::("calendar_name") + .expect(" is a mandatory parameter, it cannot be missing"); + commands::schedule_csv::with(&api_client, calendar_name.as_str(), &csv_file).await + } + _ => unreachable!("Exhausted list of subcommands and subcommand_required prevents `None`"), + } +} + +fn get_api_client(sub_matches: &ArgMatches, configuration: &Config) -> Result { let server_name = sub_matches .get_one::("server") .unwrap_or_else(|| { diff --git a/src/recipe.rs b/src/recipe.rs index ab56983..7dfd1df 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -2,17 +2,131 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use { - chrono::{DateTime, Utc}, - serde::Deserialize, + chrono::Duration, + serde::{Deserialize, Deserializer}, }; #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] -pub struct Recipe { +pub struct Metadata { #[serde(rename = "recipe_id")] - recipe_id: u32, - name: String, - keywords: String, - date_created: DateTime, - date_modified: DateTime, + pub id: u32, + pub name: String, + pub keywords: 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: String, + pub keywords: String, + + #[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(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Ingredient(String); + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Tool(String); + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Instruction(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())) + } }