Skip to content

Commit 631c4f6

Browse files
committed
[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 pimutils#448
1 parent b534cc7 commit 631c4f6

File tree

4 files changed

+87
-9
lines changed

4 files changed

+87
-9
lines changed

khal/icalendar.py

+41
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,47 @@ def sanitize(vevent, default_timezone, href='', calendar=''):
401401
return vevent
402402

403403

404+
def sanitize_vtodo(vtodo, default_timezone, href='', calendar=''):
405+
"""
406+
cleanup vtodos so they look like vevents for khal
407+
408+
:param vtodo: the vtodo that needs to be cleaned
409+
:type vtodo: icalendar.cal.Todo
410+
:param default_timezone: timezone to apply to start and/or end dates which
411+
were supposed to be localized but which timezone was not understood
412+
by icalendar
413+
:type timezone: pytz.timezone
414+
:param href: used for logging to inform user which .ics files are
415+
problematic
416+
:type href: str
417+
:param calendar: used for logging to inform user which .ics files are
418+
problematic
419+
:type calendar: str
420+
:returns: clean vtodo as vevent
421+
:rtype: icalendar.cal.Event
422+
"""
423+
vdtstart = vtodo.pop('DTSTART', None)
424+
vdue = vtodo.pop('DUE', None)
425+
426+
# it seems to be common for VTODOs to have DUE but no DTSTART
427+
# so we default to that. E.g. NextCloud does something similar
428+
if vdtstart is None and vdue is not None:
429+
vdtstart = vdue
430+
431+
# Based loosely on new_event
432+
event = icalendar.Event()
433+
event.add('dtstart', vdtstart)
434+
event.add('due', vdue)
435+
# Copy common/necessary attributes
436+
for attr in ['uid', 'summary', 'dtend', 'dtstamp', 'description',
437+
'location', 'categories', 'url']:
438+
if attr in vtodo:
439+
event.add(attr, vtodo.pop(attr))
440+
441+
# Chain with event sanitation
442+
return sanitize(event, default_timezone, href=href, calendar=calendar)
443+
444+
404445
def sanitize_timerange(dtstart, dtend, duration=None):
405446
'''return sensible dtstart and end for events that have an invalid or
406447
missing DTEND, assuming the event just lasts one hour.'''

khal/khalendar/backend.py

+8-3
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
from dateutil import parser
3636

3737
from .. import utils
38-
from ..icalendar import assert_only_one_uid, cal_from_ics
38+
from ..icalendar import assert_only_one_uid, cal_from_ics, sanitize_vtodo
3939
from ..icalendar import expand as expand_vevent
4040
from ..icalendar import sanitize as sanitize_vevent
4141
from ..icalendar import sort_key as sort_vevent_key
@@ -52,6 +52,11 @@
5252

5353
PROTO = 'PROTO'
5454

55+
SANITIZE_MAP = {
56+
'VEVENT': sanitize_vevent,
57+
'VTODO': sanitize_vtodo,
58+
}
59+
5560

5661
class EventType(IntEnum):
5762
DATE = 0
@@ -226,8 +231,8 @@ def update(self, vevent_str: str, href: str, etag: str='', calendar: str=None) -
226231
"If you want to import it, please use `khal import FILE`."
227232
)
228233
raise NonUniqueUID
229-
vevents = (sanitize_vevent(c, self.locale['default_timezone'], href, calendar) for
230-
c in ical.walk() if c.name == 'VEVENT')
234+
vevents = (SANITIZE_MAP[c.name](c, self.locale['default_timezone'], href, calendar) for
235+
c in ical.walk() if c.name in SANITIZE_MAP.keys())
231236
# Need to delete the whole event in case we are updating a
232237
# recurring event with an event which is either not recurring any
233238
# more or has EXDATEs, as those would be left in the recursion

khal/khalendar/event.py

+36-4
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ def fromVEvents(cls, events_list, ref=None, **kwargs):
151151
@classmethod
152152
def fromString(cls, event_str, ref=None, **kwargs):
153153
calendar_collection = cal_from_ics(event_str)
154-
events = [item for item in calendar_collection.walk() if item.name == 'VEVENT']
154+
events = [item for item in calendar_collection.walk() if item.name in ['VEVENT', 'VTODO']]
155155
return cls.fromVEvents(events, ref, **kwargs)
156156

