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