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#"
+
+
+
+
+
+
+
+
+
+
+
+ "#
+ )
+}