1use 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}