#![doc(html_logo_url = "https://i.imgur.com/Le9EOww.png")]
mod events;
use crate::events::{CronEvent, CronEventRequest, Event, EventRepo, EventRequest};
use chrono::offset::TimeZone;
use chrono::{DateTime, Local, NaiveDateTime};
use cron::Schedule;
use eyre::{eyre, Result, WrapErr};
use notify_rust::Notification;
use rodio::{Decoder, OutputStream, Sink};
pub use sqlx::sqlite::SqlitePool;
use std::fs::File;
use std::io::BufReader;
use std::str::FromStr;
use std::thread;
static SQLITE_DATE: &str = "%Y-%m-%d %H:%M:%S";
static CLI_DATE: &str = "%Y-%m-%dT%H:%M";
fn play_sound(sound_file_str: &str) -> Result<()> {
let stream = OutputStream::try_default().wrap_err("Unable to open sound output stream")?;
let (_stream, stream_handle) = stream;
let sink = Sink::try_new(&stream_handle).wrap_err("Unable to open sound output device")?;
let file_buffer: BufReader<File> = BufReader::new(File::open(sound_file_str)?);
match Decoder::new(file_buffer) {
Ok(x) => sink.append(x),
Err(why) => return Err(eyre!(why)),
}
sink.sleep_until_end();
Ok(())
}
fn notify(
name: String,
desc: String,
icon: Option<String>,
sound_file_path: Option<String>,
) -> Result<()> {
let icon_name = match icon {
Some(icon) => icon,
None => "".to_string(),
};
Notification::new()
.summary(&name)
.body(&desc)
.icon(&icon_name)
.timeout(0)
.show()
.unwrap();
if sound_file_path.is_some() {
play_sound(&sound_file_path.unwrap())?;
}
Ok(())
}
fn prepare_notification_body(
event_desc: Option<String>,
now: NaiveDateTime,
event_date: NaiveDateTime,
) -> String {
let diff_in_sec = now.signed_duration_since(event_date).num_seconds();
let mut description = match event_desc {
Some(x) => x,
None => "".to_string(),
};
if diff_in_sec > 60 {
let time_passed = match diff_in_sec {
i if i > 120 && i <= 7200 => format!("{} minutes ago", i / 60),
i if i > 7200 && i <= 172800 => format!("{} hours ago", i / 3600),
i if i > 172800 && i <= 1209600 => format!("{} days ago", i / 86400),
i if i > 1209600 => format!("{} weeks ago", i / 604800),
_ => format!("{} seconds ago", diff_in_sec),
};
if description.is_empty() {
description.push_str(&time_passed);
} else {
description.push_str(format!("\n{}", time_passed).as_ref());
}
}
description
}
fn get_valid_scheduled_dates(
start_date: DateTime<Local>,
end_date: NaiveDateTime,
last_notify_date: Option<String>,
cron_string: String,
) -> Result<Vec<NaiveDateTime>> {
let schedule = Schedule::from_str(&cron_string)?;
let valid_events = schedule
.after(&start_date)
.take(5)
.filter(|x| x <= &Local.from_local_datetime(&end_date).unwrap())
.map(|x| x.naive_local());
let last_notify_date = match last_notify_date {
Some(date) => Some(NaiveDateTime::parse_from_str(&date, SQLITE_DATE)?),
None => None,
};
let valid_events: Vec<NaiveDateTime> = match last_notify_date {
Some(date) => valid_events.filter(|x| x > &date).collect(),
None => valid_events.collect(),
};
Ok(valid_events)
}
pub async fn check_events(pool: &SqlitePool) -> Result<()> {
let now: NaiveDateTime = Local::now().naive_local();
let past_events = Event::find_current_events(pool, now.to_string()).await?;
for event in past_events {
let event_date: NaiveDateTime =
NaiveDateTime::parse_from_str(&event.date, SQLITE_DATE).unwrap();
let description: String = prepare_notification_body(event.description, now, event_date);
notify(
event.name,
description,
event.icon_name,
event.sound_file_path,
)?;
Event::delete_event(pool, event.id).await?;
}
let recurring_events = CronEvent::find_events(pool).await?;
let past_events_check = Local
.from_local_datetime(
&now.checked_sub_signed(chrono::Duration::minutes(30))
.unwrap(),
)
.unwrap();
for event in recurring_events {
let valid_dates = get_valid_scheduled_dates(
past_events_check,
now,
event.last_notification,
event.cron_string,
)?;
if valid_dates.is_empty() {
continue;
}
let event_date: NaiveDateTime = *valid_dates.last().unwrap();
let description = prepare_notification_body(event.description, now, event_date);
notify(
event.name,
description,
event.icon_name,
event.sound_file_path,
)?;
CronEvent::update_last_notification_date(pool, event.id, event_date).await?;
}
Ok(())
}
fn parse_date(date_str: &str) -> Result<String> {
let now: NaiveDateTime = Local::now().naive_local();
let date = NaiveDateTime::parse_from_str(date_str, CLI_DATE)?;
if date < now {
return Err(eyre!("Date must be in the future, got: {}", date));
}
Ok(date.to_string())
}
fn get_date_after_duration(expr: &str, now: NaiveDateTime) -> Result<String> {
let duration = parse_duration::parse(expr);
if duration.is_err() {
return Err(eyre!(format!(
"Invalid duration expression because {}",
duration
.as_ref()
.err()
.unwrap()
.to_string()
.split(": ")
.last()
.unwrap()
)));
}
let chrono_duration = chrono::Duration::from_std(duration.unwrap())?;
Ok(now
.checked_add_signed(chrono_duration)
.ok_or_else(|| eyre!("Invalid duration {}", chrono_duration))?
.format("%Y-%m-%d %H:%M:%S")
.to_string())
}
#[derive(Clone)]
pub struct EventTemplate {
pub name: String,
pub desc: String,
pub date_str: Option<String>,
pub wait_expr: Option<String>,
pub cron_str: Option<String>,
pub icon_name: Option<String>,
pub sound_path: Option<String>,
pub announce: bool,
pub test_sound: bool,
}
async fn schedule_event(template: EventTemplate, pool: &SqlitePool) -> Result<()> {
let mut notify_at = String::from("");
let mut date: String = String::from("");
if let Some(ref date_str) = &template.date_str {
date = parse_date(date_str)?;
} else if let Some(ref expr) = &template.wait_expr {
let now: NaiveDateTime = Local::now().naive_local();
date = get_date_after_duration(expr, now)?;
} else if let Some(ref cron_expr) = &template.cron_str {
Schedule::from_str(cron_expr)?;
notify_at = format!("trigger at {}", cron_expr);
let request = CronEventRequest {
name: template.name.to_string(),
description: template.desc.to_string(),
cron_string: cron_expr.to_string(),
icon_name: template.icon_name.as_ref().map(|x| x.to_string()),
sound_file_path: template.sound_path.as_ref().map(|x| x.to_string()),
};
CronEvent::create_event(pool, request).await?;
}
if !date.is_empty() {
notify_at = date.clone();
let request = EventRequest {
name: template.name.to_string(),
description: template.desc.to_string(),
date,
icon_name: template.icon_name.as_ref().map(|x| x.to_string()),
sound_file_path: template.sound_path.as_ref().map(|x| x.to_string()),
};
Event::create_event(pool, request).await?;
}
if template.announce {
let mut with_sound = String::from("");
if template.test_sound {
with_sound = String::from("\n(play the sound as a test..)")
}
let display_name = String::from("New notification created");
let display_desc = format!(
"Title:\n{}\n\nDescription:\n{}\n\nNotify at:\n{}\n{}",
template.name, template.desc, notify_at, with_sound
);
notify(
display_name,
display_desc,
template.icon_name.to_owned(),
match template.test_sound {
true => template.sound_path.to_owned(),
false => None,
},
)?;
}
Ok(())
}
pub async fn set_event(template: EventTemplate, pool: &SqlitePool) -> Result<()> {
if template.date_str.is_some() || template.cron_str.is_some() || template.wait_expr.is_some() {
schedule_event(template, pool).await?;
} else {
notify(
template.name.to_owned(),
template.desc.to_owned(),
template.icon_name.to_owned(),
template.sound_path.to_owned(),
)?;
}
Ok(())
}
pub fn emit_event(template: EventTemplate) -> Result<()> {
let local_dates: Vec<NaiveDateTime>;
if let Some(ref date_str) = template.date_str {
let date = parse_date(&date_str.to_string())?;
local_dates = vec![NaiveDateTime::parse_from_str(&date, SQLITE_DATE)
.wrap_err(format!("Invalid date {}", date))?]
} else if let Some(ref expr) = template.wait_expr {
let now: NaiveDateTime = Local::now().naive_local();
let date = get_date_after_duration(expr.as_ref(), now)?;
local_dates = vec![NaiveDateTime::parse_from_str(&date, SQLITE_DATE)
.wrap_err(format!("Invalid date {}", date))?]
} else if let Some(ref cron_expr) = template.cron_str {
local_dates = Schedule::from_str(cron_expr.as_ref())?
.upcoming(Local)
.take(50)
.map(|date| date.naive_local())
.collect();
} else {
notify(
template.name.to_string(),
template.desc.to_string(),
template.icon_name,
template.sound_path,
)?;
return Ok(());
}
let dates = local_dates;
std::thread::spawn(move || {
for date in dates {
let now: NaiveDateTime = Local::now().naive_local();
let duration = date.signed_duration_since(now);
thread::sleep(duration.to_std().unwrap());
let notify_result = notify(
template.name.to_string(),
template.desc.to_string(),
template.icon_name.as_ref().map(|x| x.to_string()),
template.sound_path.as_ref().map(|x| x.to_string()),
);
assert!(
!notify_result.is_err(),
"Notification failed {}",
notify_result.err().unwrap()
);
}
});
Ok(())
}
fn print_event_response<T>(
response: &[T],
print_function: fn(&mut Box<term::StdoutTerminal>, event: &T),
) {
let mut terminal = term::stdout().unwrap();
for event in response {
print_function(&mut terminal, event);
}
}
fn print_header(id: i64, terminal: &mut Box<term::StdoutTerminal>) {
terminal.fg(term::color::BRIGHT_YELLOW).unwrap();
terminal.bg(term::color::BRIGHT_BLACK).unwrap();
terminal.attr(term::Attr::Bold).unwrap();
print!(" {0: <93}", format!("Event id: {}", id));
terminal.reset().unwrap();
println!();
}
fn print_title(title: &str, terminal: &mut Box<term::StdoutTerminal>) {
terminal.fg(term::color::BRIGHT_YELLOW).unwrap();
terminal.bg(term::color::BRIGHT_BLACK).unwrap();
terminal.attr(term::Attr::Underline(true)).unwrap();
print!(" {0: <27}", title);
terminal.reset().unwrap();
}
fn print_content(content: &str, terminal: &mut Box<term::StdoutTerminal>) {
terminal.fg(term::color::MAGENTA).unwrap();
terminal.bg(term::color::BRIGHT_YELLOW).unwrap();
terminal.attr(term::Attr::Bold).unwrap();
print!(" {0: <65}", content);
terminal.reset().unwrap();
println!();
}
fn print_event(terminal: &mut Box<term::StdoutTerminal>, event: &Event) {
print_header(event.id, terminal);
print_title("Datetime", terminal);
print_content(&event.date, terminal);
print_title("Name", terminal);
print_content(&event.name, terminal);
print_title("Description", terminal);
let desc = match &event.description {
Some(x) => x,
None => "---",
};
print_content(desc, terminal);
print_title("Icon", terminal);
let icon_name = match &event.icon_name {
Some(x) => x,
None => "---",
};
print_content(icon_name, terminal);
print_title("Sound file", terminal);
let sound_file_path = match &event.sound_file_path {
Some(x) => x,
None => "---",
};
print_content(sound_file_path, terminal);
println!();
}
fn get_upcoming_scheduled_date(cron_string: String) -> Result<NaiveDateTime> {
let schedule = Schedule::from_str(&cron_string)?;
let upcoming = schedule
.upcoming(Local)
.next()
.ok_or_else(|| eyre!("No upcoming date found"))?
.naive_local();
Ok(upcoming)
}
fn print_cron(terminal: &mut Box<term::StdoutTerminal>, event: &CronEvent) {
print_header(event.id, terminal);
print_title("Next notification", terminal);
let next_notify = get_upcoming_scheduled_date(event.cron_string.clone()).unwrap();
print_content(&next_notify.to_string(), terminal);
print_title("Last notification", terminal);
let last_notify = match &event.last_notification {
Some(date) => date.to_string(),
None => "---".to_string(),
};
print_content(&last_notify, terminal);
print_title("Name", terminal);
print_content(&event.name, terminal);
print_title("Description", terminal);
let desc = match &event.description {
Some(x) => x,
None => "---",
};
print_content(desc, terminal);
print_title("Cron expression", terminal);
print_content(&event.cron_string, terminal);
print_title("Icon", terminal);
let icon_name = match &event.icon_name {
Some(x) => x,
None => "---",
};
print_content(icon_name, terminal);
print_title("Sound file", terminal);
let sound_file_path = match &event.sound_file_path {
Some(x) => x,
None => "---",
};
print_content(sound_file_path, terminal);
println!();
}
pub async fn list_events(
pool: &SqlitePool,
expression: Option<&str>,
cron_mode: bool,
) -> Result<()> {
let now: NaiveDateTime = Local::now().naive_local();
if let Some(expr) = expression {
let end_date = get_date_after_duration(expr, now)?;
print_event_response::<Event>(
&Event::find_future_events(pool, now.to_string(), Some(end_date)).await?,
print_event,
);
} else if cron_mode {
print_event_response::<CronEvent>(&CronEvent::find_events(pool).await?, print_cron);
} else {
print_event_response::<Event>(
&Event::find_future_events(pool, now.to_string(), None).await?,
print_event,
);
}
Ok(())
}
fn deletion_confirmation(event_id: i64, event_name: String) -> bool {
loop {
let mut input = String::new();
println!(
"Delete event {} with the name: {} (Y/n)",
event_id, event_name
);
std::io::stdin()
.read_line(&mut input)
.expect("Unable to read user input");
if !input.is_ascii() {
println!("Input contains non ASCII values ...");
continue;
}
match input.chars().next().unwrap().to_ascii_lowercase() {
'y' | 'j' | '\n' => return true,
'n' => return false,
_ => continue,
}
}
}
pub async fn delete_event(
pool: &SqlitePool,
id: i64,
cron_mode: bool,
confirm: bool,
) -> Result<()> {
if !confirm {
if cron_mode {
let event = &CronEvent::get_event(pool, id).await;
match event {
Ok(ev) => {
if !deletion_confirmation(ev.id, ev.name.clone()) {
return Err(eyre!("Deletion aborted by the user"));
}
}
Err(err) => {
return Err(eyre!(
"Deletion of the recurring event with ID {} failed, due to {}",
id,
err
))
}
}
} else {
let event = &Event::get_event(pool, id).await;
match event {
Ok(ev) => {
if !deletion_confirmation(ev.id, ev.name.clone()) {
return Err(eyre!("Deletion aborted by the user"));
}
}
Err(err) => {
return Err(eyre!(
"Deletion of the event with ID {} failed, due to {}",
id,
err
))
}
}
}
}
match cron_mode {
true => &CronEvent::delete_event(pool, id).await?,
false => &Event::delete_event(pool, id).await?,
};
Ok(())
}
pub async fn prepare_environment(pool: &SqlitePool) -> Result<()> {
Event::create_table(pool).await?;
CronEvent::create_table(pool).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use chrono::NaiveDate;
use super::*;
#[test]
fn test_get_valid_scheduled_dates_with_no_valid_dates() -> Result<(), String> {
let start_date = NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 0, 0);
let end_date = NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 5, 0);
let last_notify_date = None;
let cron_string = String::from("0 30 16 * * *");
let expected_result = vec![];
let result = get_valid_scheduled_dates(
Local.from_local_datetime(&start_date).unwrap(),
end_date,
last_notify_date,
cron_string,
)
.unwrap();
assert_eq!(result, expected_result);
Ok(())
}
#[test]
fn test_get_valid_scheduled_dates_with_one_valid_date() -> Result<(), String> {
let start_date = NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 0, 0);
let end_date = NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 5, 0);
let last_notify_date = None;
let cron_string = String::from("0 3 16 * * *");
let expected_result = vec![NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 3, 0)];
let result = get_valid_scheduled_dates(
Local.from_local_datetime(&start_date).unwrap(),
end_date,
last_notify_date,
cron_string,
)
.unwrap();
assert_eq!(result, expected_result);
Ok(())
}
#[test]
fn test_get_valid_scheduled_dates_with_multiple_matches() -> Result<(), String> {
let start_date = NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 0, 0);
let end_date = NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 10, 0);
let last_notify_date = None;
let cron_string = String::from("0 */3 * * * *");
let expected_result = vec![
NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 3, 0),
NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 6, 0),
NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 9, 0),
];
let result = get_valid_scheduled_dates(
Local.from_local_datetime(&start_date).unwrap(),
end_date,
last_notify_date,
cron_string,
)
.unwrap();
assert_eq!(result, expected_result);
Ok(())
}
#[test]
fn test_get_valid_scheduled_dates_with_last_notify_hide_past_events() -> Result<(), String> {
let start_date = NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 0, 0);
let end_date = NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 10, 0);
let last_notify_date = Some(String::from("2021-09-10 16:05:00"));
let cron_string = String::from("0 */5 * * * *");
let expected_result = vec![NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 10, 0)];
let result = get_valid_scheduled_dates(
Local.from_local_datetime(&start_date).unwrap(),
end_date,
last_notify_date,
cron_string,
)
.unwrap();
assert_eq!(result, expected_result);
Ok(())
}
#[test]
fn test_get_valid_scheduled_dates_with_last_notify_no_matches() -> Result<(), String> {
let start_date = NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 0, 0);
let end_date = NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 15, 0);
let last_notify_date = Some(String::from("2021-09-10 16:00:00"));
let cron_string = String::from("0 */30 * * * *");
let expected_result = vec![];
let result = get_valid_scheduled_dates(
Local.from_local_datetime(&start_date).unwrap(),
end_date,
last_notify_date,
cron_string,
)
.unwrap();
assert_eq!(result, expected_result);
Ok(())
}
}