A Quick Notes Script for Taskwarrior

I need more than annotations for my Taskwarrior tasks. Let’s write some Python!
People with blogs need to keep in mind that most people do not know how blogs work. A little bit of explanatory text can go a long way towards making your site easier to follow.
I plan to work on that today, but — well — there’s another problem too.
What’s the problem?
What tasks am I working on right now? Let’s get the active task report.
$ task active
ID Started Active Age P Project Tags Due Description
232 2020-01-12 2min 2h H taskwarrior 2020-01-12 quick and easy notes script
220 2020-01-12 51min 2w Site content 2020-01-11 describe RSS and link to
tools in Follow page
2019-12-28 reference
https://twitter.com/brianwi-
sti/status/1210771041783447-
553
This is a mess. My task descriptions can get verbose. That makes my reports look busy. Annotations give additional information, but at the cost of cluttering the report even more.
The edit view isn’t any better, really.
$ task 220 edit
task edit
gives me something like this:
# Annotations look like this: <date> -- <text> and there can be any number of them.
# The ' -- ' separator between the date and text field should not be removed.
# A "blank slot" for adding an annotation follows for your convenience.
Annotation: 2019-12-28 14:33:53 -- reference https:\/\/twitter.com\/brianwisti\/status\/1210771041783447553
Annotation: 2020-01-12 13:43:40 --
I want some way of adding and reviewing information about a particular task without cluttering my Taskwarrior reports.
What I need today
I need the ability to open a text file with notes for a specific task. I shouldn’t have to find or name the file myself. If the file doesn’t exist yet, it should be created.
What I don’t need today
Things I’m sure will be useful at some point, but I don’t need today.
- Listing notes
- Adding metadata like task description or tags before editing
- Deleting notes
- Adding one note to multiple tasks
- Deep taskwarrior integration
- Configuration. For now, everything’s hard-coded in the script.
Except
$EDITOR
.
Let’s get to it.
I’m not good at Python for quick glue tasks. Maybe Perl? I need to learn how to do this in Python at some point. Let’s try anyways.
That will be today’s learning experience.
Writing notes
Given a task, open
$EDITOR
in a Markdown file for that task. The task can be indicated via ID, UUID, or a filter that returns a single task
How can we identify tasks consistently? IDs change as we complete tasks. Task descriptions change as we modify them. Fortunately, Every task has a UUID — a Universally Unique Identifier.
The _get
command gives us access to specific attributes of a task.
$ task _get 220.uuid
7887cab7-5ec4-4e8f-a257-edbd28f61301
But how do I get this information from Python?
#!/usr/bin/env python
"""Manage Taskwarrior notes"""
import os
task_id = 220
task_uuid = os.popen(f"task _get {task_id}.uuid").read().rstrip()
print(f"Task {task_id} has UUID {task_uuid}")
$ task-note.py
Task 220 has UUID 7887cab7-5ec4-4e8f-a257-edbd28f61301
That wasn’t so hard. I got lost in subprocess last time I tried anything interesting with Python and processes. Turns out os.popen provides a relatively straightforward approach.
Where will I put my notes? Maybe ~/task-notes
. No,
~/Dropbox/task-notes
. That way everything is synchronized across my
machines.
notes_dir = os.path.expanduser("~/Dropbox/task-notes")
os.makedirs(notes_dir, exist_ok=True)
print(f"Saving notes to {notes_dir}")
Later I might want to be more careful with directory creation. But
today’s guideline is “quick and dirty.”
os.makedirs
will recursively create notes_dir
if needed. Since I specified
exist_ok=True
, we silently move on if notes_dir
already exists.
I want the file to be named something like UUID.md
.
notes_basename = f"{task_uuid}.md"
notes_file = os.path.join(notes_dir, notes_basename)
print(notes_file)
$ task-note.py
Task 220 has UUID 7887cab7-5ec4-4e8f-a257-edbd28f61301
Saving notes to /home/randomgeek/Dropbox/task-notes
/home/randomgeek/Dropbox/task-notes/7887cab7-5ec4-4e8f-a257-edbd28f61301.md
editor = os.environ["EDITOR"]
os.execlp(editor, editor, notes_file)
The various exec*
functions of module
os replace the Python
process with a new command. The suffixes indicate additional details.
- The
l
— that’s a lowercaseL
— simplifies the case when you know exactly what arguments to use. All I needed was$EDITOR <file>
.execl*
functions let me specify program arguments as arguments to the function itself. - The
p
indicates that I expect$EDITOR
to be somewhere in the current$PATH
.
So os.execlp
tells Python I’m running editor
. I expect to find editor
in my
environment path. The rest of the function arguments will be handed to
editor
.
Sweet. It worked!
Generalize for any task
I learned what I needed to learn. Next is cleaning up and accepting command line arguments.
argparse will take
care of the command line arguments. Might as well replace print
with
logging calls. You
know, just a little bit of tidying.
task-note.py
#!/usr/bin/env python
"""Manage Taskwarrior notes"""
import argparse
import logging
import os
import sys
NOTES_DIR = "~/Dropbox/task-notes"
EDITOR = os.environ["EDITOR"]
logging.basicConfig(level=logging.DEBUG)
def write_note(task_id: int):
"""Open `$EDITOR` to take notes about task with ID `task_id`."""
task_uuid = os.popen(f"task _get {task_id}.uuid").read().rstrip()
if not task_uuid:
logging.error(f"{task_id} has no UUID!")
sys.exit(1)
logging.debug(f"Task {task_id} has UUID {task_uuid}")
notes_dir = os.path.expanduser(NOTES_DIR)
os.makedirs(notes_dir, exist_ok=True)
notes_basename = f"{task_uuid}.md"
notes_file = os.path.join(notes_dir, notes_basename)
logging.debug(f"Notes file is {notes_file}")
if not os.path.exists(notes_file):
logging.info("Adding description to empty notes file")
task_description = os.popen(f"task _get {task_id}.description").read()
with open(notes_file, "w") as f:
f.write(f"description: {task_description}\n\n")
f.flush()
os.execlp(EDITOR, EDITOR, notes_file)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Write Taskwarrior notes")
parser.add_argument('task_id', metavar='ID', type=int, help="ID of the task to note")
args = parser.parse_args()
write_note(args.task_id)
I know. I didn’t want task metadata yet. It quickly became obvious that I would forget what task is involved unless I put something. So now the script adds the task description to a header line the first time a note is opened.
Also threw in some error checking after the first time I tried writing notes for a nonexistent task.
What’s Next?
- Keeping that description header current
- Adding other task data?
- Maybe a UDA to integrate this more with Taskwarrior itself
But what’s really next is finishing that other task. Should be easier now that I have my notes.