├── .dependabot └── config.yml ├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── doc ├── cli.md ├── config.md ├── features.md └── imgs │ ├── add_metadata_syntax.png │ ├── ls_project.png │ ├── show.png │ └── show_and_history.png ├── intg-tests ├── README.md ├── config.toml └── tasks.json ├── rustfmt.toml └── src ├── app ├── cli.rs ├── interactive_editor.rs ├── main.rs └── term.rs ├── config.rs ├── error.rs ├── filter.rs ├── lib.rs ├── metadata.rs └── task.rs /.dependabot/config.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | update_configs: 3 | - package_manager: "rust:cargo" 4 | directory: "." 5 | update_schedule: "live" 6 | target_branch: "master" 7 | default_reviewers: 8 | - "phaazon" 9 | default_assignees: 10 | - "phaazon" 11 | default_labels: 12 | - "dependency-update" 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [pull_request] 3 | 4 | jobs: 5 | build-linux: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v2 9 | - name: Build 10 | run: | 11 | cargo build 12 | cargo test 13 | 14 | build-windows: 15 | runs-on: windows-latest 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Build 19 | run: | 20 | cargo build 21 | cargo test 22 | 23 | build-macosx: 24 | runs-on: macOS-latest 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: Install Rust 28 | run: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal 29 | - name: Build 30 | run: | 31 | . ~/.cargo/env 32 | cargo build 33 | cargo test 34 | 35 | rust-fmt: 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v2 39 | - name: Install dependencies 40 | run: rustup component add rustfmt 41 | - name: rustfmt 42 | run: cargo fmt -- --check 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.4 2 | 3 | > Apr 05, 2021 4 | 5 | - Big refactoring. 6 | - Bump `serde`. 7 | - Bump `serde-json`. 8 | - Bump `serde_test`. 9 | 10 | # 0.3.3 11 | 12 | > Jan 26, 2021 13 | 14 | ## Additions 15 | 16 | - Add a new set of commands, under `td project`. The only currently available sub-command is `rename`, allowing to 17 | perform mass renaming of project. Have a look at [this](./doc/cli.md#mass-renaming-projects) for further details. 18 | 19 | ## Patches / fixes 20 | 21 | - Fix the case sensitive / insensitive filters (they were incorrectly reversed) when filtering on tasks’ contents. 22 | 23 | # 0.3.2 24 | 25 | > Jan 21, 2021 26 | 27 | ## Additions 28 | 29 | - Add the history view. See [this](./doc/cli.md#consult-the-history-of-a-task) for further details. 30 | - Add support for tags display in listings (`td ls`). Because this might generate big listings, it is possible not to 31 | show tags via the configuration file. See [this](./doc/config.md#display_tags_listings) for further details. 32 | - When adding / editing notes, the previously recorded notes are automatically shown as a header in the editor prompt. 33 | This help section can be switched off manually with the `--no-history` switch and is configurable in the configuration 34 | file. See [this](./doc/config.md#previous_notes_help). 35 | - When creating a task, the `--note` argument can be passed to automatically be dropped in an editor prompt and record 36 | a note right way for the task that was just added. This is short cut to prevent users from having to read a task UID 37 | and is similar to the following: 38 | ``` 39 | td add Something to do 40 | td note add 41 | ``` 42 | The shortcut way is: 43 | ``` 44 | td add Something to do --note 45 | ``` 46 | - Three new documents were added to help users get more information and guides: 47 | - The [features](./doc/features.md) file, describing all the features currently supported by the latest version. 48 | - The [CLI user guide](./doc/cli.md), providing a guide on how to use the CLI. Very similar to a _man(1)_. 49 | - The [configuration user guide](./doc/config.md), which describes the configuration file and all the customization 50 | options. 51 | 52 | ## Patches / fixes 53 | 54 | - Fix a bug when editing note, where note UIDs were 0-based instead of being 1-based (as shown in the output of 55 | `td show`). 56 | - Various internal refactorings. 57 | 58 | # 0.3.1 59 | 60 | > Jan 15, 2021 61 | 62 | - Fix a panic where the terminal doesn’t export its size (such as `eshell`). 63 | 64 | # 0.3 65 | 66 | > Nov 16th, 2020 67 | 68 | - Add note support (addition, deletion). It’s a breaking change as notes were already supported in secret (in the 69 | serialized form), even though the interface was not exposed. Nothing should break if you didn’t try to do sneaky 70 | things. 71 | 72 | # 0.2.6 73 | 74 | > Nov 6th, 2020 75 | 76 | - Task listing now accepts description filtering. It works the same way tasks are created: meta data can be found at the 77 | beginning and/or the end of the content: what’s in the middle is the description part. It is comprised of _search_ 78 | terms. By default, these are sorted uniquely and are case-insensitive. It is possible to change the case behavior 79 | with the `-C` switch. Right now, it is not possible to enforce term order, nor cardinality (i.e. duplicated terms 80 | resolve to a single one, and terms are lexicographically sorted). 81 | 82 | # 0.2.5 83 | 84 | > Nov 2nd, 2020 85 | 86 | - Replace the `m` suffix for month durations with `mth`, which is less confusing. 87 | 88 | # 0.2.4 89 | 90 | > Oct 28th, 2020 91 | 92 | - Fix alignments for task descriptions while listing them. 93 | 94 | # 0.2.3 95 | 96 | > Oct 24th, 2020 97 | 98 | - Fix configuration errors when colors are not present in the file. 99 | 100 | # 0.2.2 101 | 102 | > Oct 23th, 2020 103 | 104 | - Add the `show` / `s` command, enabling to show details about a task. 105 | 106 | # 0.2.1 107 | 108 | > Oct 23th, 2020 109 | 110 | - Fix a panic when the terminal’s width was too short. 111 | - Fix a visual glitch related to cutting sentences when the final cut word ends up at the end of the terminal. 112 | - Add support for metadata filtering. You can now fuzzy filter tasks in the `ls` command by adding the regular metadata 113 | (`+l`, `+m`, `+h`, `+c`, `#a-tag` or `@a-project`). 114 | 115 | # 0.2 116 | 117 | > Oct 19th, 2020 118 | 119 | ## Breaking changes 120 | 121 | - Add support for color and style configuration. This is a breaking change as configuration files will need to be 122 | updated. Typical migration path is to simply remove your `~/.config/toodoux/config.toml` and run `td` again to 123 | re-create a default configuration file. 124 | 125 | ## Additions 126 | 127 | - Add support for color and style configuration in the TOML file. This currently works for the task listing. 128 | 129 | ## Patch 130 | 131 | - Bump `env_logger`. 132 | - Fix the description output 133 | 134 | # 0.1 135 | 136 | > Oct 10th, 2020 137 | 138 | - Initial revision. 139 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This document is the official contribution guide contributors must follow. It will be **greatly appreciated** if you 4 | read it first before contributing. It will also prevent you from losing your time if you open an issue / make a PR that 5 | doesn’t comply to this document. 6 | 7 | 8 | 9 | * [Disclaimer and why this document](#disclaimer-and-why-this-document) 10 | * [How to make a change](#how-to-make-a-change) 11 | * [Process](#process) 12 | * [Conventions](#conventions) 13 | * [Coding](#coding) 14 | * [Git](#git) 15 | * [Git message](#git-message) 16 | * [Commit atomicity](#commit-atomicity) 17 | * [Hygiene](#hygiene) 18 | * [Release process](#release-process) 19 | * [Overall process](#overall-process) 20 | * [Changelogs update](#changelogs-update) 21 | * [Git tag](#git-tag) 22 | * [Support and donation](#support-and-donation) 23 | 24 | 25 | 26 | # Disclaimer and why this document 27 | 28 | People contributing is awesome. The more people contribute to Free & Open-Source software, the better the 29 | world is to me. However, the more people contribute, the more work we have to do on our spare-time. Good 30 | contributions are highly appreciated, especially if they thoroughly follow the conventions and guidelines of 31 | each and every repository. However, bad contributions — that don’t follow this document, for instance — are 32 | going to require me more work than was involved into making the actual change. It’s even worse when the contribution 33 | actually solves a bug or add a new feature. 34 | 35 | So please read this document; it’s not hard and the few rules here are easy to respect. You might already do 36 | everything in this list anyway, but reading it won’t hurt you. For more junior / less-experienced developers, it’s 37 | very likely you will learn a bit of process that is considered good practice, especially when working with VCS like 38 | Git. 39 | 40 | > Thank you! 41 | 42 | # How to make a change 43 | 44 | ## Process 45 | 46 | The typical process is to base your work on the `master` branch. The `master` branch must always contain a stable 47 | version of the project. It is possible to make changes by basing your work on other branches but the source 48 | of truth is `master`. If you want to synchronize with other people on other branches, feel free to. 49 | 50 | The process is: 51 | 52 | 1. (optional) Open an issue and discuss what you want to do. This is optional but highly recommended. If you 53 | don’t open an issue first and work on something that is not in the scope of the project, or already being 54 | made by someone else, you’ll be working for nothing. Also, keep in mind that if your change doesn’t refer to an 55 | existing issue, I will be wondering what is the context of your change. So prepare to be asked about the motivation 56 | and need behind your changes — it’s greatly appreciated if the commit messages, code and PR’s content already 57 | contains this information so that people don’t have to ask. 58 | 2. Fork the project. 59 | 3. Create a branch starting from `master` – or the branch you need to work on. Even though this is not really enforced, 60 | you’re advised to name your branch according to the _Git Flow_ naming convention: 61 | - `fix/your-bug-here`: if you’re fixing a bug, name your branch. 62 | - `feature/new-feature-here`: if you’re adding some work. 63 | - Free for anything else. 64 | - The special `release/*` branch is used to either back-port changes from newer versions to previous 65 | versions, or to release new versions by updating `Cargo.toml` files, changelogs, etc. Normally, contributors should 66 | never have to worry about this kind of brach as their creations is often triggered when wanting to make a release. 67 | 4. Make some commits! 68 | 5. Once you’re ready, open a Pull Request (PR) to merge your work on the target branch. For instance, open a PR for 69 | `master <- feature/something-new`. 70 | 6. (optional) Ask someone to review your code in the UI. Normally, I’m pretty reactive to notifications but it never 71 | hurts to ask for a review. 72 | 7. Discussion and peer-review. 73 | 8. Once the CI is all green, someone (likely me [@phaazon]) will merge your code and close your PR. 74 | 9. Feel free to delete your branch. 75 | 76 | # Conventions 77 | 78 | ## Coding 79 | 80 | Coding conventions are enforced by `rustfmt`. You are expected to provide code that is formatted by `rustfmt` 81 | with the top-level `rustfmt.toml` file. 82 | 83 | Coding convention is enforced in the Continuous Integration pipeline. If you want your work to be 84 | mergeable, format your code. 85 | 86 | > Note: please do not format your code in a separate, standalone commit. This way of doing is 87 | > considered as a bad practice as the commit will not contain _anything_ useful (but code 88 | > reformatted). Please format all your commits. You can use various tools in your editor to do so, 89 | > such as [rust-analyzer](https://github.com/rust-analyzer/rust-analyzer). 90 | 91 | ## Git 92 | 93 | ### Git message 94 | 95 | Please format your git messages like so: 96 | 97 | > [project(s)] Starting with an uppercase letter, ending with a dot. #343 98 | > 99 | > The #343 after the dot is appreciated to link to issues. Feel free to add, like this message, more context 100 | > and/or precision to your git message. You don’t have to put it in the first line of the commit message, 101 | > but if you are fixing a bug or implementing a feature thas has an issue linked, please reference it, so 102 | > that it is easier to generate changelogs when reading the git log. 103 | 104 | The `[project(s)]` header is mandatory if your commit has changes in any of the crates handled by this repository. 105 | If the repository you contribute to has a single project in it, you can omit the `[project(s)]` part. If you make 106 | a change that is cross-crate, feel free to separate them with commas, like `[crate_a, crate_b]`. If 107 | you make a change that touches all the crates, you can use `[all]`. If you change something that is not related 108 | to a crate, like the front README, CONTRIBUTING file, CI setup, top-level `Cargo.toml`, etc., then you can omit 109 | this header. 110 | 111 | **I’m very strict on git messages as I use them to write `CHANGELOG.md` files. Don’t be surprised if I ask you 112 | to edit a commit message. :)** 113 | 114 | ### Commit atomicity 115 | 116 | Your commits should be as atomic as possible. That means that if you make a change that touches two different 117 | concepts / has different scopes, most of the time, you want two commits – for instance one commit for the backend crate 118 | and one commit for the interface crate. There are exceptions, so this is not an absolute rule, but take some time 119 | thinking about whether you should split your commits or not. Commits which add a feature / fix a bug _and_ add tests at 120 | the same time are fine. 121 | 122 | However, here’s a non-comprehensive list of commits considered bad and that will be refused: 123 | 124 | - **Formatting, refactoring, cleaning, linting code in a PR that is not strictly about formatting**. If you open a PR to 125 | fix a bug, implement a feature, change configuration, add metadata to the CI, etc. — pretty much anything — but you 126 | also format some old code that has nothing to do with your PR, apply a linter’s suggestions (such as `clippy`), remove 127 | old code, etc., then I will refuse your commit(s) and ask you to edit your PR. 128 | - **Too atomic commits**. If two commits are logically connected to one another and are small, it’s likely that you want 129 | to merge them as a single commit — unless they work on too different parts of your code. This is a bit subjective 130 | topic, so I won’t be _too picky_ about it, but if I judge that you should split a commit into two or fixup two commits, 131 | please don’t take it too personal. :) 132 | 133 | If you don’t know how to write your commits in an atomic maneer, think about how one would revert your commits if 134 | something bad happens with your changes — like a big breaking change we need to roll back from very quickly. If your 135 | commits are not atomic enough, rolling them back will also roll back code that has nothing to do with your changes. 136 | 137 | ### Hygiene 138 | 139 | When working on a fix or a feature, it’s very likely that you will periodically need to update your branch 140 | with the `master` branch. **Do not use merge commits**, as your contributions will be refused if you have 141 | merge commits in them. The only case where merge commits are accepted is when you work with someone else 142 | and are required to merge another branch into your feature branch (and even then, it is even advised to 143 | simply rebase). If you want to synchronize your branch with `master`, please use: 144 | 145 | ``` 146 | git switch 147 | git fetch origin --prune 148 | git rebase origin/master 149 | ``` 150 | 151 | # Release process 152 | 153 | ## Overall process 154 | 155 | Releases occur at arbitrary rates. If something is considered urgent, it is most of the time released immediately 156 | after being merged and tested. Sometimes, several issues are being fixed at the same time (spanning on a few 157 | days at max). Those will be gathered inside a single update. 158 | 159 | Feature requests might be delayed a bit to be packed together as well but eventually get released, even if 160 | they’re small. Getting your PR merged means it will be released _soon_, but depending on the urgency of your changes, 161 | it might take a few minutes to a few days. 162 | 163 | ## Changelogs update 164 | 165 | `CHANGELOG.md` files must be updated **before any release**. Especially, they must contain: 166 | 167 | - The version of the release. 168 | - The date of the release. 169 | - How to migrate from a minor to the next major. 170 | - Everything that a release has introduced, such as major, minor and patch changes. 171 | 172 | Because I don’t ask people to maintain changelogs, I have a high esteem of people knowing how to use Git and create 173 | correct commits. Be advised that I will refuse any commit that prevents me from writing the changelog correctly. 174 | 175 | ## Git tag 176 | 177 | Once a new release occurs, a Git tag is created. Git tags are formatted regarding the project they refer to, if several 178 | projects are present in the repository. If only one project is present, tags will refer to this project by the same 179 | naming scheme anyway: 180 | 181 | > -X.Y.Z 182 | 183 | Where `X` is the _major version_, `Y` is the _minor version_ and `Z` is the _patch version_. For instance 184 | `project-0.37.1` is a valid Git tag, so is `project-derive-0.5.3`. 185 | 186 | A special kind of tag is also possible: 187 | 188 | > -X.Y.Z-rc.W 189 | 190 | Where `W` is a number starting from `1` and incrementing. This format is for _release candidates_ and occurs 191 | when a new version (most of the time a major one) is to be released but more feedback is required. 192 | 193 | Crates are pushed to [crates.io](https://crates.io) and tagged on Git manually (no CD involved). 194 | 195 | # Support and donation 196 | 197 | This project is a _free and open-source_ project. It has no financial motivation nor support. I 198 | ([@phaazon]) would like to make it very clear that: 199 | 200 | - Sponsorship is not available. You cannot pay me to make me do things for you. That includes issues reports, 201 | features requests and such. 202 | - If you still want to donate because you like the project and think I should be rewarded, you are free to 203 | give whatever you want. 204 | - However, keep in mind that donating doesn’t unlock any privilege people who don’t donate wouldn’t already 205 | have. This is very important as it would bias priorities. Donations must remain anonymous. 206 | - For this reason, no _sponsor badge_ will be shown, as it would distinguish people who donate from those 207 | who don’t. This is a _free and open-source_ project, everybody is welcome to contribute, with or without 208 | money. 209 | 210 | [@phaazon]: https://github.com/phaazon 211 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | [[package]] 4 | name = "aho-corasick" 5 | version = "0.7.15" 6 | source = "registry+https://github.com/rust-lang/crates.io-index" 7 | checksum = "7404febffaa47dac81aa44dba71523c9d069b1bdc50a77db41195149e17f68e5" 8 | dependencies = [ 9 | "memchr", 10 | ] 11 | 12 | [[package]] 13 | name = "ansi_term" 14 | version = "0.11.0" 15 | source = "registry+https://github.com/rust-lang/crates.io-index" 16 | checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" 17 | dependencies = [ 18 | "winapi", 19 | ] 20 | 21 | [[package]] 22 | name = "arrayref" 23 | version = "0.3.6" 24 | source = "registry+https://github.com/rust-lang/crates.io-index" 25 | checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544" 26 | 27 | [[package]] 28 | name = "arrayvec" 29 | version = "0.5.2" 30 | source = "registry+https://github.com/rust-lang/crates.io-index" 31 | checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" 32 | 33 | [[package]] 34 | name = "atty" 35 | version = "0.2.14" 36 | source = "registry+https://github.com/rust-lang/crates.io-index" 37 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 38 | dependencies = [ 39 | "hermit-abi", 40 | "libc", 41 | "winapi", 42 | ] 43 | 44 | [[package]] 45 | name = "autocfg" 46 | version = "1.0.1" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" 49 | 50 | [[package]] 51 | name = "base64" 52 | version = "0.13.0" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" 55 | 56 | [[package]] 57 | name = "bitflags" 58 | version = "1.2.1" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" 61 | 62 | [[package]] 63 | name = "blake2b_simd" 64 | version = "0.5.11" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587" 67 | dependencies = [ 68 | "arrayref", 69 | "arrayvec", 70 | "constant_time_eq", 71 | ] 72 | 73 | [[package]] 74 | name = "cfg-if" 75 | version = "1.0.0" 76 | source = "registry+https://github.com/rust-lang/crates.io-index" 77 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 78 | 79 | [[package]] 80 | name = "chrono" 81 | version = "0.4.19" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" 84 | dependencies = [ 85 | "libc", 86 | "num-integer", 87 | "num-traits", 88 | "serde", 89 | "time", 90 | "winapi", 91 | ] 92 | 93 | [[package]] 94 | name = "clap" 95 | version = "2.33.3" 96 | source = "registry+https://github.com/rust-lang/crates.io-index" 97 | checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" 98 | dependencies = [ 99 | "ansi_term", 100 | "atty", 101 | "bitflags", 102 | "strsim", 103 | "textwrap", 104 | "unicode-width", 105 | "vec_map", 106 | ] 107 | 108 | [[package]] 109 | name = "colored" 110 | version = "2.0.0" 111 | source = "registry+https://github.com/rust-lang/crates.io-index" 112 | checksum = "b3616f750b84d8f0de8a58bda93e08e2a81ad3f523089b05f1dffecab48c6cbd" 113 | dependencies = [ 114 | "atty", 115 | "lazy_static", 116 | "winapi", 117 | ] 118 | 119 | [[package]] 120 | name = "constant_time_eq" 121 | version = "0.1.5" 122 | source = "registry+https://github.com/rust-lang/crates.io-index" 123 | checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" 124 | 125 | [[package]] 126 | name = "crossbeam-utils" 127 | version = "0.8.1" 128 | source = "registry+https://github.com/rust-lang/crates.io-index" 129 | checksum = "02d96d1e189ef58269ebe5b97953da3274d83a93af647c2ddd6f9dab28cedb8d" 130 | dependencies = [ 131 | "autocfg", 132 | "cfg-if", 133 | "lazy_static", 134 | ] 135 | 136 | [[package]] 137 | name = "dirs" 138 | version = "3.0.1" 139 | source = "registry+https://github.com/rust-lang/crates.io-index" 140 | checksum = "142995ed02755914747cc6ca76fc7e4583cd18578746716d0508ea6ed558b9ff" 141 | dependencies = [ 142 | "dirs-sys", 143 | ] 144 | 145 | [[package]] 146 | name = "dirs-sys" 147 | version = "0.3.5" 148 | source = "registry+https://github.com/rust-lang/crates.io-index" 149 | checksum = "8e93d7f5705de3e49895a2b5e0b8855a1c27f080192ae9c32a6432d50741a57a" 150 | dependencies = [ 151 | "libc", 152 | "redox_users", 153 | "winapi", 154 | ] 155 | 156 | [[package]] 157 | name = "either" 158 | version = "1.6.1" 159 | source = "registry+https://github.com/rust-lang/crates.io-index" 160 | checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" 161 | 162 | [[package]] 163 | name = "env_logger" 164 | version = "0.8.2" 165 | source = "registry+https://github.com/rust-lang/crates.io-index" 166 | checksum = "f26ecb66b4bdca6c1409b40fb255eefc2bd4f6d135dab3c3124f80ffa2a9661e" 167 | dependencies = [ 168 | "atty", 169 | "humantime", 170 | "log", 171 | "regex", 172 | "termcolor", 173 | ] 174 | 175 | [[package]] 176 | name = "fuchsia-cprng" 177 | version = "0.1.1" 178 | source = "registry+https://github.com/rust-lang/crates.io-index" 179 | checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" 180 | 181 | [[package]] 182 | name = "getrandom" 183 | version = "0.1.16" 184 | source = "registry+https://github.com/rust-lang/crates.io-index" 185 | checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" 186 | dependencies = [ 187 | "cfg-if", 188 | "libc", 189 | "wasi 0.9.0+wasi-snapshot-preview1", 190 | ] 191 | 192 | [[package]] 193 | name = "heck" 194 | version = "0.3.2" 195 | source = "registry+https://github.com/rust-lang/crates.io-index" 196 | checksum = "87cbf45460356b7deeb5e3415b5563308c0a9b057c85e12b06ad551f98d0a6ac" 197 | dependencies = [ 198 | "unicode-segmentation", 199 | ] 200 | 201 | [[package]] 202 | name = "hermit-abi" 203 | version = "0.1.18" 204 | source = "registry+https://github.com/rust-lang/crates.io-index" 205 | checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c" 206 | dependencies = [ 207 | "libc", 208 | ] 209 | 210 | [[package]] 211 | name = "humantime" 212 | version = "2.1.0" 213 | source = "registry+https://github.com/rust-lang/crates.io-index" 214 | checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" 215 | 216 | [[package]] 217 | name = "itertools" 218 | version = "0.10.0" 219 | source = "registry+https://github.com/rust-lang/crates.io-index" 220 | checksum = "37d572918e350e82412fe766d24b15e6682fb2ed2bbe018280caa810397cb319" 221 | dependencies = [ 222 | "either", 223 | ] 224 | 225 | [[package]] 226 | name = "itoa" 227 | version = "0.4.7" 228 | source = "registry+https://github.com/rust-lang/crates.io-index" 229 | checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" 230 | 231 | [[package]] 232 | name = "lazy_static" 233 | version = "1.4.0" 234 | source = "registry+https://github.com/rust-lang/crates.io-index" 235 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 236 | 237 | [[package]] 238 | name = "libc" 239 | version = "0.2.82" 240 | source = "registry+https://github.com/rust-lang/crates.io-index" 241 | checksum = "89203f3fba0a3795506acaad8ebce3c80c0af93f994d5a1d7a0b1eeb23271929" 242 | 243 | [[package]] 244 | name = "log" 245 | version = "0.4.14" 246 | source = "registry+https://github.com/rust-lang/crates.io-index" 247 | checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" 248 | dependencies = [ 249 | "cfg-if", 250 | ] 251 | 252 | [[package]] 253 | name = "memchr" 254 | version = "2.3.4" 255 | source = "registry+https://github.com/rust-lang/crates.io-index" 256 | checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525" 257 | 258 | [[package]] 259 | name = "num-integer" 260 | version = "0.1.44" 261 | source = "registry+https://github.com/rust-lang/crates.io-index" 262 | checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" 263 | dependencies = [ 264 | "autocfg", 265 | "num-traits", 266 | ] 267 | 268 | [[package]] 269 | name = "num-traits" 270 | version = "0.2.14" 271 | source = "registry+https://github.com/rust-lang/crates.io-index" 272 | checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" 273 | dependencies = [ 274 | "autocfg", 275 | ] 276 | 277 | [[package]] 278 | name = "proc-macro-error" 279 | version = "1.0.4" 280 | source = "registry+https://github.com/rust-lang/crates.io-index" 281 | checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 282 | dependencies = [ 283 | "proc-macro-error-attr", 284 | "proc-macro2", 285 | "quote", 286 | "syn", 287 | "version_check", 288 | ] 289 | 290 | [[package]] 291 | name = "proc-macro-error-attr" 292 | version = "1.0.4" 293 | source = "registry+https://github.com/rust-lang/crates.io-index" 294 | checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 295 | dependencies = [ 296 | "proc-macro2", 297 | "quote", 298 | "version_check", 299 | ] 300 | 301 | [[package]] 302 | name = "proc-macro2" 303 | version = "1.0.24" 304 | source = "registry+https://github.com/rust-lang/crates.io-index" 305 | checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71" 306 | dependencies = [ 307 | "unicode-xid", 308 | ] 309 | 310 | [[package]] 311 | name = "quote" 312 | version = "1.0.8" 313 | source = "registry+https://github.com/rust-lang/crates.io-index" 314 | checksum = "991431c3519a3f36861882da93630ce66b52918dcf1b8e2fd66b397fc96f28df" 315 | dependencies = [ 316 | "proc-macro2", 317 | ] 318 | 319 | [[package]] 320 | name = "rand" 321 | version = "0.4.6" 322 | source = "registry+https://github.com/rust-lang/crates.io-index" 323 | checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" 324 | dependencies = [ 325 | "fuchsia-cprng", 326 | "libc", 327 | "rand_core 0.3.1", 328 | "rdrand", 329 | "winapi", 330 | ] 331 | 332 | [[package]] 333 | name = "rand_core" 334 | version = "0.3.1" 335 | source = "registry+https://github.com/rust-lang/crates.io-index" 336 | checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" 337 | dependencies = [ 338 | "rand_core 0.4.2", 339 | ] 340 | 341 | [[package]] 342 | name = "rand_core" 343 | version = "0.4.2" 344 | source = "registry+https://github.com/rust-lang/crates.io-index" 345 | checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" 346 | 347 | [[package]] 348 | name = "rdrand" 349 | version = "0.4.0" 350 | source = "registry+https://github.com/rust-lang/crates.io-index" 351 | checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" 352 | dependencies = [ 353 | "rand_core 0.3.1", 354 | ] 355 | 356 | [[package]] 357 | name = "redox_syscall" 358 | version = "0.1.57" 359 | source = "registry+https://github.com/rust-lang/crates.io-index" 360 | checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" 361 | 362 | [[package]] 363 | name = "redox_users" 364 | version = "0.3.5" 365 | source = "registry+https://github.com/rust-lang/crates.io-index" 366 | checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d" 367 | dependencies = [ 368 | "getrandom", 369 | "redox_syscall", 370 | "rust-argon2", 371 | ] 372 | 373 | [[package]] 374 | name = "regex" 375 | version = "1.4.3" 376 | source = "registry+https://github.com/rust-lang/crates.io-index" 377 | checksum = "d9251239e129e16308e70d853559389de218ac275b515068abc96829d05b948a" 378 | dependencies = [ 379 | "aho-corasick", 380 | "memchr", 381 | "regex-syntax", 382 | "thread_local", 383 | ] 384 | 385 | [[package]] 386 | name = "regex-syntax" 387 | version = "0.6.22" 388 | source = "registry+https://github.com/rust-lang/crates.io-index" 389 | checksum = "b5eb417147ba9860a96cfe72a0b93bf88fee1744b5636ec99ab20c1aa9376581" 390 | 391 | [[package]] 392 | name = "remove_dir_all" 393 | version = "0.5.3" 394 | source = "registry+https://github.com/rust-lang/crates.io-index" 395 | checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" 396 | dependencies = [ 397 | "winapi", 398 | ] 399 | 400 | [[package]] 401 | name = "rust-argon2" 402 | version = "0.8.3" 403 | source = "registry+https://github.com/rust-lang/crates.io-index" 404 | checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb" 405 | dependencies = [ 406 | "base64", 407 | "blake2b_simd", 408 | "constant_time_eq", 409 | "crossbeam-utils", 410 | ] 411 | 412 | [[package]] 413 | name = "ryu" 414 | version = "1.0.5" 415 | source = "registry+https://github.com/rust-lang/crates.io-index" 416 | checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" 417 | 418 | [[package]] 419 | name = "serde" 420 | version = "1.0.125" 421 | source = "registry+https://github.com/rust-lang/crates.io-index" 422 | checksum = "558dc50e1a5a5fa7112ca2ce4effcb321b0300c0d4ccf0776a9f60cd89031171" 423 | dependencies = [ 424 | "serde_derive", 425 | ] 426 | 427 | [[package]] 428 | name = "serde_derive" 429 | version = "1.0.125" 430 | source = "registry+https://github.com/rust-lang/crates.io-index" 431 | checksum = "b093b7a2bb58203b5da3056c05b4ec1fed827dcfdb37347a8841695263b3d06d" 432 | dependencies = [ 433 | "proc-macro2", 434 | "quote", 435 | "syn", 436 | ] 437 | 438 | [[package]] 439 | name = "serde_json" 440 | version = "1.0.64" 441 | source = "registry+https://github.com/rust-lang/crates.io-index" 442 | checksum = "799e97dc9fdae36a5c8b8f2cae9ce2ee9fdce2058c57a93e6099d919fd982f79" 443 | dependencies = [ 444 | "itoa", 445 | "ryu", 446 | "serde", 447 | ] 448 | 449 | [[package]] 450 | name = "serde_test" 451 | version = "1.0.125" 452 | source = "registry+https://github.com/rust-lang/crates.io-index" 453 | checksum = "b4bb5fef7eaf5a97917567183607ac4224c5b451c15023930f23b937cce879fe" 454 | dependencies = [ 455 | "serde", 456 | ] 457 | 458 | [[package]] 459 | name = "strsim" 460 | version = "0.8.0" 461 | source = "registry+https://github.com/rust-lang/crates.io-index" 462 | checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" 463 | 464 | [[package]] 465 | name = "structopt" 466 | version = "0.3.21" 467 | source = "registry+https://github.com/rust-lang/crates.io-index" 468 | checksum = "5277acd7ee46e63e5168a80734c9f6ee81b1367a7d8772a2d765df2a3705d28c" 469 | dependencies = [ 470 | "clap", 471 | "lazy_static", 472 | "structopt-derive", 473 | ] 474 | 475 | [[package]] 476 | name = "structopt-derive" 477 | version = "0.4.14" 478 | source = "registry+https://github.com/rust-lang/crates.io-index" 479 | checksum = "5ba9cdfda491b814720b6b06e0cac513d922fc407582032e8706e9f137976f90" 480 | dependencies = [ 481 | "heck", 482 | "proc-macro-error", 483 | "proc-macro2", 484 | "quote", 485 | "syn", 486 | ] 487 | 488 | [[package]] 489 | name = "syn" 490 | version = "1.0.60" 491 | source = "registry+https://github.com/rust-lang/crates.io-index" 492 | checksum = "c700597eca8a5a762beb35753ef6b94df201c81cca676604f547495a0d7f0081" 493 | dependencies = [ 494 | "proc-macro2", 495 | "quote", 496 | "unicode-xid", 497 | ] 498 | 499 | [[package]] 500 | name = "tempdir" 501 | version = "0.3.7" 502 | source = "registry+https://github.com/rust-lang/crates.io-index" 503 | checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8" 504 | dependencies = [ 505 | "rand", 506 | "remove_dir_all", 507 | ] 508 | 509 | [[package]] 510 | name = "term_size" 511 | version = "0.3.2" 512 | source = "registry+https://github.com/rust-lang/crates.io-index" 513 | checksum = "1e4129646ca0ed8f45d09b929036bafad5377103edd06e50bf574b353d2b08d9" 514 | dependencies = [ 515 | "libc", 516 | "winapi", 517 | ] 518 | 519 | [[package]] 520 | name = "termcolor" 521 | version = "1.1.2" 522 | source = "registry+https://github.com/rust-lang/crates.io-index" 523 | checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" 524 | dependencies = [ 525 | "winapi-util", 526 | ] 527 | 528 | [[package]] 529 | name = "textwrap" 530 | version = "0.11.0" 531 | source = "registry+https://github.com/rust-lang/crates.io-index" 532 | checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" 533 | dependencies = [ 534 | "unicode-width", 535 | ] 536 | 537 | [[package]] 538 | name = "thread_local" 539 | version = "1.1.0" 540 | source = "registry+https://github.com/rust-lang/crates.io-index" 541 | checksum = "bb9bc092d0d51e76b2b19d9d85534ffc9ec2db959a2523cdae0697e2972cd447" 542 | dependencies = [ 543 | "lazy_static", 544 | ] 545 | 546 | [[package]] 547 | name = "time" 548 | version = "0.1.44" 549 | source = "registry+https://github.com/rust-lang/crates.io-index" 550 | checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" 551 | dependencies = [ 552 | "libc", 553 | "wasi 0.10.0+wasi-snapshot-preview1", 554 | "winapi", 555 | ] 556 | 557 | [[package]] 558 | name = "toml" 559 | version = "0.5.8" 560 | source = "registry+https://github.com/rust-lang/crates.io-index" 561 | checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" 562 | dependencies = [ 563 | "serde", 564 | ] 565 | 566 | [[package]] 567 | name = "toodoux" 568 | version = "0.4.0" 569 | dependencies = [ 570 | "chrono", 571 | "colored", 572 | "dirs", 573 | "env_logger", 574 | "itertools", 575 | "log", 576 | "serde", 577 | "serde_json", 578 | "serde_test", 579 | "structopt", 580 | "tempdir", 581 | "term_size", 582 | "toml", 583 | "unicase", 584 | "unicode-width", 585 | ] 586 | 587 | [[package]] 588 | name = "unicase" 589 | version = "2.6.0" 590 | source = "registry+https://github.com/rust-lang/crates.io-index" 591 | checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" 592 | dependencies = [ 593 | "version_check", 594 | ] 595 | 596 | [[package]] 597 | name = "unicode-segmentation" 598 | version = "1.7.1" 599 | source = "registry+https://github.com/rust-lang/crates.io-index" 600 | checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796" 601 | 602 | [[package]] 603 | name = "unicode-width" 604 | version = "0.1.8" 605 | source = "registry+https://github.com/rust-lang/crates.io-index" 606 | checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" 607 | 608 | [[package]] 609 | name = "unicode-xid" 610 | version = "0.2.1" 611 | source = "registry+https://github.com/rust-lang/crates.io-index" 612 | checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" 613 | 614 | [[package]] 615 | name = "vec_map" 616 | version = "0.8.2" 617 | source = "registry+https://github.com/rust-lang/crates.io-index" 618 | checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" 619 | 620 | [[package]] 621 | name = "version_check" 622 | version = "0.9.2" 623 | source = "registry+https://github.com/rust-lang/crates.io-index" 624 | checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed" 625 | 626 | [[package]] 627 | name = "wasi" 628 | version = "0.9.0+wasi-snapshot-preview1" 629 | source = "registry+https://github.com/rust-lang/crates.io-index" 630 | checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" 631 | 632 | [[package]] 633 | name = "wasi" 634 | version = "0.10.0+wasi-snapshot-preview1" 635 | source = "registry+https://github.com/rust-lang/crates.io-index" 636 | checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" 637 | 638 | [[package]] 639 | name = "winapi" 640 | version = "0.3.9" 641 | source = "registry+https://github.com/rust-lang/crates.io-index" 642 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 643 | dependencies = [ 644 | "winapi-i686-pc-windows-gnu", 645 | "winapi-x86_64-pc-windows-gnu", 646 | ] 647 | 648 | [[package]] 649 | name = "winapi-i686-pc-windows-gnu" 650 | version = "0.4.0" 651 | source = "registry+https://github.com/rust-lang/crates.io-index" 652 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 653 | 654 | [[package]] 655 | name = "winapi-util" 656 | version = "0.1.5" 657 | source = "registry+https://github.com/rust-lang/crates.io-index" 658 | checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" 659 | dependencies = [ 660 | "winapi", 661 | ] 662 | 663 | [[package]] 664 | name = "winapi-x86_64-pc-windows-gnu" 665 | version = "0.4.0" 666 | source = "registry+https://github.com/rust-lang/crates.io-index" 667 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 668 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "toodoux" 3 | version = "0.4.0" 4 | license = "BSD-3-Clause" 5 | authors = ["Dimitri Sabadie "] 6 | description = "A modern task management tool" 7 | keywords = ["todo", "task"] 8 | categories = ["development-tools"] 9 | homepage = "https://github.com/phaazon/toodoux" 10 | repository = "https://github.com/phaazon/toodoux" 11 | readme = "README.md" 12 | edition = "2018" 13 | 14 | [lib] 15 | name = "toodoux" 16 | 17 | [[bin]] 18 | name = "td" 19 | path = "src/app/main.rs" 20 | 21 | [dependencies] 22 | chrono = { version = "0.4.11", features = ["serde"] } 23 | colored = "2" 24 | dirs = "3" 25 | env_logger = ">=0.8.2, <0.8.4" 26 | itertools = "0.10.0" 27 | log = "0.4.14" 28 | serde = { version = "1", features = ["derive"] } 29 | serde_json = "1" 30 | structopt = "0.3.21" 31 | term_size = "0.3.2" 32 | tempdir = "0.3.7" 33 | toml = "0.5.8" 34 | unicase = "2.6" 35 | unicode-width = "0.1.8" 36 | 37 | [dev-dependencies] 38 | serde_test = "1" 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, Dimitri Sabadie 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Dimitri Sabadie nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # toodoux, a task manager in CLI / TUI 2 | 3 |

