├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── images └── screenshot.png ├── setup.iss └── src ├── basic_op.rs ├── errors.rs ├── import_export_op.rs ├── lib.rs ├── list_op.rs ├── main.rs ├── repo.rs ├── report_op.rs └── util.rs /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | 12 | 13 | #Added by cargo 14 | # 15 | #already existing elements are commented out 16 | 17 | /target 18 | #**/*.rs.bk 19 | /.idea/ 20 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "timers" 3 | version = "0.4.0" 4 | authors = ["Francesco Pasa "] 5 | edition = "2018" 6 | 7 | [dependencies] 8 | clap = "2.33.0" 9 | dirs = "2.0.2" 10 | chrono = "0.4.10" 11 | colored = "1.9" 12 | itertools = "0.8" 13 | term_size = "0.3" 14 | csv = "1.1.3" 15 | fs_extra = "1.1.0" 16 | scrawl = "1.1.0" 17 | 18 | [profile.release] 19 | lto = true 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Francesco Pasa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `timers` - Command line time tracking tool 2 | 3 | `timers` is a simple and effective time tracking tool with a easy-to-use 4 | command line interface. With `timers` you can: 5 | 6 | - Log time on tasks, 7 | - Get a report on how much you worked each day of the week, 8 | - Show a timeline with your tasks today, 9 | 10 | `timers` is written in rust and uses simple text files to save the tasks, which make it 11 | extremely fast and lightweight. 12 | 13 | ![Screenshot of a terminal running timers.](images/screenshot.png) 14 | 15 | ## Install 16 | 17 | ### Linux 18 | 19 | On linux, just download the precompiled binary: 20 | 21 | ```bash 22 | EXEC='/usr/local/bin/timers' && sudo wget https://github.com/frapa/timers/releases/latest/download/timers-linux -O $EXEC && sudo chmod +x $EXEC 23 | ``` 24 | 25 | To update, simply run the command again, it will overwrite the old version. To remove, type 26 | 27 | ```bash 28 | EXEC='/usr/local/bin/timers' && sudo rm $EXEC 29 | ``` 30 | 31 | ### Windows 32 | 33 | Download and run installer from [here](https://github.com/frapa/timers/releases/latest). 34 | To update, just install the new version. Remove like any other Windows program. 35 | 36 | **Note:** Colored output is supported only by PowerShell and not by CMD. If you want to use the watch feature 37 | you need to use a compliant terminal such as git bash. 38 | 39 | ## How to use 40 | 41 | ### The basics 42 | 43 | To start logging time on a task, you use the `log` (or `start`) sub-command, like this: 44 | 45 | ```bash 46 | $ timers log "Writing timers readme" 47 | @1: Writing timers readme 48 | status: logging 49 | time: 0s 50 | ``` 51 | 52 | The `@1` is the task id, that you can use to reference the task later. 53 | 54 | If you were already logging a task, `timers` will ask if you want to stop the current task and start the logging 55 | on the new one. 56 | 57 | You can check the current logging status with `timers status`, which will print a message with the currently 58 | logged task and the amount of time you have logged on it: 59 | 60 | ```bash 61 | $ timers status 62 | @15: Another task 63 | status: logging 64 | time: 1d 2h 42m 65 | 66 | # We can pass --timeline to show a nice timeline of todays tasks 67 | $ timers status --timeline 68 | █████████████████████▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░ 69 | First task Third task 70 | 71 | @15: Third task 72 | status: logging 73 | time: 1h 47m 74 | ``` 75 | 76 | Another cool option is `--watch` that keeps the status displayed in the console and updates it periodically 77 | (this option is supported for git bash in Windows). 78 | 79 | You can stop logging on a task with `timers stop`: 80 | 81 | ```bash 82 | $ timers stop 83 | @1: Writing timers readme 84 | status: stopped 85 | time: 19m 41s 86 | ``` 87 | 88 | You can then resume logging on a previous task by id, as follows: 89 | 90 | ```bash 91 | $ timers log @1 92 | @1: Writing timers readme 93 | status: logging 94 | time: 19m 41s 95 | ``` 96 | 97 | ### Introspection commands 98 | 99 | If you want to see the list of all your tasks, you can run `timers tasks` 100 | 101 | ```bash 102 | $ timers tasks 103 | ID TASK DURATION 104 | ---------------------------------------------------------- 105 | @1 My first task 2h 44m 12s 106 | @2 Another task 3h 5m 47s 107 | ``` 108 | 109 | You can get a the total time logged with the `report` command. 110 | It works like this: 111 | 112 | ```bash 113 | $ timers report days # Or even simply `timers report` 114 | DAY TIME LOGGED TASKS 115 | ---------------------------------- 116 | Monday 8h 26m 3 117 | Tuesday 7h 49m 4 118 | Wednesday 8h 4m 6 119 | Thursday 0s 0 120 | Friday 0s 0 121 | Saturday 0s 0 122 | Sunday 0s 0 123 | ---------------------------------- 124 | Total 24h 19m 13 125 | ``` 126 | 127 | ### "Advanced" features 128 | 129 | You can start logging at a certain time with the `--at` option: 130 | 131 | ```bash 132 | $ timers log "Your task" --at 10:34 133 | 134 | # You can also set the time to yesterday, by prepending y 135 | $ timers log "Your task" --at y10:34 # yesterday at 10:34 136 | 137 | # Or you can use relative time with + and - 138 | $ timers log "Your task" --at -10 # 10 minutes ago 139 | 140 | # For custom things, you can specify the full local date with time 141 | $ timers log "Your task" --at "2019-11-10 11:10" 142 | ``` 143 | 144 | The nice thing is that if you're already logging, it will end 145 | the current task at the specified past time point, so no overlapping 146 | tasks will be logged! 147 | 148 | ### Export 149 | 150 | `timers` can export data into CSV format. You can either export logs 151 | (which are the entries of when you started and stopped working on a task. 152 | each task can have multiple logs), with the detailed start and end times, 153 | or tasks, with aggregated duration. 154 | 155 | To export use the `export` command, followed by the name of the thing you want 156 | to export, as in 157 | 158 | ```bash 159 | $ timers export logs 160 | Task ID,Task name,Begin (UTC),End (UTC),Duration (hours) 161 | 1,My first task,2020-02-24T21:30:57.613882582+00:00,2020-02-26T20:36:44.803991524+00:00,47.09638888888889 162 | 1,My first task,2020-02-26T20:36:44.805320312+00:00,2020-02-26T20:43:54.642466170+00:00,0.11916666666666667 163 | 2,Another task,2020-02-26T20:43:54.643808060+00:00,2020-02-26T20:50:39.862161146+00:00,0.1125 164 | ... 165 | 166 | $ timers export tasks 167 | Task ID,Task name,Begin (UTC),End (UTC),Duration (hours) 168 | Task ID,Task name,Logs,Duration (hours) 169 | 1,My first task,1,0.0022222222222222222 170 | 2,Another task,1,17.691944444444445 171 | ... 172 | ``` 173 | 174 | You can also save the data to a file and only export data between certain dates: 175 | 176 | ```bash 177 | # Save to logs.csv 178 | $ timers export logs -o logs.csv 179 | 180 | # Export only the February 2020 logs (to date is not included) 181 | $ timer export logs --from 2020-02-01 --to 2020-03-01 182 | ``` 183 | 184 | There are also other options, please consult the command line help for details. 185 | 186 | ## FAQ 187 | 188 | **Why should I choose `timers` instead of any other time tracking tool?** 189 | 190 | 3 reasons: 191 | 192 | - You like simple things like plain text files. Who ever want a database instead of those? 193 | - Let your colleagues think you're a genius who's always mysteriously typing into a black box (the terminal). 194 | - You believe global warming is a threat or you're laptop has a really shitty battery. 195 | - Colored terminal output. This alone would be enough do discard any competing tool. 196 | 197 | **A command line tool? Come on, we're in the 2020s!** 198 | 199 | Command line can be very simple and let me focus my energy on more important features. 200 | Plus it's much more efficient (battery, memory, CPU) than any GUI tool, 201 | or even worse web-based solution. 202 | 203 | You're free to use your web-based tool that will eat 100 MB of your memory, download 10 Mb 204 | of crap (including the very necessary 8 Mb of javascript code) just so you can click 205 | the "log" button on a task. 206 | 207 | **Where is my data stored? Are you stealing it?** 208 | 209 | No way, it's all in the `timers_time_logs` folder inside your user app data folder (typically 210 | `/home//.local/share/timers_time_logs` on unix systems and `C:\Users\\AppData\Roaming\timers_time_logs` 211 | on Windows). 212 | 213 | **`timers` has a bug, what do I do?** 214 | 215 | File a issue on the tab above. If the gods of the internet are favourable, I might 216 | look into fixing it. Please provide a decent bug report. 217 | -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frapa/timers/c935e58164780984e65d8ae4c24a43e0684a8165/images/screenshot.png -------------------------------------------------------------------------------- /setup.iss: -------------------------------------------------------------------------------- 1 | ; Script generated by the Inno Setup Script Wizard. 2 | ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! 3 | 4 | [Setup] 5 | AppName=timers 6 | AppVersion=0.3.0 7 | DefaultDirName={pf}\timers 8 | ChangesEnvironment=true 9 | 10 | [Tasks] 11 | Name: envPath; Description: "Add to PATH variable" 12 | 13 | [Files] 14 | Source: "target/release/timers.exe"; DestDir: "{app}" 15 | 16 | [Code] 17 | const EnvironmentKey = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'; 18 | 19 | procedure EnvAddPath(Path: string); 20 | var 21 | Paths: string; 22 | begin 23 | { Retrieve current path (use empty string if entry not exists) } 24 | if not RegQueryStringValue(HKEY_LOCAL_MACHINE, EnvironmentKey, 'Path', Paths) 25 | then Paths := ''; 26 | 27 | { Skip if string already found in path } 28 | if Pos(';' + Uppercase(Path) + ';', ';' + Uppercase(Paths) + ';') > 0 then exit; 29 | 30 | { App string to the end of the path variable } 31 | Paths := Paths + ';'+ Path +';' 32 | 33 | { Overwrite (or create if missing) path environment variable } 34 | if RegWriteStringValue(HKEY_LOCAL_MACHINE, EnvironmentKey, 'Path', Paths) 35 | then Log(Format('The [%s] added to PATH: [%s]', [Path, Paths])) 36 | else Log(Format('Error while adding the [%s] to PATH: [%s]', [Path, Paths])); 37 | end; 38 | 39 | procedure EnvRemovePath(Path: string); 40 | var 41 | Paths: string; 42 | P: Integer; 43 | begin 44 | { Skip if registry entry not exists } 45 | if not RegQueryStringValue(HKEY_LOCAL_MACHINE, EnvironmentKey, 'Path', Paths) then 46 | exit; 47 | 48 | { Skip if string not found in path } 49 | P := Pos(';' + Uppercase(Path) + ';', ';' + Uppercase(Paths) + ';'); 50 | if P = 0 then exit; 51 | 52 | { Update path variable } 53 | Delete(Paths, P - 1, Length(Path) + 1); 54 | 55 | { Overwrite path environment variable } 56 | if RegWriteStringValue(HKEY_LOCAL_MACHINE, EnvironmentKey, 'Path', Paths) 57 | then Log(Format('The [%s] removed from PATH: [%s]', [Path, Paths])) 58 | else Log(Format('Error while removing the [%s] from PATH: [%s]', [Path, Paths])); 59 | end; 60 | 61 | procedure CurStepChanged(CurStep: TSetupStep); 62 | begin 63 | if (CurStep = ssPostInstall) and IsTaskSelected('envPath') 64 | then EnvAddPath(ExpandConstant('{app}')); 65 | end; 66 | 67 | procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); 68 | begin 69 | if CurUninstallStep = usPostUninstall 70 | then EnvRemovePath(ExpandConstant('{app}')); 71 | end; 72 | -------------------------------------------------------------------------------- /src/basic_op.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Add; 2 | use std::{thread, time}; 3 | 4 | use chrono; 5 | use colored::*; 6 | use term_size; 7 | use scrawl; 8 | 9 | use super::util::*; 10 | 11 | use timers; 12 | 13 | pub fn log_command(matches: &clap::ArgMatches) { 14 | // Cannot panic as the argument parser already ensures it exist 15 | let task = matches.value_of("TASK").unwrap(); 16 | 17 | if task.len() == 0 { 18 | println!("Cannot create empty task."); 19 | } 20 | 21 | let time = match matches.value_of("AT") { 22 | Some(raw_time) => match parse_time(raw_time) { 23 | Some(time) => time, 24 | None => return, 25 | }, 26 | None => chrono::Utc::now(), 27 | }; 28 | 29 | if !confirm_stop_current(time) { 30 | return; 31 | } 32 | 33 | if task.starts_with('@') { 34 | // This will strip multiple @ if present, 35 | // currently it's not worth to fix this behavior 36 | let task_id_result = task.trim_start_matches('@').parse::(); 37 | 38 | match task_id_result { 39 | Ok(task_id) => match timers::log_task_at(task_id, time) { 40 | Ok(task) => print_status(&task), 41 | Err(err) => println!("Error logging on task: {}", err), 42 | }, 43 | Err(_) => println!("'{}' is an invalid task ID", task), 44 | }; 45 | } else { 46 | match timers::create_log_task_at(task, time) { 47 | Ok(task) => print_status(&task), 48 | Err(err) => println!("Error creating task: {}", err), 49 | } 50 | } 51 | } 52 | 53 | fn confirm_stop_current(time: chrono::DateTime) -> bool { 54 | match timers::get_current_log_task() { 55 | Ok(Some(task)) => { 56 | println!( 57 | "Currently logging on task {} {}", 58 | format!("@{}:", task.id).yellow().bold(), 59 | task.name.red().bold(), 60 | ); 61 | let answer = user_input("Do you want to start the new task? [y/n] "); 62 | 63 | if answer.trim() == "n" || answer.trim() == "no" { 64 | println!("aborting"); 65 | false 66 | } else { 67 | match timers::stop_current_task_at(time) { 68 | Err(err) => { 69 | println!("Error stopping current task: {}", err); 70 | false 71 | } 72 | // stopped current, continue with new 73 | _ => true, 74 | } 75 | } 76 | } 77 | Err(err) => { 78 | println!("Error finding current task: {}", err); 79 | false 80 | } 81 | // no current task, continue with new 82 | _ => true, 83 | } 84 | } 85 | 86 | pub fn status_command(matches: &clap::ArgMatches) { 87 | let minutes = match matches.value_of("watch") { 88 | Some(val) => match parse_float(val) { 89 | Ok(val) => val, 90 | Err(_) => { 91 | println!("Invalid watch interval '{}'", val); 92 | return; 93 | } 94 | }, 95 | None => 1., 96 | } as f64; 97 | 98 | loop { 99 | // clear screen 100 | if matches.occurrences_of("watch") != 0 { 101 | print!("\x1B[H\x1B[2J\r"); 102 | } 103 | 104 | if matches.is_present("timeline") { 105 | let start = chrono::Local::today() 106 | .and_hms(0, 0, 0) 107 | .with_timezone(&chrono::Utc); 108 | let end = chrono::Local::today() 109 | .and_hms(0, 0, 0) 110 | .add(chrono::Duration::days(1)) 111 | .with_timezone(&chrono::Utc); 112 | print_timeline(start, end); 113 | } else { 114 | match timers::get_current_log_task() { 115 | Ok(task) => match task { 116 | Some(task) => print_status(&task), 117 | None => print!("You are not logging on any task."), 118 | }, 119 | Err(err) => print!("Error finding current task: {}", err), 120 | }; 121 | } 122 | 123 | if matches.occurrences_of("watch") == 0 { 124 | break; 125 | } 126 | 127 | thread::sleep(time::Duration::from_secs((minutes * 60.) as u64)); 128 | } 129 | } 130 | 131 | fn get_term_height() -> f64 { 132 | (match term_size::dimensions() { 133 | Some((_, h)) => h, 134 | None => 24, 135 | }) as f64 136 | } 137 | 138 | fn print_timeline( 139 | start: chrono::DateTime, 140 | end: chrono::DateTime, 141 | ) { 142 | let mut logs = match timers::get_all_logs_between(start, end) { 143 | Ok(logs) => logs, 144 | Err(err) => { 145 | println!("Error while retrieving logs: {}", err); 146 | return; 147 | } 148 | }; 149 | 150 | if logs.len() == 0 { 151 | println!("There are no logs."); 152 | return; 153 | } 154 | 155 | clip_logs(start, end, &mut logs).unwrap(); 156 | 157 | let unit = compute_unit(&logs).unwrap(); 158 | 159 | let mut cumulative = 0.; 160 | let mut printed_size = 0; 161 | for (task, log) in logs.iter() { 162 | let size = log.duration().num_seconds() as f64 * unit; 163 | cumulative += size; 164 | 165 | let print_size = cumulative as i32 - printed_size; 166 | 167 | print_timeline_log(&task.name, print_size, log.start, log.end()); 168 | 169 | printed_size += print_size; 170 | } 171 | } 172 | 173 | fn clip_logs( 174 | start: chrono::DateTime, 175 | end: chrono::DateTime, 176 | logs: &mut Vec<(timers::Task, timers::Log)> 177 | ) -> Result<(), timers::Error> { 178 | if logs.len() == 0 { 179 | return Err(timers::Error::Value(timers::ValueError::new("There are no logs."))); 180 | } 181 | 182 | { 183 | let mut first = logs.first_mut().unwrap(); 184 | let logs_start = first.1.start; 185 | 186 | if logs_start < start { 187 | first.1.start = start; 188 | } 189 | } 190 | 191 | { 192 | let mut last = logs.last_mut().unwrap(); 193 | let logs_end = last.1.end(); 194 | 195 | if logs_end > end { 196 | last.1.end = Some(end); 197 | } 198 | } 199 | 200 | Ok(()) 201 | } 202 | 203 | fn compute_unit(logs: &Vec<(timers::Task, timers::Log)>) -> Result { 204 | if logs.len() == 0 { 205 | return Err(timers::Error::Value(timers::ValueError::new("There are no logs."))); 206 | } 207 | 208 | let start = logs.first().unwrap().1.start; 209 | let end = logs.last().unwrap().1.end(); 210 | 211 | let timespan = end - start; 212 | let height = get_term_height() - 1.; 213 | 214 | let unit = height as f64 / timespan.num_seconds() as f64; 215 | 216 | Ok(unit) 217 | } 218 | 219 | fn print_timeline_log( 220 | name: &str, 221 | size: i32, 222 | start: chrono::DateTime, 223 | end: chrono::DateTime, 224 | ) { 225 | let duration = timers::format_duration(end - start); 226 | let start = start.with_timezone(&chrono::Local) 227 | .format("%H:%M").to_string(); 228 | let end = end.with_timezone(&chrono::Local) 229 | .format("%H:%M").to_string(); 230 | match size { 231 | 0 => println!( 232 | " ◇ {} -> {} {} [{}]", 233 | start, 234 | end, 235 | name.bold(), 236 | duration, 237 | ), 238 | 1 => println!( 239 | " ◇ {} -> {} {} [{}]", 240 | start, 241 | end, 242 | name.bold(), 243 | duration, 244 | ), 245 | 2 => { 246 | println!(" ◇ {} {} [{}]", start, name.bold(), duration); 247 | println!(" | {}", end); 248 | } 249 | 3 => { 250 | println!(" ◇ {}", start); 251 | println!(" | {} [{}]", name.bold(), duration); 252 | println!(" ◆ {}", end); 253 | } 254 | n => { 255 | println!("{} {}", n, size); 256 | println!(" ◇ {}", start); 257 | println!(" | {}", name.bold()); 258 | println!(" | {}", duration); 259 | for _ in 0..(n - 4) { 260 | println!(" |"); 261 | } 262 | println!(" ◆ {}", end); 263 | } 264 | }; 265 | } 266 | 267 | pub fn stop_command(matches: &clap::ArgMatches) { 268 | let time = match matches.value_of("AT") { 269 | Some(raw_time) => match parse_time(raw_time) { 270 | Some(time) => time, 271 | None => return, 272 | }, 273 | None => chrono::Utc::now(), 274 | }; 275 | 276 | match timers::stop_current_task_at(time) { 277 | Ok(task) => print_status(&task), 278 | Err(timers::Error::Value(_)) => { 279 | println!("Cannot stop because you're not logging on any task.") 280 | } 281 | Err(err) => println!("An stopping task: {}", err), 282 | } 283 | } 284 | 285 | pub fn edit_command(matches: &clap::ArgMatches) { 286 | let task = matches.value_of("TASK").unwrap(); 287 | 288 | match task.trim_start_matches('@').parse::() { 289 | Ok(task_id) => { 290 | let path = timers::task_path(task_id); 291 | let path_ref = path.to_str().unwrap(); 292 | scrawl::editor::new().file(path_ref).edit().open().unwrap(); 293 | }, 294 | Err(_) => println!("'{}' is an invalid task ID", task), 295 | }; 296 | } 297 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug)] 2 | pub struct ValueError { 3 | description: String, 4 | } 5 | 6 | impl std::fmt::Display for ValueError { 7 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 8 | write!(f, "Value error: {}", self.description) 9 | } 10 | } 11 | 12 | impl std::error::Error for ValueError { 13 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 14 | None 15 | } 16 | } 17 | 18 | impl ValueError { 19 | pub fn new(description: &str) -> ValueError { 20 | ValueError { 21 | description: description.to_string(), 22 | } 23 | } 24 | } 25 | 26 | #[derive(Debug)] 27 | pub enum Error { 28 | Io(std::io::Error), 29 | Value(ValueError), 30 | } 31 | 32 | impl From for Error { 33 | fn from(err: std::io::Error) -> Error { 34 | Error::Io(err) 35 | } 36 | } 37 | 38 | impl From for Error { 39 | fn from(err: ValueError) -> Error { 40 | Error::Value(err) 41 | } 42 | } 43 | 44 | impl std::fmt::Display for Error { 45 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 46 | match self { 47 | Error::Io(err) => err.fmt(f), 48 | Error::Value(err) => err.fmt(f), 49 | } 50 | } 51 | } 52 | 53 | impl std::error::Error for Error { 54 | fn cause(&self) -> Option<&dyn std::error::Error> { 55 | match self { 56 | Error::Io(err) => err.source(), 57 | Error::Value(err) => err.source(), 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/import_export_op.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use csv; 4 | use timers; 5 | use chrono; 6 | use itertools::Itertools; 7 | use chrono::TimeZone; 8 | use crate::util::parse_time; 9 | 10 | pub fn export_command(matches: &clap::ArgMatches) { 11 | let object = matches.value_of("OBJECT").unwrap(); 12 | 13 | let output: Box = match matches.value_of("output") { 14 | Some(output_path_str) => { 15 | let output_path = Path::new(output_path_str); 16 | match std::fs::File::create(output_path) { 17 | Ok(file) => Box::new(file), 18 | Err(err) => { 19 | println!("Impossible to write file '{}': {}", output_path_str, err); 20 | return; 21 | } 22 | } 23 | }, 24 | None => Box::new(std::io::stdout()), 25 | }; 26 | 27 | let delimiter = matches.value_of("delimiter").unwrap(); 28 | 29 | let mut writer = csv::WriterBuilder::new() 30 | .delimiter(delimiter.bytes().next().unwrap()) 31 | .from_writer(output); 32 | 33 | // Header 34 | if object == "logs" { 35 | writer.write_record( 36 | &["Task ID", "Task name", "Begin (UTC)", "End (UTC)", "Duration (hours)"] 37 | ).unwrap(); 38 | } else { 39 | writer.write_record( 40 | &["Task ID", "Task name", "Logs", "Duration (hours)"] 41 | ).unwrap(); 42 | } 43 | 44 | let from = match matches.value_of("from") { 45 | Some(from) => match parse_time(from) { 46 | Some(from) => from, 47 | None => return, 48 | }, 49 | None => chrono::Utc.ymd(1900, 1, 1).and_hms(0, 0, 0), 50 | }; 51 | let to = match matches.value_of("to") { 52 | Some(to) => match parse_time(to) { 53 | Some(to) => to, 54 | None => return, 55 | } 56 | None => chrono::Utc.ymd(2100, 1, 1).and_hms(0, 0, 0), 57 | }; 58 | 59 | match timers::get_all_tasks_between(from, to) { 60 | Ok(tasks) => for id in tasks.keys().sorted() { 61 | let task = tasks.get(id).unwrap(); 62 | if object == "logs" { 63 | write_task_logs(&mut writer, task); 64 | } else { 65 | write_task(&mut writer, task); 66 | } 67 | }, 68 | Err(err) => println!("Error retrieving tasks: {}", err) 69 | } 70 | 71 | writer.flush().unwrap(); 72 | } 73 | 74 | fn write_task(writer: &mut csv::Writer, task: &timers::Task) 75 | where 76 | T: std::io::Write, 77 | { 78 | writer.write_record(&[ 79 | task.id.to_string().as_str(), 80 | task.name.as_str(), 81 | task.logs.len().to_string().as_str(), 82 | (task.duration().num_seconds() as f64 / 3600.).to_string().as_str(), 83 | ]).unwrap(); 84 | } 85 | 86 | fn write_task_logs(writer: &mut csv::Writer, task: &timers::Task) 87 | where 88 | T: std::io::Write 89 | { 90 | for log in task.logs.iter() { 91 | write_log(writer, task, log); 92 | } 93 | } 94 | 95 | fn write_log(writer: &mut csv::Writer, task: &timers::Task, log: &timers::Log) 96 | where 97 | T: std::io::Write, 98 | { 99 | let end_str = match log.end { 100 | Some(end) => end.to_rfc3339(), 101 | None => String::new(), 102 | }; 103 | 104 | writer.write_record(&[ 105 | task.id.to_string().as_str(), 106 | task.name.as_str(), 107 | log.start.to_rfc3339().as_str(), 108 | end_str.as_str(), 109 | (log.duration().num_seconds() as f64 / 3600.).to_string().as_str(), 110 | ]).unwrap(); 111 | } -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::collections::BTreeMap; 3 | use std::ops::Add; 4 | use std::path::PathBuf; 5 | 6 | use chrono; 7 | use dirs; 8 | use fs_extra; 9 | 10 | mod errors; 11 | pub use errors::{Error, ValueError}; 12 | mod repo; 13 | pub use repo::{Log, Repo, Task, TaskStatus}; 14 | 15 | fn data_path() -> PathBuf { 16 | let mut path = dirs::data_dir().unwrap(); 17 | path.push("timers_time_logs"); 18 | path 19 | } 20 | 21 | fn get_repo() -> Result { 22 | let path = data_path(); 23 | 24 | // Temporary code: migrate old folder if it exists 25 | // ------------- 26 | let mut old_path = dirs::home_dir().unwrap(); 27 | old_path.push(".timers"); 28 | if old_path.exists() { 29 | println!("Warning: Migrating files to new data location."); 30 | 31 | // move to newpath 32 | fs_extra::dir::move_dir( 33 | &old_path, 34 | dirs::data_dir().unwrap(), 35 | &fs_extra::dir::CopyOptions::new(), 36 | ) 37 | .unwrap(); 38 | 39 | let mut moved_path = dirs::data_dir().unwrap(); 40 | moved_path.push(".timers"); 41 | std::fs::rename(&moved_path, &path).unwrap(); 42 | } 43 | // ------------- 44 | 45 | // ensure folder exists 46 | if !path.exists() { 47 | std::fs::create_dir_all(&path)?; 48 | } 49 | 50 | Ok(Repo { path }) 51 | } 52 | 53 | pub fn create_task(name: &str) -> Result { 54 | let repo = get_repo()?; 55 | Ok(repo.create_task(name)?) 56 | } 57 | 58 | pub fn log_task_at(id: u32, at: chrono::DateTime) -> Result { 59 | let repo = get_repo()?; 60 | let mut task = repo.get_task(id)?; 61 | repo.log_task(&mut task, at)?; 62 | Ok(task) 63 | } 64 | 65 | pub fn log_task(id: u32) -> Result { 66 | Ok(log_task_at(id, chrono::Utc::now())?) 67 | } 68 | 69 | pub fn create_log_task_at(name: &str, at: chrono::DateTime) -> Result { 70 | let repo = get_repo()?; 71 | let mut task = repo.create_task(name)?; 72 | repo.log_task(&mut task, at)?; 73 | Ok(task) 74 | } 75 | 76 | pub fn create_log_task(name: &str) -> Result { 77 | Ok(create_log_task_at(name, chrono::Utc::now())?) 78 | } 79 | 80 | pub fn get_current_log_task() -> Result, Error> { 81 | let repo = get_repo()?; 82 | let mut tasks = repo.list_tasks()?; 83 | 84 | let mut found_id: Option = None; 85 | for (id, task) in tasks.iter() { 86 | if task.logging { 87 | found_id = Some(*id); 88 | break; 89 | } 90 | } 91 | 92 | match found_id { 93 | Some(id) => Ok(tasks.remove(&id)), 94 | None => Ok(None), 95 | } 96 | } 97 | 98 | pub fn stop_current_task_at(at: chrono::DateTime) -> Result { 99 | let repo = get_repo()?; 100 | 101 | match get_current_log_task()? { 102 | Some(mut task) => { 103 | repo.stop_task(&mut task, at)?; 104 | Ok(task) 105 | } 106 | None => Err(Error::Value(ValueError::new( 107 | "Not task currently being logged.", 108 | ))), 109 | } 110 | } 111 | 112 | pub fn stop_current_task() -> Result { 113 | Ok(stop_current_task_at(chrono::Utc::now())?) 114 | } 115 | 116 | pub fn get_all_tasks() -> Result, Error> { 117 | let repo = get_repo()?; 118 | Ok(repo.list_tasks()?) 119 | } 120 | 121 | pub fn get_all_tasks_between( 122 | start: chrono::DateTime, 123 | end: chrono::DateTime, 124 | ) -> Result, Error> { 125 | let repo = get_repo()?; 126 | let tasks = repo.list_tasks()?; 127 | 128 | let mut filtered = HashMap::new(); 129 | for (id, task) in tasks { 130 | for log in task.logs.iter() { 131 | if log.start.ge(&start) && log.start.le(&end) { 132 | filtered.insert(id, task); 133 | break; 134 | } 135 | 136 | let log_end = match log.end { 137 | Some(end) => end, 138 | None => chrono::Utc::now(), 139 | }; 140 | if log_end.ge(&start) && log_end.le(&end) { 141 | filtered.insert(id, task); 142 | break; 143 | } 144 | } 145 | } 146 | 147 | Ok(filtered) 148 | } 149 | 150 | // Returns all logs between start and end, 151 | // sorted chronologically 152 | pub fn get_all_logs_between( 153 | start: chrono::DateTime, 154 | end: chrono::DateTime, 155 | ) -> Result, Error> { 156 | let tasks = get_all_tasks_between(start, end)?; 157 | 158 | let mut logs = BTreeMap::new(); 159 | for task in tasks.values() { 160 | for log in task.logs.iter() { 161 | if log.start > end || log.end() < start { 162 | continue 163 | } 164 | 165 | logs.insert(log.start, (task.clone(), *log)); 166 | } 167 | } 168 | 169 | Ok(logs.values().map(|log| log.clone()).collect()) 170 | } 171 | 172 | pub fn get_total_duration( 173 | start: chrono::DateTime, 174 | end: chrono::DateTime, 175 | ) -> Result { 176 | let repo = get_repo()?; 177 | 178 | let mut total_duration = chrono::Duration::seconds(0); 179 | 180 | for task in repo.list_tasks()?.values() { 181 | total_duration = total_duration.add(task.duration_between(start, end)); 182 | } 183 | 184 | Ok(total_duration) 185 | } 186 | 187 | pub fn format_duration(duration: chrono::Duration) -> String { 188 | let mut formatted_duration = String::new(); 189 | 190 | if duration.num_hours() >= 24 { 191 | formatted_duration.push_str(format!("{}d ", duration.num_days()).as_str()); 192 | } 193 | 194 | if duration.num_minutes() >= 60 { 195 | formatted_duration.push_str(format!("{}h ", duration.num_hours() % 24).as_str()); 196 | } 197 | 198 | if duration.num_seconds() >= 60 { 199 | formatted_duration.push_str(format!("{}m ", duration.num_minutes() % 60).as_str()); 200 | } 201 | 202 | if duration.num_seconds() < 60 { 203 | formatted_duration.push_str(format!("{}s ", duration.num_seconds() % 60).as_str()); 204 | } 205 | 206 | formatted_duration.trim().to_string() 207 | } 208 | 209 | pub fn format_duration_hours(duration: chrono::Duration) -> String { 210 | let mut formatted_duration = String::new(); 211 | 212 | if duration.num_minutes() >= 60 { 213 | formatted_duration.push_str(format!("{}h ", duration.num_hours()).as_str()); 214 | } 215 | 216 | if duration.num_seconds() >= 60 { 217 | formatted_duration.push_str(format!("{}m ", duration.num_minutes() % 60).as_str()); 218 | } 219 | 220 | if duration.num_seconds() < 60 { 221 | formatted_duration.push_str(format!("{}s ", duration.num_seconds() % 60).as_str()); 222 | } 223 | 224 | formatted_duration.trim().to_string() 225 | } 226 | 227 | pub fn find_start(tasks: &HashMap) -> Result, Error> { 228 | if tasks.len() == 0 { 229 | return Err(Error::Value(ValueError::new("There are no tasks."))); 230 | } 231 | 232 | let mut start = tasks 233 | .values().next().unwrap() 234 | .logs.first().unwrap() 235 | .start 236 | ; 237 | for task in tasks.values() { 238 | let log = task.logs.first().unwrap(); 239 | 240 | if log.start < start { 241 | start = log.start; 242 | } 243 | } 244 | Ok(start) 245 | } 246 | 247 | pub fn find_end(tasks: &HashMap) -> Result, Error> { 248 | if tasks.len() == 0 { 249 | return Err(Error::Value(ValueError::new("There are no tasks."))); 250 | } 251 | 252 | let mut end = tasks 253 | .values().next().unwrap() 254 | .logs.last().unwrap() 255 | .end() 256 | ; 257 | for task in tasks.values() { 258 | let log = task.logs.last().unwrap(); 259 | 260 | if log.end() < end { 261 | end = log.end(); 262 | } 263 | } 264 | Ok(end) 265 | } 266 | 267 | pub fn task_path(task: u32) -> PathBuf { 268 | let mut path = data_path(); 269 | path.push(task.to_string()); 270 | path 271 | } -------------------------------------------------------------------------------- /src/list_op.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use colored::*; 4 | use itertools::{Itertools, enumerate}; 5 | use crate::util::parse_int; 6 | 7 | trait PrintTasks { 8 | fn print_header(&self); 9 | fn print_ellipsis(&self); 10 | fn print_task(&self, task: &timers::Task); 11 | 12 | fn print_tasks(&self, tasks: HashMap, num: usize, plain: bool) { 13 | if !plain { 14 | self.print_header(); 15 | 16 | if tasks.len() > num { 17 | self.print_ellipsis(); 18 | } 19 | } 20 | 21 | for (i, id) in enumerate(tasks.keys().sorted()) { 22 | if tasks.len() > num && i < tasks.len()-num { 23 | continue 24 | } 25 | 26 | let task = &tasks[id]; 27 | self.print_task(task); 28 | } 29 | } 30 | } 31 | 32 | struct ShortPrinter (); 33 | 34 | impl PrintTasks for ShortPrinter { 35 | fn print_header(&self) { 36 | println!("{:<6} {:<36} {}", "ID", "TASK", "DURATION"); 37 | println!("{}", "-".repeat(58)); 38 | } 39 | 40 | fn print_ellipsis(&self) { 41 | println!("{:<6} {:<36} {}", "...", "...", "..."); 42 | } 43 | 44 | fn print_task(&self, task: &timers::Task) { 45 | match task.status() { 46 | timers::TaskStatus::Logging() => println!( 47 | "{:<6} {:<36} {}", 48 | format!("@{}", task.id).yellow().bold(), 49 | task.name.red().bold(), 50 | timers::format_duration(task.duration()).bold(), 51 | ), 52 | timers::TaskStatus::Stopped() => println!( 53 | "{:<6} {:<36} {}", 54 | format!("@{}", task.id), 55 | task.name, 56 | timers::format_duration(task.duration()), 57 | ), 58 | } 59 | } 60 | } 61 | 62 | struct LongPrinter (); 63 | 64 | impl PrintTasks for LongPrinter { 65 | fn print_header(&self) { 66 | println!( 67 | "{:<6} {:<36} {:<14} {:<8} {:<6} {}", 68 | "ID", "TASK", "DURATION", "STATUS", "LOGS", "LAST LOG" 69 | ); 70 | println!("{}", "-".repeat(91)); 71 | } 72 | 73 | fn print_ellipsis(&self) { 74 | println!( 75 | "{:<6} {:<36} {:<14} {:<8} {:<6} {}", 76 | "...", "...", "...", "...", "...", "..." 77 | ); 78 | } 79 | 80 | fn print_task(&self, task: &timers::Task) { 81 | let last = task.logs.last().unwrap() 82 | .start.with_timezone(&chrono::Local) 83 | .format("%a %b %d %H:%M").to_string(); 84 | 85 | match task.status() { 86 | timers::TaskStatus::Logging() => println!( 87 | "{:<6} {:<36} {:<14} {:<8} {:<6} {}", 88 | format!("@{}", task.id).yellow().bold(), 89 | task.name.red().bold(), 90 | timers::format_duration(task.duration()).bold(), 91 | task.status_text().bold(), 92 | format!("{}", task.logs.len()).bold(), 93 | last.bold(), 94 | ), 95 | timers::TaskStatus::Stopped() => println!( 96 | "{:<6} {:<36} {:<14} {:<8} {:<6} {}", 97 | format!("@{}", task.id), 98 | task.name, 99 | timers::format_duration(task.duration()), 100 | task.status_text(), 101 | task.logs.len(), 102 | last, 103 | ), 104 | } 105 | } 106 | } 107 | 108 | pub fn tasks_command(matches: &clap::ArgMatches) { 109 | let raw_num = matches.value_of("num").unwrap(); 110 | let num = parse_int(raw_num) 111 | .unwrap_or_else(|_| { 112 | println!("Invalid number of tasks: '{}'", raw_num); 113 | std::process::exit(1); 114 | }) as usize; 115 | 116 | let plain = matches.is_present("plain"); 117 | match timers::get_all_tasks() { 118 | Ok(tasks) => match matches.is_present("long") { 119 | true => LongPrinter{}.print_tasks(tasks, num, plain), 120 | false => ShortPrinter{}.print_tasks(tasks, num, plain), 121 | }, 122 | Err(err) => println!("Error retrieving tasks: {}", err) 123 | } 124 | } -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use clap; 2 | 3 | mod basic_op; 4 | mod util; 5 | use basic_op::*; 6 | mod list_op; 7 | use list_op::*; 8 | mod report_op; 9 | use report_op::*; 10 | mod import_export_op; 11 | use import_export_op::*; 12 | 13 | fn main() { 14 | let matches = parse_args(); 15 | 16 | match matches.subcommand_name() { 17 | Some("log") => log_command(matches.subcommand_matches("log").unwrap()), 18 | Some("status") => status_command(matches.subcommand_matches("status").unwrap()), 19 | Some("stop") => stop_command(matches.subcommand_matches("stop").unwrap()), 20 | Some("report") => { 21 | let submatches = matches.subcommand_matches("report").unwrap(); 22 | match submatches.subcommand_name() { 23 | Some("days") => report_days_command(submatches), 24 | _ => report_days_command(submatches), 25 | } 26 | } 27 | Some("tasks") => tasks_command(matches.subcommand_matches("tasks").unwrap()), 28 | Some("edit") => edit_command(matches.subcommand_matches("edit").unwrap()), 29 | Some("export") => export_command(matches.subcommand_matches("export").unwrap()), 30 | _ => {} 31 | } 32 | } 33 | 34 | fn parse_args() -> clap::ArgMatches<'static> { 35 | return clap::App::new("timers") 36 | .author("Francesco Pasa ") 37 | .version(clap::crate_version!()) 38 | .about("Track time spent on tasks") 39 | .setting(clap::AppSettings::ArgRequiredElseHelp) 40 | .subcommand( 41 | clap::SubCommand::with_name("log") 42 | .alias("start") 43 | .about("Log time on a task") 44 | .arg(clap::Arg::with_name("TASK") 45 | .required(true) 46 | .index(1) 47 | .help( 48 | "Name of the task to log, or ID of an existing task, \ 49 | to continue logging on an existing task.", 50 | ) 51 | ) 52 | .arg( 53 | clap::Arg::with_name("AT") 54 | .long("at") 55 | .takes_value(true) 56 | .value_name("TIME") 57 | .allow_hyphen_values(true) 58 | .help("Start logging at the specified time."), 59 | ), 60 | ) 61 | .subcommand( 62 | clap::SubCommand::with_name("status") 63 | .about("Get logging status") 64 | .arg( 65 | clap::Arg::with_name("watch") 66 | .short("w") 67 | .long("watch") 68 | .takes_value(true) 69 | .min_values(0) 70 | .max_values(1) 71 | .default_value("5") 72 | .help("Keep watching the status, for a GUI like effect."), 73 | ) 74 | .arg( 75 | clap::Arg::with_name("timeline") 76 | .short("T") 77 | .long("timeline") 78 | .help("Print timeline with the current status today."), 79 | ) 80 | ) 81 | .subcommand( 82 | clap::SubCommand::with_name("stop") 83 | .about("Stop logging time on the current task") 84 | .arg( 85 | clap::Arg::with_name("AT") 86 | .long("at") 87 | .takes_value(true) 88 | .value_name("TIME") 89 | .allow_hyphen_values(true) 90 | .help("Stop logging at the specified time."), 91 | ), 92 | ) 93 | .subcommand( 94 | clap::SubCommand::with_name("report") 95 | .about("Report statistics on the tasks") 96 | .subcommand(clap::SubCommand::with_name("days") 97 | .about("Report statistics on days.") 98 | ) 99 | .arg( 100 | clap::Arg::with_name("plain") 101 | .long("--plain") 102 | .help("Omit printing table header and totals."), 103 | ) 104 | .arg( 105 | clap::Arg::with_name("tot-hours") 106 | .long("--tot-hours") 107 | .help("Print totals in hours."), 108 | ), 109 | ) 110 | .subcommand( 111 | clap::SubCommand::with_name("tasks") 112 | .about("Print tasks") 113 | .arg( 114 | clap::Arg::with_name("long") 115 | .short("-l") 116 | .long("--long") 117 | .help("Display more information for each task."), 118 | ) 119 | .arg( 120 | clap::Arg::with_name("num") 121 | .short("-n") 122 | .long("--num") 123 | .default_value("30") 124 | .help("Display the last tasks. Default 30."), 125 | ) 126 | .arg( 127 | clap::Arg::with_name("plain") 128 | .long("--plain") 129 | .help("Omit printing table header."), 130 | ), 131 | ) 132 | .subcommand( 133 | clap::SubCommand::with_name("edit") 134 | .about("Edit a task") 135 | .arg(clap::Arg::with_name("TASK") 136 | .required(true) 137 | .index(1) 138 | .help("The ID of the task to be edited.", 139 | ) 140 | ) 141 | ) 142 | .subcommand( 143 | clap::SubCommand::with_name("export") 144 | .about("Export tasks to CSV") 145 | .arg( 146 | clap::Arg::with_name("OBJECT") 147 | .required(true) 148 | .index(1) 149 | .possible_values(&["logs", "tasks"]) 150 | .help( 151 | "Either 'logs', to export log information or 'tasks' \ 152 | to export task information.", 153 | ), 154 | ) 155 | .arg( 156 | clap::Arg::with_name("output") 157 | .short("-o") 158 | .long("--output") 159 | .takes_value(true) 160 | .number_of_values(1) 161 | .help("Export to the given file instead of printing to standard output."), 162 | ) 163 | .arg( 164 | clap::Arg::with_name("delimiter") 165 | .short("-d") 166 | .long("--delimiter") 167 | .takes_value(true) 168 | .number_of_values(1) 169 | .default_value(",") 170 | .help("Field delimiter to use. Only a single character is allowed."), 171 | ) 172 | .arg( 173 | clap::Arg::with_name("from") 174 | .long("--from") 175 | .takes_value(true) 176 | .number_of_values(1) 177 | .help("Export only starting from the given date and time."), 178 | ) 179 | .arg( 180 | clap::Arg::with_name("to") 181 | .long("--to") 182 | .takes_value(true) 183 | .number_of_values(1) 184 | .help("Export only up to the given date and time."), 185 | ), 186 | ) 187 | .get_matches(); 188 | } 189 | -------------------------------------------------------------------------------- /src/repo.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::io::prelude::*; 3 | use std::ops::Add; 4 | 5 | use crate::errors::{Error, ValueError}; 6 | 7 | #[derive(Debug, Copy, Clone)] 8 | pub struct Log { 9 | pub start: chrono::DateTime, 10 | pub end: Option>, 11 | } 12 | 13 | impl Log { 14 | pub fn duration(&self) -> chrono::Duration { 15 | if let Some(end) = self.end { 16 | end.signed_duration_since(self.start) 17 | } else { 18 | chrono::Utc::now().signed_duration_since(self.start) 19 | } 20 | } 21 | 22 | pub fn duration_between( 23 | &self, 24 | start: chrono::DateTime, 25 | end: chrono::DateTime, 26 | ) -> chrono::Duration { 27 | let task_end = self.end(); 28 | 29 | if start.le(&self.start) && end.ge(&task_end) { 30 | self.duration() 31 | } else if start.gt(&self.start) && end.ge(&task_end) { 32 | let duration = task_end.signed_duration_since(start); 33 | if duration.num_seconds() >= 0 { 34 | duration 35 | } else { 36 | chrono::Duration::seconds(0) 37 | } 38 | } else if start.le(&self.start) && end.lt(&task_end) { 39 | let duration = end.signed_duration_since(self.start); 40 | if duration.num_seconds() >= 0 { 41 | duration 42 | } else { 43 | chrono::Duration::seconds(0) 44 | } 45 | } else { 46 | end.signed_duration_since(start) 47 | } 48 | } 49 | 50 | pub fn end(&self) -> chrono::DateTime { 51 | if self.end.is_none() { 52 | chrono::Utc::now() 53 | } else { 54 | self.end.unwrap() 55 | } 56 | } 57 | } 58 | 59 | #[derive(Debug, Clone)] 60 | pub struct Task { 61 | pub id: u32, 62 | pub path: std::path::PathBuf, 63 | pub name: String, 64 | pub logs: Vec, 65 | pub logging: bool, 66 | } 67 | 68 | #[derive(Debug, Copy, Clone)] 69 | pub enum TaskStatus { 70 | Logging(), 71 | Stopped(), 72 | } 73 | 74 | impl Task { 75 | pub fn duration(&self) -> chrono::Duration { 76 | let mut duration = chrono::Duration::seconds(0); 77 | for log in self.logs.iter() { 78 | duration = duration + log.duration(); 79 | } 80 | 81 | duration 82 | } 83 | 84 | pub fn duration_between( 85 | &self, 86 | start: chrono::DateTime, 87 | end: chrono::DateTime, 88 | ) -> chrono::Duration { 89 | let mut total = chrono::Duration::seconds(0); 90 | 91 | for log in self.logs.iter() { 92 | total = total.add(log.duration_between(start, end)); 93 | } 94 | 95 | total 96 | } 97 | 98 | pub fn status(&self) -> TaskStatus { 99 | for log in self.logs.iter() { 100 | if log.end.is_none() { 101 | return TaskStatus::Logging(); 102 | } 103 | } 104 | 105 | TaskStatus::Stopped() 106 | } 107 | 108 | pub fn status_text(&self) -> &str { 109 | match self.status() { 110 | TaskStatus::Logging() => "logging", 111 | TaskStatus::Stopped() => "stopped", 112 | } 113 | } 114 | } 115 | 116 | #[derive(Debug)] 117 | pub struct Repo { 118 | pub path: std::path::PathBuf, 119 | } 120 | 121 | impl Repo { 122 | fn read_task(path: std::path::PathBuf) -> Result { 123 | let file = std::fs::File::open(&path)?; 124 | let mut reader = std::io::BufReader::new(file); 125 | 126 | let mut id_str = String::new(); 127 | reader.read_line(&mut id_str)?; 128 | let id = id_str 129 | .trim() 130 | .parse::() 131 | .expect("Unexpected or corrupt id value in task file"); 132 | 133 | let mut name = String::new(); 134 | reader.read_line(&mut name)?; 135 | 136 | let mut logs = Vec::new(); 137 | let mut logging = false; 138 | loop { 139 | let mut line = String::new(); 140 | let num_bytes_read = reader.read_line(&mut line)?; 141 | if num_bytes_read == 0 { 142 | break; 143 | } 144 | 145 | let split: Vec<&str> = line.split(" ").collect(); 146 | let start = split[0].trim(); 147 | let end = split[1].trim(); 148 | if end.len() == 0 { 149 | logging = true; 150 | } 151 | 152 | logs.push(Log { 153 | start: chrono::DateTime::parse_from_rfc3339(start) 154 | .expect("Unexpected or corrupt start date value in task file") 155 | .with_timezone(&chrono::Utc), 156 | end: if logging { 157 | None 158 | } else { 159 | Some( 160 | chrono::DateTime::parse_from_rfc3339(end) 161 | .expect("Unexpected or corrupt end date value in task file") 162 | .with_timezone(&chrono::Utc), 163 | ) 164 | }, 165 | }) 166 | } 167 | 168 | Ok(Task { 169 | id, 170 | path, 171 | name: name.trim().to_string(), 172 | logs, 173 | logging, 174 | }) 175 | } 176 | 177 | fn write_task(task: &Task) -> Result<(), Error> { 178 | let mut file = std::fs::File::create(&task.path)?; 179 | 180 | write!(file, "{}\n{}\n", task.id, task.name)?; 181 | 182 | for log in task.logs.iter() { 183 | write!(file, "{} ", log.start.to_rfc3339())?; 184 | 185 | if let Some(end) = log.end { 186 | write!(file, "{}\n", end.to_rfc3339())?; 187 | } 188 | } 189 | 190 | Ok(()) 191 | } 192 | 193 | pub fn list_tasks(&self) -> Result, Error> { 194 | let paths = std::fs::read_dir(&self.path)?; 195 | 196 | let mut tasks = HashMap::new(); 197 | for path in paths { 198 | let path = path.unwrap().path(); 199 | let task = Repo::read_task(path)?; 200 | tasks.insert(task.id, task); 201 | } 202 | 203 | Ok(tasks) 204 | } 205 | 206 | pub fn get_task(&self, id: u32) -> Result { 207 | let mut path = self.path.clone(); 208 | path.push(id.to_string()); 209 | 210 | Ok(Repo::read_task(path)?) 211 | } 212 | 213 | pub fn create_task(&self, name: &str) -> Result { 214 | let id = self.next_id()?; 215 | 216 | let mut path = self.path.clone(); 217 | path.push(id.to_string()); 218 | 219 | let task = Task { 220 | id, 221 | path, 222 | name: name.to_string(), 223 | logs: Vec::new(), 224 | logging: false, 225 | }; 226 | 227 | Repo::write_task(&task)?; 228 | 229 | Ok(task) 230 | } 231 | 232 | fn next_id(&self) -> Result { 233 | let tasks = self.list_tasks()?; 234 | 235 | let mut max_id = 0u32; 236 | for id in tasks.keys() { 237 | if id > &max_id { 238 | max_id = *id; 239 | } 240 | } 241 | 242 | Ok(max_id + 1) 243 | } 244 | 245 | pub fn log_task( 246 | &self, 247 | task: &mut Task, 248 | time: chrono::DateTime, 249 | ) -> Result<(), Error> { 250 | task.logging = true; 251 | task.logs.push(Log { 252 | start: time, 253 | end: None, 254 | }); 255 | 256 | Repo::write_task(&task)?; 257 | 258 | Ok(()) 259 | } 260 | 261 | pub fn stop_task( 262 | &self, 263 | task: &mut Task, 264 | time: chrono::DateTime, 265 | ) -> Result<(), Error> { 266 | task.logging = false; 267 | 268 | match task.logs.last_mut() { 269 | Some(log) => { 270 | if log.end.is_some() { 271 | return Err(Error::Value(ValueError::new( 272 | "Task was not started, cannot stop logging.", 273 | ))); 274 | } 275 | 276 | log.end = Some(time) 277 | } 278 | None => { 279 | return Err(Error::Value(ValueError::new( 280 | "Task was not started, cannot stop logging.", 281 | ))) 282 | } 283 | } 284 | 285 | Repo::write_task(&task)?; 286 | 287 | Ok(()) 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /src/report_op.rs: -------------------------------------------------------------------------------- 1 | use std::ops::{Add, Sub}; 2 | 3 | use chrono::{Datelike, Timelike}; 4 | use colored::*; 5 | 6 | use timers; 7 | 8 | pub fn report_days_command(matches: &clap::ArgMatches) { 9 | if !matches.is_present("plain") { 10 | println!("{:<12} {:<14} {}", "DAY", "TIME LOGGED", "TASKS"); 11 | println!("{}", "-".repeat(34)); 12 | } 13 | 14 | let week_offset = chrono::Local::now().weekday().num_days_from_monday() as i64; 15 | let week_start = chrono::Local::now() 16 | .with_hour(0) 17 | .unwrap() 18 | .with_minute(0) 19 | .unwrap() 20 | .with_second(0) 21 | .unwrap() 22 | .with_nanosecond(0) 23 | .unwrap() 24 | .sub(chrono::Duration::days(week_offset)) 25 | .with_timezone(&chrono::Utc); 26 | 27 | for i in 0..7 { 28 | let start = week_start.add(chrono::Duration::days(i)); 29 | let end = week_start.add(chrono::Duration::days(i + 1)); 30 | 31 | let local_start = start.with_timezone(&chrono::Local); 32 | let tasks = timers::get_all_tasks_between(start, end).unwrap_or_else(|err| { 33 | println!("Error retrieving tasks: {}", err); 34 | std::process::exit(2); 35 | }); 36 | 37 | let duration = timers::get_total_duration(start, end).unwrap_or_else(|err| { 38 | println!("Error computing duration: {}", err); 39 | std::process::exit(2); 40 | }); 41 | 42 | println!( 43 | "{:<12} {:<14} {}", 44 | if i < 5 { 45 | local_start.format("%A").to_string().green() 46 | } else { 47 | local_start.format("%A").to_string().red() 48 | }, 49 | timers::format_duration(duration), 50 | tasks.len(), 51 | ) 52 | } 53 | 54 | if !matches.is_present("plain") { 55 | println!("{}", "-".repeat(34)); 56 | 57 | let week_end = week_start.add(chrono::Duration::weeks(1)); 58 | 59 | let tasks = timers::get_all_tasks_between(week_start, week_end).unwrap_or_else(|err| { 60 | println!("Error retrieving tasks: {}", err); 61 | std::process::exit(2); 62 | }); 63 | 64 | let duration = timers::get_total_duration(week_start, week_end).unwrap_or_else(|err| { 65 | println!("Error computing duration: {}", err); 66 | std::process::exit(2); 67 | }); 68 | 69 | println!( 70 | "{:<12} {:<14} {}", 71 | "Total", 72 | if matches.is_present("tot-hours") { 73 | timers::format_duration_hours(duration) 74 | } else { 75 | timers::format_duration(duration) 76 | }, 77 | tasks.len(), 78 | ) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use std::io::prelude::*; 2 | use std::num::{ParseIntError, ParseFloatError}; 3 | use std::ops::Add; 4 | 5 | use colored::*; 6 | use chrono; 7 | use chrono::{Timelike, TimeZone}; 8 | use chrono::offset::LocalResult::Single; 9 | 10 | pub fn user_input(prompt: &str) -> String { 11 | print!("{}", prompt); 12 | std::io::stdout().flush().unwrap(); 13 | 14 | let mut answer = String::new(); 15 | std::io::stdin().read_line(&mut answer).unwrap(); 16 | 17 | answer 18 | } 19 | 20 | pub fn print_status(task: &timers::Task) { 21 | println!( 22 | "{} {}\nstatus: {}\ntime: {}", 23 | format!("@{}:", task.id).yellow().bold(), 24 | task.name.red().bold(), 25 | task.status_text().bold(), 26 | timers::format_duration(task.duration()).bold() 27 | ); 28 | } 29 | 30 | pub fn parse_time(raw_time: &str) -> Option> { 31 | if raw_time.starts_with("y") { 32 | let stripped_time = raw_time.get(1..).unwrap(); 33 | return match parse_time(stripped_time) { 34 | Some(datetime) => Some(datetime - chrono::Duration::days(1)), 35 | None => None 36 | } 37 | } 38 | 39 | if raw_time.starts_with("-") || raw_time.starts_with("+") { 40 | let sign = raw_time.get(..1).unwrap(); 41 | let stripped_time = raw_time.get(1..).unwrap(); 42 | return match parse_duration(stripped_time) { 43 | Some(duration) => 44 | Some(chrono::Utc::now() + if sign == "+" {duration} else {-duration}), 45 | None => None 46 | } 47 | } 48 | 49 | if let Some(datetime) = try_parse_time(raw_time, "%H:%M") { 50 | return Some(datetime) 51 | } 52 | 53 | if let Some(datetime) = try_parse_time(raw_time, "%H:%M:%S") { 54 | return Some(datetime) 55 | } 56 | 57 | if let Some(date) = try_parse_date(raw_time, "%Y-%m-%d") { 58 | return Some(date) 59 | } 60 | 61 | if let Some(datetime) = try_parse_datetime(raw_time, "%Y-%m-%d %H:%M") { 62 | return Some(datetime) 63 | } 64 | 65 | if let Some(datetime) = try_parse_datetime(raw_time, "%Y-%m-%d %H:%M:%S") { 66 | return Some(datetime) 67 | } 68 | 69 | println!("Time format '{}' not understood", raw_time); 70 | None 71 | } 72 | 73 | pub fn try_parse_time(raw_time: &str, fmt: &str) -> Option> { 74 | match chrono::NaiveTime::parse_from_str(raw_time, fmt) { 75 | Ok(parsed_time) => { 76 | let datetime = chrono::Local::now() 77 | .with_nanosecond(parsed_time.nanosecond()).unwrap() 78 | .with_second(parsed_time.second()).unwrap() 79 | .with_minute(parsed_time.minute()).unwrap() 80 | .with_hour(parsed_time.hour()).unwrap() 81 | .with_timezone(&chrono::Utc); 82 | Some(datetime) 83 | }, 84 | Err(_) => None, 85 | } 86 | } 87 | 88 | pub fn try_parse_datetime(raw_datetime: &str, fmt: &str) -> Option> { 89 | match chrono::NaiveDateTime::parse_from_str(raw_datetime, fmt) { 90 | Ok(parsed_datetime) => { 91 | match chrono::Local.from_local_datetime(&parsed_datetime) { 92 | Single(datetime) => Some(datetime.with_timezone(&chrono::Utc)), 93 | _ => None, 94 | } 95 | } 96 | Err(_) => None, 97 | } 98 | } 99 | 100 | pub fn try_parse_date(raw_date: &str, fmt: &str) -> Option> { 101 | match chrono::NaiveDate::parse_from_str(raw_date, fmt) { 102 | Ok(parsed_date) => { 103 | match chrono::Local.from_local_date(&parsed_date) { 104 | Single(date) => Some( 105 | date 106 | .and_hms(0, 0, 0) 107 | .with_timezone(&chrono::Utc) 108 | ), 109 | _ => None, 110 | } 111 | } 112 | Err(_) => None, 113 | } 114 | } 115 | 116 | pub fn parse_duration(raw_duration: &str) -> Option { 117 | return if raw_duration.to_string().contains(":") { 118 | let split: Vec<&str> = raw_duration.splitn(2, ":").collect(); 119 | let raw_hours = split[0]; 120 | let raw_minutes = split[1]; 121 | 122 | let mut duration = chrono::Duration::seconds(0); 123 | match parse_int(raw_hours) { 124 | Ok(hours) => duration = duration.add(chrono::Duration::hours(hours)), 125 | Err(_) => { 126 | println!("Duration format '{}' not understood", raw_duration); 127 | return None; 128 | }, 129 | } 130 | 131 | match parse_int(raw_minutes) { 132 | Ok(minutes) => duration = duration.add(chrono::Duration::minutes(minutes)), 133 | Err(_) => { 134 | println!("Duration format '{}' not understood", raw_duration); 135 | return None; 136 | }, 137 | } 138 | 139 | Some(duration) 140 | } else { 141 | match parse_int(raw_duration) { 142 | Ok(minutes) => Some(chrono::Duration::minutes(minutes)), 143 | Err(_) => { 144 | println!("Duration format '{}' not understood", raw_duration); 145 | None 146 | }, 147 | } 148 | } 149 | } 150 | 151 | pub fn parse_int(text: &str) -> Result { 152 | Ok(text.trim().parse::()?) 153 | } 154 | 155 | pub fn parse_float(text: &str) -> Result { 156 | Ok(text.trim().parse::()?) 157 | } --------------------------------------------------------------------------------