i3status-rust icon indicating copy to clipboard operation
i3status-rust copied to clipboard

Add Calendar block

Open luca-iachini opened this issue 1 year ago • 16 comments

This PR adds a Calendar block that displays upcoming events.

luca-iachini avatar May 16 '24 17:05 luca-iachini

I'd suggest making this a calendar block and letting google be one of several providers, or perhaps use CalDAV by default since most calendars (including Google) support that.

bim9262 avatar May 17 '24 00:05 bim9262

Hey @bim9262, thanks for the suggestion. I did not know the CalDav protocol. I'll try to push an implementation for that.

luca-iachini avatar May 19 '24 08:05 luca-iachini

It would be nice to be able to leverage https://crates.io/crates/caldav-utils, which seems to be the most used async caldav library, blut it's missing oauth.

That would be nice. Unfortunately, I did not find Rust clients that support OAuth2 or have a way to intercept HTTP errors in a usable way.

luca-iachini avatar May 26 '24 07:05 luca-iachini

There's a lot of unwraps that should be handled too.

Ah yep, I forgot to properly handle those cases :sweat_smile: .

BTW good work on this, your contribution is appreciated :)

Thanks :smile:

luca-iachini avatar May 27 '24 09:05 luca-iachini

expect is not much better than unwrap, it still panics. Please use error.

MaxVerevkin avatar May 28 '24 08:05 MaxVerevkin

expect is not much better than unwrap, it still panics. Please use error.

I know both panic. I changed to expect the unwraps that cannot happen by design. Such as a url parsing unwrap from a hard-code string, HTTP method from a static string, .... Maybe, there is some that should be a proper error instead. Please, can you point those out?

luca-iachini avatar May 28 '24 08:05 luca-iachini

Can you have more than one calendar source in the same block? That would be my specific use case.

cfsmp3 avatar Jun 01 '24 05:06 cfsmp3

That should be doable. @luca-iachini if you want an example of how to do that check out the privacy block's drivers and see how the drivers are polled together

bim9262 avatar Jun 01 '24 16:06 bim9262

Can you have more than one calendar source in the same block? That would be my specific use case.

Hey @cfsmp3, can you expand a bit on your use case? Why are you looking to have events from two distinct servers on the same block? Why using two calendar blocks doesn't work for you?

That should be doable. @luca-iachini if you want an example of how to do that, check out the privacy block's drivers and see how the drivers are polled together.

@bim9262 It is probably doable, but there are caveats to consider in order to make this work properly and avoid overcomplicating the solution:

  • How do we display conflicting events between the two sources? (alternating between sources, give priorities, ...)
  • How do you handle errors from both clients effectively?
  • How do you manage the authentication flows?
  • Allow multiple sources or just 2?

using two separate blocks is the easiest way to go.

IMHO, I would commit to close this PR and provide an initial solution and then iterate to improve it. WDYT?

BTW, I would like to work on displaying conflicting events in an effective way, such as alternating events that are scheduled in overlapping periods, rather than supporting multiple sources in the same block.

luca-iachini avatar Jun 01 '24 17:06 luca-iachini

I think the logic for conflicts for one or more calendars would be the same. You could have overlapping events on a single calendar too.

You would have auth per calendar. The reson to do it now, add multiple calendars from the get go, is that it means users won't have to change their configs when multi calendar support is added.

The try_join_all macro will return an exception if any of the calendars fail to load.

bim9262 avatar Jun 01 '24 17:06 bim9262

@MaxVerevkin what are your thoughts on the calendar feature flag? I think that it's probably not required since the dependencies are pretty light and don't require system libraries.

bim9262 avatar Jun 01 '24 17:06 bim9262

I think the logic for conflicts for one or more calendars would be the same. You could have overlapping events on a single calendar too. You would have auth per calendar. The reson to do it now, add multiple calendars from the get go, is that it means users won't have to change their configs when multi calendar support is added. The try_join_all macro will return an exception if any of the calendars fail to load.

@bim9262 Yes, I know how to do it. My questions were not for technical help but to highlight that adding more sources requires considering additional aspects, which increases complexity. If we keep adding features, we won't reach a conclusion. In my opinion, we should agree on a minimum viable product that is stable and meets most users' needs. Since I can only work on this in my free time, my focus is to create a stable solution and push to close this PR. This will allow you and the community to take over and add any features you like. What is currently implemented meets my needs. I can continue to work on it but at my own pace. Since two blocks or sync the external calendar in one of the two server is a fair solution, I don't see a point in supporting multiple sources from the start.

luca-iachini avatar Jun 01 '24 20:06 luca-iachini

This should allow us to keep the same config for when multiple source are supported, but not actually implement how multiple sources are handled.

diff --git a/src/blocks/calendar.rs b/src/blocks/calendar.rs
index 5f8c3243e..250454549 100644
--- a/src/blocks/calendar.rs
+++ b/src/blocks/calendar.rs
@@ -11,13 +11,21 @@
 //! `no_events_format` | A string to customize the output of this block when there are no events | <code>\" $icon \"</code>
 //! `redirect_format` | A string to customize the output of this block when the authorization is asked | <code>\" $icon Check your web browser \"</code>
 //! `interval` | Update interval in seconds | `60`
-//! `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. | `[]`
 //! `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
@@ -33,12 +41,13 @@
 //! ongoing_event_format = " $icon $summary (ends at $end.datetime(f:'%H:%M')) "
 //! no_events_format = " $icon no events "
 //! interval = 30
-//! url = "https://caldav.example.com/calendar/"
-//! calendars = ["user/calendar"]
 //! events_within_hours = 48
 //! warning_threshold = 600
 //! browser_cmd = "firefox"
