├── .dockerignore ├── .github └── workflows │ └── rust.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── commands.md ├── example └── rtw_config.json ├── img └── day.png ├── release.bash ├── shell-completion.md ├── src ├── chrono_clock.rs ├── cli_helper.rs ├── ical_export.rs ├── json_storage.rs ├── lib.rs ├── main.rs ├── rtw_cli.rs ├── rtw_config.rs ├── rtw_core │ ├── activity.rs │ ├── clock.rs │ ├── datetimew.rs │ ├── durationw.rs │ ├── mod.rs │ ├── service.rs │ └── storage.rs ├── service.rs ├── status.rs ├── time_tools.rs └── timeline.rs └── tests └── integration_test.rs /.dockerignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: RTW CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build_linux: 7 | name: build ubuntu 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Build 12 | run: cargo build --verbose 13 | - name: Run tests 14 | run: cargo test --verbose 15 | - name: Check formatting (cargo fmt) 16 | run: rustfmt --check src/*.rs 17 | - name: Run linting check (clippy) 18 | run: cargo clippy 19 | 20 | build_mac: 21 | name: build macos 22 | runs-on: macos-latest 23 | steps: 24 | - uses: actions/checkout@v2 25 | - name: Build 26 | run: cargo build --verbose 27 | - name: Run tests 28 | run: cargo test --verbose 29 | 30 | build_windows: 31 | name: build windows 32 | runs-on: windows-latest 33 | steps: 34 | - uses: actions/checkout@v2 35 | - name: Build 36 | run: cargo build --verbose 37 | - name: Run tests 38 | run: cargo test --verbose 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | .idea/ 4 | ignore/ 5 | /gh-md-toc 6 | /commands.md.* 7 | /releases 8 | rustfmt.toml 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [2.3.1](https://crates.io/crates/rtw/2.3.1) Jun 6, 2021 9 | 10 | * Fix CVE-2022-24713 11 | * Remove superfluous config crate features, fixes CVE-2020-25573. 12 | * Bump `htp` to `0.4.0` cf [htp changelog](https://github.com/PicoJr/htp/blob/master/CHANGELOG.md) 13 | 14 | ## [2.3.0](https://crates.io/crates/rtw/2.3.0) Apr 24, 2021 15 | 16 | * Doc add starship prompt instructions 17 | * Add `status` subcommand 18 | * Fix all clippy lints 19 | 20 | ## [2.2.0](https://crates.io/crates/rtw/2.2.0) Mar 20, 2021 21 | 22 | * Add `` optional parameter to `continue`. 23 | * Add tip for using `--overlap` when activities are overlapping. 24 | * Clean timeline legend. 25 | * Use `dirs-next` instead of unmaintained `dirs`. 26 | * Bump dependencies. 27 | 28 | ## [2.1.0](https://crates.io/crates/rtw/2.1.0) Dec 8, 2020 29 | 30 | * Add `--report` option to summary command. 31 | 32 | ## [2.0.1](https://crates.io/crates/rtw/2.0.1) Nov 3, 2020 33 | 34 | * Fix CLI output for Windows 10 cf [#43](https://github.com/PicoJr/rtw/pull/43) thanks [ythri](https://github.com/ythri) 35 | 36 | ## [2.0.0](https://crates.io/crates/rtw/2.0.0) Jul 30, 2020 37 | 38 | * Stabilize multiple ongoing activities 39 | * Stabilize long descriptions 40 | 41 | ## 2.0.0-rc1 (not released on crates.io) Jul 20, 2020 42 | 43 | * Timeline now displays ongoing activities. 44 | 45 | ## [2.0.0-beta](https://crates.io/crates/rtw/2.0.0-beta) Jul 16, 2020 46 | 47 | This version is mostly backward compatible with previous `rtw` data, 48 | please discard ongoing activities (remove `~/.rtw.json`) before using this version. 49 | However previous versions of `rtw` will not work on data generated by this version. 50 | 51 | * Support multiple ongoing activities. 52 | * Timeline now supports overlapping activities (experimental). 53 | * `stop` and `cancel` now have a `--id` optional parameter in order to disambiguate multiple ongoing activities. 54 | * Add `deny_overlapping` option to `rtw` config. 55 | * the json containing finished activities now also contains the `rtw` version. 56 | * add `-d` `--description` option for `start`, `track` and `summary` cf [#40](https://github.com/PicoJr/rtw/issues/40). 57 | * when provided, descriptions are used when exporting to calendar 58 | 59 | ## [2.0.0-alpha.1](https://crates.io/crates/rtw/2.0.0-alpha.1) Jul 12, 2020 60 | 61 | * bump `htp` to 0.2.1 (fix `next `) 62 | 63 | ## [2.0.0-alpha](https://crates.io/crates/rtw/2.0.0-alpha) Jul 5, 2020 64 | 65 | * Replace `chrono-english` with `htp`. 66 | * Fix [#37](https://github.com/PicoJr/rtw/issues/37) 67 | 68 | ## [1.5.0](https://crates.io/crates/rtw/1.5.0) Jun 21, 2020 69 | 70 | * add `completion ` command. 71 | 72 | `rtw completion ` generates completion file for `` 73 | 74 | ## [1.4.1](https://crates.io/crates/rtw/1.4.1) Jun 19, 2020 75 | 76 | * Fix timeline crash when activity spans over several days (#33) 77 | 78 | ## [1.4.0](https://crates.io/crates/rtw/1.3.1) Jun 17, 2020 79 | 80 | * Add `dump` subcommand, dumps finished activities to [ICalendar](https://en.wikipedia.org/wiki/ICalendar). 81 | 82 | ## [1.3.1](https://crates.io/crates/rtw/1.3.1) Jun 16, 2020 83 | 84 | * Fix timeline crash when activity is too short to be displayed (#28) 85 | 86 | ## [1.3.0](https://crates.io/crates/rtw/1.3.0) Jun 13, 2020 87 | 88 | * Add multiline timeline 89 | 90 | ## [1.2.2](https://crates.io/crates/rtw/1.2.2) Jun 09, 2020 91 | 92 | * Add `-n` dry-run option. 93 | 94 | ## [1.2.1](https://crates.io/crates/rtw/1.2.1) Jun 07, 2020 95 | 96 | * Add warning: CLI usage stable but not `lib.rs` content. 97 | * Fix doc.rs build issue (restore `lib.rs`). 98 | 99 | ## [1.2.0](https://crates.io/crates/rtw/1.2.0) Jun 07, 2020 100 | 101 | * add `cancel` subcommand. 102 | * deny overlapping activities 103 | * add `timeline` subcommand. 104 | * timeline colors can be configured in `rtw_config.json` 105 | * add `day` subcommand (display timeline for the current day) 106 | * add `week` subcommand (display timeline for the current week) 107 | 108 | ## [1.1.0](https://crates.io/crates/rtw/1.1.0) Mar 22, 2020 109 | 110 | ### Added 111 | 112 | * add config using [config-rs](https://docs.rs/crate/config/0.10.1). 113 | 114 | ### Changed 115 | 116 | * activities title are no longer truncated in summary 117 | 118 | ### Github CI 119 | 120 | * Add platforms: `macos-latest`, `windows-latest` (see [rust.yml](.github/workflows/rust.yml)). 121 | 122 | ## [1.0.0](https://crates.io/crates/rtw/1.0.0) Mar 16, 2020 123 | 124 | ### Added 125 | 126 | * crate [chrono-english](https://docs.rs/chrono-english/) for time parsing see [commands](commands.md). 127 | * more unit and integration tests 128 | * `summary --week` option 129 | * `summary range_start - range_end` syntax 130 | 131 | ### Fixed 132 | 133 | * Duration display bug: 1h was displayed as `01:60:3600` instead of `01:00:00` 134 | 135 | ### Breaking API Changes 136 | 137 | `rtw` now uses the crate [chrono-english](https://docs.rs/chrono-english/) for time parsing. 138 | 139 | As a result `rtw` now support the following [formats](https://docs.rs/chrono-english/#supported-formats) when supplying time hints. 140 | 141 | The following syntax are not supported anymore: 142 | 143 | * `rtw start 4m foo`, use `rtw start 4m ago foo` instead. 144 | * `rtw stop 4m`, use `rtw stop 4m ago` instead. 145 | * `rtw track 2019-12-25T19:43:00 2019-12-25T19:45:00 write doc`, use `rtw track 2019-12-25T19:43:00 - 2019-12-25T19:45:00 write doc` instead 146 | 147 | ## [0.2.1](https://crates.io/crates/rtw/0.2.1) Mar 8, 2020 148 | 149 | ### Fixed 150 | 151 | * fix cargo-audit warning on `quote:1.0.2` being yanked 152 | 153 | ### Removed 154 | 155 | * ram-only implementations 156 | 157 | ## [0.2.0](https://crates.io/crates/rtw/0.2.0) Dec 31, 2019 158 | 159 | ### Added 160 | 161 | * `track` command 162 | * `delete` command 163 | * `summary --id` option 164 | * doc test 165 | * `continue` command 166 | * `CHANGELOG.md` 167 | * `commands.md` 168 | * `summary --lastweek` option 169 | * github action 170 | * badges 171 | 172 | ### Changed 173 | 174 | * `AbsTime` renamed to `DateTimeW` 175 | * `ActiveActivity` renamed to `OngoingActivity` 176 | 177 | ### Fixed 178 | 179 | * `summary` output is now sorted by start date 180 | * `tempfile` and `assert_cmd` no longer required for build 181 | * CLI version now matches `Cargo.toml` version 182 | 183 | ## [0.1.1](https://crates.io/crates/rtw/0.1.1) Dec 26, 2019 184 | 185 | ### Added 186 | 187 | * repository url in `Cargo.toml` 188 | 189 | ## [0.1.0](https://crates.io/crates/rtw/0.1.0) Dec 26, 2019 190 | 191 | ### Added 192 | 193 | * `start` command 194 | * `stop` command 195 | * `summary` command 196 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "0.7.18" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "ansi_term" 16 | version = "0.11.0" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" 19 | dependencies = [ 20 | "winapi", 21 | ] 22 | 23 | [[package]] 24 | name = "ansi_term" 25 | version = "0.12.1" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" 28 | dependencies = [ 29 | "winapi", 30 | ] 31 | 32 | [[package]] 33 | name = "anyhow" 34 | version = "1.0.39" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "81cddc5f91628367664cc7c69714ff08deee8a3efc54623011c772544d7b2767" 37 | 38 | [[package]] 39 | name = "arrayvec" 40 | version = "0.5.2" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" 43 | 44 | [[package]] 45 | name = "assert_cmd" 46 | version = "2.0.4" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "93ae1ddd39efd67689deb1979d80bad3bf7f2b09c6e6117c8d1f2443b5e2f83e" 49 | dependencies = [ 50 | "bstr", 51 | "doc-comment", 52 | "predicates", 53 | "predicates-core", 54 | "predicates-tree", 55 | "wait-timeout", 56 | ] 57 | 58 | [[package]] 59 | name = "atty" 60 | version = "0.2.14" 61 | source = "registry+https://github.com/rust-lang/crates.io-index" 62 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 63 | dependencies = [ 64 | "hermit-abi", 65 | "libc", 66 | "winapi", 67 | ] 68 | 69 | [[package]] 70 | name = "autocfg" 71 | version = "1.0.1" 72 | source = "registry+https://github.com/rust-lang/crates.io-index" 73 | checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" 74 | 75 | [[package]] 76 | name = "bitflags" 77 | version = "1.2.1" 78 | source = "registry+https://github.com/rust-lang/crates.io-index" 79 | checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" 80 | 81 | [[package]] 82 | name = "block-buffer" 83 | version = "0.7.3" 84 | source = "registry+https://github.com/rust-lang/crates.io-index" 85 | checksum = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b" 86 | dependencies = [ 87 | "block-padding", 88 | "byte-tools", 89 | "byteorder", 90 | "generic-array", 91 | ] 92 | 93 | [[package]] 94 | name = "block-padding" 95 | version = "0.1.5" 96 | source = "registry+https://github.com/rust-lang/crates.io-index" 97 | checksum = "fa79dedbb091f449f1f39e53edf88d5dbe95f895dae6135a8d7b881fb5af73f5" 98 | dependencies = [ 99 | "byte-tools", 100 | ] 101 | 102 | [[package]] 103 | name = "bstr" 104 | version = "0.2.17" 105 | source = "registry+https://github.com/rust-lang/crates.io-index" 106 | checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223" 107 | dependencies = [ 108 | "lazy_static", 109 | "memchr", 110 | "regex-automata", 111 | ] 112 | 113 | [[package]] 114 | name = "byte-tools" 115 | version = "0.3.1" 116 | source = "registry+https://github.com/rust-lang/crates.io-index" 117 | checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" 118 | 119 | [[package]] 120 | name = "byteorder" 121 | version = "1.4.3" 122 | source = "registry+https://github.com/rust-lang/crates.io-index" 123 | checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" 124 | 125 | [[package]] 126 | name = "cfg-if" 127 | version = "1.0.0" 128 | source = "registry+https://github.com/rust-lang/crates.io-index" 129 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 130 | 131 | [[package]] 132 | name = "chrono" 133 | version = "0.4.19" 134 | source = "registry+https://github.com/rust-lang/crates.io-index" 135 | checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" 136 | dependencies = [ 137 | "libc", 138 | "num-integer", 139 | "num-traits", 140 | "serde", 141 | "time", 142 | "winapi", 143 | ] 144 | 145 | [[package]] 146 | name = "chrono-humanize" 147 | version = "0.1.2" 148 | source = "registry+https://github.com/rust-lang/crates.io-index" 149 | checksum = "8164ae3089baf04ff71f32aeb70213283dcd236dce8bc976d00b17a458f5f71c" 150 | dependencies = [ 151 | "chrono", 152 | ] 153 | 154 | [[package]] 155 | name = "clap" 156 | version = "2.33.3" 157 | source = "registry+https://github.com/rust-lang/crates.io-index" 158 | checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" 159 | dependencies = [ 160 | "ansi_term 0.11.0", 161 | "atty", 162 | "bitflags", 163 | "strsim", 164 | "textwrap", 165 | "unicode-width", 166 | "vec_map", 167 | ] 168 | 169 | [[package]] 170 | name = "config" 171 | version = "0.10.1" 172 | source = "registry+https://github.com/rust-lang/crates.io-index" 173 | checksum = "19b076e143e1d9538dde65da30f8481c2a6c44040edb8e02b9bf1351edb92ce3" 174 | dependencies = [ 175 | "lazy_static", 176 | "nom", 177 | "serde", 178 | "serde_json", 179 | ] 180 | 181 | [[package]] 182 | name = "difflib" 183 | version = "0.4.0" 184 | source = "registry+https://github.com/rust-lang/crates.io-index" 185 | checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" 186 | 187 | [[package]] 188 | name = "digest" 189 | version = "0.8.1" 190 | source = "registry+https://github.com/rust-lang/crates.io-index" 191 | checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" 192 | dependencies = [ 193 | "generic-array", 194 | ] 195 | 196 | [[package]] 197 | name = "dirs-next" 198 | version = "2.0.0" 199 | source = "registry+https://github.com/rust-lang/crates.io-index" 200 | checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" 201 | dependencies = [ 202 | "cfg-if", 203 | "dirs-sys-next", 204 | ] 205 | 206 | [[package]] 207 | name = "dirs-sys-next" 208 | version = "0.1.2" 209 | source = "registry+https://github.com/rust-lang/crates.io-index" 210 | checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" 211 | dependencies = [ 212 | "libc", 213 | "redox_users", 214 | "winapi", 215 | ] 216 | 217 | [[package]] 218 | name = "doc-comment" 219 | version = "0.3.3" 220 | source = "registry+https://github.com/rust-lang/crates.io-index" 221 | checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" 222 | 223 | [[package]] 224 | name = "either" 225 | version = "1.6.1" 226 | source = "registry+https://github.com/rust-lang/crates.io-index" 227 | checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" 228 | 229 | [[package]] 230 | name = "fake-simd" 231 | version = "0.1.2" 232 | source = "registry+https://github.com/rust-lang/crates.io-index" 233 | checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed" 234 | 235 | [[package]] 236 | name = "float-cmp" 237 | version = "0.9.0" 238 | source = "registry+https://github.com/rust-lang/crates.io-index" 239 | checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" 240 | dependencies = [ 241 | "num-traits", 242 | ] 243 | 244 | [[package]] 245 | name = "generic-array" 246 | version = "0.12.4" 247 | source = "registry+https://github.com/rust-lang/crates.io-index" 248 | checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" 249 | dependencies = [ 250 | "typenum", 251 | ] 252 | 253 | [[package]] 254 | name = "getrandom" 255 | version = "0.2.2" 256 | source = "registry+https://github.com/rust-lang/crates.io-index" 257 | checksum = "c9495705279e7140bf035dde1f6e750c162df8b625267cd52cc44e0b156732c8" 258 | dependencies = [ 259 | "cfg-if", 260 | "libc", 261 | "wasi", 262 | ] 263 | 264 | [[package]] 265 | name = "hermit-abi" 266 | version = "0.1.18" 267 | source = "registry+https://github.com/rust-lang/crates.io-index" 268 | checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c" 269 | dependencies = [ 270 | "libc", 271 | ] 272 | 273 | [[package]] 274 | name = "htp" 275 | version = "0.4.0" 276 | source = "registry+https://github.com/rust-lang/crates.io-index" 277 | checksum = "9c6cd254130cc8a55d22f42ef674be43424a81ff94a43827f9fe43d345816f0c" 278 | dependencies = [ 279 | "chrono", 280 | "pest", 281 | "pest_derive", 282 | "thiserror", 283 | ] 284 | 285 | [[package]] 286 | name = "icalendar" 287 | version = "0.9.0" 288 | source = "registry+https://github.com/rust-lang/crates.io-index" 289 | checksum = "246d9eb8ae541ca077ff2b4fda671cb8ec7aaa8dabafdb61b48f745e77f86181" 290 | dependencies = [ 291 | "chrono", 292 | "uuid", 293 | ] 294 | 295 | [[package]] 296 | name = "itertools" 297 | version = "0.9.0" 298 | source = "registry+https://github.com/rust-lang/crates.io-index" 299 | checksum = "284f18f85651fe11e8a991b2adb42cb078325c996ed026d994719efcfca1d54b" 300 | dependencies = [ 301 | "either", 302 | ] 303 | 304 | [[package]] 305 | name = "itertools" 306 | version = "0.10.3" 307 | source = "registry+https://github.com/rust-lang/crates.io-index" 308 | checksum = "a9a9d19fa1e79b6215ff29b9d6880b706147f16e9b1dbb1e4e5947b5b02bc5e3" 309 | dependencies = [ 310 | "either", 311 | ] 312 | 313 | [[package]] 314 | name = "itoa" 315 | version = "0.4.7" 316 | source = "registry+https://github.com/rust-lang/crates.io-index" 317 | checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" 318 | 319 | [[package]] 320 | name = "lazy_static" 321 | version = "1.4.0" 322 | source = "registry+https://github.com/rust-lang/crates.io-index" 323 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 324 | 325 | [[package]] 326 | name = "lexical-core" 327 | version = "0.7.5" 328 | source = "registry+https://github.com/rust-lang/crates.io-index" 329 | checksum = "21f866863575d0e1d654fbeeabdc927292fdf862873dc3c96c6f753357e13374" 330 | dependencies = [ 331 | "arrayvec", 332 | "bitflags", 333 | "cfg-if", 334 | "ryu", 335 | "static_assertions", 336 | ] 337 | 338 | [[package]] 339 | name = "libc" 340 | version = "0.2.90" 341 | source = "registry+https://github.com/rust-lang/crates.io-index" 342 | checksum = "ba4aede83fc3617411dc6993bc8c70919750c1c257c6ca6a502aed6e0e2394ae" 343 | 344 | [[package]] 345 | name = "maplit" 346 | version = "1.0.2" 347 | source = "registry+https://github.com/rust-lang/crates.io-index" 348 | checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" 349 | 350 | [[package]] 351 | name = "memchr" 352 | version = "2.5.0" 353 | source = "registry+https://github.com/rust-lang/crates.io-index" 354 | checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" 355 | 356 | [[package]] 357 | name = "nom" 358 | version = "5.1.2" 359 | source = "registry+https://github.com/rust-lang/crates.io-index" 360 | checksum = "ffb4262d26ed83a1c0a33a38fe2bb15797329c85770da05e6b828ddb782627af" 361 | dependencies = [ 362 | "lexical-core", 363 | "memchr", 364 | "version_check", 365 | ] 366 | 367 | [[package]] 368 | name = "normalize-line-endings" 369 | version = "0.3.0" 370 | source = "registry+https://github.com/rust-lang/crates.io-index" 371 | checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" 372 | 373 | [[package]] 374 | name = "num-integer" 375 | version = "0.1.44" 376 | source = "registry+https://github.com/rust-lang/crates.io-index" 377 | checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" 378 | dependencies = [ 379 | "autocfg", 380 | "num-traits", 381 | ] 382 | 383 | [[package]] 384 | name = "num-traits" 385 | version = "0.2.14" 386 | source = "registry+https://github.com/rust-lang/crates.io-index" 387 | checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" 388 | dependencies = [ 389 | "autocfg", 390 | ] 391 | 392 | [[package]] 393 | name = "opaque-debug" 394 | version = "0.2.3" 395 | source = "registry+https://github.com/rust-lang/crates.io-index" 396 | checksum = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c" 397 | 398 | [[package]] 399 | name = "pest" 400 | version = "2.1.3" 401 | source = "registry+https://github.com/rust-lang/crates.io-index" 402 | checksum = "10f4872ae94d7b90ae48754df22fd42ad52ce740b8f370b03da4835417403e53" 403 | dependencies = [ 404 | "ucd-trie", 405 | ] 406 | 407 | [[package]] 408 | name = "pest_derive" 409 | version = "2.1.0" 410 | source = "registry+https://github.com/rust-lang/crates.io-index" 411 | checksum = "833d1ae558dc601e9a60366421196a8d94bc0ac980476d0b67e1d0988d72b2d0" 412 | dependencies = [ 413 | "pest", 414 | "pest_generator", 415 | ] 416 | 417 | [[package]] 418 | name = "pest_generator" 419 | version = "2.1.3" 420 | source = "registry+https://github.com/rust-lang/crates.io-index" 421 | checksum = "99b8db626e31e5b81787b9783425769681b347011cc59471e33ea46d2ea0cf55" 422 | dependencies = [ 423 | "pest", 424 | "pest_meta", 425 | "proc-macro2", 426 | "quote", 427 | "syn", 428 | ] 429 | 430 | [[package]] 431 | name = "pest_meta" 432 | version = "2.1.3" 433 | source = "registry+https://github.com/rust-lang/crates.io-index" 434 | checksum = "54be6e404f5317079812fc8f9f5279de376d8856929e21c184ecf6bbd692a11d" 435 | dependencies = [ 436 | "maplit", 437 | "pest", 438 | "sha-1", 439 | ] 440 | 441 | [[package]] 442 | name = "ppv-lite86" 443 | version = "0.2.10" 444 | source = "registry+https://github.com/rust-lang/crates.io-index" 445 | checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857" 446 | 447 | [[package]] 448 | name = "predicates" 449 | version = "2.1.1" 450 | source = "registry+https://github.com/rust-lang/crates.io-index" 451 | checksum = "a5aab5be6e4732b473071984b3164dbbfb7a3674d30ea5ff44410b6bcd960c3c" 452 | dependencies = [ 453 | "difflib", 454 | "float-cmp", 455 | "itertools 0.10.3", 456 | "normalize-line-endings", 457 | "predicates-core", 458 | "regex", 459 | ] 460 | 461 | [[package]] 462 | name = "predicates-core" 463 | version = "1.0.2" 464 | source = "registry+https://github.com/rust-lang/crates.io-index" 465 | checksum = "57e35a3326b75e49aa85f5dc6ec15b41108cf5aee58eabb1f274dd18b73c2451" 466 | 467 | [[package]] 468 | name = "predicates-tree" 469 | version = "1.0.2" 470 | source = "registry+https://github.com/rust-lang/crates.io-index" 471 | checksum = "15f553275e5721409451eb85e15fd9a860a6e5ab4496eb215987502b5f5391f2" 472 | dependencies = [ 473 | "predicates-core", 474 | "treeline", 475 | ] 476 | 477 | [[package]] 478 | name = "proc-macro2" 479 | version = "1.0.24" 480 | source = "registry+https://github.com/rust-lang/crates.io-index" 481 | checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71" 482 | dependencies = [ 483 | "unicode-xid", 484 | ] 485 | 486 | [[package]] 487 | name = "quote" 488 | version = "1.0.9" 489 | source = "registry+https://github.com/rust-lang/crates.io-index" 490 | checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" 491 | dependencies = [ 492 | "proc-macro2", 493 | ] 494 | 495 | [[package]] 496 | name = "rand" 497 | version = "0.8.3" 498 | source = "registry+https://github.com/rust-lang/crates.io-index" 499 | checksum = "0ef9e7e66b4468674bfcb0c81af8b7fa0bb154fa9f28eb840da5c447baeb8d7e" 500 | dependencies = [ 501 | "libc", 502 | "rand_chacha", 503 | "rand_core", 504 | "rand_hc", 505 | ] 506 | 507 | [[package]] 508 | name = "rand_chacha" 509 | version = "0.3.0" 510 | source = "registry+https://github.com/rust-lang/crates.io-index" 511 | checksum = "e12735cf05c9e10bf21534da50a147b924d555dc7a547c42e6bb2d5b6017ae0d" 512 | dependencies = [ 513 | "ppv-lite86", 514 | "rand_core", 515 | ] 516 | 517 | [[package]] 518 | name = "rand_core" 519 | version = "0.6.2" 520 | source = "registry+https://github.com/rust-lang/crates.io-index" 521 | checksum = "34cf66eb183df1c5876e2dcf6b13d57340741e8dc255b48e40a26de954d06ae7" 522 | dependencies = [ 523 | "getrandom", 524 | ] 525 | 526 | [[package]] 527 | name = "rand_hc" 528 | version = "0.3.0" 529 | source = "registry+https://github.com/rust-lang/crates.io-index" 530 | checksum = "3190ef7066a446f2e7f42e239d161e905420ccab01eb967c9eb27d21b2322a73" 531 | dependencies = [ 532 | "rand_core", 533 | ] 534 | 535 | [[package]] 536 | name = "redox_syscall" 537 | version = "0.2.5" 538 | source = "registry+https://github.com/rust-lang/crates.io-index" 539 | checksum = "94341e4e44e24f6b591b59e47a8a027df12e008d73fd5672dbea9cc22f4507d9" 540 | dependencies = [ 541 | "bitflags", 542 | ] 543 | 544 | [[package]] 545 | name = "redox_users" 546 | version = "0.4.0" 547 | source = "registry+https://github.com/rust-lang/crates.io-index" 548 | checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" 549 | dependencies = [ 550 | "getrandom", 551 | "redox_syscall", 552 | ] 553 | 554 | [[package]] 555 | name = "regex" 556 | version = "1.5.6" 557 | source = "registry+https://github.com/rust-lang/crates.io-index" 558 | checksum = "d83f127d94bdbcda4c8cc2e50f6f84f4b611f69c902699ca385a39c3a75f9ff1" 559 | dependencies = [ 560 | "aho-corasick", 561 | "memchr", 562 | "regex-syntax", 563 | ] 564 | 565 | [[package]] 566 | name = "regex-automata" 567 | version = "0.1.10" 568 | source = "registry+https://github.com/rust-lang/crates.io-index" 569 | checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" 570 | 571 | [[package]] 572 | name = "regex-syntax" 573 | version = "0.6.26" 574 | source = "registry+https://github.com/rust-lang/crates.io-index" 575 | checksum = "49b3de9ec5dc0a3417da371aab17d729997c15010e7fd24ff707773a33bddb64" 576 | 577 | [[package]] 578 | name = "remove_dir_all" 579 | version = "0.5.3" 580 | source = "registry+https://github.com/rust-lang/crates.io-index" 581 | checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" 582 | dependencies = [ 583 | "winapi", 584 | ] 585 | 586 | [[package]] 587 | name = "rtw" 588 | version = "2.3.1" 589 | dependencies = [ 590 | "ansi_term 0.12.1", 591 | "anyhow", 592 | "assert_cmd", 593 | "chrono", 594 | "chrono-humanize", 595 | "clap", 596 | "config", 597 | "dirs-next", 598 | "htp", 599 | "icalendar", 600 | "itertools 0.9.0", 601 | "predicates", 602 | "regex", 603 | "serde", 604 | "serde_json", 605 | "tbl", 606 | "tempfile", 607 | "term_size", 608 | "thiserror", 609 | ] 610 | 611 | [[package]] 612 | name = "ryu" 613 | version = "1.0.5" 614 | source = "registry+https://github.com/rust-lang/crates.io-index" 615 | checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" 616 | 617 | [[package]] 618 | name = "serde" 619 | version = "1.0.124" 620 | source = "registry+https://github.com/rust-lang/crates.io-index" 621 | checksum = "bd761ff957cb2a45fbb9ab3da6512de9de55872866160b23c25f1a841e99d29f" 622 | dependencies = [ 623 | "serde_derive", 624 | ] 625 | 626 | [[package]] 627 | name = "serde_derive" 628 | version = "1.0.124" 629 | source = "registry+https://github.com/rust-lang/crates.io-index" 630 | checksum = "1800f7693e94e186f5e25a28291ae1570da908aff7d97a095dec1e56ff99069b" 631 | dependencies = [ 632 | "proc-macro2", 633 | "quote", 634 | "syn", 635 | ] 636 | 637 | [[package]] 638 | name = "serde_json" 639 | version = "1.0.64" 640 | source = "registry+https://github.com/rust-lang/crates.io-index" 641 | checksum = "799e97dc9fdae36a5c8b8f2cae9ce2ee9fdce2058c57a93e6099d919fd982f79" 642 | dependencies = [ 643 | "itoa", 644 | "ryu", 645 | "serde", 646 | ] 647 | 648 | [[package]] 649 | name = "sha-1" 650 | version = "0.8.2" 651 | source = "registry+https://github.com/rust-lang/crates.io-index" 652 | checksum = "f7d94d0bede923b3cea61f3f1ff57ff8cdfd77b400fb8f9998949e0cf04163df" 653 | dependencies = [ 654 | "block-buffer", 655 | "digest", 656 | "fake-simd", 657 | "opaque-debug", 658 | ] 659 | 660 | [[package]] 661 | name = "static_assertions" 662 | version = "1.1.0" 663 | source = "registry+https://github.com/rust-lang/crates.io-index" 664 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 665 | 666 | [[package]] 667 | name = "strsim" 668 | version = "0.8.0" 669 | source = "registry+https://github.com/rust-lang/crates.io-index" 670 | checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" 671 | 672 | [[package]] 673 | name = "syn" 674 | version = "1.0.64" 675 | source = "registry+https://github.com/rust-lang/crates.io-index" 676 | checksum = "3fd9d1e9976102a03c542daa2eff1b43f9d72306342f3f8b3ed5fb8908195d6f" 677 | dependencies = [ 678 | "proc-macro2", 679 | "quote", 680 | "unicode-xid", 681 | ] 682 | 683 | [[package]] 684 | name = "tbl" 685 | version = "1.1.0" 686 | source = "registry+https://github.com/rust-lang/crates.io-index" 687 | checksum = "01c3c86796fd3013af7438a1dc273901b1ea2d954e1da1eab803d63f9780047a" 688 | dependencies = [ 689 | "itertools 0.9.0", 690 | "thiserror", 691 | ] 692 | 693 | [[package]] 694 | name = "tempfile" 695 | version = "3.2.0" 696 | source = "registry+https://github.com/rust-lang/crates.io-index" 697 | checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22" 698 | dependencies = [ 699 | "cfg-if", 700 | "libc", 701 | "rand", 702 | "redox_syscall", 703 | "remove_dir_all", 704 | "winapi", 705 | ] 706 | 707 | [[package]] 708 | name = "term_size" 709 | version = "0.3.2" 710 | source = "registry+https://github.com/rust-lang/crates.io-index" 711 | checksum = "1e4129646ca0ed8f45d09b929036bafad5377103edd06e50bf574b353d2b08d9" 712 | dependencies = [ 713 | "libc", 714 | "winapi", 715 | ] 716 | 717 | [[package]] 718 | name = "textwrap" 719 | version = "0.11.0" 720 | source = "registry+https://github.com/rust-lang/crates.io-index" 721 | checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" 722 | dependencies = [ 723 | "unicode-width", 724 | ] 725 | 726 | [[package]] 727 | name = "thiserror" 728 | version = "1.0.24" 729 | source = "registry+https://github.com/rust-lang/crates.io-index" 730 | checksum = "e0f4a65597094d4483ddaed134f409b2cb7c1beccf25201a9f73c719254fa98e" 731 | dependencies = [ 732 | "thiserror-impl", 733 | ] 734 | 735 | [[package]] 736 | name = "thiserror-impl" 737 | version = "1.0.24" 738 | source = "registry+https://github.com/rust-lang/crates.io-index" 739 | checksum = "7765189610d8241a44529806d6fd1f2e0a08734313a35d5b3a556f92b381f3c0" 740 | dependencies = [ 741 | "proc-macro2", 742 | "quote", 743 | "syn", 744 | ] 745 | 746 | [[package]] 747 | name = "time" 748 | version = "0.1.43" 749 | source = "registry+https://github.com/rust-lang/crates.io-index" 750 | checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" 751 | dependencies = [ 752 | "libc", 753 | "winapi", 754 | ] 755 | 756 | [[package]] 757 | name = "treeline" 758 | version = "0.1.0" 759 | source = "registry+https://github.com/rust-lang/crates.io-index" 760 | checksum = "a7f741b240f1a48843f9b8e0444fb55fb2a4ff67293b50a9179dfd5ea67f8d41" 761 | 762 | [[package]] 763 | name = "typenum" 764 | version = "1.13.0" 765 | source = "registry+https://github.com/rust-lang/crates.io-index" 766 | checksum = "879f6906492a7cd215bfa4cf595b600146ccfac0c79bcbd1f3000162af5e8b06" 767 | 768 | [[package]] 769 | name = "ucd-trie" 770 | version = "0.1.3" 771 | source = "registry+https://github.com/rust-lang/crates.io-index" 772 | checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c" 773 | 774 | [[package]] 775 | name = "unicode-width" 776 | version = "0.1.8" 777 | source = "registry+https://github.com/rust-lang/crates.io-index" 778 | checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" 779 | 780 | [[package]] 781 | name = "unicode-xid" 782 | version = "0.2.1" 783 | source = "registry+https://github.com/rust-lang/crates.io-index" 784 | checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" 785 | 786 | [[package]] 787 | name = "uuid" 788 | version = "0.8.2" 789 | source = "registry+https://github.com/rust-lang/crates.io-index" 790 | checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" 791 | dependencies = [ 792 | "getrandom", 793 | ] 794 | 795 | [[package]] 796 | name = "vec_map" 797 | version = "0.8.2" 798 | source = "registry+https://github.com/rust-lang/crates.io-index" 799 | checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" 800 | 801 | [[package]] 802 | name = "version_check" 803 | version = "0.9.3" 804 | source = "registry+https://github.com/rust-lang/crates.io-index" 805 | checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" 806 | 807 | [[package]] 808 | name = "wait-timeout" 809 | version = "0.2.0" 810 | source = "registry+https://github.com/rust-lang/crates.io-index" 811 | checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" 812 | dependencies = [ 813 | "libc", 814 | ] 815 | 816 | [[package]] 817 | name = "wasi" 818 | version = "0.10.2+wasi-snapshot-preview1" 819 | source = "registry+https://github.com/rust-lang/crates.io-index" 820 | checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" 821 | 822 | [[package]] 823 | name = "winapi" 824 | version = "0.3.9" 825 | source = "registry+https://github.com/rust-lang/crates.io-index" 826 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 827 | dependencies = [ 828 | "winapi-i686-pc-windows-gnu", 829 | "winapi-x86_64-pc-windows-gnu", 830 | ] 831 | 832 | [[package]] 833 | name = "winapi-i686-pc-windows-gnu" 834 | version = "0.4.0" 835 | source = "registry+https://github.com/rust-lang/crates.io-index" 836 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 837 | 838 | [[package]] 839 | name = "winapi-x86_64-pc-windows-gnu" 840 | version = "0.4.0" 841 | source = "registry+https://github.com/rust-lang/crates.io-index" 842 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 843 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rtw" 3 | version = "2.3.1" 4 | authors = ["PicoJr "] 5 | edition = "2018" 6 | repository = "https://github.com/PicoJr/rtw" 7 | description = "time tracker command line tool" 8 | license = "MIT OR Apache-2.0" 9 | readme = "README.md" 10 | keywords = ["time", "tracker", "cli", "tool"] 11 | categories = ["command-line-utilities"] 12 | include = ["src/**/*", "/LICENSE", "/README.md", "/CHANGELOG.md", "/commands.md", "/shell-completion.md", "/img/*"] 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [dependencies] 17 | clap = "2.33.0" 18 | anyhow = "1.0" 19 | thiserror = "1.0.19" 20 | serde = { version = "1.0", features = ["derive"] } 21 | serde_json = "1.0" 22 | dirs-next = "2.0.0" 23 | chrono = { version = "0.4", features = ["serde"] } 24 | htp = "0.4.0" 25 | config = { version = "0.10.1", default-features = false, features = ["json"] } 26 | ansi_term = "0.12.1" 27 | term_size = "0.3.2" 28 | tbl = "1.1.0" 29 | icalendar = "0.9.0" 30 | itertools = "0.9" 31 | chrono-humanize = "0.1.2" 32 | 33 | [dev-dependencies] 34 | tempfile = "3" 35 | assert_cmd = "2.0.4" 36 | predicates = "2.1.1" 37 | 38 | # Fix CVE-2022-24713 39 | regex = "1.5.5" 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![rtw crate](https://img.shields.io/crates/v/rtw.svg)](https://crates.io/crates/rtw) 2 | [![rtw documentation](https://docs.rs/rtw/badge.svg)](https://docs.rs/rtw) 3 | [![GitHub license](https://img.shields.io/github/license/PicoJr/rtw)](https://github.com/PicoJr/rtw/blob/master/LICENSE) 4 | 5 | |Branch|Status| 6 | |------|------| 7 | |[master](https://github.com/PicoJr/rtw/tree/master)|![Build Status](https://github.com/PicoJr/rtw/workflows/Rust/badge.svg?branch=master)| 8 | 9 | # RTW - Rust Time Watcher 10 | 11 | Command-line interface (CLI) time tracker. 12 | 13 | CLI usage is stable, underlying API is **not stable**. 14 | 15 | > Note: This software is built specifically as a productivity tool for myself, 16 | > not as a consumer resource. I cannot commit a large amount of time to maintaining this 17 | > software but I'll do my best to provide support if something fails =). 18 | 19 | This project is heavily inspired from [Timewarrior](https://github.com/GothenburgBitFactory/timewarrior). 20 | 21 | > For a stable feature-rich CLI time tracker, please use Timewarrior: https://timewarrior.net/. 22 | 23 | ## Why another time tracker tool? 24 | 25 | 1. learn Rust 26 | 2. I once lost a month worth of data with another time tracker tool (database corruption)...never again! 27 | 28 | ## Install 29 | 30 | Supported OS: Linux, MacOS, Windows 31 | 32 | CI runs on `ubuntu-latest`, `macos-latest`, `windows-latest`. 33 | 34 | Note: Windows support is only experimental. Some features may not be supported on Windows. 35 | 36 | ### Cargo 37 | 38 | ``` 39 | cargo install rtw 40 | ``` 41 | 42 | ### Build From Source 43 | 44 | rtw compiles with Rust 1.42.0 (stable) or newer. 45 | 46 | Clone and build from source: 47 | ``` 48 | git clone https://github.com/PicoJr/rtw.git 49 | cd rtw 50 | cargo build --release 51 | ``` 52 | 53 | ### From binaries (Linux only) 54 | 55 | Download the corresponding archive from the [Release page](https://github.com/picojr/rtw/releases). 56 | 57 | ### Shell Completion (Bash, Zsh, Fish, Powershell, Elvish) 58 | 59 | Please see [shell completion](shell-completion.md). 60 | 61 | ### [Starship](https://github.com/starship/starship) prompt integration 62 | 63 | ```toml 64 | # starship.toml 65 | [custom.rtw] 66 | command = """ rtw status --format "{ongoing} {human_duration}" """ 67 | when = "test -f ~/.rtw.json" 68 | shell = ["bash", "--noprofile", "--norc"] 69 | ``` 70 | 71 | > ~/.rtw.json is the file where `rtw` stores ongoing activities 72 | 73 | ## Changelog 74 | 75 | Please see the [CHANGELOG](CHANGELOG.md) for a release history. 76 | 77 | ## Basic Usage 78 | 79 | ### Start tracking an activity 80 | 81 | Example: 82 | ```bash 83 | rtw start "learn rust" 84 | ``` 85 | 86 | Example output: 87 | ``` 88 | Tracking learn rust 89 | Started 2019-12-25T19:43:00 90 | ``` 91 | 92 | ### Display current activity 93 | 94 | ``` bash 95 | rtw 96 | ``` 97 | 98 | Example output: 99 | ``` 100 | Tracking learn rust 101 | Total 01:15:00 102 | ``` 103 | 104 | ### Stop current activity 105 | 106 | ```bash 107 | rtw stop 108 | ``` 109 | 110 | Example output: 111 | ``` 112 | Recorded learn rust 113 | Started 2019-12-25T19:43:00 114 | Ended 2019-12-25T21:00:00 115 | Total 01:17:000 116 | ``` 117 | 118 | ### Display the day's activity summary 119 | 120 | ```bash 121 | rtw summary 122 | ``` 123 | 124 | Example output: 125 | ``` 126 | read the doc 2019-12-25T11:49:30 2019-12-25T11:53:36 00:04:246 127 | eat cookies 2019-12-25T12:08:49 2019-12-25T12:12:14 00:03:204 128 | ``` 129 | 130 | ### Display a timeline for the day 131 | 132 | ```bash 133 | rtw day 134 | ``` 135 | 136 | Example output (YMMV): 137 | 138 | ![timeline](img/day.png) 139 | 140 | ### More? 141 | 142 | For further details see [Full Usage](commands.md). 143 | 144 | ## Configuration 145 | 146 | RTW doesn't create the config file for you, but it looks for one in the following locations (in this order): 147 | 148 | 1. `$XDG_CONFIG_HOME/rtw/rtw_config.json` 149 | 2. `$HOME/.config/rtw/rtw_config.json` 150 | 3. `$XDG_CONFIG_HOME/.config/rtw_config.json` 151 | 4. `$HOME/.config/rtw_config.json` 152 | 153 | see `example` folder for a default config file. 154 | 155 | ## Implementation 156 | 157 | RTW relies on json files for persistence. 158 | 159 | Default location is the home (`~`) directory. 160 | 161 | ``` 162 | ~/.rtw.json # stores current activity 163 | ~/.rtwh.json # stores finished activities 164 | ``` 165 | 166 | **there is currently no file locking mechanism**: running several `rtw` commands at the same time 167 | may lead to undefined behavior. 168 | 169 | ## Similar Tools 170 | 171 | * [timewarrior](https://github.com/GothenburgBitFactory/timewarrior) 172 | * [watson](https://github.com/TailorDev/Watson) 173 | * [jobrog](https://github.com/dfhoughton/jobrog) 174 | * [doug](https://github.com/chdsbd/doug) 175 | * [timetracking](https://github.com/hardliner66/timetracking/) 176 | * ... 177 | -------------------------------------------------------------------------------- /commands.md: -------------------------------------------------------------------------------- 1 | # RTW Commands 2 | 3 | 4 | * [RTW Commands](#rtw-commands) 5 | * [Start New Activity](#start-new-activity) 6 | * [Start tracking an activity now](#start-tracking-an-activity-now) 7 | * [Start tracking an activity 4 minutes ago](#start-tracking-an-activity-4-minutes-ago) 8 | * [Start tracking an activity at a specific time](#start-tracking-an-activity-at-a-specific-time) 9 | * [Stop Current Activity](#stop-current-activity) 10 | * [Stop current activity now](#stop-current-activity-now) 11 | * [Stop current activity 4 minutes ago](#stop-current-activity-4-minutes-ago) 12 | * [Stop current activity at a specific time](#stop-current-activity-at-a-specific-time) 13 | * [Cancel current activity](#cancel-current-activity) 14 | * [Display Summary](#display-summary) 15 | * [Display finished activities summary for today](#display-finished-activities-summary-for-today) 16 | * [Display finished activities summary for yesterday](#display-finished-activities-summary-for-yesterday) 17 | * [Display finished activities summary for last week](#display-finished-activities-summary-for-last-week) 18 | * [Display finished activities summary for range](#display-finished-activities-summary-for-range) 19 | * [Display finished activities id](#display-finished-activities-id) 20 | * [Display a report (sum same activities)](#display-a-report-sum-same-activities) 21 | * [Display a timeline](#display-a-timeline) 22 | * [For the day](#for-the-day) 23 | * [For the week](#for-the-week) 24 | * [For a time range](#for-a-time-range) 25 | * [Export Finished Activities to iCalendar](#export-finished-activities-to-icalendar) 26 | * [For today](#for-today) 27 | * [For last week](#for-last-week) 28 | * [For a given date range](#for-a-given-date-range) 29 | * [Continue Activity](#continue-activity) 30 | * [Continue last finished activity](#continue-last-finished-activity) 31 | * [Continue finished activity with id](#continue-finished-activity-with-id) 32 | * [Delete Activity](#delete-activity) 33 | * [Delete Activity with id](#delete-activity-with-id) 34 | * [Track a finished activity](#track-a-finished-activity) 35 | * [Track a finished activity with dates](#track-a-finished-activity-with-dates) 36 | * [Track a finished activity the same day](#track-a-finished-activity-the-same-day) 37 | * [Track an activity and provide a long description](#track-an-activity-and-provide-a-long-description) 38 | * [Display current status (for usage in scripts/status bars/prompts...)](#display-current-status-for-usage-in-scriptsstatus-barsprompts) 39 | * [For multitasking people](#for-multitasking-people) 40 | * [Start (overlapping) activities](#start-overlapping-activities) 41 | * [Stop ongoing activity](#stop-ongoing-activity) 42 | 43 | 44 | Created by [gh-md-toc](https://github.com/ekalinin/github-markdown-toc) 45 | 46 | ## Start New Activity 47 | 48 | ### Start tracking an activity now 49 | 50 | Example: 51 | ``` 52 | rtw start write doc 53 | ``` 54 | 55 | Example output: 56 | ``` 57 | Tracking write doc 58 | Started 2019-12-25T19:43:00 59 | ``` 60 | 61 | ### Start tracking an activity 4 minutes ago 62 | 63 | Example: 64 | ``` 65 | rtw start 4 min ago write doc 66 | ``` 67 | 68 | Example output: 69 | ``` 70 | Tracking write doc 71 | Started 2019-12-25T19:39:00 72 | ``` 73 | 74 | ### Start tracking an activity at a specific time 75 | 76 | Example: 77 | ``` 78 | rtw start 2019-12-24T19:43:00 write doc 79 | ``` 80 | 81 | Example output: 82 | ``` 83 | Tracking write doc 84 | Started 2019-12-24T19:43:00 85 | ``` 86 | 87 | ## Stop Current Activity 88 | 89 | ### Stop current activity now 90 | 91 | Example: 92 | ``` 93 | rtw stop 94 | ``` 95 | 96 | Example output: 97 | ``` 98 | Recorded write doc 99 | Started 2019-12-25T19:43:00 100 | Ended 2019-12-25T19:50:00 101 | Total 00:07:00 102 | ``` 103 | 104 | ### Stop current activity 4 minutes ago 105 | 106 | Example: 107 | ``` 108 | rtw stop 4m ago 109 | ``` 110 | 111 | Example output: 112 | ``` 113 | Recorded write doc 114 | Started 2019-12-25T19:43:00 115 | Ended 2019-12-25T19:46:00 116 | Total 00:03:00 117 | ``` 118 | 119 | ### Stop current activity at a specific time 120 | 121 | Example: 122 | ``` 123 | rtw stop 2019-12-25T19:45:00 124 | ``` 125 | 126 | Example output: 127 | ``` 128 | Recorded write doc 129 | Started 2019-12-25T19:43:00 130 | Ended 2019-12-25T19:45:00 131 | Total 00:02:00 132 | ``` 133 | 134 | ## Cancel current activity 135 | 136 | Example: 137 | ``` 138 | rtw cancel 139 | ``` 140 | 141 | Example output: 142 | ``` 143 | Cancelled write doc 144 | Started 2019-12-24T19:43:00 145 | Total 00:20:05 146 | ``` 147 | 148 | ## Display Summary 149 | 150 | ### Display finished activities summary for today 151 | 152 | Example: 153 | ``` 154 | rtw summary 155 | ``` 156 | 157 | Example output: 158 | ``` 159 | write doc 2019-12-25T19:43:00 2019-12-25T19:45:00 00:03:000 160 | ``` 161 | 162 | ### Display finished activities summary for yesterday 163 | 164 | Example: 165 | ``` 166 | rtw summary --yesterday 167 | ``` 168 | 169 | Example output: 170 | ``` 171 | write doc 2019-12-24T19:43:00 2019-12-24T19:45:00 00:03:000 172 | ``` 173 | 174 | ### Display finished activities summary for last week 175 | 176 | Example: 177 | ``` 178 | rtw summary --lastweek 179 | ``` 180 | 181 | Example output: 182 | ``` 183 | write doc 2019-12-17T19:43:00 2019-12-17T19:45:00 00:03:000 184 | ``` 185 | 186 | ### Display finished activities summary for range 187 | 188 | Example: 189 | ``` 190 | rtw summary 19:00 - 20:00 191 | ``` 192 | 193 | Example output: 194 | ``` 195 | write doc 2019-12-17T19:43:00 2019-12-17T19:45:00 00:03:000 196 | ``` 197 | 198 | ### Display finished activities id 199 | 200 | Example: 201 | ``` 202 | rtw summary --id 203 | ``` 204 | 205 | Example output: 206 | ``` 207 | 2 foo 2019-12-25T17:43:00 2019-12-25T17:44:00 00:01:00 208 | 1 another foo 2019-12-25T18:43:00 2019-12-25T18:44:00 00:01:00 209 | 0 bar 2019-12-25T19:43:00 2019-12-25T19:44:00 00:01:00 210 | ``` 211 | 212 | > id 0 = last finished activity 213 | 214 | ### Display a report (sum same activities) 215 | 216 | Example: 217 | ``` 218 | rtw track 8 - 9 foo 219 | rtw track 9 - 10 foo 220 | rtw track 10 - 11 bar 221 | rtw summary --report 222 | ``` 223 | 224 | Example output: 225 | ``` 226 | foo 02:00:00 (2 segments) 227 | bar 01:00:00 (1 segments) 228 | ``` 229 | 230 | ## Display a timeline 231 | 232 | ### For the day 233 | 234 | ```bash 235 | rtw day 236 | ``` 237 | 238 | Example output (YMMV): 239 | 240 | ![timeline](img/day.png) 241 | 242 | ### For the week 243 | 244 | ```bash 245 | rtw week 246 | ``` 247 | 248 | ### For a time range 249 | 250 | ```bash 251 | rtw timeline last monday - now 252 | ``` 253 | 254 | ## Export Finished Activities to iCalendar 255 | 256 | ### For today 257 | 258 | Example: 259 | ``` 260 | rtw dump 261 | ``` 262 | 263 | Example output: 264 | ``` 265 | BEGIN:VCALENDAR 266 | VERSION:2.0 267 | PRODID:ICALENDAR-RS 268 | CALSCALE:GREGORIAN 269 | BEGIN:VEVENT 270 | DTSTAMP:20200616T184116Z 271 | DTEND:20200616T203000 272 | DTSTART:20200616T160000 273 | SUMMARY:build a spaceship 274 | UID:3bc8b3b6-d17b-4e1d-8323-2f55bfb14792 275 | END:VEVENT 276 | END:VCALENDAR 277 | ``` 278 | 279 | Dump to ics file: `rtw dump > today.ics` 280 | 281 | ### For last week 282 | 283 | Example: 284 | ``` 285 | rtw dump lastweek 286 | ``` 287 | 288 | Dump to ics file: `rtw dump --lastweek > lastweek.ics` 289 | 290 | ### For a given date range 291 | 292 | Example: 293 | ``` 294 | rtw dump last monday - now 295 | ``` 296 | 297 | Dump to ics file: `rtw dump last monday - now > lastweek.ics` 298 | 299 | ## Continue Activity 300 | 301 | ### Continue last finished activity 302 | 303 | Example: 304 | ``` 305 | rtw continue 306 | ``` 307 | 308 | Example output: 309 | ``` 310 | Tracking write doc 311 | ``` 312 | 313 | ### Continue finished activity with id 314 | 315 | Example: 316 | ``` 317 | rtw continue 2 318 | ``` 319 | 320 | Example output: 321 | ``` 322 | Tracking read twir 323 | ``` 324 | 325 | ## Delete Activity 326 | 327 | ### Delete Activity with id 328 | 329 | Example: 330 | ``` 331 | rtw delete 1 332 | ``` 333 | 334 | Example output: 335 | ``` 336 | Deleted write doc 337 | Started 2019-12-25T19:43:00 338 | Ended 2019-12-25T19:45:00 339 | Total 00:02:00 340 | ``` 341 | 342 | ## Track a finished activity 343 | 344 | ### Track a finished activity with dates 345 | 346 | Example: 347 | ``` 348 | rtw track 2019-12-25T19:43:00 - 2019-12-25T19:45:00 write doc 349 | ``` 350 | 351 | > please note the `-` separator 352 | 353 | Example output 354 | ``` 355 | Recorded write doc 356 | Started 2019-12-25T19:43:00 357 | Ended 2019-12-25T19:45:00 358 | Total 00:02:00 359 | ``` 360 | 361 | ### Track a finished activity the same day 362 | 363 | Example: 364 | ``` 365 | rtw track 09:00 - 10:00 write doc 366 | ``` 367 | 368 | > please note the `-` separator 369 | 370 | Example output 371 | ``` 372 | Recorded write doc 373 | Started 2020-03-14T09:00:00 374 | Ended 2020-03-14T10:00:00 375 | Total 01:00:00 376 | ``` 377 | 378 | ## Track an activity and provide a long description 379 | 380 | Example: 381 | 382 | ``` 383 | rtw track 9 - 10 breakfast -d "I ate delicious pancakes" 384 | rtw summary -d 385 | ``` 386 | 387 | output: 388 | ``` 389 | breakfast 2020-07-11T09:00:00 2020-07-11T10:00:00 01:00:00 390 | I ate delicious pancakes 391 | ``` 392 | 393 | ## Display current status (for usage in scripts/status bars/prompts...) 394 | 395 | Example: 396 | 397 | ``` 398 | rtw start foo 399 | rtw status --format "{id} {ongoing} {start} {human_duration} {duration}" 400 | ``` 401 | 402 | output: 403 | 404 | ``` 405 | 0 foo 2021-04-20T19:31:09 2 hours ago 02:20:53 406 | ``` 407 | 408 | ## For multitasking people 409 | 410 | Requires `deny_overlapping: false` in `rtw_config.json` 411 | 412 | ### Start (overlapping) activities 413 | 414 | Example: 415 | 416 | ``` 417 | rtw start work 418 | rtw start child question -d "answer how fish can breath under water" 419 | rtw 420 | ``` 421 | 422 | Output: 423 | 424 | ``` 425 | ./target/debug/rtw PicoJr 426 | Tracking work 427 | Total 00:03:03 428 | Id 0 429 | Tracking child question 430 | Total 00:01:25 431 | Id 1 432 | ``` 433 | 434 | ### Stop ongoing activity 435 | 436 | `--id` is only required when ongoing activities > 1. 437 | 438 | Example: 439 | 440 | ``` 441 | rtw stop --id 1 442 | ``` 443 | 444 | Output: 445 | 446 | ``` PicoJr 447 | Recorded child question 448 | Started 2020-07-14T10:54:36 449 | Ended 2020-07-14T10:57:23 450 | Total 00:02:47 451 | ``` 452 | 453 | stop the other remaining ongoing activity: 454 | 455 | ``` 456 | rtw stop 457 | ``` 458 | 459 | Output: 460 | 461 | ``` 462 | Recorded work 463 | Started 2020-07-14T10:52:58 464 | Ended 2020-07-14T11:00:17 465 | Total 00:07:18 466 | ``` 467 | -------------------------------------------------------------------------------- /example/rtw_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "storage_dir_path": "/home/nol", 3 | "timeline_colors": [[183,28,28], [26,35,126], [0,77,64], [130,119,23]], 4 | "deny_overlapping": true 5 | } 6 | -------------------------------------------------------------------------------- /img/day.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PicoJr/rtw/d01f8787ed2fc5d576dbca007c766ddb43aec557/img/day.png -------------------------------------------------------------------------------- /release.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash -v 2 | 3 | RELEASE_DIR="release$(date +%F-%H-%M-%S)" 4 | 5 | rustfmt --check src/**/*.rs && 6 | cargo test && 7 | docker run --rm -it -v "$(pwd)":/home/rust/src ekidd/rust-musl-builder cargo build --release && 8 | mkdir -p ${RELEASE_DIR} && 9 | cp -r ./CHANGELOG.md ./commands.md ./shell-completion.md \ 10 | example/ img/ ./README.md ./LICENSE ${RELEASE_DIR} && 11 | cp target/x86_64-unknown-linux-musl/release/rtw ${RELEASE_DIR}/rtw-x86_64-unknown-linux-musl 12 | 13 | -------------------------------------------------------------------------------- /shell-completion.md: -------------------------------------------------------------------------------- 1 | # RTW Shell Completion 2 | 3 | supported shells: bash, zsh, fish, powershell, elvish 4 | 5 | Write completion file for `` to stdout: 6 | 7 | ``` 8 | rtw completion 9 | ``` 10 | 11 | ## oh-my-zsh 12 | 13 | ``` 14 | .oh-my-zsh/custom/plugins/rtw 15 | ├── _rtw 16 | └── rtw.plugin.zsh 17 | ``` 18 | 19 | ``` 20 | mkdir -p ~/.oh-my-zsh/custom/plugins/rtw 21 | rtw completion zsh > ~/.oh-my-zsh/custom/plugins/rtw/_rtw 22 | echo "#rtw completion plugin" > ~/.oh-my-zsh/custom/plugins/rtw/rtw.plugin.zsh 23 | ``` 24 | 25 | Add `rtw` to `plugins` in `.zshrc`: 26 | 27 | ``` 28 | # Which plugins would you like to load? (plugins can be found in ~/.oh-my-zsh/plugins/*) 29 | # Custom plugins may be added to ~/.oh-my-zsh/custom/plugins/ 30 | # Example format: plugins=(rails git textmate ruby lighthouse) 31 | # Add wisely, as too many plugins slow down shell startup. 32 | plugins=(git rtw) 33 | ``` 34 | -------------------------------------------------------------------------------- /src/chrono_clock.rs: -------------------------------------------------------------------------------- 1 | //! Clock impl using chrono. 2 | use crate::rtw_core::clock::{Clock, Time}; 3 | use crate::rtw_core::datetimew::DateTimeW; 4 | use chrono::{Date, Datelike, Duration, Local}; 5 | 6 | pub struct ChronoClock {} 7 | 8 | impl Clock for ChronoClock { 9 | fn get_time(&self) -> DateTimeW { 10 | chrono::Local::now().into() 11 | } 12 | 13 | fn date_time(&self, time: Time) -> DateTimeW { 14 | match time { 15 | Time::Now => self.get_time(), 16 | Time::DateTime(abs_time) => abs_time, 17 | } 18 | } 19 | 20 | fn today_range(&self) -> (DateTimeW, DateTimeW) { 21 | let today = chrono::Local::today(); 22 | self.day_range(today) 23 | } 24 | 25 | fn yesterday_range(&self) -> (DateTimeW, DateTimeW) { 26 | let today = chrono::Local::today(); 27 | let yesterday = today - chrono::Duration::days(1); // so proud 28 | self.day_range(yesterday) 29 | } 30 | 31 | fn last_week_range(&self) -> (DateTimeW, DateTimeW) { 32 | let today = chrono::Local::today(); 33 | let weekday = today.weekday(); 34 | let this_week_monday = today - Duration::days(weekday.num_days_from_monday() as i64); 35 | let last_week_monday = this_week_monday - Duration::days(7); 36 | let last_week_sunday = this_week_monday - Duration::days(1); 37 | self.days_range(last_week_monday, last_week_sunday) 38 | } 39 | 40 | fn this_week_range(&self) -> (DateTimeW, DateTimeW) { 41 | let today = chrono::Local::today(); 42 | let weekday = today.weekday(); 43 | let this_week_monday = today - Duration::days(weekday.num_days_from_monday() as i64); 44 | let this_week_sunday = this_week_monday + Duration::days(6); 45 | self.days_range(this_week_monday, this_week_sunday) 46 | } 47 | } 48 | 49 | impl ChronoClock { 50 | fn day_range(&self, day: Date) -> (DateTimeW, DateTimeW) { 51 | self.days_range(day, day) 52 | } 53 | 54 | fn days_range(&self, day_start: Date, day_end: Date) -> (DateTimeW, DateTimeW) { 55 | ( 56 | day_start.and_hms(0, 0, 0).into(), 57 | day_end.and_hms(23, 59, 59).into(), 58 | ) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/cli_helper.rs: -------------------------------------------------------------------------------- 1 | //! CLI parsing helpers and clap App. 2 | use clap::{App, Arg, ArgMatches, SubCommand}; 3 | 4 | use crate::rtw_core::clock::{Clock, Time}; 5 | use crate::rtw_core::datetimew::DateTimeW; 6 | use crate::rtw_core::{ActivityId, Description, Tags}; 7 | use crate::time_tools::TimeTools; 8 | use std::str::FromStr; 9 | 10 | // 09:00 foo -> (09:00, foo) 11 | // foo -> (Now, foo) 12 | // last friday 8pm foo -> (last friday 8pm, foo) 13 | fn split_time_clue_from_tags(tokens: &[String], clock: &dyn Clock) -> (Time, Tags) { 14 | for at in (0..=tokens.len()).rev() { 15 | let (possibly_time_clue, possibly_tags) = tokens.split_at(at); 16 | let possibly_time_clue_joined: &str = &possibly_time_clue.join(" "); 17 | if TimeTools::is_time(possibly_time_clue_joined) { 18 | let time = TimeTools::time_from_str(possibly_time_clue_joined, clock).unwrap(); 19 | return (time, possibly_tags.to_vec()); 20 | } 21 | } 22 | (Time::Now, tokens.to_vec()) 23 | } 24 | 25 | // "09:00 - 10:00 foo" -> (09:00, 10:00, foo) 26 | fn split_time_range_from_tags( 27 | tokens: &[String], 28 | clock: &dyn Clock, 29 | ) -> anyhow::Result<(Time, Time, Tags)> { 30 | let separator = "-"; 31 | let sp = tokens.splitn(2, |e| e == separator); 32 | let sp: Vec<&[String]> = sp.collect(); 33 | match sp.as_slice() { 34 | [range_start, range_end_and_tags] => { 35 | let range_start_maybe = TimeTools::time_from_str(&range_start.join(" "), clock); 36 | let (range_end, activity_tags) = split_time_clue_from_tags(range_end_and_tags, clock); 37 | match range_start_maybe { 38 | Ok(range_start) => Ok((range_start, range_end, activity_tags)), 39 | Err(e) => Err(anyhow::anyhow!(e)), 40 | } 41 | } 42 | _ => Err(anyhow::anyhow!( 43 | "missing ' - ' between range start and range end? " 44 | )), 45 | } 46 | } 47 | 48 | // 09:00 - 10:00 -> (09:00, 10:00) 49 | // 09:00 - -> (09:00, Now) 50 | fn split_time_range(tokens: &[String], clock: &dyn Clock) -> anyhow::Result<(Time, Time)> { 51 | let separator = "-"; 52 | let sp = tokens.splitn(2, |e| e == separator); 53 | let sp: Vec<&[String]> = sp.collect(); 54 | match sp.as_slice() { 55 | [range_start, range_end] => { 56 | let range_start_maybe = TimeTools::time_from_str(&range_start.join(" "), clock); 57 | let range_end_maybe = if range_end.is_empty() { 58 | Ok(Time::Now) 59 | } else { 60 | TimeTools::time_from_str(&range_end.join(" "), clock) 61 | }; 62 | match (range_start_maybe, range_end_maybe) { 63 | (Ok(range_start), Ok(range_end)) => Ok((range_start, range_end)), 64 | _ => Err(anyhow::anyhow!("invalid range")), 65 | } 66 | } 67 | _ => Err(anyhow::anyhow!( 68 | "missing ' - ' between range start and range end? " 69 | )), 70 | } 71 | } 72 | 73 | pub fn get_app() -> App<'static, 'static> { 74 | App::new(crate_name!()) 75 | .version(crate_version!()) 76 | .author("PicoJr") 77 | .about("rust time tracking CLI") 78 | .arg( 79 | Arg::with_name("directory") 80 | .short("d") 81 | .long("dir") 82 | .value_name("DIR") 83 | .required(false) 84 | .help("storage directory") 85 | .hidden(true) // only useful for testing 86 | .takes_value(true), 87 | ) 88 | .arg( 89 | Arg::with_name("default") 90 | .long("default") 91 | .required(false) 92 | .help("use default config") 93 | .hidden(true), // only useful for testing 94 | ) 95 | .arg( 96 | Arg::with_name("overlap") 97 | .long("overlap") 98 | .required(false) 99 | .conflicts_with("default") 100 | .conflicts_with("no_overlap") 101 | .help("allow overlapping activities"), 102 | ) 103 | .arg( 104 | Arg::with_name("no_overlap") 105 | .long("no_overlap") 106 | .required(false) 107 | .conflicts_with("overlap") 108 | .conflicts_with("default") 109 | .help("disallow overlapping activities"), 110 | ) 111 | .arg( 112 | Arg::with_name("dry-run") 113 | .short("n") 114 | .long("dry") 115 | .required(false) 116 | .help("dry run: don't write anything to the filesystem"), 117 | ) 118 | .subcommand( 119 | SubCommand::with_name("start") 120 | .about("Start new activity") 121 | .arg( 122 | Arg::with_name("tokens") 123 | .multiple(true) 124 | .required(true) 125 | .help(concat!( 126 | "optional time clue followed by at least 1 tag\n", 127 | "e.g '4 min ago foo' or '09:00 foo' or 'foo' " 128 | )), 129 | ) 130 | .arg( 131 | Arg::with_name("description") 132 | .short("d") 133 | .long("description") 134 | .takes_value(true) 135 | .help("long activity description"), 136 | ), 137 | ) 138 | .subcommand( 139 | SubCommand::with_name("track") 140 | .about("Track a finished activity") 141 | .arg( 142 | Arg::with_name("tokens") 143 | .multiple(true) 144 | .required(true) 145 | .help(concat!( 146 | "interval time clue followed by at least 1 tag\n", 147 | "start - end tags...\n", 148 | "e.g '09:00 - 10:00 foo' " 149 | )), 150 | ) 151 | .arg( 152 | Arg::with_name("description") 153 | .short("d") 154 | .long("description") 155 | .takes_value(true) 156 | .help("long activity description"), 157 | ), 158 | ) 159 | .subcommand( 160 | SubCommand::with_name("stop") 161 | .about("Stop activity") 162 | .arg( 163 | Arg::with_name("time") 164 | .multiple(true) 165 | .required(false) 166 | .help(concat!( 167 | "optional time clue e.g. 4min ago\n", 168 | "current time is used when omitted" 169 | )), 170 | ) 171 | .arg( 172 | Arg::with_name("id") 173 | .long("id") 174 | .takes_value(true) 175 | .help(concat!( 176 | "optional activity id\n", 177 | "current activity is stopped when omitted" 178 | )), 179 | ), 180 | ) 181 | .subcommand( 182 | SubCommand::with_name("summary") 183 | .about("Display finished activities") 184 | .arg( 185 | Arg::with_name("tokens") 186 | .multiple(true) 187 | .required(false) 188 | .conflicts_with_all(&["yesterday", "lastweek", "week"]) 189 | .help(concat!( 190 | "optional interval time clue\n", 191 | "start - end\n", 192 | "e.g '09:00 - 10:00' " 193 | )), 194 | ) 195 | .arg( 196 | Arg::with_name("yesterday") 197 | .long("yesterday") 198 | .help("activities done yesterday"), 199 | ) 200 | .arg( 201 | Arg::with_name("lastweek") 202 | .long("lastweek") 203 | .help("activities done last week"), 204 | ) 205 | .arg( 206 | Arg::with_name("week") 207 | .long("week") 208 | .help("activities done this week"), 209 | ) 210 | .arg( 211 | Arg::with_name("id") 212 | .long("id") 213 | .help("display activities id"), 214 | ) 215 | .arg( 216 | Arg::with_name("description") 217 | .short("d") 218 | .long("description") 219 | .help("display activities descriptions"), 220 | ) 221 | .arg( 222 | Arg::with_name("report") 223 | .short("r") 224 | .long("report") 225 | .help("sum up activities with same tag together"), 226 | ), 227 | ) 228 | .subcommand( 229 | SubCommand::with_name("dump") 230 | .about("Dump finished activities to stdout in iCalendar format") 231 | .after_help(concat!( 232 | "examples:\n", 233 | "rtw dump > today.ics\n", 234 | "rtw dump --lastweek > lastweek.ics\n", 235 | "rtw dump last friday - now > recent.ics\n" 236 | )) 237 | .arg( 238 | Arg::with_name("tokens") 239 | .multiple(true) 240 | .required(false) 241 | .conflicts_with_all(&["yesterday", "lastweek", "week"]) 242 | .help(concat!( 243 | "optional interval time clue\n", 244 | "start - end\n", 245 | "e.g '09:00 - 10:00' " 246 | )), 247 | ) 248 | .arg( 249 | Arg::with_name("yesterday") 250 | .long("yesterday") 251 | .help("activities done yesterday"), 252 | ) 253 | .arg( 254 | Arg::with_name("lastweek") 255 | .long("lastweek") 256 | .help("activities done last week"), 257 | ) 258 | .arg( 259 | Arg::with_name("week") 260 | .long("week") 261 | .help("activities done this week"), 262 | ), 263 | ) 264 | .subcommand( 265 | SubCommand::with_name("continue") 266 | .about("Continue a finished activity") 267 | .arg( 268 | Arg::with_name("id") 269 | .required(false) 270 | .help("activity id (when id is not provided continue last activity)"), 271 | ), 272 | ) 273 | .subcommand(SubCommand::with_name("day").about("Display the current day as a timeline")) 274 | .subcommand(SubCommand::with_name("week").about("Display the current week as a timeline")) 275 | .subcommand( 276 | SubCommand::with_name("timeline") 277 | .about("Display finished activities as a timeline") 278 | .arg( 279 | Arg::with_name("tokens") 280 | .multiple(true) 281 | .required(false) 282 | .help(concat!( 283 | "optional interval time clue\n", 284 | "start - end\n", 285 | "e.g 'last monday - now' " 286 | )), 287 | ), 288 | ) 289 | .subcommand( 290 | SubCommand::with_name("delete") 291 | .about("Delete activity") 292 | .arg(Arg::with_name("id").required(true).help("activity id")), 293 | ) 294 | .subcommand( 295 | SubCommand::with_name("cancel") 296 | .about("cancel current activity") 297 | .arg( 298 | Arg::with_name("id") 299 | .long("id") 300 | .takes_value(true) 301 | .help(concat!( 302 | "optional activity id\n", 303 | "current activity is stopped when omitted" 304 | )), 305 | ), 306 | ) 307 | .subcommand( 308 | SubCommand::with_name("completion") 309 | .about("generate completion file") 310 | .arg( 311 | Arg::with_name("shell") 312 | .possible_values(&["bash", "zsh", "fish", "powershell", "elvish"]) 313 | .takes_value(true) 314 | .required(true), 315 | ), 316 | ) 317 | .subcommand( 318 | SubCommand::with_name("status") 319 | .about("print status data, suitable for use in status bar or prompts") 320 | .arg( 321 | Arg::with_name("format") 322 | .long("format") 323 | .takes_value(true) 324 | .help(concat!( 325 | "format string e.g. \"{id} {ongoing} {start} {human_duration} {duration}\"" 326 | )), 327 | ), 328 | ) 329 | } 330 | 331 | pub fn parse_start_args( 332 | start_m: &ArgMatches, 333 | clock: &dyn Clock, 334 | ) -> anyhow::Result<(Time, Tags, Option)> { 335 | let description = start_m.value_of("description").map(|s| s.to_string()); 336 | let values_arg = start_m.values_of("tokens"); // optional time clue, tags 337 | if let Some(values) = values_arg { 338 | let values: Tags = values.map(String::from).collect(); 339 | let (time, tags) = split_time_clue_from_tags(&values, clock); 340 | return if tags.is_empty() { 341 | Err(anyhow::anyhow!("no tags provided")) 342 | } else { 343 | Ok((time, tags, description)) 344 | }; 345 | } 346 | Err(anyhow::anyhow!("neither time clue nor tags provided")) // it should be prevented by clap 347 | } 348 | 349 | pub fn parse_track_args( 350 | track_m: &ArgMatches, 351 | clock: &dyn Clock, 352 | ) -> anyhow::Result<(Time, Time, Tags, Option)> { 353 | let description = track_m.value_of("description").map(|s| s.to_string()); 354 | let values_arg = track_m 355 | .values_of("tokens") 356 | .expect("start time, end time and at least 1 tag required"); 357 | let values: Tags = values_arg.map(String::from).collect(); 358 | let (range_start, range_end, activity_tags) = split_time_range_from_tags(&values, clock)?; 359 | Ok((range_start, range_end, activity_tags, description)) 360 | } 361 | 362 | pub fn parse_stop_args( 363 | stop_m: &ArgMatches, 364 | clock: &dyn Clock, 365 | ) -> anyhow::Result<(Time, Option)> { 366 | let stopped_id_maybe = stop_m.value_of("id").map(usize::from_str).transpose()?; 367 | let time_arg = stop_m.values_of("time"); 368 | if let Some(values) = time_arg { 369 | let values: Vec = values.map(String::from).collect(); 370 | let time_str = values.join(" "); 371 | let stop_time = TimeTools::time_from_str(&time_str, clock)?; 372 | Ok((stop_time, stopped_id_maybe)) 373 | } else { 374 | Ok((Time::Now, stopped_id_maybe)) 375 | } 376 | } 377 | 378 | pub fn parse_continue_args(continue_m: &ArgMatches) -> anyhow::Result> { 379 | let continue_id_maybe = continue_m.value_of("id").map(usize::from_str).transpose()?; 380 | Ok(continue_id_maybe) 381 | } 382 | 383 | pub fn parse_cancel_args(cancel_m: &ArgMatches) -> anyhow::Result> { 384 | let cancelled_id_maybe = cancel_m.value_of("id").map(usize::from_str).transpose()?; 385 | Ok(cancelled_id_maybe) 386 | } 387 | 388 | pub fn parse_summary_args( 389 | summary_m: &ArgMatches, 390 | clock: &dyn Clock, 391 | ) -> anyhow::Result<((DateTimeW, DateTimeW), bool, bool, bool)> { 392 | let display_id = summary_m.is_present("id"); 393 | let report = summary_m.is_present("report"); 394 | let display_description = summary_m.is_present("description"); 395 | let values_arg = summary_m.values_of("tokens"); 396 | if let Some(values) = values_arg { 397 | let values: Vec = values.map(String::from).collect(); 398 | let range_maybe = split_time_range(&values, clock); 399 | return match range_maybe { 400 | Ok((range_start, range_end)) => { 401 | let range_start = clock.date_time(range_start); 402 | let range_end = clock.date_time(range_end); 403 | Ok(( 404 | (range_start, range_end), 405 | display_id, 406 | display_description, 407 | report, 408 | )) 409 | } 410 | Err(e) => Err(anyhow::anyhow!(e)), 411 | }; 412 | } 413 | let range = { 414 | if summary_m.is_present("yesterday") { 415 | clock.yesterday_range() 416 | } else if summary_m.is_present("lastweek") { 417 | clock.last_week_range() 418 | } else if summary_m.is_present("week") { 419 | clock.this_week_range() 420 | } else { 421 | clock.today_range() 422 | } 423 | }; 424 | Ok((range, display_id, display_description, report)) 425 | } 426 | 427 | pub fn parse_timeline_args( 428 | timeline_m: &ArgMatches, 429 | clock: &dyn Clock, 430 | ) -> anyhow::Result<((DateTimeW, DateTimeW), bool)> { 431 | let display_id = timeline_m.is_present("id"); 432 | let values_arg = timeline_m.values_of("tokens"); 433 | if let Some(values) = values_arg { 434 | let values: Vec = values.map(String::from).collect(); 435 | let range_maybe = split_time_range(&values, clock); 436 | match range_maybe { 437 | Ok((range_start, range_end)) => { 438 | let range_start = clock.date_time(range_start); 439 | let range_end = clock.date_time(range_end); 440 | Ok(((range_start, range_end), display_id)) 441 | } 442 | Err(e) => Err(anyhow::anyhow!(e)), 443 | } 444 | } else { 445 | Ok((clock.today_range(), display_id)) 446 | } 447 | } 448 | 449 | pub fn parse_delete_args(delete_m: &ArgMatches) -> anyhow::Result { 450 | let id_opt = delete_m.value_of("id").map(usize::from_str); 451 | if let Some(Ok(id)) = id_opt { 452 | Ok(id) 453 | } else { 454 | Err(anyhow::anyhow!("could not parse id")) 455 | } 456 | } 457 | 458 | pub fn parse_completion_args(completion_m: &ArgMatches) -> anyhow::Result { 459 | let shell_maybe = completion_m.value_of("shell"); 460 | match shell_maybe { 461 | Some("bash") => Ok(clap::Shell::Bash), 462 | Some("zsh") => Ok(clap::Shell::Zsh), 463 | Some("fish") => Ok(clap::Shell::Fish), 464 | Some("powershell") => Ok(clap::Shell::PowerShell), 465 | Some("elvish") => Ok(clap::Shell::Elvish), 466 | None => Err(anyhow::anyhow!("missing shell")), // should never happen thanks to clap check 467 | _ => Err(anyhow::anyhow!("invalid shell")), // should never happen thanks to clap check 468 | } 469 | } 470 | 471 | pub fn parse_status_args(status_m: &ArgMatches) -> Option { 472 | let format_maybe = status_m.value_of("format"); 473 | format_maybe.map(String::from) 474 | } 475 | 476 | #[cfg(test)] 477 | mod tests { 478 | use crate::chrono_clock::ChronoClock; 479 | use crate::cli_helper::{ 480 | split_time_clue_from_tags, split_time_range, split_time_range_from_tags, 481 | }; 482 | use crate::rtw_core::clock::Time; 483 | use crate::rtw_core::Tags; 484 | use crate::time_tools::TimeTools; 485 | 486 | #[test] 487 | // rtw start 488 | fn test_split_time_clue_from_tags_0_0() { 489 | let clock = ChronoClock {}; 490 | let values: Tags = vec![]; 491 | let (time, tags) = split_time_clue_from_tags(&values, &clock); 492 | assert_eq!(Time::Now, time); 493 | assert!(tags.is_empty()); 494 | } 495 | 496 | #[test] 497 | // rtw start foo 498 | fn test_split_time_clue_from_tags_0_1() { 499 | let clock = ChronoClock {}; 500 | let values: Tags = vec![String::from("foo")]; 501 | let (time, tags) = split_time_clue_from_tags(&values, &clock); 502 | assert_eq!(Time::Now, time); 503 | assert_eq!(tags, values); 504 | } 505 | 506 | #[test] 507 | // rtw start foo bar 508 | fn test_split_time_clue_from_tags_0_2() { 509 | let clock = ChronoClock {}; 510 | let values: Tags = vec![String::from("foo"), String::from("bar")]; 511 | let (time, tags) = split_time_clue_from_tags(&values, &clock); 512 | assert_eq!(Time::Now, time); 513 | assert_eq!(tags, values); 514 | } 515 | 516 | #[test] 517 | // rtw start 1 h ago 518 | fn test_split_time_clue_from_tags_3_0() { 519 | let clock = ChronoClock {}; 520 | let values: Tags = vec![String::from("1"), String::from("h"), String::from("ago")]; 521 | let (time, tags) = split_time_clue_from_tags(&values, &clock); 522 | assert_ne!(Time::Now, time); 523 | assert!(tags.is_empty()); 524 | } 525 | 526 | #[test] 527 | // rtw start 1 h ago foo 528 | fn test_split_time_clue_from_tags_3_1() { 529 | let clock = ChronoClock {}; 530 | let tokens: Vec = vec![ 531 | String::from("1"), 532 | String::from("h"), 533 | String::from("ago"), 534 | String::from("foo"), 535 | ]; 536 | let (time, tags) = split_time_clue_from_tags(&tokens, &clock); 537 | assert_ne!(Time::Now, time); 538 | assert_eq!(tags, vec![String::from("foo")]); 539 | } 540 | 541 | #[test] 542 | // rtw track 09:00 - 10:00 foo 543 | fn test_split_time_range_from_tags_1_1_1() { 544 | let clock = ChronoClock {}; 545 | let tokens: Vec = vec![ 546 | String::from("09:00"), 547 | String::from("-"), 548 | String::from("10:00"), 549 | String::from("foo"), 550 | ]; 551 | let time_range_and_tags = split_time_range_from_tags(&tokens, &clock); 552 | assert!(time_range_and_tags.is_ok()); 553 | } 554 | 555 | #[test] 556 | // rtw summary 09:00 - 10:00 557 | fn test_split_range_1_1() { 558 | let clock = ChronoClock {}; 559 | let tokens: Vec = vec![ 560 | String::from("09:00"), 561 | String::from("-"), 562 | String::from("10:00"), 563 | ]; 564 | let time_range = split_time_range(&tokens, &clock); 565 | assert!(time_range.is_ok()); 566 | let time_range = time_range.unwrap(); 567 | assert_eq!( 568 | time_range.0, 569 | TimeTools::time_from_str("09:00", &clock).unwrap() 570 | ); 571 | assert_eq!( 572 | time_range.1, 573 | TimeTools::time_from_str("10:00", &clock).unwrap() 574 | ); 575 | } 576 | 577 | #[test] 578 | // rtw summary 09:00 - 579 | fn test_split_range_1_0() { 580 | let clock = ChronoClock {}; 581 | let tokens: Vec = vec![String::from("09:00"), String::from("-")]; 582 | let time_range = split_time_range(&tokens, &clock); 583 | assert!(time_range.is_ok()); 584 | assert_eq!(time_range.unwrap().1, Time::Now) 585 | } 586 | } 587 | -------------------------------------------------------------------------------- /src/ical_export.rs: -------------------------------------------------------------------------------- 1 | use crate::rtw_core::activity::Activity; 2 | use crate::rtw_core::datetimew::DateTimeW; 3 | use chrono::{DateTime, Local}; 4 | use icalendar::Calendar; 5 | use icalendar::CalendarDateTime; 6 | use icalendar::Component; 7 | use icalendar::Event; 8 | 9 | impl From for CalendarDateTime { 10 | fn from(d: DateTimeW) -> Self { 11 | let local: DateTime = d.into(); 12 | local.naive_utc().into() 13 | } 14 | } 15 | 16 | impl From for Event { 17 | fn from(a: Activity) -> Self { 18 | let start_time = a.get_start_time(); 19 | let stop_time = a.get_stop_time(); 20 | let title = a.get_title(); 21 | match a.get_description() { 22 | None => Event::new() 23 | .summary(title.as_str()) 24 | .starts(start_time) 25 | .ends(stop_time) 26 | .done(), 27 | Some(description) => Event::new() 28 | .summary(title.as_str()) 29 | .description(&description) 30 | .starts(start_time) 31 | .ends(stop_time) 32 | .done(), 33 | } 34 | } 35 | } 36 | 37 | pub(crate) fn export_activities_to_ical(activities: &[Activity]) -> Calendar { 38 | let mut calendar = Calendar::new(); 39 | for activity in activities { 40 | let event: Event = activity.clone().into(); 41 | calendar.push(event); 42 | } 43 | calendar 44 | } 45 | -------------------------------------------------------------------------------- /src/json_storage.rs: -------------------------------------------------------------------------------- 1 | //! Store activities (current, finished) as Json files. 2 | use crate::rtw_core::activity::{Activity, OngoingActivity}; 3 | use crate::rtw_core::storage::Storage; 4 | use crate::rtw_core::ActivityId; 5 | use itertools::Itertools; 6 | use serde::{Deserialize, Serialize}; 7 | use std::fs::{File, OpenOptions}; 8 | use std::path::{Path, PathBuf}; 9 | use thiserror::Error; 10 | 11 | type Activities = Vec; 12 | type ActivityWithId = (ActivityId, Activity); 13 | type OngoingActivityWithId = (ActivityId, OngoingActivity); 14 | 15 | #[derive(Debug, Clone, Serialize, Deserialize)] 16 | struct FinishedActivities { 17 | #[serde(default)] 18 | pub semver: Option, 19 | pub activities: Activities, 20 | } 21 | 22 | impl Default for FinishedActivities { 23 | fn default() -> Self { 24 | FinishedActivities { 25 | semver: Some(crate_version!().to_string()), 26 | activities: vec![], 27 | } 28 | } 29 | } 30 | 31 | #[derive(Error, Debug)] 32 | pub enum JsonStorageError { 33 | #[error("storage io error")] 34 | IoError(#[from] std::io::Error), 35 | #[error("(de)serialization failed")] 36 | SerdeJsonError(#[from] serde_json::error::Error), 37 | } 38 | 39 | pub struct JsonStorage { 40 | current_path: PathBuf, 41 | finished_path: PathBuf, 42 | } 43 | 44 | #[derive(Debug, Clone, Serialize, Deserialize)] 45 | pub struct OngoingActivities { 46 | ongoing: Vec, 47 | } 48 | 49 | impl JsonStorage { 50 | pub fn new(current_path: PathBuf, finished_path: PathBuf) -> Self { 51 | JsonStorage { 52 | current_path, 53 | finished_path, 54 | } 55 | } 56 | 57 | fn get_finished_activities(&self) -> Result { 58 | if Path::exists(&self.finished_path) { 59 | let file = OpenOptions::new() 60 | .read(true) 61 | .write(false) 62 | .open(&self.finished_path)?; 63 | let finished_activities: serde_json::error::Result = 64 | serde_json::from_reader(file); 65 | finished_activities.or_else(|_| { 66 | let file = OpenOptions::new() 67 | .read(true) 68 | .write(false) 69 | .open(&self.finished_path)?; 70 | // try to parse legacy format. 71 | let activities: Activities = serde_json::from_reader(file)?; 72 | Ok(FinishedActivities { 73 | semver: None, 74 | activities, 75 | }) 76 | }) 77 | } else { 78 | Ok(FinishedActivities::default()) 79 | } 80 | } 81 | 82 | fn get_sorted_activities(&self) -> Result, JsonStorageError> { 83 | let mut finished_activities = self.get_finished_activities()?; 84 | finished_activities.activities.sort(); 85 | Ok((0..finished_activities.activities.len()) 86 | .rev() 87 | .zip(finished_activities.activities) 88 | .collect()) 89 | } 90 | } 91 | 92 | impl Storage for JsonStorage { 93 | type StorageError = JsonStorageError; 94 | 95 | fn write_activity(&mut self, activity: Activity) -> Result<(), Self::StorageError> { 96 | if !Path::exists(&self.finished_path) { 97 | let file = File::create(&self.finished_path)?; 98 | let activities: Activities = vec![activity]; 99 | let finished_activities = FinishedActivities { 100 | semver: Some(crate_version!().to_string()), 101 | activities, 102 | }; 103 | serde_json::to_writer(file, &finished_activities)?; 104 | Ok(()) 105 | } else { 106 | let mut finished_activities = self.get_finished_activities()?; 107 | finished_activities.activities.push(activity); 108 | let file = OpenOptions::new() 109 | .write(true) 110 | .truncate(true) 111 | .open(&self.finished_path)?; 112 | finished_activities.semver = Some(crate_version!().to_string()); 113 | serde_json::to_writer(file, &finished_activities)?; 114 | Ok(()) 115 | } 116 | } 117 | 118 | fn filter_activities

