1#![doc(html_logo_url = "https://i.imgur.com/Le9EOww.png")]
2mod events;
117
118use crate::events::{CronEvent, CronEventRequest, Event, EventRepo, EventRequest};
119use chrono::offset::TimeZone;
120use chrono::{DateTime, Local, NaiveDateTime};
121use cron::Schedule;
122use eyre::{eyre, Result, WrapErr};
123use notify_rust::Notification;
124use rodio::{Decoder, OutputStream, Sink};
125pub use sqlx::sqlite::SqlitePool;
126
127use std::fs::File;
128use std::io::BufReader;
129use std::str::FromStr;
130use std::thread;
131
132static SQLITE_DATE: &str = "%Y-%m-%d %H:%M:%S";
133static CLI_DATE: &str = "%Y-%m-%dT%H:%M";
134
135fn play_sound(sound_file_str: &str) -> Result<()> {
141 let stream = OutputStream::try_default().wrap_err("Unable to open sound output stream")?;
142 let (_stream, stream_handle) = stream;
143 let sink = Sink::try_new(&stream_handle).wrap_err("Unable to open sound output device")?;
144 let file_buffer: BufReader<File> = BufReader::new(File::open(sound_file_str)?);
145 match Decoder::new(file_buffer) {
146 Ok(x) => sink.append(x),
147 Err(why) => return Err(eyre!(why)),
148 }
149 sink.sleep_until_end();
150 Ok(())
151}
152
153fn notify(
161 name: String,
162 desc: String,
163 icon: Option<String>,
164 sound_file_path: Option<String>,
165) -> Result<()> {
166 let icon_name = match icon {
167 Some(icon) => icon,
168 None => "".to_string(),
169 };
170 Notification::new()
172 .summary(&name)
173 .body(&desc)
174 .icon(&icon_name)
175 .timeout(0)
176 .show()
177 .unwrap();
178
179 if sound_file_path.is_some() {
180 play_sound(&sound_file_path.unwrap())?;
181 }
182 Ok(())
183}
184
185fn prepare_notification_body(
192 event_desc: Option<String>,
193 now: NaiveDateTime,
194 event_date: NaiveDateTime,
195) -> String {
196 let diff_in_sec = now.signed_duration_since(event_date).num_seconds();
197 let mut description = match event_desc {
198 Some(x) => x,
199 None => "".to_string(),
200 };
201 if diff_in_sec > 60 {
202 let time_passed = match diff_in_sec {
206 i if i > 120 && i <= 7200 => format!("{} minutes ago", i / 60),
207 i if i > 7200 && i <= 172800 => format!("{} hours ago", i / 3600),
208 i if i > 172800 && i <= 1209600 => format!("{} days ago", i / 86400),
209 i if i > 1209600 => format!("{} weeks ago", i / 604800),
210 _ => format!("{} seconds ago", diff_in_sec),
211 };
212 if description.is_empty() {
213 description.push_str(&time_passed);
214 } else {
215 description.push_str(format!("\n{}", time_passed).as_ref());
216 }
217 }
218 description
219}
220
221fn get_valid_scheduled_dates(
239 start_date: DateTime<Local>,
240 end_date: NaiveDateTime,
241 last_notify_date: Option<String>,
242 cron_string: String,
243) -> Result<Vec<NaiveDateTime>> {
244 let schedule = Schedule::from_str(&cron_string)?;
245 let valid_events = schedule
248 .after(&start_date)
249 .take(5)
250 .filter(|x| x <= &Local.from_local_datetime(&end_date).unwrap())
251 .map(|x| x.naive_local());
252 let last_notify_date = match last_notify_date {
253 Some(date) => Some(NaiveDateTime::parse_from_str(&date, SQLITE_DATE)?),
254 None => None,
255 };
256 let valid_events: Vec<NaiveDateTime> = match last_notify_date {
257 Some(date) => valid_events.filter(|x| x > &date).collect(),
258 None => valid_events.collect(),
259 };
260 Ok(valid_events)
261}
262
263pub async fn check_events(pool: &SqlitePool) -> Result<()> {
276 let now: NaiveDateTime = Local::now().naive_local();
277 let past_events = Event::find_current_events(pool, now.to_string()).await?;
278 for event in past_events {
282 let event_date: NaiveDateTime =
283 NaiveDateTime::parse_from_str(&event.date, SQLITE_DATE).unwrap();
284 let description: String = prepare_notification_body(event.description, now, event_date);
285
286 notify(
287 event.name,
288 description,
289 event.icon_name,
290 event.sound_file_path,
291 )?;
292 Event::delete_event(pool, event.id).await?;
293 }
294
295 let recurring_events = CronEvent::find_events(pool).await?;
296 let past_events_check = Local
297 .from_local_datetime(
298 &now.checked_sub_signed(chrono::Duration::minutes(30))
299 .unwrap(),
300 )
301 .unwrap();
302
303 for event in recurring_events {
304 let valid_dates = get_valid_scheduled_dates(
305 past_events_check,
306 now,
307 event.last_notification,
308 event.cron_string,
309 )?;
310
311 if valid_dates.is_empty() {
312 continue;
313 }
314 let event_date: NaiveDateTime = *valid_dates.last().unwrap();
315 let description = prepare_notification_body(event.description, now, event_date);
316 notify(
317 event.name,
318 description,
319 event.icon_name,
320 event.sound_file_path,
321 )?;
322 CronEvent::update_last_notification_date(pool, event.id, event_date).await?;
323 }
325 Ok(())
326}
327
328fn parse_date(date_str: &str) -> Result<String> {
340 let now: NaiveDateTime = Local::now().naive_local();
341 let date = NaiveDateTime::parse_from_str(date_str, CLI_DATE)?;
342 if date < now {
343 return Err(eyre!("Date must be in the future, got: {}", date));
344 }
345 Ok(date.to_string())
346}
347
348fn get_date_after_duration(expr: &str, now: NaiveDateTime) -> Result<String> {
360 let duration = parse_duration::parse(expr);
361 if duration.is_err() {
362 return Err(eyre!(format!(
365 "Invalid duration expression because {}",
366 duration
367 .as_ref()
368 .err()
369 .unwrap()
370 .to_string()
371 .split(": ")
372 .last()
373 .unwrap()
374 )));
375 }
376 let chrono_duration = chrono::Duration::from_std(duration.unwrap())?;
377 Ok(now
378 .checked_add_signed(chrono_duration)
379 .ok_or_else(|| eyre!("Invalid duration {}", chrono_duration))?
380 .format("%Y-%m-%d %H:%M:%S")
381 .to_string())
382}
383
384#[derive(Clone)]
386pub struct EventTemplate {
387 pub name: String,
389
390 pub desc: String,
392
393 pub date_str: Option<String>,
398
399 pub wait_expr: Option<String>,
408
409 pub cron_str: Option<String>,
415
416 pub icon_name: Option<String>,
419
420 pub sound_path: Option<String>,
422
423 pub announce: bool,
429
430 pub test_sound: bool,
433}
434
435async fn schedule_event(template: EventTemplate, pool: &SqlitePool) -> Result<()> {
442 let mut notify_at = String::from("");
443 let mut date: String = String::from("");
444 if let Some(ref date_str) = &template.date_str {
446 date = parse_date(date_str)?;
447 } else if let Some(ref expr) = &template.wait_expr {
448 let now: NaiveDateTime = Local::now().naive_local();
449 date = get_date_after_duration(expr, now)?;
450 } else if let Some(ref cron_expr) = &template.cron_str {
451 Schedule::from_str(cron_expr)?;
456 notify_at = format!("trigger at {}", cron_expr);
458 let request = CronEventRequest {
459 name: template.name.to_string(),
460 description: template.desc.to_string(),
461 cron_string: cron_expr.to_string(),
462 icon_name: template.icon_name.as_ref().map(|x| x.to_string()),
463 sound_file_path: template.sound_path.as_ref().map(|x| x.to_string()),
464 };
465 CronEvent::create_event(pool, request).await?;
466 }
467 if !date.is_empty() {
468 notify_at = date.clone();
469 let request = EventRequest {
470 name: template.name.to_string(),
471 description: template.desc.to_string(),
472 date,
473 icon_name: template.icon_name.as_ref().map(|x| x.to_string()),
474 sound_file_path: template.sound_path.as_ref().map(|x| x.to_string()),
475 };
476 Event::create_event(pool, request).await?;
477 }
478
479 if template.announce {
480 let mut with_sound = String::from("");
481 if template.test_sound {
482 with_sound = String::from("\n(play the sound as a test..)")
483 }
484 let display_name = String::from("New notification created");
485 let display_desc = format!(
486 "Title:\n{}\n\nDescription:\n{}\n\nNotify at:\n{}\n{}",
487 template.name, template.desc, notify_at, with_sound
488 );
489 notify(
490 display_name,
491 display_desc,
492 template.icon_name.to_owned(),
493 match template.test_sound {
494 true => template.sound_path.to_owned(),
495 false => None,
496 },
497 )?;
498 }
499 Ok(())
500}
501
502pub async fn set_event(template: EventTemplate, pool: &SqlitePool) -> Result<()> {
519 if template.date_str.is_some() || template.cron_str.is_some() || template.wait_expr.is_some() {
520 schedule_event(template, pool).await?;
521 } else {
522 notify(
523 template.name.to_owned(),
524 template.desc.to_owned(),
525 template.icon_name.to_owned(),
526 template.sound_path.to_owned(),
527 )?;
528 }
529 Ok(())
530}
531
532pub fn emit_event(template: EventTemplate) -> Result<()> {
533 let local_dates: Vec<NaiveDateTime>;
534 if let Some(ref date_str) = template.date_str {
535 let date = parse_date(&date_str.to_string())?;
536 local_dates = vec![NaiveDateTime::parse_from_str(&date, SQLITE_DATE)
537 .wrap_err(format!("Invalid date {}", date))?]
538 } else if let Some(ref expr) = template.wait_expr {
539 let now: NaiveDateTime = Local::now().naive_local();
540 let date = get_date_after_duration(expr.as_ref(), now)?;
541 local_dates = vec![NaiveDateTime::parse_from_str(&date, SQLITE_DATE)
542 .wrap_err(format!("Invalid date {}", date))?]
543 } else if let Some(ref cron_expr) = template.cron_str {
544 local_dates = Schedule::from_str(cron_expr.as_ref())?
546 .upcoming(Local)
547 .take(50)
548 .map(|date| date.naive_local())
549 .collect();
550 } else {
551 notify(
552 template.name.to_string(),
553 template.desc.to_string(),
554 template.icon_name,
555 template.sound_path,
556 )?;
557 return Ok(());
558 }
559 let dates = local_dates;
560 std::thread::spawn(move || {
565 for date in dates {
566 let now: NaiveDateTime = Local::now().naive_local();
567 let duration = date.signed_duration_since(now);
568 thread::sleep(duration.to_std().unwrap());
569 let notify_result = notify(
570 template.name.to_string(),
571 template.desc.to_string(),
572 template.icon_name.as_ref().map(|x| x.to_string()),
573 template.sound_path.as_ref().map(|x| x.to_string()),
574 );
575 assert!(
576 !notify_result.is_err(),
577 "Notification failed {}",
578 notify_result.err().unwrap()
579 );
580 }
581 });
582 Ok(())
583}
584
585fn print_event_response<T>(
595 response: &[T],
596 print_function: fn(&mut Box<term::StdoutTerminal>, event: &T),
597) {
598 let mut terminal = term::stdout().unwrap();
599 for event in response {
600 print_function(&mut terminal, event);
601 }
602}
603
604fn print_header(id: i64, terminal: &mut Box<term::StdoutTerminal>) {
605 terminal.fg(term::color::BRIGHT_YELLOW).unwrap();
606 terminal.bg(term::color::BRIGHT_BLACK).unwrap();
607 terminal.attr(term::Attr::Bold).unwrap();
608 print!(" {0: <93}", format!("Event id: {}", id));
609 terminal.reset().unwrap();
610 println!();
611}
612
613fn print_title(title: &str, terminal: &mut Box<term::StdoutTerminal>) {
614 terminal.fg(term::color::BRIGHT_YELLOW).unwrap();
615 terminal.bg(term::color::BRIGHT_BLACK).unwrap();
616 terminal.attr(term::Attr::Underline(true)).unwrap();
617 print!(" {0: <27}", title);
618 terminal.reset().unwrap();
619}
620
621fn print_content(content: &str, terminal: &mut Box<term::StdoutTerminal>) {
622 terminal.fg(term::color::MAGENTA).unwrap();
623 terminal.bg(term::color::BRIGHT_YELLOW).unwrap();
624 terminal.attr(term::Attr::Bold).unwrap();
625 print!(" {0: <65}", content);
626 terminal.reset().unwrap();
627 println!();
628}
629
630fn print_event(terminal: &mut Box<term::StdoutTerminal>, event: &Event) {
637 print_header(event.id, terminal);
638 print_title("Datetime", terminal);
639 print_content(&event.date, terminal);
640 print_title("Name", terminal);
641 print_content(&event.name, terminal);
642 print_title("Description", terminal);
643 let desc = match &event.description {
644 Some(x) => x,
645 None => "---",
646 };
647 print_content(desc, terminal);
648 print_title("Icon", terminal);
649 let icon_name = match &event.icon_name {
650 Some(x) => x,
651 None => "---",
652 };
653 print_content(icon_name, terminal);
654 print_title("Sound file", terminal);
655 let sound_file_path = match &event.sound_file_path {
656 Some(x) => x,
657 None => "---",
658 };
659 print_content(sound_file_path, terminal);
660 println!();
661}
662
663fn get_upcoming_scheduled_date(cron_string: String) -> Result<NaiveDateTime> {
673 let schedule = Schedule::from_str(&cron_string)?;
674 let upcoming = schedule
675 .upcoming(Local)
676 .next()
677 .ok_or_else(|| eyre!("No upcoming date found"))?
678 .naive_local();
679 Ok(upcoming)
680}
681
682fn print_cron(terminal: &mut Box<term::StdoutTerminal>, event: &CronEvent) {
690 print_header(event.id, terminal);
691 print_title("Next notification", terminal);
692 let next_notify = get_upcoming_scheduled_date(event.cron_string.clone()).unwrap();
693 print_content(&next_notify.to_string(), terminal);
694 print_title("Last notification", terminal);
695 let last_notify = match &event.last_notification {
696 Some(date) => date.to_string(),
697 None => "---".to_string(),
698 };
699 print_content(&last_notify, terminal);
700 print_title("Name", terminal);
701 print_content(&event.name, terminal);
702 print_title("Description", terminal);
703 let desc = match &event.description {
704 Some(x) => x,
705 None => "---",
706 };
707 print_content(desc, terminal);
708 print_title("Cron expression", terminal);
709 print_content(&event.cron_string, terminal);
710 print_title("Icon", terminal);
711 let icon_name = match &event.icon_name {
712 Some(x) => x,
713 None => "---",
714 };
715 print_content(icon_name, terminal);
716 print_title("Sound file", terminal);
717 let sound_file_path = match &event.sound_file_path {
718 Some(x) => x,
719 None => "---",
720 };
721 print_content(sound_file_path, terminal);
722 println!();
723}
724
725pub async fn list_events(
737 pool: &SqlitePool,
738 expression: Option<&str>,
739 cron_mode: bool,
740) -> Result<()> {
741 let now: NaiveDateTime = Local::now().naive_local();
742 if let Some(expr) = expression {
743 let end_date = get_date_after_duration(expr, now)?;
744 print_event_response::<Event>(
745 &Event::find_future_events(pool, now.to_string(), Some(end_date)).await?,
746 print_event,
747 );
748 } else if cron_mode {
749 print_event_response::<CronEvent>(&CronEvent::find_events(pool).await?, print_cron);
750 } else {
751 print_event_response::<Event>(
753 &Event::find_future_events(pool, now.to_string(), None).await?,
754 print_event,
755 );
756 }
757 Ok(())
758}
759
760fn deletion_confirmation(event_id: i64, event_name: String) -> bool {
761 loop {
762 let mut input = String::new();
763 println!(
764 "Delete event {} with the name: {} (Y/n)",
765 event_id, event_name
766 );
767 std::io::stdin()
768 .read_line(&mut input)
769 .expect("Unable to read user input");
770 if !input.is_ascii() {
771 println!("Input contains non ASCII values ...");
772 continue;
773 }
774 match input.chars().next().unwrap().to_ascii_lowercase() {
775 'y' | 'j' | '\n' => return true,
776 'n' => return false,
777 _ => continue,
778 }
779 }
780}
781
782pub async fn delete_event(
790 pool: &SqlitePool,
791 id: i64,
792 cron_mode: bool,
793 confirm: bool,
794) -> Result<()> {
795 if !confirm {
796 if cron_mode {
797 let event = &CronEvent::get_event(pool, id).await;
798 match event {
799 Ok(ev) => {
800 if !deletion_confirmation(ev.id, ev.name.clone()) {
801 return Err(eyre!("Deletion aborted by the user"));
802 }
803 }
804 Err(err) => {
805 return Err(eyre!(
806 "Deletion of the recurring event with ID {} failed, due to {}",
807 id,
808 err
809 ))
810 }
811 }
812 } else {
813 let event = &Event::get_event(pool, id).await;
814 match event {
815 Ok(ev) => {
816 if !deletion_confirmation(ev.id, ev.name.clone()) {
817 return Err(eyre!("Deletion aborted by the user"));
818 }
819 }
820 Err(err) => {
821 return Err(eyre!(
822 "Deletion of the event with ID {} failed, due to {}",
823 id,
824 err
825 ))
826 }
827 }
828 }
829 }
830 match cron_mode {
831 true => &CronEvent::delete_event(pool, id).await?,
832 false => &Event::delete_event(pool, id).await?,
833 };
834 Ok(())
835}
836
837pub async fn prepare_environment(pool: &SqlitePool) -> Result<()> {
838 Event::create_table(pool).await?;
839 CronEvent::create_table(pool).await?;
840 Ok(())
841}
842
843#[cfg(test)]
844mod tests {
845 use chrono::NaiveDate;
846
847 use super::*;
848
849 #[test]
850 fn test_get_valid_scheduled_dates_with_no_valid_dates() -> Result<(), String> {
851 let start_date = NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 0, 0);
852 let end_date = NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 5, 0);
853 let last_notify_date = None;
854 let cron_string = String::from("0 30 16 * * *");
855 let expected_result = vec![];
856 let result = get_valid_scheduled_dates(
857 Local.from_local_datetime(&start_date).unwrap(),
858 end_date,
859 last_notify_date,
860 cron_string,
861 )
862 .unwrap();
863 assert_eq!(result, expected_result);
864 Ok(())
865 }
866
867 #[test]
868 fn test_get_valid_scheduled_dates_with_one_valid_date() -> Result<(), String> {
869 let start_date = NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 0, 0);
870 let end_date = NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 5, 0);
871 let last_notify_date = None;
872 let cron_string = String::from("0 3 16 * * *");
873 let expected_result = vec![NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 3, 0)];
874 let result = get_valid_scheduled_dates(
875 Local.from_local_datetime(&start_date).unwrap(),
876 end_date,
877 last_notify_date,
878 cron_string,
879 )
880 .unwrap();
881 assert_eq!(result, expected_result);
882 Ok(())
883 }
884
885 #[test]
886 fn test_get_valid_scheduled_dates_with_multiple_matches() -> Result<(), String> {
887 let start_date = NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 0, 0);
888 let end_date = NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 10, 0);
889 let last_notify_date = None;
890 let cron_string = String::from("0 */3 * * * *");
891 let expected_result = vec![
892 NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 3, 0),
893 NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 6, 0),
894 NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 9, 0),
895 ];
896 let result = get_valid_scheduled_dates(
897 Local.from_local_datetime(&start_date).unwrap(),
898 end_date,
899 last_notify_date,
900 cron_string,
901 )
902 .unwrap();
903 assert_eq!(result, expected_result);
904 Ok(())
905 }
906
907 #[test]
908 fn test_get_valid_scheduled_dates_with_last_notify_hide_past_events() -> Result<(), String> {
909 let start_date = NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 0, 0);
910 let end_date = NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 10, 0);
911 let last_notify_date = Some(String::from("2021-09-10 16:05:00"));
912 let cron_string = String::from("0 */5 * * * *");
913 let expected_result = vec![NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 10, 0)];
914 let result = get_valid_scheduled_dates(
915 Local.from_local_datetime(&start_date).unwrap(),
916 end_date,
917 last_notify_date,
918 cron_string,
919 )
920 .unwrap();
921 assert_eq!(result, expected_result);
922 Ok(())
923 }
924
925 #[test]
926 fn test_get_valid_scheduled_dates_with_last_notify_no_matches() -> Result<(), String> {
927 let start_date = NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 0, 0);
928 let end_date = NaiveDate::from_ymd(2021, 9, 10).and_hms(16, 15, 0);
929 let last_notify_date = Some(String::from("2021-09-10 16:00:00"));
930 let cron_string = String::from("0 */30 * * * *");
931 let expected_result = vec![];
932 let result = get_valid_scheduled_dates(
933 Local.from_local_datetime(&start_date).unwrap(),
934 end_date,
935 last_notify_date,
936 cron_string,
937 )
938 .unwrap();
939 assert_eq!(result, expected_result);
940 Ok(())
941 }
942}