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