From 631c4f68f1346f56688fa20c36fc351c256c0b28 Mon Sep 17 00:00:00 2001 From: Evilham Date: Thu, 17 Mar 2022 20:26:31 +0100 Subject: [PATCH 1/3] [WIP] Add preliminary support for VTODOs Quick PoC that converts VTODOs to VEVENTs before adding them to the backend, this enables us to not treat tasks as something very special. If this approach is interesting enough, it should be forbidden that khal edits tasks as it is out of its scope. This would fix #448 --- khal/icalendar.py | 41 +++++++++++++++++++++++++++++++++++++++ khal/khalendar/backend.py | 11 ++++++++--- khal/khalendar/event.py | 40 ++++++++++++++++++++++++++++++++++---- khal/settings/khal.spec | 4 ++-- 4 files changed, 87 insertions(+), 9 deletions(-) diff --git a/khal/icalendar.py b/khal/icalendar.py index 7b5a7aefc..9844e14b4 100644 --- a/khal/icalendar.py +++ b/khal/icalendar.py @@ -401,6 +401,47 @@ def sanitize(vevent, default_timezone, href='', calendar=''): return vevent +def sanitize_vtodo(vtodo, default_timezone, href='', calendar=''): + """ + cleanup vtodos so they look like vevents for khal + + :param vtodo: the vtodo that needs to be cleaned + :type vtodo: icalendar.cal.Todo + :param default_timezone: timezone to apply to start and/or end dates which + were supposed to be localized but which timezone was not understood + by icalendar + :type timezone: pytz.timezone + :param href: used for logging to inform user which .ics files are + problematic + :type href: str + :param calendar: used for logging to inform user which .ics files are + problematic + :type calendar: str + :returns: clean vtodo as vevent + :rtype: icalendar.cal.Event + """ + vdtstart = vtodo.pop('DTSTART', None) + vdue = vtodo.pop('DUE', None) + + # it seems to be common for VTODOs to have DUE but no DTSTART + # so we default to that. E.g. NextCloud does something similar + if vdtstart is None and vdue is not None: + vdtstart = vdue + + # Based loosely on new_event + event = icalendar.Event() + event.add('dtstart', vdtstart) + event.add('due', vdue) + # Copy common/necessary attributes + for attr in ['uid', 'summary', 'dtend', 'dtstamp', 'description', + 'location', 'categories', 'url']: + if attr in vtodo: + event.add(attr, vtodo.pop(attr)) + + # Chain with event sanitation + return sanitize(event, default_timezone, href=href, calendar=calendar) + + def sanitize_timerange(dtstart, dtend, duration=None): '''return sensible dtstart and end for events that have an invalid or missing DTEND, assuming the event just lasts one hour.''' diff --git a/khal/khalendar/backend.py b/khal/khalendar/backend.py index 011f9e38c..09d51dbdc 100644 --- a/khal/khalendar/backend.py +++ b/khal/khalendar/backend.py @@ -35,7 +35,7 @@ from dateutil import parser from .. import utils -from ..icalendar import assert_only_one_uid, cal_from_ics +from ..icalendar import assert_only_one_uid, cal_from_ics, sanitize_vtodo from ..icalendar import expand as expand_vevent from ..icalendar import sanitize as sanitize_vevent from ..icalendar import sort_key as sort_vevent_key @@ -52,6 +52,11 @@ PROTO = 'PROTO' +SANITIZE_MAP = { + 'VEVENT': sanitize_vevent, + 'VTODO': sanitize_vtodo, +} + class EventType(IntEnum): DATE = 0 @@ -226,8 +231,8 @@ def update(self, vevent_str: str, href: str, etag: str='', calendar: str=None) - "If you want to import it, please use `khal import FILE`." ) raise NonUniqueUID - vevents = (sanitize_vevent(c, self.locale['default_timezone'], href, calendar) for - c in ical.walk() if c.name == 'VEVENT') + vevents = (SANITIZE_MAP[c.name](c, self.locale['default_timezone'], href, calendar) for + c in ical.walk() if c.name in SANITIZE_MAP.keys()) # Need to delete the whole event in case we are updating a # recurring event with an event which is either not recurring any # more or has EXDATEs, as those would be left in the recursion diff --git a/khal/khalendar/event.py b/khal/khalendar/event.py index 69874a5d3..d3ffc8f87 100644 --- a/khal/khalendar/event.py +++ b/khal/khalendar/event.py @@ -151,7 +151,7 @@ def fromVEvents(cls, events_list, ref=None, **kwargs): @classmethod def fromString(cls, event_str, ref=None, **kwargs): calendar_collection = cal_from_ics(event_str) - events = [item for item in calendar_collection.walk() if item.name == 'VEVENT'] + events = [item for item in calendar_collection.walk() if item.name in ['VEVENT', 'VTODO']] return cls.fromVEvents(events, ref, **kwargs) def __lt__(self, other): @@ -277,7 +277,8 @@ def symbol_strings(self): 'range': '\N{Left right arrow}', 'range_end': '\N{Rightwards arrow to bar}', 'range_start': '\N{Rightwards arrow from bar}', - 'right_arrow': '\N{Rightwards arrow}' + 'right_arrow': '\N{Rightwards arrow}', + 'task': '\N{Pencil}', } else: return { @@ -286,7 +287,8 @@ def symbol_strings(self): 'range': '<->', 'range_end': '->|', 'range_start': '|->', - 'right_arrow': '->' + 'right_arrow': '->', + 'task': '(T)', } @property @@ -304,6 +306,24 @@ def start(self): """this should return the start date(time) as saved in the event""" return self._start + @property + def task(self): + """this should return whether or not we are representing a task""" + return self._vevents[self.ref].name == 'VTODO' + + @property + def task_status(self): + """nice representation of a task status""" + vstatus = self._vevents[self.ref].get('STATUS', 'NEEDS-ACTION') + status = ' ' + if vstatus == 'COMPLETED': + status = 'X' + elif vstatus == 'IN-PROGRESS': + status = '/' + elif vstatus == 'CANCELLED': + status = '-' + return status + @property def end(self): """this should return the end date(time) as saved in the event or @@ -427,7 +447,10 @@ def summary(self): name=name, number=number, suffix=suffix, desc=description, leap=leap, ) else: - return self._vevents[self.ref].get('SUMMARY', '') + summary = self._vevents[self.ref].get('SUMMARY', '') + if self.task: + summary = '[{state}] {summary}'.format(state=self.task_status, summary=summary) + return summary def update_summary(self, summary): self._vevents[self.ref]['SUMMARY'] = summary @@ -516,6 +539,14 @@ def _alarm_str(self): alarmstr = '' return alarmstr + @property + def _task_str(self): + if self.task: + taskstr = ' ' + self.symbol_strings['task'] + else: + taskstr = '' + return taskstr + def format(self, format_string, relative_to, env=None, colors=True): """ :param colors: determines if colors codes should be printed or not @@ -642,6 +673,7 @@ def format(self, format_string, relative_to, env=None, colors=True): attributes["repeat-symbol"] = self._recur_str attributes["repeat-pattern"] = self.recurpattern attributes["alarm-symbol"] = self._alarm_str + attributes["task-symbol"] = self._task_str attributes["title"] = self.summary attributes["organizer"] = self.organizer.strip() attributes["description"] = self.description.strip() diff --git a/khal/settings/khal.spec b/khal/settings/khal.spec index b22610598..47990cf4e 100644 --- a/khal/settings/khal.spec +++ b/khal/settings/khal.spec @@ -273,7 +273,7 @@ bold_for_light_color = boolean(default=True) # ignored in `ikhal`, where events will always be shown in the color of the # calendar they belong to. # The syntax is the same as for :option:`--format`. -agenda_event_format = string(default='{calendar-color}{cancelled}{start-end-time-style} {title}{repeat-symbol}{alarm-symbol}{description-separator}{description}{reset}') +agenda_event_format = string(default='{calendar-color}{cancelled}{start-end-time-style} {title}{repeat-symbol}{alarm-symbol}{task-symbol}{description-separator}{description}{reset}') # Specifies how each *day header* is formatted. agenda_day_format = string(default='{bold}{name}, {date-long}{reset}') @@ -288,7 +288,7 @@ monthdisplay = monthdisplay(default='firstday') # but :command:`list` and :command:`calendar`. It is therefore probably a # sensible choice to include the start- and end-date. # The syntax is the same as for :option:`--format`. -event_format = string(default='{calendar-color}{cancelled}{start}-{end} {title}{repeat-symbol}{alarm-symbol}{description-separator}{description}{reset}') +event_format = string(default='{calendar-color}{cancelled}{start}-{end} {title}{repeat-symbol}{alarm-symbol}{task-symbol}{description-separator}{description}{reset}') # When highlight_event_days is enabled, this section specifies how # the highlighting/coloring of days is handled. From 0bcff973fe4461b676f5078881fb5425bfadc381 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 17 Mar 2022 19:35:22 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- khal/khalendar/backend.py | 3 ++- khal/khalendar/event.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/khal/khalendar/backend.py b/khal/khalendar/backend.py index 09d51dbdc..67feb57a4 100644 --- a/khal/khalendar/backend.py +++ b/khal/khalendar/backend.py @@ -35,9 +35,10 @@ from dateutil import parser from .. import utils -from ..icalendar import assert_only_one_uid, cal_from_ics, sanitize_vtodo +from ..icalendar import assert_only_one_uid, cal_from_ics from ..icalendar import expand as expand_vevent from ..icalendar import sanitize as sanitize_vevent +from ..icalendar import sanitize_vtodo from ..icalendar import sort_key as sort_vevent_key from .exceptions import (CouldNotCreateDbDir, NonUniqueUID, OutdatedDbVersionError, UpdateFailed) diff --git a/khal/khalendar/event.py b/khal/khalendar/event.py index d3ffc8f87..258a11300 100644 --- a/khal/khalendar/event.py +++ b/khal/khalendar/event.py @@ -449,7 +449,7 @@ def summary(self): else: summary = self._vevents[self.ref].get('SUMMARY', '') if self.task: - summary = '[{state}] {summary}'.format(state=self.task_status, summary=summary) + summary = f'[{self.task_status}] {summary}' return summary def update_summary(self, summary): From a4ef8e3e27810effdc65f3ec41af1444e7f686e6 Mon Sep 17 00:00:00 2001 From: Evilham Date: Sat, 19 Mar 2022 10:38:20 +0100 Subject: [PATCH 3/3] Fix bug with tasks that end in LocalizedEvents --- khal/khalendar/event.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/khal/khalendar/event.py b/khal/khalendar/event.py index 258a11300..4eafb6f42 100644 --- a/khal/khalendar/event.py +++ b/khal/khalendar/event.py @@ -762,7 +762,8 @@ class LocalizedEvent(DatetimeEvent): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) try: - starttz = getattr(self._vevents[self.ref]['DTSTART'].dt, 'tzinfo', None) + sattr = 'DUE' if self.task else 'DTSTART' + starttz = getattr(self._vevents[self.ref][sattr].dt, 'tzinfo', None) except KeyError: msg = ( f"Cannot understand event {kwargs.get('href')} from "