From ecee822bebf62049803b90fc8d8a0b484915a0fc Mon Sep 17 00:00:00 2001 From: Christian Heller Date: Thu, 29 Feb 2024 04:59:47 +0100 Subject: [PATCH] Improve todo accounting. --- todo.py | 302 ++++++++++++++++++++-------- todo_templates/calendar.html | 4 + todo_templates/calendar_export.html | 31 +++ todo_templates/day_todos.html | 6 +- todo_templates/macros.html | 8 + todo_templates/task.html | 32 ++- todo_templates/tasks.html | 13 +- todo_templates/todo.html | 2 +- todo_templates/watch_form.html | 6 +- 9 files changed, 301 insertions(+), 103 deletions(-) create mode 100644 todo_templates/calendar_export.html diff --git a/todo.py b/todo.py index c3551a4..94f3882 100644 --- a/todo.py +++ b/todo.py @@ -21,27 +21,31 @@ def today_date(with_time=False): class AttributeWithHistory: - def __init__(self, default_if_empty, history=None, then_date='2000-01-01', set_check=None): + def __init__(self, parent, name, default_if_empty, history=None, then_date='2000-01-01', set_check=None): + self.parent = parent + self.name = name self.default = default_if_empty self.then_date = then_date self.history = history if history else {} self.set_check = set_check def set(self, value): - print("DEBUG set", value, "HAS SET CHECK?", self.set_check) if self.set_check: - print("DEBUG set CALLED SET CHECK") self.set_check(value) keys = sorted(self.history.keys()) if len(self.history) == 0 or value != self.history[keys[-1]]: self.history[today_date(with_time=True)] = value def at(self, queried_date): - if 0 == len(self.history): + end_of_queried_day = f'{queried_date} 23:59:59' + sorted_dates = sorted(self.history.keys()) + if self.parent.forked_task and (0 == len(sorted_dates) or sorted_dates[0] > end_of_queried_day): + return getattr(self.parent.forked_task, self.name).at(queried_date) + elif 0 == len(sorted_dates): return self.default - ret = self.history[sorted(self.history.keys())[0]] + ret = self.history[sorted_dates[0]] for date_key, item in self.history.items(): - if date_key > f'{queried_date} 23:59:59': + if date_key > end_of_queried_day: break ret = item return ret @@ -49,7 +53,11 @@ class AttributeWithHistory: @property def now(self): keys = sorted(self.history.keys()) - return self.default if 0 == len(self.history) else self.history[keys[-1]] + if 0 == len(self.history): + if self.parent.forked_task: + return getattr(self.parent.forked_task, self.name).now + return self.default + return self.history[keys[-1]] @property def then(self): @@ -102,12 +110,14 @@ class Task(TaskLike): tags_history=None, default_effort_history=None, dep_ids_history=None, - comment=''): + comment='', + forks_id=None): super().__init__(db, id_, comment) - self.title = AttributeWithHistory('', title_history, self.db.selected_date) - self.tags = AttributeWithHistory(set(), tags_history, self.db.selected_date) - self.default_effort = AttributeWithHistory(0.0, default_effort_history, self.db.selected_date) - self.dep_ids = AttributeWithHistory(set(), dep_ids_history, self.db.selected_date, set_check=self.dep_loop_checker()) + self.forks_id = forks_id + self.title = AttributeWithHistory(self, 'title', '', title_history, self.db.selected_date) + self.tags = AttributeWithHistory(self, 'tags', set(), tags_history, self.db.selected_date) + self.default_effort = AttributeWithHistory(self, 'default_effort', 0.0, default_effort_history, self.db.selected_date) + self.dep_ids = AttributeWithHistory(self, 'dep_ids', set(), dep_ids_history, self.db.selected_date, set_check=self.dep_loop_checker()) @classmethod def from_dict(cls, db, d, id_): @@ -118,7 +128,8 @@ class Task(TaskLike): {k: set(v) for k, v in d['tags_history'].items()}, d['default_effort_history'], {k: set(v) for k, v in d['deps_history'].items()}, - d['comment']) + d['comment'], + d['forks']) return t def to_dict(self): @@ -128,8 +139,24 @@ class Task(TaskLike): 'tags_history': {k: list(v) for k, v in self.tags.history.items()}, 'deps_history': {k: list(v) for k, v in self.dep_ids.history.items()}, 'comment': self.comment, + 'forks': self.forks_id, } + # def fork(self): + # now = today_date(with_time=True) + # dict_source = { + # 'title_history': {now: self}, + # 'default_effort_history': {now: self}, + # 'tags_history': {now: self}, + # 'deps_history': {now: self}, + # 'comment': self.comment, + # 'forks': self.id_} + # return self.db.add_task(dict_source=dict_source) + + @property + def forked_task(self): + return self.db.tasks[self.forks_id] if self.forks_id else None + @property def deps(self): deps = [] @@ -167,6 +194,15 @@ class Task(TaskLike): else: return search in self.title.now or search in self.comment or search in '$'.join(self.tags.now) + @property + def latest_effort_date(self): + todos = [t for t in self.db.todos.values() if t.task.id_ == self.id_] + todos.sort(key=lambda t: t.latest_date) + if len(todos) > 0: + return todos[-1].latest_date + else: + return '' + class Day: @@ -234,6 +270,10 @@ class Day: todos = trunk return todos + @property + def visible_in_export(self): + return len(self.comment) + len([t for t in self.linked_todos_as_list if t.visible]) > 0 + class Todo(TaskLike): @@ -251,6 +291,11 @@ class Todo(TaskLike): super().__init__(db, id_, comment) self.task = task self._done = done + for k in efforts.keys(): + try: + datetime.strptime(k, DATE_FORMAT) + except ValueError: + raise PlomException(f'Todo creation: bad date string: {k}') self.efforts = efforts if efforts else {} self.day_tags = day_tags if day_tags else set() self.importance = importance @@ -475,7 +520,7 @@ class TodoDB(PlomDB): dep.dependers += [task] for id_, todo_dict in d['todos'].items(): - todo = self.add_todo(todo_dict, id_, link_into_day=False) + todo = self.add_todo(todo_dict, id_) for tag in todo.day_tags: self.t_tags.add(tag) for todo in self.todos.values(): @@ -532,41 +577,60 @@ class TodoDB(PlomDB): self.tasks[id_] = t return t - def update_task(self, id_, title, default_effort, tags, dep_ids, comment): + def fork_task(self, id_): + origin = self.tasks[id_] + now = today_date(with_time=True) + fork_id = str(uuid4()) + fork = Task(self, fork_id, {}, {}, {}, {}, origin.comment, id_) + self.tasks[fork_id] = fork + return fork + + def update_task(self, id_, title, default_effort, tags, comment, dep_ids, depender_ids): task = self.tasks[id_] if id_ in self.tasks.keys() else self.add_task(id_) task.title.set(title) task.default_effort.set(default_effort) task.tags.set(tags) task.dep_ids.set(dep_ids) task.comment = comment + for depender in task.dependers: + if not depender.id_ in depender_ids: + depender_dep_ids = depender.dep_ids.now + depender_dep_ids.remove(task.id_) + depender.dep_ids.set(depender_dep_ids) + for depender_id in depender_ids: + depender= self.tasks[depender_id] + depender_dep_ids = depender.dep_ids.now + depender_dep_ids.add(task.id_) + depender.dep_ids.set(depender_dep_ids) return task - def add_todo(self, todo_dict=None, id_=None, task=None, efforts=None, link_into_day=True, adopt_if_possible=True): + def add_todo(self, todo_dict=None, id_=None, task=None, efforts=None, parenthood=''): + make_children = parenthood != 'childless' + adopt_if_possible = parenthood == 'adoption' id_ = id_ if id_ else str(uuid4()) if todo_dict: todo = Todo.from_dict(self, todo_dict, id_) elif task and efforts: todo = Todo(self, id_, task, efforts=efforts) deps = [] - for dep_task in task.deps: - # if Todo expects dependencies, adopt any compatibles found in DB.selected_date - # before creating new depended Todos - dep_todo = None - if adopt_if_possible and list(efforts.keys())[0] == self.selected_date: - adoptable_todos = [t for t in self.selected_day.linked_todos_as_list - if t.task.id_ == dep_task.id_] - if len(adoptable_todos) > 0: - dep_todo = adoptable_todos[0] - if not dep_todo: - dep_todo = self.add_todo(task=dep_task, efforts=efforts, - link_into_day=link_into_day, - adopt_if_possible=adopt_if_possible) - deps += [dep_todo] - # todo.dep_ids = [dep.id_ for dep in deps] + if make_children and todo.latest_date in self.days.keys(): + for dep_task in task.deps: + # if Todo expects dependencies, adopt any compatibles found in DB.selected_date + # before creating new depended Todos + dep_todo = None + if adopt_if_possible: + adoptable_todos = [t for t in self.days[todo.latest_date].linked_todos_as_list + if t.task.id_ == dep_task.id_] + if len(adoptable_todos) > 0: + dep_todo = adoptable_todos[0] + if not dep_todo: + dep_todo = self.add_todo(task=dep_task, efforts=efforts, parenthood=parenthood) + deps += [dep_todo] todo.deps = deps self.todos[id_] = todo - if link_into_day: - self.selected_day.linked_todos_as_list += [todo] + for date in todo.efforts.keys(): + if date in self.days.keys(): # possibly not all Days have been initialized yet + self.days[date].linked_todos_as_list += [todo] return todo def _update_todo_shared(self, id_, done, comment, importance): @@ -618,41 +682,46 @@ class TodoDB(PlomDB): def show_message(self, message): return j2env.get_template('message.html').render(message=message) - def show_calendar(self, start_date_str, end_date_str): - self.tag_filter_and = ['calendar'] - self.tag_filter_not = ['deleted'] - - todays_date_obj = datetime.strptime(today_date(), DATE_FORMAT) - yesterdays_date_obj = todays_date_obj - timedelta(1) - def get_day_limit_obj(index, day_limit_string): - date_obj = datetime.strptime(sorted(self.days.keys())[index], DATE_FORMAT) - if day_limit_string and len(day_limit_string) > 0: - if day_limit_string in {'today', 'yesterday'}: - date_obj = todays_date_obj if day_limit_string == 'today' else yesterdays_date_obj - else: - date_obj = datetime.strptime(day_limit_string, DATE_FORMAT) - return date_obj - start_date_obj = get_day_limit_obj(0, start_date_str) - end_date_obj = get_day_limit_obj(-1, end_date_str) - - days_to_show = {} - for n in range(int((end_date_obj - start_date_obj).days) + 1): - date_obj = start_date_obj + timedelta(n) - date_str = date_obj.strftime(DATE_FORMAT) - if date_str not in self.days.keys(): - days_to_show[date_str] = self.add_day(date_str) - else: - days_to_show[date_str] = self.days[date_str] - days_to_show[date_str].month_title = date_obj.strftime('%B') if date_obj.day == 1 else None - days_to_show[date_str].weekday = datetime.strptime(date_str, DATE_FORMAT).strftime('%A')[:2] + def show_calendar_export(self, start_date_str, end_date_str): + days_to_show = self.init_calendar_items(start_date_str, end_date_str) + return j2env.get_template('calendar_export.html').render(days=days_to_show) + def show_calendar(self, start_date_str, end_date_str): + # self.tag_filter_and = ['calendar'] + # self.tag_filter_not = ['deleted'] + + # todays_date_obj = datetime.strptime(today_date(), DATE_FORMAT) + # yesterdays_date_obj = todays_date_obj - timedelta(1) + # def get_day_limit_obj(index, day_limit_string): + # date_obj = datetime.strptime(sorted(self.days.keys())[index], DATE_FORMAT) + # if day_limit_string and len(day_limit_string) > 0: + # if day_limit_string in {'today', 'yesterday'}: + # date_obj = todays_date_obj if day_limit_string == 'today' else yesterdays_date_obj + # else: + # date_obj = datetime.strptime(day_limit_string, DATE_FORMAT) + # return date_obj + # start_date_obj = get_day_limit_obj(0, start_date_str) + # end_date_obj = get_day_limit_obj(-1, end_date_str) + + # days_to_show = {} + # for n in range(int((end_date_obj - start_date_obj).days) + 1): + # date_obj = start_date_obj + timedelta(n) + # date_str = date_obj.strftime(DATE_FORMAT) + # if date_str not in self.days.keys(): + # days_to_show[date_str] = self.add_day(date_str) + # else: + # days_to_show[date_str] = self.days[date_str] + # days_to_show[date_str].month_title = date_obj.strftime('%B') if date_obj.day == 1 else None + # days_to_show[date_str].weekday = datetime.strptime(date_str, DATE_FORMAT).strftime('%A')[:2] + + days_to_show = self.init_calendar_items(start_date_str, end_date_str) return j2env.get_template('calendar.html').render( selected_date=self.selected_date, days=days_to_show, start_date=start_date_str, end_date=end_date_str) - def show_day_todos(self, undone_sort_order=None, done_sort_order=None, is_tree_shaped=False): + def show_day_todos(self, undone_sort_order=None, done_sort_order=None, is_tree_shaped=False, todo_parenthood=None): legal_undone_sort_keys = {'title', 'sort_done', 'default_effort', 'importance'} legal_done_sort_keys = {'title', 'effort_at_selected_date', 'family_effort'} @@ -686,9 +755,10 @@ class TodoDB(PlomDB): done_todos=done_todos, is_tree_shaped=is_tree_shaped, undone_sort=undone_sort_order, - done_sort=done_sort_order) + done_sort=done_sort_order, + parenthood=todo_parenthood) - def show_todo(self, id_): + def show_todo(self, id_, parenthood): todo = self.todos[id_] filtered_tasks = [t for t in self.tasks.values() if t != todo.task] @@ -723,10 +793,16 @@ class TodoDB(PlomDB): dep_slots=dep_slots, suggested_todos=suggested_todos, additional_deps=additional_deps, + parentood=parenthood, dep_todos=todo.deps) def show_task(self, id_): - task = self.tasks[id_] if id_ else self.add_task() + if id_: + if not id_ in self.tasks.keys(): + raise PlomException('no Task for ID') + task = self.tasks[id_] + else: + task = self.add_task() if not id_: task.default_effort.set(1.0) filtered_tasks = [t for t in self.tasks.values() @@ -754,6 +830,8 @@ class TodoDB(PlomDB): filtered_tasks.sort(key=lambda t: t.default_effort.now) elif sort_column == 'weight': filtered_tasks.sort(key=lambda t: t.deps_weight) + elif sort_column == 'latest_effort_date': + filtered_tasks.sort(key=lambda t: t.latest_effort_date) if reverse: filtered_tasks.reverse() return j2env.get_template('tasks.html').render( @@ -764,6 +842,37 @@ class TodoDB(PlomDB): filter_not=self.tag_filter_not, search=search) + # helpers + + def init_calendar_items(self, start_date_str, end_date_str): + self.tag_filter_and = ['calendar'] + self.tag_filter_not = ['deleted'] + + todays_date_obj = datetime.strptime(today_date(), DATE_FORMAT) + yesterdays_date_obj = todays_date_obj - timedelta(1) + def get_day_limit_obj(index, day_limit_string): + date_obj = datetime.strptime(sorted(self.days.keys())[index], DATE_FORMAT) + if day_limit_string and len(day_limit_string) > 0: + if day_limit_string in {'today', 'yesterday'}: + date_obj = todays_date_obj if day_limit_string == 'today' else yesterdays_date_obj + else: + date_obj = datetime.strptime(day_limit_string, DATE_FORMAT) + return date_obj + start_date_obj = get_day_limit_obj(0, start_date_str) + end_date_obj = get_day_limit_obj(-1, end_date_str) + + days_to_show = {} + for n in range(int((end_date_obj - start_date_obj).days) + 1): + date_obj = start_date_obj + timedelta(n) + date_str = date_obj.strftime(DATE_FORMAT) + if date_str not in self.days.keys(): + days_to_show[date_str] = self.add_day(date_str) + else: + days_to_show[date_str] = self.days[date_str] + days_to_show[date_str].month_title = date_obj.strftime('%B') if date_obj.day == 1 else None + days_to_show[date_str].weekday = datetime.strptime(date_str, DATE_FORMAT).strftime('%A')[:2] + return days_to_show + class ParamsParser: @@ -883,8 +992,9 @@ class TodoHandler(PlomHandler): if site in {'calendar', 'todo'}: redir_params += [('end', postvars.get('end', '-'))] redir_params += [('start', postvars.get('start', '-'))] - # if site in {'tasks', 'pick_tasks'}: - # redir_params += [('search', postvars.get('search', ''))] + if site in {'day_todos', 'todo'}: + todo_parenthood = postvars.get('parenthood') + redir_params += [('parenthood', todo_parenthood)] if postvars.has('filter'): postvars.set('return_to', '') @@ -900,10 +1010,6 @@ class TodoHandler(PlomHandler): for i, date in enumerate(postvars.get_all('effort_date', [])): if '' == date: continue - try: - datetime.strptime(date, DATE_FORMAT) - except ValueError: - raise PlomException(f'bad date string') latest_date = date efforts[date] = None if not (old_todo and old_todo.deps): @@ -928,9 +1034,12 @@ class TodoHandler(PlomHandler): for dep in deps: if not todo_id in [t.id_ for t in dep.dependers]: dep.dependers += [db.todos[todo_id]] - tasks_to_birth = [db.tasks[id_] for id_ in postvars.get_all('birth_dep', [])] + birth_dep_ids = postvars.get_all('birth_dep', []) + for id_ in [id_ for id_ in birth_dep_ids if not id_ in db.tasks.keys()]: + raise PlomException('submitted illegal dep ID') + tasks_to_birth = [db.tasks[id_] for id_ in birth_dep_ids] for task in tasks_to_birth: - deps += [db.add_todo(task=task, efforts={latest_date: None})] + deps += [db.add_todo(task=task, efforts={latest_date: None}, parenthood=todo_parenthood)] db.update_todo(id_=todo_id, efforts=efforts, done=postvars.has('done'), @@ -941,7 +1050,10 @@ class TodoHandler(PlomHandler): elif 'task' == site: task_id = postvars.get('task_id') - if postvars.has('delete') and task_id in db.tasks.keys(): + if (postvars.has('delete') or postvars.has('fork')) and (not task_id in db.tasks.keys()): + if not task_id in db.tasks.keys(): + raise PlomException('can only do this on Task that already exists') + if postvars.has('delete'): if [t for t in db.todos.values() if task_id == t.task.id_]: raise PlomException('will not remove Task describing existing Todos') if postvars.get('title', '')\ @@ -954,15 +1066,40 @@ class TodoHandler(PlomHandler): dep_ids = postvars.get_all('dep', []) for id_ in [id_ for id_ in dep_ids if not id_ in db.tasks.keys()]: raise PlomException('submitted illegal dep ID') + depender_ids = postvars.get_all('depender', []) + for id_ in [id_ for id_ in depender_ids if not id_ in db.tasks.keys()]: + raise PlomException('submitted illegal dep ID') task = db.update_task( id_=task_id, title=postvars.get('title', ''), default_effort=postvars.get('default_effort', float_if_possible=True), tags=postvars.get_all('tag', []), + comment=postvars.get('comment', ''), dep_ids=dep_ids, - comment=postvars.get('comment', '')) + depender_ids=depender_ids) if postvars.has('add_as_todo'): - db.add_todo(task=task, efforts={postvars.get('selected_date'): None}) + db.add_todo(task=task, efforts={postvars.get('new_todo_date'): None}) + elif postvars.has('fork'): + t = db.fork_task(task_id) + task_id = t.id_ + + # dep_ids = postvars.get_all('dep', []) + # for id_ in [id_ for id_ in dep_ids if not id_ in db.tasks.keys()]: + # raise PlomException('submitted illegal dep ID') + # depender_ids = postvars.get_all('depender', []) + # for id_ in [id_ for id_ in depender_ids if not id_ in db.tasks.keys()]: + # raise PlomException('submitted illegal dep ID') + # task = db.update_task( + # id_=task_id, + # title=postvars.get('title', ''), + # default_effort=postvars.get('default_effort', float_if_possible=True), + # tags=postvars.get_all('tag', []), + # comment=postvars.get('comment', ''), + # dep_ids=dep_ids, + # depender_ids=depender_ids) + # if postvars.has('add_as_todo'): + # db.add_todo(task=task, efforts={postvars.get('new_todo_date'): None}) + redir_params += [('id', task_id)] elif 'day_todos' == site: @@ -975,7 +1112,7 @@ class TodoHandler(PlomHandler): if task_id not in db.tasks.keys(): raise PlomException('illegal task ID entered') db.add_todo(task=db.tasks[task_id], efforts={db.selected_date: None}, - adopt_if_possible=(not postvars.has('dont_adopt'))) + parenthood=todo_parenthood) for id_ in postvars.get_all('choose_todo', []): db.todos[id_].efforts[db.selected_date] = None for i, todo_id in enumerate(postvars.get_all('todo_id', [])): @@ -1028,6 +1165,11 @@ class TodoHandler(PlomHandler): if site in {'day_todos', 'tasks'}: tag_filter_and = params.get_cookied_chain('and_tag', prefix=site) tag_filter_not = params.get_cookied_chain('not_tag', prefix=site) + if site in {'day_todos', 'todo'}: + todo_parenthood = params.get_cookied('parenthood', prefix=site) + if site in {'calendar', 'calendar_export', ''}: + start_date = params.get_cookied('start', prefix=site) + end_date = params.get_cookied('end', prefix=site) db = TodoDB(selected_date, tag_filter_and, tag_filter_not) if site in {'todo', 'task'}: id_ = params.get('id') @@ -1043,9 +1185,9 @@ class TodoHandler(PlomHandler): is_tree_shaped = params.get_cookied('tree', prefix=site, as_bool=True) undone_sort_order = params.get_cookied('undone_sort', prefix=site) done_sort_order = params.get_cookied('done_sort', prefix=site) - page = db.show_day_todos(undone_sort_order, done_sort_order, is_tree_shaped) + page = db.show_day_todos(undone_sort_order, done_sort_order, is_tree_shaped, todo_parenthood) elif site == 'todo': - page = db.show_todo(id_) + page = db.show_todo(id_, parenthood=todo_parenthood) elif 'task' == site: page = db.show_task(id_) elif 'tasks' == site: @@ -1054,9 +1196,9 @@ class TodoHandler(PlomHandler): page = db.show_tasks(search, sort_order) elif 'add_task' == site: page = db.show_task(None) + elif 'calendar_export' == site: + page = db.show_calendar_export(start_date, end_date) else: # 'calendar' == site - start_date = params.get_cookied('start', prefix=site) - end_date = params.get_cookied('end', prefix=site) page = db.show_calendar(start_date, end_date) self.set_cookie(config['cookie_name'], config['cookie_path'], cookie_db) diff --git a/todo_templates/calendar.html b/todo_templates/calendar.html index 343019a..59dd5c5 100644 --- a/todo_templates/calendar.html +++ b/todo_templates/calendar.html @@ -70,6 +70,10 @@ to: +