(&self, p: P) -> Result, Self::StorageError> 119 | where 120 | P: Fn(&(ActivityId, Activity)) -> bool, 121 | { 122 | let indexed_finished_activities = self.get_sorted_activities()?; 123 | let filtered = indexed_finished_activities.into_iter().filter(p); 124 | Ok(filtered.collect()) 125 | } 126 | 127 | fn get_finished_activities(&self) -> Result, Self::StorageError> { 128 | self.get_sorted_activities() 129 | } 130 | 131 | fn delete_activity(&self, id: usize) -> Result, Self::StorageError> { 132 | let finished_activities = self.get_sorted_activities()?; 133 | let (removed, kept): (Vec<&ActivityWithId>, Vec<&ActivityWithId>) = finished_activities 134 | .iter() 135 | .partition(|(finished_id, _)| *finished_id == id); 136 | let kept: Vec<&Activity> = kept.iter().map(|(_, a)| a).collect(); 137 | let file = OpenOptions::new() 138 | .create(true) 139 | .write(true) 140 | .truncate(true) 141 | .open(&self.finished_path)?; 142 | serde_json::to_writer(file, &kept)?; 143 | Ok(match removed.as_slice() { 144 | [(_, removed)] => Some(removed.clone()), 145 | _ => None, 146 | }) 147 | } 148 | 149 | fn get_ongoing_activities(&self) -> Result, Self::StorageError> { 150 | if !Path::exists(&self.current_path) { 151 | Ok(vec![]) 152 | } else { 153 | let file = File::open(&self.current_path)?; 154 | let ongoing_activities: OngoingActivities = serde_json::from_reader(file)?; 155 | Ok(ongoing_activities 156 | .ongoing 157 | .iter() 158 | .cloned() 159 | .sorted() 160 | .enumerate() 161 | .collect()) 162 | } 163 | } 164 | 165 | fn get_ongoing_activity( 166 | &self, 167 | id: ActivityId, 168 | ) -> Result, Self::StorageError> { 169 | let ongoing_activities = self.get_ongoing_activities()?; 170 | let ongoing = ongoing_activities 171 | .iter() 172 | .find(|(a_id, _a)| *a_id == id) 173 | .map(|(_a_id, a)| a); 174 | Ok(ongoing.cloned()) 175 | } 176 | 177 | fn add_ongoing_activity( 178 | &mut self, 179 | activity: OngoingActivity, 180 | ) -> Result<(), Self::StorageError> { 181 | let ongoing_activities = self.get_ongoing_activities()?; 182 | let file = OpenOptions::new() 183 | .write(true) 184 | .create(true) 185 | .truncate(true) 186 | .open(&self.current_path)?; 187 | serde_json::to_writer( 188 | file, 189 | &OngoingActivities { 190 | ongoing: ongoing_activities 191 | .iter() 192 | .map(|(_a_id, a)| a) 193 | .sorted() 194 | .cloned() 195 | .chain(std::iter::once(activity)) 196 | .collect(), 197 | }, 198 | )?; 199 | Ok(()) 200 | } 201 | 202 | fn remove_ongoing_activity( 203 | &mut self, 204 | id: ActivityId, 205 | ) -> Result, Self::StorageError> { 206 | let ongoing_activities = self.get_ongoing_activities()?; 207 | let (removed, kept): (Vec, Vec) = 208 | ongoing_activities 209 | .iter() 210 | .cloned() 211 | .partition(|(a_id, _a)| *a_id == id); 212 | let file = OpenOptions::new() 213 | .write(true) 214 | .create(true) 215 | .truncate(true) 216 | .open(&self.current_path)?; 217 | let kept_without_id: Vec = 218 | kept.iter().cloned().sorted().map(|(_a_id, a)| a).collect(); 219 | serde_json::to_writer( 220 | file, 221 | &OngoingActivities { 222 | ongoing: kept_without_id, 223 | }, 224 | )?; 225 | Ok(removed.first().cloned().map(|(_a_id, a)| a)) 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # RTW 2 | //! 3 | //! Command-line interface (CLI) time tracker. 4 | //! 5 | //! CLI usage is stable, underlying API is **not stable**. 6 | //! 7 | //! This project is heavily inspired from [Timewarrior](https://github.com/GothenburgBitFactory/timewarrior). 8 | //! 9 | //! For a stable feature-rich CLI time tracker, please use Timewarrior: . 10 | //! 11 | //! ## Design 12 | //! 13 | //! * Activities are stored inside a `Storage`. 14 | //! * An `ActivityService` provides the logic above a storage. 15 | //! * `rtw_cli::run` translates CLI args to actions (`RTWAction`). 16 | //! * `rtw_cli::run_action` performs actions `RTWAction` by calling the service. 17 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate clap; 3 | 4 | use crate::chrono_clock::ChronoClock; 5 | use crate::cli_helper::get_app; 6 | use crate::json_storage::JsonStorage; 7 | use crate::rtw_cli::{dry_run_action, run, run_mutation}; 8 | use crate::rtw_config::{load_config, RtwConfig}; 9 | use crate::service::Service; 10 | use std::path::PathBuf; 11 | use std::str::FromStr; 12 | 13 | mod chrono_clock; 14 | mod cli_helper; 15 | mod ical_export; 16 | mod json_storage; 17 | mod rtw_cli; 18 | mod rtw_config; 19 | mod rtw_core; 20 | mod service; 21 | mod status; 22 | mod time_tools; 23 | mod timeline; 24 | 25 | fn main() -> anyhow::Result<()> { 26 | let clock = ChronoClock {}; 27 | let app = get_app(); 28 | let matches = app.get_matches(); 29 | let config = load_config()?; 30 | let config = if matches.is_present("default") { 31 | RtwConfig::default() 32 | } else { 33 | config 34 | }; 35 | let config = if matches.is_present("overlap") { 36 | config.deny_overlapping(false) 37 | } else { 38 | config 39 | }; 40 | let config = if matches.is_present("no_overlap") { 41 | config.deny_overlapping(true) 42 | } else { 43 | config 44 | }; 45 | let storage_dir = match matches.value_of("directory") { 46 | None => config.storage_dir_path.clone(), 47 | Some(dir_str) => PathBuf::from_str(dir_str).expect("invalid directory"), 48 | }; 49 | let current_activity_path = storage_dir.join(".rtw.json"); 50 | let finished_activity_path = storage_dir.join(".rtwh.json"); 51 | let mut service = Service::new(JsonStorage::new( 52 | current_activity_path, 53 | finished_activity_path, 54 | )); 55 | 56 | #[cfg(windows)] 57 | { 58 | ansi_term::enable_ansi_support().unwrap_or(()); 59 | } 60 | let action = run(&matches, &clock)?; 61 | let mutation = dry_run_action(action, &service, &clock, &config)?; 62 | if matches.is_present("dry-run") { 63 | println!("(dry-run) nothing done"); 64 | Ok(()) 65 | } else { 66 | run_mutation(mutation, &mut service, &config) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/rtw_cli.rs: -------------------------------------------------------------------------------- 1 | //! Translate CLI args to calls to activity Service. 2 | use crate::cli_helper; 3 | use crate::ical_export::export_activities_to_ical; 4 | use crate::rtw_cli::OptionalOrAmbiguousOrNotFound::Optional; 5 | use crate::rtw_config::RtwConfig; 6 | use crate::rtw_core::activity::{Activity, OngoingActivity}; 7 | use crate::rtw_core::clock::Clock; 8 | use crate::rtw_core::datetimew::DateTimeW; 9 | use crate::rtw_core::durationw::DurationW; 10 | use crate::rtw_core::service::ActivityService; 11 | use crate::rtw_core::storage::Storage; 12 | use crate::rtw_core::ActivityId; 13 | use crate::rtw_core::{Description, Tags}; 14 | use crate::service::Service; 15 | use crate::status::{format_status, FormatString}; 16 | use crate::timeline::render_days; 17 | use clap::ArgMatches; 18 | use itertools::Itertools; 19 | 20 | type ActivityWithId = (ActivityId, Activity); 21 | 22 | /// Describe the action to be made 23 | /// 24 | /// see `run` 25 | pub enum RtwAction { 26 | Cancel(Option), 27 | Start(DateTimeW, Tags, Option), 28 | Track((DateTimeW, DateTimeW), Tags, Option), 29 | Stop(DateTimeW, Option), 30 | Summary((DateTimeW, DateTimeW), bool, bool, bool), 31 | DumpICal((DateTimeW, DateTimeW)), 32 | Continue(Option), 33 | Delete(ActivityId), 34 | DisplayCurrent, 35 | Timeline((DateTimeW, DateTimeW)), 36 | Completion(clap::Shell), 37 | Status(Option), 38 | } 39 | 40 | pub enum RtwMutation { 41 | Start(OngoingActivity), 42 | Track(Activity), 43 | Stop(DateTimeW, ActivityId), 44 | Delete(ActivityId), 45 | Cancel(ActivityId), 46 | Pure, 47 | } 48 | 49 | enum OptionalOrAmbiguousOrNotFound { 50 | Optional(Option<(ActivityId, OngoingActivity)>), 51 | Ambiguous, 52 | NotFound(ActivityId), 53 | } 54 | 55 | fn merge_same_tags(activities: &[ActivityWithId]) -> Vec<(ActivityId, Activity, DurationW, usize)> { 56 | let uniques: Vec = activities 57 | .iter() 58 | .cloned() 59 | .unique_by(|(_i, activity)| activity.get_title()) 60 | .collect(); 61 | uniques 62 | .iter() 63 | .cloned() 64 | .map(|(i, activity)| { 65 | let same_tag = activities 66 | .iter() 67 | .filter(|(_i, other)| activity.get_title() == other.get_title()); 68 | let durations: Vec = same_tag.map(|(_i, a)| a.get_duration()).collect(); 69 | let segments = durations.len(); 70 | let duration = durations.into_iter().sum(); 71 | (i, activity, duration, segments) 72 | }) 73 | .collect() 74 | } 75 | 76 | fn get_ongoing_activity( 77 | id_maybe: Option, 78 | service: &Service, 79 | ) -> anyhow::Result { 80 | match id_maybe { 81 | None => match service.get_ongoing_activities()?.as_slice() { 82 | [] => Ok(OptionalOrAmbiguousOrNotFound::Optional(None)), 83 | [(cancelled_id, cancelled)] => Ok(OptionalOrAmbiguousOrNotFound::Optional(Some(( 84 | *cancelled_id, 85 | cancelled.clone(), 86 | )))), 87 | _ => Ok(OptionalOrAmbiguousOrNotFound::Ambiguous), 88 | }, 89 | Some(cancelled_id) => match service.get_ongoing_activity(cancelled_id)? { 90 | None => Ok(OptionalOrAmbiguousOrNotFound::NotFound(cancelled_id)), 91 | Some(cancelled) => Ok(OptionalOrAmbiguousOrNotFound::Optional(Some(( 92 | cancelled_id, 93 | cancelled, 94 | )))), 95 | }, 96 | } 97 | } 98 | 99 | /// Translate CLI args to actions (side-effect free) 100 | /// 101 | /// It may fetch data from underlying activity storage but it should not write anything. 102 | pub fn run(matches: &ArgMatches, clock: &Cl) -> anyhow::Result 103 | where 104 | Cl: Clock, 105 | { 106 | match matches.subcommand() { 107 | ("start", Some(sub_m)) => { 108 | let (start_time, tags, description) = cli_helper::parse_start_args(sub_m, clock)?; 109 | let abs_start_time = clock.date_time(start_time); 110 | Ok(RtwAction::Start(abs_start_time, tags, description)) 111 | } 112 | ("stop", Some(sub_m)) => { 113 | let (stop_time, stopped_id_maybe) = cli_helper::parse_stop_args(sub_m, clock)?; 114 | let abs_stop_time = clock.date_time(stop_time); 115 | Ok(RtwAction::Stop(abs_stop_time, stopped_id_maybe)) 116 | } 117 | ("summary", Some(sub_m)) => { 118 | let ((range_start, range_end), display_id, display_description, report) = 119 | cli_helper::parse_summary_args(sub_m, clock)?; 120 | Ok(RtwAction::Summary( 121 | (range_start, range_end), 122 | display_id, 123 | display_description, 124 | report, 125 | )) 126 | } 127 | ("timeline", Some(sub_m)) => { 128 | let ((range_start, range_end), _display_id) = 129 | cli_helper::parse_timeline_args(sub_m, clock)?; 130 | Ok(RtwAction::Timeline((range_start, range_end))) 131 | } 132 | ("continue", Some(sub_m)) => { 133 | let continue_id_maybe = cli_helper::parse_continue_args(sub_m)?; 134 | Ok(RtwAction::Continue(continue_id_maybe)) 135 | } 136 | ("delete", Some(sub_m)) => { 137 | let id = cli_helper::parse_delete_args(sub_m)?; 138 | Ok(RtwAction::Delete(id)) 139 | } 140 | ("track", Some(sub_m)) => { 141 | let (start_time, stop_time, tags, description) = 142 | cli_helper::parse_track_args(sub_m, clock)?; 143 | let start_time = clock.date_time(start_time); 144 | let stop_time = clock.date_time(stop_time); 145 | Ok(RtwAction::Track((start_time, stop_time), tags, description)) 146 | } 147 | ("day", Some(_sub_m)) => { 148 | let (range_start, range_end) = clock.today_range(); 149 | Ok(RtwAction::Timeline((range_start, range_end))) 150 | } 151 | ("week", Some(_sub_m)) => { 152 | let (range_start, range_end) = clock.this_week_range(); 153 | Ok(RtwAction::Timeline((range_start, range_end))) 154 | } 155 | ("cancel", Some(sub_m)) => { 156 | let cancelled_id_maybe = cli_helper::parse_cancel_args(sub_m)?; 157 | Ok(RtwAction::Cancel(cancelled_id_maybe)) 158 | } 159 | ("dump", Some(sub_m)) => { 160 | let ((range_start, range_end), _display_id, _description, _report) = 161 | cli_helper::parse_summary_args(sub_m, clock)?; 162 | Ok(RtwAction::DumpICal((range_start, range_end))) 163 | } 164 | ("completion", Some(sub_m)) => { 165 | let shell = cli_helper::parse_completion_args(sub_m)?; 166 | Ok(RtwAction::Completion(shell)) 167 | } 168 | ("status", Some(sub_m)) => { 169 | let format = cli_helper::parse_status_args(sub_m); 170 | Ok(RtwAction::Status(format)) 171 | } 172 | // default case: display current activity 173 | _ => Ok(RtwAction::DisplayCurrent), 174 | } 175 | } 176 | 177 | /// Dry run (side effect-free) 178 | pub fn dry_run_action( 179 | action: RtwAction, 180 | service: &Service, 181 | clock: &Cl, 182 | config: &RtwConfig, 183 | ) -> anyhow::Result 184 | where 185 | S: Storage, 186 | Cl: Clock, 187 | { 188 | match action { 189 | RtwAction::Start(start_time, tags, description) => { 190 | let started = OngoingActivity::new(start_time, tags, description); 191 | println!("Tracking {}", started.get_title()); 192 | println!("Started {}", started.get_start_time()); 193 | Ok(RtwMutation::Start(started)) 194 | } 195 | RtwAction::Track((start_time, stop_time), tags, description) => { 196 | let tracked = 197 | OngoingActivity::new(start_time, tags, description).into_activity(stop_time)?; 198 | println!("Recorded {}", tracked.get_title()); 199 | println!("Started {:>20}", tracked.get_start_time()); 200 | println!("Ended {:>20}", tracked.get_stop_time()); 201 | println!("Total {:>20}", tracked.get_duration()); 202 | Ok(RtwMutation::Track(tracked)) 203 | } 204 | RtwAction::Stop(stop_time, activity_id) => { 205 | match get_ongoing_activity(activity_id, service)? { 206 | Optional(None) => { 207 | println!("There is no active time tracking."); 208 | Ok(RtwMutation::Pure) 209 | } 210 | Optional(Some((stopped_id, stopped))) => { 211 | println!("Recorded {}", stopped.get_title()); 212 | println!("Started {:>20}", stopped.get_start_time()); 213 | println!("Ended {:>20}", stop_time); 214 | println!("Total {:>20}", stop_time - stopped.get_start_time()); 215 | Ok(RtwMutation::Stop(stop_time, stopped_id)) 216 | } 217 | OptionalOrAmbiguousOrNotFound::Ambiguous => { 218 | println!("Multiple ongoing activities, please provide an id."); 219 | Ok(RtwMutation::Pure) 220 | } 221 | OptionalOrAmbiguousOrNotFound::NotFound(stopped_id) => { 222 | println!("No ongoing activity with id {}.", stopped_id); 223 | Ok(RtwMutation::Pure) 224 | } 225 | } 226 | } 227 | RtwAction::Summary((range_start, range_end), display_id, display_description, report) => { 228 | let activities = service.get_finished_activities()?; 229 | let activities: Vec<(ActivityId, Activity)> = activities 230 | .iter() 231 | .filter(|(_i, a)| { 232 | range_start <= a.get_start_time() && a.get_start_time() <= range_end 233 | }) 234 | .cloned() 235 | .collect(); 236 | let longest_title = activities 237 | .iter() 238 | .map(|(_id, a)| a.get_title().len()) 239 | .max() 240 | .unwrap_or_default(); 241 | if activities.is_empty() { 242 | println!("No filtered data found."); 243 | } else if report { 244 | let activities_report = merge_same_tags(activities.as_slice()); 245 | for (_id, finished, duration, segments) in activities_report { 246 | let singular_or_plural = if segments <= 1 { 247 | String::from("segment") 248 | } else { 249 | // segments > 1 250 | String::from("segments") 251 | }; 252 | let output = format!( 253 | "{:width$} {} ({} {})", 254 | finished.get_title(), 255 | duration, 256 | segments, 257 | singular_or_plural, 258 | width = longest_title 259 | ); 260 | println!("{}", output) 261 | } 262 | } else { 263 | for (id, finished) in activities { 264 | let output = format!( 265 | "{:width$} {} {} {}", 266 | finished.get_title(), 267 | finished.get_start_time(), 268 | finished.get_stop_time(), 269 | finished.get_duration(), 270 | width = longest_title 271 | ); 272 | let output = if display_id { 273 | format!("{:>1} {}", id, output) 274 | } else { 275 | output 276 | }; 277 | let output = match (display_description, finished.get_description()) { 278 | (false, _) => output, 279 | (true, None) => output, 280 | (true, Some(description)) => format!("{}\n{}", output, description), 281 | }; 282 | println!("{}", output) 283 | } 284 | } 285 | Ok(RtwMutation::Pure) 286 | } 287 | RtwAction::Continue(activity_id) => { 288 | let activities = service.get_finished_activities()?; 289 | let activity_id = activity_id.unwrap_or(0); // id 0 == last finished activity 290 | let continued_maybe = activities.iter().find(|(id, _a)| *id == activity_id); 291 | match continued_maybe { 292 | None => { 293 | println!("No activity to continue from."); 294 | Ok(RtwMutation::Pure) 295 | } 296 | Some((_id, finished)) => { 297 | println!("Tracking {}", finished.get_title()); 298 | let new_current = OngoingActivity::new( 299 | clock.get_time(), 300 | finished.get_tags(), 301 | finished.get_description(), 302 | ); 303 | Ok(RtwMutation::Start(new_current)) 304 | } 305 | } 306 | } 307 | RtwAction::Delete(activity_id) => { 308 | let deleted = service.filter_activities(|(i, _)| *i == activity_id)?; 309 | let deleted_maybe = deleted.first(); 310 | match deleted_maybe { 311 | None => { 312 | println!("No activity found for id {}.", activity_id); 313 | Ok(RtwMutation::Pure) 314 | } 315 | Some((deleted_id, deleted)) => { 316 | println!("Deleted {}", deleted.get_title()); 317 | println!("Started {:>20}", deleted.get_start_time()); 318 | println!("Ended {:>20}", deleted.get_stop_time()); 319 | println!("Total {:>20}", deleted.get_duration()); 320 | Ok(RtwMutation::Delete(*deleted_id)) 321 | } 322 | } 323 | } 324 | RtwAction::DisplayCurrent => { 325 | let ongoing_activities = service.get_ongoing_activities()?; 326 | if ongoing_activities.is_empty() { 327 | println!("There is no active time tracking."); 328 | } else { 329 | for (id, ongoing_activity) in ongoing_activities { 330 | println!("Tracking {}", ongoing_activity.get_title()); 331 | println!( 332 | "Total {}", 333 | clock.get_time() - ongoing_activity.get_start_time() 334 | ); 335 | println!("Id {}", id); 336 | } 337 | } 338 | Ok(RtwMutation::Pure) 339 | } 340 | RtwAction::Timeline((range_start, range_end)) => { 341 | let activities = service.get_finished_activities()?; 342 | let activities: Vec = activities 343 | .iter() 344 | .filter(|(_i, a)| { 345 | range_start <= a.get_start_time() && a.get_start_time() <= range_end 346 | }) 347 | .cloned() 348 | .collect(); 349 | let now = clock.get_time(); 350 | let ongoing_activities = service.get_ongoing_activities()?; 351 | let ongoing_activities: Vec = ongoing_activities 352 | .iter() 353 | .filter(|(_i, a)| { 354 | range_start <= a.get_start_time() && a.get_start_time() <= range_end 355 | }) 356 | .filter_map(|(i, a)| match a.clone().into_activity(now) { 357 | Ok(a) => Some((*i, a)), 358 | _ => None, 359 | }) 360 | .collect(); 361 | let timeline_activities: Vec = activities 362 | .iter() 363 | .cloned() 364 | .chain(ongoing_activities.iter().cloned()) 365 | .collect(); 366 | let rendered = render_days(timeline_activities.as_slice(), &config.timeline_colors)?; 367 | for line in rendered { 368 | println!("{}", line); 369 | } 370 | Ok(RtwMutation::Pure) 371 | } 372 | RtwAction::Cancel(id_maybe) => match get_ongoing_activity(id_maybe, service)? { 373 | Optional(None) => { 374 | println!("Nothing to cancel: there is no active time tracking."); 375 | Ok(RtwMutation::Pure) 376 | } 377 | Optional(Some((cancelled_id, cancelled))) => { 378 | println!("Cancelled {}", cancelled.get_title()); 379 | println!("Started {:>20}", cancelled.get_start_time()); 380 | println!( 381 | "Total {:>20}", 382 | clock.get_time() - cancelled.get_start_time() 383 | ); 384 | Ok(RtwMutation::Cancel(cancelled_id)) 385 | } 386 | OptionalOrAmbiguousOrNotFound::Ambiguous => { 387 | println!("Multiple ongoing activities, please provide an id."); 388 | Ok(RtwMutation::Pure) 389 | } 390 | OptionalOrAmbiguousOrNotFound::NotFound(cancelled_id) => { 391 | println!("No ongoing activity with id {}.", cancelled_id); 392 | Ok(RtwMutation::Pure) 393 | } 394 | }, 395 | RtwAction::DumpICal((range_start, range_end)) => { 396 | let activities = service.get_finished_activities()?; 397 | let activities: Vec = activities 398 | .iter() 399 | .map(|(_i, a)| a) 400 | .filter(|a| range_start <= a.get_start_time() && a.get_start_time() <= range_end) 401 | .cloned() 402 | .collect(); 403 | let calendar = export_activities_to_ical(activities.as_slice()); 404 | println!("{}", calendar); 405 | Ok(RtwMutation::Pure) 406 | } 407 | RtwAction::Completion(shell) => { 408 | let mut app = cli_helper::get_app(); 409 | app.gen_completions_to(crate_name!(), shell, &mut std::io::stdout()); 410 | Ok(RtwMutation::Pure) 411 | } 412 | RtwAction::Status(format_maybe) => { 413 | let status_maybe = format_status(format_maybe, service, clock)?; 414 | if let Some(status) = status_maybe { 415 | println!("{}", status); 416 | } 417 | Ok(RtwMutation::Pure) 418 | } 419 | } 420 | } 421 | 422 | /// Side effect 423 | pub fn run_mutation( 424 | action: RtwMutation, 425 | service: &mut Service, 426 | config: &RtwConfig, 427 | ) -> anyhow::Result<()> 428 | where 429 | S: Storage, 430 | { 431 | match action { 432 | RtwMutation::Start(activity) => { 433 | let _started = service.start_activity(activity, config.deny_overlapping)?; 434 | Ok(()) 435 | } 436 | RtwMutation::Track(activity) => { 437 | let _tracked = service.track_activity(activity, config.deny_overlapping)?; 438 | Ok(()) 439 | } 440 | RtwMutation::Stop(stop_time, activity_id) => { 441 | let _stopped = 442 | service.stop_ongoing_activity(stop_time, activity_id, config.deny_overlapping)?; 443 | Ok(()) 444 | } 445 | RtwMutation::Delete(activity_id) => { 446 | let _deleted = service.delete_activity(activity_id)?; 447 | Ok(()) 448 | } 449 | RtwMutation::Cancel(activity_id) => { 450 | let _cancelled = service.cancel_ongoing_activity(activity_id)?; 451 | Ok(()) 452 | } 453 | RtwMutation::Pure => { 454 | // pure nothing to do 455 | Ok(()) 456 | } 457 | } 458 | } 459 | -------------------------------------------------------------------------------- /src/rtw_config.rs: -------------------------------------------------------------------------------- 1 | //! Config. 2 | extern crate config; 3 | 4 | use self::config::FileFormat; 5 | use serde::Deserialize; 6 | use serde::Serialize; 7 | use std::path::{Path, PathBuf}; 8 | 9 | const DEFAULT_CONFIG: &str = r#" 10 | { 11 | "timeline_colors": [[183,28,28], [26,35,126], [0,77,64], [38,50,56]], 12 | "deny_overlapping": true 13 | } 14 | "#; 15 | 16 | type Rgb = (u8, u8, u8); 17 | 18 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 19 | pub struct RtwConfig { 20 | pub storage_dir_path: PathBuf, 21 | pub timeline_colors: Vec, 22 | pub deny_overlapping: bool, 23 | } 24 | 25 | impl RtwConfig { 26 | pub fn default() -> Self { 27 | let home_dir = dirs_next::home_dir().expect("could not find home dir"); 28 | RtwConfig { 29 | storage_dir_path: home_dir, // stores finished activities 30 | timeline_colors: vec![(183, 28, 28), (26, 35, 126), (0, 77, 64), (38, 50, 56)], 31 | deny_overlapping: true, 32 | } 33 | } 34 | 35 | pub fn deny_overlapping(self, deny: bool) -> Self { 36 | RtwConfig { 37 | storage_dir_path: self.storage_dir_path, 38 | timeline_colors: self.timeline_colors, 39 | deny_overlapping: deny, 40 | } 41 | } 42 | } 43 | 44 | fn load_config_from_config_dir( 45 | config_dir: &Path, 46 | default_config: RtwConfig, 47 | ) -> anyhow::Result { 48 | let mut settings = config::Config::default(); 49 | let config_path = config_dir.join("rtw").join("rtw_config.json"); 50 | let config_path_fallback = config_dir.join("rtw_config.json"); 51 | settings 52 | .set_default( 53 | "storage_dir_path", 54 | default_config.storage_dir_path.to_str().unwrap(), 55 | )? 56 | .merge(config::File::from_str(DEFAULT_CONFIG, FileFormat::Json))? 57 | .merge(config::File::with_name(config_path.to_str().unwrap()).required(false))? 58 | .merge(config::File::with_name(config_path_fallback.to_str().unwrap()).required(false))?; 59 | let rtw_config: RtwConfig = settings.try_into()?; 60 | Ok(rtw_config) 61 | } 62 | 63 | pub fn load_config() -> anyhow::Result { 64 | match dirs_next::config_dir() { 65 | None => Ok(RtwConfig::default()), 66 | Some(config_dir) => load_config_from_config_dir(&config_dir, RtwConfig::default()), 67 | } 68 | } 69 | 70 | #[cfg(test)] 71 | mod tests { 72 | use crate::rtw_config::{load_config_from_config_dir, RtwConfig}; 73 | use std::fs; 74 | use std::fs::File; 75 | use std::io::Write; 76 | use std::path::PathBuf; 77 | use std::str::FromStr; 78 | use tempfile::tempdir; 79 | 80 | #[test] 81 | // make sure the config file in `example` folder is valid 82 | fn example_config_valid() { 83 | let example_config = PathBuf::from_str("example/rtw_config.json").unwrap(); 84 | let reader = File::open(example_config); 85 | let config: serde_json::Result = serde_json::from_reader(reader.unwrap()); 86 | assert!(config.is_ok()) 87 | } 88 | 89 | #[test] 90 | fn test_config_not_found_in_config_dir() { 91 | let test_config_dir = tempdir().expect("could not create temp directory"); 92 | let test_dir_path = test_config_dir.path().to_path_buf(); 93 | let config = load_config_from_config_dir(&test_dir_path, RtwConfig::default()); 94 | assert_eq!(config.unwrap(), RtwConfig::default()) 95 | } 96 | 97 | #[test] 98 | // .config/rtw_config.json 99 | fn test_config_found_in_config_dir() -> anyhow::Result<()> { 100 | let expected = PathBuf::from_str("/expected").unwrap(); 101 | let test_config_dir = tempdir().expect("could not create temp directory"); 102 | let mut tmp_config = File::create(test_config_dir.path().join("rtw_config.json"))?; 103 | writeln!(tmp_config, "{{\n\"storage_dir_path\": \"/expected\"\n}}")?; 104 | let config = load_config_from_config_dir( 105 | &test_config_dir.path().to_path_buf(), 106 | RtwConfig::default(), 107 | ); 108 | assert_eq!(config.unwrap().storage_dir_path, expected); 109 | Ok(()) 110 | } 111 | 112 | #[test] 113 | // .config/rtw/rtw_config.json 114 | fn test_config_found_in_sub_config_dir() -> anyhow::Result<()> { 115 | let expected = PathBuf::from_str("/expected").unwrap(); 116 | let test_config_dir = tempdir().expect("could not create temp directory"); 117 | let test_config_sub_dir = test_config_dir.path().join("rtw"); 118 | fs::create_dir(test_config_sub_dir.clone()).expect("could not create temp/rtw directory"); 119 | let mut tmp_config = File::create(test_config_sub_dir.join("rtw_config.json"))?; 120 | writeln!(tmp_config, "{{\n\"storage_dir_path\": \"/expected\"\n}}")?; 121 | let config = load_config_from_config_dir( 122 | &test_config_dir.path().to_path_buf(), 123 | RtwConfig::default(), 124 | ); 125 | assert_eq!(config.unwrap().storage_dir_path, expected); 126 | Ok(()) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/rtw_core/activity.rs: -------------------------------------------------------------------------------- 1 | //! Activity and OngoingActivity 2 | 3 | use crate::rtw_core::datetimew::DateTimeW; 4 | use crate::rtw_core::durationw::DurationW; 5 | use crate::rtw_core::{Description, Tags}; 6 | use anyhow::anyhow; 7 | use serde::{Deserialize, Serialize}; 8 | use std::cmp::Ordering; 9 | 10 | /// A finished activity (with a stop time) 11 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 12 | pub struct Activity { 13 | /// Activity start time 14 | start_time: DateTimeW, 15 | /// Activity `stop time` >= `start time` 16 | stop_time: DateTimeW, 17 | /// Activity tags 18 | tags: Tags, 19 | #[serde(default)] 20 | description: Option, 21 | } 22 | 23 | impl Activity { 24 | /// start time getter 25 | pub fn get_start_time(&self) -> DateTimeW { 26 | self.start_time 27 | } 28 | /// stop time getter 29 | pub fn get_stop_time(&self) -> DateTimeW { 30 | self.stop_time 31 | } 32 | /// Return activity duration 33 | pub fn get_duration(&self) -> DurationW { 34 | self.stop_time - self.start_time 35 | } 36 | /// Return activity title (its tags joined by a space) 37 | pub fn get_title(&self) -> String { 38 | self.tags.join(" ") 39 | } 40 | /// Return tags 41 | pub fn get_tags(&self) -> Tags { 42 | self.tags.clone() 43 | } 44 | 45 | /// Return Description 46 | pub fn get_description(&self) -> Option { 47 | self.description.clone() 48 | } 49 | } 50 | 51 | /// Activities are sorted by start time 52 | impl Ord for Activity { 53 | fn cmp(&self, other: &Self) -> Ordering { 54 | self.get_start_time().cmp(&other.get_start_time()) 55 | } 56 | } 57 | 58 | impl PartialOrd for Activity { 59 | fn partial_cmp(&self, other: &Self) -> Option { 60 | Some(self.cmp(other)) 61 | } 62 | } 63 | 64 | /// A started and unfinished activity (no stop time) 65 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 66 | pub struct OngoingActivity { 67 | /// start time 68 | pub start_time: DateTimeW, 69 | /// Activity tags 70 | pub tags: Tags, 71 | #[serde(default)] 72 | pub description: Option, 73 | } 74 | 75 | /// OngoingActivities are sorted by start time 76 | impl Ord for OngoingActivity { 77 | fn cmp(&self, other: &Self) -> Ordering { 78 | self.get_start_time().cmp(&other.get_start_time()) 79 | } 80 | } 81 | 82 | impl PartialOrd for OngoingActivity { 83 | fn partial_cmp(&self, other: &Self) -> Option { 84 | Some(self.cmp(other)) 85 | } 86 | } 87 | 88 | impl OngoingActivity { 89 | /// Constructor 90 | pub fn new(start_time: DateTimeW, tags: Tags, description: Option) -> Self { 91 | OngoingActivity { 92 | start_time, 93 | tags, 94 | description, 95 | } 96 | } 97 | /// Start time getter 98 | pub fn get_start_time(&self) -> DateTimeW { 99 | self.start_time 100 | } 101 | /// Return title (activity tags joined by a space) 102 | pub fn get_title(&self) -> String { 103 | self.tags.join(" ") 104 | } 105 | /// Convert active activity to finished activity 106 | /// `stop_time` should be >= `start_time` otherwise error 107 | pub fn into_activity(self, stop_time: DateTimeW) -> anyhow::Result { 108 | if self.start_time <= stop_time { 109 | Ok(Activity { 110 | start_time: self.start_time, 111 | stop_time, 112 | tags: self.tags, 113 | description: self.description, 114 | }) 115 | } else { 116 | Err(anyhow!( 117 | "stop time ({}) < start_time ({})", 118 | stop_time, 119 | self.start_time 120 | )) 121 | } 122 | } 123 | } 124 | 125 | /// Check intersection between a finished activity and a date 126 | /// 127 | /// Returns Some(activity) if it intersects else None. 128 | pub fn intersect(finished: &Activity, datetimew: &DateTimeW) -> Option { 129 | if (&finished.start_time < datetimew) && (datetimew < &finished.stop_time) { 130 | Some(finished.clone()) 131 | } else { 132 | None 133 | } 134 | } 135 | 136 | /// Check overlap between 2 finished activities 137 | /// 138 | /// Returns Some(first) if first activity overlaps with the second else None. 139 | pub fn overlap(finished: &Activity, other: &Activity) -> Option { 140 | if finished < other { 141 | intersect(finished, &other.start_time) 142 | } else { 143 | intersect(other, &finished.start_time).map(|_| finished.clone()) 144 | } 145 | } 146 | 147 | #[cfg(test)] 148 | mod tests { 149 | use crate::rtw_core::activity::{intersect, overlap, Activity}; 150 | use chrono::{Local, TimeZone}; 151 | 152 | #[test] 153 | fn test_intersect() { 154 | let finished = Activity { 155 | start_time: Local 156 | .datetime_from_str("2020-12-25T09:00:00", "%Y-%m-%dT%H:%M:%S") 157 | .unwrap() 158 | .into(), 159 | stop_time: Local 160 | .datetime_from_str("2020-12-25T10:00:00", "%Y-%m-%dT%H:%M:%S") 161 | .unwrap() 162 | .into(), 163 | tags: vec![], 164 | description: None, 165 | }; 166 | let date = Local 167 | .datetime_from_str("2020-12-25T09:30:00", "%Y-%m-%dT%H:%M:%S") 168 | .unwrap() 169 | .into(); 170 | assert!(intersect(&finished, &date).is_some()); 171 | let date = Local 172 | .datetime_from_str("2020-12-25T10:30:00", "%Y-%m-%dT%H:%M:%S") 173 | .unwrap() 174 | .into(); 175 | assert!(intersect(&finished, &date).is_none()); 176 | } 177 | 178 | #[test] 179 | fn test_overlap() { 180 | let finished = Activity { 181 | start_time: Local 182 | .datetime_from_str("2020-12-25T09:00:00", "%Y-%m-%dT%H:%M:%S") 183 | .unwrap() 184 | .into(), 185 | stop_time: Local 186 | .datetime_from_str("2020-12-25T10:00:00", "%Y-%m-%dT%H:%M:%S") 187 | .unwrap() 188 | .into(), 189 | tags: vec![], 190 | description: None, 191 | }; 192 | let other = Activity { 193 | start_time: Local 194 | .datetime_from_str("2020-12-25T09:30:00", "%Y-%m-%dT%H:%M:%S") 195 | .unwrap() 196 | .into(), 197 | stop_time: Local 198 | .datetime_from_str("2020-12-25T11:00:00", "%Y-%m-%dT%H:%M:%S") 199 | .unwrap() 200 | .into(), 201 | tags: vec![], 202 | description: None, 203 | }; 204 | assert!(overlap(&finished, &other).is_some()); 205 | let other = Activity { 206 | start_time: Local 207 | .datetime_from_str("2020-12-25T08:30:00", "%Y-%m-%dT%H:%M:%S") 208 | .unwrap() 209 | .into(), 210 | stop_time: Local 211 | .datetime_from_str("2020-12-25T09:30:00", "%Y-%m-%dT%H:%M:%S") 212 | .unwrap() 213 | .into(), 214 | tags: vec![], 215 | description: None, 216 | }; 217 | assert!(overlap(&finished, &other).is_some()); 218 | let other = Activity { 219 | start_time: Local 220 | .datetime_from_str("2020-12-25T08:30:00", "%Y-%m-%dT%H:%M:%S") 221 | .unwrap() 222 | .into(), 223 | stop_time: Local 224 | .datetime_from_str("2020-12-25T10:30:00", "%Y-%m-%dT%H:%M:%S") 225 | .unwrap() 226 | .into(), 227 | tags: vec![], 228 | description: None, 229 | }; 230 | assert!(overlap(&finished, &other).is_some()); 231 | let other = Activity { 232 | start_time: Local 233 | .datetime_from_str("2020-12-25T09:30:00", "%Y-%m-%dT%H:%M:%S") 234 | .unwrap() 235 | .into(), 236 | stop_time: Local 237 | .datetime_from_str("2020-12-25T09:45:00", "%Y-%m-%dT%H:%M:%S") 238 | .unwrap() 239 | .into(), 240 | tags: vec![], 241 | description: None, 242 | }; 243 | assert!(overlap(&finished, &other).is_some()); 244 | let other = Activity { 245 | start_time: Local 246 | .datetime_from_str("2020-12-25T10:30:00", "%Y-%m-%dT%H:%M:%S") 247 | .unwrap() 248 | .into(), 249 | stop_time: Local 250 | .datetime_from_str("2020-12-25T11:45:00", "%Y-%m-%dT%H:%M:%S") 251 | .unwrap() 252 | .into(), 253 | tags: vec![], 254 | description: None, 255 | }; 256 | assert!(overlap(&finished, &other).is_none()); 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /src/rtw_core/clock.rs: -------------------------------------------------------------------------------- 1 | //! Clock abstraction 2 | 3 | use crate::rtw_core::datetimew::DateTimeW; 4 | 5 | /// Time (absolute or relative) 6 | #[derive(Debug, Clone, Copy, PartialEq)] 7 | pub enum Time { 8 | /// Now, can be converted to `DateTimeW` using `Clock.date_time` 9 | Now, 10 | DateTime(DateTimeW), 11 | } 12 | 13 | /// Clock Abstraction 14 | pub trait Clock { 15 | /// Get current local time 16 | fn get_time(&self) -> DateTimeW; 17 | /// Convert a `Time` to absolute time 18 | /// 19 | /// `clock.date_time(Time::Now)` equals approximately clock.get_time(); 20 | fn date_time(&self, time: Time) -> DateTimeW; 21 | 22 | /// Get time range for today 23 | /// 24 | /// today: 00:00:00 - 23:59:59 25 | fn today_range(&self) -> (DateTimeW, DateTimeW); 26 | 27 | /// Get time range for yesterday 28 | /// 29 | /// yesterday: 00:00:00 - 23:59:59 30 | fn yesterday_range(&self) -> (DateTimeW, DateTimeW); 31 | 32 | /// Get time range for last week 33 | /// 34 | /// last week (ISO 8601, week start on monday) 35 | /// 36 | /// last week: monday: 00:00:00 - sunday: 23:59:59 37 | fn last_week_range(&self) -> (DateTimeW, DateTimeW); 38 | 39 | /// Get time range for this week 40 | /// 41 | /// this week (ISO 8601, week start on monday) 42 | /// 43 | /// this week: monday: 00:00:00 - sunday: 23:59:59 44 | fn this_week_range(&self) -> (DateTimeW, DateTimeW); 45 | } 46 | -------------------------------------------------------------------------------- /src/rtw_core/datetimew.rs: -------------------------------------------------------------------------------- 1 | //! Newtype on `chrono::Date` 2 | use crate::rtw_core::durationw::DurationW; 3 | use crate::rtw_core::DATETIME_FMT; 4 | use chrono::{DateTime, Local}; 5 | use std::fmt::{Error, Formatter}; 6 | 7 | use serde::{Deserialize, Serialize}; 8 | 9 | /// Newtype on `chrono::Date` 10 | /// 11 | /// Date is given in local time for convenience 12 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] 13 | pub struct DateTimeW(DateTime); 14 | 15 | impl From> for DateTimeW { 16 | fn from(dt: DateTime) -> Self { 17 | DateTimeW(dt) 18 | } 19 | } 20 | impl From for DateTime { 21 | fn from(dt: DateTimeW) -> Self { 22 | dt.0 23 | } 24 | } 25 | 26 | impl std::ops::Sub for DateTimeW { 27 | type Output = DurationW; 28 | 29 | fn sub(self, rhs: Self) -> Self::Output { 30 | DurationW::new(self.0 - rhs.0) 31 | } 32 | } 33 | 34 | impl std::fmt::Display for DateTimeW { 35 | fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { 36 | write!(f, "{}", self.0.format(DATETIME_FMT)) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/rtw_core/durationw.rs: -------------------------------------------------------------------------------- 1 | //! Newtype on `chrono::Duration` 2 | use chrono::Duration; 3 | use std::fmt; 4 | use std::fmt::{Error, Formatter}; 5 | use std::iter::Sum; 6 | use std::ops::Add; 7 | 8 | /// Newtype on `chrono::Duration` 9 | pub struct DurationW(chrono::Duration); 10 | 11 | impl fmt::Display for DurationW { 12 | fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { 13 | write!( 14 | f, 15 | "{:02}:{:02}:{:02}", 16 | self.0.num_seconds() / 3600, 17 | (self.0.num_seconds() / 60) % 60, 18 | (self.0.num_seconds() % 60) 19 | ) 20 | } 21 | } 22 | 23 | impl DurationW { 24 | pub fn new(d: Duration) -> Self { 25 | DurationW(d) 26 | } 27 | } 28 | 29 | impl Default for DurationW { 30 | fn default() -> Self { 31 | DurationW::new(Duration::seconds(0)) 32 | } 33 | } 34 | 35 | impl From for DurationW { 36 | fn from(d: Duration) -> Self { 37 | DurationW(d) 38 | } 39 | } 40 | 41 | impl From for Duration { 42 | fn from(d: DurationW) -> Self { 43 | d.0 44 | } 45 | } 46 | 47 | impl Add for DurationW { 48 | type Output = DurationW; 49 | 50 | fn add(self, rhs: DurationW) -> Self::Output { 51 | DurationW::new(self.0 + rhs.0) 52 | } 53 | } 54 | 55 | impl Sum for DurationW { 56 | fn sum>(iter: I) -> Self { 57 | iter.fold(DurationW::default(), Add::add) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/rtw_core/mod.rs: -------------------------------------------------------------------------------- 1 | //! Core traits and data structures. 2 | pub mod activity; 3 | pub mod clock; 4 | pub mod datetimew; 5 | pub mod durationw; 6 | pub mod service; 7 | pub mod storage; 8 | 9 | /// Absolute dates are parsed and displayed using this format 10 | /// 11 | /// e.g. 2019-12-25T18:43:00 12 | pub const DATETIME_FMT: &str = "%Y-%m-%dT%H:%M:%S"; 13 | 14 | /// `Tag` = `String` 15 | pub type Tag = String; 16 | /// `Tags` = `Vec` 17 | pub type Tags = Vec; 18 | /// `ActivityId` = `usize` 19 | pub type ActivityId = usize; 20 | /// `Description` = `String` 21 | pub type Description = String; 22 | -------------------------------------------------------------------------------- /src/rtw_core/service.rs: -------------------------------------------------------------------------------- 1 | //! A service for activities: abstracts activities queries and modifications. 2 | use crate::rtw_core::activity::{Activity, OngoingActivity}; 3 | use crate::rtw_core::datetimew::DateTimeW; 4 | use crate::rtw_core::ActivityId; 5 | 6 | /// A service for activities 7 | /// 8 | /// Abstracts activities queries and modifications 9 | pub trait ActivityService { 10 | /// Get ongoing activities if any 11 | /// 12 | /// May fail depending on backend implementation 13 | fn get_ongoing_activities(&self) -> anyhow::Result>; 14 | /// Get ongoing activity with id if any 15 | /// 16 | /// May fail depending on backend implementation 17 | fn get_ongoing_activity(&self, id: ActivityId) -> anyhow::Result>; 18 | /// Start a new activity 19 | /// 20 | /// May fail depending on backend implementation 21 | /// 22 | /// Returns new current activity and optionally the previously ongoing activity 23 | fn start_activity( 24 | &mut self, 25 | activity: OngoingActivity, 26 | deny_overlapping: bool, 27 | ) -> anyhow::Result<(OngoingActivity, Option)>; 28 | /// Stop current activity 29 | /// 30 | /// May fail depending on backend implementation 31 | /// 32 | /// Returns stopped activity if any 33 | fn stop_ongoing_activity( 34 | &mut self, 35 | time: DateTimeW, 36 | id: ActivityId, 37 | deny_overlapping: bool, 38 | ) -> anyhow::Result>; 39 | /// Cancel current activity 40 | /// 41 | /// May fail depending on backend implementation 42 | /// 43 | /// Returns cancelled activity if any 44 | fn cancel_ongoing_activity( 45 | &mut self, 46 | id: ActivityId, 47 | ) -> anyhow::Result>; 48 | /// Filter finished activities 49 | /// 50 | /// May fail depending on implementation 51 | /// 52 | /// Returns finished activities sorted by start date 53 | /// 54 | /// ActivityId: 0 <=> last finished activity 55 | fn filter_activities

(&self, p: P) -> anyhow::Result> 56 | where 57 | P: Fn(&(ActivityId, Activity)) -> bool; 58 | /// Get all finished activities 59 | /// 60 | /// May fail depending on implementation 61 | /// 62 | /// Returns finished activities sorted by start date 63 | /// 64 | /// ActivityId: 0 <=> last finished activity 65 | fn get_finished_activities(&self) -> anyhow::Result>; 66 | /// Delete activity with id 67 | /// 68 | /// May fail depending on implementation 69 | /// 70 | /// Returns deleted activity if successful 71 | fn delete_activity(&self, id: ActivityId) -> anyhow::Result>; 72 | /// Track a finished activity 73 | /// 74 | /// May fail depending on backend implementation 75 | /// 76 | /// Returns tracked activity if successful 77 | fn track_activity( 78 | &mut self, 79 | activity: Activity, 80 | deny_overlapping: bool, 81 | ) -> anyhow::Result; 82 | } 83 | -------------------------------------------------------------------------------- /src/rtw_core/storage.rs: -------------------------------------------------------------------------------- 1 | //! Storage: abstracts activities storage (file, memory...) 2 | use crate::rtw_core::activity::{Activity, OngoingActivity}; 3 | use crate::rtw_core::ActivityId; 4 | use std::error::Error; 5 | 6 | pub trait Storage { 7 | // see anyhow::Error type constraints 8 | type StorageError: Error + Sync + Send + 'static; 9 | 10 | /// Write finished activity 11 | /// 12 | /// May fail depending on backend implementation 13 | fn write_activity(&mut self, activity: Activity) -> Result<(), Self::StorageError>; 14 | /// Filter finished activities 15 | /// 16 | /// May fail depending on implementation 17 | /// 18 | /// Returns finished activities sorted by start date 19 | /// 20 | /// ActivityId: 0 <=> last finished activity 21 | fn filter_activities

(&self, p: P) -> Result, Self::StorageError> 22 | where 23 | P: Fn(&(ActivityId, Activity)) -> bool; 24 | /// Get all finished activities 25 | /// 26 | /// May fail depending on implementation 27 | /// 28 | /// Returns finished activities sorted by start date 29 | /// 30 | /// ActivityId: 0 <=> last finished activity 31 | fn get_finished_activities(&self) -> Result, Self::StorageError>; 32 | /// Delete activity with id 33 | /// 34 | /// May fail depending on implementation 35 | /// 36 | /// Returns deleted activity if successful 37 | fn delete_activity(&self, id: ActivityId) -> Result, Self::StorageError>; 38 | /// Retrieve ongoing activities if any 39 | /// 40 | /// May fail depending on backend implementation 41 | fn get_ongoing_activities( 42 | &self, 43 | ) -> Result, Self::StorageError>; 44 | /// Retrieve ongoing activity with id if any 45 | /// 46 | /// May fail depending on backend implementation 47 | fn get_ongoing_activity( 48 | &self, 49 | id: ActivityId, 50 | ) -> Result, Self::StorageError>; 51 | /// Add `activity` to ongoing activities 52 | /// 53 | /// May fail depending on backend implementation 54 | fn add_ongoing_activity(&mut self, activity: OngoingActivity) 55 | -> Result<(), Self::StorageError>; 56 | /// Remove ongoing activity 57 | /// 58 | /// May fail depending on backend implementation 59 | fn remove_ongoing_activity( 60 | &mut self, 61 | id: ActivityId, 62 | ) -> Result, Self::StorageError>; 63 | } 64 | -------------------------------------------------------------------------------- /src/service.rs: -------------------------------------------------------------------------------- 1 | //! Logic above an activity storage 2 | use crate::rtw_core::activity::{intersect, overlap, Activity, OngoingActivity}; 3 | use crate::rtw_core::datetimew::DateTimeW; 4 | use crate::rtw_core::service::ActivityService; 5 | use crate::rtw_core::storage::Storage; 6 | use crate::rtw_core::ActivityId; 7 | use anyhow::anyhow; 8 | 9 | pub struct Service 10 | where 11 | S: Storage, 12 | { 13 | storage: S, 14 | } 15 | 16 | impl Service 17 | where 18 | S: Storage, 19 | { 20 | pub fn new(storage: S) -> Self { 21 | Service { storage } 22 | } 23 | } 24 | 25 | impl ActivityService for Service 26 | where 27 | S: Storage, 28 | { 29 | fn get_ongoing_activities(&self) -> anyhow::Result> { 30 | self.storage.get_ongoing_activities().map_err(|e| e.into()) 31 | } 32 | 33 | fn get_ongoing_activity(&self, id: ActivityId) -> anyhow::Result> { 34 | self.storage.get_ongoing_activity(id).map_err(|e| e.into()) 35 | } 36 | 37 | fn start_activity( 38 | &mut self, 39 | activity: OngoingActivity, 40 | deny_overlapping: bool, 41 | ) -> anyhow::Result<(OngoingActivity, Option)> { 42 | let finished = self.storage.get_finished_activities()?; 43 | if deny_overlapping { 44 | let intersections = time_intersections(finished.as_slice(), &activity.start_time); 45 | if intersections.is_empty() { 46 | let ongoing_activities = self.storage.get_ongoing_activities()?; 47 | match ongoing_activities.as_slice() { 48 | [] => { 49 | self.storage.add_ongoing_activity(activity.clone())?; 50 | Ok((activity, None)) 51 | } 52 | [(ongoing_id, _ongoing)] => { 53 | let stopped_maybe = 54 | self.stop_ongoing_activity(activity.start_time, *ongoing_id, true)?; 55 | self.storage.add_ongoing_activity(activity.clone())?; 56 | Ok((activity, stopped_maybe)) 57 | } 58 | _ => Err(anyhow!( 59 | "multiple ongoing activities but overlapping is disabled\n\ 60 | Tip: you can enable overlapping using `rtw --overlap (start|stop|track|...)`" 61 | )), 62 | } 63 | } else { 64 | Err(anyhow!( 65 | "{:?} would overlap {:?}\n\ 66 | Tip: you can enable overlapping using `rtw --overlap (start|stop|track|...)`", 67 | activity, 68 | intersections 69 | )) 70 | } 71 | } else { 72 | self.storage.add_ongoing_activity(activity.clone())?; 73 | Ok((activity, None)) 74 | } 75 | } 76 | 77 | fn stop_ongoing_activity( 78 | &mut self, 79 | time: DateTimeW, 80 | id: ActivityId, 81 | deny_overlapping: bool, 82 | ) -> anyhow::Result> { 83 | let stopped_maybe = self.storage.get_ongoing_activity(id)?; 84 | match stopped_maybe { 85 | None => Ok(None), 86 | Some(ongoing_activity) => { 87 | let stopped = ongoing_activity.clone().into_activity(time)?; 88 | let finished = self.storage.get_finished_activities()?; 89 | let intersections = activity_intersections(finished.as_slice(), &stopped); 90 | if !deny_overlapping || intersections.is_empty() { 91 | self.storage.write_activity(stopped)?; 92 | self.storage.remove_ongoing_activity(id)?; 93 | Ok(Some(ongoing_activity.into_activity(time)?)) 94 | } else { 95 | Err(anyhow!( 96 | "{:?} would overlap {:?}\n\ 97 | Tip: you can enable overlapping using `rtw --overlap (start|stop|track|...)`", 98 | stopped, 99 | intersections 100 | )) 101 | } 102 | } 103 | } 104 | } 105 | 106 | fn cancel_ongoing_activity( 107 | &mut self, 108 | id: ActivityId, 109 | ) -> anyhow::Result> { 110 | self.storage 111 | .remove_ongoing_activity(id) 112 | .map_err(|e| e.into()) 113 | } 114 | 115 | fn filter_activities

(&self, p: P) -> anyhow::Result> 116 | where 117 | P: Fn(&(ActivityId, Activity)) -> bool, 118 | { 119 | self.storage.filter_activities(p).map_err(|e| e.into()) 120 | } 121 | 122 | fn get_finished_activities(&self) -> anyhow::Result> { 123 | self.storage.get_finished_activities().map_err(|e| e.into()) 124 | } 125 | 126 | fn delete_activity(&self, id: ActivityId) -> anyhow::Result> { 127 | self.storage.delete_activity(id).map_err(|e| e.into()) 128 | } 129 | 130 | fn track_activity( 131 | &mut self, 132 | activity: Activity, 133 | deny_overlapping: bool, 134 | ) -> anyhow::Result { 135 | let finished = self.storage.get_finished_activities()?; 136 | let intersections = activity_intersections(finished.as_slice(), &activity); 137 | if !deny_overlapping || intersections.is_empty() { 138 | self.storage.write_activity(activity.clone())?; 139 | Ok(activity) 140 | } else { 141 | Err(anyhow!( 142 | "{:?} would overlap {:?}\n\ 143 | Tip: you can enable overlapping using `rtw --overlap (start|stop|track|...)`", 144 | activity, 145 | intersections 146 | )) 147 | } 148 | } 149 | } 150 | 151 | fn activity_intersections( 152 | activities: &[(ActivityId, Activity)], 153 | activity: &Activity, 154 | ) -> Vec { 155 | activities 156 | .iter() 157 | .filter_map(|(_, a)| overlap(a, activity)) 158 | .collect() 159 | } 160 | 161 | fn time_intersections( 162 | activities: &[(ActivityId, Activity)], 163 | start_time: &DateTimeW, 164 | ) -> Vec { 165 | activities 166 | .iter() 167 | .filter_map(|(_, a)| intersect(a, start_time)) 168 | .collect() 169 | } 170 | 171 | #[cfg(test)] 172 | mod tests { 173 | use crate::chrono_clock::ChronoClock; 174 | use crate::json_storage::JsonStorage; 175 | use crate::rtw_core::activity::OngoingActivity; 176 | use crate::rtw_core::clock::Clock; 177 | use crate::rtw_core::datetimew::DateTimeW; 178 | use crate::rtw_core::service::ActivityService; 179 | use crate::service::Service; 180 | use chrono::{Local, TimeZone}; 181 | use tempfile::{tempdir, TempDir}; 182 | 183 | fn build_json_service(test_dir: &TempDir) -> Service { 184 | let finished_path = test_dir.path().join(".rtwh.json"); 185 | let current_path = test_dir.path().join(".rtwc.json"); 186 | Service::new(JsonStorage::new(current_path, finished_path)) 187 | } 188 | 189 | #[test] 190 | fn test_no_activity() { 191 | let clock = ChronoClock {}; 192 | let test_dir = tempdir().expect("error while creating tempdir"); 193 | let mut service = build_json_service(&test_dir); 194 | assert!(service 195 | .stop_ongoing_activity(clock.get_time(), 0, true) 196 | .is_ok()); 197 | assert!(service.get_ongoing_activities().unwrap().is_empty()); 198 | } 199 | 200 | #[test] 201 | fn test_start_activity() { 202 | let clock = ChronoClock {}; 203 | let test_dir = tempdir().expect("error while creating tempdir"); 204 | let mut service = build_json_service(&test_dir); 205 | assert!(service 206 | .stop_ongoing_activity(clock.get_time(), 0, true) 207 | .is_ok()); 208 | let start = service.start_activity( 209 | OngoingActivity { 210 | start_time: clock.get_time(), 211 | tags: vec![String::from("a")], 212 | description: None, 213 | }, 214 | true, 215 | ); 216 | start.unwrap(); 217 | let current = service.get_ongoing_activities(); 218 | assert!(current.is_ok()); 219 | assert!(!current.unwrap().is_empty()); 220 | } 221 | 222 | #[test] 223 | fn test_stop_activity_with_active() { 224 | let clock = ChronoClock {}; 225 | let test_dir = tempdir().expect("error while creating tempdir"); 226 | let mut service = build_json_service(&test_dir); 227 | let start = service.start_activity( 228 | OngoingActivity { 229 | start_time: clock.get_time(), 230 | tags: vec![String::from("a")], 231 | description: None, 232 | }, 233 | true, 234 | ); 235 | start.unwrap(); 236 | assert!(!service.get_ongoing_activities().unwrap().is_empty()); 237 | assert!(service 238 | .stop_ongoing_activity(clock.get_time(), 0, true) 239 | .is_ok()); 240 | assert!(service.get_ongoing_activities().unwrap().is_empty()); 241 | } 242 | 243 | #[test] 244 | fn test_start_stop_start() { 245 | let clock = ChronoClock {}; 246 | let test_dir = tempdir().expect("error while creating tempdir"); 247 | let mut service = build_json_service(&test_dir); 248 | let start_0 = service.start_activity( 249 | OngoingActivity { 250 | start_time: clock.get_time(), 251 | tags: vec![String::from("a")], 252 | description: None, 253 | }, 254 | true, 255 | ); 256 | assert!(start_0.is_ok()); 257 | assert!(!service.get_ongoing_activities().unwrap().is_empty()); 258 | let stop = service.stop_ongoing_activity(clock.get_time(), 0, true); 259 | assert!(stop.is_ok()); 260 | assert!(service.get_ongoing_activities().unwrap().is_empty()); 261 | let start_1 = service.start_activity( 262 | OngoingActivity { 263 | start_time: clock.get_time(), 264 | tags: vec![String::from("b")], 265 | description: None, 266 | }, 267 | true, 268 | ); 269 | assert!(start_1.is_ok()); 270 | assert!(!service.get_ongoing_activities().unwrap().is_empty()); 271 | } 272 | 273 | #[test] 274 | fn test_start_intersecting_activity() { 275 | let test_dir = tempdir().expect("error while creating tempdir"); 276 | let mut service = build_json_service(&test_dir); 277 | let finished = OngoingActivity::new( 278 | Local 279 | .datetime_from_str("2020-12-25T09:00:00", "%Y-%m-%dT%H:%M:%S") 280 | .unwrap() 281 | .into(), 282 | vec![], 283 | None, 284 | ) 285 | .into_activity( 286 | Local 287 | .datetime_from_str("2020-12-25T10:00:00", "%Y-%m-%dT%H:%M:%S") 288 | .unwrap() 289 | .into(), 290 | ) 291 | .unwrap(); 292 | let tracked = service.track_activity(finished, true); 293 | assert!(tracked.is_ok()); 294 | let other = OngoingActivity::new( 295 | Local 296 | .datetime_from_str("2020-12-25T09:30:00", "%Y-%m-%dT%H:%M:%S") 297 | .unwrap() 298 | .into(), 299 | vec![], 300 | None, 301 | ); 302 | let started = service.start_activity(other, true); 303 | assert!(started.is_err()); 304 | } 305 | 306 | #[test] 307 | fn test_stop_intersecting_activity() { 308 | let test_dir = tempdir().expect("error while creating tempdir"); 309 | let mut service = build_json_service(&test_dir); 310 | let finished = OngoingActivity::new( 311 | Local 312 | .datetime_from_str("2020-12-25T09:00:00", "%Y-%m-%dT%H:%M:%S") 313 | .unwrap() 314 | .into(), 315 | vec![], 316 | None, 317 | ) 318 | .into_activity( 319 | Local 320 | .datetime_from_str("2020-12-25T10:00:00", "%Y-%m-%dT%H:%M:%S") 321 | .unwrap() 322 | .into(), 323 | ) 324 | .unwrap(); 325 | let tracked = service.track_activity(finished, true); 326 | assert!(tracked.is_ok()); 327 | let other = OngoingActivity::new( 328 | Local 329 | .datetime_from_str("2020-12-25T08:30:00", "%Y-%m-%dT%H:%M:%S") 330 | .unwrap() 331 | .into(), 332 | vec![], 333 | None, 334 | ); 335 | let started = service.start_activity(other, true); 336 | assert!(started.is_ok()); 337 | let stopped = service.stop_ongoing_activity( 338 | Local 339 | .datetime_from_str("2020-12-25T09:30:00", "%Y-%m-%dT%H:%M:%S") 340 | .unwrap() 341 | .into(), 342 | 0, // only one ongoing activity => id is 0 343 | true, 344 | ); 345 | assert!(stopped.is_err()); 346 | } 347 | 348 | #[test] 349 | fn test_summary_nothing() { 350 | let clock = ChronoClock {}; 351 | let test_dir = tempdir().expect("error while creating tempdir"); 352 | let service = build_json_service(&test_dir); 353 | let (today_start, today_end) = clock.today_range(); 354 | let activities = service.filter_activities(|(_id, a)| { 355 | today_start <= a.get_start_time() && a.get_start_time() <= today_end 356 | }); 357 | assert!(activities.is_ok()); 358 | } 359 | 360 | #[test] 361 | fn test_summary_something() { 362 | let test_dir = tempdir().expect("error while creating tempdir"); 363 | let mut service = build_json_service(&test_dir); 364 | let today = chrono::Local::today(); 365 | let range_start: DateTimeW = today.and_hms(8, 0, 0).into(); 366 | let activity_start: DateTimeW = today.and_hms(8, 30, 0).into(); 367 | let activity_end: DateTimeW = today.and_hms(8, 45, 0).into(); 368 | let range_end: DateTimeW = today.and_hms(9, 0, 0).into(); 369 | service 370 | .track_activity( 371 | OngoingActivity::new(activity_start, vec![], None) 372 | .into_activity(activity_end) 373 | .unwrap(), 374 | true, 375 | ) 376 | .unwrap(); 377 | let activities = service.filter_activities(|(_id, a)| { 378 | range_start <= a.get_start_time() && a.get_start_time() <= range_end 379 | }); 380 | assert!(!activities.unwrap().is_empty()); 381 | } 382 | 383 | #[test] 384 | fn test_summary_not_in_range() { 385 | let test_dir = tempdir().expect("error while creating tempdir"); 386 | let mut service = build_json_service(&test_dir); 387 | let today = chrono::Local::today(); 388 | let range_start: DateTimeW = today.and_hms(9, 0, 0).into(); 389 | let activity_start: DateTimeW = today.and_hms(8, 30, 0).into(); 390 | let activity_end: DateTimeW = today.and_hms(8, 45, 0).into(); 391 | let range_end: DateTimeW = today.and_hms(10, 0, 0).into(); 392 | service 393 | .track_activity( 394 | OngoingActivity::new(activity_start, vec![], None) 395 | .into_activity(activity_end) 396 | .unwrap(), 397 | true, 398 | ) 399 | .unwrap(); 400 | let activities = service.filter_activities(|(_id, a)| { 401 | range_start <= a.get_start_time() && a.get_start_time() <= range_end 402 | }); 403 | assert!(activities.unwrap().is_empty()); 404 | } 405 | 406 | #[test] 407 | fn test_track_intersecting_activity() { 408 | let test_dir = tempdir().expect("error while creating tempdir"); 409 | let mut service = build_json_service(&test_dir); 410 | let finished = OngoingActivity::new( 411 | Local 412 | .datetime_from_str("2020-12-25T09:00:00", "%Y-%m-%dT%H:%M:%S") 413 | .unwrap() 414 | .into(), 415 | vec![], 416 | None, 417 | ) 418 | .into_activity( 419 | Local 420 | .datetime_from_str("2020-12-25T10:00:00", "%Y-%m-%dT%H:%M:%S") 421 | .unwrap() 422 | .into(), 423 | ) 424 | .unwrap(); 425 | let tracked = service.track_activity(finished, true); 426 | assert!(tracked.is_ok()); 427 | let other = OngoingActivity::new( 428 | Local 429 | .datetime_from_str("2020-12-25T09:30:00", "%Y-%m-%dT%H:%M:%S") 430 | .unwrap() 431 | .into(), 432 | vec![], 433 | None, 434 | ) 435 | .into_activity( 436 | Local 437 | .datetime_from_str("2020-12-25T10:30:00", "%Y-%m-%dT%H:%M:%S") 438 | .unwrap() 439 | .into(), 440 | ) 441 | .unwrap(); 442 | let tracked = service.track_activity(other, true); 443 | assert!(tracked.is_err()); 444 | } 445 | } 446 | -------------------------------------------------------------------------------- /src/status.rs: -------------------------------------------------------------------------------- 1 | use crate::rtw_core::clock::Clock; 2 | use crate::rtw_core::service::ActivityService; 3 | use crate::rtw_core::storage::Storage; 4 | use crate::service::Service; 5 | use chrono::Duration; 6 | use chrono_humanize::HumanTime; 7 | use itertools::Itertools; 8 | 9 | pub(crate) type FormatString = String; 10 | 11 | pub(crate) fn format_status( 12 | format_string: Option, 13 | service: &Service, 14 | clock: &Cl, 15 | ) -> anyhow::Result> 16 | where 17 | S: Storage, 18 | Cl: Clock, 19 | { 20 | let format_string = format_string.unwrap_or_else(|| String::from("{ongoing}")); 21 | let now = clock.get_time(); 22 | let ongoing_activities = service.get_ongoing_activities()?; 23 | if !ongoing_activities.is_empty() { 24 | Ok(Some( 25 | ongoing_activities 26 | .iter() 27 | .map(|(id, ongoing)| { 28 | let started: Duration = (ongoing.start_time - now).into(); 29 | format_string 30 | .replace("{id}", &format!("{}", id)) 31 | .replace("{ongoing}", &ongoing.get_title()) 32 | .replace("{start}", &format!("{}", ongoing.start_time)) 33 | .replace("{human_duration}", &format!("{}", HumanTime::from(started))) 34 | .replace("{duration}", &format!("{}", now - ongoing.start_time)) 35 | }) 36 | .join(" "), 37 | )) 38 | } else { 39 | Ok(None) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/time_tools.rs: -------------------------------------------------------------------------------- 1 | //! Time parsing utils. 2 | use crate::rtw_core::clock::{Clock, Time}; 3 | use anyhow::anyhow; 4 | use chrono::Local; 5 | use htp::parse; 6 | 7 | pub struct TimeTools {} 8 | 9 | impl TimeTools { 10 | pub fn is_time(s: &str) -> bool { 11 | parse(s, Local::now()).is_ok() 12 | } 13 | 14 | pub fn time_from_str(s: &str, clock: &dyn Clock) -> anyhow::Result