home · contact · privacy
Improve todo accounting.
[misc] / todo.py
1 from plomlib import PlomDB, run_server, PlomHandler, PlomException
2 import json
3 from uuid import uuid4
4 from datetime import datetime, timedelta
5 from urllib.parse import parse_qs
6 from jinja2 import Environment as JinjaEnv, FileSystemLoader as JinjaFSLoader 
7 from urllib.parse import urlparse
8 from os.path import split as path_split
9 db_path = '/home/plom/org/todo_new.json'
10 server_port = 8082
11 DATE_FORMAT = '%Y-%m-%d'
12 j2env = JinjaEnv(loader=JinjaFSLoader('todo_templates'))
13
14
15
16 def today_date(with_time=False):
17     length = 19 if with_time else 10
18     return str(datetime.now())[:length]
19
20
21
22 class AttributeWithHistory:
23
24     def __init__(self, parent, name, default_if_empty, history=None, then_date='2000-01-01', set_check=None):
25         self.parent = parent 
26         self.name = name
27         self.default = default_if_empty
28         self.then_date = then_date
29         self.history = history if history else {}
30         self.set_check = set_check
31
32     def set(self, value):
33         if self.set_check:
34             self.set_check(value)
35         keys = sorted(self.history.keys())
36         if len(self.history) == 0 or value != self.history[keys[-1]]:
37             self.history[today_date(with_time=True)] = value
38
39     def at(self, queried_date):
40         end_of_queried_day = f'{queried_date} 23:59:59'
41         sorted_dates = sorted(self.history.keys())
42         if self.parent.forked_task and (0 == len(sorted_dates) or sorted_dates[0] > end_of_queried_day):
43             return getattr(self.parent.forked_task, self.name).at(queried_date)
44         elif 0 == len(sorted_dates):
45             return self.default
46         ret = self.history[sorted_dates[0]]
47         for date_key, item in self.history.items():
48             if date_key > end_of_queried_day:
49                 break
50             ret = item
51         return ret
52
53     @property
54     def now(self):
55         keys = sorted(self.history.keys())
56         if 0 == len(self.history):
57             if self.parent.forked_task:
58                 return getattr(self.parent.forked_task, self.name).now
59             return self.default
60         return self.history[keys[-1]]
61
62     @property
63     def then(self):
64         return self.at(self.then_date)
65
66
67
68 class TaskLike:
69
70     def __init__(self, db, id_, comment):
71         self.db = db
72         self.id_ = id_
73         self.comment = comment
74         self.dependers = []
75
76     @property
77     def visible(self):
78         visible_by_and = 0 == len([tag for tag in self.db.tag_filter_and
79                                    if not tag in self.tags])
80         visible_by_not = 0 == len([tag for tag in self.db.tag_filter_not
81                                    if tag in self.tags])
82         return visible_by_and and visible_by_not
83
84     @property
85     def deps_depth(self):
86         if len(self.deps) == 0:
87             return 0
88         return 1 + max([d.deps_depth for d in self.deps])
89
90     @property
91     def deps_chain(self):
92         all_deps = set()
93         def add_deps(todo, all_deps):
94             for dep in todo.deps:
95                 all_deps.add(dep)
96                 add_deps(dep, all_deps)
97         add_deps(self, all_deps)
98         chain = list(all_deps)
99         chain.sort(key=lambda t: t.deps_depth)
100         return chain
101
102
103
104 class Task(TaskLike):
105
106     def __init__(self,
107             db,
108             id_,
109             title_history=None,
110             tags_history=None,
111             default_effort_history=None,
112             dep_ids_history=None,
113             comment='',
114             forks_id=None):
115         super().__init__(db, id_, comment)
116         self.forks_id = forks_id 
117         self.title = AttributeWithHistory(self, 'title', '', title_history, self.db.selected_date)
118         self.tags = AttributeWithHistory(self, 'tags', set(), tags_history, self.db.selected_date)
119         self.default_effort = AttributeWithHistory(self, 'default_effort', 0.0, default_effort_history, self.db.selected_date)
120         self.dep_ids = AttributeWithHistory(self, 'dep_ids', set(), dep_ids_history, self.db.selected_date, set_check=self.dep_loop_checker())
121
122     @classmethod
123     def from_dict(cls, db, d, id_):
124         t = cls(
125                db,
126                id_,
127                d['title_history'],
128                {k: set(v) for k, v in d['tags_history'].items()},
129                d['default_effort_history'],
130                {k: set(v) for k, v in d['deps_history'].items()},
131                d['comment'],
132                d['forks'])
133         return t
134
135     def to_dict(self):
136         return {
137             'title_history': self.title.history,
138             'default_effort_history': self.default_effort.history,
139             'tags_history': {k: list(v) for k, v in self.tags.history.items()},
140             'deps_history': {k: list(v) for k, v in self.dep_ids.history.items()},
141             'comment': self.comment,
142             'forks': self.forks_id,
143         }
144
145     # def fork(self):
146     #     now = today_date(with_time=True)
147     #     dict_source = {
148     #         'title_history': {now: self},
149     #         'default_effort_history': {now: self}, 
150     #         'tags_history': {now: self},
151     #         'deps_history': {now: self},
152     #         'comment': self.comment,
153     #         'forks': self.id_}
154     #     return self.db.add_task(dict_source=dict_source)
155
156     @property
157     def forked_task(self):
158         return self.db.tasks[self.forks_id] if self.forks_id else None
159
160     @property
161     def deps(self):
162         deps = []
163         for id_ in self.dep_ids.now:
164             if len(id_) == 0:
165                 continue
166             elif id_ not in self.db.tasks.keys():
167                 raise PlomException(f'dep referenced on Task {self.id_} not found: {id_}')
168             deps += [self.db.tasks[id_]]
169         return deps
170
171     @property
172     def deps_weight(self):
173         def count_weight(task):
174             sub_count = 1
175             for dep in task.deps:
176                 sub_count += count_weight(dep)
177             return sub_count
178         return count_weight(self) 
179
180     def dep_loop_checker(self):
181         def f(dep_ids_now):
182             loop_msg = "can't set dep, would create loop"
183             for id_ in dep_ids_now:
184                 if id_ == self.id_:
185                     raise PlomException(loop_msg)
186                 elif id_ in self.db.tasks.keys():
187                     dep = self.db.tasks[id_]
188                     f(dep.dep_ids.now)
189         return f 
190
191     def matches(self, search):
192         if search is None:
193             return False
194         else:
195             return search in self.title.now or search in self.comment or search in '$'.join(self.tags.now)
196
197     @property
198     def latest_effort_date(self):
199         todos = [t for t in self.db.todos.values() if t.task.id_ == self.id_]
200         todos.sort(key=lambda t: t.latest_date)
201         if len(todos) > 0:
202             return todos[-1].latest_date 
203         else:
204             return '' 
205
206
207
208 class Day:
209
210     def __init__(self, db, date, comment=''):
211         self.date = date 
212         self.db = db
213         self.comment = comment
214         self.linked_todos_as_list = []
215
216     @classmethod
217     def from_dict(cls, db, d, date=None):
218         comment = d['comment'] if 'comment' in d.keys() else ''
219         day = cls(db, date, comment)
220         return day
221
222     def to_dict(self):
223         d = {'comment': self.comment}
224         return d
225
226     @property
227     def linked_todos(self):
228         linked_todos = {}
229         for todo in self.linked_todos_as_list:
230             linked_todos[todo.id_] = todo
231         return linked_todos
232
233     def _todos_sum(self, include_undone=False):
234         s = 0
235         for todo in [todo for todo in self.linked_todos.values()
236                      if self.date in todo.efforts.keys()]:
237             day_effort = todo.efforts[self.date]
238             if todo.done:
239                 s += day_effort if day_effort else todo.task.default_effort.at(self.date)
240             elif include_undone:
241                 s += day_effort if day_effort else 0 
242         return s
243
244     @property
245     def todos_sum(self):
246         return self._todos_sum()
247
248     def sorted_todos(self, done, is_tree_shaped, sort_order, legal_keys):
249         todos = [t for t in self.linked_todos_as_list if t.visible and t.done == done]
250         reverse = False
251         sort_column = sort_order[:]
252         if sort_order and '-' == sort_order[0]:
253             reverse = True
254             sort_column = sort_order[1:]
255         if sort_column in legal_keys:
256             todos.sort(key=lambda t: getattr(t, sort_column))
257         if reverse:
258             todos.reverse()
259         if is_tree_shaped:
260             def walk_tree(todo, sorted_todos):
261                 todo.deps = [t for t in sorted_todos if t in todo.deps]
262                 for dep in [t for t in sorted_todos if t in todo.deps]:
263                     walk_tree(dep, sorted_todos)
264             if done:
265                 trunk = [t for t in todos if len([td for td in t.dependers if td.done]) == 0]
266             else:
267                 trunk = [t for t in todos if len(t.dependers) == 0]
268             for node in trunk:
269                 walk_tree(node, todos)
270             todos = trunk 
271         return todos
272
273     @property
274     def visible_in_export(self):
275         return len(self.comment) + len([t for t in self.linked_todos_as_list if t.visible]) > 0
276
277
278
279 class Todo(TaskLike):
280
281     def __init__(self,
282                  db,
283                  id_,
284                  task,
285                  done=False,
286                  comment='',
287                  day_tags=None,
288                  importance=1.0,
289                  efforts=None,
290                  dep_ids=None):
291         super().__init__(db, id_, comment)
292         self.task = task 
293         self._done = done
294         for k in efforts.keys():
295             try:
296                 datetime.strptime(k, DATE_FORMAT)
297             except ValueError:
298                 raise PlomException(f'Todo creation: bad date string: {k}')
299         self.efforts = efforts if efforts else {}
300         self.day_tags = day_tags if day_tags else set()
301         self.importance = importance
302         self._dep_ids = dep_ids if dep_ids else [] 
303         self.already_listed = False
304         self.been_observed = False 
305
306     @classmethod
307     def from_dict(cls, db, d, id_):
308         todo = cls(
309                 db,
310                 id_,
311                 db.tasks[d['task']],
312                 d['done'],
313                 d['comment'],
314                 set(d['day_tags']),
315                 d['importance'],
316                 d['efforts'],
317                 d['deps'])
318         return todo
319
320     def to_dict(self):
321         return {
322                 'task': self.task.id_,
323                 'done': self.done,
324                 'comment': self.comment,
325                 'day_tags': list(self.day_tags),
326                 'importance': self.importance,
327                 'efforts': self.efforts,
328                 'deps': self._dep_ids}
329
330     @property
331     def title(self):
332         return self.task.title.at(self.db.selected_date)
333
334     @property
335     def dated_title(self):
336         return f'{self.earliest_date}:{self.title}'
337
338     @property
339     def deps(self):
340         return [self.db.todos[id_] for id_ in self._dep_ids]
341
342     @deps.setter
343     def deps(self, deps):
344         self._dep_ids = [dep.id_ for dep in deps]
345         def f(deps):
346             loop_msg = "can't set dep, would create loop"
347             for dep in deps: 
348                 if dep == self:
349                     raise PlomException(loop_msg)
350                 else:
351                     f(dep.deps)
352         f(self.deps)
353
354     @property
355     def default_effort(self):
356         return self.task.default_effort.at(self.earliest_date)
357
358     @property
359     def done(self):
360         return self.deps_done and self._done
361
362     @done.setter
363     def done(self, doneness):
364         self._done = doneness
365
366     @property
367     def deps_done(self):
368         if len(self.deps) > 0:
369             for dep in self.deps:
370                 if not dep.done:
371                     return False
372         return True
373
374     @property
375     def dep_efforts(self):
376         dep_efforts = 0
377         if self.deps:
378             for dep in self.deps: 
379                 dep_efforts += dep.all_days_effort
380                 dep_efforts += dep.dep_efforts
381         return dep_efforts
382
383     def depender_path(self, dep):
384         if self.dependers:
385             return f'Y{depender.depender_path()}:{self.title}Y'
386         else:
387             return f'X{self.title}X'
388
389     @property
390     def depender_paths(self):
391         paths = []
392         for depender in self.dependers:
393             if len(depender.depender_paths) == 0:
394                  paths += [[depender]]
395             else:
396                 for path in depender.depender_paths:
397                     paths += [path + [depender]] 
398         return paths
399
400     @property
401     def shortened_depender_paths(self):
402         new_paths = []
403         for path in self.depender_paths:
404             new_path = []
405             for i, step in enumerate(path):
406                 next_step = None if len(path) <= i+1 else path[i+1]
407                 for distinct_path in new_paths:
408                     if len(distinct_path) >= i+2 and distinct_path[i+1] == next_step:
409                         step = None
410                 new_path += [step]
411             new_paths += [new_path]
412         return new_paths
413
414     @property
415     def all_days_effort(self):
416         total = 0
417         for effort in self.efforts.values():
418             total += effort if effort else 0
419         if self.done:
420             total = max(total, self.task.default_effort.at(self.latest_date))
421         return total
422
423     def matches(self, search):
424         if search is None:
425             return False
426         else:
427             return search in self.comment or search in '$'.join(self.tags) or search in self.title
428
429     def is_effort_removable(self, date):
430         if not date in self.efforts.keys():
431             return False
432         if self.efforts[date]:
433             return False
434         if self.done and date == self.latest_date:
435             return False
436         return True
437
438     @property
439     def tags(self):
440         return self.day_tags | self.task.tags.now
441
442     @property
443     def day_effort(self):
444         return self.efforts[self.db.selected_date]
445
446     @property
447     def effort_at_selected_date(self):
448         if self.db.selected_date in self.efforts.keys() and self.day_effort is not None:
449             return self.day_effort 
450         else:
451             return self.task.default_effort.then
452
453     @property
454     def day(self):
455         return self.db.days[self.earliest_date]
456
457     @property
458     def sorted_effort_dates(self):
459         dates = list(self.efforts.keys())
460         dates.sort()
461         return dates
462
463     @property
464     def earliest_date(self):
465         return self.sorted_effort_dates[0]
466
467     @property
468     def latest_date(self):
469         return self.sorted_effort_dates[-1]
470
471     @property
472     def has_dependers(self):
473         return len(self.dependers) > 0
474
475     @property
476     def has_deps(self):
477         return len(self.deps) > 0
478
479     @property
480     def sort_done(self):
481         return self.day_effort if self.day_effort else 0 
482
483     @property
484     def family_effort(self):
485         return self.effort_at_selected_date + self.dep_efforts
486
487     def observe(self):
488         self.been_observed = True
489         return '' 
490
491
492
493 class TodoDB(PlomDB):
494
495     def __init__(self,
496             selected_date=None,
497             tag_filter_and = None,
498             tag_filter_not = None):
499         self.selected_date = selected_date if selected_date else today_date()
500         self.tag_filter_and = tag_filter_and if tag_filter_and else []
501         self.tag_filter_not = tag_filter_not if tag_filter_not else []
502
503         self.days = {}
504         self.tasks = {}
505         self.t_tags = set()
506         self.todos = {}
507         super().__init__(db_path)
508
509     # savefile I/O
510
511     def read_db_file(self, f):
512         d = json.load(f)
513
514         for id_, t_dict in d['tasks'].items():
515             t = self.add_task(id_=id_, dict_source=t_dict)
516             for tag in t.tags.now:
517                 self.t_tags.add(tag)
518         for task in self.tasks.values():
519             for dep in task.deps:
520                 dep.dependers += [task]
521
522         for id_, todo_dict in d['todos'].items():
523             todo = self.add_todo(todo_dict, id_)
524             for tag in todo.day_tags:
525                 self.t_tags.add(tag)
526         for todo in self.todos.values():
527             for dep in todo.deps:
528                 dep.dependers += [todo]
529
530         for date, day_dict in d['days'].items():
531             self.add_day(dict_source=day_dict, date=date)
532         for todo in self.todos.values():
533             for date in todo.efforts.keys():
534                 if not date in self.days.keys():
535                     self.add_day(date)
536                 self.days[date].linked_todos_as_list += [todo]
537
538         # self.set_visibilities()
539
540     def to_dict(self):
541         d = {'tasks': {}, 'days': {}, 'todos': {}}
542         for uuid, t in self.tasks.items():
543              d['tasks'][uuid] = t.to_dict()
544         for date, day in self.days.items():
545             d['days'][date] = day.to_dict()
546         for id_, todo in self.todos.items():
547             d['todos'][id_] = todo.to_dict()
548         return d
549
550     def write(self):
551         dates_to_purge = []
552         for date, day in self.days.items():
553             if len(day.linked_todos) == 0 and len(day.comment) == 0:
554                 dates_to_purge += [date]
555         for date in dates_to_purge:
556             del self.days[date]
557         self.write_text_to_db(json.dumps(self.to_dict()))
558
559     # properties 
560
561     @property
562     def selected_day(self):
563         if not self.selected_date in self.days.keys():
564             self.days[self.selected_date] = self.add_day(date=self.selected_date)
565         return self.days[self.selected_date]
566
567     # table manipulations 
568
569     def add_day(self, date, dict_source=None):
570         day = Day.from_dict(self, dict_source, date) if dict_source else Day(self, date)
571         self.days[date] = day 
572         return day
573
574     def add_task(self, id_=None, dict_source=None):
575         id_ = id_ if id_ else str(uuid4())
576         t = Task.from_dict(self, dict_source, id_) if dict_source else Task(self, id_)
577         self.tasks[id_] = t
578         return t
579
580     def fork_task(self, id_):
581         origin = self.tasks[id_]
582         now = today_date(with_time=True)
583         fork_id = str(uuid4())
584         fork = Task(self, fork_id, {}, {}, {}, {}, origin.comment, id_)
585         self.tasks[fork_id] = fork
586         return fork
587
588     def update_task(self, id_, title, default_effort, tags, comment, dep_ids, depender_ids):
589         task = self.tasks[id_] if id_ in self.tasks.keys() else self.add_task(id_)
590         task.title.set(title)
591         task.default_effort.set(default_effort)
592         task.tags.set(tags)
593         task.dep_ids.set(dep_ids)
594         task.comment = comment 
595         for depender in task.dependers:
596             if not depender.id_ in depender_ids:
597                 depender_dep_ids = depender.dep_ids.now
598                 depender_dep_ids.remove(task.id_)
599                 depender.dep_ids.set(depender_dep_ids)
600         for depender_id in depender_ids:
601             depender= self.tasks[depender_id]
602             depender_dep_ids = depender.dep_ids.now
603             depender_dep_ids.add(task.id_)
604             depender.dep_ids.set(depender_dep_ids)
605         return task
606
607     def add_todo(self, todo_dict=None, id_=None, task=None, efforts=None, parenthood=''):
608         make_children = parenthood != 'childless'
609         adopt_if_possible = parenthood == 'adoption'
610         id_ = id_ if id_ else str(uuid4())
611         if todo_dict:
612             todo = Todo.from_dict(self, todo_dict, id_)
613         elif task and efforts:
614             todo = Todo(self, id_, task, efforts=efforts)
615             deps = []
616             if make_children and todo.latest_date in self.days.keys():
617                 for dep_task in task.deps:
618                     # if Todo expects dependencies, adopt any compatibles found in DB.selected_date
619                     # before creating new depended Todos
620                     dep_todo = None
621                     if adopt_if_possible:
622                         adoptable_todos = [t for t in self.days[todo.latest_date].linked_todos_as_list
623                                            if t.task.id_ == dep_task.id_]
624                         if len(adoptable_todos) > 0:
625                             dep_todo = adoptable_todos[0]
626                     if not dep_todo:
627                         dep_todo = self.add_todo(task=dep_task, efforts=efforts, parenthood=parenthood)
628                     deps += [dep_todo]
629             todo.deps = deps 
630         self.todos[id_] = todo 
631         for date in todo.efforts.keys():
632             if date in self.days.keys():  # possibly not all Days have been initialized yet
633                 self.days[date].linked_todos_as_list += [todo] 
634         return todo
635
636     def _update_todo_shared(self, id_, done, comment, importance):
637         todo = self.todos[id_]
638         todo.done = done
639         todo.comment = comment 
640         todo.importance = importance 
641         return todo
642
643     def update_todo_for_day(self, id_, date, effort, done, comment, importance):
644         todo = self._update_todo_shared(id_, done, comment, importance)
645         todo.efforts[date] = effort 
646
647     def update_todo(self, id_, efforts, done, comment, tags, importance, deps):
648         todo = self._update_todo_shared(id_, done, comment, importance)
649         if len(efforts) == 0 and not todo.deps:
650             raise PlomException('todo must have at least one effort!')
651         todo.deps = deps 
652         todo.efforts = efforts 
653         for date in todo.efforts.keys():
654             if not date in self.days.keys():
655                 self.add_day(date=date) 
656             if not self in self.days[date].linked_todos_as_list:
657                 self.days[date].linked_todos_as_list += [todo]
658         todo.day_tags = tags
659
660     def delete_todo(self, id_):
661         todo = self.todos[id_]
662         dates_to_delete = []
663         for date in todo.efforts.keys():
664             dates_to_delete += [date]
665         for date in dates_to_delete:
666             self.delete_effort(todo, date, force=True)
667         for depender in todo.dependers:
668             depender._dep_ids.remove(todo.id_)
669         del self.todos[id_]
670
671     def delete_task(self, id_):
672         del self.tasks[id_]
673
674     def delete_effort(self, todo, date, force=False):
675         if (not force) and len(todo.efforts) == 1:
676             raise PlomException('todo must retain at least one effort!')
677         self.days[date].linked_todos_as_list.remove(todo)
678         del todo.efforts[date]
679
680     # views
681
682     def show_message(self, message):
683         return j2env.get_template('message.html').render(message=message)
684
685     def show_calendar_export(self, start_date_str, end_date_str):
686         days_to_show = self.init_calendar_items(start_date_str, end_date_str)
687         return j2env.get_template('calendar_export.html').render(days=days_to_show)
688
689     def show_calendar(self, start_date_str, end_date_str):
690         # self.tag_filter_and = ['calendar']
691         # self.tag_filter_not = ['deleted']
692
693         # todays_date_obj = datetime.strptime(today_date(), DATE_FORMAT) 
694         # yesterdays_date_obj = todays_date_obj - timedelta(1)
695         # def get_day_limit_obj(index, day_limit_string):
696         #     date_obj = datetime.strptime(sorted(self.days.keys())[index], DATE_FORMAT)
697         #     if day_limit_string and len(day_limit_string) > 0:
698         #         if day_limit_string in {'today', 'yesterday'}:
699         #             date_obj = todays_date_obj if day_limit_string == 'today' else yesterdays_date_obj
700         #         else:
701         #             date_obj = datetime.strptime(day_limit_string, DATE_FORMAT)
702         #     return date_obj
703         # start_date_obj = get_day_limit_obj(0, start_date_str)
704         # end_date_obj = get_day_limit_obj(-1, end_date_str)
705
706         # days_to_show = {}
707         # for n in range(int((end_date_obj - start_date_obj).days) + 1):
708         #     date_obj = start_date_obj + timedelta(n)
709         #     date_str = date_obj.strftime(DATE_FORMAT)
710         #     if date_str not in self.days.keys():
711         #         days_to_show[date_str] = self.add_day(date_str)
712         #     else:
713         #         days_to_show[date_str] = self.days[date_str]
714         #     days_to_show[date_str].month_title = date_obj.strftime('%B') if date_obj.day == 1 else None 
715         #     days_to_show[date_str].weekday = datetime.strptime(date_str, DATE_FORMAT).strftime('%A')[:2]
716
717         days_to_show = self.init_calendar_items(start_date_str, end_date_str)
718         return j2env.get_template('calendar.html').render(
719                 selected_date=self.selected_date,
720                 days=days_to_show,
721                 start_date=start_date_str,
722                 end_date=end_date_str)
723
724     def show_day_todos(self, undone_sort_order=None, done_sort_order=None, is_tree_shaped=False, todo_parenthood=None):
725         legal_undone_sort_keys = {'title', 'sort_done', 'default_effort', 'importance'} 
726         legal_done_sort_keys = {'title', 'effort_at_selected_date', 'family_effort'} 
727
728         current_date = datetime.strptime(self.selected_date, DATE_FORMAT)
729         prev_date = current_date - timedelta(days=1)
730         prev_date_str = prev_date.strftime(DATE_FORMAT)
731         next_date = current_date + timedelta(days=1)
732         next_date_str = next_date.strftime(DATE_FORMAT)
733
734         adoptable_past_todos = []
735         for todo in [t for t in self.todos.values()
736                      if t.visible
737                      and (not t.done)
738                      and t.earliest_date < self.selected_date]:
739              adoptable_past_todos += [todo] 
740         undone_todos = self.selected_day.sorted_todos(False, is_tree_shaped, undone_sort_order,
741                                                       legal_undone_sort_keys)
742         done_todos = self.selected_day.sorted_todos(True, is_tree_shaped, done_sort_order,
743                                                     legal_done_sort_keys)
744
745         return j2env.get_template('day_todos.html').render(
746                 day=self.selected_day,
747                 tags=self.t_tags,
748                 filter_and=self.tag_filter_and,
749                 filter_not=self.tag_filter_not,
750                 prev_date=prev_date_str,
751                 adoptable_past_todos=adoptable_past_todos,
752                 next_date=next_date_str,
753                 all_tasks=[t for t in self.tasks.values()],
754                 undone_todos=undone_todos,
755                 done_todos=done_todos,
756                 is_tree_shaped=is_tree_shaped,
757                 undone_sort=undone_sort_order,
758                 done_sort=done_sort_order,
759                 parenthood=todo_parenthood)
760
761     def show_todo(self, id_, parenthood):
762         todo = self.todos[id_]
763         filtered_tasks = [t for t in self.tasks.values()
764                           if t != todo.task] 
765         filtered_todos = [t for t in self.todos.values()
766                           if t != todo
767                           and t not in todo.deps]
768
769         legal_dates = list(todo.efforts.keys())
770         date_filtered_todos = [] 
771         for date in legal_dates:
772             for filtered_todo in filtered_todos:
773                 if filtered_todo in date_filtered_todos:
774                     continue
775                 if date in filtered_todo.efforts.keys():
776                     date_filtered_todos += [filtered_todo]
777
778         dep_slots = []
779         for dep in todo.task.deps:
780             dep_slots += [{'task': dep,
781                                'todos': [t for t in todo.deps if t.task == dep]}]
782
783         suggested_todos = {}
784         for dep in todo.task.deps:
785             suggested_todos[dep.id_] = [t for t in date_filtered_todos if t.task.id_ == dep.id_]
786         additional_deps = [t for t in todo.deps if not t.task in todo.task.deps]
787
788         return j2env.get_template('todo.html').render(
789                 tags=self.t_tags,
790                 todo=todo,
791                 filtered_todos=date_filtered_todos,
792                 filtered_tasks=filtered_tasks,
793                 dep_slots=dep_slots,
794                 suggested_todos=suggested_todos,
795                 additional_deps=additional_deps,
796                 parentood=parenthood, 
797                 dep_todos=todo.deps)
798
799     def show_task(self, id_):
800         if id_:
801             if not id_ in self.tasks.keys():
802                 raise PlomException('no Task for ID')
803             task = self.tasks[id_]
804         else:
805             task = self.add_task()
806         if not id_:
807             task.default_effort.set(1.0)
808         filtered_tasks = [t for t in self.tasks.values()
809                           if t != task
810                           and (t not in task.deps)]
811
812         return j2env.get_template('task.html').render(
813                 selected_date=self.selected_date,
814                 tags=self.t_tags, 
815                 filtered_tasks=filtered_tasks,
816                 task=task)
817
818     def show_tasks(self, search, sort_order=None):
819         filtered_tasks = [] 
820         for task in [t for t in self.tasks.values() if (not search) or t.matches(search)]:
821             filtered_tasks += [task]
822         reverse = False
823         sort_column = sort_order 
824         if sort_order and '-' == sort_order[0]:
825             reverse = True
826             sort_column = sort_order[1:]
827         if sort_column == 'title':
828             filtered_tasks.sort(key=lambda t: t.title.now)
829         elif sort_column == 'default_effort':
830             filtered_tasks.sort(key=lambda t: t.default_effort.now)
831         elif sort_column == 'weight':
832             filtered_tasks.sort(key=lambda t: t.deps_weight)
833         elif sort_column == 'latest_effort_date':
834             filtered_tasks.sort(key=lambda t: t.latest_effort_date)
835         if reverse:
836             filtered_tasks.reverse()
837         return j2env.get_template('tasks.html').render(
838                 sort=sort_order,
839                 tasks=filtered_tasks,
840                 tags=self.t_tags,
841                 filter_and=self.tag_filter_and,
842                 filter_not=self.tag_filter_not,
843                 search=search)
844
845     # helpers
846
847     def init_calendar_items(self, start_date_str, end_date_str):
848         self.tag_filter_and = ['calendar']
849         self.tag_filter_not = ['deleted']
850
851         todays_date_obj = datetime.strptime(today_date(), DATE_FORMAT) 
852         yesterdays_date_obj = todays_date_obj - timedelta(1)
853         def get_day_limit_obj(index, day_limit_string):
854             date_obj = datetime.strptime(sorted(self.days.keys())[index], DATE_FORMAT)
855             if day_limit_string and len(day_limit_string) > 0:
856                 if day_limit_string in {'today', 'yesterday'}:
857                     date_obj = todays_date_obj if day_limit_string == 'today' else yesterdays_date_obj
858                 else:
859                     date_obj = datetime.strptime(day_limit_string, DATE_FORMAT)
860             return date_obj
861         start_date_obj = get_day_limit_obj(0, start_date_str)
862         end_date_obj = get_day_limit_obj(-1, end_date_str)
863
864         days_to_show = {}
865         for n in range(int((end_date_obj - start_date_obj).days) + 1):
866             date_obj = start_date_obj + timedelta(n)
867             date_str = date_obj.strftime(DATE_FORMAT)
868             if date_str not in self.days.keys():
869                 days_to_show[date_str] = self.add_day(date_str)
870             else:
871                 days_to_show[date_str] = self.days[date_str]
872             days_to_show[date_str].month_title = date_obj.strftime('%B') if date_obj.day == 1 else None 
873             days_to_show[date_str].weekday = datetime.strptime(date_str, DATE_FORMAT).strftime('%A')[:2]
874         return days_to_show
875
876
877
878 class ParamsParser:
879
880     def __init__(self, parsed_url_query, cookie_db):
881         self.params = parse_qs(parsed_url_query)
882         self.cookie_db = cookie_db
883
884     def get(self, key, default=None, as_bool=False):
885         # boolean = bool == type(default) 
886         param = self.params.get(key, [default])[0]
887         if as_bool:
888             if param == '0':
889                 param = False
890             elif param is not None:
891                 param = True
892         return param 
893
894     def cookie_key_from_params_key(self, prefix, key):
895         return f'{prefix}:{key}' if prefix else key
896
897     def get_cookied(self, key, default=None, prefix=None, as_bool=False):
898         cookie_key = self.cookie_key_from_params_key(prefix, key) 
899         param = self.get(key, default, as_bool)
900         if param == '-':
901             param = None
902             if cookie_key in self.cookie_db.keys():
903                 del self.cookie_db[cookie_key]
904         if param is None and cookie_key in self.cookie_db.keys():
905             param = self.cookie_db[cookie_key]
906         if param is not None:
907             self.cookie_db[cookie_key] = param
908         return param
909
910     def get_cookied_chain(self, key, default=None, prefix=None):
911         cookie_key = self.cookie_key_from_params_key(prefix, key) 
912         params = self.params.get(key, default)
913         if params == ['-']:
914             params = None 
915             if cookie_key in self.cookie_db.keys():
916                 del self.cookie_db[cookie_key]
917         if params is None and cookie_key in self.cookie_db.keys():
918             params = self.cookie_db[cookie_key]
919         if params is not None:
920             self.cookie_db[cookie_key] = params
921         return params
922
923
924
925 class PostvarsParser:
926
927     def __init__(self, postvars):
928         self.postvars = postvars
929
930     def has(self, key):
931         return key in self.postvars.keys()
932
933     def get(self, key, on_empty=None, float_if_possible=False):
934         return self.get_at_index(key, 0, on_empty, float_if_possible)
935
936     def get_at_index(self, key, i, on_empty=None, float_if_possible=False):
937         if self.has(key) and len(self.postvars[key][i]) > 0:
938             val = self.postvars[key][i] 
939         else:
940             val = on_empty 
941         if float_if_possible and val is not None:
942             return float(val)
943         else:
944             return val 
945
946     def get_all(self, key, on_empty=None):
947         if self.has(key) and len(self.postvars[key]) > 0:
948             return [v for v in self.postvars[key] if len(v) > 0]
949         return on_empty
950
951     def set(self, key, value):
952         self.postvars[key] = [value]
953
954
955 class TodoHandler(PlomHandler):
956
957     def config_init(self):
958         return {
959             'cookie_name': 'todo_cookie',
960             'cookie_path': '/'
961         }
962
963     def app_init(self, handler):
964         default_path = '/todo'
965         handler.add_route('GET', default_path, self.show_db)
966         handler.add_route('POST', default_path, self.write_db)
967         return 'todo', {'cookie_name': 'todo_cookie', 'cookie_path': default_path}
968
969     def do_POST(self):
970         self.try_do(self.write_db)
971
972     def write_db(self):
973         from urllib.parse import urlencode
974         config = self.apps['todo'] if hasattr(self, 'apps') else self.config_init()
975         parsed_url = urlparse(self.path)
976         site = path_split(parsed_url.path)[1]
977         length = int(self.headers['content-length'])
978         postvars = PostvarsParser(parse_qs(self.rfile.read(length).decode(), keep_blank_values=1))
979
980         db = TodoDB()
981         redir_params = []
982         # if we do encounter a filter post, we repost it (and if empty, the emptying command '-')
983         for param_name, filter_db_name in {('and_tag', 'tag_filter_and'), ('not_tag', 'tag_filter_not')}:
984             filter_db = getattr(db, filter_db_name)
985             if postvars.has(param_name): 
986                 for target in postvars.get_all(param_name, []):
987                     if len(target) > 0 and not target in filter_db:
988                         filter_db += [target]
989                 if len(filter_db) == 0:
990                     redir_params += [(param_name, '-')]
991             redir_params += [(param_name, f) for f in filter_db]
992         if site in {'calendar', 'todo'}:
993             redir_params += [('end', postvars.get('end', '-'))]
994             redir_params += [('start', postvars.get('start', '-'))]
995         if site in {'day_todos', 'todo'}:
996             todo_parenthood = postvars.get('parenthood')
997             redir_params += [('parenthood', todo_parenthood)]
998         if postvars.has('filter'): 
999             postvars.set('return_to', '')
1000         
1001         if 'tasks' == site:
1002             redir_params += [('search', postvars.get('search', ''))]
1003
1004         elif 'todo' == site:
1005             todo_id = postvars.get('todo_id')
1006             redir_params += [('id', todo_id)]
1007             old_todo = db.todos[todo_id] if todo_id in db.todos.keys() else None
1008             efforts = {}
1009             latest_date = db.selected_date 
1010             for i, date in enumerate(postvars.get_all('effort_date', [])):
1011                 if '' == date:
1012                     continue
1013                 latest_date = date
1014                 efforts[date] = None
1015                 if not (old_todo and old_todo.deps):
1016                     efforts[date] = postvars.get_at_index('effort', i, on_empty=None,
1017                                                           float_if_possible=True)
1018             if postvars.has('delete'): 
1019                 has_day_effort = len([e for e in efforts.values() if e is not None]) > 0
1020                 if postvars.has('done')\
1021                         or postvars.get('comment')\
1022                         or postvars.get_all('tag', [])\
1023                         or has_day_effort:
1024                     raise PlomException('will not remove todo of preserve-worthy values')
1025                 db.delete_todo(todo_id) 
1026                 postvars.set('return_to', 'calendar')
1027             elif postvars.has('update'):
1028                 if postvars.has('delete_effort'):
1029                     for date in postvars.get_all('delete_effort'):
1030                         db.delete_effort(old_todo, date)
1031                         del efforts[date]
1032                 deps = [db.todos[id_] for id_ in postvars.get_all('adopt_dep', [])
1033                         if id_ in db.todos.keys()] 
1034                 for dep in deps:
1035                     if not todo_id in [t.id_ for t in dep.dependers]:
1036                         dep.dependers += [db.todos[todo_id]]
1037                 birth_dep_ids = postvars.get_all('birth_dep', [])
1038                 for id_ in [id_ for id_ in birth_dep_ids if not id_ in db.tasks.keys()]:
1039                     raise PlomException('submitted illegal dep ID')
1040                 tasks_to_birth = [db.tasks[id_] for id_ in birth_dep_ids]
1041                 for task in tasks_to_birth:
1042                     deps += [db.add_todo(task=task, efforts={latest_date: None}, parenthood=todo_parenthood)]
1043                 db.update_todo(id_=todo_id,
1044                                efforts=efforts,
1045                                done=postvars.has('done'),
1046                                comment=postvars.get('comment', ''),
1047                                tags=postvars.get_all('tag', []),
1048                                importance=float(postvars.get('importance')),
1049                                deps=deps)
1050
1051         elif 'task' == site:
1052             task_id = postvars.get('task_id')
1053             if (postvars.has('delete') or postvars.has('fork')) and (not task_id in db.tasks.keys()):
1054                 if not task_id in db.tasks.keys():
1055                     raise PlomException('can only do this on Task that already exists')
1056             if postvars.has('delete'):
1057                 if [t for t in db.todos.values() if task_id == t.task.id_]:
1058                     raise PlomException('will not remove Task describing existing Todos')
1059                 if postvars.get('title', '')\
1060                         or postvars.get_all('tag', [])\
1061                         or postvars.get_all('dep', [])\
1062                         or postvars.get('comment', ''):
1063                     raise PlomException('will not remove Task of preserve-worthy values')
1064                 db.delete_task(task_id)
1065             elif postvars.has('update'):
1066                 dep_ids = postvars.get_all('dep', [])
1067                 for id_ in [id_ for id_ in dep_ids if not id_ in db.tasks.keys()]:
1068                     raise PlomException('submitted illegal dep ID')
1069                 depender_ids = postvars.get_all('depender', [])
1070                 for id_ in [id_ for id_ in depender_ids if not id_ in db.tasks.keys()]:
1071                     raise PlomException('submitted illegal dep ID')
1072                 task = db.update_task(
1073                         id_=task_id,
1074                         title=postvars.get('title', ''),
1075                         default_effort=postvars.get('default_effort', float_if_possible=True), 
1076                         tags=postvars.get_all('tag', []),
1077                         comment=postvars.get('comment', ''),
1078                         dep_ids=dep_ids,
1079                         depender_ids=depender_ids)
1080                 if postvars.has('add_as_todo'):
1081                     db.add_todo(task=task, efforts={postvars.get('new_todo_date'): None})
1082             elif postvars.has('fork'):
1083                 t = db.fork_task(task_id)
1084                 task_id = t.id_
1085
1086                 # dep_ids = postvars.get_all('dep', [])
1087                 # for id_ in [id_ for id_ in dep_ids if not id_ in db.tasks.keys()]:
1088                 #     raise PlomException('submitted illegal dep ID')
1089                 # depender_ids = postvars.get_all('depender', [])
1090                 # for id_ in [id_ for id_ in depender_ids if not id_ in db.tasks.keys()]:
1091                 #     raise PlomException('submitted illegal dep ID')
1092                 # task = db.update_task(
1093                 #         id_=task_id,
1094                 #         title=postvars.get('title', ''),
1095                 #         default_effort=postvars.get('default_effort', float_if_possible=True), 
1096                 #         tags=postvars.get_all('tag', []),
1097                 #         comment=postvars.get('comment', ''),
1098                 #         dep_ids=dep_ids,
1099                 #         depender_ids=depender_ids)
1100                 # if postvars.has('add_as_todo'):
1101                 #     db.add_todo(task=task, efforts={postvars.get('new_todo_date'): None})
1102
1103             redir_params += [('id', task_id)]
1104
1105         elif 'day_todos' == site:
1106             if postvars.has('update'): 
1107                 db.selected_date = postvars.get('date') 
1108                 redir_params += [('date', db.selected_date)]
1109                 db.selected_day.comment = postvars.get('day_comment', '') 
1110                 task_id = postvars.get('choose_task', None)
1111                 if task_id:
1112                     if task_id not in db.tasks.keys():
1113                         raise PlomException('illegal task ID entered')
1114                     db.add_todo(task=db.tasks[task_id], efforts={db.selected_date: None},
1115                                 parenthood=todo_parenthood)
1116                 for id_ in postvars.get_all('choose_todo', []):
1117                     db.todos[id_].efforts[db.selected_date] = None
1118                 for i, todo_id in enumerate(postvars.get_all('todo_id', [])):
1119                     old_todo = db.todos[todo_id]
1120                     done = todo_id in postvars.get_all('done', [])
1121                     day_effort_input = postvars.get_at_index('effort', i, '') 
1122                     day_effort = float(day_effort_input) if len(day_effort_input) > 0 else None
1123                     comment = postvars.get_at_index('effort_comment', i, '') 
1124                     if (day_effort is not None) and (not done) and day_effort < 0 and 0 == len(comment):
1125                         if len(old_todo.efforts) > 1:
1126                             db.delete_effort(old_todo, db.selected_date) 
1127                         else:
1128                             db.delete_todo(todo_id)
1129                         continue
1130                     importance = float(postvars.get_at_index('importance', i))
1131                     if old_todo\
1132                             and old_todo.done == done\
1133                             and old_todo.day_effort == day_effort\
1134                             and comment == old_todo.comment\
1135                             and old_todo.importance == importance:
1136                         continue
1137                     db.update_todo_for_day(
1138                             todo_id,
1139                             db.selected_date,
1140                             day_effort,
1141                             done,
1142                             comment,
1143                             importance)
1144
1145         homepage = postvars.get('return_to')
1146         if not homepage:
1147             encoded_params = urlencode(redir_params)
1148             homepage = f'{site}?{encoded_params}'
1149         db.write()
1150         self.redirect(homepage)
1151
1152     def do_GET(self):
1153         self.try_do(self.show_db)
1154
1155     def show_db(self):
1156         config = self.apps['todo'] if hasattr(self, 'apps') else self.config_init()
1157         parsed_url = urlparse(self.path)
1158         site = path_split(parsed_url.path)[1]
1159         cookie_db = self.get_cookie_db(config['cookie_name'])
1160         params = ParamsParser(parsed_url.query, cookie_db)
1161
1162         selected_date = tag_filter_and = tag_filter_not = None
1163         if site in {'day_todos', 'calendar', 'task'}:
1164             selected_date = params.get_cookied('date')
1165         if site in {'day_todos', 'tasks'}:
1166             tag_filter_and = params.get_cookied_chain('and_tag', prefix=site)
1167             tag_filter_not = params.get_cookied_chain('not_tag', prefix=site)
1168         if site in {'day_todos', 'todo'}:
1169             todo_parenthood = params.get_cookied('parenthood', prefix=site)
1170         if site in {'calendar', 'calendar_export', ''}:
1171             start_date = params.get_cookied('start', prefix=site)
1172             end_date = params.get_cookied('end', prefix=site)
1173         db = TodoDB(selected_date, tag_filter_and, tag_filter_not)
1174         if site in {'todo', 'task'}:
1175             id_ = params.get('id')
1176         if 'reset_cookie' == site:
1177             cookie_db = {  # sensible defaults
1178                 params.cookie_key_from_params_key('day_todos', 'tree'): True,
1179                 params.cookie_key_from_params_key('todo', 'and_tag'): ['default'],
1180                 params.cookie_key_from_params_key('todo', 'not_tag'): ['ignore'],
1181                 params.cookie_key_from_params_key('todo', 'start'): 'yesterday',
1182                 params.cookie_key_from_params_key('todo', 'end'): 'today'}
1183             page = db.show_message('cookie unset!')
1184         elif 'day_todos' == site:
1185             is_tree_shaped = params.get_cookied('tree', prefix=site, as_bool=True)
1186             undone_sort_order = params.get_cookied('undone_sort', prefix=site)
1187             done_sort_order = params.get_cookied('done_sort', prefix=site)
1188             page = db.show_day_todos(undone_sort_order, done_sort_order, is_tree_shaped, todo_parenthood)
1189         elif site == 'todo':
1190             page = db.show_todo(id_, parenthood=todo_parenthood)
1191         elif 'task' == site:
1192             page = db.show_task(id_)
1193         elif 'tasks' == site:
1194             sort_order = params.get_cookied('sort', prefix=site)
1195             search = params.get('search', '')
1196             page = db.show_tasks(search, sort_order)
1197         elif 'add_task' == site:
1198             page = db.show_task(None)
1199         elif 'calendar_export' == site:
1200             page = db.show_calendar_export(start_date, end_date)
1201         else:  # 'calendar' == site
1202             page = db.show_calendar(start_date, end_date)
1203
1204         self.set_cookie(config['cookie_name'], config['cookie_path'], cookie_db)
1205         self.send_HTML(page)
1206
1207
1208 if __name__ == "__main__":
1209     run_server(server_port, TodoHandler)