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.