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