Use only the icalendar crate instead of ics
This commit is contained in:
parent
ed5a6d2306
commit
1510eb4f3d
|
@ -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",
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
});
|
});
|
||||||
|
|
|
@ -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";
|
||||||
|
|
68
src/event.rs
68
src/event.rs
|
@ -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()
|
|
||||||
}
|
|
||||||
|
|
|
@ -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) }
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -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")]
|
||||||
|
|
Loading…
Reference in New Issue