diff --git a/.vscode/launch.json b/.vscode/launch.json index 1230984..e880500 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,7 @@ { "type": "lldb", "request": "launch", - "name": "Debug executable 'cook'", + "name": "Debug executable 'cook' -> groceries", "cargo": { "args": [ "build", @@ -21,6 +21,24 @@ "args": ["groceries", "Cucina"], "cwd": "${workspaceFolder}" }, + { + "type": "lldb", + "request": "launch", + "name": "Debug executable 'cook' -> schedule-csv", + "cargo": { + "args": [ + "build", + "--bin=cook", + "--package=cooking-schedule" + ], + "filter": { + "name": "cook", + "kind": "bin" + } + }, + "args": ["schedule-csv", "Cucina", "examples/example-schedule.csv"], + "cwd": "${workspaceFolder}" + }, { "type": "lldb", "request": "launch", diff --git a/Cargo.lock b/Cargo.lock index 0d4da34..e6f93c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,15 +11,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "android_system_properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7ed72e1635e121ca3e79420540282af22da58be50de153d36f81ddc6b83aa9e" -dependencies = [ - "libc", -] - [[package]] name = "anyhow" version = "1.0.58" @@ -166,11 +157,8 @@ dependencies = [ "csv", "directories", "futures", - "iana-time-zone", "icalendar", - "ics", "libxml", - "markdown-gen", "regex", "reqwest", "rusty-hook", @@ -561,19 +549,6 @@ dependencies = [ "tokio-native-tls", ] -[[package]] -name = "iana-time-zone" -version = "0.1.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00c0d80ad9ca8d30ca648bf6cb1e3e3326d75071b76dbe143dd4a9cedcd58975" -dependencies = [ - "android_system_properties", - "core-foundation", - "js-sys", - "wasm-bindgen", - "winapi", -] - [[package]] name = "icalendar" version = "0.13.0" @@ -585,12 +560,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "ics" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b891481ef6353e3b97118d4650469e379a39e4373a66908c12f99763182826b1" - [[package]] name = "ident_case" version = "1.0.1" @@ -706,12 +675,6 @@ 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" diff --git a/Cargo.toml b/Cargo.toml index 9e33d76..7ebd68b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,9 +26,6 @@ version = "0.13" version = "0.4" features = ["serde"] -[dependencies.iana-time-zone] -version = "0.1" - [dependencies.csv] version = "1.1" @@ -46,15 +43,9 @@ version = "0.3" version = "0.13.0" features = ["parser"] -[dependencies.ics] -version = "0.5" - [dependencies.libxml] version = "0.3" -[dependencies.markdown-gen] -version = "1.2" - [dependencies.regex] version = "1.6" diff --git a/src/commands/groceries.rs b/src/commands/groceries.rs index 430ee62..b887971 100644 --- a/src/commands/groceries.rs +++ b/src/commands/groceries.rs @@ -15,12 +15,13 @@ use { 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).await?; let ingredients = get_ingredients(api_client, ids).await?; + let ingredients = merge_ingredients(ingredients); + let md = prepare_grocery_list(&ingredients)?; - todo!("Recipe ingredients: {:?}", ingredients) + println!("{}", md); - // let ingredients = merge_ingredients(ingredients); - // let md = prepare_grocery_list(&ingredients); // save_grocery_list(api_client, "", md).await?; + Ok(()) } async fn map_events_to_recipe_ids( @@ -70,3 +71,28 @@ where let ingredients = futures::future::try_join_all(ingredients).await?; Ok(ingredients.into_iter().flatten().collect()) } + +fn merge_ingredients(mut ingredients: Vec) -> Vec { + ingredients.sort(); + + // TODO actual merging + + ingredients +} + +fn prepare_grocery_list(ingredients: &Vec) -> Result { + let mut out = String::new(); + use std::fmt::Write; + + writeln!( + out, + "# Grocery list - {}", + chrono::Local::now().format("%Y-%m-%d").to_string() + )?; + for ingredient in ingredients { + let ingredient = ingredient.0.as_str(); + writeln!(out, "* {}", ingredient)?; + } + + Ok(out) +} diff --git a/src/commands/schedule_csv.rs b/src/commands/schedule_csv.rs index 1595129..f63ae7a 100644 --- a/src/commands/schedule_csv.rs +++ b/src/commands/schedule_csv.rs @@ -4,9 +4,9 @@ use { crate::api_client::ApiClient, crate::commands::import, - crate::constants, crate::event::{Event, Meal}, crate::recipe, + crate::{constants, helpers}, anyhow::{bail, Result}, chrono::naive::NaiveDate, futures::future::try_join_all, @@ -110,9 +110,6 @@ async fn publish_events<'a, EventsIter>( where EventsIter: Iterator, { - let calendar_prototype: ics::ICalendar = - ics::ICalendar::new("2.0", constants::CALENDAR_PROVIDER); - let dav_base = api_client .rest() .head(api_client.base_url().join("/.well-known/caldav")?) @@ -125,18 +122,37 @@ where calendar.to_lowercase().as_str().replace(" ", "-") ))?; - let calendar_prototype = &calendar_prototype; let calendar_url = &calendar_url; let update_requests = events.map(|ev| async move { let url = calendar_url.join(&format!("{}.ics", ev.uid)).unwrap(); - let mut cal = calendar_prototype.clone(); - cal.add_event(ev.into()); + let alarm_text_repr = format!( + "BEGIN:VALARM\nACTION:DISPLAY\nTRIGGER:-PT15M\nDESCRIPTION:{}\nEND:VALARM", + &helpers::ical_escape_text(&ev.recipe.name) + ); + + let cal = icalendar::Calendar::new() + .push::(ev.into()) + .done(); + + let cal_as_string = (&cal.to_string()) + .replacen( + // need to hack around inability to set PRODID in icalendar::Calendar + "PRODID:ICALENDAR-RS", + &format!("PRODID:{}", constants::CALENDAR_PROVIDER), + 1, + ) + .replacen( + // need to hack around inability to set VALARM in icalendar::Event + "END:VEVENT", + &format!("{}\nEND:VEVENT", alarm_text_repr), + 1, + ); api_client .rest() .put(url) .header("Content-Type", "text/calendar; charset=utf-8") - .body(cal.to_string()) + .body(cal_as_string) .send() .await }); diff --git a/src/constants.rs b/src/constants.rs index dce91a4..d1e38e0 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -9,5 +9,4 @@ 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 bc16bca..8a35e20 100644 --- a/src/event.rs +++ b/src/event.rs @@ -2,12 +2,9 @@ // 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, - ics::properties as calprop, - ics::Event as CalEvent, + chrono::{Datelike, NaiveDate, NaiveDateTime, NaiveTime, Utc}, + icalendar::Event as CalEvent, std::rc::Rc, }; @@ -47,55 +44,34 @@ impl Event { } } -impl<'a> From for CalEvent<'a> { +impl From for CalEvent { fn from(ev: Event) -> Self { - let start_time = Local - .from_local_datetime(&(*&ev.ends_at - ev.recipe.total_time())) - .unwrap(); - let end_time = Local.from_local_datetime(&ev.ends_at).unwrap(); - let timezone = iana_time_zone::get_timezone().unwrap(); + use icalendar::Component; - let mut event = ics::Event::new(ev.uid.clone(), dt_utc_fmt(&Utc::now())); - event.push(calprop::Summary::new(escape_text(ev.recipe.name.clone()))); - event.push(calprop::Description::new(format!( - "cookbook@{}", - ev.recipe.id - ))); - event.push(calprop::Location::new(escape_text(ev.recipe.url.clone()))); - - let mut dtstart = calprop::DtStart::new(dt_fmt(&start_time)); - dtstart.append(ics::parameters!("TZID" => timezone.clone())); - event.push(dtstart); - - let mut dtend = calprop::DtEnd::new(dt_fmt(&end_time)); - dtend.append(ics::parameters!("TZID" => timezone)); - event.push(dtend); - - // TODO make configurable yearly repetition, for now this suits my personal uses const DAY_NAMES: [&str; 7] = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]; - event.push(calprop::RRule::new(format!( - "FREQ=YEARLY;BYDAY={weekday};BYWEEKNO={weekno}", - weekday = DAY_NAMES - .get(start_time.weekday().num_days_from_monday() as usize) - .unwrap(), - weekno = start_time.iso_week().week(), - ))); + let start_time = ev.ends_at - ev.recipe.total_time(); - let mut trigger = calprop::Trigger::new("-PT15M"); - trigger.append(ics::parameters!("RELATED" => "START")); - let alarm = ics::Alarm::display( - trigger, - calprop::Description::new(escape_text(ev.recipe.name.clone())), - ); - event.add_alarm(alarm); - event + let cal_event = CalEvent::new() + .uid(&ev.uid) + .summary(&ev.recipe.name) + .description(&format!("cookbook@{}", ev.recipe.id)) + .location(&ev.recipe.url) + .timestamp(Utc::now()) + .starts(start_time) + .ends(ev.ends_at) + .add_property( + // TODO make configurable yearly repetition, for now this suits my personal uses + "RRULE", + &format!( + "FREQ=YEARLY;BYDAY={weekday};BYWEEKNO={weekno}", + weekday = DAY_NAMES + .get(start_time.weekday().num_days_from_monday() as usize) + .unwrap(), + weekno = start_time.iso_week().week(), + ), + ) + .done(); + + cal_event } } - -fn dt_fmt(datetime: &DateTime) -> String { - datetime.format(constants::ICAL_LOCALTIME_FMT).to_string() -} - -fn dt_utc_fmt(datetime: &DateTime) -> String { - datetime.format(constants::ICAL_UTCTIME_FMT).to_string() -} diff --git a/src/helpers.rs b/src/helpers.rs new file mode 100644 index 0000000..1b86316 --- /dev/null +++ b/src/helpers.rs @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2022 Matteo Settenvini +// SPDX-License-Identifier: AGPL-3.0-or-later + +pub fn ical_escape_text(text: &str) -> String { + /* https://www.kanzaki.com/docs/ical/text.html + + The "TEXT" property values may also contain special characters that + are used to signify delimiters, such as a COMMA character for lists of + values or a SEMICOLON character for structured values. In order to + support the inclusion of these special characters in "TEXT" property + values, they MUST be escaped with a BACKSLASH character. A BACKSLASH + character (US-ASCII decimal 92) in a "TEXT" property value MUST be + escaped with another BACKSLASH character. A COMMA character in a "TEXT" + property value MUST be escaped with a BACKSLASH character + (US-ASCII decimal 92). A SEMICOLON character in a "TEXT" property + value MUST be escaped with a BACKSLASH character (US-ASCII decimal + 92). However, a COLON character in a "TEXT" property value SHALL + NOT be escaped with a BACKSLASH character. + */ + + let mut out = Vec::::with_capacity(text.len()); + for c in text.as_bytes() { + match c { + b'\\' => out.extend_from_slice(&[b'\\', b'\\']), + b',' => out.extend_from_slice(&[b'\\', b',']), + b';' => out.extend_from_slice(&[b'\\', b';']), + _ => out.push(*c), + } + } + + unsafe { String::from_utf8_unchecked(out) } +} diff --git a/src/main.rs b/src/main.rs index cc0b3fd..deaa747 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod commands; mod config; mod constants; mod event; +mod helpers; mod recipe; use { diff --git a/src/recipe.rs b/src/recipe.rs index 7dfd1df..ba7a504 100644 --- a/src/recipe.rs +++ b/src/recipe.rs @@ -60,17 +60,17 @@ pub struct Recipe { //pub nutrition: Nutrition, } -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Debug, Ord, Eq, PartialEq, PartialOrd)] #[serde(rename_all = "camelCase")] -pub struct Ingredient(String); +pub struct Ingredient(pub String); #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] -pub struct Tool(String); +pub struct Tool(pub String); #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] -pub struct Instruction(String); +pub struct Instruction(pub String); #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")]