// SPDX-FileCopyrightText: 2022 Matteo Settenvini // SPDX-License-Identifier: AGPL-3.0-or-later use { crate::api_client::ApiClient, crate::commands::import, crate::recipe, crate::scheduling::{Meal, Scheduling}, crate::{constants, helpers}, anyhow::{anyhow, bail, Result}, chrono::naive::NaiveDate, futures::future::try_join_all, icalendar::Event, reqwest::{StatusCode, Url}, std::collections::{HashMap, HashSet}, std::fmt::Write, std::iter::Iterator, std::path::Path, std::rc::Rc, }; #[derive(serde::Deserialize)] struct CsvRecord { day: NaiveDate, lunch: String, dinner: String, } pub async fn with( api_client: &ApiClient, calendar: &str, csv_file: &Path, yearly_recurring_events: bool, ) -> Result<()> { let mut csv = csv::Reader::from_path(csv_file)?; let records = csv.deserialize::().flatten().collect::>(); let recipe_urls = urls_from_csv(records.iter())?; import::with(&api_client, recipe_urls.into_iter()).await?; // Unfortunately, Nextcloud Cookbook doesn't return an id for imported recipes, // so we have to resort to fetch all of them to match them let recipes = get_all_recipes(&api_client).await?; let schedulings = records .iter() .flat_map(|r| { let lunch = recipes.get(&r.lunch); let dinner = recipes.get(&r.dinner); let to_schedule = [ lunch.map(|recipe| Scheduling::new(r.day, Meal::Lunch, recipe.clone())), dinner.map(|recipe| Scheduling::new(r.day, Meal::Dinner, recipe.clone())), ]; to_schedule }) .flatten(); publish_events(&api_client, calendar, schedulings, yearly_recurring_events).await?; Ok(()) } fn urls_from_csv<'a, RecordsIter>(records: RecordsIter) -> Result> where RecordsIter: Iterator, { Ok( records.fold(std::collections::HashSet::new(), |mut set, r| { if let Ok(lunch) = Url::try_from(r.lunch.as_str()) { set.insert(lunch); } if let Ok(dinner) = Url::try_from(r.dinner.as_str()) { set.insert(dinner); } set }), ) } async fn get_all_recipes(api_client: &ApiClient) -> Result>> { log::info!("Getting list of all recipes"); let metadata = api_client .rest(|client| async { let response = client .get(api_client.base_url().join("apps/cookbook/api/recipes")?) .send() .await; Ok(response?) }) .await? .json::>() .await?; let recipes = metadata.iter().map(|rm| async { let recipe_url = api_client .base_url() .join(&format!("apps/cookbook/api/recipes/{id}", id = rm.id)) .unwrap(); let response = api_client .rest(|client| async { let r = client.get(recipe_url.clone()).send().await; Ok(r?) }) .await?; response .json::() .await .map(|r| Rc::new(r)) .map_err(|err| anyhow!(err).context(format!("while fetching {}", recipe_url))) }); let recipes = try_join_all(recipes).await?; Ok(HashMap::from_iter(recipes.into_iter().map(|r| { // Fallback to recipe name as ID if URL is empty let id = match &r.url { Some(u) if !u.is_empty() => u, _ => &r.name, } .clone(); (id, r) }))) } async fn publish_events<'a, SchedulingsIter>( api_client: &ApiClient, calendar: &str, schedulings: SchedulingsIter, yearly_recurring_events: bool, ) -> Result<()> where SchedulingsIter: Iterator, { let calendar_url = api_client.caldav_base_url().join(&format!( "{}/", calendar.to_lowercase().as_str().replace(" ", "-") ))?; let calendar_url = &calendar_url; let update_requests = schedulings.map(|scheduling| async move { let url = calendar_url .join(&format!("{}.ics", scheduling.uid)) .unwrap(); let alarm_text_repr = format!( "BEGIN:VALARM\nACTION:DISPLAY\nTRIGGER:-PT15M\nDESCRIPTION:{}\nEND:VALARM", &helpers::ical_escape_text(&scheduling.recipe.name) ); let info_message = format!( "Saving event at {} for '{}'", &scheduling.ends_at.date(), &scheduling.recipe.name ); let end_time = scheduling.ends_at; let mut event: Event = scheduling.into(); if yearly_recurring_events { use chrono::Datelike; use icalendar::Component; const DAY_NAMES: [&str; 7] = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"]; event.add_property( "RRULE", &format!( "FREQ=YEARLY;BYDAY={weekday};BYWEEKNO={weekno}", weekday = DAY_NAMES .get(end_time.weekday().num_days_from_monday() as usize) .unwrap(), weekno = end_time.iso_week().week(), ), ); } let cal = icalendar::Calendar::new().push(event).done(); let cal_as_string = (&cal.to_string()) .replacen( // need to hack around inability to set PRODID in icalendar::Calendar "PRODID:ICALENDAR-RS", &format!("PRODID:{}", constants::CALENDAR_PROVIDER), 1, ) .replacen( // need to hack around inability to set VALARM in icalendar::Event "END:VEVENT", &format!("{}\nEND:VEVENT", alarm_text_repr), 1, ); let response = api_client .rest(|client| async { let response = client .put(url.clone()) .header("Content-Type", "text/calendar; charset=utf-8") .body(cal_as_string.clone()) .send() .await; Ok(response?) }) .await; log::info!("{}", info_message); response }); let responses = try_join_all(update_requests).await?; let failed_responses = responses.into_iter().filter(|response| { ![StatusCode::NO_CONTENT, StatusCode::CREATED].contains(&response.status()) }); let mut errors = String::new(); for r in failed_responses { write!(errors, "\n{}", r.text().await.unwrap())?; } if !errors.is_empty() { bail!("Error while updating calendar events: {}", errors); } Ok(()) }