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