Collect ingredients from recipes for grocery list
This commit is contained in:
parent
c649fbf88c
commit
ed5a6d2306
7 changed files with 168 additions and 239 deletions
|
@ -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<_>>())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue