Use only the icalendar crate instead of ics

This commit is contained in:
Matteo Settenvini 2022-07-30 23:30:08 +02:00
parent ed5a6d2306
commit 1510eb4f3d
Signed by: matteo
GPG Key ID: 8576CC1AD97D42DF
10 changed files with 136 additions and 114 deletions

20
.vscode/launch.json vendored
View File

@ -6,7 +6,7 @@
{ {
"type": "lldb", "type": "lldb",
"request": "launch", "request": "launch",
"name": "Debug executable 'cook'", "name": "Debug executable 'cook' -> groceries",
"cargo": { "cargo": {
"args": [ "args": [
"build", "build",
@ -21,6 +21,24 @@
"args": ["groceries", "Cucina"], "args": ["groceries", "Cucina"],
"cwd": "${workspaceFolder}" "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", "type": "lldb",
"request": "launch", "request": "launch",

37
Cargo.lock generated
View File

@ -11,15 +11,6 @@ dependencies = [
"memchr", "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]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.58" version = "1.0.58"
@ -166,11 +157,8 @@ dependencies = [
"csv", "csv",
"directories", "directories",
"futures", "futures",
"iana-time-zone",
"icalendar", "icalendar",
"ics",
"libxml", "libxml",
"markdown-gen",
"regex", "regex",
"reqwest", "reqwest",
"rusty-hook", "rusty-hook",
@ -561,19 +549,6 @@ dependencies = [
"tokio-native-tls", "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]] [[package]]
name = "icalendar" name = "icalendar"
version = "0.13.0" version = "0.13.0"
@ -585,12 +560,6 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "ics"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b891481ef6353e3b97118d4650469e379a39e4373a66908c12f99763182826b1"
[[package]] [[package]]
name = "ident_case" name = "ident_case"
version = "1.0.1" version = "1.0.1"
@ -706,12 +675,6 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "markdown-gen"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8034621d7f1258317ca1dfb9205e3925d27ee4aa2a46620a09c567daf0310562"
[[package]] [[package]]
name = "matches" name = "matches"
version = "0.1.9" version = "0.1.9"

View File

@ -26,9 +26,6 @@ version = "0.13"
version = "0.4" version = "0.4"
features = ["serde"] features = ["serde"]
[dependencies.iana-time-zone]
version = "0.1"
[dependencies.csv] [dependencies.csv]
version = "1.1" version = "1.1"
@ -46,15 +43,9 @@ version = "0.3"
version = "0.13.0" version = "0.13.0"
features = ["parser"] features = ["parser"]
[dependencies.ics]
version = "0.5"
[dependencies.libxml] [dependencies.libxml]
version = "0.3" version = "0.3"
[dependencies.markdown-gen]
version = "1.2"
[dependencies.regex] [dependencies.regex]
version = "1.6" version = "1.6"

View File

@ -15,12 +15,13 @@ use {
pub async fn with(api_client: &ApiClient, calendar_name: &str, days: u32) -> Result<()> { 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 ids = map_events_to_recipe_ids(api_client, calendar_name, days).await?;
let ingredients = get_ingredients(api_client, ids).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?; // save_grocery_list(api_client, "", md).await?;
Ok(())
} }
async fn map_events_to_recipe_ids( async fn map_events_to_recipe_ids(
@ -70,3 +71,28 @@ where
let ingredients = futures::future::try_join_all(ingredients).await?; let ingredients = futures::future::try_join_all(ingredients).await?;
Ok(ingredients.into_iter().flatten().collect()) Ok(ingredients.into_iter().flatten().collect())
} }
fn merge_ingredients(mut ingredients: Vec<Ingredient>) -> Vec<Ingredient> {
ingredients.sort();
// TODO actual merging
ingredients
}
fn prepare_grocery_list(ingredients: &Vec<Ingredient>) -> Result<String> {
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)
}

View File

@ -4,9 +4,9 @@
use { use {
crate::api_client::ApiClient, crate::api_client::ApiClient,
crate::commands::import, crate::commands::import,
crate::constants,
crate::event::{Event, Meal}, crate::event::{Event, Meal},
crate::recipe, crate::recipe,
crate::{constants, helpers},
anyhow::{bail, Result}, anyhow::{bail, Result},
chrono::naive::NaiveDate, chrono::naive::NaiveDate,
futures::future::try_join_all, futures::future::try_join_all,
@ -110,9 +110,6 @@ async fn publish_events<'a, EventsIter>(
where where
EventsIter: Iterator<Item = Event>, EventsIter: Iterator<Item = Event>,
{ {
let calendar_prototype: ics::ICalendar =
ics::ICalendar::new("2.0", constants::CALENDAR_PROVIDER);
let dav_base = api_client let dav_base = api_client
.rest() .rest()
.head(api_client.base_url().join("/.well-known/caldav")?) .head(api_client.base_url().join("/.well-known/caldav")?)
@ -125,18 +122,37 @@ where
calendar.to_lowercase().as_str().replace(" ", "-") calendar.to_lowercase().as_str().replace(" ", "-")
))?; ))?;
let calendar_prototype = &calendar_prototype;
let calendar_url = &calendar_url; let calendar_url = &calendar_url;
let update_requests = events.map(|ev| async move { let update_requests = events.map(|ev| async move {
let url = calendar_url.join(&format!("{}.ics", ev.uid)).unwrap(); let url = calendar_url.join(&format!("{}.ics", ev.uid)).unwrap();
let mut cal = calendar_prototype.clone(); let alarm_text_repr = format!(
cal.add_event(ev.into()); "BEGIN:VALARM\nACTION:DISPLAY\nTRIGGER:-PT15M\nDESCRIPTION:{}\nEND:VALARM",
&helpers::ical_escape_text(&ev.recipe.name)
);
let cal = icalendar::Calendar::new()
.push::<icalendar::Event>(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 api_client
.rest() .rest()
.put(url) .put(url)
.header("Content-Type", "text/calendar; charset=utf-8") .header("Content-Type", "text/calendar; charset=utf-8")
.body(cal.to_string()) .body(cal_as_string)
.send() .send()
.await .await
}); });

View File

@ -9,5 +9,4 @@ pub const CALENDAR_PROVIDER: &str = concat!(
env!("CARGO_PKG_VERSION"), env!("CARGO_PKG_VERSION"),
"//EN" "//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"; pub const ICAL_UTCTIME_FMT: &str = "%Y%m%dT%H%M%SZ";

View File

@ -2,12 +2,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use { use {
crate::constants,
crate::recipe::Recipe, crate::recipe::Recipe,
chrono::{DateTime, Datelike, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}, chrono::{Datelike, NaiveDate, NaiveDateTime, NaiveTime, Utc},
ics::escape_text, icalendar::Event as CalEvent,
ics::properties as calprop,
ics::Event as CalEvent,
std::rc::Rc, std::rc::Rc,
}; };
@ -47,55 +44,34 @@ impl Event {
} }
} }
impl<'a> From<Event> for CalEvent<'a> { impl From<Event> for CalEvent {
fn from(ev: Event) -> Self { fn from(ev: Event) -> Self {
let start_time = Local use icalendar::Component;
.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();
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"]; const DAY_NAMES: [&str; 7] = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"];
event.push(calprop::RRule::new(format!( let start_time = ev.ends_at - ev.recipe.total_time();
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}", "FREQ=YEARLY;BYDAY={weekday};BYWEEKNO={weekno}",
weekday = DAY_NAMES weekday = DAY_NAMES
.get(start_time.weekday().num_days_from_monday() as usize) .get(start_time.weekday().num_days_from_monday() as usize)
.unwrap(), .unwrap(),
weekno = start_time.iso_week().week(), weekno = start_time.iso_week().week(),
))); ),
)
.done();
let mut trigger = calprop::Trigger::new("-PT15M"); cal_event
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
} }
} }
fn dt_fmt(datetime: &DateTime<Local>) -> String {
datetime.format(constants::ICAL_LOCALTIME_FMT).to_string()
}
fn dt_utc_fmt(datetime: &DateTime<Utc>) -> String {
datetime.format(constants::ICAL_UTCTIME_FMT).to_string()
}

32
src/helpers.rs Normal file
View File

@ -0,0 +1,32 @@
// SPDX-FileCopyrightText: 2022 Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// 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::<u8>::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) }
}

View File

@ -6,6 +6,7 @@ mod commands;
mod config; mod config;
mod constants; mod constants;
mod event; mod event;
mod helpers;
mod recipe; mod recipe;
use { use {

View File

@ -60,17 +60,17 @@ pub struct Recipe {
//pub nutrition: Nutrition, //pub nutrition: Nutrition,
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug, Ord, Eq, PartialEq, PartialOrd)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Ingredient(String); pub struct Ingredient(pub String);
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Tool(String); pub struct Tool(pub String);
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Instruction(String); pub struct Instruction(pub String);
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]