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

2
.vscode/launch.json vendored
View File

@ -18,7 +18,7 @@
"kind": "bin" "kind": "bin"
} }
}, },
"args": ["schedule-csv", "Cucina", "examples/example-schedule.csv"], "args": ["groceries", "Cucina"],
"cwd": "${workspaceFolder}" "cwd": "${workspaceFolder}"
}, },
{ {

199
Cargo.lock generated
View File

@ -2,12 +2,6 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 3
[[package]]
name = "adler"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "0.7.18" version = "0.7.18"
@ -117,12 +111,6 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "chunked_transfer"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fff857943da45f546682664a79488be82e69e43c1a7a2307679ab9afb3a66d2e"
[[package]] [[package]]
name = "ci_info" name = "ci_info"
version = "0.10.2" version = "0.10.2"
@ -179,9 +167,10 @@ dependencies = [
"directories", "directories",
"futures", "futures",
"iana-time-zone", "iana-time-zone",
"icalendar",
"ics", "ics",
"libxml",
"markdown-gen", "markdown-gen",
"minicaldav",
"regex", "regex",
"reqwest", "reqwest",
"rusty-hook", "rusty-hook",
@ -191,7 +180,6 @@ dependencies = [
"strum_macros", "strum_macros",
"tokio", "tokio",
"toml", "toml",
"ureq",
"webbrowser", "webbrowser",
] ]
@ -211,15 +199,6 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc"
[[package]]
name = "crc32fast"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "csv" name = "csv"
version = "1.1.6" version = "1.1.6"
@ -325,16 +304,6 @@ dependencies = [
"instant", "instant",
] ]
[[package]]
name = "flate2"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f82b0f4c27ad9f8bfd1f3208d882da2b09c301bc1c828fd3a00d0216d2fbbff6"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@ -605,6 +574,17 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "icalendar"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf4c8ac7e9b7eefe850f6380c68ef5fd1300858c1ef65d121d602a9bab63c2f4"
dependencies = [
"chrono",
"nom",
"uuid",
]
[[package]] [[package]]
name = "ics" name = "ics"
version = "0.5.7" version = "0.5.7"
@ -706,6 +686,17 @@ version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836"
[[package]]
name = "libxml"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "687f5a78939052c5d02865c0fe3ea2ce2acdca875f7f81db82f7aef256dd97ac"
dependencies = [
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.17" version = "0.4.17"
@ -740,26 +731,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d"
[[package]] [[package]]
name = "minicaldav" name = "minimal-lexical"
version = "0.2.0" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb263a7d12c40d5f200dda93b3665b9ae714d4fe64a6467938c92d974a579edb" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
dependencies = [
"base64",
"log",
"ureq",
"url",
"xmltree",
]
[[package]]
name = "miniz_oxide"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f5c75688da582b8ffc1f1799e9db273f32133c49e048f614d22ec3256773ccc"
dependencies = [
"adler",
]
[[package]] [[package]]
name = "mio" name = "mio"
@ -853,6 +828,16 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab250442c86f1850815b5d268639dff018c0627022bc1940eb2d642ca1ce12f0" checksum = "ab250442c86f1850815b5d268639dff018c0627022bc1940eb2d642ca1ce12f0"
[[package]]
name = "nom"
version = "7.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]] [[package]]
name = "num-integer" name = "num-integer"
version = "0.1.45" version = "0.1.45"
@ -1101,33 +1086,6 @@ dependencies = [
"winreg", "winreg",
] ]
[[package]]
name = "ring"
version = "0.16.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc"
dependencies = [
"cc",
"libc",
"once_cell",
"spin",
"untrusted",
"web-sys",
"winapi",
]
[[package]]
name = "rustls"
version = "0.20.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5aab8ee6c7097ed6057f43c187a62418d0c05a4bd5f18b3571db50ee0f9ce033"
dependencies = [
"log",
"ring",
"sct",
"webpki",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.8" version = "1.0.8"
@ -1171,16 +1129,6 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "sct"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d53dcdb7c9f8158937a7981b48accfd39a43af418591a5d008c7b22b5e1b7ca4"
dependencies = [
"ring",
"untrusted",
]
[[package]] [[package]]
name = "security-framework" name = "security-framework"
version = "2.6.1" version = "2.6.1"
@ -1273,12 +1221,6 @@ dependencies = [
"strum_macros", "strum_macros",
] ]
[[package]]
name = "spin"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.10.0" version = "0.10.0"
@ -1514,30 +1456,6 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973"
[[package]]
name = "untrusted"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a"
[[package]]
name = "ureq"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97acb4c28a254fd7a4aeec976c46a7fa404eac4d7c134b30c75144846d7cb8f"
dependencies = [
"base64",
"chunked_transfer",
"encoding_rs",
"flate2",
"log",
"once_cell",
"rustls",
"url",
"webpki",
"webpki-roots",
]
[[package]] [[package]]
name = "url" name = "url"
version = "2.2.2" version = "2.2.2"
@ -1550,6 +1468,15 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "uuid"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
dependencies = [
"getrandom",
]
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"
@ -1679,25 +1606,6 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "webpki"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd"
dependencies = [
"ring",
"untrusted",
]
[[package]]
name = "webpki-roots"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1c760f0d366a6c24a02ed7816e23e691f5d92291f94d15e836006fd11b04daf"
dependencies = [
"webpki",
]
[[package]] [[package]]
name = "widestring" name = "widestring"
version = "0.5.1" version = "0.5.1"
@ -1786,18 +1694,3 @@ checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "xml-rs"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3"
[[package]]
name = "xmltree"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb"
dependencies = [
"xml-rs",
]

View File

@ -42,15 +42,19 @@ version = "4.0"
[dependencies.futures] [dependencies.futures]
version = "0.3" version = "0.3"
[dependencies.icalendar]
version = "0.13.0"
features = ["parser"]
[dependencies.ics] [dependencies.ics]
version = "0.5" version = "0.5"
[dependencies.libxml]
version = "0.3"
[dependencies.markdown-gen] [dependencies.markdown-gen]
version = "1.2" version = "1.2"
[dependencies.minicaldav]
version = "0.2"
[dependencies.regex] [dependencies.regex]
version = "1.6" version = "1.6"
@ -78,8 +82,5 @@ version = "0.5"
version = "1" version = "1"
features = ["rt-multi-thread", "net", "macros"] features = ["rt-multi-thread", "net", "macros"]
[dependencies.ureq]
version = "2.5"
[dependencies.webbrowser] [dependencies.webbrowser]
version = "0.7" version = "0.7"

View File

@ -3,16 +3,15 @@
use { use {
crate::config::Config, crate::constants, anyhow::anyhow, anyhow::Result, 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 { pub struct ApiClient {
rest: reqwest::Client, rest: reqwest::Client,
agent: ureq::Agent,
base_url: Url, base_url: Url,
caldav_base_url: Url, caldav_base_url: Url,
username: String, username: String,
password: String,
} }
impl ApiClient { impl ApiClient {
@ -59,9 +58,7 @@ impl ApiClient {
base_url, base_url,
caldav_base_url, caldav_base_url,
username: server.login_name.clone(), username: server.login_name.clone(),
password: server.password.clone(),
rest: rest_client, rest: rest_client,
agent: ureq::Agent::new(),
}) })
} }
@ -77,27 +74,97 @@ impl ApiClient {
&self.username &self.username
} }
pub fn get_calendars( pub async fn get_events<Tz>(
&self, &self,
) -> core::result::Result<Vec<minicaldav::Calendar>, minicaldav::Error> { calendar_name: &str,
minicaldav::get_calendars( date_range: Range<DateTime<Tz>>,
self.agent.clone(), ) -> Result<Vec<icalendar::Event>>
self.username(), where
&self.password, Tz: chrono::TimeZone,
&self.caldav_base_url, Tz::Offset: std::fmt::Display,
)
}
pub fn get_events(
&self,
calendar: &minicaldav::Calendar,
) -> core::result::Result<(Vec<minicaldav::Event>, Vec<minicaldav::Error>), minicaldav::Error>
{ {
minicaldav::get_events( let report_method = reqwest::Method::from_bytes(b"REPORT").unwrap();
self.agent.clone(), let events_xml = self
.rest()
.request(
report_method,
// TODO extract into helper method
self.caldav_base_url.join(&format!(
"calendars/{}/{}",
self.username(), self.username(),
&self.password, calendar_name.to_lowercase().replace(" ", "-")
calendar, ))?,
) )
.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 { use {
crate::api_client::ApiClient, crate::api_client::ApiClient,
crate::constants,
crate::recipe::{Ingredient, Recipe}, crate::recipe::{Ingredient, Recipe},
anyhow::{anyhow, Result}, anyhow::Result,
chrono::{Duration, Local, NaiveDateTime}, chrono::{Duration, Local},
icalendar::Component,
regex::Regex, regex::Regex,
std::collections::HashSet, std::collections::HashSet,
std::ops::Range,
}; };
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)?; 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?;
todo!("Recipe ingredients: {:?}", ingredients) 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?; // save_grocery_list(api_client, "", md).await?;
} }
fn map_events_to_recipe_ids( async fn map_events_to_recipe_ids(
api_client: &ApiClient, api_client: &ApiClient,
calendar_name: &str, calendar_name: &str,
days: u32, days: u32,
) -> Result<HashSet<usize>> { ) -> Result<HashSet<usize>> {
// minicaldav is quite hamfisted in retrieving events (it gets everything, every time). let date_range = Range {
// Consider crafting an XML request and use something else for ical parsing. start: Local::now(),
end: Local::now() + Duration::days(days as i64),
};
let calendars = api_client let all_events = api_client.get_events(calendar_name, date_range).await?;
.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 recipe_id_regex: Regex = Regex::new(r"cookbook@(\d+)").unwrap(); let recipe_id_regex: Regex = Regex::new(r"cookbook@(\d+)").unwrap();
let recipe_ids = relevant_events let recipe_ids = all_events
.flat_map(|event| {
event
.property("DESCRIPTION")
.iter() .iter()
.flat_map(|descr| recipe_id_regex.captures(descr.value())) .flat_map(|event| event.property_value("DESCRIPTION"))
.flat_map(|descr| recipe_id_regex.captures(descr))
.flat_map(|c| c.get(1)) .flat_map(|c| c.get(1))
.flat_map(|m| m.as_str().parse::<usize>()) .flat_map(|m| m.as_str().parse::<usize>())
.collect::<Vec<usize>>()
})
.collect::<HashSet<_>>(); .collect::<HashSet<_>>();
Ok(recipe_ids) Ok(recipe_ids)
@ -101,7 +70,3 @@ 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 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"), 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";

View File

@ -2,6 +2,7 @@
// 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::{DateTime, Datelike, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc},
ics::escape_text, ics::escape_text,
@ -92,9 +93,9 @@ impl<'a> From<Event> for CalEvent<'a> {
} }
fn dt_fmt(datetime: &DateTime<Local>) -> String { 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 { 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()
} }