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

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-License-Identifier: AGPL-3.0-or-later
pub mod groceries;
pub mod import;
pub mod init;
pub mod schedule;

View file

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

View file

@ -2,4 +2,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
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")),
)
.subcommand(
Command::new("schedule")
.about("")
Command::new("groceries")
.about("Create a grocery list from scheduled calendar events")
.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..))
.required(false)
.default_value("7"))
@ -77,6 +87,14 @@ async fn parse_args(args: &ArgMatches) -> Result<()> {
.map(|s| s.as_str());
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)) => {
let api_client = get_api_client(&sub_matches, &configuration)?;
commands::schedule::with(&api_client).await