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

View file

@ -8,7 +8,8 @@ use {
pub struct ApiClient {
pub base_url: Url,
pub client: reqwest::Client,
pub username: String,
pub rest: reqwest::Client,
}
impl ApiClient {
@ -37,14 +38,15 @@ impl ApiClient {
auth_header.set_sensitive(true);
default_headers.insert(header::AUTHORIZATION, auth_header);
let client = reqwest::Client::builder()
let rest_client = reqwest::Client::builder()
.user_agent(constants::USER_AGENT)
.default_headers(default_headers)
.build()?;
Ok(ApiClient {
base_url: Url::parse(&server.url)?,
client: client,
username: server.login_name.clone(),
rest: rest_client,
})
}
}

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(())
}

View file

@ -2,3 +2,4 @@
// 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";

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
mod api_client;
mod commands;
mod config;
mod constants;
mod recipe;
@ -9,106 +10,18 @@ mod recipe;
use {
crate::api_client::ApiClient,
crate::config::Config,
crate::recipe::Recipe,
anyhow::Result,
clap::{arg, command, ArgMatches, Command},
reqwest::{StatusCode, Url},
std::path::PathBuf,
};
#[tokio::main(flavor = "multi_thread")]
async fn main() -> anyhow::Result<()> {
let args = parse_args();
let mut configuration = Config::new();
match args.subcommand() {
Some(("init", sub_matches)) => {
let server = sub_matches
.get_one::<String>("server")
.expect("Mandatory parameter <server>");
tokio::task::block_in_place(move || -> anyhow::Result<()> {
configuration
.credentials
.add(Url::parse(server)?)
.expect("Unable to authenticate to NextCloud instance");
Ok(())
})?;
}
Some(("import", sub_matches)) => {
let api_client = get_api_client(&sub_matches, &configuration)?;
for url in sub_matches
.get_many::<String>("url")
.expect("At least one url is required")
{
let response = api_client
.client
.post(api_client.base_url.join("apps/cookbook/import")?)
.json(&serde_json::json!({
"url": url,
}))
.send()
.await?;
println!("{:#?}", response); // TODO
assert!([StatusCode::OK, StatusCode::CONFLICT].contains(&response.status()));
}
}
Some(("schedule", sub_matches)) => {
let api_client = get_api_client(&sub_matches, &configuration)?;
let recipes = api_client
.client
.get(api_client.base_url.join("apps/cookbook/api/recipes")?)
.send()
.await?;
println!("{:#?}", recipes.json::<Vec<Recipe>>().await?); // TODO
}
Some(("schedule-csv", sub_matches)) => {
let csv_file = sub_matches
.get_one::<PathBuf>("csv_file")
.expect("<csv_file> is a mandatory parameter, it cannot be missing");
let calendar_name = sub_matches
.get_one::<String>("calendar_name")
.expect("<calendar_name> is a mandatory parameter, it cannot be missing");
let mut csv = csv::Reader::from_path(csv_file)?;
#[derive(serde::Deserialize)]
struct CsvRecord {
day: chrono::naive::NaiveDate,
lunch: String,
dinner: String,
}
let recipe_urls = csv.deserialize::<CsvRecord>().fold(
std::collections::HashSet::new(),
|mut set, r| {
if let Ok(r) = r {
set.insert(r.lunch);
set.insert(r.dinner);
}
set
},
);
let api_client = get_api_client(&sub_matches, &configuration)?;
for url in recipe_urls {
let response = api_client
.client
.post(api_client.base_url.join("apps/cookbook/import")?)
.json(&serde_json::json!({
"url": url,
}))
.send()
.await?;
println!("{:#?}", response); // TODO
assert!([StatusCode::OK, StatusCode::CONFLICT].contains(&response.status()));
}
}
_ => unreachable!("Exhausted list of subcommands and subcommand_required prevents `None`"),
};
Ok(())
async fn main() -> Result<()> {
let args = setup_args();
parse_args(&args).await
}
fn parse_args() -> ArgMatches {
fn setup_args() -> ArgMatches {
let server_arg = arg!(-s --server <server> "NextCloud server to connect to").required(false);
command!()
@ -137,7 +50,7 @@ fn parse_args() -> ArgMatches {
)
.subcommand(
Command::new("schedule-csv")
.about("TEMPORARY WIP FUNCTION, UNSTABLE")
.about("TEMPORARY WIP FUNCTION USED FOR INTERNAL TESTING")
.arg(server_arg.clone())
.arg(arg!(<calendar_name> ""))
.arg(arg!(<csv_file> "").value_parser(clap::value_parser!(PathBuf)))
@ -145,7 +58,43 @@ fn parse_args() -> ArgMatches {
.get_matches()
}
fn get_api_client(sub_matches: &ArgMatches, configuration: &Config) -> anyhow::Result<ApiClient> {
async fn parse_args(args: &ArgMatches) -> Result<()> {
let mut configuration = Config::new();
match args.subcommand() {
Some(("init", sub_matches)) => {
let server = sub_matches
.get_one::<String>("server")
.expect("Mandatory parameter <server>");
commands::init::with(&mut configuration, server).await
}
Some(("import", sub_matches)) => {
let api_client = get_api_client(&sub_matches, &configuration)?;
let urls = sub_matches
.get_many::<String>("url")
.expect("At least one url is required")
.map(|s| s.as_str());
commands::import::with(&api_client, urls).await
}
Some(("schedule", sub_matches)) => {
let api_client = get_api_client(&sub_matches, &configuration)?;
commands::schedule::with(&api_client).await
}
Some(("schedule-csv", sub_matches)) => {
let api_client = get_api_client(&sub_matches, &configuration)?;
let csv_file = sub_matches
.get_one::<PathBuf>("csv_file")
.expect("<csv_file> is a mandatory parameter, it cannot be missing");
let calendar_name = sub_matches
.get_one::<String>("calendar_name")
.expect("<calendar_name> is a mandatory parameter, it cannot be missing");
commands::schedule_csv::with(&api_client, calendar_name.as_str(), &csv_file).await
}
_ => unreachable!("Exhausted list of subcommands and subcommand_required prevents `None`"),
}
}
fn get_api_client(sub_matches: &ArgMatches, configuration: &Config) -> Result<ApiClient> {
let server_name = sub_matches
.get_one::<String>("server")
.unwrap_or_else(|| {

View file

@ -2,17 +2,131 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use {
chrono::{DateTime, Utc},
serde::Deserialize,
chrono::Duration,
serde::{Deserialize, Deserializer},
};
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Recipe {
pub struct Metadata {
#[serde(rename = "recipe_id")]
recipe_id: u32,
name: String,
keywords: String,
date_created: DateTime<Utc>,
date_modified: DateTime<Utc>,
pub id: u32,
pub name: String,
pub keywords: String,
pub date_created: DateTime,
pub date_modified: DateTime,
}
/// A recipe according to [schema.org](http://schema.org/Recipe)
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Recipe {
pub id: isize,
pub name: String,
pub description: String,
pub url: String,
pub keywords: String,
#[serde(rename = "dateCreated")]
pub created: DateTime,
#[serde(rename = "dateModified")]
pub modified: Option<DateTime>,
pub image_url: String,
#[serde(deserialize_with = "deserialize_duration")]
pub prep_time: Duration,
#[serde(default)]
#[serde(deserialize_with = "deserialize_maybe_duration")]
pub cook_time: Option<Duration>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_maybe_duration")]
pub total_time: Option<Duration>,
pub image: Option<String>,
pub recipe_yield: isize,
#[serde(rename = "recipeCategory")]
pub category: Option<String>,
pub tools: Option<Vec<Tool>>,
#[serde(rename = "recipeIngredient")]
pub ingredients: Vec<Ingredient>,
#[serde(rename = "recipeInstructions")]
pub instructions: Vec<Instruction>,
//pub nutrition: Nutrition,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Ingredient(String);
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Tool(String);
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Instruction(String);
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Nutrition {
pub calories: Option<String>,
pub carbohydrates_content: Option<String>,
pub cholesterol_content: Option<String>,
pub fat_content: Option<String>,
pub fiber_content: Option<String>,
pub protein_content: Option<String>,
pub saturated_fat_content: Option<String>,
pub serving_size: Option<String>,
pub sodium_content: Option<String>,
pub sugar_content: Option<String>,
pub trans_fat_content: Option<String>,
pub unsaturated_fat_content: Option<String>,
}
type DateTime = chrono::DateTime<chrono::Utc>;
fn deserialize_maybe_duration<'de, D>(deserializer: D) -> Result<Option<Duration>, D::Error>
where
D: Deserializer<'de>,
{
Ok(Some(deserialize_duration(deserializer)?))
}
fn deserialize_duration<'de, D>(deserializer: D) -> Result<Duration, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(DurationVisitor)
}
struct DurationVisitor;
impl<'de> serde::de::Visitor<'de> for DurationVisitor {
type Value = Duration;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a duration in ISO 8601 format")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
speedate::Duration::parse_str(value)
.map(|dt| Duration::seconds(dt.signed_total_seconds()))
.map_err(|e| E::custom(e.to_string()))
}
}
impl Recipe {
pub fn total_time(&self) -> Duration {
self.total_time
.unwrap_or_else(|| self.prep_time + self.cook_time.unwrap_or(Duration::zero()))
}
}