From a510603825050a5face70d5387f0be830a4d685b Mon Sep 17 00:00:00 2001 From: Felix Zwettler Date: Tue, 7 Jun 2022 23:14:29 +0200 Subject: add csv export --- .gitignore | 3 + Cargo.lock | 82 +++++++++++++++ Cargo.toml | 5 + src/application.rs | 7 ++ src/database.rs | 178 +++++++++++++++++++++++++++----- src/furtherance.gresource.xml | 1 + src/gtk/dialogs.ui | 100 ++++++++++++++++++ src/gtk/window.ui | 4 + src/ui/report.rs | 140 +++++++++++++------------ src/ui/tasks_page.rs | 16 ++- src/ui/window.rs | 230 +++++++++++++++++++++++++++++++++--------- 11 files changed, 615 insertions(+), 151 deletions(-) create mode 100644 src/gtk/dialogs.ui diff --git a/.gitignore b/.gitignore index 1cea60c..b91e3fd 100755 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ com.lakoliu.Furtherance.json~ src/config.rs +/.flatpak +/.vscode +/target \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 7e20984..0ee3f73 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,6 +66,18 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "bstr" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223" +dependencies = [ + "lazy_static", + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "cairo-rs" version = "0.15.10" @@ -139,6 +151,28 @@ dependencies = [ "vec_map", ] +[[package]] +name = "csv" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22813a6dc45b335f9bade10bf7271dc477e81113e89eb251a0bc2a8a81c536e1" +dependencies = [ + "bstr", + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90" +dependencies = [ + "memchr", +] + [[package]] name = "dbus" version = "0.9.5" @@ -213,7 +247,9 @@ dependencies = [ name = "furtherance" version = "1.4.0" dependencies = [ + "anyhow", "chrono", + "csv", "dbus", "dbus-codegen", "directories", @@ -223,8 +259,11 @@ dependencies = [ "itertools", "libadwaita", "log", + "num-derive", + "num-traits", "once_cell", "rusqlite", + "serde", ] [[package]] @@ -608,6 +647,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + [[package]] name = "lazy_static" version = "1.4.0" @@ -717,6 +762,17 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num-derive" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876a53fff98e03a936a674b29568b0e605f06b29372c2489ff4de23f1949743d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "num-integer" version = "0.1.44" @@ -914,6 +970,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" + [[package]] name = "regex-syntax" version = "0.6.25" @@ -944,6 +1006,12 @@ dependencies = [ "semver", ] +[[package]] +name = "ryu" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3f6f92acf49d1b98f7a81226834412ada05458b7364277387724a237f062695" + [[package]] name = "semver" version = "0.11.0" @@ -967,6 +1035,20 @@ name = "serde" version = "1.0.136" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce31e24b01e1e524df96f1c2fdd054405f8d7376249a5110886fb4b658484789" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.136" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08597e7152fcd306f41838ed3e37be9eaeed2b61c42e2117266a554fab4662f9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "slab" diff --git a/Cargo.toml b/Cargo.toml index 97078f2..a870325 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,11 @@ dbus-codegen = "0.10.0" log = "0.4" gtk4-macros = "0.4.8" itertools = "0.10.3" +anyhow = "1.0" +serde = { version = "1.0", features = ["derive"] } +csv = "1.1.6" +num-traits = "0.2.14" +num-derive = "0.3.3" [dependencies.gtk] package = "gtk4" diff --git a/src/application.rs b/src/application.rs index ad6ee51..fdc3637 100755 --- a/src/application.rs +++ b/src/application.rs @@ -156,6 +156,13 @@ impl FurtheranceApplication { imp.pomodoro_dialog.lock().unwrap().response(gtk::ResponseType::Reject); })); self.add_action(&stop_pomodoro_action); + + let export_csv_action = gio::SimpleAction::new("export-csv", None); + export_csv_action.connect_activate(clone!(@weak self as app => move |_, _| { + let window = FurtheranceWindow::default(); + window.open_csv_export_dialog(); + })); + self.add_action(&export_csv_action); } fn setup_application(&self) { diff --git a/src/database.rs b/src/database.rs index a2485c6..0ccf693 100755 --- a/src/database.rs +++ b/src/database.rs @@ -14,13 +14,15 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . -use rusqlite::{Connection, Result}; use chrono::{DateTime, Local}; use directories::ProjectDirs; -use std::path::PathBuf; +use gtk::glib; +use rusqlite::{Connection, Result}; +use std::convert::TryFrom; use std::fs::create_dir_all; +use std::path::PathBuf; -#[derive(Clone, Debug)] +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] pub struct Task { pub id: i32, pub task_name: String, @@ -29,12 +31,104 @@ pub struct Task { pub tags: String, } +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + num_derive::FromPrimitive, + num_derive::ToPrimitive, + glib::Enum, +)] +#[enum_type(name = "SortOrder")] +pub enum SortOrder { + #[enum_value(name = "Ascending", nick = "ascending")] + Ascending = 0, + #[enum_value(name = "Descending", nick = "descending")] + Descending, +} + +impl Default for SortOrder { + fn default() -> Self { + // matches the default in sqlite + Self::Ascending + } +} + +impl TryFrom for SortOrder { + type Error = anyhow::Error; + + fn try_from(value: u32) -> Result { + num_traits::FromPrimitive::from_u32(value) + .ok_or_else(|| anyhow::anyhow!("SortOrder from_u32() failed for value {}", value)) + } +} + +impl SortOrder { + fn to_sqlite(&self) -> &str { + match self { + SortOrder::Ascending => "ASC", + SortOrder::Descending => "DESC", + } + } +} + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + num_derive::ToPrimitive, + num_derive::FromPrimitive, + glib::Enum, +)] +#[enum_type(name = "TaskSort")] +pub enum TaskSort { + #[enum_value(name = "StartTime", nick = "start time")] + StartTime, + #[enum_value(name = "StopTime", nick = "stop time")] + StopTime, + #[enum_value(name = "TaskName", nick = "task name")] + TaskName, +} + +impl Default for TaskSort { + fn default() -> Self { + Self::StartTime + } +} + +impl TryFrom for TaskSort { + type Error = anyhow::Error; + + fn try_from(value: u32) -> Result { + num_traits::FromPrimitive::from_u32(value) + .ok_or_else(|| anyhow::anyhow!("TaskSort from_u32() failed for value {}", value)) + } +} + +impl TaskSort { + fn to_sqlite(&self) -> &str { + match self { + Self::StartTime => "start_time", + Self::StopTime => "stop_time", + Self::TaskName => "task_name", + } + } +} + pub fn get_directory() -> PathBuf { - if let Some(proj_dirs) = ProjectDirs::from("com", "lakoliu", "Furtherance") { + if let Some(proj_dirs) = ProjectDirs::from("com", "lakoliu", "Furtherance") { let mut path = PathBuf::from(proj_dirs.data_dir()); create_dir_all(path.clone()).expect("Unable to create database directory"); path.extend(&["furtherance.db"]); - return path + return path; } PathBuf::new() } @@ -58,33 +152,39 @@ pub fn upgrade_old_db() -> Result<()> { // Update from old DB w/o tags let conn = Connection::open(get_directory())?; - conn.execute( - "ALTER TABLE tasks ADD COLUMN tags TEXT DEFAULT ' '", - [], - )?; + conn.execute("ALTER TABLE tasks ADD COLUMN tags TEXT DEFAULT ' '", [])?; Ok(()) } -pub fn db_write(task_name: &str, - start_time: DateTime, - stop_time: DateTime, - tags: String) -> Result<()> { +pub fn db_write( + task_name: &str, + start_time: DateTime, + stop_time: DateTime, + tags: String, +) -> Result<()> { // Write data into database let conn = Connection::open(get_directory())?; conn.execute( "INSERT INTO tasks (task_name, start_time, stop_time, tags) values (?1, ?2, ?3, ?4)", - &[&task_name.to_string(), &start_time.to_rfc3339(), &stop_time.to_rfc3339(), &tags], + &[ + &task_name.to_string(), + &start_time.to_rfc3339(), + &stop_time.to_rfc3339(), + &tags, + ], )?; Ok(()) } -pub fn write_autosave(task_name: &str, - start_time: &str, - stop_time: &str, - tags: &str) -> Result<()> { +pub fn write_autosave( + task_name: &str, + start_time: &str, + stop_time: &str, + tags: &str, +) -> Result<()> { // Write data into database let conn = Connection::open(get_directory())?; @@ -96,11 +196,18 @@ pub fn write_autosave(task_name: &str, Ok(()) } -pub fn retrieve() -> Result, rusqlite::Error> { +pub fn retrieve(sort: TaskSort, order: SortOrder) -> Result, rusqlite::Error> { // Retrieve all tasks from the database let conn = Connection::open(get_directory())?; - let mut query = conn.prepare("SELECT * FROM tasks ORDER BY start_time")?; + let mut query = conn.prepare( + format!( + "SELECT * FROM tasks ORDER BY {0} {1}", + sort.to_sqlite(), + order.to_sqlite() + ) + .as_str(), + )?; let task_iter = query.query_map([], |row| { Ok(Task { id: row.get(0)?, @@ -117,11 +224,27 @@ pub fn retrieve() -> Result, rusqlite::Error> { } Ok(tasks_vec) +} +/// Exports the database as CSV. +/// The delimiter parameter is interpreted as a ASCII character. +pub fn export_as_csv(sort: TaskSort, order: SortOrder, delimiter: u8) -> anyhow::Result { + let mut csv_writer = csv::WriterBuilder::new() + .delimiter(delimiter) + .from_writer(vec![]); + let tasks = retrieve(sort, order)?; + + for task in tasks { + csv_writer.serialize(task)?; + } + + csv_writer.flush()?; + + Ok(String::from_utf8(csv_writer.into_inner()?)?) } // pub fn retrieve_date_range() -> Result, rusqlite::Error> { - // Retrieve all tasks from the database +// Retrieve all tasks from the database // let conn = Connection::open(get_directory())?; // let mut query = conn.prepare("SELECT * FROM tasks ORDER BY start_time")?; @@ -151,7 +274,7 @@ pub fn update_start_time(id: i32, start_time: String) -> Result<()> { conn.execute( "UPDATE tasks SET start_time = (?1) WHERE id = (?2)", - &[&start_time, &id.to_string()] + &[&start_time, &id.to_string()], )?; Ok(()) @@ -162,7 +285,7 @@ pub fn update_stop_time(id: i32, stop_time: String) -> Result<()> { conn.execute( "UPDATE tasks SET stop_time = (?1) WHERE id = (?2)", - &[&stop_time, &id.to_string()] + &[&stop_time, &id.to_string()], )?; Ok(()) @@ -173,7 +296,7 @@ pub fn update_task_name(id: i32, task_name: String) -> Result<()> { conn.execute( "UPDATE tasks SET task_name = (?1) WHERE id = (?2)", - &[&task_name, &id.to_string()] + &[&task_name, &id.to_string()], )?; Ok(()) @@ -184,7 +307,7 @@ pub fn update_tags(id: i32, tags: String) -> Result<()> { conn.execute( "UPDATE tasks SET tags = (?1) WHERE id = (?2)", - &[&tags, &id.to_string()] + &[&tags, &id.to_string()], )?; Ok(()) @@ -195,8 +318,7 @@ pub fn get_list_by_id(id_list: Vec) -> Result, rusqlite::Error> { let mut tasks_vec: Vec = Vec::new(); for id in id_list { - let mut query = conn.prepare( - "SELECT * FROM tasks WHERE id = :id;")?; + let mut query = conn.prepare("SELECT * FROM tasks WHERE id = :id;")?; let task_iter = query.query_map(&[(":id", &id.to_string())], |row| { Ok(Task { id: row.get(0)?, @@ -247,7 +369,7 @@ pub fn delete_all() -> Result<()> { // Delete everything from the database let conn = Connection::open(get_directory())?; - conn.execute("delete from tasks",[],)?; + conn.execute("delete from tasks", [])?; Ok(()) } diff --git a/src/furtherance.gresource.xml b/src/furtherance.gresource.xml index bb1a9fb..bd94858 100755 --- a/src/furtherance.gresource.xml +++ b/src/furtherance.gresource.xml @@ -9,6 +9,7 @@ gtk/tasks_group.ui gtk/tasks_page.ui gtk/task_row.ui + gtk/dialogs.ui gtk/window.ui diff --git a/src/gtk/dialogs.ui b/src/gtk/dialogs.ui new file mode 100644 index 0000000..a4c1e6f --- /dev/null +++ b/src/gtk/dialogs.ui @@ -0,0 +1,100 @@ + + + + + 1 + true + Export as CSV + + + Cancel + + + + + Export + + + + + csv_export_cancelbutton + csv_export_applybutton + + + + 800 + 600 + true + false + fill + fill + + + vertical + 24 + 12 + 12 + 12 + 12 + + + horizontal + 12 + + + - no file selected - + true + start + start + + + + + + folder-open-symbolic + + + + + + + CSV export preferences + fill + + + Sort by + + + TaskSort + + + + + + + + + + Sort order + + + SortOrder + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/gtk/window.ui b/src/gtk/window.ui index 174acc0..25b81ca 100755 --- a/src/gtk/window.ui +++ b/src/gtk/window.ui @@ -100,6 +100,10 @@ _Delete history app.delete-history + + _Export as CSV + app.export-csv + _About Furtherance app.about diff --git a/src/ui/report.rs b/src/ui/report.rs index 4fcfc51..c91806d 100644 --- a/src/ui/report.rs +++ b/src/ui/report.rs @@ -15,16 +15,16 @@ // along with this program. If not, see . use adw::subclass::prelude::*; +use chrono::{offset::TimeZone, Date, DateTime, Datelike, Duration, Local, NaiveDate}; use gettextrs::*; use glib::clone; use gtk::subclass::prelude::*; use gtk::{glib, prelude::*, CompositeTemplate}; -use chrono::{DateTime, NaiveDate, Local, Duration, Date, Datelike, offset::TimeZone}; use itertools::Itertools; -use crate::FurtheranceApplication; +use crate::database::{self, SortOrder, TaskSort}; use crate::ui::FurtheranceWindow; -use crate::database; +use crate::FurtheranceApplication; mod imp { use super::*; @@ -81,7 +81,6 @@ mod imp { } impl ObjectImpl for FurReport { - fn constructed(&self, obj: &Self::Type) { obj.setup_widgets(); self.parent_constructed(obj); @@ -119,43 +118,49 @@ impl FurReport { imp.range_combo.set_active_id(Some("week_item")); imp.filter_combo.set_active_id(Some("tasks_item")); - imp.range_combo.connect_changed(clone!(@weak self as this => move |combo|{ - let imp = imp::FurReport::from_instance(&this); - if combo.active_id().unwrap() != "date_range_item" { - imp.date_range_box.set_visible(false); - this.refresh_report(); - } else { - imp.date_range_box.set_visible(true); - } - })); - - imp.filter_check.connect_toggled(clone!(@weak self as this => move |_|{ - let imp = imp::FurReport::from_instance(&this); - if imp.filter_box.get_visible() { - imp.filter_box.set_visible(false); - } else { - imp.filter_box.set_visible(true); - } - })); - - imp.filter_combo.connect_changed(clone!(@weak self as this => move |combo|{ - let imp = imp::FurReport::from_instance(&this); - if combo.active_id().unwrap() == "tasks_item" { - imp.filter_entry.set_placeholder_text(Some(&gettext("Task, Task 2"))); - } else { - imp.filter_entry.set_placeholder_text(Some(&gettext("tag, tag 2"))); - } - })); + imp.range_combo + .connect_changed(clone!(@weak self as this => move |combo|{ + let imp = imp::FurReport::from_instance(&this); + if combo.active_id().unwrap() != "date_range_item" { + imp.date_range_box.set_visible(false); + this.refresh_report(); + } else { + imp.date_range_box.set_visible(true); + } + })); - imp.refresh_btn.connect_clicked(clone!(@weak self as this => move |_|{ - this.refresh_report(); - })); + imp.filter_check + .connect_toggled(clone!(@weak self as this => move |_|{ + let imp = imp::FurReport::from_instance(&this); + if imp.filter_box.get_visible() { + imp.filter_box.set_visible(false); + } else { + imp.filter_box.set_visible(true); + } + })); + + imp.filter_combo + .connect_changed(clone!(@weak self as this => move |combo|{ + let imp = imp::FurReport::from_instance(&this); + if combo.active_id().unwrap() == "tasks_item" { + imp.filter_entry.set_placeholder_text(Some(&gettext("Task, Task 2"))); + } else { + imp.filter_entry.set_placeholder_text(Some(&gettext("tag, tag 2"))); + } + })); + + imp.refresh_btn + .connect_clicked(clone!(@weak self as this => move |_|{ + this.refresh_report(); + })); let renderer = gtk::CellRendererText::new(); - let task_column = gtk::TreeViewColumn::with_attributes(&gettext("Task"), &renderer, &[("text", 0)]); + let task_column = + gtk::TreeViewColumn::with_attributes(&gettext("Task"), &renderer, &[("text", 0)]); task_column.set_expand(true); task_column.set_resizable(true); - let duration_column = gtk::TreeViewColumn::with_attributes(&gettext("Duration"), &renderer, &[("text", 1)]); + let duration_column = + gtk::TreeViewColumn::with_attributes(&gettext("Duration"), &renderer, &[("text", 1)]); duration_column.set_expand(false); duration_column.set_resizable(true); imp.results_tree.append_column(&task_column); @@ -172,8 +177,7 @@ impl FurReport { let results_model = gtk::TreeStore::new(&[String::static_type(), String::static_type()]); - let mut task_list = database::retrieve().unwrap(); - task_list.reverse(); + let task_list = database::retrieve(TaskSort::StartTime, SortOrder::Descending).unwrap(); // Get date range let active_range = imp.range_combo.active_id().unwrap(); @@ -192,26 +196,27 @@ impl FurReport { } else if active_range == "year_item" { range_start_date = today - Duration::days(365); } else { - let input_start_date = NaiveDate::parse_from_str(&imp.start_date_entry.text(), "%m/%d/%Y"); + let input_start_date = + NaiveDate::parse_from_str(&imp.start_date_entry.text(), "%m/%d/%Y"); let input_end_date = NaiveDate::parse_from_str(&imp.end_date_entry.text(), "%m/%d/%Y"); // Check if user entered dates properly if let Err(_) = input_start_date { imp.format_error.set_visible(true); results_model.clear(); imp.results_tree.set_model(Some(&results_model)); - return + return; } if let Err(_) = input_end_date { imp.format_error.set_visible(true); results_model.clear(); imp.results_tree.set_model(Some(&results_model)); - return + return; } // Start date cannot be after end date if (input_end_date.unwrap() - input_start_date.unwrap()).num_days() < 0 { imp.start_end_error.set_visible(true); results_model.clear(); - return + return; } range_start_date = Local.from_local_date(&input_start_date.unwrap()).unwrap(); range_end_date = Local.from_local_date(&input_end_date.unwrap()).unwrap(); @@ -222,8 +227,12 @@ impl FurReport { let mut user_chosen_tags: Vec = Vec::new(); let mut only_this_tag = false; for task in task_list { - let start = DateTime::parse_from_rfc3339(&task.start_time).unwrap().with_timezone(&Local); - let stop = DateTime::parse_from_rfc3339(&task.stop_time).unwrap().with_timezone(&Local); + let start = DateTime::parse_from_rfc3339(&task.start_time) + .unwrap() + .with_timezone(&Local); + let stop = DateTime::parse_from_rfc3339(&task.stop_time) + .unwrap() + .with_timezone(&Local); // Check if start time is in date range and if not remove it from task_list let start_date = start.date(); if start_date >= range_start_date && start_date <= range_end_date { @@ -240,7 +249,8 @@ impl FurReport { // Handle duplicate tasks split_tasks = split_tasks.into_iter().unique().collect(); // Lowercase tags - let lower_tasks: Vec = split_tasks.iter().map(|x| x.to_lowercase()).collect(); + let lower_tasks: Vec = + split_tasks.iter().map(|x| x.to_lowercase()).collect(); if lower_tasks.contains(&task.task_name.to_lowercase()) { let duration = stop - start; @@ -248,7 +258,6 @@ impl FurReport { tasks_in_range.push((task, duration)); total_time += duration; } - } else if imp.filter_combo.active_id().unwrap() == "tags_item" { // Split user chosen tags let chosen_tasgs = imp.filter_entry.text(); @@ -287,22 +296,20 @@ impl FurReport { } } - let all_tasks_iter:gtk::TreeIter; + let all_tasks_iter: gtk::TreeIter; if tasks_in_range.is_empty() { - all_tasks_iter = results_model.insert_with_values(None, - None, - &[ - (0, &gettext("No Results")), - (1, &"") - ]); + all_tasks_iter = results_model.insert_with_values( + None, + None, + &[(0, &gettext("No Results")), (1, &"")], + ); } else { let total_time_str = FurReport::format_duration(total_time); - all_tasks_iter = results_model.insert_with_values(None, - None, - &[ - (0, &gettext("All Results")), - (1, &total_time_str) - ]); + all_tasks_iter = results_model.insert_with_values( + None, + None, + &[(0, &gettext("All Results")), (1, &total_time_str)], + ); } if imp.sort_by_task.is_active() { @@ -368,12 +375,12 @@ impl FurReport { let _child_iter = results_model.insert_with_values( Some(&header_iter), None, - &[(0, &task), (1, &FurReport::format_duration(task_duration))] + &[(0, &task), (1, &FurReport::format_duration(task_duration))], ); } results_model.set( &header_iter, - &[(0, &stbd.0), (1, &FurReport::format_duration(stbd.1))] + &[(0, &stbd.0), (1, &FurReport::format_duration(stbd.1))], ); } } else if imp.sort_by_tag.is_active() { @@ -432,13 +439,15 @@ impl FurReport { } for mut stbd in sorted_tasks_by_duration { - if !only_this_tag || (only_this_tag && user_chosen_tags.contains(&stbd.0[1..].to_string())) { + if !only_this_tag + || (only_this_tag && user_chosen_tags.contains(&stbd.0[1..].to_string())) + { let header_iter = results_model.append(Some(&all_tasks_iter)); for (task, task_duration) in stbd.2 { let _child_iter = results_model.insert_with_values( Some(&header_iter), None, - &[(0, &task), (1, &FurReport::format_duration(task_duration))] + &[(0, &task), (1, &FurReport::format_duration(task_duration))], ); } if stbd.0 == "#" { @@ -446,7 +455,7 @@ impl FurReport { } results_model.set( &header_iter, - &[(0, &stbd.0), (1, &FurReport::format_duration(stbd.1))] + &[(0, &stbd.0), (1, &FurReport::format_duration(stbd.1))], ); } } @@ -459,11 +468,10 @@ impl FurReport { } fn format_duration(total_time: i64) -> String { - // Format total time to readable string + // Format total time to readable string let h = total_time / 3600; let m = total_time % 3600 / 60; let s = total_time % 60; format!("{:02}:{:02}:{:02}", h, m, s) } } - diff --git a/src/ui/tasks_page.rs b/src/ui/tasks_page.rs index 7fb6e63..911b005 100755 --- a/src/ui/tasks_page.rs +++ b/src/ui/tasks_page.rs @@ -14,16 +14,16 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use adw::prelude::{PreferencesGroupExt, PreferencesPageExt}; use adw::subclass::prelude::*; -use adw::prelude::{PreferencesPageExt, PreferencesGroupExt}; +use chrono::{DateTime, Duration, Local}; use gettextrs::*; use gtk::subclass::prelude::*; use gtk::{glib, prelude::*}; -use chrono::{DateTime, Local, Duration}; -use crate::ui::FurTasksGroup; -use crate::database; +use crate::database::{self, SortOrder, TaskSort}; use crate::settings_manager; +use crate::ui::FurTasksGroup; mod imp { use super::*; @@ -37,7 +37,6 @@ mod imp { pub all_groups: RefCell>, } - #[glib::object_subclass] impl ObjectSubclass for FurTasksPage { const NAME: &'static str = "FurTasksPage"; @@ -88,10 +87,8 @@ impl FurTasksPage { pub fn build_task_list(&self) { let imp = imp::FurTasksPage::from_instance(&self); - let mut tasks_list = database::retrieve().unwrap(); + let tasks_list = database::retrieve(TaskSort::StartTime, SortOrder::Descending).unwrap(); - // Reversing chronological order of tasks_list - tasks_list.reverse(); let mut uniq_date_list: Vec = Vec::new(); let mut same_date_list: Vec = Vec::new(); let mut tasks_sorted_by_day: Vec> = Vec::new(); @@ -144,7 +141,7 @@ impl FurTasksPage { let group = FurTasksGroup::new(); if uniq_date_list[i] == today { group.set_title(&gettext("Today")); - } else if uniq_date_list[i] == yesterday{ + } else if uniq_date_list[i] == yesterday { group.set_title(&gettext("Yesterday")); } else { group.set_title(&uniq_date_list[i]); @@ -171,4 +168,3 @@ impl FurTasksPage { } } } - diff --git a/src/ui/window.rs b/src/ui/window.rs index 133da09..d02d1b5 100755 --- a/src/ui/window.rs +++ b/src/ui/window.rs @@ -14,31 +14,34 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use adw::prelude::*; use adw::subclass::prelude::AdwApplicationWindowImpl; +use chrono::{offset::TimeZone, DateTime, Duration as ChronDur, Local, NaiveDateTime, ParseError}; +use dbus::blocking::Connection; +use directories::ProjectDirs; use gettextrs::*; -use gtk::prelude::*; +use glib::{clone, timeout_add_local}; use gtk::subclass::prelude::*; use gtk::{gio, glib, CompositeTemplate}; -use glib::{clone, timeout_add_local}; -use std::time::Duration; -use std::sync::Mutex; -use std::rc::Rc; -use std::cell::RefCell; -use chrono::{DateTime, Local, NaiveDateTime, ParseError, Duration as ChronDur, offset::TimeZone}; -use dbus::blocking::Connection; use itertools::Itertools; -use std::fs::{File, create_dir_all, remove_file}; -use std::io::{self, BufWriter, Write, BufReader, BufRead}; -use directories::ProjectDirs; +use std::cell::RefCell; +use std::convert::TryFrom; +use std::fs::{create_dir_all, remove_file, File}; +use std::io::{self, BufRead, BufReader, BufWriter, Write}; use std::path::PathBuf; +use std::rc::Rc; +use std::sync::Mutex; +use std::time::Duration; +use crate::config; +use crate::database::{self, SortOrder, TaskSort}; +use crate::settings_manager; use crate::ui::FurHistoryBox; use crate::FurtheranceApplication; -use crate::database; -use crate::settings_manager; -use crate::config; mod imp { + use crate::database::{SortOrder, TaskSort}; + use super::*; #[derive(Debug, Default, CompositeTemplate)] @@ -68,6 +71,9 @@ mod imp { pub running: Mutex, pub pomodoro_continue: Mutex, pub idle_dialog: Mutex, + + // We have to keep a reference to the current popped up filechooser dialog + pub filechooser: RefCell, } #[glib::object_subclass] @@ -78,6 +84,8 @@ mod imp { fn class_init(klass: &mut Self::Class) { FurHistoryBox::static_type(); + TaskSort::static_type(); + SortOrder::static_type(); Self::bind_template(klass); } @@ -133,7 +141,8 @@ impl FurtheranceWindow { let imp = imp::FurtheranceWindow::from_instance(self); if *imp.subtract_idle.lock().unwrap() { - let idle_start = DateTime::parse_from_rfc3339(&imp.idle_start_time.lock().unwrap()).unwrap(); + let idle_start = + DateTime::parse_from_rfc3339(&imp.idle_start_time.lock().unwrap()).unwrap(); stop_time = idle_start.with_timezone(&Local); *imp.subtract_idle.lock().unwrap() = false; } @@ -186,16 +195,17 @@ impl FurtheranceWindow { let start_time = Rc::new(RefCell::new(Local::now())); let stop_time = Rc::new(RefCell::new(Local::now())); - imp.task_input.connect_changed(clone!(@weak self as this => move |task_input| { - let imp2 = imp::FurtheranceWindow::from_instance(&this); - let task_input_text = task_input.text(); - let split_tags: Vec<&str> = task_input_text.trim().split("#").collect(); - if split_tags[0].trim().is_empty() { - imp2.start_button.set_sensitive(false); - } else { - imp2.start_button.set_sensitive(true); - } - })); + imp.task_input + .connect_changed(clone!(@weak self as this => move |task_input| { + let imp2 = imp::FurtheranceWindow::from_instance(&this); + let task_input_text = task_input.text(); + let split_tags: Vec<&str> = task_input_text.trim().split("#").collect(); + if split_tags[0].trim().is_empty() { + imp2.start_button.set_sensitive(false); + } else { + imp2.start_button.set_sensitive(true); + } + })); imp.start_button.connect_clicked(clone!(@weak self as this => move |button| { let imp2 = imp::FurtheranceWindow::from_instance(&this); @@ -502,11 +512,13 @@ impl FurtheranceWindow { fn get_idle_time(&self) -> Result> { let c = Connection::new_session()?; - let p = c.with_proxy("org.gnome.Mutter.IdleMonitor", + let p = c.with_proxy( + "org.gnome.Mutter.IdleMonitor", "/org/gnome/Mutter/IdleMonitor/Core", - Duration::from_millis(5000) + Duration::from_millis(5000), ); - let (idle_time,): (u64,) = p.method_call("org.gnome.Mutter.IdleMonitor", "GetIdletime", ())?; + let (idle_time,): (u64,) = + p.method_call("org.gnome.Mutter.IdleMonitor", "GetIdletime", ())?; Ok(idle_time / 1000) } @@ -521,20 +533,20 @@ impl FurtheranceWindow { // If user was idle and has now returned... if idle_time < (settings_manager::get_int("idle-time") * 60) as u64 && *imp.idle_time_reached.lock().unwrap() - && !*imp.idle_notified.lock().unwrap() { - - *imp.idle_notified.lock().unwrap() = true; - self.resume_from_idle(); + && !*imp.idle_notified.lock().unwrap() + { + *imp.idle_notified.lock().unwrap() = true; + self.resume_from_idle(); } *imp.stored_idle.lock().unwrap() = idle_time; // If user is idle but has not returned... if *imp.stored_idle.lock().unwrap() >= (settings_manager::get_int("idle-time") * 60) as u64 - && !*imp.idle_time_reached.lock().unwrap() { - + && !*imp.idle_time_reached.lock().unwrap() + { *imp.idle_time_reached.lock().unwrap() = true; - let true_idle_start_time = Local::now() - - ChronDur::seconds((settings_manager::get_int("idle-time") * 60) as i64); + let true_idle_start_time = Local::now() + - ChronDur::seconds((settings_manager::get_int("idle-time") * 60) as i64); *imp.idle_start_time.lock().unwrap() = true_idle_start_time.to_rfc3339(); } } @@ -543,14 +555,21 @@ impl FurtheranceWindow { let imp = imp::FurtheranceWindow::from_instance(self); let resume_time = Local::now(); - let idle_start = DateTime::parse_from_rfc3339(&imp.idle_start_time.lock().unwrap()).unwrap(); + let idle_start = + DateTime::parse_from_rfc3339(&imp.idle_start_time.lock().unwrap()).unwrap(); let idle_start = idle_start.with_timezone(&Local); let idle_time = resume_time - idle_start; let idle_time = idle_time.num_seconds(); let h = idle_time / 60 / 60; let m = (idle_time / 60) - (h * 60); let s = idle_time - (m * 60); - let idle_time_str = format!("{}{:02}:{:02}:{:02}", gettext("You have been idle for "), h, m, s); + let idle_time_str = format!( + "{}{:02}:{:02}:{:02}", + gettext("You have been idle for "), + h, + m, + s + ); let question_str = gettext("\nWould you like to discard that time, or continue the clock?"); let idle_time_msg = format!("{}{}", idle_time_str, question_str); @@ -559,11 +578,14 @@ impl FurtheranceWindow { gtk::DialogFlags::MODAL, gtk::MessageType::Warning, gtk::ButtonsType::None, - Some(&format!("{}", &gettext("Idle"))), + Some(&format!( + "{}", + &gettext("Idle") + )), ); dialog.add_buttons(&[ (&gettext("Discard"), gtk::ResponseType::Reject), - (&gettext("Continue"), gtk::ResponseType::Accept) + (&gettext("Continue"), gtk::ResponseType::Accept), ]); dialog.set_secondary_text(Some(&idle_time_msg)); @@ -594,11 +616,14 @@ impl FurtheranceWindow { gtk::DialogFlags::MODAL, gtk::MessageType::Warning, gtk::ButtonsType::None, - Some(&format!("{}", &gettext("Time's up!"))), + Some(&format!( + "{}", + &gettext("Time's up!") + )), ); dialog.add_buttons(&[ (&gettext("Continue"), gtk::ResponseType::Accept), - (&gettext("Stop"), gtk::ResponseType::Reject) + (&gettext("Stop"), gtk::ResponseType::Reject), ]); let app = FurtheranceApplication::default(); @@ -652,7 +677,7 @@ impl FurtheranceWindow { fn get_autosave_path() -> PathBuf { let mut path = PathBuf::new(); - if let Some(proj_dirs) = ProjectDirs::from("com", "lakoliu", "Furtherance") { + if let Some(proj_dirs) = ProjectDirs::from("com", "lakoliu", "Furtherance") { path = PathBuf::from(proj_dirs.data_dir()); create_dir_all(path.clone()).expect("Unable to create autosave directory"); path.extend(&["furtherance_autosave.txt"]); @@ -694,9 +719,9 @@ impl FurtheranceWindow { gtk::ButtonsType::Ok, &gettext("Autosave Restored"), ); - dialog.set_secondary_text(Some( - &gettext("Furtherance shut down improperly. An autosave was restored.") - )); + dialog.set_secondary_text(Some(&gettext( + "Furtherance shut down improperly. An autosave was restored.", + ))); dialog.connect_response(clone!( @weak self as this, @@ -773,6 +798,118 @@ impl FurtheranceWindow { imp.watch.set_text("00:00:00"); } } + + pub async fn export_csv_to_file( + sort: TaskSort, + order: SortOrder, + file: &gio::File, + ) -> anyhow::Result<()> { + async fn overwrite_file_future(file: &gio::File, bytes: Vec) -> anyhow::Result<()> { + let output_stream = file + .replace_future( + None, + false, + gio::FileCreateFlags::REPLACE_DESTINATION, + glib::PRIORITY_DEFAULT, + ) + .await?; + + output_stream + .write_all_future(bytes, glib::PRIORITY_DEFAULT) + .await + .map_err(|e| anyhow::anyhow!(e.1))?; + output_stream.close_future(glib::PRIORITY_DEFAULT).await?; + + Ok(()) + } + + let csv = database::export_as_csv(sort, order, b',')?; + overwrite_file_future(file, csv.into_bytes()).await + } + + pub fn open_csv_export_dialog(&self) { + let builder = gtk::Builder::from_resource("/com/lakoliu/Furtherance/gtk/dialogs.ui"); + let dialog = builder.object::("dialog_csv_export").unwrap(); + let tasksort_row = builder + .object::("csv_export_tasksort_row") + .unwrap(); + let sortorder_row = builder + .object::("csv_export_sortorder_row") + .unwrap(); + let filechooser_button = builder + .object::("csv_export_filechooser_button") + .unwrap(); + let chosenfile_label = builder + .object::("csv_export_chosenfile_label") + .unwrap(); + + dialog.set_transient_for(Some(self)); + + let filefilter = gtk::FileFilter::new(); + filefilter.add_mime_type("text/csv"); + filefilter.add_pattern("*.csv"); + + let filechooser = gtk::FileChooserNative::builder() + .title(&gettext("Create or choose a CSV file")) + .modal(true) + .transient_for(self) + .action(gtk::FileChooserAction::Save) + .accept_label(&gettext("Accept")) + .cancel_label(&gettext("Cancel")) + .select_multiple(false) + .filter(&filefilter) + .build(); + + filechooser.set_current_name("data.csv"); + + filechooser_button.connect_clicked( + clone!(@weak self as window, @weak filechooser, @weak dialog => move |_| { + dialog.hide(); + filechooser.show(); + }), + ); + + filechooser.connect_response( + clone!(@weak dialog, @weak chosenfile_label => move |filechooser, response| { + if response == gtk::ResponseType::Accept { + if let Some(path) = filechooser.file().and_then(|file| file.path()) { + chosenfile_label.set_label(&path.to_string_lossy()); + } else { + chosenfile_label.set_label(&gettext(" - no file selected - ")); + } + } + + dialog.show(); + }), + ); + + dialog.connect_response(clone!(@weak self as window, @weak filechooser, @weak tasksort_row, @weak sortorder_row => move |dialog, response| { + match response { + gtk::ResponseType::Apply => { + let sort = TaskSort::try_from(tasksort_row.selected()).unwrap_or_default(); + let order = SortOrder::try_from(sortorder_row.selected()).unwrap_or_default(); + + if let Some(file) = filechooser.file() { + glib::MainContext::default().spawn_local(clone!(@strong window, @strong file => async move { + if let Err(e) = FurtheranceWindow::export_csv_to_file(sort, order, &file).await { + log::error!("replace file {:?} failed, Err {}", file, e); + window.display_toast(&gettext("Exporting as CSV failed.")); + } else { + window.display_toast(&gettext("Exported as CSV successfully.")); + }; + })); + } + } + _ => {} + } + + dialog.close(); + })); + + *self.imp().filechooser.borrow_mut() = filechooser; + + dialog.show() + } } impl Default for FurtheranceWindow { @@ -784,4 +921,3 @@ impl Default for FurtheranceWindow { .unwrap() } } - -- cgit 1.4.1