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
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             # print("DEBUG b", day_effort, todo.task.default_effort_at(self.date))
193             if todo.done:
194                 s += day_effort if day_effort else todo.task.default_effort.at(self.date)
195             elif include_undone:
196                 s += day_effort if day_effort else 0 
197         return s
198
199     @property
200     def todos_sum(self):
201         return self._todos_sum()
202
203     # @property
204     # def todos_sum2(self):
205     #     return self._todos_sum(True)
206
207
208 class Todo:
209
210     def __init__(self,
211                  db,
212                  id_,
213                  task,
214                  done=False,
215                  comment='',
216                  day_tags=None,
217                  importance=1.0,
218                  efforts=None,
219                  child_ids=None):
220         self.id_ = id_
221         self.db = db 
222         self.task = task 
223         self._done = done
224         self._efforts = efforts if efforts else {}
225         self.comment = comment
226         self.day_tags = day_tags if day_tags else set()
227         self.importance = importance
228         self.child_ids = child_ids if child_ids else [] 
229
230         self.parent = None 
231
232     @classmethod
233     def from_dict(cls, db, d, id_):
234         todo = cls(
235                 db,
236                 id_,
237                 db.tasks[d['task']],
238                 d['done'],
239                 d['comment'],
240                 set(d['day_tags']),
241                 d['importance'],
242                 d['efforts'],
243                 d['children'])
244         return todo
245
246     def to_dict(self):
247         return {
248                 'task': self.task.id_,
249                 'done': self.done,
250                 'comment': self.comment,
251                 'day_tags': list(self.day_tags),
252                 'importance': self.importance,
253                 'efforts': self.efforts,
254                 'children': self.child_ids}
255
256     @property
257     def title(self):
258         return self.task.title.at(self.earliest_date)
259
260     @property
261     def children(self):
262         return [self.db.todos[id_] for id_ in self.child_ids]
263
264     @property
265     def default_effort(self):
266         return self.task.default_effort.at(self.earliest_date)
267
268     @property
269     def done(self):
270         if len(self.children) > 0:
271             for child in self.children:
272                 if not child.done:
273                     return False
274             return True
275         else:
276             return self._done
277
278     @done.setter
279     def done(self, doneness):
280         self._done = doneness
281
282     @property
283     def efforts(self):
284         if self.children:
285             efforts = {} 
286             for date in self._efforts.keys():
287                 efforts[date] = None
288             for child in self.children:
289                 to_add = None 
290                 for date, effort in child.efforts.items():
291                     if not date in efforts.keys():
292                         efforts[date] = None 
293                     if effort is not None:
294                         to_add = effort
295                     elif child.done:
296                         to_add = child.task.default_effort.at(date)
297                 if to_add is not None:
298                     if efforts[date] is not None:
299                         efforts[date] += to_add
300                     else:
301                         efforts[date] = to_add
302             return efforts
303         else:
304             return self._efforts
305
306     @efforts.setter
307     def efforts(self, efforts_dict):
308         self._efforts = efforts_dict
309
310     @property
311     def all_days_effort(self):
312         total = 0
313         for effort in self.efforts.values():
314             total += effort if effort else 0
315         if self.done:
316             total = max(total, self.task.default_effort.at(self.latest_date))
317         return total
318
319     def matches(self, search):
320         if search is None:
321             return False
322         else:
323             return search in self.comment or search in '$'.join(self.tags) or search in self.title
324
325     def is_effort_removable(self, date):
326         if not date in self.efforts.keys():
327             return False
328         if self.efforts[date]:
329             return False
330         if self.done and date == self.latest_date:
331             return False
332         return True
333
334     @property
335     def path(self):
336         path = ''
337         if self.parent:
338             path = f'{self.parent.path}{self.parent.title}:'
339         return path
340
341     @property
342     def tags(self):
343         return self.day_tags | self.task.tags.now
344
345     @property
346     def day_effort(self):
347         return self.efforts[self.db.selected_date]
348
349     @property
350     def day(self):
351         return self.db.days[self.earliest_date]
352
353     @property
354     def sorted_effort_dates(self):
355         dates = list(self.efforts.keys())
356         dates.sort()
357         return dates
358
359     @property
360     def earliest_date(self):
361         return self.sorted_effort_dates[0]
362
363     @property
364     def latest_date(self):
365         return self.sorted_effort_dates[-1]
366
367
368 class TodoDB(PlomDB):
369
370     def __init__(self,
371             prefix,
372             selected_date=None,
373             t_filter_and = None,
374             t_filter_not = None,
375             hide_unchosen=False,
376             hide_done=False):
377         self.prefix = prefix
378         self.selected_date = selected_date if selected_date else today_date() #str(datetime.now())[:10]
379         self.t_filter_and = t_filter_and if t_filter_and else []
380         self.t_filter_not = t_filter_not if t_filter_not else []
381         self.hide_unchosen = hide_unchosen
382         self.hide_done = hide_done
383
384         self.days = {}
385         self.tasks = {}
386         self.t_tags = set()
387         self.todos = {}
388         super().__init__(db_path)
389
390     def read_db_file(self, f):
391         d = json.load(f)
392         for id_, t_dict in d['tasks'].items():
393             t = self.add_task(id_=id_, dict_source=t_dict)
394             for tag in t.tags.now:
395                 self.t_tags.add(tag)
396         for id_, todo_dict in d['todos'].items():
397             todo = self.add_todo(todo_dict, id_)
398             self.todos[id_] = todo 
399             for tag in todo.day_tags:
400                 self.t_tags.add(tag)
401         for date, day_dict in d['days'].items():
402             self.add_day(dict_source=day_dict, date=date)
403         for todo in self.todos.values():
404             for child in todo.children:
405                 child.parent = todo
406             for date in todo.efforts.keys():
407                 if not date in self.days.keys():
408                     self.add_day(date)
409                 if not todo in self.days[date].linked_todos_as_list:
410                     self.days[date].linked_todos_as_list += [todo]
411         self.set_visibilities()
412
413     def set_visibilities(self):
414         for uuid, t in self.tasks.items():
415             t.visible = len([tag for tag in self.t_filter_and if not tag in t.tags.now]) == 0\
416                     and len([tag for tag in self.t_filter_not if tag in t.tags.now]) == 0\
417                     and ((not self.hide_unchosen) or uuid in self.selected_day.todos.keys())
418         # for day in self.days.values():
419         #     for todo in day.todos.values():
420         #         todo.visible = len([tag for tag in self.t_filter_and if not tag in todo.day_tags | todo.task.tags ]) == 0\
421         #             and len([tag for tag in self.t_filter_not if tag in todo.day_tags | todo.task.tags ]) == 0\
422         #             and ((not self.hide_done) or (not todo.done))
423         for todo in self.todos.values():
424             todo.visible = len([tag for tag in self.t_filter_and if not tag in todo.day_tags | todo.task.tags.now ]) == 0\
425                 and len([tag for tag in self.t_filter_not if tag in todo.day_tags | todo.task.tags.now ]) == 0\
426                 and ((not self.hide_done) or (not todo.done))
427
428     def to_dict(self):
429         d = {'tasks': {}, 'days': {}, 'todos': {}}
430         for uuid, t in self.tasks.items():
431              d['tasks'][uuid] = t.to_dict()
432         for date, day in self.days.items():
433             d['days'][date] = day.to_dict()
434             for todo in day.todos.values():
435                 d['todos'][todo.id_] = todo.to_dict()
436         for id_, todo in self.todos.items():
437             d['todos'][id_] = todo.to_dict()
438         return d
439
440     @property
441     def selected_day(self):
442         if not self.selected_date in self.days.keys():
443             self.days[self.selected_date] = self.add_day(date=self.selected_date)
444             # print("DEBUG selected_day", self.days[self.selected_date].date)
445         return self.days[self.selected_date]
446
447     def write(self):
448         dates_to_purge = []
449         for date, day in self.days.items():
450             if len(day.linked_todos) == 0 and len(day.comment) == 0:
451                 dates_to_purge += [date]
452         for date in dates_to_purge:
453             del self.days[date]
454         self.write_text_to_db(json.dumps(self.to_dict()))
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 add_todo(self, todo_dict=None, id_=None, task=None, efforts=None):
463         id_ = id_ if id_ else str(uuid4())
464         if todo_dict:
465             todo = Todo.from_dict(self, todo_dict, id_)
466         elif task and efforts:
467             todo = Todo(self, id_, task, efforts=efforts)
468             children = []
469             for child_task in task.subtasks:
470                 children += [self.add_todo(task=child_task, efforts=efforts)]
471             todo.child_ids = [child.id_ for child in children]
472         self.todos[id_] = todo 
473         return todo
474
475     def add_day(self, date, dict_source=None):
476         day = Day.from_dict(self, dict_source, date) if dict_source else Day(self, date)
477         self.days[date] = day 
478         return day
479
480     def neighbor_dates(self):
481         current_date = datetime.strptime(self.selected_date, DATE_FORMAT)
482         prev_date = current_date - timedelta(days=1)
483         prev_date_str = prev_date.strftime(DATE_FORMAT)
484         next_date = current_date + timedelta(days=1)
485         next_date_str = next_date.strftime(DATE_FORMAT)
486         return prev_date_str, next_date_str
487
488     def show_do_todos(self, sort_order=None):
489         prev_date_str, next_date_str = self.neighbor_dates()
490         todos = [t for t in self.selected_day.linked_todos_as_list if t.visible]
491         if sort_order == 'title':
492             todos.sort(key=lambda t: t.task.title.then)
493         elif sort_order == 'done':
494             todos.sort(key=lambda t: t.day_effort if t.day_effort else t.default_effort.then if t.done else 0, reverse=True)
495         elif sort_order == 'default_effort':
496             todos.sort(key=lambda t: t.default_effort, reverse=True)
497         elif sort_order == 'importance':
498             todos.sort(key=lambda t: t.importance, reverse=True)
499         return j2env.get_template('do_todos.html').render(
500                 day=self.selected_day,
501                 tags=self.t_tags,
502                 filter_and=self.t_filter_and,
503                 filter_not=self.t_filter_not,
504                 prev_date=prev_date_str,
505                 next_date=next_date_str,
506                 todos=todos,
507                 sort=sort_order,
508                 hide_done=self.hide_done)
509
510     def show_calendar(self, start_date_str, end_date_str):
511         self.t_filter_and = ['calendar']
512         self.t_filter_not = ['deleted']
513         self.set_visibilities()
514         days_to_show = {}
515         todays_date_str = today_date() # str(datetime.now())[:10]
516         todays_date_obj = datetime.strptime(todays_date_str, DATE_FORMAT) 
517         yesterdays_date_obj = todays_date_obj - timedelta(1)
518         yesterdays_date_str = yesterdays_date_obj.strftime(DATE_FORMAT) 
519         start_date_obj = datetime.strptime(sorted(self.days.keys())[0], DATE_FORMAT)
520         if start_date_str and len(start_date_str) > 0:
521             if start_date_str in {'today', 'yesterday'}:
522                 start_date_obj = todays_date_obj if start_date_str == 'today' else yesterdays_date_obj
523             else:
524                 start_date_obj = datetime.strptime(start_date_str, DATE_FORMAT)
525         end_date_obj = datetime.strptime(sorted(self.days.keys())[-1], DATE_FORMAT)
526         if end_date_str and len(end_date_str) > 0:
527             if end_date_str in {'today', 'yesterday'}:
528                 end_date_obj = todays_date_obj if end_date_str == 'today' else yesterdays_date_obj
529             else:
530                 end_date_obj = datetime.strptime(end_date_str, DATE_FORMAT)
531         for n in range(int((end_date_obj - start_date_obj).days) + 1):
532             current_date_obj = start_date_obj + timedelta(n)
533             current_date_str = current_date_obj.strftime(DATE_FORMAT)
534             if current_date_str not in self.days.keys():
535                 days_to_show[current_date_str] = self.add_day(current_date_str)
536             else:
537                 days_to_show[current_date_str] = self.days[current_date_str]
538             days_to_show[current_date_str].weekday = datetime.strptime(current_date_str, DATE_FORMAT).strftime('%A')[:2]
539         return j2env.get_template('calendar.html').render(
540                 db=self,
541                 days=days_to_show,
542                 start_date=start_date_str,
543                 end_date=end_date_str)
544
545     def show_todo(self, id_, return_to):
546         todo = self.todos[id_]
547         filtered_todos = [] #[t for t in self.todos.values() if t.visible and t != self and (t not in linked_todos) and (len(search) == 0 or t.matches(search))] 
548         return j2env.get_template('todo.html').render(
549                 db=self,
550                 todo=todo,
551                 filtered_todos=filtered_todos,
552                 child_todos=todo.children,
553                 return_to=return_to)
554
555     def collect_tags(self, tags_joined, tags_checked):
556         tags = set()
557         for tag in [tag.strip() for tag in tags_joined.split(';') if tag.strip() != '']:
558             tags.add(tag)
559         for tag in tags_checked:
560             tags.add(tag)
561         return tags
562
563     def update_todo_for_day(self, id_, date, effort, done, comment, importance):
564         todo = self.todos[id_]
565         todo.done = done
566         todo.efforts[date] = effort 
567         todo.comment = comment 
568         todo.importance = importance 
569
570     def update_todo(self, id_, efforts, done, comment, day_tags_joined, day_tags_checked, importance):
571         todo = self.todos[id_]
572         if len(efforts) == 0 and not todo.children:
573             raise PlomException('todo must have at least one effort!')
574         todo.done = done
575         todo.efforts = efforts 
576         for date in todo.efforts:
577             if not date in self.days.keys():
578                 self.add_day(date=date) 
579             if not self in self.days[date].linked_todos_as_list:
580                 self.days[date].linked_todos_as_list += [todo]
581         todo.comment = comment 
582         todo.day_tags = self.collect_tags(day_tags_joined, day_tags_checked) 
583         todo.importance = importance 
584
585     def delete_todo(self, id_):
586         todo = self.todos[id_]
587         dates_to_delete = []
588         for date in todo.efforts.keys():
589             dates_to_delete += [date]
590         for date in dates_to_delete:
591             self.delete_effort(todo, date, force=True)
592         if todo.parent:
593             todo.parent.child_ids.remove(todo.id_)
594         del self.todos[id_]
595
596     def delete_effort(self, todo, date, force=False):
597         if (not force) and len(todo.efforts) == 1:
598             raise PlomException('todo must retain at least one effort!')
599         self.days[date].linked_todos_as_list.remove(todo)
600         del todo.efforts[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() if t.visible and t != self and (t not in task.subtasks) and (len(search) == 0 or t.matches(search))] 
606         return j2env.get_template('task.html').render(
607                 db=self,
608                 search=search,
609                 tags=self.t_tags, 
610                 filter_and=self.t_filter_and,
611                 filter_not=self.t_filter_not,
612                 filtered_tasks=filtered_tasks,
613                 task=task,
614                 return_to=return_to)
615
616     def update_task(self, id_, title, default_effort, tags_joined, tags_checked, subtask_ids, comment):
617         task = self.tasks[id_] if id_ in self.tasks.keys() else self.add_task(id_)
618         task.title.set(title)
619         task.default_effort.set(float(default_effort) if len(default_effort) > 0 else None)
620         task.tags.set(self.collect_tags(tags_joined, tags_checked)) 
621         task.subtask_ids.set(subtask_ids)
622         task.comment = comment 
623
624     def show_tasks(self, expand_uuid):
625         expanded_tasks = {}
626         if expand_uuid:
627             for uuid in self.tasks[expand_uuid].subtask_ids.now:
628                 expanded_tasks[uuid] = self.tasks[uuid]
629         return j2env.get_template('tasks.html').render(
630                 db=self,
631                 filter_and=self.t_filter_and,
632                 filter_not=self.t_filter_not,
633                 expand_uuid=expand_uuid,
634                 expanded_tasks=expanded_tasks)
635
636     def show_pick_tasks(self, search, hide_chosen_tasks, sort_order=None):
637         prev_date_str, next_date_str = self.neighbor_dates()
638         chosen_todos = self.selected_day.linked_todos_as_list
639         relevant_todos = []
640         for todo in self.todos.values():
641             if todo.done or (not todo.visible) or (not todo.matches(search)) or todo.earliest_date >= self.selected_date:
642                 continue
643             relevant_todos += [todo] 
644         tasks = []
645         chosen_tasks = [todo.task for todo in self.selected_day.linked_todos_as_list]
646         for uuid, task in self.tasks.items():
647             if (not task.visible) or (not task.matches(search)) or (hide_chosen_tasks and task in chosen_tasks):
648                 continue
649             tasks += [task]
650         if sort_order == 'title':
651             chosen_todos.sort(key=lambda t: t.title)
652             relevant_todos.sort(key=lambda t: t.title)
653             tasks.sort(key=lambda t: t.title.then)
654         elif sort_order == 'effort':
655             chosen_todos.sort(key=lambda t: t.day_effort if t.day_effort else t.default_effort if t.done else 0, reverse=True)
656             relevant_todos.sort(key=lambda t: t.all_days_effort, reverse=True)
657             tasks.sort(key=lambda t: t.default_effort.then, reverse=True)
658         return j2env.get_template('pick_tasks.html').render(
659                 sort=sort_order,
660                 tags=self.t_tags,
661                 chosen_todos=chosen_todos,
662                 filter_and=self.t_filter_and,
663                 filter_not=self.t_filter_not,
664                 day=self.selected_day,
665                 prev_date=prev_date_str,
666                 next_date=next_date_str,
667                 tasks=tasks,
668                 hide_chosen_tasks=hide_chosen_tasks,
669                 relevant_todos=relevant_todos,
670                 search=search)
671
672
673 class ParamsParser:
674
675     def __init__(self, parsed_url_query, cookie_db):
676         self.params = parse_qs(parsed_url_query)
677         self.cookie_db = cookie_db
678
679     def get(self, key, default=None):
680         boolean = bool == type(default) 
681         param = self.params.get(key, [default])[0]
682         if boolean:
683             param = param != '0' 
684         return param 
685
686     def get_cookied(self, key, default=None):
687         param = self.get(key, default)
688         if param == '-':
689             param = None
690             if key in self.cookie_db.keys():
691                 del self.cookie_db[key]
692         if param is None and key in self.cookie_db.keys():
693             param = self.cookie_db[key]
694         if param is not None:
695             self.cookie_db[key] = param
696         return param
697
698     def get_cookied_chain(self, key, default=None):
699         # default = default if default else ['-']
700         params = self.params.get(key, default)
701         if params == ['-']:
702             params = None 
703             if key in self.cookie_db.keys():
704                 del self.cookie_db[key]
705         if params is None and key in self.cookie_db.keys():
706             params = self.cookie_db[key]
707         if params is not None:
708             self.cookie_db[key] = params
709         return params
710
711
712 class TodoHandler(PlomHandler):
713
714     def config_init(self):
715         return {
716             'cookie_name': 'todo_cookie',
717             'prefix': '',
718             'cookie_path': '/'
719         }
720
721     def app_init(self, handler):
722         default_path = '/todo'
723         handler.add_route('GET', default_path, self.show_db)
724         handler.add_route('POST', default_path, self.write_db)
725         return 'todo', {'cookie_name': 'todo_cookie', 'prefix': default_path, 'cookie_path': default_path}
726
727     def do_POST(self):
728         self.try_do(self.config_init)
729         self.try_do(self.write_db)
730
731     def write_db(self):
732         from urllib.parse import urlencode
733         config = self.apps['todo'] if hasattr(self, 'apps') else self.config_init()
734         length = int(self.headers['content-length'])
735         postvars = parse_qs(self.rfile.read(length).decode(), keep_blank_values=1)
736         parsed_url = urlparse(self.path)
737         site = path_split(urlparse(self.path).path)[1]
738         db = TodoDB(prefix=config['prefix'])
739         redir_params = []
740         for param_name, filter_db_name in {('t_and', 't_filter_and'), ('t_not', 't_filter_not')}:
741             filter_db = getattr(db, filter_db_name)
742             if param_name in postvars.keys():
743                 for target in postvars[param_name]:
744                     if len(target) > 0 and not target in filter_db:
745                         filter_db += [target]
746                 if len(filter_db) == 0:
747                     redir_params += [(param_name, '-')]
748             redir_params += [(param_name, f) for f in filter_db]
749
750         def collect_checked(prefix, postvars):
751             tags_checked = []
752             for k in postvars.keys():
753                 if k.startswith(prefix):
754                     tags_checked += [k[len(prefix):]]
755             return tags_checked
756         
757         if 'calendar' == site:
758             redir_params += [('start', postvars['start'][0] if len(postvars['start'][0]) > 0 else '-')]
759             redir_params += [('end', postvars['end'][0] if len(postvars['end'][0]) > 0 else '-')]
760
761         elif 'todo' == site:
762             todo_id = postvars['todo_id'][0]
763             old_todo = db.todos[todo_id] 
764             efforts = {}
765             for i, date in enumerate(postvars['effort_date']):
766                 if '' == date:
767                     continue
768                 try:
769                     datetime.strptime(date, DATE_FORMAT)
770                 except ValueError:
771                     raise PlomException(f'bad date string')
772                 efforts[date] = None
773                 if not old_todo.children:
774                     efforts[date] = float(postvars['effort'][i]) if len(postvars['effort'][i]) > 0 else None 
775             if 'delete' in postvars.keys():
776                 has_day_effort = False 
777                 for effort in efforts.values():
778                     if effort is not None:
779                         has_day_effort = True 
780                         break
781                 if 'done' in postvars or postvars['comment'][0] != '' or len(collect_checked('day_tag_', postvars)) > 0 or postvars['joined_day_tags'][0] != '' or has_day_effort:
782                     raise PlomException('will not remove todo of preserve-worthy values')
783                 db.delete_todo(todo_id) 
784             else:
785                 redir_params += [('id', todo_id)]
786                 if 'delete_effort' in postvars.keys():
787                     for date in postvars['delete_effort']:
788                         db.delete_effort(old_todo, date)
789                         del efforts[date]
790                 db.update_todo(id_=todo_id,
791                                efforts=efforts,
792                                done='done' in postvars.keys(),
793                                comment=postvars['comment'][0],
794                                day_tags_joined=postvars['joined_day_tags'][0],
795                                day_tags_checked=collect_checked('day_tag_', postvars),
796                                importance=float(postvars['importance'][0]))
797
798         elif 'task' == site:
799             id_ = postvars['id'][0]
800             if 'filter' in postvars.keys():
801                 redir_params += [('search', postvars['search'][0])]
802             elif 'title' in postvars.keys():
803                 subtask_ids = [] if not 'subtask' in postvars.keys() else postvars['subtask']
804                 default_effort = '0' if not 'default_effort' in postvars.keys() else postvars['default_effort'][0]
805                 db.update_task(id_, postvars['title'][0], default_effort, postvars['joined_tags'][0], collect_checked('tag_', postvars), subtask_ids, postvars['comment'][0])
806             redir_params += [('id', id_)]
807
808         elif 'pick_tasks' == site:
809             if 'filter' in postvars.keys():
810                 redir_params += [('search', postvars['search'][0])]
811                 redir_params += [('hide_chosen_tasks', int('hide_chosen_tasks' in postvars.keys()))]
812             else:
813                 db.selected_date = postvars['date'][0]
814                 todos_to_shrink = []
815                 todos_to_delete = []
816                 for todo in db.selected_day.linked_todos_as_list:
817                     if todo.visible and not ('chosen_todo' in postvars.keys() and todo.id_ in postvars['chosen_todo']):
818                         if len(todo.comment) > 0 or len(todo.day_tags) > 0 or not todo.is_effort_removable(db.selected_date):
819                             # print("DEBUG", len(todo.comment) > 0, len(todo.day_tags) > 0, todo.is_effort_removable(db.selected_date))
820                             raise PlomException('will not remove effort of preserve-worthy values')
821                         if len(todo.efforts) > 1:
822                             todos_to_shrink += [todo]
823                             # db.delete_effort(todo, db.selected_date)
824                         else:
825                             todos_to_delete += [todo]
826                             # db.delete_todo(todo.id_)
827                 for todo in todos_to_shrink:
828                     db.delete_effort(todo, db.selected_date)
829                 for todo in todos_to_delete:
830                     db.delete_todo(todo.id_)
831                 if 'choose_task' in postvars.keys():
832                     for id_ in postvars['choose_task']:
833                         db.add_todo(task=db.tasks[id_], efforts={db.selected_date: None})
834                 if 'choose_todo' in postvars.keys():
835                     for id_ in postvars['choose_todo']:
836                         todo = db.todos[id_]
837                         todo.efforts[db.selected_date] = None
838
839         elif 'do_todos' == site:
840             if 'filter' in postvars.keys():
841                 redir_params += [('hide_done', int('hide_done' in postvars.keys()))]
842             else:
843                 db.selected_date = postvars['date'][0]
844                 redir_params += [('date', db.selected_date)]
845                 db.selected_day.comment = postvars['day_comment'][0]
846                 if 'todo_id' in postvars.keys():
847                     for i, todo_id in enumerate(postvars['todo_id']):
848                         old_todo = None if not todo_id in db.todos.keys() else db.todos[todo_id]
849                         done = ('done' in postvars) and (todo_id in postvars['done'])
850                         day_effort_input = postvars['effort'][i]
851                         day_effort = float(day_effort_input) if len(day_effort_input) > 0 else None
852                         comment = postvars['effort_comment'][i]
853                         importance = float(postvars['importance'][i])
854                         if old_todo and old_todo.done == done and old_todo.day_effort == day_effort and comment == old_todo.comment and old_todo.importance == importance:
855                             continue
856                         db.update_todo_for_day(todo_id, db.selected_date, day_effort, done, comment, importance)
857
858         elif 'day' == site:
859             # always store the two hide params in the URL if possible … TODO: find out if really necessary
860             if 'expect_unchosen_done' in postvars.keys():
861                 redir_params += [('hide_unchosen', int('hide_unchosen' in postvars.keys()))] + [('hide_done', int('hide_done' in postvars.keys()))]
862
863             if 'date' in postvars.keys():
864                 db.selected_date = postvars['date'][0]
865                 if 'day_comment' in postvars.keys():
866                     db.selected_day.comment = postvars['day_comment'][0]
867                 redir_params += [('date', db.selected_date)]
868
869                 # handle todo list updates via task UUIDs
870                 if 't_uuid' in postvars.keys():
871                     for i, uuid in enumerate(postvars['t_uuid']):
872                         task = db.tasks[uuid]
873                         old_todo = None if not uuid in db.selected_day.todos.keys() else db.selected_day.todos[uuid]
874                         selects_as_todo = 'choose' in postvars and uuid in postvars['choose']
875                         too_much_keepworthy_data = ('done' in postvars and uuid in postvars['done']) or postvars['day_effort'][i] != '' or (old_todo and not old_todo.internals_empty())
876                         if old_todo and too_much_keepworthy_data and not selects_as_todo:
877                             raise PlomException('cannot deselect task as todo of preserve-worthy values')
878                         elif old_todo and not selects_as_todo:
879                             del db.selected_day.todos[uuid]
880                         elif too_much_keepworthy_data or selects_as_todo:
881                             done = ('done' in postvars) and (uuid in postvars['done'])
882                             day_effort_input = postvars['day_effort'][i]
883                             day_effort = float(day_effort_input) if len(day_effort_input) > 0 else None
884                             importance = float(postvars['importance'][i])
885                             if old_todo and old_todo.done == done and old_todo.day_effort == day_effort and old_todo.importance == importance:
886                                 continue
887                             db.update_todo_mini(uuid, db.selected_date, day_effort, done, importance)
888
889         if 'return_to' in postvars.keys() and len(postvars['return_to'][0]) > 0:
890             homepage = postvars['return_to'][0]
891         else:
892             encoded_params = urlencode(redir_params)
893             homepage = f'{site}?{encoded_params}'
894         db.write()
895         self.redirect(homepage)
896
897     def do_GET(self):
898         self.try_do(self.config_init)
899         self.try_do(self.show_db)
900
901     def show_db(self):
902         config = self.apps['todo'] if hasattr(self, 'apps') else self.config_init()
903         cookie_db = self.get_cookie_db(config['cookie_name'])
904         parsed_url = urlparse(self.path)
905         site = path_split(parsed_url.path)[1]
906
907         params = ParamsParser(parsed_url.query, cookie_db)
908         selected_date = t_filter_and = t_filter_not = None
909         hide_unchosen = hide_done = False
910         return_to = params.get('return_to', '')
911         if site in {'do_todos', 'pick_tasks'}:
912             selected_date = params.get_cookied('date')
913         if site in {'do_todos', 'pick_tasks', 'task', 'tasks'}:
914             t_filter_and = params.get_cookied_chain('t_and')
915             t_filter_not = params.get_cookied_chain('t_not')
916         if 'do_todos' == site:
917             hide_done = params.get('hide_done', False) 
918         db = TodoDB(config['prefix'], selected_date, t_filter_and, t_filter_not, hide_unchosen, hide_done)
919         if 'do_todos' == site:
920             sort_order = params.get_cookied('sort')
921             page = db.show_do_todos(sort_order)
922         elif 'pick_tasks' == site:
923             sort_order = params.get_cookied('sort')
924             hide_chosen_tasks = params.get('hide_chosen_tasks', False)
925             search = params.get('search', '')
926             page = db.show_pick_tasks(search, hide_chosen_tasks, sort_order)
927         elif site == 'todo':
928             todo_id = params.get('id')
929             page = db.show_todo(todo_id, return_to)
930         elif 'task' == site:
931             id_ = params.get('id')
932             search = params.get('search', '')
933             page = db.show_task(id_, return_to, search)
934         elif 'tasks' == site:
935             expand_uuid = params.get('expand_uuid')
936             page = db.show_tasks(expand_uuid)
937         elif 'add_task' == site:
938             page = db.show_task(None)
939         elif 'unset_cookie' == site:
940             page = 'no cookie to unset.'
941             if len(cookie_db) > 0:
942                 self.unset_cookie(config['cookie_name'], config['cookie_path'])
943                 page = 'cookie unset!'
944         else:
945             start_date = params.get_cookied('start')
946             end_date = params.get_cookied('end')
947             page = db.show_calendar(start_date, end_date)
948
949         if 'unset_cookie' != site:
950             self.set_cookie(config['cookie_name'], config['cookie_path'], cookie_db)
951         self.send_HTML(page)
952
953
954 if __name__ == "__main__":
955     run_server(server_port, TodoHandler)