
I need more than annotations for my Taskwarrior tasks. Let’s write some Python!
It’s the weekend, and I have a task that started with a tweet.
The more blogs the merrier.
— brIan wisti (@brianwisti) December 28, 2019
And especially noteworthy: introduce your readers to RSS. My "follow" page assumes potential blog readers know RSS, but TBH that's not a safe assumption in 2020.
So blurbed goal:
* have the RSS links
* explain what RSS is
* link to reading tools. https://t.co/xhqktOR3de
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
# 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!
Specifying the program twice confused me at first. Things clicked for me
when I tried the v
variant:
os.execvp(editor, [editor, notes_file])
With v, you construct your program arguments with a list or tuple. Now
It looks we’re constructing the ARGV
list — or
sys.argv in
Python. The program itself usually gets the first slot in ARGV
. For
example, here’s sys.argv for my task-note.py
invocation:
['/home/randomgeek/bin/task-note.py', '220']
Most user-facing programs hide that detail from you — even Vim.
:echo argv()
['/home/randomgeek/Dropbox/task-notes/7887cab7-5ec4-4e8f-a257-edbd28f61301.md']
I think that’s what’s going on anyways.
I won’t lie. This exec*
stuff is easier to say in Perl:
exec($ENV{EDITOR}, $notes_file);
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.
Warning
Remember toflush
your filehandles before handing control over to
external processes like Vim. Python takes care of files and
buffers on its own schedule. Launching an external process interrupts
Python’s schedule. So let Python know!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.