diff --git a/Cargo.lock b/Cargo.lock index 6bb7b82..ab7d06e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aho-corasick" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +dependencies = [ + "memchr", +] + [[package]] name = "android_system_properties" version = "0.1.4" @@ -171,7 +180,9 @@ dependencies = [ "futures", "iana-time-zone", "ics", + "markdown-gen", "minicaldav", + "regex", "reqwest", "rusty-hook", "serde", @@ -704,6 +715,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "markdown-gen" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034621d7f1258317ca1dfb9205e3925d27ee4aa2a46620a09c567daf0310562" + [[package]] name = "matches" version = "0.1.9" @@ -1015,12 +1032,29 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +[[package]] +name = "regex-syntax" +version = "0.6.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244" + [[package]] name = "remove_dir_all" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index 06b7af8..798dedc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,9 +45,15 @@ version = "0.3" [dependencies.ics] version = "0.5" +[dependencies.markdown-gen] +version = "1.2" + [dependencies.minicaldav] version = "0.2" +[dependencies.regex] +version = "1.6" + [dependencies.reqwest] version = "0.11" features = ["json", "blocking"] diff --git a/src/commands/groceries.rs b/src/commands/groceries.rs new file mode 100644 index 0000000..7d4068f --- /dev/null +++ b/src/commands/groceries.rs @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: 2022 Matteo Settenvini +// SPDX-License-Identifier: AGPL-3.0-or-later + +use { + crate::api_client::ApiClient, + crate::constants, + crate::recipe::{Ingredient, Recipe}, + anyhow::{anyhow, Result}, + chrono::{Duration, Local, NaiveDateTime}, + regex::Regex, + std::collections::HashSet, +}; + +pub async fn with(api_client: &ApiClient, calendar_name: &str, days: u32) -> Result<()> { + let ids = map_events_to_recipe_ids(api_client, calendar_name, days)?; + let ingredients = get_ingredients(api_client, ids).await?; + + todo!("Recipe ingredients: {:?}", ingredients) + + // let ingredients = merge_ingredients(ingredients); + // let md = prepare_grocery_list(&ingredients); + // save_grocery_list(api_client, "", md).await?; +} + +fn map_events_to_recipe_ids( + api_client: &ApiClient, + calendar_name: &str, + days: u32, +) -> Result> { + // minicaldav is quite hamfisted in retrieving events (it gets everything, every time). + // Consider crafting an XML request and use something else for ical parsing. + + let calendars = api_client + .get_calendars() + .map_err(|err| convert_caldav_err(err))?; + let calendar = calendars + .into_iter() + .filter(|c| c.name() == calendar_name) + .next() + .ok_or(anyhow!( + "Unable to find calendar '{}' on server", + calendar_name + ))?; + + let all_events = api_client + .get_events(&calendar) + .map_err(|err| convert_caldav_err(err))? + .0; + + let relevant_events = all_events.into_iter().filter(|ev| { + ev.get("PRODID") + .map(|p| p == constants::CALENDAR_PROVIDER) + .unwrap_or(false) + && { + let start_time = NaiveDateTime::parse_from_str( + ev.property("DTSTART").unwrap().value(), + "%Y%m%dT%H%M%S", + ) + .unwrap_or(NaiveDateTime::from_timestamp(0, 0)); + let now = Local::now().naive_local(); + start_time >= now && start_time < now + Duration::days(days as i64) + } + }); + + let recipe_id_regex: Regex = Regex::new(r"cookbook@(\d+)").unwrap(); + let recipe_ids = relevant_events + .flat_map(|event| { + event + .property("DESCRIPTION") + .iter() + .flat_map(|descr| recipe_id_regex.captures(descr.value())) + .flat_map(|c| c.get(1)) + .flat_map(|m| m.as_str().parse::()) + .collect::>() + }) + .collect::>(); + + Ok(recipe_ids) +} + +async fn get_ingredients( + api_client: &ApiClient, + recipe_ids: RecipeIds, +) -> Result> +where + RecipeIds: IntoIterator, +{ + let ingredients = recipe_ids.into_iter().map(|id: usize| async move { + // TODO code duplicated with schedule_csv::get_all_recipes + let recipe_url = format!("apps/cookbook/api/recipes/{id}"); + let response = api_client + .rest() + .get(api_client.base_url().join(&recipe_url).unwrap()) + .send() + .await + .expect(&format!("Cannot fetch recipe with id {}", id)); + + response.json::().await.map(|r| r.ingredients) + }); + + let ingredients = futures::future::try_join_all(ingredients).await?; + Ok(ingredients.into_iter().flatten().collect()) +} + +fn convert_caldav_err(err: minicaldav::Error) -> anyhow::Error { + anyhow!(format!("{:?}", err)) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 32611c5..042df50 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: 2022 Matteo Settenvini // SPDX-License-Identifier: AGPL-3.0-or-later +pub mod groceries; pub mod import; pub mod init; pub mod schedule; diff --git a/src/commands/schedule_csv.rs b/src/commands/schedule_csv.rs index 40769fa..1595129 100644 --- a/src/commands/schedule_csv.rs +++ b/src/commands/schedule_csv.rs @@ -110,15 +110,8 @@ async fn publish_events<'a, EventsIter>( where EventsIter: Iterator, { - let calendar_prototype: ics::ICalendar = ics::ICalendar::new( - "2.0", - format!( - "-//IDN {}//{} {}//EN", - constants::VENDOR, - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_VERSION") - ), - ); + let calendar_prototype: ics::ICalendar = + ics::ICalendar::new("2.0", constants::CALENDAR_PROVIDER); let dav_base = api_client .rest() diff --git a/src/constants.rs b/src/constants.rs index 4c99f75..7ea9183 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -2,4 +2,10 @@ // 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"; +pub const CALENDAR_PROVIDER: &str = concat!( + "-//IDN montecristosoftware.eu//", + env!("CARGO_PKG_NAME"), + " ", + env!("CARGO_PKG_VERSION"), + "//EN" +); diff --git a/src/main.rs b/src/main.rs index 69aa993..cc0b3fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,10 +41,20 @@ fn setup_args() -> ArgMatches { .arg(arg!( ... "One or more URLs each pointing to page with a recipe to import in NextCloud")), ) .subcommand( - Command::new("schedule") - .about("") + Command::new("groceries") + .about("Create a grocery list from scheduled calendar events") .arg(server_arg.clone()) - .arg(arg!(-d --days "") + .arg(arg!( "")) + .arg(arg!(-d --days "Number of days in the future to consider") + .value_parser(clap::builder::RangedU64ValueParser::::new().range(1..)) + .required(false) + .default_value("7")) + ) + .subcommand( + Command::new("schedule") + .about("TODO; not implemented yet (pick automatically recipes and create calendar events for them)") + .arg(server_arg.clone()) + .arg(arg!(-d --days "Number of days to schedule") .value_parser(clap::builder::RangedU64ValueParser::::new().range(1..)) .required(false) .default_value("7")) @@ -77,6 +87,14 @@ async fn parse_args(args: &ArgMatches) -> Result<()> { .map(|s| s.as_str()); commands::import::with(&api_client, urls).await } + Some(("groceries", sub_matches)) => { + let api_client = get_api_client(&sub_matches, &configuration)?; + let calendar_name = sub_matches + .get_one::("calendar_name") + .expect(" is a mandatory parameter, it cannot be missing"); + let days = sub_matches.get_one::("days").unwrap(); + commands::groceries::with(&api_client, calendar_name.as_str(), *days).await + } Some(("schedule", sub_matches)) => { let api_client = get_api_client(&sub_matches, &configuration)?; commands::schedule::with(&api_client).await