Create (but not publish) events from recipes

This commit is contained in:
Matteo Settenvini 2022-07-28 16:38:12 +02:00
parent f201329441
commit dce761b0f9
Signed by: matteo
GPG key ID: 8576CC1AD97D42DF
13 changed files with 560 additions and 119 deletions

30
src/commands/import.rs Normal file
View file

@ -0,0 +1,30 @@
// SPDX-FileCopyrightText: 2022 Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
use {crate::api_client::ApiClient, anyhow::Result, reqwest::StatusCode};
pub async fn with<UrlsIter>(api_client: &ApiClient, urls: UrlsIter) -> Result<()>
where
UrlsIter: std::iter::Iterator,
UrlsIter::Item: AsRef<str>,
{
for url in urls {
let response = api_client
.rest
.post(api_client.base_url.join("apps/cookbook/import")?)
.json(&serde_json::json!({
"url": url.as_ref(),
}))
.send()
.await?;
if ![StatusCode::OK, StatusCode::CONFLICT].contains(&response.status()) {
anyhow::bail!(
"Unable to import recipe {}, received status code {}",
url.as_ref(),
response.status()
);
}
}
Ok(())
}

14
src/commands/init.rs Normal file
View file

@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: 2022 Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
use {crate::config::Config, anyhow::Result, reqwest::Url};
pub async fn with(configuration: &mut Config, server: &str) -> Result<()> {
tokio::task::block_in_place(move || -> anyhow::Result<()> {
configuration
.credentials
.add(Url::parse(server)?)
.expect("Unable to authenticate to NextCloud instance");
Ok(())
})
}

7
src/commands/mod.rs Normal file
View file

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

14
src/commands/schedule.rs Normal file
View file

@ -0,0 +1,14 @@
// SPDX-FileCopyrightText: 2022 Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
// SPDX-License-Identifier: AGPL-3.0-or-later
use {crate::api_client::ApiClient, crate::recipe, anyhow::Result};
pub async fn with(api_client: &ApiClient) -> Result<()> {
let recipes = api_client
.rest
.get(api_client.base_url.join("apps/cookbook/api/recipes")?)
.send()
.await?;
println!("{:#?}", recipes.json::<Vec<recipe::Metadata>>().await?);
todo!();
}

View file

@ -0,0 +1,181 @@
// 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::constants,
crate::recipe,
anyhow::Result,
chrono::naive::{NaiveDate, NaiveDateTime, NaiveTime},
chrono::{DateTime, Local, TimeZone},
futures::future::try_join_all,
ics::properties as calprop,
ics::Event,
std::collections::{HashMap, HashSet},
std::iter::Iterator,
std::path::Path,
};
#[derive(serde::Deserialize)]
struct CsvRecord {
day: NaiveDate,
lunch: String,
dinner: String,
}
#[derive(strum_macros::Display)]
enum Meal {
Lunch,
Dinner,
}
pub async fn with(api_client: &ApiClient, calendar: &str, csv_file: &Path) -> 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?;
let recipes = get_all_recipes(&api_client).await?;
let events = records
.iter()
.flat_map(|r| {
let lunch = recipes.get(&r.lunch);
let dinner = recipes.get(&r.dinner);
let events = [
lunch.map(|recipe| to_event(r.day, Meal::Lunch, recipe)),
dinner.map(|recipe| to_event(r.day, Meal::Dinner, recipe)),
];
events
})
.flatten();
publish_events(&api_client, calendar, events)?;
Ok(())
}
fn to_event(date: NaiveDate, meal: Meal, recipe: &recipe::Recipe) -> Event {
// TODO: this is momentarily hardcoded, should be an option
let meal_time = match meal {
Meal::Lunch => NaiveTime::from_hms(12, 00, 00),
Meal::Dinner => NaiveTime::from_hms(19, 00, 00),
};
let uid = format!(
"{}-{}@{}.montecristosoftware.eu",
date,
meal,
env!("CARGO_PKG_NAME")
);
let end_time = NaiveDateTime::new(date, meal_time);
let start_time = end_time - recipe.total_time();
let mut event = Event::new(uid, dt_fmt(&Local::now()));
event.push(calprop::Summary::new(&recipe.name));
event.push(calprop::Description::new(format!("cookbook@{}", recipe.id)));
event.push(calprop::Location::new(&recipe.url));
event.push(calprop::DtStart::new(dt_fmt(
&Local.from_local_datetime(&start_time).unwrap(),
)));
event.push(calprop::DtEnd::new(dt_fmt(
&Local.from_local_datetime(&end_time).unwrap(),
)));
let alarm = ics::Alarm::display(
calprop::Trigger::new("-P15M"),
calprop::Description::new(&recipe.name),
);
event.add_alarm(alarm);
event
}
fn urls_from_csv<'a, RecordsIter>(records: RecordsIter) -> Result<HashSet<String>>
where
RecordsIter: Iterator<Item = &'a CsvRecord>,
{
Ok(
records.fold(std::collections::HashSet::new(), |mut set, r| {
if !r.lunch.is_empty() {
set.insert(r.lunch.clone());
}
if !r.dinner.is_empty() {
set.insert(r.dinner.clone());
}
set
}),
)
}
fn dt_fmt(datetime: &DateTime<Local>) -> String {
datetime.format("%Y%m%dT%H%M%SZ").to_string()
}
async fn get_all_recipes(api_client: &ApiClient) -> Result<HashMap<String, recipe::Recipe>> {
let metadata = api_client
.rest
.get(api_client.base_url.join("apps/cookbook/api/recipes")?)
.send()
.await?
.json::<Vec<recipe::Metadata>>()
.await?;
let recipes = metadata.iter().map(|rm| async {
let response = api_client
.rest
.get(
api_client
.base_url
.join(&format!("apps/cookbook/api/recipes/{id}", id = rm.id))
.unwrap(),
)
.send()
.await
.expect(&format!(
"Cannot fetch recipe {} with id {}",
rm.name, rm.id
));
response.json::<recipe::Recipe>().await
});
let recipes = try_join_all(recipes).await?;
Ok(HashMap::from_iter(
recipes.into_iter().map(|r| (r.url.clone(), r)),
))
}
fn publish_events<'a, EventsIter>(
api_client: &ApiClient,
calendar: &str,
events: EventsIter,
) -> Result<()>
where
EventsIter: Iterator<Item = Event<'a>>,
{
let calendar_url = api_client.base_url.join(&format!(
"remote.php/dav/calendars/{}/{}/",
&api_client.username,
calendar.to_lowercase().as_str().replace(" ", "-")
));
let cal = ics::ICalendar::new(
"2.0",
format!(
"-//{}//NONSGML {}//EN",
constants::VENDOR,
env!("CARGO_PKG_NAME")
),
);
let cal = events.fold(cal, |mut cal, e| {
cal.add_event(e);
cal
});
println!("CALENDAR: \n {:?}", cal);
Ok(())
}