Hack of the day: tool for maintaining a daily agenda in Toodledo

By | February 8, 2023

Every day, I assemble my agenda for the day from multiple sources, e.g., the Toodledo account where I keep my personal to-do list, my personal, family and work calendars, my work to-do list, personal emails, work emails, etc.

For years I’ve been doing that by aggregating all of these into a daily to-do list in Google Keep, deleting and recreating that list from scratch every morning. This was annoying because many of the items come from Toodledo, which means copying a bunch of items and checking them off in two places as they’re completed.

What I really wanted to do was to instead maintain my daily agenda in Toodledo. However, Toodledo is missing one major feature which makes that difficult: it doesn’t support fine-grained prioritization of tasks, e.g., by drag-and-drop. I don’t expect to get to everything on my agenda every day, so I need to prioritize, and it’s definitely not good enough to just complete “Top” priority tasks with today’s date in alphabetical order, which is how Toodledo presents them.

I finally got fed up enough with this that today I wrote a tool to solve it. The tool allows me to do fine-grained prioritization by arbitrarily enforcing the convention that task due times between 11pm and midnight are “fake” and can be used to persistently reorder tasks that don’t already have a due time.

The tool pulls my Toodledo tasks due on or before today, as well as today’s calendar entries, into a text file. I can edit and reorder the tasks in the text file as well as adding new texts. When I’m done editing, the tool propagates my changes back into Toodledo and uses the aforementioned 11pm-midnight hack to save the order I put them in so they show up in the correct order in Toodledo.

I don’t know if this is remotely useful to anyone else, but if it is, please leave a comment below and let me know. You can find the tool below. It has copious installation and usage instructions in it.

