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/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/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..305e468 --- /dev/null +++ b/src/commands/edit.rs @@ -0,0 +1,69 @@ +use crate::api::client::ApiClient; +use crate::constants::DEFAULT_ENTITY_ID; +use crate::models::{self, TimeEntry}; +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 = { + 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()) + .await; + if updated_entry_id.is_err() { + eprintln!("{}", "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/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/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/commands/start.rs b/src/commands/start.rs index 5dca2aa..cd90ff0 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 @@ -148,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 779b291..b0e4616 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. @@ -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); @@ -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 21bfa4a..aab37ce 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -27,9 +27,11 @@ 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"; +pub const DEFAULT_ENTITY_ID: i64 = -1; #[cfg(target_os = "macos")] pub const SIMPLE_HOME_PATH: &str = "~/Library/Application Support"; 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..ec10990 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}; @@ -31,6 +31,19 @@ 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)] @@ -170,6 +183,23 @@ impl std::fmt::Display for Project { } } +impl Default for Project { + fn default() -> Self { + Self { + id: constants::DEFAULT_ENTITY_ID, + name: constants::NO_PROJECT.to_string(), + workspace_id: constants::DEFAULT_ENTITY_ID, + 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 +215,17 @@ pub struct Task { pub project: Project, } +impl Default for Task { + fn default() -> Self { + Self { + id: constants::DEFAULT_ENTITY_ID, + name: constants::NO_TASK.to_string(), + workspace_id: constants::DEFAULT_ENTITY_ID, + project: Project::default(), + } + } +} + impl TimeEntry { pub fn get_description(&self) -> String { match self.description.as_ref() { @@ -245,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(), @@ -255,7 +296,7 @@ impl Default for TimeEntry { stop: None, tags: Vec::new(), task: None, - workspace_id: -1, + workspace_id: constants::DEFAULT_ENTITY_ID, } } } @@ -290,3 +331,94 @@ 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) -> Vec { + 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)); + } + + if let Some(task) = &self.task { + serialized.push_str(&format!("Task: {}\n\n", task.name)); + } + + serialized.into_bytes() + } + + fn deserialize(&self, data: Vec) -> Self { + let mut time_entry = self.clone(); + + let data = String::from_utf8(data).unwrap(); + + 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().map(|v| v.trim()).unwrap_or("NOT FOUND"); + + 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" => { + let project_name = value.to_string(); + time_entry.project = Some(Project { + id: constants::DEFAULT_ENTITY_ID, + name: project_name, + workspace_id: time_entry.workspace_id, + ..Project::default() + }); + } + "Task" => { + let task_name = value.to_string(); + time_entry.task = Some(Task { + id: constants::DEFAULT_ENTITY_ID, + name: task_name, + workspace_id: time_entry.workspace_id, + project: time_entry.project.clone().unwrap_or_default(), + }); + } + "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..1a221fc --- /dev/null +++ b/src/parcel.rs @@ -0,0 +1,35 @@ +use std::fs::{self, File}; +use std::io::Write; + +use tempfile::tempdir; + +use crate::utilities; + +pub trait Parcel { + fn serialize(&self) -> Vec; + fn deserialize(&self, data: Vec) -> Self; + + fn launch_in_editor(&self) -> Result + where + Self: Sized, + { + let contents = self.serialize(); + + 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()).expect("Failed to create file"); + 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(file_path).expect("Failed to read file time-entry editing in editor"); + + dir.close().expect("Failed to clear temp directory"); + + Ok(self.deserialize(contents)) + } +}