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