226 lines
6.8 KiB
Rust
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(())
|
|
}
|