4 | 5 |

6 | 7 | **toodoux** – English/French pun between _todo_ (EN) and _doux_ (FR, _soft_) — is a task management system that aims to 8 | be _super simple_ to operate but yet provide access to powerful features. It is heavily mainly based on [taskwarrior], 9 | for its powerful CLI and presentation. However, the opinionated task workflow in **toodoux** is rather different from 10 | what you would find in [taskwarrior]. 11 | 12 | Just like [taskwarrior], **toodoux** is a CLI application and not a plugin for an editor. It will remain a CLI 13 | application and contributions are welcomed as long as they keep that in mind (see the [contributing guide]). No support 14 | for any editor will be added directly into **toodoux**. It doesn’t prevent us to provide libraries and helpers to help 15 | external applications integrate **toodoux** directly, but it will not be on our side. 16 | 17 | **toodoux** is made out of two main components: 18 | 19 | - The `toodoux` Rust crate. This library crate allows other Rust developer to manipulate and use **toodoux** 20 | capabilities from within a developer perspective. It also ships the binary version. 21 | - The `td` binary, which uses the `toodoux` crate. It is what people will most likely use. 22 | 23 | Some other components might be shipped, such as _completions_ for typical shells (**bash**, **zsh** and **fish**), man 24 | pages, etc. 25 | 26 | ## Features 27 | 28 | The exhaustive feature set is available [here](./doc/features.md). 29 | 30 | ## CLI User Guide 31 | 32 | The user guide is availble [here](./doc/cli.md). 33 | 34 | ## User configuration 35 | 36 | The user configuration is available [here](./doc/config.md). 37 | 38 | ## Screenshots 39 | 40 |

41 | 42 | Main listing view. 43 |

44 | 45 |

46 | 47 | Capturing tasks from the CLI with a simple metadata syntax. 48 |

49 | 50 |

51 | 52 | Display the current state of a task. 53 |

54 | 55 |

56 | 57 | Display the current state of a task and its history in time. 58 |

