nextcloud-cooking-schedule/src/commands/schedule_csv.rs

226 lines
6.8 KiB
Rust

// SPDX-FileCopyrightText: 2022 Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// 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::<CsvRecord>().flatten().collect::<Vec<_>>();
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<HashSet<Url>>
where
RecordsIter: Iterator<Item = &'a CsvRecord>,
{
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<HashMap<String, Rc<recipe::Recipe>>> {
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::<Vec<recipe::Metadata>>()
.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::<recipe::Recipe>()
.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<Item = Scheduling>,
{
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(())
}