~em-dash/todo

ff5b6aac6a698a65c86d9094aabce10f2e578e62 — Emily Ellis 9 days ago
initial commit
2 files changed, 245 insertions(+), 0 deletions(-)

A README.md
A todo.py
A  => README.md +68 -0
@@ 1,68 @@
This is a todo list manager with support for tasks that depend on each other.

All usage is through the command line.

Some commands open `$EDITOR` to edit an item. In these files, the first line is the task title, and the rest is the task description. No other parsing is done; you can format this in any format you want.

Some commands take tasks as arguments. A full task ID may be provided, or a unique suffix of an ID, or a unique substring of the title.

All other words used in commands can be similarly abbreviated: `todo l a v` is equivalent to `todo ls all verbose`.

Adding items:
```
# only title, no description:
$ todo add buy ingredients for muffins
$ todo add bake muffins
$ todo add eat muffins

# or to open $EDITOR on a new item:

$ todo add
```

Editing existing items:
```
$ todo edit buy
```

Adding dependencies:
```
# 'a after b' is equivalent to 'b before a'
$ todo dep add buy before bake
$ todo dep add eat after bake
```

Listing items:
```
# items that can be done right now (no dependencies)
$ todo ls
t20220220165525 buy ingredients for muffins

# show all items
$ todo ls all
t20220220165525 buy ingredients for muffins
t20220220165535 bake muffins
t20220220165542 eat muffins

# include task descriptions
$ todo ls verbose
t20220220165525 buy ingredients for muffins
  flour, sugar, butter, eggs, milk

# they can be combined:
$ todo ls all verbose
t20220220165525 buy ingredients for muffins
  flour, sugar, butter, eggs, milk
t20220220165535 bake muffins
  (depends on t20220220165525 buy ingredients for muffins)
t20220220165542 eat muffins
  (depends on t20220220165535 bake muffins)
```

Completing/removing items (there is no separate 'completed' state, items just go away when they no longer need to be done):
```
$ todo rm buy

$ todo ls
t20220220165535 bake muffins
```
\ No newline at end of file

A  => todo.py +177 -0
@@ 1,177 @@
#!/usr/bin/env python
import sys, os, json, argparse, time, tempfile, re

# file format: a json object of { uuid: { "title": str, "text": str, "dependencies": [uuid] } }
DB_PATH = os.getenv('TODO_PATH', os.path.expanduser('~/.todo'))

def read_db():
	with open(DB_PATH, 'r') as f:
		return json.load(f)

def write_db(tasks: dict[str, dict]):
	with open(DB_PATH, 'w') as f:
		json.dump(tasks, f)

def new_id() -> str:
	return time.strftime('t%Y%m%d%H%M%S')

HELP = '''
subcommands: 
	ls (verbose|all)*
		verbose: include task descriptions + dependencies
		all: include tasks that are blocked by dependencies
	add [title]
	rm [tasks...]
	edit [tasks...]
	dependency (add|rm) [tasks...] (before|after) [tasks...]
'''

def try_fuzzy_match_word(choices: list[str], arg: str) -> str:
	""" match `arg` to a prefix of one of `choices`, return the full choice or None if no match """
	candidates = [word for word in choices if word.lower().startswith(arg.lower())]

	if len(candidates) == 1:
		return candidates[0]
	elif len(candidates) == 0:
		return None
	else:
		abort(f'{arg} is ambiguous: {", ".join(candidates)}')

def fuzzy_match_word(choices: list[str], arg: str) -> str:
	""" like `try_fuzzy_match_word` but aborts if no match instead of returning None """

	rv = try_fuzzy_match_word(choices, arg)
	if rv is None:
		abort(f'couldn\'t match {arg} to any of {", ".join(choices)}')
	return rv

