├── .github └── workflows │ └── docker.yml ├── Dockerfile ├── LICENSE ├── README.md ├── assets └── example.gif ├── bin ├── local-trackwarrior-docker ├── trackwarrior-docker ├── trackwarrior-docker-tasksh └── trackwarrior-docker-tui ├── dev.py ├── docker ├── base.fish ├── sysinit.vim ├── taskrc ├── trackwarrior.sh └── unbuffer ├── taskwarrior └── hooks │ ├── library │ ├── Config.js │ ├── Init.js │ ├── Record.js │ ├── settings.js │ └── tools.js │ ├── on-add.trackwarrior │ └── on-modify.trackwarrior └── timewarrior └── extensions ├── library ├── Config.js ├── Init.js ├── Interval.js ├── Intervals.js ├── Parser.js └── tools.js ├── trackwarrior-duration.js └── trackwarrior-ids.js /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker image 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | push_to_registry: 10 | name: Push Docker image to Docker Hub 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out the repo 14 | uses: actions/checkout@v3 15 | 16 | - name: Log in to Docker Hub 17 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 18 | with: 19 | username: ${{ secrets.DOCKER_USERNAME }} 20 | password: ${{ secrets.DOCKER_PASSWORD }} 21 | 22 | - name: Extract metadata (tags, labels) for Docker 23 | id: meta 24 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 25 | with: 26 | images: hardliner66/trackwarrior 27 | 28 | - name: Build and push Docker image 29 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 30 | with: 31 | context: . 32 | push: true 33 | tags: ${{ steps.meta.outputs.tags }} 34 | labels: ${{ steps.meta.outputs.labels }} 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM archlinux 2 | 3 | RUN pacman -Syu --needed --noconfirm base-devel expect 4 | RUN pacman -Syu --noconfirm git cmake man 5 | 6 | RUN git clone --recursive https://github.com/GothenburgBitFactory/taskshell.git && \ 7 | cd taskshell && cmake -DCMAKE_BUILD_TYPE=release . && make && make install 8 | 9 | RUN rm -rf taskshell 10 | 11 | RUN git clone https://github.com/gkssjovi/trackwarrior /trackwarrior 12 | COPY ./docker /trackwarrior-docker 13 | COPY ./docker/unbuffer /usr/bin/unbuffer 14 | 15 | RUN chmod +x /trackwarrior-docker/trackwarrior.sh 16 | RUN chmod +x /usr/bin/unbuffer 17 | 18 | RUN pacman -Syu --noconfirm task timew nodejs fish neovim taskwarrior-tui 19 | 20 | COPY ./docker/sysinit.vim /etc/xdg/nvim/sysinit.vim 21 | COPY ./docker/base.fish /etc/fish/conf.d/base.fish 22 | 23 | RUN ln -s /usr/bin/nvim /usr/bin/vi 24 | 25 | ENTRYPOINT [ "/trackwarrior-docker/trackwarrior.sh" ] 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 gkssjovi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | This extension create a link between [taskwarrior](https://github.com/GothenburgBitFactory/taskwarrior) and [timewarrior](https://github.com/GothenburgBitFactory/timewarrior) that allows you to keep track of time spend on tasks. 4 | The time will be displayed in a new column in taskwarrior. 5 | 6 | ## Docker 7 | 8 | ```sh 9 | git clone https://github.com/gkssjovi/trackwarrior.git 10 | cd trackwarrior 11 | 12 | sudo ln -s "$PWD/bin/trackwarrior-docker" /usr/local/bin/trackwarrior 13 | ``` 14 | 15 | ### Use tasksh as main frontend 16 | ```sh 17 | sudo rm /usr/local/bin/trackwarrior 18 | sudo ln -s "$PWD/bin/trackwarrior-docker-tasksh" /usr/local/bin/trackwarrior 19 | ``` 20 | 21 | ### Use taskwarrior-tui as main frontend 22 | ```sh 23 | sudo rm /usr/local/bin/trackwarrior 24 | sudo ln -s "$PWD/bin/trackwarrior-docker-tui" /usr/local/bin/trackwarrior 25 | ``` 26 | 27 | ## Local Installation 28 | 29 | ```sh 30 | git clone https://github.com/gkssjovi/trackwarrior.git 31 | cd trackwarrior 32 | 33 | cp -r ./taskwarrior/hooks/. ~/.task/hooks 34 | cp -r ./timewarrior/extensions/. ~/.timewarrior/extensions 35 | 36 | cd ~/.task/hooks 37 | chmod +x on-modify.trackwarrior on-add.trackwarrior 38 | 39 | cd ~/.timewarrior/extensions 40 | chmod +x trackwarrior-duration.js trackwarrior-ids.js 41 | ``` 42 | 43 | ## Configuration 44 | 45 | Copy those lines into your `~/.taskrc` file 46 | ```sh 47 | uda.trackwarrior.type=string 48 | uda.trackwarrior.label=Total active time 49 | uda.trackwarrior.values= 50 | 51 | uda.trackwarrior_rate.type=string 52 | uda.trackwarrior_rate.label=Rate 53 | uda.trackwarrior_rate.values= 54 | 55 | uda.trackwarrior_total_amount.type=string 56 | uda.trackwarrior_total_amount.label=Total amount 57 | uda.trackwarrior_total_amount.values= 58 | 59 | # this allow only one task to be active 60 | max_active_tasks=1 61 | # when you delete the task, the time tracking will be also be deleted from timewarrior 62 | erase_time_on_delete=false 63 | # those are tags in taskwarrior.When you add one of them the time tracking will be deleted from timewarrior 64 | clear_time_tags=cleartime,ctime,deletetime,dtime 65 | update_time_tags=update,updatetime,utime,recalc 66 | create_time_when_add_task=false 67 | rate_per_hour=10 68 | rate_per_hour_decimals=2 69 | rate_per_hour_project=Inbox:0,Other:10 70 | rate_format_with_spaces=10 71 | currency_format=de-DE,EUR 72 | ``` 73 | 74 | To display the new column on the next report modify the `~/.taskrc` file 75 | ```sh 76 | report.next.labels=ID,St,Active,Age,Time,Rate,Total,...,Description,Urg 77 | report.next.columns=id,status.short,start.age,entry.age,trackwarrior,trackwarrior_rate,trackwarrior_total_amount,...,description,urgency 78 | ``` 79 | 80 | ## Usage 81 | If you installed the docker version, just run `trackwarrior` to open the configured fronted (default: fish shell). 82 | 83 | ## Integrate with starship 84 | 1) Locally install taskwarrior 85 | 2) Install [starship](https://starship.rs/guide/#%F0%9F%9A%80-installation) 86 | 3) Set the correct rights for your local taskwarrior to read the data from the container 87 | ```sh 88 | # trackwarrior needs to be used at least once 89 | sudo chown "$(id -u):$(id -g)" ~/.trackwarrior-docker/.task/pending.data 90 | ``` 91 | 92 | 4) Add the following to your starship.toml 93 | 94 | ```toml 95 | [custom.current_task] 96 | command = """TASKRC=~/.trackwarrior-docker/.taskrc TASKDATA=~/.trackwarrior-docker/.task unbuffer task starship-project | head -5 | tail -1 | sed "s/No matches./[No active task]/" | xargs""" 97 | when = true 98 | shell = "bash" 99 | ``` 100 | 101 | ## Usage Examples 102 | 103 | If you use a tasksh as a frontend, you can use the same commands as shown here, 104 | but without typing task in the beginning. 105 | 106 | If you use taskwarrior-tui as a frontend, check the [taskwarrior-tui documentation](https://kdheepak.com/taskwarrior-tui/) instead. 107 | 108 | ![Example](./assets/example.gif) 109 | 110 | Create a new task 111 | 112 | `task add 'This is task 1' project:Example` \ 113 | `task next` 114 | ```sh 115 | ID St Age Project Description Urg 116 | 1 P 10s Example This is task 1 1 117 | 118 | ``` 119 | Start the new created task 120 | 121 | `task 1 start` \ 122 | `task next` 123 | ```sh 124 | ID St Active Age Time Project Description Urg 125 | 1 P 4s 18s 0:00:00 Example This is task 1 5 126 | ``` 127 | 128 | Stop the new created task 129 | 130 | `task 1 stop` \ 131 | `task next` \ 132 | View the time tracking in taskwarrior 133 | ```sh 134 | ID St Age Time Project Description Urg 135 | 1 P 46s 0:00:15 Example This is task 1 1 136 | ``` 137 | View the time tracking in timewarrior 138 | 139 | `timew summary Example` 140 | 141 | ```sh 142 | Wk Date Day Tags Start End Time Total 143 | W43 2021-10-31 Sun Example, This is task 1, |20d4fa9c| 22:49:41 22:49:49 0:00:08 144 | Example, This is task 1, |20d4fa9c| 22:50:04 22:50:11 0:00:07 0:00:15 145 | 146 | 0:00:15 147 | ``` 148 | 149 | Delete the time tracking 150 | 151 | `task 1 mod +cleartime` 152 | or 153 | `task 1 mod +ctime` 154 | or 155 | `task 1 mod +deletetime` 156 | or 157 | `task 1 mod +dtime` 158 | 159 | Update time tracking 160 | 161 | `task 1 mod +update` 162 | or 163 | `task 1 mod +updatetime` 164 | or 165 | `task 1 mod +utime` 166 | or 167 | `task 1 mod +recalc` 168 | 169 | -------------------------------------------------------------------------------- /assets/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gkssjovi/trackwarrior/029a11a0bc639dc9e7cd22d2f70b06a8e69dacf1/assets/example.gif -------------------------------------------------------------------------------- /bin/local-trackwarrior-docker: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | source_path="$(readlink -f "$(readlink "${BASH_SOURCE[0]}")")" 6 | source_dir="$(dirname "${source_path}")" 7 | 8 | docker build -t trackwarrior/trackwarrior:latest "$source_dir" 9 | 10 | "$source_dir"/trackwarror-docker "$@" 11 | -------------------------------------------------------------------------------- /bin/trackwarrior-docker: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | docker run --rm \ 6 | -it \ 7 | -v "/etc/timezone:/etc/timezone:ro" \ 8 | -v "/etc/localtime:/etc/localtime:ro" \ 9 | -v "$HOME/.trackwarrior-docker:/root" \ 10 | trackwarrior/trackwarrior:latest "$@" 11 | -------------------------------------------------------------------------------- /bin/trackwarrior-docker-tasksh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | source_path="$(readlink -f "$(readlink "${BASH_SOURCE[0]}")")" 6 | source_dir="$(dirname "${source_path}")" 7 | 8 | "$source_dir"/trackwarrior-docker tasksh 9 | -------------------------------------------------------------------------------- /bin/trackwarrior-docker-tui: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | source_path="$(readlink -f "$(readlink "${BASH_SOURCE[0]}")")" 6 | source_dir="$(dirname "${source_path}")" 7 | 8 | "$source_dir"/trackwarrior-docker taskwarrior-tui 9 | -------------------------------------------------------------------------------- /dev.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import time 4 | import os 5 | from watchdog.observers import Observer 6 | from watchdog.events import PatternMatchingEventHandler 7 | import shutil 8 | 9 | timewarrior_abs_dest = os.path.expanduser('~/.timewarrior/extensions') 10 | timewarrior_src = './timewarrior/extensions' 11 | timewarrior_abs_src = os.path.abspath(timewarrior_src) 12 | 13 | taskwarrior_abs_dest = os.path.expanduser('~/.task/hooks') 14 | taskwarrior_src = './taskwarrior/hooks' 15 | taskwarrior_abs_src = os.path.abspath(taskwarrior_src) 16 | 17 | if __name__ == '__main__': 18 | patterns = ['*'] 19 | ignore_patterns = None 20 | ignore_directories = False 21 | case_sensitive = True 22 | timewarrior_event_handler = PatternMatchingEventHandler(patterns, ignore_patterns, ignore_directories, case_sensitive) 23 | taskwarrior_event_handler = PatternMatchingEventHandler(patterns, ignore_patterns, ignore_directories, case_sensitive) 24 | 25 | def watch_timewarrior(event): 26 | src_path = event.src_path 27 | is_directory = event.is_directory 28 | event_type = event.event_type 29 | 30 | if src_path == timewarrior_abs_src: 31 | return None 32 | 33 | src_path_rel = os.path.relpath(src_path, timewarrior_src) 34 | dest = os.path.join(timewarrior_abs_dest, src_path_rel) 35 | 36 | try: 37 | if event_type == 'deleted': 38 | if os.path.exists(dest) and not is_directory: 39 | os.remove(dest) 40 | print(f'{event_type}: {dest}') 41 | else: 42 | shutil.copyfile(src_path, dest) 43 | print(f'{event_type}: {dest}') 44 | except OSError as e: 45 | print(str(e)) 46 | 47 | timewarrior_event_handler.on_created = watch_timewarrior 48 | timewarrior_event_handler.on_deleted = watch_timewarrior 49 | timewarrior_event_handler.on_modified = watch_timewarrior 50 | timewarrior_event_handler.on_moved = watch_timewarrior 51 | 52 | go_recursively = True 53 | timewarrior_observer = Observer() 54 | timewarrior_observer.schedule(timewarrior_event_handler, timewarrior_src, recursive=go_recursively) 55 | timewarrior_observer.start() 56 | 57 | def watch_taskwarrior(event): 58 | src_path = event.src_path 59 | is_directory = event.is_directory 60 | event_type = event.event_type 61 | 62 | if src_path == taskwarrior_abs_src: 63 | return None 64 | 65 | src_path_rel = os.path.relpath(src_path, taskwarrior_src) 66 | dest = os.path.join(taskwarrior_abs_dest, src_path_rel) 67 | 68 | try: 69 | if event_type == 'deleted': 70 | if os.path.exists(dest) and not is_directory: 71 | os.remove(dest) 72 | print(f'{event_type}: {dest}') 73 | else: 74 | shutil.copyfile(src_path, dest) 75 | print(f'{event_type}: {dest}') 76 | except OSError as e: 77 | print(str(e)) 78 | 79 | taskwarrior_event_handler.on_created = watch_taskwarrior 80 | taskwarrior_event_handler.on_deleted = watch_taskwarrior 81 | taskwarrior_event_handler.on_modified = watch_taskwarrior 82 | taskwarrior_event_handler.on_moved = watch_taskwarrior 83 | 84 | go_recursively = True 85 | taskwarrior_observer = Observer() 86 | taskwarrior_observer.schedule(taskwarrior_event_handler, taskwarrior_src, recursive=go_recursively) 87 | taskwarrior_observer.start() 88 | 89 | try: 90 | while True: 91 | time.sleep(1) 92 | except KeyboardInterrupt: 93 | timewarrior_observer.stop() 94 | timewarrior_observer.join() 95 | 96 | taskwarrior_observer.stop() 97 | taskwarrior_observer.join() 98 | -------------------------------------------------------------------------------- /docker/base.fish: -------------------------------------------------------------------------------- 1 | function fish_greeting 2 | end 3 | 4 | abbr ta 'task' 5 | abbr ti 'timew' 6 | -------------------------------------------------------------------------------- /docker/sysinit.vim: -------------------------------------------------------------------------------- 1 | let mapleader = " " 2 | 3 | set tabstop=4 4 | set shiftwidth=4 5 | set noexpandtab 6 | set nobackup 7 | set nowritebackup 8 | set whichwrap+=<,>,h,l,[,] 9 | set incsearch 10 | set ignorecase 11 | set smartcase 12 | set smartindent 13 | set wildmenu 14 | set wildmode=full 15 | set foldmethod=indent 16 | set foldenable 17 | set foldlevelstart=10 18 | set foldnestmax=10 19 | set laststatus=2 20 | set splitright 21 | set splitbelow 22 | set backspace=indent,eol,start 23 | set nowrap 24 | set nohlsearch 25 | set timeoutlen=2000 26 | set mouse= 27 | set noswapfile 28 | set hidden 29 | 30 | " Better display for messages 31 | set cmdheight=2 32 | 33 | " You will have bad experience for diagnostic messages when it's default 4000. 34 | set updatetime=300 35 | 36 | " don't give |ins-completion-menu| messages. 37 | set shortmess+=c 38 | 39 | " always show signcolumns 40 | set signcolumn=yes 41 | 42 | set relativenumber 43 | set number 44 | 45 | nnoremap k :bn 46 | nnoremap k :bn! 47 | nnoremap j :bp 48 | nnoremap j :bp! 49 | nnoremap d :bd 50 | nnoremap d :bd! 51 | nnoremap w :w 52 | nnoremap wq :wq 53 | nnoremap q :q 54 | nnoremap o gf 55 | -------------------------------------------------------------------------------- /docker/taskrc: -------------------------------------------------------------------------------- 1 | # Taskwarrior program configuration file. 2 | # For more documentation, see http://taskwarrior.org or try 'man task', 'man task-color', 3 | # 'man task-sync' or 'man taskrc' 4 | 5 | # Here is an example of entries that use the default, override and blank values 6 | # variable=foo -- By specifying a value, this overrides the default 7 | # variable= -- By specifying no value, this means no default 8 | # #variable=foo -- By commenting out the line, or deleting it, this uses the default 9 | 10 | # Use the command 'task show' to see all defaults and overrides 11 | 12 | # Files 13 | data.location=~/.task 14 | 15 | # Color theme (uncomment one to use) 16 | #include /usr/share/taskwarrior/light-16.theme 17 | #include /usr/share/taskwarrior/light-256.theme 18 | #include /usr/share/taskwarrior/dark-16.theme 19 | #include /usr/share/taskwarrior/dark-256.theme 20 | #include /usr/share/taskwarrior/dark-red-256.theme 21 | #include /usr/share/taskwarrior/dark-green-256.theme 22 | #include /usr/share/taskwarrior/dark-blue-256.theme 23 | #include /usr/share/taskwarrior/dark-violets-256.theme 24 | #include /usr/share/taskwarrior/dark-yellow-green.theme 25 | #include /usr/share/taskwarrior/dark-gray-256.theme 26 | #include /usr/share/taskwarrior/dark-gray-blue-256.theme 27 | #include /usr/share/taskwarrior/solarized-dark-256.theme 28 | #include /usr/share/taskwarrior/solarized-light-256.theme 29 | #include /usr/share/taskwarrior/no-color.theme 30 | 31 | uda.trackwarrior.type=string 32 | uda.trackwarrior.label=Total active time 33 | uda.trackwarrior.values= 34 | 35 | uda.trackwarrior_rate.type=string 36 | uda.trackwarrior_rate.label=Rate 37 | uda.trackwarrior_rate.values= 38 | 39 | uda.trackwarrior_total_amount.type=string 40 | uda.trackwarrior_total_amount.label=Total amount 41 | uda.trackwarrior_total_amount.values= 42 | 43 | # this allow only one task to be active 44 | max_active_tasks=1 45 | 46 | # when you delete the task, the time tracking will be also be deleted from timewarrior 47 | erase_time_on_delete=false 48 | 49 | # those are tags in taskwarrior.When you add one of them the time tracking will be deleted from timewarrior 50 | clear_time_tags=cleartime,ctime,deletetime,dtime 51 | update_time_tags=updatetime,utime,update 52 | rate_per_hour=10 53 | rate_per_hour_decimals=2 54 | rate_per_hour_project=Inbox:0,Other:10 55 | rate_format_with_spaces=10 56 | currency_format=de-DE,EUR 57 | 58 | report.next.labels=ID,St,Active,Age,Time,Description,Urg 59 | report.next.columns=id,status.short,start.age,entry.age,trackwarrior,description,urgency 60 | uda.reviewed.type=date 61 | uda.reviewed.label=Reviewed 62 | report._reviewed.description=Tasksh review report. Adjust the filter to your needs. 63 | report._reviewed.columns=uuid 64 | report._reviewed.sort=reviewed+,modified+ 65 | report._reviewed.filter=( reviewed.none: or reviewed.before:now-6days ) and ( +PENDING or +WAITING ) 66 | urgency.user.tag.problem.coefficient=4.5 67 | urgency.user.tag.later.coefficient=-6.0 68 | 69 | create_time_when_add_task = true 70 | 71 | report.starship-project.description=Shows current task for use in starship 72 | report.starship-project.columns=id,project 73 | report.starship-project.filter=status:pending and +ACTIVE 74 | report.starship-project.sort=project+,start+ 75 | -------------------------------------------------------------------------------- /docker/trackwarrior.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | start_command=${1:-fish} 4 | 5 | yes | timew &> /dev/null 6 | yes | task &> /dev/null 7 | 8 | mkdir -p /root/.config/fish/conf.d 9 | mkdir -p /root/.task/hooks 10 | mkdir -p /root/.timewarrior/extensions 11 | 12 | cp -n /trackwarrior-docker/taskrc /root/.taskrc 13 | cp -n /trackwarrior-docker/base.fish /root/.config/fish/conf.d/base.fish 14 | cp -n -r /trackwarrior/taskwarrior/hooks/* /root/.task/hooks/ 15 | cp -n -r /trackwarrior/timewarrior/extensions/* /root/.timewarrior/extensions/ 16 | 17 | cd /root/.task/hooks && chmod +x on-modify.trackwarrior on-add.trackwarrior 18 | cd /root/.timewarrior/extensions && chmod +x trackwarrior-duration.js trackwarrior-ids.js 19 | 20 | # change directory to home 21 | cd 22 | 23 | $start_command 24 | -------------------------------------------------------------------------------- /docker/unbuffer: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # -*- tcl -*- 3 | # The next line is executed by /bin/sh, but not tcl \ 4 | exec tclsh8.6 "$0" ${1+"$@"} 5 | 6 | package require Expect 7 | 8 | 9 | # -*- tcl -*- 10 | # Description: unbuffer stdout of a program 11 | # Author: Don Libes, NIST 12 | 13 | if {[string compare [lindex $argv 0] "-p"] == 0} { 14 | # pipeline 15 | set stty_init "-echo" 16 | eval [list spawn -noecho] [lrange $argv 1 end] 17 | close_on_eof -i $user_spawn_id 0 18 | interact { 19 | eof { 20 | # flush remaining output from child 21 | expect -timeout 1 -re .+ 22 | return 23 | } 24 | } 25 | } else { 26 | set stty_init "-opost" 27 | set timeout -1 28 | eval [list spawn -noecho] $argv 29 | expect 30 | exit [lindex [wait] 3] 31 | } 32 | -------------------------------------------------------------------------------- /taskwarrior/hooks/library/Config.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | class Config { 4 | data = {}; 5 | 6 | constructor(data) { 7 | this.data = data; 8 | 9 | } 10 | 11 | get(key = null, defaultValue = '') { 12 | if (!key) { 13 | return this.data; 14 | } 15 | 16 | if (this.has(key)) { 17 | return this.data[key]; 18 | } 19 | 20 | return defaultValue; 21 | } 22 | 23 | getBool(key) { 24 | const value = this.get(key); 25 | return /^(on|1|yes|y|true)$/i.test(value); 26 | } 27 | 28 | getInt(key) { 29 | const value = this.get(key); 30 | return !isNaN(Number(value)) ? Number(value) : 0; 31 | } 32 | 33 | getArray(key, defaultValue = []) { 34 | const value = this.get(key); 35 | 36 | if (typeof value === 'string') { 37 | let result = value.split(','); 38 | if (result.length == 1 && result[0].trim() == '') { 39 | return []; 40 | } 41 | 42 | return result.map(item => item.trim()); 43 | } 44 | 45 | return defaultValue; 46 | } 47 | 48 | has(key) { 49 | return Object.prototype.hasOwnProperty.call(this.data, key); 50 | } 51 | } 52 | 53 | module.exports = { 54 | Config, 55 | }; -------------------------------------------------------------------------------- /taskwarrior/hooks/library/Init.js: -------------------------------------------------------------------------------- 1 | 2 | const readline = require('readline'); 3 | const child_process = require('child_process'); 4 | const { spawn } = child_process; 5 | const util = require('util'); 6 | const exec = util.promisify(child_process.exec); 7 | 8 | 9 | const ACTION_OLD_START = 0x1; 10 | const ACTION_CURRENT_START = 0x2; 11 | 12 | const ACTION_START = 'start'; 13 | const ACTION_STOP = 'stop'; 14 | 15 | async function Init() { 16 | let input = []; 17 | 18 | const rl = readline.createInterface({ 19 | input: process.stdin, 20 | output: process.stdout, 21 | }); 22 | for await (const line of rl) { 23 | input.push(line); 24 | } 25 | 26 | const [oldInput, currentInput] = input; 27 | 28 | const old = JSON.parse(oldInput); 29 | const current = JSON.parse(currentInput); 30 | 31 | const call = async (command, args, silent = false) => { 32 | const data = []; 33 | const child = spawn(command, args); 34 | 35 | for await (const line of child.stdout) { 36 | if (!silent) { 37 | process.stdout.write(line) 38 | } 39 | data.push(line.toString()); 40 | } 41 | 42 | return data.join(''); 43 | }; 44 | 45 | const flags = (actionFlags) => { 46 | let inputFlags = 0; 47 | if (current.start) { 48 | inputFlags |= ACTION_CURRENT_START; 49 | } 50 | if (old.start) { 51 | inputFlags |= ACTION_OLD_START; 52 | } 53 | 54 | return ((inputFlags & actionFlags) == actionFlags) 55 | }; 56 | 57 | const getAction = () => { 58 | if (flags(ACTION_CURRENT_START) && !flags(ACTION_OLD_START)) { 59 | return ACTION_START; 60 | } else if (!flags(ACTION_CURRENT_START) && flags(ACTION_OLD_START)) { 61 | return ACTION_STOP; 62 | } 63 | }; 64 | 65 | return { 66 | input: { 67 | old, 68 | current, 69 | }, 70 | call, 71 | exec, 72 | flags, 73 | action: getAction(), 74 | }; 75 | } 76 | 77 | module.exports = { 78 | Init, 79 | ACTION_OLD_START, 80 | ACTION_CURRENT_START, 81 | ACTION_START, 82 | ACTION_STOP, 83 | }; -------------------------------------------------------------------------------- /taskwarrior/hooks/library/Record.js: -------------------------------------------------------------------------------- 1 | 2 | const { formatTime, formatWithSpaces } = require('./tools') 3 | const { config, settings } = require('./settings') 4 | 5 | const STATUS_DELETED = 'deleted'; 6 | const STATUS_PENDING = 'pending'; 7 | const STATUS_START = 'start'; 8 | const STATUS_COMPLETED = 'completed'; 9 | 10 | class Record { 11 | data = null 12 | cleartime = false 13 | 14 | constructor(data) { 15 | this.data = data; 16 | 17 | if (this.data.tags) { 18 | const clearTimeTags = config.getArray('clear_time_tags', settings.clearTimeTags); 19 | const updateTimeTags = config.getArray('update_time_tags', settings.clearTimeTags); 20 | this.data.tags = this.data.tags.filter(tag => { 21 | const condition = !clearTimeTags.includes(tag); 22 | if (!condition) { 23 | this.cleartime = true; 24 | } 25 | 26 | return condition && !updateTimeTags.includes(tag); 27 | }); 28 | } 29 | } 30 | 31 | hasCleartime() { 32 | return this.cleartime; 33 | } 34 | 35 | getEntry() { 36 | return this.get('entry'); 37 | } 38 | 39 | getDuration() { 40 | return this.get('trackwarrior'); 41 | } 42 | 43 | getStatus() { 44 | return this.get('status'); 45 | } 46 | 47 | delete(key) { 48 | delete this.data[key]; 49 | } 50 | 51 | setDuration(value) { 52 | value = String(value).trim(); 53 | const duration = !isNaN(parseInt(value)) ? parseInt(value) : 0; 54 | 55 | this.set('trackwarrior', duration != 0 ? formatTime(String(duration)) : ''); 56 | 57 | let ratePerHour = config.getInt('rate_per_hour', settings.ratePerHour); 58 | const currencyFormat = config.getArray('currency_format', settings.currencyFormat); 59 | 60 | const project = this.get('project', ''); 61 | if (project.length > 0) { 62 | const ratePerHourProject = config.getArray('rate_per_hour_project', settings.ratePerHourProject); 63 | const rates = {}; 64 | for (let item of ratePerHourProject) { 65 | const [projectName, projectRate] = item.split(':'); 66 | rates[projectName.trim()] = !isNaN(Number(projectRate.trim())) ? Number(projectRate.trim()) : 0; 67 | } 68 | 69 | if (Object.keys(rates).includes(project)) { 70 | ratePerHour = rates[project]; 71 | } 72 | } 73 | 74 | const total = ((ratePerHour / 3600) * duration); 75 | const formatConfig = { 76 | style: 'currency', 77 | currency: currencyFormat[1], 78 | useGrouping: true, 79 | }; 80 | 81 | const amountDecimals = {}; 82 | 83 | const decimals = config.getInt('rate_per_hour_decimals', settings.ratePerHourDecimals); 84 | 85 | const amountFormatter = Intl.NumberFormat(currencyFormat[0], { 86 | ...formatConfig, 87 | ...amountDecimals, 88 | minimumFractionDigits: decimals, 89 | maximumFractionDigits: decimals, 90 | }); 91 | const rateFormatter = Intl.NumberFormat(currencyFormat[0], { 92 | ...formatConfig, 93 | minimumFractionDigits: 0, 94 | maximumFractionDigits: 0, 95 | }); 96 | 97 | const ratelFormatted = ratePerHour == 0 ? '' : `${rateFormatter.format(ratePerHour)}`; 98 | this.set('trackwarrior_rate', ratelFormatted); 99 | 100 | const spaces = config.getInt('rate_format_with_spaces', settings.rateFormatWithSpaces); 101 | const totalFormatted = total == 0 ? '' : formatWithSpaces(amountFormatter.format(total), spaces); 102 | this.set('trackwarrior_total_amount', totalFormatted); 103 | } 104 | 105 | hasStart() { 106 | return this.get('start') ? true : false; 107 | } 108 | 109 | set(key, value) { 110 | this.data[key] = value; 111 | } 112 | 113 | get(key = null, defaultValue = '') { 114 | if (!key) { 115 | return this.data; 116 | } 117 | 118 | if (Object.prototype.hasOwnProperty.call(this.data, key)) { 119 | return this.data[key]; 120 | } 121 | return defaultValue; 122 | } 123 | 124 | getId(format = true) { 125 | const id = this.data.uuid.split('-')[0]; 126 | if (format) { 127 | // `:${id}` display at the begining of the table based on ascii, `|${id}|` on the end of the table 128 | return `|${id}|`; 129 | } 130 | return id; 131 | } 132 | 133 | getUUID() { 134 | return this.data.uuid; 135 | } 136 | 137 | getDescription() { 138 | return this.data.description; 139 | } 140 | 141 | getTags(escaped = false) { 142 | const { data } = this; 143 | 144 | let tags = [ 145 | this.getId(), 146 | data.description 147 | ]; 148 | 149 | if (data.project) { 150 | if (!tags.includes(data.project)) { 151 | tags.push(data.project); 152 | } 153 | } 154 | 155 | if (data.tags) { 156 | tags = tags.concat(data.tags.filter((tag) => !tags.includes(tag))); 157 | } 158 | 159 | if (!escaped) { 160 | return tags; 161 | } 162 | 163 | return tags.map(tag => { 164 | return (/\s/.test(tag) ? `"${tag.replace(/"/g, "\\\"")}"` : tag); 165 | }); 166 | } 167 | 168 | getAnnotation = () => { 169 | const { data } = this; 170 | if (!data.annotations) { 171 | return ''; 172 | } 173 | 174 | return data.annotations[0].description; 175 | } 176 | 177 | output() { 178 | return JSON.stringify(this.data); 179 | } 180 | } 181 | 182 | 183 | module.exports = { 184 | Record, 185 | STATUS_DELETED, 186 | STATUS_PENDING, 187 | STATUS_START, 188 | STATUS_COMPLETED, 189 | }; 190 | -------------------------------------------------------------------------------- /taskwarrior/hooks/library/settings.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | const path = require('path'); 4 | const os = require('os'); 5 | const { Config } = require('./Config'); 6 | const fs = require('fs'); 7 | 8 | const settings = { 9 | taskrc: path.join(os.homedir(), '.taskrc'), 10 | maxActiveTasks: 1, // if '1' you can start one one task at the time 11 | eraseTimeOnDelete: false, // if 'true' erase the time tracking when you delete the task 12 | clearTimeTags: ['cleartime', 'ctime', 'deletetime', 'dtime'], 13 | createTimeWhenAddTask: false, 14 | ratePerHour: 1, 15 | currencyFormat: ['de-De', 'EUR'], 16 | ratePerHourProject: ['Inbox:1', 'Other:10'], 17 | ratePerHourDecimals: 2, 18 | rateFormatWithSpaces: 10, 19 | }; 20 | 21 | const configObj = {}; 22 | 23 | if (fs.existsSync(settings.taskrc)) { 24 | const configData = (fs.readFileSync(settings.taskrc, 'utf-8')).split('\n'); 25 | 26 | for (const line of configData) { 27 | if (!(/^((\s+)?\#)/gim.test(line))) { 28 | let l = line.trim(); 29 | if (l.length > 0) { 30 | const [left, right] = l.split('='); 31 | if (left && right) { 32 | configObj[left.trim()] = right.trim(); 33 | } 34 | } 35 | } 36 | } 37 | } 38 | 39 | const config = new Config(configObj); 40 | 41 | module.exports = { 42 | settings, 43 | config, 44 | }; -------------------------------------------------------------------------------- /taskwarrior/hooks/library/tools.js: -------------------------------------------------------------------------------- 1 | 2 | function formatWithSpaces(value, spaces = 10) { 3 | let diff = spaces - String(value).length; 4 | 5 | if (diff < 0) { 6 | diff = 0; 7 | } 8 | 9 | return `${' '.repeat(diff)}${value}`; 10 | } 11 | 12 | function formatTime(seconds) { 13 | const h = Math.floor(seconds / 3600); 14 | const m = Math.floor((seconds % 3600) / 60); 15 | const s = Math.round(seconds % 60); 16 | return [ 17 | h, 18 | // m > 9 ? m : (h ? '0' + m : m || '0'), 19 | m > 9 ? m : '0' +m , 20 | s > 9 ? s : '0' + s 21 | ].join(':'); 22 | } 23 | 24 | function sameArray(arr1, arr2) { 25 | const set1 = new Set(arr1); 26 | const set2 = new Set(arr2); 27 | return arr1.every(item => set2.has(item)) && 28 | arr2.every(item => set1.has(item)) 29 | }; 30 | 31 | module.exports = { 32 | formatTime, 33 | sameArray, 34 | formatWithSpaces, 35 | }; -------------------------------------------------------------------------------- /taskwarrior/hooks/on-add.trackwarrior: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const readline = require('readline'); 4 | const { Record } = require('./library/Record'); 5 | const { settings, config } = require('./library/settings'); 6 | 7 | const main = async () => { 8 | let input = ''; 9 | const rl = readline.createInterface({ 10 | input: process.stdin, 11 | output: process.stdout, 12 | }); 13 | 14 | for await (const line of rl) { 15 | input += line.toString() 16 | } 17 | 18 | const current = new Record(JSON.parse(input)); 19 | 20 | if (config.getBool('create_time_when_add_task', settings.createTimeWhenAddTask)) { 21 | current.setDuration('0'); 22 | } 23 | 24 | process.stdout.write(current.output()); 25 | }; 26 | 27 | main(); -------------------------------------------------------------------------------- /taskwarrior/hooks/on-modify.trackwarrior: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const { sameArray } = require('./library/tools') 4 | const { Init, ACTION_CURRENT_START, ACTION_OLD_START, ACTION_START, ACTION_STOP } = require('./library/Init'); 5 | const { Record, STATUS_DELETED } = require('./library/Record'); 6 | const { settings, config } = require('./library/settings'); 7 | 8 | const main = async () => { 9 | const { input, call, flags, action } = await Init(); 10 | 11 | const old = new Record(input.old); 12 | const current = new Record(input.current); 13 | 14 | if (action) { 15 | // if one is started and has action 16 | if (action == ACTION_START) { 17 | 18 | const totalActive = parseInt((await call('task', ['+ACTIVE', 'status:pending', 'count', 'rc.verbose:off'], true)).trim()); 19 | const maxActive = config.getInt('max_active_tasks', settings.maxActiveTasks); 20 | 21 | if (totalActive + 1 > maxActive) { 22 | console.log(`Only ${totalActive} task(s) can be active at time. See 'max_active_tasks' in .taskrc.`); 23 | process.exit(1); 24 | } 25 | 26 | await call('timew', ['stop', ':yes'], true) 27 | } 28 | 29 | await call('timew', [action, ...current.getTags(), ':yes']) 30 | } else if (flags(ACTION_OLD_START | ACTION_CURRENT_START)) { 31 | // if both are started 32 | const oldTags = old.getTags() 33 | const currentTags = current.getTags() 34 | 35 | if (!sameArray(oldTags, currentTags)) { 36 | await call('timew', ['untag', '@1', ...oldTags, ':yes']); 37 | await call('timew', ['tag', '@1', ...currentTags, ':yes']); 38 | } 39 | 40 | const oldAnnotation = old.getAnnotation(); 41 | const currentAnnotation = current.getAnnotation(); 42 | 43 | if (oldAnnotation != currentAnnotation) { 44 | await call('timew', ['annotate', '@1', currentAnnotation]); 45 | } 46 | } 47 | 48 | if ((config.getBool('erase_time_on_delete', settings.eraseTimeOnDelete) && current.getStatus() == STATUS_DELETED) || current.hasCleartime()) { 49 | if (current.hasCleartime() && flags(ACTION_CURRENT_START)) { 50 | current.delete('start'); 51 | } 52 | const ids = await call('timew', ['trackwarrior-ids.js', current.getId(), current.getEntry(), '-', 'now', ':ids'], true); 53 | 54 | if (ids.length > 0) { 55 | await call('timew', ['delete', ...ids.split(' ')], false); 56 | current.setDuration('0'); 57 | } 58 | } else { 59 | const duration = await call('timew', ['trackwarrior-duration.js', current.getId(), current.getEntry(), '-', 'now'], true); 60 | current.setDuration(duration); 61 | } 62 | 63 | process.stdout.write(current.output()); 64 | }; 65 | 66 | main(); 67 | -------------------------------------------------------------------------------- /timewarrior/extensions/library/Config.js: -------------------------------------------------------------------------------- 1 | 2 | class Config { 3 | data = {}; 4 | 5 | constructor(data) { 6 | this.data = data; 7 | 8 | } 9 | 10 | get(key = null, defaultValue = '') { 11 | if (!key) { 12 | return this.data; 13 | } 14 | 15 | if (this.has(key)) { 16 | return this.data[key]; 17 | } 18 | 19 | return defaultValue; 20 | } 21 | 22 | getBool(key) { 23 | const value = this.get(key); 24 | return /^(on|1|yes|y|true)$/i.test(value); 25 | } 26 | 27 | getInt(key) { 28 | const value = this.get(key); 29 | return !isNaN(value) ? value : 0; 30 | } 31 | 32 | has(key) { 33 | return Object.prototype.hasOwnProperty.call(this.data, key); 34 | } 35 | } 36 | 37 | module.exports = Config; -------------------------------------------------------------------------------- /timewarrior/extensions/library/Init.js: -------------------------------------------------------------------------------- 1 | 2 | const readline = require('readline'); 3 | const Parser = require('./Parser'); 4 | 5 | async function Init() { 6 | let input = []; 7 | 8 | const rl = readline.createInterface({ 9 | input: process.stdin, 10 | output: process.stdout, 11 | }); 12 | 13 | for await (const line of rl) { 14 | input.push(line); 15 | } 16 | 17 | const parser = new Parser(input); 18 | 19 | return { 20 | parser, 21 | }; 22 | } 23 | 24 | module.exports = Init; -------------------------------------------------------------------------------- /timewarrior/extensions/library/Interval.js: -------------------------------------------------------------------------------- 1 | 2 | const { parseUTCDate, escapeTags } = require('./tools'); 3 | 4 | 5 | class Interval { 6 | id = 0; 7 | start = null; 8 | end = null; 9 | tags = []; 10 | annotation = null; 11 | 12 | constructor(props) { 13 | this.id = props.id; 14 | this.start = props.start; 15 | this.end = props.end || null; 16 | this.tags = props.tags || []; 17 | this.annotation = props.annotation || null; 18 | } 19 | 20 | getId() { 21 | return `@${this.id}`; 22 | } 23 | 24 | getStart() { 25 | return parseUTCDate(this.start); 26 | } 27 | 28 | getEnd() { 29 | return (!this.isOpen() ? parseUTCDate(this.end) : null); 30 | } 31 | 32 | getTags(escape = false) { 33 | if (escape) { 34 | return escapeTags(this.tags); 35 | } 36 | return this.tags; 37 | } 38 | 39 | getAnnotation() { 40 | return this.annotation; 41 | } 42 | 43 | getDuration(seconds = true) { 44 | const start = this.getStart(); 45 | const end = !this.isOpen() ? this.getEnd() : new Date(Date.now()); 46 | const duration = end.getTime() - start.getTime() 47 | 48 | return seconds ? Math.trunc(duration / 1000) : duration; 49 | } 50 | 51 | isOpen() { 52 | return this.end == null; 53 | } 54 | 55 | } 56 | 57 | module.exports = Interval; -------------------------------------------------------------------------------- /timewarrior/extensions/library/Intervals.js: -------------------------------------------------------------------------------- 1 | 2 | const Interval = require('./Interval'); 3 | 4 | class Intervals { 5 | data = []; 6 | intervals = []; 7 | 8 | constructor(data) { 9 | this.data = data; 10 | 11 | this.intervals = this.data.map(item => new Interval({ 12 | id: item.id, 13 | start: item.start, 14 | end: item.end, 15 | tags: item.tags, 16 | annotation: item.annotation, 17 | })); 18 | } 19 | 20 | get() { 21 | return this.intervals; 22 | } 23 | 24 | getData() { 25 | return this.data; 26 | } 27 | 28 | getIds(string = false) { 29 | 30 | const ids = this.intervals.map(interval => interval.getId()); 31 | 32 | if (string) { 33 | return ids.join(' ').trim(); 34 | } 35 | 36 | return ids; 37 | } 38 | 39 | getDuration(seconds = true) { 40 | const duration = this.intervals.reduce((total, interval) => total + interval.getDuration(false), 0); 41 | 42 | return seconds ? Math.trunc(duration / 1000) : duration; 43 | } 44 | } 45 | 46 | module.exports = Intervals; -------------------------------------------------------------------------------- /timewarrior/extensions/library/Parser.js: -------------------------------------------------------------------------------- 1 | 2 | const Config = require('./Config'); 3 | const Intervals = require('./Intervals'); 4 | 5 | class Parser { 6 | config = {}; 7 | intervals = []; 8 | 9 | constructor(input) { 10 | const { config, intervals } = this.parse(input); 11 | 12 | this.config = new Config(config); 13 | this.intervals = new Intervals(intervals); 14 | } 15 | 16 | parse(input) { 17 | const config = {}; 18 | 19 | let configEnd = false; 20 | let intervals = ''; 21 | for (const line of input) { 22 | if (line == '') { 23 | configEnd = true; 24 | } 25 | 26 | if (!configEnd) { 27 | const [left, right] = line.split(':'); 28 | config[left] = right.trim(); 29 | } else { 30 | intervals += line; 31 | } 32 | } 33 | intervals = JSON.parse(intervals); 34 | 35 | return { 36 | config, 37 | intervals, 38 | }; 39 | } 40 | } 41 | 42 | module.exports = Parser; -------------------------------------------------------------------------------- /timewarrior/extensions/library/tools.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | function parseUTCDate(str) { 4 | const match = str.match(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z/); 5 | return new Date(Date.UTC( 6 | parseInt(match[1]), 7 | parseInt(match[2]) - 1, 8 | parseInt(match[3]), 9 | parseInt(match[4]), 10 | parseInt(match[5]), 11 | parseInt(match[6]) 12 | )); 13 | } 14 | 15 | function escapeTags(tags) { 16 | return tags.map(tag => { 17 | return (/\s/.test(tag) ? `"${tag.replace(/"/g, "\\\"")}"` : tag); 18 | }); 19 | } 20 | 21 | module.exports = { 22 | parseUTCDate, 23 | escapeTags, 24 | }; -------------------------------------------------------------------------------- /timewarrior/extensions/trackwarrior-duration.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const Init = require('./library/Init'); 4 | 5 | const main = async () => { 6 | const { parser } = await Init(); 7 | const { config, intervals } = parser; 8 | 9 | const duration = intervals.getDuration(true); 10 | process.stdout.write(String(duration)); 11 | }; 12 | 13 | main(); 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /timewarrior/extensions/trackwarrior-ids.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const Init = require('./library/Init'); 4 | 5 | const main = async () => { 6 | const { parser } = await Init(); 7 | const { config, intervals } = parser; 8 | 9 | const ids = intervals.getIds(true); 10 | process.stdout.write(ids); 11 | }; 12 | 13 | main(); 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | --------------------------------------------------------------------------------