use chrono::{Duration, Local, Utc};
use oauth2::{AuthUrl, ClientId, ClientSecret, Scope, TokenUrl};
use reqwest::Url;
use crate::util;
use crate::{subprocess::spawn_process, util::has_command};
mod auth;
mod caldav;
use self::auth::{Authorize, AuthorizeUrl, OAuth2Flow, TokenStore, TokenStoreError};
use self::caldav::Event;
use super::prelude::*;
use std::path::Path;
use std::sync::Arc;
use caldav::Client;
#[derive(Deserialize, Debug, SmartDefault, Clone)]
#[serde(deny_unknown_fields, default)]
pub struct BasicCredentials {
pub username: Option<String>,
pub password: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct BasicAuthConfig {
#[serde(flatten)]
pub credentials: BasicCredentials,
pub credentials_path: Option<ShellString>,
}
#[derive(Deserialize, Debug, SmartDefault, Clone)]
#[serde(deny_unknown_fields, default)]
pub struct OAuth2Credentials {
pub client_id: Option<String>,
pub client_secret: Option<String>,
}
#[derive(Deserialize, Debug, SmartDefault, Clone)]
#[serde(deny_unknown_fields, default)]
pub struct OAuth2Config {
#[serde(flatten)]
pub credentials: OAuth2Credentials,
pub credentials_path: Option<ShellString>,
pub auth_url: String,
pub token_url: String,
#[default("~/.config/i3status-rust/calendar.auth_token".into())]
pub auth_token: ShellString,
#[default(8080)]
pub redirect_port: u16,
pub scopes: Vec<Scope>,
}
#[derive(Deserialize, Default, Debug, Clone)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum AuthConfig {
#[default]
Unauthenticated,
Basic(BasicAuthConfig),
OAuth2(OAuth2Config),
}
#[derive(Deserialize, Debug, SmartDefault, Clone)]
#[serde(deny_unknown_fields, default)]
pub struct SourceConfig {
pub url: String,
pub auth: AuthConfig,
pub calendars: Vec<String>,
}
#[derive(Deserialize, Debug, SmartDefault)]
#[serde(deny_unknown_fields, default)]
pub struct Config {
pub next_event_format: FormatConfig,
pub ongoing_event_format: FormatConfig,
pub no_events_format: FormatConfig,
pub redirect_format: FormatConfig,
#[default(60.into())]
pub fetch_interval: Seconds,
#[default(10.into())]
pub alternate_events_interval: Seconds,
#[default(48)]
pub events_within_hours: u32,
pub source: Vec<SourceConfig>,
#[default(300)]
pub warning_threshold: u32,
#[default("xdg-open".into())]
pub browser_cmd: ShellString,
}
enum WidgetStatus {
AlternateEvents,
FetchSources,
}
pub async fn run(config: &Config, api: &CommonApi) -> Result<()> {
let next_event_format = config
.next_event_format
.with_default(" $icon $start.datetime(f:'%a %H:%M') $summary ")?;
let ongoing_event_format = config
.ongoing_event_format
.with_default(" $icon $summary (ends at $end.datetime(f:'%H:%M')) ")?;
let no_events_format = config.no_events_format.with_default(" $icon ")?;
let redirect_format = config
.redirect_format
.with_default(" $icon Check your web browser ")?;
api.set_default_actions(&[(MouseButton::Left, None, "open_link")])?;
let source_config = match config.source.len() {
0 => return Err(Error::new("A calendar source must be supplied")),
1 => config
.source
.first()
.expect("There must be a first entry since the length is 1"),
_ => {
return Err(Error::new(
"Currently only one calendar source is supported",
));
}
};
let warning_threshold = Duration::try_seconds(config.warning_threshold.into())
.error("Invalid warning threshold configuration")?;
let mut source = Source::new(source_config.to_owned()).await?;
let mut timer = config.fetch_interval.timer();
let mut alternate_events_timer = config.alternate_events_interval.timer();
let mut actions = api.get_actions()?;
let events_within = Duration::try_hours(config.events_within_hours.into())
.error("Invalid events within hours configuration")?;
let mut widget_status = WidgetStatus::FetchSources;
let mut next_events = OverlappingEvents::default();
loop {
let mut widget = Widget::new().with_format(no_events_format.clone());
widget.set_values(map! {
"icon" => Value::icon("calendar"),
});
if matches!(widget_status, WidgetStatus::FetchSources) {
for retries in 0..=1 {
match source.get_next_events(events_within).await {
Ok(events) => {
next_events.refresh(events);
break;
}
Err(err) => match err {
CalendarError::AuthRequired => {
let authorization = source
.client
.authorize()
.await
.error("Authorization failed")?;
match &authorization {
Authorize::AskUser(AuthorizeUrl { url, .. }) if retries == 0 => {
widget.set_format(redirect_format.clone());
api.set_widget(widget.clone())?;
open_browser(config, url).await?;
source
.client
.ask_user(authorization)
.await
.error("Ask user failed")?;
}
_ => {
return Err(Error::new(
"Authorization failed. Check your configurations",
));
}
}
}
e => {
return Err(Error {
message: None,
cause: Some(Arc::new(e)),
});
}
},
};
}
}
if let Some(event) = next_events.current().cloned()
&& let Some(start_date) = event.start_at
&& let Some(end_date) = event.end_at
{
let warn_datetime = start_date - warning_threshold;
if warn_datetime < Utc::now() && Utc::now() < start_date {
widget.state = State::Warning;
}
if start_date < Utc::now() && Utc::now() < end_date {
widget.set_format(ongoing_event_format.clone());
} else {
widget.set_format(next_event_format.clone());
}
widget.set_values(map! {
"icon" => Value::icon("calendar"),
[if let Some(summary) = event.summary] "summary" => Value::text(summary),
[if let Some(description) = event.description] "description" => Value::text(description),
[if let Some(location) = event.location] "location" => Value::text(location),
[if let Some(url) = event.url] "url" => Value::text(url),
"start" => Value::datetime(start_date, None),
"end" => Value::datetime(end_date, None),
});
}
api.set_widget(widget)?;
loop {
select! {
_ = timer.tick() => {
widget_status = WidgetStatus::FetchSources;
break
}
_ = alternate_events_timer.tick() => {
next_events.cycle_warning_or_ongoing(warning_threshold);
widget_status = WidgetStatus::AlternateEvents;
break
}
_ = api.wait_for_update_request() => break,
Some(action) = actions.recv() => match action.as_ref() {
"open_link" => {
if let Some(Event { url: Some(url), .. }) = next_events.current()
&& let Ok(url) = Url::parse(url) {
open_browser(config, &url).await?;
}
}
_ => ()
}
}
}
}
}
struct Source {
pub client: caldav::Client,
pub config: SourceConfig,
}
impl Source {
async fn new(config: SourceConfig) -> Result<Self> {
let auth = match &config.auth {
AuthConfig::Unauthenticated => auth::Auth::Unauthenticated,
AuthConfig::Basic(BasicAuthConfig {
credentials,
credentials_path,
}) => {
let credentials = if let Some(path) = credentials_path {
util::async_deserialize_toml_file(path.expand()?.to_string())
.await
.error("Failed to read basic credentials file")?
} else {
credentials.clone()
};
let BasicCredentials {
username: Some(username),
password: Some(password),
} = credentials
else {
return Err(Error::new("Basic credentials are not configured"));
};
auth::Auth::basic(username, password)
}
AuthConfig::OAuth2(oauth2) => {
let credentials = if let Some(path) = &oauth2.credentials_path {
util::async_deserialize_toml_file(path.expand()?.to_string())
.await
.error("Failed to read oauth2 credentials file")?
} else {
oauth2.credentials.clone()
};
let OAuth2Credentials {
client_id: Some(client_id),
client_secret: Some(client_secret),
} = credentials
else {
return Err(Error::new("Oauth2 credentials are not configured"));
};
let auth_url =
AuthUrl::new(oauth2.auth_url.clone()).error("Invalid authorization url")?;
let token_url =
TokenUrl::new(oauth2.token_url.clone()).error("Invalid token url")?;
let flow = OAuth2Flow::new(
ClientId::new(client_id),
ClientSecret::new(client_secret),
auth_url,
token_url,
oauth2.redirect_port,
);
let token_store =
TokenStore::new(Path::new(&oauth2.auth_token.expand()?.to_string()));
auth::Auth::oauth2(flow, token_store, oauth2.scopes.clone())
}
};
Ok(Self {
client: Client::new(
Url::parse(&config.url).error("Invalid CalDav server url")?,
auth,
),
config,
})
}
async fn get_next_events(
&mut self,
within: Duration,
) -> Result<OverlappingEvents, CalendarError> {
let calendars: Vec<_> = self
.client
.calendars()
.await?
.into_iter()
.filter(|c| self.config.calendars.is_empty() || self.config.calendars.contains(&c.name))
.collect();
let mut events: Vec<Event> = vec![];
for calendar in calendars {
let calendar_events: Vec<_> = self
.client
.events(
&calendar,
Local::now()
.date_naive()
.and_hms_opt(0, 0, 0)
.expect("A valid time")
.and_local_timezone(Local)
.earliest()
.expect("A valid datetime")
.to_utc(),
Utc::now() + within,
)
.await?
.into_iter()
.filter(|e| {
let not_started = e.start_at.is_some_and(|d| d > Utc::now());
let is_ongoing = e.start_at.is_some_and(|d| d < Utc::now())
&& e.end_at.is_some_and(|d| d > Utc::now());
not_started || is_ongoing
})
.collect();
events.extend(calendar_events);
}
events.sort_by_key(|e| e.start_at);
let Some(next_event) = events.first().cloned() else {
return Ok(OverlappingEvents::default());
};
let overlapping_events = events
.into_iter()
.take_while(|e| e.start_at <= next_event.end_at)
.collect();
Ok(OverlappingEvents::new(overlapping_events))
}
}
#[derive(Default)]
struct OverlappingEvents {
current: Option<Event>,
events: Vec<Event>,
}
impl OverlappingEvents {
fn new(events: Vec<Event>) -> Self {
Self {
current: events.first().cloned(),
events,
}
}
fn refresh(&mut self, other: OverlappingEvents) {
if self.current.is_none() {
self.current = other.events.first().cloned();
}
self.events = other.events;
}
fn current(&self) -> Option<&Event> {
self.current.as_ref()
}
fn cycle_warning_or_ongoing(&mut self, warning_threshold: Duration) {
self.current = if let Some(current) = &self.current {
if self.events.iter().any(|e| e.uid == current.uid) {
let mut iter = self
.events
.iter()
.cycle()
.skip_while(|e| e.uid != current.uid);
iter.next();
iter.find(|e| {
let is_ongoing = e.start_at.is_some_and(|d| d < Utc::now())
&& e.end_at.is_some_and(|d| d > Utc::now());
let is_warning = e
.start_at
.is_some_and(|d| d - warning_threshold < Utc::now() && Utc::now() < d);
e.uid == current.uid || is_warning || is_ongoing
})
.cloned()
} else {
self.events.first().cloned()
}
} else {
self.events.first().cloned()
};
}
}
async fn open_browser(config: &Config, url: &Url) -> Result<()> {
let cmd = config.browser_cmd.expand()?;
has_command(&cmd)
.await
.or_error(|| "Browser command not found")?;
spawn_process(&cmd, &[url.as_ref()]).error("Open browser failed")
}
#[derive(thiserror::Error, Debug)]
pub enum CalendarError {
#[error(transparent)]
Http(#[from] reqwest::Error),
#[error(transparent)]
Deserialize(#[from] quick_xml::de::DeError),
#[error("Parsing error: {0}")]
Parsing(String),
#[error("Auth required")]
AuthRequired,
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Serialize(#[from] serde_json::Error),
#[error("Request token error: {0}")]
RequestToken(String),
#[error("Store token error: {0}")]
StoreToken(#[from] TokenStoreError),
#[error("local time falls in a _gap_ in the local time, or if there was an error")]
TzConversion,
}