diff --git a/Cargo.lock b/Cargo.lock index 2f748c87c0..90f195ca40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -273,6 +273,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -445,8 +451,11 @@ checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "pure-rust-locales", + "serde", + "wasm-bindgen", "windows-targets 0.52.6", ] @@ -475,9 +484,9 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.8.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "67523a3b4be3ce1989d607a828d036249522dd9c1c8de7f4dd2dae43a37369d1" dependencies = [ "glob", "libc", @@ -1027,8 +1036,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1156,6 +1167,20 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http", + "hyper", + "rustls", + "tokio", + "tokio-rustls", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -1175,6 +1200,7 @@ version = "0.33.1" dependencies = [ "async-trait", "backon", + "base64 0.22.1", "calibright", "chrono", "chrono-tz", @@ -1186,6 +1212,7 @@ dependencies = [ "glob", "hyper", "iana-time-zone", + "icalendar", "icu_calendar", "icu_datetime", "icu_locid", @@ -1201,7 +1228,9 @@ dependencies = [ "nix 0.29.0", "nom", "notmuch", + "oauth2", "pipewire", + "quick-xml 0.31.0", "regex", "reqwest", "sensors", @@ -1244,6 +1273,19 @@ dependencies = [ "cc", ] +[[package]] +name = "icalendar" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "292907754aa6a75fade69fcd77f36fdd0d0d7029d401f2f1bfc0ee8606f04626" +dependencies = [ + "chrono", + "chrono-tz", + "iso8601", + "nom", + "uuid", +] + [[package]] name = "icu_calendar" version = "1.5.2" @@ -1490,6 +1532,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "iso8601" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924e5d73ea28f59011fec52a0d12185d496a9b075d360657aed2a5707f701153" +dependencies = [ + "nom", +] + [[package]] name = "itertools" version = "0.12.1" @@ -1882,6 +1933,26 @@ dependencies = [ "autocfg", ] +[[package]] +name = "oauth2" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" +dependencies = [ + "base64 0.13.1", + "chrono", + "getrandom", + "http", + "rand", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror", + "url", +] + [[package]] name = "object" version = "0.36.4" @@ -2130,6 +2201,16 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1190fd18ae6ce9e137184f207593877e70f39b015040156b1e05081cdfe3733a" +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quick-xml" version = "0.36.1" @@ -2248,6 +2329,7 @@ dependencies = [ "http", "http-body", "hyper", + "hyper-rustls", "hyper-tls", "ipnet", "js-sys", @@ -2257,6 +2339,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "rustls", "rustls-pemfile", "serde", "serde_json", @@ -2265,14 +2348,31 @@ dependencies = [ "system-configuration", "tokio", "tokio-native-tls", + "tokio-rustls", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", "winreg", ] +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "roff" version = "0.2.2" @@ -2304,6 +2404,18 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -2313,6 +2425,16 @@ dependencies = [ "base64 0.21.7", ] +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "ryu" version = "1.0.18" @@ -2337,6 +2459,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -2402,6 +2534,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +dependencies = [ + "itoa", + "serde", +] + [[package]] name = "serde_repr" version = "0.1.19" @@ -2445,6 +2587,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shellexpand" version = "3.1.0" @@ -2533,6 +2686,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -2758,6 +2917,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.12" @@ -2899,6 +3068,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.2" @@ -2908,6 +3083,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -2916,6 +3092,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" +dependencies = [ + "getrandom", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -3054,7 +3240,7 @@ version = "2.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "076ab8342497b77753c4f882f6d1654e1f8f4bd648ce72d045f237b8a727f4c9" dependencies = [ - "quick-xml", + "quick-xml 0.36.1", "thiserror", ] @@ -3089,6 +3275,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index b01177e4bf..868497a082 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ rustdoc-args = ["--cfg", "docsrs"] [dependencies] async-trait = "0.1" backon = { version = "1.2", default-features = false, features = ["tokio-sleep"] } +base64 = { version = "0.22.1" } calibright = { version = "0.1.6", features = ["watch"] } chrono = { version = "0.4", default-features = false, features = ["clock", "unstable-locales"] } chrono-tz = { version = "0.9", features = ["serde"] } @@ -42,6 +43,7 @@ futures = { version = "0.3", default-features = false } glob = { version = "0.3.1", optional = true } hyper = "0.14" iana-time-zone = "0.1.60" +icalendar = { version = "0.16.2", features = ["chrono-tz"] } icu_calendar = { version = "1.3.0", optional = true } icu_datetime = { version = "1.3.0", optional = true } icu_locid = { version = "1.3.0", optional = true } @@ -57,7 +59,9 @@ neli-wifi = { version = "0.6", features = ["async"] } nix = { version = "0.29", features = ["fs", "process"] } nom = "7.1.2" notmuch = { version = "0.8", optional = true } +oauth2 = { version = "4.4.2" } pipewire = { version = "0.8", default-features = false, optional = true } +quick-xml = { version = "0.31.0", features = ["serialize"] } regex = "1.5" reqwest = { version = "0.11", features = ["json"] } sensors = "0.2.2" diff --git a/cspell.yaml b/cspell.yaml index e86574ce2c..260e56e952 100644 --- a/cspell.yaml +++ b/cspell.yaml @@ -30,6 +30,7 @@ words: - bokmaal - bugz - busctl + - caldav - chrono - clippy - CLOEXEC @@ -63,6 +64,7 @@ words: - horiz - hypot - ibus + - icalendar - iface - ifaces - ifaddrmsg @@ -114,6 +116,7 @@ words: - pavucontrol - percents - pikaur + - pkce - pkill - playerctl - playerctld @@ -169,6 +172,8 @@ words: - upower - uptodate - vals + - VCALENDAR + - VEVENT - wayrs - wdoll - WLAN diff --git a/src/blocks.rs b/src/blocks.rs index 28fadd9c4e..f00a294547 100644 --- a/src/blocks.rs +++ b/src/blocks.rs @@ -152,6 +152,7 @@ define_blocks!( backlight, battery, bluetooth, + calendar, cpu, custom, custom_dbus, diff --git a/src/blocks/calendar.rs b/src/blocks/calendar.rs new file mode 100644 index 0000000000..8cf81da061 --- /dev/null +++ b/src/blocks/calendar.rs @@ -0,0 +1,610 @@ +//! Calendar +//! +//! This block displays upcoming calendar events retrieved from a CalDav ICalendar server. +//! +//! # Configuration +//! +//! Key | Values | Default +//! ----|--------|-------- +//! `next_event_format` | A string to customize the output of this block when there is a next event in the calendar. See below for available placeholders. | \" $icon $start.datetime(f:'%a %H:%M') $summary \" +//! `ongoing_event_format` | A string to customize the output of this block when an event is ongoing. | \" $icon $summary (ends at $end.datetime(f:'%H:%M')) \" +//! `no_events_format` | A string to customize the output of this block when there are no events | \" $icon \" +//! `redirect_format` | A string to customize the output of this block when the authorization is asked | \" $icon Check your web browser \" +//! `fetch_interval` | Fetch events interval in seconds | `60` +//! `alternate_events_interval` | Alternate overlapping events interval in seconds | `10` +//! `events_within_hours` | Number of hours to look for events in the future | `48` +//! `source` | Array of sources to pull calendars from | `[]` +//! `warning_threshold` | Warning threshold in seconds for the upcoming event | `300` +//! `browser_cmd` | Command to open event details in a browser. The block passes the HTML link as an argument | `"xdg-open"` +//! +//! # Source Configuration +//! +//! Key | Values | Default +//! ----|--------|-------- +//! `url` | CalDav calendar server URL | N/A +//! `auth` | Authentication configuration (unauthenticated, basic, or oauth2) | `unauthenticated` +//! `calendars` | List of calendar names to monitor. If empty, all calendars will be fetched. | `[]` +//! +//! Note: Currently only one source is supported +//! +//! Action | Description | Default button +//! ----------------|-------------------------------------------|--------------- +//! `open_link` | Opens the HTML link of the event | Left +//! +//! # Examples +//! +//! ## Unauthenticated +//! +//! ```toml +//! [[block]] +//! block = "calendar" +//! next_event_format = " $icon $start.datetime(f:'%a %H:%M') $summary " +//! ongoing_event_format = " $icon $summary (ends at $end.datetime(f:'%H:%M')) " +//! no_events_format = " $icon no events " +//! fetch_interval = 30 +//! alternate_events_interval = 10 +//! events_within_hours = 48 +//! warning_threshold = 600 +//! browser_cmd = "firefox" +//! [[block.source]] +//! url = "https://caldav.example.com/calendar/" +//! calendars = ["user/calendar"] +//! [block.source.auth] +//! type = "unauthenticated" +//! ``` +//! +//! ## Basic Authentication +//! +//! ```toml +//! [[block]] +//! block = "calendar" +//! next_event_format = " $icon $start.datetime(f:'%a %H:%M') $summary " +//! ongoing_event_format = " $icon $summary (ends at $end.datetime(f:'%H:%M')) " +//! no_events_format = " $icon no events " +//! fetch_interval = 30 +//! alternate_events_interval = 10 +//! events_within_hours = 48 +//! warning_threshold = 600 +//! browser_cmd = "firefox" +//! [[block.source]] +//! url = "https://caldav.example.com/calendar/" +//! calendars = [ "Holidays" ] +//! [block.source.auth] +//! type = "basic" +//! username = "your_username" +//! password = "your_password" +//! ``` +//! +//! Note: You can also configure the `username` and `password` in a separate TOML file. +//! +//! `~/.config/i3status-rust/example_credentials.toml` +//! ```toml +//! username = "my-username" +//! password = "my-password" +//! ``` +//! +//! Source auth configuration with `credentials_path`: +//! +//! ```toml +//! [block.source.auth] +//! type = "basic" +//! credentials_path = "~/.config/i3status-rust/example_credentials.toml" +//! ``` +//! +//! ## OAuth2 Authentication (Google Calendar) +//! +//! To access the CalDav API of Google, follow these steps to enable the API and obtain the `client_id` and `client_secret`: +//! 1. **Go to the Google Cloud Console**: Navigate to the [Google Cloud Console](https://console.cloud.google.com/). +//! 2. **Create a New Project**: If you don't already have a project, click on the project dropdown and select "New Project". Give your project a name and click "Create". +//! 3. **Enable the CalDAV API**: In the project dashboard, go to the "APIs & Services" > "Library". Search for "CalDAV API" and click on it, then click "Enable". +//! 4. **Set Up OAuth Consent Screen**: Go to "APIs & Services" > "OAuth consent screen". Fill out the required information and save. +//! 5. **Create Credentials**: +//! - Navigate to "APIs & Services" > "Credentials". +//! - Click "Create Credentials" and select "OAuth 2.0 Client IDs". +//! - Configure the consent screen if you haven't already. +//! - Set the application type to "Web application". +//! - Add your authorized redirect URIs. For example, `http://localhost:8080`. +//! - Click "Create" and note down the `client_id` and `client_secret`. +//! 6. **Download the Credentials**: Click on the download icon next to your OAuth 2.0 Client ID to download the JSON file containing your client ID and client secret. Use these values in your configuration. +//! +//! ```toml +//! [[block]] +//! block = "calendar" +//! next_event_format = " $icon $start.datetime(f:'%a %H:%M') $summary " +//! ongoing_event_format = " $icon $summary (ends at $end.datetime(f:'%H:%M')) " +//! no_events_format = " $icon no events " +//! fetch_interval = 30 +//! alternate_events_interval = 10 +//! events_within_hours = 48 +//! warning_threshold = 600 +//! browser_cmd = "firefox" +//! [[block.source]] +//! url = "https://apidata.googleusercontent.com/caldav/v2/" +//! calendars = ["primary"] +//! [block.source.auth] +//! type = "oauth2" +//! client_id = "your_client_id" +//! client_secret = "your_client_secret" +//! auth_url = "https://accounts.google.com/o/oauth2/auth" +//! token_url = "https://oauth2.googleapis.com/token" +//! auth_token = "~/.config/i3status-rust/calendar.auth_token" +//! redirect_port = 8080 +//! scopes = ["https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/calendar.events"] +//! ``` +//! +//! Note: You can also configure the `client_id` and `client_secret` in a separate TOML file. +//! +//! `~/.config/i3status-rust/google_credentials.toml` +//! ```toml +//! client_id = "my-client_id" +//! client_secret = "my-client_secret" +//! ``` +//! +//! Source auth configuration with `credentials_path`: +//! +//! ```toml +//! [block.source.auth] +//! type = "oauth2" +//! credentials_path = "~/.config/i3status-rust/google_credentials.toml" +//! auth_url = "https://accounts.google.com/o/oauth2/auth" +//! token_url = "https://oauth2.googleapis.com/token" +//! auth_token = "~/.config/i3status-rust/calendar.auth_token" +//! redirect_port = 8080 +//! scopes = ["https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/calendar.events"] +//! ``` +//! +//! # Format Configuration +//! +//! The format configuration is a string that can include placeholders to be replaced with dynamic content. +//! Placeholders can be: +//! - `$summary`: Summary of the event +//! - `$description`: Description of the event +//! - `$url`: Url of the event +//! - `$location`: Location of the event +//! - `$start`: Start time of the event +//! - `$end`: End time of the event +//! +//! # Icons Used +//! - `calendar` + +use chrono::{Duration, Local, Utc}; +use oauth2::{AuthUrl, ClientId, ClientSecret, Scope, TokenUrl}; +use reqwest::Url; + +use crate::util; +use crate::{subprocess::spawn_process, util::has_command}; + +mod auth; +mod caldav; + +use self::auth::{Authorize, AuthorizeUrl, OAuth2Flow, TokenStore, TokenStoreError}; +use self::caldav::Event; + +use super::prelude::*; + +use std::path::Path; +use std::sync::Arc; + +use caldav::Client; + +#[derive(Deserialize, Debug, SmartDefault, Clone)] +#[serde(deny_unknown_fields, default)] +pub struct BasicCredentials { + pub username: Option, + pub password: Option, +} + +#[derive(Deserialize, Debug, Clone)] +pub struct BasicAuthConfig { + #[serde(flatten)] + pub credentials: BasicCredentials, + pub credentials_path: Option, +} + +#[derive(Deserialize, Debug, SmartDefault, Clone)] +#[serde(deny_unknown_fields, default)] +pub struct OAuth2Credentials { + pub client_id: Option, + pub client_secret: Option, +} + +#[derive(Deserialize, Debug, SmartDefault, Clone)] +#[serde(deny_unknown_fields, default)] +pub struct OAuth2Config { + #[serde(flatten)] + pub credentials: OAuth2Credentials, + pub credentials_path: Option, + pub auth_url: String, + pub token_url: String, + #[default("~/.config/i3status-rust/calendar.auth_token".into())] + pub auth_token: ShellString, + #[default(8080)] + pub redirect_port: u16, + pub scopes: Vec, +} + +#[derive(Deserialize, Default, Debug, Clone)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum AuthConfig { + #[default] + Unauthenticated, + Basic(BasicAuthConfig), + OAuth2(OAuth2Config), +} + +#[derive(Deserialize, Debug, SmartDefault, Clone)] +#[serde(deny_unknown_fields, default)] +pub struct SourceConfig { + pub url: String, + pub auth: AuthConfig, + pub calendars: Vec, +} + +#[derive(Deserialize, Debug, SmartDefault)] +#[serde(deny_unknown_fields, default)] +pub struct Config { + pub next_event_format: FormatConfig, + pub ongoing_event_format: FormatConfig, + pub no_events_format: FormatConfig, + pub redirect_format: FormatConfig, + #[default(60.into())] + pub fetch_interval: Seconds, + #[default(10.into())] + pub alternate_events_interval: Seconds, + #[default(48)] + pub events_within_hours: u32, + pub source: Vec, + #[default(300)] + pub warning_threshold: u32, + #[default("xdg-open".into())] + pub browser_cmd: ShellString, +} + +enum WidgetStatus { + AlternateEvents, + FetchSources, +} + +pub async fn run(config: &Config, api: &CommonApi) -> Result<()> { + let next_event_format = config + .next_event_format + .with_default(" $icon $start.datetime(f:'%a %H:%M') $summary ")?; + let ongoing_event_format = config + .ongoing_event_format + .with_default(" $icon $summary (ends at $end.datetime(f:'%H:%M')) ")?; + let no_events_format = config.no_events_format.with_default(" $icon ")?; + let redirect_format = config + .redirect_format + .with_default(" $icon Check your web browser ")?; + + api.set_default_actions(&[(MouseButton::Left, None, "open_link")])?; + + let source_config = match config.source.len() { + 0 => return Err(Error::new("A calendar source must be supplied")), + 1 => config + .source + .first() + .expect("There must be a first entry since the length is 1"), + _ => { + return Err(Error::new( + "Currently only one calendar source is supported", + )) + } + }; + + let warning_threshold = Duration::try_seconds(config.warning_threshold.into()) + .error("Invalid warning threshold configuration")?; + + let mut source = Source::new(source_config.clone()).await?; + + let mut timer = config.fetch_interval.timer(); + + let mut alternate_events_timer = config.alternate_events_interval.timer(); + + let mut actions = api.get_actions()?; + + let events_within = Duration::try_hours(config.events_within_hours.into()) + .error("Invalid events within hours configuration")?; + + let mut widget_status = WidgetStatus::FetchSources; + + let mut next_events = OverlappingEvents::default(); + + loop { + let mut widget = Widget::new().with_format(no_events_format.clone()); + widget.set_values(map! { + "icon" => Value::icon("calendar"), + }); + + if matches!(widget_status, WidgetStatus::FetchSources) { + for retries in 0..=1 { + match source.get_next_events(events_within).await { + Ok(events) => { + next_events.refresh(events); + break; + } + Err(err) => match err { + CalendarError::AuthRequired => { + let authorization = source + .client + .authorize() + .await + .error("Authorization failed")?; + match &authorization { + Authorize::AskUser(AuthorizeUrl { url, .. }) if retries == 0 => { + widget.set_format(redirect_format.clone()); + api.set_widget(widget.clone())?; + open_browser(config, url).await?; + source + .client + .ask_user(authorization) + .await + .error("Ask user failed")?; + } + _ => { + return Err(Error::new( + "Authorization failed. Check your configurations", + )) + } + } + } + e => { + return Err(Error { + message: None, + cause: Some(Arc::new(e)), + }) + } + }, + }; + } + } + + if let Some(event) = next_events.current().cloned() { + if let (Some(start_date), Some(end_date)) = (event.start_at, event.end_at) { + let warn_datetime = start_date - warning_threshold; + if warn_datetime < Utc::now() && Utc::now() < start_date { + widget.state = State::Warning; + } + if start_date < Utc::now() && Utc::now() < end_date { + widget.set_format(ongoing_event_format.clone()); + } else { + widget.set_format(next_event_format.clone()); + } + widget.set_values(map! { + "icon" => Value::icon("calendar"), + [if let Some(summary) = event.summary] "summary" => Value::text(summary), + [if let Some(description) = event.description] "description" => Value::text(description), + [if let Some(location) = event.location] "location" => Value::text(location), + [if let Some(url) = event.url] "url" => Value::text(url), + "start" => Value::datetime(start_date, None), + "end" => Value::datetime(end_date, None), + }); + } + } + + api.set_widget(widget)?; + loop { + select! { + _ = timer.tick() => { + widget_status = WidgetStatus::FetchSources; + break + } + _ = alternate_events_timer.tick() => { + next_events.cycle_warning_or_ongoing(warning_threshold); + widget_status = WidgetStatus::AlternateEvents; + break + } + _ = api.wait_for_update_request() => break, + Some(action) = actions.recv() => match action.as_ref() { + "open_link" => { + if let Some(Event { url: Some(url), .. }) = next_events.current(){ + if let Ok(url) = Url::parse(url) { + open_browser(config, &url).await?; + } + } + } + _ => () + } + } + } + } +} + +struct Source { + pub client: caldav::Client, + pub config: SourceConfig, +} + +impl Source { + async fn new(config: SourceConfig) -> Result { + let auth = match &config.auth { + AuthConfig::Unauthenticated => auth::Auth::Unauthenticated, + AuthConfig::Basic(BasicAuthConfig { + credentials, + credentials_path, + }) => { + let credentials = if let Some(path) = credentials_path { + util::deserialize_toml_file(path.expand()?.to_string()) + .error("Failed to read basic credentials file")? + } else { + credentials.clone() + }; + let BasicCredentials { + username: Some(username), + password: Some(password), + } = credentials + else { + return Err(Error::new("Basic credentials are not configured")); + }; + auth::Auth::basic(username, password) + } + AuthConfig::OAuth2(oauth2) => { + let credentials = if let Some(path) = &oauth2.credentials_path { + util::deserialize_toml_file(path.expand()?.to_string()) + .error("Failed to read oauth2 credentials file")? + } else { + oauth2.credentials.clone() + }; + let OAuth2Credentials { + client_id: Some(client_id), + client_secret: Some(client_secret), + } = credentials + else { + return Err(Error::new("Oauth2 credentials are not configured")); + }; + let auth_url = + AuthUrl::new(oauth2.auth_url.clone()).error("Invalid authorization url")?; + let token_url = + TokenUrl::new(oauth2.token_url.clone()).error("Invalid token url")?; + + let flow = OAuth2Flow::new( + ClientId::new(client_id), + ClientSecret::new(client_secret), + auth_url, + token_url, + oauth2.redirect_port, + ); + let token_store = + TokenStore::new(Path::new(&oauth2.auth_token.expand()?.to_string())); + auth::Auth::oauth2(flow, token_store, oauth2.scopes.clone()) + } + }; + Ok(Self { + client: Client::new( + Url::parse(&config.url).error("Invalid CalDav server url")?, + auth, + ), + config, + }) + } + + async fn get_next_events( + &mut self, + within: Duration, + ) -> Result { + let calendars: Vec<_> = self + .client + .calendars() + .await? + .into_iter() + .filter(|c| self.config.calendars.is_empty() || self.config.calendars.contains(&c.name)) + .collect(); + let mut events: Vec = vec![]; + for calendar in calendars { + let calendar_events: Vec<_> = self + .client + .events( + &calendar, + Local::now() + .date_naive() + .and_hms_opt(0, 0, 0) + .expect("A valid time") + .and_local_timezone(Local) + .earliest() + .expect("A valid datetime") + .to_utc(), + Utc::now() + within, + ) + .await? + .into_iter() + .filter(|e| { + let not_started = e.start_at.is_some_and(|d| d > Utc::now()); + let is_ongoing = e.start_at.is_some_and(|d| d < Utc::now()) + && e.end_at.is_some_and(|d| d > Utc::now()); + not_started || is_ongoing + }) + .collect(); + events.extend(calendar_events); + } + + events.sort_by_key(|e| e.start_at); + let Some(next_event) = events.first().cloned() else { + return Ok(OverlappingEvents::default()); + }; + let overlapping_events = events + .into_iter() + .take_while(|e| e.start_at <= next_event.end_at) + .collect(); + Ok(OverlappingEvents::new(overlapping_events)) + } +} + +#[derive(Default)] +struct OverlappingEvents { + current: Option, + events: Vec, +} + +impl OverlappingEvents { + fn new(events: Vec) -> Self { + Self { + current: events.first().cloned(), + events, + } + } + + fn refresh(&mut self, other: OverlappingEvents) { + if self.current.is_none() { + self.current = other.events.first().cloned(); + } + self.events = other.events; + } + + fn current(&self) -> Option<&Event> { + self.current.as_ref() + } + + fn cycle_warning_or_ongoing(&mut self, warning_threshold: Duration) { + self.current = if let Some(current) = &self.current { + if self.events.iter().any(|e| e.uid == current.uid) { + let mut iter = self + .events + .iter() + .cycle() + .skip_while(|e| e.uid != current.uid); + iter.next(); + iter.find(|e| { + let is_ongoing = e.start_at.is_some_and(|d| d < Utc::now()) + && e.end_at.is_some_and(|d| d > Utc::now()); + let is_warning = e + .start_at + .is_some_and(|d| d - warning_threshold < Utc::now() && Utc::now() < d); + e.uid == current.uid || is_warning || is_ongoing + }) + .cloned() + } else { + self.events.first().cloned() + } + } else { + self.events.first().cloned() + }; + } +} + +async fn open_browser(config: &Config, url: &Url) -> Result<()> { + let cmd = config.browser_cmd.expand()?; + has_command(&cmd) + .await + .or_error(|| "Browser command not found")?; + spawn_process(&cmd, &[url.as_ref()]).error("Open browser failed") +} + +#[derive(thiserror::Error, Debug)] +pub enum CalendarError { + #[error(transparent)] + Http(#[from] reqwest::Error), + #[error(transparent)] + Deserialize(#[from] quick_xml::de::DeError), + #[error("Parsing error: {0}")] + Parsing(String), + #[error("Auth required")] + AuthRequired, + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Serialize(#[from] serde_json::Error), + #[error("Request token error: {0}")] + RequestToken(String), + #[error("Store token error: {0}")] + StoreToken(#[from] TokenStoreError), +} diff --git a/src/blocks/calendar/auth.rs b/src/blocks/calendar/auth.rs new file mode 100644 index 0000000000..0877e03c32 --- /dev/null +++ b/src/blocks/calendar/auth.rs @@ -0,0 +1,295 @@ +use base64::Engine; +use oauth2::basic::{BasicClient, BasicTokenType}; +use oauth2::reqwest::async_http_client; +use oauth2::{ + AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields, + PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, Scope, StandardTokenResponse, + TokenResponse, TokenUrl, +}; +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION}; +use reqwest::Url; +use std::path::{Path, PathBuf}; +use tokio::fs::File; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; + +use super::CalendarError; + +pub enum Auth { + Unauthenticated, + Basic(Basic), + OAuth2(Box), +} + +impl Auth { + pub fn oauth2(flow: OAuth2Flow, token_store: TokenStore, scopes: Vec) -> Self { + Self::OAuth2(Box::new(OAuth2 { + flow, + token_store, + scopes, + })) + } + pub fn basic(username: String, password: String) -> Self { + Self::Basic(Basic { username, password }) + } + pub async fn headers(&mut self) -> HeaderMap { + match self { + Auth::Unauthenticated => HeaderMap::new(), + Auth::Basic(auth) => auth.headers().await, + Auth::OAuth2(auth) => auth.headers().await, + } + } + + pub async fn handle_error(&mut self, error: reqwest::Error) -> Result<(), CalendarError> { + match self { + Auth::Unauthenticated | Auth::Basic(_) => Err(CalendarError::Http(error)), + Auth::OAuth2(auth) => auth.handle_error(error).await, + } + } + + pub async fn authorize(&mut self) -> Result { + match self { + Auth::Unauthenticated | Auth::Basic(_) => Ok(Authorize::Completed), + Auth::OAuth2(auth) => Ok(Authorize::AskUser(auth.authorize().await?)), + } + } + pub async fn ask_user(&mut self, authorize_url: AuthorizeUrl) -> Result<(), CalendarError> { + match self { + Auth::Unauthenticated | Auth::Basic(_) => Ok(()), + Auth::OAuth2(auth) => auth.ask_user(authorize_url).await, + } + } +} + +pub struct Basic { + username: String, + password: String, +} + +impl Basic { + pub async fn headers(&mut self) -> HeaderMap { + let mut headers = HeaderMap::new(); + let header = + base64::prelude::BASE64_STANDARD.encode(format!("{}:{}", self.username, self.password)); + let mut header_value = HeaderValue::from_str(format!("Basic {header}").as_str()) + .expect("A valid basic header"); + header_value.set_sensitive(true); + headers.insert(AUTHORIZATION, header_value); + headers + } +} + +pub struct OAuth2 { + flow: OAuth2Flow, + token_store: TokenStore, + scopes: Vec, +} + +impl OAuth2 { + pub async fn headers(&mut self) -> HeaderMap { + let mut headers = HeaderMap::new(); + if let Some(token) = self.token_store.get().await { + let mut auth_value = + HeaderValue::from_str(format!("Bearer {}", token.access_token().secret()).as_str()) + .expect("A valid access token"); + auth_value.set_sensitive(true); + headers.insert(AUTHORIZATION, auth_value); + } + headers + } + + async fn handle_error(&mut self, error: reqwest::Error) -> Result<(), CalendarError> { + if let Some(status) = error.status() { + if status == 401 { + match self + .token_store + .get() + .await + .and_then(|t| t.refresh_token().cloned()) + { + Some(refresh_token) => { + let mut token = self.flow.refresh_token_exchange(&refresh_token).await?; + if token.refresh_token().is_none() { + token.set_refresh_token(Some(refresh_token)); + } + self.token_store.store(token).await?; + return Ok(()); + } + None => return Err(CalendarError::AuthRequired), + } + } + if status == 403 { + return Err(CalendarError::AuthRequired); + } + } + Err(CalendarError::Http(error)) + } + + async fn authorize(&mut self) -> Result { + Ok(self.flow.authorize_url(self.scopes.clone())) + } + + async fn ask_user(&mut self, authorize_url: AuthorizeUrl) -> Result<(), CalendarError> { + let token = self.flow.redirect(authorize_url).await?; + self.token_store.store(token).await?; + Ok(()) + } +} +pub struct OAuth2Flow { + client: BasicClient, + redirect_port: u16, +} + +impl OAuth2Flow { + pub fn new( + client_id: ClientId, + client_secret: ClientSecret, + auth_url: AuthUrl, + token_url: TokenUrl, + redirect_port: u16, + ) -> Self { + Self { + client: BasicClient::new(client_id, Some(client_secret), auth_url, Some(token_url)) + .set_redirect_uri( + RedirectUrl::new(format!("http://localhost:{redirect_port}").to_string()) + .expect("A valid redirect URL"), + ), + redirect_port, + } + } + + pub fn authorize_url(&self, scopes: Vec) -> AuthorizeUrl { + let (pkce_code_challenge, pkce_code_verifier) = PkceCodeChallenge::new_random_sha256(); + let (authorize_url, csrf_token) = self + .client + .authorize_url(CsrfToken::new_random) + .add_scopes(scopes) + .set_pkce_challenge(pkce_code_challenge.clone()) + .url(); + AuthorizeUrl { + pkce_code_verifier, + url: authorize_url, + csrf_token, + } + } + + pub async fn refresh_token_exchange( + &self, + token: &RefreshToken, + ) -> Result { + self.client + .exchange_refresh_token(token) + .request_async(async_http_client) + .await + .map_err(|e| CalendarError::RequestToken(e.to_string())) + } + + pub async fn redirect( + &self, + authorize_url: AuthorizeUrl, + ) -> Result { + let client = self.client.clone(); + let redirect_port = self.redirect_port; + let listener = TcpListener::bind(format!("127.0.0.1:{}", redirect_port)).await?; + let (mut stream, _) = listener.accept().await?; + let mut request_line = String::new(); + let mut reader = BufReader::new(&mut stream); + reader.read_line(&mut request_line).await?; + + let redirect_url = request_line + .split_whitespace() + .nth(1) + .ok_or(CalendarError::RequestToken("Invalid redirect url".into()))?; + let url = Url::parse(&("http://localhost".to_string() + redirect_url)) + .map_err(|e| CalendarError::RequestToken(e.to_string()))?; + + let (_, code_value) = + url.query_pairs() + .find(|(key, _)| key == "code") + .ok_or(CalendarError::RequestToken( + "code query param is missing".into(), + ))?; + let code = AuthorizationCode::new(code_value.into_owned()); + let (_, state_value) = url.query_pairs().find(|(key, _)| key == "state").ok_or( + CalendarError::RequestToken("state query param is missing".into()), + )?; + let state = CsrfToken::new(state_value.into_owned()); + if state.secret() != authorize_url.csrf_token.secret() { + return Err(CalendarError::RequestToken( + "Received state and csrf token are different".to_string(), + )); + } + + let message = "Now your i3status-rust calendar is authorized"; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}", + message.len(), + message + ); + stream.write_all(response.as_bytes()).await?; + + client + .exchange_code(code) + .set_pkce_verifier(authorize_url.pkce_code_verifier) + .request_async(async_http_client) + .await + .map_err(|e| CalendarError::RequestToken(e.to_string())) + } +} + +#[derive(Debug)] +pub enum Authorize { + Completed, + AskUser(AuthorizeUrl), +} + +#[derive(Debug)] +pub struct AuthorizeUrl { + pkce_code_verifier: PkceCodeVerifier, + pub url: Url, + csrf_token: CsrfToken, +} + +type OAuth2TokenResponse = StandardTokenResponse; + +#[derive(Debug)] +pub struct TokenStore { + path: PathBuf, + token: Option, +} + +impl TokenStore { + pub fn new(path: &Path) -> Self { + Self { + path: path.into(), + token: None, + } + } + + pub async fn store(&mut self, token: OAuth2TokenResponse) -> Result<(), TokenStoreError> { + let mut file = File::create(&self.path).await?; + let value = serde_json::to_string(&token)?; + file.write_all(value.as_bytes()).await?; + self.token = Some(token); + Ok(()) + } + + pub async fn get(&mut self) -> Option { + if self.token.is_none() { + if let Ok(mut file) = File::open(&self.path).await { + let mut content = vec![]; + file.read_to_end(&mut content).await.ok()?; + self.token = serde_json::from_slice(&content).ok(); + } + } + self.token.clone() + } +} + +#[derive(thiserror::Error, Debug)] +pub enum TokenStoreError { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Serde(#[from] serde_json::Error), +} diff --git a/src/blocks/calendar/caldav.rs b/src/blocks/calendar/caldav.rs new file mode 100644 index 0000000000..1a89b4a3be --- /dev/null +++ b/src/blocks/calendar/caldav.rs @@ -0,0 +1,373 @@ +use std::{str::FromStr, time::Duration, vec}; + +use chrono::{DateTime, Local, Utc}; +use icalendar::{Component, EventLike}; +use reqwest::{ + self, + header::{HeaderMap, HeaderValue, CONTENT_TYPE}, + ClientBuilder, Method, Url, +}; +use serde::Deserialize; + +use super::{ + auth::{Auth, Authorize}, + CalendarError, +}; + +#[derive(Clone, Debug)] +pub struct Event { + pub uid: Option, + pub summary: Option, + pub description: Option, + pub location: Option, + pub url: Option, + pub start_at: Option>, + pub end_at: Option>, +} + +#[derive(Deserialize, Debug)] +pub struct Calendar { + pub url: Url, + pub name: String, +} + +pub struct Client { + url: Url, + client: reqwest::Client, + auth: Auth, +} + +impl Client { + pub fn new(url: Url, auth: Auth) -> Self { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/xml")); + Self { + url, + client: ClientBuilder::new() + .timeout(Duration::from_secs(10)) + .default_headers(headers) + .build() + .expect("A valid http client"), + auth, + } + } + async fn propfind_request( + &mut self, + url: Url, + depth: usize, + body: String, + ) -> Result { + let request = self + .client + .request(Method::from_str("PROPFIND").expect("A valid method"), url) + .body(body.clone()) + .headers(self.auth.headers().await) + .header("Depth", depth) + .build() + .expect("A valid propfind request"); + self.call(request).await + } + + async fn report_request( + &mut self, + url: Url, + depth: usize, + body: String, + ) -> Result { + let request = self + .client + .request(Method::from_str("REPORT").expect("A valid method"), url) + .body(body) + .headers(self.auth.headers().await) + .header("Depth", depth) + .build() + .expect("A valid report request"); + self.call(request).await + } + + async fn call(&mut self, request: reqwest::Request) -> Result { + let mut retries = 0; + loop { + let result = self + .client + .execute(request.try_clone().expect("Request to be cloneable")) + .await?; + match result.error_for_status() { + Err(err) if retries == 0 => { + self.auth.handle_error(err).await?; + retries += 1; + } + Err(err) => return Err(CalendarError::Http(err)), + Ok(result) => return Ok(quick_xml::de::from_str(result.text().await?.as_str())?), + }; + } + } + + async fn user_principal_url(&mut self) -> Result { + let multi_status = self + .propfind_request(self.url.clone(), 1, CURRENT_USER_PRINCIPAL.into()) + .await?; + parse_href(multi_status, self.url.clone()) + } + + async fn home_set_url(&mut self, user_principal_url: Url) -> Result { + let multi_status = self + .propfind_request(user_principal_url, 0, CALENDAR_HOME_SET.into()) + .await?; + parse_href(multi_status, self.url.clone()) + } + + async fn calendars_query(&mut self, home_set_url: Url) -> Result, CalendarError> { + let multi_status = self + .propfind_request(home_set_url, 1, CALENDAR_REQUEST.into()) + .await?; + parse_calendars(multi_status, self.url.clone()) + } + + pub async fn calendars(&mut self) -> Result, CalendarError> { + let user_principal_url = self.user_principal_url().await?; + let home_set_url = self.home_set_url(user_principal_url).await?; + self.calendars_query(home_set_url).await + } + + pub async fn events( + &mut self, + calendar: &Calendar, + start: DateTime, + end: DateTime, + ) -> Result, CalendarError> { + let multi_status = self + .report_request(calendar.url.clone(), 1, calendar_events_request(start, end)) + .await?; + parse_events(multi_status) + } + + pub async fn authorize(&mut self) -> Result { + self.auth.authorize().await + } + + pub async fn ask_user(&mut self, authorize: Authorize) -> Result<(), CalendarError> { + match authorize { + Authorize::Completed => Ok(()), + Authorize::AskUser(authorize_url) => self.auth.ask_user(authorize_url).await, + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename = "multistatus")] +struct Multistatus { + #[serde(rename = "response", default)] + responses: Vec, +} + +#[derive(Debug, Deserialize)] +struct Response { + href: String, + #[serde(rename = "propstat", default)] + propstats: Vec, +} + +impl Response { + fn valid_props(self) -> Vec { + self.propstats + .into_iter() + .filter(|p| p.status.contains("200")) + .flat_map(|p| p.prop.values.into_iter()) + .collect() + } +} + +#[derive(Debug, Deserialize)] +struct Propstat { + status: String, + prop: Prop, +} + +#[derive(Debug, Deserialize)] +struct Prop { + #[serde(rename = "$value")] + pub values: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum PropValue { + CurrentUserPrincipal(HrefProperty), + CalendarHomeSet(HrefProperty), + SupportedCalendarComponentSet(SupportedCalendarComponentSet), + #[serde(rename = "displayname")] + DisplayName(String), + #[serde(rename = "resourcetype")] + ResourceType(ResourceTypes), + CalendarData(String), +} + +#[derive(Debug, Deserialize)] +pub struct HrefProperty { + href: String, +} + +#[derive(Debug, Deserialize)] +struct ResourceTypes { + #[serde(rename = "$value")] + pub values: Vec, +} + +impl ResourceTypes { + fn is_calendar(&self) -> bool { + self.values.contains(&ResourceType::Calendar) + } +} +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +enum ResourceType { + Calendar, + #[serde(other)] + Unsupported, +} + +#[derive(Debug, Deserialize)] +struct SupportedCalendarComponentSet { + comp: Option, +} +impl SupportedCalendarComponentSet { + fn supports_events(&self) -> bool { + self.comp.as_ref().is_some_and(|v| v.name == "VEVENT") + } +} + +#[derive(Debug, Deserialize)] +struct Comp { + #[serde(rename = "@name", default)] + name: String, +} + +fn parse_href(multi_status: Multistatus, base_url: Url) -> Result { + let props = multi_status + .responses + .into_iter() + .flat_map(|r| r.valid_props().into_iter()) + .next(); + match props.ok_or_else(|| CalendarError::Parsing("Property not found".into()))? { + PropValue::CurrentUserPrincipal(href) | PropValue::CalendarHomeSet(href) => base_url + .join(&href.href) + .map_err(|e| CalendarError::Parsing(e.to_string())), + _ => Err(CalendarError::Parsing("Invalid property".into())), + } +} + +fn parse_calendars( + multi_status: Multistatus, + base_url: Url, +) -> Result, CalendarError> { + let mut result = vec![]; + for response in multi_status.responses { + let mut is_calendar = false; + let mut supports_events = false; + let mut name = None; + let href = response.href.clone(); + for prop in response.valid_props() { + match prop { + PropValue::SupportedCalendarComponentSet(comp) => { + supports_events = comp.supports_events(); + } + PropValue::DisplayName(display_name) => name = Some(display_name), + PropValue::ResourceType(ty) => is_calendar = ty.is_calendar(), + _ => {} + } + } + if is_calendar && supports_events { + if let Some(name) = name { + result.push(Calendar { + name, + url: base_url + .join(&href) + .map_err(|_| CalendarError::Parsing("Malformed calendar url".into()))?, + }); + } + } + } + Ok(result) +} + +fn parse_events(multi_status: Multistatus) -> Result, CalendarError> { + let mut result = vec![]; + for response in multi_status.responses { + for prop in response.valid_props() { + if let PropValue::CalendarData(data) = prop { + let calendar = + icalendar::Calendar::from_str(&data).map_err(CalendarError::Parsing)?; + for component in calendar.components { + if let icalendar::CalendarComponent::Event(event) = component { + let start_at = event.get_start().and_then(|d| match d { + icalendar::DatePerhapsTime::DateTime(dt) => dt.try_into_utc(), + icalendar::DatePerhapsTime::Date(d) => d + .and_hms_opt(0, 0, 0) + .and_then(|d| d.and_local_timezone(Local).earliest()) + .map(|d| d.to_utc()), + }); + let end_at = event.get_end().and_then(|d| match d { + icalendar::DatePerhapsTime::DateTime(dt) => dt.try_into_utc(), + icalendar::DatePerhapsTime::Date(d) => d + .and_hms_opt(23, 59, 59) + .and_then(|d| d.and_local_timezone(Local).earliest()) + .map(|d| d.to_utc()), + }); + result.push(Event { + uid: event.get_uid().map(Into::into), + summary: event.get_summary().map(Into::into), + description: event.get_description().map(Into::into), + location: event.get_location().map(Into::into), + url: event.get_url().map(Into::into), + start_at, + end_at, + }); + } + } + } + } + } + Ok(result) +} + +static CURRENT_USER_PRINCIPAL: &str = r#" + + + + "#; + +static CALENDAR_HOME_SET: &str = r#" + + + + "#; + +static CALENDAR_REQUEST: &str = r#" + + + + + + "#; + +pub fn calendar_events_request(start: DateTime, end: DateTime) -> String { + const DATE_FORMAT: &str = "%Y%m%dT%H%M%SZ"; + let start = start.format(DATE_FORMAT); + let end = end.format(DATE_FORMAT); + format!( + r#" + + + + + + + + + + + + "# + ) +}