-//! [block.auth]
+//! [[block.source]]
+//! url = "https://caldav.example.com/calendar/"
+//! calendars = ["user/calendar"]
+//! [block.source.auth]
 //! type = "unauthenticated"
 //! ```
 //!
@@ -51,12 +60,13 @@
 //! ongoing_event_format = " $icon $summary (ends at $end.datetime(f:'%H:%M')) "
 //! no_events_format = " $icon no events "
 //! interval = 30
-//! url = "https://caldav.example.com/calendar/"
-//! calendars = [ "Holidays" ]
 //! events_within_hours = 48
 //! warning_threshold = 600
 //! browser_cmd = "firefox"
-//! [block.auth]
+//! [[block.source]]
+//! url = "https://caldav.example.com/calendar/"
+//! calendars = [ "Holidays" ]
+//! [block.source.auth]
 //! type = "basic"
 //! username = "your_username"
 //! password = "your_password"
@@ -86,12 +96,13 @@
 //! ongoing_event_format = " $icon $summary (ends at $end.datetime(f:'%H:%M')) "
 //! no_events_format = " $icon no events "
 //! interval = 30
-//! url = "https://apidata.googleusercontent.com/caldav/v2/"
-//! calendars = ["primary"]
 //! events_within_hours = 48
 //! warning_threshold = 600
 //! browser_cmd = "firefox"
-//! [block.auth]
+//! [[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"
@@ -165,6 +176,14 @@ pub enum AuthConfig {
     OAuth2(OAuth2Config),
 }
 
+#[derive(Deserialize, Debug, SmartDefault)]
+#[serde(deny_unknown_fields, default)]
+pub struct Source {
+    pub url: String,
+    pub auth: AuthConfig,
+    pub calendars: Vec<String>,
+}
+
 #[derive(Deserialize, Debug, SmartDefault)]
 #[serde(deny_unknown_fields, default)]
 pub struct Config {
@@ -174,11 +193,9 @@ pub struct Config {
     pub redirect_format: FormatConfig,
     #[default(60.into())]
     pub interval: Seconds,
-    pub url: String,
-    pub auth: AuthConfig,
-    pub calendars: Vec<String>,
     #[default(48)]
     pub events_within_hours: u32,
+    pub source: Vec<Source>,
     #[default(300)]
     pub warning_threshold: u32,
     #[default("xdg-open".into())]
@@ -199,7 +216,20 @@ pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
 
     api.set_default_actions(&[(MouseButton::Left, None, "open_link")])?;
 
-    let mut client = caldav_client(config).await?;
+    let source = 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 mut client = caldav_client(source).await?;
 
     let mut timer = config.interval.timer();
 
@@ -216,7 +246,7 @@ pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
 
         let mut next_event = None;
         for retries in 0..=1 {
-            let next_event_result = get_next_event(config, &mut client, events_within).await;
+            let next_event_result = get_next_event(source, &mut client, events_within).await;
             match next_event_result {
                 Ok(event) => {
                     next_event = event;
@@ -297,8 +327,8 @@ pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
     }
 }
 
-async fn caldav_client(config: &Config) -> Result<caldav::CalDavClient> {
-    let auth = match &config.auth {
+async fn caldav_client(source: &Source) -> Result<caldav::CalDavClient> {
+    let auth = match &source.auth {
         AuthConfig::Unauthenticated => auth::Auth::Unauthenticated,
         AuthConfig::Basic(BasicAuthConfig { username, password }) => {
             let username = username
@@ -338,13 +368,13 @@ async fn caldav_client(config: &Config) -> Result<caldav::CalDavClient> {
         }
     };
     Ok(CalDavClient::new(
-        Url::parse(&config.url).error("Invalid CalDav server url")?,
+        Url::parse(&source.url).error("Invalid CalDav server url")?,
         auth,
     ))
 }
 
 async fn get_next_event(
-    config: &Config,
+    source: &Source,
     client: &mut CalDavClient,
     within: Duration,
 ) -> Result<Option<caldav::Event>, CalendarError> {
@@ -352,7 +382,7 @@ async fn get_next_event(
         .calendars()
         .await?
         .into_iter()
-        .filter(|c| config.calendars.is_empty() || config.calendars.contains(&c.name))
+        .filter(|c| source.calendars.is_empty() || source.calendars.contains(&c.name))
         .collect();
     let mut events: Vec<Event> = vec![];
     for calendar in calendars {

bim9262 avatar Jun 01 '24 23:06 bim9262

@MaxVerevkin what are your thoughts on the calendar feature flag? I think that it's probably not required since the dependencies are pretty light and don't require system libraries.

If there are no runtime dependencies, and if the binary size is not affected significantly (unlike icu_calendar), then lets not put it behind a feature flag. (We also may reconsider if maildir has to be behind a feature flag too).

MaxVerevkin avatar Jun 04 '24 11:06 MaxVerevkin

Hey @cfsmp3, can you expand a bit on your use case? Why are you looking to have events from two distinct servers on the same block? Why using two calendar blocks doesn't work for you?

I have more than one Google account, and I'd like to see the next event considering all calendars (not separately for each calendar).

cfsmp3 avatar Jun 04 '24 12:06 cfsmp3

@bim9262 I couldn't replicate the bug you found regarding upcoming events. However, I did update the icalendar-rs dependency to fix a parser bug. @MaxVerevkin I've also removed the feature flag calendar. Let me know if this works for you.

luca-iachini avatar Jul 09 '24 08:07 luca-iachini

Looks like Cargo.lock didn't get updated to reflect once_cell being removed.

bim9262 avatar Oct 19 '24 19:10 bim9262

Thanks @luca-iachini !!

bim9262 avatar Oct 19 '24 21:10 bim9262