diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e97ce5..d962d73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Feature `second-precision` to build bartib with the ability to track activities to the second (thank to [@johnDeSilencio](https://github.com/johnDeSilencio)) - Subcommand `search` to search the list of last activities for terms (thanks to [@Pyxels](https://github.com/Pyxels)) - Subcommand `status` to display the total duration of activities today, in the current week and in the current month (thanks to [@airenas](https://github.com/airenas)) - Option `--no-quotes` to `project` to suppres quotes in the projects list (thanks to [@defigli](https://github.com/defigli)) diff --git a/Cargo.toml b/Cargo.toml index 254580a..840e0c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,12 +2,12 @@ name = "bartib" version = "1.1.0" authors = ["Nikolas Schmidt-Voigt "] +edition = "2018" description = "A simple timetracker for the command line" +readme = "README.md" homepage = "https://github.com/nikolassv/bartib" repository = "https://github.com/nikolassv/bartib" -edition = "2018" license = "GPL-3.0-or-later" -readme = "README.md" keywords = ["cli"] categories = ["command-line-utilities"] @@ -17,30 +17,39 @@ path-guid = "BE8CBFAC-1DE6-4B0D-BB4B-C31A85A34AC5" license = false eula = false +# Config for 'cargo dist' +[workspace.metadata.dist] +# The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.10.0" +# CI backends to support +ci = ["github"] +# The installers to generate for each app +installers = ["shell", "powershell", "msi"] +# Target platforms to build apps for (Rust target-triple syntax) +targets = [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", +] +# Publish jobs to run in CI +pr-run-mode = "upload" + [dependencies] +anyhow = "1.0.0" chrono = "0.4.0" clap = "2.0.0" -thiserror = "1.0.0" -anyhow = "1.0.0" nu-ansi-term = "0.46.0" term_size = "0.3.0" textwrap = "0.16.0" +thiserror = "1.0.0" wildmatch = "2.3.0" +[features] +# Timestamps are recorded with second precision instead of the default minute precision +second-precision = [] + # The profile that 'cargo dist' will build with [profile.dist] inherits = "release" lto = "thin" - -# Config for 'cargo dist' -[workspace.metadata.dist] -# The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) -cargo-dist-version = "0.10.0" -# CI backends to support -ci = ["github"] -# The installers to generate for each app -installers = ["shell", "powershell", "msi"] -# Target platforms to build apps for (Rust target-triple syntax) -targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] -# Publish jobs to run in CI -pr-run-mode = "upload" diff --git a/README.md b/README.md index 73d3528..a427f7c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Bartib is an easy to use time tracking tool for the command line. It saves a log - [Via homebrew](#via-homebrew) - [Via apk (Alpine Linux)](#via-apk-alpine-linux) - [How to build Bartib](#how-to-build-bartib) + - [Precision](#precision) - [How to define in which file to save the log of your activities](#how-to-define-in-which-file-to-save-the-log-of-your-activities) - [How to edit or delete tracked activities](#how-to-edit-or-delete-tracked-activities) - [How to activate auto completion](#how-to-activate-auto-completion) @@ -186,6 +187,14 @@ Bartib is written in rust. You may build it yourself with the help of cargo. Jus cargo build --release ``` +#### Precision + +By default, Bartib records timestamps in minutes. If you would like to record timestamps with second precision, you can enable the `second-precision` feature: + +```bash +cargo build --features=second-precision --release +``` + ### How to define in which file to save the log of your activities You may either specify the absolute path to your log as an extra parameter (`--file` or `-f`) to your bartib command: diff --git a/src/conf.rs b/src/conf.rs index 710fb09..c508abd 100644 --- a/src/conf.rs +++ b/src/conf.rs @@ -1,7 +1,18 @@ use chrono::Duration; -pub static FORMAT_DATETIME: &str = "%F %R"; +pub static FORMAT_MINUTE_PRECISION_DATETIME: &str = "%F %R"; +pub static FORMAT_SECOND_PRECISION_DATETIME: &str = "%F %T"; + +#[cfg(not(feature = "second-precision"))] +pub static FORMAT_DATETIME: &str = FORMAT_MINUTE_PRECISION_DATETIME; +#[cfg(feature = "second-precision")] +pub static FORMAT_DATETIME: &str = FORMAT_SECOND_PRECISION_DATETIME; + +#[cfg(not(feature = "second-precision"))] pub static FORMAT_TIME: &str = "%R"; +#[cfg(feature = "second-precision")] +pub static FORMAT_TIME: &str = "%T"; + pub static FORMAT_DATE: &str = "%F"; pub static DEFAULT_WIDTH: usize = usize::MAX; pub static REPORT_INDENTATION: usize = 4; diff --git a/src/data/activity.rs b/src/data/activity.rs index 09e7eda..4fff73c 100644 --- a/src/data/activity.rs +++ b/src/data/activity.rs @@ -1,3 +1,7 @@ +#[cfg(not(feature = "second-precision"))] +use chrono::DurationRound; +#[cfg(feature = "second-precision")] +use chrono::Timelike; use chrono::{Duration, Local, NaiveDateTime}; use std::fmt; use std::str::{Chars, FromStr}; @@ -115,9 +119,60 @@ impl FromStr for Activity { } } +#[cfg(not(feature = "second-precision"))] fn parse_timepart(time_part: &str) -> Result { - NaiveDateTime::parse_from_str(time_part.trim(), conf::FORMAT_DATETIME) - .map_err(|_| ActivityError::DateTimeParseError) + match NaiveDateTime::parse_from_str(time_part.trim(), conf::FORMAT_DATETIME) { + Ok(datetime) => Ok(datetime), + Err(_) => { + // Presume that the timestamp has second-precision and that is the cause of the error + match NaiveDateTime::parse_from_str( + time_part.trim(), + conf::FORMAT_SECOND_PRECISION_DATETIME, + ) { + Ok(datetime) => { + // Successfully parsed timestamp. Notify the user about the mismatch + // and round to the nearest minute + eprintln!("WARNING: Bartib log encountered timestamps with minute precision."); + eprintln!("This version of Bartib has been compiled with second precision"); + + let datetime = datetime + .duration_round(Duration::minutes(1)) + .map_err(|_| ActivityError::DateTimeParseError)?; + + Ok(datetime) + } + Err(_) => Err(ActivityError::DateTimeParseError), + } + } + } +} + +#[cfg(feature = "second-precision")] +fn parse_timepart(time_part: &str) -> Result { + match NaiveDateTime::parse_from_str(time_part.trim(), conf::FORMAT_DATETIME) { + Ok(datetime) => Ok(datetime), + Err(_) => { + // Presume that the timestamp has minute-precision and that is the cause of the error + match NaiveDateTime::parse_from_str( + time_part.trim(), + conf::FORMAT_MINUTE_PRECISION_DATETIME, + ) { + Ok(datetime) => { + // Successfully parsed timestamp. Notify the user about the mismatch + // and set seconds to zero + eprintln!("WARNING: Bartib log encountered timestamps with minute precision."); + eprintln!("This version of Bartib has been compiled with second precision"); + + let datetime = datetime + .with_second(0) + .ok_or(ActivityError::DateTimeParseError)?; + + Ok(datetime) + } + Err(_) => Err(ActivityError::DateTimeParseError), + } + } + } } /** @@ -195,6 +250,7 @@ mod tests { } #[test] + #[cfg(not(feature = "second-precision"))] fn display() { let mut t = Activity::start( "test project| 1".to_string(), @@ -215,6 +271,30 @@ mod tests { } #[test] + #[cfg(feature = "second-precision")] + fn display() { + let mut t = Activity::start( + "test project| 1".to_string(), + "test\\description".to_string(), + None, + ); + t.start = + NaiveDateTime::parse_from_str("2021-02-16 16:14:53", conf::FORMAT_DATETIME).unwrap(); + assert_eq!( + format!("{t}"), + "2021-02-16 16:14:53 | test project\\| 1 | test\\\\description\n" + ); + t.end = Some( + NaiveDateTime::parse_from_str("2021-02-16 18:23:17", conf::FORMAT_DATETIME).unwrap(), + ); + assert_eq!( + format!("{t}"), + "2021-02-16 16:14:53 - 2021-02-16 18:23:17 | test project\\| 1 | test\\\\description\n" + ); + } + + #[test] + #[cfg(not(feature = "second-precision"))] fn from_str_running_activity() { let t = Activity::from_str("2021-02-16 16:14 | test project | test description").unwrap(); @@ -231,6 +311,26 @@ mod tests { } #[test] + #[cfg(feature = "second-precision")] + fn from_str_running_activity() { + let t = + Activity::from_str("2021-02-16 16:14:53 | test project | test description").unwrap(); + + assert_eq!(t.start.date().year(), 2021); + assert_eq!(t.start.date().month(), 2); + assert_eq!(t.start.date().day(), 16); + + assert_eq!(t.start.time().hour(), 16); + assert_eq!(t.start.time().minute(), 14); + assert_eq!(t.start.time().second(), 53); + + assert_eq!(t.description, "test description".to_string()); + assert_eq!(t.project, "test project".to_string()); + assert_eq!(t.end, None); + } + + #[test] + #[cfg(not(feature = "second-precision"))] fn from_str_running_activity_no_description() { let t = Activity::from_str("2021-02-16 16:14 | test project").unwrap(); @@ -247,6 +347,25 @@ mod tests { } #[test] + #[cfg(feature = "second-precision")] + fn from_str_running_activity_no_description() { + let t = Activity::from_str("2021-02-16 16:14:53 | test project").unwrap(); + + assert_eq!(t.start.date().year(), 2021); + assert_eq!(t.start.date().month(), 2); + assert_eq!(t.start.date().day(), 16); + + assert_eq!(t.start.time().hour(), 16); + assert_eq!(t.start.time().minute(), 14); + assert_eq!(t.start.time().second(), 53); + + assert_eq!(t.description, String::new()); + assert_eq!(t.project, "test project".to_string()); + assert_eq!(t.end, None); + } + + #[test] + #[cfg(not(feature = "second-precision"))] fn from_str_stopped_activity() { let t = Activity::from_str( "2021-02-16 16:14 - 2021-02-16 18:23 | test project | test description", @@ -266,6 +385,28 @@ mod tests { } #[test] + #[cfg(feature = "second-precision")] + fn from_str_stopped_activity() { + let t = Activity::from_str( + "2021-02-16 16:14:53 - 2021-02-16 18:23:17 | test project | test description", + ) + .unwrap(); + + assert_ne!(t.end, None); + + let end = t.end.unwrap(); + + assert_eq!(end.date().year(), 2021); + assert_eq!(end.date().month(), 2); + assert_eq!(end.date().day(), 16); + + assert_eq!(end.time().hour(), 18); + assert_eq!(end.time().minute(), 23); + assert_eq!(end.time().second(), 17); + } + + #[test] + #[cfg(not(feature = "second-precision"))] fn from_str_escaped_chars() { let t = Activity::from_str( "2021-02-16 16:14 - 2021-02-16 18:23 | test project\\| 1 | test\\\\description", @@ -277,6 +418,19 @@ mod tests { } #[test] + #[cfg(feature = "second-precision")] + fn from_str_escaped_chars() { + let t = Activity::from_str( + "2021-02-16 16:14:53 - 2021-02-16 18:23:17 | test project\\| 1 | test\\\\description", + ) + .unwrap(); + + assert_eq!(t.project, "test project| 1"); + assert_eq!(t.description, "test\\description"); + } + + #[test] + #[cfg(not(feature = "second-precision"))] fn string_roundtrip() { let mut t = Activity::start( "ex\\ample\\\\pro|ject".to_string(), @@ -304,6 +458,24 @@ mod tests { assert_eq!(t.description, t2.description); } + #[test] + #[cfg(feature = "second-precision")] + fn string_roundtrip() { + let mut t = Activity::start( + "ex\\ample\\\\pro|ject".to_string(), + "e\\\\xam|||ple tas\t\t\nk".to_string(), + None, + ); + t.stop(None); + let t2 = Activity::from_str(format!("{t}").as_str()).unwrap(); + + assert_eq!(t.start.with_nanosecond(0).unwrap(), t2.start); + assert_eq!(t.end.unwrap().with_nanosecond(0).unwrap(), t2.end.unwrap()); + + assert_eq!(t.project, t2.project); + assert_eq!(t.description, t2.description); + } + #[test] fn from_str_errors() { let t = Activity::from_str("2021 test project"); @@ -312,4 +484,62 @@ mod tests { let t = Activity::from_str("asb - 2021- | project"); assert!(matches!(t, Err(ActivityError::DateTimeParseError))); } + + #[test] + #[cfg(not(feature = "second-precision"))] + fn from_str_second_precision_encountered_when_minute_precision_expected() { + let t = Activity::from_str( + "2021-02-16 16:14:53 - 2021-02-16 18:23:17 | test project | test description", + ) + .unwrap(); + + assert_eq!(t.start.date().year(), 2021); + assert_eq!(t.start.date().month(), 2); + assert_eq!(t.start.date().day(), 16); + + assert_eq!(t.start.time().hour(), 16); + assert_eq!(t.start.time().minute(), 15); + assert_eq!(t.start.time().second(), 0); + + assert_ne!(t.end, None); + + let end = t.end.unwrap(); + + assert_eq!(end.date().year(), 2021); + assert_eq!(end.date().month(), 2); + assert_eq!(end.date().day(), 16); + + assert_eq!(end.time().hour(), 18); + assert_eq!(end.time().minute(), 23); + assert_eq!(end.time().second(), 0); + } + + #[test] + #[cfg(feature = "second-precision")] + fn from_str_minute_precision_encountered_when_second_precision_expected() { + let t = Activity::from_str( + "2021-02-16 16:14 - 2021-02-16 18:23 | test project | test description", + ) + .unwrap(); + + assert_eq!(t.start.date().year(), 2021); + assert_eq!(t.start.date().month(), 2); + assert_eq!(t.start.date().day(), 16); + + assert_eq!(t.start.time().hour(), 16); + assert_eq!(t.start.time().minute(), 14); + assert_eq!(t.start.time().second(), 0); + + assert_ne!(t.end, None); + + let end = t.end.unwrap(); + + assert_eq!(end.date().year(), 2021); + assert_eq!(end.date().month(), 2); + assert_eq!(end.date().day(), 16); + + assert_eq!(end.time().hour(), 18); + assert_eq!(end.time().minute(), 23); + assert_eq!(end.time().second(), 0); + } } diff --git a/src/view/format_util.rs b/src/view/format_util.rs index 4f56dde..2dec57b 100644 --- a/src/view/format_util.rs +++ b/src/view/format_util.rs @@ -10,7 +10,10 @@ pub fn format_duration(duration: &Duration) -> String { if duration.num_minutes() > 0 { duration_string.push_str(&format!("{:0>2}m", duration.num_minutes() % 60)); } else { + #[cfg(not(feature = "second-precision"))] duration_string.push_str("<1m"); + #[cfg(feature = "second-precision")] + duration_string.push_str(&format!("{:0>2}s", duration.num_seconds() % 60)); } duration_string