Create (but not publish) events from recipes
This commit is contained in:
parent
f201329441
commit
dce761b0f9
|
@ -2,3 +2,4 @@
|
||||||
# SPDX-License-Identifier: CC0-1.0
|
# SPDX-License-Identifier: CC0-1.0
|
||||||
|
|
||||||
/target
|
/target
|
||||||
|
/*.csv
|
|
@ -0,0 +1,44 @@
|
||||||
|
{
|
||||||
|
// SPDX-FileCopyrightText: 2022 Matteo Settenvini <matteo.settenvini@montecristosoftware.eu>
|
||||||
|
// SPDX-License-Identifier: CC0-1.0
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "lldb",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Debug executable 'cook'",
|
||||||
|
"cargo": {
|
||||||
|
"args": [
|
||||||
|
"build",
|
||||||
|
"--bin=cook",
|
||||||
|
"--package=cooking-schedule"
|
||||||
|
],
|
||||||
|
"filter": {
|
||||||
|
"name": "cook",
|
||||||
|
"kind": "bin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"args": ["schedule-csv", "Cucina", "example-schedule.csv"],
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "lldb",
|
||||||
|
"request": "launch",
|
||||||
|
"name": "Debug unit tests in executable 'cook'",
|
||||||
|
"cargo": {
|
||||||
|
"args": [
|
||||||
|
"test",
|
||||||
|
"--no-run",
|
||||||
|
"--bin=cook",
|
||||||
|
"--package=cooking-schedule"
|
||||||
|
],
|
||||||
|
"filter": {
|
||||||
|
"name": "cook",
|
||||||
|
"kind": "bin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"args": [],
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -147,12 +147,14 @@ dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"csv",
|
"csv",
|
||||||
"directories",
|
"directories",
|
||||||
|
"futures",
|
||||||
"ics",
|
"ics",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rusty-hook",
|
"rusty-hook",
|
||||||
"rustydav",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"speedate",
|
||||||
|
"strum_macros",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
"webbrowser",
|
"webbrowser",
|
||||||
|
@ -316,6 +318,21 @@ version = "0.1.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c1fd087255f739f4f1aeea69f11b72f8080e9c2e7645cd06955dad4a178a49e3"
|
checksum = "c1fd087255f739f4f1aeea69f11b72f8080e9c2e7645cd06955dad4a178a49e3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures"
|
||||||
|
version = "0.3.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f73fe65f54d1e12b726f517d3e2135ca3125a437b6d998caf1962961f7172d9e"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-executor",
|
||||||
|
"futures-io",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.21"
|
version = "0.3.21"
|
||||||
|
@ -323,6 +340,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010"
|
checksum = "c3083ce4b914124575708913bca19bfe887522d6e2e6d0952943f5eac4a74010"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
@ -331,12 +349,34 @@ version = "0.3.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3"
|
checksum = "0c09fd04b7e4073ac7156a9539b57a484a8ea920f79c7c675d05d289ab6110d3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-executor"
|
||||||
|
version = "0.3.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9420b90cfa29e327d0429f19be13e7ddb68fa1cccb09d65e5706b8c7a749b8a6"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-task",
|
||||||
|
"futures-util",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-io"
|
name = "futures-io"
|
||||||
version = "0.3.21"
|
version = "0.3.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b"
|
checksum = "fc4045962a5a5e935ee2fdedaa4e08284547402885ab326734432bed5d12966b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-macro"
|
||||||
|
version = "0.3.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33c1e13800337f4d4d7a316bf45a567dbcb6ffe087f16424852d97e97a91f512"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
version = "0.3.21"
|
version = "0.3.21"
|
||||||
|
@ -355,8 +395,11 @@ version = "0.3.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a"
|
checksum = "d8b7abd5d659d9b90c8cba917f6ec750a74e2dc23902ef9cd4cc8c8b22e6036a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
|
"futures-macro",
|
||||||
|
"futures-sink",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
"memchr",
|
"memchr",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
@ -409,6 +452,12 @@ version = "0.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "db0d4cf898abf0081f964436dc980e96670a0f36863e4b83aaacdb65c9d7ccc3"
|
checksum = "db0d4cf898abf0081f964436dc980e96670a0f36863e4b83aaacdb65c9d7ccc3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hermit-abi"
|
name = "hermit-abi"
|
||||||
version = "0.1.19"
|
version = "0.1.19"
|
||||||
|
@ -940,6 +989,12 @@ dependencies = [
|
||||||
"winreg",
|
"winreg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24c8ad4f0c00e1eb5bc7614d236a7f1300e3dbd76b68cac8e06fb00b015ad8d8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rusty-hook"
|
name = "rusty-hook"
|
||||||
version = "0.11.2"
|
version = "0.11.2"
|
||||||
|
@ -952,15 +1007,6 @@ dependencies = [
|
||||||
"toml",
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustydav"
|
|
||||||
version = "0.1.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bc4c86c47126ac8bfc573084610e93f4ca8726f3ae7bf6c64bd60476731b6e42"
|
|
||||||
dependencies = [
|
|
||||||
"reqwest",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.10"
|
version = "1.0.10"
|
||||||
|
@ -1068,12 +1114,44 @@ dependencies = [
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "speedate"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "519ab0d5d5dc6d050a2327ea508d20b460dba1e3a76f1933c56b7c4da2b5c620"
|
||||||
|
dependencies = [
|
||||||
|
"strum",
|
||||||
|
"strum_macros",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
|
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum"
|
||||||
|
version = "0.24.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f"
|
||||||
|
dependencies = [
|
||||||
|
"strum_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strum_macros"
|
||||||
|
version = "0.24.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4faebde00e8ff94316c01800f9054fd2ba77d30d9e922541913051d1d978918b"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustversion",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "1.0.98"
|
version = "1.0.98"
|
||||||
|
|
12
Cargo.toml
12
Cargo.toml
|
@ -36,6 +36,9 @@ features = ["cargo"]
|
||||||
[dependencies.directories]
|
[dependencies.directories]
|
||||||
version = "4.0"
|
version = "4.0"
|
||||||
|
|
||||||
|
[dependencies.futures]
|
||||||
|
version = "0.3"
|
||||||
|
|
||||||
[dependencies.ics]
|
[dependencies.ics]
|
||||||
version = "0.5"
|
version = "0.5"
|
||||||
|
|
||||||
|
@ -43,9 +46,6 @@ version = "0.5"
|
||||||
version = "0.11"
|
version = "0.11"
|
||||||
features = ["json", "blocking"]
|
features = ["json", "blocking"]
|
||||||
|
|
||||||
[dependencies.rustydav]
|
|
||||||
version = "0.1"
|
|
||||||
|
|
||||||
[dependencies.serde]
|
[dependencies.serde]
|
||||||
version = "1.0"
|
version = "1.0"
|
||||||
features = ["derive"]
|
features = ["derive"]
|
||||||
|
@ -53,6 +53,12 @@ features = ["derive"]
|
||||||
[dependencies.serde_json]
|
[dependencies.serde_json]
|
||||||
version = "1.0"
|
version = "1.0"
|
||||||
|
|
||||||
|
[dependencies.speedate]
|
||||||
|
version = "0.6"
|
||||||
|
|
||||||
|
[dependencies.strum_macros]
|
||||||
|
version = "0.24"
|
||||||
|
|
||||||
[dependencies.toml]
|
[dependencies.toml]
|
||||||
version = "0.5"
|
version = "0.5"
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,8 @@ use {
|
||||||
|
|
||||||
pub struct ApiClient {
|
pub struct ApiClient {
|
||||||
pub base_url: Url,
|
pub base_url: Url,
|
||||||
pub client: reqwest::Client,
|
pub username: String,
|
||||||
|
pub rest: reqwest::Client,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApiClient {
|
impl ApiClient {
|
||||||
|
@ -37,14 +38,15 @@ impl ApiClient {
|
||||||
auth_header.set_sensitive(true);
|
auth_header.set_sensitive(true);
|
||||||
default_headers.insert(header::AUTHORIZATION, auth_header);
|
default_headers.insert(header::AUTHORIZATION, auth_header);
|
||||||
|
|
||||||
let client = reqwest::Client::builder()
|
let rest_client = reqwest::Client::builder()
|
||||||
.user_agent(constants::USER_AGENT)
|
.user_agent(constants::USER_AGENT)
|
||||||
.default_headers(default_headers)
|
.default_headers(default_headers)
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
Ok(ApiClient {
|
Ok(ApiClient {
|
||||||
base_url: Url::parse(&server.url)?,
|
base_url: Url::parse(&server.url)?,
|
||||||
client: client,
|
username: server.login_name.clone(),
|
||||||
|
rest: rest_client,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(())
|
||||||
|
}
|
|
@ -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(())
|
||||||
|
})
|
||||||
|
}
|
|
@ -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;
|
|
@ -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!();
|
||||||
|
}
|
|
@ -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(())
|
||||||
|
}
|
|
@ -2,3 +2,4 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
pub const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
|
pub const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
|
||||||
|
pub const VENDOR: &str = "montecristosoftware.eu";
|
||||||
|
|
139
src/main.rs
139
src/main.rs
|
@ -2,6 +2,7 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
mod api_client;
|
mod api_client;
|
||||||
|
mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
mod constants;
|
mod constants;
|
||||||
mod recipe;
|
mod recipe;
|
||||||
|
@ -9,106 +10,18 @@ mod recipe;
|
||||||
use {
|
use {
|
||||||
crate::api_client::ApiClient,
|
crate::api_client::ApiClient,
|
||||||
crate::config::Config,
|
crate::config::Config,
|
||||||
crate::recipe::Recipe,
|
anyhow::Result,
|
||||||
clap::{arg, command, ArgMatches, Command},
|
clap::{arg, command, ArgMatches, Command},
|
||||||
reqwest::{StatusCode, Url},
|
|
||||||
std::path::PathBuf,
|
std::path::PathBuf,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tokio::main(flavor = "multi_thread")]
|
#[tokio::main(flavor = "multi_thread")]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> Result<()> {
|
||||||
let args = parse_args();
|
let args = setup_args();
|
||||||
let mut configuration = Config::new();
|
parse_args(&args).await
|
||||||
|
|
||||||
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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_args() -> ArgMatches {
|
fn setup_args() -> ArgMatches {
|
||||||
let server_arg = arg!(-s --server <server> "NextCloud server to connect to").required(false);
|
let server_arg = arg!(-s --server <server> "NextCloud server to connect to").required(false);
|
||||||
|
|
||||||
command!()
|
command!()
|
||||||
|
@ -137,7 +50,7 @@ fn parse_args() -> ArgMatches {
|
||||||
)
|
)
|
||||||
.subcommand(
|
.subcommand(
|
||||||
Command::new("schedule-csv")
|
Command::new("schedule-csv")
|
||||||
.about("TEMPORARY WIP FUNCTION, UNSTABLE")
|
.about("TEMPORARY WIP FUNCTION USED FOR INTERNAL TESTING")
|
||||||
.arg(server_arg.clone())
|
.arg(server_arg.clone())
|
||||||
.arg(arg!(<calendar_name> ""))
|
.arg(arg!(<calendar_name> ""))
|
||||||
.arg(arg!(<csv_file> "").value_parser(clap::value_parser!(PathBuf)))
|
.arg(arg!(<csv_file> "").value_parser(clap::value_parser!(PathBuf)))
|
||||||
|
@ -145,7 +58,43 @@ fn parse_args() -> ArgMatches {
|
||||||
.get_matches()
|
.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
|
let server_name = sub_matches
|
||||||
.get_one::<String>("server")
|
.get_one::<String>("server")
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
|
|
130
src/recipe.rs
130
src/recipe.rs
|
@ -2,17 +2,131 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use {
|
use {
|
||||||
chrono::{DateTime, Utc},
|
chrono::Duration,
|
||||||
serde::Deserialize,
|
serde::{Deserialize, Deserializer},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Recipe {
|
pub struct Metadata {
|
||||||
#[serde(rename = "recipe_id")]
|
#[serde(rename = "recipe_id")]
|
||||||
recipe_id: u32,
|
pub id: u32,
|
||||||
name: String,
|
pub name: String,
|
||||||
keywords: String,
|
pub keywords: String,
|
||||||
date_created: DateTime<Utc>,
|
pub date_created: DateTime,
|
||||||
date_modified: DateTime<Utc>,
|
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()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue