aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock40
-rw-r--r--Cargo.toml6
-rw-r--r--README.md31
-rw-r--r--flake.lock12
-rw-r--r--flake.nix2
-rw-r--r--src/app/form/mod.rs (renamed from src/app/form.rs)55
-rw-r--r--src/app/form/repetition.rs151
-rw-r--r--src/app/style.css4
-rw-r--r--src/app/update.rs14
-rw-r--r--src/db/migrations/1-init.sql1
-rw-r--r--src/db/mod.rs22
-rw-r--r--src/model/event.rs14
-rw-r--r--src/model/mod.rs1
-rw-r--r--src/model/repetition.rs26
14 files changed, 325 insertions, 54 deletions
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",
]
@@ -520,6 +523,12 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -724,6 +733,12 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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/mod.rs
index 7f75db0..5c60bc5 100644
--- a/src/app/form.rs
+++ b/src/app/form/mod.rs
@@ -1,3 +1,5 @@
+mod repetition;
+
use gtk4 as gtk;
use gtk::glib;
@@ -19,19 +21,32 @@ pub async fn show(app: &App, event: Event, is_new: bool) {
let content_area = dialog.content_area();
- let vbox = gtk::Box::builder()
+ 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();
- vbox.add_css_class("g-Form__Inputs");
- content_area.append(&vbox);
+ column1.add_css_class("g-Form__Inputs");
+ columns.append(&column1);
let name = entry(&event.name);
- vbox.append(&label("Événement"));
- vbox.append(&name);
+ column1.append(&label("Événement"));
+ column1.append(&name);
let date = entry(&event.date.format(event::DATE_FORMAT).to_string());
- vbox.append(&label("Jour"));
- vbox.append(&date);
+ column1.append(&label("Jour"));
+ column1.append(&date);
let start = entry(
&event
@@ -39,22 +54,30 @@ pub async fn show(app: &App, event: Event, is_new: bool) {
.map(event::pprint_time)
.unwrap_or("".to_string()),
);
- vbox.append(&label("Début"));
- vbox.append(&start);
+ column1.append(&label("Début"));
+ column1.append(&start);
let end = entry(&event.end.map(event::pprint_time).unwrap_or("".to_string()));
- vbox.append(&label("Fin"));
- vbox.append(&end);
+ 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();
- vbox.append(&button);
+ lines.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()) {
+ 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(_) => {
@@ -62,16 +85,16 @@ pub async fn show(app: &App, event: Event, is_new: bool) {
update::send(tx.clone(), msg);
dialog.close()
},
- Err(_) => ()
+ Err(err) => println!("Error when upserting event: {err}")
}
},
- None => ()
+ None => println!("Event is not valid: {event:?}")
}
}));
if !is_new {
let button = gtk::Button::builder().label("Supprimer").build();
- vbox.append(&button);
+ lines.append(&button);
let conn = app.conn.clone();
let tx = app.tx.clone();
button.connect_clicked(glib::clone!(@weak dialog => move |_| {
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<gtk::CheckButton>,
+ active: bool,
+ input: &impl IsA<gtk::Widget>,
+ 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<Repetition> {
+ 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<Msg>, 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<Connection> {
}
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<Vec<Event>> {
- 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<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()),
})
})?;
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<NaiveTime>,
pub end: Option<NaiveTime>,
pub name: String,
+ pub repetition: Option<Repetition>,
}
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<NaiveTime> {
// Validation
-pub fn validate(id: Uuid, date: String, name: String, start: String, end: String) -> Option<Event> {
+pub fn validate(
+ id: Uuid,
+ date: String,
+ name: String,
+ start: String,
+ end: String,
+ repetition: Option<Repetition>,
+) -> Option<Event> {
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<u8> {
+ let n = str.parse::<u8>().ok()?;
+ if n >= 1 && n <= 31 {
+ Some(n)
+ } else {
+ None
+ }
+}