59 | 60 | [taskwarrior]: https://taskwarrior.org 61 | [contributing guide]: CONTRIBUTING.md 62 | -------------------------------------------------------------------------------- /doc/cli.md: -------------------------------------------------------------------------------- 1 | # Command Line Interface: User Guide 2 | 3 | Two groups of main commands exist: 4 | 5 | - td `cmd` **options** 6 | - td **task_uid** `verb` **options** 7 | 8 | This is heavily inspired by [taskwarrior]’s CLI. The first form allows to interact with tasks without specifying a 9 | task. It is useful for listing all tasks or adding a new task. The second form acts on a specific task by using its 10 | UID. It can be useful to change its status, add notes, tags, move it into a project, change its priority, etc. 11 | 12 | Lots of commands accept _aliases_. For instance, the `add` command also accepts the `a` alias. When a command has 13 | possible aliases, those will be listed when the command is introduced. 14 | 15 | 16 | 17 | * [Adding a new task](#adding-a-new-task) 18 | * [Editing a task](#editing-a-task) 19 | * [Describing a task](#describing-a-task) 20 | * [Consult the history of a task](#consult-the-history-of-a-task) 21 | * [Switch the status of a task](#switch-the-status-of-a-task) 22 | * [Listing tasks](#listing-tasks) 23 | * [Adding notes](#adding-notes) 24 | * [Editing notes](#editing-notes) 25 | * [Mass renaming projects](#mass-renaming-projects) 26 | 27 | 28 | 29 | ## Adding a new task 30 | 31 | ``` 32 | td add [options] 33 | td a [options] 34 | ``` 35 | 36 | This command captures a new task in the system. 37 | 38 | - **content** is the content of the task as described in the [metadata syntax] section. When creating 39 | a new task, you can pass the actual name of the task, such as `Do this`, but you can also mix the metadata syntax 40 | with it, such as `@my-project Do this +h #documentation`. 41 | - _options_ can be zero or several of: 42 | - `--done`: mark the item as done. 43 | - `--start`: immediately start working on the task. 44 | 45 | ## Editing a task 46 | 47 | ``` 48 | td edit [content] 49 | td ed [content] 50 | td e [content] 51 | ``` 52 | 53 | This command edits an already registered task by registering new values or its content / metadata. You can change 54 | its content, any of the metadata or all at the same time. If you omit the content, it will be left untouched. If you 55 | omit a metadata, it will be left untouched. 56 | 57 | - **task-uid** is the task UID referring to the task to edit. 58 | - **content** is the content of the task as described in the [metadata syntax] section. 59 | 60 | ## Describing a task 61 | 62 | ``` 63 | td show 64 | td s 65 | ``` 66 | 67 | Show the current state of a task. 68 | 69 | This command is currently the only one showing the notes and their respective UIDs, too. 70 | 71 | - **task-uid** is the task UID referring to the task to edit. 72 | 73 | ## Consult the history of a task 74 | 75 | ``` 76 | td history 77 | ``` 78 | 79 | Show the history of a task. This command will print everything that has happened to a task, with the associated time 80 | at which the event happened. 81 | 82 | - **task-uid** is the task UID referring to the task to edit. 83 | 84 | ## Switch the status of a task 85 | 86 | ``` 87 | td (todo | start | done | cancel) 88 | ``` 89 | 90 | These four commands allow to change the status of a task, whatever the previous status is. It is important to notice 91 | that you should not have to use `todo` too often: indeed, you will only need that command when you have started working 92 | on a task and want to “stop working on it.” This workflow is useful as it will take into account _only_ the time you 93 | work on a task. If you care about this kind of stats, moving the task back to its _todo_ state will stop counting spent 94 | time on it. You can then resume it later. It is also an interesting tool if you care about the change history of a 95 | task, as it is recorded there. 96 | 97 | - **task-uid** is the task UID referring to the task to edit. 98 | 99 | ## Listing tasks 100 | 101 | ``` 102 | td list [content] [options] 103 | td ls [content] [options] 104 | td l [content] [options] 105 | ``` 106 | 107 | Listing tasks is one of the most useful commands you will ever run. For this reason, it’s the default command if you 108 | don’t pass any command to run to the binary. Listing tasks is currently the only way you have to know the UID of a task 109 | (besides the output of the `add` command, which will also give you this information). 110 | 111 | - **content** is the content of the tasks as described in the [metadata syntax] section. In this case, 112 | it’s used as a language query. Terms are used to refined the search by conjunction (i.e. a task must fulfill all the 113 | query terms). For instance, `@toodoux +h` – or `+h @toodoux`, it is the same query – will only list high priority 114 | tasks for the `toodoux` project; it will not list all `toodoux` tasks along with all high priority tasks. 115 | - _options_: 116 | - `--todo` will list tasks still left to do. 117 | - `--start` will list tasks. 118 | - `--done` will list done tasks. 119 | - `--cancelled` will list cancelled tasks. 120 | - The flags above are additive. 121 | - `--all` will list all tasks and is the same as `--todo --start --done --cancelled`. 122 | - If you don’t specify one or more of `--all`, `--todo`, `--start`, `--done` and/or `--cancelled`, then the 123 | listing will default to _active_ tasks. 124 | - `--case-insensitive` allows to perform search inside the name of tasks with a case-insensitive algorithm. 125 | 126 | ## Adding notes 127 | 128 | ``` 129 | td note add [options] 130 | td note a [options] 131 | ``` 132 | 133 | This command allows you to record a new note for a given task, referred to by **task-uid**. The note will be written 134 | in an editor, open trying to use the first of, in order: 135 | 136 | 1. The `$EDITOR` environment variable. 137 | 2. The `interactive_editor` entry in the user configuration file. 138 | 139 | If none of those choices ended up with a working editor, an error is emitted and it’s not possible to add the note. 140 | Otherwise, the editor is open and let the user write their note in it. Once the note is written, quitting the editor 141 | after having saved the file will make **toodoux** record this note for the task referred to by **task-uid**. 142 | 143 | Several possibilities can arise when the editor opens, though: 144 | 145 | - If the user has the `previous_notes_help` flag set to `true` in the user configuration file, then the content of the 146 | file will be open with is pre-filled with the note history of the given task. That pre-fill is part of a header 147 | separated from the user note with a line that must not be removed. Anything in the header can be modified as it will 148 | be discarded before recording the new note. 149 | - If that flag is set to `false`, no history will be shown – hence no marker line either. 150 | - If `--no-history` is passed to this command, the previous two points above are overridden and no history will be 151 | shown. 152 | 153 | Saving an empty note (with or without the header) aborts the operation. 154 | 155 | - **task-uid** is the task UID referring to the task to edit. 156 | - _options_: 157 | - `--no-history`: override user configuration and do not see the note history help. 158 | 159 | ## Editing notes 160 | 161 | ``` 162 | td note edit [options] 163 | td note ed [options] 164 | td note e [options] 165 | ``` 166 | 167 | This command is very similar to `note add` but expects a note — referred to by `` – to operate on. When 168 | opening the editor, the same rules apply as with `note add`, but the pre-fill is also appended the current note 169 | you are editing. 170 | 171 | Saving an empty note (with or without the header) aborts the operation. 172 | 173 | - **task-uid** is the task UID referring to the task to edit. 174 | - **note-uid** is the note UID referring to the note to edit. 175 | - _options_: 176 | - `--no-history`: override user configuration and do not see the note history help. 177 | 178 | ## Mass renaming projects 179 | 180 | ``` 181 | td project rename 182 | td proj rename 183 | ``` 184 | 185 | This command allows to massively change the project of all the tasks of the same project, effectively renaming the 186 | project. It is similar to manually editing all the tasks one by one and changing setting the new project name on them. 187 | 188 | - **current-project** is the project to change. 189 | - **new-project** is the new name of the project. 190 | 191 | [metadata syntax]: ./features.md#metadata-syntax 192 | [taskwarrior]: https://taskwarrior.org 193 | [contributing guide]: CONTRIBUTING.md 194 | [XDG Base Directory specification]: https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html 195 | -------------------------------------------------------------------------------- /doc/config.md: -------------------------------------------------------------------------------- 1 | # User Configuration 2 | 3 | Configuration is done by following the [XDG Base Directory specification] by default but can be overridden by the user 4 | if required. The configuration root directory is `$XDG_CONFIG_DIR/toodoux` — it should be `~/.config/toodoux` for most 5 | people on Linux, for instance. 6 | 7 | The configuration file, `config.toml`, is a TOML file that contains several sections: 8 | 9 | - `[main]`, containing the main configuration of **toodoux**. 10 | - `[colors]`, containing all the configuration keys to customize the colors and styles used by **toodoux**. 11 | 12 | > We reserve the right to use other sections for further, more precise configuration. 13 | 14 | 15 | 16 | 17 | * [Main configuration](#main-configuration) 18 | * [`interactive_editor`](#interactive_editor) 19 | * [`tasks_file`](#tasks_file) 20 | * [`todo_alias`](#todo_alias) 21 | * [`wip_alias`](#wip_alias) 22 | * [`done_alias`](#done_alias) 23 | * [`cancelled_alias`](#cancelled_alias) 24 | * [`uid_col_name`](#uid_col_name) 25 | * [`age_col_name`](#age_col_name) 26 | * [`spent_col_name`](#spent_col_name) 27 | * [`prio_col_name`](#prio_col_name) 28 | * [`project_col_name`](#project_col_name) 29 | * [`tags_col_name`](#tags_col_name) 30 | * [`status_col_name`](#status_col_name) 31 | * [`description_col_name`](#description_col_name) 32 | * [`notes_nb_col_name`](#notes_nb_col_name) 33 | * [`display_empty_cols`](#display_empty_cols) 34 | * [`max_description_lines`](#max_description_lines) 35 | * [`display_tags_listings`](#display_tags_listings) 36 | * [`previous_notes_help`](#previous_notes_help) 37 | * [Colors configuration](#colors-configuration) 38 | * [`[colors.description.todo]`](#colorsdescriptiontodo) 39 | * [`[colors.description.ongoing]`](#colorsdescriptionongoing) 40 | * [`[colors.description.done]`](#colorsdescriptiondone) 41 | * [`[colors.description.cancelled]`](#colorsdescriptioncancelled) 42 | * [`[colors.status.todo]`](#colorsstatustodo) 43 | * [`[colors.status.ongoing]`](#colorsstatusongoing) 44 | * [`[colors.status.done]`](#colorsstatusdone) 45 | * [`[colors.status.cancelled]`](#colorsstatuscancelled) 46 | * [`[colors.priority.low]`](#colorsprioritylow) 47 | * [`[colors.priority.medium]`](#colorsprioritymedium) 48 | * [`[colors.priority.high]`](#colorspriorityhigh) 49 | * [`[colors.priority.critical]`](#colorsprioritycritical) 50 | * [`[colors.show_header]`](#colorsshow_header) 51 | 52 | 53 | 54 | ## Main configuration 55 | 56 | The `[main]` section contains the following keys. 57 | 58 | ### `interactive_editor` 59 | 60 | - Editor to use for interactive editing. 61 | - Defaults to none. 62 | 63 | ### `tasks_file` 64 | 65 | - Path to the folder containing all the tasks. 66 | - Defaults to `"$XDG_CONFIG_DIR/toodoux"`. 67 | 68 | ### `todo_alias` 69 | 70 | - Name of the _tood_ state. 71 | - Defaults to `"TODO"`. 72 | 73 | ### `wip_alias` 74 | 75 | - Name of the _on-going_ state. 76 | - Defaults to `"WIP"`. 77 | 78 | ### `done_alias` 79 | 80 | - Name of the _done_ state. 81 | - Defaults to `"DONE"`. 82 | 83 | ### `cancelled_alias` 84 | 85 | - Name of the _cancelled_ state. 86 | - Defaults to `"CANCELLED"`. 87 | 88 | ### `uid_col_name` 89 | 90 | - UID column name. 91 | - Defaults to `"IUD"`. 92 | 93 | ### `age_col_name` 94 | 95 | - Age column name. 96 | - Defaults to `"Age"`. 97 | 98 | ### `spent_col_name` 99 | 100 | - Spent column name. 101 | - Defaults to `"Spent"`. 102 | 103 | ### `prio_col_name` 104 | 105 | - Priority column name. 106 | - Defaults to `"Prio"`. 107 | 108 | ### `project_col_name` 109 | 110 | - Project column name. 111 | - Defaults to `"Project"`. 112 | 113 | ### `tags_col_name` 114 | 115 | - Tags column name. 116 | - Defaults to `"Tags"`. 117 | 118 | ### `status_col_name` 119 | 120 | - Status column name. 121 | - Defaults to `"Status"`. 122 | 123 | ### `description_col_name` 124 | 125 | - Description column name. 126 | - Defaults to `"Description"`. 127 | 128 | ### `notes_nb_col_name` 129 | 130 | - Number of notes column name. 131 | - Defaults to `"Notes"`. 132 | 133 | ### `display_empty_cols` 134 | 135 | - Whether or not display empty columns in listing views. 136 | - Defaults to `false`. 137 | 138 | ### `max_description_lines` 139 | 140 | - Maximum number of warping lines of task description before breaking it (and adding the ellipsis character) if it’s 141 | too long. 142 | - Defaults to `2`. 143 | 144 | ### `display_tags_listings` 145 | 146 | - Display tags in listings. 147 | - Defaults to `true`. 148 | 149 | ### `previous_notes_help` 150 | 151 | - Show the previously recorded notes when adding a new note for a given task. 152 | - Defaults to `true`. 153 | 154 | ## Colors configuration 155 | 156 | Colors are configured via several sub-sections: 157 | 158 | - `[colors.description.*]` contains all the styles for changing the description content in listing depending on the 159 | status of the task. 160 | - `[colors.status.*]` contains all the styles for changing the status content in listing depending on the 161 | status of the task. 162 | - `[colors.priority.*]` contains all the styles for changing the priority content in listing depending on the 163 | priority of the task. 164 | - `[colors.show_header]` contains the style to apply on headers while describing notes. 165 | 166 | Colors can be encoded via several formats: 167 | 168 | - Regular RGB hexadecimal strings — `"#rrggbb"` or `"#rgb"`. 169 | - Terminal colors are supported with the following names: 170 | - `"black"`. 171 | - `"red"`. 172 | - `"green"`. 173 | - `"yellow"`. 174 | - `"blue"`. 175 | - `"magenta"`. 176 | - `"cyan"`. 177 | - `"white"`. 178 | - `"bright black"`. 179 | - `"bright red"`. 180 | - `"bright green"`. 181 | - `"bright yellow"`. 182 | - `"bright blue"`. 183 | - `"bright magenta"`. 184 | - `"bright cyan"`. 185 | - `"bright white"`. 186 | 187 | Style attributes are applied above colors to implement a specific style. They are: 188 | 189 | - `"bold"`. 190 | - `"dimmed"`. 191 | - `"underline"`. 192 | - `"reversed"`. 193 | - `"italic"`. 194 | - `"blink"`. 195 | - `"hidden"`. 196 | - `"strikethrough"`. 197 | 198 | A _style_ is an object composed of three keys: 199 | 200 | - `foreground` is the color to use as foreground. 201 | - `background` is the color to use as foreground. 202 | - `style` is a list of zero or more style attributes to apply. 203 | 204 | ### `[colors.description.todo]` 205 | 206 | - Style to apply on description content of a task still left to do. 207 | - Defaults to: 208 | - Foreground is `"bright white"`. 209 | - Background is `"black"`. 210 | - Style is `[]`. 211 | 212 | ### `[colors.description.ongoing]` 213 | 214 | - Style to apply on description content of an on-going task. 215 | - Defaults to: 216 | - Foreground is `"black"`. 217 | - Background is `"bright green"`. 218 | - Style is `[]`. 219 | 220 | ### `[colors.description.done]` 221 | 222 | - Style to apply on description content of a done task. 223 | - Defaults to: 224 | - Foreground is `"bright black"`. 225 | - Background is `"black"`. 226 | - Style is `["dimmed"]`. 227 | 228 | ### `[colors.description.cancelled]` 229 | 230 | - Style to apply on description content of a cancelled task. 231 | - Defaults to: 232 | - Foreground is `"bright black"`. 233 | - Background is `"black"`. 234 | - Style is `["dimmed", "strikethrough"]`. 235 | 236 | ### `[colors.status.todo]` 237 | 238 | - Style to apply on status content of a task still left to do. 239 | - Defaults to: 240 | - Foreground is `"magenta"`. 241 | - Background is none. 242 | - Style is `["bold"]`. 243 | 244 | ### `[colors.status.ongoing]` 245 | 246 | - Style to apply on status content of an on-going task. 247 | - Defaults to: 248 | - Foreground is `"green"`. 249 | - Background is none. 250 | - Style is `["bold"]`. 251 | 252 | ### `[colors.status.done]` 253 | 254 | - Style to apply on status content of a done task. 255 | - Defaults to: 256 | - Foreground is `"bright black"`. 257 | - Background is none. 258 | - Style is `["dimmed"]`. 259 | 260 | ### `[colors.status.cancelled]` 261 | 262 | - Style to apply on status content of a cancelled task. 263 | - Defaults to: 264 | - Foreground is `"bright red"`. 265 | - Background is none. 266 | - Style is `["dimmed"]`. 267 | 268 | ### `[colors.priority.low]` 269 | 270 | - Style to apply on priority content of a low priority task. 271 | - Defaults to: 272 | - Foreground is `"bright black"`. 273 | - Background is none. 274 | - Style is `["dimmed"]`. 275 | 276 | ### `[colors.priority.medium]` 277 | 278 | - Style to apply on priority content of a medium priority task. 279 | - Defaults to: 280 | - Foreground is `"blue"`. 281 | - Background is none. 282 | - Style is `[]`. 283 | 284 | ### `[colors.priority.high]` 285 | 286 | - Style to apply on priority content of a high priority task. 287 | - Defaults to: 288 | - Foreground is `"red"`. 289 | - Background is none. 290 | - Style is `[]`. 291 | 292 | ### `[colors.priority.critical]` 293 | 294 | - Style to apply on priority content of a high priority task. 295 | - Defaults to: 296 | - Foreground is `"black"`. 297 | - Background is `"bright red"`. 298 | - Style is `[]`. 299 | 300 | ### `[colors.show_header]` 301 | 302 | - Style to apply on headers while showing tasks. 303 | - Defaults to: 304 | - Foreground is `"bright black"`. 305 | - Background is none. 306 | - Style is `[]`. 307 | 308 | [XDG Base Directory specification]: https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html 309 | -------------------------------------------------------------------------------- /doc/features.md: -------------------------------------------------------------------------------- 1 | # Features 2 | 3 | This document provides a comprehensive list of the features as currently implemented in the binary form of **toodoux**. 4 | 5 | 6 | 7 | * [Metadata](#metadata) 8 | * [Lifecycle](#lifecycle) 9 | * [The four status](#the-four-status) 10 | * [Status customization](#status-customization) 11 | * [Implicit / computed metadata](#implicit--computed-metadata) 12 | * [Projects](#projects) 13 | * [Priorities](#priorities) 14 | * [Tags / labels](#tags--labels) 15 | * [Notes](#notes) 16 | * [Metadata syntax](#metadata-syntax) 17 | * [Operators](#operators) 18 | * [Inline syntax](#inline-syntax) 19 | 20 | 21 | 22 | ## Metadata 23 | 24 | Tasks have _metadata_. A metadata is an information that is associated with a task, such as its creation date, 25 | the project it belongs to, its creation / modification dates, priority, etc. The complete list: 26 | 27 | - _Unique identifier (UID)_: a number that uniquely identifies the task and is used to manipulate it. 28 | - _Project_: a task belongs to either no project, or a single project. Tasks without project are considered _orphaned_. 29 | Orphaned tasks are useful to quickly capture an idea and move it to a project later. 30 | - _Creation date_: the date when the task was captured into the system. 31 | - _Modification dates_: the dates when the task was modified. 32 | - _Status_: the status of the task. 33 | - _Priority_: four priorities are supported and help sorting the tasks. 34 | - _Tags_: free and user-defined tags that can be used to filter and sort tasks more easily. A task can have as many tags 35 | as wanted. 36 | - _Notes_: an optional set of ordered texts users can use to add more details to a task; for instance while working on a 37 | task, a user can recorde some notes about the resolution of a problem, what they tried, what worked, etc. Notes are 38 | formatted in Markdown. 39 | - _Event history_: a set of ordered events that have happened to the task. It gathers all the other metadata and pins 40 | them to a date to provide a proper historical view of what happened to a project. 41 | 42 | ## Lifecycle 43 | 44 | ### The four status 45 | 46 | The four _status_ define the lifecycle of a task. Without explicit setting, a task starts in the _todo_ status. It will 47 | then evolve through different status, depending on what is happening: 48 | 49 | 1. `TODO`: the initial and default status of a task; means that the task was recorded in the system is not currently 50 | on-going. 51 | 2. `WIP`: status of a task when it is been started. 52 | 3. `DONE`: the task has been completely done. 53 | 4. `CANCELLED`: the task has been cancelled. This status is useful to keep track of the task even if not done. 54 | 55 | On those four status, `TODO` and `WIP` are considered _active_ and `DONE` and `CANCELLED` are considered _inactive_. 56 | Those considerations define the default behavior when listing tasks: only _active_ tasks are shown. Inactive tasks can 57 | still be listed by providing the right filtering options. 58 | 59 | Switching from one status to another is logged in history. It is possible to go from any status to any other. 60 | 61 | ### Status customization 62 | 63 | The real names the status will use is a user-defined setting. See the [Configuration](./config.md) section for 64 | further information. 65 | 66 | ### Implicit / computed metadata 67 | 68 | Besides all the metadata and notes, a task is also added some metadata related to its life cycle. Those information, 69 | automatically computed, are: 70 | 71 | - Its _creation date_, which allows to show its _age_. This information allows to know how old a task is and can be 72 | used to re-prioritize it. 73 | - Its _activation duration_. Activation duration is a measure that is done by computing the time user has been spending on 74 | it. The way it is done is rather simple: it is the sum of the durations the user passed on the task while in `WIP` 75 | status. Switching its status back to `TODO`, to `DONE` or `CANCELLED` will not make the duration impacted anymore. If 76 | a task has some _activation duration_ and is moved back to `TODO`, the activation duration should still be visible in 77 | listings, but greyed out. 78 | - Its _completion duration_. When a task is moved to `DONE` or `CANCELLED` status, its _activation duration_ is 79 | automatically transformed into a _completion duration_. 80 | 81 | ## Projects 82 | 83 | Tasks are by default _orphaned_: they don’t belong to any project. You can gather tasks under a project to group them 84 | in a more easy way. 85 | 86 | Projects are free names acting like special tags: a task can be in either no or one project at most. Projects on their 87 | own don’t exist currently as a specific kind of object in **toodoux**: they are just labels. 88 | 89 | ## Priorities 90 | 91 | Priorities are a simple way to sort tasks in a way that shows urgent ones first. Four level of priorities are provided: 92 | 93 | 1. `LOW`: low-priority task that will be shown after all higher ones. 94 | 2. `MEDIUM`: medium-priority task that will be shown after all higher ones. 95 | 3. `HIGH`: high-priority task that will be shown after all higher ones. 96 | 4. `CRITICAL`: task that will be shown with emphasis after all others. 97 | 98 | A task can have only one priority, but it is possible to change it whenever wanted. 99 | 100 | ## Tags / labels 101 | 102 | Tags / labels are a way to add additional filtering flags to tasks to group and classify them. For instance, inside a 103 | _“project-alpha”_ project, one might want to classify tasks regarding whether they are about documentation, bugs, 104 | features or regressions, for instance. Tags are free objects users can set on tasks, and a task can have as many tags 105 | as wanted. 106 | 107 | ## Notes 108 | 109 | Tasks can be added notes, which are Markdown entries associated with a timestamp. A task is always added notes one by 110 | one — i.e. there is no rich text editing where several notes can be edited all at once. Task edition is performed by 111 | opening an interactive editor instead of typing the note on the command line. 112 | 113 | When editing notes, it is possible to ask for the history help – i.e. previously recorded notes – for the task you are 114 | adding a note for. See the `--note` switch. 115 | 116 | ## Metadata syntax 117 | 118 | The metadata syntax is a simple yet powerful mechanism to quickly add metadata to a task or to refine a query. Several 119 | metadata are associated with a prefix operator that represents the class of the metadata: 120 | 121 | ### Operators 122 | 123 | | Class | Operator | Example | 124 | | ----- | -------- | ------- | 125 | | **Project** | `@` | `@toodoux` | 126 | | **Priority** | `+` | `+h` | 127 | | **Tags** | `#` | `#documentation` | 128 | 129 | Each operator is expected to be in a prefix position behind a string, representing the value for this class. For 130 | instance, `@toodoux` means “the toodoux project.” `+h` means the high priority. Etc. etc. 131 | 132 | Priorities are a bit special as they do not accept arbitrary strings. Refer to this table to know which string to use 133 | regarding the kind of priority you want to use: 134 | 135 | | Priority | String | 136 | | -------- | ------ | 137 | | `CRITICAL` | `c` | 138 | | `HIGH` | `h` | 139 | | `MEDIUM` | `m` | 140 | | `LOW` | `l` | 141 | 142 | ### Inline syntax 143 | 144 | Metadata operators can be inlined and combined while adding, editing or quering tasks. For instance, the following 145 | string means “tasks in the toodoux project, high priority, with tags _#foo_ and _#bar_:” 146 | 147 | ``` 148 | @toodoux +h #foo #bar 149 | ``` 150 | 151 | The order is not important, for any of the metadata. All of the following strings are equivalent: 152 | 153 | ``` 154 | @toodoux +h #foo #bar 155 | +h @toodoux #foo #bar 156 | #foo +h #bar @toodoux 157 | ``` 158 | 159 | Adding _free terms_ allow to, depending on the command, fill the task name or refine a query: 160 | 161 | ``` 162 | @toodoux +h #foo #bar reduce 163 | ``` 164 | 165 | In the context of a query, this string will match any task containing `reduce` for “the toodoux project, high 166 | priority with tags _#foo_ and _#bar_.” Free text can be placed anywhere. 167 | -------------------------------------------------------------------------------- /doc/imgs/add_metadata_syntax.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hadronized/toodoux/d3b045e72b24f2ee1772e3e1c359c7f97a4d04a0/doc/imgs/add_metadata_syntax.png -------------------------------------------------------------------------------- /doc/imgs/ls_project.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hadronized/toodoux/d3b045e72b24f2ee1772e3e1c359c7f97a4d04a0/doc/imgs/ls_project.png -------------------------------------------------------------------------------- /doc/imgs/show.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hadronized/toodoux/d3b045e72b24f2ee1772e3e1c359c7f97a4d04a0/doc/imgs/show.png -------------------------------------------------------------------------------- /doc/imgs/show_and_history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hadronized/toodoux/d3b045e72b24f2ee1772e3e1c359c7f97a4d04a0/doc/imgs/show_and_history.png -------------------------------------------------------------------------------- /intg-tests/README.md: -------------------------------------------------------------------------------- 1 | # Integration environment 2 | 3 | This directory holds a bunch of data you can use to test a change you made / are making. The way you should be using 4 | this is — at the root of the repository, for instance: 5 | 6 | ```sh 7 | cargo run -- -c ./intg-tests 8 | ``` 9 | 10 | For instance, to list all the tasks in the data set: 11 | 12 | ```sh 13 | cargo run -- -c ./intg-tests ls --all 14 | ``` 15 | -------------------------------------------------------------------------------- /intg-tests/config.toml: -------------------------------------------------------------------------------- 1 | [main] 2 | tasks_file = './intg-tests' 3 | todo_alias = 'TODO' 4 | wip_alias = 'WIP' 5 | done_alias = 'DONE' 6 | cancelled_alias = 'CANCELLED' 7 | uid_col_name = 'UID' 8 | age_col_name = 'Age' 9 | spent_col_name = 'Spent' 10 | prio_col_name = 'Prio' 11 | project_col_name = 'Project' 12 | tags_col_name = 'Tags' 13 | status_col_name = 'Status' 14 | description_col_name = 'Description' 15 | display_empty_cols = false 16 | max_description_lines = 2 17 | notes_nb_col_name = "Notes" 18 | display_tags_listings = true 19 | 20 | [colors.description.ongoing] 21 | foreground = 'black' 22 | background = 'bright green' 23 | 24 | [colors.description.todo] 25 | foreground = 'bright white' 26 | background = 'black' 27 | 28 | [colors.description.done] 29 | foreground = 'bright black' 30 | background = 'black' 31 | style = ['dimmed'] 32 | 33 | [colors.description.cancelled] 34 | foreground = 'bright black' 35 | background = 'black' 36 | style = [ 37 | 'dimmed', 38 | 'strikethrough', 39 | ] 40 | 41 | [colors.status.ongoing] 42 | foreground = 'green' 43 | style = ['bold'] 44 | 45 | [colors.status.todo] 46 | foreground = 'magenta' 47 | style = ['bold'] 48 | 49 | [colors.status.done] 50 | foreground = 'bright black' 51 | style = ['dimmed'] 52 | 53 | [colors.status.cancelled] 54 | foreground = 'bright red' 55 | style = ['dimmed'] 56 | 57 | [colors.priority.low] 58 | foreground = 'bright black' 59 | style = ['dimmed'] 60 | 61 | [colors.priority.medium] 62 | foreground = 'blue' 63 | 64 | [colors.priority.high] 65 | foreground = 'red' 66 | 67 | [colors.priority.critical] 68 | foreground = 'black' 69 | background = 'bright red' 70 | -------------------------------------------------------------------------------- /intg-tests/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "next_uid": 12, 3 | "tasks": { 4 | "2": { 5 | "name": "Foo", 6 | "history": [ 7 | { 8 | "Created": "2020-10-13T19:04:10.527938957Z" 9 | }, 10 | { 11 | "StatusChanged": { 12 | "event_date": "2020-10-13T19:04:10.527938957Z", 13 | "status": "Todo" 14 | } 15 | } 16 | ] 17 | }, 18 | "1": { 19 | "name": "Show the recent updates", 20 | "history": [ 21 | { 22 | "Created": "2020-10-13T18:12:34.160928899Z" 23 | }, 24 | { 25 | "StatusChanged": { 26 | "event_date": "2020-10-13T18:12:34.160928899Z", 27 | "status": "Todo" 28 | } 29 | } 30 | ] 31 | }, 32 | "11": { 33 | "name": "Foo", 34 | "history": [ 35 | { 36 | "Created": "2021-01-31T18:25:32.021734697Z" 37 | }, 38 | { 39 | "StatusChanged": { 40 | "event_date": "2021-01-31T18:25:32.021734697Z", 41 | "status": "Todo" 42 | } 43 | } 44 | ] 45 | }, 46 | "4": { 47 | "name": "Zoo", 48 | "history": [ 49 | { 50 | "Created": "2020-10-13T19:04:54.834792525Z" 51 | }, 52 | { 53 | "StatusChanged": { 54 | "event_date": "2020-10-13T19:04:54.834792525Z", 55 | "status": "Todo" 56 | } 57 | }, 58 | { 59 | "SetPriority": { 60 | "event_date": "2020-10-13T19:04:54.834795370Z", 61 | "priority": "Medium" 62 | } 63 | }, 64 | { 65 | "StatusChanged": { 66 | "event_date": "2020-10-13T19:04:54.834798776Z", 67 | "status": "Ongoing" 68 | } 69 | } 70 | ] 71 | }, 72 | "7": { 73 | "name": "This is done", 74 | "history": [ 75 | { 76 | "Created": "2020-10-15T12:06:20.564230601Z" 77 | }, 78 | { 79 | "StatusChanged": { 80 | "event_date": "2020-10-15T12:06:20.564230601Z", 81 | "status": "Todo" 82 | } 83 | }, 84 | { 85 | "SetProject": { 86 | "event_date": "2020-10-15T12:06:20.564232554Z", 87 | "project": "funny-project" 88 | } 89 | }, 90 | { 91 | "AddTag": { 92 | "event_date": "2020-10-15T12:06:20.564234217Z", 93 | "tag": "some-tags" 94 | } 95 | }, 96 | { 97 | "AddTag": { 98 | "event_date": "2020-10-15T12:06:20.564234548Z", 99 | "tag": "another-tag" 100 | } 101 | }, 102 | { 103 | "StatusChanged": { 104 | "event_date": "2020-10-15T12:06:20.564235951Z", 105 | "status": "Done" 106 | } 107 | }, 108 | { 109 | "SetPriority": { 110 | "event_date": "2020-10-15T12:07:10.294990990Z", 111 | "priority": "High" 112 | } 113 | } 114 | ] 115 | }, 116 | "0": { 117 | "name": "Work on a next iteration version of toodoux", 118 | "history": [ 119 | { 120 | "Created": "2020-10-13T18:12:07.366945740Z" 121 | }, 122 | { 123 | "StatusChanged": { 124 | "event_date": "2020-10-13T18:12:07.366945740Z", 125 | "status": "Todo" 126 | } 127 | }, 128 | { 129 | "StatusChanged": { 130 | "event_date": "2020-10-13T18:12:07.366955739Z", 131 | "status": "Ongoing" 132 | } 133 | }, 134 | { 135 | "SetProject": { 136 | "event_date": "2020-10-13T18:14:18.451702524Z", 137 | "project": "toodoux" 138 | } 139 | } 140 | ] 141 | }, 142 | "8": { 143 | "name": "We shouldn’t do this", 144 | "history": [ 145 | { 146 | "Created": "2020-10-15T12:06:32.845996823Z" 147 | }, 148 | { 149 | "StatusChanged": { 150 | "event_date": "2020-10-15T12:06:32.845996823Z", 151 | "status": "Todo" 152 | } 153 | }, 154 | { 155 | "StatusChanged": { 156 | "event_date": "2020-10-15T12:06:41.116349475Z", 157 | "status": "Cancelled" 158 | } 159 | } 160 | ] 161 | }, 162 | "3": { 163 | "name": "Bar", 164 | "history": [ 165 | { 166 | "Created": "2020-10-13T19:04:35.546321677Z" 167 | }, 168 | { 169 | "StatusChanged": { 170 | "event_date": "2020-10-13T19:04:35.546321677Z", 171 | "status": "Todo" 172 | } 173 | }, 174 | { 175 | "SetProject": { 176 | "event_date": "2020-10-13T19:04:35.546323530Z", 177 | "project": "super-cool-project" 178 | } 179 | }, 180 | { 181 | "AddTag": { 182 | "event_date": "2020-10-13T19:04:35.546325073Z", 183 | "tag": "tag0" 184 | } 185 | }, 186 | { 187 | "AddTag": { 188 | "event_date": "2020-10-13T19:04:35.546325444Z", 189 | "tag": "i-love-dogs" 190 | } 191 | }, 192 | { 193 | "SetPriority": { 194 | "event_date": "2020-10-13T19:04:35.546326416Z", 195 | "priority": "High" 196 | } 197 | }, 198 | { 199 | "NoteAdded": { 200 | "event_date": "2020-10-13T19:06:01Z", 201 | "content": "This is the first note. This is a test. Be advised." 202 | } 203 | }, 204 | { 205 | "NoteAdded": { 206 | "event_date": "2020-10-13T19:10:50Z", 207 | "content": "I’d like to discuss about my previous note. This is not a test. I repeat: this is not a test." 208 | } 209 | }, 210 | { 211 | "NoteAdded": { 212 | "event_date": "2020-11-16T00:38:03.545945645Z", 213 | "content": "# Yay!\n\nThis note was added in interactive mode with `neovim`!\n\n" 214 | } 215 | }, 216 | { 217 | "NoteReplaced": { 218 | "event_date": "2020-11-16T19:46:31.241326865Z", 219 | "note_uid": 1, 220 | "content": "I’d like to discuss about my previous note. This is not a test. I repeat: this is not a test.\n\nActually, it’s a test, I’m just kidding. lol. :D\n" 221 | } 222 | } 223 | ] 224 | }, 225 | "9": { 226 | "name": "Something with an initial note", 227 | "history": [ 228 | { 229 | "Created": "2021-01-31T18:18:58.067327610Z" 230 | }, 231 | { 232 | "StatusChanged": { 233 | "event_date": "2021-01-31T18:18:58.067327610Z", 234 | "status": "Todo" 235 | } 236 | }, 237 | { 238 | "SetPriority": { 239 | "event_date": "2021-01-31T18:18:58.067331016Z", 240 | "priority": "Critical" 241 | } 242 | }, 243 | { 244 | "StatusChanged": { 245 | "event_date": "2021-01-31T18:18:58.067334413Z", 246 | "status": "Ongoing" 247 | } 248 | } 249 | ] 250 | }, 251 | "6": { 252 | "name": "Do this right now!", 253 | "history": [ 254 | { 255 | "Created": "2020-10-13T19:05:13.148969605Z" 256 | }, 257 | { 258 | "StatusChanged": { 259 | "event_date": "2020-10-13T19:05:13.148969605Z", 260 | "status": "Todo" 261 | } 262 | }, 263 | { 264 | "AddTag": { 265 | "event_date": "2020-10-13T19:05:13.148972370Z", 266 | "tag": "urgent" 267 | } 268 | }, 269 | { 270 | "SetPriority": { 271 | "event_date": "2020-10-13T19:05:13.148973984Z", 272 | "priority": "Critical" 273 | } 274 | } 275 | ] 276 | }, 277 | "10": { 278 | "name": "Something with an initial note", 279 | "history": [ 280 | { 281 | "Created": "2021-01-31T18:19:08.713642314Z" 282 | }, 283 | { 284 | "StatusChanged": { 285 | "event_date": "2021-01-31T18:19:08.713642314Z", 286 | "status": "Todo" 287 | } 288 | }, 289 | { 290 | "SetPriority": { 291 | "event_date": "2021-01-31T18:19:08.713645971Z", 292 | "priority": "Critical" 293 | } 294 | }, 295 | { 296 | "StatusChanged": { 297 | "event_date": "2021-01-31T18:19:08.713649588Z", 298 | "status": "Ongoing" 299 | } 300 | } 301 | ] 302 | }, 303 | "5": { 304 | "name": "Zoo high", 305 | "history": [ 306 | { 307 | "Created": "2020-10-13T19:05:04.062904024Z" 308 | }, 309 | { 310 | "StatusChanged": { 311 | "event_date": "2020-10-13T19:05:04.062904024Z", 312 | "status": "Todo" 313 | } 314 | }, 315 | { 316 | "SetPriority": { 317 | "event_date": "2020-10-13T19:05:04.062907911Z", 318 | "priority": "High" 319 | } 320 | }, 321 | { 322 | "StatusChanged": { 323 | "event_date": "2020-10-13T19:05:04.062911948Z", 324 | "status": "Ongoing" 325 | } 326 | }, 327 | { 328 | "StatusChanged": { 329 | "event_date": "2021-01-31T15:59:03.582654482Z", 330 | "status": "Done" 331 | } 332 | } 333 | ] 334 | } 335 | } 336 | } -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2018" 2 | 3 | fn_args_layout = "Tall" 4 | force_explicit_abi = true 5 | hard_tabs = false 6 | max_width = 100 7 | merge_derives = true 8 | newline_style = "Unix" 9 | remove_nested_parens = true 10 | reorder_imports = true 11 | reorder_modules = true 12 | tab_spaces = 2 13 | use_field_init_shorthand = true 14 | use_small_heuristics = "Default" 15 | use_try_shorthand = true 16 | -------------------------------------------------------------------------------- /src/app/cli.rs: -------------------------------------------------------------------------------- 1 | //! Command line interface. 2 | 3 | use crate::{ 4 | interactive_editor::{interactively_edit, InteractiveEditingError}, 5 | term::Terminal, 6 | }; 7 | use chrono::{DateTime, Duration, Utc}; 8 | use colored::Colorize as _; 9 | use itertools::Itertools; 10 | use std::{fmt, fmt::Display, iter::once, path::PathBuf}; 11 | use structopt::StructOpt; 12 | use toodoux::{ 13 | config::Config, 14 | error::Error, 15 | filter::TaskDescriptionFilter, 16 | metadata::{Metadata, MetadataValidationError, Priority}, 17 | task::{Event, Status, Task, TaskManager, UID}, 18 | }; 19 | use unicode_width::UnicodeWidthStr; 20 | 21 | const PREVIOUS_NOTES_HELP_END_MARKER: &str = "---------------------- >8 ----------------------\n"; 22 | 23 | #[derive(Debug, StructOpt)] 24 | #[structopt( 25 | name = "toodoux", 26 | about = "A modern task / todo / note management tool." 27 | )] 28 | pub struct Command { 29 | /// UID of a task to operate on. 30 | pub task_uid: Option, 31 | 32 | #[structopt(subcommand)] 33 | pub subcmd: Option, 34 | 35 | /// Non-default config root to read data and configuration from. 36 | #[structopt(long, short)] 37 | pub config: Option, 38 | } 39 | 40 | #[derive(Debug, StructOpt)] 41 | pub enum SubCommand { 42 | /// Add a task. 43 | #[structopt(visible_aliases = &["a"])] 44 | Add { 45 | /// Mark the item as ONGOING. 46 | #[structopt(long)] 47 | start: bool, 48 | 49 | /// Mark the item as DONE. 50 | #[structopt(long)] 51 | done: bool, 52 | 53 | /// Log a note after creating the item. 54 | #[structopt(short, long)] 55 | note: bool, 56 | 57 | /// Content of the task. 58 | /// 59 | /// If nothing is set, an interactive prompt is spawned for you to enter the content 60 | /// of what to do. 61 | content: Vec, 62 | }, 63 | 64 | /// Edit a task. 65 | #[structopt(visible_aliases = &["e", "ed"])] 66 | Edit { 67 | /// Change the name or metadata of the task. 68 | content: Vec, 69 | }, 70 | 71 | /// Show the details of a task. 72 | #[structopt(visible_aliases = &["s"])] 73 | Show, 74 | 75 | /// Mark a task as todo. 76 | Todo, 77 | 78 | /// Mark a task as started. 79 | Start, 80 | 81 | /// Mark a task as done. 82 | Done, 83 | 84 | /// Mark a task as cancelled. 85 | Cancel, 86 | 87 | /// Remove a task. 88 | #[structopt(visible_aliases = &["r", "rm"])] 89 | Remove { 90 | /// Remove all the tasks. 91 | #[structopt(short, long)] 92 | all: bool, 93 | }, 94 | 95 | /// List all the tasks. 96 | #[structopt(visible_aliases = &["l", "ls"])] 97 | List { 98 | /// Filter with todo items. 99 | #[structopt(short, long)] 100 | todo: bool, 101 | 102 | /// Filter with started items. 103 | #[structopt(short, long)] 104 | start: bool, 105 | 106 | /// Filter with done items. 107 | #[structopt(short, long)] 108 | done: bool, 109 | 110 | /// Filter with cancelled items. 111 | #[structopt(short, long)] 112 | cancelled: bool, 113 | 114 | /// Do not filter the items and show them all. 115 | #[structopt(short, long)] 116 | all: bool, 117 | 118 | /// Apply filters ignoring case. 119 | #[structopt(short = "C", long)] 120 | case_insensitive: bool, 121 | 122 | /// Metadata filter. 123 | metadata_filter: Vec, 124 | }, 125 | 126 | /// List, add and edit notes. 127 | Note { 128 | /// UID of a note to operate on. 129 | note_uid: Option, 130 | 131 | #[structopt(subcommand)] 132 | subcmd: NoteCommand, 133 | }, 134 | 135 | /// Show the edit history of a task. 136 | History, 137 | 138 | /// Manipulate projects. 139 | #[structopt(visible_aliases = &["proj"])] 140 | Project(ProjectCommand), 141 | } 142 | 143 | #[derive(Debug, StructOpt)] 144 | pub enum NoteCommand { 145 | /// Add a new note. 146 | /// 147 | /// You will be prompted to write a note within an editor. 148 | #[structopt(visible_aliases = &["a"])] 149 | Add { 150 | /// Edit the note without note history. 151 | /// 152 | /// Overrides the user configuration. 153 | #[structopt(long)] 154 | no_history: bool, 155 | }, 156 | 157 | /// Edit a note. 158 | /// 159 | /// You will be prompted to edit the node within an editor. 160 | #[structopt(visible_aliases = &["ed", "e"])] 161 | Edit { 162 | /// Edit the note without note history. 163 | /// 164 | /// Overrides the user configuration. 165 | #[structopt(long)] 166 | no_history: bool, 167 | }, 168 | } 169 | 170 | #[derive(Debug, StructOpt)] 171 | pub enum ProjectCommand { 172 | /// Rename a project. 173 | /// 174 | /// This has the effect of renamming the project used for all tasks if their current project is the one to rename. 175 | Rename { 176 | /// Project to rename. 177 | current_project: String, 178 | 179 | /// New name of the project. 180 | new_project: String, 181 | }, 182 | } 183 | 184 | #[derive(Debug)] 185 | pub enum SubCmdError { 186 | MetadataValidationError(MetadataValidationError), 187 | CannotEditNote(String), 188 | EmptyNote, 189 | InteractiveEditingError(InteractiveEditingError), 190 | ToodouxError(Error), 191 | } 192 | 193 | impl fmt::Display for SubCmdError { 194 | fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { 195 | match *self { 196 | SubCmdError::MetadataValidationError(ref e) => write!(f, "metadata validation error: {}", e), 197 | SubCmdError::CannotEditNote(ref reason) => write!(f, "cannot edit note: {}", reason), 198 | SubCmdError::EmptyNote => f.write_str("the note was empty; nothing added"), 199 | SubCmdError::InteractiveEditingError(ref e) => write!(f, "interactive edit error: {}", e), 200 | SubCmdError::ToodouxError(ref e) => write!(f, "toodoux error: {}", e), 201 | } 202 | } 203 | } 204 | 205 | impl std::error::Error for SubCmdError {} 206 | 207 | impl From for SubCmdError { 208 | fn from(err: MetadataValidationError) -> Self { 209 | Self::MetadataValidationError(err) 210 | } 211 | } 212 | 213 | impl From for SubCmdError { 214 | fn from(err: InteractiveEditingError) -> Self { 215 | Self::InteractiveEditingError(err) 216 | } 217 | } 218 | 219 | impl From for SubCmdError { 220 | fn from(err: Error) -> Self { 221 | Self::ToodouxError(err) 222 | } 223 | } 224 | 225 | pub struct CLI { 226 | config: Config, 227 | term: Term, 228 | } 229 | 230 | impl CLI 231 | where 232 | Term: Terminal, 233 | { 234 | /// Create a CLI. 235 | pub fn new(config: Config, term: Term) -> Self { 236 | Self { config, term } 237 | } 238 | 239 | /// Run a subcommand of the CLI. 240 | pub fn run( 241 | &mut self, 242 | task_mgr: &mut TaskManager, 243 | subcmd: Option, 244 | task_uid: Option, 245 | ) -> Result<(), SubCmdError> { 246 | match subcmd { 247 | // default subcommand 248 | None => { 249 | self.list_active_tasks(task_mgr, true, true, false, false, false, false, vec![])?; 250 | } 251 | 252 | Some(subcmd) => { 253 | match subcmd { 254 | SubCommand::Add { 255 | start, 256 | done, 257 | note: with_note, 258 | content, 259 | } => { 260 | if task_uid.is_none() { 261 | let uid = self.add_task(task_mgr, start, done, content)?; 262 | 263 | // TODO: rework this while refactoring 264 | if with_note { 265 | if let Some(task) = task_mgr.get_mut(uid) { 266 | let note = interactively_edit_note(&self.config, false, &task, "")?; 267 | task.add_note(note); 268 | task_mgr.save(&self.config)?; 269 | } 270 | } 271 | } else { 272 | println!( 273 | "{}", 274 | "cannot add a task to another one; maybe you were looking for dependencies instead?" 275 | .red() 276 | ); 277 | } 278 | } 279 | 280 | SubCommand::Edit { content } => { 281 | if let Some(task) = task_uid.and_then(|uid| task_mgr.get_mut(uid)) { 282 | Self::edit_task(task, content.iter().map(String::as_str))?; 283 | task_mgr.save(&self.config)?; 284 | } else { 285 | println!("{}", "missing or unknown task to edit".red()); 286 | } 287 | } 288 | 289 | SubCommand::Show => { 290 | if let Some((uid, task)) = 291 | task_uid.and_then(|uid| task_mgr.get(uid).map(|task| (uid, task))) 292 | { 293 | self.show_task(uid, task); 294 | } else { 295 | println!("{}", "missing or unknown task to show".red()); 296 | } 297 | } 298 | 299 | SubCommand::Todo => { 300 | if let Some(task) = task_uid.and_then(|uid| task_mgr.get_mut(uid)) { 301 | task.change_status(Status::Todo); 302 | task_mgr.save(&self.config)?; 303 | } else { 304 | println!("{}", "missing or unknown task".red()); 305 | } 306 | } 307 | 308 | SubCommand::Start => { 309 | if let Some(task) = task_uid.and_then(|uid| task_mgr.get_mut(uid)) { 310 | task.change_status(Status::Ongoing); 311 | task_mgr.save(&self.config)?; 312 | } else { 313 | println!("{}", "missing or unknown task to start".red()); 314 | } 315 | } 316 | 317 | SubCommand::Done => { 318 | if let Some(task) = task_uid.and_then(|uid| task_mgr.get_mut(uid)) { 319 | task.change_status(Status::Done); 320 | task_mgr.save(&self.config)?; 321 | } else { 322 | println!("{}", "missing or unknown task to finish".red()); 323 | } 324 | } 325 | 326 | SubCommand::Cancel => { 327 | if let Some(task) = task_uid.and_then(|uid| task_mgr.get_mut(uid)) { 328 | task.change_status(Status::Cancelled); 329 | task_mgr.save(&self.config)?; 330 | } else { 331 | println!("{}", "missing or unknown task to cancel".red()); 332 | } 333 | } 334 | 335 | SubCommand::Remove { .. } => {} 336 | 337 | SubCommand::List { 338 | todo, 339 | start, 340 | done, 341 | cancelled, 342 | all, 343 | case_insensitive, 344 | metadata_filter, 345 | } => { 346 | self.list_active_tasks( 347 | task_mgr, 348 | todo, 349 | start, 350 | cancelled, 351 | done, 352 | all, 353 | case_insensitive, 354 | metadata_filter, 355 | )?; 356 | } 357 | 358 | // TODO: simplify this pile of shit. 359 | SubCommand::Note { note_uid, subcmd } => { 360 | if let Some((uid, task)) = 361 | task_uid.and_then(|uid| task_mgr.get_mut(uid).map(|task| (uid, task))) 362 | { 363 | match subcmd { 364 | NoteCommand::Add { no_history } => { 365 | let note = interactively_edit_note( 366 | &self.config, 367 | !no_history && self.config.previous_notes_help(), 368 | &task, 369 | "\n", 370 | )?; 371 | task.add_note(note); 372 | task_mgr.save(&self.config)?; 373 | } 374 | 375 | NoteCommand::Edit { no_history } => { 376 | if let Some(note_uid) = note_uid { 377 | // get the note so that we can put it in the temporary file 378 | let notes = task.notes(); 379 | let note_uid = note_uid.dec(); 380 | let prenote = notes 381 | .get(usize::from(note_uid)) 382 | .map(|note| note.content.as_str()) 383 | .unwrap_or_default(); 384 | 385 | // open an interactive editor and replace the previous note 386 | let note = interactively_edit_note( 387 | &self.config, 388 | !no_history && self.config.previous_notes_help(), 389 | &task, 390 | prenote, 391 | )?; 392 | task.replace_note(note_uid, note)?; 393 | task_mgr.save(&self.config)?; 394 | } else { 395 | println!( 396 | "{}", 397 | format!("cannot edit task {}’s note: no note UID provided", uid).red() 398 | ); 399 | } 400 | } 401 | } 402 | } else { 403 | println!( 404 | "{}", 405 | "missing or unknown task to add, edit or list notes about".red() 406 | ); 407 | } 408 | } 409 | 410 | SubCommand::History => { 411 | if let Some((uid, task)) = 412 | task_uid.and_then(|uid| task_mgr.get(uid).map(|task| (uid, task))) 413 | { 414 | self.show_task_history(uid, task); 415 | } else { 416 | println!("{}", "missing or unknown task to display history".red()); 417 | } 418 | } 419 | 420 | SubCommand::Project(ProjectCommand::Rename { 421 | current_project, 422 | new_project, 423 | }) => { 424 | Self::rename_project(task_mgr, current_project, new_project); 425 | task_mgr.save(&self.config)?; 426 | } 427 | } 428 | } 429 | } 430 | 431 | Ok(()) 432 | } 433 | 434 | /// Extract metadata and print them (if any) on screen to help the user know what they are using. 435 | fn extract_metadata( 436 | metadata_filter: &[String], 437 | ) -> Result<(Vec, String), MetadataValidationError> { 438 | let (metadata, name) = Metadata::from_words(metadata_filter.iter().map(String::as_str)); 439 | Metadata::validate(&metadata)?; 440 | 441 | if !metadata.is_empty() { 442 | print!( 443 | "{} {} {}", 444 | "[".bright_black(), 445 | metadata.iter().map(Metadata::filter_like).format(", "), 446 | "]".bright_black() 447 | ); 448 | } 449 | 450 | Ok((metadata, name)) 451 | } 452 | 453 | /// Extract name filters and print them (if any) on screen to help the user know what they are using. 454 | fn extract_name_filters<'a>(name: &'a str, case_insensitive: bool) -> TaskDescriptionFilter<'a> { 455 | let name_filter = TaskDescriptionFilter::new(name.split_ascii_whitespace(), case_insensitive); 456 | 457 | if !name_filter.is_empty() { 458 | println!( 459 | "{} {}: {} {}", 460 | "[".bright_black(), 461 | "contains".italic(), 462 | name_filter.terms().format(", "), 463 | "]".bright_black() 464 | ); 465 | } else { 466 | println!(); 467 | } 468 | 469 | name_filter 470 | } 471 | 472 | /// List all tasks. 473 | /// 474 | /// The various arguments allow to refine the listing. 475 | pub fn list_tasks( 476 | &self, 477 | task_mgr: &TaskManager, 478 | todo: bool, 479 | start: bool, 480 | cancelled: bool, 481 | done: bool, 482 | case_insensitive: bool, 483 | metadata_filter: Vec, 484 | ) -> Result<(), SubCmdError> { 485 | // extract metadata if any and build the name filter 486 | let (metadata, name) = Self::extract_metadata(&metadata_filter)?; 487 | 488 | // put an extra space between sections (metadata and name filter) if they are both present 489 | if !metadata.is_empty() && !name.is_empty() { 490 | print!(" "); 491 | } 492 | 493 | let name_filter = Self::extract_name_filters(&name, case_insensitive); 494 | 495 | // get the filtered tasks 496 | let tasks = task_mgr.filtered_task_listing( 497 | metadata, 498 | name_filter, 499 | todo, 500 | start, 501 | done, 502 | cancelled, 503 | case_insensitive, 504 | ); 505 | 506 | // precompute a bunch of data for display widths / padding / etc. 507 | let display_opts = DisplayOptions::new( 508 | &self.config, 509 | &self.term, 510 | tasks.iter().map(|&(uid, task)| (*uid, task)), 511 | ); 512 | 513 | // actual display 514 | // only display header if there are tasks to display 515 | if !tasks.is_empty() { 516 | self.display_task_header(&display_opts); 517 | } 518 | 519 | for (&uid, task) in tasks { 520 | self.display_task_inline(uid, task, &display_opts); 521 | } 522 | 523 | Ok(()) 524 | } 525 | 526 | pub fn list_active_tasks( 527 | &self, 528 | task_mgr: &TaskManager, 529 | mut todo: bool, 530 | mut start: bool, 531 | mut cancelled: bool, 532 | mut done: bool, 533 | all: bool, 534 | case_insensitive: bool, 535 | metadata_filter: Vec, 536 | ) -> Result<(), SubCmdError> { 537 | // handle filtering logic 538 | if all { 539 | todo = true; 540 | start = true; 541 | done = true; 542 | cancelled = true; 543 | } else if !(todo || start || done || cancelled) { 544 | // if nothing is set, we use “sensible” defaults by listing only “active” tasks (todo and ongoing) 545 | todo = true; 546 | start = true; 547 | } 548 | 549 | self.list_tasks( 550 | task_mgr, 551 | todo, 552 | start, 553 | cancelled, 554 | done, 555 | case_insensitive, 556 | metadata_filter, 557 | ) 558 | } 559 | 560 | /// Display the header of tasks. 561 | fn display_task_header(&self, opts: &DisplayOptions) { 562 | print!( 563 | " {uid:() 704 | .yellow(), 705 | tags_width = opts.tags_width, 706 | ); 707 | } 708 | 709 | /// Display a description by respecting the allowed description column size. 710 | /// 711 | /// The description is not displayed if no space is available on screen. 712 | fn display_description(&self, opts: &DisplayOptions, status: Status, description: &str) { 713 | if let Some(max_description_cols) = opts.max_description_cols { 714 | let mut line_index = 0; // line number we are currently at; cannot exceed config.max_description_lines() 715 | let mut rel_offset = 0; // unicode offset in the current line; cannot exceed the description width 716 | let mut line_buffer = String::new(); // buffer for the current line 717 | let description_width = opts.description_width.min(max_description_cols); 718 | 719 | // The algorithm is a bit convoluted, so here’s a bit of explanation. It’s an iterative algorithm that splits the 720 | // description into an iterator over words. Each word has a unicode width, which is used to determine whether 721 | // appending it to the buffer line will make it longer than the description width. The tricky part comes in with 722 | // the fact that we want to display a ellipsis character if the next word is too long (…) and that we would end up 723 | // on more line than required. 724 | // 725 | // Before adding a new word, we check that its size + 1 added to the current unicode offset is still smaller than 726 | // the acceptable description width. If it is not the case, it means that adding this word would be out of sight, 727 | // so it has to be put on another line. However, if we cannot add another line, we simply add “…” to the current 728 | // line buffer and we are done. Otherwise, we just go to the next line, reset the offset and output the word. If we 729 | // haven’t passed the end of the line, we simply output the word. 730 | print!(" "); 731 | for word in description.split_ascii_whitespace() { 732 | let word_size = word.width() + 1; // TODO: check what to do about CJK 733 | 734 | if rel_offset + word_size > description_width { 735 | // we’ve passed the end of the line; break into another line 736 | line_index += 1; 737 | 738 | if line_index >= self.config.max_description_lines() { 739 | // we reserve the last column for … 740 | // we cannot create another line; add the ellipsis (…) character and stop 741 | line_buffer.push('…'); 742 | break; 743 | } 744 | 745 | // we can create another line; display the line buffer first 746 | let hl_description = self.highlight_description_line(status, &line_buffer); 747 | println!("{: 0 { 757 | line_buffer.push(' '); 758 | } 759 | 760 | line_buffer.push_str(word); 761 | rel_offset += word_size; 762 | } 763 | } 764 | 765 | let hl_description = self.highlight_description_line(status, &line_buffer); 766 | println!("{: impl Display { 772 | let dur = 773 | Utc::now().signed_duration_since(task.creation_date().cloned().unwrap_or_else(Utc::now)); 774 | Self::friendly_duration(dur) 775 | } 776 | 777 | /// Friendly representation of duration. 778 | fn friendly_duration(dur: Duration) -> String { 779 | if dur.num_minutes() < 1 { 780 | format!("{}s", dur.num_seconds()) 781 | } else if dur.num_hours() < 1 { 782 | format!("{}min", dur.num_minutes()) 783 | } else if dur.num_days() < 1 { 784 | format!("{}h", dur.num_hours()) 785 | } else if dur.num_weeks() < 2 { 786 | format!("{}d", dur.num_days()) 787 | } else if dur.num_weeks() < 4 { 788 | // less than four weeks 789 | format!("{}w", dur.num_weeks()) 790 | } else { 791 | format!("{}mth", dur.num_weeks() / 4) 792 | } 793 | } 794 | 795 | /// String representation of a spent-time. 796 | /// 797 | /// If no time has been spent on this task, an empty string is returned. 798 | fn friendly_spent_time(dur: Duration, status: Status) -> impl Display { 799 | if dur == Duration::zero() { 800 | return String::new().normal(); 801 | } 802 | 803 | let output = Self::friendly_duration(dur); 804 | 805 | match status { 806 | Status::Ongoing => output.blue(), 807 | _ => output.bright_black(), 808 | } 809 | } 810 | 811 | /// Friendly representation of priorities. 812 | fn friendly_priority(&self, prio: Priority) -> impl Display { 813 | match prio { 814 | Priority::Low => self.config.colors.priority.low.highlight("LOW"), 815 | Priority::Medium => self.config.colors.priority.medium.highlight("MED"), 816 | Priority::High => self.config.colors.priority.high.highlight("HIGH"), 817 | Priority::Critical => self.config.colors.priority.critical.highlight("CRIT"), 818 | } 819 | } 820 | 821 | /// Friendly representation of a project name. 822 | fn friendly_project(project: impl AsRef) -> impl Display { 823 | project.as_ref().italic() 824 | } 825 | 826 | /// Friendly representation of a number of notes. 827 | fn friendly_notes_nb(nb: usize) -> impl Display { 828 | if nb != 0 { 829 | nb.to_string().blue().italic() 830 | } else { 831 | "".normal() 832 | } 833 | } 834 | 835 | /// Friendly representation of a status. 836 | fn highlight_status(&self, status: Status) -> impl Display { 837 | match status { 838 | Status::Todo => self 839 | .config 840 | .colors 841 | .status 842 | .todo 843 | .highlight(self.config.todo_alias()), 844 | Status::Ongoing => self 845 | .config 846 | .colors 847 | .status 848 | .ongoing 849 | .highlight(self.config.wip_alias()), 850 | Status::Done => self 851 | .config 852 | .colors 853 | .status 854 | .done 855 | .highlight(self.config.done_alias()), 856 | Status::Cancelled => self 857 | .config 858 | .colors 859 | .status 860 | .cancelled 861 | .highlight(self.config.cancelled_alias()), 862 | } 863 | } 864 | 865 | /// Highlight a description line 866 | fn highlight_description_line(&self, status: Status, line: &str) -> impl Display { 867 | match status { 868 | Status::Todo => self.config.colors.description.todo.highlight(line), 869 | Status::Ongoing => self.config.colors.description.ongoing.highlight(line), 870 | Status::Done => self.config.colors.description.done.highlight(line), 871 | Status::Cancelled => self.config.colors.description.cancelled.highlight(line), 872 | } 873 | } 874 | 875 | /// Friendly string representation of a date. 876 | fn friendly_date_time(date_time: &DateTime) -> impl Display { 877 | date_time_to_string(date_time).italic().blue() 878 | } 879 | 880 | /// Add a new task. 881 | pub fn add_task( 882 | &mut self, 883 | task_mgr: &mut TaskManager, 884 | start: bool, 885 | done: bool, 886 | content: Vec, 887 | ) -> Result { 888 | // validate the metadata extracted from the content, if any 889 | let (metadata, name) = Metadata::from_words(content.iter().map(|s| s.as_str())); 890 | Metadata::validate(&metadata)?; 891 | 892 | let mut task = Task::new(name); 893 | 894 | // apply the metadata 895 | task.apply_metadata(metadata); 896 | 897 | // determine if we need to switch to another status 898 | if start { 899 | task.change_status(Status::Ongoing); 900 | } else if done { 901 | task.change_status(Status::Done); 902 | } 903 | 904 | let uid = task_mgr.register_task(task.clone()); 905 | task_mgr.save(&self.config)?; 906 | 907 | // display options 908 | let display_opts = DisplayOptions::new(&self.config, &self.term, once((uid, &task))); 909 | 910 | self.display_task_header(&display_opts); 911 | self.display_task_inline(uid, &task, &display_opts); 912 | 913 | Ok(uid) 914 | } 915 | 916 | /// Edit a task’s name or metadata. 917 | pub fn edit_task<'a>( 918 | task: &mut Task, 919 | content: impl IntoIterator, 920 | ) -> Result<(), SubCmdError> { 921 | // validate the metadata extracted from the content, if any 922 | let (metadata, name) = Metadata::from_words(content); 923 | Metadata::validate(&metadata)?; 924 | 925 | // apply the metadata 926 | task.apply_metadata(metadata); 927 | 928 | // if we have a new name, apply it too 929 | if !name.is_empty() { 930 | task.change_name(name); 931 | } 932 | 933 | Ok(()) 934 | } 935 | 936 | /// Show a task. 937 | pub fn show_task(&self, uid: UID, task: &Task) { 938 | let header_hl = &self.config.colors.show_header; 939 | let status = task.status(); 940 | 941 | println!( 942 | " {}: {}", 943 | header_hl.highlight(self.config.description_col_name()), 944 | task.name().bold() 945 | ); 946 | println!( 947 | " {}: {}", 948 | header_hl.highlight(self.config.uid_col_name()), 949 | uid 950 | ); 951 | println!( 952 | " {}: {}", 953 | header_hl.highlight(self.config.age_col_name()), 954 | Self::friendly_task_age(task) 955 | ); 956 | 957 | let spent_time = task.spent_time(); 958 | if spent_time == Duration::zero() { 959 | println!( 960 | " {}: {}", 961 | header_hl.highlight(self.config.spent_col_name()), 962 | "not started yet".bright_black().italic() 963 | ); 964 | } else { 965 | println!( 966 | " {}: {}", 967 | header_hl.highlight(self.config.spent_col_name()), 968 | Self::friendly_spent_time(task.spent_time(), status) 969 | ); 970 | } 971 | 972 | if let Some(prio) = task.priority() { 973 | println!( 974 | " {}: {}", 975 | header_hl.highlight(self.config.prio_col_name()), 976 | self.friendly_priority(prio) 977 | ); 978 | } 979 | 980 | if let Some(project) = task.project() { 981 | println!( 982 | " {}: {}", 983 | header_hl.highlight(self.config.project_col_name()), 984 | Self::friendly_project(project) 985 | ); 986 | } 987 | 988 | let mut tags = task.tags(); 989 | 990 | if let Some(first_tag) = tags.next() { 991 | let hash = "#".bright_black(); 992 | 993 | print!(" {}: ", header_hl.highlight("Tags")); 994 | print!("{}{}", hash, first_tag.yellow()); 995 | 996 | for tag in tags { 997 | print!(", {}{}", hash, tag.yellow()); 998 | } 999 | 1000 | println!(); 1001 | } 1002 | 1003 | println!( 1004 | " {}: {}", 1005 | header_hl.highlight(self.config.status_col_name()), 1006 | self.highlight_status(status) 1007 | ); 1008 | 1009 | println!(); 1010 | 1011 | // show the notes 1012 | for (nb, note) in task.notes().into_iter().enumerate() { 1013 | print!( 1014 | "{}{}{}{}", 1015 | " Note #".bright_black().italic(), 1016 | (nb + 1).to_string().blue().italic(), 1017 | ", on ".bright_black().italic(), 1018 | Self::friendly_date_time(¬e.creation_date) 1019 | ); 1020 | 1021 | if note.last_modification_date != note.creation_date { 1022 | print!( 1023 | "{}{}", 1024 | ", edited on ".bright_black().italic(), 1025 | Self::friendly_date_time(¬e.last_modification_date) 1026 | ); 1027 | } 1028 | println!(); 1029 | 1030 | println!("{}", note.content.trim()); 1031 | println!(); 1032 | } 1033 | } 1034 | 1035 | pub fn show_task_history(&self, uid: UID, task: &Task) { 1036 | for event in task.history() { 1037 | // Extract event date from all variants 1038 | match event { 1039 | Event::Created(event_date) 1040 | | Event::StatusChanged { event_date, .. } 1041 | | Event::NoteAdded { event_date, .. } 1042 | | Event::NoteReplaced { event_date, .. } 1043 | | Event::SetProject { event_date, .. } 1044 | | Event::SetPriority { event_date, .. } 1045 | | Event::AddTag { event_date, .. } => { 1046 | print!("{}: ", Self::friendly_date_time(event_date)); 1047 | } 1048 | } 1049 | 1050 | match event { 1051 | Event::Created(_) => { 1052 | println!("{} {}", "Task created with uid".bright_black(), uid); 1053 | } 1054 | 1055 | Event::StatusChanged { status, .. } => { 1056 | println!( 1057 | "{} {}", 1058 | "Status changed to".bright_black(), 1059 | self.highlight_status(*status) 1060 | ); 1061 | } 1062 | 1063 | Event::NoteAdded { content, .. } => { 1064 | println!("{} {}", "Note added".bright_black(), content); 1065 | } 1066 | 1067 | Event::NoteReplaced { 1068 | content, note_uid, .. 1069 | } => { 1070 | println!( 1071 | "{} {} {} {}", 1072 | "Note".bright_black(), 1073 | note_uid.to_string().blue(), 1074 | "updated".bright_black(), 1075 | content 1076 | ); 1077 | } 1078 | 1079 | Event::SetProject { project, .. } => { 1080 | println!( 1081 | "{} {}", 1082 | "Project set to".bright_black(), 1083 | Self::friendly_project(project) 1084 | ); 1085 | } 1086 | 1087 | Event::SetPriority { priority, .. } => { 1088 | println!( 1089 | "{} {}", 1090 | "Priority set to".bright_black(), 1091 | self.friendly_priority(*priority) 1092 | ); 1093 | } 1094 | 1095 | Event::AddTag { tag, .. } => { 1096 | println!("{}{}", "Tag added #".bright_black(), tag.yellow()); 1097 | } 1098 | } 1099 | } 1100 | } 1101 | 1102 | pub fn rename_project( 1103 | task_mgr: &mut TaskManager, 1104 | current_project: impl AsRef, 1105 | new_project: impl AsRef, 1106 | ) { 1107 | let current_project = current_project.as_ref(); 1108 | let new_project = new_project.as_ref(); 1109 | let mut count = 0; 1110 | 1111 | task_mgr.rename_project(¤t_project, &new_project, |_| { 1112 | count += 1; 1113 | }); 1114 | 1115 | if count != 0 { 1116 | println!("updated {} tasks", count); 1117 | } else { 1118 | println!("{}", "no task for this project".yellow()); 1119 | } 1120 | } 1121 | } 1122 | 1123 | /// Display options to use when rendering in CLI. 1124 | struct DisplayOptions { 1125 | /// Width of the task UID column. 1126 | task_uid_width: usize, 1127 | /// Width of the task age column. 1128 | age_width: usize, 1129 | /// Width of the task spent column. 1130 | spent_width: usize, 1131 | /// Width of the task status column. 1132 | status_width: usize, 1133 | /// Width of the task description column. 1134 | description_width: usize, 1135 | /// Width of the task project column. 1136 | project_width: usize, 1137 | /// Width of the task tags column. 1138 | tags_width: usize, 1139 | /// Whether any task has spent time. 1140 | has_spent_time: bool, 1141 | /// Whether we have a priority in at least one task. 1142 | has_priorities: bool, 1143 | /// Whether we have a project in at least one task. 1144 | has_projects: bool, 1145 | /// Whether we have a tag in at least one task. 1146 | has_tags: bool, 1147 | /// Offset to use for the description column. 1148 | description_offset: usize, 1149 | /// Maximum columns to fit in the description column. 1150 | /// 1151 | /// [`None`] implies that the dimension of the terminal don’t allow for descriptions. 1152 | max_description_cols: Option, 1153 | /// With of the number of notes column. 1154 | /// 1155 | /// `0` indicates no data. 1156 | notes_nb_width: usize, 1157 | } 1158 | 1159 | impl DisplayOptions { 1160 | /// Create a new renderer for a set of tasks. 1161 | fn new<'a>( 1162 | config: &Config, 1163 | term: &impl Terminal, 1164 | tasks: impl IntoIterator, 1165 | ) -> Self { 1166 | // FIXME: switch to a builder pattern here, because it’s starting to becoming a mess 1167 | let ( 1168 | task_uid_width, 1169 | age_width, 1170 | spent_width, 1171 | status_width, 1172 | description_width, 1173 | project_width, 1174 | tags_width, 1175 | has_spent_time, 1176 | has_priorities, 1177 | has_projects, 1178 | has_tags, 1179 | notes_nb_width, 1180 | ) = tasks.into_iter().fold( 1181 | (0, 0, 0, 0, 0, 0, 0, false, false, false, false, 0), 1182 | |( 1183 | task_uid_width, 1184 | age_width, 1185 | spent_width, 1186 | status_width, 1187 | description_width, 1188 | project_width, 1189 | tags_width, 1190 | has_spent_time, 1191 | has_priorities, 1192 | has_projects, 1193 | has_tags, 1194 | notes_nb_width, 1195 | ), 1196 | (uid, task)| { 1197 | let task_uid_width = task_uid_width.max(Self::guess_task_uid_width(uid)); 1198 | let age_width = age_width.max(Self::guess_duration_width(&task.age())); 1199 | let spent_width = spent_width.max(Self::guess_duration_width(&task.spent_time())); 1200 | let status_width = status_width.max(Self::guess_task_status_width(&config, task.status())); 1201 | let description_width = description_width.max(task.name().width()); 1202 | let project_width = project_width.max(Self::guess_task_project_width(&task).unwrap_or(0)); 1203 | let tags_width = tags_width.max(Self::guess_tags_width(&task)); 1204 | let has_spent_time = has_spent_time || task.spent_time() != Duration::zero(); 1205 | let has_priorities = has_priorities || task.priority().is_some(); 1206 | let has_projects = has_projects || task.project().is_some(); 1207 | let has_tags = has_tags || task.tags().next().is_some(); 1208 | let notes_nb_width = notes_nb_width.max(Self::guess_notes_width( 1209 | task.notes().iter().map(|note| note.content.as_str()), 1210 | )); 1211 | 1212 | ( 1213 | task_uid_width, 1214 | age_width, 1215 | spent_width, 1216 | status_width, 1217 | description_width, 1218 | project_width, 1219 | tags_width, 1220 | has_spent_time, 1221 | has_priorities, 1222 | has_projects, 1223 | has_tags, 1224 | notes_nb_width, 1225 | ) 1226 | }, 1227 | ); 1228 | 1229 | let mut opts = Self { 1230 | task_uid_width: task_uid_width.max(config.uid_col_name().width()), 1231 | age_width: age_width.max(config.age_col_name().width()), 1232 | spent_width: spent_width.max(config.spent_col_name().width()), 1233 | status_width: status_width.max(config.status_col_name().width()), 1234 | description_width: description_width.max(config.description_col_name().width()), 1235 | project_width: project_width.max(config.project_col_name().width()), 1236 | tags_width: tags_width.max(config.tags_col_name().width()), 1237 | has_spent_time, 1238 | has_priorities, 1239 | has_projects, 1240 | has_tags, 1241 | description_offset: 0, 1242 | max_description_cols: None, 1243 | notes_nb_width, 1244 | }; 1245 | 1246 | opts.description_offset = opts.guess_description_col_offset(config); 1247 | 1248 | if let Some(term_dims) = term.dimensions() { 1249 | opts.max_description_cols = term_dims[0].checked_sub(opts.description_offset); 1250 | } else { 1251 | println!( 1252 | "{}", 1253 | "⚠ You’re using a terminal that doesn’t expose its dimensions; expect broken output ⚠" 1254 | .yellow() 1255 | .bold() 1256 | ); 1257 | } 1258 | 1259 | opts 1260 | } 1261 | 1262 | /// Guess the number of characters needed to represent a number. 1263 | /// 1264 | /// We limit ourselves to number < 100000. 1265 | fn guess_number_width(mut val: usize) -> usize { 1266 | let mut w = 1; 1267 | 1268 | while val >= 10 { 1269 | val /= 10; 1270 | w += 1; 1271 | } 1272 | 1273 | w 1274 | } 1275 | 1276 | /// Guess the width required to represent the task UID. 1277 | fn guess_task_uid_width(uid: UID) -> usize { 1278 | Self::guess_number_width(uid.val() as _) 1279 | } 1280 | 1281 | /// Guess the width required to represent a duration. 1282 | /// 1283 | /// The width is smart enough to take into account the unit (s, min, h, d, w, m or y) as well as the number. 1284 | fn guess_duration_width(dur: &Duration) -> usize { 1285 | if dur.num_minutes() < 1 { 1286 | // seconds, encoded with "Ns" 1287 | Self::guess_number_width(dur.num_seconds() as _) + "s".len() 1288 | } else if dur.num_hours() < 1 { 1289 | // minutes, encoded with "Nmin" 1290 | Self::guess_number_width(dur.num_minutes() as _) + "min".len() 1291 | } else if dur.num_days() < 1 { 1292 | // hours, encoded with "Nh" 1293 | Self::guess_number_width(dur.num_hours() as _) + "h".len() 1294 | } else if dur.num_weeks() < 2 { 1295 | // days, encoded with "Nd" 1296 | Self::guess_number_width(dur.num_days() as _) + "d".len() 1297 | } else if dur.num_weeks() < 4 { 1298 | // weeks, encoded with "Nw" 1299 | Self::guess_number_width(dur.num_weeks() as _) + "w".len() 1300 | } else { 1301 | // months, encoded with "Nmth" 1302 | Self::guess_number_width(dur.num_weeks() as usize / 4) + "mth".len() 1303 | } 1304 | } 1305 | 1306 | /// Guess the width required to represent the task status. 1307 | fn guess_task_status_width(config: &Config, status: Status) -> usize { 1308 | let width = match status { 1309 | Status::Ongoing => config.wip_alias().width(), 1310 | Status::Todo => config.todo_alias().width(), 1311 | Status::Done => config.done_alias().width(), 1312 | Status::Cancelled => config.cancelled_alias().width(), 1313 | }; 1314 | 1315 | width.max("Status".len()) 1316 | } 1317 | 1318 | fn guess_task_project_width(task: &Task) -> Option { 1319 | task.project().map(UnicodeWidthStr::width) 1320 | } 1321 | 1322 | /// Guess the width required to represent the task tags. 1323 | fn guess_tags_width(task: &Task) -> usize { 1324 | Itertools::intersperse(task.tags(), ", ") 1325 | .map(UnicodeWidthStr::width) 1326 | .sum() 1327 | } 1328 | 1329 | /// Compute the column offset at which descriptions can start. 1330 | /// 1331 | /// The way we compute this is by summing all the display width and adding the require padding. 1332 | fn guess_description_col_offset(&self, config: &Config) -> usize { 1333 | let spent_width; 1334 | let prio_width; 1335 | let project_width; 1336 | let tags_width; 1337 | let notes_nb_width; 1338 | 1339 | if config.display_empty_cols() { 1340 | spent_width = self.spent_width + 1; 1341 | prio_width = config.prio_col_name().width() + 1; 1342 | project_width = self.project_width + 1; 1343 | tags_width = self.tags_width + 1; 1344 | notes_nb_width = self.notes_nb_width + 1; 1345 | } else { 1346 | // compute spent time if any 1347 | if self.has_spent_time { 1348 | spent_width = self.spent_width + 1; 1349 | } else { 1350 | spent_width = 0; 1351 | } 1352 | 1353 | // compute priority width if any 1354 | if self.has_priorities { 1355 | prio_width = config.prio_col_name().width() + 1; 1356 | } else { 1357 | prio_width = 0; 1358 | } 1359 | 1360 | // compute project width if any 1361 | if self.has_projects { 1362 | project_width = self.project_width + 1; // FIXME 1363 | } else { 1364 | project_width = 0; 1365 | } 1366 | 1367 | // compute tags width if any 1368 | if config.display_tags_listings() && self.has_tags { 1369 | tags_width = self.tags_width + 1; // FIXME 1370 | } else { 1371 | tags_width = 0; 1372 | } 1373 | 1374 | // compute notes number width if any 1375 | if self.notes_nb_width != 0 { 1376 | notes_nb_width = config.notes_nb_col_name().width() + 1; 1377 | } else { 1378 | notes_nb_width = 0; 1379 | } 1380 | } 1381 | 1382 | // The “+ 1” are there because of the blank spaces we have in the output to separate columns. 1383 | 1 + self.task_uid_width 1384 | + 1 1385 | + self.age_width 1386 | + 1 1387 | + spent_width 1388 | + prio_width 1389 | + project_width 1390 | + tags_width 1391 | + notes_nb_width 1392 | + self.status_width 1393 | + 1 // to end up on the first column in the description 1394 | } 1395 | 1396 | /// Guess the maximum width to align notes. 1397 | fn guess_notes_width<'a>(notes: impl Iterator) -> usize { 1398 | let nb = notes.count(); 1399 | 1400 | if nb == 0 { 1401 | 0 1402 | } else { 1403 | Self::guess_number_width(nb) 1404 | } 1405 | } 1406 | } 1407 | 1408 | /// Friendly string representation of a date. 1409 | pub fn date_time_to_string(date_time: &DateTime) -> String { 1410 | date_time.format("%a, %d %b %Y at %H:%M").to_string() 1411 | } 1412 | 1413 | /// Interactively edit a note for a given task. 1414 | /// 1415 | /// The note will be pre-populated by the note history if the config allows for it. The `prefill` argument allows to 1416 | /// pre-populate the content of the note. 1417 | /// 1418 | /// The note is returned as a [`String`]. 1419 | fn interactively_edit_note( 1420 | config: &Config, 1421 | with_history: bool, 1422 | task: &Task, 1423 | prefill: &str, 1424 | ) -> Result { 1425 | let prefill = if with_history { 1426 | // if we have the previously recorded note help, pre-populate the file with the previous notes 1427 | let mut new_prefill = task 1428 | .notes() 1429 | .into_iter() 1430 | .enumerate() 1431 | .map(|(i, note)| { 1432 | let modified_date_str = if note.last_modification_date >= note.creation_date { 1433 | format!( 1434 | ", modified on {}", 1435 | date_time_to_string(¬e.last_modification_date) 1436 | ) 1437 | } else { 1438 | String::new() 1439 | }; 1440 | 1441 | format!( 1442 | "> Note #{nb}, on {creation_date}{modification_date}\n{content}", 1443 | nb = i + 1, 1444 | creation_date = date_time_to_string(¬e.creation_date), 1445 | modification_date = modified_date_str, 1446 | content = note.content, 1447 | ) 1448 | }) 1449 | .join("\n\n"); 1450 | 1451 | new_prefill += 1452 | "> Above are the previously recorded notes. You are free to temper with them if you want.\n"; 1453 | new_prefill += "> You can add the content of your note under the following line. However, do not remove this line!\n"; 1454 | new_prefill += PREVIOUS_NOTES_HELP_END_MARKER; 1455 | new_prefill += prefill; 1456 | new_prefill 1457 | } else { 1458 | prefill.to_owned() 1459 | }; 1460 | 1461 | let note_content = interactively_edit(config, "NEW_NOTE.md", &prefill)?; 1462 | 1463 | if with_history { 1464 | if let Some(marker_index) = note_content.find(PREVIOUS_NOTES_HELP_END_MARKER) { 1465 | let content = note_content 1466 | .get(marker_index + PREVIOUS_NOTES_HELP_END_MARKER.len()..) 1467 | .unwrap(); 1468 | 1469 | if content.trim().is_empty() { 1470 | Err(SubCmdError::EmptyNote) 1471 | } else { 1472 | Ok(content.to_owned()) 1473 | } 1474 | } else { 1475 | Err(SubCmdError::CannotEditNote( 1476 | "I told you not to temper with this line!".to_owned(), 1477 | )) 1478 | } 1479 | } else { 1480 | if note_content.trim().is_empty() { 1481 | Err(SubCmdError::EmptyNote) 1482 | } else { 1483 | Ok(note_content) 1484 | } 1485 | } 1486 | } 1487 | 1488 | #[cfg(test)] 1489 | mod unit_tests { 1490 | use super::*; 1491 | 1492 | use toodoux::config::{ColorConfig, MainConfig}; 1493 | 1494 | struct DummyTerm { 1495 | dimensions: [usize; 2], 1496 | } 1497 | 1498 | impl DummyTerm { 1499 | pub fn new(dimensions: [usize; 2]) -> Self { 1500 | Self { dimensions } 1501 | } 1502 | } 1503 | 1504 | impl Terminal for DummyTerm { 1505 | fn dimensions(&self) -> Option<[usize; 2]> { 1506 | Some(self.dimensions) 1507 | } 1508 | } 1509 | 1510 | #[test] 1511 | fn guess_number_width() { 1512 | for i in 0..10 { 1513 | assert_eq!(DisplayOptions::guess_number_width(i), 1); 1514 | } 1515 | 1516 | for i in 10..100 { 1517 | assert_eq!(DisplayOptions::guess_number_width(i), 2); 1518 | } 1519 | 1520 | for i in 100..1000 { 1521 | assert_eq!(DisplayOptions::guess_number_width(i), 3); 1522 | } 1523 | } 1524 | 1525 | #[test] 1526 | fn guess_duration_width() { 1527 | assert_eq!( 1528 | DisplayOptions::guess_duration_width(&Duration::seconds(5)), 1529 | 2 1530 | ); // 5s 1531 | assert_eq!( 1532 | DisplayOptions::guess_duration_width(&Duration::seconds(10)), 1533 | 3 1534 | ); // 10s 1535 | assert_eq!( 1536 | DisplayOptions::guess_duration_width(&Duration::seconds(60)), 1537 | 4 1538 | ); // 1min 1539 | assert_eq!( 1540 | DisplayOptions::guess_duration_width(&Duration::minutes(59)), 1541 | 5 1542 | ); // 59min 1543 | } 1544 | 1545 | #[test] 1546 | fn display_options_term_width() { 1547 | let main_config = MainConfig::default(); 1548 | let config = Config::new(main_config, ColorConfig::default()); 1549 | let tasks = &[(UID::default(), &Task::new("Foo"))]; 1550 | let term = DummyTerm::new([100, 1]); 1551 | let opts = DisplayOptions::new(&config, &term, tasks.iter().copied()); 1552 | 1553 | let description_offset = " UID ".len() + "Age ".len() + "Status ".len(); 1554 | assert_eq!(opts.description_offset, description_offset,); 1555 | assert_eq!( 1556 | opts.max_description_cols, 1557 | Some(term.dimensions().unwrap()[0] - description_offset) 1558 | ); 1559 | } 1560 | 1561 | #[test] 1562 | fn display_options_should_yield_no_description_if_too_short() { 1563 | let main_config = MainConfig::default(); 1564 | let config = Config::new(main_config, ColorConfig::default()); 1565 | let tasks = &[(UID::default(), &Task::new("Foo"))]; 1566 | let term = DummyTerm::new([100, 1]); 1567 | let opts = DisplayOptions::new(&config, &term, tasks.iter().copied()); 1568 | 1569 | let description_offset = " UID ".len() + "Age ".len() + "Status ".len(); 1570 | assert_eq!(opts.description_offset, description_offset,); 1571 | assert_eq!( 1572 | opts.max_description_cols, 1573 | Some(term.dimensions().unwrap()[0] - description_offset) 1574 | ); 1575 | } 1576 | } 1577 | -------------------------------------------------------------------------------- /src/app/interactive_editor.rs: -------------------------------------------------------------------------------- 1 | //! Interactive editor session. 2 | //! 3 | //! This module provides a way to open an editor based on the `$EDITOR` environment variable or what is defined in the 4 | //! configuration. 5 | 6 | use std::{ 7 | env, error, fmt, fs, io, 8 | path::{Path, PathBuf}, 9 | process, 10 | string::FromUtf8Error, 11 | }; 12 | 13 | use toodoux::config::Config; 14 | 15 | /// Errors that can happen while interactively editing files. 16 | #[derive(Debug)] 17 | pub enum InteractiveEditingError { 18 | FileError(io::Error), 19 | MissingInteractiveEditor, 20 | InteractiveEditorError(PathBuf, io::Error), 21 | Utf8Error(FromUtf8Error), 22 | } 23 | 24 | impl fmt::Display for InteractiveEditingError { 25 | fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { 26 | match *self { 27 | InteractiveEditingError::FileError(ref err) => write!(f, "unable to open file: {}", err), 28 | InteractiveEditingError::MissingInteractiveEditor => f.write_str( 29 | "no interactive editor was found; consider configuring either $EDITOR or the configuration", 30 | ), 31 | InteractiveEditingError::InteractiveEditorError(ref path, ref err) => { 32 | write!( 33 | f, 34 | "interactive editor error at path {}: {}", 35 | path.display(), 36 | err 37 | ) 38 | } 39 | InteractiveEditingError::Utf8Error(ref err) => { 40 | write!(f, "error while decoding UTF-8: {}", err) 41 | } 42 | } 43 | } 44 | } 45 | 46 | impl error::Error for InteractiveEditingError {} 47 | 48 | impl From for InteractiveEditingError { 49 | fn from(err: io::Error) -> Self { 50 | Self::FileError(err) 51 | } 52 | } 53 | 54 | impl From for InteractiveEditingError { 55 | fn from(err: FromUtf8Error) -> Self { 56 | Self::Utf8Error(err) 57 | } 58 | } 59 | 60 | /// Open an interactive editor for the file named `file_name` and once the file is saved and the editor 61 | /// exits, returns what the file contains. 62 | /// 63 | /// If `content` contains a non-empty [`String`], its content will be automatically inserted in the file before opening 64 | /// the editor. 65 | pub fn interactively_edit( 66 | config: &Config, 67 | file_name: &str, 68 | content: &str, 69 | ) -> Result { 70 | log::debug!("creating temporary directory for interactive session"); 71 | let dir = tempdir::TempDir::new("")?; 72 | let file_path = dir.path().join(Path::new(file_name)); 73 | 74 | log::debug!("creating temporary file {}", file_path.display()); 75 | fs::write(&file_path, content)?; 76 | 77 | let editor; 78 | if let Some(env_editor) = env::var("EDITOR").ok() { 79 | if env_editor.is_empty() { 80 | return Err(InteractiveEditingError::MissingInteractiveEditor); 81 | } 82 | 83 | log::debug!("editing via $EDITOR ({})", env_editor); 84 | editor = env_editor; 85 | } else if let Some(conf_editor) = config.interactive_editor() { 86 | if conf_editor.is_empty() { 87 | return Err(InteractiveEditingError::MissingInteractiveEditor); 88 | } 89 | 90 | log::debug!("editing via configuration editor ({})", conf_editor); 91 | editor = conf_editor.to_owned(); 92 | } else { 93 | log::error!("cannot find a suitable interactive editor"); 94 | return Err(InteractiveEditingError::MissingInteractiveEditor); 95 | } 96 | 97 | let _ = process::Command::new(editor) 98 | .arg(&file_path) 99 | .arg("+$") 100 | .spawn() 101 | .map_err(|e| InteractiveEditingError::InteractiveEditorError(file_path.clone(), e))? 102 | .wait() 103 | .map_err(|e| InteractiveEditingError::InteractiveEditorError(file_path.clone(), e))?; 104 | let content = fs::read_to_string(file_path)?; 105 | 106 | Ok(content) 107 | } 108 | -------------------------------------------------------------------------------- /src/app/main.rs: -------------------------------------------------------------------------------- 1 | mod cli; 2 | mod interactive_editor; 3 | mod term; 4 | 5 | use crate::{ 6 | cli::{Command, SubCmdError, SubCommand}, 7 | term::DefaultTerm, 8 | }; 9 | use cli::CLI; 10 | use colored::Colorize as _; 11 | 12 | use std::{ 13 | io::{self, Write as _}, 14 | path::Path, 15 | }; 16 | use structopt::StructOpt; 17 | use toodoux::task::UID; 18 | use toodoux::{config::Config, task::TaskManager}; 19 | 20 | fn print_introduction_text() { 21 | println!( 22 | "Hello! It seems like you’re new to {toodoux}! 23 | 24 | {toodoux} is a modern take on task / todo lists, mostly based on the amazing emacs’ {org_mode} and {taskwarrior}. Instead of recreating the same plugin inside everybody’s favorite editors over and over, {toodoux} takes it the UNIX way and just does one thing good: {editing_tasks}. 25 | 26 | You will first be able to {add_tasks} new tasks, {edit_tasks} existing tasks, {remove_tasks} some and {list_tasks} them all. Then, you will be able to enjoy more advanced features, such as {capturing} and {refiling} tasks, {filtering} them, as well as {putting_deadlines}. Time metadata are automatically handled for you to follow along.", 27 | toodoux = "toodoux".purple().bold(), 28 | org_mode = "Org-Mode".purple().bold(), 29 | taskwarrior = "taskwarrior".purple().bold(), 30 | editing_tasks = "editing tasks".bold(), 31 | add_tasks = "add".green().bold(), 32 | edit_tasks = "edit".green().bold(), 33 | remove_tasks = "remove".green().bold(), 34 | list_tasks = "list".green().bold(), 35 | capturing = "capturing".green().bold(), 36 | refiling = "refiling".green().bold(), 37 | filtering = "filtering".green().bold(), 38 | putting_deadlines = "putting deadlines".green().bold(), 39 | ); 40 | } 41 | 42 | fn print_wizard_question() { 43 | print!( 44 | "\n{wizard_question} ({Y}/{n}) ➤ ", 45 | wizard_question = 46 | "You don’t seem to have a configuration set up…\nWould you like to set it up?".blue(), 47 | Y = "Y".green().bold(), 48 | n = "n".red(), 49 | ); 50 | 51 | io::stdout().flush().unwrap(); 52 | } 53 | 54 | fn print_no_file_information() { 55 | println!("\n{toodoux} {rest}", toodoux = "toodoux".purple().bold(), rest = "won’t work without a configuration file. If you don’t want to generate it via this interactive wizard, you can create it by hand and put it in the right folder, depending on the platform you run on.".red()); 56 | } 57 | 58 | fn main() { 59 | if let Err(err) = entry_point() { 60 | eprintln!("{}", err.to_string().red().bold()) 61 | } 62 | } 63 | 64 | fn entry_point() -> Result<(), SubCmdError> { 65 | let Command { 66 | subcmd, 67 | config, 68 | task_uid, 69 | } = Command::from_args(); // TODO: use the task_uid 70 | 71 | // initialize the logger 72 | log::debug!("initializing logger"); 73 | env_logger::init(); 74 | 75 | // override the config if explicitly passed a configuration path; otherwise, use the one by provided by default 76 | log::debug!("initializing configuration"); 77 | match config { 78 | Some(path) => initiate_explicit_config(path, subcmd, task_uid), 79 | None => initiate(subcmd, task_uid), 80 | } 81 | } 82 | 83 | /// Initiate configuration with an explicitly provided path. 84 | fn initiate_explicit_config( 85 | config_path: impl AsRef, 86 | subcmd: Option, 87 | task_uid: Option, 88 | ) -> Result<(), SubCmdError> { 89 | let path = config_path.as_ref(); 90 | let config = Config::from_dir(path)?; 91 | 92 | initiate_with_config(Some(path), config, subcmd, task_uid) 93 | } 94 | 95 | /// Initiate configuration by using the default configuration path. 96 | fn initiate(subcmd: Option, task_uid: Option) -> Result<(), SubCmdError> { 97 | let config = Config::get()?; 98 | initiate_with_config(None, config, subcmd, task_uid) 99 | } 100 | 101 | fn initiate_with_config( 102 | path: Option<&Path>, 103 | config: Option, 104 | subcmd: Option, 105 | task_uid: Option, 106 | ) -> Result<(), SubCmdError> { 107 | let term = DefaultTerm; 108 | 109 | match config { 110 | // explicit configuration 111 | Some(config) => { 112 | log::info!( 113 | "running on configuration at {}", 114 | config.root_dir().display() 115 | ); 116 | 117 | let mut task_mgr = TaskManager::new_from_config(&config)?; 118 | CLI::new(config, term).run(&mut task_mgr, subcmd, task_uid) 119 | } 120 | 121 | // no configuration; create it 122 | None => { 123 | log::warn!("no configuration detected"); 124 | 125 | let mut input = String::new(); 126 | 127 | // initiate configuration file creation wizard and create the configuration file 128 | print_introduction_text(); 129 | 130 | let must_create_config_file = loop { 131 | input.clear(); 132 | 133 | print_wizard_question(); 134 | io::stdin().read_line(&mut input).unwrap(); 135 | 136 | match input.trim_end() { 137 | "Y" | "y" | "" => { 138 | break true; 139 | } 140 | 141 | "N" | "n" => { 142 | break false; 143 | } 144 | 145 | _ => { 146 | println!("{}", "I’m so sorry, but I didn’t quite get that.".red()); 147 | } 148 | } 149 | }; 150 | 151 | if must_create_config_file { 152 | let config = Config::create(path)?; 153 | config.save()?; 154 | 155 | let mut task_mgr = TaskManager::new_from_config(&config)?; 156 | CLI::new(config, term).run(&mut task_mgr, subcmd, task_uid) 157 | } else { 158 | print_no_file_information(); 159 | Ok(()) 160 | } 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/app/term.rs: -------------------------------------------------------------------------------- 1 | //! An abstracton of a terminal. 2 | 3 | pub trait Terminal { 4 | /// Get the dimension (in characters / columns) of the terminal. 5 | fn dimensions(&self) -> Option<[usize; 2]>; 6 | } 7 | 8 | /// Default terminal abstraction.. 9 | pub struct DefaultTerm; 10 | 11 | impl Terminal for DefaultTerm { 12 | fn dimensions(&self) -> Option<[usize; 2]> { 13 | term_size::dimensions().map(|(w, h)| [w, h]) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | //! Initiate the configuration file creation when not present. 2 | 3 | use colored::{Color as Col, ColoredString, Colorize}; 4 | use core::fmt::Formatter; 5 | use serde::{ 6 | de::{self, Visitor}, 7 | Deserialize, Serialize, 8 | }; 9 | use std::{ 10 | fmt, fs, 11 | ops::Deref, 12 | path::{Path, PathBuf}, 13 | str::FromStr, 14 | }; 15 | 16 | use crate::error::Error; 17 | 18 | #[derive(Debug, Deserialize, Serialize, Default)] 19 | #[serde(default)] 20 | pub struct Config { 21 | pub main: MainConfig, 22 | pub colors: ColorConfig, 23 | } 24 | 25 | #[derive(Debug, Deserialize, Serialize)] 26 | #[serde(default)] 27 | pub struct MainConfig { 28 | /// Editor to use for interactive editing. 29 | /// 30 | /// If absent, default to `$EDITOR`. If neither the configuration or `$EDITOR` is set, 31 | /// interactive editing is disabled. 32 | interactive_editor: Option, 33 | 34 | /// Path to the folder containing all the tasks. 35 | tasks_file: PathBuf, 36 | 37 | /// Name of the “TODO” state. 38 | todo_alias: String, 39 | 40 | /// Name of the “ONGOING” state. 41 | wip_alias: String, 42 | 43 | /// Name of the “DONE” state. 44 | done_alias: String, 45 | 46 | /// Name of the “CANCELLED” state. 47 | cancelled_alias: String, 48 | 49 | /// “UID” column name. 50 | uid_col_name: String, 51 | 52 | /// “Age” column name. 53 | age_col_name: String, 54 | 55 | /// “Spent” column name. 56 | spent_col_name: String, 57 | 58 | /// “Prio” column name. 59 | prio_col_name: String, 60 | 61 | /// “Project” column name. 62 | project_col_name: String, 63 | 64 | /// “Tags” column name. 65 | tags_col_name: String, 66 | 67 | /// “Status” column name. 68 | status_col_name: String, 69 | 70 | /// “Description” column name. 71 | description_col_name: String, 72 | 73 | /// Should we display empty columns? 74 | display_empty_cols: bool, 75 | 76 | /// Maximum number of warping lines of task description before breaking it (and adding the ellipsis character). 77 | max_description_lines: usize, 78 | 79 | /// "Number of notes” column name." 80 | notes_nb_col_name: String, 81 | 82 | /// Display tags in listings. 83 | display_tags_listings: bool, 84 | 85 | /// Show the previous notes when adding a new note. 86 | /// 87 | /// This option allows to show all the previously recorded notes for a given task as a header of the current note. 88 | /// The note history will be automatically discarded and will not appear in the new note. 89 | previous_notes_help: bool, 90 | } 91 | 92 | impl Default for MainConfig { 93 | fn default() -> Self { 94 | Self { 95 | interactive_editor: None, 96 | tasks_file: dirs::config_dir().unwrap().join("toodoux"), 97 | todo_alias: "TODO".to_owned(), 98 | wip_alias: "WIP".to_owned(), 99 | done_alias: "DONE".to_owned(), 100 | cancelled_alias: "CANCELLED".to_owned(), 101 | uid_col_name: "UID".to_owned(), 102 | age_col_name: "Age".to_owned(), 103 | spent_col_name: "Spent".to_owned(), 104 | prio_col_name: "Prio".to_owned(), 105 | project_col_name: "Project".to_owned(), 106 | tags_col_name: "Tags".to_owned(), 107 | status_col_name: "Status".to_owned(), 108 | description_col_name: "Description".to_owned(), 109 | notes_nb_col_name: "Notes".to_owned(), 110 | display_empty_cols: false, 111 | max_description_lines: 2, 112 | display_tags_listings: true, 113 | previous_notes_help: true, 114 | } 115 | } 116 | } 117 | 118 | impl MainConfig { 119 | #[allow(dead_code)] 120 | pub fn new( 121 | interactive_editor: impl Into>, 122 | tasks_file: impl Into, 123 | todo_alias: impl Into, 124 | wip_alias: impl Into, 125 | done_alias: impl Into, 126 | cancelled_alias: impl Into, 127 | uid_col_name: impl Into, 128 | age_col_name: impl Into, 129 | spent_col_name: impl Into, 130 | prio_col_name: impl Into, 131 | project_col_name: impl Into, 132 | tags_col_name: impl Into, 133 | status_col_name: impl Into, 134 | description_col_name: impl Into, 135 | notes_nb_col_name: impl Into, 136 | display_empty_cols: bool, 137 | max_description_lines: usize, 138 | display_tags_listings: bool, 139 | previous_notes_help: bool, 140 | ) -> Self { 141 | Self { 142 | interactive_editor: interactive_editor.into(), 143 | tasks_file: tasks_file.into(), 144 | todo_alias: todo_alias.into(), 145 | wip_alias: wip_alias.into(), 146 | done_alias: done_alias.into(), 147 | cancelled_alias: cancelled_alias.into(), 148 | uid_col_name: uid_col_name.into(), 149 | age_col_name: age_col_name.into(), 150 | spent_col_name: spent_col_name.into(), 151 | prio_col_name: prio_col_name.into(), 152 | project_col_name: project_col_name.into(), 153 | tags_col_name: tags_col_name.into(), 154 | status_col_name: status_col_name.into(), 155 | description_col_name: description_col_name.into(), 156 | notes_nb_col_name: notes_nb_col_name.into(), 157 | display_empty_cols, 158 | max_description_lines, 159 | display_tags_listings, 160 | previous_notes_help, 161 | } 162 | } 163 | } 164 | 165 | impl Config { 166 | #[allow(dead_code)] 167 | pub fn new(main: MainConfig, colors: ColorConfig) -> Self { 168 | Config { main, colors } 169 | } 170 | 171 | fn get_config_path() -> Result { 172 | log::trace!("getting configuration root path from the environment"); 173 | let home = dirs::config_dir().ok_or_else(|| Error::NoConfigDir)?; 174 | let path = Path::new(&home).join("toodoux"); 175 | 176 | Ok(path) 177 | } 178 | 179 | pub fn from_dir(path: impl AsRef) -> Result, Error> { 180 | let path = path.as_ref().join("config.toml"); 181 | 182 | log::trace!("reading configuration from {}", path.display()); 183 | if path.is_file() { 184 | let content = fs::read_to_string(&path).map_err(Error::CannotOpenFile)?; 185 | let parsed = toml::from_str(&content).map_err(Error::CannotDeserializeFromTOML)?; 186 | Ok(Some(parsed)) 187 | } else { 188 | Ok(None) 189 | } 190 | } 191 | 192 | pub fn root_dir(&self) -> &Path { 193 | &self.main.tasks_file 194 | } 195 | 196 | pub fn config_toml_path(&self) -> PathBuf { 197 | self.main.tasks_file.join("config.toml") 198 | } 199 | 200 | pub fn interactive_editor(&self) -> Option<&str> { 201 | self.main.interactive_editor.as_deref() 202 | } 203 | 204 | pub fn tasks_path(&self) -> PathBuf { 205 | self.main.tasks_file.join("tasks.json") 206 | } 207 | 208 | pub fn todo_alias(&self) -> &str { 209 | &self.main.todo_alias 210 | } 211 | 212 | pub fn wip_alias(&self) -> &str { 213 | &self.main.wip_alias 214 | } 215 | 216 | pub fn done_alias(&self) -> &str { 217 | &self.main.done_alias 218 | } 219 | 220 | pub fn cancelled_alias(&self) -> &str { 221 | &self.main.cancelled_alias 222 | } 223 | 224 | pub fn uid_col_name(&self) -> &str { 225 | &self.main.uid_col_name 226 | } 227 | 228 | pub fn age_col_name(&self) -> &str { 229 | &self.main.age_col_name 230 | } 231 | 232 | pub fn spent_col_name(&self) -> &str { 233 | &self.main.spent_col_name 234 | } 235 | 236 | pub fn prio_col_name(&self) -> &str { 237 | &self.main.prio_col_name 238 | } 239 | 240 | pub fn project_col_name(&self) -> &str { 241 | &self.main.project_col_name 242 | } 243 | 244 | pub fn tags_col_name(&self) -> &str { 245 | &self.main.tags_col_name 246 | } 247 | 248 | pub fn status_col_name(&self) -> &str { 249 | &self.main.status_col_name 250 | } 251 | 252 | pub fn description_col_name(&self) -> &str { 253 | &self.main.description_col_name 254 | } 255 | 256 | pub fn notes_nb_col_name(&self) -> &str { 257 | &self.main.notes_nb_col_name 258 | } 259 | 260 | pub fn display_empty_cols(&self) -> bool { 261 | self.main.display_empty_cols 262 | } 263 | 264 | pub fn max_description_lines(&self) -> usize { 265 | self.main.max_description_lines 266 | } 267 | 268 | pub fn display_tags_listings(&self) -> bool { 269 | self.main.display_tags_listings 270 | } 271 | 272 | pub fn previous_notes_help(&self) -> bool { 273 | self.main.previous_notes_help 274 | } 275 | 276 | pub fn get() -> Result, Error> { 277 | let path = Self::get_config_path()?; 278 | Self::from_dir(path) 279 | } 280 | 281 | pub fn create(path: Option<&Path>) -> Result { 282 | let default_config = Self::default(); 283 | let tasks_file = path.map_or_else(Self::get_config_path, |p| Ok(p.to_owned()))?; 284 | 285 | let main = MainConfig { 286 | tasks_file, 287 | ..default_config.main 288 | }; 289 | let config = Self { 290 | main, 291 | ..default_config 292 | }; 293 | 294 | log::trace!("creating new configuration:\n{:#?}", config); 295 | 296 | Ok(config) 297 | } 298 | 299 | pub fn save(&self) -> Result<(), Error> { 300 | let root_dir = self.root_dir(); 301 | fs::create_dir_all(root_dir).map_err(Error::CannotSave)?; 302 | 303 | let serialized = toml::to_string_pretty(self).map_err(Error::CannotSerializeToTOML)?; 304 | let _ = fs::write(self.config_toml_path(), serialized).map_err(Error::CannotSave)?; 305 | 306 | Ok(()) 307 | } 308 | } 309 | 310 | #[derive(Debug, Deserialize, Serialize)] 311 | #[serde(rename_all = "lowercase")] 312 | pub enum StyleAttribute { 313 | Bold, 314 | Dimmed, 315 | Underline, 316 | Reversed, 317 | Italic, 318 | Blink, 319 | Hidden, 320 | Strikethrough, 321 | } 322 | 323 | impl StyleAttribute { 324 | /// Apply this style attribute to the input colored string. 325 | fn apply_style(&self, s: ColoredString) -> ColoredString { 326 | match self { 327 | StyleAttribute::Bold => s.bold(), 328 | StyleAttribute::Dimmed => s.dimmed(), 329 | StyleAttribute::Underline => s.underline(), 330 | StyleAttribute::Reversed => s.reversed(), 331 | StyleAttribute::Italic => s.italic(), 332 | StyleAttribute::Blink => s.blink(), 333 | StyleAttribute::Hidden => s.hidden(), 334 | StyleAttribute::Strikethrough => s.strikethrough(), 335 | } 336 | } 337 | } 338 | 339 | #[derive(Debug, Deserialize, Serialize, Default)] 340 | #[serde(default)] 341 | pub struct ColorConfig { 342 | pub description: TaskDescriptionColorConfig, 343 | pub status: TaskStatusColorConfig, 344 | pub priority: PriorityColorConfig, 345 | pub show_header: ShowHeaderColorConfig, 346 | } 347 | 348 | #[derive(Debug, Deserialize, Serialize)] 349 | pub struct TaskDescriptionColorConfig { 350 | pub ongoing: Highlight, 351 | pub todo: Highlight, 352 | pub done: Highlight, 353 | pub cancelled: Highlight, 354 | } 355 | 356 | impl Default for TaskDescriptionColorConfig { 357 | fn default() -> Self { 358 | Self { 359 | ongoing: Highlight { 360 | foreground: Some(Color(Col::Black)), 361 | background: Some(Color(Col::BrightGreen)), 362 | style: vec![], 363 | }, 364 | todo: Highlight { 365 | foreground: Some(Color(Col::BrightWhite)), 366 | background: Some(Color(Col::Black)), 367 | style: vec![], 368 | }, 369 | done: Highlight { 370 | foreground: Some(Color(Col::BrightBlack)), 371 | background: Some(Color(Col::Black)), 372 | style: vec![StyleAttribute::Dimmed], 373 | }, 374 | cancelled: Highlight { 375 | foreground: Some(Color(Col::BrightBlack)), 376 | background: Some(Color(Col::Black)), 377 | style: vec![StyleAttribute::Dimmed, StyleAttribute::Strikethrough], 378 | }, 379 | } 380 | } 381 | } 382 | 383 | #[derive(Debug, Deserialize, Serialize)] 384 | pub struct TaskStatusColorConfig { 385 | pub ongoing: Highlight, 386 | pub todo: Highlight, 387 | pub done: Highlight, 388 | pub cancelled: Highlight, 389 | } 390 | 391 | impl Default for TaskStatusColorConfig { 392 | fn default() -> Self { 393 | Self { 394 | ongoing: Highlight { 395 | foreground: Some(Color(Col::Green)), 396 | background: None, 397 | style: vec![StyleAttribute::Bold], 398 | }, 399 | todo: Highlight { 400 | foreground: Some(Color(Col::Magenta)), 401 | background: None, 402 | style: vec![StyleAttribute::Bold], 403 | }, 404 | done: Highlight { 405 | foreground: Some(Color(Col::BrightBlack)), 406 | background: None, 407 | style: vec![StyleAttribute::Dimmed], 408 | }, 409 | cancelled: Highlight { 410 | foreground: Some(Color(Col::BrightRed)), 411 | background: None, 412 | style: vec![StyleAttribute::Dimmed], 413 | }, 414 | } 415 | } 416 | } 417 | 418 | #[derive(Debug, Deserialize, Serialize)] 419 | pub struct PriorityColorConfig { 420 | pub low: Highlight, 421 | pub medium: Highlight, 422 | pub high: Highlight, 423 | pub critical: Highlight, 424 | } 425 | 426 | impl Default for PriorityColorConfig { 427 | fn default() -> Self { 428 | Self { 429 | low: Highlight { 430 | foreground: Some(Color(Col::BrightBlack)), 431 | background: None, 432 | style: vec![StyleAttribute::Dimmed], 433 | }, 434 | medium: Highlight { 435 | foreground: Some(Color(Col::Blue)), 436 | background: None, 437 | style: vec![], 438 | }, 439 | high: Highlight { 440 | foreground: Some(Color(Col::Red)), 441 | background: None, 442 | style: vec![], 443 | }, 444 | critical: Highlight { 445 | foreground: Some(Color(Col::Black)), 446 | background: Some(Color(Col::BrightRed)), 447 | style: vec![], 448 | }, 449 | } 450 | } 451 | } 452 | 453 | #[derive(Debug, Deserialize, Serialize)] 454 | pub struct ShowHeaderColorConfig(Highlight); 455 | 456 | impl Default for ShowHeaderColorConfig { 457 | fn default() -> Self { 458 | Self(Highlight { 459 | foreground: Some(Color(Col::BrightBlack)), 460 | background: None, 461 | style: vec![], 462 | }) 463 | } 464 | } 465 | 466 | impl Deref for ShowHeaderColorConfig { 467 | type Target = Highlight; 468 | 469 | fn deref(&self) -> &Self::Target { 470 | &self.0 471 | } 472 | } 473 | 474 | /// Highlight definition. 475 | /// 476 | /// Contains foreground and background colors as well as the style to use. 477 | #[derive(Debug, Deserialize, Serialize)] 478 | pub struct Highlight { 479 | /// Foreground color. 480 | /// 481 | /// Leaving it empty implies using the default foreground color of your terminal 482 | pub foreground: Option, 483 | 484 | /// Background color. 485 | /// 486 | /// Leaving it empty implies using the default background color of your terminal 487 | pub background: Option, 488 | 489 | /// Style attributes to use. 490 | #[serde(default, skip_serializing_if = "Vec::is_empty")] 491 | pub style: Vec, 492 | } 493 | 494 | impl Highlight { 495 | /// Apply the highlight to an input string. 496 | pub fn highlight(&self, input: impl AsRef) -> HighlightedString { 497 | let mut colored: ColoredString = input.as_ref().into(); 498 | 499 | if let Some(foreground) = &self.foreground { 500 | colored = colored.color(foreground.0); 501 | } 502 | 503 | if let Some(background) = &self.background { 504 | colored = colored.on_color(background.0); 505 | } 506 | 507 | for s in &self.style { 508 | colored = s.apply_style(colored); 509 | } 510 | 511 | HighlightedString(colored) 512 | } 513 | } 514 | 515 | /// Highlighted string — i.e. all color information and styles have been applied. 516 | #[derive(Clone, Debug, Eq, PartialEq)] 517 | pub struct HighlightedString(ColoredString); 518 | 519 | impl fmt::Display for HighlightedString { 520 | fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { 521 | self.0.fmt(f) 522 | } 523 | } 524 | 525 | /// a wrapper around colored::Color in order to implement serialization 526 | #[derive(Debug, PartialEq)] 527 | pub struct Color(pub Col); 528 | 529 | impl Color { 530 | /// Parse a [`Color`] from a hexadecimal string. 531 | /// 532 | /// Supports two simple formats (uppercase / lowercase supported in either case): 533 | /// 534 | /// - `#rrggbb`. Example: `#34f1c8`. 535 | /// - `#rgb`, which desugars to repeating each channel once. Example: `#3fc`. 536 | pub fn from_hex(hex: impl AsRef) -> Option { 537 | let hex = hex.as_ref(); 538 | let bytes = hex.as_bytes(); 539 | let (mut r, mut g, mut b); 540 | 541 | if hex.len() == 4 && bytes[0] == b'#' { 542 | // triplet form (#rgb) 543 | let mut h = u16::from_str_radix(&hex[1..], 16).ok()?; 544 | 545 | b = (h & 0xf) as _; 546 | b += b << 4; 547 | 548 | h >>= 4; 549 | g = (h & 0xf) as _; 550 | g += g << 4; 551 | 552 | h >>= 4; 553 | r = (h & 0xf) as _; 554 | r += r << 4; 555 | } else if hex.len() == 7 && bytes[0] == b'#' { 556 | // #rrggbb form 557 | let mut h = u32::from_str_radix(&hex[1..], 16).ok()?; 558 | 559 | b = (h & 0xff) as _; 560 | 561 | h >>= 8; 562 | g = (h & 0xff) as _; 563 | 564 | h >>= 8; 565 | r = (h & 0xff) as _; 566 | } else { 567 | return None; 568 | } 569 | 570 | Some(Color(Col::TrueColor { r, g, b })) 571 | } 572 | } 573 | 574 | impl<'de> Deserialize<'de> for Color { 575 | fn deserialize(deserializer: D) -> Result 576 | where 577 | D: serde::Deserializer<'de>, 578 | { 579 | struct ColorVisitor; 580 | 581 | const EXPECTING: &str = "a color name or hexadecimal color"; 582 | 583 | impl<'de> Visitor<'de> for ColorVisitor { 584 | type Value = Color; 585 | 586 | fn expecting(&self, formatter: &mut Formatter<'_>) -> fmt::Result { 587 | formatter.write_str(EXPECTING) 588 | } 589 | 590 | fn visit_str(self, value: &str) -> Result 591 | where 592 | E: de::Error, 593 | { 594 | // try to use from_str to get color; if this doesn't work we try to parse it as hex 595 | Col::from_str(value) 596 | .ok() 597 | .map(Color) 598 | .or_else(|| Color::from_hex(value)) 599 | .ok_or_else(|| { 600 | // in the case we were unable to parse either a color name or hexadecimal color, we emit a serde error 601 | E::invalid_value(de::Unexpected::Str(value), &EXPECTING) 602 | }) 603 | } 604 | } 605 | 606 | deserializer.deserialize_str(ColorVisitor) 607 | } 608 | } 609 | 610 | impl Serialize for Color { 611 | fn serialize(&self, serializer: S) -> Result 612 | where 613 | S: serde::Serializer, 614 | { 615 | // this is a bit of a hack in order to extend the life time of a string 616 | // so we can return a ref to it from a match 617 | let true_color; 618 | // this is a reversed version of colored::Color::from_str() 619 | // with hex added 620 | let clr = match self.0 { 621 | Col::Black => "black", 622 | Col::Red => "red", 623 | Col::Green => "green", 624 | Col::Yellow => "yellow", 625 | Col::Blue => "blue", 626 | Col::Magenta => "magenta", 627 | Col::Cyan => "cyan", 628 | Col::White => "white", 629 | Col::BrightBlack => "bright black", 630 | Col::BrightRed => "bright red", 631 | Col::BrightGreen => "bright green", 632 | Col::BrightYellow => "bright yellow", 633 | Col::BrightBlue => "bright blue", 634 | Col::BrightMagenta => "bright magenta", 635 | Col::BrightCyan => "bright cyan", 636 | Col::BrightWhite => "bright white", 637 | Col::TrueColor { r, g, b } => { 638 | true_color = format!("#{:02x}{:02x}{:02x}", r, g, b); 639 | &true_color 640 | } 641 | }; 642 | 643 | serializer.serialize_str(clr) 644 | } 645 | } 646 | 647 | #[cfg(test)] 648 | mod tests { 649 | use super::*; 650 | use serde_test::*; 651 | 652 | #[test] 653 | fn color_hex() { 654 | assert_eq!( 655 | Color::from_hex("#123"), 656 | Some(Color(Col::TrueColor { 657 | r: 0x11, 658 | g: 0x22, 659 | b: 0x33 660 | })) 661 | ); 662 | 663 | assert_eq!( 664 | Color::from_hex("#112234"), 665 | Some(Color(Col::TrueColor { 666 | r: 0x11, 667 | g: 0x22, 668 | b: 0x34 669 | })) 670 | ); 671 | } 672 | 673 | #[test] 674 | fn color_colored_name() { 675 | let c = Color(Col::White); 676 | assert_tokens(&c, &[Token::Str("white")]) 677 | } 678 | 679 | #[test] 680 | fn apply_color_options() { 681 | // with color 682 | { 683 | let expected = HighlightedString("test".on_black().white().bold()); 684 | let opts = Highlight { 685 | background: Some(Color(Col::Black)), 686 | foreground: Some(Color(Col::White)), 687 | style: vec![StyleAttribute::Bold], 688 | }; 689 | assert_eq!(expected, opts.highlight("test")); 690 | } 691 | 692 | // only styles 693 | { 694 | let expected = HighlightedString("test".italic().bold()); 695 | let opts = Highlight { 696 | background: None, 697 | foreground: None, 698 | style: vec![StyleAttribute::Bold, StyleAttribute::Italic], 699 | }; 700 | assert_eq!(expected, opts.highlight("test")); 701 | } 702 | } 703 | } 704 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use crate::task::UID; 2 | use serde_json as json; 3 | use std::{fmt, io}; 4 | 5 | #[derive(Debug)] 6 | pub enum Error { 7 | CannotOpenFile(io::Error), 8 | CannotSave(io::Error), 9 | CannotDeserializeFromJSON(json::Error), 10 | CannotDeserializeFromTOML(toml::de::Error), 11 | CannotSerializeToTOML(toml::ser::Error), 12 | CannotDeserializeFromSerde(serde::de::value::Error), 13 | NoConfigDir, 14 | UnknownNote(UID), 15 | } 16 | 17 | impl fmt::Display for Error { 18 | fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { 19 | match *self { 20 | Error::CannotOpenFile(ref e) => { 21 | write!(f, "cannot open file: {}", e) 22 | } 23 | 24 | Error::CannotSave(ref e) => write!(f, "cannot save: {}", e), 25 | 26 | Error::CannotDeserializeFromJSON(ref e) => { 27 | write!(f, "cannot deserialize from JSON: {}", e) 28 | } 29 | 30 | Error::CannotDeserializeFromTOML(ref e) => { 31 | write!(f, "cannot deserialize from TOML: {}", e) 32 | } 33 | 34 | Error::CannotSerializeToTOML(ref e) => { 35 | write!(f, "cannot serialize to TOML: {}", e) 36 | } 37 | 38 | Error::CannotDeserializeFromSerde(ref e) => { 39 | write!(f, "cannot deserialize: {}", e) 40 | } 41 | 42 | Error::NoConfigDir => f.write_str("cannot find configuration directory"), 43 | 44 | Error::UnknownNote(uid) => write!(f, "note {} doesn’t exist", uid), 45 | } 46 | } 47 | } 48 | 49 | impl From for Error { 50 | fn from(err: json::Error) -> Self { 51 | Self::CannotDeserializeFromJSON(err) 52 | } 53 | } 54 | 55 | impl From for Error { 56 | fn from(err: toml::de::Error) -> Self { 57 | Self::CannotDeserializeFromTOML(err) 58 | } 59 | } 60 | 61 | impl From for Error { 62 | fn from(err: toml::ser::Error) -> Self { 63 | Self::CannotSerializeToTOML(err) 64 | } 65 | } 66 | 67 | impl From for Error { 68 | fn from(err: serde::de::value::Error) -> Self { 69 | Self::CannotDeserializeFromSerde(err) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/filter.rs: -------------------------------------------------------------------------------- 1 | //! Various types used to filter tasks in listings. 2 | 3 | use std::collections::HashSet; 4 | use unicase::UniCase; 5 | 6 | /// A filter based on tasks’ description. 7 | #[derive(Clone)] 8 | pub enum TaskDescriptionFilter<'a> { 9 | /// Case-sensitive filter. 10 | /// 11 | /// Searching for `foo` is not the same as searching for `Foo`. 12 | CaseSensitive(HashSet<&'a str>), 13 | /// Case-insensitive filter. 14 | /// 15 | /// Searching for `foo` is the same as searching for `Foo`. 16 | CaseInsensitive(HashSet>), 17 | } 18 | 19 | impl<'a> TaskDescriptionFilter<'a> { 20 | /// Create a new task description filter based on an iterator on strings. 21 | /// 22 | /// If `case_insensitive` is `true`, the resulting filter will ignore case. 23 | pub fn new(name: impl Iterator, case_insensitive: bool) -> Self { 24 | if case_insensitive { 25 | TaskDescriptionFilter::CaseInsensitive(name.map(UniCase::new).collect()) 26 | } else { 27 | TaskDescriptionFilter::CaseSensitive(name.collect()) 28 | } 29 | } 30 | 31 | /// Check whether the filter contains any term. 32 | pub fn is_empty(&self) -> bool { 33 | match self { 34 | TaskDescriptionFilter::CaseSensitive(set) => set.is_empty(), 35 | TaskDescriptionFilter::CaseInsensitive(set) => set.is_empty(), 36 | } 37 | } 38 | 39 | /// Remove a search term from the filter. 40 | pub fn remove(&mut self, word: &'a str) -> bool { 41 | match self { 42 | TaskDescriptionFilter::CaseSensitive(set) => set.remove(word), 43 | TaskDescriptionFilter::CaseInsensitive(set) => set.remove(&UniCase::new(word)), 44 | } 45 | } 46 | 47 | /// Get an iterator on the carried search terms. 48 | pub fn terms(&'a self) -> Box> { 49 | match self { 50 | TaskDescriptionFilter::CaseSensitive(ref set) => Box::new(set.iter().copied()), 51 | TaskDescriptionFilter::CaseInsensitive(ref set) => Box::new(set.iter().map(AsRef::as_ref)), 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod error; 3 | pub mod filter; 4 | pub mod metadata; 5 | pub mod task; 6 | -------------------------------------------------------------------------------- /src/metadata.rs: -------------------------------------------------------------------------------- 1 | //! Metadata available to users for filtering / creating tasks. 2 | 3 | use colored::Colorize as _; 4 | use serde::{Deserialize, Serialize}; 5 | use std::{ 6 | error::Error, 7 | fmt::{self, Display}, 8 | str::FromStr, 9 | }; 10 | 11 | /// Possible errors that can happen when validating metadata. 12 | #[derive(Debug)] 13 | pub enum MetadataValidationError { 14 | /// Too many projects; you should use only one or none. 15 | TooManyProjects(usize), 16 | 17 | /// Too many priorities; you should use only one or none. 18 | TooManyPriorities(usize), 19 | } 20 | 21 | impl Error for MetadataValidationError {} 22 | 23 | impl Display for MetadataValidationError { 24 | fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { 25 | match *self { 26 | MetadataValidationError::TooManyProjects(nb) => write!(f, "too many projects: {}", nb), 27 | MetadataValidationError::TooManyPriorities(nb) => write!(f, "too many priorities: {}", nb), 28 | } 29 | } 30 | } 31 | 32 | /// Task metadata. 33 | #[derive(Clone, Debug, Eq, PartialEq)] 34 | pub enum Metadata { 35 | /// Project name. 36 | Project(String), 37 | /// Priority. 38 | Priority(Priority), 39 | /// Tag. 40 | Tag(String), 41 | } 42 | 43 | impl From for Metadata { 44 | fn from(v: Priority) -> Self { 45 | Metadata::Priority(v) 46 | } 47 | } 48 | 49 | impl Metadata { 50 | // TODO: decide what to do with duplicated tags 51 | /// Validate a list (set) of metadata. 52 | pub fn validate<'a>( 53 | metadata: impl IntoIterator, 54 | ) -> Result<(), MetadataValidationError> { 55 | let (proj_nb, prio_nb) = metadata 56 | .into_iter() 57 | .fold((0, 0), |(proj_nb, prio_nb), md| match md { 58 | Metadata::Project(_) => (proj_nb + 1, proj_nb), 59 | Metadata::Priority(_) => (proj_nb, prio_nb + 1), 60 | _ => (proj_nb, prio_nb), 61 | }); 62 | 63 | if proj_nb > 1 { 64 | return Err(MetadataValidationError::TooManyProjects(proj_nb)); 65 | } 66 | 67 | if prio_nb > 1 { 68 | return Err(MetadataValidationError::TooManyPriorities(prio_nb)); 69 | } 70 | 71 | Ok(()) 72 | } 73 | 74 | /// Create a metadata representing a project. 75 | pub fn project(name: impl Into) -> Self { 76 | Metadata::Project(name.into()) 77 | } 78 | 79 | /// Create a metadata representing a priority. 80 | pub fn priority(priority: Priority) -> Self { 81 | Metadata::Priority(priority) 82 | } 83 | 84 | /// Create a metadata representing a tag. 85 | pub fn tag(name: impl Into) -> Self { 86 | Metadata::Tag(name.into()) 87 | } 88 | 89 | /// Find metadata in a list of words encoded as a string. 90 | pub fn from_words<'a>(strings: impl IntoIterator) -> (Vec, String) { 91 | let mut metadata = Vec::new(); 92 | let mut output = Vec::new(); 93 | 94 | for s in strings { 95 | let words = s.split(' ').filter(|s| !s.is_empty()); 96 | 97 | for word in words { 98 | if let Ok(md) = word.parse() { 99 | metadata.push(md); 100 | } else { 101 | output.push(word); 102 | } 103 | } 104 | } 105 | 106 | log::debug!("extracted metadata:"); 107 | log::debug!(" metadata: {:?}", metadata); 108 | log::debug!(" output: {:?}", output); 109 | 110 | (metadata, output.join(" ")) 111 | } 112 | 113 | /// Return a “filter-like” representation of this metadata. 114 | pub fn filter_like(&self) -> impl Display { 115 | match *self { 116 | Metadata::Project(ref p) => format!("@{}", p).magenta(), 117 | Metadata::Priority(ref p) => format!("+{:?}", p).yellow(), 118 | Metadata::Tag(ref t) => format!("#{}", t).green(), 119 | } 120 | } 121 | } 122 | 123 | impl FromStr for Metadata { 124 | type Err = MetadataParsingError; 125 | 126 | fn from_str(s: &str) -> Result { 127 | let len = s.len(); 128 | if len < 2 { 129 | return Err(MetadataParsingError::Unknown(s.to_owned())); 130 | } 131 | 132 | match s.as_bytes()[0] { 133 | b'@' => Ok(Metadata::project(&s[1..])), 134 | b'+' => { 135 | if len == 2 { 136 | match s.as_bytes()[1] { 137 | b'l' => Ok(Metadata::priority(Priority::Low)), 138 | b'm' => Ok(Metadata::priority(Priority::Medium)), 139 | b'h' => Ok(Metadata::priority(Priority::High)), 140 | b'c' => Ok(Metadata::priority(Priority::Critical)), 141 | _ => Err(MetadataParsingError::UnknownPriority), 142 | } 143 | } else { 144 | Err(MetadataParsingError::UnknownPriority) 145 | } 146 | } 147 | b'#' => Ok(Metadata::tag(&s[1..])), 148 | _ => Err(MetadataParsingError::Unknown(s.to_owned())), 149 | } 150 | } 151 | } 152 | 153 | #[derive(Clone, Debug, Eq, PartialEq)] 154 | pub enum MetadataParsingError { 155 | /// Occurs when a priority is not recognized as valid. 156 | UnknownPriority, 157 | /// Occurs when a string is not recognized as metadata. 158 | Unknown(String), 159 | } 160 | 161 | /// Priority. 162 | #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] 163 | pub enum Priority { 164 | Low, 165 | Medium, 166 | High, 167 | Critical, 168 | } 169 | 170 | #[cfg(test)] 171 | mod unit_tests { 172 | use super::*; 173 | 174 | #[test] 175 | fn project() { 176 | assert_eq!("@foo".parse::(), Ok(Metadata::project("foo"))); 177 | 178 | assert_eq!( 179 | "@foo bar".parse::(), 180 | Ok(Metadata::project("foo bar")) 181 | ); 182 | 183 | assert_eq!( 184 | "@".parse::(), 185 | Err(MetadataParsingError::Unknown("@".to_owned())) 186 | ); 187 | } 188 | 189 | #[test] 190 | fn tag() { 191 | assert_eq!("#foo".parse::(), Ok(Metadata::tag("foo"))); 192 | 193 | assert_eq!("#foo bar".parse::(), Ok(Metadata::tag("foo bar"))); 194 | 195 | assert_eq!( 196 | "#".parse::(), 197 | Err(MetadataParsingError::Unknown("#".to_owned())) 198 | ); 199 | } 200 | 201 | #[test] 202 | fn priority() { 203 | assert_eq!( 204 | "+l".parse::(), 205 | Ok(Metadata::Priority(Priority::Low)) 206 | ); 207 | 208 | assert_eq!( 209 | "+m".parse::(), 210 | Ok(Metadata::Priority(Priority::Medium)) 211 | ); 212 | 213 | assert_eq!( 214 | "+h".parse::(), 215 | Ok(Metadata::Priority(Priority::High)) 216 | ); 217 | 218 | assert_eq!( 219 | "+c".parse::(), 220 | Ok(Metadata::Priority(Priority::Critical)) 221 | ); 222 | 223 | assert_eq!( 224 | "+a".parse::(), 225 | Err(MetadataParsingError::UnknownPriority) 226 | ); 227 | 228 | assert_eq!( 229 | "+la".parse::(), 230 | Err(MetadataParsingError::UnknownPriority) 231 | ); 232 | } 233 | 234 | #[test] 235 | fn extract_metadata_output() { 236 | let input = "@project1 #tag1 +h Hello, this is world! #tag2"; 237 | let (metadata, output) = Metadata::from_words(vec![input].into_iter()); 238 | 239 | assert_eq!( 240 | metadata, 241 | vec![ 242 | Metadata::project("project1"), 243 | Metadata::tag("tag1"), 244 | Metadata::priority(Priority::High), 245 | Metadata::tag("tag2") 246 | ] 247 | ); 248 | assert_eq!(output, "Hello, this is world!"); 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/task.rs: -------------------------------------------------------------------------------- 1 | //! Tasks related code. 2 | 3 | use crate::{ 4 | config::Config, error::Error, filter::TaskDescriptionFilter, metadata::Metadata, 5 | metadata::Priority, 6 | }; 7 | use chrono::{DateTime, Duration, Utc}; 8 | use serde::{Deserialize, Serialize}; 9 | use serde_json as json; 10 | use std::{cmp::Reverse, collections::HashMap, fmt, fs, str::FromStr}; 11 | use unicase::UniCase; 12 | 13 | /// Create, edit, remove and list tasks. 14 | #[derive(Debug, Deserialize, Serialize)] 15 | pub struct TaskManager { 16 | /// Next UID to use for the next task to create. 17 | next_uid: UID, 18 | /// List of known tasks. 19 | tasks: HashMap, 20 | } 21 | 22 | impl TaskManager { 23 | /// Create a manager from a configuration. 24 | pub fn new_from_config(config: &Config) -> Result { 25 | let path = config.tasks_path(); 26 | 27 | if path.is_file() { 28 | Ok(json::from_reader( 29 | fs::File::open(path).map_err(Error::CannotOpenFile)?, 30 | )?) 31 | } else { 32 | let task_mgr = TaskManager { 33 | next_uid: UID::default(), 34 | tasks: HashMap::new(), 35 | }; 36 | Ok(task_mgr) 37 | } 38 | } 39 | 40 | /// Increment the next UID to use. 41 | fn increment_uid(&mut self) { 42 | let uid = self.next_uid.0 + 1; 43 | self.next_uid = UID(uid); 44 | } 45 | 46 | /// Register a task and give it an [`UID`]. 47 | pub fn register_task(&mut self, task: Task) -> UID { 48 | let uid = self.next_uid; 49 | 50 | self.increment_uid(); 51 | self.tasks.insert(uid, task); 52 | 53 | uid 54 | } 55 | 56 | pub fn save(&mut self, config: &Config) -> Result<(), Error> { 57 | Ok(json::to_writer_pretty( 58 | fs::File::create(config.tasks_path()).map_err(Error::CannotSave)?, 59 | self, 60 | )?) 61 | } 62 | 63 | pub fn tasks(&self) -> impl Iterator { 64 | self.tasks.iter() 65 | } 66 | 67 | pub fn get(&self, uid: UID) -> Option<&Task> { 68 | self.tasks.get(&uid) 69 | } 70 | 71 | pub fn get_mut(&mut self, uid: UID) -> Option<&mut Task> { 72 | self.tasks.get_mut(&uid) 73 | } 74 | 75 | pub fn rename_project( 76 | &mut self, 77 | current_project: impl AsRef, 78 | new_project: impl AsRef, 79 | mut on_renamed: impl FnMut(UID), 80 | ) { 81 | let current_project = current_project.as_ref(); 82 | let new_project = new_project.as_ref(); 83 | 84 | for (uid, task) in &mut self.tasks { 85 | match task.project() { 86 | Some(project) if project == current_project => { 87 | task.set_project(new_project); 88 | on_renamed(*uid); 89 | } 90 | 91 | _ => (), 92 | } 93 | } 94 | } 95 | 96 | /// Get a listing of tasks that can be filtered with metadata and name filters. 97 | pub fn filtered_task_listing( 98 | &self, 99 | metadata: Vec, 100 | name_filter: TaskDescriptionFilter, 101 | todo: bool, 102 | start: bool, 103 | done: bool, 104 | cancelled: bool, 105 | case_insensitive: bool, 106 | ) -> Vec<(&UID, &Task)> { 107 | let mut tasks: Vec<_> = self 108 | .tasks() 109 | .filter(|(_, task)| { 110 | // filter the task depending on what is passed as argument 111 | let status_filter = match task.status() { 112 | Status::Ongoing => start, 113 | Status::Todo => todo, 114 | Status::Done => done, 115 | Status::Cancelled => cancelled, 116 | }; 117 | 118 | if metadata.is_empty() { 119 | status_filter 120 | } else { 121 | status_filter && task.check_metadata(metadata.iter(), case_insensitive) 122 | } 123 | }) 124 | .filter(|(_, task)| { 125 | if !name_filter.is_empty() { 126 | let mut name_filter = name_filter.clone(); 127 | 128 | for word in task.name().split_ascii_whitespace() { 129 | let word_found = name_filter.remove(word); 130 | 131 | if word_found && name_filter.is_empty() { 132 | return true; 133 | } 134 | } 135 | 136 | false 137 | } else { 138 | true 139 | } 140 | }) 141 | .collect(); 142 | 143 | tasks.sort_by_key(|&(uid, task)| Reverse((task.priority(), task.age(), task.status(), uid))); 144 | 145 | tasks 146 | } 147 | } 148 | 149 | #[derive(Clone, Debug, Serialize, Deserialize)] 150 | pub struct Task { 151 | /// Name of the task. 152 | name: String, 153 | /// Event history. 154 | history: Vec, 155 | } 156 | 157 | impl Task { 158 | /// Create a new [`Task`] and populate automatically its history with creation date and status. 159 | pub fn new(name: impl Into) -> Self { 160 | let date = Utc::now(); 161 | 162 | Task { 163 | name: name.into(), 164 | history: vec![ 165 | Event::Created(date), 166 | Event::StatusChanged { 167 | event_date: date, 168 | status: Status::Todo, 169 | }, 170 | ], 171 | } 172 | } 173 | 174 | /// Get the name of the [`Task`]. 175 | pub fn name(&self) -> &str { 176 | &self.name 177 | } 178 | 179 | /// Get the current status of the [`Task`]. 180 | pub fn status(&self) -> Status { 181 | self 182 | .history 183 | .iter() 184 | .filter_map(|event| match event { 185 | Event::StatusChanged { status, .. } => Some(status), 186 | _ => None, 187 | }) 188 | .copied() 189 | .last() 190 | .unwrap_or(Status::Todo) 191 | } 192 | 193 | /// Get the creation date of the [`Task`]. 194 | pub fn creation_date(&self) -> Option<&DateTime> { 195 | self.history.iter().find_map(|event| match event { 196 | Event::Created(ref date) => Some(date), 197 | _ => None, 198 | }) 199 | } 200 | 201 | /// Get the age of the [`Task`]; i.e. the duration since its creation date. 202 | pub fn age(&self) -> Duration { 203 | Utc::now().signed_duration_since(self.creation_date().copied().unwrap_or_else(Utc::now)) 204 | } 205 | 206 | /// Change the name of the [`Task`]. 207 | pub fn change_name(&mut self, name: impl Into) { 208 | self.name = name.into() 209 | } 210 | 211 | /// Change the status of the [`Task`]. 212 | pub fn change_status(&mut self, status: Status) { 213 | self.history.push(Event::StatusChanged { 214 | event_date: Utc::now(), 215 | status, 216 | }); 217 | } 218 | 219 | /// Add a new note to the [`Task`]. 220 | pub fn add_note(&mut self, content: impl Into) { 221 | self.history.push(Event::NoteAdded { 222 | event_date: Utc::now(), 223 | content: content.into(), 224 | }); 225 | } 226 | 227 | /// Replace the content of a note for a given [`Task`]. 228 | pub fn replace_note(&mut self, note_uid: UID, content: impl Into) -> Result<(), Error> { 229 | // ensure the note exists first 230 | let mut count = 0; 231 | let id: u32 = note_uid.into(); 232 | let previous_note = self.history.iter().find(|event| match event { 233 | Event::NoteAdded { .. } => { 234 | if id == count { 235 | true 236 | } else { 237 | count += 1; 238 | false 239 | } 240 | } 241 | 242 | _ => false, 243 | }); 244 | 245 | if previous_note.is_none() { 246 | return Err(Error::UnknownNote(note_uid)); 247 | } 248 | 249 | self.history.push(Event::NoteReplaced { 250 | event_date: Utc::now(), 251 | note_uid, 252 | content: content.into(), 253 | }); 254 | 255 | Ok(()) 256 | } 257 | 258 | /// Iterate over the notes, if any. 259 | pub fn notes(&self) -> Vec { 260 | let mut notes = Vec::new(); 261 | 262 | for event in &self.history { 263 | match event { 264 | Event::NoteAdded { 265 | event_date, 266 | content, 267 | } => { 268 | let note = Note { 269 | creation_date: *event_date, 270 | last_modification_date: *event_date, 271 | content: content.clone(), 272 | }; 273 | notes.push(note); 274 | } 275 | 276 | Event::NoteReplaced { 277 | event_date, 278 | note_uid, 279 | content, 280 | } => { 281 | if let Some(note) = notes.get_mut(usize::from(*note_uid)) { 282 | note.last_modification_date = *event_date; 283 | note.content = content.clone(); 284 | } 285 | } 286 | 287 | _ => (), 288 | } 289 | } 290 | 291 | notes 292 | } 293 | 294 | /// Iterate over the whole history, if any. 295 | pub fn history(&self) -> impl Iterator { 296 | self.history.iter() 297 | } 298 | 299 | /// Compute the time spent on this task. 300 | pub fn spent_time(&self) -> Duration { 301 | let (spent, last_wip) = 302 | self 303 | .history 304 | .iter() 305 | .fold((Duration::zero(), None), |(spent, last_wip), event| { 306 | match event { 307 | Event::StatusChanged { event_date, status } => match (status, last_wip) { 308 | // We go from any status to WIP status; return the spent time untouched and set the new “last_wip” with the 309 | // time at which the status change occurred 310 | (Status::Ongoing, _) => (spent, Some(*event_date)), 311 | // We go to anything but WIP while the previous status was WIP; accumulate. 312 | (_, Some(last_wip)) => (spent + (event_date.signed_duration_since(last_wip)), None), 313 | // We go between inactive status, ignore 314 | _ => (spent, last_wip), 315 | }, 316 | _ => (spent, last_wip), 317 | } 318 | }); 319 | 320 | if let Some(last_wip) = last_wip { 321 | // last status was WIP; accumulate moaaar 322 | spent + Utc::now().signed_duration_since(last_wip) 323 | } else { 324 | spent 325 | } 326 | } 327 | 328 | /// Mark this task as part of the input project. 329 | /// 330 | /// If a project was already present, this method overrides it. Passing an empty string puts that task into the 331 | /// _orphaned_ project. 332 | pub fn set_project(&mut self, project: impl Into) { 333 | self.history.push(Event::SetProject { 334 | event_date: Utc::now(), 335 | project: project.into(), 336 | }); 337 | } 338 | 339 | /// Set the priority of this task. 340 | /// 341 | /// If a priority was already set, this method overrides it. Passing [`None`] removes the priority. 342 | pub fn set_priority(&mut self, priority: Priority) { 343 | self.history.push(Event::SetPriority { 344 | event_date: Utc::now(), 345 | priority, 346 | }); 347 | } 348 | 349 | /// Add a tag to task. 350 | pub fn add_tag(&mut self, tag: impl Into) { 351 | self.history.push(Event::AddTag { 352 | event_date: Utc::now(), 353 | tag: tag.into(), 354 | }); 355 | } 356 | 357 | /// Apply a list of metadata. 358 | pub fn apply_metadata(&mut self, metadata: impl IntoIterator) { 359 | for md in metadata { 360 | match md { 361 | Metadata::Project(project) => self.set_project(project), 362 | Metadata::Priority(priority) => self.set_priority(priority), 363 | Metadata::Tag(tag) => self.add_tag(tag), 364 | } 365 | } 366 | } 367 | 368 | /// Check all metadata against this I have no idea how to express the end of this sentence so good luck. 369 | pub fn check_metadata<'a>( 370 | &self, 371 | metadata: impl IntoIterator, 372 | case_insensitive: bool, 373 | ) -> bool { 374 | if case_insensitive { 375 | let own_project = self.project().map(UniCase::new); 376 | let own_tags = self.tags().map(UniCase::new).collect::>(); 377 | metadata.into_iter().all(|md| match md { 378 | Metadata::Project(ref project) => own_project == Some(UniCase::new(project)), 379 | Metadata::Priority(priority) => self.priority() == Some(*priority), 380 | Metadata::Tag(ref tag) => own_tags.contains(&UniCase::new(tag)), 381 | }) 382 | } else { 383 | metadata.into_iter().all(|md| match md { 384 | Metadata::Project(ref project) => self.project() == Some(project), 385 | Metadata::Priority(priority) => self.priority() == Some(*priority), 386 | Metadata::Tag(ref tag) => self.tags().any(|t| t == tag), 387 | }) 388 | } 389 | } 390 | 391 | /// Get the current project. 392 | pub fn project(&self) -> Option<&str> { 393 | self 394 | .history 395 | .iter() 396 | .filter_map(|event| match event { 397 | Event::SetProject { ref project, .. } => Some(project.as_str()), 398 | _ => None, 399 | }) 400 | .last() 401 | } 402 | 403 | /// Get the current project. 404 | pub fn priority(&self) -> Option { 405 | self 406 | .history 407 | .iter() 408 | .filter_map(|event| match event { 409 | Event::SetPriority { priority, .. } => Some(*priority), 410 | _ => None, 411 | }) 412 | .last() 413 | } 414 | 415 | /// Get the current tags of a task. 416 | pub fn tags(&self) -> impl Iterator { 417 | self.history.iter().filter_map(|event| match event { 418 | Event::AddTag { ref tag, .. } => Some(tag.as_str()), 419 | _ => None, 420 | }) 421 | } 422 | } 423 | 424 | /// Unique identifier. 425 | #[derive(Clone, Copy, Debug, Deserialize, Hash, Eq, Ord, PartialEq, PartialOrd, Serialize)] 426 | pub struct UID(u32); 427 | 428 | impl UID { 429 | pub fn val(self) -> u32 { 430 | self.0 431 | } 432 | 433 | pub fn dec(self) -> Self { 434 | Self(self.0.checked_sub(1).unwrap_or(0)) 435 | } 436 | } 437 | 438 | impl From for u32 { 439 | fn from(uid: UID) -> Self { 440 | uid.0 441 | } 442 | } 443 | 444 | impl From for usize { 445 | fn from(uid: UID) -> Self { 446 | uid.0 as _ 447 | } 448 | } 449 | 450 | impl Default for UID { 451 | fn default() -> Self { 452 | UID(0) 453 | } 454 | } 455 | 456 | impl FromStr for UID { 457 | type Err = ::Err; 458 | 459 | fn from_str(s: &str) -> Result { 460 | u32::from_str(s).map(UID) 461 | } 462 | } 463 | 464 | impl fmt::Display for UID { 465 | fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { 466 | self.0.fmt(f) 467 | } 468 | } 469 | 470 | /// State of a task. 471 | #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] 472 | pub enum Status { 473 | /// An “ongoing” state. 474 | /// 475 | /// Users will typically have “ONGOING”, “WIP”, etc. 476 | Ongoing, 477 | /// A “todo” state. 478 | /// 479 | /// Users will typically have “TODO“, “PLANNED”, etc. 480 | Todo, 481 | /// A “done” state. 482 | /// 483 | /// Users will typically have "DONE". 484 | Done, 485 | /// A “cancelled” state. 486 | /// 487 | /// Users will typically have "CANCELLED", "WONTFIX", etc. 488 | Cancelled, 489 | } 490 | 491 | /// Task event. 492 | /// 493 | /// Such events occurred when a change is made to a task (created, edited, scheduled, state 494 | /// changed, etc.). 495 | #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 496 | pub enum Event { 497 | /// Event generated when a task is created. 498 | Created(DateTime), 499 | 500 | /// Event generated when the status of a task changes. 501 | StatusChanged { 502 | event_date: DateTime, 503 | status: Status, 504 | }, 505 | 506 | /// Event generated when a note is added to a task. 507 | NoteAdded { 508 | event_date: DateTime, 509 | content: String, 510 | }, 511 | 512 | /// Event generated when a note is replaced in a task. 513 | NoteReplaced { 514 | event_date: DateTime, 515 | note_uid: UID, 516 | content: String, 517 | }, 518 | 519 | /// Event generated when a project is set on a task. 520 | SetProject { 521 | event_date: DateTime, 522 | project: String, 523 | }, 524 | 525 | /// Event generated when a priority is set on a task. 526 | SetPriority { 527 | event_date: DateTime, 528 | priority: Priority, 529 | }, 530 | 531 | /// Event generated when a tag is added to a task. 532 | AddTag { 533 | event_date: DateTime, 534 | tag: String, 535 | }, 536 | } 537 | 538 | /// A note. 539 | #[derive(Debug, Clone, Eq, PartialEq)] 540 | pub struct Note { 541 | pub creation_date: DateTime, 542 | pub last_modification_date: DateTime, 543 | pub content: String, 544 | } 545 | --------------------------------------------------------------------------------