diff options
author | Joris | 2022-03-12 13:27:29 +0100 |
---|---|---|
committer | Joris | 2022-03-12 13:36:09 +0100 |
commit | d584df359640176ec4bc06f59d1e8d42ab17a413 (patch) | |
tree | 6cfaf676fc2ecf4e61067aa376fb2bed0d984d79 | |
parent | aad7b9601dfa05255d5c24f4a6377d9a25646d45 (diff) |
Update and delete recurring events
-rw-r--r-- | Cargo.lock | 1 | ||||
-rw-r--r-- | Cargo.toml | 5 | ||||
-rw-r--r-- | README.md | 15 | ||||
-rw-r--r-- | flake.nix | 2 | ||||
-rw-r--r-- | src/cli/mod.rs | 6 | ||||
-rw-r--r-- | src/db/migrations/1-init.sql | 3 | ||||
-rw-r--r-- | src/db/mod.rs | 82 | ||||
-rw-r--r-- | src/gui/app.rs | 17 | ||||
-rw-r--r-- | src/gui/calendar.rs | 28 | ||||
-rw-r--r-- | src/gui/form/mod.rs | 238 | ||||
-rw-r--r-- | src/gui/form/repetition.rs | 47 | ||||
-rw-r--r-- | src/gui/mod.rs | 9 | ||||
-rw-r--r-- | src/gui/style.css | 2 | ||||
-rw-r--r-- | src/gui/update.rs | 107 | ||||
-rw-r--r-- | src/model/event.rs | 13 | ||||
-rw-r--r-- | src/model/repetition.rs | 142 |
16 files changed, 552 insertions, 165 deletions
@@ -105,6 +105,7 @@ dependencies = [ "serde", "serde_json", "structopt", + "thiserror", "uuid", ] @@ -1,8 +1,8 @@ [package] name = "calendar" version = "0.1.0" -authors = ["Joris Guyonvarch"] -edition = "2021" +authors = ["Joris GUYONVARCH"] +edition = "2018" [dependencies] anyhow = "1.0" @@ -14,4 +14,5 @@ rusqlite_migration = "0.5" serde = { version = "1.0", features = [ "derive" ] } serde_json = "1.0" structopt = "0.3" +thiserror = "1.0" uuid = { version = "0.8", features = [ "v4" ] } @@ -16,16 +16,21 @@ cargo test # TODO -## V1 - -- Update / delete specific repetition occurences. - ## V2 ### Optimizations - Optimize refresh +### Mouse + +- Improve mouse precision + +### Recurring + +- Add end date for a recurring event +- Give the possibility to update only future events when modifying recurring event + ### Categorize events 1. CRUD for list of types (name + color). @@ -42,4 +47,4 @@ cargo test - Print errors on forms when validating. - Validate the form when pressing enter on any field. - Select the default focus with a button or a shortcut. -- Specify until which date a repeated event is +- Specify until which date a recurring event is @@ -19,7 +19,7 @@ { devShell = mkShell { buildInputs = [ - rust-bin.stable."1.58.0".default + rust-bin.stable."1.56.1".default rust-analyzer cargo-watch pkgconfig diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 862bcbe..88726ca 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,9 +6,9 @@ use crate::{db, model::event, model::event::Event}; pub fn today(conn: &Connection) -> Result<String> { let today = Local::today().naive_local(); - let mut events = db::list_non_repeated_between(conn, today, today)?; - let repeated_events = db::list_repeated(conn)?; - let repetitions = event::repetitions_between(&repeated_events, today, today); + let mut events = db::list_non_recurring_between(conn, today, today)?; + let recurring_events = db::list_recurring(conn)?; + let repetitions = event::repetitions_between(&recurring_events, today, today); for repetition in repetitions.values().flatten() { events.push(repetition.clone()); } diff --git a/src/db/migrations/1-init.sql b/src/db/migrations/1-init.sql index 7e49764..467e481 100644 --- a/src/db/migrations/1-init.sql +++ b/src/db/migrations/1-init.sql @@ -8,3 +8,6 @@ CREATE TABLE IF NOT EXISTS "events" ( "created" TEXT NOT NULL, /* DATETIME */ "updated" TEXT NOT NULL /* DATETIME */ ); + +CREATE INDEX events_date_index on events (date); +CREATE INDEX events_repetition_index on events (repetition); diff --git a/src/db/mod.rs b/src/db/mod.rs index 1bbf21e..3d498a8 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use chrono::NaiveDate; +use chrono::{NaiveDate, NaiveTime}; use rusqlite::{params, Connection}; use rusqlite_migration::{Migrations, M}; use uuid::Uuid; @@ -34,7 +34,7 @@ pub fn update(conn: &Connection, event: &Event) -> Result<()> { }; conn.execute( - "UPDATE events SET date = ?, start = ?, end = ?, name = ?, repetition = ?, updated = datetime() where id = ?", + "UPDATE events SET date = ?, start = ?, end = ?, name = ?, repetition = ?, updated = datetime() WHERE id = ?", params![event.date, event.start, event.end, event.name, repetition, event.id.to_hyphenated().to_string()] )?; @@ -50,8 +50,7 @@ pub fn delete(conn: &Connection, id: &Uuid) -> Result<()> { Ok(()) } -// TODO: Don’t use unwrap -pub fn list_repeated(conn: &Connection) -> Result<Vec<Event>> { +pub fn list_recurring(conn: &Connection) -> Result<Vec<Event>> { let mut stmt = conn.prepare( " SELECT id, date, start, end, name, repetition @@ -60,23 +59,48 @@ pub fn list_repeated(conn: &Connection) -> Result<Vec<Event>> { )?; let iter = stmt.query_map([], |row| { - let uuid: String = row.get(0)?; - let repetition: Option<String> = row.get(5)?; - Ok(Event { - id: Uuid::parse_str(&uuid).unwrap(), - date: row.get(1)?, - start: row.get(2)?, - end: row.get(3)?, - name: row.get(4)?, - repetition: repetition.and_then(|r: String| serde_json::from_str(&r).ok()), - }) + Ok(read_recurring_event( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) })?; - Ok(iter.map(|r| r.unwrap()).collect()) + let mut res = vec![]; + for event in iter { + res.push(event??) + } + Ok(res) +} + +fn read_recurring_event( + uuid: String, + date: NaiveDate, + start: Option<NaiveTime>, + end: Option<NaiveTime>, + name: String, + repetition: Option<String>, +) -> Result<Event> { + let id = Uuid::parse_str(&uuid)?; + let repetition = match repetition { + Some(r) => Some(serde_json::from_str(&r)?), + None => None, + }; + + Ok(Event { + id, + date, + start, + end, + name, + repetition, + }) } -// TODO: Don’t use unwrap -pub fn list_non_repeated_between( +pub fn list_non_recurring_between( conn: &Connection, start: NaiveDate, end: NaiveDate, @@ -94,15 +118,23 @@ pub fn list_non_repeated_between( let iter = stmt.query_map([start, end], |row| { let uuid: String = row.get(0)?; - Ok(Event { - id: Uuid::parse_str(&uuid).unwrap(), - date: row.get(1)?, - start: row.get(2)?, - end: row.get(3)?, - name: row.get(4)?, + let date = row.get(1)?; + let start = row.get(2)?; + let end = row.get(3)?; + let name = row.get(4)?; + Ok(Uuid::parse_str(&uuid).map(|id| Event { + id, + date, + start, + end, + name, repetition: None, - }) + })) })?; - Ok(iter.map(|r| r.unwrap()).collect()) + let mut res = vec![]; + for event in iter { + res.push(event??) + } + Ok(res) } diff --git a/src/gui/app.rs b/src/gui/app.rs index ebaceb3..9f37301 100644 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -1,5 +1,6 @@ use gtk4 as gtk; +use anyhow::Result; use async_channel::Sender; use chrono::{Datelike, Duration, NaiveDate, Weekday}; use gtk::glib::signal::Inhibit; @@ -16,7 +17,7 @@ pub struct App { pub window: Rc<gtk::ApplicationWindow>, pub grid: gtk::Grid, pub events: Vec<Event>, - pub repeated_events: Vec<Event>, + pub recurring_events: Vec<Event>, pub today: NaiveDate, pub start_date: NaiveDate, pub end_date: NaiveDate, @@ -24,7 +25,7 @@ pub struct App { } impl App { - pub fn new(conn: Rc<Connection>, app: >k::Application, tx: Sender<Msg>) -> Self { + pub fn new(conn: Rc<Connection>, app: >k::Application, tx: Sender<Msg>) -> Result<Self> { let window = Rc::new( gtk::ApplicationWindow::builder() .application(app) @@ -40,8 +41,8 @@ impl App { NaiveDate::from_isoywd(today.year(), today.iso_week().week(), Weekday::Mon); let end_date = start_date + Duration::days(7 * 4 - 1); - let events = db::list_non_repeated_between(&conn, start_date, end_date).unwrap_or_default(); - let repeated_events = db::list_repeated(&conn).unwrap_or_default(); + let events = db::list_non_recurring_between(&conn, start_date, end_date)?; + let recurring_events = db::list_recurring(&conn)?; let grid = calendar::create( tx.clone(), @@ -49,7 +50,7 @@ impl App { start_date, end_date, &events, - &repeated_events, + &recurring_events, ); window.set_child(Some(&grid)); @@ -61,16 +62,16 @@ impl App { Inhibit(false) }); - Self { + Ok(Self { conn, window, grid, events, - repeated_events, + recurring_events, today, start_date, end_date, tx, - } + }) } } diff --git a/src/gui/calendar.rs b/src/gui/calendar.rs index 3f5b6a7..cad2465 100644 --- a/src/gui/calendar.rs +++ b/src/gui/calendar.rs @@ -19,7 +19,7 @@ pub fn create( start_date: NaiveDate, end_date: NaiveDate, events: &[Event], - repeated_events: &[Event], + recurring_events: &[Event], ) -> gtk::Grid { let grid = gtk::Grid::builder().build(); @@ -27,7 +27,7 @@ pub fn create( grid.attach(&day_title(col), col, 0, 1, 1); } - let repetitions = event::repetitions_between(repeated_events, start_date, end_date); + let repetitions = event::repetitions_between(recurring_events, start_date, end_date); attach_days(tx.clone(), &grid, start_date, today, events, &repetitions); let event_controller_key = gtk::EventControllerKey::new(); @@ -126,12 +126,12 @@ pub fn day_entry( .iter() .filter(|e| e.date == date) .collect::<Vec<&Event>>(); - let repeated_events = repetitions.get(&date).cloned().unwrap_or_default(); - events.extend(repeated_events.iter()); + let recurring_events = repetitions.get(&date).cloned().unwrap_or_default(); + events.extend(recurring_events.iter()); events.sort_by_key(|e| e.start); if !events.is_empty() { - vbox.append(&day_events(tx, events)); + vbox.append(&day_events(date, tx, events)); } gtk::ScrolledWindow::builder() @@ -147,18 +147,24 @@ fn day_label(today: NaiveDate, date: NaiveDate) -> gtk::Label { .label(&format!( "{} {}", date.day(), - if date == today || date.day() == 1 { MONTHES[date.month0() as usize] } else { "" } + if date == today || date.day() == 1 { + MONTHES[date.month0() as usize] + } else { + "" + } )) .halign(gtk::Align::Start) .build(); label.add_css_class("g-Calendar__DayNumber"); - if date.day() == 1 { label.add_css_class("g-Calendar__DayNumber--FirstOfMonth") } + if date.day() == 1 { + label.add_css_class("g-Calendar__DayNumber--FirstOfMonth") + } label } -fn day_events(tx: Sender<Msg>, events: Vec<&Event>) -> gtk::Box { +fn day_events(date: NaiveDate, tx: Sender<Msg>, events: Vec<&Event>) -> gtk::Box { let vbox = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .build(); @@ -174,7 +180,11 @@ fn day_events(tx: Sender<Msg>, events: Vec<&Event>) -> gtk::Box { glib::clone!(@strong event, @strong tx => move |gesture, n, _, _| { gesture.set_state(gtk::EventSequenceState::Claimed); if n == 2 { - update::send(tx.clone(), Msg::ShowUpdateForm { event: event.clone() }); + if event.repetition.is_some() { + update::send(tx.clone(), Msg::ShowRepetitionDialog { date, event: event.clone() }); + } else { + update::send(tx.clone(), Msg::ShowUpdateForm { event: event.clone() }); + } } }), ); diff --git a/src/gui/form/mod.rs b/src/gui/form/mod.rs index 57ccac7..68e6539 100644 --- a/src/gui/form/mod.rs +++ b/src/gui/form/mod.rs @@ -2,8 +2,13 @@ mod repetition; use gtk4 as gtk; +use anyhow::Result; +use chrono::{NaiveDate, NaiveTime}; use gtk::glib; use gtk::prelude::*; +use rusqlite::Connection; +use thiserror::Error; +use uuid::Uuid; use crate::{ db, @@ -11,12 +16,70 @@ use crate::{ model::{event, event::Event}, }; -pub async fn show(app: &App, event: Event, is_new: bool) { +pub async fn repetition_dialog(app: &App, date: NaiveDate, event: Event) { let dialog = gtk::Dialog::builder() .transient_for(&*app.window) .modal(true) - .title(if is_new { "Ajouter" } else { "Modifier" }) - .css_classes(vec!["g-Form".to_string()]) + .title("Modifier") + .css_classes(vec!["g-Dialog".to_string()]) + .build(); + + let content_area = dialog.content_area(); + + let lines = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .build(); + content_area.append(&lines); + + let button = gtk::Button::builder() + .label("Cette occurence") + .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::ShowUpdateRepetitionForm { date, event: event.clone() }); + dialog.close() + })); + + let button = gtk::Button::builder() + .label("Toutes les occurences") + .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() }); + dialog.close() + })); + + dialog.run_future().await; +} + +#[derive(Clone)] +pub enum Target { + New { date: NaiveDate }, + Update { event: Event }, + UpdateRepetition { event: Event, date: NaiveDate }, +} + +pub async fn show(app: &App, target: Target) { + let event = match target { + Target::New { .. } => None, + Target::Update { ref event } => Some(event.clone()), + Target::UpdateRepetition { ref event, .. } => Some(event.clone()), + }; + + let title = if event.is_some() { + "Modifier" + } else { + "Ajouter" + }; + + let dialog = gtk::Dialog::builder() + .transient_for(&*app.window) + .modal(true) + .title(title) + .css_classes(vec!["g-Dialog".to_string()]) .build(); let content_area = dialog.content_area(); @@ -29,7 +92,6 @@ pub async fn show(app: &App, event: Event, is_new: bool) { let columns = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .build(); - columns.add_css_class("g-Form__Columns"); lines.append(&columns); // First column @@ -37,79 +99,145 @@ pub async fn show(app: &App, event: Event, is_new: bool) { let column1 = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .build(); - column1.add_css_class("g-Form__Inputs"); columns.append(&column1); - let name = entry(&event.name); + let name = event.as_ref().map(|e| entry(&e.name)).unwrap_or_default(); column1.append(&label("Événement")); column1.append(&name); - let date = entry(&event.date.format(event::DATE_FORMAT).to_string()); + let date = match target { + Target::New { date } => date, + Target::Update { ref event } => event.date, + Target::UpdateRepetition { date, .. } => date, + }; + let date = entry(&date.format(event::DATE_FORMAT).to_string()); column1.append(&label("Jour")); column1.append(&date); - let start = entry( - &event - .start - .map(event::pprint_time) - .unwrap_or_else(|| "".to_string()), - ); + let start = event + .as_ref() + .map(|e| time_entry(e.start)) + .unwrap_or_else(|| entry("")); column1.append(&label("Début")); column1.append(&start); - let end = entry( - &event - .end - .map(event::pprint_time) - .unwrap_or_else(|| "".to_string()), - ); + let end = event + .as_ref() + .map(|e| time_entry(e.end)) + .unwrap_or_else(|| entry("")); column1.append(&label("Fin")); column1.append(&end); // Second column - let repetition_model = repetition::view(&event); + let repetition = match target { + Target::Update { ref event } => event.repetition.as_ref(), + _ => None, + }; + let repetition_model = repetition::view(repetition); columns.append(&repetition_model.view); // Buttons + let button_title = match target { + Target::New { .. } => "Créer", + Target::Update { .. } => "Modifier", + Target::UpdateRepetition { .. } => "Modifier l’occurence", + }; + let button = gtk::Button::builder() - .label(if is_new { "Créer" } else { "Modifier" }) + .label(button_title) .margin_bottom(10) .build(); lines.append(&button); let conn = app.conn.clone(); let tx = app.tx.clone(); - button.connect_clicked(glib::clone!(@weak dialog, @strong event => move |_| { + button.connect_clicked(glib::clone!(@weak dialog, @strong target, @strong event => move |_| { match repetition::validate(&repetition_model) { Ok(repetition) => { - match event::validate(event.id, date.buffer().text(), name.buffer().text(), start.buffer().text(), end.buffer().text(), repetition) { + let id = match &target { + Target::Update {event} => event.id, + _ => Uuid::new_v4(), + }; + match event::validate(id, date.buffer().text(), name.buffer().text(), start.buffer().text(), end.buffer().text(), repetition) { Some(new) => { - match if is_new { db::insert(&conn, &new) } else { db::update(&conn, &new) } { - Ok(_) => { - let msg = if is_new { Msg::AddEvent { new } } else { Msg::UpdateEvent { old: event.clone(), new } }; - update::send(tx.clone(), msg); - dialog.close() - }, - Err(err) => eprintln!("Error when upserting event: {err}") + match &target { + Target::New {..} => { + match db::insert(&conn, &new) { + Ok(_) => { + update::send(tx.clone(), Msg::AddEvent { new }); + dialog.close() + }, + Err(err) => eprintln!("Error when inserting event: {}", err) + } + } + Target::Update {event} => { + match db::update(&conn, &new) { + Ok(_) => { + update::send(tx.clone(), Msg::UpdateEvent { old: event.clone(), new }); + dialog.close() + }, + Err(err) => eprintln!("Error when updating event: {}", err) + } + } + Target::UpdateRepetition { event, date } => { + // TODO: improve intermediate error state + match delete_repetition_occurence(&conn, event, *date) { + Ok(occurence) => { + match db::insert(&conn, &new) { + Ok(_) => { + update::send(tx.clone(), Msg::UpdateEventOccurence { + event: event.clone(), + occurence, + date: *date, + new + }) + } + Err(err) => eprintln!("Error when updating repetition: {}", err) + }; + dialog.close() + }, + Err(err) => eprintln!("Error when updating repetition: {}", err) + } + } } } - None => eprintln!("Event is not valid: {event:?}") + None => eprintln!("Event is not valid.") } }, - Err(message) => eprintln!("{message}") + Err(message) => eprintln!("{}", message) } })); - if !is_new { - let button = gtk::Button::builder().label("Supprimer").build(); + if let Some(event) = event { + let label = match target { + Target::Update { .. } => "Supprimer", + _ => "Supprimer l’occurence", + }; + let button = gtk::Button::builder().label(label).build(); lines.append(&button); let conn = app.conn.clone(); let tx = app.tx.clone(); button.connect_clicked(glib::clone!(@weak dialog => move |_| { - if db::delete(&conn, &event.id).is_ok() { - update::send(tx.clone(), Msg::DeleteEvent { event: event.clone() }); - dialog.close() + match target { + Target::UpdateRepetition { date, .. } => { + match delete_repetition_occurence(&conn, &event, date) { + Ok(occurence) => { + update::send(tx.clone(), Msg::DeleteOccurence { event: event.clone(), date, occurence }); + dialog.close() + } + Err(err) => { + eprintln!("{:?}", err); + } + } + } + _ => { + let operation = db::delete(&conn, &event.id); + if operation.is_ok() { + update::send(tx.clone(), Msg::DeleteEvent { event: event.clone() }); + dialog.close() + } + } } })); } @@ -117,6 +245,42 @@ pub async fn show(app: &App, event: Event, is_new: bool) { dialog.run_future().await; } +#[derive(Error, Debug)] +enum DeleteError { + #[error("Repetition not found")] + RepetitionNotFound, + #[error("Occurence not found")] + OccurenceNotFound, +} + +fn delete_repetition_occurence( + conn: &Connection, + event: &Event, + occurence_date: NaiveDate, +) -> Result<usize> { + if let Some(ref repetition) = event.repetition { + if let Some(occurence) = repetition.occurence_index(event.date, occurence_date) { + let mut event = event.clone(); + let mut repetition = repetition.clone(); + repetition.removed_occurences.insert(occurence); + event.repetition = Some(repetition); + db::update(conn, &event).map(|_| occurence) + } else { + Err(anyhow::Error::new(DeleteError::OccurenceNotFound)) + } + } else { + Err(anyhow::Error::new(DeleteError::RepetitionNotFound)) + } +} + +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() } diff --git a/src/gui/form/repetition.rs b/src/gui/form/repetition.rs index accb091..1d36765 100644 --- a/src/gui/form/repetition.rs +++ b/src/gui/form/repetition.rs @@ -2,13 +2,11 @@ use gtk4 as gtk; use chrono::{Weekday, Weekday::*}; use gtk::prelude::*; +use std::collections::HashSet; -use crate::{ - model::event::Event, - model::{ - repetition, - repetition::{DayOfMonth, Repetition}, - }, +use crate::model::{ + repetition, + repetition::{DayOfMonth, Frequency, Repetition}, }; static WEEKDAYS_STR: [&str; 7] = [ @@ -29,7 +27,7 @@ pub struct Model { yearly_radio: gtk::CheckButton, } -pub fn view(event: &Event) -> Model { +pub fn view(repetition: Option<&Repetition>) -> Model { let view = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .build(); @@ -39,12 +37,14 @@ pub fn view(event: &Event) -> Model { let no_radio = gtk::CheckButton::builder() .label("Non") - .active(event.repetition.is_none()) + .active(repetition.is_none()) .build(); view.append(&no_radio); - let default = match event.repetition { - Some(Repetition::Daily { period }) => period.to_string(), + let frequency = repetition.as_ref().map(|r| r.frequency.clone()); + + let default = match frequency { + Some(Frequency::Daily { period }) => period.to_string(), _ => "".to_string(), }; let day_interval_entry = gtk::Entry::builder().text(&default).build(); @@ -56,8 +56,8 @@ pub fn view(event: &Event) -> Model { ); view.append(&day_interval_box); - let default = match event.repetition { - Some(Repetition::Monthly { + let default = match frequency { + Some(Frequency::Monthly { day: DayOfMonth::Day { day }, }) => day.to_string(), _ => "".to_string(), @@ -67,8 +67,8 @@ pub fn view(event: &Event) -> Model { radio_input(&no_radio, !default.is_empty(), &monthly_entry, "Mensuel"); view.append(&monthly_box); - let (active, default) = match event.repetition { - Some(Repetition::Monthly { + let (active, default) = match frequency { + Some(Frequency::Monthly { day: DayOfMonth::Weekday { weekday }, }) => (true, weekday), _ => (false, Mon), @@ -83,7 +83,7 @@ pub fn view(event: &Event) -> Model { let yearly_radio = gtk::CheckButton::builder() .group(&no_radio) .label("Annuel") - .active(event.repetition == Some(Repetition::Yearly)) + .active(frequency == Some(Frequency::Yearly)) .build(); view.append(&yearly_radio); @@ -127,24 +127,29 @@ fn label(text: &str) -> gtk::Label { } pub fn validate(model: &Model) -> Result<Option<Repetition>, String> { - if model.no_radio.is_active() { + let frequency = if model.no_radio.is_active() { Ok(None) } else if model.day_interval_radio.is_active() { let period = repetition::validate_period(&model.day_interval_entry.buffer().text())?; - Ok(Some(Repetition::Daily { period })) + Ok(Some(Frequency::Daily { period })) } else if model.monthly_radio.is_active() { let day = repetition::validate_day(&model.monthly_entry.buffer().text())?; - Ok(Some(Repetition::Monthly { + Ok(Some(Frequency::Monthly { day: DayOfMonth::Day { day }, })) } else if model.first_day_radio.is_active() { let weekday = WEEKDAYS[model.first_day_dropdown.selected() as usize]; - Ok(Some(Repetition::Monthly { + Ok(Some(Frequency::Monthly { day: DayOfMonth::Weekday { weekday }, })) } else if model.yearly_radio.is_active() { - Ok(Some(Repetition::Yearly)) + Ok(Some(Frequency::Yearly)) } else { Err("Aucune option n’a été sélectionnée".to_string()) - } + }?; + + Ok(frequency.map(|frequency| Repetition { + frequency, + removed_occurences: HashSet::new(), + })) } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index c33500b..e7f457f 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -24,8 +24,13 @@ pub fn run(conn: Connection) { fn build_ui(conn: Rc<Connection>, app: >k::Application) { let (tx, rx) = async_channel::unbounded(); - let app = App::new(conn, app, tx); - utils::spawn(update::event_handler(rx, app)) + match App::new(conn, app, tx) { + Ok(app) => utils::spawn(update::event_handler(rx, app)), + Err(err) => { + eprintln!("{}", err); + std::process::exit(1) + } + } } fn load_style() { diff --git a/src/gui/style.css b/src/gui/style.css index 70385d1..59ae30d 100644 --- a/src/gui/style.css +++ b/src/gui/style.css @@ -38,7 +38,7 @@ background-color: pink; } -.g-Form { +.g-Dialog { background-color: white; color: black; padding: 10px; diff --git a/src/gui/update.rs b/src/gui/update.rs index c8dfa6d..419a6e4 100644 --- a/src/gui/update.rs +++ b/src/gui/update.rs @@ -2,6 +2,7 @@ use async_channel::{Receiver, Sender}; use chrono::{Duration, NaiveDate}; use gtk4::prelude::GridExt; use std::collections::HashSet; +use std::iter::FromIterator; use crate::{ gui::{calendar, form, utils, App}, @@ -15,11 +16,41 @@ pub fn send(tx: Sender<Msg>, msg: Msg) { } pub enum Msg { - ShowAddForm { date: NaiveDate }, - ShowUpdateForm { event: Event }, - AddEvent { new: Event }, - UpdateEvent { old: Event, new: Event }, - DeleteEvent { event: Event }, + ShowAddForm { + date: NaiveDate, + }, + ShowRepetitionDialog { + date: NaiveDate, + event: Event, + }, + ShowUpdateForm { + event: Event, + }, + ShowUpdateRepetitionForm { + date: NaiveDate, + event: Event, + }, + AddEvent { + new: Event, + }, + UpdateEvent { + old: Event, + new: Event, + }, + UpdateEventOccurence { + event: Event, + occurence: usize, + date: NaiveDate, + new: Event, + }, + DeleteEvent { + event: Event, + }, + DeleteOccurence { + event: Event, + date: NaiveDate, + occurence: usize, + }, SelectPreviousWeek, SelectNextWeek, } @@ -27,8 +58,14 @@ pub enum Msg { 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, event::init(date), true).await, - Msg::ShowUpdateForm { event } => form::show(&app, event, false).await, + Msg::ShowAddForm { date } => form::show(&app, form::Target::New { date }).await, + Msg::ShowRepetitionDialog { date, event } => { + form::repetition_dialog(&app, date, event).await + } + 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::AddEvent { new } => { let refresh_dates = add(&mut app, &new); refresh(&app, &refresh_dates) @@ -38,10 +75,29 @@ pub async fn event_handler(rx: Receiver<Msg>, mut app: App) { refresh_dates.extend(add(&mut app, &new)); refresh(&app, &refresh_dates); } + Msg::UpdateEventOccurence { + event, + occurence, + date, + new, + } => { + remove_occurence(&mut app, &event, occurence); + let mut refresh_dates = add(&mut app, &new); + refresh_dates.insert(date); + refresh(&app, &refresh_dates) + } Msg::DeleteEvent { event } => { let refresh_dates = remove(&mut app, &event); refresh(&app, &refresh_dates) } + Msg::DeleteOccurence { + event, + date, + occurence, + } => { + remove_occurence(&mut app, &event, occurence); + refresh(&app, &HashSet::from([date])) + } Msg::SelectPreviousWeek => { app.grid.remove_row(4); app.grid.insert_row(1); @@ -66,12 +122,10 @@ pub async fn event_handler(rx: Receiver<Msg>, mut app: App) { /// Remove event and return dates that should be refreshed. fn remove(app: &mut App, event: &Event) -> HashSet<NaiveDate> { if event.repetition.is_some() { - match app.repeated_events.iter().position(|e| e.id == event.id) { + match app.recurring_events.iter().position(|e| e.id == event.id) { Some(index) => { - app.repeated_events.remove(index); - let mut dates = repetition_dates(app, event); - dates.insert(event.date); - dates + app.recurring_events.remove(index); + repetition_dates(app, event) } None => { eprintln!("Event not found when trying to delete {:?}", event); @@ -92,10 +146,35 @@ fn remove(app: &mut App, event: &Event) -> HashSet<NaiveDate> { } } +/// Remove event repetition +fn remove_occurence(app: &mut App, event: &Event, occurence: usize) { + match app.recurring_events.iter().position(|e| e.id == event.id) { + Some(index) => { + let event = app.recurring_events.get_mut(index).unwrap(); + match event.repetition.clone() { + Some(mut repetition) => { + repetition.removed_occurences.insert(occurence); + event.repetition = Some(repetition.clone()) + } + None => eprintln!( + "Repetition not found when trying to delete repetition of {:?}", + event + ), + } + } + None => { + eprintln!( + "Event not found when trying to delete repetition of {:?}", + event + ) + } + } +} + /// Add event and return dates that should be refreshed. fn add(app: &mut App, event: &Event) -> HashSet<NaiveDate> { if event.repetition.is_some() { - app.repeated_events.push(event.clone()); + app.recurring_events.push(event.clone()); let mut dates = repetition_dates(app, event); dates.insert(event.date); dates @@ -114,7 +193,7 @@ fn repetition_dates(app: &App, event: &Event) -> HashSet<NaiveDate> { /// Refresh app for the given dates. fn refresh(app: &App, dates: &HashSet<NaiveDate>) { let repetitions = - event::repetitions_between(&app.repeated_events, app.start_date, app.end_date); + event::repetitions_between(&app.recurring_events, app.start_date, app.end_date); for date in dates { calendar::refresh_date(app, *date, &repetitions) diff --git a/src/model/event.rs b/src/model/event.rs index 249d077..5e92692 100644 --- a/src/model/event.rs +++ b/src/model/event.rs @@ -17,17 +17,6 @@ pub struct Event { pub repetition: Option<Repetition>, } -pub fn init(date: NaiveDate) -> Event { - Event { - id: Uuid::new_v4(), - date, - start: None, - end: None, - name: "".to_string(), - repetition: None, - } -} - impl Event { pub fn pprint(&self) -> String { let start = self.start.map(pprint_time).unwrap_or_default(); @@ -44,7 +33,7 @@ impl Event { } } -/// Repeated events in an included date range +/// Recurring events in an date range (inclusive) pub fn repetitions_between( events: &[Event], start: NaiveDate, diff --git a/src/model/repetition.rs b/src/model/repetition.rs index 2e790d1..872944a 100644 --- a/src/model/repetition.rs +++ b/src/model/repetition.rs @@ -1,8 +1,15 @@ use chrono::{Datelike, Duration, NaiveDate, Weekday}; use serde::{Deserialize, Serialize}; +use std::collections::HashSet; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum Repetition { +pub struct Repetition { + pub frequency: Frequency, + pub removed_occurences: HashSet<usize>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum Frequency { Daily { period: u32 }, Monthly { day: DayOfMonth }, Yearly, @@ -40,39 +47,51 @@ impl Repetition { pub fn between(&self, event: NaiveDate, start: NaiveDate, end: NaiveDate) -> Vec<NaiveDate> { let repeat = |mut date, next: Box<dyn Fn(NaiveDate) -> NaiveDate>| { let mut repetitions = vec![]; + let mut iteration: usize = 0; while date <= end { - if date >= event && date >= start { - repetitions.push(date) + if date >= event { + if date >= start && !self.removed_occurences.contains(&iteration) { + repetitions.push(date) + } + iteration += 1 } - date = next(date) + date = next(date); } repetitions }; - match self { - Repetition::Daily { period } => { - let n = start.signed_duration_since(event).num_days() % (*period as i64); - let duration = Duration::days(*period as i64); - repeat(start - Duration::days(n), Box::new(|d| d + duration)) + match self.frequency { + Frequency::Daily { period } => { + let duration = Duration::days(period as i64); + repeat(event, Box::new(|d| d + duration)) } - Repetition::Monthly { + Frequency::Monthly { day: DayOfMonth::Day { day }, - } => match start.with_day(*day as u32) { + } => match event.with_day(day as u32) { Some(first_repetition) => repeat(first_repetition, Box::new(next_month)), None => vec![], }, - Repetition::Monthly { + Frequency::Monthly { day: DayOfMonth::Weekday { weekday }, } => repeat( - first_weekday_of_month(start, *weekday), - Box::new(|d| first_weekday_of_month(next_month(d), *weekday)), + first_weekday_of_month(event, weekday), + Box::new(|d| first_weekday_of_month(next_month(d), weekday)), ), - Repetition::Yearly => repeat( - NaiveDate::from_ymd(start.year(), event.month(), event.day()), + Frequency::Yearly => repeat( + NaiveDate::from_ymd(event.year(), event.month(), event.day()), Box::new(|d| NaiveDate::from_ymd(d.year() + 1, d.month(), d.day())), ), } } + + pub fn occurence_index(&self, event: NaiveDate, date: NaiveDate) -> Option<usize> { + let mut without_removed_occurences = self.clone(); + without_removed_occurences.removed_occurences = HashSet::new(); + without_removed_occurences + .between(event, event, date) + .iter() + .position(|d| d == &date) + } } fn first_weekday_of_month(date: NaiveDate, weekday: Weekday) -> NaiveDate { @@ -93,7 +112,7 @@ mod tests { #[test] fn every_day_event_before() { - let repetition = Repetition::Daily { period: 1 }; + let repetition = from_freq(Frequency::Daily { period: 1 }); assert_eq!( repetition.between(d(2022, 6, 1), d(2022, 7, 1), d(2022, 8, 31)), d(2022, 7, 1) @@ -105,7 +124,7 @@ mod tests { #[test] fn every_day_event_between() { - let repetition = Repetition::Daily { period: 1 }; + let repetition = from_freq(Frequency::Daily { period: 1 }); assert_eq!( repetition.between(d(2022, 8, 10), d(2022, 7, 1), d(2022, 8, 31)), d(2022, 8, 10) @@ -117,7 +136,7 @@ mod tests { #[test] fn every_day_event_after() { - let repetition = Repetition::Daily { period: 1 }; + let repetition = from_freq(Frequency::Daily { period: 1 }); assert!(repetition .between(d(2022, 9, 1), d(2022, 7, 1), d(2022, 8, 31)) .is_empty()) @@ -125,7 +144,7 @@ mod tests { #[test] fn every_three_days() { - let repetition = Repetition::Daily { period: 3 }; + let repetition = from_freq(Frequency::Daily { period: 3 }); assert_eq!( repetition.between(d(2022, 2, 16), d(2022, 2, 21), d(2022, 3, 6)), vec!( @@ -140,9 +159,9 @@ mod tests { #[test] fn day_of_month() { - let repetition = Repetition::Monthly { + let repetition = from_freq(Frequency::Monthly { day: DayOfMonth::Day { day: 8 }, - }; + }); assert_eq!( repetition.between(d(2022, 2, 7), d(2022, 1, 1), d(2022, 4, 7)), vec!(d(2022, 2, 8), d(2022, 3, 8)) @@ -151,11 +170,11 @@ mod tests { #[test] fn weekday_of_month() { - let repetition = Repetition::Monthly { + let repetition = from_freq(Frequency::Monthly { day: DayOfMonth::Weekday { weekday: Weekday::Tue, }, - }; + }); assert_eq!( repetition.between(d(2022, 1, 5), d(2022, 1, 1), d(2022, 4, 4)), vec!(d(2022, 2, 1), d(2022, 3, 1)) @@ -164,14 +183,87 @@ mod tests { #[test] fn yearly() { - let repetition = Repetition::Yearly; + let repetition = from_freq(Frequency::Yearly); assert_eq!( repetition.between(d(2020, 5, 5), d(2018, 1, 1), d(2022, 5, 5)), vec!(d(2020, 5, 5), d(2021, 5, 5), d(2022, 5, 5)) ) } + #[test] + fn every_two_days_removed_occurence() { + let repetition = Repetition { + frequency: Frequency::Daily { period: 2 }, + removed_occurences: HashSet::from([0, 2, 3]), + }; + assert_eq!( + repetition.between(d(2020, 7, 1), d(2020, 7, 1), d(2020, 7, 9)), + vec!(d(2020, 7, 3), d(2020, 7, 9)) + ) + } + + #[test] + fn day_of_month_removed_occurence() { + let repetition = Repetition { + frequency: Frequency::Monthly { + day: DayOfMonth::Day { day: 8 }, + }, + removed_occurences: HashSet::from([1, 3]), + }; + assert_eq!( + repetition.between(d(2020, 1, 8), d(2020, 1, 8), d(2020, 4, 8)), + vec!(d(2020, 1, 8), d(2020, 3, 8)) + ) + } + + #[test] + fn weekday_of_month_removed_occurence() { + let repetition = Repetition { + frequency: Frequency::Monthly { + day: DayOfMonth::Weekday { + weekday: Weekday::Fri, + }, + }, + removed_occurences: HashSet::from([1, 2, 3]), + }; + assert_eq!( + repetition.between(d(2020, 2, 1), d(2020, 2, 1), d(2020, 7, 1)), + vec!(d(2020, 2, 7), d(2020, 6, 5)) + ) + } + + #[test] + fn yearly_removed_occurence() { + let repetition = Repetition { + frequency: Frequency::Yearly, + removed_occurences: HashSet::from([3]), + }; + assert_eq!( + repetition.between(d(2018, 5, 5), d(2019, 8, 1), d(2022, 5, 5)), + vec!(d(2020, 5, 5), d(2022, 5, 5)) + ) + } + + #[test] + fn occurence_index_after_removed_occurence() { + let repetition = Repetition { + frequency: Frequency::Yearly, + removed_occurences: HashSet::from([1]), + }; + assert_eq!( + repetition.occurence_index(d(2020, 1, 1), d(2022, 1, 1)), + Some(2) + ) + } + fn d(y: i32, m: u32, d: u32) -> NaiveDate { NaiveDate::from_ymd(y, m, d) } + + fn from_freq(frequency: Frequency) -> Repetition { + Repetition { + frequency, + removed_occurences: HashSet::new(), + } + } } |