From ce44b3ae5738c1db82c6b64bd2058ea7d6970b33 Mon Sep 17 00:00:00 2001 From: Shantanu Raj Date: Tue, 13 Feb 2024 11:55:55 +0900 Subject: [PATCH 01/13] =?UTF-8?q?=F0=9F=8E=AC=20Allow=20editing=20time-ent?= =?UTF-8?q?ries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + Cargo.toml | 1 + src/arguments.rs | 4 +++ src/commands/edit.rs | 34 ++++++++++++++++++ src/commands/mod.rs | 1 + src/main.rs | 8 +++++ src/models.rs | 86 +++++++++++++++++++++++++++++++++++++++++++- src/parcel.rs | 42 ++++++++++++++++++++++ 8 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 src/commands/edit.rs create mode 100644 src/parcel.rs diff --git a/Cargo.lock b/Cargo.lock index 65e86fa..576e956 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2325,6 +2325,7 @@ dependencies = [ "serde_json", "skim", "structopt", + "tempfile", "tokio", "tokio-test", "toml", diff --git a/Cargo.toml b/Cargo.toml index ffc81d5..32de835 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ async-trait = "0.1.68" # Models chrono = { version = "0.4.24", features = ["serde"] } +tempfile = "3" [target.'cfg(unix)'.dependencies] skim = "0.10.4" diff --git a/src/arguments.rs b/src/arguments.rs index a0a15f9..b8d5e82 100644 --- a/src/arguments.rs +++ b/src/arguments.rs @@ -61,6 +61,10 @@ pub enum Command { #[structopt(short, long)] billable: bool, }, + Edit { + #[structopt(short, long)] + interactive: bool, + }, Continue { #[structopt(short, long)] interactive: bool, diff --git a/src/commands/edit.rs b/src/commands/edit.rs new file mode 100644 index 0000000..16176b4 --- /dev/null +++ b/src/commands/edit.rs @@ -0,0 +1,34 @@ +use crate::api::client::ApiClient; +use crate::models; +use crate::parcel::Parcel; +use crate::picker; +use colored::Colorize; +use models::ResultWithDefaultError; +use picker::ItemPicker; + +pub struct EditCommand; + +impl EditCommand { + pub async fn execute( + api_client: impl ApiClient, + _picker: Option>, + _interactive: bool, + ) -> ResultWithDefaultError<()> { + let entities = api_client.get_entities().await?; + match entities.running_time_entry() { + None => println!("{}", "No time entry is running at the moment".yellow()), + Some(running_time_entry) => { + let updated_time_entry = running_time_entry + .launch_in_editor() + .map_err(|e| { + println!("{}", e.to_string().red()); + e + }) + .unwrap(); + + api_client.update_time_entry(updated_time_entry).await?; + } + } + Ok(()) + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index d949193..b6a7ccb 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod cont; +pub mod edit; pub mod list; pub mod running; pub mod start; diff --git a/src/main.rs b/src/main.rs index dd95738..6b87bc0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod constants; mod credentials; mod error; mod models; +mod parcel; mod picker; mod utilities; @@ -15,6 +16,7 @@ use arguments::Command::Auth; use arguments::Command::Config; use arguments::Command::Continue; use arguments::Command::Current; +use arguments::Command::Edit; use arguments::Command::List; use arguments::Command::Logout; use arguments::Command::Running; @@ -24,6 +26,7 @@ use arguments::CommandLineArguments; use arguments::ConfigSubCommand; use commands::auth::AuthenticationCommand; use commands::cont::ContinueCommand; +use commands::edit::EditCommand; use commands::list::ListCommand; use commands::running::RunningTimeEntryCommand; use commands::start::StartCommand; @@ -75,6 +78,11 @@ async fn execute_subcommand(args: CommandLineArguments) -> ResultWithDefaultErro ContinueCommand::execute(get_default_api_client()?, picker).await? } + Edit { interactive } => { + let picker = if interactive { Some(picker) } else { None }; + EditCommand::execute(get_default_api_client()?, picker, interactive).await? + } + List { number, entity } => { ListCommand::execute(get_default_api_client()?, number, entity).await? } diff --git a/src/models.rs b/src/models.rs index 53dde38..0d1efb2 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,6 +1,6 @@ use std::{cmp, env}; -use crate::constants; +use crate::{constants, parcel::Parcel}; use std::collections::HashMap; use chrono::{DateTime, Duration, Utc}; @@ -290,3 +290,87 @@ impl std::fmt::Display for TimeEntry { write!(f, "{}", summary) } } + +impl Parcel for TimeEntry { + // Format time-entry to plain text so user can save it to a file + // and edit it later and create a new time-entry from it + // + // Format: + // Description: [Description] + // + // Start: [Start Time] + // + // Stop: [Stop Time] // Optional + // + // Billable: [true/false] + // + // Tags: [Tag1, Tag2, ...] + // + // Project: [Project Name] -- [Project ID] + // + // Task: [Task Name] -- [Task ID] + fn serialize(&self) -> String { + let mut serialized = format!( + "Description: {}\n\nStart: {}\n\n", + self.description, self.start + ); + + if let Some(stop) = self.stop { + serialized.push_str(&format!("Stop: {}\n\n", stop)); + } + + serialized.push_str(&format!("Billable: {}\n\n", self.billable)); + + if !self.tags.is_empty() { + serialized.push_str(&format!("Tags: {}\n\n", self.tags.join(", "))); + } + + if let Some(project) = &self.project { + serialized.push_str(&format!("Project: {} -- {}\n\n", project.name, project.id)); + } + + if let Some(task) = &self.task { + serialized.push_str(&format!("Task: {} -- {}\n\n", task.name, task.id)); + } + + serialized + } + + fn deserialize(&self, data: &str) -> Self { + let mut time_entry = self.clone(); + let project: Option = None; + let task: Option = None; + + for line in data.lines() { + if line.is_empty() { + continue; + } + let mut parts = line.splitn(2, ": "); + let key = parts.next().unwrap(); + let value = parts.next().unwrap_or("NOT FOUND"); + + println!("{}: {}", key, value); + + match key { + "Start" => time_entry.start = value.parse().unwrap(), + "Stop" => time_entry.stop = Some(value.parse().unwrap()), + "Billable" => time_entry.billable = value.parse().unwrap(), + "Tags" => time_entry.tags = value.split(", ").map(String::from).collect(), + "Project" => { + if project.is_some() { + time_entry.project = project.clone(); + } + } + "Task" => { + if task.is_some() { + time_entry.task = task.clone(); + } + } + "Description" => time_entry.description = value.to_string(), + _ => {} + } + } + + time_entry + } +} diff --git a/src/parcel.rs b/src/parcel.rs new file mode 100644 index 0000000..11f3f09 --- /dev/null +++ b/src/parcel.rs @@ -0,0 +1,42 @@ +use std::fs::OpenOptions; +use std::io::{Read, Seek, Write}; + +use tempfile::tempdir; + +use crate::utilities; + +pub trait Parcel { + fn serialize(&self) -> String; + fn deserialize(&self, data: &str) -> Self; + + fn launch_in_editor(&self) -> Result + where + Self: Sized, + { + let contents = self.serialize(); + + let dir = tempdir().map_err(|e| e.to_string())?; + let file_path = dir.path().join("toggl.txt"); + + // TODO: Replace with `File::create_new` when it's stable + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(file_path.clone()) + .map_err(|e| e.to_string())?; + write!(file, "{}", contents).unwrap(); + + utilities::open_path_in_editor(file_path).map_err(|e| e.to_string())?; + file.rewind().map_err(|e| e.to_string())?; + + let mut contents = String::new(); + file.read_to_string(&mut contents) + .map_err(|e| e.to_string())?; + + drop(file); + dir.close().map_err(|e| e.to_string())?; + + Ok(self.deserialize(&contents)) + } +} From 18c7a513a852e07e87d0b1be832b61b21b0cb4d1 Mon Sep 17 00:00:00 2001 From: Shantanu Raj Date: Sun, 22 Sep 2024 17:25:33 +0200 Subject: [PATCH 02/13] =?UTF-8?q?=E2=9C=A8=20Use=20File::create=5Fnew?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/parcel.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/parcel.rs b/src/parcel.rs index 11f3f09..46ecb1e 100644 --- a/src/parcel.rs +++ b/src/parcel.rs @@ -1,4 +1,4 @@ -use std::fs::OpenOptions; +use std::fs::File; use std::io::{Read, Seek, Write}; use tempfile::tempdir; @@ -18,13 +18,7 @@ pub trait Parcel { let dir = tempdir().map_err(|e| e.to_string())?; let file_path = dir.path().join("toggl.txt"); - // TODO: Replace with `File::create_new` when it's stable - let mut file = OpenOptions::new() - .read(true) - .write(true) - .create_new(true) - .open(file_path.clone()) - .map_err(|e| e.to_string())?; + let mut file = File::create_new(file_path.clone()).map_err(|e| e.to_string())?; write!(file, "{}", contents).unwrap(); utilities::open_path_in_editor(file_path).map_err(|e| e.to_string())?; From 34cedfb4a71fc549d5648b349982a50401aa9644 Mon Sep 17 00:00:00 2001 From: Shantanu Raj Date: Sun, 22 Sep 2024 17:32:32 +0200 Subject: [PATCH 03/13] =?UTF-8?q?=E2=9C=8D=EF=B8=8F=20Use=20File::write=5F?= =?UTF-8?q?all=20to=20write=20time-entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/parcel.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/parcel.rs b/src/parcel.rs index 46ecb1e..4632c29 100644 --- a/src/parcel.rs +++ b/src/parcel.rs @@ -19,7 +19,8 @@ pub trait Parcel { let file_path = dir.path().join("toggl.txt"); let mut file = File::create_new(file_path.clone()).map_err(|e| e.to_string())?; - write!(file, "{}", contents).unwrap(); + file.write_all(contents.as_bytes()) + .expect("Failed to write current time-entry to file"); utilities::open_path_in_editor(file_path).map_err(|e| e.to_string())?; file.rewind().map_err(|e| e.to_string())?; From 9e03221351f3c5822c7d12fcfc469f10be5a2186 Mon Sep 17 00:00:00 2001 From: Shantanu Raj Date: Sun, 22 Sep 2024 18:33:53 +0200 Subject: [PATCH 04/13] =?UTF-8?q?=F0=9F=94=A8=20Fix=20incorrect=20file=20c?= =?UTF-8?q?ontents=20being=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reusing the same handle wasn't helping --- src/parcel.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/parcel.rs b/src/parcel.rs index 4632c29..65e7c94 100644 --- a/src/parcel.rs +++ b/src/parcel.rs @@ -1,5 +1,5 @@ -use std::fs::File; -use std::io::{Read, Seek, Write}; +use std::fs::{self, File}; +use std::io::Write; use tempfile::tempdir; @@ -22,14 +22,12 @@ pub trait Parcel { file.write_all(contents.as_bytes()) .expect("Failed to write current time-entry to file"); - utilities::open_path_in_editor(file_path).map_err(|e| e.to_string())?; - file.rewind().map_err(|e| e.to_string())?; + utilities::open_path_in_editor(&file_path).map_err(|e| e.to_string())?; + drop(file); - let mut contents = String::new(); - file.read_to_string(&mut contents) + let contents = fs::read_to_string(file_path) .map_err(|e| e.to_string())?; - drop(file); dir.close().map_err(|e| e.to_string())?; Ok(self.deserialize(&contents)) From 3804d6bbaac74d17743c51038c4f1c6104b72580 Mon Sep 17 00:00:00 2001 From: Shantanu Raj Date: Sun, 22 Sep 2024 18:36:05 +0200 Subject: [PATCH 05/13] =?UTF-8?q?=F0=9F=AA=B5=20Add=20expectation=20=20err?= =?UTF-8?q?ors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/parcel.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/parcel.rs b/src/parcel.rs index 65e7c94..09d913e 100644 --- a/src/parcel.rs +++ b/src/parcel.rs @@ -15,20 +15,20 @@ pub trait Parcel { { let contents = self.serialize(); - let dir = tempdir().map_err(|e| e.to_string())?; + let dir = tempdir().expect("Failed to create temp directory"); let file_path = dir.path().join("toggl.txt"); - let mut file = File::create_new(file_path.clone()).map_err(|e| e.to_string())?; + let mut file = File::create_new(file_path.clone()).expect("Failed to create file"); file.write_all(contents.as_bytes()) .expect("Failed to write current time-entry to file"); - utilities::open_path_in_editor(&file_path).map_err(|e| e.to_string())?; + utilities::open_path_in_editor(&file_path).expect("Failed to open file in editor"); drop(file); let contents = fs::read_to_string(file_path) - .map_err(|e| e.to_string())?; + .expect("Failed to read file time-entry editing in editor"); - dir.close().map_err(|e| e.to_string())?; + dir.close().expect("Failed to clear temp directory"); Ok(self.deserialize(&contents)) } From 2731b66739688c436d8c2da1132f2af4c786aac7 Mon Sep 17 00:00:00 2001 From: Shantanu Raj Date: Sun, 22 Sep 2024 18:50:52 +0200 Subject: [PATCH 06/13] =?UTF-8?q?=E2=9B=B7=EF=B8=8F=20Serialize=20directly?= =?UTF-8?q?=20into=20bytes=20instead=20of=20json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Also deserialize from bytes, makes both marshal and unmarshal process a bit more open to different use-cases --- src/models.rs | 8 +++++--- src/parcel.rs | 10 +++++----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/models.rs b/src/models.rs index 0d1efb2..a14a067 100644 --- a/src/models.rs +++ b/src/models.rs @@ -309,7 +309,7 @@ impl Parcel for TimeEntry { // Project: [Project Name] -- [Project ID] // // Task: [Task Name] -- [Task ID] - fn serialize(&self) -> String { + fn serialize(&self) -> Vec { let mut serialized = format!( "Description: {}\n\nStart: {}\n\n", self.description, self.start @@ -333,14 +333,16 @@ impl Parcel for TimeEntry { serialized.push_str(&format!("Task: {} -- {}\n\n", task.name, task.id)); } - serialized + serialized.into_bytes() } - fn deserialize(&self, data: &str) -> Self { + fn deserialize(&self, data: Vec) -> Self { let mut time_entry = self.clone(); let project: Option = None; let task: Option = None; + let data = String::from_utf8(data).unwrap(); + for line in data.lines() { if line.is_empty() { continue; diff --git a/src/parcel.rs b/src/parcel.rs index 09d913e..ca9de15 100644 --- a/src/parcel.rs +++ b/src/parcel.rs @@ -6,8 +6,8 @@ use tempfile::tempdir; use crate::utilities; pub trait Parcel { - fn serialize(&self) -> String; - fn deserialize(&self, data: &str) -> Self; + fn serialize(&self) -> Vec; + fn deserialize(&self, data: Vec) -> Self; fn launch_in_editor(&self) -> Result where @@ -19,17 +19,17 @@ pub trait Parcel { let file_path = dir.path().join("toggl.txt"); let mut file = File::create_new(file_path.clone()).expect("Failed to create file"); - file.write_all(contents.as_bytes()) + file.write_all(&contents) .expect("Failed to write current time-entry to file"); utilities::open_path_in_editor(&file_path).expect("Failed to open file in editor"); drop(file); - let contents = fs::read_to_string(file_path) + let contents = fs::read(file_path) .expect("Failed to read file time-entry editing in editor"); dir.close().expect("Failed to clear temp directory"); - Ok(self.deserialize(&contents)) + Ok(self.deserialize(contents)) } } From efa44a4d0f316882635836ff9e493a52fce527a5 Mon Sep 17 00:00:00 2001 From: Shantanu Raj Date: Sun, 22 Sep 2024 19:08:07 +0200 Subject: [PATCH 07/13] =?UTF-8?q?=F0=9F=93=9C=20Drop=20some=20logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/models.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/models.rs b/src/models.rs index a14a067..eaa6fcd 100644 --- a/src/models.rs +++ b/src/models.rs @@ -351,8 +351,6 @@ impl Parcel for TimeEntry { let key = parts.next().unwrap(); let value = parts.next().unwrap_or("NOT FOUND"); - println!("{}: {}", key, value); - match key { "Start" => time_entry.start = value.parse().unwrap(), "Stop" => time_entry.stop = Some(value.parse().unwrap()), From 0be7c46132ce65ccd2334f05634365a023c31d77 Mon Sep 17 00:00:00 2001 From: Shantanu Raj Date: Sun, 22 Sep 2024 19:25:20 +0200 Subject: [PATCH 08/13] =?UTF-8?q?=F0=9F=8C=B3=20Implement=20project/task?= =?UTF-8?q?=20deserialization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/constants.rs | 1 + src/models.rs | 52 ++++++++++++++++++++++++++++++++++++++++++------ src/parcel.rs | 4 ++-- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/src/constants.rs b/src/constants.rs index 21bfa4a..52fca69 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -27,6 +27,7 @@ pub const CONFIG_UNRECOGNIZED_MACRO_ERROR: &str = "Unrecognized macro in config pub const CONFIG_SHELL_MACRO_RESOLUTION_ERROR: &str = "Failed to resolve shell macro"; pub const CONFIG_INVALID_WORKSPACE_ERROR: &str = "Workspace not found"; pub const NO_PROJECT: &str = "No Project"; +pub const NO_TASK: &str = "No Task"; pub const NO_DESCRIPTION: &str = "(no description)"; pub const DIRECTORY_NOT_FOUND_ERROR: &str = "Directory not found"; pub const NOT_A_DIRECTORY_ERROR: &str = "Not a directory"; diff --git a/src/models.rs b/src/models.rs index eaa6fcd..28a21cb 100644 --- a/src/models.rs +++ b/src/models.rs @@ -170,6 +170,23 @@ impl std::fmt::Display for Project { } } +impl Default for Project { + fn default() -> Self { + Self { + id: -1, + name: constants::NO_PROJECT.to_string(), + workspace_id: -1, + client: None, + is_private: false, + active: true, + at: Utc::now(), + created_at: Utc::now(), + color: "0".to_string(), + billable: None, + } + } +} + #[derive(Serialize, Deserialize, Clone, Debug)] pub struct Client { pub id: i64, @@ -185,6 +202,17 @@ pub struct Task { pub project: Project, } +impl Default for Task { + fn default() -> Self { + Self { + id: -1, + name: constants::NO_TASK.to_string(), + workspace_id: -1, + project: Project::default(), + } + } +} + impl TimeEntry { pub fn get_description(&self) -> String { match self.description.as_ref() { @@ -338,8 +366,6 @@ impl Parcel for TimeEntry { fn deserialize(&self, data: Vec) -> Self { let mut time_entry = self.clone(); - let project: Option = None; - let task: Option = None; let data = String::from_utf8(data).unwrap(); @@ -357,14 +383,28 @@ impl Parcel for TimeEntry { "Billable" => time_entry.billable = value.parse().unwrap(), "Tags" => time_entry.tags = value.split(", ").map(String::from).collect(), "Project" => { - if project.is_some() { - time_entry.project = project.clone(); + let project_parts: Vec<&str> = value.split(" -- ").collect(); + if project_parts.len() < 2 { + continue; } + time_entry.project = Some(Project { + id: project_parts[1].parse().unwrap(), + name: project_parts[0].to_string(), + workspace_id: time_entry.workspace_id, + ..Project::default() + }); } "Task" => { - if task.is_some() { - time_entry.task = task.clone(); + let task_parts: Vec<&str> = value.split(" -- ").collect(); + if task_parts.len() < 2 { + continue; } + time_entry.task = Some(Task { + id: task_parts[1].parse().unwrap(), + name: task_parts[0].to_string(), + workspace_id: time_entry.workspace_id, + project: time_entry.project.clone().unwrap_or(Project::default()), + }); } "Description" => time_entry.description = value.to_string(), _ => {} diff --git a/src/parcel.rs b/src/parcel.rs index ca9de15..1a221fc 100644 --- a/src/parcel.rs +++ b/src/parcel.rs @@ -25,8 +25,8 @@ pub trait Parcel { utilities::open_path_in_editor(&file_path).expect("Failed to open file in editor"); drop(file); - let contents = fs::read(file_path) - .expect("Failed to read file time-entry editing in editor"); + let contents = + fs::read(file_path).expect("Failed to read file time-entry editing in editor"); dir.close().expect("Failed to clear temp directory"); From 36b3bd936d8098ff2d04e3c895f5430937162ead Mon Sep 17 00:00:00 2001 From: Shantanu Raj Date: Fri, 1 Nov 2024 01:15:57 +0100 Subject: [PATCH 09/13] wip --- src/models.rs | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/models.rs b/src/models.rs index 28a21cb..4424528 100644 --- a/src/models.rs +++ b/src/models.rs @@ -31,6 +31,13 @@ impl Entities { .find(|w| w.name == name) .map(|w| w.id) } + pub fn project_for_name(&self, workspace_id: i64, name: &str) -> Option { + self.projects.values().find(|p| p.workspace_id == workspace_id && p.name == name).cloned() + } + + pub fn task_for_name(&self, workspace_id: i64, name: &str) -> Option { + self.tasks.values().find(|t| t.workspace_id == workspace_id && t.name == name).cloned() + } } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -384,24 +391,36 @@ impl Parcel for TimeEntry { "Tags" => time_entry.tags = value.split(", ").map(String::from).collect(), "Project" => { let project_parts: Vec<&str> = value.split(" -- ").collect(); - if project_parts.len() < 2 { + if project_parts.len() < 1 { continue; } + let project_name = project_parts[0].to_string(); + let project_id = if project_parts.len() > 1 { + project_parts[1].parse().unwrap() + } else { + -1 + }; time_entry.project = Some(Project { - id: project_parts[1].parse().unwrap(), - name: project_parts[0].to_string(), + id: project_id, + name: project_name, workspace_id: time_entry.workspace_id, ..Project::default() }); } "Task" => { let task_parts: Vec<&str> = value.split(" -- ").collect(); - if task_parts.len() < 2 { + if task_parts.len() < 1 { continue; } + let task_name = task_parts[0].to_string(); + let task_id = if task_parts.len() > 1 { + task_parts[1].parse().unwrap() + } else { + -1 + }; time_entry.task = Some(Task { - id: task_parts[1].parse().unwrap(), - name: task_parts[0].to_string(), + id: task_id, + name: task_name, workspace_id: time_entry.workspace_id, project: time_entry.project.clone().unwrap_or(Project::default()), }); From d48fcf96c2fae3819f26dfd42766c7b0a4d91ce3 Mon Sep 17 00:00:00 2001 From: Shantanu Raj Date: Fri, 8 Nov 2024 16:32:11 +0100 Subject: [PATCH 10/13] refactor magic number -1, used for default entity ids --- src/api/client.rs | 13 ++++++++++--- src/commands/edit.rs | 14 +++++++++++--- src/commands/start.rs | 3 ++- src/config/model.rs | 22 +++++++++++----------- src/constants.rs | 1 + src/models.rs | 32 +++++++++++++++++++------------- 6 files changed, 54 insertions(+), 31 deletions(-) diff --git a/src/api/client.rs b/src/api/client.rs index 17bafc0..84e874f 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; +use crate::constants; use crate::credentials; use crate::error; use crate::models; @@ -188,7 +189,9 @@ impl ApiClient for V9ApiClient { id: p.id, name: p.name.clone(), workspace_id: p.workspace_id, - client: clients.get(&p.client_id.unwrap_or(-1)).cloned(), + client: clients + .get(&p.client_id.unwrap_or(constants::DEFAULT_ENTITY_ID)) + .cloned(), is_private: p.is_private, active: p.active, at: p.at, @@ -228,8 +231,12 @@ impl ApiClient for V9ApiClient { billable: te.billable, workspace_id: te.workspace_id, tags: te.tags.clone(), - project: projects.get(&te.project_id.unwrap_or(-1)).cloned(), - task: tasks.get(&te.task_id.unwrap_or(-1)).cloned(), + project: projects + .get(&te.project_id.unwrap_or(constants::DEFAULT_ENTITY_ID)) + .cloned(), + task: tasks + .get(&te.task_id.unwrap_or(constants::DEFAULT_ENTITY_ID)) + .cloned(), ..Default::default() }) .collect(); diff --git a/src/commands/edit.rs b/src/commands/edit.rs index 16176b4..b3a79e2 100644 --- a/src/commands/edit.rs +++ b/src/commands/edit.rs @@ -20,13 +20,21 @@ impl EditCommand { Some(running_time_entry) => { let updated_time_entry = running_time_entry .launch_in_editor() - .map_err(|e| { + .inspect_err(|e| { println!("{}", e.to_string().red()); - e }) .unwrap(); - api_client.update_time_entry(updated_time_entry).await?; + let updated_entry_id = api_client + .update_time_entry(updated_time_entry.clone()) + .await; + if updated_entry_id.is_err() { + println!("{}", "Failed to update time entry".red()); + return Err(updated_entry_id.err().unwrap()); + } + + println!("{}\n{}", "Time entry updated".green(), updated_time_entry); + return Ok(()); } } Ok(()) diff --git a/src/commands/start.rs b/src/commands/start.rs index 5dca2aa..abf8652 100644 --- a/src/commands/start.rs +++ b/src/commands/start.rs @@ -1,6 +1,7 @@ use crate::api; use crate::commands; use crate::config; +use crate::constants; use crate::models; use crate::models::Entities; use crate::picker::ItemPicker; @@ -104,7 +105,7 @@ impl StartCommand { .and_then(|track_config| track_config.get_default_entry(entities.clone())) .unwrap_or_else(|_| TimeEntry::default()); - let workspace_id = if default_time_entry.workspace_id != -1 { + let workspace_id = if default_time_entry.workspace_id != constants::DEFAULT_ENTITY_ID { default_time_entry.workspace_id } else { workspace_id diff --git a/src/config/model.rs b/src/config/model.rs index 779b291..8cb4e29 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::error::ConfigError; use crate::models::{Entities, ResultWithDefaultError, TimeEntry}; -use crate::utilities; +use crate::{constants, utilities}; /// BranchConfig optionally determines workspace, description, project, task, /// tags, and billable status of a time entry. @@ -546,16 +546,16 @@ impl TrackConfig { .find(|t| t.name == name && t.project.id == project_id.unwrap()) }); - let workspace_id = config.workspace.as_ref().map_or( - // Default to -1 if workspace is not set - Ok(-1), - |name| { - entities.workspace_id_for_name(name).ok_or_else(|| { - Box::new(ConfigError::WorkspaceNotFound(name.clone())) - as Box - }) - }, - )?; + let workspace_id = + config + .workspace + .as_ref() + .map_or(Ok(constants::DEFAULT_ENTITY_ID), |name| { + entities.workspace_id_for_name(name).ok_or_else(|| { + Box::new(ConfigError::WorkspaceNotFound(name.clone())) + as Box + }) + })?; let time_entry = TimeEntry { workspace_id, diff --git a/src/constants.rs b/src/constants.rs index 52fca69..aab37ce 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -31,6 +31,7 @@ pub const NO_TASK: &str = "No Task"; pub const NO_DESCRIPTION: &str = "(no description)"; pub const DIRECTORY_NOT_FOUND_ERROR: &str = "Directory not found"; pub const NOT_A_DIRECTORY_ERROR: &str = "Not a directory"; +pub const DEFAULT_ENTITY_ID: i64 = -1; #[cfg(target_os = "macos")] pub const SIMPLE_HOME_PATH: &str = "~/Library/Application Support"; diff --git a/src/models.rs b/src/models.rs index 4424528..f6e50e9 100644 --- a/src/models.rs +++ b/src/models.rs @@ -32,11 +32,17 @@ impl Entities { .map(|w| w.id) } pub fn project_for_name(&self, workspace_id: i64, name: &str) -> Option { - self.projects.values().find(|p| p.workspace_id == workspace_id && p.name == name).cloned() + self.projects + .values() + .find(|p| p.workspace_id == workspace_id && p.name == name) + .cloned() } pub fn task_for_name(&self, workspace_id: i64, name: &str) -> Option { - self.tasks.values().find(|t| t.workspace_id == workspace_id && t.name == name).cloned() + self.tasks + .values() + .find(|t| t.workspace_id == workspace_id && t.name == name) + .cloned() } } @@ -180,9 +186,9 @@ impl std::fmt::Display for Project { impl Default for Project { fn default() -> Self { Self { - id: -1, + id: constants::DEFAULT_ENTITY_ID, name: constants::NO_PROJECT.to_string(), - workspace_id: -1, + workspace_id: constants::DEFAULT_ENTITY_ID, client: None, is_private: false, active: true, @@ -212,9 +218,9 @@ pub struct Task { impl Default for Task { fn default() -> Self { Self { - id: -1, + id: constants::DEFAULT_ENTITY_ID, name: constants::NO_TASK.to_string(), - workspace_id: -1, + workspace_id: constants::DEFAULT_ENTITY_ID, project: Project::default(), } } @@ -280,7 +286,7 @@ impl Default for TimeEntry { fn default() -> Self { let start = Utc::now(); Self { - id: -1, + id: constants::DEFAULT_ENTITY_ID, created_with: Some(constants::CLIENT_NAME.to_string()), billable: false, description: "".to_string(), @@ -290,7 +296,7 @@ impl Default for TimeEntry { stop: None, tags: Vec::new(), task: None, - workspace_id: -1, + workspace_id: constants::DEFAULT_ENTITY_ID, } } } @@ -391,14 +397,14 @@ impl Parcel for TimeEntry { "Tags" => time_entry.tags = value.split(", ").map(String::from).collect(), "Project" => { let project_parts: Vec<&str> = value.split(" -- ").collect(); - if project_parts.len() < 1 { + if project_parts.is_empty() { continue; } let project_name = project_parts[0].to_string(); let project_id = if project_parts.len() > 1 { project_parts[1].parse().unwrap() } else { - -1 + constants::DEFAULT_ENTITY_ID }; time_entry.project = Some(Project { id: project_id, @@ -409,20 +415,20 @@ impl Parcel for TimeEntry { } "Task" => { let task_parts: Vec<&str> = value.split(" -- ").collect(); - if task_parts.len() < 1 { + if task_parts.is_empty() { continue; } let task_name = task_parts[0].to_string(); let task_id = if task_parts.len() > 1 { task_parts[1].parse().unwrap() } else { - -1 + constants::DEFAULT_ENTITY_ID }; time_entry.task = Some(Task { id: task_id, name: task_name, workspace_id: time_entry.workspace_id, - project: time_entry.project.clone().unwrap_or(Project::default()), + project: time_entry.project.clone().unwrap_or_default(), }); } "Description" => time_entry.description = value.to_string(), From 6049864b8bc533dc4a97a105e1739ff9dfdca9df Mon Sep 17 00:00:00 2001 From: Shantanu Raj Date: Wed, 11 Dec 2024 20:35:41 +0100 Subject: [PATCH 11/13] =?UTF-8?q?=F0=9F=8E=AC=20Allow=20editing=20time-ent?= =?UTF-8?q?ries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/edit.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/edit.rs b/src/commands/edit.rs index b3a79e2..b891977 100644 --- a/src/commands/edit.rs +++ b/src/commands/edit.rs @@ -21,7 +21,7 @@ impl EditCommand { let updated_time_entry = running_time_entry .launch_in_editor() .inspect_err(|e| { - println!("{}", e.to_string().red()); + eprintln!("{}", e.to_string().red()); }) .unwrap(); @@ -29,7 +29,7 @@ impl EditCommand { .update_time_entry(updated_time_entry.clone()) .await; if updated_entry_id.is_err() { - println!("{}", "Failed to update time entry".red()); + eprintln!("{}", "Failed to update time entry".red()); return Err(updated_entry_id.err().unwrap()); } From 3b2a55208670011600df5b93aa0ad02f70970ef8 Mon Sep 17 00:00:00 2001 From: Shantanu Raj Date: Wed, 11 Dec 2024 20:43:16 +0100 Subject: [PATCH 12/13] =?UTF-8?q?=E2=9A=A0=EF=B8=8FTry=20and=20always=20lo?= =?UTF-8?q?g=20errors=20to=20stderr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/list.rs | 2 +- src/commands/start.rs | 2 +- src/config/manage.rs | 2 +- src/config/model.rs | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index 742a584..5056b97 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -15,7 +15,7 @@ impl ListCommand { entity: Option, ) -> ResultWithDefaultError<()> { match api_client.get_entities().await { - Err(error) => println!( + Err(error) => eprintln!( "{}\n{}", "Couldn't fetch time entries the from API".red(), error diff --git a/src/commands/start.rs b/src/commands/start.rs index abf8652..cd90ff0 100644 --- a/src/commands/start.rs +++ b/src/commands/start.rs @@ -149,7 +149,7 @@ impl StartCommand { .create_time_entry(time_entry_to_create.clone()) .await; if started_entry_id.is_err() { - println!("{}", "Failed to start time entry".red()); + eprintln!("{}", "Failed to start time entry".red()); return Err(started_entry_id.err().unwrap()); } diff --git a/src/config/manage.rs b/src/config/manage.rs index 8b98cb5..6f688fe 100644 --- a/src/config/manage.rs +++ b/src/config/manage.rs @@ -38,7 +38,7 @@ impl ConfigManageCommand { Ok(()) } Err(e) => { - println!("In config parse {}", e); + eprintln!("In config parse {}", e); Err(Box::new(ConfigError::Parse)) } } diff --git a/src/config/model.rs b/src/config/model.rs index 8cb4e29..b0e4616 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -486,8 +486,8 @@ fn process_config_value(base_dir: &Path, input: String) -> Option { } token.push(c); } - let resolved = resolve_token(base_dir, &token).map_err(|e| { - println!("Failed to resolve token: {}", e); + let resolved = resolve_token(base_dir, &token).inspect_err(|e| { + eprintln!("Failed to resolve token: {}", e); }); if let Ok(resolved_token) = resolved { result.push_str(&resolved_token); From a41bbb1a180fb59b209f193f71d1d2ef5ebde2f2 Mon Sep 17 00:00:00 2001 From: Shantanu Raj Date: Wed, 11 Dec 2024 21:40:40 +0100 Subject: [PATCH 13/13] =?UTF-8?q?=F0=9F=93=9D=20Apply=20project=20and=20ta?= =?UTF-8?q?sk=20edits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/edit.rs | 41 ++++++++++++++++++++++++++++++++++------- src/models.rs | 33 ++++++++------------------------- 2 files changed, 42 insertions(+), 32 deletions(-) diff --git a/src/commands/edit.rs b/src/commands/edit.rs index b891977..305e468 100644 --- a/src/commands/edit.rs +++ b/src/commands/edit.rs @@ -1,5 +1,6 @@ use crate::api::client::ApiClient; -use crate::models; +use crate::constants::DEFAULT_ENTITY_ID; +use crate::models::{self, TimeEntry}; use crate::parcel::Parcel; use crate::picker; use colored::Colorize; @@ -18,12 +19,38 @@ impl EditCommand { match entities.running_time_entry() { None => println!("{}", "No time entry is running at the moment".yellow()), Some(running_time_entry) => { - let updated_time_entry = running_time_entry - .launch_in_editor() - .inspect_err(|e| { - eprintln!("{}", e.to_string().red()); - }) - .unwrap(); + let updated_time_entry = { + let workspace_id = running_time_entry.workspace_id; + + let new_entry = running_time_entry + .launch_in_editor() + .inspect_err(|e| { + eprintln!("{}", e.to_string().red()); + }) + .unwrap(); + + let project = new_entry.project.and_then(|project| { + if project.id == DEFAULT_ENTITY_ID { + entities.project_for_name(workspace_id, &project.name) + } else { + Some(project) + } + }); + + let task = new_entry.task.and_then(|task| { + if task.id == DEFAULT_ENTITY_ID { + entities.task_for_name(workspace_id, &task.name) + } else { + Some(task) + } + }); + + TimeEntry { + project, + task, + ..new_entry + } + }; let updated_entry_id = api_client .update_time_entry(updated_time_entry.clone()) diff --git a/src/models.rs b/src/models.rs index f6e50e9..ec10990 100644 --- a/src/models.rs +++ b/src/models.rs @@ -367,11 +367,11 @@ impl Parcel for TimeEntry { } if let Some(project) = &self.project { - serialized.push_str(&format!("Project: {} -- {}\n\n", project.name, project.id)); + serialized.push_str(&format!("Project: {}\n\n", project.name)); } if let Some(task) = &self.task { - serialized.push_str(&format!("Task: {} -- {}\n\n", task.name, task.id)); + serialized.push_str(&format!("Task: {}\n\n", task.name)); } serialized.into_bytes() @@ -388,7 +388,8 @@ impl Parcel for TimeEntry { } let mut parts = line.splitn(2, ": "); let key = parts.next().unwrap(); - let value = parts.next().unwrap_or("NOT FOUND"); + + let value = parts.next().map(|v| v.trim()).unwrap_or("NOT FOUND"); match key { "Start" => time_entry.start = value.parse().unwrap(), @@ -396,36 +397,18 @@ impl Parcel for TimeEntry { "Billable" => time_entry.billable = value.parse().unwrap(), "Tags" => time_entry.tags = value.split(", ").map(String::from).collect(), "Project" => { - let project_parts: Vec<&str> = value.split(" -- ").collect(); - if project_parts.is_empty() { - continue; - } - let project_name = project_parts[0].to_string(); - let project_id = if project_parts.len() > 1 { - project_parts[1].parse().unwrap() - } else { - constants::DEFAULT_ENTITY_ID - }; + let project_name = value.to_string(); time_entry.project = Some(Project { - id: project_id, + id: constants::DEFAULT_ENTITY_ID, name: project_name, workspace_id: time_entry.workspace_id, ..Project::default() }); } "Task" => { - let task_parts: Vec<&str> = value.split(" -- ").collect(); - if task_parts.is_empty() { - continue; - } - let task_name = task_parts[0].to_string(); - let task_id = if task_parts.len() > 1 { - task_parts[1].parse().unwrap() - } else { - constants::DEFAULT_ENTITY_ID - }; + let task_name = value.to_string(); time_entry.task = Some(Task { - id: task_id, + id: constants::DEFAULT_ENTITY_ID, name: task_name, workspace_id: time_entry.workspace_id, project: time_entry.project.clone().unwrap_or_default(),