diff --git a/.vscode/launch.json b/.vscode/launch.json index 2d64fc4..1230984 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -18,7 +18,7 @@ "kind": "bin" } }, - "args": ["schedule-csv", "Cucina", "examples/example-schedule.csv"], + "args": ["groceries", "Cucina"], "cwd": "${workspaceFolder}" }, { diff --git a/Cargo.lock b/Cargo.lock index ab7d06e..0d4da34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 3 -[[package]] -name = "adler" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - [[package]] name = "aho-corasick" version = "0.7.18" @@ -117,12 +111,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "chunked_transfer" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fff857943da45f546682664a79488be82e69e43c1a7a2307679ab9afb3a66d2e" - [[package]] name = "ci_info" version = "0.10.2" @@ -179,9 +167,10 @@ dependencies = [ "directories", "futures", "iana-time-zone", + "icalendar", "ics", + "libxml", "markdown-gen", - "minicaldav", "regex", "reqwest", "rusty-hook", @@ -191,7 +180,6 @@ dependencies = [ "strum_macros", "tokio", "toml", - "ureq", "webbrowser", ] @@ -211,15 +199,6 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" -[[package]] -name = "crc32fast" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" -dependencies = [ - "cfg-if", -] - [[package]] name = "csv" version = "1.1.6" @@ -325,16 +304,6 @@ dependencies = [ "instant", ] -[[package]] -name = "flate2" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f82b0f4c27ad9f8bfd1f3208d882da2b09c301bc1c828fd3a00d0216d2fbbff6" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - [[package]] name = "fnv" version = "1.0.7" @@ -605,6 +574,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "icalendar" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4c8ac7e9b7eefe850f6380c68ef5fd1300858c1ef65d121d602a9bab63c2f4" +dependencies = [ + "chrono", + "nom", + "uuid", +] + [[package]] name = "ics" version = "0.5.7" @@ -706,6 +686,17 @@ version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" +[[package]] +name = "libxml" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687f5a78939052c5d02865c0fe3ea2ce2acdca875f7f81db82f7aef256dd97ac" +dependencies = [ + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "log" version = "0.4.17" @@ -740,26 +731,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" [[package]] -name = "minicaldav" -version = "0.2.0" +name = "minimal-lexical" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb263a7d12c40d5f200dda93b3665b9ae714d4fe64a6467938c92d974a579edb" -dependencies = [ - "base64", - "log", - "ureq", - "url", - "xmltree", -] - -[[package]] -name = "miniz_oxide" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f5c75688da582b8ffc1f1799e9db273f32133c49e048f614d22ec3256773ccc" -dependencies = [ - "adler", -] +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" @@ -853,6 +828,16 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab250442c86f1850815b5d268639dff018c0627022bc1940eb2d642ca1ce12f0" +[[package]] +name = "nom" +version = "7.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-integer" version = "0.1.45" @@ -1101,33 +1086,6 @@ dependencies = [ "winreg", ] -[[package]] -name = "ring" -version = "0.16.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" -dependencies = [ - "cc", - "libc", - "once_cell", - "spin", - "untrusted", - "web-sys", - "winapi", -] - -[[package]] -name = "rustls" -version = "0.20.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aab8ee6c7097ed6057f43c187a62418d0c05a4bd5f18b3571db50ee0f9ce033" -dependencies = [ - "log", - "ring", - "sct", - "webpki", -] - [[package]] name = "rustversion" version = "1.0.8" @@ -1171,16 +1129,6 @@ dependencies = [ "windows-sys", ] -[[package]] -name = "sct" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "security-framework" version = "2.6.1" @@ -1273,12 +1221,6 @@ dependencies = [ "strum_macros", ] -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - [[package]] name = "strsim" version = "0.10.0" @@ -1514,30 +1456,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[package]] -name = "ureq" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97acb4c28a254fd7a4aeec976c46a7fa404eac4d7c134b30c75144846d7cb8f" -dependencies = [ - "base64", - "chunked_transfer", - "encoding_rs", - "flate2", - "log", - "once_cell", - "rustls", - "url", - "webpki", - "webpki-roots", -] - [[package]] name = "url" version = "2.2.2" @@ -1550,6 +1468,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -1679,25 +1606,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "webpki" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "webpki-roots" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1c760f0d366a6c24a02ed7816e23e691f5d92291f94d15e836006fd11b04daf" -dependencies = [ - "webpki", -] - [[package]] name = "widestring" version = "0.5.1" @@ -1786,18 +1694,3 @@ checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" dependencies = [ "winapi", ] - -[[package]] -name = "xml-rs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3" - -[[package]] -name = "xmltree" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" -dependencies = [ - "xml-rs", -] diff --git a/Cargo.toml b/Cargo.toml index 798dedc..9e33d76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,15 +42,19 @@ version = "4.0" [dependencies.futures] version = "0.3" +[dependencies.icalendar] +version = "0.13.0" +features = ["parser"] + [dependencies.ics] version = "0.5" +[dependencies.libxml] +version = "0.3" + [dependencies.markdown-gen] version = "1.2" -[dependencies.minicaldav] -version = "0.2" - [dependencies.regex] version = "1.6" @@ -78,8 +82,5 @@ version = "0.5" version = "1" features = ["rt-multi-thread", "net", "macros"] -[dependencies.ureq] -version = "2.5" - [dependencies.webbrowser] version = "0.7" diff --git a/src/api_client.rs b/src/api_client.rs index 33d7c43..59827b1 100644 --- a/src/api_client.rs +++ b/src/api_client.rs @@ -3,16 +3,15 @@ use { crate::config::Config, crate::constants, anyhow::anyhow, anyhow::Result, - base64::write::EncoderWriter as Base64Encoder, reqwest::Url, std::io::Write, + base64::write::EncoderWriter as Base64Encoder, chrono::DateTime, icalendar::CalendarComponent, + reqwest::Url, std::io::Write, std::ops::Range, }; pub struct ApiClient { rest: reqwest::Client, - agent: ureq::Agent, base_url: Url, caldav_base_url: Url, username: String, - password: String, } impl ApiClient { @@ -59,9 +58,7 @@ impl ApiClient { base_url, caldav_base_url, username: server.login_name.clone(), - password: server.password.clone(), rest: rest_client, - agent: ureq::Agent::new(), }) } @@ -77,27 +74,97 @@ impl ApiClient { &self.username } - pub fn get_calendars( + pub async fn get_events( &self, - ) -> core::result::Result, minicaldav::Error> { - minicaldav::get_calendars( - self.agent.clone(), - self.username(), - &self.password, - &self.caldav_base_url, - ) - } - - pub fn get_events( - &self, - calendar: &minicaldav::Calendar, - ) -> core::result::Result<(Vec, Vec), minicaldav::Error> + calendar_name: &str, + date_range: Range>, + ) -> Result> + where + Tz: chrono::TimeZone, + Tz::Offset: std::fmt::Display, { - minicaldav::get_events( - self.agent.clone(), - self.username(), - &self.password, - calendar, - ) + let report_method = reqwest::Method::from_bytes(b"REPORT").unwrap(); + let events_xml = self + .rest() + .request( + report_method, + // TODO extract into helper method + self.caldav_base_url.join(&format!( + "calendars/{}/{}", + self.username(), + calendar_name.to_lowercase().replace(" ", "-") + ))?, + ) + .header("Prefer", "return-minimal") + .header("Content-Type", "application/xml; charset=utf-8") + .header("Depth", 1) + .body(format!( + " + + + + + + + {} + + + + + + + + + + ", + constants::CALENDAR_PROVIDER, + date_range + .start + .naive_utc() + .format(constants::ICAL_UTCTIME_FMT) + .to_string(), + date_range + .end + .naive_utc() + .format(constants::ICAL_UTCTIME_FMT) + .to_string() + )) + .send() + .await? + .text() + .await?; + + let xml_doc = libxml::parser::Parser::default().parse_string(events_xml.as_bytes())?; + let mut xpath_ctx = libxml::xpath::Context::new(&xml_doc).unwrap(); + xpath_ctx + .register_namespace("cal", "urn:ietf:params:xml:ns:caldav") + .unwrap(); + let calendars_txt = xpath_ctx + .findnodes("//cal:calendar-data/text()", None) + .unwrap() + .into_iter() + .map(|n| icalendar::parser::unfold(&n.get_content())) + .collect::>(); + + let calendars = calendars_txt + .iter() + .map(|cal| icalendar::parser::read_calendar(cal).unwrap()) + .collect::>(); + let events = calendars + .into_iter() + .flat_map(|cal| { + cal.components + .into_iter() + .filter(|comp| comp.name == "VEVENT") + }) + .map(|comp| { + let event: CalendarComponent = comp.into(); + match event { + CalendarComponent::Event(e) => e, + _ => unreachable!(), + } + }); + + Ok(events.collect::>()) } } diff --git a/src/commands/groceries.rs b/src/commands/groceries.rs index 7d4068f..430ee62 100644 --- a/src/commands/groceries.rs +++ b/src/commands/groceries.rs @@ -3,16 +3,17 @@ use { crate::api_client::ApiClient, - crate::constants, crate::recipe::{Ingredient, Recipe}, - anyhow::{anyhow, Result}, - chrono::{Duration, Local, NaiveDateTime}, + anyhow::Result, + chrono::{Duration, Local}, + icalendar::Component, regex::Regex, std::collections::HashSet, + std::ops::Range, }; 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 ids = map_events_to_recipe_ids(api_client, calendar_name, days).await?; let ingredients = get_ingredients(api_client, ids).await?; todo!("Recipe ingredients: {:?}", ingredients) @@ -22,57 +23,25 @@ pub async fn with(api_client: &ApiClient, calendar_name: &str, days: u32) -> Res // save_grocery_list(api_client, "", md).await?; } -fn map_events_to_recipe_ids( +async 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 date_range = Range { + start: Local::now(), + end: Local::now() + Duration::days(days as i64), + }; - 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 all_events = api_client.get_events(calendar_name, date_range).await?; 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::>() - }) + let recipe_ids = all_events + .iter() + .flat_map(|event| event.property_value("DESCRIPTION")) + .flat_map(|descr| recipe_id_regex.captures(descr)) + .flat_map(|c| c.get(1)) + .flat_map(|m| m.as_str().parse::()) .collect::>(); Ok(recipe_ids) @@ -101,7 +70,3 @@ where 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/constants.rs b/src/constants.rs index 7ea9183..dce91a4 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -9,3 +9,5 @@ pub const CALENDAR_PROVIDER: &str = concat!( env!("CARGO_PKG_VERSION"), "//EN" ); +pub const ICAL_LOCALTIME_FMT: &str = "%Y%m%dT%H%M%S"; +pub const ICAL_UTCTIME_FMT: &str = "%Y%m%dT%H%M%SZ"; diff --git a/src/event.rs b/src/event.rs index 03c8f50..bc16bca 100644 --- a/src/event.rs +++ b/src/event.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use { + crate::constants, crate::recipe::Recipe, chrono::{DateTime, Datelike, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}, ics::escape_text, @@ -92,9 +93,9 @@ impl<'a> From for CalEvent<'a> { } fn dt_fmt(datetime: &DateTime) -> String { - datetime.format("%Y%m%dT%H%M%S").to_string() + datetime.format(constants::ICAL_LOCALTIME_FMT).to_string() } fn dt_utc_fmt(datetime: &DateTime) -> String { - datetime.format("%Y%m%dT%H%M%SZ").to_string() + datetime.format(constants::ICAL_UTCTIME_FMT).to_string() }