i3status_rs/blocks/
calendar.rs

1//! Calendar
2//!
3//! This block displays upcoming calendar events retrieved from a CalDav ICalendar server.
4//!
5//! # Configuration
6//!
7//! Key | Values | Default
8//! ----|--------|--------
9//! `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. | <code>\" $icon $start.datetime(f:'%a %H:%M') $summary \"</code>
10//! `ongoing_event_format` | A string to customize the output of this block when an event is ongoing. | <code>\" $icon $summary (ends at $end.datetime(f:'%H:%M')) \"</code>
11//! `no_events_format` | A string to customize the output of this block when there are no events | <code>\" $icon \"</code>
12//! `redirect_format` | A string to customize the output of this block when the authorization is asked | <code>\" $icon Check your web browser \"</code>
13//! `fetch_interval` | Fetch events interval in seconds | `60`
14//! `alternate_events_interval` | Alternate overlapping events interval in seconds | `10`
15//! `events_within_hours` | Number of hours to look for events in the future | `48`
16//! `source` | Array of sources to pull calendars from | `[]`
17//! `warning_threshold` | Warning threshold in seconds for the upcoming event | `300`
18//! `browser_cmd` | Command to open event details in a browser. The block passes the HTML link as an argument | `"xdg-open"`
19//!
20//! # Source Configuration
21//!
22//! Key | Values | Default
23//! ----|--------|--------
24//! `url` | CalDav calendar server URL | N/A
25//! `auth` | Authentication configuration (unauthenticated, basic, or oauth2) | `unauthenticated`
26//! `calendars` | List of calendar names to monitor. If empty, all calendars will be fetched. | `[]`
27//!
28//! Note: Currently only one source is supported
29//!
30//! Action          | Description                               | Default button
31//! ----------------|-------------------------------------------|---------------
32//! `open_link` | Opens the HTML link of the event | Left
33//!
34//! # Examples
35//!
36//! ## Unauthenticated
37//!
38//! ```toml
39//! [[block]]
40//! block = "calendar"
41//! next_event_format = " $icon $start.datetime(f:'%a %H:%M') $summary "
42//! ongoing_event_format = " $icon $summary (ends at $end.datetime(f:'%H:%M')) "
43//! no_events_format = " $icon no events "
44//! fetch_interval = 30
45//! alternate_events_interval = 10
46//! events_within_hours = 48
47//! warning_threshold = 600
48//! browser_cmd = "firefox"
49//! [[block.source]]
50//! url = "https://caldav.example.com/calendar/"
51//! calendars = ["user/calendar"]
52//! [block.source.auth]
53//! type = "unauthenticated"
54//! ```
55//!
56//! ## Basic Authentication
57//!
58//! ```toml
59//! [[block]]
60//! block = "calendar"
61//! next_event_format = " $icon $start.datetime(f:'%a %H:%M') $summary "
62//! ongoing_event_format = " $icon $summary (ends at $end.datetime(f:'%H:%M')) "
63//! no_events_format = " $icon no events "
64//! fetch_interval = 30
65//! alternate_events_interval = 10
66//! events_within_hours = 48
67//! warning_threshold = 600
68//! browser_cmd = "firefox"
69//! [[block.source]]
70//! url = "https://caldav.example.com/calendar/"
71//! calendars = [ "Holidays" ]
72//! [block.source.auth]
73//! type = "basic"
74//! username = "your_username"
75//! password = "your_password"
76//! ```
77//!
78//! Note: You can also configure the `username` and `password` in a separate TOML file.
79//!
80//! `~/.config/i3status-rust/example_credentials.toml`
81//! ```toml
82//! username = "my-username"
83//! password = "my-password"
84//! ```
85//!
86//! Source auth configuration with `credentials_path`:
87//!
88//! ```toml
89//! [block.source.auth]
90//! type = "basic"
91//! credentials_path = "~/.config/i3status-rust/example_credentials.toml"
92//! ```
93//!
94//! ## OAuth2 Authentication (Google Calendar)
95//!
96//! To access the CalDav API of Google, follow these steps to enable the API and obtain the `client_id` and `client_secret`:
97//! 1. **Go to the Google Cloud Console**: Navigate to the [Google Cloud Console](https://console.cloud.google.com/).
98//! 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".
99//! 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".
100//! 4. **Set Up OAuth Consent Screen**: Go to "APIs & Services" > "OAuth consent screen". Fill out the required information and save.
101//! 5. **Create Credentials**:
102//!    - Navigate to "APIs & Services" > "Credentials".
103//!    - Click "Create Credentials" and select "OAuth 2.0 Client IDs".
104//!    - Configure the consent screen if you haven't already.
105//!    - Set the application type to "Web application".
106//!    - Add your authorized redirect URIs. For example, `http://localhost:8080`.
107//!    - Click "Create" and note down the `client_id` and `client_secret`.
108//! 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.
109//!
110//! ```toml
111//! [[block]]
112//! block = "calendar"
113//! next_event_format = " $icon $start.datetime(f:'%a %H:%M') $summary "
114//! ongoing_event_format = " $icon $summary (ends at $end.datetime(f:'%H:%M')) "
115//! no_events_format = " $icon no events "
116//! fetch_interval = 30
117//! alternate_events_interval = 10
118//! events_within_hours = 48
119//! warning_threshold = 600
120//! browser_cmd = "firefox"
121//! [[block.source]]
122//! url = "https://apidata.googleusercontent.com/caldav/v2/"
123//! calendars = ["primary"]
124//! [block.source.auth]
125//! type = "oauth2"
126//! client_id = "your_client_id"
127//! client_secret = "your_client_secret"
128//! auth_url = "https://accounts.google.com/o/oauth2/auth"
129//! token_url = "https://oauth2.googleapis.com/token"
130//! auth_token = "~/.config/i3status-rust/calendar.auth_token"
131//! redirect_port = 8080
132//! scopes = ["https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/calendar.events"]
133//! ```
134//!
135//! Note: You can also configure the `client_id` and `client_secret` in a separate TOML file.
136//!
137//! `~/.config/i3status-rust/google_credentials.toml`
138//! ```toml
139//! client_id = "my-client_id"
140//! client_secret = "my-client_secret"
141//! ```
142//!
143//! Source auth configuration with `credentials_path`:
144//!
145//! ```toml
146//! [block.source.auth]
147//! type = "oauth2"
148//! credentials_path = "~/.config/i3status-rust/google_credentials.toml"
149//! auth_url = "https://accounts.google.com/o/oauth2/auth"
150//! token_url = "https://oauth2.googleapis.com/token"
151//! auth_token = "~/.config/i3status-rust/calendar.auth_token"
152//! redirect_port = 8080
153//! scopes = ["https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/calendar.events"]
154//! ```
155//!
156//! # Format Configuration
157//!
158//! The format configuration is a string that can include placeholders to be replaced with dynamic content.
159//! Placeholders can be:
160//! - `$summary`: Summary of the event
161//! - `$description`: Description of the event
162//! - `$url`: Url of the event
163//! - `$location`: Location of the event
164//! - `$start`: Start time of the event
165//! - `$end`: End time of the event
166//!
167//! # Icons Used
168//! - `calendar`
169
170use chrono::{Duration, Local, Utc};
171use oauth2::{AuthUrl, ClientId, ClientSecret, Scope, TokenUrl};
172use reqwest::Url;
173
174use crate::util;
175use crate::{subprocess::spawn_process, util::has_command};
176
177mod auth;
178mod caldav;
179
180use self::auth::{Authorize, AuthorizeUrl, OAuth2Flow, TokenStore, TokenStoreError};
181use self::caldav::Event;
182
183use super::prelude::*;
184
185use std::path::Path;
186use std::sync::Arc;
187
188use caldav::Client;
189
190#[derive(Deserialize, Debug, SmartDefault, Clone)]
191#[serde(deny_unknown_fields, default)]
192pub struct BasicCredentials {
193    pub username: Option<String>,
194    pub password: Option<String>,
195}
196
197#[derive(Deserialize, Debug, Clone)]
198pub struct BasicAuthConfig {
199    #[serde(flatten)]
200    pub credentials: BasicCredentials,
201    pub credentials_path: Option<ShellString>,
202}
203
204#[derive(Deserialize, Debug, SmartDefault, Clone)]
205#[serde(deny_unknown_fields, default)]
206pub struct OAuth2Credentials {
207    pub client_id: Option<String>,
208    pub client_secret: Option<String>,
209}
210
211#[derive(Deserialize, Debug, SmartDefault, Clone)]
212#[serde(deny_unknown_fields, default)]
213pub struct OAuth2Config {
214    #[serde(flatten)]
215    pub credentials: OAuth2Credentials,
216    pub credentials_path: Option<ShellString>,
217    pub auth_url: String,
218    pub token_url: String,
219    #[default("~/.config/i3status-rust/calendar.auth_token".into())]
220    pub auth_token: ShellString,
221    #[default(8080)]
222    pub redirect_port: u16,
223    pub scopes: Vec<Scope>,
224}
225
226#[derive(Deserialize, Default, Debug, Clone)]
227#[serde(tag = "type", rename_all = "lowercase")]
228pub enum AuthConfig {
229    #[default]
230    Unauthenticated,
231    Basic(BasicAuthConfig),
232    OAuth2(OAuth2Config),
233}
234
235#[derive(Deserialize, Debug, SmartDefault, Clone)]
236#[serde(deny_unknown_fields, default)]
237pub struct SourceConfig {
238    pub url: String,
239    pub auth: AuthConfig,
240    pub calendars: Vec<String>,
241}
242
243#[derive(Deserialize, Debug, SmartDefault)]
244#[serde(deny_unknown_fields, default)]
245pub struct Config {
246    pub next_event_format: FormatConfig,
247    pub ongoing_event_format: FormatConfig,
248    pub no_events_format: FormatConfig,
249    pub redirect_format: FormatConfig,
250    #[default(60.into())]
251    pub fetch_interval: Seconds,
252    #[default(10.into())]
253    pub alternate_events_interval: Seconds,
254    #[default(48)]
255    pub events_within_hours: u32,
256    pub source: Vec<SourceConfig>,
257    #[default(300)]
258    pub warning_threshold: u32,
259    #[default("xdg-open".into())]
260    pub browser_cmd: ShellString,
261}
262
263enum WidgetStatus {
264    AlternateEvents,
265    FetchSources,
266}
267
268pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
269    let next_event_format = config
270        .next_event_format
271        .with_default(" $icon $start.datetime(f:'%a %H:%M') $summary ")?;
272    let ongoing_event_format = config
273        .ongoing_event_format
274        .with_default(" $icon $summary (ends at $end.datetime(f:'%H:%M')) ")?;
275    let no_events_format = config.no_events_format.with_default(" $icon ")?;
276    let redirect_format = config
277        .redirect_format
278        .with_default(" $icon Check your web browser ")?;
279
280    api.set_default_actions(&[(MouseButton::Left, None, "open_link")])?;
281
282    let source_config = match config.source.len() {
283        0 => return Err(Error::new("A calendar source must be supplied")),
284        1 => config
285            .source
286            .first()
287            .expect("There must be a first entry since the length is 1"),
288        _ => {
289            return Err(Error::new(
290                "Currently only one calendar source is supported",
291            ));
292        }
293    };
294
295    let warning_threshold = Duration::try_seconds(config.warning_threshold.into())
296        .error("Invalid warning threshold configuration")?;
297
298    let mut source = Source::new(source_config.clone()).await?;
299
300    let mut timer = config.fetch_interval.timer();
301
302    let mut alternate_events_timer = config.alternate_events_interval.timer();
303
304    let mut actions = api.get_actions()?;
305
306    let events_within = Duration::try_hours(config.events_within_hours.into())
307        .error("Invalid events within hours configuration")?;
308
309    let mut widget_status = WidgetStatus::FetchSources;
310
311    let mut next_events = OverlappingEvents::default();
312
313    loop {
314        let mut widget = Widget::new().with_format(no_events_format.clone());
315        widget.set_values(map! {
316            "icon" => Value::icon("calendar"),
317        });
318
319        if matches!(widget_status, WidgetStatus::FetchSources) {
320            for retries in 0..=1 {
321                match source.get_next_events(events_within).await {
322                    Ok(events) => {
323                        next_events.refresh(events);
324                        break;
325                    }
326                    Err(err) => match err {
327                        CalendarError::AuthRequired => {
328                            let authorization = source
329                                .client
330                                .authorize()
331                                .await
332                                .error("Authorization failed")?;
333                            match &authorization {
334                                Authorize::AskUser(AuthorizeUrl { url, .. }) if retries == 0 => {
335                                    widget.set_format(redirect_format.clone());
336                                    api.set_widget(widget.clone())?;
337                                    open_browser(config, url).await?;
338                                    source
339                                        .client
340                                        .ask_user(authorization)
341                                        .await
342                                        .error("Ask user failed")?;
343                                }
344                                _ => {
345                                    return Err(Error::new(
346                                        "Authorization failed. Check your configurations",
347                                    ));
348                                }
349                            }
350                        }
351                        e => {
352                            return Err(Error {
353                                message: None,
354                                cause: Some(Arc::new(e)),
355                            });
356                        }
357                    },
358                };
359            }
360        }
361
362        if let Some(event) = next_events.current().cloned() {
363            if let (Some(start_date), Some(end_date)) = (event.start_at, event.end_at) {
364                let warn_datetime = start_date - warning_threshold;
365                if warn_datetime < Utc::now() && Utc::now() < start_date {
366                    widget.state = State::Warning;
367                }
368                if start_date < Utc::now() && Utc::now() < end_date {
369                    widget.set_format(ongoing_event_format.clone());
370                } else {
371                    widget.set_format(next_event_format.clone());
372                }
373                widget.set_values(map! {
374                  "icon" => Value::icon("calendar"),
375                   [if let Some(summary) = event.summary] "summary" => Value::text(summary),
376                   [if let Some(description) = event.description] "description" => Value::text(description),
377                   [if let Some(location) = event.location] "location" => Value::text(location),
378                   [if let Some(url) = event.url] "url" => Value::text(url),
379                   "start" => Value::datetime(start_date, None),
380                   "end" => Value::datetime(end_date, None),
381                });
382            }
383        }
384
385        api.set_widget(widget)?;
386        loop {
387            select! {
388                _ = timer.tick() => {
389                  widget_status = WidgetStatus::FetchSources;
390                  break
391                }
392                _ = alternate_events_timer.tick() => {
393                  next_events.cycle_warning_or_ongoing(warning_threshold);
394                  widget_status = WidgetStatus::AlternateEvents;
395                  break
396                }
397                _ = api.wait_for_update_request() => break,
398                Some(action) = actions.recv() => match action.as_ref() {
399                      "open_link" => {
400                          if let Some(Event { url: Some(url), .. }) = next_events.current(){
401                              if let Ok(url) = Url::parse(url) {
402                                  open_browser(config, &url).await?;
403                              }
404                          }
405                      }
406                      _ => ()
407                }
408            }
409        }
410    }
411}
412
413struct Source {
414    pub client: caldav::Client,
415    pub config: SourceConfig,
416}
417
418impl Source {
419    async fn new(config: SourceConfig) -> Result<Self> {
420        let auth = match &config.auth {
421            AuthConfig::Unauthenticated => auth::Auth::Unauthenticated,
422            AuthConfig::Basic(BasicAuthConfig {
423                credentials,
424                credentials_path,
425            }) => {
426                let credentials = if let Some(path) = credentials_path {
427                    util::deserialize_toml_file(path.expand()?.to_string())
428                        .error("Failed to read basic credentials file")?
429                } else {
430                    credentials.clone()
431                };
432                let BasicCredentials {
433                    username: Some(username),
434                    password: Some(password),
435                } = credentials
436                else {
437                    return Err(Error::new("Basic credentials are not configured"));
438                };
439                auth::Auth::basic(username, password)
440            }
441            AuthConfig::OAuth2(oauth2) => {
442                let credentials = if let Some(path) = &oauth2.credentials_path {
443                    util::deserialize_toml_file(path.expand()?.to_string())
444                        .error("Failed to read oauth2 credentials file")?
445                } else {
446                    oauth2.credentials.clone()
447                };
448                let OAuth2Credentials {
449                    client_id: Some(client_id),
450                    client_secret: Some(client_secret),
451                } = credentials
452                else {
453                    return Err(Error::new("Oauth2 credentials are not configured"));
454                };
455                let auth_url =
456                    AuthUrl::new(oauth2.auth_url.clone()).error("Invalid authorization url")?;
457                let token_url =
458                    TokenUrl::new(oauth2.token_url.clone()).error("Invalid token url")?;
459
460                let flow = OAuth2Flow::new(
461                    ClientId::new(client_id),
462                    ClientSecret::new(client_secret),
463                    auth_url,
464                    token_url,
465                    oauth2.redirect_port,
466                );
467                let token_store =
468                    TokenStore::new(Path::new(&oauth2.auth_token.expand()?.to_string()));
469                auth::Auth::oauth2(flow, token_store, oauth2.scopes.clone())
470            }
471        };
472        Ok(Self {
473            client: Client::new(
474                Url::parse(&config.url).error("Invalid CalDav server url")?,
475                auth,
476            ),
477            config,
478        })
479    }
480
481    async fn get_next_events(
482        &mut self,
483        within: Duration,
484    ) -> Result<OverlappingEvents, CalendarError> {
485        let calendars: Vec<_> = self
486            .client
487            .calendars()
488            .await?
489            .into_iter()
490            .filter(|c| self.config.calendars.is_empty() || self.config.calendars.contains(&c.name))
491            .collect();
492        let mut events: Vec<Event> = vec![];
493        for calendar in calendars {
494            let calendar_events: Vec<_> = self
495                .client
496                .events(
497                    &calendar,
498                    Local::now()
499                        .date_naive()
500                        .and_hms_opt(0, 0, 0)
501                        .expect("A valid time")
502                        .and_local_timezone(Local)
503                        .earliest()
504                        .expect("A valid datetime")
505                        .to_utc(),
506                    Utc::now() + within,
507                )
508                .await?
509                .into_iter()
510                .filter(|e| {
511                    let not_started = e.start_at.is_some_and(|d| d > Utc::now());
512                    let is_ongoing = e.start_at.is_some_and(|d| d < Utc::now())
513                        && e.end_at.is_some_and(|d| d > Utc::now());
514                    not_started || is_ongoing
515                })
516                .collect();
517            events.extend(calendar_events);
518        }
519
520        events.sort_by_key(|e| e.start_at);
521        let Some(next_event) = events.first().cloned() else {
522            return Ok(OverlappingEvents::default());
523        };
524        let overlapping_events = events
525            .into_iter()
526            .take_while(|e| e.start_at <= next_event.end_at)
527            .collect();
528        Ok(OverlappingEvents::new(overlapping_events))
529    }
530}
531
532#[derive(Default)]
533struct OverlappingEvents {
534    current: Option<Event>,
535    events: Vec<Event>,
536}
537
538impl OverlappingEvents {
539    fn new(events: Vec<Event>) -> Self {
540        Self {
541            current: events.first().cloned(),
542            events,
543        }
544    }
545
546    fn refresh(&mut self, other: OverlappingEvents) {
547        if self.current.is_none() {
548            self.current = other.events.first().cloned();
549        }
550        self.events = other.events;
551    }
552
553    fn current(&self) -> Option<&Event> {
554        self.current.as_ref()
555    }
556
557    fn cycle_warning_or_ongoing(&mut self, warning_threshold: Duration) {
558        self.current = if let Some(current) = &self.current {
559            if self.events.iter().any(|e| e.uid == current.uid) {
560                let mut iter = self
561                    .events
562                    .iter()
563                    .cycle()
564                    .skip_while(|e| e.uid != current.uid);
565                iter.next();
566                iter.find(|e| {
567                    let is_ongoing = e.start_at.is_some_and(|d| d < Utc::now())
568                        && e.end_at.is_some_and(|d| d > Utc::now());
569                    let is_warning = e
570                        .start_at
571                        .is_some_and(|d| d - warning_threshold < Utc::now() && Utc::now() < d);
572                    e.uid == current.uid || is_warning || is_ongoing
573                })
574                .cloned()
575            } else {
576                self.events.first().cloned()
577            }
578        } else {
579            self.events.first().cloned()
580        };
581    }
582}
583
584async fn open_browser(config: &Config, url: &Url) -> Result<()> {
585    let cmd = config.browser_cmd.expand()?;
586    has_command(&cmd)
587        .await
588        .or_error(|| "Browser command not found")?;
589    spawn_process(&cmd, &[url.as_ref()]).error("Open browser failed")
590}
591
592#[derive(thiserror::Error, Debug)]
593pub enum CalendarError {
594    #[error(transparent)]
595    Http(#[from] reqwest::Error),
596    #[error(transparent)]
597    Deserialize(#[from] quick_xml::de::DeError),
598    #[error("Parsing error: {0}")]
599    Parsing(String),
600    #[error("Auth required")]
601    AuthRequired,
602    #[error(transparent)]
603    Io(#[from] std::io::Error),
604    #[error(transparent)]
605    Serialize(#[from] serde_json::Error),
606    #[error("Request token error: {0}")]
607    RequestToken(String),
608    #[error("Store token error: {0}")]
609    StoreToken(#[from] TokenStoreError),
610}