aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoris2022-03-12 13:27:29 +0100
committerJoris2022-03-12 13:36:09 +0100
commitd584df359640176ec4bc06f59d1e8d42ab17a413 (patch)
tree6cfaf676fc2ecf4e61067aa376fb2bed0d984d79
parentaad7b9601dfa05255d5c24f4a6377d9a25646d45 (diff)
Update and delete recurring events
-rw-r--r--Cargo.lock1
-rw-r--r--Cargo.toml5
-rw-r--r--README.md15
-rw-r--r--flake.nix2
-rw-r--r--src/cli/mod.rs6
-rw-r--r--src/db/migrations/1-init.sql3
-rw-r--r--src/db/mod.rs82
-rw-r--r--src/gui/app.rs17
-rw-r--r--src/gui/calendar.rs28
-rw-r--r--src/gui/form/mod.rs238
-rw-r--r--src/gui/form/repetition.rs47
-rw-r--r--src/gui/mod.rs9
-rw-r--r--src/gui/style.css2
-rw-r--r--src/gui/update.rs107
-rw-r--r--src/model/event.rs13
-rw-r--r--src/model/repetition.rs142
16 files changed, 552 insertions, 165 deletions
diff --git a/Cargo.lock b/Cargo.lock
index b65ab13..26bd600 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -105,6 +105,7 @@ dependencies = [
"serde",
"serde_json",
"structopt",
+ "thiserror",
"uuid",
]
diff --git a/Cargo.toml b/Cargo.toml
index 5e6c659..8af0f5f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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" ] }
diff --git a/README.md b/README.md
index 064fa6d..a83fda3 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/flake.nix b/flake.nix
index 274f421..447a9c2 100644
--- a/flake.nix
+++ b/flake.nix
@@ -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: &gtk::Application, tx: Sender<Msg>) -> Self {
+ pub fn new(conn: Rc<Connection>, app: &gtk::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: &gtk::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(),
+ }
+ }
}