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