def fuzzy_match_task(tasks: dict[str, dict], idish: str) -> str:
	"""
	try matching `idish` to a prefix or suffix of a task id in `tasks`.
	if that fails, try substring matching on the titles of those tasks.
	if *that* fails, abort.
	"""

	candidates = []
	for id in tasks.keys():
		if id == idish:
			return id
		elif id.startswith(idish) or id.endswith(idish):
			candidates.append(id)
	
	# no matching ids? try searching in titles
	if len(candidates) == 0:
		for id, task in tasks.items():
			if idish in task['title']:
				candidates.append(id)

	if len(candidates) == 1:
		print(f'{idish} => {candidates[0]}')
		return candidates[0]
	elif len(candidates) == 0:
		abort(f'couldn\'t match {idish} to a task id')
	else:
		abort(f'{idish} is ambiguous: {", ".join(candidates)}')

def abort(err):
	""" print an error and exit """
	print(err, file = sys.stderr)
	sys.exit(1)

def edit(task):
	""" use $EDITOR to edit the task, updating it in place """
	with tempfile.NamedTemporaryFile(mode = 'w+') as edit_file:
		edit_file.write(task['title'] + '\n')
		edit_file.write(task['text'])
		edit_file.flush()

		os.system(f'$EDITOR {edit_file.name}')

		edit_file.seek(0)
		task['title'] = edit_file.readline().strip()
		task['text'] = edit_file.read().strip()

def main():
	tasks = read_db()
	db_is_dirty = False
	
	verb = fuzzy_match_word(('ls', 'add', 'rm', 'edit', 'dependency'), sys.argv[1])
	args = sys.argv[2:]

	if verb == 'ls':
		flags = [fuzzy_match_word(('verbose', 'all'), arg) for arg in args]
		verbose = 'verbose' in flags
		include_dependents = 'all' in flags

		for id, task in tasks.items():
			if not include_dependents and len(task['dependencies']) > 0:
				continue

			print(id, task['title'])
			if verbose:
				for dep_id in task['dependencies']:
					print(f'  (depends on {dep_id} {tasks[dep_id]["title"]})')
				if task['text']:
					print(re.sub(r'^', '  ', task['text'], flags = re.MULTILINE))

	elif verb == 'add':
		# for convenience, todo add do the thing => todo add "do the thing"
		title = ' '.join(args).strip()

		id = new_id()
		assert id not in tasks
		tasks[id] = {'title': title, 'text': '', 'dependencies': []}
		if title == '':
			# plain 'todo add', run the editor to get content
			edit(tasks[id])
		write_db(tasks)

	elif verb == 'rm':
		ids = [fuzzy_match_task(tasks, id) for id in args]
		for id in ids:
			del tasks[id]
			for maybe_dependent in tasks.values():
				maybe_dependent['dependencies'] = list(filter(lambda dep_id: id != dep_id, maybe_dependent['dependencies']))

		write_db(tasks)

	elif verb == 'edit':
		ids = [fuzzy_match_task(tasks, id) for id in args]
		for id in ids:
			edit(tasks[id])
		write_db(tasks)
	
	elif verb == 'dependency':
		subverb = fuzzy_match_word(('add', 'rm'), args.pop(0))

		first_list, second_list = [], []
		direction = None
		into_list = first_list
		for arg in args:
			maybe_direction = try_fuzzy_match_word(('before', 'after'), arg)
			if direction is None and maybe_direction:
				into_list = second_list
				direction = maybe_direction
			else:
				into_list.append(fuzzy_match_task(tasks, arg))

		assert direction is not None and len(first_list) > 0 and len(second_list) > 0

		if direction == 'before':
			dependents, dependencies = second_list, first_list
		else:
			dependents, dependencies = first_list, second_list

		for dependent_id in dependents:
			if subverb == 'rm':
				tasks[dependent_id]['dependencies'] = list(filter(lambda id: id not in dependencies, tasks[dependent_id]['dependencies']))
			else: # 'add'
				tasks[dependent_id]['dependencies'] = list(set(tasks[dependent_id]['dependencies'] + dependencies))

		write_db(tasks)

	else:
		raise NotImplementedError(verb)

if __name__ == '__main__':
	main()