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.to_owned()).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            && let Some(start_date) = event.start_at
364            && let Some(end_date) = event.end_at
365        {
366            let warn_datetime = start_date - warning_threshold;
367            if warn_datetime < Utc::now() && Utc::now() < start_date {
368                widget.state = State::Warning;
369            }
370            if start_date < Utc::now() && Utc::now() < end_date {
371                widget.set_format(ongoing_event_format.clone());
372            } else {
373                widget.set_format(next_event_format.clone());
374            }
375            widget.set_values(map! {
376                  "icon" => Value::icon("calendar"),
377                   [if let Some(summary) = event.summary] "summary" => Value::text(summary),
378                   [if let Some(description) = event.description] "description" => Value::text(description),
379                   [if let Some(location) = event.location] "location" => Value::text(location),
380                   [if let Some(url) = event.url] "url" => Value::text(url),
381                   "start" => Value::datetime(start_date, None),
382                   "end" => Value::datetime(end_date, None),
383                });
384        }
385
386        api.set_widget(widget)?;
387        loop {
388            select! {
389                _ = timer.tick() => {
390                  widget_status = WidgetStatus::FetchSources;
391                  break
392                }
393                _ = alternate_events_timer.tick() => {
394                  next_events.cycle_warning_or_ongoing(warning_threshold);
395                  widget_status = WidgetStatus::AlternateEvents;
396                  break
397                }
398                _ = api.wait_for_update_request() => break,
399                Some(action) = actions.recv() => match action.as_ref() {
400                      "open_link" => {
401                          if let Some(Event { url: Some(url), .. }) = next_events.current()
402                              && let Ok(url) = Url::parse(url) {
403                                  open_browser(config, &url).await?;
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::async_deserialize_toml_file(path.expand()?.to_string())
428                        .await
429                        .error("Failed to read basic credentials file")?
430                } else {
431                    credentials.clone()
432                };
433                let BasicCredentials {
434                    username: Some(username),
435                    password: Some(password),
436                } = credentials
437                else {
438                    return Err(Error::new("Basic credentials are not configured"));
439                };
440                auth::Auth::basic(username, password)
441            }
442            AuthConfig::OAuth2(oauth2) => {
443                let credentials = if let Some(path) = &oauth2.credentials_path {
444                    util::async_deserialize_toml_file(path.expand()?.to_string())
445                        .await
446                        .error("Failed to read oauth2 credentials file")?
447                } else {
448                    oauth2.credentials.clone()
449                };
450                let OAuth2Credentials {
451                    client_id: Some(client_id),
452                    client_secret: Some(client_secret),
453                } = credentials
454                else {
455                    return Err(Error::new("Oauth2 credentials are not configured"));
456                };
457                let auth_url =
458                    AuthUrl::new(oauth2.auth_url.clone()).error("Invalid authorization url")?;
459                let token_url =
460                    TokenUrl::new(oauth2.token_url.clone()).error("Invalid token url")?;
461
462                let flow = OAuth2Flow::new(
463                    ClientId::new(client_id),
464                    ClientSecret::new(client_secret),
465                    auth_url,
466                    token_url,
467                    oauth2.redirect_port,
468                );
469                let token_store =
470                    TokenStore::new(Path::new(&oauth2.auth_token.expand()?.to_string()));
471                auth::Auth::oauth2(flow, token_store, oauth2.scopes.clone())
472            }
473        };
474        Ok(Self {
475            client: Client::new(
476                Url::parse(&config.url).error("Invalid CalDav server url")?,
477                auth,
478            ),
479            config,
480        })
481    }
482
483    async fn get_next_events(
484        &mut self,
485        within: Duration,
486    ) -> Result<OverlappingEvents, CalendarError> {
487        let calendars: Vec<_> = self
488            .client
489            .calendars()
490            .await?
491            .into_iter()
492            .filter(|c| self.config.calendars.is_empty() || self.config.calendars.contains(&c.name))
493            .collect();
494        let mut events: Vec<Event> = vec![];
495        for calendar in calendars {
496            let calendar_events: Vec<_> = self
497                .client
498                .events(
499                    &calendar,
500                    Local::now()
501                        .date_naive()
502                        .and_hms_opt(0, 0, 0)
503                        .expect("A valid time")
504                        .and_local_timezone(Local)
505                        .earliest()
506                        .expect("A valid datetime")
507                        .to_utc(),
508                    Utc::now() + within,
509                )
510                .await?
511                .into_iter()
512                .filter(|e| {
513                    let not_started = e.start_at.is_some_and(|d| d > Utc::now());
514                    let is_ongoing = e.start_at.is_some_and(|d| d < Utc::now())
515                        && e.end_at.is_some_and(|d| d > Utc::now());
516                    not_started || is_ongoing
517                })
518                .collect();
519            events.extend(calendar_events);
520        }
521
522        events.sort_by_key(|e| e.start_at);
523        let Some(next_event) = events.first().cloned() else {
524            return Ok(OverlappingEvents::default());
525        };
526        let overlapping_events = events
527            .into_iter()
528            .take_while(|e| e.start_at <= next_event.end_at)
529            .collect();
530        Ok(OverlappingEvents::new(overlapping_events))
531    }
532}
533
534#[derive(Default)]
535struct OverlappingEvents {
536    current: Option<Event>,
537    events: Vec<Event>,
538}
539
540impl OverlappingEvents {
541    fn new(events: Vec<Event>) -> Self {
542        Self {
543            current: events.first().cloned(),
544            events,
545        }
546    }
547
548    fn refresh(&mut self, other: OverlappingEvents) {
549        if self.current.is_none() {
550            self.current = other.events.first().cloned();
551        }
552        self.events = other.events;
553    }
554
555    fn current(&self) -> Option<&Event> {
556        self.current.as_ref()
557    }
558
559    fn cycle_warning_or_ongoing(&mut self, warning_threshold: Duration) {
560        self.current = if let Some(current) = &self.current {
561            if self.events.iter().any(|e| e.uid == current.uid) {
562                let mut iter = self
563                    .events
564                    .iter()
565                    .cycle()
566                    .skip_while(|e| e.uid != current.uid);
567                iter.next();
568                iter.find(|e| {
569                    let is_ongoing = e.start_at.is_some_and(|d| d < Utc::now())
570                        && e.end_at.is_some_and(|d| d > Utc::now());
571                    let is_warning = e
572                        .start_at
573                        .is_some_and(|d| d - warning_threshold < Utc::now() && Utc::now() < d);
574                    e.uid == current.uid || is_warning || is_ongoing
575                })
576                .cloned()
577            } else {
578                self.events.first().cloned()
579            }
580        } else {
581            self.events.first().cloned()
582        };
583    }
584}
585
586async fn open_browser(config: &Config, url: &Url) -> Result<()> {
587    let cmd = config.browser_cmd.expand()?;
588    has_command(&cmd)
589        .await
590        .or_error(|| "Browser command not found")?;
591    spawn_process(&cmd, &[url.as_ref()]).error("Open browser failed")
592}
593
594#[derive(thiserror::Error, Debug)]
595pub enum CalendarError {
596    #[error(transparent)]
597    Http(#[from] reqwest::Error),
598    #[error(transparent)]
599    Deserialize(#[from] quick_xml::de::DeError),
600    #[error("Parsing error: {0}")]
601    Parsing(String),
602    #[error("Auth required")]
603    AuthRequired,
604    #[error(transparent)]
605    Io(#[from] std::io::Error),
606    #[error(transparent)]
607    Serialize(#[from] serde_json::Error),
608    #[error("Request token error: {0}")]
609    RequestToken(String),
610    #[error("Store token error: {0}")]
611    StoreToken(#[from] TokenStoreError),
612}