├── LICENSE ├── README.md ├── convert-5.1-to-5.2.sh ├── manage_notes_annotation.py ├── manage_notes_annotation_test.py ├── onenote ├── onenote_test ├── run-tests.sh └── vim-line-to-task.sh /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Chad Phillips 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OneNote 2 | Easily manage embedded multiline notes in [Taskwarrior](https://taskwarrior.org) 3 | 4 | ### Why would you want this? 5 | 6 | While Taskwarrior provides a robust system for annotating tasks, using these 7 | to include extensive notes on a task is cumbersome and difficult to edit in my 8 | experience. 9 | 10 | There are some third party scripts that associate files with tasks, however 11 | this involves a whole other system that has to be synchronized/maintained, 12 | which seems unnecessary given Taskwarrior's current syncing capabilities. 13 | 14 | OneNote manages notes in a custom data field directly in Taskwarrior, and 15 | automatically opens a pipe editor for managing notes efficiently. 16 | 17 | ### Installation 18 | 19 | #### Dependencies 20 | 21 | * Taskwarrior (preferably 2.5.2 or greater) 22 | * Bash 23 | * Vipe pipe editor (readily available in the ```moreutils``` package on most platforms) 24 | * Python (if using the add/modify hooks) 25 | 26 | #### Taskwarrior configuration 27 | 28 | OneNote expects a ```notes``` UDA to be available for data storage. 29 | 30 | Add this to your ```.taskrc``` config file with the following commands: 31 | 32 | ``` 33 | task config uda.notes.type string 34 | task config uda.notes.label Notes 35 | ``` 36 | 37 | #### Script 38 | 39 | Place the ```onenote``` script anywhere in your PATH and make sure it's 40 | executable. 41 | 42 | #### Taskwarrior add/modify hooks 43 | 44 | The optional add/modify hooks manage a Taskwarrior annotation related to the existence of 45 | notes on a task in an intelligent fashion: 46 | 47 | * Adds an annotation when a new note is added 48 | * Updates the annotation entry date if notes are updated 49 | * Removes the annotation if notes are completely removed 50 | 51 | To use the hook, copy or symlink ```manage_notes_annotation.py``` to your 52 | Taskwarrior hooks directory, and make sure it's executeable. 53 | 54 | * For the add hook, name it ```on-add-onenote-manage-notes-annotation.py``` 55 | * For the modify hook, name it ```on-modify-onenote-manage-notes-annotation.py``` 56 | 57 | ### Usage 58 | 59 | To add/edit notes attached to a task, execute ```onenote ```, which 60 | automatically opens an editor for the notes. 61 | 62 | Upon save/exit of the editor, the new notes are updated on the task. 63 | 64 | Vipe, the default pipe editor, opens the note in Vim. You can override this 65 | with your own pipe editor by setting the ```ONENOTE_PIPE_EDITOR``` environment 66 | variable to another pipe editor available in your path. 67 | 68 | To remove notes, you can use the procedure above and completely remove all 69 | content in the editor, or for a shortcut modify the 70 | ```notes``` attribute directly: 71 | 72 | ``` 73 | task modify notes: 74 | ``` 75 | 76 | Notes can also be piped from other processes directly to a task: 77 | 78 | ``` 79 | echo "foo" | onenote - 80 | ``` 81 | 82 | **Just be aware this will overwrite any existing notes on the task!** 83 | 84 | #### Environment variables 85 | 86 | OneNote sets the environment variable ```ONENOTE_TASK``` for the pipe editor 87 | process, the value of which is the task ID/UUID associated with the note. 88 | 89 | #### Default note content (optional) 90 | 91 | You can configure default content for an empty note by setting the 92 | ```ONENOTE_DEFAULT_CONTENT``` environment variable. If OneNote detects that 93 | the opened note is empty, it will insert the configured default content 94 | instead. If the note has any content at all, the default content will not be 95 | added to the note. 96 | 97 | This is an extremely handy feature when used with something like Vim's 98 | modelines. For example, the VimOutliner plugin registers the ```votl``` 99 | filetype for its outlining functionality. With a Bash alias like this, you can 100 | ensure that the note always contains the correct modeline to activate the 101 | outliner, even if the note was originally empty: 102 | 103 | ```sh 104 | alias outline="ONENOTE_DEFAULT_CONTENT='# vi: ft=votl' onenote" 105 | ``` 106 | 107 | #### Converting a line in a note to a task (optional) 108 | 109 | The included ```vim-line-to-task.sh``` script provides integrated support for 110 | converting a line of text in a note to a task on the same project as the task 111 | that contains the note. For example, to configure the script to work with 112 | Vim/Vipe, add the following to your ```.vimrc```: 113 | 114 | ```vimrc 115 | nnoremap t :.w !/path/to/vim-line-to-task.sh 116 | ``` 117 | 118 | The script also has basic support for converting a line of text outside of 119 | OneNote's default editor, in this case it will assign the task to your configured 120 | default project. 121 | 122 | ### VIT configuration (optional) 123 | 124 | If you're using a version of [VIT](https://github.com/vit-project/vit) that 125 | supports map commands, you can easily add a command to open a task's notes via 126 | onenote: 127 | 128 | ```dosini 129 | # Open OneNote for the current task. 130 | o = :!wr onenote {TASK_UUID} 131 | ``` 132 | 133 | Then in VIT, highlighting a task and hitting the ```o``` key will open the 134 | notes editor, and saving will return you to VIT -- pretty convenient! 135 | 136 | ### Support 137 | 138 | The issue tracker for this project is provided to file bug reports, feature 139 | requests, and project tasks -- support requests are not accepted via the issue 140 | tracker. For all support-related issues, including configuration, usage, and 141 | training, consider hiring a competent consultant. 142 | 143 | ### Unit tests 144 | 145 | OneNote has full unit test coverage. They can be run with: 146 | 147 | ``` 148 | cd /path/to/onenote 149 | ./run-tests.sh 150 | ``` 151 | 152 | Python >= 2.7 and the [basht](https://github.com/progrium/basht) testing 153 | framework are required. 154 | 155 | ### Caveats 156 | 157 | While it's possible to modify the ```notes``` UDA directly using standard 158 | Taskwarrior syntax, it's not advisable (except in the deletion case), as, by 159 | necessity, multiline UDA fields are stored as a single-line string with newline 160 | separators, which would be pretty tedious to navigate directly. 161 | 162 | Because of 163 | [this bug](https://github.com/GothenburgBitFactory/taskwarrior/issues/2107), 164 | in Taskwarrior versions less than 2.5.2, newlines are represented internally by the marker 165 | ```###NEWLINE###```. This is not an issue in TaskWarrior versions from 2.5.2 on. 166 | If you use OneNote with a Taskwarrior version less than 2.5.2, then 167 | upgrade to Taskwarrior version 2.5.2 or greater, you can use the 168 | [convert-5.1-to-5.2.sh](convert-5.1-to-5.2.sh) script to upgrade your tasks, but you are 169 | strongly encouraged to run ```convert-5.1-to-5.2.sh --help``` first and read the 170 | instructions -- the script will automatically edit **ALL** of your tasks! 171 | -------------------------------------------------------------------------------- /convert-5.1-to-5.2.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | PROGNAME="$(basename $0)" 4 | 5 | function usage() { 6 | echo " 7 | Usage: ${PROGNAME} 8 | Usage: ${PROGNAME} --help 9 | 10 | Converts the the OneNote 'notes' UDA from TaskWarrior 5.1 and prior format to 11 | TaskWarrior 5.2 and beyond format. 12 | 13 | This script requires the jq binary be in your path. 14 | 15 | CAVEATS: 16 | 17 | BACK UP YOUR DATA FIRST!!! 18 | 19 | This is a one-way operation, and if there are issues, well, you should really 20 | have a backup. 21 | 22 | By default, this converts all tasks, including completed/deleted. 23 | " 24 | } 25 | 26 | if [ $# -gt 0 ]; then 27 | usage 28 | exit 1 29 | fi 30 | 31 | if [ -z "$(which jq)" ]; then 32 | echo "ERROR: missing jq binary" 33 | usage 34 | exit 1 35 | fi 36 | 37 | uuids="$(task _uuids)" 38 | 39 | for uuid in $uuids; do 40 | notes="$(task verbose=nothing ${uuid} export | jq .[0].notes?)" 41 | if [ "${notes}" != "null" ]; then 42 | adjusted_notes="$(echo "${notes}" | sed -e 's/###NEWLINE###/\\n/g')" 43 | #echo "${adjusted_notes}" 44 | task rc.confirmation=off ${uuid} modify notes:"${adjusted_notes}" 45 | fi 46 | done 47 | 48 | -------------------------------------------------------------------------------- /manage_notes_annotation.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | import time 6 | import json 7 | 8 | LOG_FILEPATH = "/tmp/onenote.log" 9 | NOTES_ANNOTATION_DESCRIPTION = "[Notes]" 10 | DEBUG = "ONENOTE_DEBUG" in os.environ 11 | 12 | def log(message): 13 | if DEBUG: 14 | with open(LOG_FILEPATH, 'a') as logfile: 15 | logfile.write("%s\n" % message) 16 | 17 | def to_iso_string(time_struct): 18 | format_string = "%Y%m%dT%H%M%SZ" 19 | return time.strftime(format_string, time_struct) 20 | 21 | def current_time(): 22 | return time.gmtime(time.time()) 23 | 24 | def current_time_iso(): 25 | return to_iso_string(current_time()) 26 | 27 | def check_notes(data): 28 | return "notes" in data and data["notes"].strip() 29 | 30 | def check_notes_updated(data_before, data_after): 31 | if not "notes" in data_before and not "notes" in data_after: 32 | return False 33 | elif not "notes" in data_before and "notes" in data_after: 34 | return data_after["notes"].strip() 35 | elif "notes" in data_before and not "notes" in data_after: 36 | return data_before["notes"].strip() 37 | else: 38 | return data_before["notes"].strip() != data_after["notes"].strip() 39 | 40 | def check_notes_annotation(data): 41 | if "annotations" in data: 42 | for i, annotation in enumerate(data["annotations"]): 43 | if annotation["description"] == NOTES_ANNOTATION_DESCRIPTION: 44 | log("Found %s annotation for task: %s" % (NOTES_ANNOTATION_DESCRIPTION, data["uuid"])) 45 | return True 46 | return False 47 | 48 | def add_notes_annotation(data): 49 | if not "annotations" in data: 50 | data["annotations"] = [] 51 | annotation = { 52 | "entry": current_time_iso(), 53 | "description": NOTES_ANNOTATION_DESCRIPTION, 54 | } 55 | data["annotations"].append(annotation) 56 | log("Added %s annotation for task: %s" % (NOTES_ANNOTATION_DESCRIPTION, data["uuid"])) 57 | return data 58 | 59 | def remove_notes_annotation(data): 60 | if "annotations" in data: 61 | for i, annotation in enumerate(data["annotations"]): 62 | if annotation["description"] == NOTES_ANNOTATION_DESCRIPTION: 63 | data["annotations"].pop(i) 64 | log("Removed %s annotation for task: %s" % (NOTES_ANNOTATION_DESCRIPTION, data["uuid"])) 65 | if not data["annotations"]: 66 | data.pop('annotations', None) 67 | return data 68 | 69 | def manage_notes_annotation(data_before, data_after): 70 | # Deletion case, just return the task as is. 71 | if data_before and not data_after: 72 | return data_before 73 | notes_exist = check_notes(data_after) 74 | notes_annotation_exists = check_notes_annotation(data_after) 75 | if check_notes_updated(data_before, data_after): 76 | # Force removal here and mark as removed, allows a new annotation 77 | # with an updated entry time for the notes. 78 | data_after = remove_notes_annotation(data_after) 79 | notes_annotation_exists = False 80 | if notes_exist and not notes_annotation_exists: 81 | data_after = add_notes_annotation(data_after) 82 | if not notes_exist and notes_annotation_exists: 83 | data_after = remove_notes_annotation(data_after) 84 | return data_after 85 | 86 | def read_stdin(): 87 | first_line = sys.stdin.readline() 88 | second_line = sys.stdin.readline() 89 | return first_line, second_line 90 | 91 | def load_task_data(first_line, second_line): 92 | if second_line: 93 | try: 94 | task_before = json.loads(first_line) 95 | task_after = json.loads(second_line) 96 | return task_before, task_after 97 | except: 98 | exit_error() 99 | else: 100 | try: 101 | task_after = json.loads(first_line) 102 | return {}, task_after 103 | except: 104 | exit_error() 105 | 106 | def exit_error(): 107 | e = sys.exc_info()[0] 108 | log("Error: %s" % e) 109 | sys.exit(1) 110 | 111 | if __name__ == '__main__': 112 | 113 | try: 114 | first_line, second_line = read_stdin() 115 | task_before, task_after = load_task_data(first_line, second_line) 116 | if task_before: 117 | if task_after: 118 | log("Modifying current task") 119 | log("Task before modification") 120 | else: 121 | log("Deleting current task") 122 | log("Task before deletion") 123 | log(json.dumps(task_before, sort_keys=True, indent=2)) 124 | else: 125 | log("Adding new task") 126 | if task_after: 127 | log("Task after modification") 128 | log(json.dumps(task_after, sort_keys=True, indent=2)) 129 | modified_task = manage_notes_annotation(task_before, task_after) 130 | log("Task after hook adjustments") 131 | log(json.dumps(modified_task, sort_keys=True, indent=2)) 132 | except: 133 | exit_error() 134 | 135 | print(json.dumps(modified_task)) 136 | sys.exit(0) 137 | -------------------------------------------------------------------------------- /manage_notes_annotation_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import copy 3 | import json 4 | 5 | from pprint import pprint 6 | from manage_notes_annotation import load_task_data, manage_notes_annotation 7 | 8 | TASK_ADD_NO_NOTES = { 9 | "uuid": "test-uuid", 10 | } 11 | 12 | TASK_ADD_WITH_NOTES = { 13 | "uuid": "test-uuid", 14 | "notes": "test notes", 15 | } 16 | 17 | NOTE_ANNOTATION_NO_NOTES_BEFORE = { 18 | "annotations": [ 19 | { 20 | "description": "[Notes]", 21 | "entry": "20190131T194834Z" 22 | }, 23 | ], 24 | "uuid": "test-uuid", 25 | } 26 | 27 | NOTE_ANNOTATION_NO_NOTES_AFTER = { 28 | "annotations": [ 29 | { 30 | "description": "[Notes]", 31 | "entry": "20190131T194834Z" 32 | }, 33 | ], 34 | "uuid": "test-uuid", 35 | "notes": "", 36 | } 37 | 38 | MULTIPLE_ANNOTATIONS_NO_NOTES_BEFORE = { 39 | "annotations": [ 40 | { 41 | "description": "[Notes]", 42 | "entry": "20190131T194834Z" 43 | }, 44 | { 45 | "description": "test description", 46 | "entry": "20190131T194834Z" 47 | }, 48 | ], 49 | "uuid": "test-uuid", 50 | "notes": "", 51 | } 52 | 53 | MULTIPLE_ANNOTATIONS_NO_NOTES_AFTER = { 54 | "annotations": [ 55 | { 56 | "description": "[Notes]", 57 | "entry": "20190131T194834Z" 58 | }, 59 | { 60 | "description": "test description", 61 | "entry": "20190131T194834Z" 62 | }, 63 | ], 64 | "uuid": "test-uuid", 65 | } 66 | 67 | NOTE_ANNOTATION_WITH_NOTES_BEFORE = { 68 | "annotations": [ 69 | { 70 | "description": "[Notes]", 71 | "entry": "20190131T194834Z" 72 | }, 73 | ], 74 | "uuid": "test-uuid", 75 | "notes": "test notes", 76 | } 77 | 78 | NOTE_ANNOTATION_WITH_NOTES_AFTER = { 79 | "annotations": [ 80 | { 81 | "description": "[Notes]", 82 | "entry": "20190131T194834Z" 83 | }, 84 | ], 85 | "uuid": "test-uuid", 86 | "notes": "test notes", 87 | } 88 | 89 | NOTE_ANNOTATION_WITH_CHANGED_NOTES_BEFORE = { 90 | "annotations": [ 91 | { 92 | "description": "[Notes]", 93 | "entry": "20190131T194834Z" 94 | }, 95 | ], 96 | "uuid": "test-uuid", 97 | "notes": "test notes", 98 | } 99 | 100 | NOTE_ANNOTATION_WITH_CHANGED_NOTES_AFTER = { 101 | "annotations": [ 102 | { 103 | "description": "[Notes]", 104 | "entry": "20190131T194834Z" 105 | }, 106 | ], 107 | "uuid": "test-uuid", 108 | "notes": "test notes changed", 109 | } 110 | 111 | MISSING_ANNOTATIONS_WITH_NOTES_BEFORE = { 112 | "uuid": "test-uuid", 113 | "notes": "test notes", 114 | } 115 | 116 | MISSING_ANNOTATIONS_WITH_NOTES_AFTER = { 117 | "uuid": "test-uuid", 118 | "notes": "test notes", 119 | } 120 | 121 | NO_ANNOTATION_WITH_NOTES_BEFORE = { 122 | "annotations": [ 123 | { 124 | "description": "test description", 125 | "entry": "20190131T194834Z" 126 | }, 127 | ], 128 | "uuid": "test-uuid", 129 | "notes": "test notes", 130 | } 131 | 132 | NO_ANNOTATION_WITH_NOTES_AFTER = { 133 | "annotations": [ 134 | { 135 | "description": "test description", 136 | "entry": "20190131T194834Z" 137 | }, 138 | ], 139 | "uuid": "test-uuid", 140 | "notes": "test notes", 141 | } 142 | 143 | MULTIPLE_ANNOTATIONS_WITH_NOTES_BEFORE = { 144 | "annotations": [ 145 | { 146 | "description": "[Notes]", 147 | "entry": "20190131T194834Z" 148 | }, 149 | { 150 | "description": "test description", 151 | "entry": "20190131T194834Z" 152 | }, 153 | ], 154 | "uuid": "test-uuid", 155 | "notes": "test notes", 156 | } 157 | 158 | MULTIPLE_ANNOTATIONS_WITH_NOTES_AFTER = { 159 | "annotations": [ 160 | { 161 | "description": "[Notes]", 162 | "entry": "20190131T194834Z" 163 | }, 164 | { 165 | "description": "test description", 166 | "entry": "20190131T194834Z" 167 | }, 168 | ], 169 | "uuid": "test-uuid", 170 | "notes": "test notes", 171 | } 172 | 173 | def process_data(data_before, data_after): 174 | data = manage_notes_annotation(copy.deepcopy(data_before), copy.deepcopy(data_after)) 175 | return data 176 | 177 | class TestManageNotesAnnotation(unittest.TestCase): 178 | 179 | def test_load_task_data_add(self): 180 | json_string = json.dumps(TASK_ADD_WITH_NOTES) 181 | task_before, task_after = load_task_data(json_string, "") 182 | self.assertEqual(task_before, {}) 183 | self.assertEqual(task_after, TASK_ADD_WITH_NOTES) 184 | 185 | def test_load_task_data_modify(self): 186 | json_string_before = json.dumps(NOTE_ANNOTATION_NO_NOTES_BEFORE) 187 | json_string_after = json.dumps(NOTE_ANNOTATION_NO_NOTES_AFTER) 188 | task_before, task_after = load_task_data(json_string_before, json_string_after) 189 | self.assertEqual(task_before, NOTE_ANNOTATION_NO_NOTES_BEFORE) 190 | self.assertEqual(task_after, NOTE_ANNOTATION_NO_NOTES_AFTER) 191 | 192 | def test_add_task_no_notes(self): 193 | data = process_data({}, TASK_ADD_NO_NOTES) 194 | self.assertNotIn("annotations", data) 195 | 196 | def test_add_task_with_notes(self): 197 | data = process_data({}, TASK_ADD_WITH_NOTES) 198 | self.assertEqual(len(data["annotations"]), 1) 199 | self.assertEqual(data["annotations"][0]["description"], "[Notes]") 200 | 201 | def test_note_annotation_no_notes(self): 202 | data = process_data(NOTE_ANNOTATION_NO_NOTES_BEFORE, NOTE_ANNOTATION_NO_NOTES_AFTER) 203 | self.assertNotIn("annotations", data) 204 | 205 | def test_multiple_annotations_no_notes(self): 206 | data = process_data(MULTIPLE_ANNOTATIONS_NO_NOTES_BEFORE, MULTIPLE_ANNOTATIONS_NO_NOTES_AFTER) 207 | self.assertEqual(len(data["annotations"]), len(MULTIPLE_ANNOTATIONS_NO_NOTES_AFTER["annotations"]) - 1) 208 | self.assertEqual(data["annotations"][0]["description"], "test description") 209 | 210 | def test_note_annotation_with_notes(self): 211 | data = process_data(NOTE_ANNOTATION_WITH_NOTES_BEFORE, NOTE_ANNOTATION_WITH_NOTES_AFTER) 212 | self.assertEqual(len(data["annotations"]), len(NOTE_ANNOTATION_WITH_NOTES_AFTER["annotations"])) 213 | self.assertEqual(data["annotations"][0]["description"], "[Notes]") 214 | 215 | def test_note_annotation_with_changed_notes(self): 216 | data = process_data(NOTE_ANNOTATION_WITH_CHANGED_NOTES_BEFORE, NOTE_ANNOTATION_WITH_CHANGED_NOTES_AFTER) 217 | self.assertEqual(len(data["annotations"]), len(NOTE_ANNOTATION_WITH_CHANGED_NOTES_AFTER["annotations"])) 218 | self.assertEqual(data["annotations"][0]["description"], "[Notes]") 219 | self.assertNotEqual(data["annotations"][0]["entry"], NOTE_ANNOTATION_WITH_CHANGED_NOTES_AFTER["annotations"][0]["entry"]) 220 | 221 | def test_missing_annotations_with_notes(self): 222 | data = process_data(MISSING_ANNOTATIONS_WITH_NOTES_BEFORE, MISSING_ANNOTATIONS_WITH_NOTES_AFTER) 223 | self.assertEqual(len(data["annotations"]), 1) 224 | self.assertEqual(data["annotations"][0]["description"], "[Notes]") 225 | 226 | def test_no_annotation_with_notes(self): 227 | data = process_data(NO_ANNOTATION_WITH_NOTES_BEFORE, NO_ANNOTATION_WITH_NOTES_AFTER) 228 | self.assertEqual(len(data["annotations"]), len(NO_ANNOTATION_WITH_NOTES_AFTER["annotations"]) + 1) 229 | self.assertEqual(data["annotations"][0]["description"], "test description") 230 | self.assertEqual(data["annotations"][1]["description"], "[Notes]") 231 | 232 | def test_multiple_annotations_with_notes(self): 233 | data = process_data(MULTIPLE_ANNOTATIONS_WITH_NOTES_BEFORE, MULTIPLE_ANNOTATIONS_WITH_NOTES_AFTER) 234 | self.assertEqual(len(data["annotations"]), len(MULTIPLE_ANNOTATIONS_WITH_NOTES_AFTER["annotations"])) 235 | self.assertEqual(data["annotations"][0]["description"], "[Notes]") 236 | self.assertEqual(data["annotations"][1]["description"], "test description") 237 | 238 | if __name__ == '__main__': 239 | unittest.main() 240 | -------------------------------------------------------------------------------- /onenote: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | TASK_VERSION_NO_NEWLINE="2.5.2" 4 | 5 | task_number=${1} 6 | consume_input=${2} 7 | 8 | progname=$(basename ${0}) 9 | newline="\n" 10 | linefeed=$'\n' 11 | consume_arg="-" 12 | output="" 13 | default_pipe_editor="vipe" 14 | pipe_editor=${ONENOTE_PIPE_EDITOR:-${default_pipe_editor}} 15 | 16 | vercomp () { 17 | if [[ $1 == $2 ]] 18 | then 19 | return 0 20 | fi 21 | local IFS=. 22 | local i ver1=($1) ver2=($2) 23 | # fill empty fields in ver1 with zeros 24 | for ((i=${#ver1[@]}; i<${#ver2[@]}; i++)) 25 | do 26 | ver1[i]=0 27 | done 28 | for ((i=0; i<${#ver1[@]}; i++)) 29 | do 30 | if [[ -z ${ver2[i]} ]] 31 | then 32 | # fill empty fields in ver2 with zeros 33 | ver2[i]=0 34 | fi 35 | if ((10#${ver1[i]} > 10#${ver2[i]})) 36 | then 37 | return 1 38 | fi 39 | if ((10#${ver1[i]} < 10#${ver2[i]})) 40 | then 41 | return 2 42 | fi 43 | done 44 | return 0 45 | } 46 | 47 | vercomp $(task --version) ${TASK_VERSION_NO_NEWLINE} 48 | vercomp_result=$? 49 | test ${vercomp_result} = 2 50 | legacy_version=$? 51 | 52 | if [ $legacy_version -eq 0 ]; then 53 | newline="###NEWLINE###" 54 | fi 55 | 56 | usage() { 57 | echo "Usage: ${progname} 58 | 59 | Edit multiline taskwarrior notes in vi/vim. 60 | 61 | Setup a 'notes' UDA for taskwarrior: 62 | task config uda.notes.type string 63 | task config uda.notes.label Notes 64 | 65 | Notes can also be piped to standard in: 66 | 67 | echo \"foo\" | ${progname} - 68 | 69 | CAVEATS: 70 | 71 | OneNote depends on a pipe editor being installed and in your path. It defaults 72 | to the 'vipe' pipe editor, available on Linux in the 'moreutils' package. To 73 | use a custom pipe editor, set the environment variable 'ONENOTE_PIPE_EDITOR'. 74 | " 75 | if [ $legacy_version -eq 0 ]; then 76 | echo " 77 | Due to a current bug in taskwarrior less than ${TASK_VERSION_NO_NEWLINE} newlines are represented 78 | internally by the marker ${newline}: 79 | https://github.com/GothenburgBitFactory/taskwarrior/issues/2107 80 | " 81 | fi 82 | } 83 | 84 | # Override the system task binary and pipe editor in testing env. 85 | if [ -n "${ONENOTE_TEST_HARNESS}" ]; then 86 | task() { 87 | if [ "${1}" = "_get" ]; then 88 | echo "${ONENOTE_TEST_NOTES}" 89 | elif [ "${1}" = "_config" ]; then 90 | if [ -n "${ONENOTE_MISSING_NOTES}" ]; then 91 | echo "" 92 | else 93 | echo "uda.notes.label\nuda.notes.type" 94 | fi 95 | else 96 | local notes="${3}" 97 | # TODO: This also strips out the single quotes added to work around 98 | # https://github.com/GothenburgBitFactory/taskwarrior/issues/2645 99 | # The below line should be used instead if that bug is fixed. 100 | # echo "${notes:6}" 101 | echo "${notes:7:-1}" 102 | 103 | fi 104 | } 105 | fi 106 | 107 | if [ "$(command -v ${pipe_editor})" = "" ]; then 108 | echo 109 | echo "ERROR: The '${pipe_editor}' binary must be installed and in your path!" 110 | echo 111 | usage 112 | exit 1 113 | fi 114 | 115 | if [ $# -lt 1 ] || [ $# -gt 2 ]; then 116 | usage 117 | exit 1 118 | else 119 | if [ $# -eq 2 ]; then 120 | if [ "${2}" != "${consume_arg}" ]; then 121 | usage 122 | exit 1 123 | else 124 | while IFS=$'\n' read -r line; do 125 | if [ -z "${output}" ]; then 126 | output="${line}" 127 | else 128 | output="${output}${linefeed}${line}" 129 | fi 130 | done 131 | # TODO: The extra single quotes are added to work around 132 | # https://github.com/GothenburgBitFactory/taskwarrior/issues/2645 133 | # The below line should be used instead if that bug is fixed. 134 | # task ${task_number} modify notes:"${output}" 135 | task ${task_number} modify notes:"'${output}'" 136 | fi 137 | else 138 | if [ "$(task _config | grep 'uda.notes.type')" = "" ] || [ "$(task _config | grep 'uda.notes.label')" = "" ]; then 139 | usage 140 | exit 1 141 | fi 142 | notes="$(task _get ${task_number}.notes)" 143 | if [ -n "${ONENOTE_DEFAULT_CONTENT}" -a -z "${notes}" ]; then 144 | notes="${ONENOTE_DEFAULT_CONTENT}" 145 | fi 146 | if [ $? -eq 0 ]; then 147 | # The literal line feed is necessary for POSIX compatibility with sed. 148 | echo "${notes}" | sed "s/${newline}/\\$linefeed/g" | ONENOTE_TASK=${task_number} ${pipe_editor} | ${0} ${task_number} ${consume_arg} 149 | fi 150 | fi 151 | fi 152 | -------------------------------------------------------------------------------- /onenote_test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Leverages the basht testing library: 4 | # https://github.com/progrium/basht 5 | 6 | progname="onenote" 7 | executable="$(pwd)/${progname}" 8 | newline="###NEWLINE###" 9 | 10 | export ONENOTE_TEST_HARNESS=1 11 | export ONENOTE_PIPE_EDITOR="cat" 12 | 13 | T_noArgsReturnsError() { 14 | ${executable} > /dev/null 15 | [[ $? -eq 1 ]] 16 | } 17 | 18 | T_moreThanTwoArgsReturnsError() { 19 | ${executable} 1 2 3 > /dev/null 20 | [[ $? -eq 1 ]] 21 | } 22 | 23 | T_twoArgsIncorrectSecondArgReturnsError() { 24 | ${executable} 1 2 > /dev/null 25 | [[ $? -eq 1 ]] 26 | } 27 | 28 | T_correctArgsNoUdaNotesReturnsError() { 29 | ONENOTE_MISSING_NOTES="1" ${executable} 1 > /dev/null 30 | [[ $? -eq 1 ]] 31 | } 32 | 33 | T_validTaskWithNoNotesReturnsEmptyNote() { 34 | export ONENOTE_TEST_NOTES="" 35 | result="$(${executable} 1)" 36 | echo "No notes result: ${result}" 37 | [[ "${result}" = "${ONENOTE_TEST_NOTES}" ]] 38 | } 39 | 40 | T_validTaskWithNotesReturnsValidFormattedNote() { 41 | export ONENOTE_TEST_NOTES="foo\nbar\n\nbaz" 42 | result="$(${executable} 1)" 43 | echo "With notes result: ${result}" 44 | [[ "${result}" = "${ONENOTE_TEST_NOTES}" ]] 45 | } 46 | 47 | T_validTaskWithNotesAndNewlineMarkerReturnsValidFormattedNote() { 48 | export ONENOTE_TEST_NOTES="foo${newline}bar${newline}${newline}baz" 49 | result="$(${executable} 1)" 50 | echo "With notes result: ${result}" 51 | [[ "${result}" = "${ONENOTE_TEST_NOTES}" ]] 52 | } 53 | 54 | T_validTaskWithNoNotesWithDefaultContentReturnsNoteWithDefaultContent() { 55 | export ONENOTE_TEST_NOTES="" 56 | export ONENOTE_DEFAULT_CONTENT="test" 57 | result="$(${executable} 1)" 58 | echo "No notes with default content result: ${result}" 59 | [[ "${result}" = "${ONENOTE_DEFAULT_CONTENT}" ]] 60 | } 61 | 62 | T_validTaskWithNotesWithDefaultContentReturnsValidFormattedNote() { 63 | export ONENOTE_TEST_NOTES="foo\nbar\n\nbaz" 64 | export ONENOTE_DEFAULT_CONTENT="test" 65 | result="$(${executable} 1)" 66 | echo "With notes with default content result: ${result}" 67 | [[ "${result}" = "${ONENOTE_TEST_NOTES}" ]] 68 | } 69 | 70 | T_validTaskWithNotesWithDefaultContentAndNewlineMarkerReturnsValidFormattedNote() { 71 | export ONENOTE_TEST_NOTES="foo${newline}bar${newline}${newline}baz" 72 | export ONENOTE_DEFAULT_CONTENT="test" 73 | result="$(${executable} 1)" 74 | echo "With notes with default content result: ${result}" 75 | [[ "${result}" = "${ONENOTE_TEST_NOTES}" ]] 76 | } 77 | -------------------------------------------------------------------------------- /run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | CWD=$(pwd) 4 | 5 | progdir="$(dirname ${0})" 6 | cd ${progdir} 7 | 8 | echo "Running onenote script tests..." 9 | echo 10 | basht onenote_test 11 | echo 12 | echo "Running onenote hook tests..." 13 | echo 14 | python manage_notes_annotation_test.py 15 | echo 16 | echo "Done!" 17 | 18 | cd ${CWD} 19 | 20 | -------------------------------------------------------------------------------- /vim-line-to-task.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Converts the current line in Vim to a Taskwarrior task in the same project 4 | # as the task associated with the note being currently edited. 5 | 6 | DEFAULT_PROJECT="$(task _show | grep default\.project | cut -f 2 -d =)" 7 | DEFAULT_TASK="None" 8 | 9 | while read line; do 10 | if [ -n "${ONENOTE_TASK}" ]; then 11 | description="$(task rc.verbose=nothing ${ONENOTE_TASK} | grep -m 1 ^Description | awk '{for (i=2; i