~em-dash/todo

todo/todo.py -rwxr-xr-x 5.0 KiB
e7b97408Emily Ellis rm stray newline in readme a month ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
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()