From 1445e23a26c6581ad0c3f5b5016e47e95d224e9f Mon Sep 17 00:00:00 2001 From: Joris Date: Sun, 20 Feb 2022 09:33:55 +0100 Subject: Save repetition in events But don’t show repetead events for now. --- Cargo.lock | 40 ++++++++++++ Cargo.toml | 6 +- README.md | 31 ++++----- flake.lock | 12 ++-- flake.nix | 2 +- src/app/form.rs | 101 ----------------------------- src/app/form/mod.rs | 124 +++++++++++++++++++++++++++++++++++ src/app/form/repetition.rs | 151 +++++++++++++++++++++++++++++++++++++++++++ src/app/style.css | 4 ++ src/app/update.rs | 14 ++-- src/db/migrations/1-init.sql | 1 + src/db/mod.rs | 22 +++++-- src/model/event.rs | 14 +++- src/model/mod.rs | 1 + src/model/repetition.rs | 26 ++++++++ 15 files changed, 410 insertions(+), 139 deletions(-) delete mode 100644 src/app/form.rs create mode 100644 src/app/form/mod.rs create mode 100644 src/app/form/repetition.rs create mode 100644 src/model/repetition.rs diff --git a/Cargo.lock b/Cargo.lock index 359f991..9671306 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,8 @@ dependencies = [ "gtk4", "rusqlite", "rusqlite_migration", + "serde", + "serde_json", "uuid", ] @@ -118,6 +120,7 @@ dependencies = [ "libc", "num-integer", "num-traits", + "serde", "time", "winapi", ] @@ -519,6 +522,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" + [[package]] name = "libc" version = "0.2.112" @@ -723,6 +732,12 @@ dependencies = [ "semver", ] +[[package]] +name = "ryu" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" + [[package]] name = "semver" version = "0.11.0" @@ -746,6 +761,31 @@ name = "serde" version = "1.0.130" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f12d06de37cf59146fbdecab66aa99f9fe4f78722e3607577a5375d66bd0c913" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7bc1a1ab1961464eae040d96713baa5a724a8152c1222492465b54322ec508b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee2bb9cd061c5865d345bb02ca49fcef1391741b672b54a0bf7b679badec3142" +dependencies = [ + "itoa", + "ryu", + "serde", +] [[package]] name = "slab" diff --git a/Cargo.toml b/Cargo.toml index f74bbd6..e79cb87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,13 +2,15 @@ name = "calendar" version = "0.1.0" authors = ["Joris Guyonvarch"] -edition = "2018" +edition = "2021" [dependencies] anyhow = "1.0" async-channel = "1.6" -chrono = "0.4" +chrono = { version = "0.4", features = [ "serde" ] } gtk4 = { version = "0.3", features = [ "v4_2" ] } rusqlite = { version = "0.26", features = [ "chrono" ] } rusqlite_migration = "0.5" +serde = { version = "1.0", features = [ "derive" ] } +serde_json = "1.0" uuid = { version = "0.8", features = [ "v4" ] } diff --git a/README.md b/README.md index ddd7ed3..e2517ad 100644 --- a/README.md +++ b/README.md @@ -12,32 +12,31 @@ nix develop --command cargo run # TODO -## Complexify event +## V1 -Be able to specify repetition. +### Repeat events -1. Modelize repetition. -2. Update the form. -3. Update the view. -4. Update a repetition event. +1. Show repeated events. +2. Update / delete specific repetition occurences. +3. When validating repetition, don’t produce None if there is a validation error. -## API +### API 1. Give DB path with CLI arg. 2. Get list of today’s events. -## Navigate around +### Navigate around -1. Select previous week (up arrow, scrolling). -2. Select Next week (down arrow, scrolling). -3. Select the default focus. +1. Select previous or next week with shortcuts. -## Categorize events +## V2 + +### Categorize events 1. CRUD for list of types (name + color). 2. Show / hide depending on the type. -## Nice to have +### Nice to have - Drag & drop events. - Show an indicator when a day can be scrolled vertically. @@ -46,5 +45,7 @@ Be able to specify repetition. - Show a date picker in dialog form. - Apply a style on times in the calendar (bold ?). - Print errors on forms when validating. -- Validate the form when pressing enter on any field -- Remove event with right click +- Validate the form when pressing enter on any field. +- Remove event with right click. +- Select the default focus with a button or a shortcut. +- Specify until which date a repeted event is diff --git a/flake.lock b/flake.lock index 211bffc..e41c323 100644 --- a/flake.lock +++ b/flake.lock @@ -17,11 +17,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1641710945, - "narHash": "sha256-hPCSOq9IcWi+ALWKNbKCeuq7ozthppKDE+VXbOkOggM=", + "lastModified": 1642256510, + "narHash": "sha256-BPE/eVoFEDIN4QiFQjFyPsOmkDPpusc0RZap028Q42o=", "owner": "nixos", "repo": "nixpkgs", - "rev": "e8daaa85d484e132ffbeac78651a2e6e56b9f8fb", + "rev": "385d12ff2668d560de1eddee33bcfd090e905295", "type": "github" }, "original": { @@ -47,11 +47,11 @@ ] }, "locked": { - "lastModified": 1641696140, - "narHash": "sha256-Q7bQ0MSq201ah4Q+3SznEmMR4Kn9pY6ta8pL6KAjZ78=", + "lastModified": 1642214598, + "narHash": "sha256-wnJimHXrC+esUSF1McC42U4u+iCi+webzB6Tmj+QuFc=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "a1b1977429de5d69a332dd87700ffb00525335f9", + "rev": "27fb59f3f4c687d599ec63a6c328e8432cd61101", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index e6561a2..4984bce 100644 --- a/flake.nix +++ b/flake.nix @@ -19,7 +19,7 @@ { devShell = mkShell { buildInputs = [ - rust-bin.stable."1.57.0".default + rust-bin.stable."1.58.0".default cargo-watch pkgconfig graphene diff --git a/src/app/form.rs b/src/app/form.rs deleted file mode 100644 index 7f75db0..0000000 --- a/src/app/form.rs +++ /dev/null @@ -1,101 +0,0 @@ -use gtk4 as gtk; - -use gtk::glib; -use gtk::prelude::*; - -use crate::{ - app::{update, update::Msg, App}, - db, - model::{event, event::Event}, -}; - -pub async fn show(app: &App, event: Event, is_new: bool) { - 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()]) - .build(); - - let content_area = dialog.content_area(); - - let vbox = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .build(); - vbox.add_css_class("g-Form__Inputs"); - content_area.append(&vbox); - - let name = entry(&event.name); - vbox.append(&label("Événement")); - vbox.append(&name); - - let date = entry(&event.date.format(event::DATE_FORMAT).to_string()); - vbox.append(&label("Jour")); - vbox.append(&date); - - let start = entry( - &event - .start - .map(event::pprint_time) - .unwrap_or("".to_string()), - ); - vbox.append(&label("Début")); - vbox.append(&start); - - let end = entry(&event.end.map(event::pprint_time).unwrap_or("".to_string())); - vbox.append(&label("Fin")); - vbox.append(&end); - - let button = gtk::Button::builder() - .label(if is_new { "Créer" } else { "Modifier" }) - .margin_bottom(10) - .build(); - vbox.append(&button); - let conn = app.conn.clone(); - let tx = app.tx.clone(); - button.connect_clicked(glib::clone!(@weak dialog, @strong event => move |_| { - match event::validate(event.id, date.buffer().text(), name.buffer().text(), start.buffer().text(), end.buffer().text()) { - 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(_) => () - } - }, - None => () - } - })); - - if !is_new { - let button = gtk::Button::builder().label("Supprimer").build(); - vbox.append(&button); - let conn = app.conn.clone(); - let tx = app.tx.clone(); - button.connect_clicked(glib::clone!(@weak dialog => move |_| { - match db::delete(&conn, &event.id) { - Ok(_) => { - update::send(tx.clone(), Msg::DeleteEvent { event: event.clone() }); - dialog.close() - }, - Err(_) => () - } - })); - } - - dialog.run_future().await; -} - -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() -} diff --git a/src/app/form/mod.rs b/src/app/form/mod.rs new file mode 100644 index 0000000..5c60bc5 --- /dev/null +++ b/src/app/form/mod.rs @@ -0,0 +1,124 @@ +mod repetition; + +use gtk4 as gtk; + +use gtk::glib; +use gtk::prelude::*; + +use crate::{ + app::{update, update::Msg, App}, + db, + model::{event, event::Event}, +}; + +pub async fn show(app: &App, event: Event, is_new: bool) { + 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()]) + .build(); + + let content_area = dialog.content_area(); + + let lines = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .build(); + content_area.append(&lines); + + let columns = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .build(); + columns.add_css_class("g-Form__Columns"); + lines.append(&columns); + + // First column + + 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); + column1.append(&label("Événement")); + column1.append(&name); + + let date = entry(&event.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("".to_string()), + ); + column1.append(&label("Début")); + column1.append(&start); + + let end = entry(&event.end.map(event::pprint_time).unwrap_or("".to_string())); + column1.append(&label("Fin")); + column1.append(&end); + + // Second column + + let repetition_model = repetition::view(&event); + columns.append(&repetition_model.view); + + // Buttons + + let button = gtk::Button::builder() + .label(if is_new { "Créer" } else { "Modifier" }) + .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 |_| { + let repetition = repetition::validate(&repetition_model).clone(); + match event::validate(event.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) => println!("Error when upserting event: {err}") + } + }, + None => println!("Event is not valid: {event:?}") + } + })); + + if !is_new { + let button = gtk::Button::builder().label("Supprimer").build(); + lines.append(&button); + let conn = app.conn.clone(); + let tx = app.tx.clone(); + button.connect_clicked(glib::clone!(@weak dialog => move |_| { + match db::delete(&conn, &event.id) { + Ok(_) => { + update::send(tx.clone(), Msg::DeleteEvent { event: event.clone() }); + dialog.close() + }, + Err(_) => () + } + })); + } + + dialog.run_future().await; +} + +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() +} diff --git a/src/app/form/repetition.rs b/src/app/form/repetition.rs new file mode 100644 index 0000000..ac56479 --- /dev/null +++ b/src/app/form/repetition.rs @@ -0,0 +1,151 @@ +use gtk4 as gtk; + +use chrono::{Weekday, Weekday::*}; +use gtk::prelude::*; + +use crate::{ + model::event::Event, + model::{ + repetition, + repetition::{MonthFrequency, Repetition}, + }, +}; + +static WEEKDAYS_STR: [&str; 7] = [ + "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche", +]; + +static WEEKDAYS: [Weekday; 7] = [Mon, Tue, Wed, Thu, Fri, Sat, Sun]; + +pub struct Model { + pub view: gtk::Box, + pub no_radio: gtk::CheckButton, + pub day_interval_radio: gtk::CheckButton, + pub day_interval_entry: gtk::Entry, + pub monthly_radio: gtk::CheckButton, + pub monthly_entry: gtk::Entry, + pub first_day_radio: gtk::CheckButton, + pub first_day_dropdown: gtk::DropDown, + pub yearly_radio: gtk::CheckButton, +} + +pub fn view(event: &Event) -> Model { + let view = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .build(); + view.add_css_class("g-Form__Inputs"); + + view.append(&label("Répétition")); + + let no_radio = gtk::CheckButton::builder() + .label("Non") + .active(event.repetition.is_none()) + .build(); + view.append(&no_radio); + + let default = match event.repetition { + Some(Repetition::Daily { frequency }) => frequency.to_string(), + _ => "".to_string(), + }; + let day_interval_entry = gtk::Entry::builder().text(&default).build(); + let (day_interval_box, day_interval_radio) = radio_input( + &no_radio, + !default.is_empty(), + &day_interval_entry, + "Interval de jours", + ); + view.append(&day_interval_box); + + let default = match event.repetition { + Some(Repetition::Monthly { + frequency: MonthFrequency::Day { day }, + }) => day.to_string(), + _ => "".to_string(), + }; + let monthly_entry = gtk::Entry::builder().text(&default).build(); + let (monthly_box, monthly_radio) = + radio_input(&no_radio, !default.is_empty(), &monthly_entry, "Mensuel"); + view.append(&monthly_box); + + let (active, default) = match event.repetition { + Some(Repetition::Monthly { + frequency: MonthFrequency::FirstDay { day }, + }) => (true, day), + _ => (false, Mon), + }; + let first_day_dropdown = gtk::DropDown::from_strings(&WEEKDAYS_STR); + first_day_dropdown + .set_selected(WEEKDAYS.iter().position(|d| d == &default).unwrap_or(0) as u32); + let (first_day_of_month_box, first_day_radio) = + radio_input(&no_radio, active, &first_day_dropdown, "1er jour du mois"); + view.append(&first_day_of_month_box); + + let yearly_radio = gtk::CheckButton::builder() + .group(&no_radio) + .label("Annuel") + .active(event.repetition == Some(Repetition::Yearly)) + .build(); + view.append(&yearly_radio); + + Model { + view, + no_radio, + day_interval_radio, + day_interval_entry, + monthly_radio, + monthly_entry, + first_day_radio, + first_day_dropdown, + yearly_radio, + } +} + +fn radio_input( + radio_group: &impl IsA, + active: bool, + input: &impl IsA, + text: &str, +) -> (gtk::Box, gtk::CheckButton) { + let radio_box = gtk::Box::builder().build(); + let radio = gtk::CheckButton::builder() + .group(radio_group) + .label(text) + .active(active) + .build(); + radio_box.append(&radio); + input.add_css_class("g-Form__RadioInput"); + radio_box.append(input); + (radio_box, radio) +} + +fn label(text: &str) -> gtk::Label { + gtk::Label::builder() + .label(text) + .halign(gtk::Align::Start) + .margin_bottom(5) + .build() +} + +pub fn validate(model: &Model) -> Option { + if model.no_radio.is_active() { + None + } else if model.day_interval_radio.is_active() { + repetition::validate_day(&model.day_interval_entry.buffer().text()) + .map(|d| Repetition::Daily { frequency: d }) + } else if model.monthly_radio.is_active() { + repetition::validate_day(&model.monthly_entry.buffer().text()).map(|d| { + Repetition::Monthly { + frequency: MonthFrequency::Day { day: d }, + } + }) + } else if model.first_day_radio.is_active() { + let day = WEEKDAYS[model.first_day_dropdown.selected() as usize]; + Some(Repetition::Monthly { + frequency: MonthFrequency::FirstDay { day }, + }) + } else if model.yearly_radio.is_active() { + Some(Repetition::Yearly) + } else { + None + } +} diff --git a/src/app/style.css b/src/app/style.css index 5cd1394..4828e41 100644 --- a/src/app/style.css +++ b/src/app/style.css @@ -43,3 +43,7 @@ .g-Form__Input { text-align: left; } + +.g-Form__RadioInput { + width: 20px; +} diff --git a/src/app/update.rs b/src/app/update.rs index baf4651..4e21050 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -48,15 +48,13 @@ pub async fn event_handler(rx: Receiver, mut app: App) { None => println!("Event not found when updating from {:?} to {:?}", old, new), } } - Msg::DeleteEvent { event } => { - match app.events.iter().position(|e| e.id == event.id) { - Some(index) => { - app.events.remove(index); - calendar::refresh_date(&app, event.date); - } - None => println!("Event not found when trying to delete {:?}", event), + Msg::DeleteEvent { event } => match app.events.iter().position(|e| e.id == event.id) { + Some(index) => { + app.events.remove(index); + calendar::refresh_date(&app, event.date); } - } + None => println!("Event not found when trying to delete {:?}", event), + }, } } } diff --git a/src/db/migrations/1-init.sql b/src/db/migrations/1-init.sql index 39b845b..a7db8b8 100644 --- a/src/db/migrations/1-init.sql +++ b/src/db/migrations/1-init.sql @@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS "events" ( "start" VARCHAR NULL, "end" VARCHAR NULL, "name" VARCHAR NOT NULL, + "repetition" VARCHAR NULL, "created" TIMESTAMP NOT NULL, "updated" TIMESTAMP NOT NULL ); diff --git a/src/db/mod.rs b/src/db/mod.rs index 0dd4ddf..2cac0d2 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -13,18 +13,28 @@ pub fn init() -> Result { } pub fn insert(conn: &Connection, event: &Event) -> Result<()> { + let repetition = match &event.repetition { + Some(r) => Some(serde_json::to_string(&r)?), + None => None, + }; + conn.execute( - "INSERT INTO events (id, date, start, end, name, created, updated) VALUES (?, ?, ?, ?, ?, datetime(), datetime())", - params![event.id.to_hyphenated().to_string(), event.date, event.start, event.end, event.name] + "INSERT INTO events (id, date, start, end, name, repetition, created, updated) VALUES (?, ?, ?, ?, ?, ?, datetime(), datetime())", + params![event.id.to_hyphenated().to_string(), event.date, event.start, event.end, event.name, repetition] )?; Ok(()) } pub fn update(conn: &Connection, event: &Event) -> Result<()> { + let repetition = match &event.repetition { + Some(r) => Some(serde_json::to_string(&r)?), + None => None, + }; + conn.execute( - "UPDATE events SET date = ?, start = ?, end = ?, name = ?, updated = datetime() where id = ?", - params![event.date, event.start, event.end, event.name, event.id.to_hyphenated().to_string()] + "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()] )?; Ok(()) @@ -41,16 +51,18 @@ pub fn delete(conn: &Connection, id: &Uuid) -> Result<()> { // TODO: Don’t use unwrap pub fn list(conn: &Connection) -> Result> { - let mut stmt = conn.prepare("SELECT id, date, start, end, name FROM events")?; + let mut stmt = conn.prepare("SELECT id, date, start, end, name, repeated FROM events")?; let iter = stmt.query_map([], |row| { let uuid: String = row.get(0)?; + let repetition: Option = 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()), }) })?; diff --git a/src/model/event.rs b/src/model/event.rs index 7ab0244..3765fec 100644 --- a/src/model/event.rs +++ b/src/model/event.rs @@ -2,6 +2,8 @@ use chrono::Timelike; use chrono::{NaiveDate, NaiveTime}; use uuid::Uuid; +use crate::model::repetition::Repetition; + pub static DATE_FORMAT: &str = "%d/%m/%Y"; #[derive(Debug, Clone)] @@ -11,6 +13,7 @@ pub struct Event { pub start: Option, pub end: Option, pub name: String, + pub repetition: Option, } pub fn init(date: NaiveDate) -> Event { @@ -20,6 +23,7 @@ pub fn init(date: NaiveDate) -> Event { start: None, end: None, name: "".to_string(), + repetition: None, } } @@ -62,7 +66,14 @@ fn parse_time(t: &str) -> Option { // Validation -pub fn validate(id: Uuid, date: String, name: String, start: String, end: String) -> Option { +pub fn validate( + id: Uuid, + date: String, + name: String, + start: String, + end: String, + repetition: Option, +) -> Option { let start = validate_time(start)?; let end = validate_time(end)?; @@ -77,6 +88,7 @@ pub fn validate(id: Uuid, date: String, name: String, start: String, end: String name: validate_name(name)?, start, end, + repetition, }) } diff --git a/src/model/mod.rs b/src/model/mod.rs index 53f1126..c1beb62 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1 +1,2 @@ pub mod event; +pub mod repetition; diff --git a/src/model/repetition.rs b/src/model/repetition.rs new file mode 100644 index 0000000..80387d9 --- /dev/null +++ b/src/model/repetition.rs @@ -0,0 +1,26 @@ +use chrono::Weekday; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum Repetition { + Daily { frequency: u8 }, + Monthly { frequency: MonthFrequency }, + Yearly, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum MonthFrequency { + Day { day: u8 }, + FirstDay { day: Weekday }, +} + +// Validation + +pub fn validate_day(str: &str) -> Option { + let n = str.parse::().ok()?; + if n >= 1 && n <= 31 { + Some(n) + } else { + None + } +} -- cgit v1.2.3