#!/usr/bin/env python3
# toodledo-agenda.py – Prepare your daily agenda in Toodledo
# Every day, I need to assemble my agenda for the day from multiple sources,
# e.g., the Toodledo account where I keep my personal to-do list, my personal
# Google calendar, my work Google calendar, my work to-do list, personal
# emails, work emails, etc.
# What I used to do was aggregate all of these sources into a daily to-do list
# in Google Keep, and then delete and recreate that list from scratch every
# morning. However, this annoyed me because many of the items going into that
# list every day were coming from Toodledo, which meant that I had to copy a
# bunch of items and check them off in two places as I completed them.
# What I really wanted to do instead was to maintain my daily agenda in
# Toodledo. However, Toodledo is missing one major feature which precluded my
# daily agenda going there: it doesn't support fine-grained prioritization of
# tasks, e.g., by drag-and-drop. I don't expect to get to everything on my
# agenda every day, so I need to prioritize, and it's definitely not good
# enough to just complete "Top" priority tasks with today's date in
# alphabetical order, which is how Toodledo presents them.
# This script solves that problem by arbitrarily enforcing the convention that
# task due times between 11:00 and midnight are "fake" and can be used to
# persistently reorder tasks that don't already have a due time associated with
# them (if they do, their priority is decided primarily by that due time).
# This script allows you to quickly reorganize "Top" priority tasks in a
# specific folder into the order you intend to work on them, and stores that
# order into Toodledo so that you can then use Toodledo throughout the day to
# manage the agenda. First I'll explain how to use it once you've installed and
# configured it, and then I'll explain how to install and configure it.
# 1. Run toodledo-agenda.py.
# 2. It will download Top-priority tasks in your configured folder into a text
# file and open that file in your text editor.
# 3. Delete completed tasks (or mark them with "-"), add new tasks, modify the
# due date or time of tasks, or reorder tasks to suit your priorities for
# the day, following the instructions in the comment at the bottom of the
# file, then save the file and exit from your editor.
# 4. The script will update the data in Toodledo, and you're done.
# When the script changes the due date of a task which is set to repeat from
# its due date at a frequency longer than daily, the script needs to know
# whether to preserve the repeat nexus for that task. For example, if you have
# a task you're supposed to do on the 8th of every month but you fell behind
# and didn't get to it, then when that task gets pulled into your daily agenda
# the next day, if the script just changes the due date to the ninth, then when
# you subsequently check off the script it'll move forward to the ninth of the
# next month, which isn't what you want.
# If you tell the script you want to preserve the repeat nexus of a task, then
# instead of just changing its due date, it'll clone the task, creating a new
# task without any repeat that's due today, and moving the original task to its
# next repeat date. The script remembers your answer so you don't need to
# answer for the same repeating task more than once.
# Save this script somewhere local and make the file executable.
# This script uses the Python Toodledo API library. How to install Python
# libraries is too much for me to document here, but there's a lot of info
# about it online, e.g., https://docs.python.org/3/installing/index.html . In a
# nutshell, `pip install toodledo` is likely to do the right thing.
# Once you've got the toodledo-python library installed, you need to register
# an "app" in your Toodledo account for the script to talk to. Log into
# Toodledo, go to https://api.toodledo.com/3/account/doc_register.php , and
# fill in the form there. It doesn't really matter what you put into any of
# the fields as long as it accepts your values. You can use fake URLs like
# https://localhost/ in the Website and Redirect URI fields.
# Once you've registered your app, run the script and it will prompt you for
# the client ID and client secret of your app from the registration page, as
# well as for the Toodledo folder you want the script to work in, and save the
# values you specify into a config file so you don't need to specify them
# every time.
# The folder whose name you specify is assumed to be the folder you use for you
# main to-do list in general. If you have Top-priority tasks in that folder
# with dates on them, then they'll get pulled in by the agenda script
# automatically every day as appropriate. If not, you'll be creating your
# agenda pretty much from scratch every day. :shrug:
# Once you've done all of the above, you should be able to run the script and
# it should just work. Let me know if you run into any trouble! Heck, let me
# know if you're using it successfully so I don't feel like I'm just screaming
# into the void here.
# The first time you run the script it'll display a URL for you to paste into
# your browser to authenticate the script. Do that and then click the sign-in
# button, which will redirect you to the bogus link you entered when
# registering the app, with a bunch of query parameters. It doesn't matter that
# the link is bogus; just copy the URL and feed it as input back into the
# script, and it'll finish logging you in and saving your authentication
# information so you don't have to keep doing that.
# The script supports pulling today's events in from one or multiple iCal URLs.
# These events will appear as comments in the agenda edit file for you to
# uncomment and add to your agenda as you see fit.
# For this to work, the icalendar and recurring_ical_events modules need to be
# installed. As above, you can install them with pip.
# To add a calendar, run the script with the –add-calendar argument followed
# by the URL of the calendar. To edit your calendars, load
# ~/.agenda-config.json into a text editor and edit as appropriate.
# Copyright 2023 Jonathan Kamens <jik@kamens.us>
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version.
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License at
# <https://www.gnu.org/licenses/> for more details.
import argparse
from collections import defaultdict
import datetime
from itertools import chain
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
from toodledo import (
from icalendar import Calendar
import recurring_ical_events
import requests
calendars_active = True
except ModuleNotFoundError:
print('Calendar module import failed, calendars disabled')
calendars_active = False
config = None
tokenFile = os.path.expanduser('~/.agenda-token.json')
configFile = os.path.expanduser('~/.agenda-config.json')
cacheFile = os.path.expanduser('~/.agenda-cache.pickle')
scope = 'basic tasks notes folders write'
tokenStorage = TokenStorageFile(tokenFile)
today = datetime.date.today()
endOfDay = datetime.datetime.today().replace(hour=23, minute=59, second=59)
yesterday = today datetime.timedelta(days=1)
tomorrow = today + datetime.timedelta(days=1)
taskFieldsNeeded = 'folder,priority,duedate,duetime,repeat,parent,meta'
custom_properties = defaultdict(dict)
lastPlaceholderId = 0
def read_config(args):
global config
if not os.path.exists(args.config_file):
config = {}
with open(args.config_file) as f:
config = json.load(f)
def write_config(args):
if os.path.exists(args.config_file):
shutil.copyfile(args.config_file, f'{args.config_file}.bak')
with open(args.config_file, 'w') as f:
json.dump(config, f)
except Exception:
print(f'Error saving {args.config_file}! Backup preserved in '
f'{args.config_file}.bak', file=sys.stderr)
def parse_args():
parser = argparse.ArgumentParser(
description='Set my daily agenda in Toodledo')
parser.add_argument('–dryrun', action='store_true', default=False)
parser.add_argument('–configure', action='store_true', default=False,
help='Configure or reconfigure the script')
parser.add_argument('–config-file', action='store', default=configFile,
help=f'Configuration file path (default {configFile})')
parser.add_argument('–no-cache', dest='cache', action='store_false',
default=True, help='Disable task cache')
parser.add_argument('–folder', action='store', help='Override the '
'default configured Toodledo folder')
parser.add_argument('–add-calendar', metavar='URL', action='append',
default=[], help="URL(s) of iCal calendar(s) to scan "
"for today's events")
parser.add_argument('–skip-calendars', default=None,
help="Don't read events from configured calendars")
parser.add_argument('–unprioritize', action='store_true', default=False,
help='Remove due times after 11pm')
args = parser.parse_args()
return args
def get_yorn(prompt):
while True:
response = input(prompt)
if re.match(r'[Yy]', response):
return True
if re.match(r'[Nn]', response):
return False
print('Unrecognized response.', file=sys.stderr)
def configure(args):
config['clientId'] = prompt_with_default('Enter Toodledo client ID',
config.get('clientId', None))
config['clientSecret'] = prompt_with_default(
'Enter Toodledo client secret', config.get('clientSecret', None))
config['folderName'] = prompt_with_default('Enter Toodledo folder name',
config.get('folderName', None))
def prompt_with_default(prompt, value):
if value:
prompt += f' (Enter for {value})'
prompt += ': '
return input(prompt)
def get_toodledo_tasks(threads, threadNum, toodledo, folderId, tasks_return):
tasks = toodledo.GetTasks(comp=0, fields=taskFieldsNeeded)
tasks = [t for t in tasks if t.folderId == folderId and
t.parent is None and t.priority == Priority.TOP and
t.dueDate is not None and t.dueDate <= today]
tasks.sort(key=lambda t: (t.dueDate, t.dueTime or endOfDay, t.title))
threads[threadNum][2] = True
def get_calendar_tasks(threads, threadNum, calendar, tasks):
response = requests.get(calendar)
cal = Calendar.from_ical(response.text)
for event in recurring_ical_events.of(cal).at(datetime.date.today()):
dt = event.decoded('DTSTART')
title = event.decoded('SUMMARY').decode()
tasks.append(Task(dueDate=dt.date(), dueTime=dt, title=title,
threads[threadNum][2] = True
def launch_calendars(args, threads, calendarTasks):
if not calendars_active or args.skip_calendars is True or \
((args.skip_calendars is None and
config.get('calendar_skip') == str(today))):
for calendar in config['calendars']:
threadNum = len(threads)
args=(threads, threadNum, calendar, calendarTasks[threadNum])),
f'Calendar {calendar}',
threads[threadNum][0].daemon = True
def main():
# Token and config files should be protected by default.
args = parse_args()
if args.configure or not config.get('clientId', None) or \
not config.get('clientSecret', None) or \
not config.get('folderName', None):
for calendar in args.add_calendar:
config['calendars'] = config.get('calendars', [])
if calendar in config['calendars']:
sys.exit(f'Calendar {calendar} is already configured')
if args.add_calendar:
clientId = config['clientId']
clientSecret = config['clientSecret']
folderName = args.folder or config['folderName']
if not os.path.exists(tokenFile):
CommandLineAuthorization(clientId, clientSecret, scope, tokenStorage)
toodledo = Toodledo(
if args.cache:
toodledo = TaskCache(toodledo, cacheFile, comp=0,
folders = toodledo.GetFolders()
folderId = next(f for f in folders
if not f.archived and f.name == folderName).id_
threads = []
threadTasks = []
threadNum = len(threads)
args=(threads, threadNum, toodledo, folderId,
'Toodledo', None])
threads[threadNum][0].daemon = True
launch_calendars(args, threads, threadTasks)
for thread in threads:
if not thread[2]:
print(f'{thread[1]} task fetch failed', file=sys.stderr)
if any(True for t in threads if not t[2]):
tasks = threadTasks[0]
tasks.sort(key=lambda t: (t.dueDate, t.dueTime or endOfDay, t.title))
tasksIndex = index_tasks(tasks)
formattedTasks = [unparse_task(t) for t in tasks]
with tempfile.NamedTemporaryFile(
mode='w+', encoding='UTF-8', delete=False) as f:
print('\n'.join(formattedTasks), file=f)
calendarEvents = [
e for e in chain.from_iterable(threadTasks[1:])
if not any(True for t in tasks
if t.title == e.title and t.dueDate == e.dueDate and
t.dueTime == e.dueTime.replace(tzinfo=None))]
if calendarEvents:
calendarEvents.sort(key=lambda t: t.dueTime)
print('\n# Calendar events:', file=f)
print('# Unncomment the next line to skip until tomorrow.', file=f)
print('#!calendar', file=f)
for event in calendarEvents:
print(f'# {unparse_task(event)}', file=f)
print('', file=f)
### Everything after this line will be ignored.
# Other lines starting with "#" will be ignored.
# You can:
# * Delete a task from this list to delete it entirely.
# * Put "=" in front of a task to prevent the script from modifying it in any
# way.
# * Put "-" in front of a task to mark it complete. If it's a repeating task,
# you can also prioritize it in the list for today.
# * Put "*" in front of a repeating task to "explode" it, i.e., separate
# today's task from the next repeating instance. Any edits you make to an
# exploded task (i.e., title, date, time, sort order) apply to today's, not
# to the next repeating instance.
# * Put [YYYY-MM-DD], [YYYY-MM-DD HH:MM], or [HH:MM] at the start of a task
# to schedule it. You can specify "Yesterday" or "Tomorrow" as shorthand.
# * Add a new task, optionally scheduled as above.
# * Reorder tasks to prioritize them.
# * Put "!calendar" on a line by itself to skip calendar updates until
# tomorrow.
# N.B. Changing the title of a task will mark the old one complete and create
# a new one with the specified title, which may not be what you want!''',
while True:
successful = edit_and_process(
args, toodledo, folderId, tasks, tasksIndex, f)
if successful:
again = get_yorn(
'Task editing/processing failed. Edit again? ')
if not again:
sys.exit(f'Edit saved in {f.name}')
except Exception:
print(f'Task editing/processing failed, edit saved in {f.name}',
def edit_and_process(args, toodledo, folderId, tasks, tasksIndex, f):
editor = os.environ.get('EDITOR', os.environ.get('VISUAL', None))
if not editor:
print('You must set EDITOR or VISUAL environment variable',
return False
subprocess.run((editor, f.name), encoding='UTF-8', check=True)
editedTasks = list(parse_tasks(args, f, folderId=folderId,
except subprocess.CalledProcessError as e:
print(f'Editor failed ({e})', file=sys.stderr)
return False
except ValueError as e:
return False
editedTasksIndex = index_tasks(editedTasks)
deletedTasks = [t for t in tasks if not find_task(t, editedTasksIndex)]
completedTasks = [t for t in editedTasks if cp(t, 'completed')]
editedTasks = [t for t in editedTasks if not cp(t, 'preserved')]
for t in tasks:
found = find_task(t, editedTasksIndex)
if found:
found.meta = t.meta
if completedTasks:
print('Marking tasks complete:')
print('', '\n '.join(t.title for t in completedTasks))
if not args.dryrun:
[Task(id_=t.id_, completedDate=t.dueDate, reschedule=1)
for t in completedTasks])
if deletedTasks:
print('Deleting tasks:')
print('', '\n '.join(t.title for t in deletedTasks))
if not args.dryrun:
toodledo.DeleteTasks([Task(id_=t.id_) for t in deletedTasks])
# Move tasks from prior days forward to today.
for t in editedTasks:
oldTask = find_task(t, tasksIndex)
if oldTask is None:
# New task
if (oldTask.dueDate == t.dueDate and t.dueDate < today) or \
cp(t, 'exploded'):
move_or_clone_task(args, toodledo, oldTask, t)
timelessTasks = [t for t in editedTasks
if t.dueTime and t.dueTime.hour > 22]
# Unprioritize and optionally reprioritize tasks without due times.
for t in timelessTasks:
t.dueTime = None
if not args.unprioritize:
timelessTasks = [t for t in editedTasks
if t.dueDate == today and t.dueTime is None]
dueTime = datetime.datetime(today.year, today.month, today.day,
hour=23, minute=59)
one_minute = datetime.timedelta(minutes=1)
for t in reversed(timelessTasks):
t.dueTime = dueTime
dueTime -= one_minute
# Figure out what's changing.
changedTasks = []
addedTasks = []
for task in editedTasks:
found = find_task(task, tasksIndex)
if not found:
print(f'Adding task {task.title}')
elif changed(found, task):
timesChanged = False
for task in timelessTasks:
oldTask = find_task(task, tasksIndex)
if not oldTask:
if task.dueTime != oldTask.dueTime:
timesChanged = True
if timesChanged:
if args.unprioritize:
print('Unprioritizing timeless tasks.')
print('Saving priorities for timeless tasks as times after 11pm')
if not args.dryrun:
if addedTasks:
# We never change task titles—-in fact we use them as unique keys–
# so we shouldn't included them in the requests when editing tasks.
# Doing so makes the requests less efficient.
for t in changedTasks:
del t.title
if changedTasks:
return True
def parse_tasks(args, file, **kwargs):
for t in file:
if t.startswith('### Everything after'):
if t.startswith('#'):
t = t.strip()
if not t:
task = parse_task(args, t, **kwargs)
if task is not None:
yield task
def changed(old_task, task):
if old_task.dueDate != task.dueDate and task.dueDate != today:
print(f'Moving {task.title} to {task.dueDate}')
# Previously set -> Currently same
# Previously unset -> Currently unset
if due_time(old_task) == due_time(task):
# Previously unset -> Currently set
elif not due_time(old_task) and due_time(task):
print(f'Setting due time of {task.title} to {due_time(task)}')
# Previously set -> Currently unset
elif due_time(old_task) and not due_time(task):
print(f'Removing due time from {task.title}')
# Previously set -> Currently different
print(f'Changing due time of {task.title} to {due_time(task)}')
return (old_task.meta != task.meta or
old_task.dueDate != task.dueDate or
old_task.dueTime != task.dueTime)
def due_time(task):
if not task.dueTime:
return None
if task.dueTime.hour > 22:
return None
return f'{task.dueTime.hour}:{task.dueTime.minute:02}'
def move_or_clone_task(args, toodledo, oldTask, task):
if task.dueDate != today:
print(f'Moving {task.title} to today')
task.dueDate = today
if cp(task, 'exploded'):
print(f'Exploding {task.title}')
preserve = True
elif not oldTask.repeat or 'FREQ=DAILY' in oldTask.repeat or \
(('FREQ=DAILY' not in oldTask.repeat and
'FROMCOMP' in oldTask.repeat)):
if not cp(task, 'exploded'):
if task.meta is None:
task.meta = ''
if 'preserve=yes' in task.meta:
preserve = True
elif 'preserve=no' in task.meta:
preserve = False
preserve = get_yorn(f'Preserve repeat nexus for {task.title}? ')
task.meta = f'preserve={"yes" if preserve else "no"};{task.meta}'
if not args.dryrun and preserve:
# 1. Mark the repeating task completed, which will cause a NEW task
# (different ID) to be created which is marked completed, and the
# OLD task to be rescheduled automatically.
# 2. Find the new task.
# 3. Uncomplete it.
# 4. Update our ID to match the ID of the new task.
with toodledo.caching_everything():
[Task(id_=task.id_, completedDate=today, reschedule=1)])
taskList = toodledo.GetTasks(
# Five minutes in the past as a clock skew safety margin
after=datetime.datetime.now().timestamp() 300)
taskList.sort(key=lambda t: t.modified, reverse=True)
newTask = next(t for t in taskList if t.title == task.title and
t.completedDate is not None)
toodledo.EditTasks([Task(id_=newTask.id_, completedDate=None)])
task.id_ = newTask.id_
oldTask.id_ = newTask.id_
def fix_due_time(task):
if task.dueTime.date() == task.dueDate:
except AttributeError:
def unparse_task(task):
title = task.title
special = re.match(r'([\[\#\-=*!])', title)
params = []
if task.dueDate == yesterday:
elif task.dueDate == tomorrow:
elif task.dueDate != today:
# Due times after 11pm are assumed to be fake times for prioritization.
if task.dueTime and task.dueTime.hour < 23:
formattedTime = f'{task.dueTime.hour or "0"}:{task.dueTime.minute:02}'
if cp(task, 'dup'):
params = "[" + " ".join(params) + "] " if params or special else ""
return f'{params}{title}'
def parse_task(args, text, **kwargs):
text = text.strip()
task = Task(**kwargs)
exploded = None
completed = None
preserved = None
match = re.match(r'\s*([-*=!])\s*(.*)', text)
if match:
text = match[2].strip()
if match[1] == '-':
completed = True
elif match[1] == '*':
exploded = True
elif match[1] == '!':
if text.lower() == 'calendar':
if config.get('calendar_skip', None) != str(today):
config['calendar_skip'] = str(today)
print('Skipping calendars until tomorrow')
raise ValueError(f'Unrecognized meta command !{text}')
return None
preserved = True
match = re.match(r'\s*\[\s*(.*?)\s*\]\s*(.*)', text)
if match:
params = match[1]
task.title = match[2]
taskDate = None
taskHour = None
taskId = None
while params:
match = re.match(r'(?i)\s*(Yesterday|Today|Tomorrow|'
r'\d\d\d\d-\d\d-\d\d)\s*(.*)', params)
if match:
taskDate = match[1]
params = match[2]
match = re.match(r'\s*(\d+):(\d+)\s*(.*)', params)
if match:
taskHour = int(match[1])
taskMinute = int(match[2])
params = match[3]
match = re.match(r'(?i)\s*id\s*=\s*(-?\d+)\s*(.*)', params)
if match:
taskId = int(match[1])
params = match[2]
raise ValueError(
f'Malformed parameter spec "{params}" in "{text}"')
if (taskDate or '').lower() == 'yesterday':
taskDate = yesterday
elif (taskDate or '').lower() == 'tomorrow':
taskDate = tomorrow
elif taskDate and taskDate.lower() != 'today':
taskDate = datetime.datetime.strptime(taskDate, '%Y-%m-%d').date()
taskDate = today
task.dueDate = taskDate
if taskHour is None:
task.dueTime = None
if taskHour < 23:
taskTime = datetime.datetime(
taskDate.year, taskDate.month, taskDate.day, taskHour,
task.dueTime = taskTime
task.id_ = placeholder_id() if taskId is None else taskId
task.dueDate = today
task.dueTime = None
task.title = text
task.id_ = placeholder_id()
cp(task, 'completed', completed)
cp(task, 'exploded', exploded)
cp(task, 'preserved', preserved)
def placeholder_id():
global lastPlaceholderId
lastPlaceholderId -= 1
return lastPlaceholderId
def index_tasks(tasks):
mapping = {}
for i in range(len(tasks)):
task = tasks[i]
title = task.title
if title in mapping:
cp(task, 'dup', True)
if isinstance(mapping[title], list):
cp(mapping[title], 'dup', True)
mapping[title] = [mapping[title], task]
mapping[title] = task
return mapping
def find_task(t, tasksIndex):
if t.title not in tasksIndex:
return None
found = tasksIndex[t.title]
if not isinstance(found, list):
found = [found]
return next(f for f in found if f.id_ == t.id_)
except StopIteration:
placeholder = next(f for f in found if f.id_ < 0)
except StopIteration:
return None
custom_properties[t.id_] = custom_properties.pop(placeholder.id_, {})
placeholder.id_ = t.id_
return placeholder
def cp(task, prop_name, value=None):
"""Get or set a custom property on a task.
This is necessary because we can't just add custom properties to Task
objects or Marshmallow will complain.
The name of this method (and the one below) is very short not to make
the code opaque, but rather to avoid cluttering it up with long method
If getting, then returns None if the property isn't set.
Keyword arguments:
value — Set properly if specified, get property otherwise
if value is not None:
custom_properties[task.id_][prop_name] = value
return custom_properties[task.id_].get(prop_name, None)
if __name__ == '__main__':
Print Friendly, PDF & Email

Leave a Reply

Your email address will not be published. Required fields are marked *