157157
def __lt__(self, other):
@@ -277,7 +277,8 @@ def symbol_strings(self):
277277
'range': '\N{Left right arrow}',
278278
'range_end': '\N{Rightwards arrow to bar}',
279279
'range_start': '\N{Rightwards arrow from bar}',
280-
'right_arrow': '\N{Rightwards arrow}'
280+
'right_arrow': '\N{Rightwards arrow}',
281+
'task': '\N{Pencil}',
281282
}
282283
else:
283284
return {
@@ -286,7 +287,8 @@ def symbol_strings(self):
286287
'range': '<->',
287288
'range_end': '->|',
288289
'range_start': '|->',
289-
'right_arrow': '->'
290+
'right_arrow': '->',
291+
'task': '(T)',
290292
}
291293

292294
@property
@@ -304,6 +306,24 @@ def start(self):
304306
"""this should return the start date(time) as saved in the event"""
305307
return self._start
306308

309+
@property
310+
def task(self):
311+
"""this should return whether or not we are representing a task"""
312+
return self._vevents[self.ref].name == 'VTODO'
313+
314+
@property
315+
def task_status(self):
316+
"""nice representation of a task status"""
317+
vstatus = self._vevents[self.ref].get('STATUS', 'NEEDS-ACTION')
318+
status = ' '
319+
if vstatus == 'COMPLETED':
320+
status = 'X'
321+
elif vstatus == 'IN-PROGRESS':
322+
status = '/'
323+
elif vstatus == 'CANCELLED':
324+
status = '-'
325+
return status
326+
307327
@property
308328
def end(self):
309329
"""this should return the end date(time) as saved in the event or
@@ -427,7 +447,10 @@ def summary(self):
427447
name=name, number=number, suffix=suffix, desc=description, leap=leap,
428448
)
429449
else:
430-
return self._vevents[self.ref].get('SUMMARY', '')
450+
summary = self._vevents[self.ref].get('SUMMARY', '')
451+
if self.task:
452+
summary = '[{state}] {summary}'.format(state=self.task_status, summary=summary)
453+
return summary
431454

432455
def update_summary(self, summary):
433456
self._vevents[self.ref]['SUMMARY'] = summary
@@ -516,6 +539,14 @@ def _alarm_str(self):
516539
alarmstr = ''
517540
return alarmstr
518541

542+
@property
543+
def _task_str(self):
544+
if self.task:
545+
taskstr = ' ' + self.symbol_strings['task']
546+
else:
547+
taskstr = ''
548+
return taskstr
549+
519550
def format(self, format_string, relative_to, env=None, colors=True):
520551
"""
521552
: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):
642673
attributes["repeat-symbol"] = self._recur_str
643674
attributes["repeat-pattern"] = self.recurpattern
644675
attributes["alarm-symbol"] = self._alarm_str
676+
attributes["task-symbol"] = self._task_str
645677
attributes["title"] = self.summary
646678
attributes["organizer"] = self.organizer.strip()
647679
attributes["description"] = self.description.strip()

khal/settings/khal.spec

+2-2
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ bold_for_light_color = boolean(default=True)
273273
# ignored in `ikhal`, where events will always be shown in the color of the
274274
# calendar they belong to.
275275
# The syntax is the same as for :option:`--format`.
276-
agenda_event_format = string(default='{calendar-color}{cancelled}{start-end-time-style} {title}{repeat-symbol}{alarm-symbol}{description-separator}{description}{reset}')
276+
agenda_event_format = string(default='{calendar-color}{cancelled}{start-end-time-style} {title}{repeat-symbol}{alarm-symbol}{task-symbol}{description-separator}{description}{reset}')
277277

278278
# Specifies how each *day header* is formatted.
279279
agenda_day_format = string(default='{bold}{name}, {date-long}{reset}')
@@ -288,7 +288,7 @@ monthdisplay = monthdisplay(default='firstday')
288288
# but :command:`list` and :command:`calendar`. It is therefore probably a
289289
# sensible choice to include the start- and end-date.
290290
# The syntax is the same as for :option:`--format`.
291-
event_format = string(default='{calendar-color}{cancelled}{start}-{end} {title}{repeat-symbol}{alarm-symbol}{description-separator}{description}{reset}')
291+
event_format = string(default='{calendar-color}{cancelled}{start}-{end} {title}{repeat-symbol}{alarm-symbol}{task-symbol}{description-separator}{description}{reset}')
292292

293293
# When highlight_event_days is enabled, this section specifies how
294294
# the highlighting/coloring of days is handled.

0 commit comments

Comments
 (0)