Go back to bed
Thursday, 16 January, 2020

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 lowercase L — 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.

Neovim launched by Python
Neovim launched by Python

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.