Skip to main content

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` (`$icon`)
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(crate) fn prepare(config: &Config) -> Result<Arc<BlockPlan>> {
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    BlockPlan::new(vec![
280        OutputPlan::new("no_events", no_events_format)
281            .icon("icon", IconChoices::one(icons::CALENDAR)),
282        OutputPlan::new("next_event", next_event_format)
283            .icon("icon", IconChoices::one(icons::CALENDAR)),
284        OutputPlan::new("ongoing_event", ongoing_event_format)
285            .icon("icon", IconChoices::one(icons::CALENDAR)),
286        OutputPlan::new("redirect", redirect_format)
287            .icon("icon", IconChoices::one(icons::CALENDAR)),
288    ])
289}
290
291pub(crate) async fn run(config: &Config, api: &CommonApi, plan: &Arc<BlockPlan>) -> Result<()> {
292    let output_no_events = plan.output("no_events")?;
293    let output_next_event = plan.output("next_event")?;
294    let output_ongoing_event = plan.output("ongoing_event")?;
295    let output_redirect = plan.output("redirect")?;
296
297    api.set_default_actions(&[(MouseButton::Left, None, "open_link")])?;
298
299    let source_config = match config.source.len() {
300        0 => return Err(Error::new("A calendar source must be supplied")),
301        1 => config
302            .source
303            .first()
304            .expect("There must be a first entry since the length is 1"),
305        _ => {
306            return Err(Error::new(
307                "Currently only one calendar source is supported",
308            ));
309        }
310    };
311
312    let warning_threshold = Duration::try_seconds(config.warning_threshold.into())
313        .error("Invalid warning threshold configuration")?;
314
315    let mut source = Source::new(source_config.to_owned()).await?;
316
317    let mut timer = config.fetch_interval.timer();
318
319    let mut alternate_events_timer = config.alternate_events_interval.timer();
320
321    let mut actions = api.get_actions()?;
322
323    let events_within = Duration::try_hours(config.events_within_hours.into())
324        .error("Invalid events within hours configuration")?;
325
326    let mut widget_status = WidgetStatus::FetchSources;
327
328    let mut next_events = OverlappingEvents::default();
329
330    loop {
331        let mut output = &output_no_events;
332        let mut values = map! {
333            "icon" => Value::icon(icons::CALENDAR),
334        };
335
336        if matches!(widget_status, WidgetStatus::FetchSources) {
337            for retries in 0..=1 {
338                match source.get_next_events(events_within).await {
339                    Ok(events) => {
340                        next_events.refresh(events);
341                        break;
342                    }
343                    Err(err) => match err {
344                        CalendarError::AuthRequired => {
345                            let authorization = source
346                                .client
347                                .authorize()
348                                .await
349                                .error("Authorization failed")?;
350                            match &authorization {
351                                Authorize::AskUser(AuthorizeUrl { url, .. }) if retries == 0 => {
352                                    output = &output_redirect;
353                                    let mut widget = output.new_widget();
354                                    widget.set_values(values.clone());
355                                    api.set_widget(widget)?;
356                                    open_browser(config, url).await?;
357                                    source
358                                        .client
359                                        .ask_user(authorization)
360                                        .await
361                                        .error("Ask user failed")?;
362                                }
363                                _ => {
364                                    return Err(Error::new(
365                                        "Authorization failed. Check your configurations",
366                                    ));
367                                }
368                            }
369                        }
370                        e => {
371                            return Err(Error {
372                                message: None,
373                                cause: Some(Arc::new(e)),
374                            });
375                        }
376                    },
377                };
378            }
379        }
380
381        let mut warning = false;
382        if let Some(event) = next_events.current().cloned()
383            && let Some(start_date) = event.start_at
384            && let Some(end_date) = event.end_at
385        {
386            let warn_datetime = start_date - warning_threshold;
387            if warn_datetime < Utc::now() && Utc::now() < start_date {
388                warning = true;
389            }
390            output = if start_date < Utc::now() && Utc::now() < end_date {
391                &output_ongoing_event
392            } else {
393                &output_next_event
394            };
395            values = map! {
396              "icon" => Value::icon(icons::CALENDAR),
397               [if let Some(summary) = event.summary] "summary" => Value::text(summary),
398               [if let Some(description) = event.description] "description" => Value::text(description),
399               [if let Some(location) = event.location] "location" => Value::text(location),
400               [if let Some(url) = event.url] "url" => Value::text(url),
401               "start" => Value::datetime(start_date, None),
402               "end" => Value::datetime(end_date, None),
403            };
404        }
405
406        let mut widget = output.new_widget();
407        if warning {
408            widget.state = State::Warning;
409        }
410        widget.set_values(values);
411        api.set_widget(widget)?;
412        loop {
413            select! {
414                _ = timer.tick() => {
415                  widget_status = WidgetStatus::FetchSources;
416                  break
417                }
418                _ = alternate_events_timer.tick() => {
419                  next_events.cycle_warning_or_ongoing(warning_threshold);
420                  widget_status = WidgetStatus::AlternateEvents;
421                  break
422                }
423                _ = api.wait_for_update_request() => break,
424                Some(action) = actions.recv() => match action.as_ref() {
425                      "open_link" => {
426                          if let Some(Event { url: Some(url), .. }) = next_events.current()
427                              && let Ok(url) = Url::parse(url) {
428                                  open_browser(config, &url).await?;
429                              }
430                      }
431                      _ => ()
432                }
433            }
434        }
435    }
436}
437
438struct Source {
439    pub client: caldav::Client,
440    pub config: SourceConfig,
441}
442
443impl Source {
444    async fn new(config: SourceConfig) -> Result<Self> {
445        let auth = match &config.auth {
446            AuthConfig::Unauthenticated => auth::Auth::Unauthenticated,
447            AuthConfig::Basic(BasicAuthConfig {
448                credentials,
449                credentials_path,
450            }) => {
451                let credentials = if let Some(path) = credentials_path {
452                    util::async_deserialize_toml_file(path.expand()?.to_string())
453                        .await
454                        .error("Failed to read basic credentials file")?
455                } else {
456                    credentials.clone()
457                };
458                let BasicCredentials {
459                    username: Some(username),
460                    password: Some(password),
461                } = credentials
462                else {
463                    return Err(Error::new("Basic credentials are not configured"));
464                };
465                auth::Auth::basic(username, password)
466            }
467            AuthConfig::OAuth2(oauth2) => {
468                let credentials = if let Some(path) = &oauth2.credentials_path {
469                    util::async_deserialize_toml_file(path.expand()?.to_string())
470                        .await
471                        .error("Failed to read oauth2 credentials file")?
472                } else {
473                    oauth2.credentials.clone()
474                };
475                let OAuth2Credentials {
476                    client_id: Some(client_id),
477                    client_secret: Some(client_secret),
478                } = credentials
479                else {
480                    return Err(Error::new("Oauth2 credentials are not configured"));
481                };
482                let auth_url =
483                    AuthUrl::new(oauth2.auth_url.clone()).error("Invalid authorization url")?;
484                let token_url =
485                    TokenUrl::new(oauth2.token_url.clone()).error("Invalid token url")?;
486
487                let flow = OAuth2Flow::new(
488                    ClientId::new(client_id),
489                    ClientSecret::new(client_secret),
490                    auth_url,
491                    token_url,
492                    oauth2.redirect_port,
493                );
494                let token_store =
495                    TokenStore::new(Path::new(&oauth2.auth_token.expand()?.to_string()));
496                auth::Auth::oauth2(flow, token_store, oauth2.scopes.clone())
497            }
498        };
499        Ok(Self {
500            client: Client::new(
501                Url::parse(&config.url).error("Invalid CalDav server url")?,
502                auth,
503            ),
504            config,
505        })
506    }
507
508    async fn get_next_events(
509        &mut self,
510        within: Duration,
511    ) -> Result<OverlappingEvents, CalendarError> {
512        let calendars: Vec<_> = self
513            .client
514            .calendars()
515            .await?
516            .into_iter()
517            .filter(|c| self.config.calendars.is_empty() || self.config.calendars.contains(&c.name))
518            .collect();
519        let mut events: Vec<Event> = vec![];
520        for calendar in calendars {
521            let calendar_events: Vec<_> = self
522                .client
523                .events(
524                    &calendar,
525                    Local::now()
526                        .date_naive()
527                        .and_hms_opt(0, 0, 0)
528                        .expect("A valid time")
529                        .and_local_timezone(Local)
530                        .earliest()
531                        .expect("A valid datetime")
532                        .to_utc(),
533                    Utc::now() + within,
534                )
535                .await?
536                .into_iter()
537                .filter(|e| {
538                    let not_started = e.start_at.is_some_and(|d| d > Utc::now());
539                    let is_ongoing = e.start_at.is_some_and(|d| d < Utc::now())
540                        && e.end_at.is_some_and(|d| d > Utc::now());
541                    not_started || is_ongoing
542                })
543                .collect();
544            events.extend(calendar_events);
545        }
546
547        events.sort_by_key(|e| e.start_at);
548        let Some(next_event) = events.first().cloned() else {
549            return Ok(OverlappingEvents::default());
550        };
551        let overlapping_events = events
552            .into_iter()
553            .take_while(|e| e.start_at <= next_event.end_at)
554            .collect();
555        Ok(OverlappingEvents::new(overlapping_events))
556    }
557}
558
559#[derive(Default)]
560struct OverlappingEvents {
561    current: Option<Event>,
562    events: Vec<Event>,
563}
564
565impl OverlappingEvents {
566    fn new(events: Vec<Event>) -> Self {
567        Self {
568            current: events.first().cloned(),
569            events,
570        }
571    }
572
573    fn refresh(&mut self, other: OverlappingEvents) {
574        if self.current.is_none() {
575            self.current = other.events.first().cloned();
576        }
577        self.events = other.events;
578    }
579
580    fn current(&self) -> Option<&Event> {
581        self.current.as_ref()
582    }
583
584    fn cycle_warning_or_ongoing(&mut self, warning_threshold: Duration) {
585        self.current = if let Some(current) = &self.current {
586            if self.events.iter().any(|e| e.uid == current.uid) {
587                let mut iter = self
588                    .events
589                    .iter()
590                    .cycle()
591                    .skip_while(|e| e.uid != current.uid);
592                iter.next();
593                iter.find(|e| {
594                    let is_ongoing = e.start_at.is_some_and(|d| d < Utc::now())
595                        && e.end_at.is_some_and(|d| d > Utc::now());
596                    let is_warning = e
597                        .start_at
598                        .is_some_and(|d| d - warning_threshold < Utc::now() && Utc::now() < d);
599                    e.uid == current.uid || is_warning || is_ongoing
600                })
601                .cloned()
602            } else {
603                self.events.first().cloned()
604            }
605        } else {
606            self.events.first().cloned()
607        };
608    }
609}
610
611async fn open_browser(config: &Config, url: &Url) -> Result<()> {
612    let cmd = config.browser_cmd.expand()?;
613    has_command(&cmd)
614        .await
615        .or_error(|| "Browser command not found")?;
616    spawn_process(&cmd, &[url.as_ref()]).error("Open browser failed")
617}
618
619#[derive(thiserror::Error, Debug)]
620pub enum CalendarError {
621    #[error(transparent)]
622    Http(#[from] reqwest::Error),
623    #[error(transparent)]
624    Deserialize(#[from] quick_xml::de::DeError),
625    #[error(transparent)]
626    RecurrenceError(#[from] icalendar::RecurrenceError),
627    #[error("Parsing error: {0}")]
628    Parsing(String),
629    #[error("Auth required")]
630    AuthRequired,
631    #[error(transparent)]
632    Io(#[from] std::io::Error),
633    #[error(transparent)]
634    Serialize(#[from] serde_json::Error),
635    #[error("Request token error: {0}")]
636    RequestToken(String),
637    #[error("Store token error: {0}")]
638    StoreToken(#[from] TokenStoreError),
639    #[error("local time falls in a _gap_ in the local time, or if there was an error")]
640    TzConversion,
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646
647    #[test]
648    fn plan_declares_every_state_with_the_calendar_icon() {
649        let plan = prepare(&Config::default()).unwrap();
650        let ids: Vec<_> = plan.outputs().map(|o| o.id()).collect();
651        assert_eq!(
652            ids,
653            ["no_events", "next_event", "ongoing_event", "redirect"]
654        );
655        for id in ids {
656            let output = plan.output(id).unwrap();
657            assert_eq!(output.single_icon("icon").unwrap(), "calendar");
658        }
659    }
660
661    #[test]
662    fn each_state_resolves_its_own_format() {
663        let config = Config {
664            next_event_format: " $icon $summary ".parse().unwrap(),
665            ..Config::default()
666        };
667        let plan = prepare(&config).unwrap();
668        assert!(
669            plan.output("next_event")
670                .unwrap()
671                .format()
672                .contains_key("summary")
673        );
674        assert!(
675            !plan
676                .output("no_events")
677                .unwrap()
678                .format()
679                .contains_key("summary")
680        );
681    }
682}