elmo_lib/
lib.rs

1#![doc(html_logo_url = "https://i.imgur.com/Le9EOww.png")]
2//! # Introduction
3//!
4//! This library provides functionality for emitting, scheduling and listing notifications. It utilizes
5//! an SQLite database for storing regular events (notify at a given datetime) and recurring events
6//! (notify at a given pattern). Basically, it is a combination of services that communicate with the
7//! notification daemon of a UNIX system, play sounds via the sound output device of the system, and
8//! insert/fetch events from the database.
9//!
10//! # Public functions
11//!
12//! Five public functions are available:
13//! - **`set_event`** (Either insert a new entry in the database or emit a notification directly)
14//! - **`emit_event`** (Sleep for a given duration in a separate thread or emit a notification directly)
15//! - **`check_events`** (Check for entries within the database that should be emitted)
16//! - **`list_events`** (List entries from the database)
17//! - **`delete_event`** (Delete a recurring or regular event by ID)
18//!
19//! **`set_event`** requires an SQlitepool from the sqlx library and needs to be called from an
20//! async function, while **`emit_event`** is the synchronous variation it doesn't require a
21//! database connection. An event stored into the database by **`set_event`** can only be triggered
22//! by calling **`check_events`**.
23//!
24//! The **`check_events`** function is designed to be used in combination with a cronjob or with a daemon,
25//! as it simply checks if an entry within the database matches the current datetime (or in case of
26//! recurring events, if we missed it within a range of 30 minutes).
27//!
28//! The **`list_events`** function is
29//! utilized to pretty-print events to the terminal, with a fixed format, so its use case is quite
30//! specialized at the moment. In order to create a notification, you have to first establish a
31//! connection to an SQLite database pool and fill out an `EventTemplate`.
32//!
33//! ## Example with a database
34//!
35//! Here we create an in-memory SQLite database with the events table and insert a single event,
36//! this is not a very practical example as events stored in an in-memory database are lost when
37//! the main function exits, but it shows how the function should be used.
38//!
39//! The function `check_events` can be used to display notifications, that are stored in a
40//! database.
41//!
42//! ```edition2018
43//! use eyre::Result;
44//! use elmo_lib::{set_event, EventTemplate};
45//! use sqlx::sqlite::SqlitePool;
46//!
47//! #[tokio::main]
48//! async fn main() -> Result<()> {
49//!     let pool = SqlitePool::connect("file::memory:").await?;
50//!     sqlx::query(
51//!         "CREATE TABLE IF NOT EXISTS events(
52//!             id INTEGER PRIMARY KEY NOT NULL,
53//!             name TEXT NOT NULL,
54//!             description TEXT NULL,
55//!             event_date TEXT NOT NULL,
56//!             icon TEXT NULL,
57//!             sound_file_path TEXT NULL
58//!             )
59//!         "
60//!         ).execute(&pool).await?;
61//!
62//!     let new_event = EventTemplate {
63//!         name: String::from("Test notification"),
64//!         desc: String::from("description of an event in the future"),
65//!         date_str: Some(String::from("2099-10-10T18:00")),
66//!         wait_expr: None,
67//!         cron_str: None,
68//!         icon_name: Some(String::from("future_icon")),
69//!         sound_path: None,
70//!         announce: true,
71//!         test_sound: false,
72//!     };
73//!     set_event(new_event, &pool).await?;
74//!     Ok(())
75//! }
76//! ```
77//!
78//! Within the repository of this project you can locate the `migrations/1_events.sql` file, which
79//! contains the SQL statements for the two tables used by the project.
80//!
81//! To create a new SQLite database and create the required tables issue the following command:
82//! ```bash
83//! sqlite3 /tmp/events.db < /path/to/elmo/migrations/1_events.sql
84//! ```
85//!
86//! ## Example without a database
87//!
88//! This example doesn't utilize a database but instead creates a separate thread that sleeps for
89//! the specified amount of time.
90//!
91//! ```edition2018
92//! use elmo_lib::{emit_event, EventTemplate};
93//!
94//! fn main() {
95//!     let new_event = EventTemplate {
96//!         name: String::from("Test notification"),
97//!         desc: String::from("description of a notification in 5 minutes"),
98//!         date_str: None,
99//!         wait_expr: Some(String::from("5 seconds")),
100//!         cron_str: None,
101//!         icon_name: Some(String::from("dog_icon")),
102//!         sound_path: None,
103//!         announce: true,
104//!         test_sound: false,
105//!     };
106//!     emit_event(new_event);
107//! }
108//! ```
109//!
110//! # Additional hints
111//!
112//! To use custom icons you have to place the new images into a folder that is known to the
113//! notification daemon.  Please refer to the documnetation of the software you use on your system to
114//! find out how to declare custom locations, for example in [dunst](https://dunst-project.org/) you
115//! simply have to add the folder to the `icon_path` variable in the configuration (dunstrc).
116mod 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
135/// TODO Implement an optional maximum sound playing time as a parameter
136/// Fetch the sound device of the system, decode the sound file and play the sound.
137///
138/// # Parameters
139/// - `sound_file_str`L Path on the system to sound file to be played
140fn 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
153/// Emit a notification and play a sound if available.
154///
155/// # Parameters:
156/// - `name`: Title of the notification window
157/// - `desc`: Text body of the notification window
158/// - `icon`: Optional name without extension or path of the icon for the notification
159/// - `sound_file_path`: Optional full-path to the sound file (e.g. mp3, mpg, wav etc)
160fn 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    // TODO make the persistence of the notification configurable
171    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
185/// Describe how much time has passed since the notification should have been triggered.
186///
187/// # Parameters
188///
189/// - `desc`: The current notification body
190/// - `diff_in_sec`: The difference between the trigger date of the event and now
191fn 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        // Notify how much time has passed since the scheduled date
203        // This useful when the system has been shutdown during the
204        // scheduled event
205        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
221/// Get all scheduled events matching the cron expression between the
222/// start and end date, that occur after the last notification was triggered.
223///
224/// # Parameters
225///
226/// - `start_date`: Datetime in the past used to find events that were missed
227///                 because the system was powered down
228/// - `end_date`: Upper end of the date range to look for valid events
229/// - `last_notify_date`: Datetime stored in the database when the event was triggered,
230///                       used to avoid triggering multiple times in a row, when the
231///                       time between each check is lower than the than the time
232///                       between each trigger of this event
233/// - `cron_string`: Cron expression of the event to find scheduled dates
234///
235/// # Returns
236///
237/// - `valid_events`: All scheduled dates that satisfy the conditions
238fn 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    // Search for all matching events within (now - 5min) and now
246    // pick the last one for the notification
247    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
263/// Search for upcoming or just missed events within all database tables.
264///
265/// The events table is scanned for events that either match the current date or which could not be
266/// notified as the no check was executed at the scheduled datetime, the event is notified and
267/// deleted from the database.
268/// The recurring_events table is scanned for the most recent match to the cronjob pattern within
269/// the last 30 minutes, the last notification date is updated to avoid issuing the same
270/// notification multiple times.
271///
272/// # Parameters
273///
274/// - `pool`: instance of an SQLite database pool, that takes SQL requests
275pub 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    // FIXME current problem is that if multiple events are handled at the
279    // same time and each of them emits a sound, then the sound device is blocked
280    // by each event
281    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        // TODO Implement an optional maximum number of repetitions
324    }
325    Ok(())
326}
327
328/// Check if the user-supplied `date_str` from the EventTemplate is valid.
329///
330///A valid date must be in the future and must have the following format: "YYYY-mm-ddTHH:MM"
331///
332///# Parameter
333///
334///- `date_str`: User-supplied string from the EventTemplate
335///
336///# Returns
337///
338///- `date`: Datetime without timezone information as String
339fn 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
348/// Interpret a time duration string expression and add it onto the current date.
349///
350/// # Parameters
351///
352/// - `expr`: String from the argument parser containing a time duration expression.
353///           Examples: 1 second, 4 minutes, 3 hours, 2 weeks 4 days 3 hours
354/// - `now`: The current date and time without timezone information
355///
356/// # Returns:
357///
358/// - A new NaiveDateTime which is equal to `now` + `expr`
359fn get_date_after_duration(expr: &str, now: NaiveDateTime) -> Result<String> {
360    let duration = parse_duration::parse(expr);
361    if duration.is_err() {
362        // Get the relevant part of the error message without the Error name
363        // We could also do this with .description() but that is deprecated
364        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/// Structure describing a notification
385#[derive(Clone)]
386pub struct EventTemplate {
387    /// Title of the notification
388    pub name: String,
389
390    /// Body of the notification message
391    pub desc: String,
392
393    /// Optional specific datetime in the future with the format YYYY-mm-ddTHH:MM
394    ///
395    /// # Example
396    /// - `2023-10-03T15:00`
397    pub date_str: Option<String>,
398
399    /// Optional duration expression that states a time span from now to now + duration as the
400    /// waiting time until the notification is issued.
401    ///
402    /// # Example
403    /// - `5 min`
404    /// - `30 minutes`
405    /// - `20 seconds`
406    /// - `3 days`
407    pub wait_expr: Option<String>,
408
409    /// An optional cron pattern to store in the database for recurring events.
410    ///
411    /// # Example
412    /// - `* */5 * * * *` (Every 5 minutes)
413    /// - `* 30 16 * * *` (Every day at 16:30)
414    pub cron_str: Option<String>,
415
416    /// Optional filename without extension or path of a specific icon, the icon must be placed within a
417    /// folder that is known to the notification daemon.
418    pub icon_name: Option<String>,
419
420    /// Optional full-path to sound file (.mp3, .mpg, etc.)
421    pub sound_path: Option<String>,
422
423    /// Decide whether to announce the notification in advance when either `date_str`, `wait_expr`,
424    /// or `cron_str` are given, the announcement shows the content of the notification and might be
425    /// used to test if the EventTemplate works as expected.
426    /// When `test_sound` is true and the `sound_path` points to a valid file the sound is played
427    /// during the announcement.
428    pub announce: bool,
429
430    /// Only works in combination with `announce` and `sound_path`, play the sound file during the
431    /// announcement to check in advance if it works.
432    pub test_sound: bool,
433}
434
435/// Handle adding a new event to the database and announce the creation.
436///
437/// # Parameters
438///
439/// - `template`: Structure that defines a new event
440/// - `pool`: Instance of an SQLite database pool, that takes SQL requests
441async fn schedule_event(template: EventTemplate, pool: &SqlitePool) -> Result<()> {
442    let mut notify_at = String::from("");
443    let mut date: String = String::from("");
444    // TODO Make sure that only valid dates are entered into the database
445    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        // Verify that the cron expression is valid
452        // TODO only take the last 5 elements from the expected 6 elements from the
453        // user as the first element describes seconds which is meaningless if we check
454        // with crontab
455        Schedule::from_str(cron_expr)?;
456        // TODO implement parse to human readable cron
457        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
502/// Create a delayed, recurring or direct notification.
503///
504/// # Parameters
505///
506/// - `template`: Structure that defines a new event
507/// - `pool`: Optional instance of a Sqlite database pool
508///
509/// # Errors
510///
511/// Possible errors are:
512/// - an invalid connection to the database
513/// - unable to access the sound output device
514/// - An invalid input date, which is either not in the format of
515///   YYYY-mm-ddTHH:MM or smaller than now
516/// - An invalid duration value for the `--wait` argument
517/// - An incorrect cron string
518pub 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        // TODO make the amount of dates configurable
545        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    // let name = template.name.to_string();
561    // let desc = template.desc.to_string();
562    // let icon = template.icon_name.map(|x| x.to_string());
563    // let sound = template.sound_path.map(|x| x.to_string());
564    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
585/// Colorful output to the terminal of all listed events
586///
587/// # Parameters:
588///
589/// - `response`: Either a vector of Event objects from the `events` table
590///               or a vector or CronEvent objects from the 'recurring_events` table'.
591///               Sorting of any kind has to be performed in advance.
592/// - `print_function`: Specific function used to print the particular kind of object.
593///                     Currently either `print_event` or `print_cron`.
594fn 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
630/// Colorful output to the terminal of a single event
631///
632/// # Parameters:
633///
634/// - `terminal`: Terminal writer with capabilities for changing colors and style
635/// - `event`: A single row from the `events` database table
636fn 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
663/// Parse the cron expression and get the next valid date that matches it
664///
665/// # Parameters
666///
667/// - `cron_string`: Cron expression with 6 fields (sec, min, hour, day(month), month, day(week))
668///
669/// # Result
670///
671/// - `upcoming`: The next matching date if any as DateTime without time zone information
672fn 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
682// TODO write the events in order of their next notification
683/// Colorful output to the terminal of a single recurring event
684///
685/// # Parameters:
686///
687/// - `terminal`: Terminal writer with capabilities for changing colors and style
688/// - `event`: A single row from the `recurring_events` database table
689fn 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
725/// List upcoming events or recurring events within the CLI with colorful output.
726///
727/// The colors are dependent on your bash color profile.
728///
729/// # Parameters
730///
731/// - `pool`: instance of an SQLite database pool, that takes SQL requests
732/// - `expression`: Optional duration expression as String reference like `30 min` or `10 days`,
733/// that limits the output to all events within now -> now + expression
734/// - `cron_mode`: When set recurring events are listed instead of regular events from the events
735/// table
736pub 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        // TODO sort the events by their next notification date before printing them
752        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
782/// Delete a recurring or regular event by ID.
783///
784/// # Parameters
785/// - `pool`: instance of an SQLite database pool, that takes SQL requests
786/// - `id`: ID of the event in the database
787/// - `cron_mode`: Delete recurring events when this is `true`
788/// - `confirm`: Ask the user for confirmation unless this is `true`
789pub 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}