diff options
| author | Joris | 2022-03-19 22:03:58 +0100 | 
|---|---|---|
| committer | Joris | 2022-03-19 22:03:58 +0100 | 
| commit | 35cc74578e969bae4812afd2ff041eba3746142d (patch) | |
| tree | c0ef18d3c81554f0e70c91fcb1006d587470365c /src | |
| parent | 199624dc03ead28ddc7454147457512d9568c593 (diff) | |
Allow to repeat an event until a specific date
Also provide a shortcut to modify a repetead event from a specific
occurence. This set the end repetition date under the hood and create a
new repeated event.
Diffstat (limited to 'src')
| -rw-r--r-- | src/gui/app.rs | 4 | ||||
| -rw-r--r-- | src/gui/calendar.rs | 4 | ||||
| -rw-r--r-- | src/gui/form/mod.rs | 115 | ||||
| -rw-r--r-- | src/gui/form/repetition.rs | 40 | ||||
| -rw-r--r-- | src/gui/form/utils.rs | 21 | ||||
| -rw-r--r-- | src/gui/update.rs | 83 | ||||
| -rw-r--r-- | src/main.rs | 1 | ||||
| -rw-r--r-- | src/model/event.rs | 52 | ||||
| -rw-r--r-- | src/model/mod.rs | 1 | ||||
| -rw-r--r-- | src/model/repetition.rs | 21 | ||||
| -rw-r--r-- | src/model/time.rs | 22 | ||||
| -rw-r--r-- | src/validation/mod.rs | 21 | 
12 files changed, 289 insertions, 96 deletions
| diff --git a/src/gui/app.rs b/src/gui/app.rs index 9f37301..8cd7096 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -16,8 +16,8 @@ pub struct App {      pub conn: Rc<Connection>,      pub window: Rc<gtk::ApplicationWindow>,      pub grid: gtk::Grid, -    pub events: Vec<Event>, -    pub recurring_events: Vec<Event>, +    pub events: Vec<Event>, // TODO: use Hashmap to have fast access to events by id ? +    pub recurring_events: Vec<Event>, // TODO: use Hashmap to have fast access to events by id ?      pub today: NaiveDate,      pub start_date: NaiveDate,      pub end_date: NaiveDate, diff --git a/src/gui/calendar.rs b/src/gui/calendar.rs index cad2465..547c087 100644 --- a/src/gui/calendar.rs +++ b/src/gui/calendar.rs @@ -181,9 +181,9 @@ fn day_events(date: NaiveDate, tx: Sender<Msg>, events: Vec<&Event>) -> gtk::Box                  gesture.set_state(gtk::EventSequenceState::Claimed);                  if n == 2 {                      if event.repetition.is_some() { -                        update::send(tx.clone(), Msg::ShowRepetitionDialog { date, event: event.clone() }); +                        update::send(tx.clone(), Msg::ShowRepetitionDialog { date, event_id: event.id });                      } else { -                        update::send(tx.clone(), Msg::ShowUpdateForm { event: event.clone() }); +                        update::send(tx.clone(), Msg::ShowUpdateForm { event_id: event.id });                      }                  }              }), diff --git a/src/gui/form/mod.rs b/src/gui/form/mod.rs index 68e6539..bb43ef5 100644 --- a/src/gui/form/mod.rs +++ b/src/gui/form/mod.rs @@ -1,22 +1,24 @@  mod repetition; +mod utils;  use gtk4 as gtk;  use anyhow::Result; -use chrono::{NaiveDate, NaiveTime}; +use chrono::{Duration, NaiveDate};  use gtk::glib;  use gtk::prelude::*;  use rusqlite::Connection; +use std::collections::HashSet;  use thiserror::Error;  use uuid::Uuid;  use crate::{      db,      gui::{update, update::Msg, App}, -    model::{event, event::Event}, +    model::{event, event::Event, repetition::Repetition},  }; -pub async fn repetition_dialog(app: &App, date: NaiveDate, event: Event) { +pub async fn repetition_dialog(app: &App, date: NaiveDate, event: &Event) {      let dialog = gtk::Dialog::builder()          .transient_for(&*app.window)          .modal(true) @@ -38,17 +40,28 @@ pub async fn repetition_dialog(app: &App, date: NaiveDate, event: Event) {      lines.append(&button);      let tx = app.tx.clone();      button.connect_clicked(glib::clone!(@weak dialog, @strong event => move |_| { -        update::send(tx.clone(), Msg::ShowUpdateRepetitionForm { date, event: event.clone() }); +        update::send(tx.clone(), Msg::ShowUpdateRepetitionForm { date, event_id: event.id });          dialog.close()      }));      let button = gtk::Button::builder()          .label("Toutes les occurences") +        .margin_bottom(10)          .build();      lines.append(&button);      let tx = app.tx.clone();      button.connect_clicked(glib::clone!(@weak dialog, @strong event => move |_| { -        update::send(tx.clone(), Msg::ShowUpdateForm { event: event.clone() }); +        update::send(tx.clone(), Msg::ShowUpdateForm { event_id: event.id }); +        dialog.close() +    })); + +    let button = gtk::Button::builder() +        .label("À partir de cette occurence") +        .build(); +    lines.append(&button); +    let tx = app.tx.clone(); +    button.connect_clicked(glib::clone!(@weak dialog, @strong event => move |_| { +        update::send(tx.clone(), Msg::ShowUpdateFromOccurence { date, event_id: event.id });          dialog.close()      })); @@ -60,6 +73,7 @@ pub enum Target {      New { date: NaiveDate },      Update { event: Event },      UpdateRepetition { event: Event, date: NaiveDate }, +    UpdateFromOccurence { event: Event, date: NaiveDate },  }  pub async fn show(app: &App, target: Target) { @@ -67,6 +81,7 @@ pub async fn show(app: &App, target: Target) {          Target::New { .. } => None,          Target::Update { ref event } => Some(event.clone()),          Target::UpdateRepetition { ref event, .. } => Some(event.clone()), +        Target::UpdateFromOccurence { ref event, .. } => Some(event.clone()),      };      let title = if event.is_some() { @@ -91,6 +106,7 @@ pub async fn show(app: &App, target: Target) {      let columns = gtk::Box::builder()          .orientation(gtk::Orientation::Horizontal) +        .spacing(10)          .build();      lines.append(&columns); @@ -101,37 +117,42 @@ pub async fn show(app: &App, target: Target) {          .build();      columns.append(&column1); -    let name = event.as_ref().map(|e| entry(&e.name)).unwrap_or_default(); -    column1.append(&label("Événement")); +    let name = event +        .as_ref() +        .map(|e| utils::entry(&e.name)) +        .unwrap_or_default(); +    column1.append(&utils::label("Événement"));      column1.append(&name);      let date = match target {          Target::New { date } => date,          Target::Update { ref event } => event.date,          Target::UpdateRepetition { date, .. } => date, +        Target::UpdateFromOccurence { date, .. } => date,      }; -    let date = entry(&date.format(event::DATE_FORMAT).to_string()); -    column1.append(&label("Jour")); +    let date = utils::entry(&date.format(event::DATE_FORMAT).to_string()); +    column1.append(&utils::label("Jour"));      column1.append(&date);      let start = event          .as_ref() -        .map(|e| time_entry(e.start)) -        .unwrap_or_else(|| entry("")); -    column1.append(&label("Début")); +        .map(|e| utils::time_entry(e.start)) +        .unwrap_or_else(|| utils::entry("")); +    column1.append(&utils::label("Début"));      column1.append(&start);      let end = event          .as_ref() -        .map(|e| time_entry(e.end)) -        .unwrap_or_else(|| entry("")); -    column1.append(&label("Fin")); +        .map(|e| utils::time_entry(e.end)) +        .unwrap_or_else(|| utils::entry("")); +    column1.append(&utils::label("Fin"));      column1.append(&end);      // Second column      let repetition = match target {          Target::Update { ref event } => event.repetition.as_ref(), +        Target::UpdateFromOccurence { ref event, .. } => event.repetition.as_ref(),          _ => None,      };      let repetition_model = repetition::view(repetition); @@ -143,6 +164,7 @@ pub async fn show(app: &App, target: Target) {          Target::New { .. } => "Créer",          Target::Update { .. } => "Modifier",          Target::UpdateRepetition { .. } => "Modifier l’occurence", +        Target::UpdateFromOccurence { .. } => "Modifier à partir de l’occurence",      };      let button = gtk::Button::builder() @@ -153,7 +175,13 @@ pub async fn show(app: &App, target: Target) {      let conn = app.conn.clone();      let tx = app.tx.clone();      button.connect_clicked(glib::clone!(@weak dialog, @strong target, @strong event => move |_| { -        match repetition::validate(&repetition_model) { +        let removed_occurences = match &target { +            Target::Update { event, .. } => { +                event.repetition.as_ref().map(|r| r.removed_occurences.clone()).unwrap_or_default() +            }, +            _ => HashSet::new(), +        }; +        match repetition::validate(&repetition_model, removed_occurences) {              Ok(repetition) => {                  let id = match &target {                      Target::Update {event} => event.id, @@ -200,6 +228,24 @@ pub async fn show(app: &App, target: Target) {                                      Err(err) => eprintln!("Error when updating repetition: {}", err)                                  }                              } +                            Target::UpdateFromOccurence { date, event } => { +                                match update_repetition_until(&conn, *date - Duration::days(1), event) { +                                    Ok(updated) => { +                                        match db::insert(&conn, &new) { +                                            Ok(_) => { +                                                update::send(tx.clone(), Msg::UpdateRepeatedFrom { +                                                    old: event.clone(), +                                                    updated, +                                                    new +                                                }); +                                                dialog.close() +                                            }, +                                            Err(err) => eprintln!("Error when inserting event: {}", err) +                                        } +                                    }, +                                    Err(err) => eprintln!("Error when updating event: {}", err) +                                } +                            }                          }                      }                      None => eprintln!("Event is not valid.") @@ -212,6 +258,7 @@ pub async fn show(app: &App, target: Target) {      if let Some(event) = event {          let label = match target {              Target::Update { .. } => "Supprimer", +            Target::UpdateFromOccurence { .. } => "Supprimer à partir de l’occurence",              _ => "Supprimer l’occurence",          };          let button = gtk::Button::builder().label(label).build(); @@ -231,6 +278,15 @@ pub async fn show(app: &App, target: Target) {                          }                      }                  } +                Target::UpdateFromOccurence { date, .. } => { +                    match update_repetition_until(&conn, date - Duration::days(1), &event) { +                        Ok(updated) => { +                            update::send(tx.clone(), Msg::UpdateEvent { old: event.clone(), new: updated }); +                            dialog.close() +                        }, +                        Err(err) => eprintln!("Error when updating event: {}", err) +                    } +                }                  _ => {                      let operation = db::delete(&conn, &event.id);                      if operation.is_ok() { @@ -273,22 +329,13 @@ fn delete_repetition_occurence(      }  } -fn time_entry(time: Option<NaiveTime>) -> gtk::Entry { -    entry( -        &time -            .map(event::pprint_time) -            .unwrap_or_else(|| "".to_string()), -    ) -} - -fn entry(text: &str) -> gtk::Entry { -    gtk::Entry::builder().text(text).margin_bottom(10).build() -} - -fn label(text: &str) -> gtk::Label { -    gtk::Label::builder() -        .label(text) -        .halign(gtk::Align::Start) -        .margin_bottom(5) -        .build() +fn update_repetition_until(conn: &Connection, date: NaiveDate, event: &Event) -> Result<Event> { +    let mut with_repetition_until = event.clone(); +    with_repetition_until.repetition = event.repetition.as_ref().map(|r| Repetition { +        frequency: r.frequency.clone(), +        removed_occurences: r.removed_occurences.clone(), +        until: Some(date), +    }); +    db::update(conn, &with_repetition_until)?; +    Ok(with_repetition_until)  } diff --git a/src/gui/form/repetition.rs b/src/gui/form/repetition.rs index 1d36765..4da65ac 100644 --- a/src/gui/form/repetition.rs +++ b/src/gui/form/repetition.rs @@ -1,13 +1,15 @@  use gtk4 as gtk; -use chrono::{Weekday, Weekday::*}; +use chrono::{NaiveDate, Weekday, Weekday::*};  use gtk::prelude::*;  use std::collections::HashSet; +use crate::gui::form::utils;  use crate::model::{ -    repetition, +    event, repetition,      repetition::{DayOfMonth, Frequency, Repetition},  }; +use crate::validation;  static WEEKDAYS_STR: [&str; 7] = [      "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche", @@ -25,6 +27,7 @@ pub struct Model {      first_day_radio: gtk::CheckButton,      first_day_dropdown: gtk::DropDown,      yearly_radio: gtk::CheckButton, +    until: gtk::Entry,  }  pub fn view(repetition: Option<&Repetition>) -> Model { @@ -52,7 +55,7 @@ pub fn view(repetition: Option<&Repetition>) -> Model {          &no_radio,          !default.is_empty(),          &day_interval_entry, -        "Interval de jours", +        "Intervalle de jours",      );      view.append(&day_interval_box); @@ -84,9 +87,18 @@ pub fn view(repetition: Option<&Repetition>) -> Model {          .group(&no_radio)          .label("Annuel")          .active(frequency == Some(Frequency::Yearly)) +        .margin_bottom(10)          .build();      view.append(&yearly_radio); +    let until = repetition +        .as_ref() +        .and_then(|r| r.until) +        .map(|u| utils::entry(&u.format(event::DATE_FORMAT).to_string())) +        .unwrap_or_default(); +    view.append(&utils::label("Répéter jusqu’au")); +    view.append(&until); +      Model {          view,          no_radio, @@ -97,6 +109,7 @@ pub fn view(repetition: Option<&Repetition>) -> Model {          first_day_radio,          first_day_dropdown,          yearly_radio, +        until,      }  } @@ -126,7 +139,10 @@ fn label(text: &str) -> gtk::Label {          .build()  } -pub fn validate(model: &Model) -> Result<Option<Repetition>, String> { +pub fn validate( +    model: &Model, +    removed_occurences: HashSet<usize>, +) -> Result<Option<Repetition>, String> {      let frequency = if model.no_radio.is_active() {          Ok(None)      } else if model.day_interval_radio.is_active() { @@ -148,8 +164,22 @@ pub fn validate(model: &Model) -> Result<Option<Repetition>, String> {          Err("Aucune option n’a été sélectionnée".to_string())      }?; +    // Check until +    let until = (if frequency.is_some() { +        match validation::non_empty(model.until.buffer().text()) { +            Some(until) => match NaiveDate::parse_from_str(&until, event::DATE_FORMAT) { +                Ok(until) => Ok(Some(until)), +                Err(_) => Err(format!("Can’t parse date from {}", until)), +            }, +            None => Ok(None), +        } +    } else { +        Ok(None) +    })?; +      Ok(frequency.map(|frequency| Repetition {          frequency, -        removed_occurences: HashSet::new(), +        removed_occurences, +        until,      }))  } diff --git a/src/gui/form/utils.rs b/src/gui/form/utils.rs new file mode 100644 index 0000000..5cf59e3 --- /dev/null +++ b/src/gui/form/utils.rs @@ -0,0 +1,21 @@ +use gtk4 as gtk; + +use chrono::NaiveTime; + +use crate::model::time; + +pub fn time_entry(time: Option<NaiveTime>) -> gtk::Entry { +    entry(&time.map(time::pprint).unwrap_or_else(|| "".to_string())) +} + +pub fn entry(text: &str) -> gtk::Entry { +    gtk::Entry::builder().text(text).margin_bottom(10).build() +} + +pub fn label(text: &str) -> gtk::Label { +    gtk::Label::builder() +        .label(text) +        .halign(gtk::Align::Start) +        .margin_bottom(5) +        .build() +} diff --git a/src/gui/update.rs b/src/gui/update.rs index 372fb24..bd4e7a9 100644 --- a/src/gui/update.rs +++ b/src/gui/update.rs @@ -3,6 +3,7 @@ use chrono::{Duration, NaiveDate};  use gtk4::prelude::GridExt;  use std::collections::HashSet;  use std::iter::FromIterator; +use uuid::Uuid;  use crate::{      db, @@ -21,15 +22,19 @@ pub enum Msg {          date: NaiveDate,      },      ShowRepetitionDialog { +        event_id: Uuid,          date: NaiveDate, -        event: Event,      },      ShowUpdateForm { -        event: Event, +        event_id: Uuid,      },      ShowUpdateRepetitionForm { +        event_id: Uuid, +        date: NaiveDate, +    }, +    ShowUpdateFromOccurence { +        event_id: Uuid,          date: NaiveDate, -        event: Event,      },      AddEvent {          new: Event, @@ -44,6 +49,11 @@ pub enum Msg {          date: NaiveDate,          new: Event,      }, +    UpdateRepeatedFrom { +        old: Event, +        updated: Event, +        new: Event, +    },      DeleteEvent {          event: Event,      }, @@ -60,12 +70,62 @@ pub async fn event_handler(rx: Receiver<Msg>, mut app: App) {      while let Ok(msg) = rx.recv().await {          match msg {              Msg::ShowAddForm { date } => form::show(&app, form::Target::New { date }).await, -            Msg::ShowRepetitionDialog { date, event } => { -                form::repetition_dialog(&app, date, event).await +            Msg::ShowRepetitionDialog { date, event_id } => { +                match app.recurring_events.iter().position(|e| e.id == event_id) { +                    Some(index) => { +                        form::repetition_dialog(&app, date, &app.recurring_events[index]).await +                    } +                    None => eprintln!("Event not found with id: {}", event_id), +                }              } -            Msg::ShowUpdateForm { event } => form::show(&app, form::Target::Update { event }).await, -            Msg::ShowUpdateRepetitionForm { date, event } => { -                form::show(&app, form::Target::UpdateRepetition { event, date }).await +            Msg::ShowUpdateForm { event_id } => { +                match app.recurring_events.iter().position(|e| e.id == event_id) { +                    Some(index) => { +                        form::show( +                            &app, +                            form::Target::Update { +                                event: app.recurring_events[index].clone(), +                            }, +                        ) +                        .await +                    } +                    None => match app.events.iter().position(|e| e.id == event_id) { +                        Some(index) => { +                            form::show( +                                &app, +                                form::Target::Update { +                                    event: app.events[index].clone(), +                                }, +                            ) +                            .await +                        } +                        None => eprintln!("Event not found with id: {}", event_id), +                    }, +                } +            } +            Msg::ShowUpdateRepetitionForm { event_id, date } => { +                match app.recurring_events.iter().position(|e| e.id == event_id) { +                    Some(index) => { +                        form::show( +                            &app, +                            form::Target::UpdateRepetition { +                                date, +                                event: app.recurring_events[index].clone(), +                            }, +                        ) +                        .await +                    } +                    None => eprintln!("Event not found with id: {}", event_id), +                } +            } +            Msg::ShowUpdateFromOccurence { event_id, date } => { +                match app.recurring_events.iter().position(|e| e.id == event_id) { +                    Some(index) => { +                        let event = app.recurring_events[index].clone(); +                        form::show(&app, form::Target::UpdateFromOccurence { date, event }).await +                    } +                    None => eprintln!("Event not found with id: {}", event_id), +                }              }              Msg::AddEvent { new } => {                  let refresh_dates = add(&mut app, &new); @@ -87,6 +147,12 @@ pub async fn event_handler(rx: Receiver<Msg>, mut app: App) {                  refresh_dates.insert(date);                  refresh(&app, &refresh_dates)              } +            Msg::UpdateRepeatedFrom { old, updated, new } => { +                let mut refresh_dates = remove(&mut app, &old); +                refresh_dates.extend(add(&mut app, &updated)); +                refresh_dates.extend(add(&mut app, &new)); +                refresh(&app, &refresh_dates); +            }              Msg::DeleteEvent { event } => {                  let refresh_dates = remove(&mut app, &event);                  refresh(&app, &refresh_dates) @@ -158,6 +224,7 @@ fn remove(app: &mut App, event: &Event) -> HashSet<NaiveDate> {  }  /// Remove event repetition +/// TODO: Completely remove the event if it’s the last remaining occurence  fn remove_occurence(app: &mut App, event: &Event, occurence: usize) {      match app.recurring_events.iter().position(|e| e.id == event.id) {          Some(index) => { diff --git a/src/main.rs b/src/main.rs index dce577a..83a0446 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod cli;  mod db;  mod gui;  mod model; +mod validation;  use anyhow::Result;  use structopt::StructOpt; diff --git a/src/model/event.rs b/src/model/event.rs index 5e92692..e556f6e 100644 --- a/src/model/event.rs +++ b/src/model/event.rs @@ -1,9 +1,10 @@ -use chrono::Timelike;  use chrono::{NaiveDate, NaiveTime};  use std::collections::HashMap;  use uuid::Uuid;  use crate::model::repetition::Repetition; +use crate::model::time; +use crate::validation;  pub static DATE_FORMAT: &str = "%d/%m/%Y"; @@ -19,10 +20,10 @@ pub struct Event {  impl Event {      pub fn pprint(&self) -> String { -        let start = self.start.map(pprint_time).unwrap_or_default(); +        let start = self.start.map(time::pprint).unwrap_or_default();          let end = self              .end -            .map(|t| format!("-{}", pprint_time(t))) +            .map(|t| format!("-{}", time::pprint(t)))              .unwrap_or_default();          let space = if self.start.is_some() || self.end.is_some() {              " " @@ -52,27 +53,6 @@ pub fn repetitions_between(      res  } -pub fn pprint_time(t: NaiveTime) -> String { -    if t.minute() == 0 { -        format!("{}h", t.hour()) -    } else { -        format!("{}h{}", t.hour(), t.minute()) -    } -} - -fn parse_time(t: &str) -> Option<NaiveTime> { -    match t.split('h').collect::<Vec<&str>>()[..] { -        [hours, minutes] => { -            if minutes.trim().is_empty() { -                NaiveTime::from_hms_opt(hours.parse().ok()?, 0, 0) -            } else { -                NaiveTime::from_hms_opt(hours.parse().ok()?, minutes.parse().ok()?, 0) -            } -        } -        _ => None, -    } -} -  // Validation  pub fn validate( @@ -83,8 +63,8 @@ pub fn validate(      end: String,      repetition: Option<Repetition>,  ) -> Option<Event> { -    let start = validate_time(start)?; -    let end = validate_time(end)?; +    let start = validation::time(start)?; +    let end = validation::time(end)?;      match (start, end) {          (Some(s), Some(e)) if s > e => None?, @@ -94,27 +74,9 @@ pub fn validate(      Some(Event {          id,          date: NaiveDate::parse_from_str(&date, DATE_FORMAT).ok()?, -        name: validate_name(name)?, +        name: validation::non_empty(name)?,          start,          end,          repetition,      })  } - -fn validate_time(time: String) -> Option<Option<NaiveTime>> { -    let time = time.trim(); -    if time.is_empty() { -        Some(None) -    } else { -        parse_time(time).map(Some) -    } -} - -fn validate_name(name: String) -> Option<String> { -    let name = name.trim(); -    if name.is_empty() { -        None -    } else { -        Some(name.to_string()) -    } -} diff --git a/src/model/mod.rs b/src/model/mod.rs index c1beb62..0aefbc6 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,2 +1,3 @@  pub mod event;  pub mod repetition; +pub mod time; diff --git a/src/model/repetition.rs b/src/model/repetition.rs index 872944a..eb8cb6d 100644 --- a/src/model/repetition.rs +++ b/src/model/repetition.rs @@ -6,6 +6,7 @@ use std::collections::HashSet;  pub struct Repetition {      pub frequency: Frequency,      pub removed_occurences: HashSet<usize>, +    pub until: Option<NaiveDate>,  }  #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -48,6 +49,7 @@ impl Repetition {          let repeat = |mut date, next: Box<dyn Fn(NaiveDate) -> NaiveDate>| {              let mut repetitions = vec![];              let mut iteration: usize = 0; +            let end = self.until.unwrap_or(end);              while date <= end {                  if date >= event {                      if date >= start && !self.removed_occurences.contains(&iteration) { @@ -195,6 +197,7 @@ mod tests {          let repetition = Repetition {              frequency: Frequency::Daily { period: 2 },              removed_occurences: HashSet::from([0, 2, 3]), +            until: None,          };          assert_eq!(              repetition.between(d(2020, 7, 1), d(2020, 7, 1), d(2020, 7, 9)), @@ -209,6 +212,7 @@ mod tests {                  day: DayOfMonth::Day { day: 8 },              },              removed_occurences: HashSet::from([1, 3]), +            until: None,          };          assert_eq!(              repetition.between(d(2020, 1, 8), d(2020, 1, 8), d(2020, 4, 8)), @@ -225,6 +229,7 @@ mod tests {                  },              },              removed_occurences: HashSet::from([1, 2, 3]), +            until: None,          };          assert_eq!(              repetition.between(d(2020, 2, 1), d(2020, 2, 1), d(2020, 7, 1)), @@ -237,6 +242,7 @@ mod tests {          let repetition = Repetition {              frequency: Frequency::Yearly,              removed_occurences: HashSet::from([3]), +            until: None,          };          assert_eq!(              repetition.between(d(2018, 5, 5), d(2019, 8, 1), d(2022, 5, 5)), @@ -249,6 +255,7 @@ mod tests {          let repetition = Repetition {              frequency: Frequency::Yearly,              removed_occurences: HashSet::from([1]), +            until: None,          };          assert_eq!(              repetition.occurence_index(d(2020, 1, 1), d(2022, 1, 1)), @@ -256,6 +263,19 @@ mod tests {          )      } +    #[test] +    fn repetition_stops_after_until() { +        let repetition = Repetition { +            frequency: Frequency::Yearly, +            removed_occurences: HashSet::new(), +            until: Some(d(2022, 1, 1)), +        }; +        assert_eq!( +            repetition.between(d(2020, 1, 1), d(2020, 1, 1), d(2024, 1, 1)), +            vec!(d(2020, 1, 1), d(2021, 1, 1), d(2022, 1, 1)) +        ) +    } +      fn d(y: i32, m: u32, d: u32) -> NaiveDate {          NaiveDate::from_ymd(y, m, d)      } @@ -264,6 +284,7 @@ mod tests {          Repetition {              frequency,              removed_occurences: HashSet::new(), +            until: None,          }      }  } diff --git a/src/model/time.rs b/src/model/time.rs new file mode 100644 index 0000000..10cf6d3 --- /dev/null +++ b/src/model/time.rs @@ -0,0 +1,22 @@ +use chrono::{NaiveTime, Timelike}; + +pub fn pprint(t: NaiveTime) -> String { +    if t.minute() == 0 { +        format!("{}h", t.hour()) +    } else { +        format!("{}h{}", t.hour(), t.minute()) +    } +} + +pub fn parse(t: &str) -> Option<NaiveTime> { +    match t.split('h').collect::<Vec<&str>>()[..] { +        [hours, minutes] => { +            if minutes.trim().is_empty() { +                NaiveTime::from_hms_opt(hours.parse().ok()?, 0, 0) +            } else { +                NaiveTime::from_hms_opt(hours.parse().ok()?, minutes.parse().ok()?, 0) +            } +        } +        _ => None, +    } +} diff --git a/src/validation/mod.rs b/src/validation/mod.rs new file mode 100644 index 0000000..07a7c4c --- /dev/null +++ b/src/validation/mod.rs @@ -0,0 +1,21 @@ +use chrono::NaiveTime; + +use crate::model::time; + +pub fn time(time: String) -> Option<Option<NaiveTime>> { +    let time = time.trim(); +    if time.is_empty() { +        Some(None) +    } else { +        time::parse(time).map(Some) +    } +} + +pub fn non_empty(str: String) -> Option<String> { +    let str = str.trim(); +    if str.is_empty() { +        None +    } else { +        Some(str.to_string()) +    } +} | 
