Collect ingredients from recipes for grocery list

This commit is contained in:
Matteo Settenvini 2022-07-30 17:33:29 +02:00
parent c649fbf88c
commit ed5a6d2306
Signed by: matteo
GPG key ID: 8576CC1AD97D42DF
7 changed files with 168 additions and 239 deletions

View file

@ -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<Tz>(
&self,
) -> core::result::Result<Vec<minicaldav::Calendar>, 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<minicaldav::Event>, Vec<minicaldav::Error>), minicaldav::Error>
calendar_name: &str,
date_range: Range<DateTime<Tz>>,
) -> Result<Vec<icalendar::Event>>
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!(
"<c:calendar-query xmlns:d=\"DAV:\" xmlns:c=\"urn:ietf:params:xml:ns:caldav\">
<d:prop>
<c:calendar-data />
</d:prop>
<c:filter>
<c:comp-filter name=\"VCALENDAR\">
<c:prop-filter name=\"PRODID\">
<c:text-match>{}</c:text-match>
</c:prop-filter>
<c:comp-filter name=\"VEVENT\">
<c:prop-filter name=\"DTSTART\">
<time-range start=\"{}\" end=\"{}\" />
</c:prop-filter>
</c:comp-filter>
</c:comp-filter>
</c:filter>
</c:calendar-query>
",
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::<Vec<_>>();
let calendars = calendars_txt
.iter()
.map(|cal| icalendar::parser::read_calendar(cal).unwrap())
.collect::<Vec<_>>();
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::<Vec<_>>())
}
}

View file

@ -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<HashSet<usize>> {
// 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::<usize>())
.collect::<Vec<usize>>()
})
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::<usize>())
.collect::<HashSet<_>>();
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))
}

View file

@ -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";

View file

@ -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<Event> for CalEvent<'a> {
}
fn dt_fmt(datetime: &DateTime<Local>) -> 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<Utc>) -> String {
datetime.format("%Y%m%dT%H%M%SZ").to_string()
datetime.format(constants::ICAL_UTCTIME_FMT).to_string()
}