Collect relevant ingredients for grocery list

This commit is contained in:
Matteo Settenvini 2022-07-30 01:02:54 +02:00
parent fd55dba23f
commit c649fbf88c
Signed by: matteo
GPG Key ID: 8576CC1AD97D42DF
7 changed files with 178 additions and 13 deletions

34
Cargo.lock generated
View File

@ -8,6 +8,15 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
[[package]]
name = "aho-corasick"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "android_system_properties" name = "android_system_properties"
version = "0.1.4" version = "0.1.4"
@ -171,7 +180,9 @@ dependencies = [
"futures", "futures",
"iana-time-zone", "iana-time-zone",
"ics", "ics",
"markdown-gen",
"minicaldav", "minicaldav",
"regex",
"reqwest", "reqwest",
"rusty-hook", "rusty-hook",
"serde", "serde",
@ -704,6 +715,12 @@ 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"
@ -1015,12 +1032,29 @@ dependencies = [
"thiserror", "thiserror",
] ]
[[package]]
name = "regex"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.1.10" version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132"
[[package]]
name = "regex-syntax"
version = "0.6.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244"
[[package]] [[package]]
name = "remove_dir_all" name = "remove_dir_all"
version = "0.5.3" version = "0.5.3"

View File

@ -45,9 +45,15 @@ version = "0.3"
[dependencies.ics] [dependencies.ics]
version = "0.5" version = "0.5"
[dependencies.markdown-gen]
version = "1.2"
[dependencies.minicaldav] [dependencies.minicaldav]
version = "0.2" version = "0.2"
[dependencies.regex]
version = "1.6"
[dependencies.reqwest] [dependencies.reqwest]
version = "0.11" version = "0.11"
features = ["json", "blocking"] features = ["json", "blocking"]

107
src/commands/groceries.rs Normal file
View File

@ -0,0 +1,107 @@
// SPDX-FileCopyrightText: 2022 Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
use {
crate::api_client::ApiClient,
crate::constants,
crate::recipe::{Ingredient, Recipe},
anyhow::{anyhow, Result},
chrono::{Duration, Local, NaiveDateTime},
regex::Regex,
std::collections::HashSet,
};
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 ingredients = get_ingredients(api_client, ids).await?;
todo!("Recipe ingredients: {:?}", ingredients)
// let ingredients = merge_ingredients(ingredients);
// let md = prepare_grocery_list(&ingredients);
// save_grocery_list(api_client, "", md).await?;
}
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 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 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>>()
})
.collect::<HashSet<_>>();
Ok(recipe_ids)
}
async fn get_ingredients<RecipeIds>(
api_client: &ApiClient,
recipe_ids: RecipeIds,
) -> Result<Vec<Ingredient>>
where
RecipeIds: IntoIterator<Item = usize>,
{
let ingredients = recipe_ids.into_iter().map(|id: usize| async move {
// TODO code duplicated with schedule_csv::get_all_recipes
let recipe_url = format!("apps/cookbook/api/recipes/{id}");
let response = api_client
.rest()
.get(api_client.base_url().join(&recipe_url).unwrap())
.send()
.await
.expect(&format!("Cannot fetch recipe with id {}", id));
response.json::<Recipe>().await.map(|r| r.ingredients)
});
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

@ -1,6 +1,7 @@
// SPDX-FileCopyrightText: 2022 Matteo Settenvini <matteo.settenvini@montecristosoftware.eu> // SPDX-FileCopyrightText: 2022 Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
pub mod groceries;
pub mod import; pub mod import;
pub mod init; pub mod init;
pub mod schedule; pub mod schedule;

View File

@ -110,15 +110,8 @@ async fn publish_events<'a, EventsIter>(
where where
EventsIter: Iterator<Item = Event>, EventsIter: Iterator<Item = Event>,
{ {
let calendar_prototype: ics::ICalendar = ics::ICalendar::new( let calendar_prototype: ics::ICalendar =
"2.0", ics::ICalendar::new("2.0", constants::CALENDAR_PROVIDER);
format!(
"-//IDN {}//{} {}//EN",
constants::VENDOR,
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION")
),
);
let dav_base = api_client let dav_base = api_client
.rest() .rest()

View File

@ -2,4 +2,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
pub const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); pub const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
pub const VENDOR: &str = "montecristosoftware.eu"; pub const CALENDAR_PROVIDER: &str = concat!(
"-//IDN montecristosoftware.eu//",
env!("CARGO_PKG_NAME"),
" ",
env!("CARGO_PKG_VERSION"),
"//EN"
);

View File

@ -41,10 +41,20 @@ fn setup_args() -> ArgMatches {
.arg(arg!(<url> ... "One or more URLs each pointing to page with a recipe to import in NextCloud")), .arg(arg!(<url> ... "One or more URLs each pointing to page with a recipe to import in NextCloud")),
) )
.subcommand( .subcommand(
Command::new("schedule") Command::new("groceries")
.about("") .about("Create a grocery list from scheduled calendar events")
.arg(server_arg.clone()) .arg(server_arg.clone())
.arg(arg!(-d --days <days> "") .arg(arg!(<calendar_name> ""))
.arg(arg!(-d --days <days> "Number of days in the future to consider")
.value_parser(clap::builder::RangedU64ValueParser::<u32>::new().range(1..))
.required(false)
.default_value("7"))
)
.subcommand(
Command::new("schedule")
.about("TODO; not implemented yet (pick automatically recipes and create calendar events for them)")
.arg(server_arg.clone())
.arg(arg!(-d --days <days> "Number of days to schedule")
.value_parser(clap::builder::RangedU64ValueParser::<u32>::new().range(1..)) .value_parser(clap::builder::RangedU64ValueParser::<u32>::new().range(1..))
.required(false) .required(false)
.default_value("7")) .default_value("7"))
@ -77,6 +87,14 @@ async fn parse_args(args: &ArgMatches) -> Result<()> {
.map(|s| s.as_str()); .map(|s| s.as_str());
commands::import::with(&api_client, urls).await commands::import::with(&api_client, urls).await
} }
Some(("groceries", sub_matches)) => {
let api_client = get_api_client(&sub_matches, &configuration)?;
let calendar_name = sub_matches
.get_one::<String>("calendar_name")
.expect("<calendar_name> is a mandatory parameter, it cannot be missing");
let days = sub_matches.get_one::<u32>("days").unwrap();
commands::groceries::with(&api_client, calendar_name.as_str(), *days).await
}
Some(("schedule", sub_matches)) => { Some(("schedule", sub_matches)) => {
let api_client = get_api_client(&sub_matches, &configuration)?; let api_client = get_api_client(&sub_matches, &configuration)?;
commands::schedule::with(&api_client).await commands::schedule::with(&api_client).await