+exportable +

+ {% endblock %} diff --git a/todo_templates/calendar_export.html b/todo_templates/calendar_export.html new file mode 100644 index 0000000..315b0a0 --- /dev/null +++ b/todo_templates/calendar_export.html @@ -0,0 +1,31 @@ +{% import 'macros.html' as macros %} + + + +{% for date, day in days.items() %} +{% if day.month_title %} + +{% endif %} +{% if day.visible_in_export %} + +{% for todo in day.linked_todos_as_list %} +{% if todo.visible %} + + + + +{% endif %} +{% endfor %} +{% endif %} +{% endfor %} +
### {{ day.month_title }} ###
# {{ date }} ({{ day.weekday }}) {{ day.comment|e }}
+{% if "cancelled" in todo.tags %}{% endif %} +{{ macros.doneness_string(todo, true) }} +{% if "deadline" in todo.tags %}DEADLINE: {% endif %} +{{ todo.title|e }} +{%if "cancelled" in todo.tags%}{% endif %} + +{{ todo.comment|e }} +
+ + diff --git a/todo_templates/day_todos.html b/todo_templates/day_todos.html index 8cf87fb..f2312ee 100644 --- a/todo_templates/day_todos.html +++ b/todo_templates/day_todos.html @@ -85,7 +85,7 @@ depends on: {% macro draw_undone_todo_row(todo, title_drawer, indent_or_doneness) %} -{% if todo.already_listed %} +{% if todo.been_observed %} @@ -165,7 +165,7 @@ comment:

undone

task quick-add: -don't adopt, make new: +{{ macros.parenthood_selector(parenthood) }}

{{ macros.datalist_tasks(all_tasks, with_weight=true) }} @@ -219,6 +219,8 @@ don't adopt, make new: {% include 'watch_form.html' %}